diff --git a/STATUS.md b/STATUS.md index f3c861f..176807b 100644 --- a/STATUS.md +++ b/STATUS.md @@ -158,9 +158,9 @@ CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --l - Группировка (date/source/status) и фильтры сохраняются после любых действий из правой панели. - Тихие OpenSpec-change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x], валидны; архивировать после подтверждения. - Правый блок действий: кнопки «🤖 Обработать моделью / ✍️ Переписать / 🚫 Отклонить» вынесены в ОДИН ряд НАД полем комментария, «✅ Опубликовать» — в начале approve-формы, поле комментария + «💾 Сохранить комментарий» — ниже (change `candidates-buttons-order`). -- После «🚫 Отклонить» (и bulk reject/reject-old) — остаёмся на странице кандидатов в текущей группировке, НЕ уходим в «🗑 Отклонённые» (change `reject-stay-candidates`); отклонённые — через фильтр статуса. - Список кандидатов: флаг «прочитан» — непрочитанные карточки жирные, выбранный пост — светло-голубая заливка (класс active-row), открытие поста помечает is_read=1; группировка по умолчанию — «дата» (хронология получения) (change `candidates-read-flag`). -- Правая панель: «✅ Опубликовать» — НИЖЕ поля комментария, рядом с «💾 Сохранить комментарий»; «🤖 Обработать / ✍️ Переписать / 🚫 Отклонить» — НАД полем комментария. Панель — фикс. высота 82vh, «Оригинальный пост» занимает всё оставшееся место (flex-grow + прокрутка), нет пустоты внизу. +- **Пайплайн «Кандидаты → Отобранные → Опубликованные» (change `selected-list`)**: новый статус `selected`; в КАНДИДАТАХ — только «⭐ Отобрать» (single+bulk), вся обработка (классификация/переписывание/комментарий/публикация) — в «⭐ Отобранных» (отдельный список в навбаре, `/selected`); классификация — только по кнопке «🤖 Обработать моделью» (токены на кандидатов не тратятся; bulk «Обработать» из кандидатов убран). +- **Подготовка своего поста (в «Отобранных»)**: рядом с оригиналом — поле «✍️ Мой пост» (textarea rewritten_text, ВСЕГДА видно), куда «✍️ Переписать» кладёт черновик модели; текст редактируется и сохраняется (публикация идёт им, а не репостом оригинала); «✅ Опубликовать» и «💾 Сохранить комментарий» — под полем комментария. ## Ключевые артефакты - /opt/vesti/AGENT.MD — правила проекта (обязательно: все изменения через OpenSpec) diff --git a/openspec/changes/selected-list/.openspec.yaml b/openspec/changes/selected-list/.openspec.yaml new file mode 100644 index 0000000..4258753 --- /dev/null +++ b/openspec/changes/selected-list/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +skip_specs: true +created: 2026-09-15 diff --git a/openspec/changes/selected-list/design.md b/openspec/changes/selected-list/design.md new file mode 100644 index 0000000..658a433 --- /dev/null +++ b/openspec/changes/selected-list/design.md @@ -0,0 +1,31 @@ +# selected-list + +## Design + +### БД + +- Статус `selected` — новое значение `posts.status`, миграция не нужна (status — TEXT без CHECK). +- Колонка `is_read` уже есть. Новых колонок нет. + +### Маршруты веб (`web/app.py`) + +- `GET /candidates` — без изменений (кандидаты = new). Из шаблона убираем approve/comment/reject/reclassify/rewrite кнопки, добавляем «⭐ Отобрать». +- `POST /candidates/select` — теперь «отобрать»: `UPDATE posts SET status='selected' WHERE id=? AND is_own=0 AND status='new'`. Удалить из старого черновика прежний смысл (выбор поста для просмотра) — он больше для «выбора карточки» не нужен (клик по карточке — GET ссылка, выборка кнопкой «Отобрать»). +- `GET /selected` — главный новый: список отобранных (status=selected, is_own=0), двухпанельный как /candidates, но с полным функционалом. Копия логики /candidates с параметром status='selected'. +- `POST /selected/select` — выбор карточки в отобранных (аналог старого candidates/select, но GET-переход по ссылке, form не нужна). +- `POST /posts/{id}/reject` — уже есть, редиректит на /candidates; добавить `back` с параметром (чтобы из selected вести обратно в selected). +- `impl_approve` — уже публикует rewritten_text если он есть; ok. +- Bulk в selected: action=select (для кандидатов), approve/reject/process/reject-old — там же, где в candidates. + +### Шаблоны + +- `candidates.html` — правый блок: только статус/бейджи + «⭐ Отобрать» (single) + bulk. Убрать approve-form/comment/reclassify/rewrite/reject. +- `selected.html` — НОВЫЙ (клон candidates.html) с полным функционалом + поле `rewritten_text` ВСЕГДА рядом с оригиналом + кнопки. +- `base.html` — навбар: ссылка «Отобранные» (со счётчиком) между «Кандидаты» и «Опубликовано». + +### Открытые решения + +- GET /candidates рендерит и левую панель; в кандидатах больше нет «выбора для просмотра» — + клик по карточке открывает пост (selected) — оставим клик по ссылке на правосторонний + просмотр (тот же механизм `?selected=`), но сам статус «отобрать» — отдельной кнопкой. +- В /selected — «🚫 Отклонить» отобранного → status=rejected, редирект на /selected. \ No newline at end of file diff --git a/openspec/changes/selected-list/proposal.md b/openspec/changes/selected-list/proposal.md new file mode 100644 index 0000000..e815b99 --- /dev/null +++ b/openspec/changes/selected-list/proposal.md @@ -0,0 +1,44 @@ +# selected-list: список отобранных между кандидатами и опубликованными + +## Улучшение + +Сейчас пайплайн «кандидаты → опубликованные» без промежуточного шага: +- Кандидаты (new): куча новых постов, классификация всех через LLM (токены), обработка + (comment/approve/reject/reclassify/rewrite) прямо здесь — всё сразу. +- Пользователь: «репост никому не интересен, подпишутся на первоисточник» — нужен + собственный подготовленный пост. Отбираем понравившееся руками, готовим свой пост + уже в отобранных. + +## Изменения + +### Новый статус `selected` + +- `posts.status`: добавляем значение `selected` («отобранный»). +- Кандидаты = `status='new'` (внешние). Отобранные = `status='selected'` (внешние). +- Свой контент (is_own=1) — не кандидаты и не отобранные (управляется отдельно). + +### Веб + +- **Кандидаты (`/candidates`)**: только «⭐ Отобрать» (single + bulk `select`). + БЕЗ approve/reject/comment/reclassify/rewrite — обработка живёт в отобранных. + Кнопки «Обработать моделью/Переписать/Отклонить/Опубликовать/комментарий» — убираем. +- **Отобранные (`/selected`)**: новый экран, полный функционал: + - карточки слева (как кандидаты), статус selected + - справа: бейджи, комментарий, «✅ Опубликовать», «💾 Сохранить комментарий», + «🤖 Обработать моделью» (reclassify), «✍️ Переписать» (rewrite), + «💾 Сохранить пересказ», «🚫 Отклонить» + - bulk: approve / reject / process / select +- `published` — без изменений. + +### Редактор всегда виден + +В отобранных рядом с оригиналом поста — поле редактирования (textarea `rewritten_text`): +- показывается ВСЕГДА (не только если уже есть пересказ) +- в него кладётся результат «✍️ Переписать» (rewrite) +- редактируется вручную +- при публикации (approve) — body = rewritten_text если есть, иначе оригинал (уже так в card.py) + +### Экономия токенов + +- Краулер НЕ классифицирует новые (сейчас и так worker не в кроне; classified остаётся 0). +- Классификация — только по кнопке «🤖 Обработать моделью» В ОТОБРАННЫХ (reclassify на selected). \ No newline at end of file diff --git a/openspec/changes/selected-list/tasks.md b/openspec/changes/selected-list/tasks.md new file mode 100644 index 0000000..dc72a82 --- /dev/null +++ b/openspec/changes/selected-list/tasks.md @@ -0,0 +1,14 @@ +# selected-list + +- [x] OpenSpec change создан (proposal/design) — skip_specs +- [x] openspec validate selected-list +- [x] web/app.py: GET /selected (двухпанельный, status=selected) + выбор поста (переклики с candidates) +- [x] web/app.py: POST /posts/{id}/select (new → selected) + bulk action=select +- [x] web/app.py: _cand_back(+src), все обработчики (reject/comment/reclassify/rewrite/rewrite-save/approve/bulk) — учитывают src +- [x] web/templates/selected.html: список + полный функционал + редактор «Мой пост» рядом с оригиналом +- [x] web/templates/candidates.html: только «⭐ Отобрать» (single + bulk) — убраны approve/reject/process/rewrite/comment/редактор +- [x] web/templates/base.html: навбар «⭐ Отобранные» +- [x] bulk rewrite (переписать выбранные) +- [x] Тесты: select single/bulk → /selected; кнопки; rewrite (реальный Ollama) → rewritten_text; живой HTTP +- [x] Откат тестовых мутаций (85/109 → new) +- [x] Обновить STATUS.md + коммит/пуш \ No newline at end of file diff --git a/web/app.py b/web/app.py index bc4a836..5f1e024 100644 --- a/web/app.py +++ b/web/app.py @@ -141,8 +141,9 @@ def _row(r): return _Row(dict(r)) if r is not None else None -def _cand_back(group_by: str = "", direction: str = "", own: str = "", q: str = "", **extra): - """URL возврата в /candidates с сохранением группировки и фильтров.""" +def _cand_back(group_by: str = "", direction: str = "", own: str = "", q: str = "", + src: str = "candidates", **extra): + """URL возврата в список (src: candidates|selected) с сохранением группировки и фильтров.""" parts = [] if group_by: parts.append(f"group_by={quote(group_by)}") @@ -155,16 +156,17 @@ def _cand_back(group_by: str = "", direction: str = "", own: str = "", q: str = for k, v in extra.items(): if v: parts.append(f"{k}={quote(str(v))}") - return "/candidates?" + "&".join(parts) if parts else "/candidates" + return (f"/{src}?" + "&".join(parts)) if parts else f"/{src}" STATUS_LABELS = { "new": "💎 Новые", + "selected": "⭐ Отобранные", "rejected": "🗑 Отклонённые", "published": "✅ Опубликованные", "": "Все", } -STATUS_COLORS = {"new": "success", "rejected": "danger", "published": "primary"} +STATUS_COLORS = {"new": "success", "selected": "warning", "rejected": "danger", "published": "primary"} def _group_label(group_by: str, key: str) -> str: @@ -331,6 +333,59 @@ def candidates(request: Request, direction: str = "", status: str = "", own: str return HTMLResponse(html) +@app.get("/selected", response_class=HTMLResponse) +def selected(request: Request, direction: str = "", own: str = "", q: str = "", + group_by: str = "date", selected: int = 0, error: str = ""): + """Отобранные: только status='selected' (внешние). Полная обработка постов.""" + _require_auth(request) + conn = _db() + # отобранные = внешние посты со статусом selected + grouped, counts, _ = _fetch_candidates(conn, direction, "selected", own, q, group_by) + counts = {} + 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 + if sel is not None and sel["id"]: + conn.execute("UPDATE posts SET is_read=1 WHERE id=? AND is_read=0", (sel["id"],)) + sel["is_read"] = 1 + conn.commit() + conn.close() + qs = [] + for k, v in (("direction", direction), ("own", own), ("q", q)): + if v: + qs.append(f"{k}={quote(v)}") + if not selected: + selected = sel["id"] if sel else 0 + filters_qs = "&".join(qs) + html = tpl.get_template("selected.html").render( + posts=grouped, counts=counts, status="selected", direction=direction, own=own, + q=q, group_by=group_by, selected=sel, error=error, filters_qs=filters_qs, + directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS, + ) + return HTMLResponse(html) + + +@app.post("/posts/{post_id}/select") +def post_select(post_id: int, request: Request, + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form("")): + """Отобрать кандидата (new → selected). Затем переходим в отобранные.""" + _require_auth(request) + conn = _db() + conn.execute("UPDATE posts SET status='selected' WHERE id=? AND status='new' AND is_own=0", (post_id,)) + conn.commit() + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src="selected", selected=post_id), status_code=302) + + @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("date")): @@ -346,23 +401,30 @@ def candidates_select(request: Request, id: int = Form(...), direction: str = Fo @app.post("/candidates/bulk") def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(default_factory=list), direction: str = Form(""), status: str = Form(""), own: str = Form(""), - q: str = Form(""), group_by: str = Form("date")): - """Bulk-операции: approve/reject/reject-old/process по отмеченным ids.""" + q: str = Form(""), group_by: str = Form("date"), src: str = Form("candidates")): + """Bulk-операции: select/reject/reject-old/process/approve по отмеченным ids. + src — откуда вызывались (candidates|selected), туда и возвращаемся.""" _require_auth(request) - back = f"/candidates?group_by={group_by}" + back = f"/{src}?group_by={group_by}" for k, v in (("direction", direction), ("status", status), ("own", own), ("q", q)): if v: back += f"&{k}={quote(v)}" if not ids: return RedirectResponse(url=back + "&error=no_selection", status_code=302) conn = _db() - ph = ",".join("?" * len(ids)) + ph = ", ".join("?" * len(ids)) rows = conn.execute(f"SELECT * FROM posts WHERE id IN ({ph})", ids).fetchall() + if action == "select": + # отбор кандидатов (new → selected) + conn.execute("UPDATE posts SET status='selected' WHERE id IN ({}) AND status='new' AND is_own=0".format(ph), ids) + conn.commit() + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src="selected"), status_code=302) if action == "reject": conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids) conn.commit() conn.close() - # остаёмся на кандидатах (текущая группировка), не уходим в отклонённые + # остаёмся в текущем списке (кандидаты/отобранные), не уходим в отклонённые return RedirectResponse(url=back, status_code=302) if action == "reject-old": # все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё» @@ -372,11 +434,10 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = 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)) + ph2 = ", ".join("?" * len(kill)) conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill) conn.commit() conn.close() - # остаёмся на кандидатах (текущая группировка), не уходим в отклонённые return RedirectResponse(url=back, status_code=302) conn.close() if action == "process": @@ -390,8 +451,37 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = except Exception as e: errs.append(f"{r['id']}({e})") if errs: - return RedirectResponse(url=f"/candidates?error=process_partial:{','.join(map(str,errs))[:120]}&group_by={group_by}", status_code=302) + return RedirectResponse(url=f"/{src}?error=process_partial:{','.join(map(str,errs))[:120]}&group_by={group_by}", status_code=302) return RedirectResponse(url=back + "&error=processed:" + str(done), status_code=302) + if action == "rewrite": + # сгенерировать черновик своего поста для каждого выбранного + errs = [] + done = 0 + for r in rows: + try: + from classifier.classify import call_ollama + d = dict(r) + text_ = (d.get("text") or "")[:2000] + prompt = ( + "Ты — редактор новостей. Перепиши новость СВОИМИ словами от первого лица\n" + "(как автор канала «Дед в АйТи»), без копипасты и без канцелярита. Сохрани смысл,\n" + "факты и ссылку на оригинал. Верни ТОЛЬКО текст пересказа, без пояснений.\n\n" + f"Оригинал:\n{text_}" + ) + res = call_ollama(prompt, 0, 0, str(d.get("direction") or "")) + draft = (res.get("text") or res.get("content") or "").strip() or res.get("summary") or "" + if draft: + conn = _db() + conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft, int(r["id"]))) + conn.commit() + conn.close() + done += 1 + else: + errs.append(f"{r['id']}(empty)") + except Exception as e: + errs.append(f"{r['id']}({e})") + errq = ("&error=rewrite_partial:" + ",".join(map(str, errs))[:120]) if errs else "" + return RedirectResponse(url=back + "&error=rewritten:" + str(done) + errq, status_code=302) # approve — каждый через существующую логику (публикация + бандл) errs = [] for r in rows: @@ -401,49 +491,52 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = 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=f"/{src}?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}/rewrite-save") async def post_rewrite_save(post_id: int, request: Request, - group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form("")): + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""), + src: str = Form("candidates")): """Сохраняет отредактированный пользователем пересказ (rewritten_text).""" _require_auth(request) conn = _db() p = conn.execute("SELECT id FROM posts WHERE id=?", (post_id,)).fetchone() if not p: conn.close() - return RedirectResponse(url=_cand_back(group_by, direction, own, q, error="notfound"), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, error="notfound"), status_code=302) form = await request.form() draft = str(form.get("rewritten_text") or "").strip() conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft or None, post_id)) conn.commit() conn.close() - return RedirectResponse(url=_cand_back(group_by, direction, own, q, selected=post_id), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302) @app.post("/posts/{post_id}/comment") def post_comment(post_id: int, request: Request, comment: str = Form(""), - group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form("")): + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""), + src: str = Form("candidates")): _require_auth(request) conn = _db() conn.execute("UPDATE posts SET comment=? WHERE id=?", (comment.strip(), post_id)) conn.commit() conn.close() - return RedirectResponse(url=_cand_back(group_by, direction, own, q, selected=post_id), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302) @app.post("/posts/{post_id}/reclassify") def post_reclassify(post_id: int, request: Request, - group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form("")): - """Переклассификация кандидата локальной моделью (qwen).""" + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""), + src: str = Form("candidates")): + """Переклассификация локальной моделью (qwen).""" _require_auth(request) try: impl_reclassify(post_id) except Exception as e: - return RedirectResponse(url=_cand_back(group_by, direction, own, q, selected=post_id, error=f"reclassify:{e}"), status_code=302) - return RedirectResponse(url=_cand_back(group_by, direction, own, q, selected=post_id), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"reclassify:{e}"), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302) def impl_reclassify(post_id: int): @@ -465,14 +558,15 @@ def impl_reclassify(post_id: int): @app.post("/posts/{post_id}/rewrite") def post_rewrite(post_id: int, request: Request, - group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form("")): + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""), + src: str = Form("candidates")): _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=_cand_back(group_by, direction, own, q, error="notfound"), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, error="notfound"), status_code=302) d = dict(p) text_ = (d.get("text") or "")[:2000] prompt = ( @@ -488,11 +582,11 @@ def post_rewrite(post_id: int, request: Request, draft = r.get("summary") or "" except Exception as e: conn.close() - return RedirectResponse(url=_cand_back(group_by, direction, own, q, selected=post_id, error=f"rewrite:{e}"), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"rewrite:{e}"), status_code=302) conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft, post_id)) conn.commit() conn.close() - return RedirectResponse(url=_cand_back(group_by, direction, own, q, selected=post_id), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302) @app.get("/published", response_class=HTMLResponse) @@ -574,7 +668,8 @@ def impl_approve(post_id: int, comment: str = "", dirs_selected: list[str] | Non @app.post("/posts/{post_id}/approve") async def approve(post_id: int, request: Request, - group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form("")): + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""), + src: str = Form("candidates")): """Подтверждение черновика → публикация в TG (fan-out по направлениям) + создание бандла + статус published.""" _require_auth(request) # направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста @@ -588,22 +683,22 @@ async def approve(post_id: int, request: Request, pass err = impl_approve(post_id, comment, dirs_selected) if err: - return RedirectResponse(url=_cand_back(group_by, direction, own, q, selected=post_id, error=err), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=err), status_code=302) # после публикации остаёмся в своей группировке, смотрим опубликованные - return RedirectResponse(url=_cand_back(group_by, direction, own, q, status="published"), status_code=302) + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, status="published"), status_code=302) @app.post("/posts/{post_id}/reject") def reject(post_id: int, request: Request, group_by: str = Form(""), direction: str = Form(""), - own: str = Form(""), q: str = Form("")): + own: str = Form(""), q: str = Form(""), src: str = Form("candidates")): _require_auth(request) conn = _db() conn.execute("UPDATE posts SET status='rejected' WHERE id=?", (post_id,)) conn.commit() conn.close() - # после отклонения остаёмся на странице кандидатов (та же группировка/фильтры), - # чтобы продолжить отбор; список отклонённых — через фильтр «🗑 Отклонённые» - return RedirectResponse(url=_cand_back(group_by, direction, own, q), status_code=302) + # после отклонения остаёмся на странице, откуда пришли (кандидаты/отобранные), + # в той же группировке/фильтрах; список отклонённых — через фильтр «🗑 Отклонённые» + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src), status_code=302) @app.get("/metrics", response_class=HTMLResponse) diff --git a/web/templates/base.html b/web/templates/base.html index de6b814..e97cc72 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -20,6 +20,7 @@ 📰 VESTI
- - - - - -
-
⚡ Массовые операции 0
-
- - - - -
-
Отметьте галочками слева и нажмите действие. «Обработать» — переклассификация локальной моделью (qwen).
-
-
+ onsubmit="return confirm('Отобрать выбранные посты?')\n || true"> + + + + + + +
+
⚡ Массовые операции 0
+
+ +
+
Отберите понравившиеся посты — они улетят в «Отобранные», где их можно обработать, переписать и опубликовать. Классификация моделью — только там, по кнопке.
+
+ @@ -149,54 +147,13 @@
{% set hid %}{% endset %} -
- {% if selected.status != 'published' %} -
+ {% if selected.status != 'selected' and selected.status != 'rejected' and selected.status != 'published' %} + {{ hid }} - +
- {% endif %} - {% if selected.status != 'published' %} -
- {{ hid }} - -
- {% endif %} - {% if selected.status != 'rejected' and selected.status != 'published' %} -
- {{ hid }} - -
- {% endif %} -
- {% if selected.status != 'published' %} -
- {{ hid }} - - -
- - -
-
- {% endif %} -
- -
- {% if selected.rewritten_text %} -
- -
- {{ hid }} - -
- -
-
-
+ {% else %} +
Этот пост уже в «Отобранных» — обработка там.
{% endif %}
diff --git a/web/templates/selected.html b/web/templates/selected.html new file mode 100644 index 0000000..4eaccee --- /dev/null +++ b/web/templates/selected.html @@ -0,0 +1,226 @@ +{% 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 %} + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +{% set selected_qs = '&selected=' ~ selected.id if selected else '' %} + +
+ +
+
+
+ Группировка: + {% for val, label in [('source', 'источник'), ('date', 'дата'), ('status', 'статус')] %} + {% if group_by == val %} + {{ label }} + {% else %} + {{ label }} + {% endif %} + {% endfor %} +
+ +
+ {% for g in posts %} +
+ + {{ g.key }} + — {{ g.posts|length }} + + +
+ {% else %} +
Нет отобранных по фильтру
+ {% endfor %} +
+ +
+ + + + + + +
+
⚡ Массовые операции 0
+
+ + + + +
+
«Обработать» — классификация/резюме локальной моделью (qwen), «Переписать» — черновик своего поста.
+
+
+
+
+ + +
+
+ {% 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 %} +
+
+ {% set hid %}{% endset %} +
+ {% if selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} + {% if selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} + {% if selected.status != 'rejected' and selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} +
+ {% if selected.status != 'published' %} +
+ {{ hid }} + + +
+ + +
+
+ {% endif %} +
+ + +
+
+
+
✍️ Мой пост (редактируйте/сохраните — пойдёт в канал от вашего имени)
+
+ {{ hid }} + +
+ +
+
+
+
+
Оригинальный пост
+
+ {% 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 %} +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file