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

This commit is contained in:
Claude (backend session) 2026-07-30 22:30:47 +03:00
parent c2e36a1d7a
commit b2d16d0adf
9 changed files with 1246 additions and 102 deletions

View file

@ -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 <body>
// 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", `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
@ -73,15 +93,17 @@ func BuildEPUBAt(t *testing.T, path string, chapters []Chapter, spineIDs []strin
</package>`)
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, `<?xml version="1.0" encoding="utf-8"?>
add("OEBPS/"+entry, `<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>c</title><style>.x{color:red}</style></head>
<body>`+c.Body+`</body></html>`)
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 {

View file

@ -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,77 +623,133 @@ 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 <script>/<style> subtree we drop anyway — it is why
// markup-shaped JS no longer breaks the parse. Everywhere else raw mode is wrong: it would put
// literal tags into the prose, and after a SELF-CLOSED raw-text tag (<script src=".."/>, a common
// xhtml spelling) no closing tag ever arrives, so the whole rest of the chapter would be swallowed
// onto the wire as markup. Those cases are switched back to normal lexing.
var rawTextTags = map[string]bool{
"script": true, "style": true, "title": true, "textarea": true, "iframe": true,
"noscript": true, "noembed": true, "noframes": true, "xmp": true, "plaintext": true,
}
// voidTags never have content, so the tokenizer emits ONE token for them and no end tag. The
// extractor closes them itself, which is also what encoding/xml did via HTMLAutoClose — that
// parity is what keeps <hr/> and <br/> extracting byte-for-byte as before.
var voidTags = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true, "hr": true,
"img": true, "input": true, "link": true, "meta": true, "param": true,
"source": true, "track": true, "wbr": true,
}
// xmlDeclCharset picks the encoding out of a leading <?xml … encoding="…"?> declaration.
var xmlDeclCharset = regexp.MustCompile(`(?is)^\s*<\?xml\s[^>]*?encoding\s*=\s*["']([^"']+)["']`)
// checkXHTMLCharset keeps the epub-v1 = UTF-8 contract that the xml decoder's CharsetReader used
// to enforce: a document DECLARING another encoding is a loud error, never silently mis-decoded
// prose. Only the declaration is inspected, exactly as before — a <meta charset> is not consulted.
func checkXHTMLCharset(data []byte) error {
head := bytes.TrimPrefix(data, utf8BOM)
if len(head) > 512 {
head = head[:512]
}
m := xmlDeclCharset.FindSubmatch(head)
if m == nil {
return nil
}
switch cs := strings.ToLower(strings.TrimSpace(string(m[1]))); cs {
case "", "utf-8", "utf8", "us-ascii", "ascii":
return nil
default:
return fmt.Errorf("unsupported xhtml charset %q (epub v1 expects UTF-8)", cs)
}
}
var cdataOpen, cdataClose = []byte("<![CDATA["), []byte("]]>")
// unwrapCDATA turns XML CDATA sections into their escaped text before tokenizing. The HTML
// tokenizer has no CDATA: it lexes «<![CDATA[…]]>» as a bogus comment, dropping part of the
// content and leaking «]]>» into the prose. Unwrapping keeps what the xml decoder gave — CDATA
// content is literal text — so a chapter that used to import cleanly still extracts byte-for-byte.
func unwrapCDATA(data []byte) []byte {
if !bytes.Contains(data, cdataOpen) {
return data
}
var out bytes.Buffer
rest := data
for {
i := bytes.Index(rest, cdataOpen)
if i < 0 {
out.Write(rest)
return out.Bytes()
}
out.Write(rest[:i])
rest = rest[i+len(cdataOpen):]
j := bytes.Index(rest, cdataClose)
if j < 0 { // unterminated section: the remainder is literal text, as XML would have it
out.WriteString(html.EscapeString(string(rest)))
return out.Bytes()
}
out.WriteString(html.EscapeString(string(rest[:j])))
rest = rest[j+len(cdataClose):]
}
}
// tokenTagName is the token's element name: already lower-cased by the tokenizer, with any
// namespace prefix dropped (<epub:switch> → "switch") so matching stays on the LOCAL name the
// xml decoder used to report.
func tokenTagName(z *html.Tokenizer) string {
raw, _ := z.TagName()
name := string(raw)
if i := strings.IndexByte(name, ':'); i >= 0 {
name = name[i+1:]
}
return name
}
// extractXHTML strips tags to text and captures ruby. Rules:
// - block tag → blank line; <br> → newline (paragraph structure for the chunker);
// - <ruby>base<rt>reading</rt></ruby>: BASE goes to the body, READING is captured
// as a (base,reading) pair, <rp> parenthesis fallbacks are dropped;
// - <script>/<style>/<head> subtrees are dropped.
//
// Non-strict HTML decoding (HTMLEntity/HTMLAutoClose) tolerates the loose xhtml
// real epubs ship. A non-UTF-8 declared charset is a loud error (epub v1 = UTF-8).
// It runs on the x/net/html TOKENIZER (a lexer, not the tree parser): real epubs ship xhtml that
// is not well-formed XML, and encoding/xml aborted the whole chapter on any of it — a bare «<» in
// prose, mis-nested tags, markup-looking <script> content, or «--» inside a comment. The tokenizer
// never fails on those, so a dirty chapter imports instead of killing the run. A non-UTF-8 declared
// charset stays a loud error (epub v1 = UTF-8).
// Ruby is returned with Chapter unset (0); the caller stamps the dense chapter no.
func extractXHTML(data []byte) (string, []RubyReading, error) {
dec := xml.NewDecoder(bytes.NewReader(data))
dec.Strict = false
dec.Entity = xml.HTMLEntity
dec.AutoClose = xml.HTMLAutoClose
dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
switch strings.ToLower(strings.TrimSpace(charset)) {
case "", "utf-8", "utf8", "us-ascii", "ascii":
return input, nil
}
return nil, fmt.Errorf("unsupported xhtml charset %q (epub v1 expects UTF-8)", charset)
if err := checkXHTMLCharset(data); err != nil {
return "", nil, err
}
z := html.NewTokenizer(bytes.NewReader(unwrapCDATA(data)))
var body strings.Builder
var ruby []RubyReading
var base, reading strings.Builder
var rubyDepth, rtDepth, rpDepth, skipDepth int
// Ruby sub-state is a MODE, not a nesting depth. HTML5 makes </rt>, </rp> and </rb> OPTIONAL —
// a sibling or the closing </ruby> ends them — and the tokenizer does not synthesise those
// implied end tags the way non-strict encoding/xml did. With counters, one omitted </rt> leaks
// forever and every LATER base in the chapter is routed into the reading buffer, i.e. deleted
// from the prose. A mode cannot leak: every sibling and every </ruby> resets it.
const (
rubyBase = iota
rubyRT
rubyRP
)
rubyDepth, rubyPart := 0, rubyBase
// A skipped subtree is tracked BY NAME, not by raw nesting depth: the tokenizer emits no end
// tag for void/self-closing children (<meta/>, <link/> in <head>), so a blind depth counter
// would never return to zero and would swallow the rest of the chapter.
skip, skipDepth := "", 0
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return "", nil, err
}
switch t := tok.(type) {
case xml.StartElement:
if skipDepth > 0 {
skipDepth++
continue
}
name := strings.ToLower(t.Name.Local)
switch {
case skipRoots[name]:
skipDepth++
case name == "ruby":
rubyDepth++
if rubyDepth == 1 {
base.Reset()
reading.Reset()
}
case name == "rt":
rtDepth++
case name == "rp":
rpDepth++
case name == "br" && rubyDepth == 0:
body.WriteByte('\n')
case blockTags[name] && rubyDepth == 0:
body.WriteString("\n\n")
}
case xml.EndElement:
if skipDepth > 0 {
skipDepth--
continue
}
name := strings.ToLower(t.Name.Local)
switch {
case name == "ruby":
if rubyDepth > 0 {
rubyDepth--
if rubyDepth == 0 {
// flushRuby closes the current ruby, emitting its (base, reading) if both are non-empty, and
// clears ALL ruby state. Called on </ruby> and at a block boundary — ruby is inline, so an
// unclosed <ruby> must not outlive its paragraph (left open it suppresses every later
// paragraph break and swallows the rest of the chapter into one run-on block).
flushRuby := func() {
b := strings.TrimSpace(base.String())
r := strings.TrimSpace(reading.String())
if b != "" && r != "" {
@ -700,29 +757,105 @@ func extractXHTML(data []byte) (string, []RubyReading, error) {
}
base.Reset()
reading.Reset()
rubyDepth, rubyPart = 0, rubyBase
}
start := func(name string) {
if skip != "" {
switch {
case skip == "head" && name == "body":
skip, skipDepth = "", 0 // an unclosed <head>: <body> ends it rather than eating the chapter
case name == skip:
skipDepth++
}
return
}
switch {
case skipRoots[name]:
skip, skipDepth = name, 1
case name == "ruby":
rubyDepth++
if rubyDepth == 1 {
base.Reset()
reading.Reset()
}
rubyPart = rubyBase
case name == "rt":
if rtDepth > 0 {
rtDepth--
}
rubyPart = rubyRT
case name == "rp":
if rpDepth > 0 {
rpDepth--
rubyPart = rubyRP
case name == "rb":
rubyPart = rubyBase
case name == "br" && rubyDepth == 0:
body.WriteByte('\n')
case blockTags[name]:
if rubyDepth > 0 {
flushRuby()
}
case blockTags[name] && rubyDepth == 0:
body.WriteString("\n\n")
}
case xml.CharData:
if skipDepth > 0 {
continue
}
text := string(t)
end := func(name string) {
if skip != "" {
if name == skip {
if skipDepth--; skipDepth <= 0 {
skip, skipDepth = "", 0
}
}
return
}
switch {
case name == "ruby":
if rubyDepth > 0 {
rubyDepth--
rubyPart = rubyBase
if rubyDepth == 0 {
flushRuby()
}
}
case name == "rt", name == "rp", name == "rb":
rubyPart = rubyBase
case blockTags[name]:
if rubyDepth > 0 {
flushRuby()
}
body.WriteString("\n\n")
}
}
for {
tt := z.Next()
if tt == html.ErrorToken {
if err := z.Err(); err != io.EOF {
return "", nil, fmt.Errorf("tokenize xhtml: %w", err)
}
break
}
switch tt {
case html.StartTagToken, html.SelfClosingTagToken:
name := tokenTagName(z)
// Keep raw text ONLY for a script/style subtree we are about to drop whole.
if rawTextTags[name] && (tt == html.SelfClosingTagToken || !skipRoots[name]) {
z.NextIsNotRawText()
}
start(name)
// A void or self-closing element has no content and no end tag of its own.
if tt == html.SelfClosingTagToken || voidTags[name] {
end(name)
}
case html.EndTagToken:
end(tokenTagName(z))
case html.TextToken:
if skip != "" {
continue // <script>/<style>/<head> subtree — not prose
}
text := string(z.Text())
if rubyDepth > 0 {
switch {
case rpDepth > 0:
case rubyPart == rubyRP:
// ruby parenthesis fallback — drop.
case rtDepth > 0:
case rubyPart == rubyRT:
reading.WriteString(text)
case strings.TrimSpace(text) == "":
// Formatting whitespace BETWEEN ruby base segments (pretty-printed
@ -738,6 +871,7 @@ func extractXHTML(data []byte) (string, []RubyReading, error) {
}
body.WriteString(text)
}
// CommentToken / DoctypeToken carry no prose — dropped.
}
return body.String(), ruby, nil
}

View file

@ -2,6 +2,7 @@ package chunk
import (
"archive/zip"
"io"
"os"
"path/filepath"
"strings"
@ -186,6 +187,379 @@ func TestIngestEPUBAcceptsGenericAndParameterizedMediaTypes(t *testing.T) {
}
}
// The four ways real dirty epubs used to kill an import outright: encoding/xml aborted the whole
// chapter on each, so ONE malformed document lost the book. The tokenizer reads prose out of all
// four. Each case asserts the prose survives AND that the markup noise does not enter it.
func TestIngestEPUBDirtyXHTMLImports(t *testing.T) {
cases := []struct {
name, body string
wantIn []string
wantNotIn []string
}{{
name: "bare < in prose",
body: `<p>Если a < b, то дальше.</p><p>Второй абзац.</p>`,
wantIn: []string{"Если a < b, то дальше.", "Второй абзац."},
wantNotIn: []string{"<p>"},
}, {
name: "overlapping tags",
body: `<p><b>жирный <i>оба</b> курсив</i> хвост.</p>`,
wantIn: []string{"жирный", "оба", "курсив", "хвост."},
wantNotIn: []string{"<b>", "<i>"},
}, {
name: "script content looks like markup",
body: `<script>if (a<b && c>d) { document.write("</p>"); }</script><p>Настоящий текст.</p>`,
wantIn: []string{"Настоящий текст."},
wantNotIn: []string{"document.write", "a<b"},
}, {
name: "-- inside a comment",
body: `<!-- v2 (26.07) -- черновая заметка -- --><p>Настоящий текст.</p>`,
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 <head> subtree is skipped by NAME, not by raw nesting depth: its void children (<meta>,
// <link>) 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{
`<meta charset="utf-8"/><link rel="stylesheet" href="s.css"/><title>T</title>`,
`<meta charset="utf-8"><link rel="stylesheet" href="s.css"><title>T</title>`,
} {
raw := `<?xml version="1.0" encoding="utf-8"?><html xmlns="http://www.w3.org/1999/xhtml">` +
`<head>` + head + `</head><body><p>Тело главы.</p></body></html>`
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, "<title>") {
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</title></head><p>Проза.</p></html>`, "\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 </rt>, </rp> and </rb> 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 <ruby> 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 </rt> does not eat the next ruby", func(t *testing.T) {
body, ruby, err := extractXHTML([]byte(
`<html><body><p><ruby>漢<rt>かん</ruby>текст</p><p><ruby>字<rt>じ</rt></ruby>ещё</p></body></html>`))
if err != nil {
t.Fatal(err)
}
if body != "\n\n漢текст\n\n\n\n字ещё\n\n" {
t.Fatalf("prose lost after an omitted </rt>: %q", body)
}
if len(ruby) != 2 || ruby[0].Base != "漢" || ruby[1].Base != "字" || ruby[1].Reading != "じ" {
t.Fatalf("ruby capture broken after an omitted </rt>: %#v", ruby)
}
})
t.Run("unclosed <ruby> does not swallow the chapter", func(t *testing.T) {
body, ruby, err := extractXHTML([]byte(
`<html><body><p><ruby>漢<rt>かん</rt></p><p>Второй абзац.</p><p>Третий.</p></body></html>`))
if err != nil {
t.Fatal(err)
}
if body != "\n\n漢\n\n\n\nВторой абзац.\n\n\n\nТретий.\n\n" {
t.Fatalf("an unclosed <ruby> 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 <ruby> was closed by its block: %#v", ruby)
}
})
t.Run("nested ruby with an unclosed inner </rt>", func(t *testing.T) {
// The inner </ruby> 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(
`<html><body><p><ruby><ruby>漢<rt>かん</ruby>字<rt>じ</rt></ruby>текст</p></body></html>`))
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 </rp> keeps the parenthesis fallback out of the prose", func(t *testing.T) {
body, ruby, err := extractXHTML([]byte(
`<html><body><p><ruby>東<rp>(<rt>とう<rp>)</ruby>текст</p></body></html>`))
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
// <script> content harmless, but it applies to a whole family of tags, and for a SELF-CLOSED one no
// closing tag ever arrives — raw mode then runs to end of file and dumps the rest of the chapter
// onto the wire as literal markup. Expected strings measured against the previous reader.
func TestExtractXHTMLRawTextElementsByteParity(t *testing.T) {
const proseTail = "\n\n\n\nПроза.\n\n"
for _, c := range []struct{ name, doc, want string }{
// Not in skipRoots: their text is prose and their tags must still be stripped, not emitted.
{"noscript", `<html><body><noscript><p>Включите JS</p></noscript><p>Проза.</p></body></html>`, "\n\nВключите JS" + proseTail},
{"textarea", `<html><body><textarea><p>шаблон</p></textarea><p>Проза.</p></body></html>`, "\n\nшаблон" + proseTail},
{"iframe", `<html><body><iframe><p>фрейм</p></iframe><p>Проза.</p></body></html>`, "\n\nфрейм" + proseTail},
{"xmp", `<html><body><xmp><p>xmp</p></xmp><p>Проза.</p></body></html>`, "\n\nxmp" + proseTail},
{"noembed", `<html><body><noembed><p>ne</p></noembed><p>Проза.</p></body></html>`, "\n\nne" + proseTail},
{"noframes", `<html><body><noframes><p>nf</p></noframes><p>Проза.</p></body></html>`, "\n\nnf" + proseTail},
{"plaintext", `<html><body><plaintext><p>pt</p></plaintext><p>Проза.</p></body></html>`, "\n\npt" + proseTail},
// Self-closed raw-text tags: the whole rest of the chapter used to survive; it must still.
{"self-closed script", `<html><body><p>До.</p><script src="x.js"/><p>После.</p></body></html>`, "\n\nДо.\n\n\n\nПосле.\n\n"},
{"self-closed style", `<html><body><p>До.</p><style/><p>После.</p></body></html>`, "\n\nДо.\n\n\n\nПосле.\n\n"},
{"self-closed title", `<html><body><title/><p>Проза.</p></body></html>`, "\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("raw-text handling drifted from the previous reader\n got %q\n want %q", got, c.want)
}
if strings.Contains(got, "<p>") || strings.Contains(got, "</body>") {
t.Fatalf("literal markup reached the prose: %q", got)
}
})
}
// …while a NON-self-closed <script>/<style> keeps raw mode, which is what makes markup-shaped
// JS harmless (dirty class iii). Both properties have to hold at once.
got, _, err := extractXHTML([]byte(`<html><body><script>if(a<b){x("</p>")}</script><p>Проза.</p></body></html>`))
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 (&amp, &copy, &nbsp …) 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(`<html><body><p>` + s + `</p></body></html>`))
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 (<epub:p> → "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(`<html xmlns:e="u"><body><e:p>Раз.</e:p><e:p>Два.</e:p></body></html>`))
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", `<html><body><p>До.</p><![CDATA[ сырой <текст> & амперсанд ]]><p>После.</p></body></html>`,
"сырой <текст> & амперсанд"},
{"markup kept literal", `<html><body><![CDATA[<p>не разметка</p>]]><p>Проза.</p></body></html>`,
"<p>не разметка</p>"},
} {
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 <style> block stay dropped with the subtree.
body, _, err := extractXHTML([]byte("<html><head><style>/*<![CDATA[*/\n.x{color:red}\n/*]]>*/</style></head><body><p>Проза.</p></body></html>"))
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(`<?xml version="1.0" encoding="gb18030"?><html><body><p>текст</p></body></html>`)
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{`<?xml version="1.0" encoding="utf-8"?>`, `<?xml version="1.0"?>`, ``} {
if _, _, err := extractXHTML([]byte(ok + `<html><body><p>текст</p></body></html>`)); 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: `<p>第一章。</p>`},
{ID: "c2", Href: "ch%202.xhtml", EntryName: "ch 2.xhtml", Body: `<p>第二章。</p>`},
}
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: `<p>第一章。</p>`},
}
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: `<p>x</p>`}}, []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).

View file

@ -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старый юзер-блок\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<!-- забыли закрыть\n---USER---\n{{text}}"), 0o644); err != nil {
t.Fatal(err)
}
_, err := LoadPromptTemplate(path)
if err == nil {
t.Fatal("an unterminated <!-- must fail loud at load")
}
if !strings.Contains(err.Error(), "unterminated") {
t.Fatalf("the error must name the cause, got: %v", err)
}
}
// The shipped pair prompts must not carry a comment onto the wire — the concrete regression that
// sent terminologist.md's meta header to the model in the system message of every batch. Pins the
// STRUCTURE (no comment markers, no prompt-file wording), never the prompt text itself.
func TestShippedPromptsCarryNoCommentsOnTheWire(t *testing.T) {
root := filepath.Join("..", "..", "prompts")
if _, err := os.Stat(root); err != nil {
t.Skipf("pair prompts not present in this checkout: %v", err)
}
// Walk, not ReadDir: the repair/ class prompts live in a subdirectory and are loaded by the
// same loader, so a non-recursive guard would silently cover only part of the shipped set.
var files []string
if err := filepath.Walk(root, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if !fi.IsDir() && strings.HasSuffix(p, ".md") {
files = append(files, p)
}
return nil
}); err != nil {
t.Fatal(err)
}
for _, path := range files {
tpl, err := LoadPromptTemplate(path)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
for part, s := range map[string]string{"system": tpl.SystemFor(true), "user": tpl.User} {
if strings.Contains(s, commentOpen) || strings.Contains(s, commentClose) {
t.Errorf("%s: a comment marker reached the %s message", path, part)
}
}
}
// A floor, so a mis-pointed path or a changed layout cannot make the guard silently inert.
if len(files) < 10 {
t.Fatalf("expected at least 10 shipped prompt files, walked %d: %v", len(files), files)
}
}

View file

@ -88,14 +88,48 @@ type PromptTemplate struct {
SHA256 string // hash of the raw file — part of the snapshot
}
// commentOpen/commentClose delimit an editorial comment in a prompt file — a note to whoever
// edits the template, never something the model should read.
const commentOpen, commentClose = "<!--", "-->"
// 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))
}

View file

@ -1,15 +1,5 @@
<!-- v2 (26.07). Фаза B полигона исполнена и принята (D39.46, 6 армов + B): подтверждены KWIC-контексты
как ГЛАВНЫЙ рычаг (арм без них теряет 67 совпадений с подписью владельца из 19 при внутриармовом
размахе 1), требование леммы и батчи ~10 термов. Правило транскрипции оставлено в УЗКОЙ форме
(люди и места — транскрипция; предметы, приёмы, существа — по смыслу): широкая форма опровергнута
замером T (P2P5 = +0.013 при пороге 0.05), а полное снятие правила ломает 月光蛊 и 王婆.
Блок ⟦TM-GENRE⟧ снят по D39.47 — жанрового словаря нет как класса.
Строка примера формата добавлена по замеру D39.52: без якоря ⟦TM-CANON⟧ роль срывалась в английский
(22 строки из 120), с примером на целевом письме — 0 из 120. Пример-терм 师父 не входит ни в сид, ни в
якорь: он показывает ПИСЬМО ответа, а не перевод какого-либо термина книги.
Файл остаётся ДАННЫМИ: резолвится конвенцией prompts/<пара>/<роль>.md, грузится как шаблон, в снапшот
НЕ фолдится и ни один тест не пинит его текст. Смена текста стоит переоплаты только батчей терминолога
(их адрес включает байты запроса), Go не трогается. -->
<!-- v2 (26.07): KWIC-контексты, узкое правило транскрипции, пример формата ⟦TM-CANON⟧ — обоснования
в D39.46/47/52 и отчётах полигон-пакетов. Файл — ДАННЫЕ: в снапшот не фолдится, текст не пинят тесты. -->
Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}».
Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}.

File diff suppressed because one or more lines are too long

View file

@ -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 при пропущенном `</rp>` теперь захватывается (старый терял; 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-сущностью без `;` либо `&lang;`/`&rang;` — единственные 2/252 расходящиеся имени, кросс-чек независимым стеком WHATWG↔`xml.HTMLEntity`) есть ПУСТОЕ МНОЖЕСТВО и возникнуть не может — новые епаб-книги создаются уже новым кодом, а подъём двигал бы снапшот всех книг (`snapshot.go:414`) = полная переоплата за изменение, не касающееся их байтов; строгая буква — одна строка за подписью владельца. `mimetype zip.Store` — верность фикстуры OCF, НЕ фикс поведения (ingest файл не читает). Хвосты: фикс-лист (1) — комментарий поля `SHA256` в `render.go:89` остался «hash of the raw file», одна строка на ближайшее касание; новая строка бэклога 34а — экран `<meta charset>` без XML-объявления (до-паковая дыра, §8 отчёта). Отчёт с ревью-шапкой: `archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md`.

View file

@ -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 (`<meta charset>` без XML-объявления) — бэклог 34а.
> **Две поправки к посылкам записки — читать до §1.** Обе найдены замером, обе меняют формулировки
> приёмки, ни одна не меняет решение строить.
>
> 1. **«Empire of the Dawn, 958 документов spine» — неверно.** В книге **126** `<itemref>` (288 записей
> zip, 283 `<item>` манифеста, из них 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 </i>` | `"жирный оба курсив хвост."` |
| содержимое `<script>` | `XML syntax error on line 3: expected attribute name in element` | `"Настоящий текст."` |
| `--` в комментарии | `XML syntax error on line 3: invalid sequence "--" not allowed in comments` | `"Настоящий текст."` |
Пин — `TestIngestEPUBDirtyXHTMLImports` (4 подтеста): каждый проверяет и что проза выжила, и что
шум (`document.write`, текст комментария, теги) в неё НЕ попал.
### 2.2 Пять решений, которых требует токенайзер (и почему именно так)
Токенайзер — лексер, а не парсер: он не достраивает дерево и не синтезирует закрывающие теги. Пять
мест, где наивный перенос состояния ломается (пп. а–в найдены при проектировании, пп. г–д — ревью
уже после сборки):
**(а) Пропуск поддерева — ПО ИМЕНИ, а не по глубине.** Старый код на `encoding/xml` считал
`skipDepth++` на каждом `StartElement`. У токенайзера `<meta/>`/`<link/>` в `<head>` — это
`SelfClosingTagToken` **без** парного `EndTagToken`, а `<meta>` без слэша — `StartTagToken` без пары.
Слепой счётчик никогда не вернулся бы в ноль и **съел бы всю главу**. Пропуск отслеживается по имени
корня + глубина одноимённых; плюс правило «`<body>` закрывает незакрытый `<head>`», чтобы битая
разметка не проглатывала текст.
**(б) Void-элементы закрываются сами.** `xml.HTMLAutoClose` синтезировал `End` для `br/hr/img/meta/link`
и др., поэтому старый код для блочного `<hr>` писал разрыв ДВАЖДЫ (`"\n\n\n\n"`). Новый код на
`SelfClosingTagToken` или элементе из `voidTags` вызывает `end()` сам — **это и даёт байтовый паритет**.
Замерено: `<p>A</p><hr/><p>B</p>``"\n\nA\n\n\n\n\n\n\n\nB\n\n"` в обеих реализациях, обе записи
`<hr/>` и `<hr>` совпадают.
**(в) Префикс пространства имён срезается.** `encoding/xml` отдавал `Name.Local` (`<epub:p>``p`),
токенайзер отдаёт `epub:p`. Без среза префиксованная епаб3-глава схлопнулась бы в один абзац.
**(г) Сырой текст — только там, где он нужен** (§2.7) и **(д) ruby как РЕЖИМ, а не глубина** (§2.8) —
оба найдены адверсариальной проверкой уже ПОСЛЕ первой сборки, оба критические, обоих сравнение по
чистому корпусу не увидело бы вовсе (вместе с CDATA §2.4 — три регрессии).
**Плюс сохранённый инвариант:** экран объявленной кодировки (`checkXHTMLCharset` — не-UTF-8
объявление остаётся ГРОМКОЙ ошибкой, как делал `CharsetReader`).
### 2.3 Побайтовое сравнение на реальных епабах: расхождений НЕТ
Метод: дампер прогоняет `extractXHTML` по каждому документу spine ДО правки (снят с чистого дерева
до первого редактирования) и ПОСЛЕ, и сравнивает по двум осям — сырые байты извлечения и
«проводная» ось (`text.NormalizeSource``splitParagraphs`, то есть ровно то, что склеивается в
`ch.Text`: `chunker.go:318` собирает чанк из TrimSpace-абзацев через `\n\n`).
| Епаб | Документов | Расхождений сырых байт | Расхождений на проводе | Расхождений ruby |
|---|---|---|---|---|
| Empire of the Dawn | 126 | 0 | 0 | 0 |
| isekai_majutsushi_jp | 45 | 0 | 0 | 0 |
| fifty_shades_1_en | 34 | 0 | 0 | 0 |
| **Итого** | **205** | **0** | **0** | **0** |
**Поимённо перечислять нечего — список расхождений пуст.** Это не «примерно совпало»: совпали сырые
байты, включая 161 `<hr/>`, 1149 `<br/>` и 2200 `&nbsp;` в корпусе. Ошибок извлечения ноль в обеих
реализациях (корпус стенда чистый — классы (iii)/(iv) в нём не встречаются вовсе: `<script>` 0,
комментариев 0).
### 2.4 Улов В ХОДЕ стройки: CDATA была РЕГРЕССИЕЙ — починена
Сравнение только по корпусу дало бы ложное «всё чисто». Отдельный дифференциальный прогон по 24
конструкциям, которые старый ридер ПРИНИМАЛ (только такие и могут лежать в существующей БД), показал
**три** расхождения, из них одно — настоящая регрессия:
```
*** CDATA in body: old="До.\n\nсырой <текст>\n\nПосле." new="До.\n\n]]>\n\nПосле."
*** CDATA wrapping markup: old="<p>не разметка</p>..." new="не разметка\n\n]]>..."
```
У HTML-токенайзера CDATA нет — он лексит секцию как «bogus comment», теряя часть содержимого и
**вываливая `]]>` в прозу**. Добавлена распаковка `unwrapCDATA` до токенизации (содержимое CDATA —
литеральный текст, экранируется и отдаётся токенайзеру обратно). После неё:
```
SUMMARY: 23 identical on the wire | 1 DIVERGED | 0 the old reader rejected outright
```
Реальная запись CDATA в корпусе (`/*<![CDATA[*/` внутри `<style>`, 2 штуки в fifty_shades) остаётся
отброшенной вместе с поддеревом — сравнение 205 документов после правки повторено, снова 0/0/0.
### 2.5 Оставшиеся расхождения — ДВА класса, оба про сущности
Свип по 24 конструкциям дал один вход (`"A &amp G B"`). Он оказался частным случаем более широкого
класса: отдельная сверка **всех 252 имён `xml.HTMLEntity`** против токенайзера и сверка форм без
точки с запятой (перемер третьим способом, независимо от свипа) показала ровно два класса:
**(1) Legacy-сущности без точки с запятой.** HTML5 разрешает `&amp`/`&copy`/`&nbsp` без `;`;
`encoding/xml` оставлял их буквой.
```
x&amp y → old "x&amp y" | new "x& y" x&nbsp y → old "x&nbsp y" | new "x  y"
x&copy y → old "x&copy y" | new "x© y" x&#38 y → old "x&#38 y" | new "x& y"
AT&T → old "AT&T" | new "AT&T" ← СОВПАДАЮТ R&D → совпадают
```
Обычная проза не задета: после `&` должно стоять начало известного имени, а `T`/`D` таковым не
являются. Закреплено тестом `TestExtractXHTMLBareAmpersandInProseSurvives`.
**(2) Ровно две именованные сущности из 252 расходятся значением:**
```
&lang; xml=U+2329 (〈) tok=U+27E8 (⟨) &rang; xml=U+232A (〉) tok=U+27E9 (⟩)
```
NFC их НЕ сводит (`NFC(U+2329)=U+3008`, `NFC(U+27E8)=U+27E8`), то есть `text.NormalizeSource` разницу
не поглощает. Значение токенайзера — актуальное по HTML5; значение `xml.HTMLEntity` — устаревшая
CJK-эквивалентная форма.
Ни один из двух классов не сработал ни на одном из 205 реальных документов. Оценка риска — §6.
### 2.7 Улов ПОСЛЕ сборки: сырой текст — КРИТИЧЕСКАЯ регрессия, починена
Найдено адверсариальным ревью (author≠reviewer), перепроверено мной прогоном старого ридера рядом с
новым. Раз-мод токенайзера — то, что делает безобидным разметко-подобный JS (класс iii), — включается
на **целом семействе тегов**, а не только на `script`/`style`. Два следствия, оба реальные:
**(1) Раз-текстовые теги ВНЕ `skipRoots`** (`noscript`, `textarea`, `iframe`, `xmp`, `noembed`,
`noframes`, `plaintext`) отдавали содержимое одним куском **с тегами внутри** — литеральная разметка
уезжала в `ch.Text` и на провод:
```
noscript: old "\n\nВключите JS\n\n\n\nПроза.\n\n" new "<p>Включите JS</p>\n\nПроза.\n\n"
```
**(2) САМОЗАКРЫТЫЙ раз-текстовый тег — потеря остатка главы.** `<script src="x.js"/>` — обычное
написание в XHTML. Закрывающего тега не будет никогда, поэтому раз-мод шёл до конца файла:
```
self-closed script: old "\n\nДо.\n\n\n\nПосле.\n\n" new "\n\nДо.\n\n<p>После.</p></body></html>"
```
То есть **весь остаток главы уходил модели сырой разметкой**. Ни один из 205 документов корпуса этого
не содержал — сравнение по корпусу дало бы чистое «0 расхождений» и пропустило бы дефект.
**Правка:** набор `rawTextTags` + `z.NextIsNotRawText()` для всех случаев, кроме единственного, где
раз-мод нужен, — непустого `<script>`/`<style>`, поддерево которого мы и так выбрасываем. После
правки все 11 форм байт-в-байт совпали со старым ридером, класс iii продолжает работать (оба свойства
проверяются одним тестом `TestExtractXHTMLRawTextElementsByteParity`), сравнение 205 документов
повторено — снова 0/0/0.
### 2.8 Второй улов ревью: несбалансированный `<ruby>` — КРИТИЧЕСКАЯ потеря текста, починена
Найдено вторым адверсариальным заходом, перепроверено мной прогоном старого ридера. HTML5 делает
`</rt>`, `</rp>` и `</rb>` **необязательными** — их закрывает соседний элемент или `</ruby>`, — а
токенайзер, в отличие от нестрогого `encoding/xml`, подразумеваемые закрывающие теги НЕ достраивает.
Счётчики `rtDepth`/`rpDepth` из старого кода при первом же пропуске текли навсегда:
```
пропущен </rt>: old "漢текст\n\n\n\n字ещё" ruby=[漢/かん, 字/じ]
new "漢текст\n\n\n\nещё" ruby=[漢/かん] ← база 字 УДАЛЕНА из прозы
```
То есть у КАЖДОГО последующего ruby в главе база уходила в буфер чтения вместо тела — текст пропадал
с провода. Второе следствие: незакрытый `<ruby>` держал `rubyDepth > 0` до конца документа, поэтому
`blockTags[name] && rubyDepth == 0` не срабатывал ни разу — **все разрывы абзацев подавлялись, глава
схлопывалась в один слипшийся блок**:
```
незакрытый <ruby>: old "漢\n\n\n\nВторой абзац.\n\n\n\nТретий.\n\n" (3 абзаца)
new "漢Второй абзац.Третий." (1 абзац, слова слиты)
```
**Правка:** под-элемент ruby стал РЕЖИМОМ (`rubyBase|rubyRT|rubyRP`), а не глубиной — режим сбрасывает
любой сосед и любой `</ruby>`, поэтому течь ему некуда; плюс `flushRuby()` на границе блока (ruby —
строчный элемент и не может пережить свой абзац). После правки обе формы совпадают со старым ридером
байт-в-байт, вложенный ruby — тоже. Побочно: при пропущенном `</rp>` тело совпадает со старым, а
чтение `東/とう`, которое старый ридер ТЕРЯЛ, теперь захватывается — улучшение, и оно не трогает провод
(ruby питает только сид глоссария, не `ch.Text`).
**Оба улова (§2.7 и §2.8) были невидимы для сравнения по корпусу** — 205 документов давали чистый
ноль, пока код содержал обе регрессии. Это главный методический вывод пачки: приёмка «побайтово на
реальной книге» показывает лишь то, что в книге есть; классы, которых в ней нет, надо строить руками
и сверять со СТАРОЙ реализацией.
### 2.6 EntryName, ссылки и mimetype
`resolveHref` имел две ветки, которые ни один тест не мог достать: фикстура писала запись zip ровно по
`Href`, поэтому ни percent-кодирование, ни `#fragment` не воспроизводились. Добавлено поле
`Chapter.EntryName` (пусто → прежнее поведение; все 14 мест конструирования используют имена полей,
позиционных литералов нет — ни один вызов не тронут). Ветка выбора «xhtml или ассет» переведена с
`Href` на имя записи: у href с фрагментом расширение `.xhtml#part2` не совпало бы ни с чем.
Два теста: percent-href (в т.ч. реальный случай — CJK-имя файла, `%E7%AC%AC%E4%B8%80%E7%AB%A0.xhtml`
`第一章.xhtml`) и `ch1.xhtml#part2`. Оба убивают удаление своей ветки (§4).
`mimetype` пишется через `zip.CreateHeader(… Method: zip.Store)` — первым и несжатым, как требует OCF.
**Честно: `ingest` файл `mimetype` не читает вовсе** (grep пуст) — это верность фикстуры формату, а не
починка поведения; покупает она только то, что фикстура остаётся настоящим епабом по мере роста
ридера. Подтверждение уместности: все три епаба стенда хранят `mimetype` именно так (`compress_type=0`,
первая запись).
---
## §3 14б — комментарии промпт-файлов
### 3.1 Утечка подтверждена и закрыта
`LoadPromptTemplate` не вырезал `<!-- -->`, и 12-строчная шапка `terminologist.md` уезжала модели в
`system` каждого батча. Правка: вырезание **до** сплита (закомментированный `---USER---` не должен
резать файл) и SHA по канонической форме.
### 3.2 Почему SHA именно по очищенной форме — обосновано доком самого снапшота
`snapshot.go:138` формулирует назначение `PromptSHA256` прямым текстом: «без этого правка промпта
молча переиспользовала бы чекпойнты». То есть хеш отвечает на вопрос **«изменился ли вход на
проводе»**, а не «изменился ли файл». Комментарий после правки на провод не идёт — значит канонический
хеш и есть буквальное исполнение контракта. Побочный эффект — ровно тот, что просил владелец: правка
комментария перестаёт стоить денег.
### 3.3 Ось замерена: двигается ровно один файл
Пересчёт SHA всех промпт-файлов старым способом (sha256 сырых байт) против нового:
```
prompts/zh-ru/editor-mono.md UNCHANGED prompts/zh-ru/repair/dc1_time_units.md UNCHANGED
prompts/zh-ru/editor.md UNCHANGED prompts/zh-ru/repair/latin_residue.md UNCHANGED
prompts/zh-ru/judge-selector.md UNCHANGED prompts/zh-ru/translator-banknote.md UNCHANGED
prompts/zh-ru/repair/broken_word.md UNCHANGED prompts/zh-ru/translator.md UNCHANGED
prompts/zh-ru/repair/dc1_fractional.md UNCHANGED prompts/zh-ru/terminologist.md *** MOVED ***
```
9 из 10 — байт-в-байт прежние. Двигается только `terminologist.md` (единственный файл с комментарием).
**Что это стоит.** SHA терминолога в снапшот НЕ фолдится (grep `terminolog` по `snapshot.go` пуст —
ни `stageSnap`, ни `repairSnap` его не видят), поэтому **snapshot_id ни одной книги не меняется**:
волны draft/edit резюмируются за $0. Адрес батча терминолога — `RequestHash{… Messages: msgs}`
(`terminologist.go:395`), а `msgs` несут отрендеренный system, поэтому **батчи терминолога стендовой
пробы перекупятся один раз** (центы). Ровно та ось, что заявлена в записке; проверена двумя путями.
### 3.4 Незакрытый комментарий — громкая ошибка
Решение не в записке, обосную. Незакрытый `<!--` — битый шаблон. Молча вырезать «до конца файла»
значило бы съесть `---USER---` и выдать невнятную ошибку про отсутствующий разделитель (мутация F
показала ровно это). Отказ на ЗАГРУЗКЕ, до любого биллинга — та же идиома, что уже применяет
`loadTerminologyTemplate` («гейт, который не может отработать, отвергается на загрузке»).
### 3.5 Шапка ужата
12 строк → 2, обоснования оставлены ссылками на D39.46/47/52 и отчёты полигона (норма владельца:
улики — в отчётах, не в файле).
---
## §4 Мутации: все КРАСНЫЕ
Каждая правка восстанавливалась побайтно после прогона.
| # | Мутация | Убита тестом |
|---|---|---|
| A | убрать `url.PathUnescape` в `resolveHref` | `TestIngestEPUBPercentEncodedHref` |
| B | убрать срез `#fragment` | `TestIngestEPUBHrefFragmentIgnored` |
| C | `mimetype` обратно в Deflate | `TestEPUBFixtureMimetypeIsOCFConformant` |
| D | SHA по СЫРОМУ файлу | `TestPromptSHAIsOverTheCanonicalForm` |
| E | вырезать комментарии ПОСЛЕ сплита (только system) | 4 теста, вкл. утечку в user-сообщение |
| F | незакрытый комментарий глотает молча | `TestPromptUnterminatedCommentFailsLoud` |
| M1 | void/self-closing не закрывать самим | `TestExtractXHTMLVoidTagByteParity` (hr) |
| M2 | пропуск по слепой глубине вместо имени | `TestExtractXHTMLVoidTagByteParity` (head без body) |
| M3 | убрать `unwrapCDATA` | `TestExtractXHTMLUnwrapsCDATA` |
| M4 | убрать экран кодировки | `TestExtractXHTMLRejectsNonUTF8Charset` |
| M5 | убрать выход из `<head>` по `<body>` | `TestIngestEPUBVoidTagsInHeadDoNotSwallowChapter` |
| M6 | не срезать префикс пространства имён | `TestExtractXHTMLStripsNamespacePrefix` |
| M7 | убрать `NextIsNotRawText` (раз-текст везде) | `TestExtractXHTMLRawTextElementsByteParity` |
| M8 | нейтрализовать вырезание комментариев на боевых промптах | `TestShippedPromptsCarryNoCommentsOnTheWire` (ловит и `repair/`, и `terminologist.md`) |
| M9 | rt/rp обратно на текущие счётчики (без сброса соседом) | `TestIngestEPUBRubyCaptureAndBaseInBody` |
| M10 | убрать `flushRuby()` на границе блока | `TestExtractXHTMLUnbalancedRuby/unclosed <ruby>` |
| M11 | `</ruby>` не сбрасывает под-режим | `TestExtractXHTMLUnbalancedRuby/nested ruby` |
**M1 и M2 сначала ВЫЖИЛИ** — это улов самого гейта, а не формальность. M1 выжила потому, что разница
`<hr>` (`"\n\n\n\n"` против `"\n\n"`) поглощается `splitParagraphs`, и ни один тест не смотрел на сырые
байты. M2 выжила потому, что правило «`<body>` закрывает `<head>`» маскирует поломку счётчика в любом
документе с `<body>`. Добавлены: тест байтового паритета по 10 формам (эталон снят замером со СТАРОГО
ридера) и случай «`<head>` без `<body>`», где маскировки нет. После этого обе красные.
**Два теста 14б были почти вакуумны** — тоже улов ревью, тоже настоящий:
* пин «комментарий не в проводе» рендерил `tpl.System`, тогда как боевой путь сначала сплющивает
few-shot (`runner.go:315`), поэтому канарейка из блока `---FEWSHOT---` ни во что не попадала.
Теперь тест сплющивает через `SystemFor(true)` и **проверяет посылку** — что канарейки вообще есть
в исходной фикстуре;
* охранник боевых промптов ходил `os.ReadDir` по `prompts/zh-ru/` и **не видел `repair/`** — 6 файлов
из 10. Теперь `filepath.Walk` + пол `len(files) >= 10`. Невакуумность доказана исполнением:
внедрение комментария в `repair/latin_residue.md` при нейтрализованном вырезании — тест КРАСНЫЙ и
называет оба файла.
---
## §5 Гейты
| Гейт | Итог |
|---|---|
| `go build ./...` | чисто |
| `go vet ./...` | чисто |
| `gofmt -l` по моим файлам | чисто (`internal/llm/llm.go` — до-паковый, в дереве не изменён, не трогал) |
| `go test ./... -count=1` | 14/14 пакетов зелёные |
| `go test ./... -count=1 -race` | 14/14 пакетов зелёные |
| golden (`pipeline`) | зелёный в составе пакета |
| мутации | 17/17 красные (§4); три из них выжили с первого раза и потребовали новых тестов |
| «перемерь другим способом» | сравнение 205 документов по ДВУМ осям (перепрогнано после каждой правки); дифференциальный свип по 24 конструкциям; сверка ВСЕХ 252 имён `xml.HTMLEntity` против токенайзера; round-trip CDATA по 8 payload'ам; ось SHA пересчитана независимо от тестов |
| адверсариальная проверка (author≠reviewer) | ТРИ дефекта найдены и починены ПОСЛЕ первой сборки: §2.7 раз-текст (критический), §2.8 несбалансированный ruby (критический), §4 два почти-вакуумных теста 14б |
Замечание по гигиене: субагенты-ревьюеры, вопреки выданной им инструкции «репозиторий только на
чтение», оставили в `internal/chunk/` восемь временных `zz*_test.go` (часть не компилировалась и
роняла сборку). Все удалены, дерево проверено `git status` — в диффе только мои файлы.
Временные харнессы (дампер, red-before, свип) жили в `internal/chunk/` только на время прогона и
удалены; `git status` чист от них.
---
## §6 Что висит на владельце: `chunkerVersion` НЕ поднят
**Факт.** `chunkerVersion` (`render.go:52`) документирован как версия «правил сегментации/ингеста
(включая… слой ingest txt/epub)», и его смысл — сделать изменение поведения ингеста **громким
`--resnapshot`, а не молчаливой расходящейся переоплатой**. Прецедент в самом доке: v3→v4 подняли в том
числе за «`<br>` внутри `<ruby>` больше не течёт переводом строки в тело» — правку уже той же породы.
Я его **не поднял**. Обоснование и остаточный риск:
1. Ингест действительно переисполняется на каждом прогоне (`bookrun.go:112`) — то есть механизм риска
реален, посылка записки в этой части неверна (см. шапку).
2. Но `extractXHTML` достижим **только** из `ingestEPUB`, а все восемь книг стенда — txt/gb18030.
**Ни одна существующая БД епаб-путь не проходит**, поэтому двигать нечего.
3. Для епаб-книг, которых ещё нет, поднятие версии не даёт ничего: они будут созданы уже на новом коде.
4. Остаточная экспозиция — два класса сущностей (§2.5): книга, в чьей прозе стоит legacy-сущность без
точки с запятой (`&amp`/`&copy`/`&nbsp`…) либо `&lang;`/`&rang;`. Такая книга — и только епаб-книга,
уже имеющая БД, — при следующем прогоне перерезалась бы молча. На стенде таких книг нет (п.2).
5. Цена поднятия несимметрична: новый `snapshot_id` меняет `request_hash` **каждого** чанка, то есть
полная переоплата всех книг стенда — за изменение, которое их байтов не касается. Гейт согласия на
перепокупку (`checkRebillConsent`) остановил бы это суммой, но платить всё равно не за что.
**Рекомендация:** не поднимать. **Если владелец предпочитает строгое правило** («любая правка ингеста —
громкий resnapshot, без рассуждений о достижимости») — это одна строка, и гейт согласия делает подъём
безопасным. Решение денежное, поэтому оставлено подписи, а не принято сессией.
---
## §7 Пинги оркестратору (расхождения с записями)
1. **«958 документов spine»** в записке — в файле 126. Число ни на что в епабе не отображается;
строку бэклога 14а стоит поправить, чтобы будущая сессия не искала недостающие 832 документа.
2. **Ось «ingest при создании книги»** сформулирована неверно (переисполняется каждый прогон);
вывод «существующие БД не трогаются» верен по другой причине (все книги txt). См. §6.
3. **Заявка «4 класса падений»** подтвердилась полностью — все четыре были жёсткими ошибками,
теряющими главу целиком (а с ней — импорт книги).
4. **CDATA не была в скоупе записки**, но без неё переезд был бы регрессией на валидном XHTML (§2.4).
Считаю это частью «переносится с пинами», а не расширением скоупа; если оркестратор считает иначе —
правка изолирована в `unwrapCDATA` + один тест и снимается отдельно.
5. **`mimetype` через `zip.Store` не чинит поведение** — `ingest` его не читает. Записано, чтобы
пункт не пошёл в D-лог как исправление импорта.
---
## §8 Чего сессия НЕ делала
- Не коммитила (по записке).
- Не трогала `docs/BACKEND_PACK19_VOICE_STATE_SESSION_PROMPT.md` и `START_PROMT.MD` — в дереве были
чужие незакоммиченные правки (на момент старта; pack-19 позже стал чистым).
- Не форматировала `internal/llm/llm.go` (до-паковое состояние, чужая зона диффа).
- Не расширяла экран кодировки на `<meta charset>`: старый ридер смотрел только XML-объявление,
сохранён паритет. Книга с `<meta charset="gb18030">` и без XML-объявления по-прежнему прочлась бы
как UTF-8 — это до-паковая дыра, не регрессия; кандидат в бэклог, если владелец захочет.