402 lines
19 KiB
Go
402 lines
19 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// sanitizer.go: the output-sanitizer (D30.3) — a deterministic verdict-axis gate on the
|
||
// FINAL chunk text that catches the "instant unreadability" defect classes NO existing
|
||
// gate detects (exp12 root-cause + flagman §5). It is OPT-IN (Gates.Sanitizer.Enabled),
|
||
// following the coverage gate's discipline: when enabled a defect flips the chunk to
|
||
// flagged (D2 flag+skip — the contaminated output never commits to TM/export), else it is
|
||
// a no-op. Pure and deterministic (no time/rand, sorted detail) so a resume re-derives the
|
||
// identical verdict — its rule version folds into the snapshot only when enabled
|
||
// (sanitizerVersion, mirroring coverageSnapshot), and moves to verdictSnapshotID once
|
||
// content-addressed resume lands (D15.2).
|
||
//
|
||
// Five classes, each tuned PRECISION over recall (fire only on a high-confidence signal;
|
||
// the ambiguous middle stays silent — a false flag turns a good chunk into an export
|
||
// placeholder, worse than a missed defect for an opt-in readability gate):
|
||
//
|
||
// 1. leading service preamble — «Вот отредактированный перевод: …» (gemini 6/6, exp12 §4.1)
|
||
// 2. trailing note/edit block — appended «Примечание:/Внесённые правки:/Что изменено:» meta
|
||
// 3. markdown ### header — «### Глава 1» (8/54 finals of stage A — D29 finding)
|
||
// 4. Latin-script insertion — an untranslated Latin phrase/heavy Latin in the ru output
|
||
// 5. broken word form — homoglyph-mixed or invalid-sign / split-duplicated tokens
|
||
//
|
||
// HONEST recall gap (documented, precision over recall): the broken-word class catches only
|
||
// the DETERMINISTIC signatures (invalid Cyrillic sign bigrams, mixed Cyrillic/Latin token,
|
||
// junction-duplicated split). A word broken WITHOUT an invalid character («Первок предок»
|
||
// split into two individually-valid tokens) or a grammatical-but-wrong form («валуне») is
|
||
// NOT offline-detectable without a morphology/dictionary pass (Ф2) — left silent.
|
||
|
||
// sanitizerVersion versions the rules below (folded into the snapshot when the gate is
|
||
// enabled, like cheapGateVersion/coverageGateVersion): editing any pattern shifts the
|
||
// resolved disposition of flagged chunks, so a bump is a loud --resnapshot. [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 sanitizerVersion = "sanitizer-v1"
|
||
|
||
// sanitizer tuning constants (versioned by sanitizerVersion).
|
||
const (
|
||
// trailingRegionLines: a note/edit header counts only when it sits in the LAST N
|
||
// non-empty lines (a meta block the model appended), so a legitimate mid-narrative
|
||
// «Примечание» in dialogue does not fire.
|
||
trailingRegionLines = 6
|
||
// latinPhraseWords: a run of this many SPACE-adjacent Latin word-tokens is a leaked
|
||
// untranslated phrase/sentence (a couple of proper names never reaches it). Set to 5
|
||
// (адверсариальное ревью): a ≤4-word quoted foreign motto («sic transit gloria mundi»)
|
||
// is legit source-preserved content and must NOT be dropped. ⚠ ACCEPTED recall/precision
|
||
// edge: a deliberately-quoted foreign phrase of ≥5 words («to be or not to be») still
|
||
// trips it — rare, and the gate is opt-in (a redrive surfaces it to a human).
|
||
latinPhraseWords = 5
|
||
// latinHeavyShare / latinHeavyTokens: heavy Latin contamination — Latin letters are a
|
||
// large share of all letters AND there are several Latin tokens (both required, so a
|
||
// short chunk with one long Latin brand name does not trip the share alone).
|
||
latinHeavyShare = 0.25
|
||
latinHeavyTokens = 5
|
||
// junctionOverlap: minimal suffix==prefix overlap between two adjacent Cyrillic tokens
|
||
// to call it a split-duplication artifact («Первопред предок»); exact duplicates
|
||
// (emphatic «хорошо хорошо») are excluded.
|
||
junctionOverlap = 4
|
||
)
|
||
|
||
// sanitizerResult is the per-chunk outcome: a count per class plus deterministic detail.
|
||
type sanitizerResult struct {
|
||
Preamble int `json:"preamble,omitempty"`
|
||
TrailingNote int `json:"trailing_note,omitempty"`
|
||
MarkdownHeader int `json:"markdown_header,omitempty"`
|
||
LatinInsert int `json:"latin_insert,omitempty"`
|
||
BrokenWord int `json:"broken_word,omitempty"`
|
||
Detail []string `json:"detail,omitempty"`
|
||
}
|
||
|
||
func (s sanitizerResult) total() int {
|
||
return s.Preamble + s.TrailingNote + s.MarkdownHeader + s.LatinInsert + s.BrokenWord
|
||
}
|
||
|
||
// summary is the human-readable disposition detail for a sanitizer flag (deterministic —
|
||
// Detail is sorted in sanitizeOutput). Kept short (the first two findings) for the log/row.
|
||
func (s sanitizerResult) summary() string {
|
||
if len(s.Detail) == 0 {
|
||
return "output-sanitizer defect"
|
||
}
|
||
if len(s.Detail) > 2 {
|
||
return strings.Join(s.Detail[:2], "; ") + fmt.Sprintf("; …(+%d)", len(s.Detail)-2)
|
||
}
|
||
return strings.Join(s.Detail, "; ")
|
||
}
|
||
|
||
// sanitizeOutput runs all five classes over one chunk's FINAL translated text.
|
||
func sanitizeOutput(text string) sanitizerResult {
|
||
var r sanitizerResult
|
||
if det := detectLeadingPreamble(text); det != "" {
|
||
r.Preamble++
|
||
r.Detail = append(r.Detail, det)
|
||
}
|
||
if det := detectTrailingNote(text); det != "" {
|
||
r.TrailingNote++
|
||
r.Detail = append(r.Detail, det)
|
||
}
|
||
if n, det := detectMarkdownHeaders(text); n > 0 {
|
||
r.MarkdownHeader = n
|
||
r.Detail = append(r.Detail, det...)
|
||
}
|
||
if det := detectLatinInsertion(text); det != "" {
|
||
r.LatinInsert++
|
||
r.Detail = append(r.Detail, det)
|
||
}
|
||
if n, det := detectBrokenWords(text); n > 0 {
|
||
r.BrokenWord = n
|
||
r.Detail = append(r.Detail, det...)
|
||
}
|
||
sort.Strings(r.Detail)
|
||
return r
|
||
}
|
||
|
||
// --- 1. leading service preamble ------------------------------------------------
|
||
|
||
// leadingPreamblePatterns match a service preamble at the very START of the output — the
|
||
// «Вот отредактированный перевод: …» leak (gemini 6/6, exp12 §4.1). PRECISION over recall
|
||
// (адверсариальное ревью: a lax rule dropped clean chapter openings like «Вот такой вариант
|
||
// развития — самый вероятный.», «Вот его вариант: он молча ушёл.»). Two guards make it tight:
|
||
// 1. terminator is a COLON only — the em-dash «—» is ubiquitous in narration, so it is NOT
|
||
// a terminator (a service preamble is a labeled prefix ending in «:»);
|
||
// 2. the phrase must be a genuine TRANSLATION/EDIT service label, not merely «lead + common
|
||
// noun»: either an edit-adjective (отредактированн/исправленн/улучшенн) + a перевод/текст/
|
||
// вариант noun, or «перевод фрагмента/текста/…», or «ниже приведён … перевод/текст».
|
||
// Go RE2 \w/\b are ASCII-only, so Cyrillic uses [а-яё].
|
||
var leadingPreamblePatterns = []*regexp.Regexp{
|
||
// «Вот/Представляю/Привожу … отредактированный/исправленный перевод/текст/вариант …:»
|
||
// (the edit-adjective front-gate holds precision, so the tail-to-colon may be long — the
|
||
// gemini leak carries «… с соблюдением всех терминов из глоссария:»).
|
||
regexp.MustCompile(`(?is)^\s*(?:вот|представляю|привожу|держите)\s+[^\n:]{0,40}?(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`),
|
||
// «Отредактированный/Исправленный перевод/текст/вариант …:» (label at the very start)
|
||
regexp.MustCompile(`(?is)^\s*(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`),
|
||
// «(Вот) перевод фрагмента/текста/отрывка/главы/черновика:»
|
||
regexp.MustCompile(`(?is)^\s*(?:вот\s+)?перевод\s+(?:фрагмента|текста|отрывка|главы|черновика)\s*:`),
|
||
// «Ниже приведён/представлен … перевод/отредактированный текст:»
|
||
regexp.MustCompile(`(?is)^\s*ниже\s+(?:привед[её]н|представлен|дан|след)[а-яё]*\s+[^\n:]{0,40}?(?:перевод|отредактированн|текст)[а-яё]*[^\n:]{0,20}?:`),
|
||
}
|
||
|
||
func detectLeadingPreamble(text string) string {
|
||
t := strings.TrimSpace(text)
|
||
for _, re := range leadingPreamblePatterns {
|
||
if loc := re.FindStringIndex(t); loc != nil {
|
||
return "ведущая служебная преамбула: " + preview(strings.TrimSpace(t[:loc[1]]))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// --- 2. trailing note / edit block ----------------------------------------------
|
||
|
||
// trailingNoteRE matches a note/edit meta-HEADER at line start: «Примечание:», «Заметки
|
||
// переводчика», «Комментарий:», «(прим. перев.)». PRECISION over recall (адверсариальное
|
||
// ревью: «Комментатор захлёбывался…», «Заметка белела на столе.», «Пояснения не требовалось.»,
|
||
// «Сноска объясняла обычай.» are ordinary narrative nouns/gerunds — NOT headers). So the header
|
||
// word MUST be followed by a COLON, or be «примечание/заметки/комментарий + переводчика/редактора»,
|
||
// or be the «(прим. перев./ред.)» marker. Only counted inside the trailing region (below).
|
||
var trailingNoteRE = regexp.MustCompile(`(?i)^[*_>\s-]{0,4}(?:(?:примечани[ея]|заметк[аи]|комментари[йяю]|пояснени[ея]|сноск[аи])\s*:|(?:примечани[ея]|заметк[аи]|комментари[йяю])\s+(?:переводчик|редактор)[а-яё]*|прим\.\s*(?:перев|ред)\.?)`)
|
||
|
||
// editMetaAnywhereRE matches UNAMBIGUOUS editor meta-commentary that is a defect wherever it
|
||
// appears (not only trailing): «Внесённые правки:», «Что изменено:», «Список правок:». The
|
||
// generic «Изменения:/Правки:» alone were REMOVED (адверсариальное ревью — «Изменения — вот что
|
||
// пугало его.» / «Правка — дело тонкое.» are common narrative openers), keeping only the
|
||
// editor-specific compound phrases that never occur in prose.
|
||
var editMetaAnywhereRE = regexp.MustCompile(`(?im)^[*_>\s-]{0,4}(?:(?:внесённ|внесен)[а-яё]+\s+правк|что\s+(?:было\s+)?(?:изменен|исправлен)|список\s+(?:правок|изменений)|список\s+внесённых)`)
|
||
|
||
func detectTrailingNote(text string) string {
|
||
if loc := editMetaAnywhereRE.FindStringIndex(text); loc != nil {
|
||
return "утёкший блок правок редактора: " + preview(strings.TrimSpace(firstLineAt(text, loc[0])))
|
||
}
|
||
lines := nonEmptyLines(text)
|
||
start := len(lines) - trailingRegionLines
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
for _, ln := range lines[start:] {
|
||
if trailingNoteRE.MatchString(ln) {
|
||
return "хвостовой блок заметок/примечаний: " + preview(strings.TrimSpace(ln))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// --- 3. markdown ### header -----------------------------------------------------
|
||
|
||
// markdownHeaderRE matches a markdown ATX header line («### Глава», «# Title»): 1–6 hashes,
|
||
// a space, then non-space content. «#1» / «№5» / a lone «#» do not match (no space+text).
|
||
var markdownHeaderRE = regexp.MustCompile(`(?m)^\s{0,3}#{1,6}\s+\S`)
|
||
|
||
func detectMarkdownHeaders(text string) (int, []string) {
|
||
var det []string
|
||
n := 0
|
||
for _, ln := range strings.Split(text, "\n") {
|
||
if markdownHeaderRE.MatchString(ln) {
|
||
n++
|
||
det = append(det, "markdown-заголовок в выходе: "+preview(strings.TrimSpace(ln)))
|
||
}
|
||
}
|
||
return n, det
|
||
}
|
||
|
||
// --- 4. Latin-script insertion --------------------------------------------------
|
||
|
||
// detectLatinInsertion flags an untranslated Latin PHRASE (≥ latinPhraseWords Latin words
|
||
// separated only by SINGLE SPACES — a real «the quick brown fox», not an alphanumeric id) OR
|
||
// heavy Latin contamination (Latin ≥ latinHeavyShare of all letters AND ≥ latinHeavyTokens
|
||
// Latin tokens). Requiring space-adjacency is load-bearing: a hex/id run like «138fd5c384e3»
|
||
// or «a1b2c3d4» yields several single-letter Latin tokens with NO space between them, which
|
||
// must NOT read as a Latin phrase (a false positive the golden fixture's tags exposed). A
|
||
// handful of Latin proper names stays silent.
|
||
func detectLatinInsertion(text string) string {
|
||
toks := scriptTokens(text)
|
||
maxRun, run, latTokens := 0, 0, 0
|
||
for _, tk := range toks {
|
||
switch {
|
||
case tk.lat:
|
||
if tk.adjacent && run > 0 {
|
||
run++ // a Latin word separated from the previous Latin word by one space
|
||
} else {
|
||
run = 1 // phrase start (digit/punct separator or a non-Latin predecessor)
|
||
}
|
||
latTokens++
|
||
if run > maxRun {
|
||
maxRun = run
|
||
}
|
||
case tk.cyr || tk.mixed:
|
||
run = 0
|
||
}
|
||
}
|
||
if maxRun >= latinPhraseWords {
|
||
return "латинская вставка (фраза из подряд идущих латинских слов)"
|
||
}
|
||
latinLetters, cyrLetters := 0, 0
|
||
for _, ru := range text {
|
||
if unicode.Is(unicode.Latin, ru) {
|
||
latinLetters++
|
||
} else if unicode.Is(unicode.Cyrillic, ru) {
|
||
cyrLetters++
|
||
}
|
||
}
|
||
total := latinLetters + cyrLetters
|
||
if total > 0 && latTokens >= latinHeavyTokens && float64(latinLetters)/float64(total) >= latinHeavyShare {
|
||
return "латиница-врезки: тяжёлая доля латиницы в ru-выходе"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// --- 5. broken word forms -------------------------------------------------------
|
||
|
||
// invalidSignRE matches Cyrillic sign bigrams that never occur in valid Russian: a hard/soft
|
||
// sign at a word START that is FOLLOWED BY A LETTER (a broken token like «ьзал»), or two signs
|
||
// adjacent (ъь/ьъ/ьь/ъъ). Requiring the following letter excludes a STANDALONE «ъ»/«ь» — a
|
||
// metalinguistic mention of the letter itself, «Буква ъ называлась ером.» (адверсариальное
|
||
// ревью nit). High precision otherwise: word-initial-sign-then-letter and doubled signs are
|
||
// impossible in well-formed text. \P{L} (non-letter), not RE2's ASCII-only \b.
|
||
var invalidSignRE = regexp.MustCompile(`(?i)(?:^|\P{L})[ъь][а-яё]|[ъь][ъь]`)
|
||
|
||
func detectBrokenWords(text string) (int, []string) {
|
||
var det []string
|
||
seen := map[string]bool{}
|
||
add := func(s string) {
|
||
if !seen[s] {
|
||
seen[s] = true
|
||
det = append(det, s)
|
||
}
|
||
}
|
||
// (a) invalid Cyrillic sign bigrams.
|
||
if invalidSignRE.MatchString(text) {
|
||
add("невалидная кириллическая биграмма (мягкий/твёрдый знак в начале слова или подряд)")
|
||
}
|
||
// (b) mixed Cyrillic+Latin within one letter-run token (homoglyph artifact) and
|
||
// (c) junction-duplicated adjacent split — walk the letter tokens once.
|
||
toks := scriptTokens(text)
|
||
for _, tk := range toks {
|
||
if tk.mixed {
|
||
add("смешение кириллицы и латиницы в одном слове (гомоглиф): " + preview(tk.text))
|
||
}
|
||
}
|
||
for i := 1; i < len(toks); i++ {
|
||
if !toks[i].adjacent {
|
||
continue
|
||
}
|
||
a, b := toks[i-1], toks[i]
|
||
if a.cyr && b.cyr && a.text != b.text && junctionDuplicated(a.text, b.text) {
|
||
add("похоже на разорванное/сдвоенное слово: " + preview(a.text+" "+b.text))
|
||
}
|
||
}
|
||
return len(det), det
|
||
}
|
||
|
||
type scriptToken struct {
|
||
text string
|
||
cyr bool // Cyrillic-only
|
||
lat bool // Latin-only
|
||
mixed bool // both scripts in one letter run (homoglyph artifact)
|
||
adjacent bool // separated from the previous token by exactly a single space (junction check)
|
||
}
|
||
|
||
// scriptTokens splits text into maximal letter-run tokens (letters only; digits/hyphens/
|
||
// spaces/punctuation are separators), tagging Cyrillic-only / Latin-only / mixed-script and
|
||
// whether the token is separated from the previous token by exactly a single space
|
||
// (adjacency — a duplication artifact sits inside a phrase, not across punctuation/newline).
|
||
func scriptTokens(text string) []scriptToken {
|
||
var out []scriptToken
|
||
var b strings.Builder
|
||
hasLat, hasCyr := false, false
|
||
gap := "" // separator run since the previous token: "" none yet, " " one space, "x" other
|
||
flush := func() {
|
||
if b.Len() > 0 {
|
||
out = append(out, scriptToken{
|
||
text: b.String(),
|
||
cyr: hasCyr && !hasLat,
|
||
lat: hasLat && !hasCyr,
|
||
mixed: hasLat && hasCyr,
|
||
adjacent: gap == " ",
|
||
})
|
||
b.Reset()
|
||
gap = ""
|
||
}
|
||
hasLat, hasCyr = false, false
|
||
}
|
||
for _, ru := range text {
|
||
switch {
|
||
case unicode.Is(unicode.Latin, ru):
|
||
b.WriteRune(ru)
|
||
hasLat = true
|
||
case unicode.Is(unicode.Cyrillic, ru):
|
||
b.WriteRune(ru)
|
||
hasCyr = true
|
||
default:
|
||
flush()
|
||
if ru == ' ' && gap == "" {
|
||
gap = " "
|
||
} else {
|
||
gap = "x" // newline / punctuation / tab / a second space breaks adjacency
|
||
}
|
||
}
|
||
}
|
||
flush()
|
||
return out
|
||
}
|
||
|
||
// junctionDuplicated reports whether adjacent tokens a, b share a PROPER suffix==prefix
|
||
// overlap of ≥ junctionOverlap runes (a split-duplication artifact like «Первопред предок»,
|
||
// overlap «пред»). CONTAINMENT is EXCLUDED (адверсариальное ревью major): if one token is a
|
||
// prefix of the other («старик старика», «город города» — a noun + its own inflection, i.e.
|
||
// legit polyptoton), the suffix==prefix loop would match at the containment length and
|
||
// false-flag it. A true broken split has a MID overlap where NEITHER token contains the
|
||
// other. Both must be reasonably long so a short common ending does not trip it.
|
||
func junctionDuplicated(a, b string) bool {
|
||
al := []rune(strings.ToLower(a))
|
||
bl := []rune(strings.ToLower(b))
|
||
if len(al) < junctionOverlap+1 || len(bl) < junctionOverlap+1 {
|
||
return false
|
||
}
|
||
if strings.HasPrefix(string(bl), string(al)) || strings.HasPrefix(string(al), string(bl)) {
|
||
return false // containment = polyptoton (word + its inflection), not a broken split
|
||
}
|
||
maxK := len(al)
|
||
if len(bl) < maxK {
|
||
maxK = len(bl)
|
||
}
|
||
// A proper overlap cannot equal a whole token (that would be containment, excluded above),
|
||
// so cap the search below the shorter length.
|
||
if maxK > 0 {
|
||
maxK--
|
||
}
|
||
for k := maxK; k >= junctionOverlap; k-- {
|
||
if runesEqual(al[len(al)-k:], bl[:k]) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// --- shared helpers -------------------------------------------------------------
|
||
|
||
func nonEmptyLines(text string) []string {
|
||
var out []string
|
||
for _, ln := range strings.Split(text, "\n") {
|
||
if strings.TrimSpace(ln) != "" {
|
||
out = append(out, ln)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// firstLineAt returns the line containing byte offset off.
|
||
func firstLineAt(text string, off int) string {
|
||
start := strings.LastIndexByte(text[:off], '\n') + 1
|
||
end := strings.IndexByte(text[off:], '\n')
|
||
if end < 0 {
|
||
return text[start:]
|
||
}
|
||
return text[start : off+end]
|
||
}
|