package pipeline import ( "archive/zip" "bytes" "encoding/xml" "fmt" "io" "net/url" "os" "path" "strings" ) // ingest.go: the шаг-3a import layer. Ingest turns a source file (txt or epub) // into an ordered list of per-chapter NORMALIZED text plus the ruby/furigana // readings captured on the way (04-unhappy §4 / D9). It is the ONLY place that // reads the source; the runner then feeds doc.Chapters to SplitChunks and persists // doc.Ruby (runner.go). It is offline and deterministic — no LLM, no time/rand — so // the whole path is $0 and reproducible. // // epub v1 is a text extraction (02-mvp:25): read the chapters in spine order, // strip tags, drop inline markup/styling. The ONE thing we do NOT drop is ruby: // basereading carries an author reading of a name/term that a // plain text export would silently lose (the documented hole, 04-unhappy §4). We // keep the BASE in the body and hand the READING to the caller for a glossary lock // (memory v2, шаг 4) — never injecting it into a prompt here (§7d). // chapterSep splits a TXT source into chapters. Form feed (U+000C) is the ASCII // "page/section break": semantically a chapter boundary, invisible in prose, and // untouched by NormalizeSource. A txt with no \f is a single chapter (Фаза-0 backward // compat: the one-file example stays one chapter). epub carries real chapter // structure in its spine, so it does not use this. const chapterSep = "\f" // RubyReading is one captured (base, reading) pair and the chapter it appeared in. // The pipeline aggregates these (min chapter, count) before persisting (runner.go). type RubyReading struct { Base string // the ruby body: the kanji/base surface form Reading string // the reading (furigana) Chapter int // 1-based chapter where this occurrence was found } // Document is the ingested source: per-chapter normalized text (in reading order) // plus every ruby occurrence found. Chapters is what SplitChunks consumes. type Document struct { Chapters []string Ruby []RubyReading } // Ingest reads a source file and returns its Document. Dispatch is by extension: // .epub → the epub reader; anything else → plain text (the example uses .txt; an // unknown extension is treated as text rather than rejected). func Ingest(p string) (*Document, error) { if strings.EqualFold(extOf(p), ".epub") { return ingestEPUB(p) } return ingestTXT(p) } // extOf returns the lower-cased file extension incl. the dot ("" if none). func extOf(p string) string { i := strings.LastIndexByte(p, '.') if i < 0 || strings.ContainsAny(p[i:], "/\\") { return "" } return strings.ToLower(p[i:]) } // ingestTXT reads a plain-text source: split on \f into chapters, NormalizeSource // each (BOM/CRLF/NFC/outer-trim). No ruby in txt. func ingestTXT(p string) (*Document, error) { raw, err := os.ReadFile(p) if err != nil { return nil, fmt.Errorf("pipeline: ingest txt: %w", err) } var chapters []string for _, part := range strings.Split(string(raw), chapterSep) { chapters = append(chapters, NormalizeSource(part)) } return &Document{Chapters: chapters}, nil } // --- epub ---------------------------------------------------------------------- // containerXML is META-INF/container.xml: it points at the OPF package file. Tags // are matched by LOCAL name (no namespace in the struct tags), so a namespaced or // prefixed container still binds. type containerXML struct { Rootfiles []struct { FullPath string `xml:"full-path,attr"` MediaType string `xml:"media-type,attr"` } `xml:"rootfiles>rootfile"` } // opfPackage is the OPF: the manifest (id→href→media-type) and the spine (the // reading order of itemref idrefs). type opfPackage struct { Manifest []struct { ID string `xml:"id,attr"` Href string `xml:"href,attr"` MediaType string `xml:"media-type,attr"` } `xml:"manifest>item"` Spine []struct { IDRef string `xml:"idref,attr"` } `xml:"spine>itemref"` } // ingestEPUB reads chapters in spine order and captures ruby. Every structural // problem (bad zip, missing container/opf, empty spine, no readable chapter) is a // loud error, never a panic (a truncated/foreign epub must not crash a run). func ingestEPUB(p string) (*Document, error) { zr, err := zip.OpenReader(p) if err != nil { return nil, fmt.Errorf("pipeline: ingest epub: open zip %s: %w", p, err) } defer zr.Close() files := map[string]*zip.File{} for _, f := range zr.File { files[path.Clean(f.Name)] = f } // 1) container.xml → OPF path. cdata, err := readZipEntry(files, "META-INF/container.xml") if err != nil { return nil, fmt.Errorf("pipeline: ingest epub: %w", err) } var container containerXML if err := xml.Unmarshal(cdata, &container); err != nil { return nil, fmt.Errorf("pipeline: ingest epub: parse container.xml: %w", err) } opfPath := "" for _, rf := range container.Rootfiles { if strings.TrimSpace(rf.FullPath) != "" { opfPath = path.Clean(rf.FullPath) break } } if opfPath == "" { return nil, fmt.Errorf("pipeline: ingest epub: container.xml has no rootfile full-path") } // 2) OPF → manifest + spine. odata, err := readZipEntry(files, opfPath) if err != nil { return nil, fmt.Errorf("pipeline: ingest epub: %w", err) } var opf opfPackage if err := xml.Unmarshal(odata, &opf); err != nil { return nil, fmt.Errorf("pipeline: ingest epub: parse opf %s: %w", opfPath, err) } if len(opf.Spine) == 0 { return nil, fmt.Errorf("pipeline: ingest epub: opf %s has an empty spine", opfPath) } byID := map[string]struct{ href, mediaType string }{} for _, it := range opf.Manifest { byID[it.ID] = struct{ href, mediaType string }{it.Href, it.MediaType} } opfDir := path.Dir(opfPath) // 3) Read each spine document in order (= one chapter). A dangling idref or a // non-(x)html item is skipped, not fatal; a spine that yields zero readable // content documents is a loud error. doc := &Document{} denseNo := 0 // matches SplitChunks: only a NON-empty chapter takes a number read := 0 for _, ref := range opf.Spine { item, ok := byID[ref.IDRef] if !ok || !isXHTML(item.mediaType, item.href) { continue } entry := resolveHref(opfDir, item.href) data, err := readZipEntry(files, entry) if err != nil { return nil, fmt.Errorf("pipeline: ingest epub: spine item %q → %s: %w", ref.IDRef, entry, err) } read++ text, ruby, err := extractXHTML(data) if err != nil { return nil, fmt.Errorf("pipeline: ingest epub: extract %s: %w", entry, err) } norm := NormalizeSource(text) doc.Chapters = append(doc.Chapters, norm) // The chapter number ruby carries MUST equal the number SplitChunks assigns // (dense — empty chapters are skipped), otherwise memory v2's since_ch is off // by the count of empty spine items (cover/nav) before the reading. A // ruby-bearing chapter is never empty (its base sits in the body), so denseNo // is well-defined here; empty chapters carry no ruby. if len(splitParagraphs(norm)) == 0 { continue } denseNo++ for i := range ruby { ruby[i].Chapter = denseNo } doc.Ruby = append(doc.Ruby, ruby...) } if read == 0 { return nil, fmt.Errorf("pipeline: ingest epub: spine resolved to no readable (x)html chapters") } return doc, nil } // isXHTML reports whether a manifest item is an (x)html content document. It // checks the media-type (parameters like "; charset=utf-8" stripped), then falls // back to the href extension for ANY unrecognized/absent type — real epubs ship // xhtml chapters typed application/xml or text/xml, so a content-document extension // is the reliable signal. A genuine non-content asset keeps its own extension // (.jpg/.css/.ncx/.svg), so the extension fallback does not misclassify it // (self-review: a present-but-unrecognized type used to silently drop the chapter). func isXHTML(mediaType, href string) bool { mt := strings.ToLower(strings.TrimSpace(mediaType)) if i := strings.IndexByte(mt, ';'); i >= 0 { mt = strings.TrimSpace(mt[:i]) } switch mt { case "application/xhtml+xml", "text/html", "application/html": return true } switch extOf(href) { case ".xhtml", ".html", ".htm": return true } return false } // resolveHref joins an OPF-relative href to the OPF directory (percent-decoded, // slash-cleaned) into a zip entry path. func resolveHref(opfDir, href string) string { if h, err := url.PathUnescape(href); err == nil { href = h } // Drop any fragment (chapter.xhtml#frag). if i := strings.IndexByte(href, '#'); i >= 0 { href = href[:i] } return path.Clean(path.Join(opfDir, href)) } // readZipEntry reads a zip entry by cleaned path. func readZipEntry(files map[string]*zip.File, name string) ([]byte, error) { f, ok := files[path.Clean(name)] if !ok { return nil, fmt.Errorf("missing zip entry %q", name) } rc, err := f.Open() if err != nil { return nil, fmt.Errorf("open zip entry %q: %w", name, err) } defer rc.Close() return io.ReadAll(rc) } // --- xhtml text + ruby extraction ---------------------------------------------- // blockTags emit a paragraph break (blank line) so the chunker's paragraph packing // survives extraction; without them a whole chapter collapses into one paragraph. var blockTags = map[string]bool{ "p": true, "div": true, "h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "li": true, "blockquote": true, "section": true, "article": true, "tr": true, "table": true, "ul": true, "ol": true, "dl": true, "dd": true, "dt": true, "pre": true, "figure": true, "figcaption": true, "header": true, "footer": true, "aside": true, "nav": true, "hr": true, "main": true, "td": true, "th": true, "caption": true, } // skipRoots are subtrees whose text is not prose (CSS/JS/metadata). var skipRoots = map[string]bool{"script": true, "style": true, "head": true} // extractXHTML strips tags to text and captures ruby. Rules: // - block tag → blank line;
→ newline (paragraph structure for the chunker); // - basereading: BASE goes to the body, READING is captured // as a (base,reading) pair, parenthesis fallbacks are dropped; // -