101 lines
26 KiB
Markdown
101 lines
26 KiB
Markdown
# backend/ — Go-бэкенд TextMachine
|
||
|
||
Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (свежая голова — с хвоста файла); целевая архитектура (7 слоёв, инвариант общности §0.1 — любая книга/пара/структура; статус стройки — шапка-таблица) — `docs/architecture/09-target-architecture.md`; норматив общности — `12-go-style-notes.md`; **актуальный сессионный промт — список «Активные хендофф-промты» в `docs/README.md`**; контракты Фазы 0 — `03-implementation-notes.md` (через баннер); квирки провайдеров — `docs/experiments/00-provider-quirks.md` (читать ПЕРЕД правкой адаптеров/вызовами). **Язык-данные майнера — вне пайплайна: `internal/lang`+`configs/langpacks/` (D39.16); пар-калибровки — `configs/pairs/<пара>.yaml`, промпты — `prompts/<пара>/<роль>.md` по конвенции (D39.23).** **Подсистемы (`chunk`/`miner`/`checks`/`membank`) не импортируют друг друга и не импортируют `pipeline` — драйвер является композит-корнем (D39.23; проверяется `go list -deps`).** **`.env` не читать.**
|
||
|
||
*(Актуализировано оркестратором 01.08 по мандату владельца, факты сверены по коду; предыдущая ревизия 17.07 — эры D39.16, до паков 16–20 и фазы 2 общности D39.64.)*
|
||
|
||
## Карта пакетов
|
||
|
||
| Пакет | Что делает | Ключевые файлы |
|
||
|---|---|---|
|
||
| `cmd/tmctl` | CLI: `translate` / `status` (read-only N/M+паспорта глав+деньги, `--json`; работает ПРИ живом прогоне — store без flock) / `report` (request-log+леджер книги **+ per-run quality-report**: структурный KPI предл./нарратив-абзац, тире, cosmetic-strip/echo-rates, глоссарий-промахи, trust-gated — наблюдаемость, НЕ гейт; D39.2-T4) / `export` (**экспорт-поверхность для полигона**: final_hash→checkpoint→checks.ExportNormalize, manifest-join c pending/ghost-учётом, ConfigDrift-поле, `--plaintext`; D39.5) / `build` (**писатель книги**, строка 236: EPUB 3 + чистый txt из той же экспорт-проекции в `<project_db>.book.<fmt>` рядом с БД, пути — в `artifacts.book_files`; книга с ЛЮБОЙ дырой — pending/withheld/incomplete/ghost — по умолчанию отказ **exit 16** с перечнем, `--partial` пишет с пометкой на первой странице и маркером на каждой дыре; слова читателя — данные `configs/langpacks/<цель>/reader.txt`, вне снапшота; `--format epub,txt`, `--out`) / `redrive` (переатака флагнутых: `--chapter/--chunk/--reason/--dry-run`, D15.3) / `manifest` (**$0-производитель персиста манифеста глав/чанков**, строка 100: нужен ДО первого прогона — дерево глав разобранной, но не запущенной книги; `--json` печатает сам документ) / `migrate` (**$0-write-open без прогона**, строка 174: read-only команды требуют ТОЧНОГО совпадения схемы и не мигрируют, поэтому апгрейд бинаря запирал все существующие книги — платформа зовёт `status --json` перед каждым спавном, а write-команда, которая мигрировала бы, не наступала никогда. Грузит ТОЛЬКО `book.yaml` — ни цен, ни ключей на $0-пути (строка 146); restore point берёт ПОД ЛОКОМ и только когда шаг реально применяется (имя `<метка>-pre-migrate.db` — вне секундного неймспейса платного пути, строка 173); несовпадение схемы у read-only путей — типизированный отказ **exit 13** с машинным токеном `schema_mismatch found=N expected=M`). **ДВА потолка прогона, и они ортогональны.** `--ceiling-usd` (строка 145) — ДЕНЬГИ: перекрывает книжный `ceilings.book_usd` ТОЛЬКО на этот прогон, в book.yaml не пишется, ноль/отрицательное = отказ запуска; каппит КУМУЛЯТИВНУЮ трату книги, а не приращение прогона. `--max-units` (D39.165 §1б) — ОБЪЁМ: не больше N ВЫХОДНЫХ ЮНИТОВ (гранулярность `units_total` манифеста, та самая, в которой платформа продаёт главы) будет ОПЛАЧЕНО этим прогоном. Юниты, которые прогон отдаёт за $0 (резюм, ре-пин), едут бесплатно и потолок не тратят; ретраи и эскалации — тоже нет, они внутри юнита. Принимает только `translate`. **Остановка по объёму — ЗАВЕРШЕНИЕ (exit 0), а не пауза:** словарь кодов выхода не расширялся, различение живёт в отчёте прогона и в логе. Отчёт различает ДОСТАВКУ (юнит, которого не было) и ПЕРЕ-ДЕЛКУ (уже доставленный юнит под сдвинутым снапшотом) — покупка, целиком ушедшая в переделку, обязана читаться как переделка. ⚠ На книге, которая МАЙНИТ банк, вторая покупка требует `--resnapshot` (авто-банк растёт между покупками и двигает edit-снапшот) — прогон предупреждает об этом в логе. main — тонкая обвязка: разбор/exit-коды/.env/рендеры в тестируемых функциях | `main.go`, `invocation.go`, `render.go`, `dotenv.go` |
|
||
| `internal/llm` | OpenAI-совместимый транспорт + retry/backoff, **capability-слой** (budget_field/temperature/reasoning per-модель), провайдеры openai/local (no-proxy)/anthropic (DEPRECATED-референс); `failover.go` удалён паком-17 — маршрутизация лейблами живёт в config/pipeline (канал B, один хоп `chain[0]`, fail-closed) | `httpllm.go`, `capability.go`, `provider_*.go` |
|
||
| `internal/ledger` | Цены по usage (вкл. reasoning/cache-поля), `PriceForResponse` по фактической модели | `pricing.go` |
|
||
| `internal/store` | SQLite (modernc, CGO-free), цепочка миграций `schema_version` (⚠ ALTER-шаги v8+ не идемпотентны вопреки шапке — бэклог-строка 49а), reserve/settle+checkpoint, chunk_status, глоссарий (+подписной цикл терминолога), ruby, retrieval_state, request_log; `OpenReadOnly` — без flock/миграций/recovery для status/report/export (схема не совпала — типизированный `*store.SchemaMismatchError{Found,Expected}`, обе стороны); `Migrate` (+шов `beforeApply` под локом: restore point берётся там) — поверхность деплой-шага строки 174, `SchemaHead` — та самая `Expected`; write-open БД новее бинаря теперь ОТКАЗ, а не тихое открытие | `ledger.go`, `migrate.go`, `glossary.go`, `store.go` |
|
||
| `internal/config` | fail-fast загрузка models/pipeline/book; **эхо-мина-гейт** `echoMineViolation`; `CheckRunnable` блокирует неисполнимое (C2/fanout/judge); **пар-слой** (D39.23): `configs/pairs/<пара>.yaml` — калибровка нарезки + коридор длины пары; **промпт-резолв КОНВЕНЦИЕЙ** `prompts/<пара>/<роль>.md` из `Book.LangPair()`+роли, **fail-loud на пару без пакета** (ja-книга не едет молча через zh-промпт), осознанное исключение — `prompt_override` на стадии | `models.go`, `pipeline.go`, `pair.go` |
|
||
| `internal/text` | Нормализация и рун-примитивы, общие для банка/майнера/чекеров (script-agnostic слой, `lang` НЕ импортирует): `NormalizeSourceKey`/`NormalizeTargetForm` (версионированный артефакт `NormVersion()`, фолдится в снапшот), `NormalizeSource`, `TokenizeScript` (алфавит цели из данных, Mn-фолд U+0301; `TokenizeCyrillic` = ru-дефолт-обёртка), `DenseScript` — единый дом sizing-таксономии (D39.64) | `norm.go`, `runes.go`, `source.go` |
|
||
| `internal/seed` | Схема сид-YAML книги (`File`/`Term`/`Decl`/`Alias`) — общий артефакт: майнер её ПИШЕТ (mined-дельта), банк ЧИТАЕТ (`LoadGlossarySeed`); тип общий, поэтому эмиссия не может разъехаться с загрузчиком | `seed.go` |
|
||
| `internal/lang` | Языковые ДАННЫЕ и пак-механизм: эмбед-плоскость `data/*.txt` (хешируется в `EmbeddedVersion()` → снапшот, D39.64) · langpack `Load`/книго-overlay (аллоулист файлов, fail-loud) · **манифест-по-каналам** (`manifest.txt` источника объявляет `source-morphology`/`transliteration`; манифест-less пак = все каналы, байт-идентичен) · единый реестр письменностей `script.go` (`LangScripts`/`IsCJKScriptLang` из `lang-script.txt`) · реестр целей из `data/target-<tgt>.txt` | `langpack.go`, `embedded.go`, `script.go` |
|
||
| `internal/terminology` | Роль терминолога (пак-20): смердженный банк + KWIC-контексты → батчи консолидации dst той же дешёвой моделью; языковой экран `target_script`; durable-деньги подписного цикла | `terminology.go`, `script.go` |
|
||
| `internal/chunk` | Ингест (txt/epub, кодировки, ruby) + чанкер: `Ingest`/`IngestEncoded` → `Document`, `SplitChunks` → `[]Chunk` по `SegBudget`; структура источника (главы, CJK-числительные, heading-правило) — ДАННЫЕ из `lang` | `ingest.go`, `chunker.go`, `chunktest/` |
|
||
| `internal/membank` | Банк памяти v2, memmatch-v4: Aho-Corasick, спойлер-окна, disposition, **trust-gated longest-match** (draft-длиннее НЕ съедает вложенный approved; отказ = громкая телеметрия — терм-дрейф закрыт в КОДЕ, D39.2-T1), post-check, рендереры инъекции, загрузка/валидация сида (`SeedLint`) | `memory.go`, `memseed.go`, `mempostcheck.go` |
|
||
| `internal/miner` | Детерминированный оффлайн-майнер банка (WS3, $0): `MineBank` над нормализованными чанками + контраст-корпус → `[]Term`, `DeltaYAML` — сид-дельта на подпись владельца; все пар-данные приходят `*lang.Pack` | `miner.go`, `miner_*.go` |
|
||
| `internal/checks` | Детерминированные $0-вердикты над текстом чанка, **target-aware ПО ДАННЫМ (D39.64): `Checkers` компилируется из цель-данных, `TargetActive()`/`TargetScriptNonLatin()` гейтят всё — цель без данных инертна, Go-веток по паре нет**: санитайзер v7 (fold-first, CJK/Hangul-leak, CJK-глосс-whitelist) + `ExportNormalize` (**export-contract слой 6**, no-op без цель-данных), cheap-гейты и DC-чекеры (пар-данные из пака), coverage-гейт эксцизии, regression-guard. Флаг-вокабуляр НЕ импортирует: возвращает факт, диспозицию назначает драйвер | `sanitizer.go`, `cheapgates.go`, `checkers.go`, `coverage.go` |
|
||
| `internal/pipeline` | Композит-корень (драйвер): сетап `runner.go` → снапшот `snapshot.go` → сид `seeding.go` → цикл книги `bookrun.go` → волны `waverun.go` → петля чанка `chunkrun.go` → стадия `stagerun.go` → эскалация с ре-гейтом `escalation.go` → resume `resume.go`; classify/disposition; **реестр `roleInjectionRenderers`** (новая роль = данные+рендерер, не правка switch); `export.go`/`quality.go`/`status.go` — read-only проекции (общая derivation member-drop = `memberDrops`); **read-out файлы для платформы** (D39.85 — приватный SQLite движка ей закрыт): `manifest.go` (структура глав/чанков + стабильный id главы, строка 100), `bankexport.go` (весь банк тремя статусами, 125), машинная стоп-таблица в `mining.go` (101); все три пишутся через `artifact.go` (write-then-rename — их читают ПОКА идёт прогон) | `bookrun.go`, `waverun.go`, `stagerun.go`, `export.go`, `manifest.go`, `disposition.go` |
|
||
| `internal/obs` | trace_id, структурные логи (contextHandler несёт book/chapter/chunk/stage/role из ctx), safego | |
|
||
|
||
## Инварианты — ЛОМАТЬ НЕЛЬЗЯ (каждый закреплён тестами)
|
||
|
||
1. **Деньги:** reserve → call → **settle+checkpoint одной транзакцией**; `committed == SUM(checkpoints)` переживает kill -9 (`kill9_test.go`); цена — по фактически ответившей модели; потолки книга/день считают committed+reserved. **Одно исключение — явный `tmctl redrive`** (D15.3): после него `committed ≥ SUM(checkpoints)` — безопасное направление.
|
||
2. **Snapshot-дисциплина:** всё, что влияет на wire-байты ИЛИ вердикты (capability, память content-hash + `memoryMatchVersion`, context-assembly, версии classify/coverage/chunker/maxtok/**sanitizer (только при enabled)**, **эмбед-данные `lang.EmbeddedVersion()` (D39.64)**, эскалационные оверрайды, **биндинг пара→промпт** через BriefHash+PromptSHA256), свёрнуто в `snapshotID`; правка = громкий `--resnapshot` = переоплата книги (D15). Новый фолд обязан быть УСЛОВНЫМ по присутствию данных (правило §6.2, D39.60).
|
||
3. **Эхо-мина DeepSeek:** thinking НИКОГДА не отключать — `reasoning:"off"` там осознанный no-op; `echoes_when_thinking_off` + рекурсивный скан extra_body валят конфиг fail-fast. У grok off = ЯВНЫЙ `reasoning_effort:"none"`.
|
||
4. **Порядок classify:** refusal-blacklist / эхо источника (`sourceScriptShare` по ОБЪЯВЛЕННОМУ письму, D39.64) — ДО length/empty; retry-flagged только {length, empty} на той же модели; loop-чек ПЕРЕД удвоением max_tokens. Санитайзер — ТОЛЬКО на финальной стадии (isFinal) и только при активной цели (`TargetActive`).
|
||
5. **Эскалация:** ровно 1 хоп, результат РЕ-гейтится, retry-бюджет не сбрасывается, editor pinned, канал adult — только permissive (fail-closed).
|
||
6. **Детерминизм:** рендер — чистая функция snapshot; сортировки перед записью; никакого map-order в выводе; length-prefixed хэши; сорс-чанкер lossless (fuzz).
|
||
7. **Нейтральность адаптера:** `internal/llm` не знает про перевод — контент-вердикты в раннере (disposition-слой, post-settle).
|
||
8. **Экспорт-контракт (D39 слой 6, D39.5):** `checks.ExportNormalize` — эфемерная проекция (НЕ входит в request_hash/чекпоинты/деньги), применяется к КАЖДОМУ финальному тексту на всех выдающих поверхностях; **любая внешняя экстракция (полигон!) обязана читать `tmctl export`**, не сырые чекпоинты store — иначе видит ненормализованные байты и молча-неполную книгу (export делает manifest-join + drift-гард, сырое чтение — нет).
|
||
|
||
## Подготовка ja-книги (чек-лист сида)
|
||
|
||
- **Kana-написания имён = matchable.** Ruby-чтения захватываются на инжесте и авто-подцепляются как alias ручной записи (D16.4, `attachRubyAliasesToManual`). Имя, встречающееся ТОЛЬКО каной, внеси в `aliases:` руками — иначе «тихо пусто».
|
||
- **Двойные чтения (強敵→とも) — под флагом:** длинное двойное чтение может дать CONFIRMED-ложь — при подозрении убери из сида. Полная дизамбигуация — Ф2 (B6).
|
||
- **Kana-precision ≥4 не замерена** (minKeyLenPhonetic=3): кана-ключи не проверяются на границу слова (нет сегментации) — задача полигона + токенизатор B6.
|
||
- ⚠ **ja-книга требует каталога `prompts/ja-ru/` (по файлу на роль)** — промпт-резолв конвенцией fail-loud'ится без него, называя ожидаемый путь (пример: закомментированный ja-блок в `example/book.yaml`). Контент пакета = данные под реальную книгу (инвариант общности §0.1), не сочинять.
|
||
|
||
## Как гонять
|
||
|
||
Стенд: WSL2 (localhost из-под прокси = 403 — для local-вызовов no-proxy транспорт). Книга-стенд: `/home/ubuntu/books/gu-zhenren/` (GB18030; тексты и производные ВНЕ git; парити-тесты майнера читают отсюда под `TM_MINER_PARITY=1`).
|
||
|
||
Батарея — ОДНА команда, `make` из `backend/` (Makefile; собирать её руками больше не нужно):
|
||
|
||
```bash
|
||
make battery # build · vet (оба тег-набора, + архитектурные анализаторы) · gofmt · lint · test -race
|
||
# в конце НАЗЫВАЕТ пропущенные тесты — вклеивать в отчёт, не опускать
|
||
make battery-stand # то же + корпусные тесты (TM_MINER_PARITY=1 TM_CHECKER_LABELS=1): отсутствие данных ПАДАЕТ
|
||
make lint # golangci-lint 2.12.2 (версия пиновата в Makefile; конфиг — .golangci.yml)
|
||
```
|
||
|
||
Линтер ставится отдельно (бинарь вне репо, `make` его НЕ доустанавливает — ссылку печатает `tools-check`).
|
||
Архитектурные инварианты живут в `internal/archguard` и запускаются двумя путями: `go vet -vettool` из
|
||
`make vet` и обычный `go test ./internal/archguard/` (он прогоняет их по всему дереву).
|
||
|
||
```bash
|
||
go run ./cmd/tmctl translate --config example/book.yaml # реальные вызовы — ключи в .env (пример: zh→ru)
|
||
go run ./cmd/tmctl status --config example/book.yaml --json # $0, N/M+паспорта+деньги, живой прогон ок
|
||
go run ./cmd/tmctl report --config example/book.yaml # $0, quality-report (KPI/rates)
|
||
go run ./cmd/tmctl export --config example/book.yaml # $0, экспорт-JSON для полигона (--plaintext для человека)
|
||
go run ./cmd/tmctl manifest --config example/book.yaml # $0, пере-строить персист манифеста глав/чанков (--json — сам документ)
|
||
go run ./cmd/tmctl migrate --config example/book.yaml # $0, довести схему проекта до головы бинаря (деплой-шаг: стоп прогонов → НОВЫЙ бинарь на место → migrate ИМ по каждой книге без открытых попыток → прогоны в работу)
|
||
go run ./cmd/tmctl translate --config example/book.yaml --ceiling-usd 0.5 # ДЕНЕЖНЫЙ потолок ТОЛЬКО на этот прогон, book.yaml не пишется
|
||
go run ./cmd/tmctl translate --config example/book.yaml --max-units 10 # ОБЪЁМНЫЙ потолок: оплатить не больше 10 выходных юнитов; стоп = завершение (exit 0)
|
||
# live-conformance (реальные провайдеры, платно, вне CI):
|
||
set -a; . ./.env; set +a; TM_LIVE=1 go test -tags live -run TestLive -v ./internal/pipeline/
|
||
```
|
||
|
||
⚠ **«Зелёное» ≠ «всё проверено»: четыре теста — ИЗМЕРЕНИЯ на корпусе, а не юнит-тесты, и на чистом клоне они молча скипаются.** Голая батарея выше их НЕ выполняет, поэтому «батарея зелёная» без списка SKIP — неполный отчёт, а не приёмка.
|
||
|
||
| Что тёмное без данных | Флаг | Данные |
|
||
|---|---|---|
|
||
| `TestMinerFullBookParity` — единственная сверка Go-порта против Python-эталона на полной книге | `TM_MINER_PARITY=1` | jieba-словарь по внутрирепозиторному ПУТИ `eval/exp16/data/`, но ВНЕ git (`eval/.gitignore:4`) — клон его не получает, регенерируется из jieba 0.42.1 (SHA в `experiments/16-bank-mining.md:183`); плюс `~/books/gu-zhenren/{rerun/records.json, guzhenren-seed-v2.yaml}` |
|
||
| `TestCheckerLabelsBaseline`, `TestCheckerLabelsCandidates`, `TestK6LabelsBaseline` — единственный замер precision/recall $0-гейтов | `TM_CHECKER_LABELS=1` | `~/books/gu-zhenren/labels/` |
|
||
|
||
Флаг переводит отсутствие данных из тихого скипа в громкий `Fatal` — то есть «я это гонял» становится проверяемым. Пути дефолтятся ОТ `$HOME` и от корня репозитория (не от `/home/ubuntu`), так что клон под любым пользователем и по любому пути находит то, что есть; точечные переопределения — `TM_CHECKER_LABELS_DIR`, `TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}`.
|
||
|
||
Полная батарея на стенде:
|
||
```bash
|
||
go build ./... && go vet ./... && go vet -tags live ./... && test -z "$(gofmt -l .)" && \
|
||
TM_MINER_PARITY=1 TM_CHECKER_LABELS=1 go test ./... -race -count=1
|
||
```
|
||
|
||
**Golden-гард детерминизма** (`golden_test.go` + `testdata/golden/`): пинит бит-в-бит snapshotID, request_hash, wire-тела, вердикты и resume-байты. Красный golden = wire/вердикты изменились = `--resnapshot` = переоплата. Обновлять ТОЛЬКО на ратифицированной смене поведения: `TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden`; при re-capture — **маскированный структурный дифф** (хеши/версии → плейсхолдер) обязан быть пустым, если смена версий не меняла вердиктов.
|
||
|
||
Финал каждой вехи — агентское адверсариальное селфревью (мандат CLAUDE.md 12.07); внешнее ревью — оркестратор.
|
||
|
||
## Известный техдолг (не трогать молча; ЕДИНЫЙ трекер — таблица «Бэклог» в `docs/PROGRESS.md`, номера строк = она)
|
||
|
||
- **D15.2:** READ-половина `tmctl export` построена (D39.5); annotation/override-половина и content-addressed resume отложены (строка 49). F3 at-most-once — после D15.2 (строка 50).
|
||
- **`Retries` НЕ фолдится в снапшот** (pre-existing, D39.4-LOW; сознательно так же для ручки `RegenerateEchoBeforeEscalate`, D39.64 — дефолт 0 = текущее поведение): в крэш-окне пониженный ретрай-бюджет молча бросает оплаченные OK-чекпоинты.
|
||
- ⚠ ALTER-шаги миграций v8+ НЕ идемпотентны вопреки шапке `migrate.go` — полу-применённая БД не сходится (строка 49а).
|
||
- Остаточные Cyrillic-хардкоды пар-гейтед DC-чекеров (`checks/repair.go:131`, `checks/checkers.go:457,474`) — инертны без dc-данных пары; кандидат на `c.wordScript` (строка 79).
|
||
- `prompts/zh-ru/editor-mono.md` — DORMANT-референс D13.1 (не боевой, тестом закреплён); боевой = `prompts/zh-ru/editor.md` v3-discourse (БИЛИНГВ). Глосс-капы санитайзера (6 CJK / 24 лат-токен) — tunable-эвристики (D39.5). Флоры min_max_tokens = схема (D24.3).
|
||
- Хвост LOW/NOTE-находок адверсариала D39.4 — в ledger-очереди (строка 53).
|