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 <head> must not eat the chapter either — <body> ends it. + raw := `<html><head><meta charset="utf-8"><body><p>Тело главы.</p></body></html>` + body, _, err := extractXHTML([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(body, "Тело главы.") { + t.Fatalf("an unclosed <head> 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 <hr> or <br>. +func TestExtractXHTMLVoidTagByteParity(t *testing.T) { + for _, c := range []struct{ name, doc, want string }{ + // <hr> 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", `<html><body><p>A</p><hr/><p>B</p></body></html>`, "\n\nA\n\n\n\n\n\n\n\nB\n\n"}, + {"hr bare", `<html><body><p>A</p><hr><p>B</p></body></html>`, "\n\nA\n\n\n\n\n\n\n\nB\n\n"}, + // <br> is not a block tag: one newline, no end-tag break. + {"br self-closed", `<html><body><p>A<br/>B</p></body></html>`, "\n\nA\nB\n\n"}, + // A void child inside a skipped subtree must not unbalance the skip and eat the rest. + {"void inside style", `<html><body><p>A</p><style>x<br/>y</style><p>B</p></body></html>`, "\n\nA\n\n\n\nB\n\n"}, + {"void inside script", `<html><body><p>A</p><script>var i=0;<br/></script><p>B</p></body></html>`, "\n\nA\n\n\n\nB\n\n"}, + {"void inside head", `<html><head><meta charset="utf-8"><style>.a{}</style></head><body><p>A</p></body></html>`, "\n\nA\n\n"}, + // No <body> wrapper: nothing rescues a skip that failed to unwind, so this is what pins + // that the <head> subtree is tracked by NAME. Its void children emit no end tag, and a + // blind depth counter would still be inside <head> here and drop the prose entirely. + {"head without body, bare void", `<html><head><meta charset="utf-8"><link rel="s"></head><p>Проза.</p></html>`, "\n\nПроза.\n\n"}, + {"head without body, self-closed void", `<html><head><meta charset="utf-8"/><title>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.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 ( → "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", `

До.

& амперсанд ]]>

После.

`, + "сырой <текст> & амперсанд"}, + {"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=`; воспроизведя куку, получили **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 ` | `"жирный оба курсив хвост."` | +| содержимое `