textmachine/backend/internal/pipeline/quality.go

197 lines
8.9 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)
CJKLeakChunks int `json:"cjk_leak_chunks"` // chunks the sanitizer stripped a CJK leak from
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.
CJKLeakRate float64 `json:"cjk_leak_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
}
rep := &QualityReport{BookID: r.Book.BookID}
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
}
// Aggregate the stored retrieval-state signals (glossary consistency, style breakdown, trust-gated).
for _, rs := range states {
q := chunkOf(chunkKey{rs.Chapter, rs.ChunkIdx})
q.GlossaryMisses += rs.NPostcheckMiss
q.TrustGated += rs.NTrustGatedSuppress
rep.GlossaryMisses += rs.NPostcheckMiss
rep.TrustGated += rs.NTrustGatedSuppress
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
}
seen := map[chunkKey]bool{}
for _, cs := range statuses {
k := chunkKey{cs.Chapter, cs.ChunkIdx}
if !seen[k] {
seen[k] = true
rep.TotalChunks++
}
if cs.Stage != lastStage {
continue // the exported text and the final verdict live on the final stage's row
}
// 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.CJKLeakChunks++ // a stripped chunk carried a markdown/CJK cosmetic leak (detail says which)
}
// 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
}
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.CJKLeakRate = float64(rep.CJKLeakChunks) / 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
}