textmachine/backend/internal/pipeline/voicerun.go

82 lines
3.6 KiB
Go

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)
}
}
}