распознавание: большие раздачи размечаются целиком, файлы вне плана видны

- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
av
2026-09-02 08:54:58 +03:00
parent 01ac0430a9
commit fc9a3b4066
26 changed files with 1978 additions and 201 deletions
+31 -28
View File
@@ -36,14 +36,14 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
"provider_hint": "строка для поиска в базе (НЕ id)",
"files": [
{
"src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
"i": номер файла из списка ниже (целое, ровно как напечатано),
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
"season": число или null,
"episode": число или null
}
],
"confidence": число 0..1,
"notes": "пояснения и неоднозначности или пустая строка"
"notes": "неоднозначности, не больше пары предложений, или пустая строка"
}
Правила:
@@ -53,10 +53,12 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
оригинальном названии — продублируй "title", но НЕ выдумывай название.
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
- Поле "i" — номер файла из напечатанного ниже списка, ровно как он там стоит.
Путь файла копировать НЕ нужно: он у нас уже есть, мы подставим его сами.
- Один и тот же файл адресуй не больше одного раза.
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
- Для фильма ровно один основной видеофайл role "main".
- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
«(…)» в конце строки; не выдумывай и не нормализуй пути.
- Для ролей "sample", "ignore" и "extra" номера сезона и серии не нужны — оставляй null.
- Внешние субтитры — role "subtitle".`
const systemPromptBase = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
@@ -89,15 +91,18 @@ func systemPrompt(lang string) string {
return systemPromptBase + schemaText + languageDirective(lang)
}
// buildMessages собирает системное и пользовательское сообщения.
func buildMessages(in Input, pre PreParse, maxFiles int, lang string) []llm.Message {
// buildMessages собирает системное и пользовательское сообщения. total —
// полное число файлов раздачи (in.Files уже упорядочен и, возможно, усечён
// пределом): список печатается ровно тем срезом, по которому потом резолвятся
// номера.
func buildMessages(in Input, pre PreParse, total int, lang string) []llm.Message {
return []llm.Message{
{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
b.WriteString("Имя торрента: ")
b.WriteString(orNone(in.Name))
@@ -122,7 +127,7 @@ func userPrompt(in Input, pre PreParse, maxFiles int) string {
b.WriteString(preParseLine(pre))
b.WriteString("\n\n")
writeFileList(&b, in.Files, maxFiles)
writeFileList(&b, in.Files, total)
return b.String()
}
@@ -149,19 +154,15 @@ func preParseLine(pre PreParse) string {
return strings.Join(parts, ", ")
}
// writeFileList печатает список файлов, усекая до maxFiles. src в плане
// должен дословно совпадать с путями отсюда.
func writeFileList(b *strings.Builder, files []File, maxFiles int) {
n := len(files)
shown := n
if maxFiles > 0 && shown > maxFiles {
shown = maxFiles
}
b.WriteString("Файлы (")
b.WriteString(strconv.Itoa(n))
b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
for i := 0; i < shown; i++ {
// writeFileList печатает нумерованный список файлов. Номер строки — то, что
// модель возвращает в поле "i"; усечение (если оно было) сделал вызывающий,
// total — полное число файлов раздачи.
func writeFileList(b *strings.Builder, files []File, total int) {
b.WriteString("Файлы раздачи (")
b.WriteString(strconv.Itoa(len(files)))
b.WriteString("). Номер в начале строки — это то, что нужно вернуть в поле \"i\";")
b.WriteString(" путь и размер копировать не нужно:\n")
for i := range files {
b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". ")
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(")\n")
}
if shown < n {
if total > len(files) {
b.WriteString("… и ещё ")
b.WriteString(strconv.Itoa(n - shown))
b.WriteString(strconv.Itoa(total - len(files)))
b.WriteString(" файлов (список усечён)\n")
}
}
// correctionMessage — сообщение для повторной попытки: что было не так + схема.
func correctionMessage(err error, in Input, maxFiles int) string {
// Список файлов сюда НЕ входит: его номера названы в первом сообщении диалога
// и сохраняют смысл на всех попытках, а повторная печать умножала бы вход на
// число попыток — ровно ту цену, которую снимает индексная адресация.
func correctionMessage(err error) string {
var b strings.Builder
b.WriteString("Ответ не принят: ")
b.WriteString(err.Error())
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.\n\n")
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.")
b.WriteString(" Файлы адресуй номерами из списка, присланного выше.\n\n")
b.WriteString(schemaText)
b.WriteString("\n\n")
writeFileList(&b, in.Files, maxFiles)
return b.String()
}