package pipeline import ( "context" "fmt" "os" "textmachine/backend/internal/store" ) // mining.go: bank-mining stop boundary (WS3 wired live, R1). Between the drafts done and the edit wave, the // miner scores WHICH-candidates over the the draft wave source (the detector is offline — it reads the SOURCE, not the // drafts; drafts only mark that a chapter was reached) against the general-zh contrast, emits an // alias-clustered seed-delta (default B, WHICH-only — the dst is the owner's to attach via the banknote at // sign time), and — on a NON-EMPTY delta — writes the owner SIGNATURE MAP and STOPS before the edit wave. The owner // reviews it, promotes terms into the mined-delta file (approved + dst), and re-runs: seedGlossary loads // those as Source:mined (moving only edit-wave snapshot), the draft wave resumes at $0, the bank-mining stop re-mines (the now-seeded terms are // excluded → the delta empties), and the edit wave runs. Mining is OFF (auto-continue) unless a langpack AND a contrast // artifact are both configured — so every $0 test / the golden fixture (no contrast) auto-continues. // signatureMapPath is where the bank-mining stop writes the mined-delta YAML for owner sign — beside the project DB, so it // travels with the book state (never in git, like the DB). Deterministic (no time/rand). func (r *Runner) signatureMapPath() string { return r.Book.ProjectDB + ".mined-signature.yaml" } // runBankMiningStop executes the bank-mining stop. Returns stopped=true (with the signature map written and // r.lastMinedCount set) when the miner proposes a non-empty delta; stopped=false (auto-continue) when mining // is unconfigured or the delta is empty. $0 to providers (the detector is deterministic + offline). func (r *Runner) runBankMiningStop(ctx context.Context, chunks []Chunk) (stopped bool, err error) { if r.pack == nil || r.Pipeline.Mining.ContrastPath == "" { return false, nil // mining not configured → auto-continue to the edit wave } f, err := os.Open(r.Pipeline.Mining.ContrastPath) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop open mining contrast %s: %w", r.Pipeline.Mining.ContrastPath, err) } defer f.Close() contrast, err := LoadContrast(f) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop load mining contrast %s: %w", r.Pipeline.Mining.ContrastPath, err) } // The miner works over the memnorm-normalized SOURCE of every chunk (the candidate space matches the // glossary/GT space). Deterministic; the annotation/blurb rule is handled inside MineBank's filters. minerChunks := make([]MinerChunk, len(chunks)) for i, ch := range chunks { minerChunks[i] = MinerChunk{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, NSource: normalizeSourceKey(ch.Text)} } seed, err := r.Store.GlossaryForBook(r.Book.BookID) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop read glossary for mining: %w", err) } mined := MineBank(minerChunks, contrast, seed, frozenMinerConfig(), r.pack) r.lastMinedCount = len(mined) if len(mined) == 0 { r.Log.InfoContext(ctx, "bank-mining: empty delta, auto-continuing to the edit wave", "book", r.Book.BookID) return false, nil } // Non-empty delta → write the owner signature map (the mined seed-delta YAML) and STOP before the edit wave. yamlDelta, err := MinedDeltaYAML(mined) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop marshal mined delta: %w", err) } if err := os.WriteFile(r.signatureMapPath(), []byte(yamlDelta), 0o644); err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop write signature map %s: %w", r.signatureMapPath(), err) } r.Log.WarnContext(ctx, "bank-mining: new terms await owner signature; run STOPPED before the edit wave (review the signature map, promote terms into the mined-delta file, then resume)", "book", r.Book.BookID, "terms", len(mined), "signature_map", r.signatureMapPath()) return true, nil } // loadMinedDelta reads the owner-curated mined-delta YAML (book.MinedDelta) and stamps every entry // Source:"mined" — NOT via loadGlossarySeed (which hardcodes Source:"seed", memseed.go, moving the base // bank / draft-wave snapshot). This is the mined-write path (plan §1(в), F2): the mined terms land in the ENRICHED // bank version but NOT the base, so adding them moves ONLY edit-wave snapshot («переоплата ОДНА»). Reuses // loadGlossarySeed's parser/validation, then re-stamps the Source. Empty path → nil (no mined terms). func (r *Runner) loadMinedDelta() ([]store.GlossaryEntry, error) { if r.Book.MinedDelta == "" { return nil, nil } entries, err := loadGlossarySeed(r.Book.MinedDelta) if err != nil { return nil, fmt.Errorf("pipeline: load mined-delta %s: %w", r.Book.MinedDelta, err) } for i := range entries { entries[i].Source = "mined" // override the seed loader's Source:seed → mined (base-excluded) } return entries, nil }