diff --git a/backend/internal/chunk/chunktest/epub.go b/backend/internal/chunk/chunktest/epub.go index 6dacaa56..749742ee 100644 --- a/backend/internal/chunk/chunktest/epub.go +++ b/backend/internal/chunk/chunktest/epub.go @@ -14,9 +14,21 @@ import ( type Chapter struct { ID string - Href string // relative to the OPF dir (OEBPS/) + Href string // relative to the OPF dir (OEBPS/), as the MANIFEST spells it MType string // "" → application/xhtml+xml Body string // inner xhtml of
+ // EntryName is the zip entry the file is actually written to (relative to the OPF dir), + // for fixtures whose manifest href does NOT spell the entry name: a percent-encoded href + // or one carrying a #fragment. "" → the href itself, so every existing fixture is unchanged. + EntryName string +} + +// entryName is where this chapter's bytes are written inside OEBPS/. +func (c Chapter) entryName() string { + if c.EntryName != "" { + return c.EntryName + } + return c.Href } // BuildEPUB writes a minimal but real epub (container.xml → OPF → spine → xhtml) @@ -48,7 +60,15 @@ func BuildEPUBAt(t *testing.T, path string, chapters []Chapter, spineIDs []strin } } - add("mimetype", "application/epub+zip") + // OCF requires "mimetype" first in the archive and STORED, not deflated. Nothing in ingest + // reads it today; the fixture matches the spec so it stays a real epub as the reader grows. + mw, err := zw.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err := mw.Write([]byte("application/epub+zip")); err != nil { + t.Fatal(err) + } add("META-INF/container.xml", `Настоящий текст.
`, + wantIn: []string{"Настоящий текст."}, + wantNotIn: []string{"document.write", "aНастоящий текст.
`, + wantIn: []string{"Настоящий текст."}, + wantNotIn: []string{"черновая заметка", "v2 (26.07)"}, + }} + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + doc, err := ingest(chunktest.BuildEPUB(t, + []chunktest.Chapter{{ID: "c1", Href: "ch1.xhtml", Body: c.body}}, []string{"c1"})) + if err != nil { + t.Fatalf("a dirty xhtml chapter must still import: %v", err) + } + if len(doc.Chapters) != 1 { + t.Fatalf("want 1 chapter, got %#v", doc.Chapters) + } + got := doc.Chapters[0] + for _, w := range c.wantIn { + if !strings.Contains(got, w) { + t.Errorf("prose %q lost from extraction: %q", w, got) + } + } + for _, w := range c.wantNotIn { + if strings.Contains(got, w) { + t.Errorf("noise %q leaked into extraction: %q", w, got) + } + } + }) + } +} + +// The subtree is skipped by NAME, not by raw nesting depth: its void children (, +// ) emit no end tag, so a depth counter would never unwind and the whole chapter would +// vanish. Both spellings — self-closed and bare — must leave the body intact. +func TestIngestEPUBVoidTagsInHeadDoNotSwallowChapter(t *testing.T) { + for _, head := range []string{ + `Тело главы.
` + body, _, err := extractXHTML([]byte(raw)) + if err != nil { + t.Fatalf("head %q: %v", head, err) + } + if !strings.Contains(body, "Тело главы.") { + t.Fatalf("head %q swallowed the body: %q", head, body) + } + if strings.Contains(body, "T") && strings.Contains(body, "Тело главы.
` + body, _, err := extractXHTML([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(body, "Тело главы.") { + t.Fatalf("an unclosed swallowed the chapter: %q", body) + } +} + +// Byte-parity with the previous encoding/xml reader on the constructs where the tokenizer differs +// most: it emits ONE token for a void or self-closed element where the xml decoder synthesised a +// start AND an end (HTMLAutoClose). The expected strings below were measured against that reader, +// so a regression here is a silent re-chunk of every book carryingA
B
`, "\n\nA\n\n\n\n\n\n\n\nB\n\n"}, + {"hr bare", `A
B
`, "\n\nA\n\n\n\n\n\n\n\nB\n\n"}, + //A
B
A
B
`, "\n\nA\n\n\n\nB\n\n"}, + {"void inside script", `A
B
`, "\n\nA\n\n\n\nB\n\n"}, + {"void inside head", `A
`, "\n\nA\n\n"}, + // No wrapper: nothing rescues a skip that failed to unwind, so this is what pins + // that the subtree is tracked by NAME. Its void children emit no end tag, and a + // blind depth counter would still be inside here and drop the prose entirely. + {"head without body, bare void", `Проза.
`, "\n\nПроза.\n\n"}, + {"head without body, self-closed void", `Проза.
`, "\n\nПроза.\n\n"}, + } { + t.Run(c.name, func(t *testing.T) { + got, _, err := extractXHTML([]byte(c.doc)) + if err != nil { + t.Fatal(err) + } + if got != c.want { + t.Fatalf("extraction drifted from the previous reader\n got %q\n want %q", got, c.want) + } + }) + } +} + +// UNBALANCED ruby. HTML5 makes , and optional, and the tokenizer — unlike the xml +// decoder — does not synthesise the implied end tags. Tracking the sub-element as a nesting depth +// leaks on the first omission and routes every LATER base into the reading buffer, deleting it from +// the prose; an unclosed likewise suppresses every later paragraph break. Both are content +// loss on the wire, and both are invisible to a clean-corpus comparison. Expected values measured +// against the previous reader. +func TestExtractXHTMLUnbalancedRuby(t *testing.T) { + t.Run("omitted does not eat the next ruby", func(t *testing.T) { + body, ruby, err := extractXHTML([]byte( + `漢текст
字ещё
`)) + if err != nil { + t.Fatal(err) + } + if body != "\n\n漢текст\n\n\n\n字ещё\n\n" { + t.Fatalf("prose lost after an omitted : %q", body) + } + if len(ruby) != 2 || ruby[0].Base != "漢" || ruby[1].Base != "字" || ruby[1].Reading != "じ" { + t.Fatalf("ruby capture broken after an omitted : %#v", ruby) + } + }) + t.Run("unclosed does not swallow the chapter", func(t *testing.T) { + body, ruby, err := extractXHTML([]byte( + `漢
Второй абзац.
Третий.
`)) + if err != nil { + t.Fatal(err) + } + if body != "\n\n漢\n\n\n\nВторой абзац.\n\n\n\nТретий.\n\n" { + t.Fatalf("an unclosed suppressed the paragraph breaks: %q", body) + } + if paras := splitParagraphs(text.NormalizeSource(body)); len(paras) != 3 { + t.Fatalf("want 3 paragraphs, got %d: %#v", len(paras), paras) + } + if len(ruby) != 1 || ruby[0].Base != "漢" || ruby[0].Reading != "かん" { + t.Fatalf("ruby lost when was closed by its block: %#v", ruby) + } + }) + t.Run("nested ruby with an unclosed inner ", func(t *testing.T) { + // The inner must clear the sub-mode even though it does not close the OUTER ruby, + // or the outer base 字 is routed into the reading and disappears from the prose. + body, ruby, err := extractXHTML([]byte( + `漢字текст
`)) + if err != nil { + t.Fatal(err) + } + if body != "\n\n漢字текст\n\n" { + t.Fatalf("nested ruby lost the outer base: %q", body) + } + if len(ruby) != 1 || ruby[0].Base != "漢字" || ruby[0].Reading != "かんじ" { + t.Fatalf("nested ruby capture = %#v", ruby) + } + }) + t.Run("omitted keeps the parenthesis fallback out of the prose", func(t *testing.T) { + body, ruby, err := extractXHTML([]byte( + `東текст
`)) + if err != nil { + t.Fatal(err) + } + if body != "\n\n東текст\n\n" { + t.Fatalf("rp fallback leaked or base lost: %q", body) + } + if len(ruby) != 1 || ruby[0].Base != "東" || ruby[0].Reading != "とう" { + t.Fatalf("ruby capture = %#v", ruby) + } + }) +} + +// Raw-text elements are the tokenizer's sharpest edge. Its raw mode is what makes markup-shaped +//Проза.
`)) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(got) != "Проза." { + t.Fatalf("script raw-text mode broken: %q", got) + } +} + +// The tokenizer resolves HTML5 legacy entities the xml decoder left literal — a bare «&» followed +// by a known name (&, ©,   …) with no semicolon. That is the correct HTML reading, but it +// means a bare ampersand in prose is now interpreted, so the ordinary prose case is pinned here: +// «AT&T», «R&D» and «Р&Б» must survive untouched, because the letter after & starts no entity name. +func TestExtractXHTMLBareAmpersandInProseSurvives(t *testing.T) { + for _, s := range []string{"AT&T", "R&D", "Тим & Ко", "1 & 2"} { + body, _, err := extractXHTML([]byte(`` + s + `
`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(body, s) { + t.Errorf("bare ampersand prose %q was altered: %q", s, body) + } + } +} + +// A namespace-prefixed block tag must still break paragraphs: the xml decoder matched on the LOCAL +// name (До.
& амперсанд ]]>После.
`, + "сырой <текст> & амперсанд"}, + {"markup kept literal", `не разметка]]>Проза.
`, + "не разметка
"}, + } { + t.Run(c.name, func(t *testing.T) { + body, _, err := extractXHTML([]byte(c.doc)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(body, c.want) { + t.Fatalf("CDATA content lost or mangled: got %q, want it to contain %q", body, c.want) + } + if strings.Contains(body, "]]>") || strings.Contains(body, "CDATA") { + t.Fatalf("CDATA delimiters leaked into prose: %q", body) + } + }) + } + // The real-world spelling: CDATA guards inside aПроза.
")) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(body) != "Проза." { + t.Fatalf("style CDATA leaked: %q", body) + } +} + +// A declared non-UTF-8 charset stays a LOUD error (epub v1 = UTF-8) — the guard the xml decoder's +// CharsetReader used to provide. Silent mojibake in ch.Text is the failure this prevents. +func TestExtractXHTMLRejectsNonUTF8Charset(t *testing.T) { + bad := []byte(`текст
`) + if _, _, err := extractXHTML(bad); err == nil { + t.Fatal("a declared non-UTF-8 xhtml charset must fail loud") + } else if !strings.Contains(err.Error(), "gb18030") { + t.Fatalf("the error must name the charset, got: %v", err) + } + for _, ok := range []string{``, ``, ``} { + if _, _, err := extractXHTML([]byte(ok + `текст
`)); err != nil { + t.Fatalf("declaration %q must be accepted: %v", ok, err) + } + } +} + +func TestIngestEPUBPercentEncodedHref(t *testing.T) { + // Real epubs percent-encode non-ASCII (and spaced) filenames in the manifest while the zip + // entry carries the decoded name. Without resolveHref's url.PathUnescape the entry is missed + // and ingest fails loud on a chapter that is actually present. + chapters := []chunktest.Chapter{ + {ID: "c1", Href: "%E7%AC%AC%E4%B8%80%E7%AB%A0.xhtml", EntryName: "第一章.xhtml", Body: `第一章。
`}, + {ID: "c2", Href: "ch%202.xhtml", EntryName: "ch 2.xhtml", Body: `第二章。
`}, + } + doc, err := ingest(chunktest.BuildEPUB(t, chapters, []string{"c1", "c2"})) + if err != nil { + t.Fatalf("percent-encoded hrefs must resolve to their decoded zip entries: %v", err) + } + if len(doc.Chapters) != 2 { + t.Fatalf("want 2 chapters, got %d: %#v", len(doc.Chapters), doc.Chapters) + } + for i, want := range []string{"第一章。", "第二章。"} { + if strings.TrimSpace(doc.Chapters[i]) != want { + t.Fatalf("chapter %d = %q, want %q", i+1, doc.Chapters[i], want) + } + } +} + +func TestIngestEPUBHrefFragmentIgnored(t *testing.T) { + // A spine href may point at an anchor inside a document (chapter.xhtml#part2). The fragment + // addresses a position, not a file: it must be dropped before the zip lookup, or the chapter + // is reported missing. + chapters := []chunktest.Chapter{ + {ID: "c1", Href: "ch1.xhtml#part2", EntryName: "ch1.xhtml", Body: `第一章。
`}, + } + doc, err := ingest(chunktest.BuildEPUB(t, chapters, []string{"c1"})) + if err != nil { + t.Fatalf("an href #fragment must be dropped before the zip lookup: %v", err) + } + if len(doc.Chapters) != 1 || strings.TrimSpace(doc.Chapters[0]) != "第一章。" { + t.Fatalf("chapters = %#v", doc.Chapters) + } +} + +func TestEPUBFixtureMimetypeIsOCFConformant(t *testing.T) { + // OCF: "mimetype" must be the FIRST entry and STORED (uncompressed). All three stand epubs + // ship it that way; the fixture must too, or it is not the file shape the reader will meet. + p := chunktest.BuildEPUB(t, []chunktest.Chapter{{ID: "c1", Href: "ch1.xhtml", Body: `x
`}}, []string{"c1"}) + zr, err := zip.OpenReader(p) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + if len(zr.File) == 0 || zr.File[0].Name != "mimetype" { + t.Fatalf("mimetype must be the first zip entry, got %q", zr.File[0].Name) + } + if zr.File[0].Method != zip.Store { + t.Fatalf("mimetype must be STORED (method %d), got method %d", zip.Store, zr.File[0].Method) + } + rc, err := zr.File[0].Open() + if err != nil { + t.Fatal(err) + } + defer rc.Close() + b, _ := io.ReadAll(rc) + if string(b) != "application/epub+zip" { + t.Fatalf("mimetype content = %q", b) + } +} + func TestIngestEPUBDanglingIdrefFailsLoud(t *testing.T) { // A spine idref with no manifest item, AMONG valid chapters, must fail loud — not // silently drop that chapter (which would also shift every later since_ch). diff --git a/backend/internal/pipeline/promptcomments_test.go b/backend/internal/pipeline/promptcomments_test.go new file mode 100644 index 00000000..4987683c --- /dev/null +++ b/backend/internal/pipeline/promptcomments_test.go @@ -0,0 +1,161 @@ +package pipeline + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +// promptcomments_test.go pins backlog 14b: an editorial note in a prompt file is for the +// human who edits the template, and must never be paid for as system tokens on every call. + +func rawSHA(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// rawTemplateFixture carries one canary comment per section, so a strip that covers only some of +// them is visible rather than silently partial. +const rawTemplateFixture = "\nЯдро системы.\n" + + "---FEWSHOT---\n\nПример.\n" + + "---USER---\n\nТекст: {{text}}" + +// A comment must not survive into ANY message — system, few-shot or user. Checking all three +// catches a partial strip (e.g. one applied only to the system half after the split). +func TestPromptCommentsNeverReachTheWire(t *testing.T) { + tpl := writeTemplate(t, rawTemplateFixture) + + // Production flattens the few-shot block into System before rendering (runner.go), so the test + // must too — otherwise the few-shot canary is never actually carried into a message and the + // assertion below passes vacuously. + flat := *tpl + flat.System = tpl.SystemFor(true) + msgs, err := MessagesWithInjection(&flat, RenderVars{Book: testBook(), Text: "исходник"}, "инъекция") + if err != nil { + t.Fatal(err) + } + // Premise check: the canaries must be present in the RAW file, or this test proves nothing. + for _, canary := range []string{"СЕКРЕТ-система", "СЕКРЕТ-фьюшот", "СЕКРЕТ-юзер"} { + if !strings.Contains(rawTemplateFixture, canary) { + t.Fatalf("fixture lost canary %q — the test would pass vacuously", canary) + } + } + for _, m := range msgs { + for _, bad := range []string{"СЕКРЕТ", commentOpen, commentClose} { + if strings.Contains(m.Content, bad) { + t.Fatalf("%s message leaked %q on the wire: %q", m.Role, bad, m.Content) + } + } + } + // The surrounding prompt text itself must survive the strip intact — in every section. + if !strings.Contains(flat.System, "Ядро системы.") || !strings.Contains(flat.System, "Пример.") { + t.Fatalf("stripping ate real prompt text: system = %q", flat.System) + } + if !strings.Contains(tpl.User, "Текст: {{text}}") { + t.Fatalf("stripping ate real prompt text: user = %q", tpl.User) + } +} + +// The "no book re-snapshots" guarantee: a file with no comments must hash EXACTLY as the raw +// bytes did before this change, so every book already in flight keeps its snapshot id. +func TestPromptCommentFreeFileKeepsRawSHA(t *testing.T) { + const content = "Система без комментариев.\n---USER---\n{{text}}" + tpl := writeTemplate(t, content) + if tpl.SHA256 != rawSHA(content) { + t.Fatalf("a comment-free prompt must keep the raw-bytes SHA (no book may move)\n got %s\n want %s", + tpl.SHA256, rawSHA(content)) + } +} + +// The mutation-killer for "hash the raw file instead of the canonical form": with a comment +// present the two hashes DIFFER, and only the canonical one is correct. It also pins the payoff — +// editing a comment no longer moves the hash, so it no longer costs money. +func TestPromptSHAIsOverTheCanonicalForm(t *testing.T) { + const withComment = "\nСистема.\n---USER---\n{{text}}" + const stripped = "\nСистема.\n---USER---\n{{text}}" + const otherComment = "\nСистема.\n---USER---\n{{text}}" + + tpl := writeTemplate(t, withComment) + if tpl.SHA256 == rawSHA(withComment) { + t.Fatal("SHA is over the RAW file: a comment edit would still re-bill the book") + } + if tpl.SHA256 != rawSHA(stripped) { + t.Fatalf("SHA must be over the comment-stripped form\n got %s\n want %s", tpl.SHA256, rawSHA(stripped)) + } + if other := writeTemplate(t, otherComment); other.SHA256 != tpl.SHA256 { + t.Fatalf("two files differing ONLY in comment text must share a SHA: %s vs %s", tpl.SHA256, other.SHA256) + } +} + +// Stripping must happen BEFORE the ---USER--- split: a separator sitting inside a comment is not a +// separator. Stripping after the split would cut the file at a line the model never sees. +func TestPromptSeparatorInsideCommentDoesNotSplit(t *testing.T) { + tpl := writeTemplate(t, "Система.\n\n---USER---\nнастоящий {{text}}") + if strings.Contains(tpl.System, "старый юзер-блок") || strings.Contains(tpl.User, "старый юзер-блок") { + t.Fatalf("commented-out block survived: system=%q user=%q", tpl.System, tpl.User) + } + if tpl.System != "Система." { + t.Fatalf("system = %q, want the text before the comment only", tpl.System) + } + if tpl.User != "настоящий {{text}}" { + t.Fatalf("user = %q — the split took the COMMENTED separator", tpl.User) + } +} + +// An unterminated comment is a malformed template: fail loud at LOAD time, before any billing, +// rather than silently swallowing the rest of the file (or shipping the note to the model). +func TestPromptUnterminatedCommentFailsLoud(t *testing.T) { + path := filepath.Join(t.TempDir(), "tpl.md") + if err := os.WriteFile(path, []byte("Система.\n" + +// stripPromptComments removes every span. HTML comments do not nest, so the first +// "-->" closes the span. An unterminated comment is a malformed template: it fails loud at LOAD +// time (before any billing), rather than silently swallowing the rest of the file. +func stripPromptComments(s, path string) (string, error) { + var b strings.Builder + rest := s + for { + i := strings.Index(rest, commentOpen) + if i < 0 { + b.WriteString(rest) + return b.String(), nil + } + b.WriteString(rest[:i]) + rest = rest[i+len(commentOpen):] + j := strings.Index(rest, commentClose) + if j < 0 { + return "", fmt.Errorf("pipeline: prompt %s has an unterminated %q comment", path, commentOpen) + } + rest = rest[j+len(commentClose):] + } +} + // LoadPromptTemplate reads and splits a template file into core-system / few-shot / user. func LoadPromptTemplate(path string) (*PromptTemplate, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("pipeline: read prompt %s: %w", path, err) } - sum := sha256.Sum256(raw) - parts := strings.SplitN(string(raw), userSeparator, 2) + // Comments are stripped BEFORE the split, so a ---USER---/---FEWSHOT--- line commented out + // cannot still cut the file. The SHA is taken over the CANONICAL (stripped) form because the + // hash answers "did the WIRE input change": a comment-free file keeps its previous hash + // byte-for-byte (no book re-snapshots), and editing a comment stops costing money. + canon, err := stripPromptComments(string(raw), path) + if err != nil { + return nil, err + } + sum := sha256.Sum256([]byte(canon)) + parts := strings.SplitN(canon, userSeparator, 2) if len(parts) != 2 { return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator)) } diff --git a/backend/prompts/zh-ru/terminologist.md b/backend/prompts/zh-ru/terminologist.md index 4e41a71d..0f5ff530 100644 --- a/backend/prompts/zh-ru/terminologist.md +++ b/backend/prompts/zh-ru/terminologist.md @@ -1,15 +1,5 @@ - + Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}». Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9c96b898..2db7f291 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -28,9 +28,7 @@ | 13 | Включение платной петли ремонта + per-class исходы ремонта (схемное решение принимается вместе) — вернуться при расширении классов детекторов и ненулевом остатке | владелец | когда-нибудь | отдельное решение | D39.38, D39.39(а), CURRENT-STATE | | 13а | Веса рубрики K1–K12 не выбраны (Q1: «по данным фазы B»; фаза B прошла на BWS без весов — выбор нужен к агрегатной приёмке качества книги) | владелец | когда-нибудь (к пилоту) | Ф2.5 / отдельное решение | D39.44 Q1, D39.46 | | **— ПАК-19 (текущий шаг очереди) —** | | | | | | -| 14а | Ingest-пачка (санкция 26.07): `extractXHTML` → `x/net/html`-токенайзер (4 класса падений импорта грязных епабов) · `EntryName` + тесты percent-href/#fragment · mimetype `zip.Store`; приёмка: байт-сравнение экстракции на реальном епабе стенда | бэкенд | скоро (до/вместе с паком-19) | малая пачка | чат 26.07, анализ ingest | -| 14б | Комментарии в промпт-файлах ТЕКУТ НА ПРОВОД (LoadPromptTemplate не вырезает ; 12-строчная мета-шапка terminologist.md уходит модели — клейм §2.5 отчёта стройки «модель не видит» ЛОЖЕН, приёмка D39.53 пропустила): вырезать комментарии до сплита, SHA по канонической форме, шапку ужать до 1–2 строк, тест-пин | бэкенд | скоро (та же малая пачка, что 14а) | малая пачка | улов владельца 26.07 | -| 14 | Пак-19 «голос/состояние» (D21, урезанный скоуп) — выдан, первый в очереди D39.53 | бэкенд | блокер-очереди | пак-19 | D39.38, D39.40, D39.53 | +| 14 | Пак-19 «голос/состояние» (D21, урезанный скоуп) — выдан, первый в очереди D39.53; малая пачка 14а+14б ЗАКРЫТА D39.54 | бэкенд | блокер-очереди | пак-19 | D39.38, D39.40, D39.53 | | 15 | Нарративная гендер-аннотация (since_ch-смены + перспектива, не булево поле) — вход пака-19 | бэкенд | блокер-очереди | пак-19 | D39.34(8) | | **— ХОЛОДНЫЙ МИНИ-ПРОГОН (после пака-19) —** | | | | | | | 16 | Холодный старт с пустым банком — единственная неизмеренная цифра цены/покрытия | бэкенд/полигон | блокер-очереди | холодный мини-прогон | D39.50 п.7, D39.51, POLYGON_PREMEASURE §5 | @@ -58,6 +56,7 @@ | 32 | ru-target долг слоя 7 (`isRuTarget`×11 · `TokenizeCyrillic`×6 · ключ «ru» в санитайзере) — ждёт своего дизайн-пака | бэкенд | когда-нибудь | отдельный пак (идеал) | CURRENT-STATE, D39.34 | | 33 | native-Gemini судья (Ф2-механизм) | бэкенд | когда-нибудь | отдельный пак (идеал) | CURRENT-STATE, D39.33 | | 34 | Генеральность нарезки: кана/ханьцзи один класс (`est_out` ja завышен ~1.7× МОЛЧА) + `EstimateTokens` недосчитывает кириллицу (резервации занижены) | бэкенд | когда-нибудь | отдельный пак (с ja→ru) | D39.37(5) | +| 34а | Экран кодировки не видит `` без XML-объявления (епаб с gb18030-метой прочтётся как UTF-8) — до-паковая дыра, сознательно не тронута пачкой 14а (паритет со старым ридером) | бэкенд | когда-нибудь | отдельное решение | D39.54, SMALLPACK §8 | | 35 | Не-CJK майнер/банк: en-детектора не существует (hanRuns=0), ja с zh-таблицами активно неверен (0/10); нужен второй ДЕТЕКТОР (прототип `mine_nonhan.py`) + G1–G10 требования generic-майнера (вкл. квадратичность G9 ≈4.6 ч) | бэкенд | когда-нибудь | отдельный пак generic-майнера (с ja→ru) | D39.37(8), D39.43, D39.50 п.8, POLYGON_PREMEASURE §6 | | 36 | Кластеризация майнера глотает родовые титулы (族长/学堂家老 в кластере 葛家) — закрыто на границе сборки входа, сама кластеризация не чинилась | бэкенд | когда-нибудь | отдельное решение | D39.43, D39.45 | | 37 | `AttachKWIC` квадратичен (16.8 с / 2000 кандидатов; фикс — многошаблонный поиск за проход) | бэкенд | когда-нибудь | отдельное решение | D39.45, PACK20_BANK_BUILD §8.5.1 | @@ -100,6 +99,10 @@ | 69 | Gemini API «под-18» обязательство на конечный продукт (независимо от лейблов) | владелец | когда-нибудь | Ф3 / отдельное решение | D39.32, D39.34(7) | | 70 | Action-security gate перед выдачей tools/webfetch (D25 п.5) | бэкенд | когда-нибудь | Ф3 | D39.34(7) | | 71 | Планы research/22: epub-tag-rewrite · Q7-леджер | бэкенд | когда-нибудь | Ф3 | D39.34(7) | +## Оркестратор №9 + бэкенд — МАЛАЯ ПАЧКА 14а+14б ПРИНЯТА И ЗАЛЕНДЕНА (D39.54), 30.07 + +Бэкенд-сессия (та же, что несёт пак-19) закрыла обе строки до дизайна: ingest грязных епабов на `x/net/html` (4 класса жёстких падений импортируются; ТРИ регрессии — CDATA, self-closed raw-text, несбалансированный ruby — пойманы адверсариальным ревью сессии до лендинга и запинены байт-эталоном старого ридера, мутации 17/17) + вырезание промпт-комментариев с провода (SHA по канонической форме; 9/10 файлов байт-неподвижны; снапшоты книг не двигаются, терминолог перекупает батчи один раз — центы). Приёмка исполнением оркестратора: независимый дампер old-vs-new — 205 документов трёх епабов стенда, дифф глав и ruby ПУСТ; сьют `-race` 14/14; 4/4 спот-мутации красные; посылки записки перемерены (126 itemref, не 958 · 8/8 книг стенда txt · 2/252 сущности — независимым стеком). chunkerVersion НЕ поднят — ратифицировано D39.54 (экспозиция — пустое множество; строгая буква — одна строка за подписью владельца). Отчёт с ревью-шапкой: `archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md`. Бэклог: 14а/14б сняты, добавлена 34а (``-дыра, до-паковая). Пак-19 фаза 1 (дизайн) — следующий шаг той же сессии; жду `PACK19_DESIGN_*` и эхо-блок релеем. + ## Полигон — ПАКЕТ-7 Z4-ДОБОР + ФАЗА B ИСПОЛНЕНЫ ($0.31030 из потолка $2.60): БКРС взят, промпт-правка найдена, слепая оценка ОТБРАКОВАНА своим же контролем, 26.07 Отчёт: `archive/reports/POLYGON_TERM_RESEARCH_B_2026-07-26.md`. Код: `eval/pkg7/` (+`bkrs_lookup` · `build_termset` · `terminologist_arms` · `blind_judge` · `repair_judge` · `score_bws`). **Добор ($0):** (1) **БКРС ВЗЯТ** — страница не JS-рендерится, а ставит куки-челлендж `ca=