textmachine/backend/README.md

26 KiB
Raw Blame History

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, до паков 1620 и фазы 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) / 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/IngestEncodedDocument, 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; собирать её руками больше не нужно):

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/ (он прогоняет их по всему дереву).

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}.

Полная батарея на стенде:

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).