Распознавание: санитайзинг названий от LLM + безгодовой фолбэк сверки

Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической
буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось),
а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой
список кандидатов, ручной ввод id.

- recognition: санитайзинг человекочитаемых полей плана (title/original_title/
  provider_hint) на границе разбора, до валидации: strip control/zero-width,
  collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой
  кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом).
- metadata-match: тот же fold в normalize (гейт) как defense-in-depth;
  безгодовой второй проход сверки как fallback при известном годе и промахе
  первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов
  review. В fallback требуем известный год кандидата (год-unknown → review,
  не авто); гейт год ±1 и инвариант авто-матча не двигаются.

Спеки recognition/metadata-match обновлены, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 15:45:19 +03:00
co-authored by Claude Opus 4.8
parent 7d8a455e47
commit e2ea1840c9
15 changed files with 1076 additions and 41 deletions
+8 -1
View File
@@ -3,6 +3,7 @@ package recognize
import (
"encoding/json"
"fmt"
"log/slog"
"sort"
"strings"
@@ -12,7 +13,11 @@ import (
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
func parsePlan(raw string, in Input) (Plan, error) {
//
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
// sanitizePlan) ДО структурной валидации: так, например, title из одних
// zero-width символов схлопывается в пустой и корректно уводит в ретрай.
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
jsonStr, err := llm.ExtractJSONObject(raw)
if err != nil {
return Plan{}, fmt.Errorf("no JSON object in response")
@@ -29,6 +34,8 @@ func parsePlan(raw string, in Input) (Plan, error) {
}
}
sanitizePlan(&p, log)
if err := validateSchema(&p, in); err != nil {
return Plan{}, err
}