распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
@@ -94,6 +94,8 @@ func runRecognize(args []string) error {
|
|||||||
}
|
}
|
||||||
rec := recognize.New(provider, providers, recognize.Config{
|
rec := recognize.New(provider, providers, recognize.Config{
|
||||||
MaxRetries: cfg.LLM.MaxRetries,
|
MaxRetries: cfg.LLM.MaxRetries,
|
||||||
|
MaxFiles: cfg.Recognition.MaxFiles,
|
||||||
|
MaxTokens: cfg.Recognition.MaxTokens,
|
||||||
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
||||||
Language: cfg.ContentLanguage(),
|
Language: cfg.ContentLanguage(),
|
||||||
}, logger)
|
}, logger)
|
||||||
@@ -169,14 +171,21 @@ func printDryRun(t qbt.Torrent, files []qbt.File, res recognize.Result, lay *lay
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n──── Решение ────\n")
|
fmt.Printf("\n──── Решение ────\n")
|
||||||
|
// Reasons печатаем на обеих ветках: на авто-ветке там лежит сводка покрытия
|
||||||
|
// («в план попало N из M») — информационные строки, не отменяющие авто.
|
||||||
|
// Ровно тем прогоном, которым эту сводку и проверяют руками, прятать её
|
||||||
|
// нельзя.
|
||||||
if res.Decision.Auto {
|
if res.Decision.Auto {
|
||||||
fmt.Printf("АВТО-раскладка (review не нужен)\n")
|
fmt.Printf("АВТО-раскладка (review не нужен)\n")
|
||||||
|
if len(res.Decision.Reasons) > 0 {
|
||||||
|
fmt.Printf("замечания:\n")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("REVIEW — причины:\n")
|
fmt.Printf("REVIEW — причины:\n")
|
||||||
|
}
|
||||||
for _, reason := range res.Decision.Reasons {
|
for _, reason := range res.Decision.Reasons {
|
||||||
fmt.Printf(" · %s\n", reason)
|
fmt.Printf(" · %s\n", reason)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\n──── Превью раскладки (хардлинки НЕ создаются) ────\n")
|
fmt.Printf("\n──── Превью раскладки (хардлинки НЕ создаются) ────\n")
|
||||||
tag := ""
|
tag := ""
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ func runServe(args []string) error {
|
|||||||
if llmProvider != nil {
|
if llmProvider != nil {
|
||||||
recognizer = recognize.New(llmProvider, providers, recognize.Config{
|
recognizer = recognize.New(llmProvider, providers, recognize.Config{
|
||||||
MaxRetries: cfg.LLM.MaxRetries,
|
MaxRetries: cfg.LLM.MaxRetries,
|
||||||
|
MaxFiles: cfg.Recognition.MaxFiles,
|
||||||
|
MaxTokens: cfg.Recognition.MaxTokens,
|
||||||
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
||||||
Language: cfg.ContentLanguage(),
|
Language: cfg.ContentLanguage(),
|
||||||
}, logger)
|
}, logger)
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ source_missing_threshold = 3 # подряд тиков сверки бе
|
|||||||
|
|
||||||
[recognition]
|
[recognition]
|
||||||
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
||||||
|
max_files = 500 # сколько файлов раздачи максимум показывать модели; целое ≥ 1. Предохранитель от аномальной раздачи, а не рабочее ограничение: усечение уводит задачу в review отдельной причиной
|
||||||
|
max_tokens = 8000 # предел длины ответа модели, токены; целое ≥ 1. Не хватило — ответ обрывается, и задача уходит в review с причиной «ответ модели обрезан»
|
||||||
|
|
||||||
[telegram]
|
[telegram]
|
||||||
enabled = false # включить Telegram-бота
|
enabled = false # включить Telegram-бота
|
||||||
|
|||||||
@@ -191,4 +191,4 @@ Jellyfin указывают на `movies`/`series`, а не на корень
|
|||||||
| Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` |
|
| Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` |
|
||||||
| Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` |
|
| Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` |
|
||||||
| История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` |
|
| История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` |
|
||||||
| Предел ответа LLM | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — |
|
| Предел ответа LLM | предел размера ответа есть (`[recognition].max_tokens`), обрыв по нему распознаётся и уводит в review; открытым остаётся согласование предела с потолком провайдера и с `[llm].timeout` | — |
|
||||||
|
|||||||
+11
-4
@@ -14,7 +14,8 @@
|
|||||||
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
|
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
|
||||||
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
|
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
|
||||||
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла),
|
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла),
|
||||||
> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON).
|
> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON),
|
||||||
|
> `0012_recognition_source_files` (снимок списка файлов раздачи рядом с планом).
|
||||||
|
|
||||||
Назначение таблиц и роль компонентов — [architecture.md](architecture.md).
|
Назначение таблиц и роль компонентов — [architecture.md](architecture.md).
|
||||||
Значения `state` и легальные переходы — нормативно в
|
Значения `state` и легальные переходы — нормативно в
|
||||||
@@ -78,9 +79,10 @@ erDiagram
|
|||||||
TEXT provider "nullable; tmdb|tvdb|tvmaze|none"
|
TEXT provider "nullable; tmdb|tvdb|tvmaze|none"
|
||||||
TEXT provider_id "nullable"
|
TEXT provider_id "nullable"
|
||||||
REAL confidence "nullable"
|
REAL confidence "nullable"
|
||||||
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
|
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины ухода в review + информационные заметки (сводка покрытия), не отменяющие авто"
|
||||||
TEXT raw_llm "nullable; сырой ответ LLM"
|
TEXT raw_llm "nullable; сырой ответ LLM"
|
||||||
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
|
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
|
||||||
|
TEXT source_files "nullable; JSON [{path,size}] — снимок файлов раздачи в порядке нумерации промпта (миграция 0012). NULL — запись старше миграции: перечень неполон, UI говорит об этом прямо"
|
||||||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +167,11 @@ erDiagram
|
|||||||
- **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких
|
- **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких
|
||||||
внешних файлов: строка читается и пишется целиком обычным запросом.
|
внешних файлов: строка читается и пишется целиком обычным запросом.
|
||||||
- **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический
|
- **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический
|
||||||
`recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (список
|
`recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (диагностика
|
||||||
причин не-авто) и `download.parsed_context` (структура имени из контекста).
|
распознавания: блокирующие причины ухода в review **и** информационные
|
||||||
|
заметки вроде сводки покрытия, которые авто-раскладку не отменяют, — само
|
||||||
|
решение auto/review из длины списка не выводится) и `download.parsed_context`
|
||||||
|
(структура имени из контекста).
|
||||||
Читаются целиком и разбираются в Go; частичного чтения и обновления поля
|
Читаются целиком и разбираются в Go; частичного чтения и обновления поля
|
||||||
внутри JSON нет, SQL по содержимому этих полей не делается.
|
внутри JSON нет, SQL по содержимому этих полей не делается.
|
||||||
- **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это
|
- **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это
|
||||||
@@ -204,6 +209,8 @@ erDiagram
|
|||||||
| `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent |
|
| `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent |
|
||||||
| `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника |
|
| `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника |
|
||||||
| `[recognition].auto_confidence_threshold` | `0.85` | порог авто-раскладки (доп. проверка к матчу в базе) |
|
| `[recognition].auto_confidence_threshold` | `0.85` | порог авто-раскладки (доп. проверка к матчу в базе) |
|
||||||
|
| `[recognition].max_files` | `500` файлов | сколько файлов раздачи максимум показывать модели в промпте. Предохранитель от аномальной раздачи, а не рабочее ограничение: 500 путей — порядка 35k токенов промпта, что модель принимает. Усечение уводит задачу в `review` отдельной причиной, а знаменатель покрытия остаётся полным числом файлов |
|
||||||
|
| `[recognition].max_tokens` | `8000` токенов | предел длины ответа модели. При индексной адресации файла (`files[].i` вместо копии пути) элемент плана стоит ~14 токенов, так что 500 файлов укладываются с запасом. Не хватило — `finish_reason = length`, и задача уходит в `review` с причиной «ответ модели обрезан», без повторного запроса |
|
||||||
| `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом |
|
| `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом |
|
||||||
| `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе |
|
| `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе |
|
||||||
|
|
||||||
|
|||||||
@@ -96,16 +96,36 @@ func TestМиграцииБезAutoincrementИСерверногоВремени
|
|||||||
// docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда
|
// docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда
|
||||||
// по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг
|
// по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг
|
||||||
// подстрокой — его ловим здесь.
|
// подстрокой — его ловим здесь.
|
||||||
|
//
|
||||||
|
// Прямой формы мало: `msg := strings.ToLower(err.Error())` и следом
|
||||||
|
// `strings.Contains(msg, …)` — то же сравнение по тексту, просто через
|
||||||
|
// промежуточную переменную (так оно и просочилось в recognize). Поэтому вторым
|
||||||
|
// проходом собираем имена, которым присвоен `.Error()`, и ищем их в тех же
|
||||||
|
// сравнениях.
|
||||||
func TestОшибкиНеМатчатсяПоТексту(t *testing.T) {
|
func TestОшибкиНеМатчатсяПоТексту(t *testing.T) {
|
||||||
re := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`)
|
direct := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`)
|
||||||
|
// Присваивание текста ошибки переменной, в т.ч. завёрнутое в вызовы
|
||||||
|
// (strings.ToLower, strings.TrimSpace и подобные).
|
||||||
|
assigned := regexp.MustCompile(`(?m)^\s*(\w+)\s*:?=\s*(?:[\w.]+\()*\s*[\w.]+\.Error\(\)`)
|
||||||
|
const why = "ошибку матчим через errors.Is/errors.As, а не по тексту сообщения"
|
||||||
for _, path := range goFiles(t) {
|
for _, path := range goFiles(t) {
|
||||||
body, err := os.ReadFile(path)
|
body, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("читаю %s: %v", path, err)
|
t.Fatalf("читаю %s: %v", path, err)
|
||||||
}
|
}
|
||||||
if loc := re.FindIndex(body); loc != nil {
|
|
||||||
rel, _ := filepath.Rel(repoRoot, path)
|
rel, _ := filepath.Rel(repoRoot, path)
|
||||||
t.Errorf("%s:%d — ошибку матчим через errors.Is/errors.As, а не по тексту сообщения", rel, lineOf(body, loc[0]))
|
if loc := direct.FindIndex(body); loc != nil {
|
||||||
|
t.Errorf("%s:%d — %s", rel, lineOf(body, loc[0]), why)
|
||||||
|
}
|
||||||
|
for _, m := range assigned.FindAllSubmatch(body, -1) {
|
||||||
|
name := regexp.QuoteMeta(string(m[1]))
|
||||||
|
used := regexp.MustCompile(
|
||||||
|
`strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\(\s*` + name + `\b` +
|
||||||
|
`|\b` + name + `\s*==\s*"` + `|"\s*==\s*` + name + `\b`)
|
||||||
|
if loc := used.FindIndex(body); loc != nil {
|
||||||
|
t.Errorf("%s:%d — %s (текст ошибки положен в %s)",
|
||||||
|
rel, lineOf(body, loc[0]), why, m[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,9 +127,17 @@ type Worker struct {
|
|||||||
SourceMissingThreshold int `toml:"source_missing_threshold"`
|
SourceMissingThreshold int `toml:"source_missing_threshold"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recognition — пороги распознавания.
|
// Recognition — пороги и пределы распознавания.
|
||||||
type Recognition struct {
|
type Recognition struct {
|
||||||
AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"`
|
AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"`
|
||||||
|
// MaxFiles — предохранитель от аномальной раздачи: сколько файлов
|
||||||
|
// максимум печатать модели в промпте. Рабочим ограничением быть не должен
|
||||||
|
// (усечение уводит задачу в review отдельной причиной), поэтому значение
|
||||||
|
// держим на уровне промпта, который модель заведомо принимает.
|
||||||
|
MaxFiles int `toml:"max_files"`
|
||||||
|
// MaxTokens — предел длины ответа модели. Обрыв по нему распознаётся
|
||||||
|
// отдельно (finish_reason = length) и уводит в review без повтора запроса.
|
||||||
|
MaxTokens int `toml:"max_tokens"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram — настройки бота (Ф5).
|
// Telegram — настройки бота (Ф5).
|
||||||
@@ -232,7 +240,15 @@ func Default() *Config {
|
|||||||
CatchTimeout: Duration(10 * time.Minute),
|
CatchTimeout: Duration(10 * time.Minute),
|
||||||
SourceMissingThreshold: 3,
|
SourceMissingThreshold: 3,
|
||||||
},
|
},
|
||||||
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
|
Recognition: Recognition{
|
||||||
|
AutoConfidenceThreshold: 0.85,
|
||||||
|
// 500 файлов — порядка 35k токенов промпта: столько модель принимает,
|
||||||
|
// а раздача крупнее уже требует разбираться руками. Согласие с
|
||||||
|
// предохранителями конструктора (recognize.defaultMaxFiles /
|
||||||
|
// defaultMaxTokens) сторожит TestDefaultsAgreeWithConfig.
|
||||||
|
MaxFiles: 500,
|
||||||
|
MaxTokens: 8000,
|
||||||
|
},
|
||||||
HTTP: HTTP{Listen: ":8080"},
|
HTTP: HTTP{Listen: ":8080"},
|
||||||
Log: Log{Level: "info", Format: "json"},
|
Log: Log{Level: "info", Format: "json"},
|
||||||
}
|
}
|
||||||
@@ -307,6 +323,12 @@ func (c *Config) validate() error {
|
|||||||
if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 {
|
if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 {
|
||||||
errs = append(errs, fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t))
|
errs = append(errs, fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t))
|
||||||
}
|
}
|
||||||
|
if c.Recognition.MaxFiles < 1 {
|
||||||
|
errs = append(errs, fmt.Errorf("recognition.max_files %d must be >= 1", c.Recognition.MaxFiles))
|
||||||
|
}
|
||||||
|
if c.Recognition.MaxTokens < 1 {
|
||||||
|
errs = append(errs, fmt.Errorf("recognition.max_tokens %d must be >= 1", c.Recognition.MaxTokens))
|
||||||
|
}
|
||||||
if c.LLM.MaxRetries < 0 {
|
if c.LLM.MaxRetries < 0 {
|
||||||
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
|
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,3 +131,54 @@ func TestValidate_Errors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пределы распознавания: дефолты применяются к конфигу, где секции нет вовсе,
|
||||||
|
// а нулевые/отрицательные значения отвергаются — молча дефолтить их нельзя,
|
||||||
|
// иначе выключенный предохранитель неотличим от настроенного.
|
||||||
|
func TestValidate_RecognitionLimits(t *testing.T) {
|
||||||
|
if d := Default().Recognition; d.MaxFiles != 500 || d.MaxTokens != 8000 {
|
||||||
|
t.Errorf("дефолты = %+v, want max_files 500 / max_tokens 8000", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, p := range []string{"downloads", "movies", "series"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, p), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "config.toml")
|
||||||
|
body := "[qbittorrent]\nurl = \"http://qbit:8080\"\n" +
|
||||||
|
"[paths]\ndownloads = \"" + filepath.Join(dir, "downloads") + "\"\n" +
|
||||||
|
"movies = \"" + filepath.Join(dir, "movies") + "\"\n" +
|
||||||
|
"series = \"" + filepath.Join(dir, "series") + "\"\n"
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Recognition.MaxFiles != 500 || cfg.Recognition.MaxTokens != 8000 {
|
||||||
|
t.Errorf("конфиг без секции не получил дефолты: %+v", cfg.Recognition)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
mut func(*Config)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"max_files 0", func(c *Config) { c.Recognition.MaxFiles = 0 }, "recognition.max_files"},
|
||||||
|
{"max_files negative", func(c *Config) { c.Recognition.MaxFiles = -1 }, "recognition.max_files"},
|
||||||
|
{"max_tokens 0", func(c *Config) { c.Recognition.MaxTokens = 0 }, "recognition.max_tokens"},
|
||||||
|
{"max_tokens negative", func(c *Config) { c.Recognition.MaxTokens = -10 }, "recognition.max_tokens"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
c := validCfg(t)
|
||||||
|
tt.mut(c)
|
||||||
|
err := c.validate()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||||
|
t.Errorf("err = %v, want contains %q", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type downloadDetailView struct {
|
|||||||
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
|
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
|
||||||
NoBase bool
|
NoBase bool
|
||||||
Confidence string
|
Confidence string
|
||||||
Files []fileRow
|
Files layoutWidget
|
||||||
|
|
||||||
// Живая статистика раздачи (заполняется из снимка воркера).
|
// Живая статистика раздачи (заполняется из снимка воркера).
|
||||||
Seeding seedingView
|
Seeding seedingView
|
||||||
@@ -178,7 +178,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Файл источника → целевой путь из превью (единая логика layout).
|
// Файл источника → целевой путь из превью (единая логика layout).
|
||||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Живая статистика раздачи — со значениями уже в первом кадре; секция
|
// Живая статистика раздачи — со значениями уже в первом кадре; секция
|
||||||
|
|||||||
+151
-15
@@ -1,6 +1,8 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
)
|
)
|
||||||
@@ -9,31 +11,165 @@ import (
|
|||||||
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
|
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
|
||||||
// internal/layout), а не вычисляется в шаблоне.
|
// internal/layout), а не вычисляется в шаблоне.
|
||||||
type fileRow struct {
|
type fileRow struct {
|
||||||
|
// Num — номер файла в списке распознавания: тот самый, которым его
|
||||||
|
// адресует ответ модели. 0 — номер неизвестен (снимка списка нет), и тогда
|
||||||
|
// он не показывается вовсе: расхождение сделало бы разбор ошибок адресации
|
||||||
|
// ложным.
|
||||||
|
Num int
|
||||||
Src string
|
Src string
|
||||||
Dst string // целевой путь; пусто — файл не раскладывается
|
Dst string // целевой путь; пусто — файл не раскладывается
|
||||||
RoleLabel string
|
RoleLabel string
|
||||||
Linked bool
|
Linked bool
|
||||||
Ignored bool
|
Ignored bool
|
||||||
|
// Unplanned — файла нет в плане распознавания: модель его не разметила.
|
||||||
|
// Молчаливый пропуск файла моделью не должен быть невидимым.
|
||||||
|
Unplanned bool
|
||||||
|
|
||||||
|
season int // ключи сортировки; 0 у файлов без нумерации
|
||||||
|
episode int
|
||||||
|
group int // 0 — разложенные, 1 — план без цели, 2 — вне плана
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFileRows сшивает файлы плана с целевыми путями из превью раскладки.
|
// layoutWidget — данные виджета раскладки: строки плюс честность о полноте.
|
||||||
func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
|
type layoutWidget struct {
|
||||||
dstBySrc := make(map[string]string, len(preview))
|
Rows []fileRow
|
||||||
for _, l := range preview {
|
// SourceUnknown — списка файлов раздачи для этой загрузки нет (запись
|
||||||
dstBySrc[l.Src] = l.Dst
|
// создана до того, как система стала его сохранять). Строки построены по
|
||||||
|
// плану, и виджет обязан назвать это прямо.
|
||||||
|
SourceUnknown bool
|
||||||
|
Planned int // файлов в плане
|
||||||
|
Total int // всего файлов раздачи (0, когда список неизвестен)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFileRows сшивает файлы РАЗДАЧИ с планом и целевыми путями из превью.
|
||||||
|
// files — снимок списка файлов раздачи момента распознавания (в порядке
|
||||||
|
// нумерации промпта); nil означает, что снимка нет: тогда строки строятся по
|
||||||
|
// плану, а виджет сообщает о неполноте.
|
||||||
|
func buildFileRows(plan recognize.Plan, preview []layout.Link, files []recognize.File, known bool) layoutWidget {
|
||||||
|
dsts := planDsts(plan, preview)
|
||||||
|
|
||||||
|
// Строки строятся по ЭЛЕМЕНТАМ плана, а не по путям источников: план из БД
|
||||||
|
// вправе разметить один файл двумя элементами (сдвоенный эпизод в записях,
|
||||||
|
// сделанных до индексной адресации). Схлопывание таких элементов по пути
|
||||||
|
// показало бы одну цель там, где Apply создаст два хардлинка, — предпросмотр
|
||||||
|
// перестал бы быть равен применению (см. требование web-ui).
|
||||||
|
planBySrc := make(map[string][]int, len(plan.Files))
|
||||||
|
distinct := make(map[string]bool, len(plan.Files))
|
||||||
|
for i, f := range plan.Files {
|
||||||
|
planBySrc[f.Src] = append(planBySrc[f.Src], i)
|
||||||
|
distinct[f.Src] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
w := layoutWidget{
|
||||||
|
SourceUnknown: !known,
|
||||||
|
Planned: len(distinct),
|
||||||
|
Rows: make([]fileRow, 0, len(files)+len(plan.Files)),
|
||||||
|
}
|
||||||
|
if known {
|
||||||
|
w.Total = len(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool, len(files))
|
||||||
|
for i, f := range files {
|
||||||
|
seen[f.Path] = true
|
||||||
|
idxs := planBySrc[f.Path]
|
||||||
|
if len(idxs) == 0 {
|
||||||
|
w.Rows = append(w.Rows, newFileRow(i+1, f.Path, "", recognize.PlanFile{}, false))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, j := range idxs {
|
||||||
|
w.Rows = append(w.Rows, newFileRow(i+1, f.Path, dsts[j], plan.Files[j], true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// План может ссылаться на файл, которого в снимке нет (снимка нет вовсе
|
||||||
|
// либо раздачу переименовали после распознавания) — такую строку всё равно
|
||||||
|
// показываем, но без номера: он ничего не адресует.
|
||||||
|
for j, f := range plan.Files {
|
||||||
|
if seen[f.Src] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.Rows = append(w.Rows, newFileRow(0, f.Src, dsts[j], f, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок задан полностью и не зависит от порядка файлов в плане:
|
||||||
|
// разложенные (сезон, серия, путь) → файлы плана без цели → файлы вне
|
||||||
|
// плана. Пустые season/episode — указатели, и свёрнутые в ноль они
|
||||||
|
// поставили бы семплы и скриншоты перед первым сезоном, поэтому место
|
||||||
|
// таких файлов задаёт group, а не умолчание нумерации.
|
||||||
|
sort.SliceStable(w.Rows, func(i, j int) bool {
|
||||||
|
a, b := w.Rows[i], w.Rows[j]
|
||||||
|
switch {
|
||||||
|
case a.group != b.group:
|
||||||
|
return a.group < b.group
|
||||||
|
case a.group == 0 && a.season != b.season:
|
||||||
|
return a.season < b.season
|
||||||
|
case a.group == 0 && a.episode != b.episode:
|
||||||
|
return a.episode < b.episode
|
||||||
|
default:
|
||||||
|
return a.Src < b.Src
|
||||||
}
|
}
|
||||||
rows := make([]fileRow, 0, len(plan.Files))
|
|
||||||
for _, f := range plan.Files {
|
|
||||||
dst := dstBySrc[f.Src]
|
|
||||||
rows = append(rows, fileRow{
|
|
||||||
Src: f.Src,
|
|
||||||
Dst: dst,
|
|
||||||
RoleLabel: roleLabel(string(f.Role)),
|
|
||||||
Linked: dst != "",
|
|
||||||
Ignored: f.Role == "ignore",
|
|
||||||
})
|
})
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
return rows
|
|
||||||
|
// planDsts — целевой путь КАЖДОГО элемента плана (пусто — элемент не
|
||||||
|
// раскладывается). Карта «путь → цель» тут не годится: у двух элементов с одним
|
||||||
|
// источником цели разные. Превью строится по этому же плану, в том же порядке и
|
||||||
|
// только по раскладываемым ролям, поэтому элементы и ссылки сопоставляются
|
||||||
|
// проходом в два указателя; расхождение (превью от другого плана) оставляет
|
||||||
|
// строку без цели, но не сдвигает остальные.
|
||||||
|
func planDsts(plan recognize.Plan, preview []layout.Link) []string {
|
||||||
|
out := make([]string, len(plan.Files))
|
||||||
|
k := 0
|
||||||
|
for i, f := range plan.Files {
|
||||||
|
if !linkableRole(f.Role) || k >= len(preview) || preview[k].Src != f.Src {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[i] = preview[k].Dst
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkableRole — роль, которую раскладка превращает в ссылку (зеркало
|
||||||
|
// worker.mapRole; здесь нужна лишь для сопоставления строк с превью, и промах
|
||||||
|
// стоит пустой цели в строке, а не неверной раскладки).
|
||||||
|
func linkableRole(r recognize.FileRole) bool {
|
||||||
|
switch r {
|
||||||
|
case recognize.RoleMain, recognize.RoleEpisode, recognize.RoleSubtitle:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFileRow собирает строку виджета по файлу раздачи и его записи в плане.
|
||||||
|
func newFileRow(num int, src, dst string, pf recognize.PlanFile, inPlan bool) fileRow {
|
||||||
|
row := fileRow{
|
||||||
|
Num: num,
|
||||||
|
Src: src,
|
||||||
|
Dst: dst,
|
||||||
|
Linked: dst != "",
|
||||||
|
Unplanned: !inPlan,
|
||||||
|
group: 2,
|
||||||
|
}
|
||||||
|
if !inPlan {
|
||||||
|
row.RoleLabel = "не в плане"
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
row.RoleLabel = roleLabel(string(pf.Role))
|
||||||
|
row.Ignored = pf.Role == "ignore"
|
||||||
|
row.group = 1
|
||||||
|
if row.Linked {
|
||||||
|
row.group = 0
|
||||||
|
}
|
||||||
|
if pf.Season != nil {
|
||||||
|
row.season = *pf.Season
|
||||||
|
}
|
||||||
|
if pf.Episode != nil {
|
||||||
|
row.episode = *pf.Episode
|
||||||
|
}
|
||||||
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
// roleLabel — человекочитаемая роль файла раскладки.
|
// roleLabel — человекочитаемая роль файла раскладки.
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ip(n int) *int { return &n }
|
||||||
|
|
||||||
|
// srcFiles — снимок списка файлов раздачи в порядке нумерации распознавания.
|
||||||
|
func srcFiles(paths ...string) []recognize.File {
|
||||||
|
out := make([]recognize.File, len(paths))
|
||||||
|
for i, p := range paths {
|
||||||
|
out[i] = recognize.File{Path: p, Size: 1 << 20}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файл, которого модель не разметила, виден отдельной строкой без цели.
|
||||||
|
func TestBuildFileRows_UnplannedFileVisible(t *testing.T) {
|
||||||
|
files := srcFiles("s1/e01.mkv", "s1/e02.mkv", "extras/screen.jpg")
|
||||||
|
plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{
|
||||||
|
{Src: "s1/e01.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)},
|
||||||
|
}}
|
||||||
|
preview := []layout.Link{{Src: "s1/e01.mkv", Dst: "/srv/series/Show/S01/E01.mkv"}}
|
||||||
|
|
||||||
|
w := buildFileRows(plan, preview, files, true)
|
||||||
|
if len(w.Rows) != 3 {
|
||||||
|
t.Fatalf("строк %d, ожидалось по одной на файл раздачи: %+v", len(w.Rows), w.Rows)
|
||||||
|
}
|
||||||
|
if w.Planned != 1 || w.Total != 3 || w.SourceUnknown {
|
||||||
|
t.Errorf("сводка = %+v", w)
|
||||||
|
}
|
||||||
|
bySrc := map[string]fileRow{}
|
||||||
|
for _, r := range w.Rows {
|
||||||
|
bySrc[r.Src] = r
|
||||||
|
}
|
||||||
|
for _, src := range []string{"s1/e02.mkv", "extras/screen.jpg"} {
|
||||||
|
r := bySrc[src]
|
||||||
|
if !r.Unplanned || r.Dst != "" || r.Linked {
|
||||||
|
t.Errorf("%s: ожидалась строка «не в плане» без цели, получено %+v", src, r)
|
||||||
|
}
|
||||||
|
if r.RoleLabel != "не в плане" {
|
||||||
|
t.Errorf("%s: роль = %q", src, r.RoleLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Номер строки — номер адресации: позиция в списке распознавания.
|
||||||
|
if bySrc["s1/e02.mkv"].Num != 2 || bySrc["extras/screen.jpg"].Num != 3 {
|
||||||
|
t.Errorf("номера не совпадают с нумерацией распознавания: %+v", w.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок строк не зависит от порядка plan.Files.
|
||||||
|
func TestBuildFileRows_OrderIndependentOfPlan(t *testing.T) {
|
||||||
|
files := srcFiles(
|
||||||
|
"pack/s1e01.mkv", "pack/s1e02.mkv", "pack/s2e01.mkv",
|
||||||
|
"pack/sample.mkv", "pack/notes.nfo")
|
||||||
|
ep := func(src string, s, e int) recognize.PlanFile {
|
||||||
|
return recognize.PlanFile{Src: src, Role: recognize.RoleEpisode, Season: ip(s), Episode: ip(e)}
|
||||||
|
}
|
||||||
|
preview := []layout.Link{
|
||||||
|
{Src: "pack/s1e01.mkv", Dst: "/srv/Show/S01/E01.mkv"},
|
||||||
|
{Src: "pack/s1e02.mkv", Dst: "/srv/Show/S01/E02.mkv"},
|
||||||
|
{Src: "pack/s2e01.mkv", Dst: "/srv/Show/S02/E01.mkv"},
|
||||||
|
}
|
||||||
|
want := []string{"pack/s1e01.mkv", "pack/s1e02.mkv", "pack/s2e01.mkv",
|
||||||
|
"pack/sample.mkv", "pack/notes.nfo"}
|
||||||
|
|
||||||
|
orders := [][]recognize.PlanFile{
|
||||||
|
{ep("pack/s1e01.mkv", 1, 1), ep("pack/s1e02.mkv", 1, 2), ep("pack/s2e01.mkv", 2, 1),
|
||||||
|
{Src: "pack/sample.mkv", Role: recognize.RoleSample}},
|
||||||
|
{{Src: "pack/sample.mkv", Role: recognize.RoleSample}, ep("pack/s2e01.mkv", 2, 1),
|
||||||
|
ep("pack/s1e02.mkv", 1, 2), ep("pack/s1e01.mkv", 1, 1)},
|
||||||
|
}
|
||||||
|
for _, pf := range orders {
|
||||||
|
w := buildFileRows(recognize.Plan{Type: recognize.MediaSeries, Files: pf}, preview, files, true)
|
||||||
|
got := make([]string, 0, len(w.Rows))
|
||||||
|
for _, r := range w.Rows {
|
||||||
|
got = append(got, r.Src)
|
||||||
|
}
|
||||||
|
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||||
|
t.Errorf("порядок = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файлы без сезона и серии не поднимаются в начало списка.
|
||||||
|
func TestBuildFileRows_UnnumberedNotFirst(t *testing.T) {
|
||||||
|
files := srcFiles("a_sample.mkv", "z_s01e01.mkv")
|
||||||
|
plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{
|
||||||
|
{Src: "a_sample.mkv", Role: recognize.RoleSample},
|
||||||
|
{Src: "z_s01e01.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)},
|
||||||
|
}}
|
||||||
|
preview := []layout.Link{{Src: "z_s01e01.mkv", Dst: "/srv/Show/S01/E01.mkv"}}
|
||||||
|
|
||||||
|
w := buildFileRows(plan, preview, files, true)
|
||||||
|
if w.Rows[0].Src != "z_s01e01.mkv" {
|
||||||
|
t.Errorf("первым обязан идти разложенный файл, получено %+v", w.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись без сохранённого списка файлов: строки по плану + честная пометка.
|
||||||
|
func TestBuildFileRows_SourceUnknown(t *testing.T) {
|
||||||
|
plan := recognize.Plan{Type: recognize.MediaMovie, Files: []recognize.PlanFile{
|
||||||
|
{Src: "film.mkv", Role: recognize.RoleMain},
|
||||||
|
}}
|
||||||
|
w := buildFileRows(plan, []layout.Link{{Src: "film.mkv", Dst: "/srv/movies/F/f.mkv"}}, nil, false)
|
||||||
|
if !w.SourceUnknown {
|
||||||
|
t.Error("отсутствие снимка обязано быть названо явно")
|
||||||
|
}
|
||||||
|
if len(w.Rows) != 1 || w.Rows[0].Src != "film.mkv" {
|
||||||
|
t.Errorf("rows = %+v", w.Rows)
|
||||||
|
}
|
||||||
|
if w.Rows[0].Num != 0 {
|
||||||
|
t.Errorf("без снимка номер адресации неизвестен и показываться не должен: %+v", w.Rows[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Страница загрузки говорит о недоступности списка файлов, а не показывает
|
||||||
|
// план как полный перечень.
|
||||||
|
func TestDownloadPage_SaysFileListUnavailable(t *testing.T) {
|
||||||
|
dl := store.Download{ID: testULID, SourceRef: "Film", State: store.StateReview}
|
||||||
|
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Film",
|
||||||
|
Files: []recognize.PlanFile{{Src: "film.mkv", Role: recognize.RoleMain}}}
|
||||||
|
rd := &worker.ReviewData{
|
||||||
|
Download: dl,
|
||||||
|
Recognition: &store.Recognition{ID: testULID, DownloadID: testULID},
|
||||||
|
Plan: plan,
|
||||||
|
}
|
||||||
|
h := testRouter(t, stubReader{list: []store.Download{dl}, one: &dl}, stubReviewer{data: rd})
|
||||||
|
|
||||||
|
rr := get(t, h, "/download/"+testULID)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /download/{id} = %d", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "Список файлов раздачи не сохранён") {
|
||||||
|
t.Errorf("страница молчит о неполноте перечня")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Со снимком — пометка исчезает, а файл вне плана виден.
|
||||||
|
rd.SourceFiles = srcFiles("film.mkv", "screens/01.jpg")
|
||||||
|
rd.SourceFilesKnown = true
|
||||||
|
rr = get(t, h, "/download/"+testULID)
|
||||||
|
body := rr.Body.String()
|
||||||
|
if strings.Contains(body, "Список файлов раздачи не сохранён") {
|
||||||
|
t.Errorf("со снимком пометка о неполноте не нужна")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "screens/01.jpg") || !strings.Contains(body, "не в плане") {
|
||||||
|
t.Errorf("файл вне плана не показан:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Легаси-план вправе разметить один файл двумя элементами (сдвоенный эпизод):
|
||||||
|
// Apply создаст два хардлинка, и виджет обязан показать обе цели — иначе
|
||||||
|
// предпросмотр перестаёт быть равен применению.
|
||||||
|
func TestBuildFileRows_DuplicateSrcShownTwice(t *testing.T) {
|
||||||
|
files := srcFiles("pack/s01e01e02.mkv", "pack/s01e03.mkv")
|
||||||
|
plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{
|
||||||
|
{Src: "pack/s01e01e02.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)},
|
||||||
|
{Src: "pack/s01e01e02.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(2)},
|
||||||
|
{Src: "pack/s01e03.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(3)},
|
||||||
|
}}
|
||||||
|
preview := []layout.Link{
|
||||||
|
{Src: "pack/s01e01e02.mkv", Dst: "/srv/Show/S01/E01.mkv"},
|
||||||
|
{Src: "pack/s01e01e02.mkv", Dst: "/srv/Show/S01/E02.mkv"},
|
||||||
|
{Src: "pack/s01e03.mkv", Dst: "/srv/Show/S01/E03.mkv"},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := buildFileRows(plan, preview, files, true)
|
||||||
|
if len(w.Rows) != 3 {
|
||||||
|
t.Fatalf("строк %d, ожидалась строка на каждый элемент плана: %+v", len(w.Rows), w.Rows)
|
||||||
|
}
|
||||||
|
var dsts []string
|
||||||
|
for _, r := range w.Rows {
|
||||||
|
if r.Src == "pack/s01e01e02.mkv" {
|
||||||
|
dsts = append(dsts, r.Dst)
|
||||||
|
}
|
||||||
|
if r.Num == 0 {
|
||||||
|
t.Errorf("номер адресации потерян: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(dsts) != 2 || dsts[0] != "/srv/Show/S01/E01.mkv" || dsts[1] != "/srv/Show/S01/E02.mkv" {
|
||||||
|
t.Errorf("обе цели сдвоенного эпизода = %v", dsts)
|
||||||
|
}
|
||||||
|
// Знаменатель покрытия — файлы раздачи, а не элементы плана.
|
||||||
|
if w.Planned != 2 || w.Total != 2 {
|
||||||
|
t.Errorf("сводка = %+v", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ type reviewView struct {
|
|||||||
Confidence string
|
Confidence string
|
||||||
Reasons []string
|
Reasons []string
|
||||||
Hints []string
|
Hints []string
|
||||||
Files []fileRow
|
Files layoutWidget
|
||||||
HasPlan bool
|
HasPlan bool
|
||||||
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||||
NoBase bool // выбрано «без базы»
|
NoBase bool // выбрано «без базы»
|
||||||
@@ -145,7 +145,7 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
|||||||
if rec.Confidence.Valid {
|
if rec.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||||
}
|
}
|
||||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown)
|
||||||
view.HasPlan = len(rd.Plan.Files) > 0
|
view.HasPlan = len(rd.Plan.Files) > 0
|
||||||
view.HasLinks = len(rd.Preview) > 0
|
view.HasLinks = len(rd.Preview) > 0
|
||||||
for _, src := range rd.Sources {
|
for _, src := range rd.Sources {
|
||||||
|
|||||||
@@ -54,8 +54,17 @@ type Response struct {
|
|||||||
Content string
|
Content string
|
||||||
Model string
|
Model string
|
||||||
Usage Usage
|
Usage Usage
|
||||||
|
// FinishReason — причина завершения генерации, как её назвал провайдер
|
||||||
|
// ("stop", "length", …). Пусто — провайдер её не сообщил. Нужна
|
||||||
|
// вызывающему: обрыв по длине неотличим от мусора по одному лишь телу
|
||||||
|
// ответа, а повторять такой запрос бессмысленно (см. recognize).
|
||||||
|
FinishReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FinishLength — значение FinishReason, которым OpenAI-совместимые провайдеры
|
||||||
|
// сообщают обрыв генерации по достижении предела токенов.
|
||||||
|
const FinishLength = "length"
|
||||||
|
|
||||||
// Provider — абстракция доступа к LLM. recognize работает только с ним и не
|
// Provider — абстракция доступа к LLM. recognize работает только с ним и не
|
||||||
// знает про конкретный транспорт.
|
// знает про конкретный транспорт.
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
@@ -75,6 +84,17 @@ type Config struct {
|
|||||||
// ErrUnknownType — запрошенный [llm].type не поддерживается.
|
// ErrUnknownType — запрошенный [llm].type не поддерживается.
|
||||||
var ErrUnknownType = errors.New("llm: unknown provider type")
|
var ErrUnknownType = errors.New("llm: unknown provider type")
|
||||||
|
|
||||||
|
// ErrRequestTooLarge — провайдер отказал из-за размера самого запроса
|
||||||
|
// (переполнение контекста), а не из-за его содержимого. Вызывающему это
|
||||||
|
// отдельная ветвь: такой отказ не лечится повтором, а называется человеку
|
||||||
|
// своей причиной и крутится настройкой ([recognition].max_files).
|
||||||
|
//
|
||||||
|
// Признак ставит транспорт — там, где ещё целы HTTP-статус и полное тело
|
||||||
|
// ответа (см. openai.go). Вызывающий проверяет его errors.Is: у итоговой
|
||||||
|
// ошибки текст обрезан и в него попадает эхо запроса, так что матчить по нему
|
||||||
|
// нельзя (docs/conventions/errors.md).
|
||||||
|
var ErrRequestTooLarge = errors.New("llm: request too large")
|
||||||
|
|
||||||
// New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
|
// New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
|
||||||
func New(cfg Config, logger *slog.Logger) (Provider, error) {
|
func New(cfg Config, logger *slog.Logger) (Provider, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package llm
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -234,3 +235,72 @@ func TestNew_OpenAICompatValidation(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Признак обрыва генерации по длине доходит до вызывающего: по одному телу
|
||||||
|
// ответа обрыв неотличим от мусора, а повторять такой запрос бессмысленно.
|
||||||
|
func TestComplete_FinishReasonReachesCaller(t *testing.T) {
|
||||||
|
for _, want := range []string{"length", "stop"} {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"model":"m","choices":[{"message":{"content":"{\"type\":"},`+
|
||||||
|
`"finish_reason":"`+want+`"}]}`)
|
||||||
|
}))
|
||||||
|
|
||||||
|
p := newTestProvider(t, srv.URL, "")
|
||||||
|
resp, err := p.Complete(context.Background(), Request{
|
||||||
|
Messages: []Message{{Role: RoleUser, Content: "hi"}},
|
||||||
|
})
|
||||||
|
srv.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Complete: %v", err)
|
||||||
|
}
|
||||||
|
if resp.FinishReason != want {
|
||||||
|
t.Errorf("finish_reason = %q, want %q", resp.FinishReason, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if FinishLength != "length" {
|
||||||
|
t.Errorf("FinishLength = %q, want length", FinishLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ по размеру запроса помечается sentinel'ом там, где ещё целы
|
||||||
|
// HTTP-статус и ПОЛНОЕ тело: вызывающему нечего матчить по тексту, а текст
|
||||||
|
// итоговой ошибки обрезан (snippet) и несёт эхо запроса.
|
||||||
|
func TestComplete_RequestTooLargeSentinel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"413 без разбора текста", http.StatusRequestEntityTooLarge, `payload too big`, true},
|
||||||
|
{"400 с маркером в теле", http.StatusBadRequest,
|
||||||
|
`{"error":{"message":"This model's maximum context length is 128000 tokens"}}`, true},
|
||||||
|
{"400 с маркером за пределом snippet", http.StatusBadRequest,
|
||||||
|
`{"echo":"` + strings.Repeat("x", 400) + `","error":{"message":"prompt is too long"}}`, true},
|
||||||
|
{"400 по другой причине", http.StatusBadRequest,
|
||||||
|
`{"error":{"message":"invalid api key"}}`, false},
|
||||||
|
{"500 — транзиентный сбой, не размер", http.StatusInternalServerError,
|
||||||
|
`context length exceeded`, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
_, _ = io.WriteString(w, tc.body)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := newTestProvider(t, srv.URL, "")
|
||||||
|
_, err := p.Complete(context.Background(), Request{
|
||||||
|
Messages: []Message{{Role: RoleUser, Content: "hi"}},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка")
|
||||||
|
}
|
||||||
|
if got := errors.Is(err, ErrRequestTooLarge); got != tc.want {
|
||||||
|
t.Errorf("errors.Is(err, ErrRequestTooLarge) = %v, want %v (err = %v)",
|
||||||
|
got, tc.want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+36
-2
@@ -193,8 +193,11 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
|
|||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
|
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
|
||||||
return Response{}, retryable, fmt.Errorf("llm: status %d: %s",
|
err := fmt.Errorf("llm: status %d: %s", resp.StatusCode, snippet(raw))
|
||||||
resp.StatusCode, snippet(raw))
|
if requestTooLarge(resp.StatusCode, raw) {
|
||||||
|
err = fmt.Errorf("%w: %w", ErrRequestTooLarge, err)
|
||||||
|
}
|
||||||
|
return Response{}, retryable, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var cr chatResponse
|
var cr chatResponse
|
||||||
@@ -211,6 +214,7 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
|
|||||||
return Response{
|
return Response{
|
||||||
Content: cr.Choices[0].Message.Content,
|
Content: cr.Choices[0].Message.Content,
|
||||||
Model: cr.Model,
|
Model: cr.Model,
|
||||||
|
FinishReason: cr.Choices[0].FinishReason,
|
||||||
Usage: Usage{
|
Usage: Usage{
|
||||||
PromptTokens: cr.Usage.PromptTokens,
|
PromptTokens: cr.Usage.PromptTokens,
|
||||||
CompletionTokens: cr.Usage.CompletionTokens,
|
CompletionTokens: cr.Usage.CompletionTokens,
|
||||||
@@ -236,6 +240,36 @@ func (c *openAICompat) wait(ctx context.Context, attempt int) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tooLargeMarkers — текстовые признаки переполнения контекста в теле отказа.
|
||||||
|
// Единого машинного кода у OpenAI-совместимых шлюзов нет: часть отдаёт 413,
|
||||||
|
// остальные кладут причину в текст при обычном 400.
|
||||||
|
var tooLargeMarkers = []string{
|
||||||
|
"context length", "context_length", "context window", "maximum context",
|
||||||
|
"too many tokens", "prompt is too long", "request too large",
|
||||||
|
"reduce the length",
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestTooLarge — отказал ли провайдер из-за размера запроса. 413 —
|
||||||
|
// безусловно, без разбора текста. 400 — только по маркерам, и смотрим их в
|
||||||
|
// ПОЛНОМ теле: обрезка тела (snippet) выбрасывает причину ровно у тех шлюзов,
|
||||||
|
// которые сперва echo'ят запрос. Ложное срабатывание безопасно: задача уходит
|
||||||
|
// в review, а не в повтор.
|
||||||
|
func requestTooLarge(status int, body []byte) bool {
|
||||||
|
if status == http.StatusRequestEntityTooLarge {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(string(body))
|
||||||
|
for _, marker := range tooLargeMarkers {
|
||||||
|
if strings.Contains(lower, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// snippet обрезает тело для сообщения об ошибке.
|
// snippet обрезает тело для сообщения об ошибке.
|
||||||
func snippet(b []byte) string {
|
func snippet(b []byte) string {
|
||||||
const max = 300
|
const max = 300
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package recognize
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// idxOf — резолвнутый номер файла (0 — не проставлен).
|
||||||
|
func idxOf(pf PlanFile) int {
|
||||||
|
if pf.Index == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(*pf.Index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// planWith собирает ответ модели из готовых элементов files[].
|
||||||
|
func planWith(files ...string) string {
|
||||||
|
return `{"type":"series","title":"Show","confidence":0.9,"files":[` +
|
||||||
|
strings.Join(files, ",") + `]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Номер строки резолвится в путь нашего же списка: src подставляем мы, а не
|
||||||
|
// модель — посторонний путь невыразим.
|
||||||
|
func TestParsePlan_IndexResolvesToPath(t *testing.T) {
|
||||||
|
in := inputWith("s1/e1.mkv", "s1/e2.mkv", "s1/e3.mkv")
|
||||||
|
raw := planWith(`{"i":2,"role":"episode","season":1,"episode":2}`)
|
||||||
|
|
||||||
|
p, problems, err := parsePlan(raw, in, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(problems) != 0 {
|
||||||
|
t.Errorf("претензий быть не должно: %v", problems)
|
||||||
|
}
|
||||||
|
if p.Files[0].Src != "s1/e2.mkv" || idxOf(p.Files[0]) != 2 {
|
||||||
|
t.Errorf("file = %+v, want s1/e2.mkv по номеру 2", p.Files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Номер вне диапазона отбрасывает элемент, а не план.
|
||||||
|
func TestParsePlan_IndexOutOfRangeDropsElementOnly(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
raw := planWith(
|
||||||
|
`{"i":1,"role":"episode","season":1,"episode":1}`,
|
||||||
|
`{"i":181,"role":"episode","season":1,"episode":2}`,
|
||||||
|
`{"i":2,"role":"episode","season":1,"episode":2}`)
|
||||||
|
|
||||||
|
p, problems, err := parsePlan(raw, in, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("план должен пережить негодный элемент: %v", err)
|
||||||
|
}
|
||||||
|
if len(p.Files) != 2 {
|
||||||
|
t.Errorf("в плане %d файлов, ожидались 2 годных: %+v", len(p.Files), p.Files)
|
||||||
|
}
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], "вне диапазона 1..2") {
|
||||||
|
t.Errorf("problems = %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторная адресация: побеждает первый элемент, второй отброшен с причиной.
|
||||||
|
func TestParsePlan_DuplicateAddressingKeepsFirst(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
raw := planWith(
|
||||||
|
`{"i":1,"role":"episode","season":1,"episode":1}`,
|
||||||
|
`{"i":1,"role":"episode","season":1,"episode":7}`)
|
||||||
|
|
||||||
|
p, problems, err := parsePlan(raw, in, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(p.Files) != 1 || *p.Files[0].Episode != 1 {
|
||||||
|
t.Errorf("должен остаться первый элемент, получено %+v", p.Files)
|
||||||
|
}
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], "адресован повторно") {
|
||||||
|
t.Errorf("problems = %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Элемент без номера и без пути адресует ничто — отбрасывается.
|
||||||
|
func TestParsePlan_NoIndexNoSrcDropped(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
raw := planWith(
|
||||||
|
`{"role":"episode","season":1,"episode":1}`,
|
||||||
|
`{"i":2,"role":"episode","season":1,"episode":2}`)
|
||||||
|
|
||||||
|
p, problems, err := parsePlan(raw, in, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(p.Files) != 1 || p.Files[0].Src != "b.mkv" {
|
||||||
|
t.Errorf("files = %+v", p.Files)
|
||||||
|
}
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], "без номера файла и без пути") {
|
||||||
|
t.Errorf("problems = %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запасной формат: путь вместо номера принимается при точном совпадении.
|
||||||
|
func TestParsePlan_SrcFallbackAccepted(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
raw := planWith(`{"src":"b.mkv","role":"episode","season":1,"episode":2}`)
|
||||||
|
|
||||||
|
p, problems, err := parsePlan(raw, in, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(problems) != 0 {
|
||||||
|
t.Errorf("запасной формат не должен давать претензий: %v", problems)
|
||||||
|
}
|
||||||
|
if p.Files[0].Src != "b.mkv" || idxOf(p.Files[0]) != 2 {
|
||||||
|
t.Errorf("file = %+v", p.Files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Номер и путь вместе: главенствует номер; расхождение — причина ревью.
|
||||||
|
func TestParsePlan_IndexWinsOverSrc(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
|
||||||
|
t.Run("совпадают", func(t *testing.T) {
|
||||||
|
raw := planWith(`{"i":1,"src":"a.mkv","role":"episode","season":1,"episode":1}`)
|
||||||
|
p, problems, err := parsePlan(raw, in, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(problems) != 0 || p.Files[0].Src != "a.mkv" {
|
||||||
|
t.Errorf("problems = %v, file = %+v", problems, p.Files[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("расходятся", func(t *testing.T) {
|
||||||
|
raw := planWith(`{"i":1,"src":"b.mkv","role":"episode","season":1,"episode":1}`)
|
||||||
|
p, problems, err := parsePlan(raw, in, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePlan: %v", err)
|
||||||
|
}
|
||||||
|
if p.Files[0].Src != "a.mkv" {
|
||||||
|
t.Errorf("src = %q, want резолв по номеру (a.mkv)", p.Files[0].Src)
|
||||||
|
}
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], "принят файл по номеру") {
|
||||||
|
t.Errorf("расхождение обязано стать причиной ревью: %v", problems)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не осталось ни одного годного элемента — план не разобран.
|
||||||
|
func TestParsePlan_AllElementsBadIsUnparsed(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv")
|
||||||
|
raw := planWith(
|
||||||
|
`{"i":9,"role":"episode","season":1,"episode":1}`,
|
||||||
|
`{"src":"zzz.mkv","role":"episode","season":1,"episode":2}`)
|
||||||
|
|
||||||
|
if _, problems, err := parsePlan(raw, in, testLogger()); err == nil {
|
||||||
|
t.Errorf("план без годных файлов обязан считаться неразобранным (problems=%v)", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Резолв не паникует ни на одном входе и никогда не выпускает посторонний путь.
|
||||||
|
func TestParsePlan_ResolveNeverPanics(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
raws := []string{
|
||||||
|
planWith(`{"i":0,"src":"a.mkv","role":"main"}`),
|
||||||
|
planWith(`{"i":-3,"role":"main"}`),
|
||||||
|
planWith(`{"i":3,"role":"main"}`),
|
||||||
|
planWith(`{"i":2.0,"role":"main"}`),
|
||||||
|
planWith(`{"i":1.7,"role":"main"}`),
|
||||||
|
planWith(`{"i":"2","role":"main"}`),
|
||||||
|
planWith(`{"i":"два","src":"b.mkv","role":"main"}`),
|
||||||
|
planWith(`{"i":null,"src":"a.mkv","role":"main"}`),
|
||||||
|
planWith(`{"i":99999999999999999999,"role":"main"}`),
|
||||||
|
`{"type":"movie","title":"X","files":[]}`,
|
||||||
|
planWith(
|
||||||
|
`{"i":1,"role":"main"}`, `{"i":2,"role":"extra"}`,
|
||||||
|
`{"i":3,"role":"extra"}`, `{"i":4,"role":"extra"}`),
|
||||||
|
}
|
||||||
|
known := map[string]bool{"a.mkv": true, "b.mkv": true}
|
||||||
|
for _, raw := range raws {
|
||||||
|
p, _, err := parsePlan(raw, in, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
continue // неразобранный план — законный исход, паники нет
|
||||||
|
}
|
||||||
|
for _, f := range p.Files {
|
||||||
|
if !known[f.Src] {
|
||||||
|
t.Fatalf("посторонний путь %q пролез в план из %s", f.Src, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок списка — свойство узла: перемешанный Input даёт ту же нумерацию.
|
||||||
|
func TestRecognize_NumberingIndependentOfCaller(t *testing.T) {
|
||||||
|
paths := []string{"s2/e01.mkv", "s1/e02.mkv", "s1/e01.mkv", "s2/e02.mkv"}
|
||||||
|
mk := func(order []int) Input {
|
||||||
|
in := Input{Name: "Show"}
|
||||||
|
for _, i := range order {
|
||||||
|
in.Files = append(in.Files, File{Path: paths[i], Size: 1 << 30})
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
resp := planWith(`{"i":3,"role":"episode","season":2,"episode":1}`)
|
||||||
|
|
||||||
|
var prompts []string
|
||||||
|
var srcs []string
|
||||||
|
for _, order := range [][]int{{0, 1, 2, 3}, {3, 2, 1, 0}, {2, 0, 3, 1}} {
|
||||||
|
f := &fakeLLM{responses: []string{resp}}
|
||||||
|
r := New(f, nil, Config{}, testLogger())
|
||||||
|
res, err := r.Recognize(context.Background(), mk(order))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recognize: %v", err)
|
||||||
|
}
|
||||||
|
prompts = append(prompts, f.lastReq.Messages[1].Content)
|
||||||
|
srcs = append(srcs, res.Plan.Files[0].Src)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(prompts); i++ {
|
||||||
|
if prompts[i] != prompts[0] {
|
||||||
|
t.Errorf("нумерация зависит от порядка Input:\n%s\n---\n%s", prompts[0], prompts[i])
|
||||||
|
}
|
||||||
|
if srcs[i] != srcs[0] {
|
||||||
|
t.Errorf("резолв номера разъехался: %q != %q", srcs[i], srcs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if srcs[0] != "s2/e01.mkv" {
|
||||||
|
t.Errorf("номер 3 обязан резолвиться в третий по порядку файл, получено %q", srcs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Входной срез вызывающего распознавание не переупорядочивает.
|
||||||
|
func TestRecognize_DoesNotReorderCallerSlice(t *testing.T) {
|
||||||
|
in := Input{Name: "Show", Files: []File{
|
||||||
|
{Path: "b.mkv", Size: 1}, {Path: "a.mkv", Size: 1},
|
||||||
|
}}
|
||||||
|
f := &fakeLLM{responses: []string{planWith(`{"i":1,"role":"episode","season":1,"episode":1}`)}}
|
||||||
|
r := New(f, nil, Config{}, testLogger())
|
||||||
|
if _, err := r.Recognize(context.Background(), in); err != nil {
|
||||||
|
t.Fatalf("Recognize: %v", err)
|
||||||
|
}
|
||||||
|
if in.Files[0].Path != "b.mkv" {
|
||||||
|
t.Errorf("срез вызывающего переупорядочен: %+v", in.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрыв ответа по длине уводит в review и НЕ порождает повторных запросов.
|
||||||
|
func TestRecognize_TruncatedResponseNoRetry(t *testing.T) {
|
||||||
|
in := Input{Name: "Big", Files: []File{{Path: "a.mkv", Size: 1}}}
|
||||||
|
f := &truncatingLLM{}
|
||||||
|
r := New(f, nil, Config{MaxRetries: 3, MaxTokens: 8000}, testLogger())
|
||||||
|
|
||||||
|
res, err := r.Recognize(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("обрыв — не транспортная ошибка: %v", err)
|
||||||
|
}
|
||||||
|
if f.calls != 1 {
|
||||||
|
t.Errorf("calls = %d, want 1 (обрыв не повторяют)", f.calls)
|
||||||
|
}
|
||||||
|
if res.Decision.Auto || !hasReason(res.Decision.Reasons, "обрезан") {
|
||||||
|
t.Errorf("reasons = %v", res.Decision.Reasons)
|
||||||
|
}
|
||||||
|
if len(res.Files) != 1 {
|
||||||
|
t.Errorf("снимок списка файлов обязан быть и на этом исходе: %+v", res.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncatingLLM всегда отвечает обрывом генерации по длине.
|
||||||
|
type truncatingLLM struct{ calls int }
|
||||||
|
|
||||||
|
func (f *truncatingLLM) Complete(_ context.Context, _ llm.Request) (llm.Response, error) {
|
||||||
|
f.calls++
|
||||||
|
return llm.Response{Content: `{"type":"movie","title":"X","files":[{"i":1`,
|
||||||
|
FinishReason: llm.FinishLength}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ модели по размеру запроса называется своей причиной, а не текстом
|
||||||
|
// провайдера, и уводит в review вместо ошибки распознавания.
|
||||||
|
func TestRecognize_RequestTooLargeNamedReason(t *testing.T) {
|
||||||
|
in := Input{Name: "Big", Files: []File{{Path: "a.mkv", Size: 1}}}
|
||||||
|
f := &fakeLLM{errs: []error{errRequestTooLarge}}
|
||||||
|
r := New(f, nil, Config{MaxRetries: 3}, testLogger())
|
||||||
|
|
||||||
|
res, err := r.Recognize(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отказ по размеру запроса — не ошибка распознавания: %v", err)
|
||||||
|
}
|
||||||
|
if f.calls != 1 {
|
||||||
|
t.Errorf("calls = %d, want 1", f.calls)
|
||||||
|
}
|
||||||
|
if !hasReason(res.Decision.Reasons, "по его размеру") {
|
||||||
|
t.Errorf("reasons = %v", res.Decision.Reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Снятие лимита на список файлов не умножает число попыток: их потолок —
|
||||||
|
// [llm].max_retries, а вторая попытка сопоставима по размеру с первой.
|
||||||
|
func TestRecognize_RetryCostDoesNotGrowWithRelease(t *testing.T) {
|
||||||
|
files := make([]File, 300)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = File{Path: "show/" + strconv.Itoa(1000+i) + ".mkv", Size: 1 << 30}
|
||||||
|
}
|
||||||
|
in := Input{Name: "Big.Pack", Files: files}
|
||||||
|
f := &fakeLLM{responses: []string{"мусор"}}
|
||||||
|
r := New(f, nil, Config{MaxRetries: 2, MaxFiles: 500}, testLogger())
|
||||||
|
|
||||||
|
if _, err := r.Recognize(context.Background(), in); err != nil {
|
||||||
|
t.Fatalf("Recognize: %v", err)
|
||||||
|
}
|
||||||
|
if f.calls != 3 {
|
||||||
|
t.Errorf("calls = %d, want 3 (1 + max_retries)", f.calls)
|
||||||
|
}
|
||||||
|
first := len(f.lastReq.Messages[1].Content)
|
||||||
|
total := 0
|
||||||
|
for _, m := range f.lastReq.Messages[2:] {
|
||||||
|
total += len(m.Content)
|
||||||
|
}
|
||||||
|
// Три попытки на 300 файлов: дописанные сообщения не несут списка, поэтому
|
||||||
|
// их суммарный объём заведомо меньше одной его копии.
|
||||||
|
if total >= first {
|
||||||
|
t.Errorf("повторные попытки весят %d при списке в %d символов — список переприслан",
|
||||||
|
total, first)
|
||||||
|
}
|
||||||
|
for i, m := range f.lastReq.Messages {
|
||||||
|
if i > 1 && strings.Contains(m.Content, "show/1000.mkv") {
|
||||||
|
t.Errorf("сообщение %d повторно печатает список файлов", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,14 +36,14 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
|||||||
"provider_hint": "строка для поиска в базе (НЕ id)",
|
"provider_hint": "строка для поиска в базе (НЕ id)",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
|
"i": номер файла из списка ниже (целое, ровно как напечатано),
|
||||||
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
|
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
|
||||||
"season": число или null,
|
"season": число или null,
|
||||||
"episode": число или null
|
"episode": число или null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"confidence": число 0..1,
|
"confidence": число 0..1,
|
||||||
"notes": "пояснения и неоднозначности или пустая строка"
|
"notes": "неоднозначности, не больше пары предложений, или пустая строка"
|
||||||
}
|
}
|
||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
@@ -53,10 +53,12 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
|||||||
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
|
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
|
||||||
оригинальном названии — продублируй "title", но НЕ выдумывай название.
|
оригинальном названии — продублируй "title", но НЕ выдумывай название.
|
||||||
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
||||||
|
- Поле "i" — номер файла из напечатанного ниже списка, ровно как он там стоит.
|
||||||
|
Путь файла копировать НЕ нужно: он у нас уже есть, мы подставим его сами.
|
||||||
|
- Один и тот же файл адресуй не больше одного раза.
|
||||||
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
||||||
- Для фильма ровно один основной видеофайл role "main".
|
- Для фильма ровно один основной видеофайл role "main".
|
||||||
- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
|
- Для ролей "sample", "ignore" и "extra" номера сезона и серии не нужны — оставляй null.
|
||||||
«(…)» в конце строки; не выдумывай и не нормализуй пути.
|
|
||||||
- Внешние субтитры — role "subtitle".`
|
- Внешние субтитры — role "subtitle".`
|
||||||
|
|
||||||
const systemPromptBase = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
|
const systemPromptBase = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
|
||||||
@@ -89,15 +91,18 @@ func systemPrompt(lang string) string {
|
|||||||
return systemPromptBase + schemaText + languageDirective(lang)
|
return systemPromptBase + schemaText + languageDirective(lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMessages собирает системное и пользовательское сообщения.
|
// buildMessages собирает системное и пользовательское сообщения. total —
|
||||||
func buildMessages(in Input, pre PreParse, maxFiles int, lang string) []llm.Message {
|
// полное число файлов раздачи (in.Files уже упорядочен и, возможно, усечён
|
||||||
|
// пределом): список печатается ровно тем срезом, по которому потом резолвятся
|
||||||
|
// номера.
|
||||||
|
func buildMessages(in Input, pre PreParse, total int, lang string) []llm.Message {
|
||||||
return []llm.Message{
|
return []llm.Message{
|
||||||
{Role: llm.RoleSystem, Content: systemPrompt(lang)},
|
{Role: llm.RoleSystem, Content: systemPrompt(lang)},
|
||||||
{Role: llm.RoleUser, Content: userPrompt(in, pre, maxFiles)},
|
{Role: llm.RoleUser, Content: userPrompt(in, pre, total)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func userPrompt(in Input, pre PreParse, maxFiles int) string {
|
func userPrompt(in Input, pre PreParse, total int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("Имя торрента: ")
|
b.WriteString("Имя торрента: ")
|
||||||
b.WriteString(orNone(in.Name))
|
b.WriteString(orNone(in.Name))
|
||||||
@@ -122,7 +127,7 @@ func userPrompt(in Input, pre PreParse, maxFiles int) string {
|
|||||||
b.WriteString(preParseLine(pre))
|
b.WriteString(preParseLine(pre))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
writeFileList(&b, in.Files, maxFiles)
|
writeFileList(&b, in.Files, total)
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,19 +154,15 @@ func preParseLine(pre PreParse) string {
|
|||||||
return strings.Join(parts, ", ")
|
return strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeFileList печатает список файлов, усекая до maxFiles. src в плане
|
// writeFileList печатает нумерованный список файлов. Номер строки — то, что
|
||||||
// должен дословно совпадать с путями отсюда.
|
// модель возвращает в поле "i"; усечение (если оно было) сделал вызывающий,
|
||||||
func writeFileList(b *strings.Builder, files []File, maxFiles int) {
|
// total — полное число файлов раздачи.
|
||||||
n := len(files)
|
func writeFileList(b *strings.Builder, files []File, total int) {
|
||||||
shown := n
|
b.WriteString("Файлы раздачи (")
|
||||||
if maxFiles > 0 && shown > maxFiles {
|
b.WriteString(strconv.Itoa(len(files)))
|
||||||
shown = maxFiles
|
b.WriteString("). Номер в начале строки — это то, что нужно вернуть в поле \"i\";")
|
||||||
}
|
b.WriteString(" путь и размер копировать не нужно:\n")
|
||||||
b.WriteString("Файлы (")
|
for i := range files {
|
||||||
b.WriteString(strconv.Itoa(n))
|
|
||||||
b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
|
|
||||||
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
|
|
||||||
for i := 0; i < shown; i++ {
|
|
||||||
b.WriteString(strconv.Itoa(i + 1))
|
b.WriteString(strconv.Itoa(i + 1))
|
||||||
b.WriteString(". ")
|
b.WriteString(". ")
|
||||||
b.WriteString(files[i].Path)
|
b.WriteString(files[i].Path)
|
||||||
@@ -169,22 +170,24 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
|
|||||||
b.WriteString(humanSize(files[i].Size))
|
b.WriteString(humanSize(files[i].Size))
|
||||||
b.WriteString(")\n")
|
b.WriteString(")\n")
|
||||||
}
|
}
|
||||||
if shown < n {
|
if total > len(files) {
|
||||||
b.WriteString("… и ещё ")
|
b.WriteString("… и ещё ")
|
||||||
b.WriteString(strconv.Itoa(n - shown))
|
b.WriteString(strconv.Itoa(total - len(files)))
|
||||||
b.WriteString(" файлов (список усечён)\n")
|
b.WriteString(" файлов (список усечён)\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// correctionMessage — сообщение для повторной попытки: что было не так + схема.
|
// correctionMessage — сообщение для повторной попытки: что было не так + схема.
|
||||||
func correctionMessage(err error, in Input, maxFiles int) string {
|
// Список файлов сюда НЕ входит: его номера названы в первом сообщении диалога
|
||||||
|
// и сохраняют смысл на всех попытках, а повторная печать умножала бы вход на
|
||||||
|
// число попыток — ровно ту цену, которую снимает индексная адресация.
|
||||||
|
func correctionMessage(err error) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("Ответ не принят: ")
|
b.WriteString("Ответ не принят: ")
|
||||||
b.WriteString(err.Error())
|
b.WriteString(err.Error())
|
||||||
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.\n\n")
|
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.")
|
||||||
|
b.WriteString(" Файлы адресуй номерами из списка, присланного выше.\n\n")
|
||||||
b.WriteString(schemaText)
|
b.WriteString(schemaText)
|
||||||
b.WriteString("\n\n")
|
|
||||||
writeFileList(&b, in.Files, maxFiles)
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+167
-23
@@ -11,15 +11,23 @@
|
|||||||
// базой), согласованности с пред-парсом и уверенности не ниже порога.
|
// базой), согласованности с пред-парсом и уверенности не ниже порога.
|
||||||
//
|
//
|
||||||
// Без включённых баз (или без матча) авто-раскладка не делается — задача
|
// Без включённых баз (или без матча) авто-раскладка не делается — задача
|
||||||
// уходит в review. Выход LLM недоверенный: план принимается только если
|
// уходит в review. Выход LLM недоверенный: файл адресуется НОМЕРОМ строки в
|
||||||
// каждый files[].src совпадает с реальным файлом торрента; итоговая
|
// напечатанном нами списке, а files[].src подставляет резолв номера по этому
|
||||||
// безопасность пути держится на раскладке (layout).
|
// же списку — посторонний путь невыразим. Присланный моделью путь принимается
|
||||||
|
// лишь как запасной формат и только при точном совпадении с файлом торрента;
|
||||||
|
// итоговая безопасность пути держится на раскладке (layout).
|
||||||
package recognize
|
package recognize
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
@@ -56,9 +64,29 @@ func (r FileRole) valid() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// File — входной файл торрента (путь относительно save_path и размер).
|
// File — входной файл торрента (путь относительно save_path и размер).
|
||||||
|
// JSON-теги — для снимка списка файлов рядом с планом (store.Recognition,
|
||||||
|
// миграция 0012): раскладка обязана показывать и те файлы, которых нет в плане.
|
||||||
type File struct {
|
type File struct {
|
||||||
Path string
|
Path string `json:"path"`
|
||||||
Size int64
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// videoExts — расширения, по которым файл считается видео (нижний регистр, с
|
||||||
|
// точкой). Единственное место перечня: на нём держится разделение покрытия
|
||||||
|
// плана — непокрытый видеофайл означает потерянную серию или фильм и блокирует
|
||||||
|
// авто-раскладку, непокрытый файл-спутник (субтитры, картинки, тексты) — нет.
|
||||||
|
// Незнакомое расширение видео попадёт в спутники, поэтому список пополняем
|
||||||
|
// здесь, а не по месту вызова.
|
||||||
|
var videoExts = map[string]bool{
|
||||||
|
".mkv": true, ".mp4": true, ".avi": true, ".m4v": true, ".mov": true,
|
||||||
|
".wmv": true, ".mpg": true, ".mpeg": true, ".m2ts": true, ".mts": true,
|
||||||
|
".ts": true, ".vob": true, ".flv": true, ".webm": true, ".ogm": true,
|
||||||
|
".divx": true, ".rmvb": true, ".3gp": true, ".iso": true, ".img": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsVideoFile — видео ли это по расширению пути. Регистр расширения не важен.
|
||||||
|
func IsVideoFile(path string) bool {
|
||||||
|
return videoExts[strings.ToLower(filepath.Ext(path))]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input — сигналы для распознавания одной раздачи.
|
// Input — сигналы для распознавания одной раздачи.
|
||||||
@@ -69,9 +97,45 @@ type Input struct {
|
|||||||
Hints []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто)
|
Hints []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileIndex — номер файла в списке, напечатанном в промпте (1-based). Модель
|
||||||
|
// иногда возвращает его строкой или дробным числом; разбор это терпит и
|
||||||
|
// сводит к целому, потому что негодный номер обязан отбраковывать элемент, а
|
||||||
|
// не весь план (см. validate.go). Неразбираемое значение даёт 0 — и элемент
|
||||||
|
// отбраковывается той же причиной, что и явно присланный ноль.
|
||||||
|
type FileIndex int
|
||||||
|
|
||||||
|
// UnmarshalJSON разбирает номер терпимо: число, строка с числом, дробное с
|
||||||
|
// нулевой дробной частью, null. Ошибку не возвращает НИКОГДА — иначе один
|
||||||
|
// кривой элемент ронял бы разбор всего плана.
|
||||||
|
func (n *FileIndex) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||||
|
if s == "" || s == "null" {
|
||||||
|
*n = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(s); err == nil {
|
||||||
|
*n = FileIndex(v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(s, 64); err == nil && f == math.Trunc(f) &&
|
||||||
|
f > math.MinInt32 && f < math.MaxInt32 {
|
||||||
|
*n = FileIndex(int(f))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*n = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы
|
// PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы
|
||||||
// выражать мультисезонные паки и спецвыпуски (см. recognition.md).
|
// выражать мультисезонные паки и спецвыпуски (см. recognition.md).
|
||||||
type PlanFile struct {
|
type PlanFile struct {
|
||||||
|
// Index — чем файл адресован в ответе модели: номер строки нашего списка.
|
||||||
|
// Src заполняет резолвом сама система, поэтому посторонний путь невыразим.
|
||||||
|
// Указатель, а не значение: «поле не прислано» и «прислан 0» — разные
|
||||||
|
// диагнозы. Ноль означает модель, посчитавшую список с нуля, и тогда все
|
||||||
|
// остальные её номера резолвятся со сдвигом на файл; отсутствие поля —
|
||||||
|
// запасной формат с путём либо элемент, не адресующий ничего.
|
||||||
|
Index *FileIndex `json:"i,omitempty"`
|
||||||
Src string `json:"src"`
|
Src string `json:"src"`
|
||||||
Role FileRole `json:"role"`
|
Role FileRole `json:"role"`
|
||||||
Season *int `json:"season,omitempty"`
|
Season *int `json:"season,omitempty"`
|
||||||
@@ -129,6 +193,13 @@ type Result struct {
|
|||||||
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
|
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
|
||||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
|
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
|
||||||
Raw string // сырой ответ LLM последней попытки
|
Raw string // сырой ответ LLM последней попытки
|
||||||
|
// Files — ПОЛНЫЙ список файлов раздачи в том порядке, в каком печатается
|
||||||
|
// промпт (и в каком резолвятся номера). Снимок момента распознавания: его
|
||||||
|
// сохраняют рядом с планом, чтобы раскладка показывала и файлы вне плана.
|
||||||
|
// Усечение пределом max_files сюда не распространяется — иначе снимок выдал
|
||||||
|
// бы показанный модели срез за весь перечень раздачи. Заполнен на всех
|
||||||
|
// исходах, включая review без разобранного плана.
|
||||||
|
Files []File
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM — нужная recognize часть провайдера.
|
// LLM — нужная recognize часть провайдера.
|
||||||
@@ -148,8 +219,12 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultMaxTokens = 4000
|
// Дефолты — предохранители для прямых вызовов конструктора (тесты, CLI без
|
||||||
defaultMaxFiles = 100
|
// конфига). Канонические значения задаёт [recognition] (config.Default);
|
||||||
|
// равенство им сторожит TestDefaultsAgreeWithConfig — иначе прод и тест
|
||||||
|
// разъедутся молча.
|
||||||
|
defaultMaxTokens = 8000
|
||||||
|
defaultMaxFiles = 500
|
||||||
defaultAutoThreshold = 0.85
|
defaultAutoThreshold = 0.85
|
||||||
defaultLanguage = "en"
|
defaultLanguage = "en"
|
||||||
)
|
)
|
||||||
@@ -210,7 +285,26 @@ func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logg
|
|||||||
func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||||
log := logctx.FromOr(ctx, r.log)
|
log := logctx.FromOr(ctx, r.log)
|
||||||
pre := preParse(in.Name)
|
pre := preParse(in.Name)
|
||||||
msgs := buildMessages(in, pre, r.maxFiles, r.language)
|
|
||||||
|
// Порядок списка — свойство узла, а не дисциплина вызывающего (сборок
|
||||||
|
// Input две: воркер и CLI). Печать промпта и резолв номеров идут дальше по
|
||||||
|
// одному и тому же срезу, поэтому повторное распознавание той же раздачи
|
||||||
|
// даёт ту же нумерацию.
|
||||||
|
//
|
||||||
|
// Усечение пределом max_files — свойство ПРОМПТА и резолва номеров, а не
|
||||||
|
// снимка: снимок хранит полный список раздачи, иначе виджет раскладки выдаёт
|
||||||
|
// показанный модели срез за весь перечень («в план попало 100 из 100», когда
|
||||||
|
// в торренте 250). Показанный список — префикс полного, поэтому номера
|
||||||
|
// адресации от этого не меняются.
|
||||||
|
all := sortedFiles(in.Files)
|
||||||
|
shown := all
|
||||||
|
if r.maxFiles > 0 && len(shown) > r.maxFiles {
|
||||||
|
shown = all[:r.maxFiles]
|
||||||
|
}
|
||||||
|
in.Files = shown
|
||||||
|
issues := planIssues{files: shown, all: all}
|
||||||
|
|
||||||
|
msgs := buildMessages(in, pre, len(all), r.language)
|
||||||
|
|
||||||
temp := 0.0
|
temp := 0.0
|
||||||
var raw string
|
var raw string
|
||||||
@@ -227,32 +321,54 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
MaxTokens: r.maxTokens,
|
MaxTokens: r.maxTokens,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("recognize: llm complete: %w", err)
|
// Отказ по размеру запроса называем своей причиной: текст провайдера
|
||||||
|
// человеку в ревью ничего не говорит, а лечится это [recognition].max_files.
|
||||||
|
if errors.Is(err, llm.ErrRequestTooLarge) {
|
||||||
|
log.Warn("recognition request rejected as too large",
|
||||||
|
"source_files", len(in.Files), "max_files", r.maxFiles,
|
||||||
|
"error", err)
|
||||||
|
return reviewResult(pre, issues, attempts, "", []string{fmt.Sprintf(
|
||||||
|
"модель отвергла запрос по его размеру: файлов в списке %d"+
|
||||||
|
" (уменьшите [recognition].max_files)", len(in.Files))}), nil
|
||||||
|
}
|
||||||
|
return Result{Files: all}, fmt.Errorf("recognize: llm complete: %w", err)
|
||||||
}
|
}
|
||||||
raw = resp.Content
|
raw = resp.Content
|
||||||
|
|
||||||
plan, parseErr = parsePlan(raw, in, log)
|
// Обрыв генерации по длине — не ошибка модели: ответ не поместился.
|
||||||
|
// Повтор тем же промптом (и его вариантом) дал бы тот же обрыв.
|
||||||
|
if resp.FinishReason == llm.FinishLength {
|
||||||
|
log.Warn("recognition llm response truncated",
|
||||||
|
"attempt", attempts, "max_tokens", r.maxTokens)
|
||||||
|
return reviewResult(pre, issues, attempts, raw, []string{fmt.Sprintf(
|
||||||
|
"ответ модели обрезан по пределу длины (max_tokens = %d):"+
|
||||||
|
" план неполон, повтор тем же запросом не поможет", r.maxTokens)}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, issues.dropped, parseErr = parsePlan(raw, in, log)
|
||||||
if parseErr == nil {
|
if parseErr == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
log.Warn("recognition llm response unparsed",
|
log.Warn("recognition llm response unparsed",
|
||||||
"attempt", attempts, "error", parseErr)
|
"attempt", attempts, "error", parseErr)
|
||||||
// Просим модель исправиться, повторяя схему и ошибку.
|
// Просим модель исправиться, повторяя схему и ошибку. Список файлов
|
||||||
|
// НЕ переприсылаем: его номера названы в первом сообщении диалога и
|
||||||
|
// сохраняют смысл на всех попытках (иначе цена неудачи росла бы вместе
|
||||||
|
// с размером раздачи).
|
||||||
msgs = append(msgs,
|
msgs = append(msgs,
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: raw},
|
llm.Message{Role: llm.RoleAssistant, Content: raw},
|
||||||
llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr, in, r.maxFiles)})
|
llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr)})
|
||||||
}
|
}
|
||||||
|
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return Result{
|
// Претензии последней попытки — единственное поэлементное объяснение
|
||||||
PreParse: pre,
|
// («номер 181 вне диапазона 1..2»); без них человек читает голое «ответ
|
||||||
Attempts: attempts,
|
// LLM не разобран». decide на успешной ветке добавляет их так же.
|
||||||
Raw: raw,
|
reasons := []string{
|
||||||
Decision: Decision{
|
"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error(),
|
||||||
Auto: false,
|
}
|
||||||
Reasons: []string{"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error()},
|
reasons = append(reasons, issues.dropped...)
|
||||||
},
|
return reviewResult(pre, issues, attempts, raw, reasons), nil
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
||||||
@@ -275,10 +391,11 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
|
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold, issues)
|
||||||
log.Info("recognition done",
|
log.Info("recognition done",
|
||||||
"media_type", plan.Type, "title", plan.Title, "year", plan.Year,
|
"media_type", plan.Type, "title", plan.Title, "year", plan.Year,
|
||||||
"files", len(plan.Files), "attempts", attempts,
|
"files", len(plan.Files), "source_files", len(in.Files),
|
||||||
|
"dropped", len(issues.dropped), "attempts", attempts,
|
||||||
"matched", match != nil, "candidates", len(candidates),
|
"matched", match != nil, "candidates", len(candidates),
|
||||||
"auto", dec.Auto, "reasons", len(dec.Reasons))
|
"auto", dec.Auto, "reasons", len(dec.Reasons))
|
||||||
return Result{
|
return Result{
|
||||||
@@ -289,5 +406,32 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
Attempts: attempts,
|
Attempts: attempts,
|
||||||
Raw: raw,
|
Raw: raw,
|
||||||
|
Files: all,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reviewResult — исход без пригодного плана: задача уходит в review с
|
||||||
|
// названными причинами. Снимок списка файлов отдаём и здесь: он нужен
|
||||||
|
// раскладке и повторному распознаванию из ревью.
|
||||||
|
func reviewResult(pre PreParse, issues planIssues, attempts int, raw string, reasons []string) Result {
|
||||||
|
if issues.truncated() > 0 {
|
||||||
|
reasons = append(reasons, truncationReason(issues))
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
PreParse: pre,
|
||||||
|
Attempts: attempts,
|
||||||
|
Raw: raw,
|
||||||
|
Files: issues.all,
|
||||||
|
Decision: Decision{Auto: false, Reasons: reasons},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedFiles — детерминированный порядок списка файлов (по пути). Возвращает
|
||||||
|
// НОВЫЙ срез: входной принадлежит вызывающему, менять его порядок нельзя.
|
||||||
|
// Путь внутри торрента уникален, поэтому порядок воспроизводим между вызовами.
|
||||||
|
func sortedFiles(files []File) []File {
|
||||||
|
out := make([]File, len(files))
|
||||||
|
copy(out, files)
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package recognize
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/config"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +37,13 @@ func (f *fakeLLM) Complete(_ context.Context, req llm.Request) (llm.Response, er
|
|||||||
return llm.Response{Content: content}, nil
|
return llm.Response{Content: content}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errRequestTooLarge — типовой отказ OpenAI-совместимого шлюза по размеру
|
||||||
|
// запроса, как он приходит из internal/llm: текст провайдера плюс sentinel,
|
||||||
|
// которым транспорт пометил отказ (признак ставится там, где ещё целы статус и
|
||||||
|
// полное тело, а recognize ветвится по errors.Is, а не по тексту).
|
||||||
|
var errRequestTooLarge = fmt.Errorf("%w: %w", llm.ErrRequestTooLarge, errors.New(
|
||||||
|
"llm: status 400: {\"error\":{\"message\":\"This model's maximum context length is 128000 tokens\"}}"))
|
||||||
|
|
||||||
func testLogger() *slog.Logger {
|
func testLogger() *slog.Logger {
|
||||||
return slog.New(slog.DiscardHandler)
|
return slog.New(slog.DiscardHandler)
|
||||||
}
|
}
|
||||||
@@ -145,11 +154,15 @@ func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) {
|
|||||||
if res.Plan.Title != "Some Movie" {
|
if res.Plan.Title != "Some Movie" {
|
||||||
t.Errorf("plan = %+v", res.Plan)
|
t.Errorf("plan = %+v", res.Plan)
|
||||||
}
|
}
|
||||||
// Корректирующее сообщение должно содержать схему и список файлов.
|
// Корректирующее сообщение несёт ошибку и схему, но НЕ список файлов:
|
||||||
|
// номера названы в первом сообщении диалога.
|
||||||
last := f.lastReq.Messages[len(f.lastReq.Messages)-1]
|
last := f.lastReq.Messages[len(f.lastReq.Messages)-1]
|
||||||
if !strings.Contains(last.Content, "Ответ не принят") || !strings.Contains(last.Content, "film.mkv") {
|
if !strings.Contains(last.Content, "Ответ не принят") || !strings.Contains(last.Content, `"i"`) {
|
||||||
t.Errorf("correction message missing context: %q", last.Content)
|
t.Errorf("correction message missing context: %q", last.Content)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(last.Content, "film.mkv") {
|
||||||
|
t.Errorf("correction message must not repeat the file list: %q", last.Content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) {
|
func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) {
|
||||||
@@ -222,29 +235,129 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) {
|
|||||||
|
|
||||||
func TestRecognize_FileListTruncated(t *testing.T) {
|
func TestRecognize_FileListTruncated(t *testing.T) {
|
||||||
files := make([]File, 250)
|
files := make([]File, 250)
|
||||||
planFiles := make([]string, 0, 250)
|
|
||||||
for i := range files {
|
for i := range files {
|
||||||
files[i] = File{Path: pathOf(i), Size: 100 << 20}
|
files[i] = File{Path: pathOf(i), Size: 100 << 20}
|
||||||
}
|
}
|
||||||
// План ссылается только на первый файл — этого достаточно для схемы.
|
|
||||||
_ = planFiles
|
|
||||||
in := Input{Name: "Big.Pack", Files: files}
|
in := Input{Name: "Big.Pack", Files: files}
|
||||||
resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) +
|
// План ссылается только на первый по порядку файл — этого достаточно схеме.
|
||||||
`","role":"episode","season":1,"episode":1}]}`
|
first := sortedFiles(files)[0].Path
|
||||||
|
resp := `{"type":"series","title":"Big","files":[{"i":1` +
|
||||||
|
`,"role":"episode","season":1,"episode":1}]}`
|
||||||
f := &fakeLLM{responses: []string{resp}}
|
f := &fakeLLM{responses: []string{resp}}
|
||||||
r := New(f, nil, Config{MaxFiles: 100}, testLogger())
|
r := New(f, nil, Config{MaxFiles: 100}, testLogger())
|
||||||
if _, err := r.Recognize(context.Background(), in); err != nil {
|
res, err := r.Recognize(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Recognize: %v", err)
|
t.Fatalf("Recognize: %v", err)
|
||||||
}
|
}
|
||||||
user := f.lastReq.Messages[1].Content
|
user := f.lastReq.Messages[1].Content
|
||||||
if !strings.Contains(user, "усечён") {
|
if !strings.Contains(user, "усечён") || !strings.Contains(user, "и ещё 150") {
|
||||||
t.Errorf("expected truncation note in prompt")
|
t.Errorf("expected truncation note in prompt:\n%s", user)
|
||||||
}
|
}
|
||||||
if !strings.Contains(user, "Файлы (250") {
|
if !strings.Contains(user, "Файлы раздачи (100)") {
|
||||||
t.Errorf("expected total count 250 in prompt")
|
t.Errorf("expected shown count 100 in prompt")
|
||||||
|
}
|
||||||
|
// Усечение — отдельная причина ухода в review, а знаменатель покрытия —
|
||||||
|
// полное число файлов раздачи, а не усечённого списка.
|
||||||
|
if !hasReason(res.Decision.Reasons, "усечён пределом") ||
|
||||||
|
!hasReason(res.Decision.Reasons, "показано 100 из 250") {
|
||||||
|
t.Errorf("reasons = %v", res.Decision.Reasons)
|
||||||
|
}
|
||||||
|
if !hasReason(res.Decision.Reasons, "в план попало 1 файлов из 250") {
|
||||||
|
t.Errorf("coverage denominator must be the full file count: %v", res.Decision.Reasons)
|
||||||
|
}
|
||||||
|
if res.Plan.Files[0].Src != first {
|
||||||
|
t.Errorf("src = %q, want %q", res.Plan.Files[0].Src, first)
|
||||||
|
}
|
||||||
|
// Снимок хранит ПОЛНЫЙ список раздачи: усечение — свойство промпта, а не
|
||||||
|
// перечня, иначе виджет раскладки выдаст показанный модели срез за всю
|
||||||
|
// раздачу. Показанный список — префикс полного, номера адресации те же.
|
||||||
|
if len(res.Files) != 250 {
|
||||||
|
t.Errorf("snapshot = %d files, want полный список из 250", len(res.Files))
|
||||||
|
}
|
||||||
|
if res.Files[0].Path != first {
|
||||||
|
t.Errorf("снимок обязан идти в порядке нумерации промпта: %q", res.Files[0].Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathOf(i int) string {
|
func pathOf(i int) string {
|
||||||
return "show/ep" + itoa(i) + ".mkv"
|
return "show/ep" + itoa(i) + ".mkv"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Список урезан пределом И пригодного плана модель не дала: причина усечения
|
||||||
|
// обязана быть названа наравне с причиной неразобранного ответа, а поэлементные
|
||||||
|
// претензии последней попытки — не потеряться. Без них человек читает голое
|
||||||
|
// «ответ LLM не разобран» и не узнаёт ни про усечение, ни про то, чем именно
|
||||||
|
// ответ негоден.
|
||||||
|
func TestRecognize_TruncatedListAndUnusablePlan(t *testing.T) {
|
||||||
|
files := make([]File, 250)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = File{Path: pathOf(i), Size: 100 << 20}
|
||||||
|
}
|
||||||
|
in := Input{Name: "Big.Pack", Files: files}
|
||||||
|
// Единственный элемент адресует файл вне показанного списка — годных не
|
||||||
|
// осталось, план считается неразобранным.
|
||||||
|
resp := `{"type":"series","title":"Big","files":[` +
|
||||||
|
`{"i":900,"role":"episode","season":1,"episode":1}]}`
|
||||||
|
f := &fakeLLM{responses: []string{resp}}
|
||||||
|
r := New(f, nil, Config{MaxRetries: 1, MaxFiles: 100}, testLogger())
|
||||||
|
|
||||||
|
res, err := r.Recognize(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("неразобранный ответ — не ошибка распознавания: %v", err)
|
||||||
|
}
|
||||||
|
if res.Decision.Auto {
|
||||||
|
t.Error("плана нет — авто недопустимо")
|
||||||
|
}
|
||||||
|
if !hasReason(res.Decision.Reasons, "не разобран") {
|
||||||
|
t.Errorf("reasons = %v", res.Decision.Reasons)
|
||||||
|
}
|
||||||
|
if !hasReason(res.Decision.Reasons, "усечён пределом") ||
|
||||||
|
!hasReason(res.Decision.Reasons, "показано 100 из 250") {
|
||||||
|
t.Errorf("усечение обязано быть названо и на этом исходе: %v", res.Decision.Reasons)
|
||||||
|
}
|
||||||
|
if !hasReason(res.Decision.Reasons, "вне диапазона 1..100") {
|
||||||
|
t.Errorf("претензии последней попытки потеряны: %v", res.Decision.Reasons)
|
||||||
|
}
|
||||||
|
if len(res.Files) != 250 {
|
||||||
|
t.Errorf("снимок = %d файлов, ожидался полный список раздачи", len(res.Files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ по размеру запроса на усечённом списке тоже называет усечение.
|
||||||
|
func TestRecognize_TruncatedListAndRequestTooLarge(t *testing.T) {
|
||||||
|
files := make([]File, 250)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = File{Path: pathOf(i), Size: 100 << 20}
|
||||||
|
}
|
||||||
|
in := Input{Name: "Big.Pack", Files: files}
|
||||||
|
f := &fakeLLM{errs: []error{errRequestTooLarge}}
|
||||||
|
r := New(f, nil, Config{MaxRetries: 2, MaxFiles: 100}, testLogger())
|
||||||
|
|
||||||
|
res, err := r.Recognize(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отказ по размеру — не ошибка распознавания: %v", err)
|
||||||
|
}
|
||||||
|
if !hasReason(res.Decision.Reasons, "по его размеру") ||
|
||||||
|
!hasReason(res.Decision.Reasons, "усечён пределом") {
|
||||||
|
t.Errorf("reasons = %v", res.Decision.Reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дефолты распознавания живут в двух местах: канонические — в [recognition]
|
||||||
|
// (config.Default), предохранители прямых вызовов конструктора — здесь.
|
||||||
|
// Согласие держалось комментарием; теперь его держит этот страж.
|
||||||
|
func TestDefaultsAgreeWithConfig(t *testing.T) {
|
||||||
|
rec := config.Default().Recognition
|
||||||
|
if rec.MaxFiles != defaultMaxFiles {
|
||||||
|
t.Errorf("[recognition].max_files = %d, recognize.defaultMaxFiles = %d",
|
||||||
|
rec.MaxFiles, defaultMaxFiles)
|
||||||
|
}
|
||||||
|
if rec.MaxTokens != defaultMaxTokens {
|
||||||
|
t.Errorf("[recognition].max_tokens = %d, recognize.defaultMaxTokens = %d",
|
||||||
|
rec.MaxTokens, defaultMaxTokens)
|
||||||
|
}
|
||||||
|
if rec.AutoConfidenceThreshold != defaultAutoThreshold {
|
||||||
|
t.Errorf("[recognition].auto_confidence_threshold = %v, recognize.defaultAutoThreshold = %v",
|
||||||
|
rec.AutoConfidenceThreshold, defaultAutoThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+349
-55
@@ -5,22 +5,25 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
|
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
|
||||||
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
|
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Второй
|
||||||
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
|
// результат — претензии к отдельным элементам files[]: они НЕ ошибка разбора
|
||||||
|
// (повторный запрос к модели не делается), а причины ухода в review.
|
||||||
|
// Структурные предупреждения (см. decide) ошибкой тоже не считаются.
|
||||||
//
|
//
|
||||||
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
|
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
|
||||||
// sanitizePlan) ДО структурной валидации: так, например, title из одних
|
// sanitizePlan) ДО структурной валидации: так, например, title из одних
|
||||||
// zero-width символов схлопывается в пустой и корректно уводит в ретрай.
|
// zero-width символов схлопывается в пустой и корректно уводит в ретрай.
|
||||||
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
|
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, []string, error) {
|
||||||
jsonStr, err := llm.ExtractJSONObject(raw)
|
jsonStr, err := llm.ExtractJSONObject(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Plan{}, fmt.Errorf("no JSON object in response")
|
return Plan{}, nil, fmt.Errorf("no JSON object in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
var p Plan
|
var p Plan
|
||||||
@@ -30,57 +33,212 @@ func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
|
|||||||
// Повторяем без строгого режима: лишние поля — не повод падать,
|
// Повторяем без строгого режима: лишние поля — не повод падать,
|
||||||
// но если и так не разобралось — это ошибка схемы.
|
// но если и так не разобралось — это ошибка схемы.
|
||||||
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
|
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
|
||||||
return Plan{}, fmt.Errorf("JSON not parsed: %w", err2)
|
return Plan{}, nil, fmt.Errorf("JSON not parsed: %w", err2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitizePlan(&p, log)
|
sanitizePlan(&p, log)
|
||||||
|
|
||||||
if err := validateSchema(&p, in); err != nil {
|
problems, err := validateSchema(&p, in)
|
||||||
return Plan{}, err
|
if err != nil {
|
||||||
|
return Plan{}, problems, err
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, problems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSchema проверяет обязательную структуру плана. Главный инвариант
|
// validateSchema проверяет обязательную структуру плана и резолвит адресацию
|
||||||
// безопасности: каждый files[].src совпадает с реальным файлом торрента —
|
// файлов. Главный инвариант безопасности: files[].src заполняем МЫ — из своего
|
||||||
// недоверенный выход LLM не может сослаться на посторонний путь.
|
// же списка, по номеру строки (i), напечатанному в промпте. Присланный моделью
|
||||||
func validateSchema(p *Plan, in Input) error {
|
// путь принимается лишь как запасной формат и только при точном совпадении с
|
||||||
|
// файлом торрента, поэтому сослаться на посторонний путь невозможно.
|
||||||
|
//
|
||||||
|
// Негодный элемент (номер вне диапазона, повторная адресация, ни номера ни
|
||||||
|
// пути, неизвестный путь) отбрасывается поимённой претензией — план при этом
|
||||||
|
// живёт: одна ошибка в одном из сотен элементов не должна ронять всю работу.
|
||||||
|
// Ошибка возвращается только когда плана не осталось вовсе.
|
||||||
|
func validateSchema(p *Plan, in Input) ([]string, error) {
|
||||||
switch p.Type {
|
switch p.Type {
|
||||||
case MediaMovie, MediaSeries:
|
case MediaMovie, MediaSeries:
|
||||||
case "":
|
case "":
|
||||||
return fmt.Errorf("field type is empty (expected movie or series)")
|
return nil, fmt.Errorf("field type is empty (expected movie or series)")
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown type %q", p.Type)
|
return nil, fmt.Errorf("unknown type %q", p.Type)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(p.Title) == "" {
|
if strings.TrimSpace(p.Title) == "" {
|
||||||
return fmt.Errorf("field title is empty")
|
return nil, fmt.Errorf("field title is empty")
|
||||||
}
|
}
|
||||||
if len(p.Files) == 0 {
|
if len(p.Files) == 0 {
|
||||||
return fmt.Errorf("files list is empty")
|
return nil, fmt.Errorf("files list is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
known := make(map[string]bool, len(in.Files))
|
byPath := make(map[string]int, len(in.Files))
|
||||||
for _, f := range in.Files {
|
for i, f := range in.Files {
|
||||||
known[f.Path] = true
|
byPath[f.Path] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var problems problemList
|
||||||
|
taken := make(map[int]bool, len(p.Files))
|
||||||
|
kept := p.Files[:0]
|
||||||
for i := range p.Files {
|
for i := range p.Files {
|
||||||
pf := &p.Files[i]
|
pf := p.Files[i]
|
||||||
|
// Роль вне перечня — ошибка схемы, а не адресации: модель ошиблась в
|
||||||
|
// нашем же словаре, и это чинится повторной попыткой. Проверка стоит ДО
|
||||||
|
// резолва, поэтому pf.Src ещё пуст: адресуем элемент так, как его назвала
|
||||||
|
// сама модель, иначе ей (и человеку) нечего чинить в списке на 180 строк.
|
||||||
if !pf.Role.valid() {
|
if !pf.Role.valid() {
|
||||||
return fmt.Errorf("file %q: unknown role %q", pf.Src, pf.Role)
|
return problems.result(), fmt.Errorf("file %s: unknown role %q",
|
||||||
|
planFileRef(pf), shorten(string(pf.Role)))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(pf.Src) == "" {
|
idx, problem, ok := resolveFile(pf, in.Files, byPath)
|
||||||
return fmt.Errorf("file with empty src")
|
if problem != "" {
|
||||||
|
problems.add(problem)
|
||||||
}
|
}
|
||||||
if !known[pf.Src] {
|
if !ok {
|
||||||
return fmt.Errorf("src %q not found among torrent files", pf.Src)
|
continue
|
||||||
|
}
|
||||||
|
if taken[idx] {
|
||||||
|
problems.add(fmt.Sprintf(
|
||||||
|
"файл %d (%s) адресован повторно — лишний элемент плана отброшен",
|
||||||
|
idx+1, shorten(in.Files[idx].Path)))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if pf.Role == RoleEpisode && pf.Episode == nil {
|
if pf.Role == RoleEpisode && pf.Episode == nil {
|
||||||
return fmt.Errorf("episode %q has no episode number", pf.Src)
|
return problems.result(), fmt.Errorf("episode %q has no episode number",
|
||||||
|
shorten(in.Files[idx].Path))
|
||||||
|
}
|
||||||
|
taken[idx] = true
|
||||||
|
n := FileIndex(idx + 1)
|
||||||
|
pf.Index = &n
|
||||||
|
pf.Src = in.Files[idx].Path
|
||||||
|
kept = append(kept, pf)
|
||||||
|
}
|
||||||
|
p.Files = kept
|
||||||
|
if len(p.Files) == 0 {
|
||||||
|
return problems.result(), fmt.Errorf("no files[] element addresses a real torrent file")
|
||||||
|
}
|
||||||
|
return problems.result(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxProblems — сколько поимённых претензий к элементам плана попадает в
|
||||||
|
// причины; остальные сворачиваются в счётчик. Причины уезжают в баннер ревью,
|
||||||
|
// в карточку Telegram и в БД навсегда, а на раздаче из 180 файлов негодным
|
||||||
|
// может оказаться каждый элемент.
|
||||||
|
const maxProblems = 12
|
||||||
|
|
||||||
|
// problemList копит претензии к элементам плана, не давая им расти без предела.
|
||||||
|
type problemList struct {
|
||||||
|
items []string
|
||||||
|
total int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *problemList) add(s string) {
|
||||||
|
l.total++
|
||||||
|
if len(l.items) < maxProblems {
|
||||||
|
l.items = append(l.items, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
// result — накопленное плюс свёрнутый хвост.
|
||||||
|
func (l *problemList) result() []string {
|
||||||
|
if l.total <= len(l.items) {
|
||||||
|
return l.items
|
||||||
|
}
|
||||||
|
return append(l.items, fmt.Sprintf("и ещё %d претензий к элементам плана",
|
||||||
|
l.total-len(l.items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// planFileRef — как элемент плана адресован МОДЕЛЬЮ: номером нашего списка,
|
||||||
|
// иначе присланным путём. Нужен в сообщениях, которые строятся до резолва.
|
||||||
|
func planFileRef(pf PlanFile) string {
|
||||||
|
if pf.Index != nil {
|
||||||
|
return fmt.Sprintf("#%d", int(*pf.Index))
|
||||||
|
}
|
||||||
|
if src := strings.TrimSpace(pf.Src); src != "" {
|
||||||
|
return strconv.Quote(shorten(src))
|
||||||
|
}
|
||||||
|
return "без номера и пути"
|
||||||
|
}
|
||||||
|
|
||||||
|
// reasonValueRunes — сколько рун внешнего значения показывать в причине.
|
||||||
|
const reasonValueRunes = 40
|
||||||
|
|
||||||
|
// shorten оставляет от внешнего значения начало и конец, выкидывая середину.
|
||||||
|
// Значения (пути, роли) приходят из недоверенного ответа модели и по длине не
|
||||||
|
// ограничены, а причина оседает в БД навсегда и занимает экран в карточке
|
||||||
|
// Telegram (docs/conventions/errors.md). Режем по рунам: обрыв посреди
|
||||||
|
// многобайтовой буквы дал бы мусор; середину — потому что у пути информативны
|
||||||
|
// оба края.
|
||||||
|
func shorten(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= reasonValueRunes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
head := reasonValueRunes / 2
|
||||||
|
return string(r[:head]) + "…" + string(r[len(r)-head:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFile определяет, какой файл раздачи адресует элемент плана. Главенствует
|
||||||
|
// номер: он назван нами, и расхождение с присланным путём — сигнал сдвига
|
||||||
|
// адресации, а не повод молча затереть путь. Возвращает индекс в списке, текст
|
||||||
|
// претензии (пусто — претензий нет) и годность элемента.
|
||||||
|
func resolveFile(pf PlanFile, files []File, byPath map[string]int) (int, string, bool) {
|
||||||
|
src := strings.TrimSpace(pf.Src)
|
||||||
|
if pf.Index != nil && int(*pf.Index) != 0 {
|
||||||
|
n := int(*pf.Index)
|
||||||
|
if n < 1 || n > len(files) {
|
||||||
|
return 0, fmt.Sprintf(
|
||||||
|
"элемент плана адресует файл номером %d вне диапазона 1..%d — отброшен",
|
||||||
|
n, len(files)), false
|
||||||
|
}
|
||||||
|
idx := n - 1
|
||||||
|
if src != "" && src != files[idx].Path {
|
||||||
|
// Модель назвала обе стороны и они разошлись — бесплатная сверка
|
||||||
|
// против сдвига нумерации. Берём номер, но сообщаем человеку.
|
||||||
|
return idx, fmt.Sprintf(
|
||||||
|
"элемент плана: номер %d указывает на %q, а присланный путь — %q;"+
|
||||||
|
" принят файл по номеру", n, shorten(files[idx].Path), shorten(src)), true
|
||||||
|
}
|
||||||
|
return idx, "", true
|
||||||
|
}
|
||||||
|
if src != "" {
|
||||||
|
// Запасной формат: модель прислала путь вместо номера. Принимаем только
|
||||||
|
// точное совпадение с реальным файлом раздачи.
|
||||||
|
idx, found := byPath[src]
|
||||||
|
if !found {
|
||||||
|
return 0, fmt.Sprintf("путь %q не найден среди файлов раздачи — элемент плана отброшен",
|
||||||
|
shorten(src)), false
|
||||||
|
}
|
||||||
|
return idx, "", true
|
||||||
|
}
|
||||||
|
if pf.Index != nil {
|
||||||
|
// Явный ноль. Модель посчитала список с нуля: этот элемент не адресует
|
||||||
|
// ничего, а все её остальные номера резолвятся со сдвигом на файл.
|
||||||
|
// Называем причину так, чтобы сдвиг был самоописывающимся.
|
||||||
|
return 0, "элемент плана адресует файл номером 0 — список нумеруется с 1," +
|
||||||
|
" элемент отброшен (остальные номера могли уехать на файл)", false
|
||||||
|
}
|
||||||
|
return 0, "элемент плана без номера файла и без пути — отброшен", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// planIssues — что вскрылось до модели уверенности: претензии разбора,
|
||||||
|
// усечение списка файлов пределом и сам список (для сводки покрытия).
|
||||||
|
type planIssues struct {
|
||||||
|
dropped []string // претензии к элементам files[] (см. validateSchema)
|
||||||
|
files []File // список, показанный модели (префикс all, в порядке нумерации)
|
||||||
|
// all — ПОЛНЫЙ упорядоченный список файлов раздачи: знаменатель покрытия и
|
||||||
|
// снимок, уходящий в Result. Усечение пределом max_files — свойство промпта
|
||||||
|
// и резолва, а не снимка (см. Recognize).
|
||||||
|
all []File
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncated — сколько файлов раздачи не показано модели (0 — список полный).
|
||||||
|
func (is planIssues) truncated() int { return len(is.all) - len(is.files) }
|
||||||
|
|
||||||
|
// truncationReason — усечение списка файлов пределом max_files.
|
||||||
|
func truncationReason(is planIssues) string {
|
||||||
|
return fmt.Sprintf("список файлов усечён пределом [recognition].max_files:"+
|
||||||
|
" модели показано %d из %d — остальные распознаны быть не могли",
|
||||||
|
len(is.files), len(is.all))
|
||||||
}
|
}
|
||||||
|
|
||||||
// decide считает решение модели уверенности (см. recognition.md). Авто —
|
// decide считает решение модели уверенности (см. recognition.md). Авто —
|
||||||
@@ -88,14 +246,38 @@ func validateSchema(p *Plan, in Input) error {
|
|||||||
// название матча пригодно как имя каталога; чистая структурная валидация (для
|
// название матча пригодно как имя каталога; чистая структурная валидация (для
|
||||||
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
|
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
|
||||||
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
|
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
|
||||||
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
|
//
|
||||||
|
// Auto считается по числу БЛОКИРУЮЩИХ причин, а не по длине Reasons: сводка
|
||||||
|
// покрытия плана показывается человеку всегда, когда покрыты не все файлы, но
|
||||||
|
// сама по себе авто-раскладку не отменяет — модель вправе не перечислять
|
||||||
|
// .nfo и скриншоты. Блокирует только непокрытый видеофайл.
|
||||||
|
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64, issues planIssues) Decision {
|
||||||
var reasons []string
|
var reasons []string
|
||||||
|
blocking := 0
|
||||||
|
// add — причина, отменяющая авто; note — то же для глаз человека, без влияния
|
||||||
|
// на решение.
|
||||||
|
add := func(s string) { reasons = append(reasons, s); blocking++ }
|
||||||
|
note := func(s string) { reasons = append(reasons, s) }
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case !metadataEnabled:
|
case !metadataEnabled:
|
||||||
reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна")
|
add("метабазы отключены → авто-раскладка недоступна")
|
||||||
case match == nil:
|
case match == nil:
|
||||||
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
|
add("не найдено в базе или несколько кандидатов")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range issues.dropped {
|
||||||
|
add(d)
|
||||||
|
}
|
||||||
|
if issues.truncated() > 0 {
|
||||||
|
add(truncationReason(issues))
|
||||||
|
}
|
||||||
|
for _, c := range coverageReasons(p, issues) {
|
||||||
|
if c.blocks {
|
||||||
|
add(c.text)
|
||||||
|
} else {
|
||||||
|
note(c.text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Каноническое название базы, непригодное как имя каталога (пустое или без
|
// Каноническое название базы, непригодное как имя каталога (пустое или без
|
||||||
@@ -105,23 +287,77 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold
|
|||||||
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
|
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
|
||||||
// условия разъедутся на первой же правке одного из них.
|
// условия разъедутся на первой же правке одного из них.
|
||||||
if match != nil && !UsableTitle(match.Title) {
|
if match != nil && !UsableTitle(match.Title) {
|
||||||
reasons = append(reasons, "название из базы непригодно как имя каталога")
|
add("название из базы непригодно как имя каталога")
|
||||||
}
|
}
|
||||||
|
|
||||||
reasons = append(reasons, structuralWarnings(p)...)
|
for _, w := range structuralWarnings(p) {
|
||||||
|
add(w)
|
||||||
|
}
|
||||||
|
|
||||||
if match != nil && p.Type == MediaSeries {
|
if match != nil && p.Type == MediaSeries {
|
||||||
reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...)
|
for _, w := range episodeCountWarnings(p, match.SeasonEpisodeCounts) {
|
||||||
|
add(w)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reasons = append(reasons, consistencyWarnings(p, pre)...)
|
for _, w := range consistencyWarnings(p, pre) {
|
||||||
|
add(w)
|
||||||
|
}
|
||||||
|
|
||||||
if p.Confidence < threshold {
|
if p.Confidence < threshold {
|
||||||
reasons = append(reasons,
|
add(fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
|
||||||
fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Decision{Auto: len(reasons) == 0, Reasons: reasons}
|
return Decision{Auto: blocking == 0, Reasons: reasons}
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverageEntry — строка сводки покрытия и её вес в решении auto/review.
|
||||||
|
type coverageEntry struct {
|
||||||
|
text string
|
||||||
|
blocks bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverageReasons — сводка «в плане N из M» и отдельная причина о непокрытых
|
||||||
|
// видеофайлах. Знаменатель — ПОЛНОЕ число файлов раздачи, а не усечённого
|
||||||
|
// списка: иначе усечение пределом молча улучшало бы покрытие.
|
||||||
|
func coverageReasons(p Plan, issues planIssues) []coverageEntry {
|
||||||
|
if len(issues.all) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
planned := make(map[string]bool, len(p.Files))
|
||||||
|
for _, f := range p.Files {
|
||||||
|
planned[f.Src] = true
|
||||||
|
}
|
||||||
|
var videos []string
|
||||||
|
for _, f := range issues.files {
|
||||||
|
if !planned[f.Path] && IsVideoFile(f.Path) {
|
||||||
|
videos = append(videos, f.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []coverageEntry
|
||||||
|
if len(planned) < len(issues.all) {
|
||||||
|
out = append(out, coverageEntry{text: fmt.Sprintf(
|
||||||
|
"в план попало %d файлов из %d", len(planned), len(issues.all))})
|
||||||
|
}
|
||||||
|
if len(videos) > 0 {
|
||||||
|
out = append(out, coverageEntry{blocks: true, text: fmt.Sprintf(
|
||||||
|
"вне плана осталось видеофайлов: %d (%s)", len(videos), listSome(videos, 3))})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSome перечисляет не больше max элементов, сворачивая хвост в «и ещё N».
|
||||||
|
// Каждое значение внешнее (путь из ответа модели) и потому усекается.
|
||||||
|
func listSome(items []string, max int) string {
|
||||||
|
out := make([]string, 0, min(len(items), max))
|
||||||
|
for _, it := range items[:min(len(items), max)] {
|
||||||
|
out = append(out, shorten(it))
|
||||||
|
}
|
||||||
|
s := strings.Join(out, ", ")
|
||||||
|
if len(items) > max {
|
||||||
|
s += ", и ещё " + strconv.Itoa(len(items)-max)
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// episodeCountWarnings сверяет число распознанных серий по сезонам с базой.
|
// episodeCountWarnings сверяет число распознанных серий по сезонам с базой.
|
||||||
@@ -181,13 +417,12 @@ func structuralWarnings(p Plan) []string {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам.
|
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. На
|
||||||
|
// сезон приходится не больше одной причины каждого рода: на паке из восьми
|
||||||
|
// сезонов строка за каждый разрыв давала сорок строк, которые человек в ревью
|
||||||
|
// уже не читает.
|
||||||
func seriesWarnings(files []PlanFile) []string {
|
func seriesWarnings(files []PlanFile) []string {
|
||||||
type key struct{ s, e int }
|
seen := map[int]map[int]int{}
|
||||||
seen := map[key]int{}
|
|
||||||
bySeason := map[int][]int{}
|
|
||||||
var w []string
|
|
||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
if f.Role != RoleEpisode || f.Episode == nil {
|
if f.Role != RoleEpisode || f.Episode == nil {
|
||||||
continue
|
continue
|
||||||
@@ -196,27 +431,86 @@ func seriesWarnings(files []PlanFile) []string {
|
|||||||
if f.Season != nil {
|
if f.Season != nil {
|
||||||
season = *f.Season
|
season = *f.Season
|
||||||
}
|
}
|
||||||
k := key{season, *f.Episode}
|
if seen[season] == nil {
|
||||||
seen[k]++
|
seen[season] = map[int]int{}
|
||||||
if seen[k] == 2 {
|
|
||||||
w = append(w, fmt.Sprintf("сериал: дубль серии S%02dE%02d", season, *f.Episode))
|
|
||||||
}
|
}
|
||||||
bySeason[season] = append(bySeason[season], *f.Episode)
|
seen[season][*f.Episode]++
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, season := range sortedKeys(bySeason) {
|
var w []string
|
||||||
eps := bySeason[season]
|
for _, season := range sortedKeys(toSlices2(seen)) {
|
||||||
sort.Ints(eps)
|
eps := seen[season]
|
||||||
for i := 1; i < len(eps); i++ {
|
nums := make([]int, 0, len(eps))
|
||||||
if eps[i] > eps[i-1]+1 {
|
for e := range eps {
|
||||||
w = append(w, fmt.Sprintf("сериал: пропуск серий в сезоне %d между E%02d и E%02d",
|
nums = append(nums, e)
|
||||||
season, eps[i-1], eps[i]))
|
|
||||||
}
|
}
|
||||||
|
sort.Ints(nums)
|
||||||
|
|
||||||
|
var dups []int
|
||||||
|
for _, e := range nums {
|
||||||
|
if eps[e] > 1 {
|
||||||
|
dups = append(dups, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(dups) > 0 {
|
||||||
|
w = append(w, fmt.Sprintf("сериал: сезон %d — дубли серий %s",
|
||||||
|
season, episodeList(dups, len(dups))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пропуски НЕ материализуем: номера серий приходят из недоверенного
|
||||||
|
// ответа модели и не клампятся, а датовая нумерация (обычная для
|
||||||
|
// ежедневных шоу: "episode": 20260902) рядом с единицей дала бы ~20 млн
|
||||||
|
// int на одном распознавании. Копим только то, что будет напечатано,
|
||||||
|
// остальное — счётчиком.
|
||||||
|
var missing []int
|
||||||
|
missingTotal := 0
|
||||||
|
for i := 1; i < len(nums); i++ {
|
||||||
|
gap := nums[i] - nums[i-1] - 1
|
||||||
|
if gap <= 0 {
|
||||||
|
continue // соседние номера (или переполнение на абсурдных значениях)
|
||||||
|
}
|
||||||
|
missingTotal += gap
|
||||||
|
for e := nums[i-1] + 1; e < nums[i] && len(missing) < episodeListMax; e++ {
|
||||||
|
missing = append(missing, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if missingTotal > 0 {
|
||||||
|
w = append(w, fmt.Sprintf("сериал: сезон %d — не хватает серий %s",
|
||||||
|
season, episodeList(missing, missingTotal)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// episodeListMax — сколько номеров серий печатается в причине; хвост
|
||||||
|
// сворачивается в «и ещё N». Предел печати задаёт и предел накопления у
|
||||||
|
// вызывающего (см. seriesWarnings).
|
||||||
|
const episodeListMax = 12
|
||||||
|
|
||||||
|
// episodeList печатает номера серий как E05, E07, … сворачивая длинный хвост.
|
||||||
|
// total — сколько номеров всего: он может превышать len(eps), потому что
|
||||||
|
// вызывающий вправе накопить лишь то, что будет напечатано.
|
||||||
|
func episodeList(eps []int, total int) string {
|
||||||
|
out := make([]string, 0, min(len(eps), episodeListMax))
|
||||||
|
for _, e := range eps[:min(len(eps), episodeListMax)] {
|
||||||
|
out = append(out, fmt.Sprintf("E%02d", e))
|
||||||
|
}
|
||||||
|
s := strings.Join(out, ", ")
|
||||||
|
if total > len(out) {
|
||||||
|
s += ", и ещё " + strconv.Itoa(total-len(out))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSlices2 — ключи map[int]map[int]int для sortedKeys.
|
||||||
|
func toSlices2(m map[int]map[int]int) map[int][]int {
|
||||||
|
out := make(map[int][]int, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out[k] = nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// consistencyWarnings — расхождения LLM с черновым пред-парсом.
|
// consistencyWarnings — расхождения LLM с черновым пред-парсом.
|
||||||
func consistencyWarnings(p Plan, pre PreParse) []string {
|
func consistencyWarnings(p Plan, pre PreParse) []string {
|
||||||
var w []string
|
var w []string
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package recognize
|
package recognize
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func intp(n int) *int { return &n }
|
func intp(n int) *int { return &n }
|
||||||
|
|
||||||
|
func idxp(n int) *FileIndex { i := FileIndex(n); return &i }
|
||||||
|
|
||||||
func inputWith(paths ...string) Input {
|
func inputWith(paths ...string) Input {
|
||||||
files := make([]File, len(paths))
|
files := make([]File, len(paths))
|
||||||
for i, p := range paths {
|
for i, p := range paths {
|
||||||
@@ -25,9 +28,17 @@ func TestValidateSchema_OK(t *testing.T) {
|
|||||||
{Src: "b.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
|
{Src: "b.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := validateSchema(&p, in); err != nil {
|
problems, err := validateSchema(&p, in)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("validateSchema: %v", err)
|
t.Fatalf("validateSchema: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(problems) != 0 {
|
||||||
|
t.Errorf("чистый план не должен давать претензий: %v", problems)
|
||||||
|
}
|
||||||
|
// Запасной формат (src без номера) резолвится в свой же номер.
|
||||||
|
if idxOf(p.Files[0]) != 1 || idxOf(p.Files[1]) != 2 {
|
||||||
|
t.Errorf("индексы не проставлены: %+v", p.Files)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateSchema_Errors(t *testing.T) {
|
func TestValidateSchema_Errors(t *testing.T) {
|
||||||
@@ -42,13 +53,13 @@ func TestValidateSchema_Errors(t *testing.T) {
|
|||||||
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"},
|
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"},
|
||||||
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files list is empty"},
|
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files list is empty"},
|
||||||
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "unknown role"},
|
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "unknown role"},
|
||||||
{"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "empty src"},
|
{"no addressing at all", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "no files[] element addresses"},
|
||||||
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "not found among torrent files"},
|
{"unknown src only", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "no files[] element addresses"},
|
||||||
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "has no episode number"},
|
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "has no episode number"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := validateSchema(&tt.p, in)
|
_, err := validateSchema(&tt.p, in)
|
||||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||||
t.Errorf("err = %v, want contains %q", err, tt.want)
|
t.Errorf("err = %v, want contains %q", err, tt.want)
|
||||||
}
|
}
|
||||||
@@ -60,7 +71,7 @@ func TestParsePlan_FencedJSON(t *testing.T) {
|
|||||||
in := inputWith("film.mkv")
|
in := inputWith("film.mkv")
|
||||||
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
|
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
|
||||||
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
|
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
|
||||||
p, err := parsePlan(raw, in, testLogger())
|
p, _, err := parsePlan(raw, in, testLogger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parsePlan: %v", err)
|
t.Fatalf("parsePlan: %v", err)
|
||||||
}
|
}
|
||||||
@@ -73,7 +84,7 @@ func TestParsePlan_UnknownFieldTolerated(t *testing.T) {
|
|||||||
in := inputWith("film.mkv")
|
in := inputWith("film.mkv")
|
||||||
raw := `{"type":"movie","title":"Film","extra_field":123,
|
raw := `{"type":"movie","title":"Film","extra_field":123,
|
||||||
"files":[{"src":"film.mkv","role":"main"}]}`
|
"files":[{"src":"film.mkv","role":"main"}]}`
|
||||||
if _, err := parsePlan(raw, in, testLogger()); err != nil {
|
if _, _, err := parsePlan(raw, in, testLogger()); err != nil {
|
||||||
t.Fatalf("unknown field should be tolerated: %v", err)
|
t.Fatalf("unknown field should be tolerated: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,10 +117,10 @@ func TestSeriesWarnings_GapAndDup(t *testing.T) {
|
|||||||
w := seriesWarnings(files)
|
w := seriesWarnings(files)
|
||||||
var dup, gap bool
|
var dup, gap bool
|
||||||
for _, s := range w {
|
for _, s := range w {
|
||||||
if strings.Contains(s, "дубль") {
|
if strings.Contains(s, "дубли серий") {
|
||||||
dup = true
|
dup = true
|
||||||
}
|
}
|
||||||
if strings.Contains(s, "пропуск") {
|
if strings.Contains(s, "не хватает серий") {
|
||||||
gap = true
|
gap = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +168,7 @@ func TestConsistencyWarnings(t *testing.T) {
|
|||||||
|
|
||||||
func TestDecide_MetadataDisabled(t *testing.T) {
|
func TestDecide_MetadataDisabled(t *testing.T) {
|
||||||
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
||||||
d := decide(p, PreParse{}, nil, false, 0.85)
|
d := decide(p, PreParse{}, nil, false, 0.85, planIssues{})
|
||||||
if d.Auto {
|
if d.Auto {
|
||||||
t.Error("без метабаз авто недопустимо")
|
t.Error("без метабаз авто недопустимо")
|
||||||
}
|
}
|
||||||
@@ -168,7 +179,7 @@ func TestDecide_MetadataDisabled(t *testing.T) {
|
|||||||
|
|
||||||
func TestDecide_NoMatch(t *testing.T) {
|
func TestDecide_NoMatch(t *testing.T) {
|
||||||
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
||||||
d := decide(p, PreParse{}, nil, true, 0.85)
|
d := decide(p, PreParse{}, nil, true, 0.85, planIssues{})
|
||||||
if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") {
|
if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") {
|
||||||
t.Errorf("reasons = %v", d.Reasons)
|
t.Errorf("reasons = %v", d.Reasons)
|
||||||
}
|
}
|
||||||
@@ -178,7 +189,7 @@ func TestDecide_AutoMovie(t *testing.T) {
|
|||||||
p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95,
|
p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95,
|
||||||
Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}}
|
Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}}
|
||||||
match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999}
|
match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999}
|
||||||
d := decide(p, PreParse{Year: 1999}, match, true, 0.85)
|
d := decide(p, PreParse{Year: 1999}, match, true, 0.85, planIssues{})
|
||||||
if !d.Auto {
|
if !d.Auto {
|
||||||
t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons)
|
t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons)
|
||||||
}
|
}
|
||||||
@@ -188,7 +199,7 @@ func TestDecide_LowConfidenceBlocksAuto(t *testing.T) {
|
|||||||
p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5,
|
p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5,
|
||||||
Files: []PlanFile{{Role: RoleMain}}}
|
Files: []PlanFile{{Role: RoleMain}}}
|
||||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000}
|
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000}
|
||||||
d := decide(p, PreParse{}, match, true, 0.85)
|
d := decide(p, PreParse{}, match, true, 0.85, planIssues{})
|
||||||
if d.Auto || !hasReason(d.Reasons, "уверенность") {
|
if d.Auto || !hasReason(d.Reasons, "уверенность") {
|
||||||
t.Errorf("low confidence must block auto, reasons: %v", d.Reasons)
|
t.Errorf("low confidence must block auto, reasons: %v", d.Reasons)
|
||||||
}
|
}
|
||||||
@@ -201,20 +212,20 @@ func TestDecide_AutoSeriesEpisodeCount(t *testing.T) {
|
|||||||
Files: []PlanFile{mk(1), mk(2), mk(3)}}
|
Files: []PlanFile{mk(1), mk(2), mk(3)}}
|
||||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014,
|
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014,
|
||||||
SeasonEpisodeCounts: map[int]int{2: 3}}
|
SeasonEpisodeCounts: map[int]int{2: 3}}
|
||||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); !d.Auto {
|
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); !d.Auto {
|
||||||
t.Errorf("full season must be auto, reasons: %v", d.Reasons)
|
t.Errorf("full season must be auto, reasons: %v", d.Reasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Неполный пак (в базе 10) — авто блокируется.
|
// Неполный пак (в базе 10) — авто блокируется.
|
||||||
match.SeasonEpisodeCounts = map[int]int{2: 10}
|
match.SeasonEpisodeCounts = map[int]int{2: 10}
|
||||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
|
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto ||
|
||||||
!hasReason(d.Reasons, "распознано серий 3, в базе 10") {
|
!hasReason(d.Reasons, "распознано серий 3, в базе 10") {
|
||||||
t.Errorf("partial pack must block auto, reasons: %v", d.Reasons)
|
t.Errorf("partial pack must block auto, reasons: %v", d.Reasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Нет данных о числе серий — авто блокируется.
|
// Нет данных о числе серий — авто блокируется.
|
||||||
match.SeasonEpisodeCounts = nil
|
match.SeasonEpisodeCounts = nil
|
||||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
|
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto ||
|
||||||
!hasReason(d.Reasons, "нет данных о числе серий") {
|
!hasReason(d.Reasons, "нет данных о числе серий") {
|
||||||
t.Errorf("missing counts must block auto, reasons: %v", d.Reasons)
|
t.Errorf("missing counts must block auto, reasons: %v", d.Reasons)
|
||||||
}
|
}
|
||||||
@@ -234,3 +245,256 @@ func TestPreParse(t *testing.T) {
|
|||||||
t.Errorf("season/episode = %d/%d, want 2/5", series.Season, series.Episode)
|
t.Errorf("season/episode = %d/%d, want 2/5", series.Season, series.Episode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Перечень видеорасширений — единственное место, где решается, что считать
|
||||||
|
// потерянной серией: незнакомое расширение уедет в спутники и авто не заблокирует.
|
||||||
|
func TestIsVideoFile(t *testing.T) {
|
||||||
|
video := []string{
|
||||||
|
"a.mkv", "a.MKV", "s1/e01.avi", "film.mp4", "x.m4v", "x.mov", "x.wmv",
|
||||||
|
"x.mpg", "x.mpeg", "BDMV/STREAM/00001.m2ts", "x.mts", "x.ts", "x.vob",
|
||||||
|
"x.flv", "x.webm", "x.ogm", "x.divx", "x.rmvb", "x.3gp", "disc.iso", "disc.img",
|
||||||
|
}
|
||||||
|
for _, p := range video {
|
||||||
|
if !IsVideoFile(p) {
|
||||||
|
t.Errorf("IsVideoFile(%q) = false, want true", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other := []string{
|
||||||
|
"a.srt", "a.ass", "a.sub", "a.idx", "a.nfo", "a.txt", "a.jpg", "a.png",
|
||||||
|
"a.md5", "a.sfv", "cover", "a.mkv.txt", "",
|
||||||
|
}
|
||||||
|
for _, p := range other {
|
||||||
|
if IsVideoFile(p) {
|
||||||
|
t.Errorf("IsVideoFile(%q) = true, want false", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сезон с тремя дырами даёт ровно одну причину, перечисляющую недостающие серии.
|
||||||
|
func TestSeriesWarnings_GapsCollapsedPerSeason(t *testing.T) {
|
||||||
|
var files []PlanFile
|
||||||
|
for _, e := range []int{1, 2, 3, 4, 6, 8, 9, 10, 12} { // нет 5, 7, 11
|
||||||
|
files = append(files, PlanFile{Role: RoleEpisode, Season: intp(1), Episode: intp(e)})
|
||||||
|
}
|
||||||
|
w := seriesWarnings(files)
|
||||||
|
if len(w) != 1 {
|
||||||
|
t.Fatalf("ожидалась одна причина на сезон, получено %d: %v", len(w), w)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w[0], "E05") || !strings.Contains(w[0], "E07") || !strings.Contains(w[0], "E11") {
|
||||||
|
t.Errorf("причина не перечисляет недостающие серии: %q", w[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мультисезонный пак: на сезон — не больше одной причины о пропусках.
|
||||||
|
func TestSeriesWarnings_OneReasonPerSeason(t *testing.T) {
|
||||||
|
var files []PlanFile
|
||||||
|
for s := 1; s <= 8; s++ {
|
||||||
|
for _, e := range []int{1, 3, 5} { // дыры в каждом сезоне
|
||||||
|
files = append(files, PlanFile{Role: RoleEpisode, Season: intp(s), Episode: intp(e)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := seriesWarnings(files); len(w) != 8 {
|
||||||
|
t.Errorf("причин %d при 8 сезонах, ожидалось 8: %v", len(w), w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Непокрытый видеофайл означает потерянную серию — авто блокируется.
|
||||||
|
func TestDecide_UncoveredVideoBlocksAuto(t *testing.T) {
|
||||||
|
in := inputWith("s1/e01.mkv", "s1/e02.mkv")
|
||||||
|
p := Plan{Type: MediaSeries, Title: "Show", Year: 2020, Confidence: 0.95,
|
||||||
|
Files: []PlanFile{{Src: "s1/e01.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(1)}}}
|
||||||
|
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Show", Year: 2020,
|
||||||
|
SeasonEpisodeCounts: map[int]int{1: 1}}
|
||||||
|
|
||||||
|
d := decide(p, PreParse{}, match, true, 0.85,
|
||||||
|
planIssues{files: in.Files, all: in.Files})
|
||||||
|
if d.Auto {
|
||||||
|
t.Errorf("непокрытый видеофайл обязан блокировать авто: %v", d.Reasons)
|
||||||
|
}
|
||||||
|
if !hasReason(d.Reasons, "вне плана осталось видеофайлов: 1") {
|
||||||
|
t.Errorf("reasons = %v", d.Reasons)
|
||||||
|
}
|
||||||
|
if !hasReason(d.Reasons, "в план попало 1 файлов из 2") {
|
||||||
|
t.Errorf("сводка покрытия обязана быть названа: %v", d.Reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Непокрытые спутники (.nfo, скриншоты, тексты) видны в покрытии, но авто не
|
||||||
|
// отменяют: модель вправе не перечислять то, что не раскладывается.
|
||||||
|
func TestDecide_UncoveredCompanionsKeepAuto(t *testing.T) {
|
||||||
|
in := inputWith("film.mkv", "film.nfo", "screens/01.jpg", "readme.txt")
|
||||||
|
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
|
||||||
|
Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}}
|
||||||
|
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
|
||||||
|
|
||||||
|
d := decide(p, PreParse{Year: 1999}, match, true, 0.85,
|
||||||
|
planIssues{files: in.Files, all: in.Files})
|
||||||
|
if !d.Auto {
|
||||||
|
t.Errorf("непокрытые спутники не должны отменять авто: %v", d.Reasons)
|
||||||
|
}
|
||||||
|
if !hasReason(d.Reasons, "в план попало 1 файлов из 4") {
|
||||||
|
t.Errorf("покрытие обязано быть показано человеку: %v", d.Reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отброшенный элемент — блокирующая причина, а полное покрытие сводкой не шумит.
|
||||||
|
func TestDecide_FullCoverageIsSilent(t *testing.T) {
|
||||||
|
in := inputWith("film.mkv")
|
||||||
|
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
|
||||||
|
Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}}
|
||||||
|
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
|
||||||
|
|
||||||
|
d := decide(p, PreParse{Year: 1999}, match, true, 0.85,
|
||||||
|
planIssues{files: in.Files, all: in.Files})
|
||||||
|
if !d.Auto || len(d.Reasons) != 0 {
|
||||||
|
t.Errorf("полное покрытие не должно давать причин: %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
withDrop := decide(p, PreParse{Year: 1999}, match, true, 0.85,
|
||||||
|
planIssues{files: in.Files, all: in.Files, dropped: []string{"элемент отброшен"}})
|
||||||
|
if withDrop.Auto || !hasReason(withDrop.Reasons, "элемент отброшен") {
|
||||||
|
t.Errorf("отбраковка обязана блокировать авто: %+v", withDrop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Датовая нумерация серий (обычная для ежедневных шоу) рядом с обычной даёт
|
||||||
|
// разрыв в миллионы номеров. Перечисление такого разрыва материализовало бы
|
||||||
|
// сотни мегабайт int при пределе печати в 12 номеров, поэтому пропуски
|
||||||
|
// считаются арифметикой, а не перечислением.
|
||||||
|
func TestSeriesWarnings_HugeGapNotMaterialized(t *testing.T) {
|
||||||
|
files := []PlanFile{
|
||||||
|
{Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||||
|
{Role: RoleEpisode, Season: intp(1), Episode: intp(20260902)},
|
||||||
|
}
|
||||||
|
|
||||||
|
var before, after runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&before)
|
||||||
|
w := seriesWarnings(files)
|
||||||
|
runtime.ReadMemStats(&after)
|
||||||
|
|
||||||
|
// Бюджет — на строки причины, а не на 20 млн номеров: старая реализация
|
||||||
|
// брала здесь ~160 МиБ.
|
||||||
|
if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 {
|
||||||
|
t.Errorf("разрыв материализован: выделено %d байт", grew)
|
||||||
|
}
|
||||||
|
if len(w) != 1 {
|
||||||
|
t.Fatalf("ожидалась одна причина на сезон, получено %v", w)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w[0], "E02, E03") || !strings.Contains(w[0], "E13") {
|
||||||
|
t.Errorf("причина обязана перечислять начало разрыва: %q", w[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(w[0], "и ещё 20260888") {
|
||||||
|
t.Errorf("хвост обязан быть назван числом: %q", w[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Длинный хвост пропусков сворачивается: печатается предел, остальное — счётчик.
|
||||||
|
func TestSeriesWarnings_LongTailFolded(t *testing.T) {
|
||||||
|
files := []PlanFile{
|
||||||
|
{Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||||
|
{Role: RoleEpisode, Season: intp(1), Episode: intp(20)}, // нет 2..19 — 18 штук
|
||||||
|
}
|
||||||
|
w := seriesWarnings(files)
|
||||||
|
if len(w) != 1 {
|
||||||
|
t.Fatalf("причины = %v", w)
|
||||||
|
}
|
||||||
|
if strings.Count(w[0], "E") != episodeListMax {
|
||||||
|
t.Errorf("напечатано номеров: %d, ожидался предел %d: %q",
|
||||||
|
strings.Count(w[0], "E"), episodeListMax, w[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(w[0], "E13") || strings.Contains(w[0], "E14") {
|
||||||
|
t.Errorf("предел печати сдвинулся: %q", w[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(w[0], "и ещё 6") {
|
||||||
|
t.Errorf("свёрнутый хвост не назван числом: %q", w[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Претензии к элементам не растут без предела, а внешние значения в них
|
||||||
|
// усечены: причины оседают в БД навсегда и уезжают в карточку Telegram.
|
||||||
|
func TestValidateSchema_ProblemsCappedAndValuesShortened(t *testing.T) {
|
||||||
|
long := "сезон 1/" + strings.Repeat("длинное-имя-", 30) + "серия.mkv"
|
||||||
|
in := inputWith(long, "b.mkv")
|
||||||
|
p := Plan{Type: MediaSeries, Title: "Show", Files: []PlanFile{
|
||||||
|
{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||||
|
}}
|
||||||
|
// Тридцать элементов, каждый повторно адресующий первый файл.
|
||||||
|
for range 30 {
|
||||||
|
p.Files = append(p.Files,
|
||||||
|
PlanFile{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(2)})
|
||||||
|
}
|
||||||
|
problems, err := validateSchema(&p, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateSchema: %v", err)
|
||||||
|
}
|
||||||
|
if len(problems) != maxProblems+1 {
|
||||||
|
t.Fatalf("претензий %d, ожидались %d поимённых плюс свёрнутый хвост: %v",
|
||||||
|
len(problems), maxProblems, problems)
|
||||||
|
}
|
||||||
|
if !strings.Contains(problems[len(problems)-1], "и ещё 18") {
|
||||||
|
t.Errorf("хвост претензий не назван числом: %q", problems[len(problems)-1])
|
||||||
|
}
|
||||||
|
for _, s := range problems {
|
||||||
|
if strings.Contains(s, long) {
|
||||||
|
t.Errorf("внешнее значение не усечено: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ по неизвестной роли называет файл так, как его адресовала модель:
|
||||||
|
// проверка стоит до резолва, и pf.Src там ещё пуст.
|
||||||
|
func TestValidateSchema_UnknownRoleNamesAddressing(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
p := Plan{Type: MediaMovie, Title: "Film", Files: []PlanFile{
|
||||||
|
{Index: idxp(2), Role: "trailer"},
|
||||||
|
}}
|
||||||
|
_, err := validateSchema(&p, in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "#2") {
|
||||||
|
t.Errorf("err = %v, ожидался номер адресации", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ноль — не «номера нет»: так адресует модель, посчитавшая список с нуля, и
|
||||||
|
// остальные её номера уезжают на файл. Диагноз обязан быть самоописывающимся.
|
||||||
|
func TestResolveFile_ZeroIndexIsItsOwnCase(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
byPath := map[string]int{"a.mkv": 0, "b.mkv": 1}
|
||||||
|
|
||||||
|
_, problem, ok := resolveFile(PlanFile{Index: idxp(0)}, in.Files, byPath)
|
||||||
|
if ok || !strings.Contains(problem, "нумеруется с 1") {
|
||||||
|
t.Errorf("явный ноль: ok=%v, problem=%q", ok, problem)
|
||||||
|
}
|
||||||
|
_, problem, ok = resolveFile(PlanFile{}, in.Files, byPath)
|
||||||
|
if ok || !strings.Contains(problem, "без номера файла и без пути") {
|
||||||
|
t.Errorf("поля нет вовсе: ok=%v, problem=%q", ok, problem)
|
||||||
|
}
|
||||||
|
// Ноль рядом с путём остаётся запасным форматом: путь резолвится.
|
||||||
|
idx, problem, ok := resolveFile(PlanFile{Index: idxp(0), Src: "b.mkv"}, in.Files, byPath)
|
||||||
|
if !ok || idx != 1 || problem != "" {
|
||||||
|
t.Errorf("путь при нулевом номере обязан резолвиться: idx=%d ok=%v %q", idx, ok, problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Структурное предупреждение и рассинхрон года — блокирующие причины, а не
|
||||||
|
// информационные заметки: развод «блокирует / не блокирует» обязан исполняться.
|
||||||
|
func TestDecide_StructuralAndConsistencyBlockAuto(t *testing.T) {
|
||||||
|
in := inputWith("a.mkv", "b.mkv")
|
||||||
|
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
|
||||||
|
Files: []PlanFile{
|
||||||
|
{Src: "a.mkv", Role: RoleMain},
|
||||||
|
{Src: "b.mkv", Role: RoleMain}, // два main у фильма
|
||||||
|
}}
|
||||||
|
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
|
||||||
|
|
||||||
|
d := decide(p, PreParse{Year: 1998}, match, true, 0.85,
|
||||||
|
planIssues{files: in.Files, all: in.Files})
|
||||||
|
if d.Auto {
|
||||||
|
t.Errorf("структурное предупреждение обязано блокировать авто: %v", d.Reasons)
|
||||||
|
}
|
||||||
|
if !hasReason(d.Reasons, "основных видеофайлов 2") {
|
||||||
|
t.Errorf("структурная причина не названа: %v", d.Reasons)
|
||||||
|
}
|
||||||
|
if !hasReason(d.Reasons, "год расходится: пред-парс=1998, LLM=1999") {
|
||||||
|
t.Errorf("рассинхрон года не назван: %v", d.Reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Снимок списка файлов раздачи на момент распознавания (JSON-массив
|
||||||
|
-- {path,size} в том же порядке, в каком список показан модели и в каком
|
||||||
|
-- резолвятся номера files[].i). Нужен раскладке: без него виден только план,
|
||||||
|
-- и файл, который модель молча пропустила, не показывается нигде. Ходить за
|
||||||
|
-- списком в qBittorrent на каждый рендер — лишний поход в чужой сервис.
|
||||||
|
-- NULL — запись создана до этой миграции: снимка нет, и UI обязан сказать об
|
||||||
|
-- этом прямо, а не выдавать план за полный перечень.
|
||||||
|
ALTER TABLE recognition ADD COLUMN source_files TEXT;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE recognition DROP COLUMN source_files;
|
||||||
@@ -27,6 +27,10 @@ type Recognition struct {
|
|||||||
Reasons string `db:"reasons"` // JSON-массив строк
|
Reasons string `db:"reasons"` // JSON-массив строк
|
||||||
RawLLM sql.NullString `db:"raw_llm"`
|
RawLLM sql.NullString `db:"raw_llm"`
|
||||||
Plan sql.NullString `db:"plan"` // JSON recognize.Plan
|
Plan sql.NullString `db:"plan"` // JSON recognize.Plan
|
||||||
|
// SourceFiles — снимок списка файлов раздачи на момент распознавания
|
||||||
|
// (JSON-массив {path,size} в порядке нумерации промпта). NULL — запись
|
||||||
|
// старше миграции 0012: снимка нет, полнота раскладки не гарантирована.
|
||||||
|
SourceFiles sql.NullString `db:"source_files"`
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +75,13 @@ func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons [
|
|||||||
const q = `
|
const q = `
|
||||||
INSERT INTO recognition
|
INSERT INTO recognition
|
||||||
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||||
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
|
year, provider, provider_id, confidence, reasons, raw_llm, plan, source_files,
|
||||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
created_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
||||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
|
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
|
||||||
FormatTime(Now())); err != nil {
|
r.SourceFiles, FormatTime(Now())); err != nil {
|
||||||
return "", fmt.Errorf("insert recognition: %w", err)
|
return "", fmt.Errorf("insert recognition: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
|
|||||||
@@ -151,6 +151,19 @@ func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res re
|
|||||||
provider, providerID = res.Match.Provider, res.Match.ProviderID
|
provider, providerID = res.Match.Provider, res.Match.ProviderID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Снимок списка файлов раздачи — из того же среза, по которому построен
|
||||||
|
// план (Result.Files), иначе номера в раскладке разъедутся с адресацией
|
||||||
|
// модели. Не сериализовался — оставляем NULL: UI назовёт список
|
||||||
|
// недоступным, а молча показать план как полный перечень нельзя.
|
||||||
|
var sourceFiles sql.NullString
|
||||||
|
if len(res.Files) > 0 {
|
||||||
|
if b, ferr := json.Marshal(res.Files); ferr != nil {
|
||||||
|
log.Warn("recognition marshal source files failed", "error", ferr)
|
||||||
|
} else {
|
||||||
|
sourceFiles = store.NullString(string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rec := &store.Recognition{
|
rec := &store.Recognition{
|
||||||
DownloadID: id,
|
DownloadID: id,
|
||||||
MediaType: store.NullString(string(res.Plan.Type)),
|
MediaType: store.NullString(string(res.Plan.Type)),
|
||||||
@@ -158,6 +171,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res re
|
|||||||
Provider: store.NullString(provider),
|
Provider: store.NullString(provider),
|
||||||
ProviderID: store.NullString(providerID),
|
ProviderID: store.NullString(providerID),
|
||||||
Plan: store.NullString(string(planJSON)),
|
Plan: store.NullString(string(planJSON)),
|
||||||
|
SourceFiles: sourceFiles,
|
||||||
RawLLM: store.NullString(res.Raw),
|
RawLLM: store.NullString(res.Raw),
|
||||||
}
|
}
|
||||||
if res.Plan.OriginalTitle != "" {
|
if res.Plan.OriginalTitle != "" {
|
||||||
@@ -968,6 +982,13 @@ type ReviewData struct {
|
|||||||
// Задача в review без записанной причины (обычный «нет матча») иначе оставила
|
// Задача в review без записанной причины (обычный «нет матча») иначе оставила
|
||||||
// бы экран без объяснения, почему пропала кнопка «Применить».
|
// бы экран без объяснения, почему пропала кнопка «Применить».
|
||||||
PreviewError string
|
PreviewError string
|
||||||
|
// SourceFiles — снимок списка файлов раздачи момента распознавания, в
|
||||||
|
// порядке нумерации промпта (номер строки = номер адресации модели).
|
||||||
|
SourceFiles []recognize.File
|
||||||
|
// SourceFilesKnown — есть ли снимок вообще. false у записей старше
|
||||||
|
// миграции 0012: транспорт обязан сказать, что полнота не гарантирована,
|
||||||
|
// а не выдавать план за полный перечень файлов.
|
||||||
|
SourceFilesKnown bool
|
||||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||||
Provider string // эффективный провайдер (с учётом выбора)
|
Provider string // эффективный провайдер (с учётом выбора)
|
||||||
@@ -1068,6 +1089,14 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
|||||||
Download: *d, Recognition: rec, Hints: hints, Overrides: overrides,
|
Download: *d, Recognition: rec, Hints: hints, Overrides: overrides,
|
||||||
Provider: prov, ProviderID: pid,
|
Provider: prov, ProviderID: pid,
|
||||||
}
|
}
|
||||||
|
if rec != nil && rec.SourceFiles.Valid {
|
||||||
|
var files []recognize.File
|
||||||
|
if err := json.Unmarshal([]byte(rec.SourceFiles.String), &files); err != nil {
|
||||||
|
log.Warn("review data unmarshal source files failed", "error", err)
|
||||||
|
} else {
|
||||||
|
rd.SourceFiles, rd.SourceFilesKnown = files, true
|
||||||
|
}
|
||||||
|
}
|
||||||
if rec != nil {
|
if rec != nil {
|
||||||
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
|
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
|
||||||
rd.Candidates = cands
|
rd.Candidates = cands
|
||||||
|
|||||||
@@ -391,6 +391,11 @@ table.tbl .size{font-family:var(--mono);color:var(--text-dim);white-space:nowrap
|
|||||||
.se-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-weak)}
|
.se-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-weak)}
|
||||||
.role-select{font:inherit;font-size:var(--fs-xs);padding:4px 6px;border-radius:var(--r-sm);
|
.role-select{font:inherit;font-size:var(--fs-xs);padding:4px 6px;border-radius:var(--r-sm);
|
||||||
border:1px solid var(--border-strong);background:var(--surface);color:var(--text)}
|
border:1px solid var(--border-strong);background:var(--surface);color:var(--text)}
|
||||||
|
/* Строка о полноте перечня над таблицей раскладки: is-warn — когда список
|
||||||
|
файлов раздачи неизвестен и полнота не гарантирована. */
|
||||||
|
.tbl-note{margin:0 0 var(--sp-3);font-size:var(--fs-sm);color:var(--text-dim)}
|
||||||
|
.tbl-note.is-warn{padding:9px 12px;border-radius:var(--r-sm);color:var(--text);
|
||||||
|
background:var(--st-wait-bg);border:1px solid color-mix(in srgb,var(--st-wait) 30%,transparent)}
|
||||||
.tbl-actions{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap;align-items:center}
|
.tbl-actions{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap;align-items:center}
|
||||||
.validation{font-size:var(--fs-xs);margin-top:var(--sp-2);display:flex;gap:var(--sp-3);flex-wrap:wrap}
|
.validation{font-size:var(--fs-xs);margin-top:var(--sp-2);display:flex;gap:var(--sp-3);flex-wrap:wrap}
|
||||||
.validation .ok{color:var(--st-ok)}
|
.validation .ok{color:var(--st-ok)}
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
{{define "layout_widget"}}
|
{{define "layout_widget"}}
|
||||||
|
{{/* Полнота перечня названа явно: молчаливый показ неполного списка как
|
||||||
|
полного неотличим от честного «файлов вне плана нет». */}}
|
||||||
|
{{if .SourceUnknown}}
|
||||||
|
<p class="tbl-note is-warn">Список файлов раздачи не сохранён — эта загрузка распознана до того,
|
||||||
|
как система стала его хранить. Строки построены по плану: файлы, которых в плане нет,
|
||||||
|
здесь не видны, и полнота не гарантирована. Повторное распознавание из ревью вернёт полный список.</p>
|
||||||
|
{{else if lt .Planned .Total}}
|
||||||
|
<p class="tbl-note">В план распознавания попало {{.Planned}} файлов из {{.Total}} —
|
||||||
|
остальные показаны ниже пометкой «не в плане».</p>
|
||||||
|
{{end}}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="tbl">
|
<table class="tbl">
|
||||||
<thead><tr><th class="cell-num">#</th><th>файл источника → раскладка</th><th>роль</th></tr></thead>
|
<thead><tr>
|
||||||
|
<th class="cell-num" title="номер файла в списке распознавания — им же его адресует ответ модели">#</th>
|
||||||
|
<th>файл источника → раскладка</th><th>роль</th>
|
||||||
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $i, $f := .}}
|
{{range .Rows}}
|
||||||
<tr class="{{if $f.Ignored}}ignored{{end}}">
|
<tr class="{{if .Ignored}}ignored{{end}}">
|
||||||
<td class="cell-num">{{add $i 1}}</td>
|
<td class="cell-num">{{if .Num}}{{.Num}}{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fname">{{$f.Src}} <span class="arr">→</span></div>
|
<div class="fname">{{.Src}} <span class="arr">→</span></div>
|
||||||
<div class="link-to">
|
<div class="link-to">
|
||||||
{{if $f.Linked}}<span class="src">{{$f.Dst}}</span>{{else}}<span class="no-link">не разложен</span>{{end}}
|
{{if .Linked}}<span class="src">{{.Dst}}</span>{{else if .Unplanned}}<span class="no-link">модель не разметила этот файл</span>{{else}}<span class="no-link">не разложен</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge {{if $f.Ignored}}st-cancelled{{else}}st-done{{end}}">{{$f.RoleLabel}}</span></td>
|
<td><span class="badge {{if .Unplanned}}st-review{{else if .Ignored}}st-cancelled{{else}}st-done{{end}}">{{.RoleLabel}}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user