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:
parent
c2e36a1d7a
commit
b2d16d0adf
9 changed files with 1246 additions and 102 deletions
|
|
@ -14,9 +14,21 @@ import (
|
||||||
|
|
||||||
type Chapter struct {
|
type Chapter struct {
|
||||||
ID string
|
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
|
MType string // "" → application/xhtml+xml
|
||||||
Body string // inner xhtml of <body>
|
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)
|
// 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"?>
|
add("META-INF/container.xml", `<?xml version="1.0"?>
|
||||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
<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>
|
<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>`)
|
</package>`)
|
||||||
|
|
||||||
for _, c := range chapters {
|
for _, c := range chapters {
|
||||||
// The FILE is (x)html when its href says so, regardless of how the manifest
|
// 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.
|
// media-type labels it — real epubs mislabel xhtml as application/xml, etc. The entry
|
||||||
switch strings.ToLower(filepath.Ext(c.Href)) {
|
// 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":
|
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>
|
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>c</title><style>.x{color:red}</style></head>
|
||||||
<body>`+c.Body+`</body></html>`)
|
<body>`+c.Body+`</body></html>`)
|
||||||
default:
|
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 {
|
if err := zw.Close(); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"textmachine/backend/internal/lang"
|
"textmachine/backend/internal/lang"
|
||||||
"textmachine/backend/internal/text"
|
"textmachine/backend/internal/text"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
xunicode "golang.org/x/text/encoding/unicode"
|
xunicode "golang.org/x/text/encoding/unicode"
|
||||||
"golang.org/x/text/transform"
|
"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).
|
// skipRoots are subtrees whose text is not prose (CSS/JS/metadata).
|
||||||
var skipRoots = map[string]bool{"script": true, "style": true, "head": true}
|
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:
|
// extractXHTML strips tags to text and captures ruby. Rules:
|
||||||
// - block tag → blank line; <br> → newline (paragraph structure for the chunker);
|
// - 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
|
// - <ruby>base<rt>reading</rt></ruby>: BASE goes to the body, READING is captured
|
||||||
// as a (base,reading) pair, <rp> parenthesis fallbacks are dropped;
|
// as a (base,reading) pair, <rp> parenthesis fallbacks are dropped;
|
||||||
// - <script>/<style>/<head> subtrees are dropped.
|
// - <script>/<style>/<head> subtrees are dropped.
|
||||||
//
|
//
|
||||||
// Non-strict HTML decoding (HTMLEntity/HTMLAutoClose) tolerates the loose xhtml
|
// It runs on the x/net/html TOKENIZER (a lexer, not the tree parser): real epubs ship xhtml that
|
||||||
// real epubs ship. A non-UTF-8 declared charset is a loud error (epub v1 = UTF-8).
|
// 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.
|
// Ruby is returned with Chapter unset (0); the caller stamps the dense chapter no.
|
||||||
func extractXHTML(data []byte) (string, []RubyReading, error) {
|
func extractXHTML(data []byte) (string, []RubyReading, error) {
|
||||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
if err := checkXHTMLCharset(data); err != nil {
|
||||||
dec.Strict = false
|
return "", nil, err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
z := html.NewTokenizer(bytes.NewReader(unwrapCDATA(data)))
|
||||||
|
|
||||||
var body strings.Builder
|
var body strings.Builder
|
||||||
var ruby []RubyReading
|
var ruby []RubyReading
|
||||||
var base, reading strings.Builder
|
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
|
||||||
|
|
||||||
|
// 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 != "" {
|
||||||
|
ruby = append(ruby, RubyReading{Base: b, Reading: r})
|
||||||
|
}
|
||||||
|
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":
|
||||||
|
rubyPart = rubyRT
|
||||||
|
case name == "rp":
|
||||||
|
rubyPart = rubyRP
|
||||||
|
case name == "rb":
|
||||||
|
rubyPart = rubyBase
|
||||||
|
case name == "br" && rubyDepth == 0:
|
||||||
|
body.WriteByte('\n')
|
||||||
|
case blockTags[name]:
|
||||||
|
if rubyDepth > 0 {
|
||||||
|
flushRuby()
|
||||||
|
}
|
||||||
|
body.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
for {
|
||||||
tok, err := dec.Token()
|
tt := z.Next()
|
||||||
if err == io.EOF {
|
if tt == html.ErrorToken {
|
||||||
|
if err := z.Err(); err != io.EOF {
|
||||||
|
return "", nil, fmt.Errorf("tokenize xhtml: %w", err)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
switch tt {
|
||||||
return "", nil, err
|
case html.StartTagToken, html.SelfClosingTagToken:
|
||||||
}
|
name := tokenTagName(z)
|
||||||
switch t := tok.(type) {
|
// Keep raw text ONLY for a script/style subtree we are about to drop whole.
|
||||||
case xml.StartElement:
|
if rawTextTags[name] && (tt == html.SelfClosingTagToken || !skipRoots[name]) {
|
||||||
if skipDepth > 0 {
|
z.NextIsNotRawText()
|
||||||
skipDepth++
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
name := strings.ToLower(t.Name.Local)
|
start(name)
|
||||||
switch {
|
// A void or self-closing element has no content and no end tag of its own.
|
||||||
case skipRoots[name]:
|
if tt == html.SelfClosingTagToken || voidTags[name] {
|
||||||
skipDepth++
|
end(name)
|
||||||
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:
|
case html.EndTagToken:
|
||||||
if skipDepth > 0 {
|
end(tokenTagName(z))
|
||||||
skipDepth--
|
case html.TextToken:
|
||||||
continue
|
if skip != "" {
|
||||||
|
continue // <script>/<style>/<head> subtree — not prose
|
||||||
}
|
}
|
||||||
name := strings.ToLower(t.Name.Local)
|
text := string(z.Text())
|
||||||
switch {
|
|
||||||
case name == "ruby":
|
|
||||||
if rubyDepth > 0 {
|
|
||||||
rubyDepth--
|
|
||||||
if rubyDepth == 0 {
|
|
||||||
b := strings.TrimSpace(base.String())
|
|
||||||
r := strings.TrimSpace(reading.String())
|
|
||||||
if b != "" && r != "" {
|
|
||||||
ruby = append(ruby, RubyReading{Base: b, Reading: r})
|
|
||||||
}
|
|
||||||
base.Reset()
|
|
||||||
reading.Reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case name == "rt":
|
|
||||||
if rtDepth > 0 {
|
|
||||||
rtDepth--
|
|
||||||
}
|
|
||||||
case name == "rp":
|
|
||||||
if rpDepth > 0 {
|
|
||||||
rpDepth--
|
|
||||||
}
|
|
||||||
case blockTags[name] && rubyDepth == 0:
|
|
||||||
body.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
case xml.CharData:
|
|
||||||
if skipDepth > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text := string(t)
|
|
||||||
if rubyDepth > 0 {
|
if rubyDepth > 0 {
|
||||||
switch {
|
switch {
|
||||||
case rpDepth > 0:
|
case rubyPart == rubyRP:
|
||||||
// ruby parenthesis fallback — drop.
|
// ruby parenthesis fallback — drop.
|
||||||
case rtDepth > 0:
|
case rubyPart == rubyRT:
|
||||||
reading.WriteString(text)
|
reading.WriteString(text)
|
||||||
case strings.TrimSpace(text) == "":
|
case strings.TrimSpace(text) == "":
|
||||||
// Formatting whitespace BETWEEN ruby base segments (pretty-printed
|
// Formatting whitespace BETWEEN ruby base segments (pretty-printed
|
||||||
|
|
@ -738,6 +871,7 @@ func extractXHTML(data []byte) (string, []RubyReading, error) {
|
||||||
}
|
}
|
||||||
body.WriteString(text)
|
body.WriteString(text)
|
||||||
}
|
}
|
||||||
|
// CommentToken / DoctypeToken carry no prose — dropped.
|
||||||
}
|
}
|
||||||
return body.String(), ruby, nil
|
return body.String(), ruby, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package chunk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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 (&, ©,   …) 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) {
|
func TestIngestEPUBDanglingIdrefFailsLoud(t *testing.T) {
|
||||||
// A spine idref with no manifest item, AMONG valid chapters, must fail loud — not
|
// 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).
|
// silently drop that chapter (which would also shift every later since_ch).
|
||||||
|
|
|
||||||
161
backend/internal/pipeline/promptcomments_test.go
Normal file
161
backend/internal/pipeline/promptcomments_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,14 +88,48 @@ type PromptTemplate struct {
|
||||||
SHA256 string // hash of the raw file — part of the snapshot
|
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.
|
// LoadPromptTemplate reads and splits a template file into core-system / few-shot / user.
|
||||||
func LoadPromptTemplate(path string) (*PromptTemplate, error) {
|
func LoadPromptTemplate(path string) (*PromptTemplate, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pipeline: read prompt %s: %w", path, err)
|
return nil, fmt.Errorf("pipeline: read prompt %s: %w", path, err)
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256(raw)
|
// Comments are stripped BEFORE the split, so a ---USER---/---FEWSHOT--- line commented out
|
||||||
parts := strings.SplitN(string(raw), userSeparator, 2)
|
// 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 {
|
if len(parts) != 2 {
|
||||||
return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator))
|
return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
<!-- v2 (26.07). Фаза B полигона исполнена и принята (D39.46, 6 армов + B′): подтверждены KWIC-контексты
|
<!-- v2 (26.07): KWIC-контексты, узкое правило транскрипции, пример формата ⟦TM-CANON⟧ — обоснования
|
||||||
как ГЛАВНЫЙ рычаг (арм без них теряет 6–7 совпадений с подписью владельца из 19 при внутриармовом
|
в D39.46/47/52 и отчётах полигон-пакетов. Файл — ДАННЫЕ: в снапшот не фолдится, текст не пинят тесты. -->
|
||||||
размахе 1), требование леммы и батчи ~10 термов. Правило транскрипции оставлено в УЗКОЙ форме
|
|
||||||
(люди и места — транскрипция; предметы, приёмы, существа — по смыслу): широкая форма опровергнута
|
|
||||||
замером T (P2−P5 = +0.013 при пороге 0.05), а полное снятие правила ломает 月光蛊 и 王婆.
|
|
||||||
Блок ⟦TM-GENRE⟧ снят по D39.47 — жанрового словаря нет как класса.
|
|
||||||
Строка примера формата добавлена по замеру D39.52: без якоря ⟦TM-CANON⟧ роль срывалась в английский
|
|
||||||
(22 строки из 120), с примером на целевом письме — 0 из 120. Пример-терм 师父 не входит ни в сид, ни в
|
|
||||||
якорь: он показывает ПИСЬМО ответа, а не перевод какого-либо термина книги.
|
|
||||||
Файл остаётся ДАННЫМИ: резолвится конвенцией prompts/<пара>/<роль>.md, грузится как шаблон, в снапшот
|
|
||||||
НЕ фолдится и ни один тест не пинит его текст. Смена текста стоит переоплаты только батчей терминолога
|
|
||||||
(их адрес включает байты запроса), Go не трогается. -->
|
|
||||||
|
|
||||||
Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}».
|
Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}».
|
||||||
Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}.
|
Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}.
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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` (ревью-шапка). Осей нет: снапшоты не двигаются; редакторская волна перекупается один раз только у книги, где банк реально изменится (иноязычная строка/кавычечные дубли).
|
**Приёмка:** 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.
|
**Находка стройки, решение задефолчено:** `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-сущностью без `;` либо `⟨`/`⟩` — единственные 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`.
|
||||||
|
|
|
||||||
422
docs/archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md
Normal file
422
docs/archive/reports/SMALLPACK_INGEST_PROMPTS_2026-07-30.md
Normal 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 ` ` в корпусе. Ошибок извлечения ноль в обеих
|
||||||
|
реализациях (корпус стенда чистый — классы (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 & G B"`). Он оказался частным случаем более широкого
|
||||||
|
класса: отдельная сверка **всех 252 имён `xml.HTMLEntity`** против токенайзера и сверка форм без
|
||||||
|
точки с запятой (перемер третьим способом, независимо от свипа) показала ровно два класса:
|
||||||
|
|
||||||
|
**(1) Legacy-сущности без точки с запятой.** HTML5 разрешает `&`/`©`/` ` без `;`;
|
||||||
|
`encoding/xml` оставлял их буквой.
|
||||||
|
|
||||||
|
```
|
||||||
|
x& y → old "x& y" | new "x& y" x  y → old "x  y" | new "x y"
|
||||||
|
x© y → old "x© y" | new "x© y" x& y → old "x& y" | new "x& y"
|
||||||
|
AT&T → old "AT&T" | new "AT&T" ← СОВПАДАЮТ R&D → совпадают
|
||||||
|
```
|
||||||
|
|
||||||
|
Обычная проза не задета: после `&` должно стоять начало известного имени, а `T`/`D` таковым не
|
||||||
|
являются. Закреплено тестом `TestExtractXHTMLBareAmpersandInProseSurvives`.
|
||||||
|
|
||||||
|
**(2) Ровно две именованные сущности из 252 расходятся значением:**
|
||||||
|
|
||||||
|
```
|
||||||
|
⟨ xml=U+2329 (〈) tok=U+27E8 (⟨) ⟩ 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-сущность без
|
||||||
|
точки с запятой (`&`/`©`/` `…) либо `⟨`/`⟩`. Такая книга — и только епаб-книга,
|
||||||
|
уже имеющая БД, — при следующем прогоне перерезалась бы молча. На стенде таких книг нет (п.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 — это до-паковая дыра, не регрессия; кандидат в бэклог, если владелец захочет.
|
||||||
Loading…
Add table
Reference in a new issue