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
-1
View File
@@ -45,7 +45,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
- [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
-7
View File
@@ -1,7 +0,0 @@
# Выбор из нескольких находок метабазы в Telegram
**Приоритет:** низкий
Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча брать первый/лучший. Веб остаётся точкой точных правок (полный выбор источника уже реализован), бот — быстрый выбор из готового короткого списка.
Связано: specs/review-ux.md (боты — быстрые действия, веб — точные правки), specs/recognition.md (кандидаты матча).
+28
View File
@@ -43,6 +43,7 @@ type Reviewer interface {
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
Apply(ctx context.Context, id string) error
Refine(ctx context.Context, id string, hint string) error
ChooseCandidate(ctx context.Context, id, candidateID string) error
SetType(ctx context.Context, id string, mediaType string) error
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
@@ -362,6 +363,33 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
b.answer(cq.ID, "Жду подсказку")
b.send(chatID, "Ответьте сообщением с подсказкой для #"+idCode(id)+".", nil)
return
case "sources":
// Первый шаг выбора базы: перечитываем задачу ради свежего списка
// кандидатов и разворачиваем клавиатуру списка. Домен не трогаем.
rd, rerr := b.reviewer.ReviewData(ctx, id)
if rerr != nil {
b.log.Warn("telegram sources review data failed", "download_id", id, "error", rerr)
b.answer(cq.ID, "Ошибка")
return
}
b.answer(cq.ID, "")
b.editMarkup(chatID, msgID, b.candidatesKeyboard(rd))
return
case "srcback":
// Возврат из списка кандидатов к обычной карточке (домен не трогаем).
b.answer(cq.ID, "Отменено")
b.refreshCard(ctx, chatID, msgID, id)
return
case "pick":
// Выбор кандидата: val — id кандидата из callback_data (недоверенный
// вход), валидируем как ULID на границе до доменного вызова.
candidateID, perr := ident.Parse(val)
if perr != nil {
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
return
}
err = b.reviewer.ChooseCandidate(ctx, id, candidateID)
note = "Источник выбран"
default:
b.answer(cq.ID, "")
return
+129
View File
@@ -2,6 +2,7 @@ package tgbot
import (
"context"
"database/sql"
"io"
"log/slog"
"strings"
@@ -72,6 +73,7 @@ type fakeReviewer struct {
retried []string
deleted []string
dismissed []string
chosen map[string]string // downloadID → выбранный candidateID
}
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
@@ -88,6 +90,13 @@ func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
if f.chosen == nil {
f.chosen = map[string]string{}
}
f.chosen[id] = candidateID
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
if f.typed == nil {
f.typed = map[string]string{}
@@ -429,6 +438,126 @@ func TestParseCallback(t *testing.T) {
}
}
// candID1/candID2 — валидные lowercase-ULID id кандидатов (callback-data
// валидируется как ULID).
const (
candID1 = "01arz3ndektsv4rrffq69g5fb1"
candID2 = "01arz3ndektsv4rrffq69g5fb2"
)
// findButton ищет inline-кнопку по callback data во всей клавиатуре.
func findButton(kb *tgbotapi.InlineKeyboardMarkup, data string) (tgbotapi.InlineKeyboardButton, bool) {
for _, row := range kb.InlineKeyboard {
for _, btn := range row {
if btn.CallbackData != nil && *btn.CallbackData == data {
return btn, true
}
}
}
return tgbotapi.InlineKeyboardButton{}, false
}
// withCandidates возвращает ReviewData с двумя кандидатами (второй — выбран).
func withCandidates() *worker.ReviewData {
rd := reviewData(store.StateReview)
rd.Candidates = []store.MetadataCandidate{
{ID: candID1, Provider: "tvdb", ProviderID: "269613", Title: sql.NullString{String: "Fargo", Valid: true}, Year: sql.NullInt64{Int64: 2014, Valid: true}},
{ID: candID2, Provider: "tmdb", ProviderID: "60622", Title: sql.NullString{String: "Фарго", Valid: true}, Year: sql.NullInt64{Int64: 2015, Valid: true}, Chosen: true},
}
return rd
}
// Карточка ревью с кандидатами показывает кнопку выбора базы; без кандидатов — нет.
func TestBot_ReviewKeyboardBaseButton(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
kb := b.reviewKeyboard(withCandidates())
if _, ok := findButton(kb, "sources:"+tid); !ok {
t.Error("карточка с кандидатами должна иметь кнопку выбора базы")
}
if kbNone := b.reviewKeyboard(reviewData(store.StateReview)); func() bool {
_, ok := findButton(kbNone, "sources:"+tid)
return ok
}() {
t.Error("без кандидатов кнопки выбора базы быть не должно")
}
}
// Список кандидатов: по кнопке на кандидата (pick:<id>:<candID>) + «Назад»;
// выбранный помечен галочкой, дубли по provider:id схлопнуты.
func TestBot_CandidatesKeyboard(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := withCandidates()
// Дубль второго кандидата (тот же provider:id) — должен схлопнуться.
rd.Candidates = append(rd.Candidates, store.MetadataCandidate{ID: "01arz3ndektsv4rrffq69g5fb3", Provider: "tmdb", ProviderID: "60622"})
kb := b.candidatesKeyboard(rd)
if _, ok := findButton(kb, "pick:"+tid+":"+candID1); !ok {
t.Error("нет кнопки первого кандидата")
}
chosen, ok := findButton(kb, "pick:"+tid+":"+candID2)
if !ok {
t.Fatal("нет кнопки выбранного кандидата")
}
if !strings.HasPrefix(chosen.Text, "✓ ") {
t.Errorf("выбранный кандидат должен быть помечен ✓, got %q", chosen.Text)
}
if _, ok := findButton(kb, "srcback:"+tid); !ok {
t.Error("нет кнопки возврата")
}
// Дубль не породил третью кнопку выбора.
if _, ok := findButton(kb, "pick:"+tid+":01arz3ndektsv4rrffq69g5fb3"); ok {
t.Error("дубль provider:id должен быть схлопнут")
}
}
// Выбор кандидата: pick с валидным id зовёт ChooseCandidate и обновляет карточку.
func TestBot_CallbackPick(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = withCandidates()
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":"+candID1))
if rev.chosen[tid] != candID1 {
t.Errorf("ChooseCandidate получил %q, want %q", rev.chosen[tid], candID1)
}
if len(api.edits) != 1 { // карточка обновлена на месте (refreshCard)
t.Errorf("edits = %v, want 1", api.edits)
}
}
// Невалидный id кандидата из callback отклоняется на границе — домен не зовём.
func TestBot_CallbackPickInvalidID(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":not-a-ulid"))
if len(rev.chosen) != 0 {
t.Errorf("невалидный id не должен доходить до домена, chosen = %v", rev.chosen)
}
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
t.Errorf("answers = %v, want понятный ответ", api.answers)
}
}
// «Назад» из списка кандидатов возвращает карточку, домен не трогает.
func TestBot_CallbackSourcesBack(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = withCandidates()
b.handleCallback(context.Background(), cbFrom(7, "sources:"+tid))
if len(rev.chosen) != 0 {
t.Error("разворачивание списка не должно трогать домен")
}
b.handleCallback(context.Background(), cbFrom(7, "srcback:"+tid))
if len(rev.chosen) != 0 {
t.Error("возврат не должен трогать домен")
}
if len(api.edits) != 1 { // srcback обновляет карточку на месте
t.Errorf("edits = %v, want 1 (srcback refreshCard)", api.edits)
}
}
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
func TestBot_CallbackStaleButton(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
+66
View File
@@ -102,6 +102,10 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
if len(rd.Preview) > 0 {
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid))
}
// Быстрый выбор базы из готового списка кандидатов (веб — точный ввод id/URL).
if n := len(dedupCandidates(rd.Candidates)); n > 0 {
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("🗂 База (%d)", n), "sources:"+sid))
}
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type))))
row2 := tgbotapi.NewInlineKeyboardRow(
@@ -119,6 +123,68 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
return &kb
}
// candidatesKeyboard — второй шаг выбора базы: список кандидатов метабазы (по
// кнопке на кандидата) + возврат к карточке. Текст кнопки Telegram не парсится как
// HTML, поэтому название кандидата в подписи не экранируем (инъекция разметки в
// кнопке невозможна); длинную подпись усекаем — косметика. Выбранный (chosen)
// помечаем галочкой. callback выбора — `pick:<id>:<candidateID>` (id кандидата
// валидируется как ULID на границе в handleCallback).
func (b *Bot) candidatesKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
sid := rd.Download.ID
var rows [][]tgbotapi.InlineKeyboardButton
for _, c := range dedupCandidates(rd.Candidates) {
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(candidateLabel(c), "pick:"+sid+":"+c.ID),
))
}
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("← Назад", "srcback:"+sid),
))
kb := tgbotapi.NewInlineKeyboardMarkup(rows...)
return &kb
}
// dedupCandidates отсеивает повторы по provider:provider_id (стабильный порядок
// исходного списка) — как единый список источников в вебе. Потолок — сами
// кандидаты (recognize.maxCandidates=8), отдельно не режем. Если среди дублей
// одного ключа выбранный (Chosen) идёт не первым, он вытесняет ранее оставленный
// невыбранный — чтобы галочка и id кнопки указывали на выбранного.
func dedupCandidates(cands []store.MetadataCandidate) []store.MetadataCandidate {
idx := map[string]int{}
out := make([]store.MetadataCandidate, 0, len(cands))
for _, c := range cands {
key := c.Provider + ":" + c.ProviderID
if i, ok := idx[key]; ok {
if c.Chosen && !out[i].Chosen {
out[i] = c
}
continue
}
idx[key] = len(out)
out = append(out, c)
}
return out
}
// candidateLabel — подпись кнопки кандидата: `provider · Название (год)`, у
// выбранного — префикс «✓ ». Обычный текст (не HTML), усекаем для читаемости.
func candidateLabel(c store.MetadataCandidate) string {
label := c.Provider
if c.Title.Valid && c.Title.String != "" {
label += " · " + c.Title.String
} else if c.ProviderID != "" {
label += " " + c.ProviderID
}
if c.Year.Valid && c.Year.Int64 != 0 {
label += fmt.Sprintf(" (%d)", c.Year.Int64)
}
label = shorten(label, 48)
if c.Chosen {
label = "✓ " + label
}
return label
}
// displayTitle — имя раздачи для уведомлений: имя раздачи (display_name, то,
// что ушло в qBittorrent и показывается в веб-UI) → распознанное название.
// Пусто, если ни того ни другого нет. Держим заголовки консистентными с UI.
@@ -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 переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).