Land backend package 2: review fix-list, GB18030 ingest with CJK chapter split, four cheap style flaggers with migration v7, D15.2 v2 spec, Gu Zhenren smoke
This commit is contained in:
parent
3011b886ca
commit
b51ac4c2e0
16 changed files with 2492 additions and 227 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# backend/ — Go-бэкенд TextMachine
|
# backend/ — Go-бэкенд TextMachine
|
||||||
|
|
||||||
Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (D1–D17); контракты Фазы 0 — `03-implementation-notes.md`; квирки провайдеров — `docs/experiments/00-provider-quirks.md`. **`.env` не читать.**
|
Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (D1–D18); контракты Фазы 0 — `03-implementation-notes.md`; квирки провайдеров — `docs/experiments/00-provider-quirks.md`. **`.env` не читать.**
|
||||||
|
|
||||||
## Карта пакетов
|
## Карта пакетов
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
| `cmd/tmctl` | CLI: `translate` / `report` / `status` (read-only проекция N/M+паспорта глав+деньги, `--json`) / `redrive` (переатака флагнутых: `--chapter/--chunk/--reason/--dry-run`, D15.3) | `main.go`, `internal/pipeline/status.go` |
|
| `cmd/tmctl` | CLI: `translate` / `report` / `status` (read-only проекция N/M+паспорта глав+деньги, `--json`) / `redrive` (переатака флагнутых: `--chapter/--chunk/--reason/--dry-run`, D15.3) | `main.go`, `internal/pipeline/status.go` |
|
||||||
| `internal/llm` | OpenAI-совместимый транспорт + retry/backoff, **capability-слой** (budget_field/temperature/reasoning per-модель), failover (написан, НЕ подключён — D4: только local-роли), нативный Anthropic = DEPRECATED-референс под будущий Gemini | `httpllm.go`, `capability.go`, `failover.go` |
|
| `internal/llm` | OpenAI-совместимый транспорт + retry/backoff, **capability-слой** (budget_field/temperature/reasoning per-модель), failover (написан, НЕ подключён — D4: только local-роли), нативный Anthropic = DEPRECATED-референс под будущий Gemini | `httpllm.go`, `capability.go`, `failover.go` |
|
||||||
| `internal/ledger` | Цены по usage (вкл. reasoning/cache-поля), `PriceForResponse` по фактической модели | `pricing.go` |
|
| `internal/ledger` | Цены по usage (вкл. reasoning/cache-поля), `PriceForResponse` по фактической модели | `pricing.go` |
|
||||||
| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1–v5, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state, request_log | `ledger.go`, `migrate.go`, `glossary.go` |
|
| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1–v6, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state, request_log | `ledger.go`, `migrate.go`, `glossary.go` |
|
||||||
| `internal/config` | fail-fast загрузка models/pipeline/book; **эхо-мина-гейт** `echoMineViolation`; `CheckRunnable` блокирует неисполнимое (C2/fanout/judge) | `models.go`, `pipeline.go` |
|
| `internal/config` | fail-fast загрузка models/pipeline/book; **эхо-мина-гейт** `echoMineViolation`; `CheckRunnable` блокирует неисполнимое (C2/fanout/judge) | `models.go`, `pipeline.go` |
|
||||||
| `internal/pipeline` | Раннер (циклы, disposition, single-hop эскалация с ре-гейтом, resume), чанкер v4, ingest txt/epub+ruby, classify/coverage, **банк памяти v2** (Aho-Corasick, спойлер-окна, disposition, post-check), рендер+инъекция | `runner.go`, `memory.go`, `disposition.go`, `coverage.go` |
|
| `internal/pipeline` | Раннер (циклы, disposition, single-hop эскалация с ре-гейтом, resume), чанкер v4, ingest txt/epub+ruby, classify/coverage, **банк памяти v2** (Aho-Corasick, спойлер-окна, disposition, post-check), рендер+инъекция | `runner.go`, `memory.go`, `disposition.go`, `coverage.go` |
|
||||||
| `internal/obs` | trace_id, структурные логи, safego | |
|
| `internal/obs` | trace_id, структурные логи, safego | |
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ func report(cfgPath string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(states) > 0 {
|
if len(states) > 0 {
|
||||||
var injected, sticky, ambiguous, spoiler, evicted, misses int
|
var injected, sticky, ambiguous, spoiler, evicted, misses, style int
|
||||||
for _, rs := range states {
|
for _, rs := range states {
|
||||||
injected += rs.NExactHits
|
injected += rs.NExactHits
|
||||||
sticky += rs.NSticky
|
sticky += rs.NSticky
|
||||||
|
|
@ -209,6 +209,7 @@ func report(cfgPath string) error {
|
||||||
spoiler += rs.NSpoilerBlocked
|
spoiler += rs.NSpoilerBlocked
|
||||||
evicted += rs.NEvicted
|
evicted += rs.NEvicted
|
||||||
misses += rs.NPostcheckMiss
|
misses += rs.NPostcheckMiss
|
||||||
|
style += rs.NStyleFlags
|
||||||
}
|
}
|
||||||
fmt.Printf("\n=== ПАМЯТЬ (retrieval-state) ===\n")
|
fmt.Printf("\n=== ПАМЯТЬ (retrieval-state) ===\n")
|
||||||
fmt.Printf("инъекций(точных)=%d sticky=%d ambiguous=%d спойлер-блок=%d вытеснено=%d post-check-промахов=%d\n",
|
fmt.Printf("инъекций(точных)=%d sticky=%d ambiguous=%d спойлер-блок=%d вытеснено=%d post-check-промахов=%d\n",
|
||||||
|
|
@ -224,6 +225,19 @@ func report(cfgPath string) error {
|
||||||
}
|
}
|
||||||
fmt.Printf("%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NPostcheckMiss, rs.PostcheckDetail)
|
fmt.Printf("%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NPostcheckMiss, rs.PostcheckDetail)
|
||||||
}
|
}
|
||||||
|
// Cheap style/number flaggers (observability, not gates): total + per-chunk detail.
|
||||||
|
fmt.Printf("\n=== СТИЛЬ-ГЕЙТЫ (наблюдаемость: тире-диалоги, ё, транслит-междометия, разряды 万/億) — всего %d ===\n", style)
|
||||||
|
stylePrinted := false
|
||||||
|
for _, rs := range states {
|
||||||
|
if rs.NStyleFlags == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !stylePrinted {
|
||||||
|
fmt.Printf("%-4s %-6s %6s %s\n", "ch", "chunk", "flags", "detail")
|
||||||
|
stylePrinted = true
|
||||||
|
}
|
||||||
|
fmt.Printf("%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NStyleFlags, rs.StyleDetail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
|
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
|
||||||
|
|
@ -236,7 +250,9 @@ func report(cfgPath string) error {
|
||||||
|
|
||||||
// status prints the READ-ONLY progress projection (D15.3): 0 LLM, 0 replay. --json emits the
|
// status prints the READ-ONLY progress projection (D15.3): 0 LLM, 0 replay. --json emits the
|
||||||
// StatusReport with stable disposition/flag_reason enums for CI/IDE; the default is a human
|
// StatusReport with stable disposition/flag_reason enums for CI/IDE; the default is a human
|
||||||
// dashboard (unit counts, per-chapter quality passports, money, secondary ETA).
|
// dashboard (unit counts, per-chapter quality passports, money, secondary ETA). BOTH modes exit 2
|
||||||
|
// (completed-with-flags) when flagged>0 (minor 1d — --json used to always exit 0), so a consumer
|
||||||
|
// never reads a clean 0 over a book that still needs attention.
|
||||||
func status(ctx context.Context, cfgPath string, asJSON bool) error {
|
func status(ctx context.Context, cfgPath string, asJSON bool) error {
|
||||||
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
|
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -252,7 +268,17 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error {
|
||||||
if asJSON {
|
if asJSON {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
return enc.Encode(rep)
|
if err := enc.Encode(rep); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Symmetric with the human mode (minor 1d): flagged>0 is exit 2 (completed-with-flags), not
|
||||||
|
// exit 0. The full JSON is already on stdout; the sentinel only sets the shell disposition
|
||||||
|
// (its message goes to stderr in main), so a CI/IDE consumer both parses the projection AND
|
||||||
|
// sees "attention needed" in the exit code instead of silently reading 0.
|
||||||
|
if rep.Flagged > 0 {
|
||||||
|
return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalChunks}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
snap := rep.Snapshot
|
snap := rep.Snapshot
|
||||||
|
|
@ -271,7 +297,8 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error {
|
||||||
fmt.Printf("=== СТАТУС: %s (snapshot %s)%s ===\n", rep.BookID, snap, drift)
|
fmt.Printf("=== СТАТУС: %s (snapshot %s)%s ===\n", rep.BookID, snap, drift)
|
||||||
fmt.Printf("Прогресс: %d/%d чанков (%.1f%%) — done=%d in_progress=%d flagged=%d pending=%d\n",
|
fmt.Printf("Прогресс: %d/%d чанков (%.1f%%) — done=%d in_progress=%d flagged=%d pending=%d\n",
|
||||||
rep.Done, rep.TotalChunks, rep.PercentDone, rep.Done, rep.InProgress, rep.Flagged, rep.Pending)
|
rep.Done, rep.TotalChunks, rep.PercentDone, rep.Done, rep.InProgress, rep.Flagged, rep.Pending)
|
||||||
fmt.Printf("Эскалаций: %d · post-check промахов (confirmed): %d\n", rep.Escalations, rep.PostcheckMisses)
|
fmt.Printf("Эскалаций: %d · post-check промахов (confirmed): %d · стиль-флагов (наблюдаемость): %d\n",
|
||||||
|
rep.Escalations, rep.PostcheckMisses, rep.StyleFlags)
|
||||||
ceil := ""
|
ceil := ""
|
||||||
if rep.BookCeilingUSD > 0 {
|
if rep.BookCeilingUSD > 0 {
|
||||||
ceil = fmt.Sprintf(" (потолок $%.2f — %.1f%%)", rep.BookCeilingUSD, rep.CeilingPct)
|
ceil = fmt.Sprintf(" (потолок $%.2f — %.1f%%)", rep.BookCeilingUSD, rep.CeilingPct)
|
||||||
|
|
@ -279,7 +306,7 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error {
|
||||||
fmt.Printf("Деньги: committed=$%.6f reserved=$%.6f%s · прогноз книги ~$%.6f\n",
|
fmt.Printf("Деньги: committed=$%.6f reserved=$%.6f%s · прогноз книги ~$%.6f\n",
|
||||||
rep.CommittedUSD, rep.ReservedUSD, ceil, rep.ProjectedBookUSD)
|
rep.CommittedUSD, rep.ReservedUSD, ceil, rep.ProjectedBookUSD)
|
||||||
if rep.ETASeconds > 0 {
|
if rep.ETASeconds > 0 {
|
||||||
fmt.Printf("ETA: ~%.0fс (по throughput fresh-вызовов; вторично)\n", rep.ETASeconds)
|
fmt.Printf("ETA: ~%.0fс (средний throughput fresh-вызовов, НЕ EWMA — D12-отступление; вторично)\n", rep.ETASeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(rep.Chapters) > 0 {
|
if len(rep.Chapters) > 0 {
|
||||||
|
|
@ -290,11 +317,20 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error {
|
||||||
dashIfEmpty(p.WorstFlagReason), p.CostUSD)
|
dashIfEmpty(p.WorstFlagReason), p.CostUSD)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// flagged≠failed (jobs.status.failed = infra-only, D12): flagged chunks are a
|
// flagged≠failed (jobs.status.failed = infra-only, D12): flagged chunks are a "clean run,
|
||||||
// "clean run, attention needed" signal, re-drivable with `tmctl redrive`.
|
// attention needed" signal. Two kinds need DIFFERENT actions (minor 1d): a chunk_status flag
|
||||||
|
// (soft_refusal, length, …) is re-drivable with `tmctl redrive`; a gate-promoted glossary_miss
|
||||||
|
// is NOT (redrive is a no-op — the miss re-derives from the unchanged glossary), so it needs a
|
||||||
|
// seed fix + `translate --resnapshot`. Advise each set separately instead of one blanket hint.
|
||||||
if rep.Flagged > 0 {
|
if rep.Flagged > 0 {
|
||||||
fmt.Printf("\n%d флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n",
|
if redrivable := rep.Flagged - rep.GlossaryMissFlagged; redrivable > 0 {
|
||||||
rep.Flagged, cfgPath)
|
fmt.Printf("\n%d флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n",
|
||||||
|
redrivable, cfgPath)
|
||||||
|
}
|
||||||
|
if rep.GlossaryMissFlagged > 0 {
|
||||||
|
fmt.Printf("\n%d чанк(ов) флагнуты post-check-гейтом (glossary_miss) — redrive для них no-op: исправьте сид-глоссарий (термин/dst) и перезапустите `tmctl translate --config %s --resnapshot` (переоплата затронутых чанков)\n",
|
||||||
|
rep.GlossaryMissFlagged, cfgPath)
|
||||||
|
}
|
||||||
return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalChunks}
|
return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalChunks}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -1,160 +1,455 @@
|
||||||
# D15.2 — Спека: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
|
# D15.2 — Спека v2: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
|
||||||
|
|
||||||
**Статус:** проект спеки бэкенд-сессии, сдан на утверждение оркестратору. Реализация — Ф1.5,
|
**Статус:** **v2, сдан на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до
|
||||||
**после** ратификации. Зона: спека написана в `backend/docs/` (зона бэкенда); при утверждении
|
ратификации v2** (D-лог §Ратификации 09.07 п.2: направление ратифицировано, реализация заперта до
|
||||||
оркестратор переносит/сворачивает в `docs/architecture/03-implementation-notes.md` новым разделом.
|
доработки трёх дыр). Зона: спека в `backend/docs/` (зона бэкенда); при утверждении оркестратор
|
||||||
|
сворачивает её в `docs/architecture/03-implementation-notes.md` новым разделом.
|
||||||
|
|
||||||
**Гейтит:** онгоинг-режим (`add-chapters`, сегмент «ИИ-фабрик», Р9) — D15.2. Текущий
|
**Supersedes:** D15.2-content-addressed-resume-spec.md (v1). Направление v1 (расщепить `snapshotID`
|
||||||
all-or-nothing resnapshot **приемлем для статичной приёмочной книги Ф1 (D15.1) — НЕ трогать до
|
на wire-часть и вердикт-часть; ключ чекпоинта держать на wire-идентичности; вердикт-смену
|
||||||
приёмки**; эта спека — для онгоинга.
|
re-classify-ить на resume бесплатно) — **ратифицировано** и сохранено. v2 **закрывает три
|
||||||
|
дизайн-дыры внешнего ревью**, которые заблокировали реализацию v1:
|
||||||
|
|
||||||
|
- **(a)** fast-path-гард v1 (content_hash + wireSnapshotID + verdict_snapshot) **слабее текущего
|
||||||
|
гарда**: не покрывает ПРЯМЫЕ wire-поля `RequestHash` (model / temperature / reasoning / maxTokens /
|
||||||
|
escalate_to). Смена `temperature` 0.3→0.4 или `reasoning` low→none тихо отдала бы stale-текст —
|
||||||
|
ре-открытие дефекта stale-serve F1. → §5, отдельная колонка `guard_hash`.
|
||||||
|
- **(b)** расщепление `memory_version` (wire vs verdict) в v1 было «(Опц.)». **Сделано ОБЯЗАТЕЛЬНЫМ**
|
||||||
|
(иначе главный сценарий онгоинга — append-термина/toggle гейта — не материализуется без переоплаты
|
||||||
|
книги). → §6.
|
||||||
|
- **(c)** loud-гейт согласия (`--resnapshot` fail-loud, Р6 «согласие на трату») content-addressed
|
||||||
|
resume **демонтирует**; v1 просто удалял его. **Заменён pre-flight dry-run** «N чанков, ~$X» с
|
||||||
|
порогом явного флага. → §7.
|
||||||
|
|
||||||
|
**Гейтит:** онгоинг-режим (`add-chapters`, сегмент «ИИ-фабрик», Р9 / D15 п.2). Текущий
|
||||||
|
all-or-nothing resnapshot остаётся **приемлемым для статичной приёмочной книги Ф1 (D15.1 / D18 蛊真人)
|
||||||
|
— НЕ трогать до приёмки**; эта спека — для онгоинга (Ф1.5).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Проблема (верифицирована D15 / 07 §4.4)
|
## 1. Проблема (верифицирована D15 / 07 §4.4)
|
||||||
|
|
||||||
`snapshotID` вшит в `RequestHash` (`render.go` — поле `snapshotID` в length-prefixed хеше). Значит
|
`snapshotID` (`runner.go:263-431`) вшит в `RequestHash` как поле `snapshotID` (`render.go:198,212-214`,
|
||||||
**любое** изменение снапшота (флип coverage-гейта, toggle `postcheck_gate`, бамп `classifierVersion`,
|
length-prefixed хеш). Значит **любое** изменение снапшота — флип coverage-гейта, toggle
|
||||||
append approved-терминов через `memoryVersion`, правка инертной ручки) после `--resnapshot`
|
`postcheck_gate`, бамп `classifierVersion`, append approved-терминов через `memoryVersion`, правка
|
||||||
пере-оплачивает **всю книгу**, включая **байт-идентичные** запросы (воспроизведено: 2→4 оплаченных
|
инертной ручки — после `--resnapshot` пере-оплачивает **всю книгу**, включая **байт-идентичные**
|
||||||
вызова на wire-идентичных телах). Механизм честного reuse существует (`msgsContentHash`,
|
запросы (воспроизведено: 2→4 оплаченных вызова на wire-идентичных телах). Механизм честного reuse
|
||||||
`render.go`), но заперт условием `cs.SnapshotID == snapID` (`runner.go`, resume fast-path).
|
существует (`msgsContentHash`, `render.go:233-249`), но заперт: fast-path в `runStage`
|
||||||
|
(`runner.go:899`) доверяет строке `chunk_status` только при `cs.SnapshotID == snapID`, поэтому
|
||||||
|
через смену снапшота не срабатывает.
|
||||||
|
|
||||||
Лгущие комментарии `runner.go`/`coverage.go` про «re-classify from checkpoint for FREE» уже
|
Лгущие комментарии `runner.go`/`coverage.go` про «re-classify from checkpoint for FREE» уже
|
||||||
исправлены в этом пакете (Задача 1a): сегодня это правда ТОЛЬКО внутри неизменного снапшота.
|
исправлены (пакет 09.07): сегодня это правда ТОЛЬКО внутри неизменного снапшота. Цель — сделать это
|
||||||
Цель спеки — **сделать это правдой и через смену снапшота**, без потери snapshot-гарантий.
|
правдой и через смену снапшота, без потери snapshot-гарантий и без ре-открытия stale-serve.
|
||||||
|
|
||||||
## 2. Ключевое наблюдение: снапшот смешивает ДВА разных класса входов
|
## 2. Ключевое наблюдение: снапшот смешивает ДВА класса входов + ТРЕТИЙ, избыточный
|
||||||
|
|
||||||
`snapshotID` сегодня сворачивает вместе:
|
`snapshotID` сегодня сворачивает вместе три категории, хотя ключ вызова нуждается только в одной:
|
||||||
|
|
||||||
**(A) Wire-определяющие входы** — меняют БАЙТЫ, отправленные провайдеру, значит меняют сам
|
**(A) Wire-определяющие, УЖЕ покрытые иначе** (msgs/content_hash или прямые поля `RequestHash`).
|
||||||
ответ, значит требуют нового вызова:
|
`brief_hash`, `chunker_version`, `context_assembly` (инъекционные ручки), `memory_version`
|
||||||
- `brief_hash`, `chunker_version`, `memory_version`, `context_assembly` — **все они уже входят в
|
(матчер+инъекция) — всё материализуется в `msgs` (метаданные книги через шаблон `render.go:108-121`,
|
||||||
`msgsContentHash`**, потому что рендер (метаданные книги в шаблоне, нарезка → `ch.Text`,
|
нарезка → `ch.Text`, инъекция глоссария как ОТДЕЛЬНОЕ сообщение `render.go:174-190`) →
|
||||||
инъекция глоссария как ОТДЕЛЬНОЕ сообщение) целиком материализуется в `msgs`. Это
|
`msgsContentHash`. `model`, `temperature`, `reasoning`, `jsonOnly`, `maxTokens` — уже ПРЯМЫЕ поля
|
||||||
belt-and-suspenders: фактический per-chunk рендер — источник истины.
|
`RequestHash`. `estimator_version`, `max_tokens_policy`, `max_output_ratio`, `min_max_tokens` — лишь
|
||||||
- `model`, `temperature`, `reasoning`, `jsonOnly`, `maxTokens` — уже ОТДЕЛЬНЫЕ поля `RequestHash`
|
ДЕРИВИРУЮТ `maxTokens`, а его фактическое значение уже прямое поле.
|
||||||
(не через снапшот).
|
|
||||||
- `capability` (budget-поле / temp-режим / reasoning-контроль), `model_extra`,
|
|
||||||
провайдер-оверрайды (local temp/max_tokens/model), эскалационные capability/оверрайды — **НЕ в
|
|
||||||
`msgs` и НЕ отдельные поля**: меняют тело запроса, но сегодня заходят в ключ только через
|
|
||||||
снапшот. Это единственная wire-релевантная часть снапшота, не покрытая иначе.
|
|
||||||
- `estimator_version`, `max_tokens_policy` — влияют на `maxTokens`, а он уже отдельное поле; сами
|
|
||||||
ВЕРСИИ избыточны (фактический `maxTokens` в ключе).
|
|
||||||
|
|
||||||
**(B) Вердикт-определяющие входы** — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса:
|
**(B) Wire-определяющие, НЕ покрытые иначе.** Резолвнутая `capability`, `model_extra`,
|
||||||
- `classifier_version` (порядок/пороги intrinsic classify), `coverage` (enabled + пороги +
|
провайдер-оверрайды (local `temp`/`max_tokens`/`model`), эскалационные `escalate_to` +
|
||||||
`coverage_gate_version`), `postcheck_gate` (on/off; сам АЛГОРИТМ post-check — в `memory_version`,
|
capability/extra/оверрайды, `pipeline_core` (C0…C3 — механика сборки), `cache_ttl` (cache-control на
|
||||||
но он влияет на вердикт, не на wire… см. §6 нюанс).
|
проводе, не в `content_hash`). Меняют ТЕЛО запроса, но не заходят в ключ никак, кроме снапшота — это
|
||||||
|
единственная wire-часть снапшота, которую надо сохранить в ключе.
|
||||||
|
|
||||||
Сегодня (B) сидит в `RequestHash` → вердикт-only правка промахивается по всем чекпоинтам и
|
**(C) Вердикт-определяющие** — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса:
|
||||||
пере-оплачивает книгу. Это и есть дефект.
|
`classifier_version` (`classifyOutput` → `classify`, `runner.go:201-219`), `coverage`
|
||||||
|
(enabled+пороги+`coverageGateVersion`), `postcheck_gate` (on/off) и алгоритм/данные post-check
|
||||||
|
(`memoryPostcheckVersion` + decl инъектнутых записей при gate ON — `computeMemoryVersion`,
|
||||||
|
`memory.go:225-229`). Все они пересчитываются ВЖИВУЮ на resume и **в `chunk_status` не хранятся**
|
||||||
|
(классификатор/coverage — в `classifyOutput`; post-check — в `translateChunk` над финальным `prev`,
|
||||||
|
`runner.go:774-788`). Значит из ключа вызова их можно убрать целиком.
|
||||||
|
|
||||||
## 3. Дизайн: расщепить снапшот на wire-часть и вердикт-часть
|
Сегодня (B) и (C) сидят в `RequestHash` → вердикт-only или book-global-wire правка промахивается по
|
||||||
|
ВСЕМ чекпоинтам и пере-оплачивает книгу. Это и есть дефект.
|
||||||
|
|
||||||
- **`wireSnapshotID`** = хеш ТОЛЬКО (A)-входов, НЕ покрытых `msgs`/прямыми полями: резолвнутая
|
## 3. Дизайн: три хеша вместо одного снапшота-в-ключе
|
||||||
`capability`, `model_extra`, провайдер-оверрайды (temp/max_tokens/model), эскалационные
|
|
||||||
capability/extra/оверрайды. (Всё остальное wire-определяющее уже в `msgsContentHash` или в
|
|
||||||
прямых полях `RequestHash`.)
|
|
||||||
- **`verdictSnapshotID`** = хеш (B)-входов: `classifier_version`, coverage-конфиг+версия,
|
|
||||||
`postcheck_gate`-состояние.
|
|
||||||
|
|
||||||
**`RequestHash` (ключ чекпоинта) = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temp,
|
Расщепляем `snapshotID()` и вводим отдельный fast-path-гард. Появляются ТРИ независимых хеша, каждый
|
||||||
reasoning, jsonOnly, maxTokens, `wireSnapshotID`, msgs).** — убираем `snapshotID`, добавляем
|
на своём слое:
|
||||||
`wireSnapshotID`. Вердикт-версии из ключа чекпоинта **уходят**.
|
|
||||||
|
|
||||||
Полный `snapshotID` (для `jobs.snapshot_id`, `snapshots`-таблицы, отчётности) остаётся как
|
### 3.1 `wireSnapshotID` — ПЕР-СТАДИЙНЫЙ, идёт в `RequestHash`
|
||||||
`hash(wireSnapshotID, verdictSnapshotID)` — но перестаёт быть частью ключа вызова.
|
Хеш ТОЛЬКО категории (B) для КОНКРЕТНОЙ стадии: резолвнутая `capability`, `model_extra`,
|
||||||
|
провайдер-оверрайды (`ProviderTemp`/`ProviderMaxTok`/`ProviderModel`), эскалационные
|
||||||
|
`EscalateTo`+`EscalateCapability`+`EscalateExtra`+`EscalateProvider*`, `pipeline_core`, `cache_ttl`.
|
||||||
|
Префикс версии `tm-wire-v1`.
|
||||||
|
|
||||||
### Что тогда re-bill-ится, а что бесплатно (операторский контракт)
|
**Пер-стадийность — часть выигрыша гранулярности.** Сегодня `snapshotID` book-global: правка
|
||||||
|
`capability` стадии-2 (редактор) меняет `RequestHash` стадии-1 (черновик). В v2 `RequestHash`
|
||||||
|
стадии-1 несёт `wireSnapshotID` ТОЛЬКО стадии-1 → правка редактора не пере-оплачивает черновик
|
||||||
|
(каскад на редактор идёт честно через изменённый `prev`, §10). Book-global входы (`pipeline_core`,
|
||||||
|
`cache_ttl`) кладём в `wireSnapshotID` каждой стадии одинаковым значением.
|
||||||
|
|
||||||
| Изменение | Класс | Эффект |
|
Смена `wireSnapshotID` стадии → `RequestHash` этой стадии по всей книге меняется → честный re-bill
|
||||||
|---|---|---|
|
(эти входы реально меняют тело/ответ каждого вызова; v1 §3 это уже фиксировал).
|
||||||
| Флип `coverage.enabled`, правка порогов coverage; бамп `classifier_version`; toggle/пороги `postcheck_gate` | (B) вердикт | **$0** — `wireSnapshotID`+`msgs` не меняются → чекпоинт ХИТ → только **re-classify** |
|
|
||||||
| Append approved-терминов | (A) через `msgs` | Re-bill **только тех чанков, где новый термин ФАЕРИТСЯ** (их инъекция → `msgs` меняются); чанки, где не фаерится — **$0** (идентичная инъекция → идентичный `msgs`) |
|
|
||||||
| Правка book-brief, меняющая метаданные части глав | (A) через `msgs` | Re-bill только затронутых чанков |
|
|
||||||
| Правка промпт-шаблона, смена модели/temp/reasoning, правка `capability`, ре-чанкинг, смена estimator/maxtok-политики (меняет фактический `maxTokens`) | (A) wire | Re-bill (честно: тело/ответ реально другие) |
|
|
||||||
|
|
||||||
**Онгоинг-выигрыш (флагман «ИИ-фабрик»):** подтверждение пачки терминов на границе тома больше не
|
### 3.2 `verdictSnapshotID` — book-global, идёт в `chunk_status.verdict_snapshot` (НЕ в `RequestHash`)
|
||||||
пере-покупает готовые главы — только те чанки, где термин действительно матчится. Это прямо снимает
|
Хеш категории (C): `classifier_version`, coverage-конфиг+`coverageGateVersion`, `postcheck_gate`,
|
||||||
D15-мину онгоинга.
|
`memoryPostcheckVersion` + decl-контент инъектируемых записей при gate ON (§6). Префикс
|
||||||
|
`tm-verdict-v1`. В ключ вызова НЕ входит. Хранится в `chunk_status` как триггер бесплатного
|
||||||
|
re-classify на fast-path (§5, §8).
|
||||||
|
|
||||||
## 4. Resume: когда байт-идентичный запрос легально переиспользуется
|
### 3.3 `guard_hash` — ПЕР-СТАДИЙНЫЙ, хранится в `chunk_status`, гейт fast-path (НЕ в `RequestHash`)
|
||||||
|
Хеш ВСЕГО wire stage-плана для чанка×стадии — суперсет того, что fast-path обязан проверить перед
|
||||||
|
доверием строке (закрывает дыру (a), §5):
|
||||||
|
`guard_hash = H(tm-guard-v1, content_hash, wireSnapshotID, model, temperature, reasoning, jsonOnly,
|
||||||
|
estimatorVersion, maxOutputRatio, minMaxTokens, maxTokensPolicyVersion)`.
|
||||||
|
Прямые wire-поля (`model`/`temperature`/`reasoning`/`jsonOnly`) и ДЕРИВАЦИЯ `maxTokens`
|
||||||
|
(версии+ratio+floor; сам текст для деривации уже в `content_hash` через `msgs`) — то, чего `content_hash`
|
||||||
|
+ `wireSnapshotID` не покрывают. `escalate_to` и эскалационная wire уже внутри `wireSnapshotID`.
|
||||||
|
|
||||||
Reuse чекпоинта легален ⟺ **полная wire-идентичность** совпадает: `msgsContentHash` И не-msgs
|
### 3.4 Итоговый `RequestHash` (ключ чекпоинта), бамп `tm-request-v2`→`tm-request-v3`
|
||||||
wire-параметры (`wireSnapshotID` + прямые поля model/temp/reasoning/maxTokens). Это ровно то, что
|
`RequestHash = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temperature, reasoning,
|
||||||
даёт новый `RequestHash` — совпал ключ ⇒ совпал wire ⇒ переиспользуем оплаченный ответ (то же
|
jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wireSnapshotID`; всё остальное
|
||||||
основание, что у сегодняшнего same-snapshot resume; провайдер недетерминирован при temp>0, но
|
как в `render.go:198`. Вердикт-версии из ключа **уходят**. Прямые wire-поля остаются прямыми (были и
|
||||||
чекпоинт — валидный ответ на ЭТОТ wire).
|
есть) — это и есть покрытие, которого не хватало fast-path-у.
|
||||||
|
|
||||||
**Вердикт-различие НИКОГДА не блокирует reuse** — доказуемо wire-нейтрально (см. §5).
|
Полный `snapshotID` (для `jobs.snapshot_id`, `snapshots`-таблицы, отчётности) сохраняется как
|
||||||
|
`H(все wireSnapshotID стадий, verdictSnapshotID)`, но перестаёт быть частью ключа вызова и
|
||||||
|
демотируется до advisory (§9).
|
||||||
|
|
||||||
## 5. Re-classify по новым порогам (и почему это безопасно, F1)
|
## 4. Полный полевой маппинг снапшота (обязательно, ревью п.2)
|
||||||
|
|
||||||
Машинерия re-classify **уже есть**: на checkpoint-hit `runAttempt` вызывает `classifyOutput` над
|
Каждое поле, сегодня сворачиваемое в `snapshotID()` (`runner.go:263-431`) + `memoryVersion`
|
||||||
текстом чекпоинта (не доверяет старому вердикту). Единственная правка — чтобы чекпоинт ХИТИЛ при
|
(`memory.go:232-259`) + coverage/classifier/postcheck, классифицировано:
|
||||||
вердикт-only смене (§3 убирает вердикт-версии из ключа). Тогда:
|
**wire** (влияет на байты запроса) / **verdict** (влияет на классификацию) / **dropped-as-redundant**
|
||||||
|
(уже покрыто иначе). Колонка «Куда в v2»: `content_hash` (msgs), прямое поле `RequestHash`,
|
||||||
|
`wireSnapshotID`, `verdictSnapshotID`, `guard_hash`, либо dropped.
|
||||||
|
|
||||||
- **Fast-path `resumeFromChunkStatus`** (короткое замыкание до `runAttempt`): доверять строке
|
| Поле (сегодня) | Класс | Куда в v2 | Почему |
|
||||||
`chunk_status` только когда совпали `content_hash` И `wireSnapshotID` И `verdictSnapshotID`. При
|
|---|---|---|---|
|
||||||
СМЕНЕ `verdictSnapshotID` — **не** отдавать старый вердикт: проваливаться в `runAttempt`, где
|
| `brief_hash` | wire | **dropped** → `content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. Caveat: поле `Adult` (`book.go:151,156`) и НЕрендеримые плейсхолдеры не в `msgs` — они wire-нейтральны by construction (не уходят на провод); если `Adult` когда-либо станет wire/verdict-влияющим — вернуть в `wireSnapshotID` (или `verdictSnapshotID`). |
|
||||||
чекпоинт хитит по wire-идентичности → re-classify бесплатно → re-upsert `chunk_status` с новым
|
| `chunker_version` | wire | **dropped** → `content_hash` | Правила чанкера дают `ch.Text` → `msgs`. Ре-чанкинг меняет границы/число чанков → `content_hash` почти всех → честный broad re-bill, гранулярно. |
|
||||||
вердиктом. Нужна колонка `chunk_status.verdict_snapshot` (в дополнение к `content_hash`).
|
| `estimator_version` | wire (деривация) | **guard_hash** (не в `RequestHash`) | Деривирует `maxTokens`, а фактический `maxTokens` — уже прямое поле `RequestHash`. В ключе избыточен; fast-path нуждается — кладём в `guard_hash`. Бамп busts fast-path каждого чанка; платят только те, чей `maxTokens` реально сменился (остальные — checkpoint-hit, re-classify $0). |
|
||||||
- **Вердикт-флип на re-classify:** `flagged→ok` (ослабили гейт) → текст чекпоинта отдаётся
|
| `max_tokens_policy` | wire (деривация) | **guard_hash** | Как `estimator_version`, для attempt≥1 (`maxTokensForAttempt`). Избыточен в ключе (фактический `maxTokens` там), нужен fast-path-у. |
|
||||||
бесплатно. `ok→flagged` (ужесточили) → чанк флагается; если это влечёт НОВЫЙ wire-запрос (retry
|
| `max_output_ratio` | wire (деривация) | **guard_hash** | Множитель `maxTokens`. То же. |
|
||||||
на attempt+1, или эскалация на другую модель) — он честно оплачивается (это реально новая работа,
|
| `min_max_tokens` | wire (деривация) | **guard_hash** | Пол `maxTokens`. То же. |
|
||||||
не re-pay старой). Ретраи/эскалация уже сидят на своих осях ключа (`attempt`, модель эскалации).
|
| `context_assembly.glossary_injection` | wire | **dropped** → `content_hash` | Режим инъекции меняет наличие/содержимое инъекц-сообщения → `msgs`. |
|
||||||
|
| `context_assembly.glossary_token_budget` | wire | **dropped** → `content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). |
|
||||||
|
| `context_assembly.stm_depth` | wire | **dropped** → `content_hash` | STM входит в `msgs`. |
|
||||||
|
| `context_assembly.overlap_tokens` | wire | **dropped** → `content_hash` | Overlap меняет `ch.Text` → `msgs`. |
|
||||||
|
| `context_assembly.cache_ttl` | wire (cache-control) | **wireSnapshotID** | НЕ в `content_hash` (в `msgs` только `Role`+`Content`; `msgsContentHash` опускает `CacheBoundary`/TTL). Response-нейтрален → reuse безопасен; пиним консервативно, чтобы смена TTL честно триггерила свежий вызов, но fast-path на `content_hash` не рушится (см. примечание ниже таблицы). |
|
||||||
|
| `model` (пер-стадийный) | wire | **dropped из снапшота** → прямое поле `RequestHash` + `guard_hash` | `RequestHash` уже несёт `model` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. |
|
||||||
|
| `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). |
|
||||||
|
| `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). |
|
||||||
|
| `jsonOnly` | wire | прямое поле `RequestHash` + `guard_hash` | Уже прямое (сейчас всегда `false`); держим в `guard_hash` для полноты. |
|
||||||
|
| `maxTokens` (фактический) | wire | прямое поле `RequestHash`; деривация → `guard_hash` | Значение — прямое поле; версии деривации — в `guard_hash` (выше). |
|
||||||
|
| `capability` (ResolveCapability) | wire | **wireSnapshotID** + `guard_hash` | Резолвнутая wire-форма (budget-ключ, temp-режим, thinking-выключатель); не в `msgs`, не прямое поле. |
|
||||||
|
| `model_extra` (`ExtraBody`) | wire | **wireSnapshotID** + `guard_hash` | Меняет тело (GLM thinking-off и т.п.); не в `msgs`. |
|
||||||
|
| `provider_temp` (local) | wire | **wireSnapshotID** + `guard_hash` | Оверрайд поверх wire ПОСЛЕ хеша; не в `msgs`. |
|
||||||
|
| `provider_max_tok` (local) | wire | **wireSnapshotID** + `guard_hash` | То же. |
|
||||||
|
| `provider_model` (local) | wire | **wireSnapshotID** + `guard_hash` | Фактически отвечающий local-бэкенд (8b→14b); самый импактный local-оверрайд. |
|
||||||
|
| `escalate_to` | wire | **wireSnapshotID** + `guard_hash` | Фолбэк-модель: меняет wire эскал-подвызова И резолвнутую диспозицию (случился ли хоп). |
|
||||||
|
| `escalate_capability` | wire | **wireSnapshotID** + `guard_hash` | Резолвнутая wire фолбэка. |
|
||||||
|
| `escalate_extra` | wire | **wireSnapshotID** + `guard_hash` | `extra_body` фолбэка. |
|
||||||
|
| `escalate_provider_temp/max_tok/model` | wire | **wireSnapshotID** + `guard_hash` | Провайдер-оверрайды фолбэка. |
|
||||||
|
| `pipeline_core` (C0…C3) | wire | **wireSnapshotID** + `guard_hash` | Механика сборки прохода (linear vs selection/fusion); консервативный catch-all wire-плана. |
|
||||||
|
| `prompt_version` (`st.PromptVersion`) | wire | **dropped** → `content_hash` | Человеческий лейбл; фактические байты промпта рендерятся в `msgs` (авторитет — рендер, не лейбл). |
|
||||||
|
| `prompt_sha256` | wire | **dropped** → `content_hash` | Шаблон рендерится в system/user сообщения → `msgs`. Рендер И ЕСТЬ `msgs`. |
|
||||||
|
| stage `name` | wire | **dropped из снапшота** → прямое поле `stage` + `content_hash` | `RequestHash` несёт `stage`; структура плана отражена в `msgs` через `prev`-цепочку. |
|
||||||
|
| stage `role` | wire | **dropped из снапшота** → прямое поле `role` + `content_hash` | `role` — прямое поле; он же выбирает инъекцию (translator/editor) → `msgs`. |
|
||||||
|
| `memory_version` (матчер+инъекция часть) = `memoryInjectVersion` | wire | **dropped** → `content_hash` | `memoryNormVersion` + матчер-часть `memoryMatchVersion` определяют, КАКИЕ записи инъектятся → инъекц-сообщение → `msgs`. §6. |
|
||||||
|
| `memory_version` (post-check часть) = `memoryPostcheckVersion` | verdict | **verdictSnapshotID** | Алгоритм/пороги post-check — вердикт, пересчитывается вживую (`translateChunk`). §6. |
|
||||||
|
| `postcheck_gate` (top-level + `gate:` в memory) | verdict | **verdictSnapshotID** | Toggle меняет резолвнутую диспозицию; пересчёт бесплатен. Сегодня двойной фолд (top-level `PostcheckGate` + `gate:bool` в `computeMemoryVersion`) → в v2 один раз. §6. |
|
||||||
|
| decl-контент инъектнутых записей | verdict (при gate ON) | **verdictSnapshotID** | Decl НЕ инъектится в `msgs`, но при gate ON влияет на post-check вердикт (`memory.go:225-229`). §6. |
|
||||||
|
| `classifier_version` | verdict | **verdictSnapshotID** | Пороги/порядок `classify`; пересчёт `classifyOutput` бесплатен на checkpoint-hit. |
|
||||||
|
| `coverage` (enabled+len_ratio+sent_cov_min+min_chunk_chars+`coverageGateVersion`) | verdict | **verdictSnapshotID** | Excision-гейт не трогает провод; `coverageCheck` пересчитывается вживую над текстом чекпоинта. |
|
||||||
|
|
||||||
**Ось безопасности (F1 — никакого тихого расходящегося re-pay):** reuse — precondition на ТОЧНОЕ
|
**Примечание к `cache_ttl`/`CacheBoundary`:** `RequestHash` включает `m.CacheBoundary`
|
||||||
совпадение wire-идентичности (`msgsContentHash` — сильнейший гард: точные байты промпта+инъекции;
|
(`render.go:219`), `msgsContentHash` — нет (`render.go:245-247`), а `guard_hash` строится из
|
||||||
плюс не-msgs wire-параметры). Вердикт-смена меняет только post-settle классификацию, НИКОГДА не
|
`content_hash`. Следствие: смена cache-границы/TTL честно даёт свежий `RequestHash` (свежий вызов
|
||||||
запрос, поэтому re-classify матчнутого чекпоинта не может отдать расходящийся ПЕРЕВОД — только другой
|
если дошли до attempt-цикла), но fast-path на `content_hash`/`guard_hash` НЕ рушит reuse — это
|
||||||
ВЕРДИКТ на том же переводе (искомый бесплатный re-verdict). Опасный кейс — reuse чекпоинта, чей wire
|
корректно, т.к. cache-control response-нейтрален (валидный старый ответ переиспользуем). `cache_ttl`
|
||||||
на новом конфиге был бы ДРУГИМ — исключён требованием точного wire-match.
|
в `wireSnapshotID` — консервативный пин для свежести ключа, не гард reuse.
|
||||||
|
|
||||||
## 6. Нюансы и открытые вопросы к оркестратору
|
## 5. HOLE (a): fast-path-гард обязан покрывать ВЕСЬ wire stage-план
|
||||||
|
|
||||||
1. **`memory_version` содержит `memoryMatchVersion` (алгоритм матчера).** Смена алгоритма матчера
|
**Дефект v1.** Fast-path (`resumeFromChunkStatus`, короткое замыкание в `runStage:897-901` ДО
|
||||||
меняет per-chunk ВЫБОР (какие записи инъектятся) → инъекция → `msgs` → корректно re-bill только
|
`runAttempt`) доверяет строке `chunk_status`, когда совпали `content_hash` + `wireSnapshotID` +
|
||||||
затронутых чанков. Значит `memory_version` из wire-ключа можно убрать (полагаться на `msgs`).
|
`verdict_snapshot`. Но `content_hash` (= `msgsContentHash`) НЕ содержит прямых wire-полей
|
||||||
**НО:** post-check-алгоритм тоже версионируется `memoryMatchVersion`, а post-check — ВЕРДИКТ, не
|
`model`/`temperature`/`reasoning`/`maxTokens`, а `wireSnapshotID` (§3.1) их тоже не содержит (они —
|
||||||
wire. Дилемма: одна версия покрывает и wire-часть матчера, и вердикт-часть post-check. Развязка:
|
прямые поля `RequestHash`, не категория B). Значит смена `temperature` 0.3→0.4, `reasoning` low→none
|
||||||
при реализации расщепить `memoryMatchVersion` на `memoryInjectVersion` (wire, покрыт `msgs`) и
|
или деривации `maxTokens` НЕ меняет ни `content_hash`, ни `wireSnapshotID`, ни `verdict_snapshot` →
|
||||||
`memoryPostcheckVersion` (вердикт, в `verdictSnapshotID`). До расщепления — держать
|
v1-fast-path ХИТИТ → отдаёт stale-текст, сгенерённый на СТАРОМ wire. Это ре-открытие stale-serve F1.
|
||||||
`memory_version` в wire-части (консервативно: лишний re-bill безопаснее тихого stale-вердикта).
|
Настоящий `RequestHash` эти поля включает, но fast-path замыкается ДО attempt-цикла, где `RequestHash`
|
||||||
2. **Гранулярность гейта.** Сегодня resnapshot-гейт — per-JOB (`jobs.snapshot_id`, глава×стадия).
|
считается.
|
||||||
Для онгоинга нужен per-CHUNK (`content_hash`) первичный гейт; job-снапшот демотируется до
|
|
||||||
грубого advisory. Раннер уже делает per-chunk `content_hash`-чек (`runStage`) — спека делает его
|
|
||||||
ПЕРВИЧНЫМ. Job-снапшот-mismatch перестаёт быть fail-loud стопом; заменяется на per-chunk решение.
|
|
||||||
3. **Экономика показа (D5 retro-patch).** Append-термин, фаерящийся в старых главах, ЗАКОННО их
|
|
||||||
re-bill-ит (термин должен примениться) — но оператору показать стоимость (`tmctl status`
|
|
||||||
projected-дельта или dry-run «сколько чанков затронет этот append»). Это UI-хвост, не F1.
|
|
||||||
4. **Миграция ключей.** Смена формулы `RequestHash` (убрать `snapshotID`, добавить `wireSnapshotID`)
|
|
||||||
— это смена ФОРМАТА ключа (как v1→v2 сейчас). Все существующие чекпоинты промахнутся ОДИН раз при
|
|
||||||
переходе на новую формулу. Приемлемо (одноразово, на границе Ф1.5); зафиксировать бампом
|
|
||||||
`tm-request-v2`→`v3` и задокументировать как осознанный one-time re-pin.
|
|
||||||
5. **`AMBIGUOUS-BILLED` / F3.** Ортогонально этой спеке (F3 — техдолг at-most-once); content-addressed
|
|
||||||
reuse не меняет F3-инвариант.
|
|
||||||
|
|
||||||
## 7. Минимальный план реализации (Ф1.5, после утверждения)
|
**Взвешены два варианта:**
|
||||||
|
|
||||||
1. Расщепить `snapshotID()` → `wireSnapshotID()` + `verdictSnapshotID()`; `snapshotID` = их хеш.
|
- **(1) Отдельная колонка `guard_hash`** (§3.3): хеш ВСЕГО wire stage-плана (`content_hash` +
|
||||||
2. `RequestHash`: `snapshotID` → `wireSnapshotID` (бамп `tm-request-v3`).
|
`wireSnapshotID` + `model`/`temp`/`reasoning`/`jsonOnly` + деривация `maxTokens` + `escalate_to`
|
||||||
3. `chunk_status`: добавить `verdict_snapshot` (миграция), писать/читать; fast-path доверяет строке
|
внутри `wireSnapshotID`). НЕ часть `RequestHash` — только гейт доверия fast-path. На fast-path
|
||||||
только при совпадении `content_hash`+`wireSnapshotID`+`verdict_snapshot`, иначе проваливается в
|
пересчитываем `guard_hash` из ТЕКУЩЕГО конфига и сравниваем со строкой.
|
||||||
`runAttempt` (re-classify бесплатно).
|
- **(2) Verify-by-recompute**: на fast-path пересчитать полные wire-параметры победившего attempt и
|
||||||
4. Job-снапшот-mismatch: демотировать из fail-loud стопа в per-chunk `content_hash`-решение.
|
сравнить, храня в строке всё нужное для пересчёта (модель/temp/reasoning/фактический maxTokens
|
||||||
5. (Опц.) расщепить `memoryMatchVersion` на inject/postcheck версии (§6.1).
|
победителя, capability и т.д.).
|
||||||
6. Тесты: (а) вердикт-only смена → $0 re-classify, вердикт обновлён; (б) append-термин → re-bill
|
|
||||||
ТОЛЬКО фаерящихся чанков, остальные $0; (в) wire-смена (промпт/модель/capability) → честный
|
**Рекомендация — вариант (1), `guard_hash`.** Проще и строго-сильнее:
|
||||||
re-bill; (г) F1: никакой матч не отдаёт расходящийся текст (mutation: ослабить wire-match →
|
- Один детерминированный хеш вместо хранения и по-полевого сравнения ~десятка параметров (вар. 2
|
||||||
тест ловит stale-serve).
|
раздувает `chunk_status` и дублирует логику резолва).
|
||||||
|
- **Строго сильнее текущего гарда**: сегодня fast-path проверяет `snapshot_id`+`content_hash`;
|
||||||
|
`guard_hash` покрывает `content_hash` (⊇) ПЛЮС весь wire-план, включая то, что `snapshot_id` терял
|
||||||
|
при пер-стадийном расщеплении. Смена `temperature`/`reasoning`/`maxTokens`-деривации/`escalate_to`
|
||||||
|
→ `guard_hash` mismatch → fast-path проваливается в attempt-цикл → там `RequestHash` (с прямыми
|
||||||
|
полями) промахивается → **честный свежий вызов** (это реально другая работа, не re-pay).
|
||||||
|
- **Разделение слоёв**: `RequestHash` — ключ оплаченного ответа; `guard_hash` — гард доверия
|
||||||
|
материализованной строки-резолва. Держать их разными хешами (а не пихать всё в `RequestHash`)
|
||||||
|
сохраняет пер-стадийную гранулярность ключа и не смешивает деривац-версии (которые в ключе
|
||||||
|
избыточны) с ключом.
|
||||||
|
|
||||||
|
**Fast-path v2** (`runStage`): доверять строке ⟺
|
||||||
|
`cs.guard_hash == guard_hash(тек.) && cs.verdict_snapshot == verdictSnapshotID(тек.) &&
|
||||||
|
cs.disposition != skipped`. `content_hash` продолжаем хранить (сигнатура msgs — нужна dry-run’у §7 и
|
||||||
|
отчётности), но гейтом служит `guard_hash` (он его суперсет). При mismatch `guard_hash` —
|
||||||
|
проваливаемся в attempt-цикл (свежий вызов, если wire реально другой); при mismatch только
|
||||||
|
`verdict_snapshot` (guard совпал) — проваливаемся в `runAttempt`, где checkpoint ХИТИТ по
|
||||||
|
wire-идентичности → **re-classify бесплатно** → re-upsert строки с новым `verdict_snapshot` и
|
||||||
|
диспозицией (§8).
|
||||||
|
|
||||||
|
## 6. HOLE (b): расщепление `memory_version` — ОБЯЗАТЕЛЬНО
|
||||||
|
|
||||||
|
`memoryMatchVersion` (`memory.go:31-39`) сегодня версионирует ОБА: матчер/селекцию инъекции И
|
||||||
|
алгоритм post-check (слаг буквально несёт `…+postcheck-declaware`). `computeMemoryVersion`
|
||||||
|
(`memory.go:232-259`) сворачивает `memoryNormVersion` + `memoryMatchVersion` + `gate:bool` + контент
|
||||||
|
строк (approved-only при gate OFF; ВСЕ строки, включая `decl`+`status`, при gate ON). Всё это сегодня
|
||||||
|
→ `MemoryVersion` → `snapshotID` → ключ. Правка → промах по книге. Ревью-инсайт: **post-check
|
||||||
|
пересчитывается вживую на каждом resume (`translateChunk:774-788` над финальным `prev`) и в
|
||||||
|
`chunk_status` НЕ хранится — значит `memory_version` можно убрать из ключа вызова целиком.**
|
||||||
|
|
||||||
|
**Расщепление (обязательное, не опциональное):**
|
||||||
|
|
||||||
|
- **`memoryInjectVersion`** (wire) = `memoryNormVersion` + матчер/селекция-часть `memoryMatchVersion`
|
||||||
|
+ инъектируемый контент approved-записей (`src`/`dst`/aliases — то, что рендерится в
|
||||||
|
`renderGlossaryBlock`/`renderEditorConstraintBlock`). Определяет, ЧТО и КАК инъектится →
|
||||||
|
инъекц-сообщение → `msgs` → `content_hash`. **Из ключа УБИРАЕТСЯ полностью; отдельным полем ключа
|
||||||
|
НЕ является** — покрыт `content_hash` пер-чанково (это и даёт гранулярный re-bill append-а, §10).
|
||||||
|
- **`memoryPostcheckVersion`** (verdict) = алгоритм+пороги post-check + `gate:bool` (`postcheck_gate`)
|
||||||
|
+ **`decl`-контент ВСЕХ инъектируемых записей при gate ON**. Уходит в **`verdictSnapshotID`**
|
||||||
|
(§3.2), пересчитывается на resume бесплатно.
|
||||||
|
|
||||||
|
**Что именно ПОКИДАЕТ wire-ключ:** весь `memory_version`. Матчер/нормализация/инъект-контент — их
|
||||||
|
эффект целиком в `msgs`. **Что входит в `verdictSnapshotID`:**
|
||||||
|
1. `memoryPostcheckVersion` (алгоритм/пороги post-check);
|
||||||
|
2. `gate:bool` — судьба `postcheck_gate` **явно**: это ВЕРДИКТ-сторона (toggle меняет резолвнутую
|
||||||
|
диспозицию `glossary_miss`, `runner.go:782-787`), кладём в `verdictSnapshotID`. Сегодняшний
|
||||||
|
двойной фолд (top-level `PostcheckGate` в `snapshotID` + `gate:` в `computeMemoryVersion`)
|
||||||
|
схлопывается в один источник — `verdictSnapshotID`;
|
||||||
|
3. **decl-контент** инъектируемых записей — судьба **явно**: `decl` НЕ инъектится в `msgs`, но при
|
||||||
|
gate ON участвует в post-check вердикте (`memory.go:225-229` — «decl edit would silently flip a
|
||||||
|
resumed chunk's disposition»). Значит `decl` — ВЕРДИКТ-сторона → в `verdictSnapshotID` (только при
|
||||||
|
gate ON, зеркаля текущее условие `gateOn` в `computeMemoryVersion:238`). При gate OFF `decl`
|
||||||
|
не влияет ни на wire, ни на вердикт → не входит никуда (dropped).
|
||||||
|
|
||||||
|
**Почему это корректно и бесплатно.** Post-check материализуется из банка (пере-материализуется
|
||||||
|
каждый прогон, `seedGlossary`→`materializeMemory`) и гоняется вживую в `translateChunk` над `prev`
|
||||||
|
независимо от того, пришёл `prev` из свежего вызова или из fast-path-резолва. Правка `decl`/порогов
|
||||||
|
→ новый банк → новый вердикт post-check → но **wire идентичен → $0**. `verdictSnapshotID` в
|
||||||
|
`chunk_status.verdict_snapshot` делает такую правку (а) видимой статусу (§9) и (б) триггером
|
||||||
|
пере-резолва per-stage fast-path (classifier/coverage тоже вердикт, но пересчитываются ТОЛЬКО в
|
||||||
|
`runAttempt`/`translateChunk`, не в `resumeFromChunkStatus` — потому `verdict_snapshot` busts
|
||||||
|
fast-path и заставляет пройти attempt-цикл, где всё пере-классифицируется бесплатно). Post-check-часть
|
||||||
|
в `verdict_snapshot` формально belt-and-suspenders (post-check и так live на chunk-level), но держит
|
||||||
|
`verdict_snapshot` полным отпечатком вердикт-конфига.
|
||||||
|
|
||||||
|
## 7. HOLE (c): замена loud-гейта согласия на pre-flight dry-run
|
||||||
|
|
||||||
|
**Что демонтируется.** Сегодня рассинхрон снапшота — fail-loud стоп (`runStage:865-869`;
|
||||||
|
`--resnapshot` = явное согласие, Р6-класс «согласие на трату»). Content-addressed resume демотирует
|
||||||
|
job-snapshot-mismatch из fail-loud в пер-чанковое `content_hash`/`guard_hash`-решение (§8, §9) — то
|
||||||
|
есть **удаляет этот гейт согласия**. Его нельзя просто выкинуть: Р6-класс согласия на трату должен
|
||||||
|
быть ЗАМЕНЁН, иначе тихая переоплата возвращается через заднюю дверь.
|
||||||
|
|
||||||
|
**Замена — pre-flight dry-run.** Перед `translate`, который сменил бы конфиг (и всегда в
|
||||||
|
онгоинг-режиме) прогоняем СУХОЙ проход (детерминированный, $0, без LLM):
|
||||||
|
|
||||||
|
1. **Проекция (источник — тот же пер-чанковый `content_hash`/`guard_hash`-диф, что и на fast-path).**
|
||||||
|
Для каждого чанка×стадии материализуем ТЕКУЩИЕ `msgs` (`MessagesWithInjection` — дёшево, только
|
||||||
|
стр__ковая подстановка) и считаем `content_hash` + `guard_hash`. Сравниваем со СТРОКОЙ
|
||||||
|
`chunk_status`. Единица «будет re-bill», если её `guard_hash` отличается от хранимого И хранимая
|
||||||
|
диспозиция была оплачена (ok/flagged). Этот диф **естественно ловит все эффекты второго порядка**
|
||||||
|
(sticky, eviction, каскад на редактор — §10), потому что он пере-материализует `msgs` per
|
||||||
|
chunk×stage, а не рассуждает о них аналитически.
|
||||||
|
2. **Стоимость.** Проецируемый $ = Σ по изменившимся единицам их хранимого `chunk_status.cost_usd`
|
||||||
|
(честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка
|
||||||
|
`ledger.EstimateUSD` над новыми `msgs`). Выдаём «**N чанков будет пере-оплачено, ~$X**».
|
||||||
|
3. **Порог + флаг.** Порог `rebill_consent_usd` (конфиг книги; дефолт, напр., `$0.50` ИЛИ ≤5% от
|
||||||
|
`ProjectedBookUSD`, что меньше). Ниже порога — **проходим автоматически** (append-термина в 3
|
||||||
|
чанках стоит центы — без трения согласия). Выше порога — **отказ без явного флага
|
||||||
|
`--accept-rebill`** (зеркалит старую семантику `--resnapshot`, но теперь content-scoped: гейт
|
||||||
|
срабатывает только на РЕАЛЬНУЮ проецируемую трату, а не на любой вердикт-only $0-change).
|
||||||
|
|
||||||
|
Это сохраняет Р6-класс согласия на трату, убирая ложные тревоги (вердикт-only смена больше не просит
|
||||||
|
согласия — она $0). `--resnapshot` как флаг остаётся синонимом-алиасом для обратной совместимости, но
|
||||||
|
его семантика — «прими проецируемый re-bill», а не «пере-пинуй всё».
|
||||||
|
|
||||||
|
## 8. Resume: легальность reuse и безопасность re-classify (F1)
|
||||||
|
|
||||||
|
Reuse чекпоинта легален ⟺ **полная wire-идентичность**: `guard_hash` совпал (⊇ `content_hash` +
|
||||||
|
`wireSnapshotID` + прямые wire-поля + деривация `maxTokens`). Это ровно то, что даёт новый
|
||||||
|
`RequestHash` на attempt-оси: совпал `guard_hash` ⇒ совпал wire ⇒ `RequestHash` попадёт в оплаченный
|
||||||
|
ответ (то же основание, что у сегодняшнего same-snapshot resume). **Вердикт-различие
|
||||||
|
(`verdict_snapshot`) НИКОГДА не блокирует reuse перевода** — только заставляет пере-классифицировать.
|
||||||
|
|
||||||
|
Машинерия re-classify уже есть: на checkpoint-hit `runAttempt` (`runner.go:1094`) зовёт
|
||||||
|
`classifyOutput` над текстом чекпоинта, не доверяя старому вердикту; post-check — `translateChunk`
|
||||||
|
над `prev`. Правки:
|
||||||
|
- **Fast-path `resumeFromChunkStatus`**: гейт §5 (`guard_hash` + `verdict_snapshot`). При смене
|
||||||
|
`verdict_snapshot` (guard совпал) — НЕ отдавать старую диспозицию: проваливаться в `runAttempt` →
|
||||||
|
checkpoint хитит по wire → re-classify бесплатно → re-upsert `chunk_status` с новыми
|
||||||
|
`verdict_snapshot`/диспозицией. Нужны колонки `chunk_status.guard_hash`, `chunk_status.verdict_snapshot`.
|
||||||
|
- **Вердикт-флип:** `flagged→ok` (ослабили гейт) → текст чекпоинта отдаётся бесплатно. `ok→flagged`
|
||||||
|
(ужесточили) → чанк флагается; если это влечёт НОВЫЙ wire-запрос (retry на `attempt+1` или
|
||||||
|
эскалация) — честно оплачивается (реально новая работа). Ретраи/эскалация уже на своих осях ключа
|
||||||
|
(`attempt`, модель эскалации).
|
||||||
|
|
||||||
|
**Ось безопасности F1 (никакого тихого расходящегося re-pay).** Reuse — precondition на ТОЧНОЕ
|
||||||
|
совпадение wire-идентичности (`guard_hash` — сильнейший гард: точные байты промпта+инъекции ПЛЮС все
|
||||||
|
прямые wire-поля и деривация maxTokens, чего v1 не покрывал). Вердикт-смена меняет только post-settle
|
||||||
|
классификацию, НИКОГДА не запрос — потому re-classify матчнутого чекпоинта не может отдать
|
||||||
|
расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей
|
||||||
|
wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён `guard_hash`.
|
||||||
|
|
||||||
|
## 9. Судьба `chunk_status.snapshot_id` и status-ридеров
|
||||||
|
|
||||||
|
- **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку;
|
||||||
|
отчётность), но **перестаёт быть гейтом корректности**. Первичный resume-гейт — `guard_hash` +
|
||||||
|
`verdict_snapshot` + `content_hash` (§5). Условие `cs.SnapshotID == snapID` (`runStage:899`)
|
||||||
|
снимается.
|
||||||
|
- **`SnapshotDrift`** (`status.go:53-55,283-284`: >1 snapshot_id среди строк) — **демотируется до
|
||||||
|
информационного advisory**, не сигнала тревоги: при content-addressed resume сосуществование строк
|
||||||
|
под разными job-снапшотами НОРМАЛЬНО (wire-идентичные чанки, резолвнутые в разное время). Больше не
|
||||||
|
«loud».
|
||||||
|
- **`ConfigDrift`** (`status.go:56-61,295-299`: текущий конфиг рендерит иной снапшот, чем хранимый) —
|
||||||
|
**заменяется/дополняется** проекцией dry-run (§7): вместо булева «снапшот отличается» показываем
|
||||||
|
**«проецируемый re-bill: N чанков, ~$X»** из того же пер-чанкового `guard_hash`-дифа. Равенство
|
||||||
|
снапшота остаётся грубой подсказкой; действенное число — пер-чанковая проекция (`currentSnapshotProjected`,
|
||||||
|
`status.go:347`, расширяется до пер-чанкового дифа).
|
||||||
|
- **Redrive drift-guard** (`status.go:462-480`, добавлен этой сессией — рефузит redrive при
|
||||||
|
`cs.SnapshotID != curSnap`): его резон-детр (drift → переоплата всей книги) **растворяется**
|
||||||
|
расщеплением — drift больше не пере-оплачивает неизменные чанки. Guard **эволюционирует**: грубое
|
||||||
|
сравнение snapshot-равенства заменяется на `guard_hash`-диф ТОЛЬКО целевых (сбрасываемых) чанков.
|
||||||
|
Redrive и так пере-гоняет цели под текущим wire — это его цель; поэтому жёсткий рефуз снимается,
|
||||||
|
заменяясь на pre-flight проекцию §7 (`--accept-rebill` при превышении порога) плюс сохранённый
|
||||||
|
инвариант «DispOK-стадии не сбрасываются» (D-лог п.5в). Итог: redrive не рушит флаг-телеметрию
|
||||||
|
громким отказом, а показывает проецируемую цену целей.
|
||||||
|
|
||||||
|
## 10. Blast-radius approved-term APPEND (со sticky + eviction + каскадом стадий)
|
||||||
|
|
||||||
|
**Первый порядок (главная история):** append approved-термина T re-bill-ит **только чанки, где T
|
||||||
|
ФАЕРИТСЯ**. T фаерится в чанке, если его нормализованный ключ встречается в `ch.Text` (для translator
|
||||||
|
`memory.go` Select) → T инъектится → инъекц-сообщение меняется → `content_hash` меняется → translator
|
||||||
|
этого чанка re-bill. Чанки, где T не фаерится → идентичная инъекция → идентичный `content_hash` → $0
|
||||||
|
(checkpoint-hit, re-classify free). Это FIRST-ORDER контракт.
|
||||||
|
|
||||||
|
**Второй порядок — три поправки (ловятся dry-run’ом §7 автоматически, т.к. он диффит `content_hash`
|
||||||
|
per chunk×stage, а не рассуждает аналитически):**
|
||||||
|
|
||||||
|
1. **Sticky-кэрри (`stickyDepth = 2`, `memory.go:70-73`).** T, фаернувший в чанке K, попадает в
|
||||||
|
`activeIDs[K]` → `unionSticky` (`runner.go:577-588`) несёт его в K+1 и K+2 (в пределах ГЛАВЫ;
|
||||||
|
сброс на границе главы, `runner.go:549-552`). В K+1/K+2 T инъектится через sticky, даже если его
|
||||||
|
ключ там не встречается → их translator-инъекция меняется → `content_hash` → **K+1, K+2 тоже
|
||||||
|
re-bill**. Радиус = {фаерящие чанки} ∪ {их следующие 2 внутри главы}.
|
||||||
|
2. **F2 eviction (`memory.go` бюджет `glossary_token_budget`).** Инъекция бюджет-лимитирована. В
|
||||||
|
бюджет-ПОЛНОМ чанке добавление T ВЫТЕСНЯЕТ более низкоприоритетную ранее-инъектнутую строку L. Ключевой
|
||||||
|
второпорядковый случай: T, принесённый в K+1 через sticky (где T НЕ фаерится), потребляет бюджет и
|
||||||
|
вытесняет строку L' — **меняя `msgs` K+1 сверх просто +T, хотя сам T там не фаерится**. Eviction
|
||||||
|
НЕ расширяет множество чанков за пределы {фаерящие ∪ sticky}, но увеличивает дельту внутри них
|
||||||
|
(не «+T», а «+T−L»).
|
||||||
|
3. **Каскад на редактор.** Инъектируемый констрейнт-блок редактора — ПОДМНОЖЕСТВО инъекции черновика
|
||||||
|
(`editorInjection = renderEditorConstraintBlock(memSel.injected)`, `runner.go:717-719` — тот же
|
||||||
|
`memSel`). Значит: (i) T-подтверждённый попадает и в констрейнт-блок редактора → `msgs` редактора
|
||||||
|
меняются; (ii) черновик translator’а меняется (T применён) → вход `{{draft}}` редактора меняется
|
||||||
|
→ `content_hash` редактора меняется. **Любой re-billed translator-чанк каскадит на свою editor-стадию.**
|
||||||
|
Первопорядковое «re-bill фаерящих чанков» надо читать как «re-bill фаерящих чанков × ОБЕ стадии».
|
||||||
|
|
||||||
|
**Итоговый радиус:** `{фаерящие T} ∪ {sticky +2/глава}` × `{translator, editor}`; всё прочее — $0
|
||||||
|
(checkpoint-hit → re-classify free). Онгоинг-выигрыш D15: подтверждение пачки терминов на границе
|
||||||
|
тома не пере-покупает готовые главы — только реально затронутые чанки×стадии, а pre-flight dry-run
|
||||||
|
(§7) показывает их число и цену ДО траты.
|
||||||
|
|
||||||
|
## 11. Sequencing миграции + одноразовый re-pin
|
||||||
|
|
||||||
|
Два изменения, оба ломающие ключ/схему:
|
||||||
|
- **Формула `RequestHash`**: `snapshotID`→`wireSnapshotID`, бамп `tm-request-v2`→`tm-request-v3`
|
||||||
|
(`render.go:212`). Это смена ФОРМАТА ключа → **все существующие чекпоинты промахнутся ОДИН раз**.
|
||||||
|
- **Схема**: миграция **v8** (текущий максимум — **v7** после cheap-gate миграции этой сессии:
|
||||||
|
`migrate.go` v7 = `ALTER TABLE retrieval_state ADD COLUMN n_style_flags/style_detail`):
|
||||||
|
`ALTER TABLE chunk_status ADD COLUMN guard_hash TEXT NOT NULL DEFAULT '';`
|
||||||
|
`ALTER TABLE chunk_status ADD COLUMN verdict_snapshot TEXT NOT NULL DEFAULT '';`
|
||||||
|
(`snapshot_id`, `content_hash` — сохраняются). Существующие строки получают пустой `guard_hash` →
|
||||||
|
fast-path у них всегда busts → падают в attempt-цикл, где `RequestHash` под v3 не совпадёт с v2 →
|
||||||
|
свежий вызов (тот самый одноразовый re-pin). Строки самозалечиваются с новыми хешами при первом
|
||||||
|
прогоне.
|
||||||
|
|
||||||
|
**Ограничение порядка (жёсткое).** Миграция+бамп ДОЛЖНЫ приземлиться **ДО первой онгоинг-книги
|
||||||
|
(`add-chapters`)**. Иначе: если книга уже несёт v2-чекпоинты, а миграция приходит В СЕРЕДИНЕ её
|
||||||
|
жизни, первый `add-chapters` после миграции **пере-оплатит ВЕСЬ бэклог** (все прежние главы
|
||||||
|
пере-пинятся под v3) — ровно та D15-мина, которую спека снимает. Поэтому:
|
||||||
|
- Статичную приёмочную книгу Ф1 (D15.1 / D18 蛊真人) либо завершаем под v2, либо принимаем её
|
||||||
|
**одноразовый re-pin** (громкий, расхождений нет — D15.1) на границе Ф1.5.
|
||||||
|
- **Первая онгоинг-книга РОЖДАЕТСЯ под `tm-request-v3`** (начальные главы кейятся v3), чтобы
|
||||||
|
последующие `add-chapters` находили v3-ключи и не пере-пинали бэклог.
|
||||||
|
- **Одноразовый re-pin осознан и задокументирован** (как v1→v2 сейчас): ни одна онгоинг-книга не
|
||||||
|
должна пересекать миграцию в середине жизненного цикла.
|
||||||
|
|
||||||
|
## 12. Минимальный план реализации (Ф1.5, после ратификации v2)
|
||||||
|
|
||||||
|
1. **Расщепить `snapshotID()`** → `wireSnapshotID(stage)` (пер-стадийный, §3.1) + `verdictSnapshotID()`
|
||||||
|
(book-global, §3.2). Полный `snapshotID` = их хеш (advisory).
|
||||||
|
2. **`RequestHash`**: `snapshotID`→`wireSnapshotID`, бамп `tm-request-v3` (§3.4).
|
||||||
|
3. **`guard_hash`** (§3.3): функция `guardHash(content_hash, wireSnapshotID, model, temp, reasoning,
|
||||||
|
jsonOnly, estimator/ratio/floor/policy)`, префикс `tm-guard-v1`.
|
||||||
|
4. **Расщепить `memoryMatchVersion`/`computeMemoryVersion` (ОБЯЗАТЕЛЬНО, §6)** на `memoryInjectVersion`
|
||||||
|
(→ `msgs`, из ключа убрать) и `memoryPostcheckVersion` (+`gate`+`decl`-при-gateON → `verdictSnapshotID`).
|
||||||
|
Снять двойной фолд `postcheck_gate`.
|
||||||
|
5. **Миграция v8** (§11): колонки `guard_hash`, `verdict_snapshot`; писать/читать в
|
||||||
|
`chunkstatus.go` (Upsert/Get/ForBook). `snapshot_id` оставить (advisory).
|
||||||
|
6. **Fast-path (`runStage`)**: гейт `cs.guard_hash == guardHash(тек.) && cs.verdict_snapshot ==
|
||||||
|
verdictSnapshotID(тек.) && disposition != skipped`; иначе `runAttempt` (re-classify бесплатно, §8).
|
||||||
|
7. **Job-snapshot-mismatch**: демотировать fail-loud стоп (`runStage:865-869`) в пер-чанковое
|
||||||
|
`guard_hash`-решение.
|
||||||
|
8. **Pre-flight dry-run (§7)**: пер-чанковый `guard_hash`-диф → «N чанков, ~$X»; порог
|
||||||
|
`rebill_consent_usd` + флаг `--accept-rebill`. `tmctl status`: заменить булев `ConfigDrift` на
|
||||||
|
проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф целей + проекция (§9).
|
||||||
|
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl) → $0 re-classify,
|
||||||
|
вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 + editor-каскад,
|
||||||
|
остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/capability/
|
||||||
|
estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать temp) →
|
||||||
|
тест ловит stale-serve при temp 0.3→0.4; (д) dry-run порог: ниже — авто, выше — отказ без флага;
|
||||||
|
(е) миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`.
|
||||||
|
|
||||||
|
## 13. Открытые вопросы оркестратору (решения, гейтящие ратификацию v2)
|
||||||
|
|
||||||
|
1. **Пер-стадийная гранулярность `wireSnapshotID`/`guard_hash`** — это v2-уточнение над book-global
|
||||||
|
`snapshotID` v1: правка `capability`/`model_extra` РЕДАКТОРА больше не пере-оплачивает ЧЕРНОВИК
|
||||||
|
(§3.1). Выигрыш реальный, но добавляет поверхность реализации (хеш на стадию, а не на книгу).
|
||||||
|
**Подтвердить: берём пер-стадийную гранулярность или держим book-global** (проще, но грубее)?
|
||||||
|
2. **Дефолт порога dry-run** `rebill_consent_usd` (§7) и **имя флага**. Предложение: порог =
|
||||||
|
`min($0.50, 5% × ProjectedBookUSD)`; флаг `--accept-rebill` (отдельный от `--resnapshot`, который
|
||||||
|
остаётся алиасом). **Нужно решение владельца/оркестратора** — это Р6-класс согласия на трату.
|
||||||
|
3. **Caveat `Adult`** (`book.go`): дроп `brief_hash` опирается на `content_hash`, но булев `Adult`
|
||||||
|
НЕ рендерится ни в один шаблон → wire-нейтрален by construction (§4, строка `brief_hash`). Если
|
||||||
|
`Adult` когда-либо должен инвалидировать работу (напр. смена канала/цензуры) — его надо ЯВНО
|
||||||
|
вернуть в `wireSnapshotID` или `verdictSnapshotID`. **Подтвердить, что `Adult` остаётся
|
||||||
|
wire/verdict-нейтральным**, либо назначить ему слой.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Резюме для оркестратора:** дефект — `snapshotID` в ключе вызова смешивает wire- и вердикт-входы.
|
**Резюме для оркестратора.** Дефект — `snapshotID` в ключе вызова смешивает wire-, вердикт- и
|
||||||
Фикс — расщепить; ключ чекпоинта держать на wire-идентичности (по факту это `msgsContentHash` +
|
избыточные входы. Фикс v2: три хеша на трёх слоях — `wireSnapshotID` (пер-стадийный, в `RequestHash`),
|
||||||
не-msgs wire-параметры), вердикт-версии убрать из ключа и re-classify-ить на resume бесплатно.
|
`verdictSnapshotID` (в `chunk_status`, не в ключе), `guard_hash` (пер-стадийный суперсет wire-плана,
|
||||||
Безопасность (F1) сохраняется: reuse — на точном wire-match, вердикт-смена доказуемо wire-нейтральна.
|
гейт fast-path). Три дыры закрыты: **(a)** `guard_hash` покрывает прямые wire-поля, которых fast-path
|
||||||
Онгоинг-мина D15 снимается: append-термин re-bill-ит только реально затронутые чанки. Реализация —
|
v1 терял (temp/reasoning/maxTokens/escalate) → нет ре-открытия stale-serve; **(b)** `memory_version`
|
||||||
малая и хирургическая (машинерия re-classify уже есть), но гейтит онгоинг Ф1.5 и требует ратификации.
|
расщеплён ОБЯЗАТЕЛЬНО — inject-часть уходит в `content_hash` (из ключа вон), post-check+gate+decl → в
|
||||||
|
`verdictSnapshotID` (пересчёт бесплатен); **(c)** loud-гейт согласия заменён pre-flight dry-run «N
|
||||||
|
чанков, ~$X» с порогом `--accept-rebill` (Р6 сохранён). Безопасность F1 усилена (guard_hash — сильнее
|
||||||
|
прежнего гарда). Онгоинг-мина D15 снята: append re-bill-ит только реально затронутые чанки×стадии.
|
||||||
|
Реализация мала и хирургична, но **остаётся ЗАБЛОКИРОВАННОЙ до ратификации v2**.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -34,12 +35,24 @@ type Book struct {
|
||||||
Honorifics string `yaml:"honorifics"` // keep | adapt
|
Honorifics string `yaml:"honorifics"` // keep | adapt
|
||||||
Transcription string `yaml:"transcription"` // e.g. polivanov | palladius
|
Transcription string `yaml:"transcription"` // e.g. polivanov | palladius
|
||||||
Footnotes string `yaml:"footnotes"` // none | minimal | rich
|
Footnotes string `yaml:"footnotes"` // none | minimal | rich
|
||||||
|
// YoPolicy is the brief's ё-policy for the cheap yofikator gate: auto (flag only inconsistency
|
||||||
|
// — the same word both with ё and е) | all-yo | all-e. "" defaults to auto. A brief field: it
|
||||||
|
// changes the style-flag verdict, so it is folded into BriefHash (a change re-pins the snapshot).
|
||||||
|
YoPolicy string `yaml:"yo_policy"`
|
||||||
|
// StyleAllowlist exempts surfaces from the translit-interjection blocklist (a per-project
|
||||||
|
// allowlist, 04-unhappy §6): a character actually named «Ара» is not an untranslated filler.
|
||||||
|
// Lower-cased on load. Verdict-affecting → also folded into BriefHash.
|
||||||
|
StyleAllowlist []string `yaml:"style_allowlist"`
|
||||||
|
|
||||||
// Wiring: paths are resolved relative to the book.yaml location.
|
// Wiring: paths are resolved relative to the book.yaml location.
|
||||||
Pipeline string `yaml:"pipeline"`
|
Pipeline string `yaml:"pipeline"`
|
||||||
ModelsFile string `yaml:"models"`
|
ModelsFile string `yaml:"models"`
|
||||||
SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк
|
SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк
|
||||||
ProjectDB string `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
|
// Encoding of the txt source: auto|utf8|gb18030 ("" defaults to auto). Real zh .txt are often
|
||||||
|
// GB18030 (the 蛊真人 acceptance book is), which the auto-detect handles; declare it explicitly
|
||||||
|
// to skip detection. Ignored for epub (its documents carry their own charset). See ingest.go.
|
||||||
|
Encoding string `yaml:"encoding"`
|
||||||
|
ProjectDB string `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
|
||||||
// GlossarySeed is the optional path to the manual glossary seed YAML (memory v2,
|
// GlossarySeed is the optional path to the manual glossary seed YAML (memory v2,
|
||||||
// шаг 4). Its curated approved/draft terms + the classified ruby readings are the
|
// шаг 4). Its curated approved/draft terms + the classified ruby readings are the
|
||||||
// deterministic inputs the glossary is REPLACED from each run; editing it changes the
|
// deterministic inputs the glossary is REPLACED from each run; editing it changes the
|
||||||
|
|
@ -110,6 +123,29 @@ func LoadBook(path string) (*Book, error) {
|
||||||
bad("glossary_seed %s is not readable: %v", b.GlossarySeed, err)
|
bad("glossary_seed %s is not readable: %v", b.GlossarySeed, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if b.Encoding == "" {
|
||||||
|
b.Encoding = "auto"
|
||||||
|
}
|
||||||
|
switch strings.ToLower(b.Encoding) {
|
||||||
|
case "auto", "utf8", "utf-8", "gb18030", "gbk", "gb2312":
|
||||||
|
default:
|
||||||
|
bad("encoding %q is not supported (use auto|utf8|gb18030)", b.Encoding)
|
||||||
|
}
|
||||||
|
if b.YoPolicy == "" {
|
||||||
|
b.YoPolicy = "auto"
|
||||||
|
}
|
||||||
|
switch b.YoPolicy {
|
||||||
|
case "auto", "all-yo", "all-e":
|
||||||
|
default:
|
||||||
|
bad("yo_policy %q is not supported (use auto|all-yo|all-e)", b.YoPolicy)
|
||||||
|
}
|
||||||
|
for i, s := range b.StyleAllowlist {
|
||||||
|
b.StyleAllowlist[i] = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
// Canonicalize (sort) the allowlist so a cosmetic reorder in book.yaml does not change BriefHash
|
||||||
|
// → the snapshot → force an unnecessary re-pin (self-review: it is a set, consumed order-
|
||||||
|
// independently in cheapGateConfig).
|
||||||
|
sort.Strings(b.StyleAllowlist)
|
||||||
if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 {
|
if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 {
|
||||||
bad("ceilings: at least one of book_usd/day_usd must be set (ledger без потолка запрещён Р7)")
|
bad("ceilings: at least one of book_usd/day_usd must be set (ledger без потолка запрещён Р7)")
|
||||||
}
|
}
|
||||||
|
|
@ -130,18 +166,20 @@ func (b *Book) BriefHash() string {
|
||||||
// Без него правка заголовка молча инвалидировала бы чекпоинты в обход
|
// Без него правка заголовка молча инвалидировала бы чекпоинты в обход
|
||||||
// snapshot-гейта (находка ревью).
|
// snapshot-гейта (находка ревью).
|
||||||
canon := struct {
|
canon := struct {
|
||||||
BookID string `json:"book_id"`
|
BookID string `json:"book_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
SourceLang string `json:"source_lang"`
|
SourceLang string `json:"source_lang"`
|
||||||
TargetLang string `json:"target_lang"`
|
TargetLang string `json:"target_lang"`
|
||||||
Genre string `json:"genre"`
|
Genre string `json:"genre"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
Adult bool `json:"adult"`
|
Adult bool `json:"adult"`
|
||||||
Venuti float64 `json:"venuti"`
|
Venuti float64 `json:"venuti"`
|
||||||
Honorifics string `json:"honorifics"`
|
Honorifics string `json:"honorifics"`
|
||||||
Transcription string `json:"transcription"`
|
Transcription string `json:"transcription"`
|
||||||
Footnotes string `json:"footnotes"`
|
Footnotes string `json:"footnotes"`
|
||||||
}{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes}
|
YoPolicy string `json:"yo_policy"`
|
||||||
|
StyleAllowlist []string `json:"style_allowlist"`
|
||||||
|
}{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes, b.YoPolicy, b.StyleAllowlist}
|
||||||
data, err := json.Marshal(canon)
|
data, err := json.Marshal(canon)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A struct of scalars cannot fail to marshal; keep the signature clean.
|
// A struct of scalars cannot fail to marshal; keep the signature clean.
|
||||||
|
|
|
||||||
546
backend/internal/pipeline/cheapgates.go
Normal file
546
backend/internal/pipeline/cheapgates.go
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cheapgates.go: four cheap, deterministic post-check flaggers on the FINAL chunk text (04-unhappy
|
||||||
|
// §6/§9, план v3). They are OBSERVABILITY, never hard gates (like the glossary post-check's default
|
||||||
|
// flagger mode): a hit is recorded in the retrieval-state and surfaced in the report / chapter
|
||||||
|
// passport, but it never changes a chunk disposition or costs an LLM call. All four are pure and
|
||||||
|
// deterministic (no time/rand, sorted detail) so a resume re-derives identical counts.
|
||||||
|
//
|
||||||
|
// 1. dialogue-dash linter (Rosenthal §47–52): direct speech marked with straight quotes or a
|
||||||
|
// hyphen/en-dash instead of the em-dash on a new line, or a chunk mixing the two styles.
|
||||||
|
// 2. yofikator: inconsistent ё — the SAME word spelled both with ё and with е (Пётр/Петр), or a
|
||||||
|
// brief ё-policy (all-ё / all-е) violated.
|
||||||
|
// 3. translit-interjection blocklist: untranslated JP/EN fillers («ара-ара», «маа», «хмф»,
|
||||||
|
// «нани», «ауч», «упс») left in the Russian output (04-unhappy §9); per-project allowlist.
|
||||||
|
// 4. 万/億 magnitude gate: a CJK myriad/hundred-million magnitude in the source whose order is not
|
||||||
|
// represented on the Russian side (三万 → «три миллиона» is a 100× error, 04-unhappy §9).
|
||||||
|
//
|
||||||
|
// FALSE POSITIVES are the main risk (quotations, stylization, homographs), so each rule is tuned
|
||||||
|
// for PRECISION over recall: it fires only on a high-confidence signal and stays silent on the
|
||||||
|
// ambiguous middle. The unit tests assert both firing AND non-firing.
|
||||||
|
|
||||||
|
// cheapGateVersion versions the rules above. It is folded into the job snapshot (like
|
||||||
|
// classifierVersion): editing any rule is a loud --resnapshot, so the reported style-flag counts
|
||||||
|
// never shift silently between runs under the same snapshot. [D15.2 note: this is a VERDICT
|
||||||
|
// version — once content-addressed resume lands it moves to verdictSnapshotID and a rule edit
|
||||||
|
// re-classifies free instead of re-paying the book.]
|
||||||
|
const cheapGateVersion = "cheapgate-v1"
|
||||||
|
|
||||||
|
// cheapGateConfig carries the brief-derived knobs: the ё-policy and the per-project allowlist of
|
||||||
|
// surfaces that look like a blocklisted interjection but are legitimate here (e.g. a character
|
||||||
|
// named «Ара»). Both come from book.yaml.
|
||||||
|
type cheapGateConfig struct {
|
||||||
|
yoPolicy string // "auto" (inconsistency only) | "all-yo" | "all-e"
|
||||||
|
allowlist map[string]bool // lower-cased surfaces exempt from the interjection blocklist
|
||||||
|
}
|
||||||
|
|
||||||
|
// cheapGateResult is the per-chunk outcome: a count per flagger plus human-readable detail lines
|
||||||
|
// (deterministic order) for the report. total() is what the passport surfaces.
|
||||||
|
type cheapGateResult struct {
|
||||||
|
DialogueDash int `json:"dialogue_dash,omitempty"`
|
||||||
|
YoInconsistent int `json:"yo,omitempty"`
|
||||||
|
TranslitInterj int `json:"translit_interj,omitempty"`
|
||||||
|
NumberMagnitude int `json:"number_magnitude,omitempty"`
|
||||||
|
Detail []string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cheapGateResult) total() int {
|
||||||
|
return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCheapGates runs all four flaggers over one chunk's source and FINAL translated text.
|
||||||
|
func runCheapGates(source, final string, cfg cheapGateConfig) cheapGateResult {
|
||||||
|
var r cheapGateResult
|
||||||
|
n, det := lintDialogueDash(final)
|
||||||
|
r.DialogueDash, r.Detail = n, append(r.Detail, det...)
|
||||||
|
n, det = lintYofikation(final, cfg.yoPolicy)
|
||||||
|
r.YoInconsistent = n
|
||||||
|
r.Detail = append(r.Detail, det...)
|
||||||
|
n, det = lintTranslitInterjections(final, cfg.allowlist)
|
||||||
|
r.TranslitInterj = n
|
||||||
|
r.Detail = append(r.Detail, det...)
|
||||||
|
n, det = lintNumberMagnitude(source, final)
|
||||||
|
r.NumberMagnitude = n
|
||||||
|
r.Detail = append(r.Detail, det...)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. dialogue-dash linter ----------------------------------------------------
|
||||||
|
|
||||||
|
// lintDialogueDash flags direct-speech lines opened with the wrong marker. Russian direct speech
|
||||||
|
// takes an EM-dash «—» at the line start; MTL output leaves a straight ASCII quote or a plain
|
||||||
|
// hyphen/en-dash. It fires per offending line, and additionally when a chunk MIXES em-dash speech
|
||||||
|
// with chevron-quote «…» speech (a within-chapter style clash). Precision guards: a hyphen only
|
||||||
|
// counts as a mis-set dash when followed by a space and a letter (so a hyphenated word wrap or a
|
||||||
|
// «- 1» list item does not fire); chevron lines count only under mixing (a chunk that uses «…» for
|
||||||
|
// speech throughout may be a deliberate style, but mixing it with dashes is an inconsistency).
|
||||||
|
func lintDialogueDash(text string) (int, []string) {
|
||||||
|
var emDash, hyphenLike, chevronLead int
|
||||||
|
var detail []string
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
t := strings.TrimLeft(line, " \t ")
|
||||||
|
rs := []rune(t)
|
||||||
|
if len(rs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch rs[0] {
|
||||||
|
case '—': // em-dash: the correct Russian dialogue marker — count only true speech shape
|
||||||
|
if dialogueShape(rs[1:]) { // «— 15 минут спустя» (dash+space+digit, a scene break) is NOT dialogue (self-review)
|
||||||
|
emDash++
|
||||||
|
}
|
||||||
|
case '"': // straight ASCII quote leading a line → Russian typography never uses these → MTL artifact
|
||||||
|
if quoteShape(rs[1:]) {
|
||||||
|
detail = append(detail, "прямая речь открыта прямой кавычкой \" вместо тире: "+preview(t))
|
||||||
|
hyphenLike++ // counted in the offending total
|
||||||
|
}
|
||||||
|
case '-', '–': // hyphen / en-dash where an em-dash belongs
|
||||||
|
if dialogueShape(rs[1:]) {
|
||||||
|
detail = append(detail, "реплика через дефис/короткое тире вместо длинного «—»: "+preview(t))
|
||||||
|
hyphenLike++
|
||||||
|
}
|
||||||
|
case '«': // chevron-led line: only an issue if the chunk ALSO uses em-dash speech (mixing)
|
||||||
|
chevronLead++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := hyphenLike
|
||||||
|
if emDash > 0 && chevronLead > 0 {
|
||||||
|
n += chevronLead
|
||||||
|
detail = append(detail, fmt.Sprintf("смешение стилей прямой речи в чанке: %d реплик через «—» и %d через «…»", emDash, chevronLead))
|
||||||
|
}
|
||||||
|
return n, detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogueShape reports whether the runes after a leading marker look like spoken text: an optional
|
||||||
|
// space then a letter. It filters out non-dialogue leading dashes (word wraps, «-1», bare marks).
|
||||||
|
func dialogueShape(after []rune) bool {
|
||||||
|
i := 0
|
||||||
|
for i < len(after) && (after[i] == ' ' || after[i] == ' ') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return false // a marker glued to the next glyph (a hyphenated fragment), not «— реплика»
|
||||||
|
}
|
||||||
|
return i < len(after) && unicode.IsLetter(after[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteShape reports whether the runes after a leading quote look like spoken text: an OPTIONAL
|
||||||
|
// space then a letter («"Привет»). Unlike a dash, a quote sits directly on the first word, so no
|
||||||
|
// space is required.
|
||||||
|
func quoteShape(after []rune) bool {
|
||||||
|
i := 0
|
||||||
|
for i < len(after) && (after[i] == ' ' || after[i] == ' ') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i < len(after) && unicode.IsLetter(after[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. yofikator ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// yoHomographEForms are е-spellings that are DISTINCT words from their ё-counterpart (все≠всё,
|
||||||
|
// небо≠нёбо, берет≠берёт, осел≠осёл, …). The inconsistency rule below would otherwise false-flag
|
||||||
|
// «все»+«всё» as one word spelled two ways. Expanded (self-review major) to cover the frequent
|
||||||
|
// distinct-word pairs and their common inflections; still not exhaustive — full disambiguation
|
||||||
|
// needs a ё-dictionary (deferred, B-tier). Names — the primary target (Пётр/Петр) — are never
|
||||||
|
// homographs, so they are always caught regardless.
|
||||||
|
var yoHomographEForms = map[string]bool{
|
||||||
|
"все": true, "всех": true, "всем": true, "всеми":true,
|
||||||
|
"небо": true, "небом": true,
|
||||||
|
"узнаем": true, "узнаете": true, "узнает": true,
|
||||||
|
"падеж": true, "падежа": true,
|
||||||
|
"совершенный": true, "совершенное": true, "совершенная": true, "совершенно": true, "совершенны": true,
|
||||||
|
"чем": true, "тем": true, "тема": true, "теме": true, "темы": true,
|
||||||
|
"берет": true, "берете": true, "берета": true,
|
||||||
|
"осел": true, "осла": true, "ослы": true, "ослов": true,
|
||||||
|
"мел": true, "мела": true,
|
||||||
|
"лен": true, "лена": true,
|
||||||
|
"нес": true, "несем": true, "несет": true,
|
||||||
|
"вел": true, "ведет": true, "ведем": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// lintYofikation flags inconsistent ё. Default ("auto"): the same word appears BOTH with ё and, as
|
||||||
|
// a separate token, with its exact ё→е form (Пётр/Петр) — excluding the homograph traps above.
|
||||||
|
// Policy "all-e": any ё present is a violation (the project wants no ё). Policy "all-yo": an е-form
|
||||||
|
// of a word that ALSO appears somewhere with ё is flagged (the partial-ёфикация case), same signal
|
||||||
|
// as auto — full "every word that SHOULD have ё" enforcement needs a ё-dictionary (deferred, B-tier).
|
||||||
|
func lintYofikation(text, policy string) (int, []string) {
|
||||||
|
words := tokenizeCyrillic(text)
|
||||||
|
if policy == "all-e" {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var det []string
|
||||||
|
n := 0
|
||||||
|
for _, w := range words {
|
||||||
|
if strings.ContainsRune(w, 'ё') && !seen[w] {
|
||||||
|
seen[w] = true
|
||||||
|
n++
|
||||||
|
det = append(det, "ё при политике all-e: "+w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(det)
|
||||||
|
return n, det
|
||||||
|
}
|
||||||
|
// auto / all-yo: detect a word present in BOTH its ё-form and its е-form.
|
||||||
|
present := map[string]bool{}
|
||||||
|
for _, w := range words {
|
||||||
|
present[w] = true
|
||||||
|
}
|
||||||
|
seenPair := map[string]bool{}
|
||||||
|
var det []string
|
||||||
|
n := 0
|
||||||
|
for _, w := range words {
|
||||||
|
if !strings.ContainsRune(w, 'ё') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eForm := strings.ReplaceAll(w, "ё", "е")
|
||||||
|
if eForm == w || yoHomographEForms[eForm] {
|
||||||
|
continue // no ё, or a distinct-word homograph (все/всё) — not an inconsistency
|
||||||
|
}
|
||||||
|
if present[eForm] && !seenPair[w] {
|
||||||
|
seenPair[w] = true
|
||||||
|
n++
|
||||||
|
det = append(det, fmt.Sprintf("непоследовательная ё: %q и %q", w, eForm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(det)
|
||||||
|
return n, det
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenizeCyrillic lower-cases and splits text into Cyrillic word tokens (ё kept distinct from е).
|
||||||
|
func tokenizeCyrillic(text string) []string {
|
||||||
|
var words []string
|
||||||
|
var b strings.Builder
|
||||||
|
flush := func() {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
words = append(words, b.String())
|
||||||
|
b.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range strings.ToLower(text) {
|
||||||
|
if unicode.Is(unicode.Cyrillic, r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. translit-interjection blocklist -----------------------------------------
|
||||||
|
|
||||||
|
// translitInterjections is the starter blocklist (04-unhappy §9): JP/EN fillers that must be
|
||||||
|
// translated/adapted, not transliterated. Only surfaces that are NOT ordinary Russian words are
|
||||||
|
// listed (self-review majors): «ара» (a macaw, also a name) and «уму» (dative of «ум») were removed
|
||||||
|
// as high-false-positive collisions; «ара-ара» (the reduplicated JP filler) stays. A residual name
|
||||||
|
// collision is handled by the per-project allowlist. Lower-cased.
|
||||||
|
var translitInterjections = []string{
|
||||||
|
"ара-ара", "маа", "хмф", "нани", "ауч", "упс", "кья", "десу",
|
||||||
|
}
|
||||||
|
|
||||||
|
// lintTranslitInterjections counts whole-word (letter-bounded, case-insensitive) occurrences of a
|
||||||
|
// blocklisted interjection in the output, minus any surface on the per-project allowlist. Whole-word
|
||||||
|
// matching keeps «ара» from firing inside «характер»; the allowlist exempts legitimate uses.
|
||||||
|
func lintTranslitInterjections(text string, allowlist map[string]bool) (int, []string) {
|
||||||
|
low := []rune(strings.ToLower(text))
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, interj := range translitInterjections {
|
||||||
|
if allowlist[interj] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := []rune(interj)
|
||||||
|
for i := 0; i+len(f) <= len(low); i++ {
|
||||||
|
if !runesEqual(low[i:i+len(f)], f) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (i == 0 || !isWordRune(low[i-1])) && (i+len(f) == len(low) || !isWordRune(low[i+len(f)])) {
|
||||||
|
counts[interj]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
surfaces := make([]string, 0, len(counts))
|
||||||
|
for k, c := range counts {
|
||||||
|
n += c
|
||||||
|
surfaces = append(surfaces, k)
|
||||||
|
}
|
||||||
|
sort.Strings(surfaces)
|
||||||
|
var det []string
|
||||||
|
for _, s := range surfaces {
|
||||||
|
det = append(det, fmt.Sprintf("непереведённое междометие %q ×%d", s, counts[s]))
|
||||||
|
}
|
||||||
|
return n, det
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWordRune reports whether r is part of a word for boundary checks (letter or a hyphen inside a
|
||||||
|
// compound interjection like «ара-ара»). The hyphen inclusion means «ара-ара» matches as one unit
|
||||||
|
// and its inner «ара» does not double-count against a hyphen boundary.
|
||||||
|
func isWordRune(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || r == '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. 万/億 magnitude gate -----------------------------------------------------
|
||||||
|
|
||||||
|
// lintNumberMagnitude flags a source CJK myriad/hundred-million magnitude whose ORDER of magnitude
|
||||||
|
// is not represented on the Russian side (04-unhappy §9: 三万 → «три миллиона» is a 100× error). It
|
||||||
|
// parses each CJK numeral RUN that carries a big marker (万/萬/億/亿/兆) into an order of magnitude,
|
||||||
|
// then checks the output's magnitude coverage (Russian magnitude words expanded to a [base,base+2]
|
||||||
|
// range for their possible multiplier, plus Arabic-number orders). It fires only when the source's
|
||||||
|
// TOP order is outside every output range — a conservative, multiplier-tolerant signal that leaves
|
||||||
|
// 三億→«триста миллионов» (8 within миллион's [6,8]) silent while catching 三万→«три миллиона».
|
||||||
|
func lintNumberMagnitude(source, final string) (int, []string) {
|
||||||
|
srcOrders := cjkMagnitudeOrders(source)
|
||||||
|
if len(srcOrders) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
maxSrc := 0
|
||||||
|
for _, o := range srcOrders {
|
||||||
|
if o > maxSrc {
|
||||||
|
maxSrc = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxSrc < 4 { // only gate on 万+ magnitudes (the 万/億 concern)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ranges := outputMagnitudeRanges(final)
|
||||||
|
if len(ranges) == 0 {
|
||||||
|
return 0, nil // no numeric magnitude in the output to compare against — silent (could be rephrased prose)
|
||||||
|
}
|
||||||
|
for _, rg := range ranges {
|
||||||
|
if maxSrc >= rg[0] && maxSrc <= rg[1] {
|
||||||
|
return 0, nil // the top source magnitude IS represented — no mismatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1, []string{fmt.Sprintf("разряд источника 10^%d (万/億) не отражён в порядках величин перевода — возможна ошибка разряда (напр. 三万→«три миллиона»)", maxSrc)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cjkNumeralRunes are the characters that can form a CJK numeral expression (digits, small units,
|
||||||
|
// big markers). A maximal run of these is one candidate number.
|
||||||
|
var cjkNumeralRunes = map[rune]bool{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for _, r := range "0123456789〇零一二三四五六七八九十百千两兩万萬億亿兆" {
|
||||||
|
cjkNumeralRunes[r] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cjkMagnitudeOrders returns the base-10 order of every CJK numeral run in text that contains a big
|
||||||
|
// marker (万/億/兆). Runs without a big marker are ignored (the gate is about myriad-scale разряды).
|
||||||
|
func cjkMagnitudeOrders(text string) []int {
|
||||||
|
var orders []int
|
||||||
|
rs := []rune(text)
|
||||||
|
for i := 0; i < len(rs); {
|
||||||
|
if !cjkNumeralRunes[rs[i]] {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := i
|
||||||
|
for j < len(rs) && cjkNumeralRunes[rs[j]] {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
run := string(rs[i:j])
|
||||||
|
// A run counts only when it carries a big marker AND has an explicit DIGIT coefficient before
|
||||||
|
// it (self-review major): this excludes the common web-novel IDIOMS that are not magnitudes —
|
||||||
|
// 万一 (in case), 万分 (extremely), 万物 (all things), 千万 (by all means), 亿万 (myriads) — where 万/億
|
||||||
|
// is not preceded by a digit. It also drops bare-unit magnitudes (十万/百万) — an accepted recall
|
||||||
|
// trade for not false-flagging the far more frequent idioms.
|
||||||
|
if strings.ContainsAny(run, "万萬億亿兆") && hasDigitBeforeBigMarker(run) {
|
||||||
|
if v, ok := parseCJKNumber(run); ok && v > 0 {
|
||||||
|
orders = append(orders, orderOf(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return orders
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasDigitBeforeBigMarker reports whether a digit (一-九 / 两 / 0-9) appears before the FIRST big
|
||||||
|
// marker (万/億/兆) in the run — the signature of a real magnitude expression (三万) vs an idiom (万一).
|
||||||
|
func hasDigitBeforeBigMarker(run string) bool {
|
||||||
|
for _, r := range run {
|
||||||
|
if strings.ContainsRune("万萬億亿兆", r) {
|
||||||
|
return false // hit a big marker with no digit before it → idiom / bare unit
|
||||||
|
}
|
||||||
|
if (r >= '0' && r <= '9') || strings.ContainsRune("一二三四五六七八九两兩", r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCJKNumber parses a CJK numeral expression (mixed with Arabic digits) into its integer value.
|
||||||
|
// Standard section algorithm: small units (十百千) scale the pending coefficient into the current
|
||||||
|
// <10^4 section; big units (万億兆) flush the section times the big unit into the total. Returns
|
||||||
|
// ok=false on a shape it cannot parse (conservative — an unparseable run does not flag).
|
||||||
|
func parseCJKNumber(s string) (int64, bool) {
|
||||||
|
var total, section, cur int64
|
||||||
|
sawBig := false
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
cur = cur*10 + int64(r-'0')
|
||||||
|
case r == '〇' || r == '零':
|
||||||
|
cur = cur * 10
|
||||||
|
default:
|
||||||
|
if d, ok := cjkDigit(r); ok {
|
||||||
|
cur = cur*10 + d // positional accumulation (一二→12), matching the Arabic-digit branch (self-review)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, ok := cjkSmallUnit(r); ok {
|
||||||
|
if cur == 0 {
|
||||||
|
cur = 1
|
||||||
|
}
|
||||||
|
section += cur * u
|
||||||
|
cur = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, ok := cjkBigUnit(r); ok {
|
||||||
|
sawBig = true
|
||||||
|
section += cur
|
||||||
|
if section == 0 {
|
||||||
|
section = 1
|
||||||
|
}
|
||||||
|
total += section * u
|
||||||
|
section = 0
|
||||||
|
cur = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return 0, false // an unexpected rune
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawBig {
|
||||||
|
return 0, false // no 万/億/兆 → not a magnitude expression this gate cares about
|
||||||
|
}
|
||||||
|
return total + section + cur, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cjkDigit(r rune) (int64, bool) {
|
||||||
|
switch r {
|
||||||
|
case '一':
|
||||||
|
return 1, true
|
||||||
|
case '二', '两', '兩':
|
||||||
|
return 2, true
|
||||||
|
case '三':
|
||||||
|
return 3, true
|
||||||
|
case '四':
|
||||||
|
return 4, true
|
||||||
|
case '五':
|
||||||
|
return 5, true
|
||||||
|
case '六':
|
||||||
|
return 6, true
|
||||||
|
case '七':
|
||||||
|
return 7, true
|
||||||
|
case '八':
|
||||||
|
return 8, true
|
||||||
|
case '九':
|
||||||
|
return 9, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func cjkSmallUnit(r rune) (int64, bool) {
|
||||||
|
switch r {
|
||||||
|
case '十':
|
||||||
|
return 10, true
|
||||||
|
case '百':
|
||||||
|
return 100, true
|
||||||
|
case '千':
|
||||||
|
return 1000, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func cjkBigUnit(r rune) (int64, bool) {
|
||||||
|
switch r {
|
||||||
|
case '万', '萬':
|
||||||
|
return 10000, true
|
||||||
|
case '億', '亿':
|
||||||
|
return 100000000, true
|
||||||
|
case '兆':
|
||||||
|
return 1000000000000, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderOf(v int64) int {
|
||||||
|
o := 0
|
||||||
|
for v >= 10 {
|
||||||
|
v /= 10
|
||||||
|
o++
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputMagnitudeRanges returns the covered [minOrder,maxOrder] ranges implied by the Russian
|
||||||
|
// output. A magnitude WORD covers [base, base+2] because an unseen multiplier can lift it up to two
|
||||||
|
// orders (триста миллионов = 3·10^8, base 6 → order 8). An Arabic integer covers its own order
|
||||||
|
// exactly (grouping spaces/commas/NBSP between 3-digit groups are stitched first).
|
||||||
|
func outputMagnitudeRanges(text string) [][2]int {
|
||||||
|
low := strings.ToLower(text)
|
||||||
|
var ranges [][2]int
|
||||||
|
for stem, base := range map[string]int{"тысяч": 3, "миллион": 6, "миллиард": 9, "триллион": 12} {
|
||||||
|
if strings.Contains(low, stem) {
|
||||||
|
ranges = append(ranges, [2]int{base, base + 2})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range arabicNumberOrders(text) {
|
||||||
|
ranges = append(ranges, [2]int{o, o})
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
// arabicNumberOrders returns the order of each Arabic integer in text, stitching grouping
|
||||||
|
// separators (space / NBSP / comma) between runs of exactly three digits so "30 000" reads as one
|
||||||
|
// 5-digit number, not "30" and "000".
|
||||||
|
func arabicNumberOrders(text string) []int {
|
||||||
|
rs := []rune(text)
|
||||||
|
var orders []int
|
||||||
|
for i := 0; i < len(rs); {
|
||||||
|
if !isASCIIDigit(rs[i]) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Leading group.
|
||||||
|
j := i
|
||||||
|
for j < len(rs) && isASCIIDigit(rs[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
digits := j - i
|
||||||
|
// Stitch " ddd" / ",ddd" groups.
|
||||||
|
for j < len(rs) {
|
||||||
|
if (rs[j] == ' ' || rs[j] == ' ' || rs[j] == ',') && j+3 < len(rs)+1 {
|
||||||
|
k := j + 1
|
||||||
|
g := 0
|
||||||
|
for k < len(rs) && isASCIIDigit(rs[k]) {
|
||||||
|
k++
|
||||||
|
g++
|
||||||
|
}
|
||||||
|
if g == 3 {
|
||||||
|
digits += 3
|
||||||
|
j = k
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
orders = append(orders, digits-1)
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return orders
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIDigit(r rune) bool { return r >= '0' && r <= '9' }
|
||||||
|
|
||||||
|
// preview trims a long line for the human detail (deterministic).
|
||||||
|
func preview(s string) string {
|
||||||
|
rs := []rune(s)
|
||||||
|
if len(rs) > 48 {
|
||||||
|
return string(rs[:48]) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
220
backend/internal/pipeline/cheapgates_test.go
Normal file
220
backend/internal/pipeline/cheapgates_test.go
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- dialogue-dash --------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLintDialogueDash(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"em-dash correct", "— Привет, — сказал он.\n— И тебе.", 0},
|
||||||
|
{"hyphen dialogue", "- Привет, — сказал он.\n- Пока.", 2},
|
||||||
|
{"en-dash dialogue", "– Привет, мир.", 1},
|
||||||
|
{"straight quote dialogue", "\"Привет\", — сказал он.", 1},
|
||||||
|
{"hyphenated word not dialogue", "Он сделал это как-то по-своему.\n-то не считается.", 0},
|
||||||
|
{"list-like dash not dialogue", "-1 балл\n-2 очка", 0},
|
||||||
|
{"mixing em-dash and chevrons", "— Реплика первая.\n«Реплика вторая», — подумал он.", 1},
|
||||||
|
{"chevron only no mixing", "«Цитата из книги».\nОбычный текст.", 0},
|
||||||
|
{"chevron term in prose not flagged", "Это была «Война и мир».", 0},
|
||||||
|
// Self-review: an em-dash + space + digit is a scene break, NOT dialogue — must not count.
|
||||||
|
{"em-dash scene break not dialogue", "— 15 минут спустя он вернулся.", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
n, _ := lintDialogueDash(c.text)
|
||||||
|
if n != c.want {
|
||||||
|
t.Errorf("lintDialogueDash(%q) = %d, want %d", c.text, n, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- yofikator ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLintYofikation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
policy string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"name both spellings auto", "Пётр вошёл. Потом Петр ушёл.", "auto", 1},
|
||||||
|
{"name consistent auto", "Пётр вошёл и Пётр ушёл.", "auto", 0},
|
||||||
|
{"homograph vse not flagged", "Все ушли. И всё пропало.", "auto", 0},
|
||||||
|
{"no yo at all auto", "Все ели мед.", "auto", 0},
|
||||||
|
{"all-e flags any yo", "Пётр ел мёд.", "all-e", 2},
|
||||||
|
{"all-e clean", "Петр ел мед.", "all-e", 0},
|
||||||
|
{"artem both spellings", "Артём и Артем — один человек.", "auto", 1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
n, _ := lintYofikation(c.text, c.policy)
|
||||||
|
if n != c.want {
|
||||||
|
t.Errorf("lintYofikation(%q, %q) = %d, want %d", c.text, c.policy, n, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- translit interjections -----------------------------------------------------
|
||||||
|
|
||||||
|
func TestLintTranslitInterjections(t *testing.T) {
|
||||||
|
empty := map[string]bool{}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
allow map[string]bool
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"ara-ara and hmf", "Ара-ара, — протянула она. Хмф!", empty, 2},
|
||||||
|
{"nani", "Нани?! — вскрикнул он.", empty, 1},
|
||||||
|
{"inside word not flagged", "У него сильный характер и добрая душа.", empty, 0},
|
||||||
|
{"allowlisted maa", "Маа, что же делать.", map[string]bool{"маа": true}, 0},
|
||||||
|
{"non-allowlisted maa fires", "Маа, что же делать.", empty, 1},
|
||||||
|
// Self-review majors: «ара» (macaw noun / name) and «уму» (dative of «ум») were removed from
|
||||||
|
// the blocklist — they must NOT flag ordinary prose.
|
||||||
|
{"bare ara is a macaw not flagged", "Красный ара сидел на ветке.", empty, 0},
|
||||||
|
{"umu dative not flagged", "Это уму непостижимо.", empty, 0},
|
||||||
|
{"clean prose", "Он молча кивнул и ушёл.", empty, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
n, _ := lintTranslitInterjections(c.text, c.allow)
|
||||||
|
if n != c.want {
|
||||||
|
t.Errorf("lintTranslitInterjections(%q) = %d, want %d", c.text, n, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CJK number parser ----------------------------------------------------------
|
||||||
|
|
||||||
|
func TestParseCJKNumber(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
s string
|
||||||
|
want int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"三万", 30000, true},
|
||||||
|
{"三千万", 30000000, true},
|
||||||
|
{"一億二千万", 120000000, true},
|
||||||
|
{"十万", 100000, true},
|
||||||
|
{"30万", 300000, true},
|
||||||
|
{"五億", 500000000, true},
|
||||||
|
{"一兆", 1000000000000, true},
|
||||||
|
{"三", 0, false}, // no big marker
|
||||||
|
{"三千", 0, false}, // no big marker (千 is a small unit)
|
||||||
|
{"", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
v, ok := parseCJKNumber(c.s)
|
||||||
|
if ok != c.ok || (ok && v != c.want) {
|
||||||
|
t.Errorf("parseCJKNumber(%q) = (%d,%v), want (%d,%v)", c.s, v, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- number magnitude gate ------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLintNumberMagnitude(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
final string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"30k mistranslated as millions", "他有三万石粮食。", "У него было три миллиона мешков зерна.", 1},
|
||||||
|
{"30k correct as tens of thousands", "他有三万石粮食。", "У него было тридцать тысяч мешков зерна.", 0},
|
||||||
|
{"300 million correct", "损失三億元。", "Убытки составили триста миллионов юаней.", 0},
|
||||||
|
{"hundred million as thousand wrong", "军队三億人。", "Армия в три тысячи человек.", 1},
|
||||||
|
{"wan as myriad not a number", "世间万物皆有灵。", "Все вещи в мире имеют душу.", 0},
|
||||||
|
// Self-review major: CJK idioms carrying 万/億 but no digit coefficient are NOT magnitudes.
|
||||||
|
{"wan-yi idiom", "万一出事了怎么办。", "Что если что-то случится?", 0},
|
||||||
|
{"qianwan idiom", "你千万要小心。", "Ты обязательно будь осторожен.", 0},
|
||||||
|
{"wanfen idiom", "他万分感激。", "Он был крайне благодарен.", 0},
|
||||||
|
{"no magnitude at all", "他走进房间。", "Он вошёл в комнату.", 0},
|
||||||
|
{"arabic output matches", "三万里。", "30000 ли.", 0},
|
||||||
|
{"no output number silent", "三万大军。", "Огромная армия.", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
n, _ := lintNumberMagnitude(c.source, c.final)
|
||||||
|
if n != c.want {
|
||||||
|
t.Errorf("lintNumberMagnitude(%q → %q) = %d, want %d", c.source, c.final, n, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- combined ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRunCheapGatesCombined(t *testing.T) {
|
||||||
|
src := "他有三万石粮食。"
|
||||||
|
final := "- Ара-ара, — у Пётр было три миллиона мешков. Потом Петр ушёл."
|
||||||
|
cfg := cheapGateConfig{yoPolicy: "auto", allowlist: map[string]bool{}}
|
||||||
|
r := runCheapGates(src, final, cfg)
|
||||||
|
if r.DialogueDash == 0 {
|
||||||
|
t.Error("expected a dialogue-dash flag (hyphen-led line)")
|
||||||
|
}
|
||||||
|
if r.TranslitInterj == 0 {
|
||||||
|
t.Error("expected a translit-interjection flag (ара-ара)")
|
||||||
|
}
|
||||||
|
if r.YoInconsistent == 0 {
|
||||||
|
t.Error("expected a yofikation flag (Пётр/Петр)")
|
||||||
|
}
|
||||||
|
if r.NumberMagnitude == 0 {
|
||||||
|
t.Error("expected a number-magnitude flag (三万 → миллиона)")
|
||||||
|
}
|
||||||
|
if r.total() < 4 {
|
||||||
|
t.Errorf("expected all four gates to fire, total=%d detail=%v", r.total(), r.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheapGatesSurfaceInStatus is the end-to-end wiring test: a chunk whose FINAL text has a style
|
||||||
|
// issue (a hyphen-led dialogue line) records a style flag in retrieval_state and surfaces it in the
|
||||||
|
// status projection — WITHOUT flagging the chunk (observability, never a disposition).
|
||||||
|
func TestCheapGatesSurfaceInStatus(t *testing.T) {
|
||||||
|
rec := &reqRec{}
|
||||||
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||||||
|
if isEditBody(body) {
|
||||||
|
return "- Привет, — сказал он.", "stop" // hyphen dialogue → dialogue-dash flag
|
||||||
|
}
|
||||||
|
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "テキスト。", regenerate: 0})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
r := newRunner(t, bookPath)
|
||||||
|
defer r.Close()
|
||||||
|
res, err := r.TranslateBook(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Flagged != 0 {
|
||||||
|
t.Fatalf("a style flag must not flag the chunk (observability only), got flagged=%d", res.Flagged)
|
||||||
|
}
|
||||||
|
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
|
||||||
|
if err != nil || rs == nil {
|
||||||
|
t.Fatalf("no retrieval state persisted: %v", err)
|
||||||
|
}
|
||||||
|
if rs.NStyleFlags == 0 {
|
||||||
|
t.Fatalf("style flag not persisted in retrieval_state: %+v", rs)
|
||||||
|
}
|
||||||
|
rep, err := r.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rep.StyleFlags == 0 {
|
||||||
|
t.Fatal("status must surface the book-wide style-flag count")
|
||||||
|
}
|
||||||
|
if rep.Done != 1 || rep.Flagged != 0 {
|
||||||
|
t.Fatalf("the chunk must be done, not flagged: done=%d flagged=%d", rep.Done, rep.Flagged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,14 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
xunicode "golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ingest.go: the шаг-3a import layer. Ingest turns a source file (txt or epub)
|
// ingest.go: the шаг-3a import layer. Ingest turns a source file (txt or epub)
|
||||||
|
|
@ -48,14 +55,24 @@ type Document struct {
|
||||||
Ruby []RubyReading
|
Ruby []RubyReading
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingest reads a source file and returns its Document. Dispatch is by extension:
|
// Ingest reads a source file with automatic encoding detection (the txt path), source language
|
||||||
// .epub → the epub reader; anything else → plain text (the example uses .txt; an
|
// unspecified. Dispatch is by extension: .epub → the epub reader; anything else → plain text. Kept
|
||||||
// unknown extension is treated as text rather than rejected).
|
// as the terse form used by tests and the epub path.
|
||||||
func Ingest(p string) (*Document, error) {
|
func Ingest(p string) (*Document, error) {
|
||||||
|
return IngestEncoded(p, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestEncoded reads a source file and returns its Document, decoding a txt source per the book's
|
||||||
|
// declared encoding (auto|utf8|gb18030; "" == auto) and source language (zh|ja|en; "" == unknown).
|
||||||
|
// sourceLang scopes the GB18030 auto-probe to Chinese sources — a Shift-JIS/EUC-JP ja file would
|
||||||
|
// otherwise decode to Han-shaped mojibake that passes the plausibility guard (self-review critical).
|
||||||
|
// Dispatch is by extension: .epub → the epub reader (its own per-document charset handling);
|
||||||
|
// anything else → plain text.
|
||||||
|
func IngestEncoded(p, encoding, sourceLang string) (*Document, error) {
|
||||||
if strings.EqualFold(extOf(p), ".epub") {
|
if strings.EqualFold(extOf(p), ".epub") {
|
||||||
return ingestEPUB(p)
|
return ingestEPUB(p)
|
||||||
}
|
}
|
||||||
return ingestTXT(p)
|
return ingestTXT(p, encoding, sourceLang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extOf returns the lower-cased file extension incl. the dot ("" if none).
|
// extOf returns the lower-cased file extension incl. the dot ("" if none).
|
||||||
|
|
@ -67,20 +84,324 @@ func extOf(p string) string {
|
||||||
return strings.ToLower(p[i:])
|
return strings.ToLower(p[i:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ingestTXT reads a plain-text source: split on \f into chapters, NormalizeSource
|
// ingestTXT reads a plain-text source: decode the raw bytes to UTF-8 per `encoding`, split on \f
|
||||||
// each (BOM/CRLF/NFC/outer-trim). No ruby in txt.
|
// into chapters, NormalizeSource each (BOM/CRLF/NFC/outer-trim). No ruby in txt. Encoding decode
|
||||||
func ingestTXT(p string) (*Document, error) {
|
// runs BEFORE NormalizeSource so the whole downstream path (chunker fuzz-invariant, memory keys)
|
||||||
|
// sees valid UTF-8; a decode failure is a LOUD error, never silent U+FFFD corruption.
|
||||||
|
func ingestTXT(p, encoding, sourceLang string) (*Document, error) {
|
||||||
raw, err := os.ReadFile(p)
|
raw, err := os.ReadFile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pipeline: ingest txt: %w", err)
|
return nil, fmt.Errorf("pipeline: ingest txt: %w", err)
|
||||||
}
|
}
|
||||||
|
text, err := decodeSourceBytes(raw, encoding, sourceLang)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pipeline: ingest txt %s: %w", p, err)
|
||||||
|
}
|
||||||
var chapters []string
|
var chapters []string
|
||||||
for _, part := range strings.Split(string(raw), chapterSep) {
|
// Honor the ASCII form feed as a hard chapter boundary (Фаза-0 example), THEN split each part
|
||||||
chapters = append(chapters, NormalizeSource(part))
|
// on CJK chapter-header lines (第N章/节/回) — real zh/ja web novels mark chapters that way, not
|
||||||
|
// with \f (the 蛊真人 acceptance book uses «第N节», ~2283 of them). A part with neither stays one
|
||||||
|
// chapter (backward-compat).
|
||||||
|
for _, part := range strings.Split(text, chapterSep) {
|
||||||
|
for _, chap := range splitTextChapters(part) {
|
||||||
|
chapters = append(chapters, NormalizeSource(chap))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return &Document{Chapters: chapters}, nil
|
return &Document{Chapters: chapters}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CJK chapter-header splitting (D18: real zh/ja txt mark chapters as «第N章/节/回») ---------
|
||||||
|
|
||||||
|
// chapterUnitRunes are the section-level chapter markers auto-detected in a txt. 卷 (volume) is
|
||||||
|
// deliberately EXCLUDED — it is coarser than a chapter and would carve a tiny title-only "chapter".
|
||||||
|
var chapterUnitRunes = []rune{'章', '节', '節', '回'}
|
||||||
|
|
||||||
|
// chapterNumeralRE matches the numeral run of a chapter header: Arabic (half/fullwidth) or CJK.
|
||||||
|
var chapterNumeralRE = regexp.MustCompile(`^\s*第[0-90-9〇零一二三四五六七八九十百千两兩]+`)
|
||||||
|
|
||||||
|
// chapterHeaderMaxRunes bounds a header line so a prose sentence that merely opens with «第三节…»
|
||||||
|
// (a longer line) is not mistaken for a header. The 蛊真人 headers are ≤23 runes; 60 leaves room for
|
||||||
|
// a long subtitle while still excluding a full prose sentence (self-review: 40 could miss a long
|
||||||
|
// legitimate subtitle → merged chapters).
|
||||||
|
const chapterHeaderMaxRunes = 60
|
||||||
|
|
||||||
|
// isCJKChapterHeader reports whether a single line is a chapter header for the given unit rune. It
|
||||||
|
// requires: a short trimmed line (≤60 runes); a leading 第<numerals>; the unit rune immediately
|
||||||
|
// after the numerals; and — critically — the char AFTER the unit is a SEPARATOR (whitespace / ::、,.
|
||||||
|
// / dash) or end-of-line, NOT a content glyph. The separator guard is the precision fix (self-
|
||||||
|
// review): 回 is a common measure word, so prose like «第一回见面…» glues a content char (见) right
|
||||||
|
// after the unit and must NOT split; a real header writes «第一节:…» or «第1章 …» with a separator.
|
||||||
|
// (A glued-title header «第一章天空…» without a separator is not detected — rare; a recall trade for
|
||||||
|
// no false splits. Deterministic and pure.)
|
||||||
|
func isCJKChapterHeader(line string, unit rune) bool {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if utf8.RuneCountInString(t) == 0 || utf8.RuneCountInString(t) > chapterHeaderMaxRunes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
loc := chapterNumeralRE.FindStringIndex(t)
|
||||||
|
if loc == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rest := t[loc[1]:] // the runes right after the numerals
|
||||||
|
r, sz := utf8.DecodeRuneInString(rest)
|
||||||
|
if r != unit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
after := rest[sz:]
|
||||||
|
if after == "" {
|
||||||
|
return true // the header is exactly 第N章
|
||||||
|
}
|
||||||
|
nr, _ := utf8.DecodeRuneInString(after)
|
||||||
|
return isHeaderSeparator(nr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHeaderSeparator reports whether a rune separates a chapter number from its title (so the line is
|
||||||
|
// a header, not a prose sentence that continues with a content glyph after 第N章). Anything that is
|
||||||
|
// NOT a letter / ideograph / kana / digit counts as a separator (space, ::、,,.。-—— etc.).
|
||||||
|
func isHeaderSeparator(r rune) bool {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) ||
|
||||||
|
unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectChapterUnit picks the section marker that appears most as a header line (≥2 to avoid a
|
||||||
|
// single stray match). Returns 0 when no unit qualifies (→ the part stays a single chapter).
|
||||||
|
func detectChapterUnit(lines []string) rune {
|
||||||
|
best, bestN := rune(0), 0
|
||||||
|
for _, unit := range chapterUnitRunes {
|
||||||
|
n := 0
|
||||||
|
for _, ln := range lines {
|
||||||
|
if isCJKChapterHeader(ln, unit) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n >= 2 && n > bestN {
|
||||||
|
best, bestN = unit, n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTextChapters splits one text block on its dominant CJK chapter-header lines. The header line
|
||||||
|
// STARTS its chapter (the title is kept). Any preamble before the FIRST header is merged into
|
||||||
|
// chapter 1 so «第一节» == chapter 1 (dense numbering aligns with the section numbers). A block with
|
||||||
|
// no detectable headers is returned unchanged as a single chapter.
|
||||||
|
func splitTextChapters(text string) []string {
|
||||||
|
norm := strings.ReplaceAll(text, "\r\n", "\n")
|
||||||
|
norm = strings.ReplaceAll(norm, "\r", "\n") // bare-CR (old Mac) lines too, so a CR-only header is seen
|
||||||
|
lines := strings.Split(norm, "\n")
|
||||||
|
unit := detectChapterUnit(lines)
|
||||||
|
if unit == 0 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
var chapters []string
|
||||||
|
var cur []string
|
||||||
|
seenHeader := false
|
||||||
|
for _, ln := range lines {
|
||||||
|
if isCJKChapterHeader(ln, unit) {
|
||||||
|
if seenHeader && hasNonBlank(cur) {
|
||||||
|
chapters = append(chapters, strings.Join(cur, "\n"))
|
||||||
|
cur = nil
|
||||||
|
}
|
||||||
|
seenHeader = true
|
||||||
|
}
|
||||||
|
cur = append(cur, ln)
|
||||||
|
}
|
||||||
|
if hasNonBlank(cur) {
|
||||||
|
chapters = append(chapters, strings.Join(cur, "\n"))
|
||||||
|
}
|
||||||
|
if len(chapters) == 0 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasNonBlank reports whether any line in the slice has non-whitespace content.
|
||||||
|
func hasNonBlank(lines []string) bool {
|
||||||
|
for _, ln := range lines {
|
||||||
|
if strings.TrimSpace(ln) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- source encoding detection (Task 2 / D18) ----------------------------------
|
||||||
|
//
|
||||||
|
// Real zh .txt are frequently GB18030 (a superset of GBK/GB2312), not UTF-8 — the 蛊真人 acceptance
|
||||||
|
// book is exactly this case. decodeSourceBytes converts raw source bytes to a UTF-8 string per the
|
||||||
|
// book's declared encoding, defaulting to a conservative auto-detect. The invariant is fail-LOUD:
|
||||||
|
// any doubt is an error, never silent mojibake — a corrupt/foreign byte must never reach a U+FFFD
|
||||||
|
// replacement in ch.Text (which would defeat the lossless-chunker fuzz invariant and mis-key the
|
||||||
|
// glossary). Dependency: golang.org/x/text/encoding (already in go.mod for NFC) — first use of its
|
||||||
|
// simplifiedchinese + unicode subpackages (noted in the session journal).
|
||||||
|
func decodeSourceBytes(raw []byte, encoding, sourceLang string) (string, error) {
|
||||||
|
switch enc := strings.ToLower(strings.TrimSpace(encoding)); enc {
|
||||||
|
case "", "auto":
|
||||||
|
return autoDecodeSource(raw, sourceLang)
|
||||||
|
case "utf8", "utf-8":
|
||||||
|
b := bytes.TrimPrefix(raw, utf8BOM)
|
||||||
|
if !utf8.Valid(b) {
|
||||||
|
return "", fmt.Errorf("declared encoding utf8 but the bytes are not valid UTF-8 (declare gb18030, or convert the file)")
|
||||||
|
}
|
||||||
|
return checkNoNUL(string(b))
|
||||||
|
case "gb18030", "gbk", "gb2312":
|
||||||
|
// Operator asserted the encoding: decode strictly (fail on undecodable bytes / U+FFFD) but
|
||||||
|
// skip the Chinese-plausibility heuristic — they told us it is GB18030.
|
||||||
|
return decodeGB18030(raw, false)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown encoding %q in book.yaml (use auto|utf8|gb18030)", encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNoNUL rejects a decoded string that contains a NUL (U+0000). Real prose never contains NUL;
|
||||||
|
// its presence means the bytes were a UTF-16 stream WITHOUT a BOM whose ASCII half is valid UTF-8
|
||||||
|
// with interleaved NULs (silent corruption the U+FFFD check cannot catch — self-review major).
|
||||||
|
func checkNoNUL(s string) (string, error) {
|
||||||
|
if strings.IndexByte(s, 0) >= 0 {
|
||||||
|
return "", fmt.Errorf("decoded text contains NUL (U+0000) — the source is likely UTF-16 without a BOM; convert it to UTF-8 or declare its encoding")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
utf16LEBOM = []byte{0xFF, 0xFE}
|
||||||
|
utf16BEBOM = []byte{0xFE, 0xFF}
|
||||||
|
)
|
||||||
|
|
||||||
|
// autoDecodeSource is the conservative detection ladder: BOM → valid UTF-8 (+NUL guard) → GB18030
|
||||||
|
// probe (ZH sources only) → fail-loud. Order matters: a BOM is definitive; valid UTF-8 is
|
||||||
|
// unambiguous (but rejected if it carries interleaved NUL — UTF-16 without a BOM); GB18030 is tried
|
||||||
|
// only when the bytes are NOT valid UTF-8 AND the source is Chinese (or unspecified). GB18030 is a
|
||||||
|
// Chinese encoding that maps almost every byte sequence to Han-shaped codepoints, so a ja Shift-JIS
|
||||||
|
// / EUC-JP or a European Latin-1 file would decode to CJK-shaped MOJIBAKE that the plausibility
|
||||||
|
// guard cannot distinguish from real Chinese (self-review CRITICAL). So we auto-probe GB18030 only
|
||||||
|
// for zh; for ja/en a non-UTF-8 file fails loud and the operator must convert or declare it.
|
||||||
|
func autoDecodeSource(raw []byte, sourceLang string) (string, error) {
|
||||||
|
switch {
|
||||||
|
case bytes.HasPrefix(raw, utf8BOM):
|
||||||
|
b := raw[len(utf8BOM):]
|
||||||
|
if !utf8.Valid(b) {
|
||||||
|
return "", fmt.Errorf("file has a UTF-8 BOM but the body is not valid UTF-8 (corrupt file)")
|
||||||
|
}
|
||||||
|
return checkNoNUL(string(b))
|
||||||
|
case bytes.HasPrefix(raw, utf16LEBOM):
|
||||||
|
return decodeUTF16(raw, xunicode.LittleEndian)
|
||||||
|
case bytes.HasPrefix(raw, utf16BEBOM):
|
||||||
|
return decodeUTF16(raw, xunicode.BigEndian)
|
||||||
|
}
|
||||||
|
if utf8.Valid(raw) {
|
||||||
|
return checkNoNUL(string(raw)) // no BOM, already UTF-8 (covers en/ja/UTF-8 zh); NUL ⇒ UTF-16-no-BOM
|
||||||
|
}
|
||||||
|
// Not valid UTF-8. GB18030 auto-probe is scoped to Chinese sources (see doc above).
|
||||||
|
zhScoped := sourceLang == "" || strings.EqualFold(sourceLang, "zh")
|
||||||
|
if zhScoped {
|
||||||
|
if s, err := decodeGB18030(raw, true); err == nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probe := "was tried and failed (truncated/corrupt or not GB18030)"
|
||||||
|
if !zhScoped {
|
||||||
|
probe = fmt.Sprintf("is restricted to zh sources (this source is %q) — a Shift-JIS/EUC-JP/Latin-1 file would decode to Han-shaped mojibake and is NOT auto-detected", sourceLang)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("could not auto-detect the source encoding: the bytes are not valid UTF-8, and the GB18030 probe %s; convert the file to UTF-8 or declare `encoding:` explicitly in book.yaml", probe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeGB18030 decodes raw as GB18030 (superset of GBK/GB2312). It is strict: a decode error, or a
|
||||||
|
// U+FFFD replacement char in the output (undecodable/truncated bytes), is a failure — broken bytes
|
||||||
|
// must never reach ch.Text. When probe is true (auto-detect) it additionally requires the decoded
|
||||||
|
// text to look like Chinese (plausibleCJK), because GB18030 maps almost every byte sequence to
|
||||||
|
// SOMETHING, so a Latin-1/other file decodes without error into mojibake unless we sanity-check it.
|
||||||
|
func decodeGB18030(raw []byte, probe bool) (string, error) {
|
||||||
|
out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GB18030 decode error (not valid GB18030): %w", err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if strings.ContainsRune(s, utf8.RuneError) {
|
||||||
|
return "", fmt.Errorf("GB18030 decode produced replacement chars (U+FFFD) — bytes are truncated/corrupt or not GB18030")
|
||||||
|
}
|
||||||
|
if probe {
|
||||||
|
if err := plausibleCJK(s); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeUTF16 decodes a BOM-prefixed UTF-16 stream (ExpectBOM consumes the BOM). Strict: a decode
|
||||||
|
// error or a U+FFFD in the output is a failure.
|
||||||
|
func decodeUTF16(raw []byte, endian xunicode.Endianness) (string, error) {
|
||||||
|
dec := xunicode.UTF16(endian, xunicode.ExpectBOM).NewDecoder()
|
||||||
|
out, _, err := transform.Bytes(dec, raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("UTF-16 decode error: %w", err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if strings.ContainsRune(s, utf8.RuneError) {
|
||||||
|
return "", fmt.Errorf("UTF-16 decode produced replacement chars (U+FFFD) — corrupt")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// plausibleCJK is the GB18030 auto-accept guard: a real GB18030 source is Chinese, so the decoded
|
||||||
|
// text must be (a) almost entirely CJK-plausible runes (Han/kana/CJK-punct/ASCII/whitespace) and
|
||||||
|
// (b) genuinely Han-dense. Latin-1/other mojibake fails (b) — it decodes to sparse Han among ASCII
|
||||||
|
// (café → "caf" + 轳), so every rune is "plausible" in isolation yet Han density is ~0. The Han
|
||||||
|
// floor is intentionally low (5%) so a heavily Latin-mixed but real Chinese source still passes,
|
||||||
|
// while mojibake (Han≈0) is rejected loudly. Thresholds bias toward fail-loud (D18 invariant); a
|
||||||
|
// false reject is recoverable (declare `encoding: gb18030`), a false accept is silent corruption.
|
||||||
|
func plausibleCJK(s string) error {
|
||||||
|
var total, han, plausible int
|
||||||
|
for _, r := range s {
|
||||||
|
total++
|
||||||
|
switch {
|
||||||
|
case unicode.Is(unicode.Han, r):
|
||||||
|
han++
|
||||||
|
plausible++
|
||||||
|
case isPlausibleSourceRune(r):
|
||||||
|
plausible++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return fmt.Errorf("GB18030 probe: empty decode")
|
||||||
|
}
|
||||||
|
if pf := float64(plausible) / float64(total); pf < 0.90 {
|
||||||
|
return fmt.Errorf("GB18030 probe: only %.1f%% of decoded runes are CJK-plausible — likely mojibake, not Chinese", 100*pf)
|
||||||
|
}
|
||||||
|
if hf := float64(han) / float64(total); hf < 0.05 {
|
||||||
|
return fmt.Errorf("GB18030 probe: Han density %.2f%% is too low for a Chinese source — likely a mis-detected encoding; declare `encoding` explicitly if this really is GB18030", 100*hf)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlausibleSourceRune reports whether a rune belongs to the expected repertoire of a zh/ja source
|
||||||
|
// (excluding Han, which the caller counts separately): ASCII text, kana, CJK/general punctuation,
|
||||||
|
// and fullwidth/halfwidth forms. Deliberately tight — it must NOT whitelist the Latin-1 supplement
|
||||||
|
// or arbitrary symbols, or Latin-1 mojibake would pass the plausibility fraction.
|
||||||
|
func isPlausibleSourceRune(r rune) bool {
|
||||||
|
switch {
|
||||||
|
case r == '\n', r == '\r', r == '\t', r == ' ':
|
||||||
|
return true
|
||||||
|
case r >= 0x20 && r <= 0x7E: // printable ASCII
|
||||||
|
return true
|
||||||
|
case unicode.Is(unicode.Hiragana, r), unicode.Is(unicode.Katakana, r):
|
||||||
|
return true
|
||||||
|
case r >= 0x3000 && r <= 0x303F: // CJK symbols & punctuation (。、「」〜…)
|
||||||
|
return true
|
||||||
|
case r >= 0x2010 && r <= 0x206F: // general punctuation (— – ‘’ “” … ‰ etc.)
|
||||||
|
return true
|
||||||
|
case r >= 0xFF00 && r <= 0xFFEF: // halfwidth & fullwidth forms (!?()ア fullwidth digits/letters)
|
||||||
|
return true
|
||||||
|
case r == 0x30FC || r == 0x30FB: // prolonged-sound mark ー, middle dot ・ (also in Katakana block)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// --- epub ----------------------------------------------------------------------
|
// --- epub ----------------------------------------------------------------------
|
||||||
|
|
||||||
// containerXML is META-INF/container.xml: it points at the OPF package file. Tags
|
// containerXML is META-INF/container.xml: it points at the OPF package file. Tags
|
||||||
|
|
|
||||||
260
backend/internal/pipeline/ingest_encoding_test.go
Normal file
260
backend/internal/pipeline/ingest_encoding_test.go
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/japanese"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
xunicode "golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A realistic zh chapter: Han + CJK punctuation + fullwidth quotes + a stray Latin name + digits.
|
||||||
|
const zhChapter = "第一章 天空之陨\n\n方源睁开眼睛,“这是哪里?”他喃喃自语。三万年后,Gu 之力遍布天地。"
|
||||||
|
|
||||||
|
func gb18030Bytes(t *testing.T, s string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, _, err := transform.Bytes(simplifiedchinese.GB18030.NewEncoder(), []byte(s))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode gb18030: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16Bytes(t *testing.T, s string, endian xunicode.Endianness) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, _, err := transform.Bytes(xunicode.UTF16(endian, xunicode.UseBOM).NewEncoder(), []byte(s))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode utf16: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceAutoGB18030(t *testing.T) {
|
||||||
|
raw := gb18030Bytes(t, zhChapter)
|
||||||
|
got, err := decodeSourceBytes(raw, "auto", "zh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("auto-detect of a GB18030 chapter failed: %v", err)
|
||||||
|
}
|
||||||
|
if got != zhChapter {
|
||||||
|
t.Fatalf("GB18030 round-trip mismatch:\n got %q\nwant %q", got, zhChapter)
|
||||||
|
}
|
||||||
|
// The whole point: a GB18030 file is NOT valid UTF-8, so the naive string(raw) would be mojibake.
|
||||||
|
if strings.ContainsRune(got, '<27>') {
|
||||||
|
t.Error("decoded text contains U+FFFD — the lossless invariant is broken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceAutoUTF8NoBOM(t *testing.T) {
|
||||||
|
got, err := decodeSourceBytes([]byte(zhChapter), "auto", "zh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid UTF-8 must pass through: %v", err)
|
||||||
|
}
|
||||||
|
if got != zhChapter {
|
||||||
|
t.Fatalf("UTF-8 passthrough mismatch: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceAutoUTF8BOM(t *testing.T) {
|
||||||
|
raw := append(append([]byte(nil), 0xEF, 0xBB, 0xBF), []byte(zhChapter)...)
|
||||||
|
got, err := decodeSourceBytes(raw, "auto", "zh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UTF-8 BOM file must decode: %v", err)
|
||||||
|
}
|
||||||
|
// The BOM byte may survive as U+FEFF here; NormalizeSource strips it downstream. What matters is
|
||||||
|
// the body decoded correctly and no mojibake appeared.
|
||||||
|
if !strings.Contains(got, "第一章 天空之陨") {
|
||||||
|
t.Fatalf("UTF-8 BOM body mis-decoded: %q", got)
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(got, '<27>') {
|
||||||
|
t.Error("U+FFFD after BOM decode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceAutoUTF16(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
endian xunicode.Endianness
|
||||||
|
}{{"LE", xunicode.LittleEndian}, {"BE", xunicode.BigEndian}} {
|
||||||
|
raw := utf16Bytes(t, zhChapter, tc.endian)
|
||||||
|
got, err := decodeSourceBytes(raw, "auto", "zh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UTF-16 %s must decode: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "方源睁开眼睛") {
|
||||||
|
t.Fatalf("UTF-16 %s mis-decoded: %q", tc.name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceAutoRejectsGarbage(t *testing.T) {
|
||||||
|
// Random high bytes: not valid UTF-8, GB18030 yields U+FFFD → fail loud, not silent junk.
|
||||||
|
raw := []byte{0x00, 0x01, 0xFF, 0xFE, 0x80, 0x81, 0x00, 0x9F, 0xC0}
|
||||||
|
if got, err := decodeSourceBytes(raw, "auto", "zh"); err == nil {
|
||||||
|
t.Fatalf("garbage bytes must fail loud, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceAutoRejectsLatin1(t *testing.T) {
|
||||||
|
// Latin-1 "Café déjà vu — naïve" is invalid UTF-8; GB18030 decodes it into mojibake that here
|
||||||
|
// contains U+FFFD, so it is rejected by the strict U+FFFD check (NOT the Han-density guard — see
|
||||||
|
// TestPlausibleCJKGuard for direct density coverage). Either way it must fail loud.
|
||||||
|
raw := []byte{0x43, 0x61, 0x66, 0xE9, 0x20, 0x64, 0xE9, 0x6A, 0xE0, 0x20, 0x76, 0x75, 0x20, 0x2D, 0x20, 0x6E, 0x61, 0xEF, 0x76, 0x65}
|
||||||
|
if got, err := decodeSourceBytes(raw, "auto", "zh"); err == nil {
|
||||||
|
t.Fatalf("Latin-1 mojibake must fail loud, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlausibleCJKGuard directly locks the Han-density guard (self-review: it had zero coverage —
|
||||||
|
// every mojibake fixture was caught earlier by the U+FFFD check). A clean-decoding low-Han string
|
||||||
|
// must be rejected; a Han-dense string must pass.
|
||||||
|
func TestPlausibleCJKGuard(t *testing.T) {
|
||||||
|
if err := plausibleCJK("Hello world, ordinary Latin prose with one 一 ideograph in it."); err == nil {
|
||||||
|
t.Error("low-Han text must fail the plausibility guard")
|
||||||
|
}
|
||||||
|
if err := plausibleCJK("方源睁开眼睛,这是哪里?天地无穷,万物玄妙。"); err != nil {
|
||||||
|
t.Errorf("Han-dense Chinese must pass the plausibility guard: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecodeSourceRejectsShiftJISForJa is the self-review CRITICAL: a Japanese Shift-JIS source must
|
||||||
|
// NOT be silently accepted as GB18030 mojibake. With source_lang=ja the GB18030 auto-probe is off,
|
||||||
|
// so it fails loud (the operator converts to UTF-8). Shift-JIS is invalid UTF-8, so the ladder does
|
||||||
|
// reach the (now gated) GB18030 step.
|
||||||
|
func TestDecodeSourceRejectsShiftJISForJa(t *testing.T) {
|
||||||
|
ja := "彼女は静かに窓の外を眺めていた。"
|
||||||
|
sj, _, err := transform.Bytes(japanese.ShiftJIS.NewEncoder(), []byte(ja))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode shift-jis: %v", err)
|
||||||
|
}
|
||||||
|
if utf8.Valid(sj) {
|
||||||
|
t.Fatal("setup: shift-jis bytes unexpectedly valid UTF-8")
|
||||||
|
}
|
||||||
|
if got, derr := decodeSourceBytes(sj, "auto", "ja"); derr == nil {
|
||||||
|
t.Fatalf("a ja Shift-JIS source must fail loud under auto, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecodeSourceRejectsUTF16NoBOM is the self-review major: ASCII text saved as UTF-16 without a
|
||||||
|
// BOM is valid UTF-8 with interleaved NUL — the NUL guard must reject it, not pass silent corruption.
|
||||||
|
func TestDecodeSourceRejectsUTF16NoBOM(t *testing.T) {
|
||||||
|
enc := xunicode.UTF16(xunicode.LittleEndian, xunicode.IgnoreBOM).NewEncoder()
|
||||||
|
raw, _, err := transform.Bytes(enc, []byte("Hello world, a plain English sentence."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode utf16-no-bom: %v", err)
|
||||||
|
}
|
||||||
|
if got, derr := decodeSourceBytes(raw, "auto", "en"); derr == nil {
|
||||||
|
t.Fatalf("UTF-16 without a BOM must fail loud (NUL guard), got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceExplicitGB18030(t *testing.T) {
|
||||||
|
raw := gb18030Bytes(t, zhChapter)
|
||||||
|
got, err := decodeSourceBytes(raw, "gb18030", "zh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicit gb18030 must decode: %v", err)
|
||||||
|
}
|
||||||
|
if got != zhChapter {
|
||||||
|
t.Fatalf("explicit gb18030 mismatch: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceExplicitUTF8OnGBFailsLoud(t *testing.T) {
|
||||||
|
raw := gb18030Bytes(t, zhChapter) // not valid UTF-8
|
||||||
|
if _, err := decodeSourceBytes(raw, "utf8", "zh"); err == nil {
|
||||||
|
t.Fatal("declaring utf8 on GB18030 bytes must fail loud, not mojibake")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceExplicitGBOnCorruptFailsLoud(t *testing.T) {
|
||||||
|
// A valid GB18030 chapter truncated mid-multibyte → U+FFFD → strict decode fails loud.
|
||||||
|
raw := gb18030Bytes(t, zhChapter)
|
||||||
|
if got, err := decodeSourceBytes(raw[:len(raw)-1], "gb18030", "zh"); err == nil {
|
||||||
|
t.Fatalf("truncated GB18030 must fail loud (U+FFFD), got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceUnknownEncoding(t *testing.T) {
|
||||||
|
if _, err := decodeSourceBytes([]byte("hi"), "shift-jis", ""); err == nil {
|
||||||
|
t.Fatal("an unsupported explicit encoding must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIngestTXTGB18030EndToEnd exercises the whole txt path: a GB18030 file with a \f chapter break
|
||||||
|
// decodes, splits, and normalizes — the D18 acceptance-book ingest case (蛊真人 is GB18030).
|
||||||
|
func TestIngestTXTGB18030EndToEnd(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "book.txt")
|
||||||
|
twoChapters := zhChapter + "\f" + "第二章 洞窟\n\n他走进了洞窟深处。"
|
||||||
|
if err := os.WriteFile(p, gb18030Bytes(t, twoChapters), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc, err := IngestEncoded(p, "gb18030", "zh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ingest gb18030 txt: %v", err)
|
||||||
|
}
|
||||||
|
if len(doc.Chapters) != 2 {
|
||||||
|
t.Fatalf("want 2 chapters (split on \\f), got %d", len(doc.Chapters))
|
||||||
|
}
|
||||||
|
if !strings.Contains(doc.Chapters[0], "方源") || !strings.Contains(doc.Chapters[1], "洞窟") {
|
||||||
|
t.Fatalf("chapters mis-decoded: %q | %q", doc.Chapters[0], doc.Chapters[1])
|
||||||
|
}
|
||||||
|
for _, ch := range doc.Chapters {
|
||||||
|
if strings.ContainsRune(ch, '<27>') {
|
||||||
|
t.Errorf("U+FFFD in a normalized chapter: %q", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplitTextChapters covers the CJK chapter-header splitting (D18: 蛊真人 uses «第N节» headers).
|
||||||
|
func TestSplitTextChapters(t *testing.T) {
|
||||||
|
jie := "第一节:начало\n方源开口。\n第二节:середина\n他走了。\n第三节:конец\n完。"
|
||||||
|
got := splitTextChapters(jie)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("第N节 split: want 3 chapters, got %d: %q", len(got), got)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got[0], "第一节") || !strings.HasPrefix(got[1], "第二节") {
|
||||||
|
t.Errorf("headers must start their chapter: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
zhang := "第1章 первая\nтекст\n第2章 вторая\nтекст"
|
||||||
|
if got := splitTextChapters(zhang); len(got) != 2 {
|
||||||
|
t.Fatalf("第N章 (arabic) split: want 2, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preamble before the first header merges into chapter 1 (第一节 == chapter 1).
|
||||||
|
withPreamble := "书名:Тест\n作者:Автор\n第一节:глава\nтело первой главы\n第二节:вторая\nтело"
|
||||||
|
got = splitTextChapters(withPreamble)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("preamble merge: want 2 chapters, got %d: %q", len(got), got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[0], "书名") || !strings.Contains(got[0], "第一节") {
|
||||||
|
t.Errorf("preamble must merge into chapter 1: %q", got[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// No headers → a single chapter (backward compat).
|
||||||
|
if got := splitTextChapters("Просто текст без глав.\nВторая строка."); len(got) != 1 {
|
||||||
|
t.Fatalf("no-header text must be one chapter, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long prose line that merely opens with «第三章…» is NOT a header (length guard).
|
||||||
|
prose := "第三章的内容非常精彩,讲述了一个漫长而复杂的故事,充满了各种各样的细节和转折点情节。"
|
||||||
|
if got := splitTextChapters(prose); len(got) != 1 {
|
||||||
|
t.Fatalf("prose starting with 第三章 must not split, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-review major: 回 is a measure word, so «第一回见面…» (a content glyph glued after the unit)
|
||||||
|
// must NOT split; a real «第N回:» header (separator after the unit) does.
|
||||||
|
huiProse := "第一回见面时他就心动了。\n第二回重逢依然如此。"
|
||||||
|
if got := splitTextChapters(huiProse); len(got) != 1 {
|
||||||
|
t.Fatalf("回 measure-word prose must not split (separator guard), got %d: %q", len(got), got)
|
||||||
|
}
|
||||||
|
huiHeader := "第一回:初见\nтекст первой.\n第二回:重逢\nтекст второй."
|
||||||
|
if got := splitTextChapters(huiHeader); len(got) != 2 {
|
||||||
|
t.Fatalf("第N回: headers must split, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -371,32 +371,50 @@ func rubyToCandidates(readings []store.RubyReading, manualSrcs map[string]bool)
|
||||||
// never blindly trusted; full offline disambiguation is B6. Auto-candidates with no dst stay
|
// never blindly trusted; full offline disambiguation is B6. Auto-candidates with no dst stay
|
||||||
// unmatchable (deliberate). Deterministic: readings are processed sorted, and GlossaryForBook
|
// unmatchable (deliberate). Deterministic: readings are processed sorted, and GlossaryForBook
|
||||||
// re-sorts aliases on read, so the materialized bank is stable regardless of input order.
|
// re-sorts aliases on read, so the materialized bank is stable regardless of input order.
|
||||||
|
//
|
||||||
|
// A ruby reading is auto-derived (not curated), so a reading that would give a FIRING key to
|
||||||
|
// two seeded terms with different dst (homophone names 高橋/高梁 both read たかはし) must NOT be
|
||||||
|
// attached to either — the reading is genuinely ambiguous between them. External-review MAJOR
|
||||||
|
// (homophone first-wins): the previous pass attached-then-blocked, so the alphabetically-first
|
||||||
|
// base won the reading as an ALIAS while the other was skipped; at ≥4 kana that alias is not
|
||||||
|
// collision-prone → it fired CONFIRMED → every kana occurrence injected as an authoritative
|
||||||
|
// (and wrong) rendering for whichever base sorted first (class A2). The key must therefore be
|
||||||
|
// decided BEFORE any attach, for ALL its owners at once: a contested firing key (≥2 would-fire
|
||||||
|
// owners with different dst over overlapping spoiler windows) skips EVERY proposed attach and
|
||||||
|
// logs it (not silent) — the kana form stays unmatchable until the seed disambiguates it with a
|
||||||
|
// curated alias (whose own collisions DO fail loud, as an authoring error). Attaching would also
|
||||||
|
// trip approvedSharedKeyCollisions loud on an alias the operator cannot edit out of the seed.
|
||||||
|
//
|
||||||
|
// Window/dst-eligibility awareness (review minor): only a dst-bearing owner fires (materializeMemory
|
||||||
|
// builds no surface for a no-dst row), and two owners with the SAME dst (a merge) or NON-overlapping
|
||||||
|
// windows (a spoiler handoff) do not contradict — so those are no longer falsely skipped.
|
||||||
func attachRubyAliasesToManual(entries []store.GlossaryEntry, readings []store.RubyReading) (skipped []string) {
|
func attachRubyAliasesToManual(entries []store.GlossaryEntry, readings []store.RubyReading) (skipped []string) {
|
||||||
idx := map[string][]int{}
|
idx := map[string][]int{}
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
idx[entries[i].Src] = append(idx[entries[i].Src], i)
|
idx[entries[i].Src] = append(idx[entries[i].Src], i)
|
||||||
}
|
}
|
||||||
// owners of each FIRING key (src + eligible aliases) → term indices, for collision detection.
|
dstBearing := func(e *store.GlossaryEntry) bool { return strings.TrimSpace(e.Dst) != "" }
|
||||||
// A ruby reading is auto-derived (not curated), so if attaching it would collide with a
|
|
||||||
// DIFFERENT term's firing key with a different dst (two homophone names 鈴木/鈴樹 both read
|
// wouldOwn[rk] = set of term indices that would own firing key rk AFTER the proposed
|
||||||
// すずき), attaching would make approvedSharedKeyCollisions fail the whole book loud on an
|
// attaches: the pre-existing dst-bearing owners (src + eligible aliases already on the seed)
|
||||||
// alias the operator cannot edit out of the seed. Instead SKIP it gracefully and LOG (not
|
// plus every deferred (fires) proposal. Only dst-bearing entries fire, so only they can
|
||||||
// silent): the kana form of that homophone stays unmatchable until the seed disambiguates it
|
// collide. addOwner keeps a set (an index is never paired with itself in contested()).
|
||||||
// explicitly (a curated manual alias — whose collisions DO fail loud, as an authoring error).
|
wouldOwn := map[string]map[int]bool{}
|
||||||
owners := map[string][]int{}
|
addOwner := func(rk string, ti int) {
|
||||||
for i, e := range entries {
|
if wouldOwn[rk] == nil {
|
||||||
for _, k := range entryFiringKeys(e) {
|
wouldOwn[rk] = map[int]bool{}
|
||||||
owners[k] = append(owners[k], i)
|
}
|
||||||
|
wouldOwn[rk][ti] = true
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
if !dstBearing(&entries[i]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, k := range entryFiringKeys(entries[i]) {
|
||||||
|
addOwner(k, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
collides := func(rk string, ti int) bool {
|
|
||||||
for _, oi := range owners[rk] {
|
|
||||||
if oi != ti && entries[oi].Dst != entries[ti].Dst {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sorted := append([]store.RubyReading(nil), readings...)
|
sorted := append([]store.RubyReading(nil), readings...)
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
if sorted[i].Base != sorted[j].Base {
|
if sorted[i].Base != sorted[j].Base {
|
||||||
|
|
@ -404,6 +422,17 @@ func attachRubyAliasesToManual(entries []store.GlossaryEntry, readings []store.R
|
||||||
}
|
}
|
||||||
return sorted[i].Reading < sorted[j].Reading
|
return sorted[i].Reading < sorted[j].Reading
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pass 1 — gather proposals. A proposal that would actually FIRE (dst-bearing target + a key
|
||||||
|
// past the per-language floor / allow_short) is DEFERRED to the contested-key decision so a
|
||||||
|
// homophone key is resolved for all its owners together, never first-wins. An inert attach (no
|
||||||
|
// dst, or a sub-floor key) can never fire → attach it immediately; it round-trips the reading
|
||||||
|
// but cannot collide.
|
||||||
|
type prop struct {
|
||||||
|
rk, reading, base string
|
||||||
|
ti int
|
||||||
|
}
|
||||||
|
var deferred []prop
|
||||||
for _, rr := range sorted {
|
for _, rr := range sorted {
|
||||||
targets, ok := idx[rr.Base]
|
targets, ok := idx[rr.Base]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -418,18 +447,48 @@ func attachRubyAliasesToManual(entries []store.GlossaryEntry, readings []store.R
|
||||||
if rr.Reading == e.Src || hasAliasSurface(e.Aliases, rr.Reading) {
|
if rr.Reading == e.Src || hasAliasSurface(e.Aliases, rr.Reading) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Only an ELIGIBLE reading (passes the min-key floor or allow_short) can actually
|
fires := dstBearing(e) && rk != "" && (significantLen(rk) >= minKeyLenFor(rk) || e.AllowShort)
|
||||||
// fire and thus collide; an inert short reading is attached freely.
|
if !fires {
|
||||||
eligible := rk != "" && (significantLen(rk) >= minKeyLenFor(rk) || e.AllowShort)
|
e.Aliases = append(e.Aliases, store.GlossaryAlias{Alias: rr.Reading, AliasType: "чтение"})
|
||||||
if eligible && collides(rk, ti) {
|
|
||||||
skipped = append(skipped, fmt.Sprintf("%q reading %q (homophone of another seeded term — kana left unmatchable; disambiguate in the seed if needed)", rr.Base, rr.Reading))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
e.Aliases = append(e.Aliases, store.GlossaryAlias{Alias: rr.Reading, AliasType: "чтение"})
|
deferred = append(deferred, prop{rk: rk, reading: rr.Reading, base: rr.Base, ti: ti})
|
||||||
if eligible {
|
addOwner(rk, ti)
|
||||||
owners[rk] = append(owners[rk], ti) // now this term owns rk → a later homophone collides and is skipped, not double-attached
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contested(rk): among ALL would-fire owners of rk, two render DIFFERENT dst over OVERLAPPING
|
||||||
|
// spoiler windows — attaching for any of them would inject a wrong authoritative rendering (and
|
||||||
|
// trip the loud shared-key check). Deterministic (owner set → sorted index pairs).
|
||||||
|
contested := func(rk string) bool {
|
||||||
|
is := make([]int, 0, len(wouldOwn[rk]))
|
||||||
|
for i := range wouldOwn[rk] {
|
||||||
|
is = append(is, i)
|
||||||
|
}
|
||||||
|
sort.Ints(is)
|
||||||
|
for a := 0; a < len(is); a++ {
|
||||||
|
for b := a + 1; b < len(is); b++ {
|
||||||
|
ei, ej := entries[is[a]], entries[is[b]]
|
||||||
|
if ei.Dst != ej.Dst && windowsOverlap(ei.SinceCh, ei.UntilCh, ej.SinceCh, ej.UntilCh) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2 — attach every deferred reading whose key is uncontested; skip+log the contested ones
|
||||||
|
// (for ALL owners symmetrically, closing the first-wins hole).
|
||||||
|
for _, p := range deferred {
|
||||||
|
if contested(p.rk) {
|
||||||
|
skipped = append(skipped, fmt.Sprintf("%q reading %q (homophone of another seeded term — kana left unmatchable; disambiguate in the seed if needed)", p.base, p.reading))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := &entries[p.ti]
|
||||||
|
if hasAliasSurface(e.Aliases, p.reading) { // a same-key sibling proposal already attached this exact surface
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.Aliases = append(e.Aliases, store.GlossaryAlias{Alias: p.reading, AliasType: "чтение"})
|
||||||
}
|
}
|
||||||
return skipped
|
return skipped
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,10 +330,12 @@ func TestRubyReadingBecomesManualAlias(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRubyHomophoneSkippedNotFailLoud covers the self-review interaction: two homophone seeded
|
// TestRubyHomophoneSkippedNotFailLoud covers the self-review interaction: two homophone seeded
|
||||||
// names (鈴木/鈴樹 both read すずき, different dst) must NOT both auto-get the kana alias — that
|
// names (鈴木/鈴樹 both read すずき, different dst) must NOT get the kana alias — the reading is
|
||||||
// would make approvedSharedKeyCollisions fail the whole book loud on an alias the operator
|
// genuinely ambiguous between them, and attaching it would make approvedSharedKeyCollisions fail
|
||||||
// cannot edit out. The colliding reading is skipped+reported (graceful), and the resulting
|
// the whole book loud on an alias the operator cannot edit out. External-review MAJOR (homophone
|
||||||
// entry set passes the shared-key collision check.
|
// first-wins): the fix skips the reading for BOTH owners symmetrically (previously the
|
||||||
|
// alphabetically-first base claimed it and only the latecomer was skipped). Both readings are
|
||||||
|
// skipped+reported (graceful), and the resulting entry set passes the shared-key collision check.
|
||||||
func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) {
|
func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) {
|
||||||
entries := []store.GlossaryEntry{
|
entries := []store.GlossaryEntry{
|
||||||
{BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"},
|
{BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"},
|
||||||
|
|
@ -344,18 +346,19 @@ func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) {
|
||||||
{BookID: "b", Base: "鈴樹", Reading: "すずき", FirstChapter: 3, Occurrences: 4}, // homophone → collision
|
{BookID: "b", Base: "鈴樹", Reading: "すずき", FirstChapter: 3, Occurrences: 4}, // homophone → collision
|
||||||
}
|
}
|
||||||
skipped := attachRubyAliasesToManual(entries, readings)
|
skipped := attachRubyAliasesToManual(entries, readings)
|
||||||
if len(skipped) != 1 {
|
// BOTH readings are skipped (first-wins fix): neither base may claim a contested reading.
|
||||||
t.Fatalf("want exactly one skipped homophone reading, got %d: %v", len(skipped), skipped)
|
if len(skipped) != 2 {
|
||||||
|
t.Fatalf("want both homophone readings skipped, got %d: %v", len(skipped), skipped)
|
||||||
}
|
}
|
||||||
// Exactly ONE term got the kana alias (the first, deterministically); the other was skipped.
|
// NEITHER term got the kana alias.
|
||||||
got := 0
|
got := 0
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if hasAliasSurface(e.Aliases, "すずき") {
|
if hasAliasSurface(e.Aliases, "すずき") {
|
||||||
got++
|
got++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got != 1 {
|
if got != 0 {
|
||||||
t.Fatalf("want the kana alias on exactly one term, got %d", got)
|
t.Fatalf("want the contested kana alias on NEITHER term, got %d", got)
|
||||||
}
|
}
|
||||||
// The resulting set must NOT trip the shared-key collision fail-loud (the whole point).
|
// The resulting set must NOT trip the shared-key collision fail-loud (the whole point).
|
||||||
if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 {
|
if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 {
|
||||||
|
|
@ -363,6 +366,77 @@ func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRubyHomophoneNoWrongConfirmedInjection is the sharp mutation-verifier for the external-review
|
||||||
|
// MAJOR (homophone first-wins, class A2). Two seeded names 高橋/高梁 both read たかはし (4 kana, so
|
||||||
|
// the key is NOT collision-prone → it would fire CONFIRMED, not AMBIGUOUS). Under the old
|
||||||
|
// attach-then-block pass the alphabetically-first base won たかはし as an authoritative alias, so a
|
||||||
|
// kana chunk injected it as a CONFIRMED (and wrong) rendering for that base. The fix skips the
|
||||||
|
// reading for both, so NEITHER is injected — the kana form is left unmatchable, never wrongly
|
||||||
|
// authoritative. Reverting the fix (restoring first-wins) makes one of them fire CONFIRMED here.
|
||||||
|
func TestRubyHomophoneNoWrongConfirmedInjection(t *testing.T) {
|
||||||
|
entries := []store.GlossaryEntry{
|
||||||
|
{BookID: "b", Src: "高橋", Dst: "Такахаси", Status: "approved", Source: "seed"},
|
||||||
|
{BookID: "b", Src: "高梁", Dst: "Такаянаги", Status: "approved", Source: "seed"},
|
||||||
|
}
|
||||||
|
readings := []store.RubyReading{
|
||||||
|
{BookID: "b", Base: "高橋", Reading: "たかはし", FirstChapter: 1, Occurrences: 9},
|
||||||
|
{BookID: "b", Base: "高梁", Reading: "たかはし", FirstChapter: 1, Occurrences: 2},
|
||||||
|
}
|
||||||
|
skipped := attachRubyAliasesToManual(entries, readings)
|
||||||
|
if len(skipped) != 2 {
|
||||||
|
t.Fatalf("want both 4-kana homophone readings skipped, got %d: %v", len(skipped), skipped)
|
||||||
|
}
|
||||||
|
if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 {
|
||||||
|
t.Errorf("contested reading must not be attached, got collisions: %v", cols)
|
||||||
|
}
|
||||||
|
// End-to-end: a kana occurrence of たかはし must NOT inject either name (no wrong authoritative
|
||||||
|
// rendering). Under first-wins the alphabetically-first base would be present here.
|
||||||
|
b := materializeMemory(entries, false)
|
||||||
|
inj := injMap(b.Select("たかはしが来た。", 1, nil, 0))
|
||||||
|
if d, ok := inj["高橋"]; ok {
|
||||||
|
t.Errorf("高橋 wrongly injected (%s) for an ambiguous homophone reading", d)
|
||||||
|
}
|
||||||
|
if d, ok := inj["高梁"]; ok {
|
||||||
|
t.Errorf("高梁 wrongly injected (%s) for an ambiguous homophone reading", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRubyReadingSharedSameDstAttached locks the review minor (window/dst-eligibility awareness):
|
||||||
|
// a reading shared by two seeded terms that render the SAME dst is NOT a contradiction (both inject
|
||||||
|
// identically), and a reading shared across NON-overlapping spoiler windows is a legitimate handoff
|
||||||
|
// — neither is falsely skipped anymore. The old collides() (dst-only, window-blind) skipped both.
|
||||||
|
func TestRubyReadingSharedSameDstAttached(t *testing.T) {
|
||||||
|
// Same dst (two kanji spellings of one character) — attach to both.
|
||||||
|
same := []store.GlossaryEntry{
|
||||||
|
{BookID: "b", Src: "斉藤", Dst: "Сайто", Status: "approved", Source: "seed"},
|
||||||
|
{BookID: "b", Src: "齊藤", Dst: "Сайто", Status: "approved", Source: "seed"},
|
||||||
|
}
|
||||||
|
sameReadings := []store.RubyReading{
|
||||||
|
{BookID: "b", Base: "斉藤", Reading: "さいとう", FirstChapter: 1, Occurrences: 4},
|
||||||
|
{BookID: "b", Base: "齊藤", Reading: "さいとう", FirstChapter: 1, Occurrences: 3},
|
||||||
|
}
|
||||||
|
if skipped := attachRubyAliasesToManual(same, sameReadings); len(skipped) != 0 {
|
||||||
|
t.Fatalf("same-dst shared reading must not be skipped, got: %v", skipped)
|
||||||
|
}
|
||||||
|
for _, e := range same {
|
||||||
|
if !hasAliasSurface(e.Aliases, "さいとう") {
|
||||||
|
t.Errorf("same-dst reading not attached to %q: %+v", e.Src, e.Aliases)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Different dst but NON-overlapping windows (a spoiler handoff of the same reading) — attach both.
|
||||||
|
handoff := []store.GlossaryEntry{
|
||||||
|
{BookID: "b", Src: "序盤", Dst: "Раннее", Status: "approved", Source: "seed", SinceCh: 1, UntilCh: 100},
|
||||||
|
{BookID: "b", Src: "終盤", Dst: "Позднее", Status: "approved", Source: "seed", SinceCh: 101},
|
||||||
|
}
|
||||||
|
handoffReadings := []store.RubyReading{
|
||||||
|
{BookID: "b", Base: "序盤", Reading: "じょばん", FirstChapter: 1, Occurrences: 5},
|
||||||
|
{BookID: "b", Base: "終盤", Reading: "じょばん", FirstChapter: 101, Occurrences: 5},
|
||||||
|
}
|
||||||
|
if skipped := attachRubyAliasesToManual(handoff, handoffReadings); len(skipped) != 0 {
|
||||||
|
t.Fatalf("non-overlapping-window shared reading must not be skipped, got: %v", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSeedSurroundingWhitespaceTrimmed covers the workflow-confirmed seed major: surrounding
|
// TestSeedSurroundingWhitespaceTrimmed covers the workflow-confirmed seed major: surrounding
|
||||||
// whitespace on a keying surface passed the emptiness check but was stored raw, so the term
|
// whitespace on a keying surface passed the emptiness check but was stored raw, so the term
|
||||||
// keyed WITH the space and could never match (silently inert), and a trailing-space sense
|
// keyed WITH the space and could never match (silently inert), and a trailing-space sense
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,13 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
|
||||||
// but it determines a checkpoint's RESOLVED verdict, so a gate change must be
|
// but it determines a checkpoint's RESOLVED verdict, so a gate change must be
|
||||||
// a loud --resnapshot, not a silent re-verdict on resume (coverageSnapshot).
|
// a loud --resnapshot, not a silent re-verdict on resume (coverageSnapshot).
|
||||||
Coverage coverageSnap `json:"coverage"`
|
Coverage coverageSnap `json:"coverage"`
|
||||||
Stages []stageSnap `json:"stages"`
|
// StyleCheckVersion versions the cheap deterministic post-check rules (dialogue-dash,
|
||||||
|
// yofikator, translit-interjection blocklist, 万/億 magnitude gate — cheapgates.go). They
|
||||||
|
// are observability, not wire, but editing a rule shifts the recorded style-flag counts, so
|
||||||
|
// a bump is a loud --resnapshot (same verdict class as ClassifierVersion). The ё-policy is
|
||||||
|
// part of BriefHash (a book field), so a policy change already re-pins via brief_hash.
|
||||||
|
StyleCheckVersion string `json:"style_check_version"`
|
||||||
|
Stages []stageSnap `json:"stages"`
|
||||||
}{
|
}{
|
||||||
BriefHash: r.Book.BriefHash(),
|
BriefHash: r.Book.BriefHash(),
|
||||||
ChunkerVersion: chunkerVersion,
|
ChunkerVersion: chunkerVersion,
|
||||||
|
|
@ -371,9 +377,10 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
|
||||||
OverlapTokens: r.Pipeline.Context.OverlapTokens,
|
OverlapTokens: r.Pipeline.Context.OverlapTokens,
|
||||||
CacheTTL: r.Pipeline.Context.CacheTTL,
|
CacheTTL: r.Pipeline.Context.CacheTTL,
|
||||||
},
|
},
|
||||||
MemoryVersion: r.memoryVersion(),
|
MemoryVersion: r.memoryVersion(),
|
||||||
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
|
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
|
||||||
Coverage: r.coverageSnapshot(),
|
Coverage: r.coverageSnapshot(),
|
||||||
|
StyleCheckVersion: cheapGateVersion,
|
||||||
}
|
}
|
||||||
for _, st := range r.Pipeline.Stages {
|
for _, st := range r.Pipeline.Stages {
|
||||||
ss := stageSnap{
|
ss := stageSnap{
|
||||||
|
|
@ -501,8 +508,9 @@ func (e *CompletedWithFlags) Error() string {
|
||||||
// error) aborts with an error, from which resume continues.
|
// error) aborts with an error, from which resume continues.
|
||||||
func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
|
func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
|
||||||
// Ingest reads + normalizes the source (txt/epub) into ordered per-chapter text
|
// Ingest reads + normalizes the source (txt/epub) into ordered per-chapter text
|
||||||
// and captures ruby readings (ingest.go). Offline and deterministic ($0, no LLM).
|
// and captures ruby readings (ingest.go), decoding the txt source per book.encoding
|
||||||
doc, err := Ingest(r.Book.SourceFile)
|
// (auto/utf8/gb18030). Offline and deterministic ($0, no LLM).
|
||||||
|
doc, err := IngestEncoded(r.Book.SourceFile, r.Book.Encoding, r.Book.SourceLang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -786,11 +794,27 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cheap deterministic style/number flaggers on the FINAL text (cheapgates.go): observability
|
||||||
|
// only, never a disposition. Runs on the same fresh-or-resumed output as the post-check, so it
|
||||||
|
// is resume-reproducible; skipped when a stage flagged (no usable output). Source is ch.Text
|
||||||
|
// (the 万/億 magnitude gate compares source↔output).
|
||||||
|
var cheap cheapGateResult
|
||||||
|
if !flagged && prev != "" {
|
||||||
|
cheap = runCheapGates(ch.Text, prev, r.cheapGateConfig())
|
||||||
|
if cheap.total() > 0 {
|
||||||
|
r.Log.InfoContext(ctx, "cheap style gates flagged the chunk (observability, not a gate)",
|
||||||
|
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "style_flags", cheap.total(),
|
||||||
|
"dialogue_dash", cheap.DialogueDash, "yo", cheap.YoInconsistent,
|
||||||
|
"translit_interj", cheap.TranslitInterj, "number_magnitude", cheap.NumberMagnitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Persist the per-chunk retrieval-state (registry gate #4: convert silent memory
|
// Persist the per-chunk retrieval-state (registry gate #4: convert silent memory
|
||||||
// degradation into a loud, visible record). Deterministic + idempotent, so a resume
|
// degradation into a loud, visible record) plus the cheap style-flag counts. Deterministic +
|
||||||
// re-derives the identical row. Only when a glossary is materialized.
|
// idempotent, so a resume re-derives the identical row. Only when a glossary is materialized
|
||||||
|
// (always true in a normal run — seedGlossary sets an at-least-empty bank).
|
||||||
if r.memory != nil {
|
if r.memory != nil {
|
||||||
if err := r.persistRetrievalState(snapID, ch, memSel, postMisses, outputChecked); err != nil {
|
if err := r.persistRetrievalState(snapID, ch, memSel, postMisses, outputChecked, cheap); err != nil {
|
||||||
return out, activeIDs, err
|
return out, activeIDs, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -809,9 +833,15 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st
|
||||||
// selection + the post-check result. n_exact_hits/n_sticky/n_ambiguous count the INJECTED
|
// selection + the post-check result. n_exact_hits/n_sticky/n_ambiguous count the INJECTED
|
||||||
// records (what the model saw); spoiler/eviction are the dropped-and-logged totals;
|
// records (what the model saw); spoiler/eviction are the dropped-and-logged totals;
|
||||||
// post-check misses are recorded only when the translator actually produced text.
|
// post-check misses are recorded only when the translator actually produced text.
|
||||||
func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelection, misses []postcheckMiss, outputChecked bool) error {
|
func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelection, misses []postcheckMiss, outputChecked bool, cheap cheapGateResult) error {
|
||||||
rs := store.RetrievalState{
|
rs := store.RetrievalState{
|
||||||
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, SnapshotID: snapID,
|
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, SnapshotID: snapID,
|
||||||
|
NStyleFlags: cheap.total(),
|
||||||
|
}
|
||||||
|
if cheap.total() > 0 {
|
||||||
|
if b, err := json.Marshal(cheap); err == nil {
|
||||||
|
rs.StyleDetail = string(b)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, p := range sel.injected {
|
for _, p := range sel.injected {
|
||||||
if p.via == "sticky" {
|
if p.via == "sticky" {
|
||||||
|
|
@ -847,6 +877,16 @@ func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelect
|
||||||
return r.Store.UpsertRetrievalState(rs)
|
return r.Store.UpsertRetrievalState(rs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cheapGateConfig builds the cheap-gate knobs from the book brief: the ё-policy and the
|
||||||
|
// lower-cased per-project interjection allowlist. Pure, no store access.
|
||||||
|
func (r *Runner) cheapGateConfig() cheapGateConfig {
|
||||||
|
allow := make(map[string]bool, len(r.Book.StyleAllowlist))
|
||||||
|
for _, s := range r.Book.StyleAllowlist {
|
||||||
|
allow[s] = true
|
||||||
|
}
|
||||||
|
return cheapGateConfig{yoPolicy: r.Book.YoPolicy, allowlist: allow}
|
||||||
|
}
|
||||||
|
|
||||||
// runStage runs ONE stage of ONE chunk to a terminal disposition. It first
|
// runStage runs ONE stage of ONE chunk to a terminal disposition. It first
|
||||||
// resumes a resolved verdict (chunk_status read BEFORE any render — anti-wedge
|
// resumes a resolved verdict (chunk_status read BEFORE any render — anti-wedge
|
||||||
// #1), otherwise walks the attempt axis: render → per-attempt checkpoint resume
|
// #1), otherwise walks the attempt axis: render → per-attempt checkpoint resume
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type ChapterPassport struct {
|
||||||
StagesSkipped int `json:"stages_skipped"` // per-stage skip count (downstream of a flag)
|
StagesSkipped int `json:"stages_skipped"` // per-stage skip count (downstream of a flag)
|
||||||
Escalations int `json:"escalations"`
|
Escalations int `json:"escalations"`
|
||||||
PostcheckMisses int `json:"postcheck_misses"` // confirmed post-check misses (retrieval_state)
|
PostcheckMisses int `json:"postcheck_misses"` // confirmed post-check misses (retrieval_state)
|
||||||
|
StyleFlags int `json:"style_flags"` // cheap style/number gate hits (observability, not a disposition)
|
||||||
WorstFlagReason string `json:"worst_flag_reason,omitempty"`
|
WorstFlagReason string `json:"worst_flag_reason,omitempty"`
|
||||||
Verdict string `json:"verdict"` // pass | attention | fail (exp07: 0/1/≥2 flagged chunks)
|
Verdict string `json:"verdict"` // pass | attention | fail (exp07: 0/1/≥2 flagged chunks)
|
||||||
CostUSD float64 `json:"cost_usd"`
|
CostUSD float64 `json:"cost_usd"`
|
||||||
|
|
@ -68,8 +69,20 @@ type StatusReport struct {
|
||||||
Pending int `json:"pending"`
|
Pending int `json:"pending"`
|
||||||
PercentDone float64 `json:"percent_done"` // 100·done/total, unit-count only (no synthetic time-bar)
|
PercentDone float64 `json:"percent_done"` // 100·done/total, unit-count only (no synthetic time-bar)
|
||||||
|
|
||||||
|
// GlossaryMissFlagged counts chunks the post-check GATE promoted to flagged/glossary_miss (a
|
||||||
|
// CONFIRMED miss on an otherwise-ok chunk). A SUBSET of Flagged and NOT re-drivable: the miss
|
||||||
|
// re-derives from the unchanged glossary, so the fix is a seed edit + `translate --resnapshot`,
|
||||||
|
// never a redrive (a no-op for them). Flagged − GlossaryMissFlagged is the re-drivable remainder,
|
||||||
|
// which is what the status CLI must advise `tmctl redrive` for (minor 1d: the old blanket hint
|
||||||
|
// sent glossary_miss chunks into a redrive dead-end).
|
||||||
|
GlossaryMissFlagged int `json:"glossary_miss_flagged"`
|
||||||
|
|
||||||
Escalations int `json:"escalations"`
|
Escalations int `json:"escalations"`
|
||||||
PostcheckMisses int `json:"postcheck_misses"`
|
PostcheckMisses int `json:"postcheck_misses"`
|
||||||
|
// StyleFlags is the book-wide total of the cheap deterministic style/number gate hits
|
||||||
|
// (dialogue-dash, ё, translit-interjection, 万/億 magnitude). Observability, never a
|
||||||
|
// disposition — a nonzero count is "attention worth a human glance", not a failed chunk.
|
||||||
|
StyleFlags int `json:"style_flags"`
|
||||||
|
|
||||||
CommittedUSD float64 `json:"committed_usd"`
|
CommittedUSD float64 `json:"committed_usd"`
|
||||||
ReservedUSD float64 `json:"reserved_usd"`
|
ReservedUSD float64 `json:"reserved_usd"`
|
||||||
|
|
@ -104,10 +117,18 @@ func flagReasonSeverity(reason string) int {
|
||||||
|
|
||||||
// bookChunks re-derives the book's chunk manifest — Ingest + SplitChunks — for the honest N/M
|
// bookChunks re-derives the book's chunk manifest — Ingest + SplitChunks — for the honest N/M
|
||||||
// denominator. Pure and $0 (no LLM); unlike TranslateBook it does NOT persist ruby or seed the
|
// denominator. Pure and $0 (no LLM); unlike TranslateBook it does NOT persist ruby or seed the
|
||||||
// glossary, so status stays a pure read. A source edit or a chunker-version change is reflected
|
// glossary, so status stays a pure read. A change to the chunk BOUNDARIES (added/removed text that
|
||||||
// here (the manifest is CURRENT), which is exactly what makes a snapshot drift visible.
|
// shifts the manifest) or the chunker VERSION is reflected here.
|
||||||
|
//
|
||||||
|
// CAVEAT (minor 1d — narrowed overclaim): a source edit that leaves the boundaries unchanged (a
|
||||||
|
// typo fix inside a chunk) is INVISIBLE to status — it changes the chunk's content_hash but not the
|
||||||
|
// manifest and not the snapshot (which carries chunker_version, not the source bytes), so status
|
||||||
|
// still reads done/pass over a translation of the OLD text. Surfacing it would need a per-chunk
|
||||||
|
// content_hash re-render ($0) compared against the stored chunk_status.content_hash; deferred,
|
||||||
|
// because the resume fast-path already re-checks content_hash on the NEXT translate — which is
|
||||||
|
// where such an edit surfaces (as a re-bill of exactly the touched chunks), just not in status.
|
||||||
func (r *Runner) bookChunks() ([]Chunk, error) {
|
func (r *Runner) bookChunks() ([]Chunk, error) {
|
||||||
doc, err := Ingest(r.Book.SourceFile)
|
doc, err := IngestEncoded(r.Book.SourceFile, r.Book.Encoding, r.Book.SourceLang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -178,8 +199,10 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
missByChunk := map[chunkKey]int{}
|
missByChunk := map[chunkKey]int{}
|
||||||
|
styleByChunk := map[chunkKey]int{}
|
||||||
for _, rs := range states {
|
for _, rs := range states {
|
||||||
missByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NPostcheckMiss
|
missByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NPostcheckMiss
|
||||||
|
styleByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NStyleFlags
|
||||||
}
|
}
|
||||||
|
|
||||||
stagesTotal := len(r.Pipeline.Stages)
|
stagesTotal := len(r.Pipeline.Stages)
|
||||||
|
|
@ -209,11 +232,15 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
|
||||||
miss := missByChunk[key]
|
miss := missByChunk[key]
|
||||||
p.PostcheckMisses += miss
|
p.PostcheckMisses += miss
|
||||||
rep.PostcheckMisses += miss
|
rep.PostcheckMisses += miss
|
||||||
|
style := styleByChunk[key]
|
||||||
|
p.StyleFlags += style
|
||||||
|
rep.StyleFlags += style
|
||||||
if gateOn && state == ChunkDone && miss > 0 {
|
if gateOn && state == ChunkDone && miss > 0 {
|
||||||
// Matches translateChunk: the gate flags only an otherwise-ok chunk. Not re-drivable
|
// Matches translateChunk: the gate flags only an otherwise-ok chunk. Not re-drivable
|
||||||
// (the miss re-derives from the unchanged glossary; a fix is a glossary edit =
|
// (the miss re-derives from the unchanged glossary; a fix is a glossary edit =
|
||||||
// --resnapshot, not a redrive).
|
// --resnapshot, not a redrive) — counted separately so the CLI advises the right action.
|
||||||
state, reason = ChunkFlagged, string(FlagGlossaryMiss)
|
state, reason = ChunkFlagged, string(FlagGlossaryMiss)
|
||||||
|
rep.GlossaryMissFlagged++
|
||||||
}
|
}
|
||||||
if escalated {
|
if escalated {
|
||||||
p.Escalations++
|
p.Escalations++
|
||||||
|
|
@ -276,12 +303,9 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
|
||||||
// seed FILE that was not re-run is NOT detected here (the stored glossary is what status
|
// seed FILE that was not re-run is NOT detected here (the stored glossary is what status
|
||||||
// projects); it surfaces on the next translate's re-seed.
|
// projects); it surfaces on the next translate's re-seed.
|
||||||
if rep.Snapshot != "" {
|
if rep.Snapshot != "" {
|
||||||
if rows, gerr := r.Store.GlossaryForBook(r.Book.BookID); gerr == nil {
|
if curSnap, serr := r.currentSnapshotProjected(); serr == nil && curSnap != rep.Snapshot {
|
||||||
r.memory = materializeMemory(rows, gateOn)
|
rep.ConfigDrift = true
|
||||||
if curSnap, _, serr := r.snapshotID(); serr == nil && curSnap != rep.Snapshot {
|
rep.CurrentSnapshot = curSnap
|
||||||
rep.ConfigDrift = true
|
|
||||||
rep.CurrentSnapshot = curSnap
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,6 +329,12 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar.
|
// ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar.
|
||||||
|
// DEVIATION from D12 (which ratified an EWMA) — minor 1d, made explicit: this is a plain
|
||||||
|
// arithmetic mean, not an exponentially-weighted one. For a batch book run throughput is
|
||||||
|
// ~stationary, so the mean and an EWMA converge; a recency-weighted EWMA (which would adapt to
|
||||||
|
// a mid-run provider slowdown) is deferred because it needs the ORDERED per-call latencies, not
|
||||||
|
// the sum+count FreshCallLatencyMS returns. Kept secondary/advisory in the output so it is never
|
||||||
|
// mistaken for a committed completion time.
|
||||||
totalMS, freshCalls, err := r.Store.FreshCallLatencyMS(r.Book.BookID)
|
totalMS, freshCalls, err := r.Store.FreshCallLatencyMS(r.Book.BookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -317,6 +347,23 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
|
||||||
return rep, nil
|
return rep, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentSnapshotProjected computes the CURRENT config's snapshotID from the STORED glossary
|
||||||
|
// (read-only: materialize what is persisted, no re-seed, no write) — the single projection
|
||||||
|
// Status uses for ConfigDrift and Redrive uses to guard its destructive reset. Side effect
|
||||||
|
// (deliberate, on the read path): it sets r.memory to the stored-glossary materialization, the
|
||||||
|
// same as Status did inline. A seed-FILE edit not yet re-run is NOT reflected here (the STORED
|
||||||
|
// glossary is projected, not the seed file) — that drift surfaces on the next translate's
|
||||||
|
// re-seed, exactly as it does for `translate` itself.
|
||||||
|
func (r *Runner) currentSnapshotProjected() (string, error) {
|
||||||
|
rows, err := r.Store.GlossaryForBook(r.Book.BookID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
r.memory = materializeMemory(rows, r.Pipeline.Gates.Glossary.PostcheckGate)
|
||||||
|
id, _, err := r.snapshotID()
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
// RedriveSelector picks the flagged chunks to re-attack. -1 on Chapter/ChunkIdx means "any"
|
// RedriveSelector picks the flagged chunks to re-attack. -1 on Chapter/ChunkIdx means "any"
|
||||||
// (a valid chunk index is ≥0, a chapter ≥1, so -1 is an unambiguous "unset"); an empty Reason
|
// (a valid chunk index is ≥0, a chapter ≥1, so -1 is an unambiguous "unset"); an empty Reason
|
||||||
// matches any flag_reason. DryRun reports what WOULD be reset without touching anything.
|
// matches any flag_reason. DryRun reports what WOULD be reset without touching anything.
|
||||||
|
|
@ -388,6 +435,10 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm
|
||||||
return order[i].chunkIdx < order[j].chunkIdx
|
return order[i].chunkIdx < order[j].chunkIdx
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Build the reset PLAN first — pure, no mutation — so the destructive reset happens only
|
||||||
|
// after the drift guard below clears (Task 1c). Each target resets its flagged stage AND its
|
||||||
|
// downstream skipped stages; upstream DispOK stages are never included (D12 — never re-pay ok
|
||||||
|
// work), which is the invariant the redrive tests mutation-lock.
|
||||||
summary := &RedriveSummary{DryRun: sel.DryRun}
|
summary := &RedriveSummary{DryRun: sel.DryRun}
|
||||||
for _, k := range order {
|
for _, k := range order {
|
||||||
rows := byChunk[k]
|
rows := byChunk[k]
|
||||||
|
|
@ -402,7 +453,6 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm
|
||||||
if !targeted {
|
if !targeted {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Reset the flagged stage AND its downstream skipped stages; keep upstream DispOK.
|
|
||||||
var stages []string
|
var stages []string
|
||||||
for _, cs := range rows {
|
for _, cs := range rows {
|
||||||
if cs.Disposition == string(DispFlagged) || cs.Disposition == string(DispSkipped) {
|
if cs.Disposition == string(DispFlagged) || cs.Disposition == string(DispSkipped) {
|
||||||
|
|
@ -413,16 +463,48 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm
|
||||||
summary.Targets = append(summary.Targets, RedriveTarget{
|
summary.Targets = append(summary.Targets, RedriveTarget{
|
||||||
Chapter: k.chapter, ChunkIdx: k.chunkIdx, FlagReason: reason, Stages: stages,
|
Chapter: k.chapter, ChunkIdx: k.chunkIdx, FlagReason: reason, Stages: stages,
|
||||||
})
|
})
|
||||||
if !sel.DryRun {
|
|
||||||
if err := r.Store.ResetChunkStages(r.Book.BookID, k.chapter, k.chunkIdx, stages); err != nil {
|
|
||||||
return summary, nil, fmt.Errorf("pipeline: redrive reset ch%d/chunk%d: %w", k.chapter, k.chunkIdx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sel.DryRun || len(summary.Targets) == 0 {
|
if sel.DryRun || len(summary.Targets) == 0 {
|
||||||
return summary, nil, nil
|
return summary, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config/seed-drift guard BEFORE the destructive reset (external-review 1c). A redrive re-runs
|
||||||
|
// under the CURRENT config; if it drifted from the snapshot the stored rows carry, TranslateBook
|
||||||
|
// fails loud in runStage with the resnapshot guidance — but the OLD code had already deleted the
|
||||||
|
// flagged chunk_status rows + checkpoints (ResetChunkStages), so that fail-loud left the flag
|
||||||
|
// telemetry destroyed and a later `status` read the chunk as pending/pass.
|
||||||
|
//
|
||||||
|
// The snapshot MUST be computed exactly the way TranslateBook will compute it — i.e. AFTER
|
||||||
|
// re-seeding the glossary from the seed FILE (self-review major: a read-only stored-glossary
|
||||||
|
// projection misses a seed-FILE edit — precisely what the glossary_miss CLI hint tells the
|
||||||
|
// operator to make — so the guard would pass, the reset would fire, and the re-run's re-seed
|
||||||
|
// would then fail loud AFTER the destructive delete). seedGlossary is idempotent and touches
|
||||||
|
// only glossary rows (never chunk_status/checkpoints), so re-seeding here is safe; TranslateBook
|
||||||
|
// re-seeds again identically. A seed error (e.g. a new collision) also surfaces here, before any
|
||||||
|
// reset. Skipped under --resnapshot (the operator explicitly accepts the re-pin/re-pay).
|
||||||
|
if !r.Resnapshot {
|
||||||
|
if err := r.seedGlossary(ctx); err != nil {
|
||||||
|
return summary, nil, fmt.Errorf("pipeline: redrive seed glossary: %w", err)
|
||||||
|
}
|
||||||
|
curSnap, _, serr := r.snapshotID()
|
||||||
|
if serr != nil {
|
||||||
|
return summary, nil, fmt.Errorf("pipeline: redrive snapshot check: %w", serr)
|
||||||
|
}
|
||||||
|
for _, cs := range statuses {
|
||||||
|
if cs.SnapshotID != "" && cs.SnapshotID != curSnap {
|
||||||
|
return summary, nil, fmt.Errorf("pipeline: redrive прерван — строки книги под снапшотом %.12s, а текущий конфиг/сид рендерит %.12s (конфиг/промпты/капабилити/сид-глоссарий изменились): сброс сейчас пере-оплатил бы книгу и уничтожил бы флаг-телеметрию; повторите `translate --resnapshot`, чтобы принять пере-оплату явно, либо верните конфиг/сид", cs.SnapshotID, curSnap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructive reset — safe now that drift has been ruled out.
|
||||||
|
for _, t := range summary.Targets {
|
||||||
|
if err := r.Store.ResetChunkStages(r.Book.BookID, t.Chapter, t.ChunkIdx, t.Stages); err != nil {
|
||||||
|
return summary, nil, fmt.Errorf("pipeline: redrive reset ch%d/chunk%d: %w", t.Chapter, t.ChunkIdx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-run the durable loop: reset chunks re-attack fresh; everything else resumes at $0.
|
// Re-run the durable loop: reset chunks re-attack fresh; everything else resumes at $0.
|
||||||
res, err := r.TranslateBook(ctx)
|
res, err := r.TranslateBook(ctx)
|
||||||
summary.ResetRun = true
|
summary.ResetRun = true
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,12 @@ func TestStatusSurfacesGlossaryMissGate(t *testing.T) {
|
||||||
if rep.Flagged != 1 || rep.Done != 0 {
|
if rep.Flagged != 1 || rep.Done != 0 {
|
||||||
t.Fatalf("status must match translate (surface glossary_miss): done=%d flagged=%d, want 0/1", rep.Done, rep.Flagged)
|
t.Fatalf("status must match translate (surface glossary_miss): done=%d flagged=%d, want 0/1", rep.Done, rep.Flagged)
|
||||||
}
|
}
|
||||||
|
// The gate-promoted flag is counted as a glossary_miss (NOT re-drivable) so the CLI advises a
|
||||||
|
// seed fix + --resnapshot, not a redrive dead-end (minor 1d). Flagged − GlossaryMissFlagged = 0
|
||||||
|
// re-drivable here.
|
||||||
|
if rep.GlossaryMissFlagged != 1 {
|
||||||
|
t.Fatalf("the gate flag must be counted as glossary_miss: GlossaryMissFlagged=%d, want 1", rep.GlossaryMissFlagged)
|
||||||
|
}
|
||||||
if len(rep.Chapters) != 1 || rep.Chapters[0].WorstFlagReason != string(FlagGlossaryMiss) {
|
if len(rep.Chapters) != 1 || rep.Chapters[0].WorstFlagReason != string(FlagGlossaryMiss) {
|
||||||
t.Fatalf("passport must show glossary_miss: %+v", rep.Chapters)
|
t.Fatalf("passport must show glossary_miss: %+v", rep.Chapters)
|
||||||
}
|
}
|
||||||
|
|
@ -234,6 +240,235 @@ func TestStatusDetectsConfigDrift(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRedriveResetsOnlyFlaggedStageNotDispOK mutation-locks the D12 invariant "a redrive NEVER
|
||||||
|
// resets DispOK work" against a mutant that resets EVERY stage (external-review 1c). The existing
|
||||||
|
// end-to-end test flags the FIRST stage (draft), so its reset set == the full set and a
|
||||||
|
// reset-everything mutant survives. Here draft is OK and only EDIT is flagged, so the reset set is
|
||||||
|
// {edit} alone: the mutant (reset draft too) is caught by both the plan (Stages == [edit]) and the
|
||||||
|
// re-run cost (draft must resume at $0 → exactly ONE fresh edit call). Reverting the
|
||||||
|
// Flagged||Skipped stage filter to `true` makes draft re-bill and Stages == [draft,edit].
|
||||||
|
func TestRedriveResetsOnlyFlaggedStageNotDispOK(t *testing.T) {
|
||||||
|
rec := &reqRec{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
refuseEdit := true // the EDIT stage refuses until flipped; draft is always OK
|
||||||
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||||||
|
if isEditBody(body) {
|
||||||
|
mu.Lock()
|
||||||
|
r := refuseEdit
|
||||||
|
mu.Unlock()
|
||||||
|
if r {
|
||||||
|
return "Извините, я не могу продолжить.", "stop" // soft refusal (matches the blacklist) → edit flagged, non-retryable
|
||||||
|
}
|
||||||
|
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
|
||||||
|
}
|
||||||
|
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОДНАГЛАВА", regenerate: 0})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
r1 := newRunner(t, bookPath)
|
||||||
|
if _, err := r1.TranslateBook(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
callsAfterRun1 := rec.count() // draft(ok) + edit(refusal) = 2
|
||||||
|
if callsAfterRun1 != 2 {
|
||||||
|
t.Fatalf("want 2 provider calls after run1, got %d", callsAfterRun1)
|
||||||
|
}
|
||||||
|
// draft is OK, edit is flagged.
|
||||||
|
if cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "ok" {
|
||||||
|
t.Fatalf("draft must be ok: %+v", cs)
|
||||||
|
}
|
||||||
|
if cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "edit"); cs == nil || cs.Disposition != "flagged" {
|
||||||
|
t.Fatalf("edit must be flagged: %+v", cs)
|
||||||
|
}
|
||||||
|
r1.Close()
|
||||||
|
|
||||||
|
// Dry-run plan: only the EDIT stage is reset — the DispOK draft is untouched (the invariant).
|
||||||
|
r2 := newRunner(t, bookPath)
|
||||||
|
sum, _, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, DryRun: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sum.Targets) != 1 {
|
||||||
|
t.Fatalf("want 1 target, got %+v", sum.Targets)
|
||||||
|
}
|
||||||
|
if got := strings.Join(sum.Targets[0].Stages, ","); got != "edit" {
|
||||||
|
t.Fatalf("reset plan = %q, want just \"edit\" (draft is DispOK and must not be reset)", got)
|
||||||
|
}
|
||||||
|
r2.Close()
|
||||||
|
|
||||||
|
// Real redrive with the editor fixed: only edit re-runs (1 fresh call); draft resumes at $0.
|
||||||
|
mu.Lock()
|
||||||
|
refuseEdit = false
|
||||||
|
mu.Unlock()
|
||||||
|
r3 := newRunner(t, bookPath)
|
||||||
|
defer r3.Close()
|
||||||
|
sum3, res3, err := r3.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sum3.ResetRun || res3 == nil || res3.Flagged != 0 {
|
||||||
|
t.Fatalf("redrive should reset+re-run and clear the flag: %+v", res3)
|
||||||
|
}
|
||||||
|
if got := rec.count() - callsAfterRun1; got != 1 {
|
||||||
|
t.Fatalf("redrive must make exactly ONE fresh call (edit only; draft resumes $0), made %d", got)
|
||||||
|
}
|
||||||
|
// The draft checkpoint was never discarded — its cost is not re-billed.
|
||||||
|
committed, _, _ := r3.Store.SpentUSD("test-book")
|
||||||
|
if math.Abs(committed-3*fakeCallUSD) > 1e-9 {
|
||||||
|
t.Errorf("committed = %v, want %v (draft + refused-edit + redriven-edit)", committed, 3*fakeCallUSD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRedriveReasonSelectorMismatch locks the --reason selector: a redrive whose reason does not
|
||||||
|
// match the flagged chunk's flag_reason must find NO target and touch nothing (mutating
|
||||||
|
// RedriveSelector.matches to ignore Reason would wrongly re-attack the chunk). The matching reason
|
||||||
|
// then does select it (so the test also proves the selector is live, not a no-op that always misses).
|
||||||
|
func TestRedriveReasonSelectorMismatch(t *testing.T) {
|
||||||
|
rec := &reqRec{}
|
||||||
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||||||
|
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
|
||||||
|
return "Извините, я не могу перевести это.", "stop" // soft_refusal
|
||||||
|
}
|
||||||
|
return draftEdit(body)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
r := newRunner(t, bookPath)
|
||||||
|
defer r.Close()
|
||||||
|
if _, err := r.TranslateBook(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calls := rec.count()
|
||||||
|
|
||||||
|
// Non-matching reason → no target, no reset, no re-run.
|
||||||
|
sum, res, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagLength)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sum.Targets) != 0 || sum.ResetRun || res != nil {
|
||||||
|
t.Fatalf("a non-matching --reason must select nothing, got %+v", sum)
|
||||||
|
}
|
||||||
|
if rec.count() != calls {
|
||||||
|
t.Fatal("non-matching redrive made provider calls")
|
||||||
|
}
|
||||||
|
if cs, _ := r.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
|
||||||
|
t.Fatalf("the flagged row must be untouched after a non-matching redrive: %+v", cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The MATCHING reason does select it (dry-run, so nothing is mutated) — proves the selector fires.
|
||||||
|
sum2, _, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagSoftRefusal), DryRun: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sum2.Targets) != 1 {
|
||||||
|
t.Fatalf("the matching --reason must select the flagged chunk, got %+v", sum2.Targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRedriveAbortsOnConfigDriftPreservingTelemetry locks Task 1c: a redrive under a DRIFTED config
|
||||||
|
// must refuse loudly BEFORE the destructive reset, leaving the flag telemetry intact. The old code
|
||||||
|
// reset (deleted) the flagged chunk_status + checkpoints first, then TranslateBook failed loud on
|
||||||
|
// the snapshot mismatch — so the flagged row was gone and status read the chunk as pending/pass.
|
||||||
|
// Reverting the guard deletes the flagged draft row here; the "still flagged" assertion catches it.
|
||||||
|
func TestRedriveAbortsOnConfigDriftPreservingTelemetry(t *testing.T) {
|
||||||
|
rec := &reqRec{}
|
||||||
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||||||
|
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
|
||||||
|
return "Извините, я не могу перевести это.", "stop"
|
||||||
|
}
|
||||||
|
return draftEdit(body)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
r1 := newRunner(t, bookPath)
|
||||||
|
if _, err := r1.TranslateBook(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r1.Close()
|
||||||
|
callsAfterRun := rec.count()
|
||||||
|
|
||||||
|
// Drift the config: bump the draft prompt_version → the current config renders a new snapshot.
|
||||||
|
dir := filepath.Dir(bookPath)
|
||||||
|
body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed := strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test-drift", 1)
|
||||||
|
if changed == string(body) {
|
||||||
|
t.Fatal("setup: prompt_version token not found")
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed)
|
||||||
|
|
||||||
|
r2 := newRunner(t, bookPath)
|
||||||
|
defer r2.Close()
|
||||||
|
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a drifted config must make redrive fail loud")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "snapshot") && !strings.Contains(err.Error(), "снапшот") {
|
||||||
|
t.Errorf("drift error should mention the snapshot mismatch, got: %v", err)
|
||||||
|
}
|
||||||
|
if res != nil || sum.ResetRun {
|
||||||
|
t.Errorf("a drift-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
|
||||||
|
}
|
||||||
|
// The whole point: the flag telemetry is intact (the row was NOT reset).
|
||||||
|
if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
|
||||||
|
t.Fatalf("the flagged row must survive a drift-aborted redrive, got: %+v", cs)
|
||||||
|
}
|
||||||
|
if rec.count() != callsAfterRun {
|
||||||
|
t.Fatal("a drift-aborted redrive must make no provider calls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRedriveAbortsOnSeedFileDriftPreservingTelemetry locks the self-review MAJOR: the redrive guard
|
||||||
|
// must catch a SEED-FILE edit (not just a config-file edit), because that is exactly what the
|
||||||
|
// glossary_miss CLI hint tells the operator to make. The guard re-seeds (like TranslateBook will) so
|
||||||
|
// an approved-term change shifts the snapshot and the redrive aborts BEFORE the destructive reset —
|
||||||
|
// leaving the flag telemetry intact. A read-only stored-glossary projection would miss it, reset,
|
||||||
|
// then fail loud on the re-seed AFTER deleting the flagged rows.
|
||||||
|
func TestRedriveAbortsOnSeedFileDriftPreservingTelemetry(t *testing.T) {
|
||||||
|
seed := "terms:\n - src: 蛊\n dst: Гу\n status: approved\n"
|
||||||
|
rec := &reqRec{}
|
||||||
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||||||
|
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
|
||||||
|
return "Извините, я не могу перевести это.", "stop"
|
||||||
|
}
|
||||||
|
return draftEdit(body)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0, glossarySeed: seed})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
r1 := newRunner(t, bookPath)
|
||||||
|
if _, err := r1.TranslateBook(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r1.Close()
|
||||||
|
|
||||||
|
// Edit the seed FILE — change the approved dst → different memoryVersion → different snapshot.
|
||||||
|
dir := filepath.Dir(bookPath)
|
||||||
|
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), "terms:\n - src: 蛊\n dst: Гу-изменённый\n status: approved\n")
|
||||||
|
|
||||||
|
r2 := newRunner(t, bookPath)
|
||||||
|
defer r2.Close()
|
||||||
|
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a seed-FILE edit must make redrive fail loud (re-seed drift guard)")
|
||||||
|
}
|
||||||
|
if res != nil || sum.ResetRun {
|
||||||
|
t.Errorf("a seed-drift-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
|
||||||
|
}
|
||||||
|
if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
|
||||||
|
t.Fatalf("the flagged row must survive a seed-drift-aborted redrive, got: %+v", cs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRedriveNoTargets confirms a selector that matches nothing is a clean no-op (no re-run).
|
// TestRedriveNoTargets confirms a selector that matches nothing is a clean no-op (no re-run).
|
||||||
func TestRedriveNoTargets(t *testing.T) {
|
func TestRedriveNoTargets(t *testing.T) {
|
||||||
rec := &reqRec{}
|
rec := &reqRec{}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ type RetrievalState struct {
|
||||||
NPostcheckMiss int
|
NPostcheckMiss int
|
||||||
PostcheckDetail string // JSON
|
PostcheckDetail string // JSON
|
||||||
InjectedIDs string // JSON
|
InjectedIDs string // JSON
|
||||||
|
NStyleFlags int // cheap style/number gate hits (dialogue-dash, ё, translit-interj, 万/億)
|
||||||
|
StyleDetail string // JSON breakdown of the style-flag hits
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceGlossary replaces a book's ENTIRE glossary (rows + aliases) with the given
|
// ReplaceGlossary replaces a book's ENTIRE glossary (rows + aliases) with the given
|
||||||
|
|
@ -246,8 +248,9 @@ func (s *Store) UpsertRetrievalState(rs RetrievalState) error {
|
||||||
INSERT INTO retrieval_state (
|
INSERT INTO retrieval_state (
|
||||||
book_id, chapter, chunk_idx, snapshot_id,
|
book_id, chapter, chunk_idx, snapshot_id,
|
||||||
n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked, n_evicted,
|
n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked, n_evicted,
|
||||||
embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids, updated_at
|
embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids,
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
n_style_flags, style_detail, updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
||||||
ON CONFLICT (book_id, chapter, chunk_idx) DO UPDATE SET
|
ON CONFLICT (book_id, chapter, chunk_idx) DO UPDATE SET
|
||||||
snapshot_id = excluded.snapshot_id,
|
snapshot_id = excluded.snapshot_id,
|
||||||
n_exact_hits = excluded.n_exact_hits,
|
n_exact_hits = excluded.n_exact_hits,
|
||||||
|
|
@ -259,10 +262,13 @@ func (s *Store) UpsertRetrievalState(rs RetrievalState) error {
|
||||||
n_postcheck_miss = excluded.n_postcheck_miss,
|
n_postcheck_miss = excluded.n_postcheck_miss,
|
||||||
postcheck_detail = excluded.postcheck_detail,
|
postcheck_detail = excluded.postcheck_detail,
|
||||||
injected_ids = excluded.injected_ids,
|
injected_ids = excluded.injected_ids,
|
||||||
|
n_style_flags = excluded.n_style_flags,
|
||||||
|
style_detail = excluded.style_detail,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
rs.BookID, rs.Chapter, rs.ChunkIdx, rs.SnapshotID,
|
rs.BookID, rs.Chapter, rs.ChunkIdx, rs.SnapshotID,
|
||||||
rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged, rs.NSpoilerBlocked, rs.NEvicted,
|
rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged, rs.NSpoilerBlocked, rs.NEvicted,
|
||||||
rs.EmbeddingTierUsed, rs.NPostcheckMiss, rs.PostcheckDetail, rs.InjectedIDs)
|
rs.EmbeddingTierUsed, rs.NPostcheckMiss, rs.PostcheckDetail, rs.InjectedIDs,
|
||||||
|
rs.NStyleFlags, rs.StyleDetail)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,11 +279,13 @@ func (s *Store) GetRetrievalState(bookID string, chapter, chunkIdx int) (*Retrie
|
||||||
rs := RetrievalState{BookID: bookID, Chapter: chapter, ChunkIdx: chunkIdx}
|
rs := RetrievalState{BookID: bookID, Chapter: chapter, ChunkIdx: chunkIdx}
|
||||||
err := s.r.QueryRowContext(ctx, `
|
err := s.r.QueryRowContext(ctx, `
|
||||||
SELECT snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked,
|
SELECT snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked,
|
||||||
n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids
|
n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids,
|
||||||
|
n_style_flags, style_detail
|
||||||
FROM retrieval_state WHERE book_id = ? AND chapter = ? AND chunk_idx = ?`,
|
FROM retrieval_state WHERE book_id = ? AND chapter = ? AND chunk_idx = ?`,
|
||||||
bookID, chapter, chunkIdx).Scan(
|
bookID, chapter, chunkIdx).Scan(
|
||||||
&rs.SnapshotID, &rs.NExactHits, &rs.NSticky, &rs.NAmbiguousFlagged, &rs.NSpoilerBlocked,
|
&rs.SnapshotID, &rs.NExactHits, &rs.NSticky, &rs.NAmbiguousFlagged, &rs.NSpoilerBlocked,
|
||||||
&rs.NEvicted, &rs.EmbeddingTierUsed, &rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs)
|
&rs.NEvicted, &rs.EmbeddingTierUsed, &rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs,
|
||||||
|
&rs.NStyleFlags, &rs.StyleDetail)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +302,8 @@ func (s *Store) RetrievalStatesForBook(bookID string) ([]RetrievalState, error)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
rows, err := s.r.QueryContext(ctx, `
|
rows, err := s.r.QueryContext(ctx, `
|
||||||
SELECT chapter, chunk_idx, snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged,
|
SELECT chapter, chunk_idx, snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged,
|
||||||
n_spoiler_blocked, n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids
|
n_spoiler_blocked, n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids,
|
||||||
|
n_style_flags, style_detail
|
||||||
FROM retrieval_state WHERE book_id = ?
|
FROM retrieval_state WHERE book_id = ?
|
||||||
ORDER BY chapter, chunk_idx`, bookID)
|
ORDER BY chapter, chunk_idx`, bookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -306,7 +315,8 @@ func (s *Store) RetrievalStatesForBook(bookID string) ([]RetrievalState, error)
|
||||||
rs := RetrievalState{BookID: bookID}
|
rs := RetrievalState{BookID: bookID}
|
||||||
if err := rows.Scan(&rs.Chapter, &rs.ChunkIdx, &rs.SnapshotID, &rs.NExactHits, &rs.NSticky,
|
if err := rows.Scan(&rs.Chapter, &rs.ChunkIdx, &rs.SnapshotID, &rs.NExactHits, &rs.NSticky,
|
||||||
&rs.NAmbiguousFlagged, &rs.NSpoilerBlocked, &rs.NEvicted, &rs.EmbeddingTierUsed,
|
&rs.NAmbiguousFlagged, &rs.NSpoilerBlocked, &rs.NEvicted, &rs.EmbeddingTierUsed,
|
||||||
&rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs); err != nil {
|
&rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs,
|
||||||
|
&rs.NStyleFlags, &rs.StyleDetail); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, rs)
|
out = append(out, rs)
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,15 @@ var migrations = []string{
|
||||||
ALTER TABLE chunk_status ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE chunk_status ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0;
|
||||||
ALTER TABLE chunk_status ADD COLUMN escalation_model TEXT NOT NULL DEFAULT '';
|
ALTER TABLE chunk_status ADD COLUMN escalation_model TEXT NOT NULL DEFAULT '';
|
||||||
`,
|
`,
|
||||||
|
// v7: the cheap deterministic style flaggers (dialogue-dash, yofikator, translit-interjection
|
||||||
|
// blocklist, 万/億 magnitude gate — cheapgates.go) ride the same per-chunk observability row as
|
||||||
|
// the glossary post-check. n_style_flags is the total hit count; style_detail is the JSON
|
||||||
|
// breakdown for the report. Recomputed deterministically each run (self-heals on resume), NOT
|
||||||
|
// wire-load-bearing (observability, never a disposition).
|
||||||
|
`
|
||||||
|
ALTER TABLE retrieval_state ADD COLUMN n_style_flags INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE retrieval_state ADD COLUMN style_detail TEXT NOT NULL DEFAULT '';
|
||||||
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate runs all pending migrations on the write pool, one transaction per
|
// migrate runs all pending migrations on the write pool, one transaction per
|
||||||
|
|
|
||||||
|
|
@ -573,6 +573,26 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор
|
||||||
- Известная граница `status`/`redrive`: glossary_miss re-деривится из неизменного глоссария → не re-drivable (фикс = правка глоссария = --resnapshot); status теперь честно это показывает.
|
- Известная граница `status`/`redrive`: glossary_miss re-деривится из неизменного глоссария → не re-drivable (фикс = правка глоссария = --resnapshot); status теперь честно это показывает.
|
||||||
- F3 at-most-once — следующий D12-хвост (status/redrive закрыты).
|
- F3 at-most-once — следующий D12-хвост (status/redrive закрыты).
|
||||||
|
|
||||||
|
### 2026-07-09 (пакет-2) — Приёмка-prep Ф1 на 蛊真人 (fix-лист ревью + GB18030 + дешёвые гейты + D15.2 v2 + smoke) ЗАВЕРШЕНА
|
||||||
|
|
||||||
|
Мандат `BACKEND_SESSION_PROMPT_ACCEPTANCE_PREP.md` (5 задач) выполнен. Порядок 1 → 2 → (3∥4) → 5. `go build/vet/test ./... -race` зелёные; фиксы mutation-verified; `tmctl report` $0. Смоук-книга/производные/сид — ВНЕ git (Р8; в журнал только метрики).
|
||||||
|
|
||||||
|
- **Задача 1 — fix-лист внешнего ревью.** (1a, **major, mutation-verified**) homophone first-wins в `attachRubyAliasesToManual`: контестед-ключ решается ДО аттача для ВСЕХ владельцев симметрично (было attach-then-block → алфавитно-первая база получала kana-alias CONFIRMED = A2 неверная авторитетная инъекция при ≥4 каны). Плюс window+dst-eligibility awareness (минор 1d — меньше ложных скипов). Ревертом ловится тест `TestRubyHomophoneNoWrongConfirmedInjection` (たかはし 4-каны материализуется → NEITHER инъектится). (1b) redrive-инвариант «DispOK НЕ сбрасывается» **закреплён мутационно** (draft=ok/edit=flagged → сброс ТОЛЬКО edit, draft резюмится за $0; мутант `filter→true` даёт Stages=[draft,edit] → тест валит) + тест НЕсовпадающего `--reason`. (1c) snapshot-чек ДО `ResetChunkStages` (**mutation-verified**: без гарда drift-редрайв удаляет флаг-строку → status pending/pass). (1d) minors: `--json` тоже exit 2 при флагах; hint для glossary_miss отдельный (redrive для него no-op → правка сида + --resnapshot); README миграции v1–v6; ETA — явная пометка D12-отступления (mean, не EWMA); overclaim-коммент `bookChunks` сужен (правка исходника без смены границ невидима status'у).
|
||||||
|
- **Задача 2 — GB18030/кодировки в ingest.** `decodeSourceBytes`: лестница BOM(UTF-8/UTF-16) → валидный UTF-8 → **GB18030-проба (только zh-источник)** → fail-loud. Гарды: U+FFFD, NUL (UTF-16-без-BOM), Han-density+plausibleCJK (анти-мохибаке). `book.yaml encoding: auto|utf8|gb18030` + `source_lang`-скоуп пробы. Chapter-split добавлен для txt: **«第N章/节/回» авто-детект дом. юнита + separator-guard** (回 — мера, «第一回见面» НЕ дробит; сепаратор после юнита обязателен), preamble→гл.1. Новая внешняя зависимость: `golang.org/x/text/encoding/{simplifiedchinese,unicode}` (+ `japanese` в тестах) — первая за пределами go.sum-минимума (x/text уже был для NFC).
|
||||||
|
- **Задача 3 — 4 дешёвых детерминированных гейта (наблюдаемость, НЕ гейты, на финальном тексте).** Линтер тире-диалогов (Розенталь §47–52: прямые кавычки/дефис/en-dash вместо «—», смешение стилей), ёфикатор (Пётр/Петр + brief-политика all-ё/all-е, ~30 омографов-исключений), блок-лист транслит-междометий (ара-ара/маа/нани/…; per-project allowlist), число-гейт 万/億 (CJK-парсер значений + диапазоны-множители на выходе; digit-coefficient-guard исключает идиомы 万一/千万/万物). `cheapGateVersion` свёрнут в snapshot (правка правил = --resnapshot, как classifierVersion); миграция store **v7** (`retrieval_state.n_style_flags`+`style_detail`); счётчики в `tmctl status`/`report`/паспорте главы + `StatusReport.StyleFlags`/`GlossaryMissFlagged`.
|
||||||
|
- **Задача 4 — спека D15.2 v2** (`backend/docs/D15.2-content-addressed-resume-spec.md`, дизайн, реализация ОСТАЁТСЯ заблокированной до ратификации v2). Закрыты 3 дыры ревью: (a) fast-path-гард — отдельная колонка `guard_hash` (весь wire stage-план, суперсет текущего); (b) расщепление `memory_version` ОБЯЗАТЕЛЬНО — inject-часть в `content_hash` (из ключа вон), post-check+gate+decl → `verdictSnapshotID` (пересчёт на resume бесплатно); (c) замена loud-гейта согласия pre-flight dry-run «N чанков, ~$X» + порог `--accept-rebill`. Плюс: полный полевой маппинг снапшота (wire/verdict/dropped), судьба `chunk_status.snapshot_id`/status-ридеров, sequencing миграции (ДО первой онгоинг-книги; миграция v8), blast-radius append-а (sticky depth2 + F2 eviction + каскад на редактор). 3 открытых вопроса оркестратору в §13. **На утверждение.**
|
||||||
|
- **Задача 5 — D18 smoke на 蛊真人.** (а) **ingest+chunker на реальном GB18030-файле (15.5 МБ):** 2283 главы (第N节-детект), **5088 чанков** (медиана 1445 ток., целевой 1500), ~6.52M токенов (медиана главы 2832), 0 ruby (zh), U+FFFD=0. (б) escalate_to заведён (интерим glm-5 фолбэком черновика). (в) **живой smoke 1 главы (draft=deepseek-v4-flash → edit=glm-5, escalate=glm-5; БЕЗ xAI — гигиена ключа D8 не подтверждена для книжного контента; потолок $0.10):** деньги сходятся **до цента** — Σ(request_log billed) == committed == **$0.031634**, reserved=$0; **живое эхо+эскалация** (chunk3 deepseek→cjk_artifact→glm-5 ok); **redrive на реальном флаге** (esc-off вариант дал cjk_artifact-флаг → redrive сбросил+перезапустил, DispOK за $0, 2-й проход: empty→удвоение бюджета→ok → флаг снят, 6/6 done); cheap gates фаернули на реальном выводе (万/億-разряды, дефис-диалоги). Полигонный сид (49 терминов, 35 approved/14 draft) грузится **чисто** через мой `loadGlossarySeed`+`approvedSharedKeyCollisions`+`materializeMemory` (схема совпадает). Суммарный живой спенд сессии ≈ $0.065.
|
||||||
|
- **Финал — агентское адверсариальное селфревью** (6 дименсий × find→refute-by-default verify, 21 агент, ~835k ток.): **15 находок, 14 подтверждено, все закрыты** с регресс-тестами. Крит: Shift-JIS/EUC-JP молча принимался как GB18030-мохибаке (плаузибилити не отличает CJK-shaped мусор) → GB18030-проба скоупнута на zh (**mutation-verified**). Мажоры: seed-FILE drift не ловился redrive-гардом (read-only проекция) → гард **re-seed'ит** как TranslateBook, ловит ДО сброса (**mutation-verified**); UTF-16-без-BOM ASCII (interleaved NUL) → NUL-гард; chapter false-split на 回 → separator-guard; cheapgate ложные позитивы («уму»/«ара» убраны из блок-листа, 万-идиомы digit-guard, ё-омографы расширены, em-dash scene-break не считается диалогом); plausibleCJK получил прямой тест (была 0-покрытость). Миноры: StyleAllowlist сортируется до BriefHash, redrive-abort-сообщение переформулировано.
|
||||||
|
|
||||||
|
**Блокеры полной приёмки (владельцу/оркестратору):**
|
||||||
|
1. **Сид готов, но политические развязки D10 открыты** (полигон §вопросы п.3): имена гу перевод-vs-транслит, 古月=Гуюэ vs Гу Юэ, пол 白凝冰 (draft/female — 他 в первых главах, gender=hidden трап). Гейтят финализацию сида.
|
||||||
|
2. **Пороги гейтов:** coverage len_ratio zh-ru [2.2,4.2] (полигон подтвердил на срезе); postcheck-гейт держать OFF до замера precision на реальной инфлекции (E1); дешёвые стиль-гейты — наблюдаемость, порогов не имеют.
|
||||||
|
3. **Канал 18+/violence:** полигон — grok thinking-off эхает 40% как ПЕРЕВОДЧИК (класс deepseek-эхо) → канал B: Mistral-переводчик + echo-гейт, ИЛИ grok reasoning-ON + reasoning-буфер (D6.2). xAI clean-key для боевого прогона (гигиена D8) — не подтверждён; smoke сознательно обошёл xAI.
|
||||||
|
4. **D3-цепочка эскалации не заведена** (deepseek-v4-pro/glm-5.1/gemini — цены/ключи) — интерим glm-5; заводка — отдельный шаг.
|
||||||
|
5. Полный прогон целой книги — **отдельная сессия** по готовности сида (п.1) + порогов (п.2) + канала (п.3).
|
||||||
|
|
||||||
|
**Вопросы оркестратору:** (1) **D15.2 v2 на ратификацию** (3 развязки в §13: пер-стадийная гранулярность wire-снапшота vs book-global; дефолт порога `rebill_consent_usd` + имя флага; слой для `Adult`). (2) Редактор smoke — glm-5 вместо ратифицированного grok-4.3 из-за xAI-гигиены: для боевой приёмки нужен ЧИСТЫЙ xAI-ключ ИЛИ решение держать glm/Mistral редактором. (3) ja-путь: Shift-JIS сознательно НЕ авто-детектится (fail-loud) — ja-книги должны быть UTF-8/epub (ja-приёмка отложена D18 — ок).
|
||||||
|
|
||||||
## Полигон
|
## Полигон
|
||||||
(секция параллельной сессии — записи добавлять сюда)
|
(секция параллельной сессии — записи добавлять сюда)
|
||||||
|
|
||||||
|
|
@ -781,3 +801,23 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор
|
||||||
> **[ГИГИЕНА]** Модель полигона `qwen3-abliterated:30b-a3b` (6.4 ГБ VRAM) **не выселялась** — GPU-проба шла в свободном CUDA-окне (WSL2 спилит в shared RAM), после — cleanup. Артефакты: `eval/adaptive_probe.py`, `eval/adaptive_incontext.py` (+ `eval/data/*.json`), новые доки — только `research/14`. Изолированный CUDA-env (Pascal-проба) — в scratchpad, не в репозитории. Полный потолок QLoRA-7B+reranker на **чистых** 8ГБ — за скоординированным стенд-окном (не мерил, чтоб не выселять 30b); на вердикт не влияет (кандидата нет).
|
> **[ГИГИЕНА]** Модель полигона `qwen3-abliterated:30b-a3b` (6.4 ГБ VRAM) **не выселялась** — GPU-проба шла в свободном CUDA-окне (WSL2 спилит в shared RAM), после — cleanup. Артефакты: `eval/adaptive_probe.py`, `eval/adaptive_incontext.py` (+ `eval/data/*.json`), новые доки — только `research/14`. Изолированный CUDA-env (Pascal-проба) — в scratchpad, не в репозитории. Полный потолок QLoRA-7B+reranker на **чистых** 8ГБ — за скоординированным стенд-окном (не мерил, чтоб не выселять 30b); на вердикт не влияет (кандидата нет).
|
||||||
|
|
||||||
> **[ДОБАВЛЕНО, 05.07 — «долбить детерминированную сторону»]** По запросу владельца (раз гибрид отклонён — максимизировать коэффициент канон-консистентности детерминированно) — замер рычагов L1→L4 на ТРУДНОМ чанке (`eval/adaptive_levers.py`, 11 сущностей, deepseek+grok), research/14 §9. **Порядок бэкенду:** **L1 post-check ЛЕММАТИЗИРУЮЩИЙ (pymorphy3), не регэксп** — ПЕРВЫМ (наивный регэксп даёт **18–36% ЛОЖНЫХ флагов** на трудном чанке; pymorphy сворачивает и OOV-транслит Вана→ван; количественная валидация несущего риска research/13 §2). **L2 recall — нормализация ПОЛНАЯ+тестируемая (OpenCC, не мини-карта: живой A4-баг 爺→爷 молча промахнул 赵太爷) + alias-граф + sticky** (recall 0.00→0.50→0.75). **L3 re-ask** (deepseek 0.82→0.91). **L4 таблица Палладия (B6) — потолок ПРАВИЛЬНОСТИ.** Побочно: 钱 (односимвольный гомограф Цянь/деньги) упорно промахивается → **min_key_len/запрет одиночных ключей A3 обязателен.** Поправка §2: soft-глоссарий на лёгком чанке = **~1.0** (0.875 был регэксп-артефакт), demos не бьют — потолок. Артефакты: `eval/adaptive_reask.py`, `eval/adaptive_levers.py`. Инстинкт «тюнить ранкер» — легитимен только во 2-м эшелоне (bge-m3 без разделяющего порога), не в костяке.
|
> **[ДОБАВЛЕНО, 05.07 — «долбить детерминированную сторону»]** По запросу владельца (раз гибрид отклонён — максимизировать коэффициент канон-консистентности детерминированно) — замер рычагов L1→L4 на ТРУДНОМ чанке (`eval/adaptive_levers.py`, 11 сущностей, deepseek+grok), research/14 §9. **Порядок бэкенду:** **L1 post-check ЛЕММАТИЗИРУЮЩИЙ (pymorphy3), не регэксп** — ПЕРВЫМ (наивный регэксп даёт **18–36% ЛОЖНЫХ флагов** на трудном чанке; pymorphy сворачивает и OOV-транслит Вана→ван; количественная валидация несущего риска research/13 §2). **L2 recall — нормализация ПОЛНАЯ+тестируемая (OpenCC, не мини-карта: живой A4-баг 爺→爷 молча промахнул 赵太爷) + alias-граф + sticky** (recall 0.00→0.50→0.75). **L3 re-ask** (deepseek 0.82→0.91). **L4 таблица Палладия (B6) — потолок ПРАВИЛЬНОСТИ.** Побочно: 钱 (односимвольный гомограф Цянь/деньги) упорно промахивается → **min_key_len/запрет одиночных ключей A3 обязателен.** Поправка §2: soft-глоссарий на лёгком чанке = **~1.0** (0.875 был регэксп-артефакт), demos не бьют — потолок. Артефакты: `eval/adaptive_reask.py`, `eval/adaptive_levers.py`. Инстинкт «тюнить ранкер» — легитимен только во 2-м эшелоне (bge-m3 без разделяющего порога), не в костяке.
|
||||||
|
|
||||||
|
## Голос и состояние
|
||||||
|
(секция ресёрч-сессии «Голос и состояние» — записи добавлять сюда)
|
||||||
|
|
||||||
|
### 2026-07-09 — Сессия «Голос и состояние» ЗАВЕРШЕНА → [research/15-voice-and-state.md](research/15-voice-and-state.md)
|
||||||
|
|
||||||
|
Метод: 12 направлений-сборщиков → адверсариальная верификация несущих клеймов refute-by-default (~60 верификаторов) → completeness-критик → 6 доборов (91 агент, ~3.2M ток., 0 отказов) + **живые пробы на SFW-чанках 蛊真人** ($0.04 из $2.50; сырые артефакты в scratchpad сессии; боевой драфт-промпт deepseek, редактор grok `reasoning_effort:none`, судьи кросс-семейно со свапом позиций). Зоны чужие не тронуты, ничего не закоммичено.
|
||||||
|
|
||||||
|
**Вердикт (кратко):**
|
||||||
|
- **Клейм «voice exemplars > дескрипторов» — REFUTED в универсальной форме** (прямого сравнения не существует нигде; ChatHaruhi количественной абляции НЕ содержит), но количественное ядро есть: RoleLLM — формат демо важнее наличия (9.85→30.19→61.79% win-rate); дистиллированный voice-sheet бьёт RAG-пример; насыщение ~5 реплик; **similarity-retrieval вредит** (69→36 AA) — удачно совпадает с детерминизмом банка. Строить **гибрид «1 строка регистра + 3–5 RU-реплик»**. Проба подтвердила направление: гибрид — ранг-1 у судей 5/5 (2 семейства); детерминированный маркер (奴婢-самоуничижение служанки): база 1/4, дескрипторы 3/4, **экземпляры/гибрид 4/4**; на «лёгкой» сцене (братья) инъекция нейтральна/шумит → **селективность по яркости голоса**.
|
||||||
|
- **Голос выигрывается на ДРАФТЕ:** grok-редактор с узким мандатом вернул черновик **дословно** в 3/4 проб (сглаживание НЕ воспроизвелось; восстановление сплющенного голоса voice-блоком — тоже НЕ произошло). Редактору — констрейнты сохранения, переводчику — инъекция.
|
||||||
|
- **Прайор-арт: клейм стратревью «аналогов нет» сузить, не снять.** Пер-персонажный голос в промышленном MT существует как продукт-клейм (NAVER «캐릭터 인식 번역», Mantra, XL8 — механизмы не раскрыты); детерминированный no-LLM словарный слой и селективная инъекция по матчу — массовая практика фан-стека (GalTransl/LunaTranslator/SakuraLLM — «учебник» найден). **Уникальными остаются:** спойлер-окна since/until (нигде, вкл. патентные индексы), scene-state и voice-exemplars в MT, формула «disposition+окна+бюджет+детерминизм».
|
||||||
|
- **Scene-state: полная схема — ниша пуста; единственный доказанный срез — направленный регистр пары** (ты/вы = 67% деиксис-ошибок En→Ru; тег-контроль 73–92% acc) → **реестр обращений пар** (speaker→addressee, ты/вы, since/until; индустриальный прототип — Netflix Treatments list / 呼称表) — самый дешёвый новый тип записи. DelTA после сверки первоисточника: чистый вклад памяти = +3.95 пп LTCR при −0.02 COMET (консистентность и качество **развязаны** — выигрыш виден только специализированной осью).
|
||||||
|
- **Эпистемика читателя: spoiler-aware MT — 0 работ (ниша пуста);** механика у нас есть — reveal-окна = расширение спойлер-окон (`reveal_ch` + pre/post-reveal алиасы); механический спойлер-канал zh→ru — **род прошедшего времени** (конкретизация C3). Имплицитному трекингу «кто что знает» доверять нельзя (FANToM 26.6% vs человек 87.5%). Dramatic irony registry в Ф1–Ф2 не строить.
|
||||||
|
- **Промптинг/параметры:** «двухпроходность в одном вызове» (V1.13) — арм НЕ ставить (план не работает, работает refinement = наш draft→editor); NO_CHANGE давать, но доля — опция-артефакт (плацебо-эффект −15.75 пп); формат инъекции — модель-специфичный гиперпараметр пилота (JSON худший по замеру OpenAI); менять draft 0.3/editor 0.4 оснований нет, НО **thinking DeepSeek молча игнорирует temperature → temp драфта, вероятно, no-op** (wire-аудит — пре-шаг любого свипа).
|
||||||
|
- **Живые грабли проб (все с сырыми артефактами):** deepseek-эхо воспроизведено на приёмочной книге (2/3 сэмплов одной конфигурации, стохастично); **gemini-2.5-flash — интермиттентный 404 «no longer available» при наличии в /models** (⚠ это судья fidelity-оси `memory_eval`!); **gemini-3.5-flash эмитит `finish_reason=content_filter: PROHIBITED_CONTENT`** на сцене насилия → вывод exp07 «ветка content_filter мертва» НЕ переносится на Gemini 3.5; gemini-3.1-flash-lite работает; CJK-утечка «每人» в ru-выход (cjk-гейт нужен, класс живой).
|
||||||
|
|
||||||
|
> **[ОРКЕСТРАТОРУ — что ратифицировать]** (1) **Voice-профиль** (схема research/15 §1.5: register/self_ref/lexicon_markers/ng_lexicon/exemplars 3–5 RU-реплик/brightness/окна) и **реестр обращений пар** (§2.1) — как механизмы Ф2 в план/реестр; сид обоих для приёмочной книги дёшев и делается вручную. (2) **Reveal-окна** (`reveal_ch`+pre/post-reveal алиасы) — расширение спойлер-окон, Ф2. (3) **Новые армы пилота:** voice-A/B/C/D (скаффолд готов — проба), формат инъекции (plain vs markdown vs XML, отдельно deepseek/grok), «сэндвич» (повтор констрейнтов после чанка), minimal-diff редактор (search/replace + гибкий апплай — режет output-токены 90%-статьи COGS); **армы НЕ ставить:** двухпроходность-в-одном-вызове, min_p. (4) **Скоуп Annotator Ф2** (§2.3): ядро = speaker+addressee с disposition (детерминированный пре-гейт 说/道-кии + LLM на implicit; CONFIRMED-only в инъекцию); отношения курировать как глоссарий (zero-shot F1 0.26–0.52 — непригоден); мусор не аннотировать (пространство/эмоции/эпистемика вне reveal). (5) Поправка постановки: SAMAS (2602.19840) — style-fidelity мультиагентник, НЕ speaker-aware. (6) Сузить формулировку «аналогов не найдено» в доках по таблице research/15 §5.
|
||||||
|
> **[ПОЛИГОНУ — готовые ТЗ]** (1) ⚠ **СРОЧНО: судья `memory_eval` gemini-2.5-flash интермиттентно 404** («model no longer available», при этом модель ЕСТЬ в /models — «есть в /models ≠ работает»); мигрировать судью (gemini-3.1-flash-lite проверен пробой — работает; 3.5-flash — 503 + content_filter) и **перепроверить refusal-таксономию exp02/07 на Gemini 3.x** — content_filter-ветка ожила (`PROHIBITED_CONTENT` на сцене пощёчин!). (2) **Параметр-свип** (ТЗ — research/15 §4): пре-шаг wire-аудита (пишутся ли sampling-параметры в thinking-канале DeepSeek; V4-маппинг temp; grok `effort:none`+penalties) → потом temp-армы с ДВУМЯ осями (fidelity + натуральность). (3) **Анти-эхо матрица** (ТЗ — §3-bis): {DeepSeek beta prefix русским × совместимость с thinking; дубль инструкции после чанка; 2 микропримера zh→ru в кэш-префиксе} на плотных CJK-чанках 蛊真人; метрика echo-rate уже в харнессе. (4) **Voice-A/B на трудном материале**: скаффолд пробы переиспользуй (scratchpad сессии `/tmp/claude-1000/-home-ubuntu-projects-textmachine/6960d2e3-8669-4b06-a5ed-12d603f53907/scratchpad/probes/` — `probe_voice.py`+`analyze_voice.py`+сырые выходы; /tmp волатилен — забрать до ребута; книжные производные в git не класть); мерить на вебновелл-срезе вне претрейна и на ТРУДНЫХ ты/вы-парах (равный статус, смена отношений — наши пары оказались «лёгкими», лифт реестра не измерен). (5) Эвал-база ru-voice-детектора: IWSLT22 formality EN→RU (600 золотых пар) + ru-срез mSynthSTEL + пертурбации 蛊真人.
|
||||||
|
> **[БЭКЕНДУ — требования к схеме на будущую веху, НЕ код сейчас]** (1) Банк v2 — заложить МЕСТО (не реализацию) под два новых типа записей: `voice_profile` (расширение `speech`: register/self_ref/lexicon_markers/ng_lexicon/exemplars[]/brightness + окна) и `address_pair` (направленная пара speaker→addressee, register ты/вы, since/until — активация по ДВУМ именам тем же AC); оба ездят существующим Select/disposition/бюджетом. (2) Reveal-окна: полю сущности — `reveal_ch` + pre/post-reveal алиасы (механика since/until уже есть). (3) Инъекция голоса — в ДРАФТ-слот (проба P3: редактор не восстанавливает); редактору — только констрейнт сохранения. (4) Wire-probe в бэклог: пишется ли temperature в thinking-канале DeepSeek (наш temp 0.3, вероятно, no-op — молчаливый вендор-факт); grok-4.3: вендорская дока на слаг исчезла (доки описывают 4.5) — квирки держатся на наших фактчеках. (5) Формат инъекции не менять до пилота (модель-специфично); в system-префиксе явно объявить формат блока (дешёвый буст робастности).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue