Add Phase 0 backend skeleton: LLM core with native Anthropic adapter, money ledger, SQLite store with atomic chunk checkpoints, deterministic C1 mini-runner, tmctl CLI, configs and prompts

This commit is contained in:
Claude (backend session) 2026-07-04 09:04:06 +03:00
parent 9bb00d49f2
commit 7954325b7e
49 changed files with 5023 additions and 8 deletions

10
backend/.env.example Normal file
View file

@ -0,0 +1,10 @@
# Скопируй в backend/.env (gitignored) и заполни. tmctl подхватывает .env из
# каталога book.yaml и из CWD; уже установленные переменные окружения не
# перетираются.
DEEPSEEK_API_KEY=
ZAI_API_KEY=
KIMI_API_KEY=
ANTHROPIC_API_KEY=
# LOG_LEVEL=debug
# LOG_FORMAT=json

155
backend/cmd/tmctl/main.go Normal file
View file

@ -0,0 +1,155 @@
// tmctl is the TextMachine CLI (Фаза 0: translate один чанк + report).
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/pipeline"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "tmctl:", err)
os.Exit(1)
}
}
func run() error {
if len(os.Args) < 2 {
return fmt.Errorf("usage: tmctl <translate|report> --config book.yaml")
}
cmd, args := os.Args[1], os.Args[2:]
fs := flag.NewFlagSet(cmd, flag.ExitOnError)
cfgPath := fs.String("config", "", "path to book.yaml")
if err := fs.Parse(args); err != nil {
return err
}
if *cfgPath == "" {
return fmt.Errorf("--config book.yaml is required")
}
loadDotEnv(filepath.Join(filepath.Dir(*cfgPath), ".env"))
loadDotEnv(".env")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx = obs.WithReqInfo(ctx, obs.ReqInfo{TraceID: obs.NewTraceID()})
switch cmd {
case "translate":
return translate(ctx, *cfgPath)
case "report":
return report(*cfgPath)
default:
return fmt.Errorf("unknown command %q (want translate|report)", cmd)
}
}
func translate(ctx context.Context, cfgPath string) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
res, err := r.TranslateOneChunk(ctx)
if err != nil {
return err
}
fmt.Println("=== ПЕРЕВОД ===")
fmt.Println(res.FinalText)
fmt.Println()
fmt.Println("=== СТОИМОСТЬ ПО СТАДИЯМ ===")
for _, st := range res.Stages {
src := "call"
if st.FromResume {
src = "checkpoint"
}
fmt.Printf("%-8s %-22s %-10s $%.6f in=%d (cached=%d, cache_write=%d) out=%d+%d %dms finish=%s\n",
st.Stage, st.Model, src, st.CostUSD,
st.Usage.PromptTokens, st.Usage.CachedTokens, st.Usage.CacheCreationTokens,
st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.LatencyMS, st.FinishReason)
}
fmt.Printf("ИТОГО (этот запуск): $%.6f\n", res.TotalUSD)
committed, reserved, err := r.Store.SpentUSD(res.BookID)
if err != nil {
return err
}
fmt.Printf("Ledger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
return nil
}
func report(cfgPath string) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
rows, err := r.Store.RequestLogRows(r.Book.BookID)
if err != nil {
return err
}
defer rows.Close()
fmt.Printf("%-20s %-8s %-12s %-22s %8s %8s %8s %8s %8s %10s %8s %-8s %-5s %-3s\n",
"ts", "stage", "role", "model", "prompt", "cached", "cwrite", "compl", "reason", "cost_usd", "ms", "finish", "tmhit", "ok")
for rows.Next() {
var ts, stage, role, model, finish string
var prompt, cached, cwrite, compl, reason, latency, tmhit, ok int
var cost float64
if err := rows.Scan(&ts, &stage, &role, &model, &prompt, &cached, &cwrite, &compl, &reason, &cost, &latency, &finish, &tmhit, &ok); err != nil {
return err
}
fmt.Printf("%-20s %-8s %-12s %-22s %8d %8d %8d %8d %8d %10.6f %8d %-8s %-5d %-3d\n",
ts, stage, role, model, prompt, cached, cwrite, compl, reason, cost, latency, finish, tmhit, ok)
}
if err := rows.Err(); err != nil {
return err
}
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
if err != nil {
return err
}
fmt.Printf("\nLedger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
return nil
}
// loadDotEnv reads KEY=VALUE lines into the environment without overriding
// already-set variables. Ключи — из backend/.env (gitignored); секреты никогда
// не попадают в конфиги/репо.
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.Trim(strings.TrimSpace(v), `"'`)
if os.Getenv(k) == "" {
os.Setenv(k, v)
}
}
}

View file

@ -0,0 +1,89 @@
# Модели и цены TextMachine (Р4/Р5: состав и цены — ТОЛЬКО здесь, с датой
# проверки; код не знает ни одной цены).
#
# Источники проверки 2026-07-04 (воркфлоу-фактчек, официальные страницы):
# DeepSeek https://api-docs.deepseek.com/quick_start/pricing
# Anthropic https://platform.claude.com/docs/en/about-claude/pricing
# Z.AI/GLM https://docs.z.ai/guides/overview/pricing
# Kimi https://platform.kimi.ai/docs/pricing/chat-k26
# xAI https://docs.x.ai/docs/models (Grok 4.1 Fast retired 15.05.2026!)
# Gemini https://ai.google.dev/gemini-api/docs/pricing
prices_checked: "2026-07-04"
# Fallback-якорь цены: неизвестная модель (например, фактическая модель после
# фейловера, которой нет в таблице) книжится по этой цене, никогда по $0.
default_model: deepseek-v4-flash
providers:
deepseek:
kind: openai
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
reasoning: subset # thinking внутри completion_tokens
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
zai: # GLM, международный контур (docs.z.ai)
kind: openai
base_url: https://api.z.ai/api/paas/v4
api_key_env: ZAI_API_KEY
reasoning: subset
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
kimi: # домен сменился: platform.moonshot.ai → 301 → platform.kimi.ai
kind: openai
base_url: https://api.kimi.ai/v1
api_key_env: KIMI_API_KEY
reasoning: subset
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
anthropic:
kind: anthropic
api_key_env: ANTHROPIC_API_KEY
cache_ttl: 5m # per-stage override — Фаза 1 (context.cache_ttl в конфиге ядра)
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
local: # ollama/llama-server на стенде (боевой env — memory textmachine-local-stand)
kind: local
base_url: http://127.0.0.1:11434/v1
model: huihui_ai/qwen3-abliterated:8b
max_tokens: 8192 # у ollama лимит покрывает thinking+ответ вместе
timeouts: { attempt_s: 600, max_attempts: 2, backoff_cap_s: 10 } # полигон: чанки 63278 с
models:
deepseek-v4-flash:
provider: deepseek
price: { input_per_m: 0.14, cached_per_m: 0.0028, cache_write_per_m: 0, output_per_m: 0.28 }
note: >-
Черновик (Р4). Автокэш по префиксу, отдельной цены записи нет.
deepseek-chat/reasoner отключаются 24.07.2026 — сюда не возвращаться.
glm-5:
provider: zai
price: { input_per_m: 1.0, cached_per_m: 0.2, cache_write_per_m: 0, output_per_m: 3.2 }
extra_body: { thinking: { type: disabled } } # эмпирика полигона: thinking GLM — таймауты ×3
note: Редактор ru-стиля (Р4). Флагманы линейки уже GLM-5.1/5.2 ($1.4/$4.4) — пересмотреть к Фазе 2.
kimi-k2.6:
provider: kimi
price: { input_per_m: 0.95, cached_per_m: 0.16, cache_write_per_m: 0, output_per_m: 4.0 }
note: Альтернативный редактор (Р4).
claude-sonnet-5:
provider: anthropic
price: { input_per_m: 2.0, cached_per_m: 0.20, cache_write_per_m: 2.50, output_per_m: 10.0 }
min_cache_prefix_tokens: 1024
note: >-
Промо $2/$10 до 31.08.2026 включительно; с 01.09.2026 — $3/$15 (кэш
write 5m $3.75, hit $0.30) — ОБНОВИТЬ. cache_write_per_m здесь — 5m TTL.
Только SFW-книги и контур прямых ключей (Р5, интерим-правило 18+).
claude-opus-4-8:
provider: anthropic
price: { input_per_m: 5.0, cached_per_m: 0.50, cache_write_per_m: 6.25, output_per_m: 25.0 }
min_cache_prefix_tokens: 1024
note: Судья/эскалация — только адресно, канал A (Р4). cache_write — 5m TTL.
local-qwen3-8b:
provider: local
price: { input_per_m: 0, cached_per_m: 0, cache_write_per_m: 0, output_per_m: 0 }
note: Скрининг/экстракция (Р4 — «спецслужба, не переводчик»). $0 допустим только для local-провайдера.

View file

@ -0,0 +1,73 @@
# Ядро C1: draft → editor (Р2). Обязательное приложение Фазы 0 — фиксирует
# границу «конфиг vs код раннера»:
# КОНФИГ: состав/порядок стадий, роль→модель, версии промптов, сэмплинг,
# пороги гейтов, режим инъекции глоссария, токен-бюджеты сборки контекста,
# именованные эскалационные цепочки, fan-out N, cache TTL.
# РАННЕР (код): циклы по главам/чанкам, ветвление по гейтам, механика
# эскалации/ретраев, правило «эскалация → re-gate → флаг», channel-aware
# фильтр цепочек (Anthropic вне 18+), resume по чекпоинтам.
core: C1
version: 1
defaults:
# max_tokens ≈ ratio × входные токены; калибровка полигона (эксп. 01):
# русский выход zh→ru ≈1.9× входа в токенах — 2.2 с запасом на ja/en.
max_output_ratio: 2.2
min_max_tokens: 2048
context:
# Р5: обе схемы инъекции глоссария обязаны поддерживаться конфигом.
# selective — ключи/алиасы/леммы в волатильном хвосте (~100800 ток.);
# full_prefix — весь глоссарий в стабильном префиксе (выгодно при cache-hit
# DeepSeek $0.0028/M — решается расчётом на прототипе).
glossary_injection: selective
glossary_token_budget: 800
stm_depth: 2 # глубина STM в чанках (Фаза 1)
overlap_tokens: 200 # перекрытие чанков = read-only контекст (Фаза 1)
cache_ttl: "5m"
retries:
# Содержательные регенерации после провала гейта ДО эскалации (транспортные
# ретраи — отдельно, в timeouts models.yaml).
regenerate_before_escalate: 1
stages:
- name: draft
role: translator
model: deepseek-v4-flash
prompt: ../prompts/translator.md
prompt_version: v0-draft
temperature: 0.3
reasoning: "off"
- name: edit
role: editor
model: glm-5
prompt: ../prompts/editor.md
prompt_version: v0-draft
temperature: 0.4
reasoning: "off"
gates:
coverage:
# Фаза 1. Пороги полигона (эксп. 02, символы БЕЗ пробелов): нижняя граница
# — подозрение на вырезание, верхняя — аномалия/галлюцинация.
enabled: false
len_ratio_bounds:
zh-ru: [2.2, 4.2]
ja-ru: [1.4, 2.6]
en-ru: [0.70, 1.4]
sent_cov_min: 0.75
min_chunk_chars: 500 # калибровка порогов — на фрагментах 5002500 симв.
escalation:
chains:
# channel-aware фильтр применяет раннер: при adult=true из любой цепочки
# исключается anthropic (интерим-правило 18+ Фазы 1).
default: [claude-sonnet-5]
adult: [kimi-k2.6]
# Адресный премиум-бюджет книги, $. Формула «≤2×» из Р5 неоднозначна после
# токен-калибровки — [НУЖНО РЕШЕНИЕ] п.1; до решения бюджет задаётся явно.
budget_usd: 0
fanout:
candidates: 1 # C1: один черновик

View file

@ -0,0 +1,64 @@
# Скелет ядра C2: generate-then-select (Р2). Тот же раннер, что C1 — различия
# только в конфиге: fan-out N кандидатов + стадия select. Фаза 0 фиксирует
# СХЕМУ (парсится и валидируется); исполнение fan-out/селекции — механика
# раннера Фазы 2 (пилот 4 рук решает, войдёт ли C2 в прод).
core: C2
version: 1
defaults:
max_output_ratio: 2.2
min_max_tokens: 2048
context:
glossary_injection: selective
glossary_token_budget: 800
stm_depth: 2
overlap_tokens: 200
cache_ttl: "5m"
retries:
regenerate_before_escalate: 1
stages:
- name: draft
role: translator
model: deepseek-v4-flash
prompt: ../prompts/translator.md
prompt_version: v0-draft
temperature: 0.8 # C2: кандидаты сэмплируются горячее (Р2: T=0.61.0)
reasoning: "off"
# Judge-селектор (пара судей, шкала Комиссарова) — Фаза 2; стадия объявлена,
# чтобы схема конфига не менялась: селектор получает N кандидатов от draft.
- name: select
role: judge
model: claude-opus-4-8
prompt: ../prompts/judge-selector.md
prompt_version: v0-skeleton
temperature: 0
reasoning: "off"
- name: edit
role: editor
model: glm-5
prompt: ../prompts/editor.md
prompt_version: v0-draft
temperature: 0.4
reasoning: "off"
gates:
coverage:
enabled: false
len_ratio_bounds:
zh-ru: [2.2, 4.2]
ja-ru: [1.4, 2.6]
en-ru: [0.70, 1.4]
sent_cov_min: 0.75
min_chunk_chars: 500
escalation:
chains:
default: [claude-sonnet-5]
adult: [kimi-k2.6]
budget_usd: 0
fanout:
candidates: 3 # C2: N кандидатов (fan-out — механика раннера, N — конфиг)

21
backend/example/book.yaml Normal file
View file

@ -0,0 +1,21 @@
# Пример проекта книги для приёмки Фазы 0: один чанк ja→ru через C1.
book_id: sample-ja
title: 夜明けの図書館 # «Библиотека на рассвете» — собственный тестовый фрагмент
source_lang: ja
target_lang: ru
genre: ранобэ
audience: взрослые читатели вебновелл
adult: false
venuti: 0.6
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: ../configs/pipeline-c1.yaml
models: ../configs/models.yaml
source_file: chapter1-chunk0.txt
project_db: sample-ja.db
ceilings:
book_usd: 1.00 # потолок $ на книгу — ledger обязан работать и в тестах
day_usd: 2.00

View file

@ -0,0 +1,11 @@
夜明け前の図書館は、静寂に包まれていた。
「先輩、本当にここに入ってもいいんですか」と、美咲は小声で尋ねた。彼女の手には、古びた鍵が握られていた。
「大丈夫だ。館長から許可はもらっている」と、拓海は答えた。「それより、例の本を探すぞ。朝が来る前に」
二人は書架の間を静かに歩いた。窓の外では、東の空がわずかに白み始めていた。埃の匂いと、古い紙の香りが漂う。美咲は足を止め、一冊の本に手を伸ばした。
「先輩……これ、見てください。表紙に何も書いてありません」
拓海が振り返った瞬間、図書館の奥で、何かが落ちる音がした。

20
backend/go.mod Normal file
View file

@ -0,0 +1,20 @@
module textmachine/backend
go 1.26.4
require (
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.53.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

55
backend/go.sum Normal file
View file

@ -0,0 +1,55 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -0,0 +1,140 @@
package config
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// book.go: translation brief — конфиг проекта книги (Фаза 0 плана: языковая
// пара, жанр, аудитория, 18+, слайдер Venuti, хонорифики, транскрипция,
// сноски). brief_hash входит в ключ TM и в snapshot: изменение брифа посреди
// книги — явная команда с показом стоимости пере-перевода (Р6).
// Book is the parsed book.yaml.
type Book struct {
BookID string `yaml:"book_id"`
Title string `yaml:"title"`
SourceLang string `yaml:"source_lang"` // zh | ja | en
TargetLang string `yaml:"target_lang"` // ru
Genre string `yaml:"genre"`
Audience string `yaml:"audience"`
// Adult: интерим-правило 18+ Фазы 1 — true принудительно исключает
// Anthropic из роутинга книги (эскалация без Opus/Sonnet).
Adult bool `yaml:"adult"`
// Venuti is the foreignization↔domestication slider, 0.0 (доместикация)
// … 1.0 (форенизация).
Venuti float64 `yaml:"venuti"`
Honorifics string `yaml:"honorifics"` // keep | adapt
Transcription string `yaml:"transcription"` // e.g. polivanov | palladius
Footnotes string `yaml:"footnotes"` // none | minimal | rich
// Wiring: paths are resolved relative to the book.yaml location.
Pipeline string `yaml:"pipeline"`
ModelsFile string `yaml:"models"`
SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк
ProjectDB string `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
Ceilings BookCap `yaml:"ceilings"`
}
// BookCap are the ledger admission limits (Р7: потолок $ на книгу/день).
type BookCap struct {
BookUSD float64 `yaml:"book_usd"`
DayUSD float64 `yaml:"day_usd"`
}
// LoadBook reads and validates book.yaml, resolving relative paths against
// its directory.
func LoadBook(path string) (*Book, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
var b Book
if err := yaml.Unmarshal(raw, &b); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}
dir := filepath.Dir(path)
resolve := func(p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(dir, p)
}
b.Pipeline = resolve(b.Pipeline)
b.ModelsFile = resolve(b.ModelsFile)
b.SourceFile = resolve(b.SourceFile)
if b.ProjectDB == "" {
b.ProjectDB = filepath.Join(dir, b.BookID+".db")
} else {
b.ProjectDB = resolve(b.ProjectDB)
}
var problems []string
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
if b.BookID == "" {
bad("book_id is required")
}
if b.SourceLang == "" || b.TargetLang == "" {
bad("source_lang and target_lang are required")
}
if b.Venuti < 0 || b.Venuti > 1 {
bad("venuti must be in [0,1], got %v", b.Venuti)
}
if b.Pipeline == "" {
bad("pipeline config path is required")
}
if b.ModelsFile == "" {
bad("models config path is required")
}
if b.SourceFile == "" {
bad("source_file is required")
} else if _, err := os.Stat(b.SourceFile); err != nil {
bad("source_file %s is not readable: %v", b.SourceFile, err)
}
if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 {
bad("ceilings: at least one of book_usd/day_usd must be set (ledger без потолка запрещён Р7)")
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
}
return &b, nil
}
// BriefHash is the canonical hash of the SEMANTIC brief fields — the ones
// whose change legitimately invalidates the TM (Р6: brief_hash входит в ключ
// TM). Wiring fields (paths, ceilings, db) deliberately excluded: перенос
// проекта в другую директорию не должен пере-переводить книгу. JSON с
// фиксированным порядком полей → детерминированный байтовый рендер.
func (b *Book) BriefHash() string {
canon := struct {
BookID string `json:"book_id"`
SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"`
Genre string `json:"genre"`
Audience string `json:"audience"`
Adult bool `json:"adult"`
Venuti float64 `json:"venuti"`
Honorifics string `json:"honorifics"`
Transcription string `json:"transcription"`
Footnotes string `json:"footnotes"`
}{b.BookID, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes}
data, err := json.Marshal(canon)
if err != nil {
// A struct of scalars cannot fail to marshal; keep the signature clean.
panic(fmt.Sprintf("config: brief hash marshal: %v", err))
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// LangPair returns "ja-ru"-style pair key (ключи порогов coverage-гейта).
func (b *Book) LangPair() string {
return b.SourceLang + "-" + b.TargetLang
}

View file

@ -0,0 +1,173 @@
// Package config loads and fail-fast-validates the three YAML files of a run:
// models.yaml (провайдеры/модели/цены/таймауты — Р4/Р5: цены только в конфиге
// с датой проверки), pipeline-*.yaml (ядро C1/C2 — граница «конфиг vs код» по
// Р2) and book.yaml (translation brief + brief_hash).
//
// Дисциплина донора: все проблемы конфига собираются СПИСКОМ и валят старт
// одной ошибкой — оператор чинит всё сразу, а не по одной кочке за запуск.
package config
import (
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
"textmachine/backend/internal/ledger"
"textmachine/backend/internal/llm"
)
// Models is the parsed models.yaml.
type Models struct {
// PricesChecked is the date the prices below were verified against the
// providers' official pages. Fail-fast rejects a stale (>120 дней) config:
// волатильность цен API — риск Р10 №5.
PricesChecked string `yaml:"prices_checked"`
DefaultModel string `yaml:"default_model"`
Providers map[string]Provider `yaml:"providers"`
Models map[string]Model `yaml:"models"`
}
// Provider is one backend endpoint.
type Provider struct {
Kind string `yaml:"kind"` // openai | anthropic | local
BaseURL string `yaml:"base_url"`
APIKeyEnv string `yaml:"api_key_env"`
Reasoning string `yaml:"reasoning"` // subset | additive (openai kind only)
CacheTTL string `yaml:"cache_ttl"` // anthropic kind: "", "5m", "1h"
Model string `yaml:"model"` // local kind: the backend's own tag
MaxTokens int `yaml:"max_tokens"`
Timeouts Timeouts `yaml:"timeouts"`
}
// Timeouts is the retry profile per provider (профиль per-провайдер из
// вердикта валидации; per-role overrides — Фаза 1).
type Timeouts struct {
AttemptS int `yaml:"attempt_s"`
MaxAttempts int `yaml:"max_attempts"`
BackoffCapS int `yaml:"backoff_cap_s"`
}
func (t Timeouts) Profile() llm.RetryProfile {
return llm.RetryProfile{
AttemptTimeout: time.Duration(t.AttemptS) * time.Second,
MaxAttempts: t.MaxAttempts,
BackoffCap: time.Duration(t.BackoffCapS) * time.Second,
}
}
// Model is one priced model entry.
type Model struct {
Provider string `yaml:"provider"`
Price Price `yaml:"price"`
// ExtraBody is merged into request JSON for this model (провайдер-
// специфичные ручки: GLM thinking.type, Qwen enable_thinking …).
ExtraBody map[string]any `yaml:"extra_body"`
// MinCachePrefixTokens: Anthropic-модели — минимальный кэшируемый префикс
// (короче — кэш молча не создаётся); сборщик контекста Фазы 1 сверяется.
MinCachePrefixTokens int `yaml:"min_cache_prefix_tokens"`
Note string `yaml:"note"`
}
// Price mirrors ledger.ModelPrice in YAML form (USD per 1M tokens).
type Price struct {
InputPerM float64 `yaml:"input_per_m"`
CachedPerM float64 `yaml:"cached_per_m"`
CacheWritePerM float64 `yaml:"cache_write_per_m"`
OutputPerM float64 `yaml:"output_per_m"`
}
func (p Price) ToLedger() ledger.ModelPrice {
return ledger.ModelPrice{
InputPerM: p.InputPerM,
CachedPerM: p.CachedPerM,
CacheWritePerM: p.CacheWritePerM,
OutputPerM: p.OutputPerM,
}
}
// maxPriceAge is how stale prices_checked may be before the config is
// rejected (ежеквартальный пересмотр Р4 + буфер).
const maxPriceAge = 120 * 24 * time.Hour
// LoadModels reads and validates models.yaml.
func LoadModels(path string) (*Models, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
var m Models
if err := yaml.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}
var problems []string
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
if m.PricesChecked == "" {
bad("prices_checked is required (цены без даты проверки запрещены Р4)")
} else if t, err := time.Parse("2006-01-02", m.PricesChecked); err != nil {
bad("prices_checked %q is not YYYY-MM-DD", m.PricesChecked)
} else if time.Since(t) > maxPriceAge {
bad("prices_checked %s is older than %d days — re-verify provider prices", m.PricesChecked, int(maxPriceAge.Hours()/24))
}
if m.DefaultModel == "" {
bad("default_model is required (fallback-якорь цены: неизвестная модель никогда не стоит $0)")
} else if _, ok := m.Models[m.DefaultModel]; !ok {
bad("default_model %q is not defined in models", m.DefaultModel)
}
for name, p := range m.Providers {
switch p.Kind {
case "openai", "anthropic", "local":
default:
bad("provider %s: unknown kind %q", name, p.Kind)
}
if p.BaseURL == "" && p.Kind != "anthropic" {
bad("provider %s: base_url is required", name)
}
if p.Kind == "openai" && p.Reasoning != "" && p.Reasoning != "subset" && p.Reasoning != "additive" {
bad("provider %s: reasoning must be subset|additive, got %q", name, p.Reasoning)
}
if p.Kind == "anthropic" && p.CacheTTL != "" && p.CacheTTL != "5m" && p.CacheTTL != "1h" {
bad("provider %s: cache_ttl must be 5m|1h, got %q", name, p.CacheTTL)
}
if p.Kind == "local" && p.Model == "" {
bad("provider %s: local kind requires model (its own tag)", name)
}
}
for name, mod := range m.Models {
prov, ok := m.Providers[mod.Provider]
if !ok {
bad("model %s: provider %q is not defined", name, mod.Provider)
continue
}
isLocal := prov.Kind == "local"
if !isLocal && (mod.Price.InputPerM <= 0 || mod.Price.OutputPerM <= 0) {
bad("model %s: non-local models require non-zero input/output prices", name)
}
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
}
return &m, nil
}
// Prices builds the ledger pricer from the config.
func (m *Models) Prices() (*ledger.Pricer, error) {
prices := make(map[string]ledger.ModelPrice, len(m.Models))
for name, mod := range m.Models {
prices[name] = mod.Price.ToLedger()
}
return ledger.NewPricer(prices, m.Models[m.DefaultModel].Price.ToLedger())
}
// APIKey resolves a provider's key from the environment ("" if the provider
// declares no env var — the local backend).
func (p Provider) APIKey() string {
if p.APIKeyEnv == "" {
return ""
}
return os.Getenv(p.APIKeyEnv)
}

View file

@ -0,0 +1,179 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// pipeline.go loads the pipeline-core config (C1/C2… — Р2). Граница «конфиг vs
// код» зафиксирована: конфиг задаёт состав/порядок стадий, роль→модель, версии
// промптов, пороги гейтов, режим инъекции глоссария, эскалационные цепочки,
// fan-out N, токен-бюджеты сборки контекста и cache TTL. Циклы по
// главам/чанкам, ветвление по гейтам, механика эскалации/ретраев и правило
// «эскалация → re-gate → флаг» зашиты в раннер.
// Pipeline is the parsed pipeline-*.yaml.
type Pipeline struct {
Core string `yaml:"core"` // C0|C1|C2|C3
Version int `yaml:"version"`
Defaults PipelineDefaults `yaml:"defaults"`
Context ContextAssembly `yaml:"context"`
Retries Retries `yaml:"retries"`
Stages []Stage `yaml:"stages"`
Gates Gates `yaml:"gates"`
Escal Escalation `yaml:"escalation"`
Fanout Fanout `yaml:"fanout"`
}
// PipelineDefaults are cross-stage knobs.
type PipelineDefaults struct {
// MaxOutputRatio sizes max_tokens ≈ ratio × input tokens (токен-калибровка
// полигона: русский выход zh→ru ≈1.9× входа; дефолт с запасом).
MaxOutputRatio float64 `yaml:"max_output_ratio"`
MinMaxTokens int `yaml:"min_max_tokens"`
}
// ContextAssembly holds the prompt-layout budgets (Р5-раскладка: стабильный
// префикс / волатильный хвост — сборка Фазы 1; поля фиксируются сейчас, чтобы
// схема конфига не менялась под ногами «Редакции»).
type ContextAssembly struct {
GlossaryInjection string `yaml:"glossary_injection"` // selective | full_prefix (Р5: обе схемы)
GlossaryTokenBudget int `yaml:"glossary_token_budget"`
STMDepth int `yaml:"stm_depth"`
OverlapTokens int `yaml:"overlap_tokens"`
CacheTTL string `yaml:"cache_ttl"` // per-stage TTL override — Фаза 1
}
// Retries distinguishes the CONTENT retry (регенерация после провала гейта до
// эскалации) from transport retries (профиль в models.yaml) — спека их
// различает явно (валидация, линза 2 п.5).
type Retries struct {
RegenerateBeforeEscalate int `yaml:"regenerate_before_escalate"`
}
// Stage is one pipeline pass.
type Stage struct {
Name string `yaml:"name"`
Role string `yaml:"role"`
Model string `yaml:"model"`
Prompt string `yaml:"prompt"` // path to the versioned template file
PromptVersion string `yaml:"prompt_version"`
Temperature float64 `yaml:"temperature"`
Reasoning string `yaml:"reasoning"` // "", off, low, medium, high
}
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
type Gates struct {
Coverage CoverageGate `yaml:"coverage"`
}
// CoverageGate v1 (Р7): метрика — символы без пробелов; нижняя граница —
// подозрение на вырезание, верхняя — аномалия; пороги из эксперимента 02.
type CoverageGate struct {
Enabled bool `yaml:"enabled"`
LenRatio map[string][]float64 `yaml:"len_ratio_bounds"` // "zh-ru": [low, high]
SentCovMin float64 `yaml:"sent_cov_min"`
MinChunkChars int `yaml:"min_chunk_chars"`
}
// Escalation holds named model chains; channel-aware filtering (Anthropic вне
// 18+ и т.д.) применяет раннер, не конфиг.
type Escalation struct {
Chains map[string][]string `yaml:"chains"`
BudgetUSD float64 `yaml:"budget_usd"` // адресный премиум-бюджет книги; формула — [НУЖНО РЕШЕНИЕ] п.1
}
// Fanout is the C2 skeleton: N候補 candidates per chunk (fan-out — механика
// раннера, N — конфиг).
type Fanout struct {
Candidates int `yaml:"candidates"`
}
// LoadPipeline reads and validates a pipeline config against the models known
// to models.yaml.
func LoadPipeline(path string, models *Models) (*Pipeline, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
var p Pipeline
if err := yaml.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}
// Prompt paths resolve relative to the pipeline file's directory, so a
// project works from any CWD.
dir := filepath.Dir(path)
for i := range p.Stages {
if pr := p.Stages[i].Prompt; pr != "" && !filepath.IsAbs(pr) {
p.Stages[i].Prompt = filepath.Join(dir, pr)
}
}
var problems []string
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
if p.Core == "" {
bad("core is required (C0|C1|C2|C3)")
}
if len(p.Stages) == 0 {
bad("at least one stage is required")
}
if p.Defaults.MaxOutputRatio <= 0 {
p.Defaults.MaxOutputRatio = 2.0
}
if p.Defaults.MinMaxTokens <= 0 {
p.Defaults.MinMaxTokens = 2048
}
if p.Fanout.Candidates <= 0 {
p.Fanout.Candidates = 1
}
switch p.Context.GlossaryInjection {
case "", "selective", "full_prefix":
default:
bad("context.glossary_injection must be selective|full_prefix, got %q", p.Context.GlossaryInjection)
}
seen := map[string]bool{}
for i, st := range p.Stages {
if st.Name == "" {
bad("stage %d: name is required", i)
}
if seen[st.Name] {
bad("stage %q: duplicate name", st.Name)
}
seen[st.Name] = true
if st.Role == "" {
bad("stage %q: role is required", st.Name)
}
if st.PromptVersion == "" {
bad("stage %q: prompt_version is required (версионирование промптов — Р2)", st.Name)
}
if _, ok := models.Models[st.Model]; !ok {
bad("stage %q: model %q is not defined in models.yaml", st.Name, st.Model)
}
if st.Prompt == "" {
bad("stage %q: prompt template path is required", st.Name)
} else if _, err := os.Stat(st.Prompt); err != nil {
bad("stage %q: prompt template %s is not readable: %v", st.Name, st.Prompt, err)
}
switch st.Reasoning {
case "", "off", "low", "medium", "high":
default:
bad("stage %q: reasoning must be off|low|medium|high, got %q", st.Name, st.Reasoning)
}
}
for chain, ms := range p.Escal.Chains {
for _, m := range ms {
if _, ok := models.Models[m]; !ok {
bad("escalation chain %q: model %q is not defined in models.yaml", chain, m)
}
}
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
}
return &p, nil
}

View file

@ -0,0 +1,83 @@
// Package ledger is the money seam: per-model prices, usage→USD computation,
// and reserve/settle bookkeeping against the store. Дисциплина vojo целиком
// (Р7): биллинг по usage из ответа API, reserve-before-call / settle-after,
// потолки $; расширение Фазы 0 — стоимость записи в кэш (Anthropic ×1.25/×2).
package ledger
import (
"fmt"
"textmachine/backend/internal/llm"
)
// ModelPrice is the per-1M-token USD price for one model, applied to the
// API's returned usage so the ceilings track real cost even as prices drift.
// Prices live ONLY in configs/models.yaml with a checked-date (Р4/Р5).
type ModelPrice struct {
InputPerM float64 // non-cached prompt tokens
// CachedPerM prices prompt tokens served FROM the cache (cache read).
CachedPerM float64
// CacheWritePerM prices prompt tokens WRITTEN to the cache. Anthropic bills
// writes at ×1.25 (5m TTL) / ×2 (1h) of input; providers without explicit
// writes keep 0 and the term vanishes.
CacheWritePerM float64
OutputPerM float64 // completion + reasoning tokens
}
// Pricer resolves model→price with an explicit default anchor. An unknown
// model falls back to the DEFAULT price rather than $0 — a $0 price would
// silently blind the ceilings to that call, the one failure mode we never
// want (у vojo якорем была XAIModel; здесь мультипровайдерный конфиг требует
// явного default_price).
type Pricer struct {
prices map[string]ModelPrice
defaultPrice ModelPrice
}
// NewPricer builds a pricer. defaultPrice must be a real (non-zero) price —
// enforced at config load (fail-fast), re-checked here defensively.
func NewPricer(prices map[string]ModelPrice, defaultPrice ModelPrice) (*Pricer, error) {
if defaultPrice.InputPerM <= 0 || defaultPrice.OutputPerM <= 0 {
return nil, fmt.Errorf("ledger: default price must be non-zero (a $0 fallback would blind the spend ceilings)")
}
return &Pricer{prices: prices, defaultPrice: defaultPrice}, nil
}
// PriceFor returns the configured price for a model, falling back to the
// default anchor for unknown models (never $0).
func (p *Pricer) PriceFor(model string) ModelPrice {
if mp, ok := p.prices[model]; ok {
return mp
}
return p.defaultPrice
}
// CostUSD prices one completion by its usage. Formula (invariant from
// llm.Usage: PromptTokens = total input = uncached + cached + cache-written):
//
// (prompt cached cacheCreation)·in + cached·cacheRead
// + cacheCreation·cacheWrite + (completion + reasoning)·out
//
// For providers without cache-write accounting the third term is 0 and the
// formula degrades to vojo's computeUSD.
func CostUSD(price ModelPrice, u llm.Usage) float64 {
uncached := u.PromptTokens - u.CachedTokens - u.CacheCreationTokens
if uncached < 0 {
// Defensive: a provider reporting cached > prompt would otherwise
// produce a negative term and understate cost.
uncached = 0
}
perTok := func(perM float64) float64 { return perM / 1_000_000 }
return float64(uncached)*perTok(price.InputPerM) +
float64(u.CachedTokens)*perTok(price.CachedPerM) +
float64(u.CacheCreationTokens)*perTok(price.CacheWritePerM) +
float64(u.CompletionTokens+u.ReasoningTokens)*perTok(price.OutputPerM)
}
// EstimateUSD is the pre-call reservation estimate: assume the whole prompt
// misses the cache and the completion runs to maxTokens. Deliberately
// pessimistic — the reservation is released down to the real cost at settle.
func EstimateUSD(price ModelPrice, promptTokens, maxTokens int) float64 {
return float64(promptTokens)*price.InputPerM/1_000_000 +
float64(maxTokens)*price.OutputPerM/1_000_000
}

View file

@ -0,0 +1,72 @@
package ledger
import (
"math"
"testing"
"textmachine/backend/internal/llm"
)
func approx(t *testing.T, got, want float64, msg string) {
t.Helper()
if math.Abs(got-want) > 1e-12 {
t.Fatalf("%s: got %v, want %v", msg, got, want)
}
}
// Формула денег — ядро юнит-экономики; проверяем каждое слагаемое, включая
// новое (cache write), и деградацию к формуле vojo без кэш-полей.
func TestCostUSD(t *testing.T) {
price := ModelPrice{InputPerM: 2.0, CachedPerM: 0.2, CacheWritePerM: 2.5, OutputPerM: 10.0}
// Anthropic-подобный вызов: 10k всего входа = 6k некэш + 3k cache-read +
// 1k cache-write; 2k выхода.
u := llm.Usage{PromptTokens: 10_000, CachedTokens: 3_000, CacheCreationTokens: 1_000, CompletionTokens: 2_000}
want := 6_000*2.0/1e6 + 3_000*0.2/1e6 + 1_000*2.5/1e6 + 2_000*10.0/1e6
approx(t, CostUSD(price, u), want, "anthropic-style usage")
// OpenAI-совместимый вызов без кэш-полей — формула vojo.
u2 := llm.Usage{PromptTokens: 10_000, CompletionTokens: 2_000}
approx(t, CostUSD(price, u2), 10_000*2.0/1e6+2_000*10.0/1e6, "plain usage")
// Reasoning-токены биллятся по output-ставке ПОВЕРХ completion (xAI).
u3 := llm.Usage{PromptTokens: 1_000, CompletionTokens: 500, ReasoningTokens: 1_500}
approx(t, CostUSD(price, u3), 1_000*2.0/1e6+2_000*10.0/1e6, "additive reasoning")
// Защита от провайдера, отрапортовавшего cached > prompt: некэш-слагаемое
// зажимается в 0, а не уходит в минус.
u4 := llm.Usage{PromptTokens: 100, CachedTokens: 500, CompletionTokens: 10}
want4 := 0*2.0/1e6 + 500*0.2/1e6 + 10*10.0/1e6
approx(t, CostUSD(price, u4), want4, "cached > prompt guard")
}
func TestPricerNeverZero(t *testing.T) {
def := ModelPrice{InputPerM: 1, OutputPerM: 2}
p, err := NewPricer(map[string]ModelPrice{"known": {InputPerM: 5, CachedPerM: 1, OutputPerM: 10}}, def)
if err != nil {
t.Fatal(err)
}
if got := p.PriceFor("known").InputPerM; got != 5 {
t.Fatalf("known model price: got %v", got)
}
// Неизвестная модель (фактическая после фейловера) — дефолтный якорь, не $0.
if got := p.PriceFor("mystery-model").InputPerM; got != 1 {
t.Fatalf("unknown model must fall back to default price, got %v", got)
}
// $0-дефолт ослепил бы потолки — конструктор обязан отказать.
if _, err := NewPricer(nil, ModelPrice{}); err == nil {
t.Fatal("zero default price must be rejected")
}
}
func TestEstimateUSDIsPessimistic(t *testing.T) {
price := ModelPrice{InputPerM: 2.0, CachedPerM: 0.2, OutputPerM: 10.0}
est := EstimateUSD(price, 1_000, 2_000)
approx(t, est, 1_000*2.0/1e6+2_000*10.0/1e6, "estimate")
// Реальный вызов с кэш-хитом обязан стоить не больше оценки — иначе
// settle раздует committed выше потолка, пропущенного на допуске.
real := CostUSD(price, llm.Usage{PromptTokens: 1_000, CachedTokens: 900, CompletionTokens: 2_000})
if real > est {
t.Fatalf("real cost %v exceeds reservation estimate %v", real, est)
}
}

View file

@ -0,0 +1,232 @@
package llm
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
// failover.go is the local-first routing decorator over the LLMClient seam:
// prefer the (free) home backend while it is reachable, fall back to the cloud
// transparently when it is not. Порт vojo failover.go; конструктор принимает
// явные параметры вместо толстого *Config, пробер использует no-proxy
// транспорт (та же прокси-ловушка, что у provider_local).
//
// Channel-isolation caveat (03-implementation-notes §3.5): the fallback leg is
// wired by the CALLER per role — for NSFW channel B (Фаза 2) the fallback must
// itself be a permissive provider or absent (fail loud), otherwise an empty
// answer from the local abliterated model would silently ship the flagged
// chunk to a restricted cloud provider.
//
// Health is decided by an ACTIVE background probe plus a request-path circuit
// breaker, both feeding one healthy flag:
// - unhealthy → requests go straight to the cloud;
// - healthy → try local under its own deadline; on transport/5xx/timeout
// trip the breaker and retry the SAME request on the cloud;
// - recovery is probe-driven ONLY (anti-flapping): a tripped breaker needs
// tripProbesToClose consecutive probe successes to close.
//
// A terminal 4xx from the local leg (except 429) does NOT fall back: that is a
// config/request error (wrong model tag, malformed body) and the cloud would
// only mask it. 429 (a busy single-slot GPU) falls back like a timeout.
const (
// localProbeTimeout bounds one health probe: a bare GET /models is answered
// from the server's process memory, no inference — slower means the path,
// not the model, is struggling.
localProbeTimeout = 3 * time.Second
// tripProbesToClose is how many consecutive probe successes re-close a
// breaker tripped by a REQUEST failure (probe can pass while requests fail:
// a cold model being loaded, a half-alive path).
tripProbesToClose = 2
// neverOnlineWarnAfter: consecutive probe failures — without the leg EVER
// having come online since boot — that trigger a one-time WARN, so an
// enabled-but-dead local leg can't silently serve 100% of traffic from the
// paid cloud.
neverOnlineWarnAfter = 3
)
// FailoverConfig wires the decorator.
type FailoverConfig struct {
LegTimeout time.Duration // per-request deadline for the local leg
ProbeURL string // <local base URL>/models
ProbeKey string // optional bearer for the probe (usually empty)
ProbeInterval time.Duration // background probe period
}
type failoverClient struct {
primary LLMClient // local backend (free, preferred)
fallback LLMClient // cloud (paid)
log *slog.Logger
legTimeout time.Duration
probeURL string
probeKey string
interval time.Duration
httpc *http.Client
// Breaker state. healthy starts false: until the first probe passes,
// traffic is served by the cloud, so a boot with the home box offline never
// eats the probe timeout on user requests.
mu sync.Mutex
healthy bool
consecOK int
needOK int
consecFail int
everHealthy bool
warnedDown bool
}
// NewFailoverClient wraps primary (local) and fallback (cloud) into one
// LLMClient and starts the background health probe, which lives until ctx is
// cancelled (process lifetime).
func NewFailoverClient(ctx context.Context, primary, fallback LLMClient, cfg FailoverConfig, logger *slog.Logger) LLMClient {
if cfg.LegTimeout <= 0 {
cfg.LegTimeout = 300 * time.Second // полигон: чанки 63278 с; 45 с vojo непригодны
}
if cfg.ProbeInterval <= 0 {
cfg.ProbeInterval = 30 * time.Second
}
c := &failoverClient{
primary: primary,
fallback: fallback,
log: logger,
legTimeout: cfg.LegTimeout,
probeURL: cfg.ProbeURL,
probeKey: cfg.ProbeKey,
interval: cfg.ProbeInterval,
httpc: NoProxyClient(),
needOK: 1,
}
logger.Info("local llm failover enabled; cloud serves until the first successful probe", "probe_url", c.probeURL)
go c.probeLoop(ctx)
return c
}
func (c *failoverClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
if !c.isHealthy() {
c.log.DebugContext(ctx, "local llm unhealthy; serving from cloud")
return c.fallback.Complete(ctx, req)
}
lctx, cancel := context.WithTimeout(ctx, c.legTimeout)
resp, err := c.primary.Complete(lctx, req)
cancel()
if err == nil {
if strings.TrimSpace(resp.Text) != "" {
return resp, nil
}
// A 2xx with EMPTY content from the local leg (thinking ate the token
// budget, a quirk of the abliterated model). The local call is free, so
// retry the turn on the cloud. The backend is up: not a breaker trip.
c.log.DebugContext(ctx, "local llm returned empty content; retrying on cloud")
return c.fallback.Complete(ctx, req)
}
if ctx.Err() != nil {
// The WHOLE request budget is gone, not just the local leg — a cloud
// call would die the same way; don't burn a paid attempt or trip the
// breaker over our own cancellation.
return nil, err
}
var se *HTTPStatusError
if errors.As(err, &se) && se.Status >= 400 && se.Status < 500 && se.Status != http.StatusTooManyRequests {
return nil, err // terminal 4xx: local config error — fail loud, never mask with the cloud
}
c.trip()
c.log.DebugContext(ctx, "local llm failed; falling back to cloud", "err", err)
resp, ferr := c.fallback.Complete(ctx, req)
if ferr != nil {
c.log.WarnContext(ctx, "local llm and cloud fallback both failed", "local_err", err, "cloud_err", ferr)
return nil, ferr
}
return resp, nil
}
func (c *failoverClient) isHealthy() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.healthy
}
// trip opens the breaker after an in-request local failure. Recovery is
// probe-driven only.
func (c *failoverClient) trip() {
c.mu.Lock()
defer c.mu.Unlock()
if c.healthy {
c.log.Info("local llm tripped offline by a request failure; serving from cloud until probes recover")
}
c.healthy = false
c.consecOK = 0
c.needOK = tripProbesToClose
}
// noteProbe folds one probe result into the breaker. A failed probe opens it
// but does not raise needOK: plain down/up recovers on the next success, only
// a request-path trip demands the longer streak.
func (c *failoverClient) noteProbe(ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
if !ok {
if c.healthy {
c.log.Info("local llm probe failed; serving from cloud")
}
c.healthy = false
c.consecOK = 0
c.consecFail++
if !c.everHealthy && !c.warnedDown && c.consecFail >= neverOnlineWarnAfter {
c.warnedDown = true
c.log.Warn("local llm has never come online since boot; ALL traffic is served by the paid cloud — check the local base URL", "probe_url", c.probeURL)
}
return
}
c.consecFail = 0
c.consecOK++
if !c.healthy && c.consecOK >= c.needOK {
c.healthy = true
c.everHealthy = true
c.needOK = 1
c.log.Info("local llm online; serving from local backend")
}
}
func (c *failoverClient) probeLoop(ctx context.Context) {
t := time.NewTicker(c.interval)
defer t.Stop()
for {
c.noteProbe(c.probe(ctx)) // first probe runs immediately, not an interval late
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// probe GETs the OpenAI models listing on the local base URL — cheap (no
// inference) and it proves the whole path end to end. It does NOT prove the
// model is resident in VRAM; keeping it warm is OLLAMA_KEEP_ALIVE=-1's job.
func (c *failoverClient) probe(ctx context.Context) bool {
pctx, cancel := context.WithTimeout(ctx, localProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(pctx, http.MethodGet, c.probeURL, nil)
if err != nil {
return false
}
if c.probeKey != "" {
req.Header.Set("Authorization", "Bearer "+c.probeKey)
}
resp, err := c.httpc.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) // drain for connection reuse
return resp.StatusCode >= 200 && resp.StatusCode < 300
}

View file

@ -0,0 +1,137 @@
package llm
import (
"context"
"errors"
"log/slog"
"testing"
)
// fakeLLM — дублёр интерфейса (стиль донора): фиксированный ответ или ошибка.
type fakeLLM struct {
resp *LLMResponse
err error
calls int
}
func (f *fakeLLM) Complete(_ context.Context, _ LLMRequest) (*LLMResponse, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.resp, nil
}
func quietLogger() *slog.Logger { return slog.New(slog.DiscardHandler) }
// newTestFailover собирает декоратор без пробера (healthy выставляется руками
// через noteProbe) — детерминированная машина брейкера, как в failover_test
// донора.
func newTestFailover(primary, fallback LLMClient) *failoverClient {
return &failoverClient{
primary: primary, fallback: fallback,
log: quietLogger(), legTimeout: 1e9, needOK: 1,
}
}
func TestFailoverUnhealthyGoesStraightToCloud(t *testing.T) {
local := &fakeLLM{resp: &LLMResponse{Text: "local"}}
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
f := newTestFailover(local, cloud) // healthy=false на старте
resp, err := f.Complete(context.Background(), LLMRequest{})
if err != nil || resp.Text != "cloud" {
t.Fatalf("resp=%v err=%v", resp, err)
}
if local.calls != 0 {
t.Fatal("unhealthy leg must not be tried")
}
}
func TestFailoverHealthyServesLocal(t *testing.T) {
local := &fakeLLM{resp: &LLMResponse{Text: "local", Model: "local-model"}}
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
f := newTestFailover(local, cloud)
f.noteProbe(true)
resp, err := f.Complete(context.Background(), LLMRequest{})
if err != nil || resp.Text != "local" {
t.Fatalf("resp=%v err=%v", resp, err)
}
if cloud.calls != 0 {
t.Fatal("cloud must not be billed when local serves")
}
}
func TestFailoverEmptyLocalRetriesOnCloud(t *testing.T) {
// Пустой 2xx локальной ноги (thinking съел бюджет) — бесплатный, поэтому
// ретраим в облако; брейкер НЕ срабатывает.
local := &fakeLLM{resp: &LLMResponse{Text: " "}}
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
f := newTestFailover(local, cloud)
f.noteProbe(true)
resp, err := f.Complete(context.Background(), LLMRequest{})
if err != nil || resp.Text != "cloud" {
t.Fatalf("resp=%v err=%v", resp, err)
}
if !f.isHealthy() {
t.Fatal("empty content must not trip the breaker")
}
}
func TestFailoverTerminal4xxFailsLoud(t *testing.T) {
// Терминальный 4xx локальной ноги — конфиг-ошибка (неверный тег модели);
// облако её маскировать не должно.
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 404, Body: "no such model"}}
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
f := newTestFailover(local, cloud)
f.noteProbe(true)
_, err := f.Complete(context.Background(), LLMRequest{})
var se *HTTPStatusError
if err == nil || !errors.As(err, &se) || se.Status != 404 {
t.Fatalf("want loud 404, got %v", err)
}
if cloud.calls != 0 {
t.Fatal("terminal 4xx must not be masked by the cloud")
}
}
func TestFailover5xxTripsBreakerAndFallsBack(t *testing.T) {
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 502, Body: "boom"}}
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
f := newTestFailover(local, cloud)
f.noteProbe(true)
resp, err := f.Complete(context.Background(), LLMRequest{})
if err != nil || resp.Text != "cloud" {
t.Fatalf("resp=%v err=%v", resp, err)
}
if f.isHealthy() {
t.Fatal("5xx must trip the breaker")
}
// Анти-flapping: после request-trip одной успешной пробы мало.
f.noteProbe(true)
if f.isHealthy() {
t.Fatal("one probe success must not re-close a request-tripped breaker")
}
f.noteProbe(true)
if !f.isHealthy() {
t.Fatal("two consecutive probe successes must re-close the breaker")
}
}
func Test429FallsBackLikeTimeout(t *testing.T) {
// 429 — занятый single-slot GPU, падает в облако как таймаут (не как
// терминальный 4xx).
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 429, Body: "busy"}}
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
f := newTestFailover(local, cloud)
f.noteProbe(true)
resp, err := f.Complete(context.Background(), LLMRequest{})
if err != nil || resp.Text != "cloud" {
t.Fatalf("resp=%v err=%v", resp, err)
}
}

View file

@ -0,0 +1,336 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/rand"
"net/http"
"strconv"
"time"
"textmachine/backend/internal/obs"
)
// httpllm.go is the shared OpenAI-compatible Chat Completions transport: one
// HTTP+retry implementation reused by every OpenAI-compatible adapter
// (DeepSeek, GLM, Kimi, xAI, ru-агрегаторы, llama-server). Порт vojo
// httpllm.go с правками Фазы 0 (03-implementation-notes §2 п.2):
//
// - все таймауты/ретраи — ПАРАМЕТРЫ клиента из профиля models.yaml, не
// константы: захардкоженный 60-секундный per-attempt дедлайн донора убивал
// бы реальные чанки 63278 с и трижды бесплатно их ретраил;
// - Retry-After на 429 читается и уважается (донор капил backoff на 8 с
// молоток по rate-limit'ам массового прогона глав);
// - тело ответа читается через LimitReader (у донора кап был только в
// gemini-native адаптере).
// RetryProfile bounds one client's retry loop. Zero fields fall back to
// defaults; the profile comes from models.yaml per provider (per-role
// overrides — Фаза 1).
type RetryProfile struct {
AttemptTimeout time.Duration // deadline for ONE HTTP attempt
MaxAttempts int
BackoffBase time.Duration // first backoff; doubles per attempt
BackoffCap time.Duration
}
func (p RetryProfile) withDefaults() RetryProfile {
if p.AttemptTimeout <= 0 {
// Полигон намерил 63278 с на чанк локальной моделью; облачные быстрее,
// но дефолт обязан переживать длинный чанк. Провайдер-специфика — в
// models.yaml, это только страховочный потолок.
p.AttemptTimeout = 300 * time.Second
}
if p.MaxAttempts <= 0 {
p.MaxAttempts = 3
}
if p.BackoffBase <= 0 {
p.BackoffBase = 500 * time.Millisecond
}
if p.BackoffCap <= 0 {
p.BackoffCap = 30 * time.Second
}
return p
}
// maxResponseBytes caps one completion body read. A translated chapter chunk
// is ~1050 KiB; 16 MiB leaves two orders of magnitude of headroom while
// keeping a misbehaving endpoint from exhausting memory.
const maxResponseBytes = 16 << 20
// openAIClient performs OpenAI-compatible /chat/completions calls with retry.
type openAIClient struct {
name string // provider label for logs/errors ("deepseek", "glm", "local")
base string
key string
http *http.Client
profile RetryProfile
headers map[string]string // extra static headers (provider-specific), may be nil
log *slog.Logger
}
// newOpenAIClient builds the shared transport. httpc may be nil (default
// client); the local provider passes an explicit no-proxy client (прокси-грабли
// стенда: env-прокси перехватывает не-loopback локальные адреса вроде 172.x).
func newOpenAIClient(name, base, key string, profile RetryProfile, headers map[string]string, httpc *http.Client, logger *slog.Logger) *openAIClient {
if httpc == nil {
httpc = &http.Client{}
}
return &openAIClient{
name: name,
base: base,
key: key,
http: httpc,
profile: profile.withDefaults(),
headers: headers,
log: logger,
}
}
// --- OpenAI-compatible wire types -------------------------------------------------
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type openAIRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream"`
// Optional; omitempty keeps the plain body minimal.
ReasoningEffort string `json:"reasoning_effort,omitempty"`
ResponseFormat any `json:"response_format,omitempty"`
// extra carries per-model provider-specific fields from models.yaml
// (например GLM {"thinking":{"type":"disabled"}}). Merged into the JSON
// body at marshal time — the neutral request stays vendor-free.
extra map[string]any
}
// MarshalJSON merges extra into the standard body. Standard fields win on
// key collision (extra is config-supplied tuning, not an override channel).
func (r openAIRequest) MarshalJSON() ([]byte, error) {
type plain openAIRequest
base, err := json.Marshal(plain(r))
if err != nil {
return nil, err
}
if len(r.extra) == 0 {
return base, nil
}
var m map[string]any
if err := json.Unmarshal(base, &m); err != nil {
return nil, err
}
for k, v := range r.extra {
if _, exists := m[k]; !exists {
m[k] = v
}
}
return json.Marshal(m)
}
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
// xAI reports reasoning tokens here SEPARATELY from completion_tokens and
// bills them at the output rate; OpenAI-spec providers count them inside
// completion_tokens. The adapter decides which semantics apply.
CompletionTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
// DeepSeek historically reports cache usage in its own top-level fields
// instead of prompt_tokens_details. Parse both so the Phase-0 cache
// experiment can't show a false zero (03-implementation-notes §3.5).
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
}
// cacheRead returns the cache-hit token count whichever field the provider
// used.
func (u openAIUsage) cacheRead() int {
if u.PromptTokensDetails.CachedTokens > 0 {
return u.PromptTokensDetails.CachedTokens
}
return u.PromptCacheHitTokens
}
type openAIResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage openAIUsage `json:"usage"`
}
func (r *openAIResponse) Text() string {
if len(r.Choices) == 0 {
return ""
}
return r.Choices[0].Message.Content
}
func (r *openAIResponse) FinishReason() string {
if len(r.Choices) == 0 {
return ""
}
// OpenAI-compat wire values map 1:1 onto the neutral constants.
return r.Choices[0].FinishReason
}
// complete calls Chat Completions with retry on transient failures (429 / 5xx /
// network, exponential backoff + jitter, Retry-After honoured). Non-retryable
// 4xx fail immediately. On exhaustion the caller releases the reservation, so
// a transient failure is never silently swallowed.
func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*openAIResponse, error) {
payload, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
var lastErr error
for attempt := 0; attempt < c.profile.MaxAttempts; attempt++ {
if attempt > 0 {
backoff := c.profile.BackoffBase << uint(attempt-1)
if backoff > c.profile.BackoffCap {
backoff = c.profile.BackoffCap
}
// A server-provided Retry-After overrides our schedule: the provider
// knows its rate-limit window better than our exponential guess.
if ra := retryAfterOf(lastErr); ra > 0 {
backoff = ra
}
backoff += time.Duration(rand.Intn(250)) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
resp, retryable, err := c.attempt(ctx, payload)
if err == nil {
return resp, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, ctx.Err()
}
if !retryable {
return nil, err
}
if c.log != nil {
c.log.WarnContext(ctx, c.name+" attempt failed, will retry", "attempt", attempt+1, "max", c.profile.MaxAttempts, "err", err)
}
}
return nil, fmt.Errorf("%s: exhausted %d attempts: %w", c.name, c.profile.MaxAttempts, lastErr)
}
// attempt performs one HTTP call. Returns retryable=true for 429/5xx and
// network errors, false for other non-2xx (terminal 4xx). The per-attempt
// deadline bounds a single hung connection; the overall per-request deadline
// (set by the caller via ctx) bounds the whole retry loop.
func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResponse, bool, error) {
attemptCtx, cancel := context.WithTimeout(ctx, c.profile.AttemptTimeout)
defer cancel()
req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.base+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
// A local backend (ollama / llama-server) usually runs without auth; an
// empty key means "no Authorization header", not "Bearer " with an empty token.
if c.key != "" {
req.Header.Set("Authorization", "Bearer "+c.key)
}
for k, v := range c.headers {
req.Header.Set(k, v)
}
resp, err := c.http.Do(req)
if err != nil {
// Network error / timeout — retryable (unless the parent ctx is done).
return nil, ctx.Err() == nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return nil, true, &HTTPStatusError{Provider: c.name, Status: resp.StatusCode, Body: snippet(data), RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"))}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, false, &HTTPStatusError{Provider: c.name, Status: resp.StatusCode, Body: snippet(data)}
}
var out openAIResponse
if err := json.Unmarshal(data, &out); err != nil {
return nil, false, fmt.Errorf("%s decode: %w", c.name, err)
}
// A 2xx is a billed call even when the model returns empty content
// (content filter, finish_reason=length with no text). Return it as a
// success so the caller settles the real cost via the ledger instead of
// releasing the reservation and losing the spend — which would let empty
// replies bypass the book/day ceilings. Gates deal with the empty text.
return &out, false, nil
}
// HTTPStatusError is a non-2xx completion failure, carrying the status code as
// a typed field so callers (the failover decorator) can classify it
// structurally — a terminal 4xx is a config/request error that must fail loud,
// not be masked by a fallback — instead of parsing the message.
type HTTPStatusError struct {
Provider string
Status int
Body string
RetryAfter time.Duration // from the Retry-After header; 0 = absent
}
func (e *HTTPStatusError) Error() string {
return fmt.Sprintf("%s http %d: %s", e.Provider, e.Status, e.Body)
}
func retryAfterOf(err error) time.Duration {
if se, ok := err.(*HTTPStatusError); ok {
return se.RetryAfter
}
return 0
}
func parseRetryAfter(v string) time.Duration {
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}
func snippet(b []byte) string {
const max = 300
if len(b) > max {
return string(b[:max]) + "…"
}
return string(b)
}

View file

@ -0,0 +1,226 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// Сценарные тесты wire-контракта через httptest — стиль донора (реальные
// HTTP-раундтрипы, не моки транспорта).
func fastProfile() RetryProfile {
return RetryProfile{AttemptTimeout: 2 * time.Second, MaxAttempts: 3, BackoffBase: time.Millisecond, BackoffCap: 5 * time.Millisecond}
}
func openAIOK(t *testing.T, w http.ResponseWriter, body string) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
func TestOpenAICompatHappyPath(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("path = %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer k" {
t.Errorf("auth = %q", got)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatal(err)
}
openAIOK(t, w, `{"id":"r1","model":"m-actual","choices":[{"message":{"content":"привет"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":100,"completion_tokens":20,"prompt_tokens_details":{"cached_tokens":40}}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(),
ExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}}}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{
Model: "m",
Messages: []Message{{Role: "system", Content: "s"}, {Role: "user", Content: "u"}},
MaxTokens: 256,
Temperature: 0.3,
})
if err != nil {
t.Fatal(err)
}
if resp.Text != "привет" || resp.Model != "m-actual" || resp.FinishReason != FinishStop {
t.Fatalf("resp = %+v", resp)
}
if resp.Usage.PromptTokens != 100 || resp.Usage.CachedTokens != 40 || resp.Usage.CompletionTokens != 20 {
t.Fatalf("usage = %+v", resp.Usage)
}
if resp.Usage.ReasoningTokens != 0 {
t.Fatalf("subset semantics must keep reasoning 0, got %d", resp.Usage.ReasoningTokens)
}
// ExtraBody из models.yaml домержен в тело запроса.
if _, ok := gotBody["thinking"]; !ok {
t.Fatalf("extra_body not merged into wire body: %v", gotBody)
}
if gotBody["stream"] != false {
t.Fatalf("stream must be false, got %v", gotBody["stream"])
}
}
func TestDeepSeekCacheFieldsVariant(t *testing.T) {
// DeepSeek-вариант usage: prompt_cache_hit_tokens вместо
// prompt_tokens_details — иначе эксперимент по кэшу покажет ложный ноль.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
openAIOK(t, w, `{"id":"r2","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "deepseek", BaseURL: srv.URL, Profile: fastProfile()}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
if resp.Usage.CachedTokens != 64 {
t.Fatalf("deepseek cache-hit tokens not parsed: %+v", resp.Usage)
}
}
func TestAdditiveReasoningSemantics(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
openAIOK(t, w, `{"id":"r3","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":5,"completion_tokens_details":{"reasoning_tokens":50}}}`)
}))
defer srv.Close()
// additive (xAI): reasoning поверх completion.
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "xai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditive}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
if resp.Usage.ReasoningTokens != 50 {
t.Fatalf("additive semantics must surface reasoning tokens, got %+v", resp.Usage)
}
// subset: то же тело, но reasoning остаётся 0 (иначе двойной биллинг).
c2 := NewOpenAICompatClient(OpenAICompatConfig{Name: "openai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningSubset}, nil)
resp2, err := c2.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
if resp2.Usage.ReasoningTokens != 0 {
t.Fatalf("subset semantics must keep reasoning 0, got %+v", resp2.Usage)
}
}
func TestRetryOn429HonoursRetryAfter(t *testing.T) {
var calls atomic.Int32
var firstRetryAt, secondCallAt time.Time
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
if n == 1 {
firstRetryAt = time.Now()
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
return
}
secondCallAt = time.Now()
openAIOK(t, w, `{"id":"r","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
if resp.Text != "ok" || calls.Load() != 2 {
t.Fatalf("text=%q calls=%d", resp.Text, calls.Load())
}
// Retry-After: 1s должен перебить миллисекундный backoff профиля.
if wait := secondCallAt.Sub(firstRetryAt); wait < 900*time.Millisecond {
t.Fatalf("Retry-After not honoured: waited only %v", wait)
}
}
func TestTerminal4xxNoRetry(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"bad model"}`))
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err == nil {
t.Fatal("terminal 4xx must fail")
}
var se *HTTPStatusError
if !asHTTPStatus(err, &se) || se.Status != 400 {
t.Fatalf("want typed 400, got %v", err)
}
if calls.Load() != 1 {
t.Fatalf("terminal 4xx must not retry, calls=%d", calls.Load())
}
}
func TestEmptyContent2xxIsSuccess(t *testing.T) {
// 2xx с пустым контентом — оплаченный вызов: транспорт возвращает успех,
// деньги книжатся, гейты разбираются с пустым текстом.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
openAIOK(t, w, `{"id":"r","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatalf("empty-content 2xx must be a success (billed call): %v", err)
}
if resp.Text != "" || resp.Usage.PromptTokens != 10 {
t.Fatalf("resp = %+v", resp)
}
}
func TestRetryOn5xxThenSuccess(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) < 3 {
w.WriteHeader(http.StatusBadGateway)
return
}
openAIOK(t, w, `{"id":"r","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil || resp.Text != "ok" {
t.Fatalf("resp=%v err=%v", resp, err)
}
if calls.Load() != 3 {
t.Fatalf("calls = %d", calls.Load())
}
}
func asHTTPStatus(err error, target **HTTPStatusError) bool {
for err != nil {
if se, ok := err.(*HTTPStatusError); ok {
*target = se
return true
}
u, ok := err.(interface{ Unwrap() error })
if !ok {
return false
}
err = u.Unwrap()
}
return false
}

108
backend/internal/llm/llm.go Normal file
View file

@ -0,0 +1,108 @@
// Package llm is the provider-neutral seam between the pipeline and concrete
// model backends (порт vojo/apps/ai-bot llm.go с расширениями Фазы 0 — см.
// docs/architecture/03-implementation-notes.md §3.5). Nothing here names a
// vendor: the runner composes context, the ledger prices usage, and thin
// adapters (provider_openai.go, provider_anthropic.go, provider_local.go) map
// these types to/from each backend's wire format.
package llm
import "context"
// Message is one provider-neutral chat turn.
type Message struct {
Role string // "system" | "user" | "assistant"
Content string
// CacheBoundary marks THIS message as the end of a stable prompt prefix
// (раскладка Р5: стабильный префикс = system+brief+style sheet+резюме,
// волатильный хвост = глоссарий+STM+чанк). The Anthropic adapter attaches
// cache_control to the corresponding block (max 4 boundaries per request —
// API limit); OpenAI-compatible providers cache by prefix automatically and
// ignore the flag. The context assembler decides the layout; adapters never
// guess where the boundary is.
CacheBoundary bool
}
// Usage is the provider-neutral token accounting returned with a completion.
// It drives billing (ledger.CostUSD) — the counts are the API's own,
// authoritative even if our price constants drift.
//
// Invariant: PromptTokens is the TOTAL input (cached + cache-written +
// uncached). Anthropic reports the three parts separately (input_tokens is
// only the uncached remainder), so its adapter SUMS them into PromptTokens;
// OpenAI-compatible providers already report the total in prompt_tokens.
type Usage struct {
PromptTokens int
// CachedTokens is the subset of PromptTokens served from the provider's
// prompt cache (cache READ — billed at the discounted cache-hit rate).
CachedTokens int
// CacheCreationTokens is the subset of PromptTokens WRITTEN to the cache
// this call. Anthropic bills writes at a premium (×1.25 for 5m TTL, ×2 for
// 1h — проверено 2026-07-04); dropping this field would undercount spend,
// the same failure class as the reasoning-token undercount already paid
// for in vojo (3044%). Providers without explicit cache writes leave 0.
CacheCreationTokens int
CompletionTokens int
// ReasoningTokens are thinking tokens billed at the output rate but NOT
// included in CompletionTokens. Semantics are per-adapter: xAI reports
// them additively (verified in vojo against cost_in_usd_ticks); the OpenAI
// spec and ollama count them INSIDE completion_tokens, so those adapters
// must leave 0 to avoid double-billing; Anthropic bills thinking inside
// output_tokens, so its adapter also leaves 0.
ReasoningTokens int
}
// Neutral finish reasons. The coverage gate branches on these: a length cut
// needs a retry with a bigger max_tokens, NOT an escalation (реальный кейс
// Gemini из эксперимента 02 — thinking съел бюджет, sent_cov упал до 0.11).
const (
FinishStop = "stop" // natural end of turn
FinishLength = "length" // max_tokens exhausted
FinishContentFilter = "content_filter" // provider-side filter cut the output
FinishRefusal = "refusal" // model/classifier refused (Anthropic stop_reason=refusal)
)
// LLMRequest is a provider-neutral completion request.
//
// Deliberately absent (vs vojo): Tools (tool-calling вне скоупа MVP по Р2) and
// ConvID (xAI-специфичный кэш-хинт; вернём с xAI-адаптером, если понадобится).
type LLMRequest struct {
Model string
Messages []Message
MaxTokens int
Temperature float64 // adapters send it only when > 0
// ReasoningEffort: "" = provider default, "off" = explicitly disable
// thinking, "low"|"medium"|"high" = enable at that depth. Each adapter maps
// this to its wire form; providers with non-standard switches (GLM
// thinking.type, Qwen enable_thinking) are handled via per-model ExtraBody
// in models.yaml instead (эмпирика полигона: thinking у GLM — таймауты ×3).
ReasoningEffort string
// JSONOnly asks the backend to constrain output to a single valid JSON
// object (OpenAI-compat response_format json_object). Used by judge/gate
// roles; false serializes away.
JSONOnly bool
}
// LLMResponse is a provider-neutral completion result.
type LLMResponse struct {
Text string
Usage Usage
// Model is the model that ACTUALLY served this completion. It can differ
// from LLMRequest.Model when a routing decorator (failover) answered with a
// different backend — billing and telemetry must follow the responder, or a
// free local answer books at cloud prices. "" = unknown; consumers fall
// back to the requested model.
Model string
// FinishReason is one of the Finish* constants ("" = unknown/legacy).
FinishReason string
ProviderRequestID string // the backend's response id, logged for support/debug
}
// LLMClient is any chat-completion backend. Implementations are thin adapters
// over a wire protocol; the pipeline depends only on this interface, so
// clients can be swapped or decorated (failover) without touching business
// logic. Streaming is deliberately NOT in the interface yet ([НУЖНО РЕШЕНИЕ]
// п.4 в PROGRESS): when it lands it will be a second, optional interface
// (StreamingLLMClient) so existing adapters keep compiling.
type LLMClient interface {
Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error)
}

View file

@ -0,0 +1,285 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/rand"
"net/http"
"time"
"textmachine/backend/internal/obs"
)
// provider_anthropic.go is the native Messages API adapter — the first NEW
// code on top of the vojo port (Р1: OpenAI-compat слоя недостаточно, нужен
// explicit cache_control). It is a thin raw-HTTP adapter like every other
// provider here — NOT the official SDK — so retries, timeouts, proxies and
// raw usage stay under the one transport discipline the ledger depends on
// (обоснование в 03-implementation-notes §3.5).
//
// Wire facts (проверено 2026-07-04, платформенная документация):
// - POST {base}/v1/messages, headers x-api-key + anthropic-version.
// - system is a list of text blocks; cache_control {type:"ephemeral",
// ttl:"5m"|"1h"} on the LAST block of the stable prefix; max 4 breakpoints.
// - usage.input_tokens is ONLY the uncached remainder;
// cache_read_input_tokens and cache_creation_input_tokens are separate.
// Neutral Usage.PromptTokens is the SUM of the three (см. llm.go invariant).
// - cache write bills ×1.25 (5m) / ×2 (1h); read ×0.1. Min cacheable prefix
// is model-dependent (короче — кэш молча не создаётся): models.yaml keeps
// the per-model value for the assembler.
// - thinking bills as output tokens (inside output_tokens, no separate
// field) → ReasoningTokens stays 0 here.
// - stop_reason: end_turn | max_tokens | stop_sequence | refusal (+ tool_use,
// pause_turn — не наши кейсы в Фазе 0).
const anthropicVersion = "2023-06-01"
// AnthropicConfig configures the adapter.
type AnthropicConfig struct {
BaseURL string // default https://api.anthropic.com
APIKey string
Profile RetryProfile
// CacheTTL is the cache_control ttl applied at CacheBoundary blocks:
// "" (omit — API default 5m), "5m" or "1h". Per-stage tuning comes from
// the pipeline config (TTL 5m может истекать между вызовами стадии —
// латентности чанков 63278 с, см. implementation-notes §3.9).
CacheTTL string
}
type anthropicClient struct {
base string
key string
http *http.Client
profile RetryProfile
cacheTTL string
log *slog.Logger
}
// NewAnthropicClient builds the native Messages API adapter.
func NewAnthropicClient(cfg AnthropicConfig, logger *slog.Logger) LLMClient {
base := cfg.BaseURL
if base == "" {
base = "https://api.anthropic.com"
}
return &anthropicClient{
base: base,
key: cfg.APIKey,
http: &http.Client{},
profile: cfg.Profile.withDefaults(),
cacheTTL: cfg.CacheTTL,
log: logger,
}
}
// --- wire types -------------------------------------------------------------
type anthropicCacheControl struct {
Type string `json:"type"`
TTL string `json:"ttl,omitempty"`
}
type anthropicTextBlock struct {
Type string `json:"type"`
Text string `json:"text"`
CacheControl *anthropicCacheControl `json:"cache_control,omitempty"`
}
type anthropicMessage struct {
Role string `json:"role"`
Content []anthropicTextBlock `json:"content"`
}
type anthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System []anthropicTextBlock `json:"system,omitempty"`
Messages []anthropicMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
}
type anthropicResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
} `json:"usage"`
}
func (c *anthropicClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
wire, err := c.buildRequest(req)
if err != nil {
return nil, err
}
payload, err := json.Marshal(wire)
if err != nil {
return nil, err
}
var lastErr error
for attempt := 0; attempt < c.profile.MaxAttempts; attempt++ {
if attempt > 0 {
backoff := c.profile.BackoffBase << uint(attempt-1)
if backoff > c.profile.BackoffCap {
backoff = c.profile.BackoffCap
}
if ra := retryAfterOf(lastErr); ra > 0 {
backoff = ra
}
backoff += time.Duration(rand.Intn(250)) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
resp, retryable, err := c.attempt(ctx, payload)
if err == nil {
return resp, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, ctx.Err()
}
if !retryable {
return nil, err
}
if c.log != nil {
c.log.WarnContext(ctx, "anthropic attempt failed, will retry", "attempt", attempt+1, "max", c.profile.MaxAttempts, "err", err)
}
}
return nil, fmt.Errorf("anthropic: exhausted %d attempts: %w", c.profile.MaxAttempts, lastErr)
}
// buildRequest maps the neutral request onto the Messages wire. Leading
// system-role messages become system blocks; CacheBoundary flags become
// cache_control on that block. Thinking stays off (Фаза 0: Anthropic — только
// SFW-роли редактора/эскалации, thinking-глубина не нужна и биллится как
// output; ReasoningEffort пока не маппится — задокументированное ограничение).
func (c *anthropicClient) buildRequest(req LLMRequest) (*anthropicRequest, error) {
wire := &anthropicRequest{
Model: req.Model,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
}
boundaries := 0
block := func(m Message) anthropicTextBlock {
b := anthropicTextBlock{Type: "text", Text: m.Content}
if m.CacheBoundary {
boundaries++
b.CacheControl = &anthropicCacheControl{Type: "ephemeral", TTL: c.cacheTTL}
}
return b
}
inSystemPrefix := true
for _, m := range req.Messages {
if inSystemPrefix && m.Role == "system" {
wire.System = append(wire.System, block(m))
continue
}
inSystemPrefix = false
if m.Role == "system" {
// Mid-conversation system turns are model-gated on this API; our
// assembler never produces them — fail loud rather than mislabel.
return nil, fmt.Errorf("anthropic: system message after non-system turn is not supported")
}
wire.Messages = append(wire.Messages, anthropicMessage{
Role: m.Role,
Content: []anthropicTextBlock{block(m)},
})
}
if boundaries > 4 {
return nil, fmt.Errorf("anthropic: %d cache boundaries, API allows at most 4", boundaries)
}
if req.JSONOnly {
// Messages API has no response_format json_object; judge roles on
// Anthropic would need structured outputs (Фаза 2). Fail loud so a
// misconfigured pipeline doesn't silently lose the JSON constraint.
return nil, fmt.Errorf("anthropic: JSONOnly is not supported by this adapter yet")
}
return wire, nil
}
func (c *anthropicClient) attempt(ctx context.Context, payload []byte) (*LLMResponse, bool, error) {
attemptCtx, cancel := context.WithTimeout(ctx, c.profile.AttemptTimeout)
defer cancel()
req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.base+"/v1/messages", bytes.NewReader(payload))
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", c.key)
req.Header.Set("anthropic-version", anthropicVersion)
resp, err := c.http.Do(req)
if err != nil {
return nil, ctx.Err() == nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
obs.LogLLMExchange(ctx, c.log, "anthropic", payload, resp.StatusCode, data)
// 529 (overloaded) is Anthropic's extra retryable status on top of 429/5xx.
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return nil, true, &HTTPStatusError{Provider: "anthropic", Status: resp.StatusCode, Body: snippet(data), RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"))}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, false, &HTTPStatusError{Provider: "anthropic", Status: resp.StatusCode, Body: snippet(data)}
}
var out anthropicResponse
if err := json.Unmarshal(data, &out); err != nil {
return nil, false, fmt.Errorf("anthropic decode: %w", err)
}
var text string
for _, b := range out.Content {
if b.Type == "text" {
text += b.Text
}
}
// A 2xx with empty content (refusal before output, filtered) is still a
// billed call — return success and let gates handle the empty text, same
// discipline as the OpenAI-compat transport.
return &LLMResponse{
Text: text,
Usage: Usage{
// Neutral invariant: PromptTokens = total input. Anthropic's
// input_tokens is only the uncached remainder — sum the parts.
PromptTokens: out.Usage.InputTokens + out.Usage.CacheReadInputTokens + out.Usage.CacheCreationInputTokens,
CachedTokens: out.Usage.CacheReadInputTokens,
CacheCreationTokens: out.Usage.CacheCreationInputTokens,
CompletionTokens: out.Usage.OutputTokens,
// ReasoningTokens 0: thinking bills inside output_tokens here.
},
Model: out.Model,
FinishReason: mapAnthropicStopReason(out.StopReason),
ProviderRequestID: out.ID,
}, false, nil
}
func mapAnthropicStopReason(s string) string {
switch s {
case "end_turn", "stop_sequence":
return FinishStop
case "max_tokens":
return FinishLength
case "refusal":
return FinishRefusal
default:
return ""
}
}

View file

@ -0,0 +1,107 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestAnthropicWireShapeAndUsage(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("x-api-key") != "k" || r.Header.Get("anthropic-version") == "" {
t.Errorf("headers: key=%q version=%q", r.Header.Get("x-api-key"), r.Header.Get("anthropic-version"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
w.Write([]byte(`{"id":"msg1","model":"claude-sonnet-5","content":[{"type":"text","text":"пере"},{"type":"text","text":"вод"}],
"stop_reason":"end_turn",
"usage":{"input_tokens":60,"output_tokens":20,"cache_creation_input_tokens":30,"cache_read_input_tokens":10}}`))
}))
defer srv.Close()
c := NewAnthropicClient(AnthropicConfig{BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(), CacheTTL: "5m"}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{
Model: "claude-sonnet-5",
Messages: []Message{
{Role: "system", Content: "роль"},
{Role: "system", Content: "бриф", CacheBoundary: true},
{Role: "user", Content: "чанк"},
},
MaxTokens: 512,
Temperature: 0.4,
})
if err != nil {
t.Fatal(err)
}
// Wire: ведущие system-сообщения стали system-блоками, cache_control — на
// помеченном блоке, с TTL.
sys, ok := got["system"].([]any)
if !ok || len(sys) != 2 {
t.Fatalf("system blocks = %v", got["system"])
}
first := sys[0].(map[string]any)
if _, hasCC := first["cache_control"]; hasCC {
t.Fatal("unmarked block must not carry cache_control")
}
second := sys[1].(map[string]any)
cc, ok := second["cache_control"].(map[string]any)
if !ok || cc["type"] != "ephemeral" || cc["ttl"] != "5m" {
t.Fatalf("cache_control = %v", second["cache_control"])
}
msgs, ok := got["messages"].([]any)
if !ok || len(msgs) != 1 {
t.Fatalf("messages = %v", got["messages"])
}
// Usage-инвариант: PromptTokens = input + cache_read + cache_creation.
if resp.Usage.PromptTokens != 100 || resp.Usage.CachedTokens != 10 || resp.Usage.CacheCreationTokens != 30 {
t.Fatalf("usage = %+v", resp.Usage)
}
if resp.Text != "перевод" || resp.FinishReason != FinishStop || resp.Model != "claude-sonnet-5" {
t.Fatalf("resp = %+v", resp)
}
}
func TestAnthropicStopReasonMapping(t *testing.T) {
cases := map[string]string{
"end_turn": FinishStop,
"max_tokens": FinishLength,
"refusal": FinishRefusal,
"weird": "",
}
for wire, want := range cases {
if got := mapAnthropicStopReason(wire); got != want {
t.Errorf("map(%q) = %q, want %q", wire, got, want)
}
}
}
func TestAnthropicTooManyBoundaries(t *testing.T) {
c := NewAnthropicClient(AnthropicConfig{BaseURL: "http://unused", APIKey: "k", Profile: fastProfile()}, nil)
msgs := make([]Message, 5)
for i := range msgs {
msgs[i] = Message{Role: "system", Content: "x", CacheBoundary: true}
}
_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: msgs, MaxTokens: 16})
if err == nil {
t.Fatal("more than 4 cache boundaries must fail loud (API limit)")
}
}
func TestAnthropicJSONOnlyUnsupported(t *testing.T) {
c := NewAnthropicClient(AnthropicConfig{BaseURL: "http://unused", APIKey: "k", Profile: fastProfile()}, nil)
_, err := c.Complete(context.Background(), LLMRequest{
Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16, JSONOnly: true,
})
if err == nil {
t.Fatal("JSONOnly must fail loud, not silently drop the constraint")
}
}

View file

@ -0,0 +1,113 @@
package llm
import (
"context"
"log/slog"
"net/http"
)
// provider_local.go is the adapter for a self-hosted OpenAI-compatible
// inference backend (ollama / llama-server on the owner's GTX 1070). Порт
// vojo provider_local.go; главное отличие — явный no-proxy транспорт.
//
// Прокси-грабли стенда (memory textmachine-local-stand + валидация): в env
// прописан webshare-прокси, NO_PROXY=<local> — виндовая нотация, которую Go
// не понимает. Go stdlib сам НЕ проксирует loopback (127.0.0.1/localhost), но
// llama-server на стенде слушает WSL-gateway-адреса вида 172.18.0.1 — такой
// запрос ушёл бы на прокси и получил 403, нога failover навсегда осталась бы
// unhealthy, молча оплачивая облако. Поэтому транспорт локального провайдера
// (и его health-пробы в failover.go) строится с Proxy: nil безусловно.
// LocalConfig configures the local backend adapter.
type LocalConfig struct {
BaseURL string // e.g. http://127.0.0.1:11434/v1 or http://172.18.0.1:11434/v1
APIKey string // usually empty — no Authorization header then
// Model is the adapter's own tag (e.g. huihui_ai/qwen3-abliterated:8b):
// the pipeline asks for role-model names that don't exist locally. The
// response's Model reports what actually served, so billing prices it at
// the local $0 entry.
Model string
// Temperature overrides the request's (ollama honours a request temperature
// over the Modelfile; passing the cloud role's value through would mistune
// the local model). 0 = keep the request's.
Temperature float64
// MaxTokens overrides the request's: ollama's cap covers thinking AND
// answer together (unlike xAI, where thinking bills on top), so a
// cloud-sized budget lets a thinking model burn it all on reasoning and
// return empty content. 0 = inherit the request's.
MaxTokens int
Profile RetryProfile
}
// NoProxyClient is an http.Client that bypasses any environment proxy.
// Exported so the failover prober uses the same transport discipline.
func NoProxyClient() *http.Client {
return &http.Client{Transport: &http.Transport{Proxy: nil}}
}
type localClient struct {
http *openAIClient
model string
temp float64
maxTok int
}
// NewLocalClient builds the local-backend adapter.
func NewLocalClient(cfg LocalConfig, logger *slog.Logger) LLMClient {
return &localClient{
http: newOpenAIClient("local", cfg.BaseURL, cfg.APIKey, cfg.Profile, nil, NoProxyClient(), logger),
model: cfg.Model,
temp: cfg.Temperature,
maxTok: cfg.MaxTokens,
}
}
func (c *localClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
msgs := make([]openAIMessage, len(req.Messages))
for i, m := range req.Messages {
msgs[i] = openAIMessage{Role: m.Role, Content: m.Content}
}
maxTok := req.MaxTokens
if c.maxTok > 0 {
maxTok = c.maxTok
}
temp := req.Temperature
if c.temp > 0 {
temp = c.temp
}
var respFormat any
if req.JSONOnly {
respFormat = map[string]string{"type": "json_object"}
}
effort := req.ReasoningEffort
if effort == "off" {
// ollama's /v1 maps reasoning_effort onto Qwen3 thinking; "none" is its
// explicit off-switch (verified in vojo).
effort = "none"
}
resp, err := c.http.complete(ctx, openAIRequest{
Model: c.model,
Messages: msgs,
MaxTokens: maxTok,
Temperature: temp,
Stream: false,
ReasoningEffort: effort,
ResponseFormat: respFormat,
})
if err != nil {
return nil, err
}
return &LLMResponse{
Text: resp.Text(),
Usage: Usage{
PromptTokens: resp.Usage.PromptTokens,
CachedTokens: resp.Usage.cacheRead(),
CompletionTokens: resp.Usage.CompletionTokens,
// ReasoningTokens deliberately 0: ollama counts thinking INSIDE
// completion_tokens (subset semantics). It's all $0 regardless.
},
Model: c.model, // the local model answered, whatever the pipeline asked for
FinishReason: resp.FinishReason(),
ProviderRequestID: resp.ID,
}, nil
}

View file

@ -0,0 +1,117 @@
package llm
import (
"context"
"log/slog"
"net/http"
)
// provider_openai.go is the generic adapter for every OpenAI-compatible cloud
// provider (DeepSeek, GLM/Z.AI, Kimi, xAI, ru-агрегаторы). Vojo had a
// per-vendor file each (provider_xai.go и т.п.), but their bodies were
// identical shells over the shared transport; TextMachine's differences are
// data (base URL, key, usage semantics, extra body), so one adapter
// parameterized from models.yaml replaces the family.
// ReasoningSemantics tells the adapter how a provider accounts thinking
// tokens (см. Usage.ReasoningTokens в llm.go).
type ReasoningSemantics string
const (
// ReasoningSubset: thinking is counted INSIDE completion_tokens (OpenAI
// spec, ollama, DeepSeek). ReasoningTokens stays 0 to avoid double-billing.
ReasoningSubset ReasoningSemantics = "subset"
// ReasoningAdditive: thinking is reported separately and billed ON TOP of
// completion_tokens (xAI — verified in vojo against cost_in_usd_ticks;
// dropping it undercounted Grok spend by 3044%).
ReasoningAdditive ReasoningSemantics = "additive"
)
// OpenAICompatConfig configures one provider instance.
type OpenAICompatConfig struct {
Name string // provider label for logs/telemetry
BaseURL string // ".../v1"-style base; transport appends /chat/completions
APIKey string
Profile RetryProfile
Headers map[string]string // static extra headers, may be nil
Reasoning ReasoningSemantics
// ExtraBody is merged into every request JSON for this provider's models
// (e.g. GLM {"thinking":{"type":"disabled"}}). Per-model extras from
// models.yaml are passed the same way when the client is built per-model.
ExtraBody map[string]any
// HTTPClient overrides the default client (nil = default). The local
// provider uses this for the no-proxy transport.
HTTPClient *http.Client
}
type openAICompatClient struct {
http *openAIClient
reasoning ReasoningSemantics
extra map[string]any
}
// NewOpenAICompatClient builds an adapter for one OpenAI-compatible provider.
func NewOpenAICompatClient(cfg OpenAICompatConfig, logger *slog.Logger) LLMClient {
sem := cfg.Reasoning
if sem == "" {
sem = ReasoningSubset // the spec default; additive is the xAI exception
}
return &openAICompatClient{
http: newOpenAIClient(cfg.Name, cfg.BaseURL, cfg.APIKey, cfg.Profile, cfg.Headers, cfg.HTTPClient, logger),
reasoning: sem,
extra: cfg.ExtraBody,
}
}
func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
msgs := make([]openAIMessage, len(req.Messages))
for i, m := range req.Messages {
// CacheBoundary is meaningless on this wire: OpenAI-compatible caches
// (DeepSeek: prefix-match с 0-го токена блоками по 64 токена) are
// automatic. The stable-prefix ordering the assembler produced is all
// that matters here.
msgs[i] = openAIMessage{Role: m.Role, Content: m.Content}
}
var respFormat any
if req.JSONOnly {
respFormat = map[string]string{"type": "json_object"}
}
effort := req.ReasoningEffort
if effort == "off" {
// "off" is our neutral value; OpenAI-compat providers with a
// non-standard disable switch get it via ExtraBody instead.
effort = ""
}
resp, err := c.http.complete(ctx, openAIRequest{
Model: req.Model,
Messages: msgs,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
Stream: false,
ReasoningEffort: effort,
ResponseFormat: respFormat,
extra: c.extra,
})
if err != nil {
return nil, err
}
usage := Usage{
PromptTokens: resp.Usage.PromptTokens,
CachedTokens: resp.Usage.cacheRead(),
CompletionTokens: resp.Usage.CompletionTokens,
}
if c.reasoning == ReasoningAdditive {
usage.ReasoningTokens = resp.Usage.CompletionTokensDetails.ReasoningTokens
}
model := resp.Model
if model == "" {
model = req.Model
}
return &LLMResponse{
Text: resp.Text(),
Usage: usage,
Model: model,
FinishReason: resp.FinishReason(),
ProviderRequestID: resp.ID,
}, nil
}

View file

@ -0,0 +1,101 @@
package obs
import (
"context"
"log/slog"
"os"
"strings"
)
// NewLogger builds the process logger from the environment: LOG_LEVEL
// (debug|info|warn|error, default info) and LOG_FORMAT (text|json, default
// text). Writes to stderr with UTC timestamps.
func NewLogger() *slog.Logger {
opts := &slog.HandlerOptions{
Level: parseLogLevel(os.Getenv("LOG_LEVEL")),
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && a.Value.Kind() == slog.KindTime {
a.Value = slog.TimeValue(a.Value.Time().UTC())
}
return a
},
}
var h slog.Handler
if strings.EqualFold(strings.TrimSpace(os.Getenv("LOG_FORMAT")), "json") {
h = slog.NewJSONHandler(os.Stderr, opts)
} else {
h = slog.NewTextHandler(os.Stderr, opts)
}
return slog.New(contextHandler{h})
}
// contextHandler wraps a slog.Handler so a record logged with one of the
// *Context methods automatically gets the call's trace_id attached — the
// userver-style trail, set once at the top of a unit of work and carried
// through to the model call. Records logged without a traced context simply
// carry no trace_id.
type contextHandler struct{ slog.Handler }
func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
if id := TraceFromContext(ctx); id != "" {
r.AddAttrs(slog.String("trace_id", id))
}
return h.Handler.Handle(ctx, r)
}
// WithAttrs/WithGroup must re-wrap, or the embedded handler's versions would
// return a bare handler and silently drop the trace_id injection for any child
// logger (типовая ошибка обёрток, поймана ещё в vojo).
func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return contextHandler{h.Handler.WithAttrs(attrs)}
}
func (h contextHandler) WithGroup(name string) slog.Handler {
return contextHandler{h.Handler.WithGroup(name)}
}
// LogLLMExchange logs the raw request/response bodies of one model call at
// DEBUG, but ONLY when the call's context opted in (ReqInfo.LogBodies) —
// message content never enters the logs unless an operator explicitly enables
// it AND runs at LOG_LEVEL=debug. Only the BODIES are logged — never the URL
// or any header — so the API key cannot leak. Bodies are truncated.
func LogLLMExchange(ctx context.Context, log *slog.Logger, provider string, reqBody []byte, status int, respBody []byte) {
if log == nil {
return
}
ri, ok := ReqInfoFromContext(ctx)
if !ok || !ri.LogBodies {
return
}
log.DebugContext(ctx, "llm exchange",
"provider", provider,
"status", status,
"request", truncateForLog(reqBody, llmBodyLogMax),
"response", truncateForLog(respBody, llmBodyLogMax),
)
}
// llmBodyLogMax caps each logged model body: keeps a typical prompt/answer
// readable while staying bounded. A constant, not a knob — it only bounds log
// volume on an opt-in debug path.
const llmBodyLogMax = 4096
func truncateForLog(b []byte, maxBytes int) string {
if len(b) > maxBytes {
return string(b[:maxBytes]) + "…(truncated)"
}
return string(b)
}
func parseLogLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View file

@ -0,0 +1,22 @@
package obs
import (
"context"
"log/slog"
"runtime/debug"
)
// SafeGo runs fn in a goroutine with panic recovery: a panic in a background
// task (async telemetry write, retention trim) logs an ERROR with the stack
// and dies alone instead of taking the process down. Вынесено из vojo bot.go —
// без него async-паттерн телеметрии не переносится.
func SafeGo(ctx context.Context, log *slog.Logger, name string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.ErrorContext(ctx, "background task panicked", "task", name, "panic", r, "stack", string(debug.Stack()))
}
}()
fn()
}()
}

View file

@ -0,0 +1,69 @@
// Package obs is the observability seam: trace ids threaded through context,
// the slog handler that stamps them on every log line, gated LLM-exchange
// logging, and recovered goroutines. Порт vojo trace.go/logging.go +
// вынесенный из bot.go safego; ось запроса заменена с (sender, verbose) на
// (book, chapter, chunk, stage, role) — домен перевода.
package obs
import (
"context"
"crypto/rand"
"encoding/hex"
)
// trace.go threads a per-run correlation id and the small request facts the
// logger needs through context — the userver / OpenTelemetry idiom: mint once
// at the top of a unit of work, and every log line below it (down to the HTTP
// call to the model) carries the same trace_id without passing a logger by
// hand.
//
// The id is 16 random bytes rendered as 32 hex chars — the W3C Trace-Context /
// OTel trace-id shape — so the field maps straight onto an OTel exporter later.
type ctxKey int
const reqInfoKey ctxKey = iota
// ReqInfo is the per-call data carried in context. Zero-value fields are
// simply omitted from logs.
type ReqInfo struct {
TraceID string
Book string
Chapter int
Chunk int
Stage string // pipeline stage: draft | edit | ...
Role string // agent role: translator | editor | ...
// LogBodies gates raw LLM request/response body logging (privacy by
// default; decided once at admission so the transport just reads the flag).
LogBodies bool
}
// WithReqInfo stamps the call's info onto ctx. WithTimeout/WithCancel preserve
// values, so it flows down into the model transport.
func WithReqInfo(ctx context.Context, ri ReqInfo) context.Context {
return context.WithValue(ctx, reqInfoKey, ri)
}
// ReqInfoFromContext returns the call info, if any.
func ReqInfoFromContext(ctx context.Context) (ReqInfo, bool) {
ri, ok := ctx.Value(reqInfoKey).(ReqInfo)
return ri, ok
}
// TraceFromContext returns the trace id, or "" when ctx carries none (startup
// paths) — the slog handler then simply omits trace_id.
func TraceFromContext(ctx context.Context) string {
if ri, ok := ReqInfoFromContext(ctx); ok {
return ri.TraceID
}
return ""
}
// NewTraceID mints a random 16-byte id as 32 hex chars (the OTel trace-id
// shape). crypto/rand.Read never returns an error on modern Go (an entropy
// failure crashes the process rather than returning a short read).
func NewTraceID() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}

View file

@ -0,0 +1,50 @@
package pipeline
import (
"fmt"
"log/slog"
"textmachine/backend/internal/config"
"textmachine/backend/internal/llm"
)
// clients.go builds LLMClients from models.yaml. One client per model name
// (a model carries its ExtraBody), lazily cached by the runner.
// BuildClient constructs the adapter for one configured model.
func BuildClient(models *config.Models, modelName string, logger *slog.Logger) (llm.LLMClient, error) {
mod, ok := models.Models[modelName]
if !ok {
return nil, fmt.Errorf("pipeline: model %q is not defined in models.yaml", modelName)
}
prov := models.Providers[mod.Provider] // существование проверено fail-fast валидацией
switch prov.Kind {
case "openai":
return llm.NewOpenAICompatClient(llm.OpenAICompatConfig{
Name: mod.Provider,
BaseURL: prov.BaseURL,
APIKey: prov.APIKey(),
Profile: prov.Timeouts.Profile(),
Reasoning: llm.ReasoningSemantics(prov.Reasoning),
ExtraBody: mod.ExtraBody,
}, logger), nil
case "anthropic":
return llm.NewAnthropicClient(llm.AnthropicConfig{
BaseURL: prov.BaseURL,
APIKey: prov.APIKey(),
Profile: prov.Timeouts.Profile(),
CacheTTL: prov.CacheTTL,
}, logger), nil
case "local":
return llm.NewLocalClient(llm.LocalConfig{
BaseURL: prov.BaseURL,
APIKey: prov.APIKey(),
Model: prov.Model,
MaxTokens: prov.MaxTokens,
Profile: prov.Timeouts.Profile(),
}, logger), nil
default:
return nil, fmt.Errorf("pipeline: provider %q has unknown kind %q", mod.Provider, prov.Kind)
}
}

View file

@ -0,0 +1,164 @@
// Package pipeline is the C-core runner: deterministic prompt rendering,
// request hashing, chunk checkpoints and the stage loop. Фаза 0 — мини-раннер
// C1 (draft→edit, один чанк); циклы по главам/чанкам, гейты и эскалация
// нарастают здесь в Фазе 1 (Р2: это код раннера, не конфиг).
package pipeline
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strconv"
"strings"
"unicode"
"textmachine/backend/internal/config"
"textmachine/backend/internal/llm"
)
// render.go enforces the determinism invariant (03-implementation-notes §3.1):
// the rendered prompt is a PURE function of (snapshot, committed prior
// outputs, chunk). Запрещены timestamps/UUID; никаких map-итераций — только
// фиксированный список плейсхолдеров. Проверяется тестом «два рендера
// байт-в-байт идентичны». Ломается детерминизм → ломается request-hash →
// resume пере-переводит и пере-оплачивает главу, а кэш DeepSeek (byte prefix
// match) промахивается.
// chunkerVersion versions the segmentation rules; it is part of the snapshot,
// so re-chunking a book is an explicit re-translation, not a silent cache miss
// (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в).
const chunkerVersion = "chunker-v0-wholefile"
// userSeparator splits a prompt template file into the system part and the
// user part. Обе части — версионируемые шаблоны в prompts/ («Редакция» позже
// правит файлы, не код).
const userSeparator = "\n---USER---\n"
// PromptTemplate is one loaded stage template.
type PromptTemplate struct {
System string
User string
SHA256 string // hash of the raw file — участвует в snapshot
}
// LoadPromptTemplate reads and splits a template file.
func LoadPromptTemplate(path string) (*PromptTemplate, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("pipeline: read prompt %s: %w", path, err)
}
sum := sha256.Sum256(raw)
parts := strings.SplitN(string(raw), userSeparator, 2)
t := &PromptTemplate{System: strings.TrimSpace(parts[0]), SHA256: hex.EncodeToString(sum[:])}
if len(parts) == 2 {
t.User = strings.TrimSpace(parts[1])
} else {
return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator))
}
return t, nil
}
// RenderVars are the ONLY placeholders a template may use. A fixed struct, not
// a map: map iteration order would randomize the render.
type RenderVars struct {
Book *config.Book
Text string // the source chunk
Draft string // prior stage output ("" on the first stage)
}
// Render substitutes placeholders. Unknown {{…}} markers are a hard error —
// молчаливо пустой плейсхолдер в промпте хуже падения.
func Render(tpl string, v RenderVars) (string, error) {
pairs := [...][2]string{
{"{{book_id}}", v.Book.BookID},
{"{{title}}", v.Book.Title},
{"{{source_lang}}", v.Book.SourceLang},
{"{{target_lang}}", v.Book.TargetLang},
{"{{genre}}", v.Book.Genre},
{"{{audience}}", v.Book.Audience},
{"{{venuti}}", strconv.FormatFloat(v.Book.Venuti, 'f', 2, 64)},
{"{{honorifics}}", v.Book.Honorifics},
{"{{transcription}}", v.Book.Transcription},
{"{{footnotes}}", v.Book.Footnotes},
{"{{text}}", v.Text},
{"{{draft}}", v.Draft},
}
out := tpl
for _, p := range pairs {
out = strings.ReplaceAll(out, p[0], p[1])
}
if i := strings.Index(out, "{{"); i >= 0 {
end := strings.Index(out[i:], "}}")
if end < 0 {
end = len(out) - i
} else {
end += 2
}
return "", fmt.Errorf("pipeline: unknown placeholder %q in template", out[i:i+end])
}
return out, nil
}
// Messages builds the wire-neutral message list for a stage. Layout по Р5:
// system (стабильный префикс, кэш-граница на последнем стабильном блоке) →
// user (волатильный хвост: чанк/черновик). Инъекция глоссария/STM встанет
// между ними в Фазе 1, не меняя схемы.
func Messages(tpl *PromptTemplate, v RenderVars) ([]llm.Message, error) {
sys, err := Render(tpl.System, v)
if err != nil {
return nil, err
}
user, err := Render(tpl.User, v)
if err != nil {
return nil, err
}
return []llm.Message{
{Role: "system", Content: sys, CacheBoundary: true},
{Role: "user", Content: user},
}, nil
}
// RequestHash is the checkpoint/resume key (§3.1): a stable hash of everything
// that determines the call. The snapshot id freezes the volatile context, so
// identical re-renders after a restart find their checkpoint and are neither
// repeated nor re-billed.
func RequestHash(bookID string, chapter, chunkIdx int, stage, role, model string, temperature float64, reasoning string, jsonOnly bool, maxTokens int, snapshotID string, msgs []llm.Message) string {
h := sha256.New()
w := func(parts ...string) {
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte{0})
}
}
w("tm-request-v1", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), stage, role, model,
strconv.FormatFloat(temperature, 'f', -1, 64), reasoning, strconv.FormatBool(jsonOnly),
strconv.Itoa(maxTokens), snapshotID)
for _, m := range msgs {
w(m.Role, m.Content, strconv.FormatBool(m.CacheBoundary))
}
return hex.EncodeToString(h.Sum(nil))
}
// EstimateTokens is a cheap, deterministic token estimate for reservation
// sizing and max_tokens derivation (NOT for billing — billing uses the API's
// usage). Калибровка полигона: иероглиф ≈0.9 ток., русский/латиница ≈3 симв.
// на токен.
func EstimateTokens(s string) int {
cjk, other := 0, 0
for _, r := range s {
switch {
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
cjk++
case unicode.IsSpace(r):
// whitespace mostly folds into neighbouring tokens
default:
other++
}
}
est := cjk + other/3
if est < 16 {
est = 16
}
return est
}

View file

@ -0,0 +1,122 @@
package pipeline
import (
"os"
"path/filepath"
"testing"
"textmachine/backend/internal/config"
"textmachine/backend/internal/llm"
)
func testBook() *config.Book {
return &config.Book{
BookID: "b", Title: "T", SourceLang: "ja", TargetLang: "ru",
Genre: "ранобэ", Audience: "взрослые", Venuti: 0.6,
Honorifics: "keep", Transcription: "polivanov", Footnotes: "minimal",
}
}
func writeTemplate(t *testing.T, content string) *PromptTemplate {
t.Helper()
path := filepath.Join(t.TempDir(), "tpl.md")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
tpl, err := LoadPromptTemplate(path)
if err != nil {
t.Fatal(err)
}
return tpl
}
// Инвариант §3.1: рендер — чистая функция; два рендера байт-в-байт идентичны.
// Без этого request-hash плывёт между запусками, resume пере-переводит и
// пере-оплачивает главу, а byte-prefix кэш DeepSeek промахивается.
func TestRenderIsDeterministic(t *testing.T) {
tpl := writeTemplate(t, "Перевод с {{source_lang}} на {{target_lang}}, жанр {{genre}}, venuti {{venuti}}.\n---USER---\nТекст: {{text}}\nЧерновик: {{draft}}")
v := RenderVars{Book: testBook(), Text: "исходник", Draft: "черновик"}
m1, err := Messages(tpl, v)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
m2, err := Messages(tpl, v)
if err != nil {
t.Fatal(err)
}
if len(m1) != len(m2) {
t.Fatal("length differs")
}
for j := range m1 {
if m1[j] != m2[j] {
t.Fatalf("render %d differs at message %d:\n%+v\nvs\n%+v", i, j, m1[j], m2[j])
}
}
}
h1 := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1)
h2 := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1)
if h1 != h2 {
t.Fatal("request hash is not stable")
}
}
func TestRequestHashSensitivity(t *testing.T) {
msgs := []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u"}}
base := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", msgs)
variants := map[string]string{
"model": RequestHash("b", 1, 0, "draft", "translator", "m2", 0.3, "off", false, 1024, "snap", msgs),
"temp": RequestHash("b", 1, 0, "draft", "translator", "m", 0.4, "off", false, 1024, "snap", msgs),
"snapshot": RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap2", msgs),
"maxTokens": RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 2048, "snap", msgs),
"chunk": RequestHash("b", 1, 1, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", msgs),
"content": RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u2"}}),
}
for name, h := range variants {
if h == base {
t.Errorf("hash must change when %s changes", name)
}
}
// Конкатенация с разделителем не склеивается: ("ab","c") != ("a","bc").
a := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
[]llm.Message{{Role: "user", Content: "ab"}, {Role: "user", Content: "c"}})
b := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
[]llm.Message{{Role: "user", Content: "a"}, {Role: "user", Content: "bc"}})
if a == b {
t.Fatal("field separator is not injective")
}
}
func TestRenderRejectsUnknownPlaceholder(t *testing.T) {
tpl := writeTemplate(t, "Привет {{nonexistent}}\n---USER---\n{{text}}")
_, err := Messages(tpl, RenderVars{Book: testBook(), Text: "x"})
if err == nil {
t.Fatal("unknown placeholder must fail loud, not render empty")
}
}
func TestTemplateRequiresUserSeparator(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.md")
if err := os.WriteFile(path, []byte("только системная часть"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadPromptTemplate(path); err == nil {
t.Fatal("template without ---USER--- must be rejected")
}
}
func TestEstimateTokens(t *testing.T) {
// Порядок величин по калибровке полигона: иероглиф ≈1 токен, кириллица ≈3
// символа на токен. Оценка — для резервов, не для биллинга.
ja := EstimateTokens("図書館は静寂に包まれていた") // 13 CJK-рун
if ja < 10 || ja > 20 {
t.Errorf("ja estimate out of range: %d", ja)
}
ru := EstimateTokens("Библиотека была окутана тишиной") // ~30 не-пробельных
if ru < 8 || ru > 16 {
t.Errorf("ru estimate out of range: %d", ru)
}
}

View file

@ -0,0 +1,347 @@
package pipeline
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"time"
"textmachine/backend/internal/config"
"textmachine/backend/internal/ledger"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// runner.go: the Phase-0 mini-runner — one chunk through the configured stage
// list (C1: draft→edit) with full money discipline: reserve → call → settle+
// checkpoint (одна транзакция) → request_log. Resume: найденный чекпоинт =
// вызов не повторяется и не оплачивается. Гейты/эскалация/циклы по главам —
// Фаза 1, сюда же (код раннера).
// Runner executes a book's pipeline.
type Runner struct {
Book *config.Book
Models *config.Models
Pipeline *config.Pipeline
Store *store.Store
Pricer *ledger.Pricer
Log *slog.Logger
clients map[string]llm.LLMClient
templates map[string]*PromptTemplate
}
// NewRunner loads all three configs rooted at book.yaml and opens the project
// store. Caller owns Close.
func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
book, err := config.LoadBook(bookPath)
if err != nil {
return nil, err
}
models, err := config.LoadModels(book.ModelsFile)
if err != nil {
return nil, err
}
pipe, err := config.LoadPipeline(book.Pipeline, models)
if err != nil {
return nil, err
}
pricer, err := models.Prices()
if err != nil {
return nil, err
}
st, err := store.Open(book.ProjectDB)
if err != nil {
return nil, err
}
r := &Runner{
Book: book,
Models: models,
Pipeline: pipe,
Store: st,
Pricer: pricer,
Log: logger,
clients: map[string]llm.LLMClient{},
templates: map[string]*PromptTemplate{},
}
if err := r.loadTemplates(); err != nil {
st.Close()
return nil, err
}
return r, nil
}
func (r *Runner) Close() error { return r.Store.Close() }
func (r *Runner) loadTemplates() error {
for _, st := range r.Pipeline.Stages {
tpl, err := LoadPromptTemplate(st.Prompt)
if err != nil {
return err
}
r.templates[st.Name] = tpl
}
return nil
}
func (r *Runner) client(model string) (llm.LLMClient, error) {
if c, ok := r.clients[model]; ok {
return c, nil
}
c, err := BuildClient(r.Models, model, r.Log)
if err != nil {
return nil, err
}
r.clients[model] = c
return c, nil
}
// snapshotID materializes the job-context snapshot (§3.2): brief_hash, chunker
// version and the full stage plan (model, prompt version + CONTENT hash,
// sampling). Payload is rendered with a fixed field order — the id is a
// content hash, so identical context re-upserts idempotently.
func (r *Runner) snapshotID() (id, payload string, err error) {
type stageSnap struct {
Name string `json:"name"`
Role string `json:"role"`
Model string `json:"model"`
PromptVersion string `json:"prompt_version"`
PromptSHA256 string `json:"prompt_sha256"`
Temperature float64 `json:"temperature"`
Reasoning string `json:"reasoning"`
}
snap := struct {
BriefHash string `json:"brief_hash"`
ChunkerVersion string `json:"chunker_version"`
PipelineCore string `json:"pipeline_core"`
Stages []stageSnap `json:"stages"`
}{
BriefHash: r.Book.BriefHash(),
ChunkerVersion: chunkerVersion,
PipelineCore: r.Pipeline.Core,
}
for _, st := range r.Pipeline.Stages {
snap.Stages = append(snap.Stages, stageSnap{
Name: st.Name, Role: st.Role, Model: st.Model,
PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256,
Temperature: st.Temperature, Reasoning: st.Reasoning,
})
}
data, err := json.Marshal(snap)
if err != nil {
return "", "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), string(data), nil
}
// StageResult reports one executed stage.
type StageResult struct {
Stage string
Role string
Model string // фактически ответившая модель
FromResume bool // served from a checkpoint, no call made
Usage llm.Usage
CostUSD float64
LatencyMS int
FinishReason string
Text string
}
// RunResult is the whole chunk's outcome.
type RunResult struct {
BookID string
Chapter int
ChunkIdx int
Stages []StageResult
FinalText string
TotalUSD float64
}
// TranslateOneChunk runs the configured stages over the book's source file as
// a single chunk (Фаза 0). Chapter/chunk are fixed at 1/0 until the chunker
// lands in Phase 1.
func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) {
srcRaw, err := os.ReadFile(r.Book.SourceFile)
if err != nil {
return nil, fmt.Errorf("pipeline: read source: %w", err)
}
source := strings.TrimSpace(string(srcRaw))
if source == "" {
return nil, fmt.Errorf("pipeline: source file %s is empty", r.Book.SourceFile)
}
snapID, snapPayload, err := r.snapshotID()
if err != nil {
return nil, err
}
if err := r.Store.UpsertSnapshot(snapID, r.Book.BriefHash(), snapPayload); err != nil {
return nil, err
}
const chapter, chunkIdx = 1, 0
res := &RunResult{BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx}
prev := ""
for _, st := range r.Pipeline.Stages {
sr, err := r.runStage(ctx, st, snapID, chapter, chunkIdx, source, prev)
if err != nil {
return res, err
}
res.Stages = append(res.Stages, *sr)
res.TotalUSD += sr.CostUSD
prev = sr.Text
}
res.FinalText = prev
return res, nil
}
func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, chapter, chunkIdx int, source, prev string) (*StageResult, error) {
job, err := r.Store.EnsureJob(r.Book.BookID, chapter, st.Name, snapID)
if err != nil {
return nil, err
}
if err := r.Store.SetJobStatus(job.ID, "running"); err != nil {
return nil, err
}
msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: source, Draft: prev})
if err != nil {
return nil, err
}
// max_tokens: калибровка полигона — русский выход ≈1.9× входа в токенах;
// ratio из конфига, floor защищает короткие чанки. Входит в request-hash
// (меняет вызов), поэтому считается детерминированно от исходника.
inputEst := EstimateTokens(source)
maxTokens := int(float64(inputEst) * r.Pipeline.Defaults.MaxOutputRatio)
if maxTokens < r.Pipeline.Defaults.MinMaxTokens {
maxTokens = r.Pipeline.Defaults.MinMaxTokens
}
reqHash := RequestHash(r.Book.BookID, chapter, chunkIdx, st.Name, st.Role, st.Model,
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
ctx = obs.WithReqInfo(ctx, obs.ReqInfo{
TraceID: obs.TraceFromContext(ctx), Book: r.Book.BookID,
Chapter: chapter, Chunk: chunkIdx, Stage: st.Name, Role: st.Role,
})
// Resume path: a checkpoint means the call already happened and was paid —
// serve it, log the hit, never re-bill (приёмка: kill -9 теряет ≤1 вызов).
if cp, err := r.Store.GetCheckpoint(reqHash); err != nil {
return nil, err
} else if cp != nil {
var usage llm.Usage
_ = json.Unmarshal([]byte(cp.UsageJSON), &usage)
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: cp.ModelActual,
RequestHash: reqHash, TMHit: true, OK: true, FinishReason: cp.FinishReason,
})
if err := r.Store.SetJobStatus(job.ID, "done"); err != nil {
return nil, err
}
r.Log.InfoContext(ctx, "stage served from checkpoint", "stage", st.Name, "hash", reqHash[:12])
return &StageResult{Stage: st.Name, Role: st.Role, Model: cp.ModelActual,
FromResume: true, Usage: usage, CostUSD: 0, FinishReason: cp.FinishReason, Text: cp.ResponseText}, nil
}
price := r.Pricer.PriceFor(st.Model)
promptEst := 0
for _, m := range msgs {
promptEst += EstimateTokens(m.Content)
}
estimate := ledger.EstimateUSD(price, promptEst, maxTokens)
resv, verdict, err := r.Store.Reserve(r.Book.BookID, estimate, store.Ceilings{
BookUSD: r.Book.Ceilings.BookUSD, DayUSD: r.Book.Ceilings.DayUSD,
})
if err != nil {
return nil, err
}
switch verdict {
case store.ReserveDeniedBook:
return nil, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop", r.Book.Ceilings.BookUSD)
case store.ReserveDeniedDay:
return nil, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD)
}
client, err := r.client(st.Model)
if err != nil {
_ = r.Store.Release(resv)
return nil, err
}
start := time.Now()
resp, err := client.Complete(ctx, llm.LLMRequest{
Model: st.Model,
Messages: msgs,
MaxTokens: maxTokens,
Temperature: st.Temperature,
ReasoningEffort: st.Reasoning,
})
latency := int(time.Since(start).Milliseconds())
if err != nil {
// No 2xx ever arrived: nothing was billed by the provider — release the
// reservation and surface the failure with a telemetry row.
if rerr := r.Store.Release(resv); rerr != nil {
r.Log.ErrorContext(ctx, "release after failed call also failed", "err", rerr)
}
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model,
RequestHash: reqHash, LatencyMS: latency, Err: err.Error(), OK: false,
})
_ = r.Store.SetJobStatus(job.ID, "failed")
return nil, fmt.Errorf("pipeline: stage %s call: %w", st.Name, err)
}
modelActual := resp.Model
if modelActual == "" {
modelActual = st.Model
}
cost := ledger.CostUSD(r.Pricer.PriceFor(modelActual), resp.Usage)
usageJSON, err := json.Marshal(resp.Usage)
if err != nil {
return nil, err
}
// Деньги: settle + сырой ответ — одна транзакция (§3.3).
if err := r.Store.SettleWithCheckpoint(resv, cost, store.Checkpoint{
RequestHash: reqHash, JobID: job.ID, ChunkIdx: chunkIdx, Stage: st.Name, Role: st.Role,
ModelRequested: st.Model, ModelActual: modelActual, ResponseText: resp.Text,
UsageJSON: string(usageJSON), CostUSD: cost, FinishReason: resp.FinishReason,
ProviderRequestID: resp.ProviderRequestID,
}); err != nil {
// The provider HAS billed this 2xx; failing to persist means the money
// state is behind reality — fail loud, never continue on top.
return nil, fmt.Errorf("pipeline: settle stage %s: %w", st.Name, err)
}
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: modelActual,
RequestHash: reqHash,
PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.CachedTokens,
CacheCreationTokens: resp.Usage.CacheCreationTokens,
CompletionTokens: resp.Usage.CompletionTokens, ReasoningTokens: resp.Usage.ReasoningTokens,
CostUSD: cost, LatencyMS: latency, FinishReason: resp.FinishReason, OK: true,
})
if err := r.Store.SetJobStatus(job.ID, "done"); err != nil {
return nil, err
}
r.Log.InfoContext(ctx, "stage completed", "stage", st.Name, "model", modelActual,
"cost_usd", fmt.Sprintf("%.6f", cost), "latency_ms", latency,
"prompt_tokens", resp.Usage.PromptTokens, "completion_tokens", resp.Usage.CompletionTokens)
return &StageResult{Stage: st.Name, Role: st.Role, Model: modelActual,
Usage: resp.Usage, CostUSD: cost, LatencyMS: latency,
FinishReason: resp.FinishReason, Text: resp.Text}, nil
}

View file

@ -0,0 +1,197 @@
package pipeline
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"textmachine/backend/internal/obs"
)
// e2e-тест мини-раннера: temp-проект книги против httptest-провайдера. Деньги,
// чекпоинты и resume проверяются через реальные конфиги/store — то, что
// демонстрирует приёмка Фазы 0, только на моке.
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// newFakeProvider serves OpenAI-совместимые ответы: draft-стадии отвечает
// «ЧЕРНОВИК», edit-стадии — «РЕДАКТУРА» (различает по наличию слова ЧЕРНОВИК
// в промпте редактора). Считает вызовы.
func newFakeProvider(t *testing.T, calls *atomic.Int32) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
body, _ := io.ReadAll(r.Body)
text := "ЧЕРНОВИК ПЕРЕВОДА"
if bytes.Contains(body, []byte("Черновик перевода для редактуры")) {
text = "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД"
}
fmt.Fprintf(w, `{"id":"fake-%d","model":"fake-model","choices":[{"message":{"content":"%s"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`,
calls.Load(), text)
}))
}
func setupProject(t *testing.T, providerURL string) string {
t.Helper()
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "prompts", "translator.md"),
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
fake:
kind: openai
base_url: %q
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02"), providerURL))
writeFile(t, filepath.Join(dir, "pipeline.yaml"), `
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 1 }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off" }
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
`)
writeFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。")
writeFile(t, filepath.Join(dir, "book.yaml"), `
book_id: test-book
title: Тест
source_lang: ja
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0, day_usd: 2.0 }
`)
return filepath.Join(dir, "book.yaml")
}
func TestRunnerEndToEndWithResume(t *testing.T) {
var calls atomic.Int32
srv := newFakeProvider(t, &calls)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// Прогон 1: два вызова (draft, edit), деньги учтены.
r1, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
res1, err := r1.TranslateOneChunk(ctx)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("expected 2 provider calls, got %d", calls.Load())
}
if len(res1.Stages) != 2 || res1.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("run1 = %+v", res1)
}
// Стоимость по формуле: (1000-200)*1 + 200*0.1 + 500*2 за 1M — на стадию.
wantStage := (800*1.0 + 200*0.1 + 500*2.0) / 1e6
if diff := res1.TotalUSD - 2*wantStage; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("total = %v, want %v", res1.TotalUSD, 2*wantStage)
}
committed, reserved, err := r1.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if reserved != 0 || committed != res1.TotalUSD {
t.Fatalf("ledger: committed=%v reserved=%v want committed=%v", committed, reserved, res1.TotalUSD)
}
r1.Close()
// Прогон 2 (новый процесс — новый Runner над тем же project.db): оба
// вызова обслуживаются чекпоинтами, провайдер не трогается, доплаты нет.
r2, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r2.Close()
res2, err := r2.TranslateOneChunk(ctx)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("resume must not call the provider again, calls=%d", calls.Load())
}
for _, st := range res2.Stages {
if !st.FromResume || st.CostUSD != 0 {
t.Fatalf("stage %s must be served from checkpoint at $0: %+v", st.Stage, st)
}
}
if res2.FinalText != res1.FinalText {
t.Fatalf("resume text differs: %q vs %q", res2.FinalText, res1.FinalText)
}
committed2, _, err := r2.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if committed2 != committed {
t.Fatalf("resume must not add spend: %v -> %v", committed, committed2)
}
}
func TestRunnerCeilingDenies(t *testing.T) {
var calls atomic.Int32
srv := newFakeProvider(t, &calls)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
// Потолок $0: первый же reserve обязан отказать ДО вызова провайдера.
raw, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
patched := strings.Replace(string(raw), "book_usd: 1.0", "book_usd: 0.0000001", 1)
writeFile(t, bookPath, patched)
r, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r.Close()
_, err = r.TranslateOneChunk(context.Background())
if err == nil {
t.Fatal("ceiling must deny the run")
}
if calls.Load() != 0 {
t.Fatalf("denied reserve must not reach the provider, calls=%d", calls.Load())
}
}

View file

@ -0,0 +1,73 @@
package store
import (
"context"
"database/sql"
"errors"
)
// jobs.go: snapshots + durable jobs. Джоба = глава×стадия; сохранность — чанк
// (checkpoints, см. ledger.go). Snapshot материализует контекст джобы, чтобы
// request-hash был детерминирован на всю её жизнь (§3.13.2).
// UpsertSnapshot stores a materialized job-context snapshot (idempotent: the
// snapshot_id is itself a content hash, so re-inserting the same id is a
// no-op).
func (s *Store) UpsertSnapshot(snapshotID, briefHash, payloadJSON string) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx, `
INSERT INTO snapshots (snapshot_id, brief_hash, payload) VALUES (?, ?, ?)
ON CONFLICT (snapshot_id) DO NOTHING`,
snapshotID, briefHash, payloadJSON)
return err
}
// Job is one chapter×stage unit of work.
type Job struct {
ID int64
BookID string
Chapter int
Stage string
Status string
SnapshotID string
}
// EnsureJob returns the existing job for (book, chapter, stage) or creates it
// bound to snapshotID. An existing job KEEPS its original snapshot — Р6: из
// ключа resume исключаются волатильные части, подтверждение термина между
// запусками не должно инвалидировать уже переведённые чанки.
func (s *Store) EnsureJob(bookID string, chapter int, stage, snapshotID string) (*Job, error) {
ctx, cancel := opContext()
defer cancel()
if _, err := s.w.ExecContext(ctx, `
INSERT INTO jobs (book_id, chapter, stage, snapshot_id) VALUES (?, ?, ?, ?)
ON CONFLICT (book_id, chapter, stage) DO NOTHING`,
bookID, chapter, stage, snapshotID); err != nil {
return nil, err
}
return s.getJob(ctx, bookID, chapter, stage)
}
func (s *Store) getJob(ctx context.Context, bookID string, chapter int, stage string) (*Job, error) {
j := Job{BookID: bookID, Chapter: chapter, Stage: stage}
err := s.r.QueryRowContext(ctx,
`SELECT id, status, snapshot_id FROM jobs WHERE book_id = ? AND chapter = ? AND stage = ?`,
bookID, chapter, stage).Scan(&j.ID, &j.Status, &j.SnapshotID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &j, nil
}
// SetJobStatus transitions a job.
func (s *Store) SetJobStatus(jobID int64, status string) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx,
`UPDATE jobs SET status = ?, updated_at = datetime('now') WHERE id = ?`, status, jobID)
return err
}

View file

@ -0,0 +1,115 @@
package store
import (
"fmt"
"math"
"math/rand"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
)
// kill9_test.go — приёмочный тест Р6/Фазы 0: kill -9 посреди работы теряет
// максимум один in-flight вызов и НИКОГДА не рассинхронизирует деньги с
// чекпоинтами. Настоящий SIGKILL по подпроцессу (helper-process паттерн
// стандартной библиотеки), не эмуляция.
//
// Инвариант после любого убийства: committed_usd == SUM(checkpoints.cost_usd)
// — потому что settle и чекпоинт пишутся ОДНОЙ транзакцией. Осиротевшие
// reserved_usd снимаются recovery-проходом при открытии.
const killLoopEnv = "TM_STORE_KILL_LOOP_DB"
func TestHelperKillLoop(t *testing.T) {
path := os.Getenv(killLoopEnv)
if path == "" {
t.Skip("helper process only")
}
s, err := Open(path)
if err != nil {
fmt.Fprintln(os.Stderr, "helper open:", err)
os.Exit(1)
}
if err := s.UpsertSnapshot("snap", "brief", `{}`); err != nil {
os.Exit(1)
}
job, err := s.EnsureJob("book", 1, "draft", "snap")
if err != nil {
os.Exit(1)
}
// Бесконечный цикл reserve → settle+checkpoint; родитель убьёт нас SIGKILL
// в случайный момент. Никаких Release — «упавшие» резервы снимет recovery.
for i := 0; ; i++ {
res, verdict, err := s.Reserve("book", 0.002, Ceilings{BookUSD: 10_000})
if err != nil || verdict != ReserveOK {
os.Exit(1)
}
cp := Checkpoint{
RequestHash: fmt.Sprintf("hash-%d-%d", os.Getpid(), i),
JobID: job.ID, ChunkIdx: i, Stage: "draft", Role: "translator",
ModelRequested: "m", ModelActual: "m",
ResponseText: "ответ", UsageJSON: "{}", CostUSD: 0.001,
}
if err := s.SettleWithCheckpoint(res, 0.001, cp); err != nil {
os.Exit(1)
}
}
}
func TestKillMinus9LosesAtMostOneCall(t *testing.T) {
if testing.Short() {
t.Skip("subprocess test")
}
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "kill9.db")
for round := 0; round < 3; round++ {
cmd := exec.Command(exe, "-test.run", "^TestHelperKillLoop$", "-test.v")
cmd.Env = append(os.Environ(), killLoopEnv+"="+path)
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
// Даём поработать случайное время, затем SIGKILL — никакого graceful.
time.Sleep(time.Duration(150+rand.Intn(400)) * time.Millisecond)
if err := cmd.Process.Signal(syscall.SIGKILL); err != nil {
t.Fatal(err)
}
_ = cmd.Wait()
s, err := Open(path) // recovery-проход снимает зависшие резервы
if err != nil {
t.Fatalf("round %d: reopen after kill: %v", round, err)
}
var checkpointSum float64
var checkpointCount int
if err := s.r.QueryRow(`SELECT COALESCE(SUM(cost_usd),0), COUNT(*) FROM checkpoints`).Scan(&checkpointSum, &checkpointCount); err != nil {
t.Fatal(err)
}
committed, reserved, err := s.SpentUSD("book")
if err != nil {
t.Fatal(err)
}
s.Close()
if reserved != 0 {
t.Fatalf("round %d: recovery must clear reservations, reserved=%v", round, reserved)
}
// Ядро приёмки: деньги и чекпоинты атомарны — расхождение значило бы
// оплаченный, но потерянный (или бесплатный, но записанный) вызов.
if math.Abs(committed-checkpointSum) > 1e-9 {
t.Fatalf("round %d: committed=%v != sum(checkpoints)=%v over %d checkpoints — settle+checkpoint not atomic",
round, committed, checkpointSum, checkpointCount)
}
if checkpointCount == 0 && round > 0 {
t.Fatalf("round %d: helper made no progress", round)
}
t.Logf("round %d: %d checkpoints, committed=$%.6f — consistent after SIGKILL", round, checkpointCount, committed)
}
}

View file

@ -0,0 +1,198 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ledger.go: reserve-before-call / settle-after money discipline (порт
// семантики vojo Reserve/Settle/ReleaseReservation на SQLite). Потолки — на
// книгу (суммарно) и на день (Р7); проверяются по committed + reserved под
// immediate-транзакцией write-пула, так что конкурентные вызовы не
// проскакивают потолок больше чем на одну максимальную резервацию.
// ReserveResult is the outcome of a pre-call admission check.
type ReserveResult int
const (
ReserveOK ReserveResult = iota
ReserveDeniedBook // per-book USD ceiling hit
ReserveDeniedDay // daily USD ceiling hit
)
// Reservation is the handle Settle/Release need to undo the admission.
type Reservation struct {
BookID string
Date string // UTC day the reservation was booked under
Estimate float64
}
// Ceilings are the admission limits. Zero means "no limit" for that axis —
// fail-fast in config forbids an all-zero pair for real runs.
type Ceilings struct {
BookUSD float64
DayUSD float64
}
func todayUTC() string { return time.Now().UTC().Format("2006-01-02") }
// Reserve books estimate USD against the ceilings BEFORE the call. On success
// the estimate is added to reserved_usd; Settle converts it to committed
// spend, Release returns it. Denials are results, not errors.
func (s *Store) Reserve(bookID string, estimate float64, c Ceilings) (Reservation, ReserveResult, error) {
ctx, cancel := opContext()
defer cancel()
day := todayUTC()
res := Reservation{BookID: bookID, Date: day, Estimate: estimate}
tx, err := s.w.BeginTx(ctx, nil) // write pool: immediate tx, serialized
if err != nil {
return res, ReserveOK, err
}
defer tx.Rollback()
var bookTotal, dayTotal float64
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(SUM(committed_usd + reserved_usd), 0) FROM spend WHERE book_id = ?`, bookID,
).Scan(&bookTotal); err != nil {
return res, ReserveOK, err
}
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(SUM(committed_usd + reserved_usd), 0) FROM spend WHERE date = ?`, day,
).Scan(&dayTotal); err != nil {
return res, ReserveOK, err
}
if c.BookUSD > 0 && bookTotal+estimate > c.BookUSD {
return res, ReserveDeniedBook, nil
}
if c.DayUSD > 0 && dayTotal+estimate > c.DayUSD {
return res, ReserveDeniedDay, nil
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO spend (book_id, date, reserved_usd) VALUES (?, ?, ?)
ON CONFLICT (book_id, date) DO UPDATE SET reserved_usd = reserved_usd + excluded.reserved_usd`,
bookID, day, estimate); err != nil {
return res, ReserveOK, err
}
if err := tx.Commit(); err != nil {
return res, ReserveOK, err
}
return res, ReserveOK, nil
}
// Release frees a reservation whose call produced no billable spend (transport
// exhaustion, terminal 4xx before a 2xx). MAX(0, …) guards a double-release.
func (s *Store) Release(res Reservation) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx,
`UPDATE spend SET reserved_usd = MAX(0, reserved_usd - ?) WHERE book_id = ? AND date = ?`,
res.Estimate, res.BookID, res.Date)
return err
}
// Checkpoint is one persisted raw LLM response (см. migrate.go v1).
type Checkpoint struct {
RequestHash string
JobID int64
ChunkIdx int
Stage string
Role string
ModelRequested string
ModelActual string
ResponseText string
UsageJSON string
CostUSD float64
FinishReason string
ProviderRequestID string
}
// SettleWithCheckpoint atomically (ONE transaction, one file) converts the
// reservation into committed spend AND persists the raw response. Это
// закрытие дыры «settle прошёл — kill -9 — чекпоинт не записан = двойная
// оплата» (implementation-notes §3.3): после рестарта либо есть и списание, и
// чекпоинт (вызов не повторится), либо нет ни того ни другого (recovery
// снимет резерв, вызов повторится и оплатится один раз). Неустранимая потеря
// — только in-flight вызов, что и есть честные «≤1 вызов» приёмки.
//
// Idempotent per request_hash: a duplicate settle for the same hash books
// nothing and keeps the original checkpoint.
func (s *Store) SettleWithCheckpoint(res Reservation, cost float64, cp Checkpoint) error {
ctx, cancel := opContext()
defer cancel()
tx, err := s.w.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
ins, err := tx.ExecContext(ctx, `
INSERT INTO checkpoints (
request_hash, job_id, chunk_idx, stage, role,
model_requested, model_actual, response_text, usage_json,
cost_usd, finish_reason, provider_request_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (request_hash) DO NOTHING`,
cp.RequestHash, cp.JobID, cp.ChunkIdx, cp.Stage, cp.Role,
cp.ModelRequested, cp.ModelActual, cp.ResponseText, cp.UsageJSON,
cp.CostUSD, cp.FinishReason, cp.ProviderRequestID)
if err != nil {
return fmt.Errorf("store: checkpoint insert: %w", err)
}
inserted, _ := ins.RowsAffected()
if inserted == 0 {
// Duplicate settle (retried caller): release the reservation, book no
// new spend — the original settle already did.
if _, err := tx.ExecContext(ctx,
`UPDATE spend SET reserved_usd = MAX(0, reserved_usd - ?) WHERE book_id = ? AND date = ?`,
res.Estimate, res.BookID, res.Date); err != nil {
return err
}
return tx.Commit()
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO spend (book_id, date, committed_usd, reserved_usd) VALUES (?, ?, ?, 0)
ON CONFLICT (book_id, date) DO UPDATE SET
committed_usd = committed_usd + excluded.committed_usd,
reserved_usd = MAX(0, reserved_usd - ?)`,
res.BookID, res.Date, cost, res.Estimate); err != nil {
return fmt.Errorf("store: settle spend: %w", err)
}
return tx.Commit()
}
// GetCheckpoint returns the persisted response for a request hash, if any —
// the resume path: a hit means the call is NOT repeated or re-billed.
func (s *Store) GetCheckpoint(requestHash string) (*Checkpoint, error) {
ctx, cancel := opContext()
defer cancel()
cp := Checkpoint{RequestHash: requestHash}
err := s.r.QueryRowContext(ctx, `
SELECT job_id, chunk_idx, stage, role, model_requested, model_actual,
response_text, usage_json, cost_usd, finish_reason, provider_request_id
FROM checkpoints WHERE request_hash = ?`, requestHash).Scan(
&cp.JobID, &cp.ChunkIdx, &cp.Stage, &cp.Role, &cp.ModelRequested, &cp.ModelActual,
&cp.ResponseText, &cp.UsageJSON, &cp.CostUSD, &cp.FinishReason, &cp.ProviderRequestID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &cp, nil
}
// SpentUSD reports (committed, reserved) for a book across all days.
func (s *Store) SpentUSD(bookID string) (committed, reserved float64, err error) {
ctx, cancel := opContext()
defer cancel()
err = s.r.QueryRowContext(ctx,
`SELECT COALESCE(SUM(committed_usd),0), COALESCE(SUM(reserved_usd),0) FROM spend WHERE book_id = ?`,
bookID).Scan(&committed, &reserved)
return
}

View file

@ -0,0 +1,133 @@
package store
import (
"context"
"fmt"
)
// migrations are applied in order; schema_version records the highest applied
// version so re-runs are no-ops. Every step is also idempotent (CREATE TABLE
// IF NOT EXISTS) so a half-applied database still converges. Append-only:
// never edit an earlier migration (дисциплина vojo).
var migrations = []string{
// v1: the Phase-0 operational schema.
`
-- Snapshot контекста джобы (Р6, implementation-notes §3.2): материализованная
-- запись; snapshot_id входит в request-hash, так что волатильность контекста
-- заморожена на джобу. payload JSON (версии промптов per-role, модели,
-- версия чанкера, style sheet, резюме на момент старта).
CREATE TABLE IF NOT EXISTS snapshots (
snapshot_id TEXT PRIMARY KEY,
brief_hash TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Durable jobs: джоба = глава×стадия (Р6). Гранулярность СОХРАННОСТИ чанк
-- (checkpoints), джоба лишь группирует их и несёт snapshot_id.
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL,
stage TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (book_id, chapter, stage)
);
-- Чанк-чекпоинты: сырой ответ LLM персистится атомарно вместе с settle
-- (одна транзакция SettleWithCheckpoint), ключ request_hash от
-- детерминированного рендера (§3.1). kill -9 теряет максимум один
-- in-flight вызов. Найденный чекпоинт на resume = вызов не повторяется и
-- не оплачивается второй раз.
CREATE TABLE IF NOT EXISTS checkpoints (
request_hash TEXT PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES jobs(id),
chunk_idx INTEGER NOT NULL,
stage TEXT NOT NULL,
role TEXT NOT NULL,
model_requested TEXT NOT NULL,
model_actual TEXT NOT NULL,
response_text TEXT NOT NULL,
usage_json TEXT NOT NULL,
cost_usd REAL NOT NULL,
finish_reason TEXT NOT NULL DEFAULT '',
provider_request_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS checkpoints_job_idx ON checkpoints (job_id, chunk_idx);
-- Дневной/книжный ledger с резервированием (порт семантики vojo spend):
-- потолки считают committed + reserved на допуске, поэтому burst
-- конкурентных вызовов не проскакивает потолок (TOCTOU-дисциплина).
CREATE TABLE IF NOT EXISTS spend (
book_id TEXT NOT NULL,
date TEXT NOT NULL, -- UTC YYYY-MM-DD
committed_usd REAL NOT NULL DEFAULT 0,
reserved_usd REAL NOT NULL DEFAULT 0,
PRIMARY KEY (book_id, date)
);
-- request_log: одна строка на LLM-вызов (Р7: per-книга/глава/чанк/стадия/
-- роль/модель, $, latency, cache-поля). Телеметрия, не источник денег
-- деньги живут в spend/checkpoints.
CREATE TABLE IF NOT EXISTS request_log (
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL DEFAULT (datetime('now')),
trace_id TEXT NOT NULL DEFAULT '',
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL DEFAULT 0,
chunk_idx INTEGER NOT NULL DEFAULT 0,
stage TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
model_requested TEXT NOT NULL DEFAULT '',
model_actual TEXT NOT NULL DEFAULT '',
request_hash TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL DEFAULT 0,
latency_ms INTEGER NOT NULL DEFAULT 0,
finish_reason TEXT NOT NULL DEFAULT '',
tm_hit INTEGER NOT NULL DEFAULT 0, -- ответ взят из чекпоинта, вызова не было
degraded TEXT NOT NULL DEFAULT '',
err TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts);
`,
}
// migrate runs all pending migrations on the write pool, one transaction per
// step, recording each in schema_version.
func (s *Store) migrate(ctx context.Context) error {
if _, err := s.w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
return fmt.Errorf("store: schema_version: %w", err)
}
var current int
if err := s.w.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&current); err != nil {
return fmt.Errorf("store: read version: %w", err)
}
for v := current; v < len(migrations); v++ {
tx, err := s.w.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin migration %d: %w", v+1, err)
}
if _, err := tx.ExecContext(ctx, migrations[v]); err != nil {
_ = tx.Rollback()
return fmt.Errorf("store: apply migration %d: %w", v+1, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (?)`, v+1); err != nil {
_ = tx.Rollback()
return fmt.Errorf("store: record migration %d: %w", v+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: commit migration %d: %w", v+1, err)
}
}
return nil
}

View file

@ -0,0 +1,89 @@
package store
import (
"context"
"database/sql"
"log/slog"
"textmachine/backend/internal/obs"
)
// RequestLog is one telemetry row (Р7: маршрут, токены с cache-полями, $,
// latency per книга/глава/чанк/стадия/роль/модель). Telemetry is strictly off
// the money path: деньги живут в spend/checkpoints, а сбой записи телеметрии
// логируется и никогда не роняет перевод.
type RequestLog struct {
TraceID string
BookID string
Chapter int
ChunkIdx int
Stage string
Role string
ModelRequested string
ModelActual string
RequestHash string
PromptTokens int
CachedTokens int
CacheCreationTokens int
CompletionTokens int
ReasoningTokens int
CostUSD float64
LatencyMS int
FinishReason string
TMHit bool // served from a checkpoint, no call was made
Degraded string
Err string
OK bool
}
// InsertRequestLog writes one telemetry row synchronously (a CLI pipeline has
// no answer-latency to protect, unlike vojo's chat path; the async+recover
// pattern returns if this ever sits on a hot path). Errors are the caller's to
// log as WARN — never to fail the pipeline on.
func (s *Store) InsertRequestLog(rl RequestLog) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx, `
INSERT INTO request_log (
trace_id, book_id, chapter, chunk_idx, stage, role,
model_requested, model_actual, request_hash,
prompt_tokens, cached_tokens, cache_creation_tokens,
completion_tokens, reasoning_tokens,
cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
rl.TraceID, rl.BookID, rl.Chapter, rl.ChunkIdx, rl.Stage, rl.Role,
rl.ModelRequested, rl.ModelActual, rl.RequestHash,
rl.PromptTokens, rl.CachedTokens, rl.CacheCreationTokens,
rl.CompletionTokens, rl.ReasoningTokens,
rl.CostUSD, rl.LatencyMS, rl.FinishReason, boolToInt(rl.TMHit), rl.Degraded, rl.Err, boolToInt(rl.OK))
return err
}
// LogRequest is the fire-and-forget wrapper the runner uses: insert, WARN on
// failure, never propagate.
func (s *Store) LogRequest(ctx context.Context, log *slog.Logger, rl RequestLog) {
if rl.TraceID == "" {
rl.TraceID = obs.TraceFromContext(ctx)
}
if err := s.InsertRequestLog(rl); err != nil && log != nil {
log.WarnContext(ctx, "request_log insert failed (non-fatal)", "err", err)
}
}
// RequestLogRows returns rows for inspection (tmctl report / приёмка Фазы 0).
func (s *Store) RequestLogRows(bookID string) (*sql.Rows, error) {
ctx, cancel := opContext()
defer cancel()
return s.r.QueryContext(ctx, `
SELECT ts, stage, role, model_actual, prompt_tokens, cached_tokens,
cache_creation_tokens, completion_tokens, reasoning_tokens,
cost_usd, latency_ms, finish_reason, tm_hit, ok
FROM request_log WHERE book_id = ? ORDER BY id`, bookID)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View file

@ -0,0 +1,109 @@
// Package store is the durable state of a book project: schema migrations,
// the spend ledger (reserve/settle), chunk checkpoints and the request_log —
// one SQLite file per project (modernc.org/sqlite, CGO-free, no native
// extensions — Р3/Р6).
//
// Семантика денег и миграций портирована из vojo store.go; сам код написан
// заново под SQLite (advisory-локов здесь нет — сериализацию даёт write-пул
// из одного соединения + BEGIN IMMEDIATE; 03-implementation-notes §2 п.5).
//
// Рецепт конкурентности (§3.6): на файл БД два пула — write (MaxOpenConns=1,
// транзакции immediate) и read (N соединений); прагмы на каждое соединение
// через DSN: WAL, busy_timeout, foreign_keys, synchronous(NORMAL — переживает
// kill -9; отключение питания вне модели угроз MVP).
package store
import (
"context"
"database/sql"
"fmt"
"net/url"
"time"
_ "modernc.org/sqlite"
)
// opTimeout bounds every store operation: SQLite is a local file and
// effectively never blocks; the cap keeps a wedged filesystem from hanging a
// pipeline goroutine forever.
const opTimeout = 10 * time.Second
// Store is one project database.
type Store struct {
w *sql.DB // single-connection write pool; all mutations go through it
r *sql.DB // read pool
}
// Open opens (or creates) the project database at path, applies pending
// migrations and recovers stale reservations from a crashed run.
func Open(path string) (*Store, error) {
dsn := func(txlock string) string {
v := url.Values{}
v.Add("_pragma", "journal_mode(WAL)")
v.Add("_pragma", "busy_timeout(5000)")
v.Add("_pragma", "foreign_keys(1)")
v.Add("_pragma", "synchronous(NORMAL)")
if txlock != "" {
v.Set("_txlock", txlock)
}
return "file:" + path + "?" + v.Encode()
}
w, err := sql.Open("sqlite", dsn("immediate"))
if err != nil {
return nil, fmt.Errorf("store: open write pool: %w", err)
}
// One writer connection: SQLite has a single writer per file anyway;
// serializing in Go turns SQLITE_BUSY storms into a plain queue.
w.SetMaxOpenConns(1)
r, err := sql.Open("sqlite", dsn(""))
if err != nil {
w.Close()
return nil, fmt.Errorf("store: open read pool: %w", err)
}
r.SetMaxOpenConns(4)
s := &Store{w: w, r: r}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := s.migrate(ctx); err != nil {
s.Close()
return nil, err
}
if err := s.recoverReservations(ctx); err != nil {
s.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error {
err1 := s.w.Close()
err2 := s.r.Close()
if err1 != nil {
return err1
}
return err2
}
func opContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), opTimeout)
}
// recoverReservations zeroes reserved_usd left over by a crashed process. The
// project file is owned by ONE process at a time (CLI), so any reservation
// present at open belongs to a run that never settled — the recovery pass из
// implementation-notes §3.3.
func (s *Store) recoverReservations(ctx context.Context) error {
res, err := s.w.ExecContext(ctx, `UPDATE spend SET reserved_usd = 0 WHERE reserved_usd > 0`)
if err != nil {
return fmt.Errorf("store: recover reservations: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
// The caller's logger isn't wired here; surface via the request_log-less
// path is overkill — a plain stderr note keeps it visible.
fmt.Printf("store: recovered %d stale reservation row(s) from a previous run\n", n)
}
return nil
}

View file

@ -0,0 +1,190 @@
package store
import (
"path/filepath"
"sync"
"testing"
)
func openTemp(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s, path
}
func mustSnapshotAndJob(t *testing.T, s *Store) *Job {
t.Helper()
if err := s.UpsertSnapshot("snap1", "brief1", `{}`); err != nil {
t.Fatal(err)
}
job, err := s.EnsureJob("book", 1, "draft", "snap1")
if err != nil {
t.Fatal(err)
}
return job
}
func TestReserveSettleLifecycle(t *testing.T) {
s, _ := openTemp(t)
job := mustSnapshotAndJob(t, s)
caps := Ceilings{BookUSD: 1.0, DayUSD: 2.0}
res, verdict, err := s.Reserve("book", 0.10, caps)
if err != nil || verdict != ReserveOK {
t.Fatalf("reserve: %v %v", verdict, err)
}
committed, reserved, _ := s.SpentUSD("book")
if committed != 0 || reserved != 0.10 {
t.Fatalf("after reserve: committed=%v reserved=%v", committed, reserved)
}
cp := Checkpoint{RequestHash: "h1", JobID: job.ID, Stage: "draft", Role: "translator",
ModelRequested: "m", ModelActual: "m", ResponseText: "текст", UsageJSON: "{}", CostUSD: 0.04}
if err := s.SettleWithCheckpoint(res, 0.04, cp); err != nil {
t.Fatal(err)
}
committed, reserved, _ = s.SpentUSD("book")
if committed != 0.04 || reserved != 0 {
t.Fatalf("after settle: committed=%v reserved=%v", committed, reserved)
}
got, err := s.GetCheckpoint("h1")
if err != nil || got == nil || got.ResponseText != "текст" {
t.Fatalf("checkpoint: %+v err=%v", got, err)
}
// Повторный settle того же hash (ретрай вызывающего) идемпотентен:
// резерв снимается, деньги второй раз не книжатся.
res2, verdict, err := s.Reserve("book", 0.10, caps)
if err != nil || verdict != ReserveOK {
t.Fatalf("reserve2: %v %v", verdict, err)
}
if err := s.SettleWithCheckpoint(res2, 0.04, cp); err != nil {
t.Fatal(err)
}
committed, reserved, _ = s.SpentUSD("book")
if committed != 0.04 || reserved != 0 {
t.Fatalf("duplicate settle must book nothing: committed=%v reserved=%v", committed, reserved)
}
}
func TestCeilingsDeny(t *testing.T) {
s, _ := openTemp(t)
mustSnapshotAndJob(t, s)
// Книжный потолок: committed+reserved+estimate > cap ⇒ отказ.
res, verdict, err := s.Reserve("book", 0.9, Ceilings{BookUSD: 1.0})
if err != nil || verdict != ReserveOK {
t.Fatalf("first reserve: %v %v", verdict, err)
}
_, verdict, err = s.Reserve("book", 0.2, Ceilings{BookUSD: 1.0})
if err != nil || verdict != ReserveDeniedBook {
t.Fatalf("second reserve must hit the book ceiling, got %v %v", verdict, err)
}
// Release возвращает headroom.
if err := s.Release(res); err != nil {
t.Fatal(err)
}
_, verdict, err = s.Reserve("book", 0.2, Ceilings{BookUSD: 1.0})
if err != nil || verdict != ReserveOK {
t.Fatalf("after release: %v %v", verdict, err)
}
// Дневной потолок считается по всем книгам за день.
_, verdict, err = s.Reserve("other-book", 0.1, Ceilings{DayUSD: 0.25})
if err != nil || verdict != ReserveDeniedDay {
t.Fatalf("day ceiling must deny across books, got %v %v", verdict, err)
}
}
func TestConcurrentReservesRespectCeiling(t *testing.T) {
s, _ := openTemp(t)
mustSnapshotAndJob(t, s)
caps := Ceilings{BookUSD: 0.5}
// 20 конкурентных резерваций по 0.1 при потолке 0.5: пройти могут максимум 5.
var wg sync.WaitGroup
var mu sync.Mutex
admitted := 0
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, verdict, err := s.Reserve("book", 0.1, caps)
if err != nil {
t.Error(err)
return
}
if verdict == ReserveOK {
mu.Lock()
admitted++
mu.Unlock()
}
}()
}
wg.Wait()
if admitted != 5 {
t.Fatalf("ceiling 0.5 with 0.1 reserves must admit exactly 5, admitted %d", admitted)
}
}
func TestRecoveryClearsStaleReservations(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if _, verdict, err := s.Reserve("book", 0.3, Ceilings{BookUSD: 1}); err != nil || verdict != ReserveOK {
t.Fatalf("%v %v", verdict, err)
}
s.Close() // «упали», не сняв резерв
s2, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
committed, reserved, _ := s2.SpentUSD("book")
if committed != 0 || reserved != 0 {
t.Fatalf("reopen must clear stale reservations: committed=%v reserved=%v", committed, reserved)
}
}
func TestJobKeepsOriginalSnapshot(t *testing.T) {
s, _ := openTemp(t)
if err := s.UpsertSnapshot("snapA", "brief", `{}`); err != nil {
t.Fatal(err)
}
if err := s.UpsertSnapshot("snapB", "brief", `{"v":2}`); err != nil {
t.Fatal(err)
}
j1, err := s.EnsureJob("book", 1, "draft", "snapA")
if err != nil {
t.Fatal(err)
}
// Повторный EnsureJob с другим snapshot НЕ перепривязывает джобу (Р6:
// snapshot фиксируется на джобу до её завершения).
j2, err := s.EnsureJob("book", 1, "draft", "snapB")
if err != nil {
t.Fatal(err)
}
if j1.ID != j2.ID || j2.SnapshotID != "snapA" {
t.Fatalf("job must keep its original snapshot: %+v", j2)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
for i := 0; i < 3; i++ {
s, err := Open(path)
if err != nil {
t.Fatalf("open #%d: %v", i, err)
}
s.Close()
}
}

View file

@ -0,0 +1,15 @@
Ты — литературный аналитик (роль Analyst, Р2: вся книга → book brief).
Черновик Фазы 0; наполняется сессией «Редакция» и включается в пайплайн Фазой 1
(map-reduce по книге).
Задача: прочитать фрагмент книги «{{title}}» ({{genre}}, язык «{{source_lang}}»)
и составить структурированный book brief: сеттинг, тон, стиль автора, главные
персонажи с речевыми характеристиками, повторяющиеся мотивы, особенности,
критичные для перевода на язык «{{target_lang}}».
Выведи brief в виде маркированного списка по секциям.
---USER---
Фрагмент книги:
{{text}}

20
backend/prompts/editor.md Normal file
View file

@ -0,0 +1,20 @@
Ты — литературный редактор художественного перевода на русский язык.
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
Твой мандат УЗКИЙ — художественная редактура черновика перевода:
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции.
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
- Не меняй имена собственные, термины и реалии — их утверждает другой пасс.
- Сохраняй разбивку на абзацы.
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений.
---USER---
Исходник (для сверки смысла, язык «{{source_lang}}»):
{{text}}
Черновик перевода для редактуры:
{{draft}}

View file

@ -0,0 +1,15 @@
Ты — судья-селектор переводов (роль Judge, ядро C2; шкала Комиссарова +
Nida-вопрос). Скелет Фазы 0 — исполнение и промпт-инженерия придут с Фазой 2
(пара судей: монолингвальный на художественность + билингвальный на верность).
Задача: из N кандидатов перевода выбрать лучший по верности исходнику и
художественности русского текста.
---USER---
Исходник (язык «{{source_lang}}»):
{{text}}
Кандидаты:
{{draft}}

View file

@ -0,0 +1,16 @@
Ты — терминолог художественного перевода (роль Terminologist, Р2: глоссарий +
style sheet ДО перевода — паттерн preparation из TransAgents).
Черновик Фазы 0; наполняется сессией «Редакция» и включается в пайплайн Фазой 1.
Задача: из фрагмента книги «{{title}}» (язык «{{source_lang}}») извлечь
кандидатов в глоссарий для перевода на язык «{{target_lang}}»: имена
собственные, титулы, топонимы, термины мира, устойчивые обращения. Для каждого
— предложить перевод с учётом транскрипции «{{transcription}}» и хонорификов
«{{honorifics}}», указать род и тип.
Выведи таблицу: src | предлагаемый dst | тип | род | комментарий.
---USER---
Фрагмент книги:
{{text}}

View file

@ -0,0 +1,17 @@
Ты — профессиональный литературный переводчик с языка «{{source_lang}}» на язык «{{target_lang}}».
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
Правила перевода (translation brief):
- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}.
- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}.
- Сноски: {{footnotes}}.
- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя.
- Сохраняй разбивку на абзацы и прямую речь как в оригинале.
- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка.
Выведи ТОЛЬКО перевод, без комментариев, пояснений и заголовков.
---USER---
Переведи следующий фрагмент:
{{text}}

View file

@ -46,7 +46,7 @@
- [ ] Сессия «Редакция» (промпты ролей) — после фиксации формата конфига в Фазе 0.
- [ ] Пилот 4 рук для выбора ядра пайплайна (Фаза 2.5).
- Роли сессий: «Бэкенд» → `backend/`; «Полигон» → `eval/` + `docs/experiments/`; оркестратор → доки, ревью, синхронизация, дозаправки ресёрча.
- [ ] **Дозаправка ресёрча запущена (04.07, оркестратор)** по запросу владельца: каталог режимов отказа по парам (ja→ru, zh→ru, ru-сторона), систематический сбор отзывов потребителей по конкретным продуктам, академические таксономии ошибок → `research/12-*.md`; затем оркестратор соберёт карту «проблема → решение в бэкенде» → `architecture/04-unhappy-paths.md`. Бэкенд-сессии Фазы 0 это не блокирует; карта понадобится к Фазе 1 (гейты, поля памяти) и «Редакции» (промпты).
- [x] **Дозаправка ресёрча выполнена (04.07, оркестратор)**: 5 документов `research/12-*.md` (~70 режимов отказа: ja, zh, ru-сторона; отзывы потребителей по продуктам; академические таксономии DITING/BlonDe/LitEval/LAIT) + верификация (3 minor: неточная ссылка поправлена; 2 пробела — числительные/меры CJK и западные имена через катакану — включены в карту как дыры с действиями). Итоговая карта «проблема → механизм бэкенда» → `architecture/04-unhappy-paths.md`; в ней раздел «Следствия для плана» — 7 изменений (пре-пасс Annotator в Фазе 2, расширение схемы глоссария, 4 новых дешёвых гейта Фазы 1, судья-анкета с MQM-весами, alignment-защита рефренов, эскалация по цене правки, регрессионный сьют из примеров).
## Бэкенд
@ -65,6 +65,14 @@
Вопросы владельцу: ключи провайдеров для `backend/.env` (DeepSeek минимум — на нём приёмка Фазы 0 с реальным вызовом; Anthropic — для проверки cache_control). Не блокируюсь: тесты на httptest-моках.
> **Ответ оркестратора на [НУЖНО РЕШЕНИЕ] (04.07).** Отличный шаг 0 — фактчек Grok и cache-write Anthropic особенно ценны. Решения:
> 1. **Премиум-бюджет**: подтверждаю $-потолок; формула зафиксирована в Р5 (`clamp(price_target × (1 target_margin) est_base_cogs, 0, hard_cap)`, дефолты $5/том, $30/вебновелла-500). «≤2×» — только прикидочная эвристика доков.
> 2. **TM-инвалидация**: консервативный ключ подтверждён и зафиксирован в Р6: `(chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash, context_snapshot_hash)`; перечанкование инвалидирует by design.
> 3. **Канал B**: Р4 обновлён — интерим до Фазы 2: DeepSeek офиц. + локальная abliterated; Grok 4.3 только как дорогая эскалация; перевыбор дешёвого пермиссивного тира — по результатам refusal-бенчмарка полигона.
> 4. **Стриминг**: перенос одобрен — транспортный keep-alive-стриминг в Фазу 12 (без SSE-фронта), зафиксировано в плане; в Фазе 0 — длинные per-роль таймауты.
> 5. **Р5 обновлён** сноской на токен-калибровку (×1.41.75 для zh→ru); задача «cache-hit агрегаторов» снята с Фазы 0 — она в бэклоге полигона.
> **Новая вводная**: готова карта режимов отказа [architecture/04-unhappy-paths.md](architecture/04-unhappy-paths.md) (~70 режимов → механизмы). Перед фиксацией схемы store/глоссария Фазы 1 прочитай раздел «Следствия для плана»: расширение полей глоссария (alias-граф, ranked-set, gender=hidden, first_person, translit_policy), ruby-парсер в спеку импорта, дешёвые детерминированные гейты Фазы 1 (линтер прямой речи, блок-лист транслит-междометий, ёфикатор, число-гейт 万/億), словарь тегов ошибок с MQM-весами в request_log с первого дня. Пороги coverage-гейта уже сняты полигоном (см. его секцию, эксперимент 02).
## Полигон
(секция параллельной сессии — записи добавлять сюда)
@ -87,7 +95,7 @@
### Следующие шаги полигона
- [x] Прогон refusal-бенчмарка (SFW/L1/L2-violence часть) — 66/66 ok, пороги в 02; explicit-часть ждёт фрагментов владельца.
- [x] Эталон DeepSeek для сравнения черновиков — в 03.
- [ ] llama.cpp `--n-cpu-moe` для 30b-a3b (сборка в фоне; после рестарта WSL с memory=26GB — замер).
- [ ] llama.cpp `--n-cpu-moe` для 30b-a3b **сборка готова** (CUDA 12.6 user-space, пути/флаги в 03), замер после рестарта WSL с memory=26GB. Пул моделей для сравнения поколений/абляции/ru-адаптации докачан: qwen3:8b, qwen3.5:9b (+abliterated), ruadapt-qwen3:8b.
- [ ] Прогон Qwen Model Studio (`data_inspection_failed`) и OpenRouter — при появлении ключей.
- [ ] Проверка ru-агрегаторов: проброс cache-hit DeepSeek (два одинаковых запроса, сравнить usage) — разблокирует экономику ru-контура.
- [ ] Довалидация калибровки на 35 главах реальных вебновелл (файлы владельца → `eval/data/samples/` + manifest).

View file

@ -46,11 +46,11 @@
| Роль | Основная модель | Альтернативы/эскалация |
|---|---|---|
| Черновик | DeepSeek V4 Flash ($0.14/$0.28; cache-hit input $0.0028; лучший zh-токенизатор) | Qwen-Flash, GPT-5 nano, Grok 4.1 Fast |
| Черновик | DeepSeek V4 Flash ($0.14/$0.28; cache-hit input $0.0028; лучший zh-токенизатор) | Qwen-Flash, GPT-5 nano |
| Редактор (ru-стиль) | GLM-5 / Kimi K2.6 (доступны из РФ напрямую) | Claude Sonnet 5 (промо $2/$10 до 31.08.2026) — только в контуре прямых ключей |
| Судья | Gemini 3.1 Pro (batch 50%) | Claude Opus 4.8 (адресно, только канал A и прямые ключи), локальный POLLUX-judge-7B как бесплатный гейт |
| Эмбеддинги/скрининг/экстракция терминов | локально: bge-m3, Qwen3.5-9B | Qwen3-Embedding-0.6B |
| 18+ (канал B) | Grok 4.1 Fast / Grok 4.x | open-weights через OpenRouter (Chutes/Venice), DeepSeek офиц. API (3-я линия), локальная abliterated Qwen3.5 |
| 18+ (канал B) | Интерим до Фазы 2: DeepSeek офиц. API + локальная abliterated Qwen3.5 | open-weights через OpenRouter (Chutes/Venice); Grok 4.3 — дорогая эскалация. ⚠ Grok 4.1 Fast retired 15.05.2026, слаги молча редиректят на grok-4.3 с ценой ×9 (фактчек бэкенд-сессии) — перевыбор дешёвой модели канала B по итогам refusal-бенчмарка полигона |
Контент-роутинг ([11-gap-2](../research/11-gap-2.md)):
- Локальный классификатор «горячести» 03. Уровни 01 → канал A. Уровень 2 → канал B (пермиссивный). Уровень 3 (несовершеннолетние в сексуальном контексте) — **жёсткий отказ на уровне чанка**: чанк не переводится, помечается в отчёте, глава и книга продолжают обрабатываться (ложное срабатывание классификатора не должно убивать многочасовую оплаченную джобу).
@ -77,6 +77,8 @@
- **Prompt caching — главный рычаг**, и раскладка промпта фиксируется под него (разрешение конфликта Р3↔Р5 из ревью): **стабильный кэшируемый префикс** = системный промпт роли + book brief + style sheet + резюме книги/арок (меняется редко, раз в главу-арку); **волатильный хвост после кэш-брейкпоинта** = селективный глоссарий + STM + чанк. На стадии edit кэшу ловить почти нечего (уникальный черновик в промпте) — ожидаемый cache-hit считать по стадиям, а не «≥50% в среднем». Альтернатива для DeepSeek (cache-hit $0.0028/M): полный глоссарий в стабильном префиксе может оказаться дешевле селективной инъекции по miss-тарифу — решить расчётом на прототипе, обе схемы поддержать конфигом.
- **Батчуемость — свойство стадии**: Analyst, Terminologist, судья/оценки, книжный QA — батчуемы (50%); черновик и редактура чанков — последовательны (STM/резюме создают зависимость, DelTA-паттерн). «С batch ≈$2» относится только к батчуемым стадиям. Batch API — Фаза 2 (к судье), не Фаза 0.
- **Бюджет премиум-токенов на книгу**: одиночный полный Sonnet-проход 500-главной вебновеллы ~$92 — впритык к якорю GlobeScribe $100 и выходит за него с буфером +25%; двойной ~$185 — ломает экономику. Отсюда: дорогая модель — только адресно по сигналу судьи/гейтов, бюджет ≤2× input-объёма книги по премиум-тарифам.
- **Формула премиум-бюджета** (уточнение по запросу бэкенд-сессии, заменяет эвристику «≤2×»): в коде — конфигурируемый $-потолок на книгу, `premium_budget_usd = clamp(price_target × (1 target_margin) est_base_cogs, 0, hard_cap)`; для локального режима без прайса — дефолты $5/том ранобэ, $30/вебновелла-500. «≤2× input-объёма» остаётся прикидочной эвристикой в доках, не контрактом кода.
- ⚠ **Абсолютные COGS этого раздела устарели после токен-калибровки** (эксперимент 01 полигона): zh→ru дороже в ~1.41.75 раза (премиум-микс вебновеллы ≈$49, с batch ≈$26; ранобэ ja→ru, наоборот, дешевле — ≈$3.1). Структура выводов не меняется; актуальные множители — `docs/experiments/01-token-calibration.md`.
- Буфер +25% к расчётному COGS (ретраи, глоссарий-строительство, регенерации). Цены/имена моделей — только в конфиге с датой проверки. Миграция deepseek-chat → deepseek-v4-flash до 24.07.2026. Для DeepSeek — планировщик внепиковых часов (надбавка ×2 в пик; сам планировщик — Фаза 3, до тех пор — просто не гнать массовые прогоны в пик).
## Р6. Хранилище и оркестрация: SQLite + durable jobs + чанк-чекпоинты. **Принято**
@ -84,6 +86,7 @@
- Локальный режим MVP: SQLite (modernc.org/sqlite, CGO-free, без нативных расширений — см. Р3) для проектных данных; отдельный файл БД на книгу для банка памяти. Интерфейс хранилища тонкий — миграция на Postgres (pgx уже в vojo) при появлении сервера.
- Оркестрация — **durable jobs в БД с resume**; джоба = глава×стадия. **Гранулярность сохранности — чанк, не глава** (правка red-team): каждый сырой ответ LLM персистится сразу после settle (ключ: request-hash), отдельно от смысловой TM; kill -9 посреди главы теряет максимум один незавершённый вызов. Из ключа resume исключаются волатильные части контекста (snapshot контекста фиксируется на джобу до её завершения) — иначе любое подтверждение термина инвалидирует TM-хиты уже переведённых чанков. `brief_hash` входит в ключ TM; изменение translation brief посреди книги — явная команда с показом стоимости пере-перевода.
- Проверяется тестом: kill -9 в середине главы → рестарт → доплата только за прерванный вызов.
- **Ключ TM подтверждён** (запрос бэкенд-сессии): консервативный вариант `(chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash, context_snapshot_hash)`; перечанкование инвалидирует TM by design (ключ от src-чанка) — это осознанная плата за корректность.
## Р7. Телеметрия и качество. **Принято**

View file

@ -10,7 +10,7 @@
Монорепо Go (`backend/`). Портирование ядра из `vojo/apps/ai-bot`:
- `pkg/llm`: интерфейс LLMClient, OpenAI-совместимый транспорт (retry/backoff), адаптеры DeepSeek/Qwen/GLM/xAI/Gemini/llama-server; **новое**: нативный Anthropic-адаптер (`cache_control`), поддержка prompt caching в структуре запроса. ~~Batch API~~ → Фаза 2; ~~SSE-стриминг~~ → Фаза 3 (нужен только IDE-фронту).
- `pkg/llm`: интерфейс LLMClient, OpenAI-совместимый транспорт (retry/backoff), адаптеры DeepSeek/Qwen/GLM/xAI/Gemini/llama-server; **новое**: нативный Anthropic-адаптер (`cache_control`), поддержка prompt caching в структуре запроса. ~~Batch API~~ → Фаза 2. Стриминг: в Фазе 0 — длинные per-роль таймауты (чанки на локальной модели идут 63278 с); **транспортный стриминг как keep-alive** (агрегация на бэкенде, без SSE-фронта) — Фаза 12 по предложению бэкенд-сессии; SSE для IDE — Фаза 3.
- `pkg/ledger`: цены в конфиге (с датой проверки), биллинг по usage (+reasoning-токены), reserve/settle, потолки $ на книгу/день.
- `pkg/obs`: trace_id, структурные логи, request_log (per-книга/глава/чанк/стадия/роль/модель, $, latency, cache-hit, вердикты гейтов).
- `pkg/store`: SQLite (modernc.org/sqlite, без нативных расширений) + идемпотентные миграции; durable jobs (глава×стадия) + **чанк-чекпоинты** (Р6: каждый сырой ответ LLM персистится после settle; snapshot контекста на джобу; kill -9-тест — часть приёмки).

View file

@ -0,0 +1,130 @@
# Карта unhappy paths: режимы отказа AI-перевода → механизмы TextMachine (v1, 2026-07-04)
Синтез по `research/12-failure-modes-{ja,zh,ru-target}.md`, `12-consumer-feedback.md`, `12-error-taxonomies.md` (+ база 01/02/07). Назначение: (1) валидационная матрица архитектуры — каждый известный режим отказа обязан иметь механизм или честную пометку «дыра»; (2) ТЗ на QA-гейты и поля памяти; (3) источник регрессионных тест-кейсов (в research-доках каждый режим — с примером «оригинал → анти-паттерн MTL → верный перевод»).
Статусы: ✅ закрыто дизайном v2 · 🔶 закрыто частично / нужно уточнение дизайна · ❌ дыра, требует действия. Фаза — где механизм появляется по плану MVP.
Опорный факт (LAIT): при переводе **не на английский** читатели предпочитают человеческий перевод в 92.8% случаев и опознают MT по калькам — на ru-выходе паритет ещё никем не достигнут, вся карта ниже и есть план его достижения.
## 1. Консистентность сущностей — жалоба №1 всех площадок
64.4% документных ошибок MT вебновелл — инконсистентность (BlonDe); «Император птиц → Птица Император → Воробьиный Император» (DTF о DeepL); Yuewen признаёт проблему публично.
| Режим отказа | Механизм | Фаза | Статус |
|---|---|---|---|
| Дрейф имён/терминов между вызовами | Глоссарий + гейт консистентности (по леммам через `decl`) | 1 | ✅ |
| Система имён 名/字/号/прозвища: «один персонаж = 36 людей» | **Alias-граф** в глоссарии + NER-гейт «новая форма известной сущности = блок»; новое имя без фуриганы/контекста → эскалация | 12 | 🔶 схему Р3 расширить: aliases → граф с типами (имя/цзы/хао/прозвище/титул) |
| Коллапс терминосистемы рангов («Immortal, immortal or immortal») | Глоссарий-тип **ranked-set** + проверка инъективности (2 исходных ≠ 1 целевой) и порядка ступеней | 12 | 🔶 новый тип записи |
| Значащие прозвища: перевести один раз и навсегда | Поле `nickname_translation`, первичный перевод — сильная модель/человек, дальше — лок | 1 | ✅ (консенсус изданий: имена — Палладий, прозвища — переводить) |
| Обращения шисюн/гэгэ/о-нии-чан: транскрипция vs перевод | Пресеты brief «фанатский/издательский» (войны фандомов — 586 стр. холивара по одной Мосян; смертный грех — непоследовательность) + **матрица обращений по парам** с версионированием | 12 | 🔶 матрица обращений = расширение series-bible-lite |
## 2. Род и пол — самая трудная ось DITING для всех 14 моделей
Pro-drop (местоимения лишь в ~11% японских предложений; zh-топик задаётся раз на 510 клауз), омофония 他/她, а русский глагол прошедшего времени обязан выбрать род в каждой реплике. «Персонажи меняют пол каждые несколько абзацев» — мем читателей.
| Режим отказа | Механизм | Фаза | Статус |
|---|---|---|---|
| Скачущий род известных персонажей | Поле `gender` + **морфо-гейт**: род русских глаголов/согласований в атрибутированных репликах против глоссария — хард-фейл без LLM | 2 (морфология = Python-сайдкар) | ✅ |
| Свап ролей «кто кого» (пассивы, бессубъектные) | Билингвальный судья по триггеру (пассив/нулевой субъект в оригинале) | 2 | ✅ |
| **Поздний гендер-твист / намеренная неопределённость ta** (данмэй, 女扮男装) | `gender=hidden(until_ch N)` + «дисциплина уклонения» в промпте (настоящее время, безличные конструкции, именование — формализовано в гайдах ООН) + детерминированный **блокер утечки рода**; перевод книги целиком видит твист заранее — структурное преимущество перед посерийным MTL | 2 | 🔶 протокол hidden — новое требование к промптам («Редакция») и гейту; **фичи нет ни у одного конкурента** |
| Феминитивы: регистровый сбой | Лексикон маркированных неофеминитивов + политика в brief + форма профессии в глоссарии | 2 | ✅ |
## 3. Регистры и отношения — потерянные сигналы, а не ошибки
Ключевой класс: кэйго-сдвиг, смена обращения, речевые маски — это сюжетные события, которые нельзя починить редактурой выхода; их надо **извлечь из оригинала пре-пассом** и передать переводчику как инструкции.
| Режим отказа | Механизм | Фаза | Статус |
|---|---|---|---|
| Рандомное ты/вы внутри сцены | ты/вы-матрица series-bible-lite → **журнал событий переходов** + детектор несанкционированных флипов; судья классифицирует флаг (ошибка vs мотивированный переход) | 2 | 🔶 матрица → журнал событий (уточнение Р3) |
| Кэйго-сдвиг как перелом отношений | **Регистровый трекер** пар персонажей по оригиналу (детерминированный: desu/masu, -сан→имя) → событие в ты/вы-журнал; русский передаёт это напрямую через ты/вы — эксплуатировать преимущество ru | 2 | 🔶 новый пре-пасс |
| Gendered 1-е лицо (боку/орэ/атаси), yakuwarigo | Анкета персонажа: `first_person`, речевой профиль, ru-компенсация маски; судья консистентности голоса | 2 | ✅ (поля speech в Р3) |
| Стирание голосов («все говорят одинаково, как ИИ-озвучка») | Речевые профили + монолингвальный судья «отличимы ли реплики без атрибуции?» + анти-translationese пасс | 2 | ✅ |
| Нормализация акцентов/диалектов/pidgin | Профиль девиации + стратегия компенсации (лексика, не фонетика) + сигнал «в источнике девиация есть, в цели 0%» | 23 | 🔶 |
| Мат: недожим (RLHF) и пережим | Двусторонний лексиконный гейт силы обсценности (03) + профиль грубости персонажа + политика brief (18+ маркировка) | 2 | ✅ |
## 4. Полнота и целостность — новый класс риска LLM
Старый MTL врал заметно, LLM врёт гладко: «добавляет и удаляет куски, сочиняя свою историю» (r/ReverendInsanity). Гейта покрытия нет почти ни у кого — **продаётся как anti-hallucination guarantee**.
| Режим отказа | Механизм | Фаза | Статус |
|---|---|---|---|
| Пропуски (omission) — «критическая» категория, контекстом не лечится | Coverage-гейт v1 (сегментация, длины по коридорам эксперимента 01, refusal-blacklist) | 1 | ✅ |
| Гладкие галлюцинации-дописывания | Coverage-гейт + счёт сущностей вход/выход + билингвальный судья серых отрезков | 12 | ✅ |
| Молчаливые цензурные вырезания провайдера | Детектор вырезаний (полигон, эксперимент 02) + refusal-мониторинг | 2 | ✅ |
| CJK-осколки, смесь языков | Регэксп CJK-диапазонов + langid по абзацам | 1 | ✅ |
| Разрушение форматирования/вёрстки | Структурный diff документа (главы/абзацы/разметка) до и после | 1 (текст) / 3 (разметка) | 🔶 epub v1 теряет инлайн-разметку осознанно |
| **Потеря ruby/фуриганы на инжесте** (двухслойные термины, авторские чтения) | Парсер ruby-тегов на инжесте: чтения имён → глоссарий-лок, двухслойные термины → флаг | 1 | ❌→действие: добавить в спеку импорта Фазы 1 (сейчас «epub v1 = извлечение текста» молча убивает слой) |
## 5. Идиоматика, реалии, аллюзии
| Режим отказа | Механизм | Фаза | Статус |
|---|---|---|---|
| Чэнъюй: буквализация («в груди готовый бамбук») | Детерминированный пре-детектор по 4-символьным словарным маскам + судья на остаток; прецеденты в глоссарий | 2 | 🔶 нужен словарь |
| Сехоуюй: смысл в опущенной половине | Словарь недоговорок (без него реплика = бессмыслица) | 2 | 🔶 |
| Полисемия жанровых слов (道/气/修; «тюлени вместо печатей») | Глоссарий доменных значений + жанровый фрейм в промпте + **отказ от релейного zh→en→ru** | 1 | ✅ (мы переводим напрямую) |
| Родовое слово вместо реалии (бэнто → «контейнер с едой») | Слайдер Venuti в brief + глоссарий реалий + судья | 12 | ✅ |
| Цензурные эвфемизмы оригинала (开车) + версия исходника (Хаски урезан ~на треть) | Словарь эвфемизмов + **поле метаданных «версия исходника»** + человеческий шаг на интейке | 1 | 🔶 поле метаданных — добавить в конфиг проекта |
| Аллюзии на классику (танка, котовадза) | N-gram-мэтчинг против корпусов классики + база канонических ru-переводов (Маркова, Санович) | 3 | 🔶 post-MVP, но БД заложить |
| Ономатопея: транслит-мусор («болит зуки-зуки», «hyeupp») vs фанатское «сохранить papapa» | Детектор каны/транслита + словарь гионго→ru-приёмы + **политика проекта** (глагол/сохранить/сноска) — это конфиг, не безусловный баг | 12 | ✅ |
| Вэньянь-вкрапления: потеря сдвига регистра | Классификатор вэньянь-плотности → отдельный промпт-профиль; дозировка архаизации — судья | 23 | 🔶 |
## 6. Русская сторона: механика, которую читатель видит первой
5 из 12 режимов чинятся детерминированно без LLM — дешёвый post-pass, убирающий самые узнаваемые маркеры MTL.
| Режим отказа | Механизм | Фаза | Статус |
|---|---|---|---|
| Английские кавычки вместо тире-диалогов | Линтер прямой речи (Розенталь §4752), автофикс, 100% детерминированный | 1 | ✅→ включить в гейты Фазы 1 (дешевле coverage) |
| Транслит-междометия («Ауч!», «Упс», «Ара?») | Блок-лист + автозамена по словарю + per-project allowlist | 1 | ✅ |
| Непоследовательная ё (Пётр/Петр в одной книге) | Ёфикатор + режим проекта + сверка имён с глоссарием | 1 | ✅ |
| Висячие деепричастия («Подъезжая к станции, слетела шляпа») | UD-парсер, флаг + автофикс дешёвой моделью | 2 | ✅ |
| Вид глагола (событие vs фон) — «misused verb forms» у топ-систем WMT25 | Маркеры-конфликты детерминированно; основное — судья с узким промптом + контрастивный регрессионный набор | 2 | 🔶 |
| Тема-рема: английский порядок слов («Девушка вошла в комнату» при вводе персонажа) | Полудетерминированный триггер (новый референт + подлежащее в начале) + монолингвальный редактор | 2 | 🔶 |
| Несобственно-прямая речь («Завтра был понедельник») | Эвристика (дейксис+прошедшее) → судья; полная гарантия — человек на выборке | 23 | 🔶 |
| Согласование времён наррратива (zh без грамм. времени; 38.7% инконсистентностей BlonDe) | Морфостатистика времён по сцене + правило «время наррратива» в style sheet | 2 | ✅ |
## 7. Синтаксис и стиль — где решается «нехудожественность»
Именно тут проигрыш человеку: «гладкость» = 28.2% причин выбора HT (LAIT); «мёртвая проза, голос как TikTok-озвучка».
| Режим отказа | Механизм | Фаза | Статус |
|---|---|---|---|
| Рубленость ko/ja (пропущен пасс «сшивания») | Распределение длин предложений vs оригинал и vs корпус живого русского → стилист | 2 | ✅ (метрики Р7) |
| Предложения-змеи из zh топик-цепочек | Счётчик клауз/длин + пометка сверхдлинных цепочек на входе | 2 | ✅ |
| Мёртвая, эмоционально плоская проза | Не-LLM метрики (MTLD, дисперсия длин) как гейт → анти-translationese пасс → монолингвальный судья | 2 | ✅ |
| **Повторы: сорный vs авторский рефрен (ритм Мураками)** | Детектор повторов лемм + **alignment-защита: повтор в параллельных позициях оригинала = «не трогать»** | 2 | 🔶 alignment-защита — новое требование к анти-translationese пассу, иначе редактор-агент убивает рефрены |
| Левоветвящиеся цепочки ja → каскады «который…который» | Триггер глубины цепочки (≥3) + ≥2 «который» в выходе → судья | 2 | ✅ |
| Формулы жанра: тавтология vs стёртый жанровый якорь («лицо потемнело») | Частотный словарь формул жанра + **бюджет повторов** в политике проекта | 23 | 🔶 |
## 8. Нарратив: кто говорит, кто думает, когда происходит
| Режим отказа | Механизм | Фаза | Статус |
|---|---|---|---|
| Диалог без атрибуции (говорящий кодируется yakuwarigo/топиком) | **Пре-пасс speaker-ID** (дешёвая модель) + структурный гейт чередования реплик + судья «кто говорит в каждой реплике?» | 2 | 🔶 новый пре-пасс |
| POV-сдвиги, head-hopping, немаркированный внутренний монолог | Пре-пасс разметки мыслей/спикеров + русская пунктуация прямой речи (детерминированно) | 2 | 🔶 |
| Потеря подтекста/留白 (эксплицирование недосказанности) | Analyst помечает ключевые сцены → судья высокого уровня/человек; автоматизации нет — честная граница | 2.5+ | 🔶 признанное ограничение |
| Потеря контекста на книжной длине («>50 страниц — имена плывут») | Сама архитектура: персистентный банк памяти + регрессионные гейты на каждой главе | 1 | ✅ ядро продукта |
## 9. ❌ Дыры, найденные верификатором (не покрыл ни один документ)
| Режим отказа | Действие |
|---|---|
| **Числительные и меры CJK**: разряды 万/億 (三万 → «три миллиона» — ошибка порядка!), 里/斤/сяку/стражи 时辰, счёт возраста (16 суй ≈ 15 лет) | Новый детерминированный **число-гейт**: извлечение и сверка числовых значений вход/выход с таблицей конвертации разрядов и единиц; словарь мер в глоссарий-пресеты жанра. Фаза 12. Дёшево и частотно — включить в план |
| **Западные имена через катакану/фонозапись**: 约翰 → «Юэхань» вместо «Джон», ヴァイオレット → Вайолет/Виолетта, васэй-эйго ложные друзья (マンション = квартира, не «особняк») | Поле глоссария `translit_policy` для псевдоевропейских имён (массовый случай в исекай-ранобэ) + словарь васэй-эйго ложных друзей. Фаза 1 (поле) / 2 (словарь) |
## 10. Мета-режимы (процесс, не лингвистика)
- **Нестабильность качества по чанкам** (41.7% MT-чанков с всплеском негатива против 11.9% у человека — LAIT): телеметрия распределения вердиктов по чанкам, эскалация хвоста, а не среднего. → в Р7.
- **«Проклятие 300-й главы»** (витрина отредактирована, платный хвост — сырец; бойкоты Novelous/Webnovel): равномерные гейты по всем главам + **«паспорт качества главы»** в отчёте — продуктовая фича доверия. → в бэклог продукта.
- **Надёжность как дифференциатор**: Trustpilot booktranslator.ai — непереведённые файлы, смесь языков, молчащая поддержка. Наши детерминированные гейты целостности — конкурентное преимущество сами по себе.
- **Пользователи хотят рычаги, а не магию** (любимые фичи — редактируемые глоссарии и правка местоимений): подтверждение IDE-стратегии и «глоссарий — ядро продукта».
## Следствия для плана (сводка изменений)
1. **Новая стадия «Annotator» (пре-пасс разметки оригинала)** — Фаза 2: speaker-ID, субъекты клауз, регистровый трекер пар, чэнъюй/вэньянь-маски. Один дешёвый пре-пасс питает 8+ режимов; это конкретизация «аннотированного подстрочника» из Р2. В Фазу 1 из него — только детерминированная часть: ruby-парсер на инжесте (закрывает дыру §4) + NER имён.
2. **Расширение схемы глоссария/series bible** (Р3): alias-граф, тип ranked-set, `first_person`, матрица обращений, `keep_ambiguous_until`/gender=hidden, `nickname_translation`, `translit_policy`, словарь эвфемизмов, профиль грубости; ты/вы — журнал событий, не статичная матрица.
3. **Новые дешёвые гейты Фазы 1**: линтер прямой речи (тире), блок-лист транслит-междометий, ёфикатор, число-гейт (порядки 万/億) — все детерминированные, дешевле уже запланированных.
4. **Промпт судьи — анкета бинарных вопросов по осям** (κ=0.94 против 0.84 у «оцени 110», DITING; M-MAD без рубрик уходит в отрицательную корреляцию) + обязательная калибровка на человеческом якоре (LAIT: все LLM-судьи предпочли MT). Словарь ~12 тегов ошибок с MQM-весами (minor=1, major=5, non-translation=25) зафиксировать в Фазе 1 — request_log копит статистику с первого дня. → вход для «Редакции».
5. **Alignment-защита рефренов** в анти-translationese пассе (иначе чиним стиль ценой авторского ритма).
6. **Эскалацию приоритизировать по цене правки** (MTPE: когерентность — самое дорогое временное усилие, meaning shifts — когнитивное), а не только по частоте.
7. Тест-кейсы: каждый режим в research/12-* имеет пример «оригинал → анти-паттерн → голд» — собрать регрессионный сьют (задача «Полигона», после пилотного корпуса).

View file

@ -54,9 +54,15 @@
- **Критично для TextMachine — тайминги**: у vojo дедлайн локальной ноги 45 с (под короткие чат-ответы). Наши книжные чанки на локалке занимают **63278 с** — профиль таймаутов нужен свой (лег-дедлайн ~300600 с по роли/модели) плюс стриминг в интерфейсе клиента (уже запланирован в Р1), иначе фейловер будет ложно ронять здоровую локалку.
- `max_tokens` у ollama покрывает thinking+ответ вместе — для thinking-режимов задавать увеличенный потолок (у vojo 1024; для перевода главы нужно 48k) или выключать thinking (`reasoning_effort: none`).
## Подготовка ко второму замеру (выполнено 2026-07-04, ~06:30)
- **llama.cpp собран с CUDA**: `/home/ubuntu/llama.cpp/build/bin/llama-server` (+`llama-cli`; commit 2d973636). Без root: user-space CUDA toolkit 12.6 в `/home/ubuntu/cuda-12.6` (nvcc 12.6.85 из .deb-распаковки + cublas из PyPI-wheel, всего ~450MB загрузки), арх sm_61, rpath вшит — `LD_LIBRARY_PATH` не нужен. CPU-fallback: `build-cpu/bin/`. **GTX 1070 (Pascal) поддерживается CUDA 12.x, на CUDA 13 не обновлять** (Pascal выпилен). Флаги пересборки — в scratchpad-логах и здесь: `cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_COMPILER=$HOME/cuda-12.6/bin/nvcc -DCMAKE_CUDA_ARCHITECTURES=61 -DCMAKE_BUILD_RPATH=$L -DCMAKE_EXE_LINKER_FLAGS="-Wl,-rpath-link,$L -Wl,-rpath,$L"`, где `L=$HOME/cuda-12.6/targets/x86_64-linux/lib`. На GPU ещё не запускался (шёл бенчмарк) — первый прогон проверит подхват `libcuda.so.1` из `/usr/lib/wsl/lib`.
- **Пул моделей расширен** (план выше выполнен досрочно): `qwen3:8b` (ваниль, 5.2GB), **`qwen3.5:9b`** (есть в реестре ollama; 9.7B, arch qwen35, vision+thinking; дефолт-сэмплеры реестра temp 1/top_p 0.95/presence 1.5 — для честного сравнения выровнять с qwen3), `huihui_ai/qwen3.5-abliterated:9b` (6.6GB), `ruadapt-qwen3:8b` (создан из официального RefalMachine/RuadaptQwen3-8B-**Hybrid**-GGUF Q4_K_M — Instruct-версии 8B не существует, hybrid = instruct с переключаемым thinking).
- **WSL RAM поднят 20→26GB** (`C:\Users\lager\.wslconfig`, бэкап рядом) — вступит после `wsl --shutdown`.
## Следующие шаги
1. Поставить llama.cpp (llama-server), перемерить 30b-a3b с `--n-cpu-moe` — проверка обещанных 2530 tok/s (главное условие полезности «тяжёлой» локалки).
1. **[после рестарта WSL]** Перемерить 30b-a3b: llama-server с `--n-cpu-moe` (веса можно грузить прямо из ollama blob-store — блобы это GGUF; путь через `ollama show`) против ollama при 26GB RAM — проверка обещанных 2530 tok/s. Добавить в `local_bench.py` ветку llama-server (он OpenAI-совместим по /v1).
2. ~~Снять эталон DeepSeek~~**сделано** (см. выше): разрыв качественный, локалка остаётся «спецслужбой».
3. Расширить пул по плану выше (ваниль 8b → Qwen3.5 → RuadaptQwen3), NSFW-фрагменты владельца — для проверки единственной переводческой роли локалки.
3. ~~Расширить пул~~**скачано** (см. выше); прогнать `local_bench.py` по четырём новым моделям: цена абляции (qwen3 ваниль vs abliterated), поколение (qwen3 vs 3.5), ru-адаптация (ruadapt). NSFW-фрагменты владельца — для проверки единственной переводческой роли локалки.
4. Замер bge-m3 (скорость/качество retrieval) — в рамках задачи 5 (мини-бенчмарк эмбеддингов).

View file

@ -5,7 +5,7 @@
## TL;DR
1. Свежий WMT 2025 test-suite по en→ru (465 заданий, 55 явлений, 13 категорий, 10 топ-систем) подтверждает: даже лучшие LLM-системы систематически дают «literal word order carry-overs, misused verb forms, rigid phrase translations» — т.е. три наших режима (тема-рема, вид глагола, кальки конструкций) — это измеренная, а не гипотетическая проблема ([ACL 2025.wmt-1.61](https://aclanthology.org/2025.wmt-1.61/)).
2. Русский — уникально «неудобный» целевой язык по роду: в исследованиях гендер-переноса доля «unknown» (род не выражен и его нельзя проверить) минимальна для всех языков, **кроме русского** прошедшее время и согласования принудительно требуют решения о роде там, где en/zh/ja молчат ([WMT 2020 gender coreference](https://arxiv.org/pdf/2010.06018), [TACL: Gender Bias in MT](https://direct.mit.edu/tacl/article/doi/10.1162/tacl_a_00401/106991/Gender-Bias-in-Machine-Translation)).
2. Русский — уникально «неудобный» целевой язык по роду: прошедшее время и согласования принудительно требуют решения о роде там, где en/zh/ja молчат ([TACL: Gender Bias in MT](https://direct.mit.edu/tacl/article/doi/10.1162/tacl_a_00401/106991/Gender-Bias-in-Machine-Translation)). *(Правка верификатора: ранее тезис ссылался и на [WMT 2020](https://arxiv.org/pdf/2010.06018) — там у русского действительно наибольшая доля «unknown»-случаев (12.913.5% против 1.31.8% у cs/de/pl), но статья объясняет её пробелами морфоанализатора, а не грамматикой, поэтому как опору для этого тезиса ссылку сняли.)*
3. Существует проверенный арсенал стратегий письма «без рода» (перестройка фразы, настоящее время, безличные/номинативные конструкции) — формализован в гайдах ООН по гендерно-нейтральному русскому; он конвертируется в протокол «gender: unknown» для переводчика-агента ([UN Russian guidelines](https://www.un.org/ru/gender-inclusive-language/guidelines.shtml)).
4. Самый дешёвый и самый заметный читателю класс отказов — механика: английские кавычки вместо тире-диалогов, дефис вместо тире, транслит-междометия («Ауч!», «Упс»), непоследовательная ё. Всё это детектится и чинится детерминированно, без LLM (Розенталь §4752, Лопатин, правила ё на Грамоте.ру).
5. Мат — двусторонний отказ: LLM-санитайзинг даёт «недожим» (злодей ругается «чёрт возьми»), буквализм — «пережим» (мат там, где в оригинале бытовое shit). Русская обсценная лексика табуированнее английских four-letter words, а закон 2014 г. требует маркировки 18+ — это и политика проекта, и двусторонний детерминированный гейт по лексиконам ([Habr/EnglishDom](https://habr.com/ru/companies/englishdom/articles/564036/)).