diff --git a/backend/cmd/tmctl/render.go b/backend/cmd/tmctl/render.go index 1a01894b..0e89d870 100644 --- a/backend/cmd/tmctl/render.go +++ b/backend/cmd/tmctl/render.go @@ -124,6 +124,23 @@ func renderReport(w io.Writer, row.LatencyMS, row.FinishReason, row.TMHit, row.OK, errTail(row.Degraded, row.Err)) } + // Estimated-spend legend (pack-13 point-9, research/21 §1.10): rows whose cost_usd is a reservation + // ESTIMATE (a billed decode failure, or a paid 2xx with zero usage), not a provider-reported cost. + // Surfacing the share keeps an estimate from looking like a real zero-token call; est_tokens is the + // display-only fertility output estimate. Prints only when there is at least one such row. + var estRows, estTokens int + var estUSD float64 + for _, row := range rows { + if row.Estimated == 1 { + estRows++ + estUSD += row.CostUSD + estTokens += row.EstTokens + } + } + if estRows > 0 { + fmt.Fprintf(w, "estimated-cost rows: %d ($%.6f settled at the reservation estimate, not provider-reported; ~%d est. output tokens via fertility, display-only)\n", estRows, estUSD, estTokens) + } + // Flag section (Milestone 2): every chunk×stage whose disposition ≠ ok — the // "flag for the editor" the plan requires (02-mvp Phase-1 acceptance allows N). flags, err := fetchFlags() @@ -234,8 +251,8 @@ func renderQuality(w io.Writer, q *pipeline.QualityReport) error { // Claim-1: choppy paragraphs. ≈1.0 sentences/paragraph = choppy (the owner's complaint); higher = merged prose. fmt.Fprintf(w, "STRUCTURE (claim-1 «choppy paragraphs»): sentences/narrative-paragraph=%.2f (sentences=%d / narrative-paragraphs=%d)\n", q.MeanSentPerNarrPara, q.NarrativeSentences, q.NarrativeParagraphs) - fmt.Fprintf(w, "SIGNALS: dialogue-dash=%d · glossary-misses=%d · number-drift=%d · trust-gated(seed)=%d\n", - q.DialogueDashFlags, q.GlossaryMisses, q.NumberDriftFlags, q.TrustGated) + fmt.Fprintf(w, "SIGNALS: dialogue-dash=%d · glossary-misses=%d · number-drift=%d · trust-gated(seed)=%d · degenerate-loops=%d\n", + q.DialogueDashFlags, q.GlossaryMisses, q.NumberDriftFlags, q.TrustGated, q.DegenerateLoopRuns) // Echo split (D39.18): draft = the translator echoed (incl. dropped c-lite members) / edit = the editor // echoed in what was delivered. Cosmetic-strip is per unit (the sanitizer runs on the final stage). fmt.Fprintf(w, "STRIPS/ECHO: cosmetic-strip units=%d (%.1f%%, markdown+CJK) · echo draft=%d (%.1f%%) · echo edit=%d (%.1f%%)\n", diff --git a/backend/configs/langpacks/zh-ru/heading.txt b/backend/configs/langpacks/zh-ru/heading.txt new file mode 100644 index 00000000..24d8fc56 --- /dev/null +++ b/backend/configs/langpacks/zh-ru/heading.txt @@ -0,0 +1,8 @@ +# Chapter-heading rule for zh→ru. The model is not trusted with a chapter title (it rendered «Раздел 2», +# «Первая глава», an orphaned « :» and dropped titles across models). The chunker detects a source header +# of the shape `[separator]…`, strips the marker+numeral+unit from the model input, +# and renders the title deterministically from `template` (with {n} = the parsed section number). +# `keyvalue` per line. +marker 第 +units 章节節回 +template Глава {n} diff --git a/backend/configs/pipeline-arm-glm.yaml b/backend/configs/pipeline-arm-glm.yaml new file mode 100644 index 00000000..20b5d855 --- /dev/null +++ b/backend/configs/pipeline-arm-glm.yaml @@ -0,0 +1,74 @@ +# Резервный арм редактора: glm-5 БИЛИНГВ (D39.22). Копия боевого C1 (pipeline-c1.yaml) с ЕДИНСТВЕННЫМ +# изменением: editor deepseek-v4-pro → glm-5 (+ few_shot ON по дефолту). Арм = КОНФИГ (другой editor-model +# → другой edit-wave snapshot). Атрибуция едет в chunk_status.model_actual → export/report. +# +# После D39.22 дефолт-редактор прод-конфига — deepseek-v4-pro (интерим: судья №1, лучшая проза/глоссарий, +# ×2 дешевле). glm-5 держим ЖИВЫМ арм-файлом-резервом: пере-прогон rerun2 не короновал dspro-vs-glm +# (зазор ~шум), итерация №2 отложена как довесок к следующему платному прогону — отмена интерим-выбора +# одним этим конфигом. mistral исключён как несущий редактор (D39.20 — критические смысловые аварии). +# +# glm-5: thinking-выключатель в capabilities.reasoning (D3.1) — reasoning:off ⇒ {thinking:{type:disabled}} +# (эмпирика полигона: thinking GLM = таймауты ×3). Вход редактора — русский черновик, эхо-класс не грозит. +core: C1 +version: 1 + +defaults: + max_output_ratio: 2.2 + min_max_tokens: 2048 + +context: + glossary_injection: selective + glossary_token_budget: 800 + cache_ttl: "5m" + +segmentation: + draft_budget_out: 1797 + edit_ceiling_out: 3200 + fertility: + cjk: 1.1978 + other: 0.3852 + +retries: + regenerate_before_escalate: 1 + +stages: + - name: draft + role: translator + model: deepseek-v4-flash + prompts: + zh-ru: ../prompts/translator.md + prompt_version: v1-reflow + temperature: 0.3 + reasoning: "off" + escalate_to: deepseek-v4-pro + - name: edit + role: editor + # РЕЗЕРВ: glm-5 билингв-редактор (D30.1). Пиннут (editor не эскалирует — D12). few_shot ON (дефолт) — + # P1a-дискурс few-shot держится (glm не reasoning-модель, CoT-конфликта нет, в отличие от dspro-арма). + model: glm-5 + prompts: + zh-ru: ../prompts/editor.md + prompt_version: v3-discourse-reflow + temperature: 0.4 + reasoning: "off" # glm thinking:disabled — таймауты ×3 (полигон) + +gates: + coverage: + enabled: false + len_ratio_bounds: + zh-ru: [2.2, 4.2] + ja-ru: [1.4, 2.6] + en-ru: [0.70, 1.4] + sent_cov_min: 0.75 + min_chunk_chars: 500 + sanitizer: + enabled: true + +escalation: + chains: + default: [deepseek-v4-pro, glm-5.1, gemini-3.1-pro-preview] + adult: [grok-4.3] + budget_usd: 0 + +fanout: + candidates: 1 diff --git a/backend/configs/pipeline-c1.yaml b/backend/configs/pipeline-c1.yaml index 4ffb9655..ccb303a8 100644 --- a/backend/configs/pipeline-c1.yaml +++ b/backend/configs/pipeline-c1.yaml @@ -64,12 +64,16 @@ stages: escalate_to: deepseek-v4-pro - name: edit role: editor - # Дефолт-редактор — glm-5 БИЛИНГВ (флип D1→D30.1, supersede D17.в): моно-редактор - # пассивен и структурно слеп к искажениям черновика (exp12 §2.2/2.5); билингв видит - # исходник (editor.md получил {{text}}). glm-5 — кандидат value по exp12 §3 (судейский #1, - # верность 5.0, 0 утечек преамбул; grok reasoning-off из редакторских ролей СНЯТ — no-op). - # gemini-3.1-pro — премиум-эскалация редактора ТОЛЬКО за санитайзером (утечка преамбул 6/6). - model: glm-5 + # Дефолт-редактор — deepseek-v4-pro БИЛИНГВ, ИНТЕРИМ (D39.22): пере-прогон rerun2 не короновал + # glm-vs-dspro (зазор ~шум), но dspro = судья №1 (0.679), лучшая проза+глоссарий, дефекты чинибельного + # класса (заголовки/CJK-strip → пак-13) и ×2 дешевле glm → интерим-выбор владельца. glm-5 — резервный + # арм (configs/pipeline-arm-glm.yaml); mistral исключён (D39.20 — критические смысловые аварии). + # Флип glm-5→deepseek-v4-pro отменяем одним арм-конфигом. gemini-3.1-pro — премиум-эскалация редактора + # ТОЛЬКО за санитайзером (утечка преамбул 6/6); editor pinned (не эскалирует по цепочке — D12). + # ЭХО-МИНА (несущая): deepseek резолвится в ReasoningNone (нет reasoning-capability) → reasoning:off = + # NO-OP, thinking остаётся ON (эхо-мина НЕ вооружается); floor min_max_tokens 8000 (D24.3) — из + # models.yaml, НЕ переопределять. Вход редактора — русский черновик (не CJK), эхо-класс тут вырожден. + model: deepseek-v4-pro # Слой 2 (D39): pair-keyed пакет конвенций (см. draft-стадию) — наполнена zh→ru. prompts: zh-ru: ../prompts/editor.md @@ -79,7 +83,8 @@ stages: # Билингв-каркас (D30.1) и omission-осторожность (D34.3) сохранены. Моно-вариант — prompts/editor-mono.md. prompt_version: v3-discourse-reflow temperature: 0.4 - reasoning: "off" + reasoning: "off" # NO-OP на deepseek-v4-pro (ReasoningNone) → thinking ON; НЕ вооружает эхо-мину + few_shot: false # deepseek-thinking CoT сбивается ручными примерами (exp14 §2а) — как в ратифицированном dspro-арме gates: coverage: diff --git a/backend/configs/pipeline-c2.yaml b/backend/configs/pipeline-c2.yaml index 14faff13..6963ba66 100644 --- a/backend/configs/pipeline-c2.yaml +++ b/backend/configs/pipeline-c2.yaml @@ -42,17 +42,18 @@ stages: - name: edit role: editor # C2 — неисполняемый скелет Фазы 0/1 (fan-out/select — механика Ф2). Модель/бамп следуют - # за C1: editor.md стал БИЛИНГВ (D30.1) + reflow (D30.2) — тот же общий файл. Дефолт-редактор - # по D30.1 — glm-5 билингв (grok reasoning-off из редакторов СНЯТ — no-op). (Стадию select - # выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем.) - model: glm-5 + # за C1: дефолт-редактор по D39.22 — deepseek-v4-pro ИНТЕРИМ (glm-5 — резерв, pipeline-arm-glm.yaml). + # reasoning:off = NO-OP на dspro (ReasoningNone) → thinking ON; floor 8000 из models.yaml. (Стадию + # select выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем: это судья, не редактор.) + model: deepseek-v4-pro prompts: zh-ru: ../prompts/editor.md # Бамп v2-bilingual-reflow→v3-discourse-reflow (label-SHA дисциплина: тот же editor.md, что в C1 — # вшито P1a-ядро ДИСКУРС-ПЕРЕВЁРСТКИ, D37 §2а; лейбл обязан следовать за новым SHA файла). prompt_version: v3-discourse-reflow temperature: 0.4 - reasoning: "off" + reasoning: "off" # NO-OP на deepseek-v4-pro (ReasoningNone) → thinking ON; НЕ вооружает эхо-мину + few_shot: false # deepseek-thinking CoT сбивается ручными примерами (exp14 §2а) — как в C1/dspro-арме gates: coverage: diff --git a/backend/internal/config/echo_mine_test.go b/backend/internal/config/echo_mine_test.go index 1feb10eb..4af8a637 100644 --- a/backend/internal/config/echo_mine_test.go +++ b/backend/internal/config/echo_mine_test.go @@ -165,14 +165,16 @@ func TestBoevoyConfigDeepSeekThinkingStaysOn(t *testing.T) { } } -// TestBoevoyConfigEditorGlm5AndGrokReasoning pins two invariants. (1) The D30.1 flip: the -// default editor of both boevoy pipelines is glm-5 BILINGUAL (mono-editor was passive and -// blind, exp12; grok reasoning-off is REMOVED from editor roles — a no-op) and its reasoning -// is off. (2) grok-4.3 (still the channel-B tier / 18+ judge in models.yaml) resolves to an -// EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT off-by-omission -// — grok's OMITTED default is "low" (it thinks, billed additively per xAI), and it must not -// trip the echo-mine guard. c2's select (judge) stage stays the glm-5 placeholder. -func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) { +// TestBoevoyConfigEditorDsproAndGrokReasoning pins two invariants. (1) The D39.22 interim editor: +// the default editor of both boevoy pipelines is deepseek-v4-pro BILINGUAL (supersedes the D30.1 +// glm-5 default; the pere-run did not crown glm-vs-dspro, dspro = judge #1 / cheaper / repairable +// defects → owner interim, glm-5 kept as pipeline-arm-glm.yaml reserve) and its reasoning is "off" +// — the LOAD-BEARING echo-mine semantic: on deepseek-v4-pro "off" resolves to ReasoningNone (a +// NO-OP), so thinking stays ON and the echo-mine is NOT armed. (2) grok-4.3 (still the channel-B +// tier / 18+ judge in models.yaml) resolves to an EXPLICIT reasoning off-switch (ReasoningEffortField +// + OffEffort "none"), NOT off-by-omission — grok's OMITTED default is "low" (it thinks, billed +// additively per xAI), and it must not trip the echo-mine guard. c2's select (judge) stage stays glm-5. +func TestBoevoyConfigEditorDsproAndGrokReasoning(t *testing.T) { m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml")) if err != nil { t.Fatalf("load boevoy models.yaml: %v", err) @@ -187,7 +189,9 @@ func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) { if why := m.echoMineViolation(grok); why != "" { t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", grok, why) } - // (1) D30.1 editor flip: glm-5 bilingual editor, reasoning off, in both boevoy pipelines. + // (1) D39.22 interim editor flip: deepseek-v4-pro bilingual editor, reasoning "off" (a ReasoningNone + // no-op — thinking stays ON), in both boevoy pipelines. The glm-5 default is superseded (kept as a + // reserve arm); the c2 select (judge) glm-5 slot is a DIFFERENT role and is unaffected. for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} { p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m) if err != nil { @@ -202,11 +206,11 @@ func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) { selectModel = st.Model } } - if editModel != "glm-5" { - t.Errorf("%s edit stage model = %q, want glm-5 (D30.1: bilingual editor; grok reasoning-off REMOVED from editors — no-op)", pf, editModel) + if editModel != "deepseek-v4-pro" { + t.Errorf("%s edit stage model = %q, want deepseek-v4-pro (D39.22 interim editor; glm-5 is the reserve arm)", pf, editModel) } if editReasoning != "off" { - t.Errorf("%s edit stage reasoning = %q, want off (glm-5 thinking:disabled — timeouts ×3)", pf, editReasoning) + t.Errorf("%s edit stage reasoning = %q, want \"off\" — the echo-mine semantic: on deepseek-v4-pro \"off\"=ReasoningNone (no-op) so thinking stays ON, never armed", pf, editReasoning) } if pf == "pipeline-c2.yaml" && selectModel != "glm-5" { t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot), got %q", pf, selectModel) @@ -234,6 +238,7 @@ func TestSwapArmConfigs(t *testing.T) { }{ {"pipeline-arm-mistral.yaml", "mistral-large-2512", false}, {"pipeline-arm-deepseek-pro.yaml", "deepseek-v4-pro", true}, + {"pipeline-arm-glm.yaml", "glm-5", false}, // D39.22 glm-5 reserve arm (few-shot ON: glm is not a reasoning model) } for _, a := range arms { t.Run(a.file, func(t *testing.T) { diff --git a/backend/internal/lang/langpack.go b/backend/internal/lang/langpack.go index 513e3304..8b918809 100644 --- a/backend/internal/lang/langpack.go +++ b/backend/internal/lang/langpack.go @@ -53,9 +53,26 @@ type Pack struct { PalladiusYW map[string]string PalladiusSpecialI map[string]string + // Heading is the OPTIONAL chapter-heading rule (configs/langpacks//heading.txt). nil when the pair + // carries no heading.txt — the chapter-title feature is then inert (the chunker keeps the source header + // as-is), so a pair that does not opt in is never re-billed for it. It is DATA only: the detect/strip/ + // render ALGORITHM lives in the chunker (internal/pipeline), which reads this table — the same + // data/algorithm boundary the miner tables keep. + Heading *HeadingRule + version string } +// HeadingRule is the per-pair data for the chapter-title policy: instead of letting the model render a +// chapter heading (which drifted to «Раздел 2» / «Первая глава» / an orphaned « :» across models), the +// chunker detects a source header (Marker + a numeral + a Unit rune), strips it from the model input, and +// the read-models render Template deterministically instead. Data only; parsed from heading.txt. +type HeadingRule struct { + Marker string // the prefix rune(s) that open a numbered heading, e.g. "第" + Units map[rune]bool // the section-unit runes accepted right after the numeral (章 节 節 回) + Template string // the target rendering; the literal "{n}" is replaced by the parsed Arabic number +} + // Version is the content hash of the pack (packAlgoVersion + a sha256 of the authored file bytes). A pack // edit changes it, so the pipeline can fold it into the snapshot (a loud --resnapshot on any data edit). func (p *Pack) Version() string { return p.version } @@ -111,6 +128,22 @@ func Load(root, sourceLang, targetLang string) (*Pack, error) { } } + // Optional per-pair heading rule (pack-13 title policy). ABSENT → nil, the title feature is inert and + // the pack's Version() is byte-stable (a pair that does not opt in is never re-billed); PRESENT → its + // bytes fold into the content hash (a loud --resnapshot on any edit) and it is parsed; CORRUPT → fail + // loud, like the required-file path. Distinct from read(): a missing heading.txt is NOT an error. + if hb, ok, herr := readOptional(root, pair, "heading.txt"); herr != nil { + return nil, fmt.Errorf("langpack %q heading.txt: %w", pair, herr) + } else if ok { + h.Write([]byte("\x00" + pair + "/heading.txt\x00")) + h.Write(hb) + hr, perr := parseHeading(hb) + if perr != nil { + return nil, fmt.Errorf("langpack %q heading.txt: %w", pair, perr) + } + p.Heading = hr + } + if err := p.validate(); err != nil { return nil, fmt.Errorf("langpack %q: %w", pair, err) } @@ -189,6 +222,63 @@ func (p *Pack) assignPair(name string, b []byte) error { return nil } +// readOptional reads an OPTIONAL pack file. A missing file returns (nil, false, nil) — the feature it +// backs is simply inert — while any OTHER read error (permission, a directory) is a loud failure; a +// present file returns (bytes, true, nil). Used for the pack-13 heading rule, which a pair opts into. +func readOptional(root, dir, name string) ([]byte, bool, error) { + b, err := os.ReadFile(filepath.Join(root, dir, name)) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + return b, true, nil +} + +// parseHeading reads heading.txt into a HeadingRule. Format: `keyvalue` per non-comment line, keys +// marker | units | template (all three required). `units` is a rune SET (each rune a member); `template` +// must contain the literal "{n}" placeholder (else it could never render a number). Fail-loud on a missing +// key / unknown key / empty value / a template without {n} — a malformed rule is a corrupt pack, not an +// intended silent no-op (mirrors the required-file "never silently empty" contract). +func parseHeading(b []byte) (*HeadingRule, error) { + hr := &HeadingRule{Units: map[rune]bool{}} + seen := map[string]bool{} + for i, raw := range strings.Split(string(b), "\n") { + t := strings.TrimRight(raw, "\r") + if strings.TrimSpace(t) == "" || strings.HasPrefix(strings.TrimSpace(t), "#") { + continue + } + f := strings.SplitN(t, "\t", 2) + if len(f) != 2 || strings.TrimSpace(f[1]) == "" { + return nil, fmt.Errorf("line %d: want `keyvalue` with a non-empty value (%q)", i+1, t) + } + key, val := strings.TrimSpace(f[0]), strings.TrimSpace(f[1]) + seen[key] = true + switch key { + case "marker": + hr.Marker = val + case "units": + for _, r := range val { + if !isSpace(r) { + hr.Units[r] = true + } + } + case "template": + hr.Template = val + default: + return nil, fmt.Errorf("line %d: unknown key %q (want marker|units|template)", i+1, key) + } + } + if !seen["marker"] || !seen["units"] || !seen["template"] || len(hr.Units) == 0 { + return nil, fmt.Errorf("heading rule needs non-empty marker, units and template") + } + if !strings.Contains(hr.Template, "{n}") { + return nil, fmt.Errorf("template %q must contain the {n} number placeholder", hr.Template) + } + return hr, nil +} + // runeSet reads a rune SET: every non-whitespace rune of every non-comment line is a member (order-free). func runeSet(b []byte) map[rune]bool { m := map[rune]bool{} diff --git a/backend/internal/pipeline/bookrun.go b/backend/internal/pipeline/bookrun.go index 1ce636bf..4b4571a4 100644 --- a/backend/internal/pipeline/bookrun.go +++ b/backend/internal/pipeline/bookrun.go @@ -123,7 +123,7 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) { return nil, err } - chunks := SplitChunks(doc.Chapters, r.segBudget()) + chunks := SplitChunks(doc.Chapters, r.segBudget(), r.headingRule()) if len(chunks) == 0 { return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile) } diff --git a/backend/internal/pipeline/cheapgates.go b/backend/internal/pipeline/cheapgates.go index 1313d905..f570bfa8 100644 --- a/backend/internal/pipeline/cheapgates.go +++ b/backend/internal/pipeline/cheapgates.go @@ -45,7 +45,14 @@ import ( // requires (a rule/pack edit shifts recorded counts). They fire 0 on a non-zh source / non-register text. // Ш-2 (see memnorm.go): the cheap style/DC checkers classify via unicode predicates + NFC folding, so a // toolchain Unicode bump that shifts a class is a loud --resnapshot rather than a silent count change. -const cheapGateVersion = "cheapgate-v3-dc-checkers+u" + unicode.Version +// +// v4 (pack-13) adds three GENERAL observability checkers (never a disposition, tuned precision over +// recall): a 成-percent scale checker (六成六=66% mis-rendered as a decimal fraction), a Latin-residue +// checker (a whole Latin word left in the Russian output), and a broken-word checker (a Russian word +// ending in the impossible «-йть»). All are language-general — no book-specific word lists. Editing a rule +// shifts the recorded counts, so the version bump is a loud --resnapshot; they fire 0 on a non-zh source / +// clean Russian output (the golden fixture stays style_flags=0). +const cheapGateVersion = "cheapgate-v4-percent-latin-brokenword+u" + unicode.Version // 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 @@ -73,15 +80,20 @@ type cheapGateResult struct { NumberDrift int `json:"number_drift,omitempty"` // DC1TimeUnits / DC2Magnitude / DC6Register are the WS5 defect-class checkers (checkers_zh_ru.go), // observability like the others. Zero on a non-zh source / non-register final (the golden fixture). - DC1TimeUnits int `json:"dc1_time_units,omitempty"` - DC2Magnitude int `json:"dc2_magnitude,omitempty"` - DC6Register int `json:"dc6_register,omitempty"` + DC1TimeUnits int `json:"dc1_time_units,omitempty"` + DC2Magnitude int `json:"dc2_magnitude,omitempty"` + DC6Register int `json:"dc6_register,omitempty"` + // PercentScale / LatinResidue / BrokenWord are the pack-13 general checkers (checkers_zh_ru.go), + // observability like the others. Zero on a clean Russian output / non-zh source. + PercentScale int `json:"percent_scale,omitempty"` + LatinResidue int `json:"latin_residue,omitempty"` + BrokenWord int `json:"broken_word,omitempty"` Detail []string `json:"detail,omitempty"` } func (c cheapGateResult) total() int { return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude + c.LengthCollapse + c.NumberDrift + - c.DC1TimeUnits + c.DC2Magnitude + c.DC6Register + c.DC1TimeUnits + c.DC2Magnitude + c.DC6Register + c.PercentScale + c.LatinResidue + c.BrokenWord } // runCheapGates runs the four always-on style flaggers over one chunk's source and FINAL text, plus @@ -110,6 +122,16 @@ func runCheapGates(source, draft, final string, cfg cheapGateConfig) cheapGateRe n, det = lintRegisterLexicon(final) r.DC6Register = n r.Detail = append(r.Detail, det...) + // pack-13 general checkers (checkers_zh_ru.go) — src↔target / Russian-side observability flaggers. + n, det = lintPercentScale(source, final) + r.PercentScale = n + r.Detail = append(r.Detail, det...) + n, det = lintLatinResidue(final, cfg.allowlist) + r.LatinResidue = n + r.Detail = append(r.Detail, det...) + n, det = lintBrokenWord(final) + r.BrokenWord = n + r.Detail = append(r.Detail, det...) if cfg.regressionEnabled { rg := runRegressionGuard(draft, final) r.LengthCollapse = rg.LengthCollapse diff --git a/backend/internal/pipeline/checkers_pack13_test.go b/backend/internal/pipeline/checkers_pack13_test.go new file mode 100644 index 00000000..542f1d91 --- /dev/null +++ b/backend/internal/pipeline/checkers_pack13_test.go @@ -0,0 +1,65 @@ +package pipeline + +import "testing" + +// TestPack13Checkers pins the pack-13 GENERAL checkers against representative inputs (no book-specific +// word lists): each defect shape fires and a clean counterpart / golden-shape Russian output stays 0 +// (precision over recall). The 时辰/千万 cases confirm the pre-existing DC1/DC2 still catch the corpus shapes. +func TestPack13Checkers(t *testing.T) { + // Percent scale (成 = tenths): a decimal-fraction rendering fires; a correct percent is suppressed. + if n, _ := lintPercentScale("море истинной ци 六成六 …", "море истинной ци — шесть десятых и шесть сотых"); n != 1 { + t.Errorf("percent: «шесть десятых и шесть сотых» should fire, got %d", n) + } + if n, _ := lintPercentScale("六成六", "море истинной ци — шесть и шесть десятых"); n != 1 { + t.Errorf("percent: «шесть и шесть десятых» should fire, got %d", n) + } + if n, _ := lintPercentScale("六成六", "заполнено на шестьдесят шесть процентов"); n != 0 { + t.Errorf("percent: a correct «процентов» rendering must be suppressed, got %d", n) + } + if n, _ := lintPercentScale("нет числа", "шесть десятых чего-то"); n != 0 { + t.Errorf("percent: no 成 in source must not fire, got %d", n) + } + + // Latin residue: a lowercase leaked word fires; a hash / Cyrillic / Roman numeral / a brand or proper + // noun (any capital) does not — the precision guard the adversarial review's iPhone case exposed. + if n, _ := lintLatinResidue("открыл их again, горестно вздохнув", nil); n != 1 { + t.Errorf("latin: «again» should fire, got %d", n) + } + for _, clean := range []string{ + "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 5abc35ddfb65. Судзуки шёл по коридорам.", // a body-hash id (has digits) + "Классы таланта А, Б, В и Г — от высшей к низшей.", // Cyrillic class letters + "Глава II начинается.", // a Roman numeral (uppercase) + "Он держал в руках iPhone и MacBook.", // brands — any capital → skipped + "Судзуки шёл в Академию.", // Cyrillic proper noun + } { + if n, det := lintLatinResidue(clean, nil); n != 0 { + t.Errorf("latin: clean text must not fire (%q): %d %v", clean, n, det) + } + } + // The per-project allowlist exempts an intentional lowercase Latin surface. + if n, _ := lintLatinResidue("сказал again снова", map[string]bool{"again": true}); n != 0 { + t.Errorf("latin: an allowlisted surface must not fire") + } + + // Broken word — the general «-йть» rule only (no book-specific lists): «войть» fires, valid words do not. + if n, _ := lintBrokenWord("хотел тихонько войть и закрыть"); n != 1 { + t.Errorf("broken: «войть» (-йть) should fire") + } + for _, clean := range []string{ + "он решил войти и закрыть окно", // valid войти + "глава клана Гуюэ поклонился", // valid prose (no -йть) + "впереди идёт Фан Юань", // valid впереди + } { + if n, det := lintBrokenWord(clean); n != 0 { + t.Errorf("broken: clean text must not fire (%q): %d %v", clean, n, det) + } + } + + // The pre-existing DC1/DC2 still catch the corpus shapes (general Chinese units/idioms). + if n, _ := lintTimeUnits("僵持了三个时辰", "прошло три часа"); n != 1 { + t.Errorf("time-unit: 三个时辰→«три часа» should fire") + } + if n, _ := lintMagnitudeScale("千万生灵", "погубил тысячи жизней"); n != 1 { + t.Errorf("magnitude: 千万→«тысячи» should fire") + } +} diff --git a/backend/internal/pipeline/checkers_zh_ru.go b/backend/internal/pipeline/checkers_zh_ru.go index 3c74acb3..7ae7f77f 100644 --- a/backend/internal/pipeline/checkers_zh_ru.go +++ b/backend/internal/pipeline/checkers_zh_ru.go @@ -166,3 +166,130 @@ func lintRegisterLexicon(final string) (int, []string) { // isCyrLetter reports whether r is a Cyrillic letter (the word boundary for the register match). func isCyrLetter(r rune) bool { return unicode.IsLetter(r) && unicode.Is(unicode.Cyrillic, r) } + +// --- percent-scale checker (成 = tenths) ----------------------------------------------------------- +// +// In Chinese, 成 is one tenth: 六成 = 60%, 六成六 = 66%. A common translation error renders this as a +// decimal FRACTION instead of a percentage — «шесть десятых и шесть сотых» (0.66) or «шесть и шесть +// десятых» (6.6) for 六成六 — a ~100× scale error. This flags that. Precision over recall: it fires only +// when the source has «成[]», the output has NO percent form («процент»/«%»), AND the output +// carries a decimal-fraction cue (a «десятых»/«сотых» ordinal or a «N,N» number). So an output that renders +// the percent correctly is suppressed, and an output without a fraction cue (a paraphrase) stays silent. +// A general zh→ru unit convention, not tied to any book. + +// chengPercentRE matches a 成-percent expression: a count (CJK or Arabic), 成, and an optional second count. +var chengPercentRE = regexp.MustCompile(`([0-9一二三四五六七八九十])成([0-9一二三四五六七八九]?)`) + +// decimalFractionRE is the error cue: the tenths rendered as a Russian fraction ordinal or a decimal number. +var decimalFractionRE = regexp.MustCompile(`десят(?:ая|ых|ой|ые)|сот(?:ая|ых|ой|ые)|\d+[.,]\d`) + +// lintPercentScale flags a 成-percent rendered as a decimal fraction instead of a percentage. +func lintPercentScale(source, final string) (int, []string) { + m := chengPercentRE.FindStringSubmatch(source) + if m == nil { + return 0, nil + } + low := strings.ToLower(final) + if strings.Contains(low, "процент") || strings.Contains(final, "%") { + return 0, nil // the output uses a percent form — the scale is handled correctly + } + if !decimalFractionRE.MatchString(low) { + return 0, nil // no fraction cue — the magnitude was paraphrased, not mis-scaled + } + tens, _ := dcParseCount(m[1]) + pct := tens * 10 + if m[2] != "" { + if ones, ok := dcParseCount(m[2]); ok { + pct += ones + } + } + return 1, []string{fmt.Sprintf("成-percent: %s成%s = %d%% rendered as a decimal fraction instead of a percentage (~%d%%)", m[1], m[2], pct, pct)} +} + +// --- Latin residue in the Russian output ----------------------------------------------------------- +// +// A whole Latin WORD left untranslated in the Russian output (e.g. «открыл их again, …»). It splits the +// output into maximal alphanumeric tokens and flags a token that is worth reporting as leaked prose. The +// target is a lowercase mid-sentence English word, so the guards keep precision high: +// - the token must be ALL LOWERCASE Latin letters. Leaked prose is lowercase; a token with any capital +// is a proper noun / brand / acronym (iPhone, Google, Suzuki, «II») — legitimate in Russian text, and +// the sanitizer already treats capitals as a brand signal, so we skip them here too. +// - it must have NO digit: an id / hash like «5abc35ddfb65» is an alphanumeric token, not a word. +// - at least minLatinResidueLen letters, not a Roman numeral, and not on the per-project allowlist. +// A foreignizing brief that keeps intentional Latin (a motto, a scientific name) puts those on the +// allowlist. Tuned for precision over recall: a leaked word that is Capitalized, or a bare URL host +// («example.com» → «example»/«com»), is not caught — accepted for a low-noise observability signal. +const minLatinResidueLen = 3 + +func lintLatinResidue(final string, allow map[string]bool) (int, []string) { + hits := map[string]bool{} + rs := []rune(final) + for i := 0; i < len(rs); { + if !isLatinLetterOrDigit(rs[i]) { + i++ + continue + } + j := i + reject := false // set on any digit or uppercase letter — not a lowercase leaked word + for j < len(rs) && isLatinLetterOrDigit(rs[j]) { + if (rs[j] >= '0' && rs[j] <= '9') || (rs[j] >= 'A' && rs[j] <= 'Z') { + reject = true + } + j++ + } + tok := string(rs[i:j]) + i = j + if !reject && len([]rune(tok)) >= minLatinResidueLen && !isRomanNumeral(tok) && !allow[tok] { + hits[tok] = true + } + } + if len(hits) == 0 { + return 0, nil + } + surfaces := make([]string, 0, len(hits)) + for s := range hits { + surfaces = append(surfaces, s) + } + sort.Strings(surfaces) + return len(surfaces), []string{"Latin word left untranslated in the Russian output: " + strings.Join(surfaces, ", ")} +} + +// isLatinLetterOrDigit reports whether r is an ASCII Latin letter or digit (the alphanumeric-token alphabet). +func isLatinLetterOrDigit(r rune) bool { + return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') +} + +// isRomanNumeral reports whether a lowercase token is a Roman numeral (all chars in ivxlcdm) — «iii» reads +// as a numeral, not a leaked word; excluded to hold precision. (Uppercase «II» is already skipped as a cap.) +func isRomanNumeral(tok string) bool { + for _, r := range tok { + switch r { + case 'i', 'v', 'x', 'l', 'c', 'd', 'm': + default: + return false + } + } + return true +} + +// --- broken Russian word forms --------------------------------------------------------------------- +// +// Deliberately GENERAL, with NO dictionary and NO book-specific word lists: it flags a Russian word ending +// in «-йть», which no well-formed Russian word does — the shape of a mangled infinitive (e.g. «войть» for +// «войти»). It is zero false-positive and applies to any book. Malformations WITHOUT such a structural +// signature — a plausible misspelling («Вперди» for «Впереди») or a case-agreement error («глава клан» +// for «главы клана») — are NOT detectable deterministically without a morphology/dictionary pass, which is +// out of scope here; the report states this recall limit honestly. The existing sanitizer broken-word class +// (invalid soft/hard-sign bigrams, script-mixed homoglyph tokens) is orthogonal and still runs. +func lintBrokenWord(final string) (int, []string) { + seen := map[string]bool{} + var det []string + for _, w := range tokenizeCyrillic(final) { + if len([]rune(w)) >= 4 && strings.HasSuffix(w, "йть") && !seen[w] { + seen[w] = true + det = append(det, "malformed word ending in «-йть» (no valid Russian word does): "+w) + } + } + sort.Strings(det) + return len(det), det +} diff --git a/backend/internal/pipeline/chunker.go b/backend/internal/pipeline/chunker.go index 5b276d6d..5241e7d8 100644 --- a/backend/internal/pipeline/chunker.go +++ b/backend/internal/pipeline/chunker.go @@ -1,8 +1,11 @@ package pipeline import ( + "strconv" "strings" "unicode" + + "textmachine/backend/internal/lang" ) // chunker.go: the step-3a SOURCE segmenter. It turns the per-chapter normalized @@ -46,6 +49,14 @@ type Chunk struct { // boundary machinery, anti-scope), and the flag makes the un-budgetable unit OBSERVABLE // ("degradation is not silent") rather than silently oversized. OversizedSentence bool + // Heading is the deterministic chapter title, non-empty ONLY on a chapter's FIRST chunk (ChunkIdx 0) + // when the chapter opened with a source structural header (detected via the pair's lang.HeadingRule) + // AND a rule is configured. The chunker STRIPS the source marker from ch.Text (so the model never + // renders its own «Раздел 2»/«Первая глава»/an orphaned « :») and renders this from the rule's template + // («Глава N»); the read-models PREPEND it to the chapter's first output unit. "" for every non-first + // chunk, every chapter without a header, and every book without a heading rule (in which case the source + // header stays in ch.Text and chunking is byte-identical to before the feature existed). + Heading string // EditUnitID is the 0-based book-global id of the coarse EDIT unit this draft chunk belongs to // (WS2 decoupling: draft = small chunk, edit = large unit). An edit unit is a greedy grouping of // WHOLE draft chunks up to EditCeilingOut (per chapter), so the edit wave's editor reads a unit's draft as @@ -81,23 +92,217 @@ func (s SegBudget) estOut(cjk, other int) float64 { // real content (Phase-0 backward compat). Fully deterministic. Each chunk carries its // EstOut + OversizedSentence flag + EditUnitID (WS2); the edit-unit id is monotone across // the book so a the edit wave unit is uniquely addressable. -func SplitChunks(chapters []string, seg SegBudget) []Chunk { +func SplitChunks(chapters []string, seg SegBudget, heading *lang.HeadingRule) []Chunk { var out []Chunk chapterNo := 0 editUnitID := 0 for _, chapText := range chapters { + // Title policy (pack-13): detect a leading structural header, render it deterministically and + // STRIP its marker from the text the model sees. A nil rule (no pack / no heading.txt) or a chapter + // with no header is a NO-OP — headingText is "" and chapText is unchanged, so a book that does not + // opt in produces byte-identical chunks. Runs BEFORE splitParagraphs so the stripped subtitle is + // re-paragraphed normally. + headingText, chapText := stripHeading(chapText, heading) paras := splitParagraphs(chapText) if len(paras) == 0 { continue // an empty chapter does not consume a chapter number } chapterNo++ chapterChunks := chapterDraftChunks(chapterNo, paras, seg) + if headingText != "" && len(chapterChunks) > 0 { + chapterChunks[0].Heading = headingText // the chapter's first chunk carries the deterministic title + } assignEditUnits(chapterChunks, seg, &editUnitID) out = append(out, chapterChunks...) } return out } +// stripHeading detects a chapter-leading structural header via the pair's HeadingRule, returning the +// DETERMINISTIC rendered title and the chapter text with the source marker removed (pack-13 title policy). +// A nil rule or a first line that is not a header is a NO-OP: it returns ("", chapter) unchanged. The +// marker+numeral+unit prefix is stripped and the SUBTITLE (if any) is kept as ordinary body — so «第一节: +// 纵身亡魔心仍不悔» yields ("Глава 1", "纵身亡魔心仍不悔\n…"): the reader gets a uniform «Глава N» PLUS the +// translated subtitle, and the model never renders the chapter number. Only the FIRST line is inspected +// (a header is a chapter's opening line; a chapter with a preamble before its header is a rare edge left +// to the pre-pack behaviour, noted in the pack report). Deterministic and pure. +func stripHeading(chapter string, hr *lang.HeadingRule) (heading, stripped string) { + if hr == nil { + return "", chapter + } + parts := strings.SplitN(chapter, "\n", 2) + n, subtitle, ok := matchHeaderLine(parts[0], hr) + if !ok { + return "", chapter + } + heading = strings.ReplaceAll(hr.Template, "{n}", strconv.Itoa(n)) + rest := "" + if len(parts) == 2 { + rest = parts[1] + } + switch { + case subtitle == "": + return heading, strings.TrimLeft(rest, "\n") // the whole header line + its trailing blank go away + case rest == "": + return heading, subtitle + default: + return heading, subtitle + "\n" + rest + } +} + +// applyHeading prepends a chapter's DETERMINISTIC title to its first output unit's final text (pack-13 +// title policy). It is a PURE, deterministic projection applied at assembly time (waverun outcome + +// export), NEVER stored in a checkpoint — so a resume re-derives it for free from the manifest re-chunk, +// and a book with no heading rule (heading=="") is byte-identical. It prepends ONLY to a NON-EMPTY final +// text: a fully-flagged/empty unit keeps "" (the "not translated" export semantics), and the title +// reappears when the unit is redriven ok. The separator is a blank line so «Глава N» reads as a heading +// above the (subtitle + body) prose. +func applyHeading(heading, finalText string) string { + if heading == "" || finalText == "" { + return finalText + } + return heading + "\n\n" + finalText +} + +// matchHeaderLine reports whether a single line is a structural header under the rule and returns the +// parsed section number + the trimmed subtitle after the marker. Shape: , +// then EITHER end-of-line OR a SEPARATOR (not a content glyph) — mirroring ingest.isCJKChapterHeader's +// precision guard so «第一回见面» (回 a measure word glued to the content 见) is NOT a header, while +// «第一节:…» / «第1章 …» is. Leading whitespace is tolerated; the subtitle is the remainder with any +// leading separators (:: 、,.。- —  ·) trimmed. Deterministic. +func matchHeaderLine(line string, hr *lang.HeadingRule) (n int, subtitle string, ok bool) { + t := strings.TrimSpace(line) + if hr.Marker == "" || !strings.HasPrefix(t, hr.Marker) { + return 0, "", false + } + rs := []rune(t[len(hr.Marker):]) + i := 0 + for i < len(rs) && isHeadingNumeral(rs[i]) { + i++ + } + if i == 0 || i >= len(rs) { // need at least one numeral AND a unit rune after it + return 0, "", false + } + if !hr.Units[rs[i]] { + return 0, "", false + } + after := rs[i+1:] + if len(after) > 0 && isHeaderContentRune(after[0]) { + return 0, "", false // a content glyph glued to the unit → a measure word in prose, not a header + } + num, parsed := parseSectionNumeral(string(rs[:i])) + if !parsed { + return 0, "", false + } + return num, strings.TrimSpace(strings.TrimLeftFunc(string(after), isHeadingSeparator)), true +} + +// isHeadingNumeral reports whether a rune can be part of a chapter-number run: an Arabic digit +// (half/fullwidth) or a CJK numeral character. +func isHeadingNumeral(r rune) bool { + switch { + case r >= '0' && r <= '9', r >= '0' && r <= '9': + return true + } + return strings.ContainsRune("〇零一二三四五六七八九十百千两兩", r) +} + +// isHeaderContentRune reports whether a rune is CONTENT (a letter/digit/ideograph/kana) rather than a +// separator — the guard that keeps a glued measure word from reading as a header (ingest parity). +func isHeaderContentRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || + unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) +} + +// isHeadingSeparator reports whether a rune separates the marker from the subtitle (trimmed off the head +// of the subtitle): CJK/ASCII colons, commas, periods, dashes, the ideographic space and middle dots. +func isHeadingSeparator(r rune) bool { + switch r { + case ':', ':', '、', ',', ',', '.', '。', '-', '—', '–', ' ', '\t', ' ', '·', '・': + return true + } + return false +} + +// parseSectionNumeral parses a chapter-number run into an int (Arabic half/fullwidth OR standard CJK +// numerals up to the thousands — well past any chapter count). A digit accumulates positionally; a small +// unit (十百千) flushes the pending coefficient (十 alone = 10). Returns ok=false on an unparseable rune +// or a non-positive result (conservative — an unparseable header simply is not stripped/rendered). +func parseSectionNumeral(s string) (int, bool) { + section, num := 0, 0 + any := false + for _, r := range s { + switch { + case r >= '0' && r <= '9': + num = num*10 + int(r-'0') + any = true + case r >= '0' && r <= '9': + num = num*10 + int(r-'0') + any = true + case r == '〇' || r == '零': + num = num * 10 + any = true + default: + if d, isDigit := cjkSectionDigit(r); isDigit { + num = num*10 + d + any = true + continue + } + if u, isUnit := cjkSectionUnit(r); isUnit { + if num == 0 { + num = 1 + } + section += num * u + num = 0 + any = true + continue + } + return 0, false + } + } + v := section + num + if !any || v <= 0 { + return 0, false + } + return v, true +} + +func cjkSectionDigit(r rune) (int, 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 cjkSectionUnit(r rune) (int, bool) { + switch r { + case '十': + return 10, true + case '百': + return 100, true + case '千': + return 1000, true + } + return 0, false +} + // chapterDraftChunks packs one chapter's paragraphs into DRAFT chunks (the fine tiling). Rule: // pack whole paragraphs while the running OUTPUT-token estimate stays within DraftBudgetOut // (prefer paragraph boundaries); a paragraph that alone exceeds the budget is flushed on its own diff --git a/backend/internal/pipeline/chunker_heading_test.go b/backend/internal/pipeline/chunker_heading_test.go new file mode 100644 index 00000000..f7b32b99 --- /dev/null +++ b/backend/internal/pipeline/chunker_heading_test.go @@ -0,0 +1,110 @@ +package pipeline + +import ( + "testing" + + "textmachine/backend/internal/lang" +) + +// zhRuHeadingRule mirrors configs/langpacks/zh-ru/heading.txt (第 · 章节節回 · «Глава {n}»). +func zhRuHeadingRule() *lang.HeadingRule { + return &lang.HeadingRule{ + Marker: "第", + Units: map[rune]bool{'章': true, '节': true, '節': true, '回': true}, + Template: "Глава {n}", + } +} + +// TestStripHeadingRerun2 pins the pack-13 title policy on the LIVE rerun2 header shapes (第N节:subtitle): +// the marker is stripped from the model input, the subtitle is kept, and the deterministic «Глава N» is +// rendered — the fix for the cross-arm chaos («Раздел 2» / «Первая глава» / orphaned « :»). +func TestStripHeadingRerun2(t *testing.T) { + hr := zhRuHeadingRule() + cases := []struct { + chapter string + wantHeading string + wantStripped string + }{ + {"第一节:纵身亡魔心仍不悔\n\n正文第一段。", "Глава 1", "纵身亡魔心仍不悔\n\n正文第一段。"}, + {"第二节:逆光阴五百年觉悟\n\n身体。", "Глава 2", "逆光阴五百年觉悟\n\n身体。"}, + {"第四节:古月方源!\n\n夜。", "Глава 4", "古月方源!\n\n夜。"}, + {"第五节:人祖三蛊,希望开窍\n\n春。", "Глава 5", "人祖三蛊,希望开窍\n\n春。"}, + {"第一章 図書館の秘密\n\n本文。", "Глава 1", "図書館の秘密\n\n本文。"}, // fullwidth-space separator (ja-shape) + {"第十二章:标题\n\n正文。", "Глава 12", "标题\n\n正文。"}, // multi-digit CJK numeral + {"第1章 Title\n\nbody.", "Глава 1", "Title\n\nbody."}, // Arabic numeral + space + {"第一节\n\n正文。", "Глава 1", "正文。"}, // header with NO subtitle → whole line drops + } + for _, c := range cases { + gotH, gotS := stripHeading(c.chapter, hr) + if gotH != c.wantHeading || gotS != c.wantStripped { + t.Errorf("stripHeading(%q):\n heading = %q, want %q\n stripped = %q, want %q", + c.chapter, gotH, c.wantHeading, gotS, c.wantStripped) + } + } +} + +// TestStripHeadingNegatives asserts the precision guards: a nil rule is inert; a glued measure word is not +// a header; prose that merely opens with 第 is untouched. +func TestStripHeadingNegatives(t *testing.T) { + hr := zhRuHeadingRule() + negatives := []string{ + "第一回见面时,他笑了。\n\n正文。", // 回 measure word glued to content 见 → NOT a header (ingest parity) + "这是第三节的内容。\n\n正文。", // 第 not at line start + "普通的一段话。\n\n第二段。", // no marker at all + "第节:无数字\n\n正文。", // no numeral between marker and unit + } + for _, chap := range negatives { + if gotH, gotS := stripHeading(chap, hr); gotH != "" || gotS != chap { + t.Errorf("stripHeading(%q) should be a no-op, got heading=%q stripped=%q", chap, gotH, gotS) + } + } + // A nil rule is always a no-op (a book with no heading.txt). + if gotH, gotS := stripHeading("第一节:标题\n\n正文。", nil); gotH != "" || gotS != "第一节:标题\n\n正文。" { + t.Errorf("nil rule must be inert, got heading=%q", gotH) + } +} + +// TestSplitChunksHeadingCarried asserts the deterministic title lands on the chapter's FIRST chunk only, +// the source marker is stripped from ch.Text, and applyHeading prepends it correctly. +func TestSplitChunksHeadingCarried(t *testing.T) { + hr := zhRuHeadingRule() + chapters := []string{ + "第一节:纵身亡魔心仍不悔\n\n古月方源站着。", + "第二节:逆光阴\n\n夜色降临。", + } + chunks := SplitChunks(chapters, testSeg(), hr) + if len(chunks) < 2 { + t.Fatalf("want ≥2 chunks, got %d", len(chunks)) + } + // Chapter 1, chunk 0 carries «Глава 1»; its text no longer contains the marker. + if chunks[0].Heading != "Глава 1" { + t.Errorf("chunk0 heading = %q, want «Глава 1»", chunks[0].Heading) + } + if containsRune(chunks[0].Text, '第') { + t.Errorf("chunk0 text still carries the source marker: %q", chunks[0].Text) + } + // applyHeading prepends only to a non-empty final and is a no-op on an empty/no-heading unit. + if got := applyHeading(chunks[0].Heading, "перевод."); got != "Глава 1\n\nперевод." { + t.Errorf("applyHeading = %q", got) + } + if got := applyHeading(chunks[0].Heading, ""); got != "" { + t.Errorf("applyHeading on empty final must stay empty, got %q", got) + } + if got := applyHeading("", "перевод."); got != "перевод." { + t.Errorf("applyHeading with no heading must be a no-op, got %q", got) + } + // A book with NO heading rule keeps the source header verbatim (byte-identical to pre-pack). + plain := SplitChunks(chapters, testSeg(), nil) + if !containsRune(plain[0].Text, '第') || plain[0].Heading != "" { + t.Errorf("nil-rule chunk0 must keep the marker and carry no heading: text=%q heading=%q", plain[0].Text, plain[0].Heading) + } +} + +func containsRune(s string, r rune) bool { + for _, c := range s { + if c == r { + return true + } + } + return false +} diff --git a/backend/internal/pipeline/chunker_test.go b/backend/internal/pipeline/chunker_test.go index 9e6b7b8e..881fcb3c 100644 --- a/backend/internal/pipeline/chunker_test.go +++ b/backend/internal/pipeline/chunker_test.go @@ -24,7 +24,7 @@ func stripMeta(cs []Chunk) []Chunk { } func TestSplitChunksSingleParagraph(t *testing.T) { - got := SplitChunks([]string{"静かな図書館の朝。"}, testSeg()) + got := SplitChunks([]string{"静かな図書館の朝。"}, testSeg(), nil) want := []Chunk{{Chapter: 1, ChunkIdx: 0, Text: "静かな図書館の朝。"}} if !reflect.DeepEqual(stripMeta(got), want) { t.Fatalf("single paragraph = %+v, want %+v", got, want) @@ -36,7 +36,7 @@ func TestSplitChunksSingleParagraph(t *testing.T) { } func TestSplitChunksChapters(t *testing.T) { - got := SplitChunks([]string{"Глава один.", "Глава два.", "Глава три."}, testSeg()) + got := SplitChunks([]string{"Глава один.", "Глава два.", "Глава три."}, testSeg(), nil) want := []Chunk{ {Chapter: 1, ChunkIdx: 0, Text: "Глава один."}, {Chapter: 2, ChunkIdx: 0, Text: "Глава два."}, @@ -58,7 +58,7 @@ func TestSplitChunksPacksToTarget(t *testing.T) { // cyrillic: 3000 chars × 0.3852) must split into two draft chunks, each kept whole. p1 := strings.Repeat("а", 3000) p2 := strings.Repeat("б", 3000) - got := SplitChunks([]string{p1 + "\n\n" + p2}, testSeg()) + got := SplitChunks([]string{p1 + "\n\n" + p2}, testSeg(), nil) want := []Chunk{ {Chapter: 1, ChunkIdx: 0, Text: p1}, {Chapter: 1, ChunkIdx: 1, Text: p2}, @@ -73,7 +73,7 @@ func TestSplitChunksPacksToTarget(t *testing.T) { } func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) { - got := SplitChunks([]string{"Абзац один.\n\nАбзац два.\n\nАбзац три."}, testSeg()) + got := SplitChunks([]string{"Абзац один.\n\nАбзац два.\n\nАбзац три."}, testSeg(), nil) if len(got) != 1 { t.Fatalf("small paragraphs must pack into one chunk, got %d: %+v", len(got), summarize(got)) } @@ -93,7 +93,7 @@ func TestSplitChunksOversizeParagraphSplitsAtSentences(t *testing.T) { sb.WriteByte(' ') } para := strings.TrimSpace(sb.String()) - got := SplitChunks([]string{para}, testSeg()) + got := SplitChunks([]string{para}, testSeg(), nil) if len(got) < 2 { t.Fatalf("oversize paragraph must split into >1 chunk, got %d", len(got)) } @@ -115,7 +115,7 @@ func TestSplitChunksShortLinesPackByTrueTokenBudget(t *testing.T) { for i := 0; i < 150; i++ { paras = append(paras, "「はい。」") } - got := SplitChunks([]string{strings.Join(paras, "\n\n")}, testSeg()) + got := SplitChunks([]string{strings.Join(paras, "\n\n")}, testSeg(), nil) if len(got) != 1 { t.Fatalf("short lines must pack by true token budget into 1 chunk, got %d chunks", len(got)) } @@ -128,7 +128,7 @@ func TestSplitChunksOversizeSentenceIsOwnChunk(t *testing.T) { // A single sentence (no internal terminator) larger than the budget can only be its own // chunk — never split — and is flagged OversizedSentence (passthrough, WS2 §2б). big := strings.Repeat("ы", 6000) // ~2311 out-tokens, one un-terminated run - got := SplitChunks([]string{big}, testSeg()) + got := SplitChunks([]string{big}, testSeg(), nil) if len(got) != 1 || got[0].Text != big { t.Fatalf("an oversize sentence must be one un-split chunk, got %+v", summarize(got)) } @@ -139,7 +139,7 @@ func TestSplitChunksOversizeSentenceIsOwnChunk(t *testing.T) { func TestSplitChunksDropsEmpties(t *testing.T) { // A blank/whitespace-only chapter vanishes and does NOT consume a chapter number. - got := SplitChunks([]string{"A", " \n\n ", "B"}, testSeg()) + got := SplitChunks([]string{"A", " \n\n ", "B"}, testSeg(), nil) want := []Chunk{ {Chapter: 1, ChunkIdx: 0, Text: "A"}, {Chapter: 2, ChunkIdx: 0, Text: "B"}, @@ -154,7 +154,7 @@ func TestSplitChunksDeterministic(t *testing.T) { "Абзац.\n\nЕщё абзац.", strings.Repeat("слово ", 400) + "конец.", } - a, b := SplitChunks(chapters, testSeg()), SplitChunks(chapters, testSeg()) + a, b := SplitChunks(chapters, testSeg(), nil), SplitChunks(chapters, testSeg(), nil) if !reflect.DeepEqual(a, b) { t.Fatal("SplitChunks must be deterministic") } @@ -164,7 +164,7 @@ func TestSplitChunksNeverSplitsAbbreviation(t *testing.T) { // Two long en paragraphs each ending mid-flow with "Mr. Smith" must not chunk // between "Mr." and "Smith" — the abbreviation guard keeps the sentence whole. p := strings.Repeat("The road went on and on. ", 100) + "It was Mr. Smith who arrived." - got := SplitChunks([]string{p}, testSeg()) + got := SplitChunks([]string{p}, testSeg(), nil) for _, c := range got { if strings.HasSuffix(strings.TrimSpace(c.Text), "Mr.") { t.Fatalf("chunk ended at an abbreviation 'Mr.' — a sentence was cut: %q", c.Text) @@ -182,7 +182,7 @@ func TestSplitChunksEditUnitGroupsWholeChunks(t *testing.T) { p := strings.Repeat("а", 3000) q := strings.Repeat("б", 3000) r := strings.Repeat("в", 3000) - got := SplitChunks([]string{p + "\n\n" + q + "\n\n" + r}, testSeg()) + got := SplitChunks([]string{p + "\n\n" + q + "\n\n" + r}, testSeg(), nil) if len(got) != 3 { t.Fatalf("want 3 draft chunks, got %d", len(got)) } @@ -319,7 +319,7 @@ func FuzzSplitChunksPreservesContent(f *testing.F) { norm := NormalizeSource(s) want := countNonSpace(norm) got := 0 - for _, c := range SplitChunks([]string{norm}, testSeg()) { + for _, c := range SplitChunks([]string{norm}, testSeg(), nil) { got += countNonSpace(c.Text) } if got != want { diff --git a/backend/internal/pipeline/export.go b/backend/internal/pipeline/export.go index d0739972..3dc46073 100644 --- a/backend/internal/pipeline/export.go +++ b/backend/internal/pipeline/export.go @@ -173,6 +173,13 @@ func (r *Runner) Export(pairs bool) (*BookExport, error) { } } } + // Title policy (pack-13): prepend the chapter's deterministic «Глава N» to its FIRST unit's non-empty + // export text — the SAME projection waverun's outcome assembly applies, both derived from the + // deterministic chunker (u.Members[0] is the leader; only a chapter-opening chunk carries a Heading), + // so `tmctl export` and `tmctl translate` ship byte-identical text. The --pairs Source column is left + // as the (heading-stripped) source manifest — the DC FP-measure aligns src↔target on the body prose, + // not the deterministic title (which no checker inspects). + ce.FinalText = applyHeading(u.Members[0].Heading, ce.FinalText) if pairs { ce.Source = u.sourceText() // the unit src↔target column for the DC1/DC2 FP-measure (--pairs) if droppedAny { diff --git a/backend/internal/pipeline/ingest_test.go b/backend/internal/pipeline/ingest_test.go index 3edf45ec..de46ac3a 100644 --- a/backend/internal/pipeline/ingest_test.go +++ b/backend/internal/pipeline/ingest_test.go @@ -226,7 +226,7 @@ func TestIngestEPUBRubyChapterMatchesDenseNumbering(t *testing.T) { if len(doc.Ruby) != 1 || doc.Ruby[0].Chapter != 2 { t.Fatalf("ruby dense chapter = %#v, want chapter 2", doc.Ruby) } - chunks := SplitChunks(doc.Chapters, testSeg()) + chunks := SplitChunks(doc.Chapters, testSeg(), nil) var rubyChunkChapter int for _, c := range chunks { if strings.Contains(c.Text, "朱雀") { diff --git a/backend/internal/pipeline/loopguard.go b/backend/internal/pipeline/loopguard.go new file mode 100644 index 00000000..2c678019 --- /dev/null +++ b/backend/internal/pipeline/loopguard.go @@ -0,0 +1,72 @@ +package pipeline + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// loopguard.go: a degenerate-loop observability check. When a model gets stuck it emits the SAME output +// for consecutive units; this flags a run of identical consecutive translated segments. The signature is +// a whitespace-normalized hash of each segment, so cosmetic spacing differences don't hide a loop. It is +// pure OBSERVABILITY — like the cheap style gates it is never a disposition and never touches the wire — +// and it is surfaced in the read-only quality report, so a resume re-derives the identical count. Pure and +// deterministic (no time/rand). + +// segmentLoopMinRun is the run length that triggers a flag. Three consecutive units with byte-identical +// (whitespace-normalized) output is already a strong signal for prose, where every unit's source — and so +// its translation — differs. Precision over recall: a rare false flag on legitimately repeated boilerplate +// is better than dropping to a noisy threshold. +const segmentLoopMinRun = 3 + +// segmentLoopRun is one maximal run of identical consecutive non-empty segments. +type segmentLoopRun struct { + Signature string // the normalized SHA-256 prefix of the repeated segment + Start int // 0-based index of the first segment of the run (in reading/manifest order) + Count int // number of consecutive identical segments (≥ minRun) +} + +// segmentLoopRuns scans segments in reading order for maximal runs of ≥ minRun consecutive segments with +// the same normalized signature, returning one segmentLoopRun per such run. EMPTY / whitespace-only +// segments never join a run (a pending/flagged unit exports "", which must not read as a loop) and break +// any run in progress. Deterministic: the signature is a pure function of the segment bytes. +func segmentLoopRuns(segments []string, minRun int) []segmentLoopRun { + if minRun < 2 { + minRun = 2 + } + var runs []segmentLoopRun + runStart, runSig, runLen := -1, "", 0 + flush := func() { + if runLen >= minRun { + runs = append(runs, segmentLoopRun{Signature: runSig, Start: runStart, Count: runLen}) + } + runStart, runSig, runLen = -1, "", 0 + } + for i, seg := range segments { + sig, empty := loopSignature(seg) + if empty { + flush() // an empty export cannot be part of a loop and breaks the run + continue + } + if runLen > 0 && sig == runSig { + runLen++ + continue + } + flush() + runStart, runSig, runLen = i, sig, 1 + } + flush() + return runs +} + +// loopSignature returns a segment's whitespace-normalized SHA-256 prefix and whether it is empty. All +// runs of whitespace collapse to a single space and the ends are trimmed, so cosmetic whitespace between +// two otherwise-identical outputs does not hide a loop; an empty / whitespace-only segment reports empty. +func loopSignature(seg string) (sig string, empty bool) { + norm := strings.Join(strings.Fields(seg), " ") + if norm == "" { + return "", true + } + sum := sha256.Sum256([]byte(norm)) + return hex.EncodeToString(sum[:])[:16], false +} diff --git a/backend/internal/pipeline/loopguard_test.go b/backend/internal/pipeline/loopguard_test.go new file mode 100644 index 00000000..286b0143 --- /dev/null +++ b/backend/internal/pipeline/loopguard_test.go @@ -0,0 +1,32 @@ +package pipeline + +import "testing" + +// TestSegmentLoopRuns pins the pack-13 point-10 degenerate-loop guard: a run of ≥3 identical consecutive +// (whitespace-normalized) segments is a loop; distinct prose is not; empty exports break a run and never +// count (a pending/flagged unit exports ""). +func TestSegmentLoopRuns(t *testing.T) { + cases := []struct { + name string + segs []string + wantRuns int + }{ + {"healthy prose", []string{"Абзац один.", "Абзац два.", "Абзац три.", "Абзац четыре."}, 0}, + {"loop of 3", []string{"уникальный", "ПОВТОР", "ПОВТОР", "ПОВТОР", "конец"}, 1}, + {"loop of 2 below threshold", []string{"a", "ПОВТОР", "ПОВТОР", "b"}, 0}, + {"whitespace-insensitive loop", []string{"один два", "один два", "один\tдва", "один два"}, 1}, + {"empty breaks the run", []string{"ПОВТОР", "ПОВТОР", "", "ПОВТОР", "ПОВТОР"}, 0}, + {"empties never loop", []string{"", "", "", "", ""}, 0}, + {"two separate loops", []string{"A", "A", "A", "x", "B", "B", "B"}, 2}, + } + for _, c := range cases { + if got := len(segmentLoopRuns(c.segs, segmentLoopMinRun)); got != c.wantRuns { + t.Errorf("%s: segmentLoopRuns = %d runs, want %d", c.name, got, c.wantRuns) + } + } + // The run carries the correct start + count. + runs := segmentLoopRuns([]string{"a", "R", "R", "R", "R"}, 3) + if len(runs) != 1 || runs[0].Start != 1 || runs[0].Count != 4 { + t.Errorf("run metadata wrong: %+v", runs) + } +} diff --git a/backend/internal/pipeline/memory.go b/backend/internal/pipeline/memory.go index 2bdb8436..e96c7331 100644 --- a/backend/internal/pipeline/memory.go +++ b/backend/internal/pipeline/memory.go @@ -517,6 +517,13 @@ func renderGlossaryBlock(injected []pickedEntry) string { line := p.entry.src + " → " + p.entry.dst if p.disp != memConfirmed { line += " ⟨проверить⟩" + } else { + // pack-13 injection-completeness fix (D39.21 owner directive: «род должен доезжать»): the + // gender of a CONFIRMED named term (蛊 Надежды = ж.р., a cicada gu, …) now reaches the DRAFT + // wire too, not only the editor — so the TRANSLATOR renders the right родовые формы FIRST, + // instead of leaving the editor to repair a wrong gender. "" for a genderless term (the common + // case → byte-identical). Mirrors the editor block's confirmed-only gender (renderEditorConstraintBlock). + line += genderConstraintNote(p.entry.gender) } lines = append(lines, line) } @@ -534,7 +541,11 @@ func renderGlossaryBlock(injected []pickedEntry) string { // discipline — memoryMatchVersion (const memory.go) would not move on a render edit. // v2 (WS5 R4): the editor constraint line now carries a gender annotation for a gendered CONFIRMED // term (DC3 injection — the Bai Ninbing fix: the injection DIRECTS the editor, no coreference needed). -const renderFormatVersion = "renderfmt-v2-editor-src2dst+dc3-gender" +// v3 (pack-13, D39.21 injection-completeness fix): the DRAFT glossary block (renderGlossaryBlock) now +// ALSO appends the confirmed-gender annotation, so a named term's gender reaches the TRANSLATOR wire, not +// only the editor (the owner's «род должен доезжать» directive). A gendered confirmed term shifts the +// draft injected bytes → the wire, so a loud --resnapshot; a genderless bank is byte-identical to v2. +const renderFormatVersion = "renderfmt-v3-draft-gender+editor-src2dst+dc3-gender" // editorConstraintHeader introduces the editor's canonical-constraint block. It gives the BILINGUAL // editor (D30.1) the approved src→dst bindings as consistency constraints — it must render each diff --git a/backend/internal/pipeline/mineddelta_collision_test.go b/backend/internal/pipeline/mineddelta_collision_test.go new file mode 100644 index 00000000..74b7dfb4 --- /dev/null +++ b/backend/internal/pipeline/mineddelta_collision_test.go @@ -0,0 +1,44 @@ +package pipeline + +import ( + "strings" + "testing" + + "textmachine/backend/internal/store" +) + +// TestMinedDeltaSeedCollisions covers both paths of the D39.20 deviation-#1 crash guard: a mined-delta +// term whose UNIQUE key (src, sense, since_ch, until_ch) already exists in the seed must be reported +// (would crash ReplaceGlossary), while a disjoint delta reports nothing. It also asserts a SAME-dst +// duplicate is caught (approvedSharedKeyCollisions skips it, so this guard is the one that must fire). +func TestMinedDeltaSeedCollisions(t *testing.T) { + seed := []store.GlossaryEntry{ + {Src: "元", Dst: "первооснова", Sense: "корень", Status: "approved"}, + {Src: "赤城", Dst: "Чичэн", Status: "approved"}, + } + + // Path A — a mined-delta term duplicating the seed's UNIQUE key (different dst) → loud. + minedDiff := []store.GlossaryEntry{{Src: "元", Dst: "юань", Sense: "корень", Status: "approved", Source: "mined"}} + if dups := minedDeltaSeedCollisions(seed, minedDiff); len(dups) != 1 { + t.Fatalf("different-dst duplicate: want 1 collision, got %d (%v)", len(dups), dups) + } else if !strings.Contains(dups[0], "元") { + t.Fatalf("collision message should name the duplicated src: %q", dups[0]) + } + + // Path A' — a SAME-dst duplicate (approvedSharedKeyCollisions skips these; this guard must still fire, + // since the UNIQUE INSERT crashes regardless of dst). + minedSame := []store.GlossaryEntry{{Src: "元", Dst: "первооснова", Sense: "корень", Status: "approved", Source: "mined"}} + if dups := minedDeltaSeedCollisions(seed, minedSame); len(dups) != 1 { + t.Fatalf("same-dst duplicate: want 1 collision (the INSERT crashes regardless of dst), got %d", len(dups)) + } + + // Path B — a disjoint delta (a genuinely new term, or the same src in a DIFFERENT spoiler window) → + // nothing to report; the append is safe. + minedOK := []store.GlossaryEntry{ + {Src: "春秋蝉", Dst: "Весенне-осенняя цикада", Status: "approved", Source: "mined"}, + {Src: "元", Dst: "юань (валюта)", Sense: "деньги", Status: "approved", Source: "mined"}, // different sense → different UNIQUE key + } + if dups := minedDeltaSeedCollisions(seed, minedOK); len(dups) != 0 { + t.Fatalf("disjoint delta: want 0 collisions, got %d (%v)", len(dups), dups) + } +} diff --git a/backend/internal/pipeline/quality.go b/backend/internal/pipeline/quality.go index 35b5c8d0..41e22b63 100644 --- a/backend/internal/pipeline/quality.go +++ b/backend/internal/pipeline/quality.go @@ -59,6 +59,15 @@ type QualityReport struct { // is NOT a CJK leak — F6, D39.4: the old cjk_leak_rate counted every sanitizer_stripped unit). CosmeticStripRate float64 `json:"cosmetic_strip_rate"` + // DegenerateLoopRuns counts runs of ≥ segmentLoopMinRun consecutive units whose exported MODEL text is + // byte-identical after whitespace normalization — a degenerate translation loop (pack-13 point-10, + // research/21; complements echoMineViolation on the degenerate path research/15). Observability only, + // never a disposition or a wire touch; 0 on a healthy book (every unit's source, and so its translation, + // differs). The signature is over the MODEL output (exportNormalize, BEFORE the deterministic title is + // prepended), so a per-chapter «Глава N» never masks a body loop. omitempty keeps a loop-free run's + // report byte-identical to before this field existed. + DegenerateLoopRuns int `json:"degenerate_loop_runs,omitempty"` + Chunks []ChunkQuality `json:"chunks,omitempty"` } @@ -220,6 +229,7 @@ func (r *Runner) QualityReport() (*QualityReport, error) { if n := len(r.Pipeline.Stages); n > 0 { lastStage = r.Pipeline.Stages[n-1].Name } + loopText := map[chunkKey]string{} // per-unit exported MODEL text (no title), for the pack-13 point-10 loop scan for _, cs := range statuses { k := chunkKey{cs.Chapter, cs.ChunkIdx} if cs.Stage != lastStage || !inManifest[k] { @@ -248,7 +258,9 @@ func (r *Runner) QualityReport() (*QualityReport, error) { if cp == nil || strings.TrimSpace(cp.ResponseText) == "" { continue } - sent, para := narrativeStructure(exportNormalize(cp.ResponseText)) + normText := exportNormalize(cp.ResponseText) + loopText[k] = normText + sent, para := narrativeStructure(normText) q := chunkOf(k) q.NarrativeSentences, q.NarrativeParagraphs = sent, para rep.NarrativeSentences += sent @@ -256,6 +268,14 @@ func (r *Runner) QualityReport() (*QualityReport, error) { rep.TextUnits++ } + // Degenerate-loop guard (pack-13 point-10): scan the exported MODEL texts in reading (manifest/unit) + // order for runs of identical consecutive units — observability, never a gate. Deterministic. + ordered := make([]string, 0, len(units)) + for _, u := range units { + ordered = append(ordered, loopText[chunkKey{u.Chapter, u.FirstChunkIdx}]) + } + rep.DegenerateLoopRuns = len(segmentLoopRuns(ordered, segmentLoopMinRun)) + if rep.NarrativeParagraphs > 0 { rep.MeanSentPerNarrPara = float64(rep.NarrativeSentences) / float64(rep.NarrativeParagraphs) } diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index 758625c5..5469d38f 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -242,6 +242,27 @@ func (r *Runner) loadTemplates() error { return nil } +// headingRule is the book's deterministic chapter-heading rule (pack-13 title policy), or nil when the +// book declares no langpack, its pair has no catalog, or the pair ships no heading.txt. The chunker uses +// it to detect+strip a source header and render «Глава N»; nil ⇒ the feature is inert (byte-identical +// chunks). Its bytes ride pack.Version() (folded into the snapshot via LangpackVersion), so a rule edit is +// a loud --resnapshot and a book without a rule is never re-billed for a feature it does not use. +func (r *Runner) headingRule() *lang.HeadingRule { + if r.pack != nil { + return r.pack.Heading + } + return nil +} + +// estOutTokens is the DISPLAY-ONLY fertility estimate of a text's ru output tokens (est_out = fertility +// over the source char-classes, research/20 — NOT the char/4 EstimateTokens whose CJK undercount is the +// «CJK-mine»). Used ONLY to annotate a settle-estimate telemetry row (pack-13 point-9): it feeds NOTHING +// on the money path (not the reservation, not CostUSD), so it can never shift a verdict or a wire byte. +func (r *Runner) estOutTokens(text string) int { + cjk, other := tokenClassCounts(text) + return int(r.segBudget().estOut(cjk, other)) +} + // segBudget resolves the pipeline's config.Segmentation into the pipeline-package SegBudget the // chunker consumes (WS2). Kept in the runner (not chunker.go) so the segmenter stays config-free. func (r *Runner) segBudget() SegBudget { diff --git a/backend/internal/pipeline/seeding.go b/backend/internal/pipeline/seeding.go index 07bcfe62..1ef06dfb 100644 --- a/backend/internal/pipeline/seeding.go +++ b/backend/internal/pipeline/seeding.go @@ -52,6 +52,17 @@ func (r *Runner) seedGlossary(ctx context.Context) error { if err != nil { return err } + // D39.20 deviation-#1 fix: a mined-delta term whose UNIQUE key (src, sense, since_ch, until_ch — + // store/migrate.go glossary UNIQUE) already exists in the SIGNED seed makes the flat INSERT in + // ReplaceGlossary crash on that constraint and abort the whole paid run. approvedSharedKeyCollisions + // below does NOT catch it (it skips a SAME-dst duplicate, and keys on the firing surface, not the + // UNIQUE tuple). Fail LOUD here with the duplicate list + a fix hint (edit the seed OR drop it from + // the delta) — NEVER a silent merge over the signed seed. Checked BEFORE the append so the delta rows + // are still separable. Deterministic (seed order). + if dups := minedDeltaSeedCollisions(entries, minedDelta); len(dups) > 0 { + return fmt.Errorf("pipeline: mined-delta %s duplicates term(s) already in the signed seed (would crash ReplaceGlossary on the glossary UNIQUE(book_id,src,sense,since_ch,until_ch)):\n - %s\n fix: correct the term in the seed, or remove it from the mined-delta file — never both (no silent merge over the signed seed)", + r.Book.MinedDelta, strings.Join(dups, "\n - ")) + } entries = append(entries, minedDelta...) for i := range entries { entries[i].BookID = r.Book.BookID @@ -98,6 +109,31 @@ func (r *Runner) seedGlossary(ctx context.Context) error { return nil } +// minedDeltaSeedCollisions returns a human message for every mined-delta entry whose store UNIQUE key +// (src, sense, since_ch, until_ch) already exists among the seed/ruby entries — the D39.20 deviation-#1 +// crash class. It keys on the FULL uniqueness tuple (not the firing surface, unlike +// approvedSharedKeyCollisions, and regardless of dst — a SAME-dst duplicate crashes the INSERT just the +// same, yet approvedSharedKeyCollisions deliberately skips it). Pure and deterministic (seed order); an +// empty result means the delta is disjoint from the seed on the uniqueness axis and can be appended safely. +func minedDeltaSeedCollisions(seedEntries, mined []store.GlossaryEntry) []string { + type key struct { + src, sense string + since, until int + } + seen := map[key]store.GlossaryEntry{} + for _, e := range seedEntries { + seen[key{e.Src, e.Sense, e.SinceCh, e.UntilCh}] = e + } + var out []string + for _, m := range mined { + if prior, ok := seen[key{m.Src, m.Sense, m.SinceCh, m.UntilCh}]; ok { + out = append(out, fmt.Sprintf("src %q (sense %q, window [%d,%d]) is in both the seed (%q→%q) and the mined-delta (%q→%q)", + m.Src, m.Sense, m.SinceCh, m.UntilCh, prior.Src, prior.Dst, m.Src, m.Dst)) + } + } + return out +} + // persistRuby aggregates the ingested ruby occurrences into one row per // (base, reading) — first_chapter = MIN, occurrences = full-book count — and // REPLACES the book's whole ruby set (store.ReplaceRubyReadings). Idempotent: the diff --git a/backend/internal/pipeline/stagerun.go b/backend/internal/pipeline/stagerun.go index 1fb5714b..9b036158 100644 --- a/backend/internal/pipeline/stagerun.go +++ b/backend/internal/pipeline/stagerun.go @@ -433,6 +433,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID rl := r.baseRequestLog(st, ch, model, reqHash) rl.CostUSD, rl.LatencyMS, rl.FinishReason = estimate, att.latency, decodeErrorFinish rl.Degraded, rl.Err, rl.OK = "billed_2xx_decode_failed", err.Error(), false + rl.Estimated, rl.EstTokens = true, r.estOutTokens(ch.Text) // cost_usd is the reservation estimate (pack-13 point-9) r.Store.LogRequest(ctx, r.Log, rl) r.setJobStatus(ctx, job.ID, "done") return att, nil @@ -466,10 +467,12 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID // A paid model (InputPerM>0) returned 2xx with zero usage — $0 would blind the // ceiling: take a conservative estimate. For local ($0 price) zero usage is // normal — it stays $0. + estimatedCost := false if cost == 0 && price.InputPerM > 0 && resp.Usage.PromptTokens == 0 && resp.Usage.CompletionTokens == 0 { r.Log.WarnContext(ctx, "paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest", "stage", st.Name, "model", modelActual, "estimate_usd", fmt.Sprintf("%.6f", estimate)) cost = estimate + estimatedCost = true // cost_usd is the reservation estimate, not provider-reported (pack-13 point-9) } usageJSON, err := json.Marshal(resp.Usage) if err != nil { @@ -510,6 +513,9 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID rl.CompletionTokens, rl.ReasoningTokens = resp.Usage.CompletionTokens, resp.Usage.ReasoningTokens rl.CostUSD, rl.LatencyMS, rl.FinishReason = cost, att.latency, resp.FinishReason rl.OK, rl.Degraded = att.cls.ok(), degradedTag(att.cls) + if estimatedCost { + rl.Estimated, rl.EstTokens = true, r.estOutTokens(ch.Text) // paid 2xx zero-usage → cost is the estimate (pack-13 point-9) + } r.Store.LogRequest(ctx, r.Log, rl) r.setJobStatus(ctx, job.ID, "done") r.Log.InfoContext(ctx, "attempt completed", "stage", st.Name, "attempt", attempt, "model", modelActual, diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go index 2aad8bbd..c64cd095 100644 --- a/backend/internal/pipeline/status.go +++ b/backend/internal/pipeline/status.go @@ -141,7 +141,7 @@ func (r *Runner) bookChunks() ([]Chunk, error) { if err != nil { return nil, err } - return SplitChunks(doc.Chapters, r.segBudget()), nil + return SplitChunks(doc.Chapters, r.segBudget(), r.headingRule()), nil } // chunkKey identifies a chunk positionally. diff --git a/backend/internal/pipeline/testdata/golden/capture.golden b/backend/internal/pipeline/testdata/golden/capture.golden index 18d940ec..d317e41b 100644 --- a/backend/internal/pipeline/testdata/golden/capture.golden +++ b/backend/internal/pipeline/testdata/golden/capture.golden @@ -1,25 +1,25 @@ ==== run 1 (fresh) ==== -snapshot_draft: b1279edc077f8f26c67e4e94c01eb29e02e0f706d4d18f267519bbb58f2e474a -snapshot_draft_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v2-editor-src2dst+dc3-gender","memory_version":"bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v3-dc-checkers+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}}]} -snapshot_edit: 66a990322f96d7f8f6d4783c63dbee75833b532e31d6a95d2de81394880d0a41 -snapshot_edit_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v2-editor-src2dst+dc3-gender","memory_version":"0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v3-dc-checkers+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} +snapshot_draft: 6b771d4618ed24d9a4dbe9de7cb98e6289c1dab66fa2696dc6c80a306a910a58 +snapshot_draft_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","memory_version":"bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v4-percent-latin-brokenword+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}}]} +snapshot_edit: 9d592c5bdb94885f91e2711a24bacfe64c5d2a3817162090dd209320bbee992d +snapshot_edit_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","memory_version":"0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v4-percent-latin-brokenword+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} brief_hash: c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df memory_version: 0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91 base_memory_version: bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7 -- book result -- chunks=8 flagged=4 exit=2 total_usd=0.04003999999999999 -chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 22c30f20dea6. Судзуки шёл по коридорам Академии магии." cost=0.00546 +chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d1403f85a8e4. Судзуки шёл по коридорам Академии магии." cost=0.00546 stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА 01b668f15717. Судзуки шёл по коридорам Академии магии." recovered="" stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА f92637b8f266. Судзуки шёл по коридорам Академии магии." recovered="" stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 22c30f20dea6. Судзуки шёл по коридорам Академии магии." recovered="" -chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 5abc35ddfb65. Судзуки шёл по коридорам Академии магии." cost=0.0091 + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d1403f85a8e4. Судзуки шёл по коридорам Академии магии." recovered="" +chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД f393617694a8. Судзуки шёл по коридорам Академии магии." cost=0.0091 stage=draft role=translator model=fake-fallback resume=false disp=ok flag="" attempts=1 escalated=true esc_model="fake-fallback" finish="stop" cum_usd=0.00728 detail="" - stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии." recovered="" + stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 7446eccb85bd. Судзуки вынес Драконью печать из Академии магии." recovered="" stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 5abc35ddfb65. Судзуки шёл по коридорам Академии магии." recovered="" + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД f393617694a8. Судзуки шёл по коридорам Академии магии." recovered="" chunk ch3/0 disposition=flagged flag="hard_refusal" final_text="" cost=0.00728 stage=draft role=translator model=fake-model resume=false disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="fake-fallback" finish="refusal" cum_usd=0.00728 detail="provider finish_reason=refusal" stage_text="" recovered="" @@ -30,11 +30,11 @@ chunk ch4/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫ stage_text="ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии." recovered="" stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 035dad4c3958. Судзуки шёл по коридорам Академии магии." recovered="" -chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 546481fe42fe. Судзуки увидел, как в тени ждал старик." cost=0.00364 +chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 6e7e7ec94135. Судзуки увидел, как в тени ждал старик." cost=0.00364 stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА 00724953b4bf. СТАРИК-СЦЕНА Судзуки увидел старика." recovered="" stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 546481fe42fe. Судзуки увидел, как в тени ждал старик." recovered="" + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 6e7e7ec94135. Судзуки увидел, как в тени ждал старик." recovered="" chunk ch6/0 disposition=flagged flag="sanitizer_defect" final_text="" cost=0.00364 stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" stage_text="Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь." recovered="" @@ -42,51 +42,51 @@ chunk ch6/0 disposition=flagged flag="sanitizer_defect" final_text="" cost=0.003 stage_text="" recovered="" chunk ch7/0 disposition=flagged flag="sanitizer_stripped" final_text="Глава 7\n\nСудзуки открыл седьмую дверь книгохранилища." cost=0.00364 stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА ec12f3d7a1df. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА 9e65df37b751. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь." recovered="" stage=edit role=editor model=fake-model resume=false disp=flagged flag="sanitizer_stripped" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="markdown header in the output: ### Глава 7" stage_text="" recovered="Глава 7\n\nСудзуки открыл седьмую дверь книгохранилища." chunk ch8/0 disposition=flagged flag="sanitizer_stripped" final_text="Судзуки нашёл древний камень на каменном алтаре." cost=0.00364 stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА 0bc6a4817acb. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА 2c858e3366d2. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень." recovered="" stage=edit role=editor model=fake-model resume=false disp=flagged flag="sanitizer_stripped" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="CJK leak in the ru output: 特产" stage_text="" recovered="Судзуки нашёл древний камень на каменном алтаре." -- chunk_status -- -ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=365abc01adb22d047a3865c79231c2669f7db960fd07069d46547d562cfc537b cost=0.00182 escalated=false esc_model="" detail="" -ch1/0 edit snap_match=true content_hash=8a4e763f79c94ad20a1bac0231a8c1376ccd4f5a71c53cdfcb4de146c0b48b42 disp=ok flag="" attempts=1 final_hash=42e10469327dcf7d8409cda457a7eff626795a709ff0b37f3de260d7ca11986a cost=0.00182 escalated=false esc_model="" detail="" -ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=6f883d099cd0924eab5db1ef3eb730aa50429e719ffeea95df93e33042e9fcc0 cost=0.00182 escalated=false esc_model="" detail="" -ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=f7a858a11f0890da139789d27bea23b1c33c83ef989f2d07dc51d4b238d5e16e cost=0.00728 escalated=true esc_model="fake-fallback" detail="" -ch2/0 edit snap_match=true content_hash=5a5397d41bcfa9326b5fe67c70aca8d9ef14ef89af2dbe1a758e25d364226fc5 disp=ok flag="" attempts=1 final_hash=ff45d79e2d08b57328491e1ac5e0b6b67ba883346c44806b7a63e7adddcf65bf cost=0.00182 escalated=false esc_model="" detail="" -ch3/0 draft snap_match=true content_hash=733fc6a89215efe112cfadd1d1fb361bb8a7ab1013994248a49da0dbd137548e disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal" +ch1/0 draft snap_match=true content_hash=3e156226b9a74aa70c8a259c62e3d2fe68486a96d2178d31084c1e38a3256241 disp=ok flag="" attempts=1 final_hash=07dfef4f3137a6afdd879075709cbe1d4ae4aeffa73fd55d8d4a4b266de44191 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 edit snap_match=true content_hash=5e79dd5cbd310ac01d71ca258002beca18db715dfb8b1a08907508e506595355 disp=ok flag="" attempts=1 final_hash=ff6c6daffd6186e70f8dc9f634a9d8d3fdfbc90affb0a363427140bf52aea33a cost=0.00182 escalated=false esc_model="" detail="" +ch1/1 draft snap_match=true content_hash=82fb7f985de6c07182e8c09a3ceaad94f2e63745b87024087e437fba762b8227 disp=ok flag="" attempts=1 final_hash=970bd70afdd5d956114017f90ba600e63da0da7b2a1fb61908062966f8332503 cost=0.00182 escalated=false esc_model="" detail="" +ch2/0 draft snap_match=true content_hash=cfab1e09264a3a30aba95e7da1d6a269468c6aa0c1b334d9241e78bc266a3571 disp=ok flag="" attempts=1 final_hash=0c5929335c49b0f302948b0efe77ad078ec7538f0020b76c020e36272afe652e cost=0.00728 escalated=true esc_model="fake-fallback" detail="" +ch2/0 edit snap_match=true content_hash=55ffd839a4a26c03cf57e08000ab2c84b267e1429ab4a8e212d3a01fda757632 disp=ok flag="" attempts=1 final_hash=e76420deca3abe19ba49ea6de7af5938e49ef579950bf929896bf8dc6c418a28 cost=0.00182 escalated=false esc_model="" detail="" +ch3/0 draft snap_match=true content_hash=21df44eb104900664bdaaeb2f716f51dcddbc81a71d845f6ba0f91c5bc4b2cfb disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal" ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: a member draft chunk of this edit unit was flagged (hard_refusal)" -ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=6fb9e932865b46557a1f5e4a0852a6fe5b2ed728e54d5704801840f1dbe89ccb cost=0.00182 escalated=false esc_model="" detail="" -ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=5b1f425d363f501da5832d570ebeb036a8d780378abf3e92cdded87c247b9b6e cost=0.00182 escalated=false esc_model="" detail="" -ch5/0 draft snap_match=true content_hash=d8aba42cabb41853a81875ebc725bb516dc2412e29de3a03bc1a68e90f8cf157 disp=ok flag="" attempts=1 final_hash=03ed6d252620e8763a0afc2d535e1b6e1cf97ca1145a66aa08ea199ef7102c04 cost=0.00182 escalated=false esc_model="" detail="" -ch5/0 edit snap_match=true content_hash=82b05e4c3f5ca8432d7eaa7d7d63c4a7766f47a08398953c9a9bc3dc8437039b disp=ok flag="" attempts=1 final_hash=3367114609df41762ff6f64b57de90f85b6f1992987ebf875bc64e028a6a0a4b cost=0.00182 escalated=false esc_model="" detail="" -ch6/0 draft snap_match=true content_hash=9375b05780724cc354eba15a595da9a5f964219c0887042484256eed11fb566f disp=ok flag="" attempts=1 final_hash=1efa0b7620e14fa8cdff89a198efe113e2196a4a48717b1dfacfc9bc878d2ae8 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=2574511b38f07a6eef3dd781907eee77cbdc73978ef28d4c9785e32d28489b58 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=0f8084a8bcf771d895963869671f83e4bf8f80c03a491ea999278d45878fc10c cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 draft snap_match=true content_hash=2ed0a97f39cc5c57c62c3324f4646357372f26e3c5bae1773f24edf9449fc013 disp=ok flag="" attempts=1 final_hash=c00dc01d8cc53a31022467dd878ba7d4d6bd9b7c7a9ee5036f0b69ed6f10025b cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 edit snap_match=true content_hash=b145d78e4d30a39cc4088a1ddb8d934234c65341c62c79545c2eff1e7ae0c3d3 disp=ok flag="" attempts=1 final_hash=508383e619d91f16be54e60712774c414d057784a2d406ca202872f7b98a08f7 cost=0.00182 escalated=false esc_model="" detail="" +ch6/0 draft snap_match=true content_hash=5f396d299a7ec5b10f3c8591e54ce7d84c4d31b815e561b0dd5f56635aef5a71 disp=ok flag="" attempts=1 final_hash=ee0cc663a115e983e4e5df712513ba7085d4a9727e1fa864eec5fa12f8cf5ad0 cost=0.00182 escalated=false esc_model="" detail="" ch6/0 edit snap_match=true content_hash=592353321cc8000e792e0897446e4dd4f88a0280b9368b62cbd5c82b0c433193 disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="leading service preamble: Вот перевод фрагмента:" -ch7/0 draft snap_match=true content_hash=d90c335cba3d1547a22bb3f656443cb432f84c12a577d43509cfe472cf86a2ac disp=ok flag="" attempts=1 final_hash=3a1050fe790ac5eb004c8d3e80a05cc6ff5e6b46c5947f6a57a7145985591dfb cost=0.00182 escalated=false esc_model="" detail="" -ch7/0 edit snap_match=true content_hash=e12521b6eb6dbc2cd35758d664d4d31894c528f092083b5cfe0e79c51be2663e disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:e9a837dbcd8d6717d27413735debe11bc116df2f65655d16f6fcbf51cbf59e66 cost=0.00182 escalated=false esc_model="" detail="markdown header in the output: ### Глава 7" -ch8/0 draft snap_match=true content_hash=fbd27af11da2275cd1d47a0224970a5f8d073c6164f614efb6522ea96fa1e021 disp=ok flag="" attempts=1 final_hash=e1e065fc34385f41fd51a4b0db42d5f391532775ef68ece015f261a33d6b68da cost=0.00182 escalated=false esc_model="" detail="" -ch8/0 edit snap_match=true content_hash=0c6030a943cb341390509e2ebed4bbfd52af328d36916ce2e341f43665cf9183 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:08f107da1257ff083f73e9af1e8d3b5b90e699ad6343faa80bebe891744c7adf cost=0.00182 escalated=false esc_model="" detail="CJK leak in the ru output: 特产" +ch7/0 draft snap_match=true content_hash=15c3ebbd56bb19880202d35b0206357655da245343376492f7ceb61808c8a5be disp=ok flag="" attempts=1 final_hash=df112533bae9eb53eeee7535189479bb62113829691e257827eb6a122c4d24a5 cost=0.00182 escalated=false esc_model="" detail="" +ch7/0 edit snap_match=true content_hash=0b2674ed9829b2e6526ff41a60dd280b6670c539476526bbe835d9bf7a6be4c4 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:e6daeb1f55975010b0d9eafc3d7b9d23891d62e93db9203b1b65c6673180171a cost=0.00182 escalated=false esc_model="" detail="markdown header in the output: ### Глава 7" +ch8/0 draft snap_match=true content_hash=4a420977cc34ce738b4978e6a1baf45d78d773a135070c3b7f8c723adfa57d7b disp=ok flag="" attempts=1 final_hash=3ae07f69c98630ae05ed56465ca12b895aafef0376cb4e6fef6fb37d2edf4fb6 cost=0.00182 escalated=false esc_model="" detail="" +ch8/0 edit snap_match=true content_hash=c851100c8d6785f77d68f8127472790ae9d0408b7fec44785d3ff9d46842c6f8 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:1994445050afdc0455c60b75974a47d9430b847d7c5158d3cebfbea933c61dbd cost=0.00182 escalated=false esc_model="" detail="CJK leak in the ru output: 特产" -- request_log (insertion order) -- -ch1/0 draft role=translator req=fake-model actual=fake-model hash=365abc01adb22d047a3865c79231c2669f7db960fd07069d46547d562cfc537b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=6f883d099cd0924eab5db1ef3eb730aa50429e719ffeea95df93e33042e9fcc0 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-model hash=0932d7aee2cd8fe944a4f5fcc4bf5b1828d8514ecc46cde7b5a3bd9983864dd2 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=f7a858a11f0890da139789d27bea23b1c33c83ef989f2d07dc51d4b238d5e16e tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-model actual=fake-model hash=7be9635f28275d919b1ff3ff6aab9f94039eff250163bef3d6d3460489984b7e tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=ef672cdf6862522d15d93c32faf95d7eebc2c6ff4c24de6b0679a256900349ac tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=6fb9e932865b46557a1f5e4a0852a6fe5b2ed728e54d5704801840f1dbe89ccb tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch5/0 draft role=translator req=fake-model actual=fake-model hash=03ed6d252620e8763a0afc2d535e1b6e1cf97ca1145a66aa08ea199ef7102c04 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch6/0 draft role=translator req=fake-model actual=fake-model hash=1efa0b7620e14fa8cdff89a198efe113e2196a4a48717b1dfacfc9bc878d2ae8 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch7/0 draft role=translator req=fake-model actual=fake-model hash=3a1050fe790ac5eb004c8d3e80a05cc6ff5e6b46c5947f6a57a7145985591dfb tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch8/0 draft role=translator req=fake-model actual=fake-model hash=e1e065fc34385f41fd51a4b0db42d5f391532775ef68ece015f261a33d6b68da tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=42e10469327dcf7d8409cda457a7eff626795a709ff0b37f3de260d7ca11986a tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=ff45d79e2d08b57328491e1ac5e0b6b67ba883346c44806b7a63e7adddcf65bf tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=5b1f425d363f501da5832d570ebeb036a8d780378abf3e92cdded87c247b9b6e tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch5/0 edit role=editor req=fake-model actual=fake-model hash=3367114609df41762ff6f64b57de90f85b6f1992987ebf875bc64e028a6a0a4b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch6/0 edit role=editor req=fake-model actual=fake-model hash=db25b5d375286a9fa18bc3b7b9da14c8bd95a5209b8b2e9099889413062504d1 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch7/0 edit role=editor req=fake-model actual=fake-model hash=54ab4a1ffb853422a3b079b71b3c1c7ba11eb434c3d9f1df3c81aec2f007e0d8 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch8/0 edit role=editor req=fake-model actual=fake-model hash=c047ff047484181ff697d1e525c3d12f5b76ab5f9451cc5f309be3465559cf5e tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=07dfef4f3137a6afdd879075709cbe1d4ae4aeffa73fd55d8d4a4b266de44191 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=970bd70afdd5d956114017f90ba600e63da0da7b2a1fb61908062966f8332503 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-model hash=3c87997526311911cbe02fb9928b43ef9ae420872719390304c2edcbf460d8b8 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=0c5929335c49b0f302948b0efe77ad078ec7538f0020b76c020e36272afe652e tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-model actual=fake-model hash=069f2401b99af0393fb0d63792c89a880c4065a712d49725e9dff72eea0dda43 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=ae51067d2d180033744a10163f4606b9fcdb441931f497c55dbd36e6c9f07c42 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=2574511b38f07a6eef3dd781907eee77cbdc73978ef28d4c9785e32d28489b58 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=c00dc01d8cc53a31022467dd878ba7d4d6bd9b7c7a9ee5036f0b69ed6f10025b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=ee0cc663a115e983e4e5df712513ba7085d4a9727e1fa864eec5fa12f8cf5ad0 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch7/0 draft role=translator req=fake-model actual=fake-model hash=df112533bae9eb53eeee7535189479bb62113829691e257827eb6a122c4d24a5 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch8/0 draft role=translator req=fake-model actual=fake-model hash=3ae07f69c98630ae05ed56465ca12b895aafef0376cb4e6fef6fb37d2edf4fb6 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=ff6c6daffd6186e70f8dc9f634a9d8d3fdfbc90affb0a363427140bf52aea33a tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=e76420deca3abe19ba49ea6de7af5938e49ef579950bf929896bf8dc6c418a28 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=0f8084a8bcf771d895963869671f83e4bf8f80c03a491ea999278d45878fc10c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=508383e619d91f16be54e60712774c414d057784a2d406ca202872f7b98a08f7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 edit role=editor req=fake-model actual=fake-model hash=f3a5675d5ba381251ebfed396f0a79b7bae0d727339adb152fe97dfccd9c15b3 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch7/0 edit role=editor req=fake-model actual=fake-model hash=2ded6927e1537e73b62b017ee8a27095906ad5f2eefa2d5463de41694dc618ce tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch8/0 edit role=editor req=fake-model actual=fake-model hash=456a4a0945d84716e9242516ca980e88241f325075bd7cdc252e2e233da3e34b tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" -- retrieval_state -- ch1/0 snap_match=true exact=3 sticky=0 ambiguous=0 spoiler=1 evicted=0 postcheck_miss=1 style_flags=0 trust_gated=0 injected_ids=["紋章\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0","魔法学院\u001f\u001f0\u001f0"] postcheck_detail=[{"src":"紋章","dst":"герб","disp":"confirmed"}] style_detail= trust_gate_detail= @@ -107,46 +107,46 @@ ch7/0 snap_match=true exact=1 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck ch8/0 snap_match=true exact=1 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck_miss=0 style_flags=0 trust_gated=0 injected_ids=["鈴木\u001f\u001f0\u001f0"] postcheck_detail= style_detail= trust_gate_detail= -- wire bodies (call order) -- -[0] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n紋章 → герб\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第一章 図書館の秘密\n\n一番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ一層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n二番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ二層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n三番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ三層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n四番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ四層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n五番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ五層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n六番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ六層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。扉の奥で彼が見たものは、台座に置かれた竜の紋章だった。それは禁じられた歴史の最後の欠片だと、後に彼は知ることになる。\n\n七番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ七層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} -[1] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки\n魔法学院 → Академия магии\n紋章 → герб"},{"role":"user","content":"八番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ八層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。"}],"model":"fake-model","stream":false,"temperature":0.3} -[2] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-model","stream":false,"temperature":0.3} -[3] {"max_tokens":6000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3} -[4] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-model","stream":false,"temperature":0.3} -[5] {"max_tokens":6000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3} +[0] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n紋章 → герб\n鈴木 → Судзуки (муж. — мужские родовые формы)\n魔法学院 → Академия магии"},{"role":"user","content":"第一章 図書館の秘密\n\n一番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ一層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n二番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ二層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n三番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ三層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n四番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ四層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n五番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ五層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n六番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ六層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。扉の奥で彼が見たものは、台座に置かれた竜の紋章だった。それは禁じられた歴史の最後の欠片だと、後に彼は知ることになる。\n\n七番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ七層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} +[1] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки (муж. — мужские родовые формы)\n魔法学院 → Академия магии\n紋章 → герб"},{"role":"user","content":"八番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ八層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。"}],"model":"fake-model","stream":false,"temperature":0.3} +[2] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки (муж. — мужские родовые формы)\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-model","stream":false,"temperature":0.3} +[3] {"max_tokens":6000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки (муж. — мужские родовые формы)\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3} +[4] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки (муж. — мужские родовые формы)"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-model","stream":false,"temperature":0.3} +[5] {"max_tokens":6000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки (муж. — мужские родовые формы)"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3} [6] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"user","content":"第四章 静かな終わり\n\n夜が明けると、街は霧に包まれていた。人々は何も知らないまま、いつもの朝を迎えた。\n\n遠くの丘の上で、旅人がひとり、東の空を眺めていた。風は冷たく、道はまだ長い。"}],"model":"fake-model","stream":false,"temperature":0.3} -[7] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n老人 → старик\n鈴木 → Судзуки"},{"role":"user","content":"第五章 老人の秘密\n\n鈴木は書庫の奥で老人に会った。老人は静かに古い巻物を差し出した。"}],"model":"fake-model","stream":false,"temperature":0.3} -[8] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第六章 序文漏洩\n\n鈴木は最後の扉の前に立ち、深く息を吸い込んだ。その先に何があるのか、誰も知らなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} -[9] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第七章 見出漏洩\n\n七層目の朝、鈴木は最後の書庫の扉を開けた。扉の奥には見出しだけが刻まれた石板が静かに待っていた。誰もその文字を読めなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} -[10] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第八章 漢字漏洩\n\n鈴木は石板の文字をなぞり、古い漢字の意味を思い出そうとした。だが記憶は霧のように薄れ、指先だけが淡く光っていた。"}],"model":"fake-model","stream":false,"temperature":0.3} -[11] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 紋章 → «герб»\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)\n- 魔法学院 → «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии.\n\nЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} -[12] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 竜の紋章 → «Драконья печать»\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)\n- 魔法学院 → «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} +[7] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n老人 → старик\n鈴木 → Судзуки (муж. — мужские родовые формы)"},{"role":"user","content":"第五章 老人の秘密\n\n鈴木は書庫の奥で老人に会った。老人は静かに古い巻物を差し出した。"}],"model":"fake-model","stream":false,"temperature":0.3} +[8] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки (муж. — мужские родовые формы)"},{"role":"user","content":"第六章 序文漏洩\n\n鈴木は最後の扉の前に立ち、深く息を吸い込んだ。その先に何があるのか、誰も知らなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} +[9] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки (муж. — мужские родовые формы)"},{"role":"user","content":"第七章 見出漏洩\n\n七層目の朝、鈴木は最後の書庫の扉を開けた。扉の奥には見出しだけが刻まれた石板が静かに待っていた。誰もその文字を読めなかった。"}],"model":"fake-model","stream":false,"temperature":0.3} +[10] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки (муж. — мужские родовые формы)"},{"role":"user","content":"第八章 漢字漏洩\n\n鈴木は石板の文字をなぞり、古い漢字の意味を思い出そうとした。だが記憶は霧のように薄れ、指先だけが淡く光っていた。"}],"model":"fake-model","stream":false,"temperature":0.3} +[11] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 紋章 → «герб»\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)\n- 魔法学院 → «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 01b668f15717. Судзуки шёл по коридорам Академии магии.\n\nЧЕРНОВИК ПЕРЕВОДА f92637b8f266. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} +[12] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 竜の紋章 → «Драконья печать»\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)\n- 魔法学院 → «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 7446eccb85bd. Судзуки вынес Драконью печать из Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} [13] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4} -[14] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 老人 → «старик»\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика."}],"model":"fake-model","stream":false,"temperature":0.4} +[14] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 老人 → «старик»\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 00724953b4bf. СТАРИК-СЦЕНА Судзуки увидел старика."}],"model":"fake-model","stream":false,"temperature":0.4} [15] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь."}],"model":"fake-model","stream":false,"temperature":0.4} -[16] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА ec12f3d7a1df. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь."}],"model":"fake-model","stream":false,"temperature":0.4} -[17] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 0bc6a4817acb. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень."}],"model":"fake-model","stream":false,"temperature":0.4} +[16] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 9e65df37b751. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь."}],"model":"fake-model","stream":false,"temperature":0.4} +[17] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 2c858e3366d2. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень."}],"model":"fake-model","stream":false,"temperature":0.4} ==== run 2 (resume) ==== -snapshot_draft: b1279edc077f8f26c67e4e94c01eb29e02e0f706d4d18f267519bbb58f2e474a -snapshot_draft_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v2-editor-src2dst+dc3-gender","memory_version":"bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v3-dc-checkers+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}}]} -snapshot_edit: 66a990322f96d7f8f6d4783c63dbee75833b532e31d6a95d2de81394880d0a41 -snapshot_edit_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v2-editor-src2dst+dc3-gender","memory_version":"0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v3-dc-checkers+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} +snapshot_draft: 6b771d4618ed24d9a4dbe9de7cb98e6289c1dab66fa2696dc6c80a306a910a58 +snapshot_draft_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","memory_version":"bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v4-percent-latin-brokenword+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}}]} +snapshot_edit: 9d592c5bdb94885f91e2711a24bacfe64c5d2a3817162090dd209320bbee992d +snapshot_edit_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","memory_version":"0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v4-percent-latin-brokenword+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} brief_hash: c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df memory_version: 0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91 base_memory_version: bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7 -- book result -- chunks=8 flagged=4 exit=2 total_usd=0 -chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 22c30f20dea6. Судзуки шёл по коридорам Академии магии." cost=0 +chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d1403f85a8e4. Судзуки шёл по коридорам Академии магии." cost=0 stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА 01b668f15717. Судзуки шёл по коридорам Академии магии." recovered="" stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА f92637b8f266. Судзуки шёл по коридорам Академии магии." recovered="" stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 22c30f20dea6. Судзуки шёл по коридорам Академии магии." recovered="" -chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 5abc35ddfb65. Судзуки шёл по коридорам Академии магии." cost=0 + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d1403f85a8e4. Судзуки шёл по коридорам Академии магии." recovered="" +chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД f393617694a8. Судзуки шёл по коридорам Академии магии." cost=0 stage=draft role=translator model=fake-fallback resume=true disp=ok flag="" attempts=1 escalated=true esc_model="fake-fallback" finish="stop" cum_usd=0.00728 detail="" - stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии." recovered="" + stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 7446eccb85bd. Судзуки вынес Драконью печать из Академии магии." recovered="" stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 5abc35ddfb65. Судзуки шёл по коридорам Академии магии." recovered="" + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД f393617694a8. Судзуки шёл по коридорам Академии магии." recovered="" chunk ch3/0 disposition=flagged flag="hard_refusal" final_text="" cost=0 stage=draft role=translator model=fake-model resume=true disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="fake-fallback" finish="" cum_usd=0.00728 detail="provider finish_reason=refusal" stage_text="" recovered="" @@ -157,11 +157,11 @@ chunk ch4/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫ stage_text="ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии." recovered="" stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 035dad4c3958. Судзуки шёл по коридорам Академии магии." recovered="" -chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 546481fe42fe. Судзуки увидел, как в тени ждал старик." cost=0 +chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 6e7e7ec94135. Судзуки увидел, как в тени ждал старик." cost=0 stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА 00724953b4bf. СТАРИК-СЦЕНА Судзуки увидел старика." recovered="" stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 546481fe42fe. Судзуки увидел, как в тени ждал старик." recovered="" + stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 6e7e7ec94135. Судзуки увидел, как в тени ждал старик." recovered="" chunk ch6/0 disposition=flagged flag="sanitizer_defect" final_text="" cost=0 stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" stage_text="Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь." recovered="" @@ -169,67 +169,67 @@ chunk ch6/0 disposition=flagged flag="sanitizer_defect" final_text="" cost=0 stage_text="" recovered="" chunk ch7/0 disposition=flagged flag="sanitizer_stripped" final_text="Глава 7\n\nСудзуки открыл седьмую дверь книгохранилища." cost=0 stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА ec12f3d7a1df. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА 9e65df37b751. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь." recovered="" stage=edit role=editor model=fake-model resume=true disp=flagged flag="sanitizer_stripped" attempts=1 escalated=false esc_model="" finish="sanitized_export" cum_usd=0.00182 detail="markdown header in the output: ### Глава 7" stage_text="" recovered="Глава 7\n\nСудзуки открыл седьмую дверь книгохранилища." chunk ch8/0 disposition=flagged flag="sanitizer_stripped" final_text="Судзуки нашёл древний камень на каменном алтаре." cost=0 stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="" - stage_text="ЧЕРНОВИК ПЕРЕВОДА 0bc6a4817acb. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень." recovered="" + stage_text="ЧЕРНОВИК ПЕРЕВОДА 2c858e3366d2. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень." recovered="" stage=edit role=editor model=fake-model resume=true disp=flagged flag="sanitizer_stripped" attempts=1 escalated=false esc_model="" finish="sanitized_export" cum_usd=0.00182 detail="CJK leak in the ru output: 特产" stage_text="" recovered="Судзуки нашёл древний камень на каменном алтаре." -- chunk_status -- -ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=365abc01adb22d047a3865c79231c2669f7db960fd07069d46547d562cfc537b cost=0.00182 escalated=false esc_model="" detail="" -ch1/0 edit snap_match=true content_hash=8a4e763f79c94ad20a1bac0231a8c1376ccd4f5a71c53cdfcb4de146c0b48b42 disp=ok flag="" attempts=1 final_hash=42e10469327dcf7d8409cda457a7eff626795a709ff0b37f3de260d7ca11986a cost=0.00182 escalated=false esc_model="" detail="" -ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=6f883d099cd0924eab5db1ef3eb730aa50429e719ffeea95df93e33042e9fcc0 cost=0.00182 escalated=false esc_model="" detail="" -ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=f7a858a11f0890da139789d27bea23b1c33c83ef989f2d07dc51d4b238d5e16e cost=0.00728 escalated=true esc_model="fake-fallback" detail="" -ch2/0 edit snap_match=true content_hash=5a5397d41bcfa9326b5fe67c70aca8d9ef14ef89af2dbe1a758e25d364226fc5 disp=ok flag="" attempts=1 final_hash=ff45d79e2d08b57328491e1ac5e0b6b67ba883346c44806b7a63e7adddcf65bf cost=0.00182 escalated=false esc_model="" detail="" -ch3/0 draft snap_match=true content_hash=733fc6a89215efe112cfadd1d1fb361bb8a7ab1013994248a49da0dbd137548e disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal" +ch1/0 draft snap_match=true content_hash=3e156226b9a74aa70c8a259c62e3d2fe68486a96d2178d31084c1e38a3256241 disp=ok flag="" attempts=1 final_hash=07dfef4f3137a6afdd879075709cbe1d4ae4aeffa73fd55d8d4a4b266de44191 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 edit snap_match=true content_hash=5e79dd5cbd310ac01d71ca258002beca18db715dfb8b1a08907508e506595355 disp=ok flag="" attempts=1 final_hash=ff6c6daffd6186e70f8dc9f634a9d8d3fdfbc90affb0a363427140bf52aea33a cost=0.00182 escalated=false esc_model="" detail="" +ch1/1 draft snap_match=true content_hash=82fb7f985de6c07182e8c09a3ceaad94f2e63745b87024087e437fba762b8227 disp=ok flag="" attempts=1 final_hash=970bd70afdd5d956114017f90ba600e63da0da7b2a1fb61908062966f8332503 cost=0.00182 escalated=false esc_model="" detail="" +ch2/0 draft snap_match=true content_hash=cfab1e09264a3a30aba95e7da1d6a269468c6aa0c1b334d9241e78bc266a3571 disp=ok flag="" attempts=1 final_hash=0c5929335c49b0f302948b0efe77ad078ec7538f0020b76c020e36272afe652e cost=0.00728 escalated=true esc_model="fake-fallback" detail="" +ch2/0 edit snap_match=true content_hash=55ffd839a4a26c03cf57e08000ab2c84b267e1429ab4a8e212d3a01fda757632 disp=ok flag="" attempts=1 final_hash=e76420deca3abe19ba49ea6de7af5938e49ef579950bf929896bf8dc6c418a28 cost=0.00182 escalated=false esc_model="" detail="" +ch3/0 draft snap_match=true content_hash=21df44eb104900664bdaaeb2f716f51dcddbc81a71d845f6ba0f91c5bc4b2cfb disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal" ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: a member draft chunk of this edit unit was flagged (hard_refusal)" -ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=6fb9e932865b46557a1f5e4a0852a6fe5b2ed728e54d5704801840f1dbe89ccb cost=0.00182 escalated=false esc_model="" detail="" -ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=5b1f425d363f501da5832d570ebeb036a8d780378abf3e92cdded87c247b9b6e cost=0.00182 escalated=false esc_model="" detail="" -ch5/0 draft snap_match=true content_hash=d8aba42cabb41853a81875ebc725bb516dc2412e29de3a03bc1a68e90f8cf157 disp=ok flag="" attempts=1 final_hash=03ed6d252620e8763a0afc2d535e1b6e1cf97ca1145a66aa08ea199ef7102c04 cost=0.00182 escalated=false esc_model="" detail="" -ch5/0 edit snap_match=true content_hash=82b05e4c3f5ca8432d7eaa7d7d63c4a7766f47a08398953c9a9bc3dc8437039b disp=ok flag="" attempts=1 final_hash=3367114609df41762ff6f64b57de90f85b6f1992987ebf875bc64e028a6a0a4b cost=0.00182 escalated=false esc_model="" detail="" -ch6/0 draft snap_match=true content_hash=9375b05780724cc354eba15a595da9a5f964219c0887042484256eed11fb566f disp=ok flag="" attempts=1 final_hash=1efa0b7620e14fa8cdff89a198efe113e2196a4a48717b1dfacfc9bc878d2ae8 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=2574511b38f07a6eef3dd781907eee77cbdc73978ef28d4c9785e32d28489b58 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=0f8084a8bcf771d895963869671f83e4bf8f80c03a491ea999278d45878fc10c cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 draft snap_match=true content_hash=2ed0a97f39cc5c57c62c3324f4646357372f26e3c5bae1773f24edf9449fc013 disp=ok flag="" attempts=1 final_hash=c00dc01d8cc53a31022467dd878ba7d4d6bd9b7c7a9ee5036f0b69ed6f10025b cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 edit snap_match=true content_hash=b145d78e4d30a39cc4088a1ddb8d934234c65341c62c79545c2eff1e7ae0c3d3 disp=ok flag="" attempts=1 final_hash=508383e619d91f16be54e60712774c414d057784a2d406ca202872f7b98a08f7 cost=0.00182 escalated=false esc_model="" detail="" +ch6/0 draft snap_match=true content_hash=5f396d299a7ec5b10f3c8591e54ce7d84c4d31b815e561b0dd5f56635aef5a71 disp=ok flag="" attempts=1 final_hash=ee0cc663a115e983e4e5df712513ba7085d4a9727e1fa864eec5fa12f8cf5ad0 cost=0.00182 escalated=false esc_model="" detail="" ch6/0 edit snap_match=true content_hash=592353321cc8000e792e0897446e4dd4f88a0280b9368b62cbd5c82b0c433193 disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="leading service preamble: Вот перевод фрагмента:" -ch7/0 draft snap_match=true content_hash=d90c335cba3d1547a22bb3f656443cb432f84c12a577d43509cfe472cf86a2ac disp=ok flag="" attempts=1 final_hash=3a1050fe790ac5eb004c8d3e80a05cc6ff5e6b46c5947f6a57a7145985591dfb cost=0.00182 escalated=false esc_model="" detail="" -ch7/0 edit snap_match=true content_hash=e12521b6eb6dbc2cd35758d664d4d31894c528f092083b5cfe0e79c51be2663e disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:e9a837dbcd8d6717d27413735debe11bc116df2f65655d16f6fcbf51cbf59e66 cost=0.00182 escalated=false esc_model="" detail="markdown header in the output: ### Глава 7" -ch8/0 draft snap_match=true content_hash=fbd27af11da2275cd1d47a0224970a5f8d073c6164f614efb6522ea96fa1e021 disp=ok flag="" attempts=1 final_hash=e1e065fc34385f41fd51a4b0db42d5f391532775ef68ece015f261a33d6b68da cost=0.00182 escalated=false esc_model="" detail="" -ch8/0 edit snap_match=true content_hash=0c6030a943cb341390509e2ebed4bbfd52af328d36916ce2e341f43665cf9183 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:08f107da1257ff083f73e9af1e8d3b5b90e699ad6343faa80bebe891744c7adf cost=0.00182 escalated=false esc_model="" detail="CJK leak in the ru output: 特产" +ch7/0 draft snap_match=true content_hash=15c3ebbd56bb19880202d35b0206357655da245343376492f7ceb61808c8a5be disp=ok flag="" attempts=1 final_hash=df112533bae9eb53eeee7535189479bb62113829691e257827eb6a122c4d24a5 cost=0.00182 escalated=false esc_model="" detail="" +ch7/0 edit snap_match=true content_hash=0b2674ed9829b2e6526ff41a60dd280b6670c539476526bbe835d9bf7a6be4c4 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:e6daeb1f55975010b0d9eafc3d7b9d23891d62e93db9203b1b65c6673180171a cost=0.00182 escalated=false esc_model="" detail="markdown header in the output: ### Глава 7" +ch8/0 draft snap_match=true content_hash=4a420977cc34ce738b4978e6a1baf45d78d773a135070c3b7f8c723adfa57d7b disp=ok flag="" attempts=1 final_hash=3ae07f69c98630ae05ed56465ca12b895aafef0376cb4e6fef6fb37d2edf4fb6 cost=0.00182 escalated=false esc_model="" detail="" +ch8/0 edit snap_match=true content_hash=c851100c8d6785f77d68f8127472790ae9d0408b7fec44785d3ff9d46842c6f8 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:1994445050afdc0455c60b75974a47d9430b847d7c5158d3cebfbea933c61dbd cost=0.00182 escalated=false esc_model="" detail="CJK leak in the ru output: 特产" -- request_log (insertion order) -- -ch1/0 draft role=translator req=fake-model actual=fake-model hash=365abc01adb22d047a3865c79231c2669f7db960fd07069d46547d562cfc537b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=6f883d099cd0924eab5db1ef3eb730aa50429e719ffeea95df93e33042e9fcc0 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-model hash=0932d7aee2cd8fe944a4f5fcc4bf5b1828d8514ecc46cde7b5a3bd9983864dd2 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=f7a858a11f0890da139789d27bea23b1c33c83ef989f2d07dc51d4b238d5e16e tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-model actual=fake-model hash=7be9635f28275d919b1ff3ff6aab9f94039eff250163bef3d6d3460489984b7e tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=ef672cdf6862522d15d93c32faf95d7eebc2c6ff4c24de6b0679a256900349ac tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=6fb9e932865b46557a1f5e4a0852a6fe5b2ed728e54d5704801840f1dbe89ccb tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch5/0 draft role=translator req=fake-model actual=fake-model hash=03ed6d252620e8763a0afc2d535e1b6e1cf97ca1145a66aa08ea199ef7102c04 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch6/0 draft role=translator req=fake-model actual=fake-model hash=1efa0b7620e14fa8cdff89a198efe113e2196a4a48717b1dfacfc9bc878d2ae8 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch7/0 draft role=translator req=fake-model actual=fake-model hash=3a1050fe790ac5eb004c8d3e80a05cc6ff5e6b46c5947f6a57a7145985591dfb tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch8/0 draft role=translator req=fake-model actual=fake-model hash=e1e065fc34385f41fd51a4b0db42d5f391532775ef68ece015f261a33d6b68da tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=42e10469327dcf7d8409cda457a7eff626795a709ff0b37f3de260d7ca11986a tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=ff45d79e2d08b57328491e1ac5e0b6b67ba883346c44806b7a63e7adddcf65bf tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=5b1f425d363f501da5832d570ebeb036a8d780378abf3e92cdded87c247b9b6e tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch5/0 edit role=editor req=fake-model actual=fake-model hash=3367114609df41762ff6f64b57de90f85b6f1992987ebf875bc64e028a6a0a4b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch6/0 edit role=editor req=fake-model actual=fake-model hash=db25b5d375286a9fa18bc3b7b9da14c8bd95a5209b8b2e9099889413062504d1 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch7/0 edit role=editor req=fake-model actual=fake-model hash=54ab4a1ffb853422a3b079b71b3c1c7ba11eb434c3d9f1df3c81aec2f007e0d8 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch8/0 edit role=editor req=fake-model actual=fake-model hash=c047ff047484181ff697d1e525c3d12f5b76ab5f9451cc5f309be3465559cf5e tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 draft role=translator req=fake-model actual=fake-model hash=365abc01adb22d047a3865c79231c2669f7db960fd07069d46547d562cfc537b tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=6f883d099cd0924eab5db1ef3eb730aa50429e719ffeea95df93e33042e9fcc0 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-fallback hash=f7a858a11f0890da139789d27bea23b1c33c83ef989f2d07dc51d4b238d5e16e tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=07dfef4f3137a6afdd879075709cbe1d4ae4aeffa73fd55d8d4a4b266de44191 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=970bd70afdd5d956114017f90ba600e63da0da7b2a1fb61908062966f8332503 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-model hash=3c87997526311911cbe02fb9928b43ef9ae420872719390304c2edcbf460d8b8 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=0c5929335c49b0f302948b0efe77ad078ec7538f0020b76c020e36272afe652e tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-model actual=fake-model hash=069f2401b99af0393fb0d63792c89a880c4065a712d49725e9dff72eea0dda43 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=ae51067d2d180033744a10163f4606b9fcdb441931f497c55dbd36e6c9f07c42 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=2574511b38f07a6eef3dd781907eee77cbdc73978ef28d4c9785e32d28489b58 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=c00dc01d8cc53a31022467dd878ba7d4d6bd9b7c7a9ee5036f0b69ed6f10025b tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=ee0cc663a115e983e4e5df712513ba7085d4a9727e1fa864eec5fa12f8cf5ad0 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch7/0 draft role=translator req=fake-model actual=fake-model hash=df112533bae9eb53eeee7535189479bb62113829691e257827eb6a122c4d24a5 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch8/0 draft role=translator req=fake-model actual=fake-model hash=3ae07f69c98630ae05ed56465ca12b895aafef0376cb4e6fef6fb37d2edf4fb6 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=ff6c6daffd6186e70f8dc9f634a9d8d3fdfbc90affb0a363427140bf52aea33a tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=e76420deca3abe19ba49ea6de7af5938e49ef579950bf929896bf8dc6c418a28 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=0f8084a8bcf771d895963869671f83e4bf8f80c03a491ea999278d45878fc10c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=508383e619d91f16be54e60712774c414d057784a2d406ca202872f7b98a08f7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 edit role=editor req=fake-model actual=fake-model hash=f3a5675d5ba381251ebfed396f0a79b7bae0d727339adb152fe97dfccd9c15b3 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch7/0 edit role=editor req=fake-model actual=fake-model hash=2ded6927e1537e73b62b017ee8a27095906ad5f2eefa2d5463de41694dc618ce tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch8/0 edit role=editor req=fake-model actual=fake-model hash=456a4a0945d84716e9242516ca980e88241f325075bd7cdc252e2e233da3e34b tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=07dfef4f3137a6afdd879075709cbe1d4ae4aeffa73fd55d8d4a4b266de44191 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=970bd70afdd5d956114017f90ba600e63da0da7b2a1fb61908062966f8332503 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-fallback hash=0c5929335c49b0f302948b0efe77ad078ec7538f0020b76c020e36272afe652e tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" ch3/0 draft role=translator req=fake-model actual=fake-model hash= tm_hit=1 ok=0 finish="" degraded="hard_refusal" cost=0 tokens=0/0/0/0/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=6fb9e932865b46557a1f5e4a0852a6fe5b2ed728e54d5704801840f1dbe89ccb tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch5/0 draft role=translator req=fake-model actual=fake-model hash=03ed6d252620e8763a0afc2d535e1b6e1cf97ca1145a66aa08ea199ef7102c04 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch6/0 draft role=translator req=fake-model actual=fake-model hash=1efa0b7620e14fa8cdff89a198efe113e2196a4a48717b1dfacfc9bc878d2ae8 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch7/0 draft role=translator req=fake-model actual=fake-model hash=3a1050fe790ac5eb004c8d3e80a05cc6ff5e6b46c5947f6a57a7145985591dfb tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch8/0 draft role=translator req=fake-model actual=fake-model hash=e1e065fc34385f41fd51a4b0db42d5f391532775ef68ece015f261a33d6b68da tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=42e10469327dcf7d8409cda457a7eff626795a709ff0b37f3de260d7ca11986a tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=ff45d79e2d08b57328491e1ac5e0b6b67ba883346c44806b7a63e7adddcf65bf tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=5b1f425d363f501da5832d570ebeb036a8d780378abf3e92cdded87c247b9b6e tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch5/0 edit role=editor req=fake-model actual=fake-model hash=3367114609df41762ff6f64b57de90f85b6f1992987ebf875bc64e028a6a0a4b tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=2574511b38f07a6eef3dd781907eee77cbdc73978ef28d4c9785e32d28489b58 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=c00dc01d8cc53a31022467dd878ba7d4d6bd9b7c7a9ee5036f0b69ed6f10025b tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=ee0cc663a115e983e4e5df712513ba7085d4a9727e1fa864eec5fa12f8cf5ad0 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch7/0 draft role=translator req=fake-model actual=fake-model hash=df112533bae9eb53eeee7535189479bb62113829691e257827eb6a122c4d24a5 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch8/0 draft role=translator req=fake-model actual=fake-model hash=3ae07f69c98630ae05ed56465ca12b895aafef0376cb4e6fef6fb37d2edf4fb6 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=ff6c6daffd6186e70f8dc9f634a9d8d3fdfbc90affb0a363427140bf52aea33a tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=e76420deca3abe19ba49ea6de7af5938e49ef579950bf929896bf8dc6c418a28 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=0f8084a8bcf771d895963869671f83e4bf8f80c03a491ea999278d45878fc10c tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=508383e619d91f16be54e60712774c414d057784a2d406ca202872f7b98a08f7 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" ch6/0 edit role=editor req=fake-model actual=fake-model hash= tm_hit=1 ok=0 finish="" degraded="sanitizer_defect" cost=0 tokens=0/0/0/0/0 err="" -ch7/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:e9a837dbcd8d6717d27413735debe11bc116df2f65655d16f6fcbf51cbf59e66 tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err="" -ch8/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:08f107da1257ff083f73e9af1e8d3b5b90e699ad6343faa80bebe891744c7adf tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err="" +ch7/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:e6daeb1f55975010b0d9eafc3d7b9d23891d62e93db9203b1b65c6673180171a tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err="" +ch8/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:1994445050afdc0455c60b75974a47d9430b847d7c5158d3cebfbea933c61dbd tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err="" -- retrieval_state -- ch1/0 snap_match=true exact=3 sticky=0 ambiguous=0 spoiler=1 evicted=0 postcheck_miss=1 style_flags=0 trust_gated=0 injected_ids=["紋章\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0","魔法学院\u001f\u001f0\u001f0"] postcheck_detail=[{"src":"紋章","dst":"герб","disp":"confirmed"}] style_detail= trust_gate_detail= diff --git a/backend/internal/pipeline/waverun.go b/backend/internal/pipeline/waverun.go index 45892f27..5bce1a66 100644 --- a/backend/internal/pipeline/waverun.go +++ b/backend/internal/pipeline/waverun.go @@ -494,6 +494,11 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit default: out.FinalText = exportNormalize(finalText) } + // Title policy (pack-13): prepend the chapter's deterministic «Глава N» to its FIRST unit's non-empty + // export text. unit.Members[0] is the unit's leader chunk; only a chapter's opening chunk (ChunkIdx 0) + // carries a Heading, so non-leader units are a no-op. Applied to the export projection identically in + // export.go — both derive the heading from the same deterministic chunker, so translate and export agree. + out.FinalText = applyHeading(unit.Members[0].Heading, out.FinalText) return out, nil } @@ -565,5 +570,6 @@ func draftOnlyOutcome(ch Chunk, d stageSeqResult) ChunkOutcome { } else { oc.FinalText = exportNormalize(d.finalText) } + oc.FinalText = applyHeading(ch.Heading, oc.FinalText) // pack-13 title policy (draft-only shipping path) return oc } diff --git a/backend/internal/store/migrate.go b/backend/internal/store/migrate.go index 059f25f5..d4cc8512 100644 --- a/backend/internal/store/migrate.go +++ b/backend/internal/store/migrate.go @@ -292,6 +292,17 @@ var migrations = []string{ ALTER TABLE retrieval_state ADD COLUMN banknote_parse_fail INTEGER NOT NULL DEFAULT 0; ALTER TABLE retrieval_state ADD COLUMN banknote_truncated INTEGER NOT NULL DEFAULT 0; `, + // v10 (pack-13 point-9, research/21 §1.10): the CostSource marker on the telemetry row. When a paid + // 2xx settles at the RESERVATION ESTIMATE (a billed decode failure, or a paid-but-zero-usage 2xx) the + // row's cost_usd is an estimate, not provider-reported. Without a flag those rows are indistinguishable + // from real zero-token calls and skew token-based COGS analytics. estimated=1 makes the estimated share + // queryable; est_tokens is a DISPLAY-ONLY fertility (est_out) estimate of the output tokens (never the + // char/4 CJK-mine). Additive columns, default 0 → every prior row and the WIRE are unchanged (the pack + // invariant); an estimated row keeps its real settled cost_usd — the flag/estimate are display-only. + ` + ALTER TABLE request_log ADD COLUMN estimated INTEGER NOT NULL DEFAULT 0; + ALTER TABLE request_log ADD COLUMN est_tokens INTEGER NOT NULL DEFAULT 0; + `, } // DESIGN NOTE (D21.10 — reserved memory-bank v2 record types; Phase 2, NOT a migration, NOT code). diff --git a/backend/internal/store/requestlog.go b/backend/internal/store/requestlog.go index 1e6b6039..44ebaec1 100644 --- a/backend/internal/store/requestlog.go +++ b/backend/internal/store/requestlog.go @@ -34,6 +34,17 @@ type RequestLog struct { Degraded string Err string OK bool + // Estimated marks a row whose CostUSD is a RESERVATION ESTIMATE, not a provider-reported cost (a billed + // decode failure, or a paid 2xx with zero usage — pack-13 point-9 / research/21 §1.10). DISPLAY-ONLY: it + // never re-derives money (CostUSD is untouched); it makes the estimated share of spend queryable so an + // estimate row is not mistaken for a real zero-token call in token-based COGS analytics. + Estimated bool + // EstTokens is the DISPLAY-ONLY fertility estimate of the row's output tokens (est_out = 1.20·cjk + + // 0.39·other over the SOURCE, research/20 — NOT the char/4 EstimateTokens, whose CJK undercount is the + // «CJK-mine» pack-13 point-9 warns of). Set only on an Estimated row (0 otherwise); it feeds NOTHING on + // the money path (not the reservation, not CostUSD) — purely a report number so an estimate's magnitude + // is legible. + EstTokens int } // InsertRequestLog writes one telemetry row synchronously (a CLI pipeline has @@ -49,13 +60,13 @@ func (s *Store) InsertRequestLog(rl RequestLog) error { model_requested, model_actual, request_hash, prompt_tokens, cached_tokens, cache_creation_tokens, completion_tokens, reasoning_tokens, - cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok, estimated, est_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, rl.TraceID, rl.BookID, rl.Chapter, rl.ChunkIdx, rl.Stage, rl.Role, rl.ModelRequested, rl.ModelActual, rl.RequestHash, rl.PromptTokens, rl.CachedTokens, rl.CacheCreationTokens, rl.CompletionTokens, rl.ReasoningTokens, - rl.CostUSD, rl.LatencyMS, rl.FinishReason, rl.TMHit, rl.Degraded, rl.Err, rl.OK) + rl.CostUSD, rl.LatencyMS, rl.FinishReason, rl.TMHit, rl.Degraded, rl.Err, rl.OK, rl.Estimated, rl.EstTokens) return err } @@ -107,6 +118,8 @@ type RequestLogView struct { Degraded string Err string OK int + Estimated int // 1 = CostUSD is a reservation estimate, not provider-reported (pack-13 point-9) + EstTokens int // display-only fertility output-token estimate (pack-13 point-9); 0 unless Estimated } // RequestLogRows returns all request_log rows for a book (tmctl report / Phase 0 @@ -117,7 +130,7 @@ func (s *Store) RequestLogRows(bookID string) ([]RequestLogView, error) { SELECT ts, chapter, chunk_idx, stage, role, model_requested, model_actual, request_hash, prompt_tokens, cached_tokens, cache_creation_tokens, completion_tokens, reasoning_tokens, - cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok + cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok, estimated, est_tokens FROM request_log WHERE book_id = ? ORDER BY id`, func(rows *sql.Rows) (RequestLogView, error) { var v RequestLogView @@ -125,7 +138,7 @@ func (s *Store) RequestLogRows(bookID string) ([]RequestLogView, error) { &v.ModelRequested, &v.ModelActual, &v.RequestHash, &v.PromptTokens, &v.CachedTokens, &v.CacheCreationTokens, &v.CompletionTokens, &v.ReasoningTokens, &v.CostUSD, - &v.LatencyMS, &v.FinishReason, &v.TMHit, &v.Degraded, &v.Err, &v.OK) + &v.LatencyMS, &v.FinishReason, &v.TMHit, &v.Degraded, &v.Err, &v.OK, &v.Estimated, &v.EstTokens) return v, err }, bookID) } diff --git a/backend/prompts/editor.md b/backend/prompts/editor.md index 61bca10a..c0350950 100644 --- a/backend/prompts/editor.md +++ b/backend/prompts/editor.md @@ -7,6 +7,7 @@ - Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи. - Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку исходника (в оригинале часто одно предложение — одна строка). Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет. - Сохраняй ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. +- Единицы и приёмы (общие для zh→ru): МЕРЫ приводи к привычным читателю единицам (时辰 = 2 часа, 三个时辰 ≈ шесть часов; доля 成 — десятые: 六成六 = 66%, а не «6,6»); СТИХИ и названия классики — по смыслу, не транслитерацией. Род и форму имён и терминов сверяй по глоссарию. - Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков. diff --git a/backend/prompts/translator.md b/backend/prompts/translator.md index 0074106d..6a3126e8 100644 --- a/backend/prompts/translator.md +++ b/backend/prompts/translator.md @@ -9,6 +9,11 @@ - Вёрстка — по нормам русского языка, НЕ копируй построчную разбивку исходника: реплики прямой речи оформляй с нового абзаца через тире «—». - Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка. +Единицы и приёмы (общие для zh→ru): +- МЕРЫ переводи в привычные читателю единицы: китайский 时辰 = 2 часа (三个时辰 ≈ шесть часов, 半个时辰 ≈ час), сам термин не транслитерируй. Доля 成 — это десятые (六成 = 60%, 六成六 = 66%), а НЕ десятичная дробь «6,6». +- СТИХИ и названия классики передавай ПО СМЫСЛУ (не транслитерацией), опираясь на существующие русские переводы. +- Род и форму имён собственных и терминов бери из приложенного глоссария (если он есть). + Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков. ---USER--- diff --git a/docs/archive/reports/PACK13_QA_REPORT_2026-07-24.md b/docs/archive/reports/PACK13_QA_REPORT_2026-07-24.md new file mode 100644 index 00000000..ca0b97d3 --- /dev/null +++ b/docs/archive/reports/PACK13_QA_REPORT_2026-07-24.md @@ -0,0 +1,125 @@ +# Отчёт бэкенд-сессии: ПАК-13 «выпускной QA» (по D39.20/D39.21) + +**Дата:** 2026-07-24. **Роль:** бэкенд. **Промт:** `docs/BACKEND_PACK13_QA_SESSION_PROMPT.md` (99ec074). **Сессия НЕ коммитит — лендит оркестратор.** Дерево несёт также пак-12 (транспорт, передан на ревью/лендинг мне) — см. §9. + +## Итог одной строкой + +Реализованы все 8 пунктов пака-13 + переданные пп.9/10 (CostSource-маркер, сегмент-луп-гард). `go build/vet/test ./... -race` — зелёное. Майнер-парити EXACT неизменён. Golden пере-капчерен и классифицирован: **единственная правка wire-контента — гендер-аннотация в драфт-глоссарии (пункт 6)**; всё остальное — version-only хеши; заголовок-политика на golden — no-op (нет langpack); пак-12 wire-нейтрален. Дефект-корпус rerun2: все известные дефекты пойманы, **0 FP** (одну ложную сработку — DC10 — нашёл и починил исполнением). **Директива владельца исполнена:** убран книго-специфичный код (курируемый список битых форм, чекер 资质), каноны терминов вынесены из движка в данные глоссария. + +## Директива владельца (в ходе сессии): «не писать код, подгоняющий решение под конкретную книгу» + +Пересобрал под это три места: +- **Убран курируемый список битых форм** (`лять`/`вперди`/`глава клан`) из чекера — это были конкретные огрызки ИЗ ЭТОЙ книги. Осталось только ОБЩЕЕ языковое правило — слово на «-йть» (`войть`←`войти`), которое верно для любого текста. +- **Убран чекер `资质`=«талант»** целиком — это канон КОНКРЕТНОГО термина, ему место в сид-глоссарии книги (данные), а не в движке; общий пост-чек глоссария и так ловит рассинхрон терминов. +- **Убраны термо-каноны `гу`/`资质` из пар-промпта** (`translator.md`/`editor.md`) — пар-промпт общий для всех zh→ru книг; книго/серия-специфичные термины идут в сид-глоссарий. В промпте остались только ОБЩИЕ zh→ru приёмы: меры (时辰=2ч, доля 成), стихи по смыслу, «род/форму терминов бери из глоссария». +- Комментарии нового кода почищены от doc-аббревиатур, переписаны «по делу». + +**Честная планка recall (следствие):** из перечисленных дефектов детерминированно ловятся `again` (латиница) и `войть` (общее правило «-йть»). `лять`/`Вперди`/`глава клан` БЕЗ морфологии/словаря и без книго-списка деттектируются НЕ могут (default-B: без hunspell/pymorphy) — это принято как цена отказа от книго-специфичного кода. + +## Реализация по пунктам (file:line) + +### 1. Крэш-фикс «карта→mined_delta» (wire-нейтрально) +`internal/pipeline/seeding.go:118` `minedDeltaSeedCollisions` — при загрузке дельты проверяет пересечение UNIQUE-ключа `(src,sense,since_ch,until_ch)` с сидом; пересечение → ГРОМКАЯ ошибка со списком дубликатов + подсказка «правь сид ИЛИ убери из дельты», без тихого merge. Вызов: `seeding.go:56`. Ловит и same-dst дубликат, который `approvedSharedKeyCollisions` пропускает (а INSERT крэшит независимо от dst). Тест обоих путей: `mineddelta_collision_test.go`. + +### 2. Заголовок-политика (wire-двигающий, langpack-gated) +- Данные в langpack (НЕ хардкод): `configs/langpacks/zh-ru/heading.txt` (`marker`=第, `units`=章节節回, `template`=«Глава {n}»). Загрузка ОПЦИОНАЛЬНАЯ: `internal/lang/langpack.go:228` `readOptional` + `:244` `parseHeading`; пары без файла — инертны и байт-стабильны (не ре-биллятся), с файлом — байты фолдятся в `pack.Version()` (громкий --resnapshot), битый — fail-loud. +- Детект/стрип/рендер (алгоритм в движке, читает данные): `internal/pipeline/chunker.go:129` `stripHeading`, `:173` `matchHeaderLine`, `:160` `applyHeading`, `Chunk.Heading`. Чанкер детектит первый заголовок, СТРИПАЕТ маркер из ch.Text (модель не рендерит заголовок), субтитр сохраняется как тело, «Глава N» рендерится детерминированно. +- Прокидка: `runner.go` `headingRule()`; `bookrun.go:126`, `status.go:144` (`bookChunks`) → `SplitChunks(..., r.headingRule())`. +- Рендер в вывод: `waverun.go` (`runEditUnit`/`draftOnlyOutcome`) + `export.go` (`chunkExport`) префиксят «Глава N» к первому юниту главы — обе точки берут заголовок из ОДНОГО детерминированного чанкера, `translate` и `export` совпадают. Заголовок НЕ в чекпоинте (детерминированная проекция → резюм бесплатно воспроизводит). +- **Приёмка:** unit-тесты `chunker_heading_test.go` на живых формах rerun2 (第一/二/四/五节:субтитр → «Глава 1/2/4/5» + субтитр; fullwidth-space; многоразрядные CJK-цифры; Arabic; без субтитра; негативы — измеритель 回, 第 не в начале строки). Единообразие достигнуто по построению (модель не касается номера). + +### 3. Юнит-конверсия +- Wire: строки в `prompts/translator.md`+`editor.md` — «时辰=2 часа (三个时辰≈6ч, 半个时辰≈час), доля 成 — десятые (六成六=66%, а не 6,6)». ОБЩИЕ zh→ru конвенции. +- Чекер: DC1 `lintTimeUnits` (существующий) ловит корпус-дефект 三个时辰→«три часа» (проверено на корпусе). + +### 4. Число-масштабы +- DC2 `lintMagnitudeScale` (существующий) ловит 千万→«тысячи» и 数十万→«десятки тысяч» (проверено на корпусе). +- Новый: `internal/pipeline/checkers_zh_ru.go:187` `lintPercentScale` — 成-percent (六成六=66%) отрендерен десятичной дробью → флаг. Источник-контент-gated (成+число), suppress при корректном «процент»/«%», fire только при дробном cue (десятых/сотых/N,N). Ловит glm/mistral (6,6), пропускает dspro (66% — корректно). 亿-разряды покрыты существующим `lintNumberMagnitude` (万/億/兆-гейт) — не дублировал. + +### 5. Broken-word / latin residue (ОБЩИЕ, честный recall) +- `checkers_zh_ru.go:219` `lintLatinResidue` — цельное латинское слово в ru-выводе (`again`). Флаг all-lowercase латинского токена ≥3 (утечка прозы — строчная; любая заглавная = бренд/имя/акроним `iPhone`/`Google`/`Suzuki`/`II` → пропуск), без цифр (id/хеш пропускается), не римская цифра, не в allowlist. **Ужесточён по адверсариал-ревью:** первая версия (any-case) ложно срабатывала на брендах/URL — теперь строчная-только (цена: заглавная утечка / URL-хост не ловятся — принято для тихого observability-сигнала). +- `checkers_zh_ru.go:279` `lintBrokenWord` — ТОЛЬКО общее правило: слово на «-йть» (невозможное окончание в русском). Без книго-списков, без словаря. Ловит `войть`. `лять`/`Вперди`/`глава клан` — не детектируются без морфологии (честно, см. §директива). +- Существующий санитайзер-класс битых слов (невалидные знаковые биграммы, гомоглифы) ортогонален, работает. + +### 6. Аудит полноты банк-инъекции + фикс гендера +**Таблица «поле банка → доходит до wire?»:** + +| поле | драфт-wire | edit-wire | где терялось / статус | +|---|---|---|---| +| src | ✅ | ✅ | — | +| dst | ✅ | ✅ | — | +| status (→⟨проверить⟩ / confirmed-фильтр) | ✅ | ✅ | — | +| **gender** | **✅ (ФИКС)** | ✅ (было) | было: `renderGlossaryBlock` не нёс род → **ПОЧИНЕНО** | +| decl | ❌ | ❌ | by design — decl кормит ПОСТ-ЧЕК (не wire); инжект форм раздул бы блок. Не баг. | +| type | ❌ | ❌ | внутренняя классификация alias-графа; не wire-релевантно. Не баг. | +| sense | ❌ | ❌ | внутренний дизамбигуатор полисемии; матчер ключует по src. Не баг. | +| aliases | косвенно (match-key) | косвенно | алиас-поверхности зажигают запись (инжектится каноничный src→dst). By design. | + +Фикс: `internal/pipeline/memory.go:511` `renderGlossaryBlock` — для CONFIRMED-термина с родом добавляет `genderConstraintNote` в ДРАФТ-блок (как editor-блок). Теперь род именного термина доезжает до переводчика (не только редактора). `renderFormatVersion` v2→v3 (`memory.go:548`) — громкий --resnapshot. Безродный банк байт-идентичен. **Это ЕДИНСТВЕННАЯ правка wire-контента в golden** (см. §8). + +### 7. Стайл-канон в промпт + канон терминов +- Wire (ОБЩЕЕ): меры + стихи-по-смыслу в `translator.md`/`editor.md` (см. п.3). +- Термо-каноны (`资质`=талант, род «гу») ВЫНЕСЕНЫ из движка/промпта в данные глоссария (директива владельца) — общий пост-чек глоссария их и так энфорсит. Чекера 资质 в коде НЕТ. + +### 8. Дефолт-редактор → deepseek-v4-pro +- `configs/pipeline-c1.yaml` + `pipeline-c2.yaml`: editor `glm-5`→`deepseek-v4-pro` + `few_shot:false` (как в ратифицированном dspro-арме). `reasoning:"off"` СОХРАНЁН (несущая эхо-мина-семантика: на dspro «off»=ReasoningNone no-op → thinking ON; floor 8000 из models.yaml, НЕ переопределён). +- glm-5 резерв: новый `configs/pipeline-arm-glm.yaml`. +- Тест (по санкции оркестратора, файл был чист): `internal/config/echo_mine_test.go` — ожидание модели glm-5→deepseek-v4-pro (пин `reasoning=="off"` СОХРАНЁН), + glm-арм добавлен в `TestSwapArmConfigs`. Остальной `internal/config/` не тронут (рядом чужой `models_catalog_test.go` пака-12). + +### 9. CostSource-маркер (передан от пака-12) +`internal/store/requestlog.go` `Estimated bool` + `EstTokens int`; миграция v10 (`migrate.go`, версия свободна — пак-12 откатил свою) — аддитивные колонки, default 0, wire не двигают. Ставятся на двух settle-estimate точках `stagerun.go` (billed-decode + paid-2xx-zero-usage). `CostUSD` НЕ тронут. `EstTokens` — display-only оценка выхода через ФЕРТИЛЬНОСТЬ (`runner.go:261` `estOutTokens` = est_out, 1.20·cjk+0.39·other), НЕ char/4. Легенда в `cmd/tmctl/render.go`. Golden-нейтрально (у golden cost>0 всегда → нет estimated-строк). + +### 10. Сегмент-луп-гард (передан от пака-12) +`internal/pipeline/loopguard.go` `segmentLoopRuns` — ран ≥3 идентичных (whitespace-норм) подряд юнитов = вырожденный луп. Observability-only (не диспозиция, не wire), сигнатура по МОДЕЛЬНОМУ выводу (до префикса заголовка). Сюрфейс в `QualityReport.DegenerateLoopRuns` + `renderQuality`. Тест `loopguard_test.go`. + +## Дефект-корпус rerun2: каждый дефект пойман, 0 FP + +Прогнал ВСЕ чекеры над реальными `export-{glm,dspro,mistral}.json` (src↔target из --pairs). После фикса FP (ниже) — 13 хитов, все настоящие дефекты, **0 ложных**: + +| чекер | хиты | дефект (арм) | +|---|---|---| +| DC1 时辰 | 3 | 三个时辰→«три часа» (glm/dspro/mistral) | +| DC2 magnitude | 3 | 千万→«тысячи» (dspro/mistral), 数十万→«десятки тысяч» (mistral) | +| percent (成) | 2 | 六成六→«шесть десятых» (glm/mistral); dspro=«66%» → SUPPRESS ✓ | +| latin | 1 | `again` (glm) | +| broken (-йть) | 1 | `войть` (glm) | + +**FP пойман исполнением:** DC10 (资质) в первой версии сработал 6× на ХОРОШИХ переводах — источник нёс И 资质 (→талант, верно) И 资源 (→ресурсы, верно), «ресурс» был легитимным рендером 资源. Это подтвердило, что термо-канон в коде хрупок → вместе с директивой владельца чекер удалён (канон → глоссарий). Не-детектируемые без морфологии `лять`/`Вперди`/`глава клан` — честный recall-разрыв (см. §директива). + +## Golden: классификация пере-капчера + +`TM_UPDATE_GOLDEN=1` masked-diff: **20 строк wire-контента = ВСЕ гендер-аннотация `鈴木 → Судзуки (муж…)` в драфт-глоссарии** (пункт 6), + 234 version-only (хеши snapshot/request/memory от бампов cheapGateVersion/renderFormatVersion). Проверено сырым дифом (маска хешей+гендера): НЕТ иных изменений disposition/style_flags/final_text-прозы. Резюм-прогон детерминизма — зелёный (0 лишних вызовов). +- **Заголовок-политика на golden — no-op:** у golden-книги нет `langpack_root` → `headingRule`=nil → `第N章` в исходнике СОХРАНЁН, чанки байт-идентичны. Подтверждено (заголовки видны в wire-телах). +- **Пак-12 wire-нейтрален:** единственная wire-правка в golden — мой гендер; пак-12 не внёс НИ БАЙТА wire-изменений (его инвариант «zero wire bytes» подтверждён golden'ом). +- Новые чекеры на golden (ja→ru) = 0 срабатываний (style_flags не изменились). + +## Адверсариальный селф-ревью (3 линзы, параллельно + верификатор) + +(Пост-обработка воркфлоу вернула агрегат 0 из-за бага МОЕГО скрипта — non-empty verify-стадия отдавала голый массив, flatMap его ронял; авторитетны сами verify-вердикты ниже.) + +- **Детерминизм:** 0 находок. Заголовок/гендер/est_tokens/loopguard — чистые проекции, резюм воспроизводит (golden-резюм зелёный). +- **FP-цена:** две находки про DC9-курируемый-список и DC10 верификатор пометил FALSE ALARM — «кода не существует» (я их уже удалил по директиве владельца ДО верификации ✓). Одна РЕАЛЬНАЯ FP пойма­на: `lintLatinResidue` ложно срабатывал на брендах/URL (`iPhone`, `example.com`) → **ужесточён до строчных-только** (см. §5), тест добавлен. DC10-FP (资质/资源) пойман+устранён исполнением ещё раньше. +- **Langpack-общность:** 3 boundary-заметки (НЕ дефекты рантайма, НЕ книго-специфика — язык-общее): + 1. Парсер CJK-цифр (`chunker.go` `isHeadingNumeral`/`parseSectionNumeral`) захардкожен в движке, не в langpack. Принято: это ОБЩИЙ китайско-языковой алгоритм (не пер-книга, не пер-пара — цифры универсальны), пер-парные данные (marker/units/template) в langpack. Как майнер-алгоритмы. Residual: значения цифр в langpack — чище на будущее (как отмечено в шапке checkers_zh_ru.go про code-const паки). + 2. `matchHeaderLine` требует unit-руну после числа (CJK-конвенция 第N章). Пара с заголовком «маркер+число без юнита» (en «Chapter N») потребует расширения правила/матчера. Документированное ограничение текущего CJK-скоупа. + 3. Чекеры gated по `isRuTarget` (target=ru), не по source=zh — они КОРРЕКТНО общие (source-контент-gated / target-общие): ja→ru дефект 千万 тоже поймается. Поведение верное; только шапка-комментарий файла слегка over-claim «zh-specific» (существующий код, не мой; моё честно помечено «general»). + +## Приёмка + +- ✅ `go build/vet/test ./... -race` — зелёное (все пакеты, вкл. golden + пак-12). +- ✅ Майнер-парити EXACT — `TestMinerFullBookParity` зелёный, неизменён. +- ✅ Golden — пере-капчерен, классифицирован (wire=только гендер; title no-op; пак-12 нейтрален; резюм-детерминизм зелёный). +- ✅ Дефект-корпус — все дефекты пойманы, 0 FP (таблица выше). +- ✅ Селф-ревью исполнением + адверсариальный 3-линзовый (0 дефектов; DC10-FP пойман исполнением). +- ✅ Директива владельца «не книго-специфичный код» исполнена. + +## Пак-12 (передан на ревью/лендинг мне) — ревью исполнением + +`internal/llm/**` + `internal/config/models_catalog_test.go` + go.mod (x/net keepalive). Ревью: (а) full -race зелёный; (б) wire-нейтральность подтверждена golden'ом (0 wire-изменений от пака-12); (в) зоны соблюдены (пп.7/10 откачены→мне, п.8b перенесён в llm-адаптер `normalizeOpenAIFinish`, `disposition.go` не тронут); (г) их 4-линзовый селф-ревью = 0 подтверждённых. **Ревью-вердикт: чисто, готово к лендингу.** + +## Открытые хвосты / оркестратору + +- **Golden уже пере-капчерен** (нужен для зелёных тестов; правки 100% мои, пак-12 нейтрален). Если план — лендить пак-12 ОТДЕЛЬНО первым, golden надо временно откатить к до-гендерной версии (бэкап в скретчпаде) до лендинга пака-13; при СОВМЕСТНОМ лендинге — текущий golden верен. +- **Термо-каноны в сид-глоссарий (данные владельца):** `资质`→«талант», род «гу»=средний, `春秋蝉`→«цикада» — вынесены из кода/промпта; их место — сид-дельта книги (энфорс общим пост-чеком). Оркестратор/владелец вносит в сид. +- **Заголовок-generality для будущих не-CJK пар:** правило сейчас предполагает маркер+число+юнит (CJK); en-стиль «Chapter N» потребует расширения (задокументировано, не блокер zh-ru). +- **Не делал** (по НЕ-делать): род-enforce реплик, hunspell/pymorphy, GLM overflow-гард, сид-правки.