package pipeline import ( "encoding/json" "textmachine/backend/internal/checks" "textmachine/backend/internal/membank" "textmachine/backend/internal/store" ) // voicerun.go: the driver half of the pack-19 flaggers — the adapter that turns the bank's chapter // projection into the checker's plain-value registry, and the two $0 calls the final wave makes. // // Both are OBSERVABILITY: neither can flag a chunk, neither touches the wire, and both are recomputed // from stored text on every run, so a resume re-derives them without re-billing anything. // voiceRegistry adapts membank's chapter projection to the checker vocabulary. Two packages, two value // types, one trivial mapping — which is what keeps checks free of store types and membank free of // checker types. Empty when the book has no voice/address content (the flagger is then inert). func voiceRegistry(mem *membank.Bank, chapter int) checks.VoiceRegistry { if mem == nil { return checks.VoiceRegistry{} } chars, addrs := mem.VoiceProjection(chapter) if len(chars) == 0 { return checks.VoiceRegistry{} } reg := checks.VoiceRegistry{Chars: make([]checks.VoiceCharacter, 0, len(chars))} for _, c := range chars { reg.Chars = append(reg.Chars, checks.VoiceCharacter{ Key: c.Key, Name: c.Name, Forms: c.Forms, SelfRef: c.SelfRef, NG: c.NG, }) } for _, a := range addrs { reg.Pairs = append(reg.Pairs, checks.VoiceAddressPair{ Speaker: a.Speaker, Addressee: a.Addressee, Register: a.Register, }) } return reg } // runVoiceChecks runs the voice flagger over one unit's shipped text, when the gate is on. A book with // the gate off, or with no voice content, gets a zero result and writes zeros — byte-identical to a run // before this existed. func (r *Runner) runVoiceChecks(mem *membank.Bank, chapter int, source, final string) checks.VoiceResult { if !r.Pipeline.Gates.Voice.Enabled || final == "" { return checks.VoiceResult{} } return checks.RunVoiceChecks(source, final, voiceRegistry(mem, chapter), r.checkers) } // spoilerLeaks checks the chapter's REJECTED bank rows against the shipped text: a rendering the spoiler // window excluded that reached the reader anyway. Unlike the voice flagger it is NOT gated — the spoiler // window is a standing safety mechanism (C1), not an opt-in measurement, and a leak is the one thing the // window exists to prevent. $0 and observability-only, like every sibling on the retrieval-state row. func (r *Runner) spoilerLeaks(mem *membank.Bank, sel membank.Selection, final string) []membank.SpoilerLeak { if mem == nil || final == "" || len(sel.Rejected) == 0 { return nil } return mem.SpoilerLeaks(sel.Rejected, final) } // setVoiceState writes the two pack-19 flagger results onto a retrieval-state row. Zero results write // zeros and an empty detail, so a gate-off book's row is byte-identical to what it was before these // columns existed. Shared by the draft-chunk and edit-unit paths so the two cannot drift. func setVoiceState(rs *store.RetrievalState, voice checks.VoiceResult, leaks []membank.SpoilerLeak) { rs.NVoiceFlags, rs.VoiceDetail = voice.Total(), "" // The detail carries the WHOLE result, not only the flagged axes: the denominators (replies, // attributed, source replies) are what make the count readable, and the axis-D indicator is reported // beside them precisely because it is excluded from the count. if voice.Total() > 0 || voice.PairRegister > 0 || voice.Replies > 0 { if b, err := json.Marshal(voice); err == nil { rs.VoiceDetail = string(b) } } rs.NSpoilerLeaks, rs.SpoilerLeakDetail = len(leaks), "" if len(leaks) > 0 { if b, err := json.Marshal(leaks); err == nil { rs.SpoilerLeakDetail = string(b) } } }