From 7954325b7ee3fbab0e56e8758874fdcf69b5bf49 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sat, 4 Jul 2026 09:04:06 +0300 Subject: [PATCH] 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 --- backend/.env.example | 10 + backend/cmd/tmctl/main.go | 155 ++++++++ backend/configs/models.yaml | 89 +++++ backend/configs/pipeline-c1.yaml | 73 ++++ backend/configs/pipeline-c2.yaml | 64 ++++ backend/example/book.yaml | 21 ++ backend/example/chapter1-chunk0.txt | 11 + backend/go.mod | 20 + backend/go.sum | 55 +++ backend/internal/config/book.go | 140 +++++++ backend/internal/config/models.go | 173 +++++++++ backend/internal/config/pipeline.go | 179 +++++++++ backend/internal/ledger/pricing.go | 83 +++++ backend/internal/ledger/pricing_test.go | 72 ++++ backend/internal/llm/failover.go | 232 ++++++++++++ backend/internal/llm/failover_test.go | 137 +++++++ backend/internal/llm/httpllm.go | 336 +++++++++++++++++ backend/internal/llm/httpllm_test.go | 226 ++++++++++++ backend/internal/llm/llm.go | 108 ++++++ backend/internal/llm/provider_anthropic.go | 285 ++++++++++++++ .../internal/llm/provider_anthropic_test.go | 107 ++++++ backend/internal/llm/provider_local.go | 113 ++++++ backend/internal/llm/provider_openai.go | 117 ++++++ backend/internal/obs/logging.go | 101 +++++ backend/internal/obs/safego.go | 22 ++ backend/internal/obs/trace.go | 69 ++++ backend/internal/pipeline/clients.go | 50 +++ backend/internal/pipeline/render.go | 164 +++++++++ backend/internal/pipeline/render_test.go | 122 ++++++ backend/internal/pipeline/runner.go | 347 ++++++++++++++++++ backend/internal/pipeline/runner_test.go | 197 ++++++++++ backend/internal/store/jobs.go | 73 ++++ backend/internal/store/kill9_test.go | 115 ++++++ backend/internal/store/ledger.go | 198 ++++++++++ backend/internal/store/migrate.go | 133 +++++++ backend/internal/store/requestlog.go | 89 +++++ backend/internal/store/store.go | 109 ++++++ backend/internal/store/store_test.go | 190 ++++++++++ backend/prompts/analyst.md | 15 + backend/prompts/editor.md | 20 + backend/prompts/judge-selector.md | 15 + backend/prompts/terminologist.md | 16 + backend/prompts/translator.md | 17 + docs/PROGRESS.md | 12 +- docs/architecture/01-decisions.md | 7 +- docs/architecture/02-mvp-plan.md | 2 +- docs/architecture/04-unhappy-paths.md | 130 +++++++ docs/experiments/03-local-stand.md | 10 +- docs/research/12-failure-modes-ru-target.md | 2 +- 49 files changed, 5023 insertions(+), 8 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/cmd/tmctl/main.go create mode 100644 backend/configs/models.yaml create mode 100644 backend/configs/pipeline-c1.yaml create mode 100644 backend/configs/pipeline-c2.yaml create mode 100644 backend/example/book.yaml create mode 100644 backend/example/chapter1-chunk0.txt create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/config/book.go create mode 100644 backend/internal/config/models.go create mode 100644 backend/internal/config/pipeline.go create mode 100644 backend/internal/ledger/pricing.go create mode 100644 backend/internal/ledger/pricing_test.go create mode 100644 backend/internal/llm/failover.go create mode 100644 backend/internal/llm/failover_test.go create mode 100644 backend/internal/llm/httpllm.go create mode 100644 backend/internal/llm/httpllm_test.go create mode 100644 backend/internal/llm/llm.go create mode 100644 backend/internal/llm/provider_anthropic.go create mode 100644 backend/internal/llm/provider_anthropic_test.go create mode 100644 backend/internal/llm/provider_local.go create mode 100644 backend/internal/llm/provider_openai.go create mode 100644 backend/internal/obs/logging.go create mode 100644 backend/internal/obs/safego.go create mode 100644 backend/internal/obs/trace.go create mode 100644 backend/internal/pipeline/clients.go create mode 100644 backend/internal/pipeline/render.go create mode 100644 backend/internal/pipeline/render_test.go create mode 100644 backend/internal/pipeline/runner.go create mode 100644 backend/internal/pipeline/runner_test.go create mode 100644 backend/internal/store/jobs.go create mode 100644 backend/internal/store/kill9_test.go create mode 100644 backend/internal/store/ledger.go create mode 100644 backend/internal/store/migrate.go create mode 100644 backend/internal/store/requestlog.go create mode 100644 backend/internal/store/store.go create mode 100644 backend/internal/store/store_test.go create mode 100644 backend/prompts/analyst.md create mode 100644 backend/prompts/editor.md create mode 100644 backend/prompts/judge-selector.md create mode 100644 backend/prompts/terminologist.md create mode 100644 backend/prompts/translator.md create mode 100644 docs/architecture/04-unhappy-paths.md diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..a687603 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/cmd/tmctl/main.go b/backend/cmd/tmctl/main.go new file mode 100644 index 0000000..527731c --- /dev/null +++ b/backend/cmd/tmctl/main.go @@ -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 --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) + } + } +} diff --git a/backend/configs/models.yaml b/backend/configs/models.yaml new file mode 100644 index 0000000..d3c74a9 --- /dev/null +++ b/backend/configs/models.yaml @@ -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 } # полигон: чанки 63–278 с + +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-провайдера. diff --git a/backend/configs/pipeline-c1.yaml b/backend/configs/pipeline-c1.yaml new file mode 100644 index 0000000..05f7c42 --- /dev/null +++ b/backend/configs/pipeline-c1.yaml @@ -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 — ключи/алиасы/леммы в волатильном хвосте (~100–800 ток.); + # 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 # калибровка порогов — на фрагментах 500–2500 симв. + +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: один черновик diff --git a/backend/configs/pipeline-c2.yaml b/backend/configs/pipeline-c2.yaml new file mode 100644 index 0000000..7fa8a91 --- /dev/null +++ b/backend/configs/pipeline-c2.yaml @@ -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.6–1.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 — конфиг) diff --git a/backend/example/book.yaml b/backend/example/book.yaml new file mode 100644 index 0000000..1dd2733 --- /dev/null +++ b/backend/example/book.yaml @@ -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 diff --git a/backend/example/chapter1-chunk0.txt b/backend/example/chapter1-chunk0.txt new file mode 100644 index 0000000..e4e7350 --- /dev/null +++ b/backend/example/chapter1-chunk0.txt @@ -0,0 +1,11 @@ +夜明け前の図書館は、静寂に包まれていた。 + +「先輩、本当にここに入ってもいいんですか」と、美咲は小声で尋ねた。彼女の手には、古びた鍵が握られていた。 + +「大丈夫だ。館長から許可はもらっている」と、拓海は答えた。「それより、例の本を探すぞ。朝が来る前に」 + +二人は書架の間を静かに歩いた。窓の外では、東の空がわずかに白み始めていた。埃の匂いと、古い紙の香りが漂う。美咲は足を止め、一冊の本に手を伸ばした。 + +「先輩……これ、見てください。表紙に何も書いてありません」 + +拓海が振り返った瞬間、図書館の奥で、何かが落ちる音がした。 diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..e1c9827 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..8d8dee1 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/book.go b/backend/internal/config/book.go new file mode 100644 index 0000000..a48ba3a --- /dev/null +++ b/backend/internal/config/book.go @@ -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: .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 +} diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go new file mode 100644 index 0000000..3072900 --- /dev/null +++ b/backend/internal/config/models.go @@ -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) +} diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go new file mode 100644 index 0000000..87b013b --- /dev/null +++ b/backend/internal/config/pipeline.go @@ -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 +} diff --git a/backend/internal/ledger/pricing.go b/backend/internal/ledger/pricing.go new file mode 100644 index 0000000..f9bc7ca --- /dev/null +++ b/backend/internal/ledger/pricing.go @@ -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 +} diff --git a/backend/internal/ledger/pricing_test.go b/backend/internal/ledger/pricing_test.go new file mode 100644 index 0000000..b92635d --- /dev/null +++ b/backend/internal/ledger/pricing_test.go @@ -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) + } +} diff --git a/backend/internal/llm/failover.go b/backend/internal/llm/failover.go new file mode 100644 index 0000000..7a43725 --- /dev/null +++ b/backend/internal/llm/failover.go @@ -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 // /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 // полигон: чанки 63–278 с; 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 +} diff --git a/backend/internal/llm/failover_test.go b/backend/internal/llm/failover_test.go new file mode 100644 index 0000000..e7f9513 --- /dev/null +++ b/backend/internal/llm/failover_test.go @@ -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) + } +} diff --git a/backend/internal/llm/httpllm.go b/backend/internal/llm/httpllm.go new file mode 100644 index 0000000..4a384e8 --- /dev/null +++ b/backend/internal/llm/httpllm.go @@ -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 дедлайн донора убивал +// бы реальные чанки 63–278 с и трижды бесплатно их ретраил; +// - 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 { + // Полигон намерил 63–278 с на чанк локальной моделью; облачные быстрее, + // но дефолт обязан переживать длинный чанк. Провайдер-специфика — в + // 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 ~10–50 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) +} diff --git a/backend/internal/llm/httpllm_test.go b/backend/internal/llm/httpllm_test.go new file mode 100644 index 0000000..6cfccb9 --- /dev/null +++ b/backend/internal/llm/httpllm_test.go @@ -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 +} diff --git a/backend/internal/llm/llm.go b/backend/internal/llm/llm.go new file mode 100644 index 0000000..1e49604 --- /dev/null +++ b/backend/internal/llm/llm.go @@ -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 (30–44%). 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) +} diff --git a/backend/internal/llm/provider_anthropic.go b/backend/internal/llm/provider_anthropic.go new file mode 100644 index 0000000..df94f9f --- /dev/null +++ b/backend/internal/llm/provider_anthropic.go @@ -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 может истекать между вызовами стадии — + // латентности чанков 63–278 с, см. 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 "" + } +} diff --git a/backend/internal/llm/provider_anthropic_test.go b/backend/internal/llm/provider_anthropic_test.go new file mode 100644 index 0000000..64631a1 --- /dev/null +++ b/backend/internal/llm/provider_anthropic_test.go @@ -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") + } +} diff --git a/backend/internal/llm/provider_local.go b/backend/internal/llm/provider_local.go new file mode 100644 index 0000000..87c76d7 --- /dev/null +++ b/backend/internal/llm/provider_local.go @@ -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= — виндовая нотация, которую 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 +} diff --git a/backend/internal/llm/provider_openai.go b/backend/internal/llm/provider_openai.go new file mode 100644 index 0000000..cf6e07a --- /dev/null +++ b/backend/internal/llm/provider_openai.go @@ -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 30–44%). + 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 +} diff --git a/backend/internal/obs/logging.go b/backend/internal/obs/logging.go new file mode 100644 index 0000000..e408b27 --- /dev/null +++ b/backend/internal/obs/logging.go @@ -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 + } +} diff --git a/backend/internal/obs/safego.go b/backend/internal/obs/safego.go new file mode 100644 index 0000000..29eee9c --- /dev/null +++ b/backend/internal/obs/safego.go @@ -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() + }() +} diff --git a/backend/internal/obs/trace.go b/backend/internal/obs/trace.go new file mode 100644 index 0000000..cf2ee01 --- /dev/null +++ b/backend/internal/obs/trace.go @@ -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[:]) +} diff --git a/backend/internal/pipeline/clients.go b/backend/internal/pipeline/clients.go new file mode 100644 index 0000000..656677e --- /dev/null +++ b/backend/internal/pipeline/clients.go @@ -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) + } +} diff --git a/backend/internal/pipeline/render.go b/backend/internal/pipeline/render.go new file mode 100644 index 0000000..896a632 --- /dev/null +++ b/backend/internal/pipeline/render.go @@ -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 +} diff --git a/backend/internal/pipeline/render_test.go b/backend/internal/pipeline/render_test.go new file mode 100644 index 0000000..a93d40c --- /dev/null +++ b/backend/internal/pipeline/render_test.go @@ -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) + } +} diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go new file mode 100644 index 0000000..e258aad --- /dev/null +++ b/backend/internal/pipeline/runner.go @@ -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 +} diff --git a/backend/internal/pipeline/runner_test.go b/backend/internal/pipeline/runner_test.go new file mode 100644 index 0000000..b1c0bef --- /dev/null +++ b/backend/internal/pipeline/runner_test.go @@ -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()) + } +} diff --git a/backend/internal/store/jobs.go b/backend/internal/store/jobs.go new file mode 100644 index 0000000..c9468a1 --- /dev/null +++ b/backend/internal/store/jobs.go @@ -0,0 +1,73 @@ +package store + +import ( + "context" + "database/sql" + "errors" +) + +// jobs.go: snapshots + durable jobs. Джоба = глава×стадия; сохранность — чанк +// (checkpoints, см. ledger.go). Snapshot материализует контекст джобы, чтобы +// request-hash был детерминирован на всю её жизнь (§3.1–3.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 +} diff --git a/backend/internal/store/kill9_test.go b/backend/internal/store/kill9_test.go new file mode 100644 index 0000000..bf97f22 --- /dev/null +++ b/backend/internal/store/kill9_test.go @@ -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) + } +} diff --git a/backend/internal/store/ledger.go b/backend/internal/store/ledger.go new file mode 100644 index 0000000..e7edc4d --- /dev/null +++ b/backend/internal/store/ledger.go @@ -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 +} diff --git a/backend/internal/store/migrate.go b/backend/internal/store/migrate.go new file mode 100644 index 0000000..3e97ac5 --- /dev/null +++ b/backend/internal/store/migrate.go @@ -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(¤t); 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 +} diff --git a/backend/internal/store/requestlog.go b/backend/internal/store/requestlog.go new file mode 100644 index 0000000..813f2ab --- /dev/null +++ b/backend/internal/store/requestlog.go @@ -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 +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..ec76279 --- /dev/null +++ b/backend/internal/store/store.go @@ -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 +} diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go new file mode 100644 index 0000000..f44d216 --- /dev/null +++ b/backend/internal/store/store_test.go @@ -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() + } +} diff --git a/backend/prompts/analyst.md b/backend/prompts/analyst.md new file mode 100644 index 0000000..3e3e125 --- /dev/null +++ b/backend/prompts/analyst.md @@ -0,0 +1,15 @@ +Ты — литературный аналитик (роль Analyst, Р2: вся книга → book brief). +Черновик Фазы 0; наполняется сессией «Редакция» и включается в пайплайн Фазой 1 +(map-reduce по книге). + +Задача: прочитать фрагмент книги «{{title}}» ({{genre}}, язык «{{source_lang}}») +и составить структурированный book brief: сеттинг, тон, стиль автора, главные +персонажи с речевыми характеристиками, повторяющиеся мотивы, особенности, +критичные для перевода на язык «{{target_lang}}». + +Выведи brief в виде маркированного списка по секциям. + +---USER--- +Фрагмент книги: + +{{text}} diff --git a/backend/prompts/editor.md b/backend/prompts/editor.md new file mode 100644 index 0000000..6a0d177 --- /dev/null +++ b/backend/prompts/editor.md @@ -0,0 +1,20 @@ +Ты — литературный редактор художественного перевода на русский язык. +Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». + +Твой мандат УЗКИЙ — художественная редактура черновика перевода: +- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции. +- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. +- Не меняй имена собственные, термины и реалии — их утверждает другой пасс. +- Сохраняй разбивку на абзацы. +- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. + +Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений. + +---USER--- +Исходник (для сверки смысла, язык «{{source_lang}}»): + +{{text}} + +Черновик перевода для редактуры: + +{{draft}} diff --git a/backend/prompts/judge-selector.md b/backend/prompts/judge-selector.md new file mode 100644 index 0000000..38f9eb7 --- /dev/null +++ b/backend/prompts/judge-selector.md @@ -0,0 +1,15 @@ +Ты — судья-селектор переводов (роль Judge, ядро C2; шкала Комиссарова + +Nida-вопрос). Скелет Фазы 0 — исполнение и промпт-инженерия придут с Фазой 2 +(пара судей: монолингвальный на художественность + билингвальный на верность). + +Задача: из N кандидатов перевода выбрать лучший по верности исходнику и +художественности русского текста. + +---USER--- +Исходник (язык «{{source_lang}}»): + +{{text}} + +Кандидаты: + +{{draft}} diff --git a/backend/prompts/terminologist.md b/backend/prompts/terminologist.md new file mode 100644 index 0000000..34a74d1 --- /dev/null +++ b/backend/prompts/terminologist.md @@ -0,0 +1,16 @@ +Ты — терминолог художественного перевода (роль Terminologist, Р2: глоссарий + +style sheet ДО перевода — паттерн preparation из TransAgents). +Черновик Фазы 0; наполняется сессией «Редакция» и включается в пайплайн Фазой 1. + +Задача: из фрагмента книги «{{title}}» (язык «{{source_lang}}») извлечь +кандидатов в глоссарий для перевода на язык «{{target_lang}}»: имена +собственные, титулы, топонимы, термины мира, устойчивые обращения. Для каждого +— предложить перевод с учётом транскрипции «{{transcription}}» и хонорификов +«{{honorifics}}», указать род и тип. + +Выведи таблицу: src | предлагаемый dst | тип | род | комментарий. + +---USER--- +Фрагмент книги: + +{{text}} diff --git a/backend/prompts/translator.md b/backend/prompts/translator.md new file mode 100644 index 0000000..84bdc06 --- /dev/null +++ b/backend/prompts/translator.md @@ -0,0 +1,17 @@ +Ты — профессиональный литературный переводчик с языка «{{source_lang}}» на язык «{{target_lang}}». +Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». + +Правила перевода (translation brief): +- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}. +- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}. +- Сноски: {{footnotes}}. +- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя. +- Сохраняй разбивку на абзацы и прямую речь как в оригинале. +- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка. + +Выведи ТОЛЬКО перевод, без комментариев, пояснений и заголовков. + +---USER--- +Переведи следующий фрагмент: + +{{text}} diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b3eebef..c8d4f0d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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-стриминг в Фазу 1–2 (без SSE-фронта), зафиксировано в плане; в Фазе 0 — длинные per-роль таймауты. +> 5. **Р5 обновлён** сноской на токен-калибровку (×1.4–1.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-контура. - [ ] Довалидация калибровки на 3–5 главах реальных вебновелл (файлы владельца → `eval/data/samples/` + manifest). diff --git a/docs/architecture/01-decisions.md b/docs/architecture/01-decisions.md index 11b5756..6cdffb2 100644 --- a/docs/architecture/01-decisions.md +++ b/docs/architecture/01-decisions.md @@ -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)): - Локальный классификатор «горячести» 0–3. Уровни 0–1 → канал 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.4–1.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. Телеметрия и качество. **Принято** diff --git a/docs/architecture/02-mvp-plan.md b/docs/architecture/02-mvp-plan.md index 8430dcc..c500a1a 100644 --- a/docs/architecture/02-mvp-plan.md +++ b/docs/architecture/02-mvp-plan.md @@ -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-роль таймауты (чанки на локальной модели идут 63–278 с); **транспортный стриминг как keep-alive** (агрегация на бэкенде, без SSE-фронта) — Фаза 1–2 по предложению бэкенд-сессии; 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-тест — часть приёмки). diff --git a/docs/architecture/04-unhappy-paths.md b/docs/architecture/04-unhappy-paths.md new file mode 100644 index 0000000..4cef73c --- /dev/null +++ b/docs/architecture/04-unhappy-paths.md @@ -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 | ✅ | +| Система имён 名/字/号/прозвища: «один персонаж = 3–6 людей» | **Alias-граф** в глоссарии + NER-гейт «новая форма известной сущности = блок»; новое имя без фуриганы/контекста → эскалация | 1–2 | 🔶 схему Р3 расширить: aliases → граф с типами (имя/цзы/хао/прозвище/титул) | +| Коллапс терминосистемы рангов («Immortal, immortal or immortal») | Глоссарий-тип **ranked-set** + проверка инъективности (2 исходных ≠ 1 целевой) и порядка ступеней | 1–2 | 🔶 новый тип записи | +| Значащие прозвища: перевести один раз и навсегда | Поле `nickname_translation`, первичный перевод — сильная модель/человек, дальше — лок | 1 | ✅ (консенсус изданий: имена — Палладий, прозвища — переводить) | +| Обращения шисюн/гэгэ/о-нии-чан: транскрипция vs перевод | Пресеты brief «фанатский/издательский» (войны фандомов — 586 стр. холивара по одной Мосян; смертный грех — непоследовательность) + **матрица обращений по парам** с версионированием | 1–2 | 🔶 матрица обращений = расширение series-bible-lite | + +## 2. Род и пол — самая трудная ось DITING для всех 14 моделей + +Pro-drop (местоимения лишь в ~11% японских предложений; zh-топик задаётся раз на 5–10 клауз), омофония 他/她, а русский глагол прошедшего времени обязан выбрать род в каждой реплике. «Персонажи меняют пол каждые несколько абзацев» — мем читателей. + +| Режим отказа | Механизм | Фаза | Статус | +|---|---|---|---| +| Скачущий род известных персонажей | Поле `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%» | 2–3 | 🔶 | +| Мат: недожим (RLHF) и пережим | Двусторонний лексиконный гейт силы обсценности (0–3) + профиль грубости персонажа + политика brief (18+ маркировка) | 2 | ✅ | + +## 4. Полнота и целостность — новый класс риска LLM + +Старый MTL врал заметно, LLM врёт гладко: «добавляет и удаляет куски, сочиняя свою историю» (r/ReverendInsanity). Гейта покрытия нет почти ни у кого — **продаётся как anti-hallucination guarantee**. + +| Режим отказа | Механизм | Фаза | Статус | +|---|---|---|---| +| Пропуски (omission) — «критическая» категория, контекстом не лечится | Coverage-гейт v1 (сегментация, длины по коридорам эксперимента 01, refusal-blacklist) | 1 | ✅ | +| Гладкие галлюцинации-дописывания | Coverage-гейт + счёт сущностей вход/выход + билингвальный судья серых отрезков | 1–2 | ✅ | +| Молчаливые цензурные вырезания провайдера | Детектор вырезаний (полигон, эксперимент 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 + глоссарий реалий + судья | 1–2 | ✅ | +| Цензурные эвфемизмы оригинала (开车) + версия исходника (Хаски урезан ~на треть) | Словарь эвфемизмов + **поле метаданных «версия исходника»** + человеческий шаг на интейке | 1 | 🔶 поле метаданных — добавить в конфиг проекта | +| Аллюзии на классику (танка, котовадза) | N-gram-мэтчинг против корпусов классики + база канонических ru-переводов (Маркова, Санович) | 3 | 🔶 post-MVP, но БД заложить | +| Ономатопея: транслит-мусор («болит зуки-зуки», «hyeupp») vs фанатское «сохранить papapa» | Детектор каны/транслита + словарь гионго→ru-приёмы + **политика проекта** (глагол/сохранить/сноска) — это конфиг, не безусловный баг | 1–2 | ✅ | +| Вэньянь-вкрапления: потеря сдвига регистра | Классификатор вэньянь-плотности → отдельный промпт-профиль; дозировка архаизации — судья | 2–3 | 🔶 | + +## 6. Русская сторона: механика, которую читатель видит первой + +5 из 12 режимов чинятся детерминированно без LLM — дешёвый post-pass, убирающий самые узнаваемые маркеры MTL. + +| Режим отказа | Механизм | Фаза | Статус | +|---|---|---|---| +| Английские кавычки вместо тире-диалогов | Линтер прямой речи (Розенталь §47–52), автофикс, 100% детерминированный | 1 | ✅→ включить в гейты Фазы 1 (дешевле coverage) | +| Транслит-междометия («Ауч!», «Упс», «Ара?») | Блок-лист + автозамена по словарю + per-project allowlist | 1 | ✅ | +| Непоследовательная ё (Пётр/Петр в одной книге) | Ёфикатор + режим проекта + сверка имён с глоссарием | 1 | ✅ | +| Висячие деепричастия («Подъезжая к станции, слетела шляпа») | UD-парсер, флаг + автофикс дешёвой моделью | 2 | ✅ | +| Вид глагола (событие vs фон) — «misused verb forms» у топ-систем WMT25 | Маркеры-конфликты детерминированно; основное — судья с узким промптом + контрастивный регрессионный набор | 2 | 🔶 | +| Тема-рема: английский порядок слов («Девушка вошла в комнату» при вводе персонажа) | Полудетерминированный триггер (новый референт + подлежащее в начале) + монолингвальный редактор | 2 | 🔶 | +| Несобственно-прямая речь («Завтра был понедельник») | Эвристика (дейксис+прошедшее) → судья; полная гарантия — человек на выборке | 2–3 | 🔶 | +| Согласование времён наррратива (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 стёртый жанровый якорь («лицо потемнело») | Частотный словарь формул жанра + **бюджет повторов** в политике проекта | 2–3 | 🔶 | + +## 8. Нарратив: кто говорит, кто думает, когда происходит + +| Режим отказа | Механизм | Фаза | Статус | +|---|---|---|---| +| Диалог без атрибуции (говорящий кодируется yakuwarigo/топиком) | **Пре-пасс speaker-ID** (дешёвая модель) + структурный гейт чередования реплик + судья «кто говорит в каждой реплике?» | 2 | 🔶 новый пре-пасс | +| POV-сдвиги, head-hopping, немаркированный внутренний монолог | Пре-пасс разметки мыслей/спикеров + русская пунктуация прямой речи (детерминированно) | 2 | 🔶 | +| Потеря подтекста/留白 (эксплицирование недосказанности) | Analyst помечает ключевые сцены → судья высокого уровня/человек; автоматизации нет — честная граница | 2.5+ | 🔶 признанное ограничение | +| Потеря контекста на книжной длине («>50 страниц — имена плывут») | Сама архитектура: персистентный банк памяти + регрессионные гейты на каждой главе | 1 | ✅ ядро продукта | + +## 9. ❌ Дыры, найденные верификатором (не покрыл ни один документ) + +| Режим отказа | Действие | +|---|---| +| **Числительные и меры CJK**: разряды 万/億 (三万 → «три миллиона» — ошибка порядка!), 里/斤/сяку/стражи 时辰, счёт возраста (16 суй ≈ 15 лет) | Новый детерминированный **число-гейт**: извлечение и сверка числовых значений вход/выход с таблицей конвертации разрядов и единиц; словарь мер в глоссарий-пресеты жанра. Фаза 1–2. Дёшево и частотно — включить в план | +| **Западные имена через катакану/фонозапись**: 约翰 → «Юэхань» вместо «Джон», ヴァイオレット → Вайолет/Виолетта, васэй-эйго ложные друзья (マンション = квартира, не «особняк») | Поле глоссария `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 у «оцени 1–10», 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-* имеет пример «оригинал → анти-паттерн → голд» — собрать регрессионный сьют (задача «Полигона», после пилотного корпуса). diff --git a/docs/experiments/03-local-stand.md b/docs/experiments/03-local-stand.md index fae1d75..dc965f9 100644 --- a/docs/experiments/03-local-stand.md +++ b/docs/experiments/03-local-stand.md @@ -54,9 +54,15 @@ - **Критично для TextMachine — тайминги**: у vojo дедлайн локальной ноги 45 с (под короткие чат-ответы). Наши книжные чанки на локалке занимают **63–278 с** — профиль таймаутов нужен свой (лег-дедлайн ~300–600 с по роли/модели) плюс стриминг в интерфейсе клиента (уже запланирован в Р1), иначе фейловер будет ложно ронять здоровую локалку. - `max_tokens` у ollama покрывает thinking+ответ вместе — для thinking-режимов задавать увеличенный потолок (у vojo 1024; для перевода главы нужно 4–8k) или выключать 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` — проверка обещанных 25–30 tok/s (главное условие полезности «тяжёлой» локалки). +1. **[после рестарта WSL]** Перемерить 30b-a3b: llama-server с `--n-cpu-moe` (веса можно грузить прямо из ollama blob-store — блобы это GGUF; путь через `ollama show`) против ollama при 26GB RAM — проверка обещанных 25–30 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 (мини-бенчмарк эмбеддингов). diff --git a/docs/research/12-failure-modes-ru-target.md b/docs/research/12-failure-modes-ru-target.md index 7afac9c..dff19aa 100644 --- a/docs/research/12-failure-modes-ru-target.md +++ b/docs/research/12-failure-modes-ru-target.md @@ -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.9–13.5% против 1.3–1.8% у cs/de/pl), но статья объясняет её пробелами морфоанализатора, а не грамматикой, поэтому как опору для этого тезиса ссылку сняли.)* 3. Существует проверенный арсенал стратегий письма «без рода» (перестройка фразы, настоящее время, безличные/номинативные конструкции) — формализован в гайдах ООН по гендерно-нейтральному русскому; он конвертируется в протокол «gender: unknown» для переводчика-агента ([UN Russian guidelines](https://www.un.org/ru/gender-inclusive-language/guidelines.shtml)). 4. Самый дешёвый и самый заметный читателю класс отказов — механика: английские кавычки вместо тире-диалогов, дефис вместо тире, транслит-междометия («Ауч!», «Упс»), непоследовательная ё. Всё это детектится и чинится детерминированно, без LLM (Розенталь §47–52, Лопатин, правила ё на Грамоте.ру). 5. Мат — двусторонний отказ: LLM-санитайзинг даёт «недожим» (злодей ругается «чёрт возьми»), буквализм — «пережим» (мат там, где в оригинале бытовое shit). Русская обсценная лексика табуированнее английских four-letter words, а закон 2014 г. требует маркировки 18+ — это и политика проекта, и двусторонний детерминированный гейт по лексиконам ([Habr/EnglishDom](https://habr.com/ru/companies/englishdom/articles/564036/)).