mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
feat(candidates): список отобранных между кандидатами и опубликованными
- новый статус selected; в кандидатах только «⭐ Отобрать» (single+bulk) - /selected — отдельный список: вся обработка (классификация/переписывание/комментарий/публикация) - классификация только по кнопке (токены на кандидатов не тратятся) - рядом с оригиналом — поле «✍️ Мой пост» (rewritten_text всегда виден), туда ложится черновик «Переписать» - все обработчики учитывают src (candidates|selected) для редиректов
This commit is contained in:
@@ -158,9 +158,9 @@ CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --l
|
|||||||
- Группировка (date/source/status) и фильтры сохраняются после любых действий из правой панели.
|
- Группировка (date/source/status) и фильтры сохраняются после любых действий из правой панели.
|
||||||
- Тихие OpenSpec-change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x], валидны; архивировать после подтверждения.
|
- Тихие OpenSpec-change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x], валидны; архивировать после подтверждения.
|
||||||
- Правый блок действий: кнопки «🤖 Обработать моделью / ✍️ Переписать / 🚫 Отклонить» вынесены в ОДИН ряд НАД полем комментария, «✅ Опубликовать» — в начале approve-формы, поле комментария + «💾 Сохранить комментарий» — ниже (change `candidates-buttons-order`).
|
- Правый блок действий: кнопки «🤖 Обработать моделью / ✍️ Переписать / 🚫 Отклонить» вынесены в ОДИН ряд НАД полем комментария, «✅ Опубликовать» — в начале approve-формы, поле комментария + «💾 Сохранить комментарий» — ниже (change `candidates-buttons-order`).
|
||||||
- После «🚫 Отклонить» (и bulk reject/reject-old) — остаёмся на странице кандидатов в текущей группировке, НЕ уходим в «🗑 Отклонённые» (change `reject-stay-candidates`); отклонённые — через фильтр статуса.
|
|
||||||
- Список кандидатов: флаг «прочитан» — непрочитанные карточки жирные, выбранный пост — светло-голубая заливка (класс active-row), открытие поста помечает is_read=1; группировка по умолчанию — «дата» (хронология получения) (change `candidates-read-flag`).
|
- Список кандидатов: флаг «прочитан» — непрочитанные карточки жирные, выбранный пост — светло-голубая заливка (класс 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)
|
- /opt/vesti/AGENT.MD — правила проекта (обязательно: все изменения через OpenSpec)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
skip_specs: true
|
||||||
|
created: 2026-09-15
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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 + коммит/пуш
|
||||||
+126
-31
@@ -141,8 +141,9 @@ def _row(r):
|
|||||||
return _Row(dict(r)) if r is not None else None
|
return _Row(dict(r)) if r is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _cand_back(group_by: str = "", direction: str = "", own: str = "", q: str = "", **extra):
|
def _cand_back(group_by: str = "", direction: str = "", own: str = "", q: str = "",
|
||||||
"""URL возврата в /candidates с сохранением группировки и фильтров."""
|
src: str = "candidates", **extra):
|
||||||
|
"""URL возврата в список (src: candidates|selected) с сохранением группировки и фильтров."""
|
||||||
parts = []
|
parts = []
|
||||||
if group_by:
|
if group_by:
|
||||||
parts.append(f"group_by={quote(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():
|
for k, v in extra.items():
|
||||||
if v:
|
if v:
|
||||||
parts.append(f"{k}={quote(str(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 = {
|
STATUS_LABELS = {
|
||||||
"new": "💎 Новые",
|
"new": "💎 Новые",
|
||||||
|
"selected": "⭐ Отобранные",
|
||||||
"rejected": "🗑 Отклонённые",
|
"rejected": "🗑 Отклонённые",
|
||||||
"published": "✅ Опубликованные",
|
"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:
|
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)
|
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")
|
@app.post("/candidates/select")
|
||||||
def candidates_select(request: Request, id: int = Form(...), direction: str = Form(""),
|
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")):
|
status: str = Form(""), own: str = Form(""), q: str = Form(""), group_by: str = Form("date")):
|
||||||
@@ -346,10 +401,11 @@ def candidates_select(request: Request, id: int = Form(...), direction: str = Fo
|
|||||||
@app.post("/candidates/bulk")
|
@app.post("/candidates/bulk")
|
||||||
def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(default_factory=list),
|
def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(default_factory=list),
|
||||||
direction: str = Form(""), status: str = Form(""), own: str = Form(""),
|
direction: str = Form(""), status: str = Form(""), own: str = Form(""),
|
||||||
q: str = Form(""), group_by: str = Form("date")):
|
q: str = Form(""), group_by: str = Form("date"), src: str = Form("candidates")):
|
||||||
"""Bulk-операции: approve/reject/reject-old/process по отмеченным ids."""
|
"""Bulk-операции: select/reject/reject-old/process/approve по отмеченным ids.
|
||||||
|
src — откуда вызывались (candidates|selected), туда и возвращаемся."""
|
||||||
_require_auth(request)
|
_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)):
|
for k, v in (("direction", direction), ("status", status), ("own", own), ("q", q)):
|
||||||
if v:
|
if v:
|
||||||
back += f"&{k}={quote(v)}"
|
back += f"&{k}={quote(v)}"
|
||||||
@@ -358,11 +414,17 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] =
|
|||||||
conn = _db()
|
conn = _db()
|
||||||
ph = ", ".join("?" * len(ids))
|
ph = ", ".join("?" * len(ids))
|
||||||
rows = conn.execute(f"SELECT * FROM posts WHERE id IN ({ph})", ids).fetchall()
|
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":
|
if action == "reject":
|
||||||
conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids)
|
conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
# остаёмся на кандидатах (текущая группировка), не уходим в отклонённые
|
# остаёмся в текущем списке (кандидаты/отобранные), не уходим в отклонённые
|
||||||
return RedirectResponse(url=back, status_code=302)
|
return RedirectResponse(url=back, status_code=302)
|
||||||
if action == "reject-old":
|
if action == "reject-old":
|
||||||
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
|
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
|
||||||
@@ -376,7 +438,6 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] =
|
|||||||
conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill)
|
conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
# остаёмся на кандидатах (текущая группировка), не уходим в отклонённые
|
|
||||||
return RedirectResponse(url=back, status_code=302)
|
return RedirectResponse(url=back, status_code=302)
|
||||||
conn.close()
|
conn.close()
|
||||||
if action == "process":
|
if action == "process":
|
||||||
@@ -390,8 +451,37 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] =
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
errs.append(f"{r['id']}({e})")
|
errs.append(f"{r['id']}({e})")
|
||||||
if errs:
|
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)
|
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 — каждый через существующую логику (публикация + бандл)
|
# approve — каждый через существующую логику (публикация + бандл)
|
||||||
errs = []
|
errs = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -401,49 +491,52 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] =
|
|||||||
if err:
|
if err:
|
||||||
errs.append(f"{r['id']}({err})")
|
errs.append(f"{r['id']}({err})")
|
||||||
if errs:
|
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)
|
return RedirectResponse(url="/published", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/posts/{post_id}/rewrite-save")
|
@app.post("/posts/{post_id}/rewrite-save")
|
||||||
async def post_rewrite_save(post_id: int, request: Request,
|
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)."""
|
"""Сохраняет отредактированный пользователем пересказ (rewritten_text)."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
conn = _db()
|
conn = _db()
|
||||||
p = conn.execute("SELECT id FROM posts WHERE id=?", (post_id,)).fetchone()
|
p = conn.execute("SELECT id FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||||
if not p:
|
if not p:
|
||||||
conn.close()
|
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()
|
form = await request.form()
|
||||||
draft = str(form.get("rewritten_text") or "").strip()
|
draft = str(form.get("rewritten_text") or "").strip()
|
||||||
conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft or None, post_id))
|
conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft or None, post_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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")
|
@app.post("/posts/{post_id}/comment")
|
||||||
def post_comment(post_id: int, request: Request, comment: str = Form(""),
|
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)
|
_require_auth(request)
|
||||||
conn = _db()
|
conn = _db()
|
||||||
conn.execute("UPDATE posts SET comment=? WHERE id=?", (comment.strip(), post_id))
|
conn.execute("UPDATE posts SET comment=? WHERE id=?", (comment.strip(), post_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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")
|
@app.post("/posts/{post_id}/reclassify")
|
||||||
def post_reclassify(post_id: int, request: Request,
|
def post_reclassify(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(""),
|
||||||
"""Переклассификация кандидата локальной моделью (qwen)."""
|
src: str = Form("candidates")):
|
||||||
|
"""Переклассификация локальной моделью (qwen)."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
try:
|
try:
|
||||||
impl_reclassify(post_id)
|
impl_reclassify(post_id)
|
||||||
except Exception as e:
|
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, src=src, 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), status_code=302)
|
||||||
|
|
||||||
|
|
||||||
def impl_reclassify(post_id: int):
|
def impl_reclassify(post_id: int):
|
||||||
@@ -465,14 +558,15 @@ def impl_reclassify(post_id: int):
|
|||||||
|
|
||||||
@app.post("/posts/{post_id}/rewrite")
|
@app.post("/posts/{post_id}/rewrite")
|
||||||
def post_rewrite(post_id: int, request: Request,
|
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)
|
_require_auth(request)
|
||||||
from classifier.classify import call_ollama
|
from classifier.classify import call_ollama
|
||||||
conn = _db()
|
conn = _db()
|
||||||
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||||
if not p:
|
if not p:
|
||||||
conn.close()
|
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)
|
d = dict(p)
|
||||||
text_ = (d.get("text") or "")[:2000]
|
text_ = (d.get("text") or "")[:2000]
|
||||||
prompt = (
|
prompt = (
|
||||||
@@ -488,11 +582,11 @@ def post_rewrite(post_id: int, request: Request,
|
|||||||
draft = r.get("summary") or ""
|
draft = r.get("summary") or ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.close()
|
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.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft, post_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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)
|
@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")
|
@app.post("/posts/{post_id}/approve")
|
||||||
async def approve(post_id: int, request: Request,
|
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."""
|
"""Подтверждение черновика → публикация в TG (fan-out по направлениям) + создание бандла + статус published."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
# направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста
|
# направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста
|
||||||
@@ -588,22 +683,22 @@ async def approve(post_id: int, request: Request,
|
|||||||
pass
|
pass
|
||||||
err = impl_approve(post_id, comment, dirs_selected)
|
err = impl_approve(post_id, comment, dirs_selected)
|
||||||
if err:
|
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")
|
@app.post("/posts/{post_id}/reject")
|
||||||
def reject(post_id: int, request: Request, group_by: str = Form(""), direction: str = Form(""),
|
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)
|
_require_auth(request)
|
||||||
conn = _db()
|
conn = _db()
|
||||||
conn.execute("UPDATE posts SET status='rejected' WHERE id=?", (post_id,))
|
conn.execute("UPDATE posts SET status='rejected' WHERE id=?", (post_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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)
|
@app.get("/metrics", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<a class="navbar-brand" href="/candidates">📰 VESTI</a>
|
<a class="navbar-brand" href="/candidates">📰 VESTI</a>
|
||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a class="nav-link" href="/candidates">Кандидаты</a>
|
<a class="nav-link" href="/candidates">Кандидаты</a>
|
||||||
|
<a class="nav-link" href="/selected">⭐ Отобранные</a>
|
||||||
<a class="nav-link" href="/published">Опубликовано</a>
|
<a class="nav-link" href="/published">Опубликовано</a>
|
||||||
<a class="nav-link" href="/metrics">Метрики</a>
|
<a class="nav-link" href="/metrics">Метрики</a>
|
||||||
<a class="nav-link" href="/crawlers">Краулеры</a>
|
<a class="nav-link" href="/crawlers">Краулеры</a>
|
||||||
|
|||||||
@@ -99,21 +99,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/candidates/bulk" id="bulk-form"
|
<form method="post" action="/candidates/bulk" id="bulk-form"
|
||||||
onsubmit="return confirm('Выполнить групповое действие над выбранными?')">
|
onsubmit="return confirm('Отобрать выбранные посты?')\n || true">
|
||||||
<input type="hidden" name="direction" value="{{ direction }}">
|
<input type="hidden" name="direction" value="{{ direction }}">
|
||||||
<input type="hidden" name="status" value="{{ status }}">
|
<input type="hidden" name="status" value="{{ status }}">
|
||||||
<input type="hidden" name="own" value="{{ own }}">
|
<input type="hidden" name="own" value="{{ own }}">
|
||||||
<input type="hidden" name="q" value="{{ q }}">
|
<input type="hidden" name="q" value="{{ q }}">
|
||||||
<input type="hidden" name="group_by" value="{{ group_by }}">
|
<input type="hidden" name="group_by" value="{{ group_by }}">
|
||||||
|
<input type="hidden" name="src" value="candidates">
|
||||||
<div class="border-top pt-2 mt-1">
|
<div class="border-top pt-2 mt-1">
|
||||||
<div class="small text-muted mb-1">⚡ Массовые операции <span class="badge bg-secondary" id="sel-count">0</span></div>
|
<div class="small text-muted mb-1">⚡ Массовые операции <span class="badge bg-secondary" id="sel-count">0</span></div>
|
||||||
<div class="btn-group btn-group-sm w-100 d-flex flex-wrap gap-1">
|
<div class="btn-group btn-group-sm w-100 d-flex flex-wrap gap-1">
|
||||||
<button class="btn btn-sm btn-success flex-fill" name="action" value="approve">✅ Опубликовать</button>
|
<button class="btn btn-sm btn-warning flex-fill text-dark" name="action" value="select">⭐ Отобрать</button>
|
||||||
<button class="btn btn-sm btn-info flex-fill text-dark" name="action" value="process">🤖 Обработать моделью</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger flex-fill" name="action" value="reject">🚫 Отклонить</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary flex-fill" name="action" value="reject-old">🗑 Старьё</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text mt-1">Отметьте галочками слева и нажмите действие. «Обработать» — переклассификация локальной моделью (qwen).</div>
|
<div class="form-text mt-1">Отберите понравившиеся посты — они улетят в «Отобранные», где их можно обработать, переписать и опубликовать. Классификация моделью — только там, по кнопке.</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,54 +147,13 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{% set hid %}<input type="hidden" name="group_by" value="{{ group_by }}"><input type="hidden" name="direction" value="{{ direction }}"><input type="hidden" name="own" value="{{ own }}"><input type="hidden" name="q" value="{{ q }}">{% endset %}
|
{% set hid %}<input type="hidden" name="group_by" value="{{ group_by }}"><input type="hidden" name="direction" value="{{ direction }}"><input type="hidden" name="own" value="{{ own }}"><input type="hidden" name="q" value="{{ q }}">{% endset %}
|
||||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
{% if selected.status != 'selected' and selected.status != 'rejected' and selected.status != 'published' %}
|
||||||
{% if selected.status != 'published' %}
|
<form method="post" action="/posts/{{ selected.id }}/select">
|
||||||
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
|
||||||
{{ hid }}
|
{{ hid }}
|
||||||
<button class="btn btn-info" type="submit" title="Заново прогнать через модель (qwen3:8b): направление, релевантность, интерес — обновит бейджи">🤖 Обработать моделью</button>
|
<button class="btn btn-warning" type="submit" title="Отобрать пост — он уйдёт в «Отобранные», где доступны обработка моделью, переписывание, комментарий и публикация">⭐ Отобрать</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% else %}
|
||||||
{% if selected.status != 'published' %}
|
<div class="alert alert-info py-2 mb-1">Этот пост уже в «Отобранных» — обработка там.</div>
|
||||||
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
|
||||||
{{ hid }}
|
|
||||||
<button class="btn btn-outline-primary" type="submit" title="Сгенерировать черновик пересказа через модель — затем можно отредактировать и сохранить (пойдёт в канал вместо оригинала, от вашего имени)">✍️ Переписать</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
|
||||||
<form method="post" action="/posts/{{ selected.id }}/reject">
|
|
||||||
{{ hid }}
|
|
||||||
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if selected.status != 'published' %}
|
|
||||||
<form method="post" action="/posts/{{ selected.id }}/approve" id="approve-form">
|
|
||||||
{{ hid }}
|
|
||||||
<label class="form-label small text-muted mb-0 d-block">💬 Мой комментарий (будет первым в канале):</label>
|
|
||||||
<textarea class="form-control form-control-sm mt-1" name="comment" rows="2"
|
|
||||||
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
|
|
||||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
|
||||||
<button class="btn btn-success" type="submit" title="Опубликовать в канал: комментарий (если есть) первым, затем текст поста/пересказ, картинка, ссылка">✅ Опубликовать</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="submit"
|
|
||||||
formaction="/posts/{{ selected.id }}/comment"
|
|
||||||
title="Сохранить комментарий в черновик (не публикует). Он подтянется при следующей публикации">💾 Сохранить комментарий</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
{% if selected.rewritten_text %}
|
|
||||||
<div class="mt-2">
|
|
||||||
<label class="form-label small text-muted">✍️ Пересказ (редактируйте и сохраните — он пойдёт в канал от вашего имени):</label>
|
|
||||||
<form method="post" action="/posts/{{ selected.id }}/rewrite-save" class="row g-1">
|
|
||||||
{{ hid }}
|
|
||||||
<textarea class="form-control form-control-sm" name="rewritten_text" rows="5">{{ selected.rewritten_text }}</textarea>
|
|
||||||
<div class="col-auto mt-1">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" type="submit">💾 Сохранить пересказ</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Отобранные{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h4 class="mb-0">⭐ Отобранные</h4>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{% if selected %}
|
||||||
|
#{{ selected.id }} · {{ selected.source_name or selected.tg_channel or '?' }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2">❌ {{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="row g-2 mb-2" method="get" action="/selected">
|
||||||
|
<div class="col-auto">
|
||||||
|
<input type="text" name="q" value="{{ q }}" class="form-control form-control-sm" placeholder="поиск…">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select name="direction" class="form-select form-select-sm">
|
||||||
|
<option value="">Все направления</option>
|
||||||
|
{% for d in directions %}
|
||||||
|
<option value="{{ d }}" {% if direction == d %}selected{% endif %}>{{ d }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select name="own" class="form-select form-select-sm">
|
||||||
|
<option value="">Все посты</option>
|
||||||
|
<option value="1" {% if own == '1' %}selected{% endif %}>⭐ Свои</option>
|
||||||
|
<option value="0" {% if own == '0' %}selected{% endif %}>Внешние</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="submit">Применить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% set selected_qs = '&selected=' ~ selected.id if selected else '' %}
|
||||||
|
|
||||||
|
<div class="row g-0">
|
||||||
|
<!-- ЛЕВАЯ ПАНЕЛЬ: список отобранных -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="border rounded p-2 d-flex flex-column" style="max-height: 82vh; background:#fff;">
|
||||||
|
<div class="d-flex flex-wrap align-items-center mb-1 gap-1">
|
||||||
|
<span class="me-1 text-muted small">Группировка:</span>
|
||||||
|
{% for val, label in [('source', 'источник'), ('date', 'дата'), ('status', 'статус')] %}
|
||||||
|
{% if group_by == val %}
|
||||||
|
<span class="badge bg-primary text-white">{{ label }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a class="badge bg-light text-dark border text-decoration-none"
|
||||||
|
href="/selected?group_by={{ val }}{% if filters_qs %}&{{ filters_qs }}{% endif %}{{ selected_qs }}">{{ label }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow-y:auto; flex:1 1 auto;">
|
||||||
|
{% for g in posts %}
|
||||||
|
<details {% if loop.first or (selected and selected.id in g.posts|map(attribute='id')|list) %}open{% endif %} class="mb-2">
|
||||||
|
<summary class="small text-secondary">
|
||||||
|
<span class="badge bg-light text-dark border">{{ g.key }}</span>
|
||||||
|
<span class="text-muted">— {{ g.posts|length }}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="list-group list-group-flush small">
|
||||||
|
{% for p in g.posts %}
|
||||||
|
<div class="list-group-item d-flex align-items-start{% if selected and selected.id == p.id %} active-row{% endif %}{% if not p.is_read %} fw-bold{% endif %}">
|
||||||
|
<a class="flex-grow-1 text-decoration-none"
|
||||||
|
href="/selected?selected={{ p.id }}&group_by={{ group_by }}{% if filters_qs %}&{{ filters_qs }}{% endif %}">
|
||||||
|
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
|
||||||
|
<span class="d-block text-muted">
|
||||||
|
{{ p.source_name }} · {{ (p.published_at or p.fetched_at) | dt }}
|
||||||
|
{% if p.is_own %} · ⭐{% endif %}
|
||||||
|
{% if p.direction %}<span class="badge bg-light">{{ p.direction }}</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<input type="checkbox" name="ids" value="{{ p.id }}" form="bulk-form"
|
||||||
|
class="ms-auto flex-shrink-0"
|
||||||
|
style="width:1.3em;height:1.3em;cursor:pointer;"
|
||||||
|
{% if selected and selected.id == p.id %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info py-2">Нет отобранных по фильтру</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/candidates/bulk" id="bulk-form"
|
||||||
|
onsubmit="return confirm('Выполнить групповое действие над выбранными?')">
|
||||||
|
<input type="hidden" name="direction" value="{{ direction }}">
|
||||||
|
<input type="hidden" name="status" value="selected">
|
||||||
|
<input type="hidden" name="own" value="{{ own }}">
|
||||||
|
<input type="hidden" name="q" value="{{ q }}">
|
||||||
|
<input type="hidden" name="group_by" value="{{ group_by }}">
|
||||||
|
<input type="hidden" name="src" value="selected">
|
||||||
|
<div class="border-top pt-2 mt-1">
|
||||||
|
<div class="small text-muted mb-1">⚡ Массовые операции <span class="badge bg-secondary" id="sel-count">0</span></div>
|
||||||
|
<div class="btn-group btn-group-sm w-100 d-flex flex-wrap gap-1">
|
||||||
|
<button class="btn btn-sm btn-info flex-fill text-dark" name="action" value="process">🤖 Обработать моделью</button>
|
||||||
|
<button class="btn btn-sm btn-white flex-fill text-dark" name="action" value="rewrite">✍️ Переписать</button>
|
||||||
|
<button class="btn btn-sm btn-success flex-fill" name="action" value="approve">✅ Опубликовать</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger flex-fill" name="action" value="reject">🚫 Отклонить</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mt-1">«Обработать» — классификация/резюме локальной моделью (qwen), «Переписать» — черновик своего поста.</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ПРАВАЯ ПАНЕЛЬ: детали отобранного + подготовка своего поста -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="border rounded p-3 bg-white d-flex flex-column" style="height: 82vh;">
|
||||||
|
{% if selected %}
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-shrink-0">
|
||||||
|
<h5 class="mb-0">#{{ selected.id }} {{ selected.source_name or '?' }}</h5>
|
||||||
|
<small class="text-muted">{{ (selected.published_at or selected.fetched_at) | dt }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{% if selected.direction %}<span class="badge bg-secondary">{{ selected.direction }}</span>{% endif %}
|
||||||
|
{% if selected.relevance %}<span class="badge bg-{{ 'success' if selected.relevance=='critical' else 'info' }}">{{ selected.relevance }}</span>{% endif %}
|
||||||
|
{% if selected.interest %}<span class="badge bg-warning text-dark">interest {{ selected.interest }}</span>{% endif %}
|
||||||
|
{% if selected.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
|
||||||
|
{% if selected.fwd_from_channel_id %}<span class="badge bg-light text-dark">↩︎ форвард</span>{% endif %}
|
||||||
|
<small class="text-muted ms-2">👁 {{ selected.views }} 💬 {{ selected.reactions_total }}</small>
|
||||||
|
{% if selected.url %}<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ selected.url }}" target="_blank">Исходник</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 flex-shrink-0">
|
||||||
|
{% set hid %}<input type="hidden" name="group_by" value="{{ group_by }}"><input type="hidden" name="direction" value="{{ direction }}"><input type="hidden" name="own" value="{{ own }}"><input type="hidden" name="q" value="{{ q }}"><input type="hidden" name="src" value="selected">{% endset %}
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-info" type="submit" title="Прогнать через модель (qwen3:8b): направление, релевантность, интерес, резюме">🤖 Обработать моделью</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-outline-primary" type="submit" title="Сгенерировать черновик своего поста через модель — затем отредактируйте в поле справа">✍️ Переписать</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/reject">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/approve" id="approve-form">
|
||||||
|
{{ hid }}
|
||||||
|
<label class="form-label small text-muted mb-0 d-block">💬 Мой комментарий (будет первым в канале):</label>
|
||||||
|
<textarea class="form-control form-control-sm mt-1" name="comment" rows="2"
|
||||||
|
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||||
|
<button class="btn btn-success" type="submit" title="Опубликовать в канал: комментарий (если есть) первым, затем ваш текст (поле справа)/пересказ, картинка, ссылка">✅ Опубликовать</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="submit"
|
||||||
|
formaction="/posts/{{ selected.id }}/comment"
|
||||||
|
title="Сохранить комментарий в черновик (не публикует). Он подтянется при следующей публикации">💾 Сохранить комментарий</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- подготовка своего поста: редактор + оригинал рядом -->
|
||||||
|
<hr class="flex-shrink-0">
|
||||||
|
<div class="row g-2 flex-shrink-0">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted">✍️ Мой пост (редактируйте/сохраните — пойдёт в канал от вашего имени)</h6>
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/rewrite-save" class="row g-1">
|
||||||
|
{{ hid }}
|
||||||
|
<textarea class="form-control form-control-sm" name="rewritten_text" rows="8">{{ selected.rewritten_text or '' }}</textarea>
|
||||||
|
<div class="col-auto mt-1">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="submit">💾 Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted">Оригинальный пост</h6>
|
||||||
|
<div class="border rounded p-2 bg-light small overflow-auto" style="max-height:300px;">
|
||||||
|
{% if selected.text %}
|
||||||
|
<div class="post-text">{{ selected.text | markdown }}</div>
|
||||||
|
{% 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'] %}
|
||||||
|
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
|
||||||
|
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:320px" alt="медиа поста">
|
||||||
|
</a>
|
||||||
|
{% elif media_ext in ['mp4','webm','mov'] %}
|
||||||
|
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:320px"></video>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-sm btn-outline-secondary mt-2" href="{{ media_src }}" target="_blank">📎 Медиафайл</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.summary %}<div class="mt-2"><span class="badge bg-light text-dark">резюме</span> {{ selected.summary }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info py-3">Выберите отобранный пост слева</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var boxes = document.querySelectorAll('#bulk-form input[name="ids"]');
|
||||||
|
var badge = document.getElementById('sel-count');
|
||||||
|
function upd(){
|
||||||
|
var n = 0;
|
||||||
|
for (var i = 0; i < boxes.length; i++) if (boxes[i].checked) n++;
|
||||||
|
badge.textContent = n;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < boxes.length; i++) boxes[i].addEventListener('change', upd);
|
||||||
|
upd();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user