tgbot: выбор кандидата метабазы inline-кнопками в карточке ревью

Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения
бота показывает кнопку «🗂 База (N)». По ней двухшагово (как delete/dismiss)
разворачивается список кандидатов inline-кнопками; выбор пиннит источник через
worker.ChooseCandidate (ручной матч, без авто-раскладки) и обновляет карточку.
Веб остаётся точкой точных правок (ручной ввод id/URL, «без базы»).

Безопасность границы: id кандидата из callback_data валидируется как ULID
(ident.Parse) до доменного вызова, как в вебе. Текст inline-кнопок Telegram не
парсится как HTML — название кандидата в подписи не экранируется.

SDD: change telegram-vybor-nahodok — дельта notifications (ADDED «Выбор
кандидата метабазы из карточки подтверждения бота») + review (MODIFIED
«Разделение труда транспортов»: быстрый выбор кандидата — Telegram-действие).
Влито в specs, change заархивирован. Миграций БД нет (кандидаты уже в БД).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-18 16:02:47 +03:00
co-authored by Claude Opus 4.8
parent 10a6348d39
commit f75d6b1f91
12 changed files with 541 additions and 12 deletions
@@ -0,0 +1,68 @@
## Контекст
Ядро выбора источника уже есть: `worker.ChooseCandidate(ctx, id, candidateID)`
пиннит кандидата (override provider/id/title/year/director), помечает `chosen`,
обновляет display_name — но **не** запускает раскладку (человек подтверждает
«Применить»). Веб-транспорт зовёт её из `handleChooseCandidate`, валидируя
`candidate_id` через `ident.Parse`. В боте механики выбора нет.
Карточка ревью в боте (`reviewCard`/`reviewKeyboard`) уже двухшаговая для
необратимых действий (delete/dismiss): первая кнопка меняет только клавиатуру
(`editMarkup`), подтверждение зовёт домен и обновляет карточку (`refreshCard`).
Тот же паттерн переиспользуем для выбора кандидата.
## Решение
**Поверхность.** В `reviewKeyboard`, когда `len(rd.Candidates) > 0`, добавляем
кнопку «🗂 База (N)» с callback `sources:<id>`. Она не трогает домен — через
`editMarkup` разворачивает клавиатуру списка кандидатов:
- по одной кнопке на кандидата (full-width row), текст `provider · Title (Year)`,
у выбранного (`Chosen`) — префикс «✓ »; дедуп по `provider:provider_id`, потолок
кнопок (кандидатов и так ≤ `maxCandidates`=8);
- callback кандидата — `pick:<downloadID>:<candidateID>`;
- нижняя кнопка «← Назад» с callback `srcback:<id>` — возвращает карточку
(`refreshCard`), домен не трогает.
**Обработка callback (`handleCallback`).**
- `sources``answer("")` + `editMarkup(candidatesKeyboard(rd))` (перечитываем
`ReviewData` ради свежего списка), домен не трогаем;
- `srcback``answer("Отменено")` + `refreshCard` (как `delete_cancel`);
- `pick``candidateID` из `val` (третий сегмент, `parseCallback` уже отдал его
как `value`) валидируем `ident.Parse` (невалидный → `answer("Кнопка устарела…")`,
состояние не меняем), затем
`reviewer.ChooseCandidate(ctx, id, candidateID)`; при успехе
`answer("Источник выбран") + refreshCard`, при ошибке — общая ветка ошибки.
**Интерфейс.** В `Reviewer` (bot.go) добавляем
`ChooseCandidate(ctx, id, candidateID string) error`; фейк в тестах дополняем.
## Границы и безопасность
- **Недоверенный вход:** `candidate_id` из `callback_data` валидируется как ULID
на границе транспорта (как в вебе). Принадлежность кандидата текущему
распознаванию доменно проверяет сам `ChooseCandidate` (не наш кандидат →
`ErrInvalidInput`).
- **Авто-раскладка не двигается:** `ChooseCandidate` только пиннит источник;
раскладка — отдельным «Применить». Инвариант «авто только по подтверждённому
матчу» не затрагивается.
- **Экранирование:** текст inline-кнопок Telegram НЕ парсится как HTML —
инъекция разметки из названия кандидата в кнопке невозможна; усечение длинных
названий — косметика. `sources`/`srcback` меняют только клавиатуру; `pick`
перерисовывает и текст карточки (`refreshCard``baseLine` с новым
provider/id), но новых **неэкранированных** фрагментов не появляется — `baseLine`
уже экранирует provider/id/URL (действующее требование «Показ записи матча…»).
## Бюджет callback_data
Лимит Telegram — 64 байта. Худший: `pick:<26>:<26>` = 4+1+26+1+26 = 58 ≤ 64.
`sources:<26>` = 34, `srcback:<26>` = 34. Запас есть.
## Отвергнутые альтернативы
- **Показывать «без базы» (нейтраль) и ручной ввод id в боте** — это точные
правки, зона веба (`ClearProvider`/`SetProviderID`); в боте раздули бы интерфейс
и клавиатуру. Бот — быстрый выбор из готового списка кандидатов.
- **Отдельное сообщение со списком вместо editMarkup** — расходится с уже
принятым двухшаговым паттерном (delete/dismiss) и плодит сообщения.
@@ -0,0 +1,59 @@
## Why
Распознавание уже копит кандидатов метабазы (`recognize.Result.Candidates`
`metadata_candidate`), и веб даёт полный «единый список источников совпадения»
для точного выбора. В Telegram же карточка подтверждения показывает лишь
эффективный матч (строка «База: …» / «нет матча»), но **не даёт выбрать** между
несколькими найденными кандидатами: когда единичного сильного матча нет (частый
триггер ревью — «несколько кандидатов»), из бота нельзя быстро закрепить нужный,
приходится открывать веб. Ядро для этого уже готово: команда
`worker.ChooseCandidate` пиннит кандидата как источник (ручной матч), не
запуская авто-раскладку.
## What Changes
- **Карточка подтверждения (review)** в боте, когда для распознавания есть
сохранённые кандидаты метабазы, показывает кнопку выбора базы. По ней карточка
двухшагово (как подтверждение удаления) разворачивает **список кандидатов
inline-кнопками** — быстрый выбор из готового короткого списка.
- **Выбор кандидата** кнопкой вызывает `ChooseCandidate` (пиннинг источника,
ручной матч) и обновляет карточку на месте. Активный (уже выбранный) кандидат
помечается в списке. Кнопка «← Назад» возвращает карточку без изменений.
- **Веб остаётся точкой точных правок** (ввод id/URL вручную, «без базы»,
предпросмотр путей): бот — только быстрый выбор из уже найденных кандидатов.
- **Безопасность границы:** id кандидата приходит в `callback_data` — недоверенный
вход; перед доменным вызовом он валидируется как ULID (`ident.Parse`), как это
уже делает веб-транспорт. Выбор кандидата — ручной матч, авто-раскладку не
запускает (нужно явное «Применить»), инвариант авто-раскладки не двигается.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `notifications`: добавляется требование к **поверхности** бота — предлагать
выбор среди сохранённых кандидатов метабазы в карточке подтверждения
(inline-кнопки, двухшаговое разворачивание), с валидацией id кандидата из
`callback_data` на границе и без авто-раскладки. Доменная механика выбора
(`ChooseCandidate`, пиннинг источника) уже покрыта capability `review` — здесь
не дублируется.
- `review`: MODIFIED требование «Разделение труда транспортов в ревью» — быстрый
выбор источника из готового списка кандидатов теперь и Telegram-действие; точный
ручной ввод id/URL и «без базы» остаются за вебом. Снимает межспековое
расхождение (прежде выбор источника целиком относился к веб-точным-правкам).
## Impact
- **Спеки:** дельта `notifications` — ADDED «Выбор кандидата метабазы из карточки
подтверждения бота»; дельта `review` — MODIFIED «Разделение труда транспортов в
ревью» (быстрый выбор кандидата — Telegram-действие).
- **Код:** `internal/tgbot/bot.go` (интерфейс `Reviewer` + `ChooseCandidate`;
callback-и списка/выбора/назад), `internal/tgbot/render.go` (кнопка выбора базы
в `reviewKeyboard`, клавиатура списка кандидатов).
- **Тесты:** `internal/tgbot` — карточка с кандидатами содержит кнопку выбора;
разворачивание даёт список; выбор зовёт `ChooseCandidate` с валидным id;
невалидный id из callback отклоняется.
- **Миграции БД:** нет (кандидаты уже в БД — таблица `metadata_candidate`).
@@ -0,0 +1,58 @@
## ADDED Requirements
### Requirement: Выбор кандидата метабазы из карточки подтверждения бота
Карточка подтверждения бота SHALL предлагать выбрать источник из сохранённых
кандидатов метабазы inline-кнопками, когда для распознавания загрузки есть хотя
бы один такой кандидат (типичный триггер ревью — несколько подходящих записей без
единичного сильного матча), а не молча оставлять загрузку с первым/лучшим или без
базы. Когда сохранённых кандидатов нет, карточка кнопку выбора базы показывать
SHALL NOT. Выбор SHALL быть двухшаговым: кнопка выбора базы разворачивает список
кандидатов (по кнопке на кандидата, выбранный помечен), а нажатие кандидата
пиннит его как источник и обновляет карточку. Отдельная кнопка SHALL возвращать
карточку без изменений.
Выбор кандидата из бота — **ручной матч** (пиннинг источника): он SHALL NOT
запускать авто-раскладку; раскладка выполняется отдельным подтверждением
(«Применить»). Бот SHALL оставаться быстрым выбором из готового короткого списка
кандидатов, тогда как точные правки (ручной ввод id/URL, «без базы») — на веб-
поверхности.
Id кандидата приходит в `callback_data` и является недоверенным входом: перед
доменным вызовом выбора система MUST валидировать его как ULID на границе
транспорта; невалидный id MUST отклоняться без изменения состояния загрузки.
Текст inline-кнопок Telegram не интерпретируется как разметка, поэтому название
кандидата в подписи кнопки инъекции разметки не создаёт.
#### Scenario: Карточка с кандидатами предлагает выбор базы
- **GIVEN** загрузка в `review`, для распознавания которой сохранены кандидаты метабазы
- **WHEN** бот рендерит карточку подтверждения
- **THEN** карточка содержит кнопку выбора базы, ведущую к списку кандидатов inline-кнопками
#### Scenario: Выбор кандидата пиннит источник без авто-раскладки
- **GIVEN** развёрнутый в карточке список кандидатов метабазы
- **WHEN** пользователь нажимает кнопку кандидата
- **THEN** этот кандидат закрепляется как источник (ручной матч), карточка
обновляется на месте, а авто-раскладка не запускается — для раскладки нужно
явное «Применить»
#### Scenario: Id кандидата из callback валидируется на границе
- **GIVEN** callback выбора кандидата, где значение id кандидата недоверенное/невалидное
- **WHEN** бот обрабатывает callback
- **THEN** id кандидата валидируется как ULID до доменного вызова, а невалидное
значение отклоняется без изменения состояния загрузки
#### Scenario: Возврат из списка не меняет состояние
- **GIVEN** развёрнутый в карточке список кандидатов
- **WHEN** пользователь нажимает кнопку возврата
- **THEN** бот возвращает исходную карточку подтверждения, не меняя выбранный источник
#### Scenario: Без кандидатов кнопки выбора базы нет
- **GIVEN** загрузка в `review`, для распознавания которой кандидаты метабазы не сохранены
- **WHEN** бот рендерит карточку подтверждения
- **THEN** карточка не содержит кнопку выбора базы
@@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: Разделение труда транспортов в ревью
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по
id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия
(одобрить, подсказать, переключить тип, **быстрый выбор источника из готового
списка кандидатов метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом
на ту же страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL,
маппинг файлов), SHALL делаться в вебе.
#### Scenario: Эскалация из Telegram в веб
- **GIVEN** загрузка в `review`, требующая точечного маппинга файлов
- **WHEN** пользователь в Telegram выбирает «В вебе»
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
#### Scenario: Быстрый выбор кандидата в Telegram, точный ввод — в вебе
- **GIVEN** загрузка в `review` с сохранёнными кандидатами метабазы
- **WHEN** пользователь выбирает кандидата inline-кнопкой в Telegram
- **THEN** кандидат закрепляется как источник (тот же единый выбор источника, что
и в вебе), а ручной ввод id/URL и «без базы» остаются точными правками веба
@@ -0,0 +1,39 @@
## 1. Интерфейс транспорта
- [x] 1.1 `internal/tgbot/bot.go`: добавить в интерфейс `Reviewer` метод
`ChooseCandidate(ctx context.Context, id, candidateID string) error`.
- [x] 1.2 `internal/tgbot/bot_test.go`: дополнить `fakeReviewer` методом
`ChooseCandidate` (запоминает выбор для проверки).
## 2. Клавиатуры (render.go)
- [x] 2.1 `reviewKeyboard`: когда `len(rd.Candidates) > 0`, добавить кнопку
«🗂 База (N)» с callback `sources:<id>`.
- [x] 2.2 Новая `candidatesKeyboard(rd)`: по кнопке на кандидата (дедуп по
`provider:provider_id`, потолок), текст `provider · Title (Year)`, у `Chosen`
префикс «✓ », callback `pick:<id>:<candidateID>`; нижняя кнопка «← Назад» с
callback `srcback:<id>`.
## 3. Обработка callback (bot.go)
- [x] 3.1 `handleCallback`: ветки `sources` (answer + `editMarkup`
списком кандидатов, перечитав `ReviewData`; домен не трогаем) и `srcback`
(answer «Отменено» + `refreshCard`).
- [x] 3.2 Ветка `pick`: валидировать `val` (id кандидата) через `ident.Parse`
(невалидный → answer «Кнопка устарела…», состояние не меняем), затем
`reviewer.ChooseCandidate(ctx, id, candidateID)`; успех → answer «Источник
выбран» + `refreshCard`; ошибка — общая ветка ошибки.
## 4. Тесты
- [x] 4.1 `internal/tgbot`: карточка ревью с кандидатами содержит кнопку
`sources:<id>`; без кандидатов — не содержит.
- [x] 4.2 `sources` разворачивает клавиатуру с кнопками `pick:<id>:<candID>` и
«← Назад»; выбранный кандидат помечен «✓ ».
- [x] 4.3 `pick` с валидным id зовёт `ChooseCandidate` с этим id и обновляет
карточку; `pick` с невалидным id — не зовёт домен.
## 5. Спека и проверки
- [x] 5.1 `openspec validate --strict telegram-vybor-nahodok`.
- [x] 5.2 `task test` и `task lint` — зелёные.
+57
View File
@@ -167,3 +167,60 @@ MUST экранировать их перед вставкой в размече
- **THEN** показываются эффективные provider и id (как на веб-странице загрузки),
а не значения сырого распознавания
### Requirement: Выбор кандидата метабазы из карточки подтверждения бота
Карточка подтверждения бота SHALL предлагать выбрать источник из сохранённых
кандидатов метабазы inline-кнопками, когда для распознавания загрузки есть хотя
бы один такой кандидат (типичный триггер ревью — несколько подходящих записей без
единичного сильного матча), а не молча оставлять загрузку с первым/лучшим или без
базы. Когда сохранённых кандидатов нет, карточка кнопку выбора базы показывать
SHALL NOT. Выбор SHALL быть двухшаговым: кнопка выбора базы разворачивает список
кандидатов (по кнопке на кандидата, выбранный помечен), а нажатие кандидата
пиннит его как источник и обновляет карточку. Отдельная кнопка SHALL возвращать
карточку без изменений.
Выбор кандидата из бота — **ручной матч** (пиннинг источника): он SHALL NOT
запускать авто-раскладку; раскладка выполняется отдельным подтверждением
(«Применить»). Бот SHALL оставаться быстрым выбором из готового короткого списка
кандидатов, тогда как точные правки (ручной ввод id/URL, «без базы») — на веб-
поверхности.
Id кандидата приходит в `callback_data` и является недоверенным входом: перед
доменным вызовом выбора система MUST валидировать его как ULID на границе
транспорта; невалидный id MUST отклоняться без изменения состояния загрузки.
Текст inline-кнопок Telegram не интерпретируется как разметка, поэтому название
кандидата в подписи кнопки инъекции разметки не создаёт.
#### Scenario: Карточка с кандидатами предлагает выбор базы
- **GIVEN** загрузка в `review`, для распознавания которой сохранены кандидаты метабазы
- **WHEN** бот рендерит карточку подтверждения
- **THEN** карточка содержит кнопку выбора базы, ведущую к списку кандидатов inline-кнопками
#### Scenario: Выбор кандидата пиннит источник без авто-раскладки
- **GIVEN** развёрнутый в карточке список кандидатов метабазы
- **WHEN** пользователь нажимает кнопку кандидата
- **THEN** этот кандидат закрепляется как источник (ручной матч), карточка
обновляется на месте, а авто-раскладка не запускается — для раскладки нужно
явное «Применить»
#### Scenario: Id кандидата из callback валидируется на границе
- **GIVEN** callback выбора кандидата, где значение id кандидата недоверенное/невалидное
- **WHEN** бот обрабатывает callback
- **THEN** id кандидата валидируется как ULID до доменного вызова, а невалидное
значение отклоняется без изменения состояния загрузки
#### Scenario: Возврат из списка не меняет состояние
- **GIVEN** развёрнутый в карточке список кандидатов
- **WHEN** пользователь нажимает кнопку возврата
- **THEN** бот возвращает исходную карточку подтверждения, не меняя выбранный источник
#### Scenario: Без кандидатов кнопки выбора базы нет
- **GIVEN** загрузка в `review`, для распознавания которой кандидаты метабазы не сохранены
- **WHEN** бот рендерит карточку подтверждения
- **THEN** карточка не содержит кнопку выбора базы
+13 -4
View File
@@ -329,10 +329,12 @@ SHALL относиться именно к активному источнику
### Requirement: Разделение труда транспортов в ревью
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
быть поверхностью точных правок (маппинг файлов, выбор/ввод источника,
предпросмотр). Telegram SHALL давать быстрые действия (одобрить, подсказать,
переключить тип, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
страницу; точечные правки, не помещающиеся в чат, SHALL делаться в вебе.
быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по
id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия
(одобрить, подсказать, переключить тип, **быстрый выбор источника из готового
списка кандидатов метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом
на ту же страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL,
маппинг файлов), SHALL делаться в вебе.
#### Scenario: Эскалация из Telegram в веб
@@ -340,6 +342,13 @@ SHALL относиться именно к активному источнику
- **WHEN** пользователь в Telegram выбирает «В вебе»
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
#### Scenario: Быстрый выбор кандидата в Telegram, точный ввод — в вебе
- **GIVEN** загрузка в `review` с сохранёнными кандидатами метабазы
- **WHEN** пользователь выбирает кандидата inline-кнопкой в Telegram
- **THEN** кандидат закрепляется как источник (тот же единый выбор источника, что
и в вебе), а ручной ввод id/URL и «без базы» остаются точными правками веба
### Requirement: Петлевые действия ревью обновляют экран на месте
Петлевые действия распознавания на экране ревью — **Распознать заново** (`rerecognize`) и **Уточнить** (`refine`) — SHALL выполняться htmx-запросом и обновлять тело экрана ревью на месте (partial swap), без полной перезагрузки страницы и без сброса позиции прокрутки. Поскольку эти действия асинхронны (переводят загрузку в `recognizing`, распознавание доделывает воркер), своп SHALL отражать актуальное состояние — состояние `recognizing` с индикацией «идёт распознавание», а не мгновенно готовый план. Накопленные подсказки и ручные override MUST переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).