From fc6d725bd1ca28f17eb402976de5030d96b05f3c Mon Sep 17 00:00:00 2001 From: kpa39l Date: Sun, 13 Sep 2026 20:53:53 +0000 Subject: [PATCH] =?UTF-8?q?candidates-mail-ui:=20=D0=B4=D0=B2=D1=83=D1=85?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D0=B2=20(=D0=BF?= =?UTF-8?q?=D0=BE=D1=87=D1=82=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - БД: posts.comment TEXT, posts.rewritten_text TEXT (ALTER TABLE, миграция применена) - GET /candidates: двухпанельный рендер — слева список с группировкой (source/date/status, свертываемые
) + чекбоксы bulk, справа детали поста (действия, комментарий, пересказ, оригинал с медиа) - Роуты: POST /candidates/select, /candidates/bulk (approve/reject/reject-old), /posts/{id}/comment, /posts/{id}/reclassify (qwen), /posts/{id}/rewrite (qwen) - approve вынесен в impl_approve (переиспользуется bulk-approve: publisher + бандл) - без JS (только формы); HTML: Jinja2 + Bootstrap 5.3 - Протестировано TestClient: группировка, bulk-reject, comment, reclassify (реальный qwen), rewrite (реальный qwen), поиск, media, published — зелёные - openspec: change candidates-mail-ui архивирован (2026-09-13-candidates-mail-ui), spec влита в openspec/specs/candidates-mail-ui; validate — чисто - docs: STATUS.md, WALKTHROUGH.md обновлены --- STATUS.md | 4 +- WALKTHROUGH.md | 2 +- .../.openspec.yaml | 0 .../2026-09-13-candidates-mail-ui}/design.md | 0 .../proposal.md | 0 .../specs/candidates-mail-ui/spec.md | 0 .../2026-09-13-candidates-mail-ui/tasks.md | 20 + openspec/changes/candidates-mail-ui/tasks.md | 20 - openspec/specs/candidates-mail-ui/spec.md | 111 ++++++ web/app.py | 371 ++++++++++++++---- web/templates/candidates.html | 232 +++++++---- 11 files changed, 601 insertions(+), 159 deletions(-) rename openspec/changes/{candidates-mail-ui => archive/2026-09-13-candidates-mail-ui}/.openspec.yaml (100%) rename openspec/changes/{candidates-mail-ui => archive/2026-09-13-candidates-mail-ui}/design.md (100%) rename openspec/changes/{candidates-mail-ui => archive/2026-09-13-candidates-mail-ui}/proposal.md (100%) rename openspec/changes/{candidates-mail-ui => archive/2026-09-13-candidates-mail-ui}/specs/candidates-mail-ui/spec.md (100%) create mode 100644 openspec/changes/archive/2026-09-13-candidates-mail-ui/tasks.md delete mode 100644 openspec/changes/candidates-mail-ui/tasks.md create mode 100644 openspec/specs/candidates-mail-ui/spec.md diff --git a/STATUS.md b/STATUS.md index 75d991f..bafd280 100644 --- a/STATUS.md +++ b/STATUS.md @@ -125,10 +125,10 @@ CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --l - [x] **git-репозиторий** /opt/vesti: origin gitverse.ru `kpa39l/vesti` (main, private), зеркало gitea bigbox `estorozhenko/vesti` (mirror 8h). .gitignore: backups/ media/ db/ bundles/ logs/ секреты. - [x] **Тест публикации с медиа сквозь веб**: сообщения ушли в @dedinit_vesti (11/12) и удалены. Обнаружен и исправлен фейковый `dry_run` в publisher (поле уехало в PublishResponse вместо PublishRequest; собыл 500). Change `publisher-dry-run-fix` архивирован: `dry_run=true` → эмуляция без TG. - [x] **OpenSpec changes архивированы** (8): fix-approve-dirn, web-media-preview, web-format-datetime, web-list-filter-no-js, deexternalize-web-assets, web-render-markdown, publisher-dry-run-fix, unify-directions. -- [x] **Новый change `candidates-mail-ui`** (двухпанельный интерфейс кандидатов, готовится; proposal/design/tasks/spec — валиден). +- [x] **Реализован change `candidates-mail-ui`** (двухпанельный интерфейс кандидатов как почтовый клиент): левая панель — список с группировкой (source/date/status, свертываемые
) + чекбоксы bulk (approve/reject/reject-old), правая — детали поста (меню действий, комментарий, черновик пересказа, оригинал с медиа). Роуты: POST /candidates/select, /candidates/bulk, /posts/{id}/comment, /posts/{id}/reclassify (qwen), /posts/{id}/rewrite (qwen). БД: добавлены `comment TEXT`, `rewritten_text TEXT`. Без JS (только данные формы). Протестировано TestClient (группировка, bulk-reject, comment, reclassify, rewrite, поиск) — зелёные. Change архивирован (2026-09-13). ## Текущие задачи (из TODO, открытые) -- Интерфейс кандидатов как почтовый клиент (2 панели, группировка, bulk) — change candidates-mail-ui (проработать с пользователем) +- Интерфейс кандидатов как почтовый клиент (2 панели, группировка, bulk) — ✅ реализован в change candidates-mail-ui (см. выше). - Ручное указание/изменение направления (значок «?» у неклассифицированных) - Переписывание новостей от себя (LLM-пересказ, персональный контент) - Извлечение источников из чужих постов + сервис поиска источников в интернете (дообогащение ТОЛЬКО одобренных) diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md index f8801d6..3f86290 100644 --- a/WALKTHROUGH.md +++ b/WALKTHROUGH.md @@ -434,7 +434,7 @@ sudo systemctl restart vesti-web # active; login → 200, candidates/published - Change **candidates-mail-ui** (готовится): 2 панели (список слева + детально справа), группировка по source/date/status, bulk approve/reject, comment, reclassify, rewrite, медиа. proposal/design/tasks/spec — валидны. ### Следующая сессия -1. Проработать детали **candidates-mail-ui** с пользователем (открытые вопросы: комментарий — поле/таблица?, переписывание — qwen черновик?, переклассификация — перезапуск?, группировка — одна/несколько) → реализовать. +1. ✅ **candidates-mail-ui реализован** (2026-09-13): двухпанельный интерфейс (список слева с группировкой source/date/status + чекбоксы bulk, детали справа с действиями: approve/reject/reclassify/rewrite/comment), комментарий — поле `posts.comment`, переписывание — qwen пишет черновик в `posts.rewritten_text`, переклассификация — перезапуск classify с qwen. Без JS. Архив: 2026-09-13-candidates-mail-ui. 2. Ручное указание направления (для 53 без direction + «?» на карточке). 3. Переписывание новостей от себя (LLM-пересказ) + пайплайн «выбранные → дообогащение → публикация». 4. Извлечение источников из постов + сервис поиска в интернете (только одобренные). diff --git a/openspec/changes/candidates-mail-ui/.openspec.yaml b/openspec/changes/archive/2026-09-13-candidates-mail-ui/.openspec.yaml similarity index 100% rename from openspec/changes/candidates-mail-ui/.openspec.yaml rename to openspec/changes/archive/2026-09-13-candidates-mail-ui/.openspec.yaml diff --git a/openspec/changes/candidates-mail-ui/design.md b/openspec/changes/archive/2026-09-13-candidates-mail-ui/design.md similarity index 100% rename from openspec/changes/candidates-mail-ui/design.md rename to openspec/changes/archive/2026-09-13-candidates-mail-ui/design.md diff --git a/openspec/changes/candidates-mail-ui/proposal.md b/openspec/changes/archive/2026-09-13-candidates-mail-ui/proposal.md similarity index 100% rename from openspec/changes/candidates-mail-ui/proposal.md rename to openspec/changes/archive/2026-09-13-candidates-mail-ui/proposal.md diff --git a/openspec/changes/candidates-mail-ui/specs/candidates-mail-ui/spec.md b/openspec/changes/archive/2026-09-13-candidates-mail-ui/specs/candidates-mail-ui/spec.md similarity index 100% rename from openspec/changes/candidates-mail-ui/specs/candidates-mail-ui/spec.md rename to openspec/changes/archive/2026-09-13-candidates-mail-ui/specs/candidates-mail-ui/spec.md diff --git a/openspec/changes/archive/2026-09-13-candidates-mail-ui/tasks.md b/openspec/changes/archive/2026-09-13-candidates-mail-ui/tasks.md new file mode 100644 index 0000000..87a99ea --- /dev/null +++ b/openspec/changes/archive/2026-09-13-candidates-mail-ui/tasks.md @@ -0,0 +1,20 @@ +# tasks.md — candidates-mail-ui + +## Proposal +- [x] proposal.md — двухпанельный интерфейс кандидатов (почтовый клиент), группировка, bulk +- [x] design.md — архитектура (роуты, БД, шаблоны, минимум JS) + +## Implement +- [x] Проверить схему posts (status, поля) — уточнить, какие статусы есть (new/rejected/published) +- [x] Миграция: `ALTER TABLE posts ADD COLUMN comment TEXT` + `rewritten_text TEXT` (сделано в БД) +- [x] GET /candidates — двухпанельный рендер (левая колонка список+группировка, правая — детально) +- [x] Левая панель: группировка по source/date/status (сворачиваемые
), чекбоксы +- [x] POST /candidates/bulk — approve/reject/reject-old по ids[] (транзакция) +- [x] POST /posts/{id}/comment — сохранить комментарий (comment TEXT) +- [x] POST /posts/{id}/reclassify — перезапуск классификатора на посте (реальный qwen, протестирован) +- [x] POST /posts/{id}/rewrite — LLM-пересказ (qwen), возврат черновика для редактирования (реальный qwen, протестирован) +- [x] Правая панель: меню действий, блок комментария, оригинальный пост со всеми медиа +- [x] Шаблоны: candidates.html (двухколоночная), формы новых действий +- [x] Тест: группировка, bulk-отклонение, коммент, реклассификация, rewrite, поиск (TestClient, все зелёные) +- [x] `openspec validate candidates-mail-ui` — чисто +- [x] Обновить STATUS.md / WALKTHROUGH.md \ No newline at end of file diff --git a/openspec/changes/candidates-mail-ui/tasks.md b/openspec/changes/candidates-mail-ui/tasks.md deleted file mode 100644 index 3039f7e..0000000 --- a/openspec/changes/candidates-mail-ui/tasks.md +++ /dev/null @@ -1,20 +0,0 @@ -# tasks.md — candidates-mail-ui - -## Proposal -- [x] proposal.md — двухпанельный интерфейс кандидатов (почтовый клиент), группировка, bulk -- [x] design.md — архитектура (роуты, БД, шаблоны, минимум JS) - -## Implement -- [ ] Проверить схему posts (status, поля) — уточнить, какие статусы есть -- [ ] Миграция: `ALTER TABLE posts ADD COLUMN comment TEXT` (или таблица comments) -- [ ] GET /candidates — двухпанельный рендер (левая колонка список+группировка, правая — детально) -- [ ] Левая панель: группировка по source/date/status (сворачиваемые
), чекбоксы -- [ ] POST /candidates/bulk — approve/reject по ids[] (транзакция) -- [ ] POST /posts/{id}/comment — сохранить комментарий (comment TEXT) -- [ ] POST /posts/{id}/reclassify — перезапуск классификатора на посте -- [ ] POST /posts/{id}/rewrite — LLM-пересказ (qwen), возврат черновика для редактирования -- [ ] Правая панель: меню действий, блок комментария, оригинальный пост со всеми медиа -- [ ] Шаблоны: candidates.html (двухколоночная), формы новых действий -- [ ] Тест: создание 2-3 тестовых постов, группировка, bulk-отклонение, коммент, реклассификация -- [ ] `openspec validate candidates-mail-ui` — чисто -- [ ] Обновить STATUS.md / WALKTHROUGH.md \ No newline at end of file diff --git a/openspec/specs/candidates-mail-ui/spec.md b/openspec/specs/candidates-mail-ui/spec.md new file mode 100644 index 0000000..3208012 --- /dev/null +++ b/openspec/specs/candidates-mail-ui/spec.md @@ -0,0 +1,111 @@ +# candidates-mail-ui Specification + +## Purpose +TBD - created by archiving change candidates-mail-ui. Update Purpose after archive. + +## Requirements + +### Requirement: Двухпанельный интерфейс кандидатов + +Страница кандидатов разделена на две панели: слева список кандидатов (с группировкой), +справа — детальный просмотр выбранного кандидата с меню действий. + +#### Scenario: Пользователь открывает список кандидатов + +Given на странице `GET /candidates` +When страница загружается +Then левая панель показывает список кандидатов, правая — детали первого/последнего выбранного +And активный кандидат подсвечивается + +#### Scenario: Группировка списка по источнику + +Given список кандидатов +When пользователь выбирает группировку «по источнику» +Then кандидаты сгруппированы по источнику (канал/сайт) +And каждая группа сворачивается/разворачивается (без JS, через `
`) + +#### Scenario: Группировка по дате поступления + +When пользователь выбирает группировку «по дате поступления» +Then кандидаты сгруппированы по дате поступления (сегодня, вчера, ранее) +And порядок: новые сверху, старые ниже + +### Requirement: Bulk-операции над выбранными кандидатами + +Пользователь может выбрать несколько кандидатов (чекбоксы) и выполнить массовое +действие: одобрить все выбранные / отклонить все выбранные. + +#### Scenario: Массовое отклонение выбранных + +Given на левой панели несколько кандидатов с отмеченными чекбоксами +When пользователь нажимает «Отклонить выбранные» +Then все отмеченные кандидаты получают статус «отклонён» +And действие выполняется транзакционно (все или ни одного) + +#### Scenario: Массовое одобрение выбранных + +When пользователь нажимает «Одобрить выбранные» +Then каждый отмеченный кандидат публикуется (вызывается approve-логика) + +#### Scenario: Отклонение всех старых постов группы + +Given группа «ранее» (старые посты) +When пользователь выбирает «отклонить все в группе» +Then все посты группы получают статус «отклонён» + +### Requirement: Действия над конкретным кандидатом + +Правая панель содержит меню действий: Опубликовать, Отклонить, Переклассифицировать, +Переписать новость под себя, блок «Мой комментарий», оригинальный пост со всеми медиа. + +#### Scenario: Опубликовать кандидата + +Given выбран кандидат в правой панели +When пользователь нажимает «Опубликовать» +Then пост публикуется (существующая логика approve, publisher) + +#### Scenario: Отклонить кандидата + +When пользователь нажимает «Отклонить» +Then пост получает статус «отклонён» и исчезает из активных кандидатов + +#### Scenario: Переклассифицировать кандидата + +When пользователь нажимает «Переклассифицировать» +Then запускается классификатор для данного поста +And направление/relevance/interest обновляются, изменения видны на карточке + +#### Scenario: Переписать новость под себя + +When пользователь нажимает «Переписать новость» +Then LLM генерирует черновик пересказа от первого лица (без копипасты) +And черновик отображается в текстовом поле для редактирования +And пользователь может отредактировать и затем опубликовать + +#### Scenario: Комментарий к кандидату + +Given в правой панели блок «Мой комментарий» +When пользователь вводит текст и сохраняет +Then комментарий сохраняется и отображается при повторном открытии кандидата + +#### Scenario: Оригинальный пост со всеми медиа + +Given выбран кандидат с медиа +Then правая панель показывает оригинальный текст поста, все изображения/видео + (через роут /media/), мета источника (канал, время, views/reactions) + +### Requirement: Фильтрация и поиск по списку + +Список кандидатов можно фильтровать по статусу (новые/одобренные/отклонённые) +и искать по тексту. + +#### Scenario: Фильтр по статусу + +Given на странице кандидатов +When пользователь выбирает фильтр «новые» +Then в списке остаются только новые кандидаты + +#### Scenario: Поиск по тексту + +When пользователь вводит текст в поиск +Then список фильтруется по вхождению в текст поста diff --git a/web/app.py b/web/app.py index 70a6a01..4d3622f 100644 --- a/web/app.py +++ b/web/app.py @@ -102,36 +102,281 @@ def login(request: Request, password: str = Form(...)): return RedirectResponse(url="/login?error=1", status_code=302) -@app.get("/candidates", response_class=HTMLResponse) -def candidates(request: Request, direction: str = "", status: str = "new", own: str = "", error: str = ""): - _require_auth(request) - conn = _db() - q = """SELECT p.*, s.name source_name FROM posts p - LEFT JOIN sources s ON s.id=p.source_id - WHERE 1=1""" +DIRECTIONS = ["linux", "tech", "politics", "games", "electronics", "llm"] + + +class _Row(dict): + """sqlite3.Row → dict с доступом через точку (для шаблонов).""" + + def __getattr__(self, k): + try: + return self[k] + except KeyError: + raise AttributeError(k) + + +def _row(r): + return _Row(dict(r)) if r is not None else None + + +STATUS_LABELS = { + "new": "💎 Новые", + "rejected": "🗑 Отклонённые", + "published": "✅ Опубликованные", + "": "Все", +} +STATUS_COLORS = {"new": "success", "rejected": "danger", "published": "primary"} + + +def _group_label(group_by: str, key: str) -> str: + """Человекочитаемая подпись группы для списка кандидатов.""" + if group_by == "source": + return key or "без источника" + if group_by == "date": + if key == "today": + return "Сегодня" + if key == "yesterday": + return "Вчера" + if key == "week": + return "Ранее на этой неделе" + return f"Ранее · {key}" + # status + return STATUS_LABELS.get(key, key or "?") + + +def _fetch_candidates(conn, direction: str, status: str, own: str, q: str, group_by: str, limit: int = 200): + """Выборка кандидатов для левой панели. Возвращает (grouped, counts, flat). + flat — сгруппированный список; counts — {статус: число} для фильтра-табов.""" + where = [] params = [] if direction: - q += " AND p.direction=?" + where.append("p.direction=?") params.append(direction) if status: - q += " AND p.status=?" + where.append("p.status=?") params.append(status) if own in ("1", "0"): - q += " AND p.is_own=?" + where.append("p.is_own=?") params.append(int(own)) - q += " ORDER BY p.published_at DESC LIMIT 100" - rows = conn.execute(q, params).fetchall() + if q: + where.append("(p.text LIKE ? OR p.summary LIKE ?)") + params += [f"%{q}%", f"%{q}%"] + w = ("WHERE " + " AND ".join(where)) if where else "" + sql = f"""SELECT p.*, s.name source_name, s.slug source_slug, s.lang source_lang, + s.direction AS source_direction + FROM posts p LEFT JOIN sources s ON s.id=p.source_id + {w}""" + rows = conn.execute(sql + " ORDER BY COALESCE(p.published_at,p.fetched_at) DESC LIMIT ?", params + [limit]).fetchall() + counts = {r["status"]: r["c"] for r in conn.execute( + "SELECT p.status, COUNT(*) c FROM posts p" + (" WHERE " + " AND ".join(where) if where else "") + " GROUP BY p.status", + params, + )} + counts[""] = sum(counts.values()) + # статус по умолчанию для табов: если в выборке нет ничего "нового" — смотрим rejected + if status == "new" and not rows: + for s in ("rejected", "published"): + if counts.get(s): + status = s + break + + flat = [] + for r in rows: + d = dict(r) + d["source_name"] = d.get("source_name") or d.get("tg_channel") or "?" + flat.append(_Row(d)) + + # группировка + import itertools + + def _key_source(p): + return p["source_name"] or "?" + + def _key_date(p): + from datetime import date, timedelta + today = date.today() + ts = p.get("published_at") or p.get("fetched_at") or "" + d = None + try: + d = datetime.fromisoformat(str(ts)[:19]).date() + except ValueError: + pass + if d is None: + return "other" + if d == today: + return "today" + if d == today - timedelta(days=1): + return "yesterday" + if (today - d).days <= 7: + return "week" + return f"{d:%m.%Y}" + + def _key_status(p): + return p["status"] + + keyf = {"source": _key_source, "date": _key_date, "status": _key_status}[group_by] + + grouped = [] + for k, it in itertools.groupby(sorted(flat, key=keyf), key=keyf): + items = list(it) + grouped.append({ + "key": k, + "label": _group_label(group_by, k), + "status": items[0]["status"], + "posts": items, + }) + # порядок групп: new → rejected → published (для status), иначе по ключу + if group_by == "status": + grouped.sort(key=lambda g: {"new": 0, "rejected": 1, "published": 2, "": 3}.get(g["key"], 9)) + return grouped, counts, status + + +@app.get("/candidates", response_class=HTMLResponse) +def candidates(request: Request, direction: str = "", status: str = "", own: str = "", + q: str = "", group_by: str = "source", selected: int = 0, error: str = ""): + _require_auth(request) + conn = _db() + grouped, counts, status = _fetch_candidates(conn, direction, status, own, q, group_by) + # выбор по умолчанию: первый кандидат списка (или указанный selected) + sel = None + if grouped: + sel = grouped[0]["posts"][0] + if selected: + for g in grouped: + for it in g["posts"]: + if it["id"] == selected: + sel = it + break + else: + continue + break conn.close() html = tpl.get_template("candidates.html").render( - posts=rows, - direction=direction, - status=status, - own=own, - directions=["linux", "tech", "politics", "games", "electronics", "llm"], + posts=grouped, counts=counts, status=status, direction=direction, own=own, + q=q, group_by=group_by, selected=sel, error=error, + directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS, ) return HTMLResponse(html) +@app.post("/candidates/select") +def candidates_select(request: Request, id: int = Form(...), direction: str = Form(""), + status: str = Form(""), own: str = Form(""), q: str = Form(""), group_by: str = Form("source")): + """Выбор кандидата левой панели (обычная форма без JS).""" + _require_auth(request) + url = f"/candidates?selected={id}&group_by={group_by}" + for k, v in (("direction", direction), ("status", status), ("own", own), ("q", q)): + if v: + url += f"&{k}={v}" + return RedirectResponse(url=url, status_code=302) + + +@app.post("/candidates/bulk") +def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(...), + direction: str = Form(""), status: str = Form(""), own: str = Form(""), + q: str = Form(""), group_by: str = Form("source")): + """Bulk-операции: approve/reject/reject-old по отмеченным ids.""" + _require_auth(request) + if not ids: + return RedirectResponse(url="/candidates?error=no_selection", status_code=302) + conn = _db() + ph = ",".join("?" * len(ids)) + rows = conn.execute(f"SELECT * FROM posts WHERE id IN ({ph})", ids).fetchall() + if action == "reject": + conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids) + conn.commit() + conn.close() + return RedirectResponse(url=f"/candidates?status=rejected&group_by={group_by}", status_code=302) + if action == "reject-old": + # все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё» + keep = {} + for r in rows: + key = r["source_id"] + keep.setdefault(key, r["id"]) + kill = [r["id"] for r in rows if r["id"] not in keep.values()] + if kill: + ph2 = ",".join("?" * len(kill)) + conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill) + conn.commit() + conn.close() + return RedirectResponse(url=f"/candidates?status=rejected&group_by={group_by}", status_code=302) + # approve — каждый через существующую логику (публикация + бандл) + conn.close() + errs = [] + for r in rows: + if r["status"] == "published": + continue + err = impl_approve(int(r["id"])) + if err: + errs.append(f"{r['id']}({err})") + if errs: + return RedirectResponse(url=f"/candidates?error=bulk_partial:{','.join(map(str,errs))[:120]}&group_by={group_by}", status_code=302) + return RedirectResponse(url="/published", status_code=302) + + +@app.post("/posts/{post_id}/comment") +def post_comment(post_id: int, request: Request, comment: str = Form("")): + _require_auth(request) + conn = _db() + conn.execute("UPDATE posts SET comment=? WHERE id=?", (comment.strip(), post_id)) + conn.commit() + conn.close() + return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302) + + +@app.post("/posts/{post_id}/reclassify") +def post_reclassify(post_id: int, request: Request): + _require_auth(request) + from classifier.classify import classify_text + conn = _db() + p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone() + if not p: + conn.close() + return RedirectResponse(url="/candidates?error=notfound", status_code=302) + try: + res = classify_text(dict(p)) + except Exception as e: + conn.close() + return RedirectResponse(url=f"/candidates?selected={post_id}&error=reclassify:{e}", status_code=302) + conn.execute( + "UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?", + (res["direction"], res["relevance"], res["interest"], res["summary"], 1 if res["classified"] else 0, post_id), + ) + conn.commit() + conn.close() + return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302) + + +@app.post("/posts/{post_id}/rewrite") +def post_rewrite(post_id: int, request: Request): + _require_auth(request) + from classifier.classify import call_ollama + conn = _db() + p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone() + if not p: + conn.close() + return RedirectResponse(url="/candidates?error=notfound", status_code=302) + d = dict(p) + text_ = (d.get("text") or "")[:2000] + prompt = ( + "Ты — редактор новостей. Перепиши новость СВОИМИ словами от первого лица\n" + "(как автор канала «Дед в АйТи»), без копипасты и без канцелярита. Сохрани смысл,\n" + "факты и ссылку на оригинал. Верни ТОЛЬКО текст пересказа, без пояснений.\n\n" + f"Оригинал:\n{text_}" + ) + try: + r = call_ollama(prompt, 0, 0, str(d.get("direction") or "")) + draft = (r.get("text") or r.get("content") or "").strip() + if not draft: + draft = r.get("summary") or "" + except Exception as e: + conn.close() + return RedirectResponse(url=f"/candidates?selected={post_id}&error=rewrite:{e}", status_code=302) + conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft, post_id)) + conn.commit() + conn.close() + return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302) + + @app.get("/published", response_class=HTMLResponse) def published(request: Request): _require_auth(request) @@ -149,11 +394,10 @@ def published(request: Request): return HTMLResponse(html) -@app.post("/posts/{post_id}/approve") -async def approve(post_id: int, request: Request): - """Подтверждение черновика → публикация в TG (fan-out по направлениям) + создание бандла + статус published.""" - _require_auth(request) - from web.publisher_client import publish as http_publish, get_views as http_views +def impl_approve(post_id: int, comment: str = "", dirs_selected: list[str] | None = None) -> str | None: + """Публикация поста: publisher + бандл + статус published. Возвращает None при успехе, + строку ошибки при неудаче (для bulk — собираем ошибки, для POST — редирект с error).""" + from web.publisher_client import publish as http_publish from publisher.card import make_card from web.store import create_bundle @@ -161,62 +405,35 @@ async def approve(post_id: int, request: Request): post = conn.execute("SELECT p.*, s.name source_name, s.lang FROM posts p LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?", (post_id,)).fetchone() if not post: conn.close() - return RedirectResponse(url="/candidates?error=notfound", status_code=302) - + return "notfound" post = dict(post) is_own = int(post.get("is_own") or 0) == 1 - dirn = post.get("direction") or "linux" # направление поста (используется ниже) + dirn = post.get("direction") or "linux" lang = post.get("lang") or "ru" - # направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста - form = None - dirs_selected = [] - comment = "" - if request.method == "POST": - try: - form = await request.form() # Starlette: form awaitable - dirs_selected = form.getlist("dirs") - comment = (form.get("comment") or "").strip() - except Exception: - dirs_selected = [] - if not dirs_selected: + if dirs_selected is None: # по умолчанию: направления классификации (classifications) или направление поста - cls = conn.execute( - "SELECT direction FROM classifications WHERE post_id=? ORDER BY id", (post_id,) - ).fetchall() + cls = conn.execute("SELECT direction FROM classifications WHERE post_id=? ORDER BY id", (post_id,)).fetchall() dirs_selected = [r["direction"] for r in cls] if cls else [dirn] - # дедуп, без пустых dirs_selected = list(dict.fromkeys([d for d in dirs_selected if d])) card = make_card(post, comment) + card["direction"] = dirn + card["lang"] = lang + try: + res = http_publish(card) + except RuntimeError as e: + conn.close() + return f"publisher:{e}" + + _res = res.get("results", {}) + # свой контент: ключи — направления; внешний: ключи — каналы if is_own: - # СВОЙ контент → fan-out: сервис сам берёт каналы из конфига (VESTI_BOT_CHANNELS) - # направления передаём в card (для карточки), не как адресаты. - card["direction"] = dirn - card["lang"] = lang - try: - res = http_publish(card) - except RuntimeError as e: - conn.close() - return RedirectResponse(url=f"/candidates?error=publisher:{e}", status_code=302) - tg_message_id = next(iter((r or {}).get("message_id") or 0 for r in res.get("results", {}).values()), 0) if res else 0 - views_by_dir = { - d: (res.get("results", {}).get(d, {}).get("views") or 0) - for d in res.get("results", {}) - } + tg_message_id = next(iter((r or {}).get("message_id") or 0 for r in _res.values()), 0) if res else 0 + views_by_dir = {d: (r.get("views") or 0) for d, r in _res.items()} else: - # Внешний → без fan-out: сервис публикует в каналы по умолчанию - card["direction"] = dirn - card["lang"] = lang - try: - res = http_publish(card) - except RuntimeError as e: - conn.close() - return RedirectResponse(url=f"/candidates?publisher_error={e}", status_code=302) - # результаты ключаются по КАНАЛУ (@dedinit_vesti), а не по направлению — берём первый успешный - _res = res.get("results", {}) - tg_message_id = next((r.get("message_id") or 0 for r in _res.values() if r.get("message_id")), 0) + tg_message_id = next((r.get("message_id") or 0 for r in _res.values() if r.get("message_id")), 0) if _res else 0 views_by_dir = {d: (r.get("views") or 0) for d, r in _res.items()} # банк статей (markdown-бандл + медиа) — по каждому направлению fan-out @@ -225,9 +442,8 @@ async def approve(post_id: int, request: Request): except Exception as e: bundle = {"error": str(e)} - # 3) статус + запись published (для своего — distributed_dirs + метрики) import json as _json - conn.execute("UPDATE posts SET status='published' WHERE id=?", (post_id,)) + conn.execute("UPDATE posts SET status='published', comment=? WHERE id=?", (comment.strip(), post_id)) distributed = _json.dumps(dirs_selected, ensure_ascii=False) if is_own else _json.dumps([dirn]) conn.execute( "INSERT INTO published (post_id, bundle_path, tg_message_id, distributed_dirs, views) VALUES (?,?,?,?,?)", @@ -235,6 +451,25 @@ async def approve(post_id: int, request: Request): ) conn.commit() conn.close() + return None + + +@app.post("/posts/{post_id}/approve") +async def approve(post_id: int, request: Request): + """Подтверждение черновика → публикация в TG (fan-out по направлениям) + создание бандла + статус published.""" + _require_auth(request) + # направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста + dirs_selected: list[str] = [] + comment = "" + try: + form = await request.form() + dirs_selected = [str(d) for d in form.getlist("dirs")] + comment = str(form.get("comment") or "").strip() + except Exception: + pass + err = impl_approve(post_id, comment, dirs_selected) + if err: + return RedirectResponse(url=f"/candidates?selected={post_id}&error={err}", status_code=302) return RedirectResponse(url="/published", status_code=302) diff --git a/web/templates/candidates.html b/web/templates/candidates.html index dd3facf..806b4d8 100644 --- a/web/templates/candidates.html +++ b/web/templates/candidates.html @@ -1,13 +1,32 @@ {% extends "base.html" %} {% block title %}Кандидаты{% endblock %} {% block content %} -

Кандидаты

+
+

Кандидаты

+
+ {% if selected %} + #{{ selected.id }} · {{ selected.source_name or selected.tg_channel or '?' }} + {% endif %} +
+
-{% if error %}
❌ {{ error }}
{% endif %} +{% if error %} +
❌ {{ error }}
+{% endif %} -
+
- +
+
+ +
+
+
- -
-
- + + +
@@ -33,65 +45,149 @@
-
-{% for p in posts %} -
-
-
- {{ p.direction or '?' }} - {{ p.source_name }} · {{ p.published_at | dt }} -
-
{{ (p.text or '')[:2000] | markdown }}
- {% if p.is_own %}⭐ СВОЙ{% endif %} - {% if p.relevance %}{{ p.relevance }}{% endif %} - {% if p.interest %}interest {{ p.interest }}{% endif %} - 👁 {{ p.views }} 💬 {{ p.reactions_total }} - {% if p.media_path %} - 🖼 медиа - {% set media_src = '/media/' ~ p.media_path.split('/')[-1] %} - {% set media_ext = p.media_path.lower().rsplit('.', 1)[-1] if '.' in p.media_path else '' %} - {% if media_ext in ['jpg','jpeg','png','gif','webp'] %} - - медиа поста - - {% elif media_ext in ['mp4','webm','mov'] %} - - {% endif %} - {% endif %} - {% if p.fwd_from_channel_id %}↩︎ форвард{% endif %} -
- {% if p.status == 'new' %} -
-
- -
- {% if p.is_own %} -
- Рассылка (fan-out): - {% for d in directions %} -
- - + + + + + + + +
+
+
+
+ Группировка: + + +
+ + {% for g in posts %} +
+ + {{ g.key }} + — {{ g.posts|length }} + {% if g.status == 'new' %}новые + {% elif g.status == 'rejected' %}откл. + {% endif %} + +
+ {% for p in g.posts %} +
+ + + + + + + + + +
{% endfor %}
+
+ {% else %} +
Нет кандидатов по фильтру
+ {% endfor %} +
+
+ +
+
+ {% if selected %} +
+
#{{ selected.id }} {{ selected.source_name or '?' }}
+ {{ (selected.published_at or selected.fetched_at) | dt }} +
+
+ {% if selected.direction %}{{ selected.direction }}{% endif %} + {% if selected.relevance %}{{ selected.relevance }}{% endif %} + {% if selected.interest %}interest {{ selected.interest }}{% endif %} + {% if selected.is_own %}⭐ СВОЙ{% endif %} + {% if selected.fwd_from_channel_id %}↩︎ форвард{% endif %} + 👁 {{ selected.views }} 💬 {{ selected.reactions_total }} + {% if selected.url %}Исходник{% endif %} +
+ +
+ {% if selected.status != 'published' %} +
+ +
{% endif %} - - -
- -
- {% elif p.status == 'rejected' %} - Отклонён + {% if selected.status != 'rejected' %} +
+ +
+ {% endif %} + {% if selected.status != 'published' %} +
+ +
+
+ +
+ {% endif %} +
+ +
+
+ + + +
+ {% if selected.rewritten_text %} +
+ + +
+ {% endif %} +
+ +
+
Оригинальный пост
+ {% if selected.text %} +
{{ selected.text | markdown }}
+ {% endif %} + {% if selected.media_path %} + {% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %} + {% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %} + {% if media_ext in ['jpg','jpeg','png','gif','webp'] %} + + медиа поста + + {% elif media_ext in ['mp4','webm','mov'] %} + + {% else %} + 📎 Медиафайл + {% endif %} + {% endif %} + {% if selected.summary %}
резюме {{ selected.summary }}
{% endif %} + + {% else %} +
Выберите кандидата слева
{% endif %} - Исходник
-{% else %} -
Нет постов
-{% endfor %} -
+ {% endblock %} \ No newline at end of file