textmachine/backend/internal/pipeline/quality.go

234 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"encoding/json"
"strings"
)
// quality.go: the DETERMINISTIC per-run quality-report (D39 слой 5, H5-no-in-loop-quality-signal) —
// the online/offline quality telemetry the owner asked for "from day one" (п.25/п.31), which
// research/18 §C1 #10 flagged as a NOW lever but was silently deferred to Ф2. It AGGREGATES signals
// that are ALREADY computed and stored (retrieval_state: glossary post-check misses, cheap style
// flaggers, trust-gated suppressions; chunk_status: echo/CJK/sanitizer flags) plus ONE cheap
// deterministic structural KPI (sentences per narrative paragraph — the "рубленые абзацы" claim-1
// signal) recomputed from the exported final text. It is a PURE READ-ONLY projection like Status: $0,
// no LLM, no snapshot touch, no checkpoint replay — only OBSERVABILITY, never a gate. The semantic
// span-judge (inversion/omission backstop for claim-2) is NOT here — it is research-dependent (пак-2).
// QualityReport is the whole-book per-run quality projection.
type QualityReport struct {
BookID string `json:"book_id"`
TotalChunks int `json:"total_chunks"`
// TextChunks is the number of chunks whose exported final text was available for the structural
// KPI (done or cosmetically-stripped); a flagged-empty chunk contributes no prose.
TextChunks int `json:"text_chunks"`
// ProcessedChunks is the number of chunks that REACHED the final stage (a final-stage row exists:
// ok, cosmetic-strip, or skipped-because-upstream-flagged) — the rate denominator, so echo/CJK
// rates are a bounded [0,1] fraction of processed chunks (an echo chunk is a skipped final row,
// disjoint from the exported-text set, which is why dividing by TextChunks alone could exceed 1).
ProcessedChunks int `json:"processed_chunks"`
// Claim-1 structural KPI (рубленые абзацы). MeanSentPerNarrPara ≈ 1 is choppy (one sentence per
// paragraph — the owner's exact complaint); higher is merged discourse prose. Aggregated as
// total sentences / total narrative paragraphs across the book, so it is a true book-wide mean.
NarrativeSentences int `json:"narrative_sentences"`
NarrativeParagraphs int `json:"narrative_paragraphs"`
MeanSentPerNarrPara float64 `json:"mean_sentences_per_narrative_paragraph"`
// Deterministic signal aggregates (all observability, never a disposition).
DialogueDashFlags int `json:"dialogue_dash_flags"` // Rosenthal dialogue-dash inconsistencies
GlossaryMisses int `json:"glossary_misses"` // CONFIRMED post-check misses (D10 consistency)
NumberDriftFlags int `json:"number_drift_flags"` // reflow number drift + 万/億 magnitude drift
TrustGated int `json:"trust_gated"` // lower-trust suppressions refused (seed hygiene)
CosmeticStripChunks int `json:"cosmetic_strip_chunks"` // chunks the sanitizer auto-stripped (markdown header OR CJK leak — F6: was mislabeled cjk_leak)
EchoChunks int `json:"echo_chunks"` // chunks flagged cjk_artifact (untranslated echo)
// Rates over ProcessedChunks (0..1) for the two "instant unreadability" families, so the numbers
// read as a bounded fraction of processed chunks rather than a bare count. CosmeticStripRate covers
// BOTH strip classes (a markdown-only strip is NOT a CJK leak — F6, D39.4: the old cjk_leak_rate
// counted every sanitizer_stripped chunk and read as false CJK leakage).
CosmeticStripRate float64 `json:"cosmetic_strip_rate"`
EchoRate float64 `json:"echo_rate"`
Chunks []ChunkQuality `json:"chunks,omitempty"`
}
// ChunkQuality is one chunk's per-chunk quality row (the "where did quality slip" signal).
type ChunkQuality struct {
Chapter int `json:"chapter"`
ChunkIdx int `json:"chunk_idx"`
NarrativeSentences int `json:"narrative_sentences"`
NarrativeParagraphs int `json:"narrative_paragraphs"`
DialogueDashFlags int `json:"dialogue_dash_flags"`
GlossaryMisses int `json:"glossary_misses"`
NumberDriftFlags int `json:"number_drift_flags"`
TrustGated int `json:"trust_gated"`
}
// narrativeStructure computes the claim-1 structural KPI over a FINAL Russian text: the count of
// NARRATIVE paragraphs (non-empty lines that are NOT a dialogue turn opened with a dash «—»/«–»/«-»)
// and the total sentences within them (the oracle-parity splitSentences). Dialogue turns are excluded
// (they are legitimately one short line). Deterministic and pure — the same signal the reflow lever
// is supposed to move (exp14 mean-sentences-per-paragraph), reused here as in-loop observability.
func narrativeStructure(final string) (sentences, paragraphs int) {
for _, line := range strings.Split(final, "\n") {
t := strings.TrimSpace(line)
if t == "" {
continue
}
if rs := []rune(t); rs[0] == '—' || rs[0] == '' || rs[0] == '-' {
continue // a dialogue turn — not a narrative paragraph
}
paragraphs++
sentences += len(splitSentences(t))
}
return sentences, paragraphs
}
// QualityReport builds the read-only per-run quality projection. It opens no jobs, reserves nothing,
// makes no LLM call — it reads the persisted chunk_status / retrieval_state and, for each chunk with
// an exported final text, the $0 final checkpoint to recompute the structural KPI. Safe to run
// whenever `report`/`status` are (the same exclusive-lock rule). Deterministic over the store.
// CAVEAT (same class as Status's config-drift): the exported-text signals key on the CURRENT config's
// final-stage NAME; if a config edit renamed the final stage since the run, the stored rows use the
// old name and the structural KPI / echo / CJK rates read 0 (the underlying spend/verdict rows are
// untouched — surface `status` shows the drift). A run under the same config reads correctly.
func (r *Runner) QualityReport() (*QualityReport, error) {
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
return nil, err
}
states, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
if err != nil {
return nil, err
}
// Total = the SHIPPING units (the manifest re-chunk, $0), matching status/export + the per-unit
// BookResult (R1): under the wave model the editor's final text is per EDIT UNIT, so ProcessedChunks
// (the lastStage rows, one per unit leader) and TotalChunks agree at unit granularity. The per-unit
// KPI/echo/strip signals land on the leader's edit row; a non-leader member's ChunkQuality row carries
// only its draft-side signals (trust-gated) with a 0 structural KPI — observability, never a gate.
chunks, err := r.bookChunks()
if err != nil {
return nil, err
}
units := r.outputUnits(chunks)
// GHOST guard (parity with Export/Status): a stored row whose unit-leader key is NOT in the current
// manifest (source shrank since the run) is a ghost — dropping it keeps ProcessedChunks ≤ TotalChunks
// and the echo/strip rates over live units only, instead of mixing in stale leader rows.
inManifest := map[chunkKey]bool{}
for _, u := range units {
inManifest[chunkKey{u.Chapter, u.FirstChunkIdx}] = true
}
rep := &QualityReport{BookID: r.Book.BookID, TotalChunks: len(units)}
byChunk := map[chunkKey]*ChunkQuality{}
order := []chunkKey{}
chunkOf := func(k chunkKey) *ChunkQuality {
if q := byChunk[k]; q != nil {
return q
}
q := &ChunkQuality{Chapter: k.chapter, ChunkIdx: k.chunkIdx}
byChunk[k] = q
order = append(order, k)
return q
}
// The glossary post-check GATE flips a chunk to withheld (flagged glossary_miss, empty export) at
// the CHUNK level without a chunk_status row (like Export/Status re-derive it). F5 (D39.4): the
// structural KPI must EXCLUDE these — their export is "", so counting their (withheld) text in
// TextChunks/KPI diverges from what `tmctl export` and `translate` actually ship.
gateOn := r.Pipeline.Gates.Glossary.PostcheckGate
withheld := map[chunkKey]bool{}
// Aggregate the stored retrieval-state signals (glossary consistency, style breakdown, trust-gated).
// A retrieval_state row is keyed per DRAFT chunk, so it is live iff its chunk is still in the manifest;
// a chunk that left the source is a ghost. (A leader row also carries the unit's post-check; if the
// leader survives, so does the unit.)
liveChunks := map[chunkKey]bool{}
for _, ch := range chunks {
liveChunks[chunkKey{ch.Chapter, ch.ChunkIdx}] = true
}
for _, rs := range states {
if !liveChunks[chunkKey{rs.Chapter, rs.ChunkIdx}] {
continue // ghost retrieval_state row (chunk dropped from source)
}
q := chunkOf(chunkKey{rs.Chapter, rs.ChunkIdx})
q.GlossaryMisses += rs.NPostcheckMiss
q.TrustGated += rs.NTrustGatedSuppress
rep.GlossaryMisses += rs.NPostcheckMiss
rep.TrustGated += rs.NTrustGatedSuppress
if gateOn && rs.NPostcheckMiss > 0 {
withheld[chunkKey{rs.Chapter, rs.ChunkIdx}] = true
}
if rs.NStyleFlags > 0 && rs.StyleDetail != "" {
var cg cheapGateResult
if json.Unmarshal([]byte(rs.StyleDetail), &cg) == nil {
dash := cg.DialogueDash
drift := cg.NumberDrift + cg.NumberMagnitude
q.DialogueDashFlags += dash
q.NumberDriftFlags += drift
rep.DialogueDashFlags += dash
rep.NumberDriftFlags += drift
}
}
}
// The exported-text chunks (the FINAL stage's row): echo/CJK flag rates + the structural KPI.
lastStage := ""
if n := len(r.Pipeline.Stages); n > 0 {
lastStage = r.Pipeline.Stages[n-1].Name
}
for _, cs := range statuses {
k := chunkKey{cs.Chapter, cs.ChunkIdx}
if cs.Stage != lastStage || !inManifest[k] {
continue // the final verdict lives on the final stage's row (per unit); drop ghost leader rows
}
// Every chunk that REACHED the final stage has exactly one lastStage row (ok, cosmetic-strip,
// or skipped-because-an-upstream-stage-flagged). This is the common rate denominator so the
// echo/CJK rates are a bounded fraction of processed chunks (an echo chunk is a skipped final
// row, disjoint from the exported-text set — dividing by TextChunks alone could exceed 100%).
rep.ProcessedChunks++
if cs.FlagReason == string(FlagCJKArtifact) {
rep.EchoChunks++
chunkOf(k) // ensure the chunk row exists even if it has no retrieval-state signals
}
if cs.FlagReason == string(FlagSanitizerStripped) {
rep.CosmeticStripChunks++ // a stripped chunk carried a markdown OR CJK cosmetic leak (F6)
}
// Structural KPI: recompute over the exported final text (ok, or the cosmetic-stripped export).
if cs.FinalHash == "" {
continue
}
if cs.Disposition != string(DispOK) && cs.FlagReason != string(FlagSanitizerStripped) {
continue // a dropped chunk exported nothing
}
if withheld[k] {
continue // F5: the glossary gate withheld this chunk's text — it exports nothing
}
cp, cperr := r.Store.GetCheckpoint(cs.FinalHash)
if cperr != nil {
return nil, cperr
}
if cp == nil || strings.TrimSpace(cp.ResponseText) == "" {
continue
}
sent, para := narrativeStructure(exportNormalize(cp.ResponseText))
q := chunkOf(k)
q.NarrativeSentences, q.NarrativeParagraphs = sent, para
rep.NarrativeSentences += sent
rep.NarrativeParagraphs += para
rep.TextChunks++
}
if rep.NarrativeParagraphs > 0 {
rep.MeanSentPerNarrPara = float64(rep.NarrativeSentences) / float64(rep.NarrativeParagraphs)
}
if rep.ProcessedChunks > 0 {
rep.CosmeticStripRate = float64(rep.CosmeticStripChunks) / float64(rep.ProcessedChunks)
rep.EchoRate = float64(rep.EchoChunks) / float64(rep.ProcessedChunks)
}
for _, k := range order {
rep.Chunks = append(rep.Chunks, *byChunk[k])
}
return rep, nil
}