распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
+349
-55
@@ -5,22 +5,25 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
|
||||
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
|
||||
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
|
||||
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Второй
|
||||
// результат — претензии к отдельным элементам files[]: они НЕ ошибка разбора
|
||||
// (повторный запрос к модели не делается), а причины ухода в review.
|
||||
// Структурные предупреждения (см. decide) ошибкой тоже не считаются.
|
||||
//
|
||||
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
|
||||
// sanitizePlan) ДО структурной валидации: так, например, title из одних
|
||||
// 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)
|
||||
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
|
||||
@@ -30,57 +33,212 @@ func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
|
||||
// Повторяем без строгого режима: лишние поля — не повод падать,
|
||||
// но если и так не разобралось — это ошибка схемы.
|
||||
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)
|
||||
|
||||
if err := validateSchema(&p, in); err != nil {
|
||||
return Plan{}, err
|
||||
problems, err := validateSchema(&p, in)
|
||||
if err != nil {
|
||||
return Plan{}, problems, err
|
||||
}
|
||||
return p, nil
|
||||
return p, problems, nil
|
||||
}
|
||||
|
||||
// validateSchema проверяет обязательную структуру плана. Главный инвариант
|
||||
// безопасности: каждый files[].src совпадает с реальным файлом торрента —
|
||||
// недоверенный выход LLM не может сослаться на посторонний путь.
|
||||
func validateSchema(p *Plan, in Input) error {
|
||||
// validateSchema проверяет обязательную структуру плана и резолвит адресацию
|
||||
// файлов. Главный инвариант безопасности: files[].src заполняем МЫ — из своего
|
||||
// же списка, по номеру строки (i), напечатанному в промпте. Присланный моделью
|
||||
// путь принимается лишь как запасной формат и только при точном совпадении с
|
||||
// файлом торрента, поэтому сослаться на посторонний путь невозможно.
|
||||
//
|
||||
// Негодный элемент (номер вне диапазона, повторная адресация, ни номера ни
|
||||
// пути, неизвестный путь) отбрасывается поимённой претензией — план при этом
|
||||
// живёт: одна ошибка в одном из сотен элементов не должна ронять всю работу.
|
||||
// Ошибка возвращается только когда плана не осталось вовсе.
|
||||
func validateSchema(p *Plan, in Input) ([]string, error) {
|
||||
switch p.Type {
|
||||
case MediaMovie, MediaSeries:
|
||||
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:
|
||||
return fmt.Errorf("unknown type %q", p.Type)
|
||||
return nil, fmt.Errorf("unknown type %q", p.Type)
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("files list is empty")
|
||||
return nil, fmt.Errorf("files list is empty")
|
||||
}
|
||||
|
||||
known := make(map[string]bool, len(in.Files))
|
||||
for _, f := range in.Files {
|
||||
known[f.Path] = true
|
||||
byPath := make(map[string]int, len(in.Files))
|
||||
for i, f := range in.Files {
|
||||
byPath[f.Path] = i
|
||||
}
|
||||
|
||||
var problems problemList
|
||||
taken := make(map[int]bool, len(p.Files))
|
||||
kept := p.Files[:0]
|
||||
for i := range p.Files {
|
||||
pf := &p.Files[i]
|
||||
pf := p.Files[i]
|
||||
// Роль вне перечня — ошибка схемы, а не адресации: модель ошиблась в
|
||||
// нашем же словаре, и это чинится повторной попыткой. Проверка стоит ДО
|
||||
// резолва, поэтому pf.Src ещё пуст: адресуем элемент так, как его назвала
|
||||
// сама модель, иначе ей (и человеку) нечего чинить в списке на 180 строк.
|
||||
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) == "" {
|
||||
return fmt.Errorf("file with empty src")
|
||||
idx, problem, ok := resolveFile(pf, in.Files, byPath)
|
||||
if problem != "" {
|
||||
problems.add(problem)
|
||||
}
|
||||
if !known[pf.Src] {
|
||||
return fmt.Errorf("src %q not found among torrent files", pf.Src)
|
||||
if !ok {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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). Авто —
|
||||
@@ -88,14 +246,38 @@ func validateSchema(p *Plan, in Input) error {
|
||||
// название матча пригодно как имя каталога; чистая структурная валидация (для
|
||||
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
|
||||
// самооценка 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
|
||||
blocking := 0
|
||||
// add — причина, отменяющая авто; note — то же для глаз человека, без влияния
|
||||
// на решение.
|
||||
add := func(s string) { reasons = append(reasons, s); blocking++ }
|
||||
note := func(s string) { reasons = append(reasons, s) }
|
||||
|
||||
switch {
|
||||
case !metadataEnabled:
|
||||
reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна")
|
||||
add("метабазы отключены → авто-раскладка недоступна")
|
||||
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), второй раз не чистим: два независимых пересчёта одного
|
||||
// условия разъедутся на первой же правке одного из них.
|
||||
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 {
|
||||
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 {
|
||||
reasons = append(reasons,
|
||||
fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
|
||||
add(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 сверяет число распознанных серий по сезонам с базой.
|
||||
@@ -181,13 +417,12 @@ func structuralWarnings(p Plan) []string {
|
||||
return w
|
||||
}
|
||||
|
||||
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам.
|
||||
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. На
|
||||
// сезон приходится не больше одной причины каждого рода: на паке из восьми
|
||||
// сезонов строка за каждый разрыв давала сорок строк, которые человек в ревью
|
||||
// уже не читает.
|
||||
func seriesWarnings(files []PlanFile) []string {
|
||||
type key struct{ s, e int }
|
||||
seen := map[key]int{}
|
||||
bySeason := map[int][]int{}
|
||||
var w []string
|
||||
|
||||
seen := map[int]map[int]int{}
|
||||
for _, f := range files {
|
||||
if f.Role != RoleEpisode || f.Episode == nil {
|
||||
continue
|
||||
@@ -196,27 +431,86 @@ func seriesWarnings(files []PlanFile) []string {
|
||||
if f.Season != nil {
|
||||
season = *f.Season
|
||||
}
|
||||
k := key{season, *f.Episode}
|
||||
seen[k]++
|
||||
if seen[k] == 2 {
|
||||
w = append(w, fmt.Sprintf("сериал: дубль серии S%02dE%02d", season, *f.Episode))
|
||||
if seen[season] == nil {
|
||||
seen[season] = map[int]int{}
|
||||
}
|
||||
bySeason[season] = append(bySeason[season], *f.Episode)
|
||||
seen[season][*f.Episode]++
|
||||
}
|
||||
|
||||
for _, season := range sortedKeys(bySeason) {
|
||||
eps := bySeason[season]
|
||||
sort.Ints(eps)
|
||||
for i := 1; i < len(eps); i++ {
|
||||
if eps[i] > eps[i-1]+1 {
|
||||
w = append(w, fmt.Sprintf("сериал: пропуск серий в сезоне %d между E%02d и E%02d",
|
||||
season, eps[i-1], eps[i]))
|
||||
var w []string
|
||||
for _, season := range sortedKeys(toSlices2(seen)) {
|
||||
eps := seen[season]
|
||||
nums := make([]int, 0, len(eps))
|
||||
for e := range eps {
|
||||
nums = append(nums, e)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 с черновым пред-парсом.
|
||||
func consistencyWarnings(p Plan, pre PreParse) []string {
|
||||
var w []string
|
||||
|
||||
Reference in New Issue
Block a user