197 lines
8.9 KiB
Go
197 lines
8.9 KiB
Go
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
|
||
}
|