candidates-mail-ui: двухпанельный интерфейс кандидатов (почтовый клиент)

- БД: posts.comment TEXT, posts.rewritten_text TEXT (ALTER TABLE, миграция применена)
- GET /candidates: двухпанельный рендер — слева список с группировкой
  (source/date/status, свертываемые <details>) + чекбоксы 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 обновлены
This commit is contained in:
kpa39l
2026-09-13 20:53:53 +00:00
parent 970bf14a9d
commit fc6d725bd1
11 changed files with 601 additions and 159 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -0,0 +1,65 @@
# design.md — candidates-mail-ui
## Текущая структура (из чтения кода)
- `web/app.py` (FastAPI) — роуты:
- `GET /candidates` — рендер `candidates.html` (список карточек).
- `POST /posts/{id}/approve` — апрув (публикация через publisher).
- `POST /posts/{id}/reject` (или аналогичный) — отклонение.
- GET /media/{filename} — медиа (добавлен в web-media-preview).
- Таблица `posts`: id, source_id (FK), text, direction, lang, views, reactions,
media_path, status (new/published/rejected?), classified, created_at.
- Таблица `classifications`: post_id, direction, relevance, interest, summary, model.
## Архитектура нового UI
### 1. Левая панель: список + группировка + bulk
- Один маршрут `GET /candidates` с query-параметрами:
- `group_by` ∈ {source, date, status} (по умолчанию source)
- `filter` (статус: new/published/rejected/all)
- `q` (поиск по тексту)
- Ответ: тот же HTML (серверный рендер, Jinja2), но layout — две колонки
(Bootstrap 5 grid, уже подключён).
- Список строится из постов (new/rejected), сгруппированных по выбранному
признаку. Группы — сворачиваемые (`<details>`/`<summary>` — без JS).
- Каждый пост имеет чекбокс `<input type="checkbox" name="ids" value="{{ id }}">`.
- Форма bulk: `POST /candidates/bulk` с `ids[]` и `action` (approve/reject/reject-old).
### 2. Правая панель: детальный просмотр + действия
- Выбранный пост: `GET /candidates/{id}` (или та же страница с `?selected=<id>`).
- Секции:
- Кнопки действий: Опубликовать, Отклонить, Переклассифицировать,
Переписать новость (LLM).
- «Мой комментарий»: `<textarea name="comment">` + `POST /posts/{id}/comment`.
- Оригинальный пост: text (markdown), media (`/media/<basename>`), мета
(source @channel, канал, время, views/reactions), direction badge.
### 3. Новые роуты/данные
- `posts.comment TEXT` (nullable) — комментарий пользователя к посту.
- `POST /posts/{id}/comment` — сохранить комментарий.
- `POST /candidates/bulk` — bulk-одобрение/отклонение (транзакция).
- `POST /posts/{id}/reclassify` — перезапуск классификатора (classifier.classify_text
на посте, обновить direction/classifications).
- `POST /posts/{id}/rewrite` — LLM-пересказ (qwen3:8b через Ollama): сгенерировать
черновик текста от себя (без копипасты), сохранить в `posts.rewritten_text`,
вернуть в текстовое поле для редактирования.
### 4. Минимум JS
- Без JS: чекбоксы + `form` с method=post (multi-select через чекбоксы в одной форме).
- Группировка/сворачивание: `<details>`.
- Автоподсветка выбранного: сервер рендерит `class="active"` по `selected` id.
## Реализация (по шагам)
1. Миграция БД: `ALTER TABLE posts ADD COLUMN comment TEXT`.
2. Роуты: GET /candidates (двухпанельный), POST /candidates/bulk,
POST /posts/{id}/comment, POST /posts/{id}/reclassify, POST /posts/{id}/rewrite.
3. Шаблоны: candidates.html переписать (двухколоночная), добавить new/reclassify/
comment/rewrite формы.
4. Bulk-логика + группировка на сервере (Python).
5. Тест: ручной сценарий (создать 2-3 поста, сгруппировать, bulk-отклонить,
коммент, реклассификация).
@@ -0,0 +1,45 @@
## Why
Пользователю неудобно работать с карточками кандидатов в текущем виде. Сейчас:
- Страница кандидатов — это лента карточек (каждая с апрув-кнопкой), нет списка слева,
нет меню действий справа, нет группировки, нет bulk-операций.
- Пользователь описал целевой интерфейс: «схожий с почтовыми клиентами» — общий список
сообщений в левой панели, конкретное сообщение справа.
- Отклонять/одобрять по группам (например, «все старые посты отклонить») сейчас нельзя:
только по одному.
## What Changes
Страница кандидатов `candidates` перерабатывается в двухпанельный интерфейс:
- **Левая панель** — список кандидатов (сообщений):
- элемент списка: аватар/иконка источника, заголовок (направление + краткий текст),
мета (источник, дата поступления, views/reactions, статус).
- активный/выбранный элемент подсвечивается.
- **группировка** (сворачиваемые группы): по источнику, по дате поступления,
по статусу (новые / одобренные / отклонённые).
- выбор нескольких элементов (чекбоксы) + **bulk-действия** над группой:
«отклонить все выбранные», «одобрить все выбранные».
- **Правая панель** — детальный просмотр выбранного кандидата:
- меню действий: **Опубликовать** (approve), **Отклонить** (reject), **Переклассифицировать**,
**Переписать новость под себя** (LLM-пересказ, ввод текста).
- блок «Мой комментарий» (textarea, сохраняется).
- оригинальный пост со всеми медиа (текст, картинки/видео, ссылки, мета источника).
- Хранение комментария — новое поле/таблица (comment в posts или отдельная
post_comments), связь с пользователем.
- Статусы: у поста уже есть status (new/rejected/published?) — использовать его для
группировки; добавить, если нет.
## Why Not
- Не делаем drag&drop, не делаем виртуальную бесконечную прокрутку — достаточно
пейджинга/группировки (минимум JS, стабильно).
- Не меняем существующие роуты approve/reject — только дополняем новыми (bulk,
переклассификация, коммент).
## Open Questions
- Комментарий: отдельная таблица или поле в posts? (предлагаю поле posts.comment TEXT)
- Переписывание: LLM (qwen) генерирует черновик, пользователь редактирует и публикует?
- «Переклассифицировать» — повторный запуск классификатора на посте?
- Группировка: одновременно несколько группировок или одна выбранная?
@@ -0,0 +1,108 @@
# Spec — candidates-mail-ui
## ADDED Requirements
### Requirement: Двухпанельный интерфейс кандидатов
Страница кандидатов разделена на две панели: слева список кандидатов (с группировкой),
справа — детальный просмотр выбранного кандидата с меню действий.
#### Scenario: Пользователь открывает список кандидатов
Given на странице `GET /candidates`
When страница загружается
Then левая панель показывает список кандидатов, правая — детали первого/последнего выбранного
And активный кандидат подсвечивается
#### Scenario: Группировка списка по источнику
Given список кандидатов
When пользователь выбирает группировку «по источнику»
Then кандидаты сгруппированы по источнику (канал/сайт)
And каждая группа сворачивается/разворачивается (без JS, через `<details>`)
#### 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 список фильтруется по вхождению в текст поста
@@ -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 (сворачиваемые <details>), чекбоксы
- [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