From b2d16d0adfba5a37bbb2b480232310659756b4d1 Mon Sep 17 00:00:00 2001
From: "Claude (backend session)"
Date: Thu, 30 Jul 2026 22:30:47 +0300
Subject: [PATCH] Land small pack 14a+14b as D39.54: dirty epub ingest on the
html tokenizer with byte parity pinned, prompt comments stripped from the
wire, SHA over the canonical form
---
backend/internal/chunk/chunktest/epub.go | 36 +-
backend/internal/chunk/ingest.go | 290 ++++++++----
backend/internal/chunk/ingest_test.go | 374 ++++++++++++++++
.../internal/pipeline/promptcomments_test.go | 161 +++++++
backend/internal/pipeline/render.go | 38 +-
backend/prompts/zh-ru/terminologist.md | 14 +-
docs/PROGRESS.md | 9 +-
docs/architecture/05-decisions-log.md | 4 +
.../SMALLPACK_INGEST_PROMPTS_2026-07-30.md | 422 ++++++++++++++++++
9 files changed, 1246 insertions(+), 102 deletions(-)
create mode 100644 backend/internal/pipeline/promptcomments_test.go
create mode 100644 docs/archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md
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", `
@@ -73,15 +93,17 @@ func BuildEPUBAt(t *testing.T, path string, chapters []Chapter, spineIDs []strin
`)
for _, c := range chapters {
- // The FILE is (x)html when its href says so, regardless of how the manifest
- // media-type labels it — real epubs mislabel xhtml as application/xml, etc.
- switch strings.ToLower(filepath.Ext(c.Href)) {
+ // The FILE is (x)html when its ENTRY name says so, regardless of how the manifest
+ // media-type labels it — real epubs mislabel xhtml as application/xml, etc. The entry
+ // name (not the href) decides, because an href may be percent-encoded or fragment-bearing.
+ entry := c.entryName()
+ switch strings.ToLower(filepath.Ext(entry)) {
case ".xhtml", ".html", ".htm", ".xml":
- add("OEBPS/"+c.Href, `
+ add("OEBPS/"+entry, `
c
`+c.Body+``)
default:
- add("OEBPS/"+c.Href, c.Body) // non-xhtml asset (e.g. image bytes stand-in)
+ add("OEBPS/"+entry, c.Body) // non-xhtml asset (e.g. image bytes stand-in)
}
}
if err := zw.Close(); err != nil {
diff --git a/backend/internal/chunk/ingest.go b/backend/internal/chunk/ingest.go
index c2ec8bb8..f694d7ba 100644
--- a/backend/internal/chunk/ingest.go
+++ b/backend/internal/chunk/ingest.go
@@ -18,6 +18,7 @@ import (
"textmachine/backend/internal/lang"
"textmachine/backend/internal/text"
+ "golang.org/x/net/html"
"golang.org/x/text/encoding/simplifiedchinese"
xunicode "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
@@ -622,107 +623,239 @@ var blockTags = map[string]bool{
// skipRoots are subtrees whose text is not prose (CSS/JS/metadata).
var skipRoots = map[string]bool{"script": true, "style": true, "head": true}
+// rawTextTags are the elements whose content the tokenizer hands back verbatim instead of lexing
+// it. That is exactly what we want for a
Настоящий текст.
`,
+ 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{
+ `T`,
+ `T`,
+ } {
+ raw := `` +
+ `` + head + `
Тело главы.
`
+ 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, "") {
+ t.Fatalf("head content leaked: %q", body)
+ }
+ }
+ // An UNCLOSED must not eat the chapter either — ends it.
+ raw := `
Тело главы.
`
+ 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 carrying or .
+func TestExtractXHTMLVoidTagByteParity(t *testing.T) {
+ for _, c := range []struct{ name, doc, want string }{
+ // is a BLOCK tag: the old reader wrote a break for its start and another for its
+ // synthesised end. Both spellings must produce the same bytes.
+ {"hr self-closed", `
A
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"},
+ // is not a block tag: one newline, no end-tag break.
+ {"br self-closed", `
A B
`, "\n\nA\nB\n\n"},
+ // A void child inside a skipped subtree must not unbalance the skip and eat the rest.
+ {"void inside style", `
`, "\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", `T
Проза.
`, "\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.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 ( → "p"), and the tokenizer reports the prefixed name, so the prefix is dropped
+// before lookup. Without that an epub3 chapter using prefixed markup collapses into one paragraph.
+func TestExtractXHTMLStripsNamespacePrefix(t *testing.T) {
+ body, _, err := extractXHTML([]byte(`Раз.Два.`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if paras := splitParagraphs(text.NormalizeSource(body)); len(paras) != 2 {
+ t.Fatalf("prefixed block tags must still split paragraphs, got %d: %#v", len(paras), paras)
+ }
+}
+
+// CDATA is literal text in XHTML, and the previous xml-based reader delivered it as such. The HTML
+// tokenizer has no CDATA — left alone it drops part of the section and leaks «]]>» into the prose —
+// so the section is unwrapped first. A chapter that imported cleanly before must not silently
+// re-chunk (which on the next run would re-pay for it).
+func TestExtractXHTMLUnwrapsCDATA(t *testing.T) {
+ for _, c := range []struct{ name, doc, want string }{
+ {"literal text", `
"},
+ } {
+ 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: `
`},
+ }
+ 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=`; воспроизведя куку, получили **84 словарные статьи** и закрыли **17 из 20 UNCLEAR-строк** словаря (сильнейший класс улики, ноль поисковой квоты); ключевое: словарь «культивации» не знает (`修炼` = «совершенствовать и закалять себя»), `修士` = «**христ. монах**» ⇒ строка `修士→культиватор` в `genre-glossary.txt` спорна, `筑基` = «закладывание фундамента» ⇒ строка ПОДТВЕРЖДЕНА; (2) **Ngram перевёл спор в измерение**: корреляция «культивация»×«культиватор» = **0.9715** на 120-летнем ряду (сельхоз-омонимия доказана), «совершенствование» частотнее в 119.6×, биграмма «мир совершенствующихся» — 0 вхождений; (3) **конкуренты по ЛОКАЛЬНЫМ клонам** (подсказка владельца, `/home/ubuntu/projects/tmp`): три независимые команды сошлись на форме `src→dst+примечание` с фильтрацией по текущему чанку; у GalTransl — трёхслойный словарь с **условной пост-заменой** (`pre_src/post_dst`, `!отрицание`) и ситуационными ключами `mono`/`diag`; у AiNiee — блок «не переводить»; у **TransAgents терминологии нет вовсе (0 файлов .py)**, а Эндрю Ын прямо называет нашу задачу открытой; (4) `Silversaint` → «**Серебряный Святой / среброносец**» (АСТ, Абдуллин) — первый живой человеческий эталон; (5) **search-inside закрыт ОТРИЦАТЕЛЬНО**: всех трёх ISBN в Google Books нет ⇒ **развилка Z2 по `修炼` НЕ снята**. **Фаза B (310 вызовов, 480 консолидаций, 0 ошибок):** объективный слой — контексты решают всё (совпадение с подписью владельца на S1: `P0b` 10/15 против `P1`/`P2`/`P0` **15/15**), а различия между армами с контекстами = один-два терма из девятнадцати; **глоссарный контраст не измерился по построению — 0 из 80 термов покрыты жанровым словарём** (WHICH-канал даёт книжный канон, словарь — жанровые понятия; множества не пересекаются, это прямое следствие recall `term`=0.040 фазы A). **Главная промпт-находка:** правило «имя собственное → транскрипция» систематически уводит от решения владельца и живого переводчика — `春秋蝉`→«Чуньцючань» вместо «Весенне-осенняя цикада» у ВСЕХ армов с правилом, `Silversaint`→«Сильверсейнт» у `P2`/`P3` против изданного «Серебряный Святой», прокси-мера на ловушке 0.50 против 0.25 ⇒ **рекомендация сузить правило до антропонимов и топонимов**. **Веб-фетч в v2 роли — НЕТ**: агрегатного выигрыша нет, единственный случай пользы (`时辰`, где справка дословно содержит «был равен 2 часам») даёт СЛОВАРЬ, а словарь — это данные, они живут офлайн и не ломают инвариант №8. **Слепая оценка ОТБРАКОВАНА собственным контролем пре-регистрации:** BWS-счета плоские (весь разброс ±0.09 при шкале ±1), F1/F1b/F3/F4 сработали, leave-one-out по судье МЕНЯЕТ победителя, эффект позиционного свопа (до 0.107) БОЛЬШЕ разброса между армами, а **`gpt-5-mini` назвал подписанный владельцем перевод катастрофой 6 раз из 19** ⇒ по §A2.4 п.4 вердикты судей не выдаются, выводы стоят на объективном слое. Урок: пре-регистрация окупилась — без `GOLD` в слепой выборке мы выдали бы «P4 победил» и не узнали бы, что измеритель негоден. **На владельце:** 17 строк словаря на подпись · `修炼` остаётся V0 · правка промпта §B3.2 · вердикт по веб-фетчу · нужен ли повторный замер ДРУГИМ инструментом. ⚠ **РЕВИЗИЯ ИСПОЛНЕНИЕМ (та же сессия, после сдачи; §B6 отчёта + `eval/pkg7/audit_phaseB.py`) — четыре вывода выше ИСПРАВЛЕНЫ, читать только вместе с §B6:** (а) **отбраковка судей СНЯТА** — контроль §A2.4 п.4 был структурно неизмерим: строка `GOLD` не была уникальной ни на одном из 19 термов, а все 6 «катастроф на подписи» судья проставил РОВНО на буквах с тем же текстом, то есть вёл себя согласованно и спорил с каноном по существу; (б) **вывод «судьи не имеют разрешающей способности» ОПРОВЕРГНУТ их же данными** — 35 термов из 80 вырождены (все семь кандидатов = одна строка), а на 45 невырожденных тем же скорером F1 переворачивается 0.044→**0.122** и F1b 0.056→**0.189**, причём `P0b` встаёт ПОСЛЕДНИМ, согласно объективному слою ⇒ негоден был НАБОР, а не измеритель; (в) **у правила транскрипции НЕ БЫЛО контрольного арма** — оно лежит в общей части промпта и присутствует во всех шести армах (канон выдал `P0`, у которого правил больше всех), поэтому рекомендация §B3.2 понижена до гипотезы (проверка — арм без правила, ≈$0.02); (г) **«три независимые улики» оказались одной** — весь разрыв прокси на S5 даёт единственный терм `春秋蝉`, а `Silversaint` держится на сводке веб-поиска, не сверенной с телом издания (в сводке — «угодник-среброносец» и «AST/EKSMO») ⇒ человеческого эталона для en у нас НЕТ. **Перепроверку прошли без правок:** §B1.1 до строки · покрытие 0/80 (и 0/80 даже на ПОЛНОМ словаре §Z4-Т) ⇒ Z-B6 устоял · деньги $0.31030 из сырья · весь Ngram до знака · якоря фазы A по пину `996bade`. **РАЗВЯЗКА — фаза B′ (санкция владельца, отчёт `archive/reports/POLYGON_TERM_RESEARCH_B2_2026-07-26.md`, $0.14013 из порога $0.30, 136 вызовов, 0 ошибок; пре-регистрация записана ДО трат):** три замера по порогам, названным заранее. **T — правило транскрипции:** арм `P5` = `P2` минус ОДНА строка (проверено diff-ом: 18641 против 18556 симв.) вернул `春秋蝉` в **точный канон владельца** и дал `Silversaint`→«Серебросвят», но агрегатный прокси-сдвиг `P2−P5 = +0.013` при пороге 0.05 ⇒ по решающему правилу **широкая формулировка §B3.2 ОПРОВЕРГНУТА, узкая ПОДДЕРЖАНА**: снятие правила помогает на предметах/приёмах (`Ashdrinker`→«Пепелитель») и ВРЕДИТ на антропонимах (`王婆`→«Старуха Ван», `月光蛊` теряет вершину `蛊`), а `提刑` без правила наоборот транслитерируется; T3 пройден (`P5` 18/19, на ловушке 4/4). **J — слепая оценка на ПОЧИНЕННОМ наборе** (45 расходящихся термов, дедуп кандидатов, подложен ДЕКОЙ): оба судьи проходят контроли с запасом — декой назван лучшим **0/45**, помечен катастрофой **43 и 45 из 45**, подпись владельца — **НИ РАЗУ**, а `grok-4.3` ставит её выше всех армов (+0.625) ⇒ **отбраковка фазы B неправомерна, находка «6 катастроф на подписи» НЕ ВОСПРОИЗВОДИТСЯ**; но ранговое согласие с объективным слоем ничтожно (ρ = +0.429 / −0.107) ⇒ **ранжировать армы слепой оценкой нельзя, её роль — катастроф-экран** (H7: разрыв до декоя 1.27–1.42 против межарменного 0.20). **R — разброс прогона:** три повтора дали `P0b` 11/12/12 против `P1` 19/18/18 ⇒ **разрыв 6 термов при размахе 1 — «контексты главный рычаг» УСТАНОВЛЕНО**, заодно объяснилась аномалия «случайный глоссарий 19/19» (лежит внутри шума прогона). H6 частично: вырожденность предсказывает не подписанность, а КЛАСС терма (S2 0.13 против S1/S4 0.53). Итого по пакету-7: $0.45043 из коридора $1.20–2.60. **На владельце теперь:** Z-B3 — принять узкую формулировку как редакторское решение · Z-B5 — менять не инструмент, а сборку набора · Z-B8 (новое) — «контексты решают» можно ратифицировать как измеренное. ⚠ **РЕШЕНИЕ ВЛАДЕЛЬЦА 26.07 — ЖАНРОВЫЙ СЛОВАРЬ ОТМЕНЁН КАК КЛАСС** (амендмент к D39.42 п.1, ратификация за оркестратором; записка полигона №10): решение о переводе принадлежит подписи владельца на банк памяти, а пар-словарь навязывал бы одно видение всем книгам пары («культивация» против «совершенствования» — законно разный выбор). **Полигон согласен, и обосновали отмену наши же улики:** V0 на центральной строке `修炼` (выбор регистра, а не факт) · ложная сходимость русских глоссариев сянься (один английский первоисточник + один блогер) · покрытие **0/80** · четыре строки из шести **вообще не встречаются** в книге стенда. **Снято:** Z-B2 (17 строк на подпись) · вторая половина Z-B4 (офлайн-словарь как пар-данные — снял сам полигон: польза 1 случай из 80) · подписной статус §A4.3/§Z4-Т (переведены в справочную фактуру) · §B3.1 п.4 из рекомендации в наблюдение. **Перепрогонов НЕ требуется** — ни один вывод на словарь не опирался (`P0b`↔`P1` без словаря вовсе; `P5`↔`P2` с одинаковыми инертными строками); обесценен один арм `P0`, **$0.01362 из $0.45043** (3%). **Разматывать в паке-20 (чужая зона, список read-only):** фактор `GenreDst` в формуле консолидации `terminology.go:357-359,432-436` · `genreGlossaryMap` в `terminologist.go:130,163-166,322` · загрузчик и `GenreGlossaryFor` в `langpack.go:73-80,255-262,590-635` · тесты `langpack_test.go:394` и `terminology_test.go:230` · файл `configs/langpacks/zh-ru/genre-glossary.txt`. **ГЛАВНОЕ СЛЕДСТВИЕ:** связывающим ограничением становится recall эмиссии на классе `term` = 0.040 — `修行` встречается в книге **326 раз** и не получит соответствия НИОТКУДА (`emissionEligible` пропускает только `name|place|title`); раньше дыру теоретически прикрывал словарь. Вопрос расширения эмиссии — основной, и он согласуется с принципом владельца: даёт больше строк НА ПОДПИСЬ, ничего не решая за него. **Процессный урок:** деливерабл промта не отменяет права сессии сказать «этого делать не надо» — мы измерили основание для отмены и всё равно принесли таблицу на подпись, вместо того чтобы поднять архитектурное возражение через канал вопросов.
diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md
index c9155d61..532ea44d 100644
--- a/docs/architecture/05-decisions-log.md
+++ b/docs/architecture/05-decisions-log.md
@@ -1168,3 +1168,7 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу
**Приёмка:** M-Я1/M-Я2 красные + моя мутация (дедуп голосов) красная · предикат по живому сырью: 27 латинских строк §3.4 побайтно + **два улова сверх слепоты старой метрики** (китайские пересказы 第四代族长, 长老 — эхо-гейт и wellFormedLemma их пропускали) · регресс 16 живых строк чист, смешанная `Фан Юань (Fang Yuan)` проходит (экран судит язык, не написание) · пины общности/NormVersion/инварианта агрегации зелёные · vet/gofmt/golden чисты. Отчёт: `docs/archive/reports/PACK20_BANKNOTE_BUILD_2026-07-26.md` (ревью-шапка). Осей нет: снапшоты не двигаются; редакторская волна перекупается один раз только у книги, где банк реально изменится (иноязычная строка/кавычечные дубли).
**Находка стройки, решение задефолчено:** `hasBankSrcHan` — канал банкноты МЁРТВ для не-CJK исходника (каждая строка отвергается с parse_fail; утечка языковой семьи в общий слой, существующая, не этой стройки). Дефолт: чинить пар-слепым правилом «исходная строка непуста и встречается в тексте книги» (заодно отсекает выдуманные строки) **на холодном мини-прогоне** — версия парсера фолдится в снапшот, а холодная база платит ноль. **Холодный мини-прогон теперь несёт:** свои пять вопросов (D39.51) + пере-замер P1 + веса §C2-3 + allow_short 蛊/转 + фикс парсера банкноты + счёт «утечки алфавита» (категория A) живьём. Очередь: пак-19 → холодный мини-прогон → пак-21.
+
+## D39.54 — МАЛАЯ ПАЧКА 14а+14б ПРИНЯТА И ЗАЛЕНДЕНА ($0, +559/−94): ingest грязных епабов на токенайзере x/net/html (4 класса жёстких падений теперь импортируются; ТРИ регрессии — CDATA, self-closed raw-text, несбалансированный ruby — пойманы адверсариальным ревью сессии ДО лендинга и запинены байт-эталоном старого ридера, мутационная таблица сессии 17/17) · комментарии промпт-файлов вырезаются С ПРОВОДА до сплита, SHA по канонической форме (9/10 файлов байт-неподвижны, снапшоты книг не двигаются, терминолог перекупает батчи один раз — центы) · chunkerVersion НЕ поднят РАТИФИЦИРОВАННО (30.07, оркестратор №9). ✅
+
+**Приёмка исполнением на неподвижных копиях:** независимый дампер оркестратора old-vs-new по трём епабам стенда — 205 документов, дифф глав и ruby ПУСТ (3.29 МБ на сторону); сьют `-race -count=1` 14/14 зелёный, vet/gofmt чисты; 4/4 спот-мутации вне порядка сессии (SHA-по-сырому · нейтрализация вырезания · raw-text везде · flushRuby в no-op) красные, восстановление зелёное. Единственная ось сверх паритета — ЛУЧШЕ старого и вне провода: чтение ruby при пропущенном `` теперь захватывается (старый терял; ruby питает только сид глоссария, не `ch.Text`). **Поправки посылок записки (замер сессии, перемерено оркестратором):** Empire of the Dawn = 126 itemref, не 958; ingest переисполняется КАЖДЫЙ прогон (`bookrun.go:112`, `status.go:190`) — «существующие БД не тронуты» держится на другом факте: все 8 книг стенда txt, `extractXHTML` достижим только из `ingestEPUB`. **chunkerVersion — осознанная девиация от буквы дока (`render.go:29`):** остаточная экспозиция (епаб-БД с legacy-сущностью без `;` либо `〈`/`〉` — единственные 2/252 расходящиеся имени, кросс-чек независимым стеком WHATWG↔`xml.HTMLEntity`) есть ПУСТОЕ МНОЖЕСТВО и возникнуть не может — новые епаб-книги создаются уже новым кодом, а подъём двигал бы снапшот всех книг (`snapshot.go:414`) = полная переоплата за изменение, не касающееся их байтов; строгая буква — одна строка за подписью владельца. `mimetype zip.Store` — верность фикстуры OCF, НЕ фикс поведения (ingest файл не читает). Хвосты: фикс-лист (1) — комментарий поля `SHA256` в `render.go:89` остался «hash of the raw file», одна строка на ближайшее касание; новая строка бэклога 34а — экран `` без XML-объявления (до-паковая дыра, §8 отчёта). Отчёт с ревью-шапкой: `archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md`.
diff --git a/docs/archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md b/docs/archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md
new file mode 100644
index 00000000..61723b58
--- /dev/null
+++ b/docs/archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md
@@ -0,0 +1,422 @@
+# Малая пачка: ingest грязных епабов (14а) + комментарии промпт-файлов (14б)
+
+**База:** записка оркестратора №8 от 30.07, санкции владельца 26.07 (бэклог `docs/PROGRESS.md`, строки 14а/14б).
+**Зона:** `backend/`. **Деньги сессии: $0** — ни одного вызова провайдера, вся приёмка на локальном сырье.
+**Статус:** построено и проверено исполнением. Сессия НЕ коммитит, лендинг — оркестратора.
+
+> **Ревью-шапка (оркестратор №9, 30.07): ПРИНЯТО И ЗАЛЕНДЕНО, D39.54.** Приёмка исполнением на
+> неподвижных копиях (старая = HEAD, новая = деливерабл): собственный дампер old-vs-new по трём епабам
+> стенда — 205 документов, дифф глав и ruby ПУСТ (3.29 МБ на сторону); полный сьют `-race -count=1`
+> 14/14 зелёный, gofmt/vet чисто; четыре спот-мутации вне порядка сессии (D — SHA по сырому, M8 —
+> нейтрализация вырезания, M7 — raw-text везде, M10 — flushRuby в no-op) — все КРАСНЫЕ, восстановление
+> зелёное. Финальная сверка сессии (§2.8, несбалансированный ruby — третий улов ревью) вошла в приёмку:
+> копии сняты ПОСЛЕ фикса, мутационная таблица сессии 17/17. Посылки
+> перемерены независимо: 126 itemref (не 958) · 8/8 книг стенда txt · ровно 2 сущности из 252 расходятся
+> (кросс-чек через WHATWG-таблицу python и GOROOT-таблицу `xml.HTMLEntity`) · цитата `snapshot.go:138`
+> дословна · grep `terminolog` по `snapshot.go` пуст. Диспозиции: CDATA (§7.4) — В СКОУПЕ (без неё
+> переезд = регрессия на валидном XHTML); chunkerVersion НЕ поднят — ратифицировано D39.54 (экспозиция —
+> пустое множество); mimetype — верность формату, не фикс поведения (§7.5 принят, в D-ноте так и
+> записано). Фикс-лист (1): комментарий поля `SHA256` в `render.go:89` остался «hash of the raw file» —
+> одна строка, на ближайшее касание. Кандидат §8 (`` без XML-объявления) — бэклог 34а.
+
+> **Две поправки к посылкам записки — читать до §1.** Обе найдены замером, обе меняют формулировки
+> приёмки, ни одна не меняет решение строить.
+>
+> 1. **«Empire of the Dawn, 958 документов spine» — неверно.** В книге **126** `` (288 записей
+> zip, 283 `` манифеста, из них 154 `.jpg`). Число 958 не соответствует ничему в файле. Сравнение
+> проведено на **всех трёх** епабах стенда — 205 документов суммарно.
+> 2. **«Ingest выполняется при создании книги — существующие БД и снапшоты не трогаются» — обоснование
+> неверно, вывод верен.** `TranslateBook` вызывает `chunk.IngestEncoded` и `SplitChunks` на **КАЖДОМ
+> прогоне** (`bookrun.go:112,140`; то же в `status.go:190,194`) — источник переизвлекается и
+> перерезается каждый раз. Существующие БД защищены другим фактом: **все книги стенда — txt/gb18030**
+> (`book.yaml` восьми проектов `gu-zhenren/*`), а `extractXHTML` достижим только из `ingestEPUB`.
+> Ни одна существующая книга епаб-путь не проходит. Подробно — §6.
+
+---
+
+## §1 Что построено
+
+| № | Пункт | Где |
+|---|---|---|
+| 14а-1 | `extractXHTML` на токенайзере `x/net/html` вместо `encoding/xml` | `chunk/ingest.go` |
+| 14а-1а | Паритетные помощники: `voidTags`, `rawTextTags`, `tokenTagName`, `checkXHTMLCharset`, `unwrapCDATA`, `flushRuby` | там же |
+| 14а-2 | `Chapter.EntryName` + два теста (percent-href, `#fragment`) | `chunk/chunktest/epub.go`, `chunk/ingest_test.go` |
+| 14а-3 | `mimetype` фикстуры через `zip.Store` + тест-пин OCF | `chunk/chunktest/epub.go`, `chunk/ingest_test.go` |
+| 14б-1 | Вырезание `` ДО сплита по `---USER---` | `pipeline/render.go` (`stripPromptComments`) |
+| 14б-2 | SHA по канонической (очищенной) форме | там же, `LoadPromptTemplate` |
+| 14б-3 | Шапка `terminologist.md` ужата 12 строк → 2 | `prompts/zh-ru/terminologist.md` |
+| 14б-4 | Тест-пины + мутации | `pipeline/promptcomments_test.go` |
+
+Диффа: 5 отслеживаемых файлов +651/−97 (из них `ingest.go` +216/−70, `ingest_test.go` +374) плюс новый
+`promptcomments_test.go` (161 строка).
+
+---
+
+## §2 14а — переезд на токенайзер
+
+### 2.1 Четыре класса: КРАСНЫЕ до, ЗЕЛЁНЫЕ после
+
+Приёмка «красное-до» снята не рассуждением, а **исполнением оригинальной функции**: старый
+`extractXHTML` извлечён дословно из `git show HEAD:backend/internal/chunk/ingest.go` и прогнан по тем же
+фикстурам рядом с новым. Старый на всех четырёх — жёсткая ошибка и **пустой текст**, то есть в
+`ingestEPUB` это `return nil, err`: один грязный документ убивал импорт всей книги.
+
+| Класс | Старый `encoding/xml` | Новый токенайзер |
+|---|---|---|
+| голый `<` в прозе | `XML syntax error on line 3: expected element name after <` | `"Если a < b, то дальше.\n\n\n\nВторой абзац."` |
+| перехлёст тегов | `XML syntax error on line 3: unexpected end element ` | `"жирный оба курсив хвост."` |
+| содержимое `