546 lines
20 KiB
Go
546 lines
20 KiB
Go
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 §47–52): 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
|
||
}
|