textmachine/backend/internal/pipeline/cheapgates.go

546 lines
20 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"fmt"
"sort"
"strings"
"unicode"
)
// cheapgates.go: four cheap, deterministic post-check flaggers on the FINAL chunk text (04-unhappy
// §6/§9, план v3). They are OBSERVABILITY, never hard gates (like the glossary post-check's default
// flagger mode): a hit is recorded in the retrieval-state and surfaced in the report / chapter
// passport, but it never changes a chunk disposition or costs an LLM call. All four are pure and
// deterministic (no time/rand, sorted detail) so a resume re-derives identical counts.
//
// 1. dialogue-dash linter (Rosenthal §4752): direct speech marked with straight quotes or a
// hyphen/en-dash instead of the em-dash on a new line, or a chunk mixing the two styles.
// 2. yofikator: inconsistent ё — the SAME word spelled both with ё and with е (Пётр/Петр), or a
// brief ё-policy (all-ё / all-е) violated.
// 3. translit-interjection blocklist: untranslated JP/EN fillers («ара-ара», «маа», «хмф»,
// «нани», «ауч», «упс») left in the Russian output (04-unhappy §9); per-project allowlist.
// 4. 万/億 magnitude gate: a CJK myriad/hundred-million magnitude in the source whose order is not
// represented on the Russian side (三万 → «три миллиона» is a 100× error, 04-unhappy §9).
//
// FALSE POSITIVES are the main risk (quotations, stylization, homographs), so each rule is tuned
// for PRECISION over recall: it fires only on a high-confidence signal and stays silent on the
// ambiguous middle. The unit tests assert both firing AND non-firing.
// cheapGateVersion versions the rules above. It is folded into the job snapshot (like
// classifierVersion): editing any rule is a loud --resnapshot, so the reported style-flag counts
// never shift silently between runs under the same snapshot. [D15.2 note: this is a VERDICT
// version — once content-addressed resume lands it moves to verdictSnapshotID and a rule edit
// re-classifies free instead of re-paying the book.]
const cheapGateVersion = "cheapgate-v1"
// cheapGateConfig carries the brief-derived knobs: the ё-policy and the per-project allowlist of
// surfaces that look like a blocklisted interjection but are legitimate here (e.g. a character
// named «Ара»). Both come from book.yaml.
type cheapGateConfig struct {
yoPolicy string // "auto" (inconsistency only) | "all-yo" | "all-e"
allowlist map[string]bool // lower-cased surfaces exempt from the interjection blocklist
}
// cheapGateResult is the per-chunk outcome: a count per flagger plus human-readable detail lines
// (deterministic order) for the report. total() is what the passport surfaces.
type cheapGateResult struct {
DialogueDash int `json:"dialogue_dash,omitempty"`
YoInconsistent int `json:"yo,omitempty"`
TranslitInterj int `json:"translit_interj,omitempty"`
NumberMagnitude int `json:"number_magnitude,omitempty"`
Detail []string `json:"detail,omitempty"`
}
func (c cheapGateResult) total() int {
return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude
}
// runCheapGates runs all four flaggers over one chunk's source and FINAL translated text.
func runCheapGates(source, final string, cfg cheapGateConfig) cheapGateResult {
var r cheapGateResult
n, det := lintDialogueDash(final)
r.DialogueDash, r.Detail = n, append(r.Detail, det...)
n, det = lintYofikation(final, cfg.yoPolicy)
r.YoInconsistent = n
r.Detail = append(r.Detail, det...)
n, det = lintTranslitInterjections(final, cfg.allowlist)
r.TranslitInterj = n
r.Detail = append(r.Detail, det...)
n, det = lintNumberMagnitude(source, final)
r.NumberMagnitude = n
r.Detail = append(r.Detail, det...)
return r
}
// --- 1. dialogue-dash linter ----------------------------------------------------
// lintDialogueDash flags direct-speech lines opened with the wrong marker. Russian direct speech
// takes an EM-dash «—» at the line start; MTL output leaves a straight ASCII quote or a plain
// hyphen/en-dash. It fires per offending line, and additionally when a chunk MIXES em-dash speech
// with chevron-quote «…» speech (a within-chapter style clash). Precision guards: a hyphen only
// counts as a mis-set dash when followed by a space and a letter (so a hyphenated word wrap or a
// «- 1» list item does not fire); chevron lines count only under mixing (a chunk that uses «…» for
// speech throughout may be a deliberate style, but mixing it with dashes is an inconsistency).
func lintDialogueDash(text string) (int, []string) {
var emDash, hyphenLike, chevronLead int
var detail []string
for _, line := range strings.Split(text, "\n") {
t := strings.TrimLeft(line, " \t ")
rs := []rune(t)
if len(rs) == 0 {
continue
}
switch rs[0] {
case '—': // em-dash: the correct Russian dialogue marker — count only true speech shape
if dialogueShape(rs[1:]) { // «— 15 минут спустя» (dash+space+digit, a scene break) is NOT dialogue (self-review)
emDash++
}
case '"': // straight ASCII quote leading a line → Russian typography never uses these → MTL artifact
if quoteShape(rs[1:]) {
detail = append(detail, "прямая речь открыта прямой кавычкой \" вместо тире: "+preview(t))
hyphenLike++ // counted in the offending total
}
case '-', '': // hyphen / en-dash where an em-dash belongs
if dialogueShape(rs[1:]) {
detail = append(detail, "реплика через дефис/короткое тире вместо длинного «—»: "+preview(t))
hyphenLike++
}
case '«': // chevron-led line: only an issue if the chunk ALSO uses em-dash speech (mixing)
chevronLead++
}
}
n := hyphenLike
if emDash > 0 && chevronLead > 0 {
n += chevronLead
detail = append(detail, fmt.Sprintf("смешение стилей прямой речи в чанке: %d реплик через «—» и %d через «…»", emDash, chevronLead))
}
return n, detail
}
// dialogueShape reports whether the runes after a leading marker look like spoken text: an optional
// space then a letter. It filters out non-dialogue leading dashes (word wraps, «-1», bare marks).
func dialogueShape(after []rune) bool {
i := 0
for i < len(after) && (after[i] == ' ' || after[i] == ' ') {
i++
}
if i == 0 {
return false // a marker glued to the next glyph (a hyphenated fragment), not «— реплика»
}
return i < len(after) && unicode.IsLetter(after[i])
}
// quoteShape reports whether the runes after a leading quote look like spoken text: an OPTIONAL
// space then a letter («"Привет»). Unlike a dash, a quote sits directly on the first word, so no
// space is required.
func quoteShape(after []rune) bool {
i := 0
for i < len(after) && (after[i] == ' ' || after[i] == ' ') {
i++
}
return i < len(after) && unicode.IsLetter(after[i])
}
// --- 2. yofikator ---------------------------------------------------------------
// yoHomographEForms are е-spellings that are DISTINCT words from their ё-counterpart (все≠всё,
// небо≠нёбо, берет≠берёт, осел≠осёл, …). The inconsistency rule below would otherwise false-flag
// «все»+«всё» as one word spelled two ways. Expanded (self-review major) to cover the frequent
// distinct-word pairs and their common inflections; still not exhaustive — full disambiguation
// needs a ё-dictionary (deferred, B-tier). Names — the primary target (Пётр/Петр) — are never
// homographs, so they are always caught regardless.
var yoHomographEForms = map[string]bool{
"все": true, "всех": true, "всем": true, "всеми":true,
"небо": true, "небом": true,
"узнаем": true, "узнаете": true, "узнает": true,
"падеж": true, "падежа": true,
"совершенный": true, "совершенное": true, "совершенная": true, "совершенно": true, "совершенны": true,
"чем": true, "тем": true, "тема": true, "теме": true, "темы": true,
"берет": true, "берете": true, "берета": true,
"осел": true, "осла": true, "ослы": true, "ослов": true,
"мел": true, "мела": true,
"лен": true, "лена": true,
"нес": true, "несем": true, "несет": true,
"вел": true, "ведет": true, "ведем": true,
}
// lintYofikation flags inconsistent ё. Default ("auto"): the same word appears BOTH with ё and, as
// a separate token, with its exact ё→е form (Пётр/Петр) — excluding the homograph traps above.
// Policy "all-e": any ё present is a violation (the project wants no ё). Policy "all-yo": an е-form
// of a word that ALSO appears somewhere with ё is flagged (the partial-ёфикация case), same signal
// as auto — full "every word that SHOULD have ё" enforcement needs a ё-dictionary (deferred, B-tier).
func lintYofikation(text, policy string) (int, []string) {
words := tokenizeCyrillic(text)
if policy == "all-e" {
seen := map[string]bool{}
var det []string
n := 0
for _, w := range words {
if strings.ContainsRune(w, 'ё') && !seen[w] {
seen[w] = true
n++
det = append(det, "ё при политике all-e: "+w)
}
}
sort.Strings(det)
return n, det
}
// auto / all-yo: detect a word present in BOTH its ё-form and its е-form.
present := map[string]bool{}
for _, w := range words {
present[w] = true
}
seenPair := map[string]bool{}
var det []string
n := 0
for _, w := range words {
if !strings.ContainsRune(w, 'ё') {
continue
}
eForm := strings.ReplaceAll(w, "ё", "е")
if eForm == w || yoHomographEForms[eForm] {
continue // no ё, or a distinct-word homograph (все/всё) — not an inconsistency
}
if present[eForm] && !seenPair[w] {
seenPair[w] = true
n++
det = append(det, fmt.Sprintf("непоследовательная ё: %q и %q", w, eForm))
}
}
sort.Strings(det)
return n, det
}
// tokenizeCyrillic lower-cases and splits text into Cyrillic word tokens (ё kept distinct from е).
func tokenizeCyrillic(text string) []string {
var words []string
var b strings.Builder
flush := func() {
if b.Len() > 0 {
words = append(words, b.String())
b.Reset()
}
}
for _, r := range strings.ToLower(text) {
if unicode.Is(unicode.Cyrillic, r) {
b.WriteRune(r)
} else {
flush()
}
}
flush()
return words
}
// --- 3. translit-interjection blocklist -----------------------------------------
// translitInterjections is the starter blocklist (04-unhappy §9): JP/EN fillers that must be
// translated/adapted, not transliterated. Only surfaces that are NOT ordinary Russian words are
// listed (self-review majors): «ара» (a macaw, also a name) and «уму» (dative of «ум») were removed
// as high-false-positive collisions; «ара-ара» (the reduplicated JP filler) stays. A residual name
// collision is handled by the per-project allowlist. Lower-cased.
var translitInterjections = []string{
"ара-ара", "маа", "хмф", "нани", "ауч", "упс", "кья", "десу",
}
// lintTranslitInterjections counts whole-word (letter-bounded, case-insensitive) occurrences of a
// blocklisted interjection in the output, minus any surface on the per-project allowlist. Whole-word
// matching keeps «ара» from firing inside «характер»; the allowlist exempts legitimate uses.
func lintTranslitInterjections(text string, allowlist map[string]bool) (int, []string) {
low := []rune(strings.ToLower(text))
counts := map[string]int{}
for _, interj := range translitInterjections {
if allowlist[interj] {
continue
}
f := []rune(interj)
for i := 0; i+len(f) <= len(low); i++ {
if !runesEqual(low[i:i+len(f)], f) {
continue
}
if (i == 0 || !isWordRune(low[i-1])) && (i+len(f) == len(low) || !isWordRune(low[i+len(f)])) {
counts[interj]++
}
}
}
n := 0
surfaces := make([]string, 0, len(counts))
for k, c := range counts {
n += c
surfaces = append(surfaces, k)
}
sort.Strings(surfaces)
var det []string
for _, s := range surfaces {
det = append(det, fmt.Sprintf("непереведённое междометие %q ×%d", s, counts[s]))
}
return n, det
}
// isWordRune reports whether r is part of a word for boundary checks (letter or a hyphen inside a
// compound interjection like «ара-ара»). The hyphen inclusion means «ара-ара» matches as one unit
// and its inner «ара» does not double-count against a hyphen boundary.
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || r == '-'
}
// --- 4. 万/億 magnitude gate -----------------------------------------------------
// lintNumberMagnitude flags a source CJK myriad/hundred-million magnitude whose ORDER of magnitude
// is not represented on the Russian side (04-unhappy §9: 三万 → «три миллиона» is a 100× error). It
// parses each CJK numeral RUN that carries a big marker (万/萬/億/亿/兆) into an order of magnitude,
// then checks the output's magnitude coverage (Russian magnitude words expanded to a [base,base+2]
// range for their possible multiplier, plus Arabic-number orders). It fires only when the source's
// TOP order is outside every output range — a conservative, multiplier-tolerant signal that leaves
// 三億→«триста миллионов» (8 within миллион's [6,8]) silent while catching 三万→«три миллиона».
func lintNumberMagnitude(source, final string) (int, []string) {
srcOrders := cjkMagnitudeOrders(source)
if len(srcOrders) == 0 {
return 0, nil
}
maxSrc := 0
for _, o := range srcOrders {
if o > maxSrc {
maxSrc = o
}
}
if maxSrc < 4 { // only gate on 万+ magnitudes (the 万/億 concern)
return 0, nil
}
ranges := outputMagnitudeRanges(final)
if len(ranges) == 0 {
return 0, nil // no numeric magnitude in the output to compare against — silent (could be rephrased prose)
}
for _, rg := range ranges {
if maxSrc >= rg[0] && maxSrc <= rg[1] {
return 0, nil // the top source magnitude IS represented — no mismatch
}
}
return 1, []string{fmt.Sprintf("разряд источника 10^%d (万/億) не отражён в порядках величин перевода — возможна ошибка разряда (напр. 三万→«три миллиона»)", maxSrc)}
}
// cjkNumeralRunes are the characters that can form a CJK numeral expression (digits, small units,
// big markers). A maximal run of these is one candidate number.
var cjkNumeralRunes = map[rune]bool{}
func init() {
for _, r := range "0123456789零一二三四五六七八九十百千两兩万萬億亿兆" {
cjkNumeralRunes[r] = true
}
}
// cjkMagnitudeOrders returns the base-10 order of every CJK numeral run in text that contains a big
// marker (万/億/兆). Runs without a big marker are ignored (the gate is about myriad-scale разряды).
func cjkMagnitudeOrders(text string) []int {
var orders []int
rs := []rune(text)
for i := 0; i < len(rs); {
if !cjkNumeralRunes[rs[i]] {
i++
continue
}
j := i
for j < len(rs) && cjkNumeralRunes[rs[j]] {
j++
}
run := string(rs[i:j])
// A run counts only when it carries a big marker AND has an explicit DIGIT coefficient before
// it (self-review major): this excludes the common web-novel IDIOMS that are not magnitudes —
// 万一 (in case), 万分 (extremely), 万物 (all things), 千万 (by all means), 亿万 (myriads) — where 万/億
// is not preceded by a digit. It also drops bare-unit magnitudes (十万/百万) — an accepted recall
// trade for not false-flagging the far more frequent idioms.
if strings.ContainsAny(run, "万萬億亿兆") && hasDigitBeforeBigMarker(run) {
if v, ok := parseCJKNumber(run); ok && v > 0 {
orders = append(orders, orderOf(v))
}
}
i = j
}
return orders
}
// hasDigitBeforeBigMarker reports whether a digit (一-九 / 两 / 0-9) appears before the FIRST big
// marker (万/億/兆) in the run — the signature of a real magnitude expression (三万) vs an idiom (万一).
func hasDigitBeforeBigMarker(run string) bool {
for _, r := range run {
if strings.ContainsRune("万萬億亿兆", r) {
return false // hit a big marker with no digit before it → idiom / bare unit
}
if (r >= '0' && r <= '9') || strings.ContainsRune("一二三四五六七八九两兩", r) {
return true
}
}
return false
}
// parseCJKNumber parses a CJK numeral expression (mixed with Arabic digits) into its integer value.
// Standard section algorithm: small units (十百千) scale the pending coefficient into the current
// <10^4 section; big units (万億兆) flush the section times the big unit into the total. Returns
// ok=false on a shape it cannot parse (conservative — an unparseable run does not flag).
func parseCJKNumber(s string) (int64, bool) {
var total, section, cur int64
sawBig := false
for _, r := range s {
switch {
case r >= '0' && r <= '9':
cur = cur*10 + int64(r-'0')
case r == '' || r == '零':
cur = cur * 10
default:
if d, ok := cjkDigit(r); ok {
cur = cur*10 + d // positional accumulation (一二→12), matching the Arabic-digit branch (self-review)
continue
}
if u, ok := cjkSmallUnit(r); ok {
if cur == 0 {
cur = 1
}
section += cur * u
cur = 0
continue
}
if u, ok := cjkBigUnit(r); ok {
sawBig = true
section += cur
if section == 0 {
section = 1
}
total += section * u
section = 0
cur = 0
continue
}
return 0, false // an unexpected rune
}
}
if !sawBig {
return 0, false // no 万/億/兆 → not a magnitude expression this gate cares about
}
return total + section + cur, true
}
func cjkDigit(r rune) (int64, bool) {
switch r {
case '一':
return 1, true
case '二', '两', '兩':
return 2, true
case '三':
return 3, true
case '四':
return 4, true
case '五':
return 5, true
case '六':
return 6, true
case '七':
return 7, true
case '八':
return 8, true
case '九':
return 9, true
}
return 0, false
}
func cjkSmallUnit(r rune) (int64, bool) {
switch r {
case '十':
return 10, true
case '百':
return 100, true
case '千':
return 1000, true
}
return 0, false
}
func cjkBigUnit(r rune) (int64, bool) {
switch r {
case '万', '萬':
return 10000, true
case '億', '亿':
return 100000000, true
case '兆':
return 1000000000000, true
}
return 0, false
}
func orderOf(v int64) int {
o := 0
for v >= 10 {
v /= 10
o++
}
return o
}
// outputMagnitudeRanges returns the covered [minOrder,maxOrder] ranges implied by the Russian
// output. A magnitude WORD covers [base, base+2] because an unseen multiplier can lift it up to two
// orders (триста миллионов = 3·10^8, base 6 → order 8). An Arabic integer covers its own order
// exactly (grouping spaces/commas/NBSP between 3-digit groups are stitched first).
func outputMagnitudeRanges(text string) [][2]int {
low := strings.ToLower(text)
var ranges [][2]int
for stem, base := range map[string]int{"тысяч": 3, "миллион": 6, "миллиард": 9, "триллион": 12} {
if strings.Contains(low, stem) {
ranges = append(ranges, [2]int{base, base + 2})
}
}
for _, o := range arabicNumberOrders(text) {
ranges = append(ranges, [2]int{o, o})
}
return ranges
}
// arabicNumberOrders returns the order of each Arabic integer in text, stitching grouping
// separators (space / NBSP / comma) between runs of exactly three digits so "30 000" reads as one
// 5-digit number, not "30" and "000".
func arabicNumberOrders(text string) []int {
rs := []rune(text)
var orders []int
for i := 0; i < len(rs); {
if !isASCIIDigit(rs[i]) {
i++
continue
}
// Leading group.
j := i
for j < len(rs) && isASCIIDigit(rs[j]) {
j++
}
digits := j - i
// Stitch " ddd" / ",ddd" groups.
for j < len(rs) {
if (rs[j] == ' ' || rs[j] == ' ' || rs[j] == ',') && j+3 < len(rs)+1 {
k := j + 1
g := 0
for k < len(rs) && isASCIIDigit(rs[k]) {
k++
g++
}
if g == 3 {
digits += 3
j = k
continue
}
}
break
}
orders = append(orders, digits-1)
i = j
}
return orders
}
func isASCIIDigit(r rune) bool { return r >= '0' && r <= '9' }
// preview trims a long line for the human detail (deterministic).
func preview(s string) string {
rs := []rune(s)
if len(rs) > 48 {
return string(rs[:48]) + "…"
}
return s
}