Add Phase 0 backend skeleton: LLM core with native Anthropic adapter, money ledger, SQLite store with atomic chunk checkpoints, deterministic C1 mini-runner, tmctl CLI, configs and prompts
This commit is contained in:
parent
9bb00d49f2
commit
7954325b7e
49 changed files with 5023 additions and 8 deletions
10
backend/.env.example
Normal file
10
backend/.env.example
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Скопируй в backend/.env (gitignored) и заполни. tmctl подхватывает .env из
|
||||||
|
# каталога book.yaml и из CWD; уже установленные переменные окружения не
|
||||||
|
# перетираются.
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
ZAI_API_KEY=
|
||||||
|
KIMI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# LOG_LEVEL=debug
|
||||||
|
# LOG_FORMAT=json
|
||||||
155
backend/cmd/tmctl/main.go
Normal file
155
backend/cmd/tmctl/main.go
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// tmctl is the TextMachine CLI (Фаза 0: translate один чанк + report).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/obs"
|
||||||
|
"textmachine/backend/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "tmctl:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
return fmt.Errorf("usage: tmctl <translate|report> --config book.yaml")
|
||||||
|
}
|
||||||
|
cmd, args := os.Args[1], os.Args[2:]
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet(cmd, flag.ExitOnError)
|
||||||
|
cfgPath := fs.String("config", "", "path to book.yaml")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *cfgPath == "" {
|
||||||
|
return fmt.Errorf("--config book.yaml is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDotEnv(filepath.Join(filepath.Dir(*cfgPath), ".env"))
|
||||||
|
loadDotEnv(".env")
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
ctx = obs.WithReqInfo(ctx, obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "translate":
|
||||||
|
return translate(ctx, *cfgPath)
|
||||||
|
case "report":
|
||||||
|
return report(*cfgPath)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown command %q (want translate|report)", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func translate(ctx context.Context, cfgPath string) error {
|
||||||
|
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
res, err := r.TranslateOneChunk(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("=== ПЕРЕВОД ===")
|
||||||
|
fmt.Println(res.FinalText)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("=== СТОИМОСТЬ ПО СТАДИЯМ ===")
|
||||||
|
for _, st := range res.Stages {
|
||||||
|
src := "call"
|
||||||
|
if st.FromResume {
|
||||||
|
src = "checkpoint"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-8s %-22s %-10s $%.6f in=%d (cached=%d, cache_write=%d) out=%d+%d %dms finish=%s\n",
|
||||||
|
st.Stage, st.Model, src, st.CostUSD,
|
||||||
|
st.Usage.PromptTokens, st.Usage.CachedTokens, st.Usage.CacheCreationTokens,
|
||||||
|
st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.LatencyMS, st.FinishReason)
|
||||||
|
}
|
||||||
|
fmt.Printf("ИТОГО (этот запуск): $%.6f\n", res.TotalUSD)
|
||||||
|
|
||||||
|
committed, reserved, err := r.Store.SpentUSD(res.BookID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Ledger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func report(cfgPath string) error {
|
||||||
|
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
rows, err := r.Store.RequestLogRows(r.Book.BookID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
fmt.Printf("%-20s %-8s %-12s %-22s %8s %8s %8s %8s %8s %10s %8s %-8s %-5s %-3s\n",
|
||||||
|
"ts", "stage", "role", "model", "prompt", "cached", "cwrite", "compl", "reason", "cost_usd", "ms", "finish", "tmhit", "ok")
|
||||||
|
for rows.Next() {
|
||||||
|
var ts, stage, role, model, finish string
|
||||||
|
var prompt, cached, cwrite, compl, reason, latency, tmhit, ok int
|
||||||
|
var cost float64
|
||||||
|
if err := rows.Scan(&ts, &stage, &role, &model, &prompt, &cached, &cwrite, &compl, &reason, &cost, &latency, &finish, &tmhit, &ok); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%-20s %-8s %-12s %-22s %8d %8d %8d %8d %8d %10.6f %8d %-8s %-5d %-3d\n",
|
||||||
|
ts, stage, role, model, prompt, cached, cwrite, compl, reason, cost, latency, finish, tmhit, ok)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("\nLedger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDotEnv reads KEY=VALUE lines into the environment without overriding
|
||||||
|
// already-set variables. Ключи — из backend/.env (gitignored); секреты никогда
|
||||||
|
// не попадают в конфиги/репо.
|
||||||
|
func loadDotEnv(path string) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v = strings.TrimSpace(k), strings.Trim(strings.TrimSpace(v), `"'`)
|
||||||
|
if os.Getenv(k) == "" {
|
||||||
|
os.Setenv(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend/configs/models.yaml
Normal file
89
backend/configs/models.yaml
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Модели и цены TextMachine (Р4/Р5: состав и цены — ТОЛЬКО здесь, с датой
|
||||||
|
# проверки; код не знает ни одной цены).
|
||||||
|
#
|
||||||
|
# Источники проверки 2026-07-04 (воркфлоу-фактчек, официальные страницы):
|
||||||
|
# DeepSeek https://api-docs.deepseek.com/quick_start/pricing
|
||||||
|
# Anthropic https://platform.claude.com/docs/en/about-claude/pricing
|
||||||
|
# Z.AI/GLM https://docs.z.ai/guides/overview/pricing
|
||||||
|
# Kimi https://platform.kimi.ai/docs/pricing/chat-k26
|
||||||
|
# xAI https://docs.x.ai/docs/models (Grok 4.1 Fast retired 15.05.2026!)
|
||||||
|
# Gemini https://ai.google.dev/gemini-api/docs/pricing
|
||||||
|
prices_checked: "2026-07-04"
|
||||||
|
|
||||||
|
# Fallback-якорь цены: неизвестная модель (например, фактическая модель после
|
||||||
|
# фейловера, которой нет в таблице) книжится по этой цене, никогда по $0.
|
||||||
|
default_model: deepseek-v4-flash
|
||||||
|
|
||||||
|
providers:
|
||||||
|
deepseek:
|
||||||
|
kind: openai
|
||||||
|
base_url: https://api.deepseek.com/v1
|
||||||
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
reasoning: subset # thinking внутри completion_tokens
|
||||||
|
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
||||||
|
|
||||||
|
zai: # GLM, международный контур (docs.z.ai)
|
||||||
|
kind: openai
|
||||||
|
base_url: https://api.z.ai/api/paas/v4
|
||||||
|
api_key_env: ZAI_API_KEY
|
||||||
|
reasoning: subset
|
||||||
|
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
||||||
|
|
||||||
|
kimi: # домен сменился: platform.moonshot.ai → 301 → platform.kimi.ai
|
||||||
|
kind: openai
|
||||||
|
base_url: https://api.kimi.ai/v1
|
||||||
|
api_key_env: KIMI_API_KEY
|
||||||
|
reasoning: subset
|
||||||
|
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
||||||
|
|
||||||
|
anthropic:
|
||||||
|
kind: anthropic
|
||||||
|
api_key_env: ANTHROPIC_API_KEY
|
||||||
|
cache_ttl: 5m # per-stage override — Фаза 1 (context.cache_ttl в конфиге ядра)
|
||||||
|
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
||||||
|
|
||||||
|
local: # ollama/llama-server на стенде (боевой env — memory textmachine-local-stand)
|
||||||
|
kind: local
|
||||||
|
base_url: http://127.0.0.1:11434/v1
|
||||||
|
model: huihui_ai/qwen3-abliterated:8b
|
||||||
|
max_tokens: 8192 # у ollama лимит покрывает thinking+ответ вместе
|
||||||
|
timeouts: { attempt_s: 600, max_attempts: 2, backoff_cap_s: 10 } # полигон: чанки 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-провайдера.
|
||||||
73
backend/configs/pipeline-c1.yaml
Normal file
73
backend/configs/pipeline-c1.yaml
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Ядро C1: draft → editor (Р2). Обязательное приложение Фазы 0 — фиксирует
|
||||||
|
# границу «конфиг vs код раннера»:
|
||||||
|
# КОНФИГ: состав/порядок стадий, роль→модель, версии промптов, сэмплинг,
|
||||||
|
# пороги гейтов, режим инъекции глоссария, токен-бюджеты сборки контекста,
|
||||||
|
# именованные эскалационные цепочки, fan-out N, cache TTL.
|
||||||
|
# РАННЕР (код): циклы по главам/чанкам, ветвление по гейтам, механика
|
||||||
|
# эскалации/ретраев, правило «эскалация → re-gate → флаг», channel-aware
|
||||||
|
# фильтр цепочек (Anthropic вне 18+), resume по чекпоинтам.
|
||||||
|
core: C1
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
# max_tokens ≈ ratio × входные токены; калибровка полигона (эксп. 01):
|
||||||
|
# русский выход zh→ru ≈1.9× входа в токенах — 2.2 с запасом на ja/en.
|
||||||
|
max_output_ratio: 2.2
|
||||||
|
min_max_tokens: 2048
|
||||||
|
|
||||||
|
context:
|
||||||
|
# Р5: обе схемы инъекции глоссария обязаны поддерживаться конфигом.
|
||||||
|
# selective — ключи/алиасы/леммы в волатильном хвосте (~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: один черновик
|
||||||
64
backend/configs/pipeline-c2.yaml
Normal file
64
backend/configs/pipeline-c2.yaml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Скелет ядра C2: generate-then-select (Р2). Тот же раннер, что C1 — различия
|
||||||
|
# только в конфиге: fan-out N кандидатов + стадия select. Фаза 0 фиксирует
|
||||||
|
# СХЕМУ (парсится и валидируется); исполнение fan-out/селекции — механика
|
||||||
|
# раннера Фазы 2 (пилот 4 рук решает, войдёт ли C2 в прод).
|
||||||
|
core: C2
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
max_output_ratio: 2.2
|
||||||
|
min_max_tokens: 2048
|
||||||
|
|
||||||
|
context:
|
||||||
|
glossary_injection: selective
|
||||||
|
glossary_token_budget: 800
|
||||||
|
stm_depth: 2
|
||||||
|
overlap_tokens: 200
|
||||||
|
cache_ttl: "5m"
|
||||||
|
|
||||||
|
retries:
|
||||||
|
regenerate_before_escalate: 1
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- name: draft
|
||||||
|
role: translator
|
||||||
|
model: deepseek-v4-flash
|
||||||
|
prompt: ../prompts/translator.md
|
||||||
|
prompt_version: v0-draft
|
||||||
|
temperature: 0.8 # C2: кандидаты сэмплируются горячее (Р2: T=0.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 — конфиг)
|
||||||
21
backend/example/book.yaml
Normal file
21
backend/example/book.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Пример проекта книги для приёмки Фазы 0: один чанк ja→ru через C1.
|
||||||
|
book_id: sample-ja
|
||||||
|
title: 夜明けの図書館 # «Библиотека на рассвете» — собственный тестовый фрагмент
|
||||||
|
source_lang: ja
|
||||||
|
target_lang: ru
|
||||||
|
genre: ранобэ
|
||||||
|
audience: взрослые читатели вебновелл
|
||||||
|
adult: false
|
||||||
|
venuti: 0.6
|
||||||
|
honorifics: keep
|
||||||
|
transcription: polivanov
|
||||||
|
footnotes: minimal
|
||||||
|
|
||||||
|
pipeline: ../configs/pipeline-c1.yaml
|
||||||
|
models: ../configs/models.yaml
|
||||||
|
source_file: chapter1-chunk0.txt
|
||||||
|
project_db: sample-ja.db
|
||||||
|
|
||||||
|
ceilings:
|
||||||
|
book_usd: 1.00 # потолок $ на книгу — ledger обязан работать и в тестах
|
||||||
|
day_usd: 2.00
|
||||||
11
backend/example/chapter1-chunk0.txt
Normal file
11
backend/example/chapter1-chunk0.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
夜明け前の図書館は、静寂に包まれていた。
|
||||||
|
|
||||||
|
「先輩、本当にここに入ってもいいんですか」と、美咲は小声で尋ねた。彼女の手には、古びた鍵が握られていた。
|
||||||
|
|
||||||
|
「大丈夫だ。館長から許可はもらっている」と、拓海は答えた。「それより、例の本を探すぞ。朝が来る前に」
|
||||||
|
|
||||||
|
二人は書架の間を静かに歩いた。窓の外では、東の空がわずかに白み始めていた。埃の匂いと、古い紙の香りが漂う。美咲は足を止め、一冊の本に手を伸ばした。
|
||||||
|
|
||||||
|
「先輩……これ、見てください。表紙に何も書いてありません」
|
||||||
|
|
||||||
|
拓海が振り返った瞬間、図書館の奥で、何かが落ちる音がした。
|
||||||
20
backend/go.mod
Normal file
20
backend/go.mod
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
module textmachine/backend
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.53.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
55
backend/go.sum
Normal file
55
backend/go.sum
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||||
|
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
140
backend/internal/config/book.go
Normal file
140
backend/internal/config/book.go
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// book.go: translation brief — конфиг проекта книги (Фаза 0 плана: языковая
|
||||||
|
// пара, жанр, аудитория, 18+, слайдер Venuti, хонорифики, транскрипция,
|
||||||
|
// сноски). brief_hash входит в ключ TM и в snapshot: изменение брифа посреди
|
||||||
|
// книги — явная команда с показом стоимости пере-перевода (Р6).
|
||||||
|
|
||||||
|
// Book is the parsed book.yaml.
|
||||||
|
type Book struct {
|
||||||
|
BookID string `yaml:"book_id"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
SourceLang string `yaml:"source_lang"` // zh | ja | en
|
||||||
|
TargetLang string `yaml:"target_lang"` // ru
|
||||||
|
Genre string `yaml:"genre"`
|
||||||
|
Audience string `yaml:"audience"`
|
||||||
|
// Adult: интерим-правило 18+ Фазы 1 — true принудительно исключает
|
||||||
|
// Anthropic из роутинга книги (эскалация без Opus/Sonnet).
|
||||||
|
Adult bool `yaml:"adult"`
|
||||||
|
// Venuti is the foreignization↔domestication slider, 0.0 (доместикация)
|
||||||
|
// … 1.0 (форенизация).
|
||||||
|
Venuti float64 `yaml:"venuti"`
|
||||||
|
Honorifics string `yaml:"honorifics"` // keep | adapt
|
||||||
|
Transcription string `yaml:"transcription"` // e.g. polivanov | palladius
|
||||||
|
Footnotes string `yaml:"footnotes"` // none | minimal | rich
|
||||||
|
|
||||||
|
// Wiring: paths are resolved relative to the book.yaml location.
|
||||||
|
Pipeline string `yaml:"pipeline"`
|
||||||
|
ModelsFile string `yaml:"models"`
|
||||||
|
SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк
|
||||||
|
ProjectDB string `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
|
||||||
|
Ceilings BookCap `yaml:"ceilings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookCap are the ledger admission limits (Р7: потолок $ на книгу/день).
|
||||||
|
type BookCap struct {
|
||||||
|
BookUSD float64 `yaml:"book_usd"`
|
||||||
|
DayUSD float64 `yaml:"day_usd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBook reads and validates book.yaml, resolving relative paths against
|
||||||
|
// its directory.
|
||||||
|
func LoadBook(path string) (*Book, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var b Book
|
||||||
|
if err := yaml.Unmarshal(raw, &b); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
resolve := func(p string) string {
|
||||||
|
if p == "" || filepath.IsAbs(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, p)
|
||||||
|
}
|
||||||
|
b.Pipeline = resolve(b.Pipeline)
|
||||||
|
b.ModelsFile = resolve(b.ModelsFile)
|
||||||
|
b.SourceFile = resolve(b.SourceFile)
|
||||||
|
if b.ProjectDB == "" {
|
||||||
|
b.ProjectDB = filepath.Join(dir, b.BookID+".db")
|
||||||
|
} else {
|
||||||
|
b.ProjectDB = resolve(b.ProjectDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
var problems []string
|
||||||
|
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
|
||||||
|
if b.BookID == "" {
|
||||||
|
bad("book_id is required")
|
||||||
|
}
|
||||||
|
if b.SourceLang == "" || b.TargetLang == "" {
|
||||||
|
bad("source_lang and target_lang are required")
|
||||||
|
}
|
||||||
|
if b.Venuti < 0 || b.Venuti > 1 {
|
||||||
|
bad("venuti must be in [0,1], got %v", b.Venuti)
|
||||||
|
}
|
||||||
|
if b.Pipeline == "" {
|
||||||
|
bad("pipeline config path is required")
|
||||||
|
}
|
||||||
|
if b.ModelsFile == "" {
|
||||||
|
bad("models config path is required")
|
||||||
|
}
|
||||||
|
if b.SourceFile == "" {
|
||||||
|
bad("source_file is required")
|
||||||
|
} else if _, err := os.Stat(b.SourceFile); err != nil {
|
||||||
|
bad("source_file %s is not readable: %v", b.SourceFile, err)
|
||||||
|
}
|
||||||
|
if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 {
|
||||||
|
bad("ceilings: at least one of book_usd/day_usd must be set (ledger без потолка запрещён Р7)")
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BriefHash is the canonical hash of the SEMANTIC brief fields — the ones
|
||||||
|
// whose change legitimately invalidates the TM (Р6: brief_hash входит в ключ
|
||||||
|
// TM). Wiring fields (paths, ceilings, db) deliberately excluded: перенос
|
||||||
|
// проекта в другую директорию не должен пере-переводить книгу. JSON с
|
||||||
|
// фиксированным порядком полей → детерминированный байтовый рендер.
|
||||||
|
func (b *Book) BriefHash() string {
|
||||||
|
canon := struct {
|
||||||
|
BookID string `json:"book_id"`
|
||||||
|
SourceLang string `json:"source_lang"`
|
||||||
|
TargetLang string `json:"target_lang"`
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Audience string `json:"audience"`
|
||||||
|
Adult bool `json:"adult"`
|
||||||
|
Venuti float64 `json:"venuti"`
|
||||||
|
Honorifics string `json:"honorifics"`
|
||||||
|
Transcription string `json:"transcription"`
|
||||||
|
Footnotes string `json:"footnotes"`
|
||||||
|
}{b.BookID, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes}
|
||||||
|
data, err := json.Marshal(canon)
|
||||||
|
if err != nil {
|
||||||
|
// A struct of scalars cannot fail to marshal; keep the signature clean.
|
||||||
|
panic(fmt.Sprintf("config: brief hash marshal: %v", err))
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// LangPair returns "ja-ru"-style pair key (ключи порогов coverage-гейта).
|
||||||
|
func (b *Book) LangPair() string {
|
||||||
|
return b.SourceLang + "-" + b.TargetLang
|
||||||
|
}
|
||||||
173
backend/internal/config/models.go
Normal file
173
backend/internal/config/models.go
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
// Package config loads and fail-fast-validates the three YAML files of a run:
|
||||||
|
// models.yaml (провайдеры/модели/цены/таймауты — Р4/Р5: цены только в конфиге
|
||||||
|
// с датой проверки), pipeline-*.yaml (ядро C1/C2 — граница «конфиг vs код» по
|
||||||
|
// Р2) and book.yaml (translation brief + brief_hash).
|
||||||
|
//
|
||||||
|
// Дисциплина донора: все проблемы конфига собираются СПИСКОМ и валят старт
|
||||||
|
// одной ошибкой — оператор чинит всё сразу, а не по одной кочке за запуск.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/ledger"
|
||||||
|
"textmachine/backend/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Models is the parsed models.yaml.
|
||||||
|
type Models struct {
|
||||||
|
// PricesChecked is the date the prices below were verified against the
|
||||||
|
// providers' official pages. Fail-fast rejects a stale (>120 дней) config:
|
||||||
|
// волатильность цен API — риск Р10 №5.
|
||||||
|
PricesChecked string `yaml:"prices_checked"`
|
||||||
|
DefaultModel string `yaml:"default_model"`
|
||||||
|
Providers map[string]Provider `yaml:"providers"`
|
||||||
|
Models map[string]Model `yaml:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is one backend endpoint.
|
||||||
|
type Provider struct {
|
||||||
|
Kind string `yaml:"kind"` // openai | anthropic | local
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
APIKeyEnv string `yaml:"api_key_env"`
|
||||||
|
Reasoning string `yaml:"reasoning"` // subset | additive (openai kind only)
|
||||||
|
CacheTTL string `yaml:"cache_ttl"` // anthropic kind: "", "5m", "1h"
|
||||||
|
Model string `yaml:"model"` // local kind: the backend's own tag
|
||||||
|
MaxTokens int `yaml:"max_tokens"`
|
||||||
|
Timeouts Timeouts `yaml:"timeouts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeouts is the retry profile per provider (профиль per-провайдер из
|
||||||
|
// вердикта валидации; per-role overrides — Фаза 1).
|
||||||
|
type Timeouts struct {
|
||||||
|
AttemptS int `yaml:"attempt_s"`
|
||||||
|
MaxAttempts int `yaml:"max_attempts"`
|
||||||
|
BackoffCapS int `yaml:"backoff_cap_s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Timeouts) Profile() llm.RetryProfile {
|
||||||
|
return llm.RetryProfile{
|
||||||
|
AttemptTimeout: time.Duration(t.AttemptS) * time.Second,
|
||||||
|
MaxAttempts: t.MaxAttempts,
|
||||||
|
BackoffCap: time.Duration(t.BackoffCapS) * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is one priced model entry.
|
||||||
|
type Model struct {
|
||||||
|
Provider string `yaml:"provider"`
|
||||||
|
Price Price `yaml:"price"`
|
||||||
|
// ExtraBody is merged into request JSON for this model (провайдер-
|
||||||
|
// специфичные ручки: GLM thinking.type, Qwen enable_thinking …).
|
||||||
|
ExtraBody map[string]any `yaml:"extra_body"`
|
||||||
|
// MinCachePrefixTokens: Anthropic-модели — минимальный кэшируемый префикс
|
||||||
|
// (короче — кэш молча не создаётся); сборщик контекста Фазы 1 сверяется.
|
||||||
|
MinCachePrefixTokens int `yaml:"min_cache_prefix_tokens"`
|
||||||
|
Note string `yaml:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price mirrors ledger.ModelPrice in YAML form (USD per 1M tokens).
|
||||||
|
type Price struct {
|
||||||
|
InputPerM float64 `yaml:"input_per_m"`
|
||||||
|
CachedPerM float64 `yaml:"cached_per_m"`
|
||||||
|
CacheWritePerM float64 `yaml:"cache_write_per_m"`
|
||||||
|
OutputPerM float64 `yaml:"output_per_m"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Price) ToLedger() ledger.ModelPrice {
|
||||||
|
return ledger.ModelPrice{
|
||||||
|
InputPerM: p.InputPerM,
|
||||||
|
CachedPerM: p.CachedPerM,
|
||||||
|
CacheWritePerM: p.CacheWritePerM,
|
||||||
|
OutputPerM: p.OutputPerM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPriceAge is how stale prices_checked may be before the config is
|
||||||
|
// rejected (ежеквартальный пересмотр Р4 + буфер).
|
||||||
|
const maxPriceAge = 120 * 24 * time.Hour
|
||||||
|
|
||||||
|
// LoadModels reads and validates models.yaml.
|
||||||
|
func LoadModels(path string) (*Models, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var m Models
|
||||||
|
if err := yaml.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var problems []string
|
||||||
|
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
|
||||||
|
|
||||||
|
if m.PricesChecked == "" {
|
||||||
|
bad("prices_checked is required (цены без даты проверки запрещены Р4)")
|
||||||
|
} else if t, err := time.Parse("2006-01-02", m.PricesChecked); err != nil {
|
||||||
|
bad("prices_checked %q is not YYYY-MM-DD", m.PricesChecked)
|
||||||
|
} else if time.Since(t) > maxPriceAge {
|
||||||
|
bad("prices_checked %s is older than %d days — re-verify provider prices", m.PricesChecked, int(maxPriceAge.Hours()/24))
|
||||||
|
}
|
||||||
|
if m.DefaultModel == "" {
|
||||||
|
bad("default_model is required (fallback-якорь цены: неизвестная модель никогда не стоит $0)")
|
||||||
|
} else if _, ok := m.Models[m.DefaultModel]; !ok {
|
||||||
|
bad("default_model %q is not defined in models", m.DefaultModel)
|
||||||
|
}
|
||||||
|
for name, p := range m.Providers {
|
||||||
|
switch p.Kind {
|
||||||
|
case "openai", "anthropic", "local":
|
||||||
|
default:
|
||||||
|
bad("provider %s: unknown kind %q", name, p.Kind)
|
||||||
|
}
|
||||||
|
if p.BaseURL == "" && p.Kind != "anthropic" {
|
||||||
|
bad("provider %s: base_url is required", name)
|
||||||
|
}
|
||||||
|
if p.Kind == "openai" && p.Reasoning != "" && p.Reasoning != "subset" && p.Reasoning != "additive" {
|
||||||
|
bad("provider %s: reasoning must be subset|additive, got %q", name, p.Reasoning)
|
||||||
|
}
|
||||||
|
if p.Kind == "anthropic" && p.CacheTTL != "" && p.CacheTTL != "5m" && p.CacheTTL != "1h" {
|
||||||
|
bad("provider %s: cache_ttl must be 5m|1h, got %q", name, p.CacheTTL)
|
||||||
|
}
|
||||||
|
if p.Kind == "local" && p.Model == "" {
|
||||||
|
bad("provider %s: local kind requires model (its own tag)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, mod := range m.Models {
|
||||||
|
prov, ok := m.Providers[mod.Provider]
|
||||||
|
if !ok {
|
||||||
|
bad("model %s: provider %q is not defined", name, mod.Provider)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isLocal := prov.Kind == "local"
|
||||||
|
if !isLocal && (mod.Price.InputPerM <= 0 || mod.Price.OutputPerM <= 0) {
|
||||||
|
bad("model %s: non-local models require non-zero input/output prices", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prices builds the ledger pricer from the config.
|
||||||
|
func (m *Models) Prices() (*ledger.Pricer, error) {
|
||||||
|
prices := make(map[string]ledger.ModelPrice, len(m.Models))
|
||||||
|
for name, mod := range m.Models {
|
||||||
|
prices[name] = mod.Price.ToLedger()
|
||||||
|
}
|
||||||
|
return ledger.NewPricer(prices, m.Models[m.DefaultModel].Price.ToLedger())
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIKey resolves a provider's key from the environment ("" if the provider
|
||||||
|
// declares no env var — the local backend).
|
||||||
|
func (p Provider) APIKey() string {
|
||||||
|
if p.APIKeyEnv == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return os.Getenv(p.APIKeyEnv)
|
||||||
|
}
|
||||||
179
backend/internal/config/pipeline.go
Normal file
179
backend/internal/config/pipeline.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pipeline.go loads the pipeline-core config (C1/C2… — Р2). Граница «конфиг vs
|
||||||
|
// код» зафиксирована: конфиг задаёт состав/порядок стадий, роль→модель, версии
|
||||||
|
// промптов, пороги гейтов, режим инъекции глоссария, эскалационные цепочки,
|
||||||
|
// fan-out N, токен-бюджеты сборки контекста и cache TTL. Циклы по
|
||||||
|
// главам/чанкам, ветвление по гейтам, механика эскалации/ретраев и правило
|
||||||
|
// «эскалация → re-gate → флаг» зашиты в раннер.
|
||||||
|
|
||||||
|
// Pipeline is the parsed pipeline-*.yaml.
|
||||||
|
type Pipeline struct {
|
||||||
|
Core string `yaml:"core"` // C0|C1|C2|C3
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Defaults PipelineDefaults `yaml:"defaults"`
|
||||||
|
Context ContextAssembly `yaml:"context"`
|
||||||
|
Retries Retries `yaml:"retries"`
|
||||||
|
Stages []Stage `yaml:"stages"`
|
||||||
|
Gates Gates `yaml:"gates"`
|
||||||
|
Escal Escalation `yaml:"escalation"`
|
||||||
|
Fanout Fanout `yaml:"fanout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineDefaults are cross-stage knobs.
|
||||||
|
type PipelineDefaults struct {
|
||||||
|
// MaxOutputRatio sizes max_tokens ≈ ratio × input tokens (токен-калибровка
|
||||||
|
// полигона: русский выход zh→ru ≈1.9× входа; дефолт с запасом).
|
||||||
|
MaxOutputRatio float64 `yaml:"max_output_ratio"`
|
||||||
|
MinMaxTokens int `yaml:"min_max_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextAssembly holds the prompt-layout budgets (Р5-раскладка: стабильный
|
||||||
|
// префикс / волатильный хвост — сборка Фазы 1; поля фиксируются сейчас, чтобы
|
||||||
|
// схема конфига не менялась под ногами «Редакции»).
|
||||||
|
type ContextAssembly struct {
|
||||||
|
GlossaryInjection string `yaml:"glossary_injection"` // selective | full_prefix (Р5: обе схемы)
|
||||||
|
GlossaryTokenBudget int `yaml:"glossary_token_budget"`
|
||||||
|
STMDepth int `yaml:"stm_depth"`
|
||||||
|
OverlapTokens int `yaml:"overlap_tokens"`
|
||||||
|
CacheTTL string `yaml:"cache_ttl"` // per-stage TTL override — Фаза 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retries distinguishes the CONTENT retry (регенерация после провала гейта до
|
||||||
|
// эскалации) from transport retries (профиль в models.yaml) — спека их
|
||||||
|
// различает явно (валидация, линза 2 п.5).
|
||||||
|
type Retries struct {
|
||||||
|
RegenerateBeforeEscalate int `yaml:"regenerate_before_escalate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage is one pipeline pass.
|
||||||
|
type Stage struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
Model string `yaml:"model"`
|
||||||
|
Prompt string `yaml:"prompt"` // path to the versioned template file
|
||||||
|
PromptVersion string `yaml:"prompt_version"`
|
||||||
|
Temperature float64 `yaml:"temperature"`
|
||||||
|
Reasoning string `yaml:"reasoning"` // "", off, low, medium, high
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
|
||||||
|
type Gates struct {
|
||||||
|
Coverage CoverageGate `yaml:"coverage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverageGate v1 (Р7): метрика — символы без пробелов; нижняя граница —
|
||||||
|
// подозрение на вырезание, верхняя — аномалия; пороги из эксперимента 02.
|
||||||
|
type CoverageGate struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
LenRatio map[string][]float64 `yaml:"len_ratio_bounds"` // "zh-ru": [low, high]
|
||||||
|
SentCovMin float64 `yaml:"sent_cov_min"`
|
||||||
|
MinChunkChars int `yaml:"min_chunk_chars"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalation holds named model chains; channel-aware filtering (Anthropic вне
|
||||||
|
// 18+ и т.д.) применяет раннер, не конфиг.
|
||||||
|
type Escalation struct {
|
||||||
|
Chains map[string][]string `yaml:"chains"`
|
||||||
|
BudgetUSD float64 `yaml:"budget_usd"` // адресный премиум-бюджет книги; формула — [НУЖНО РЕШЕНИЕ] п.1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fanout is the C2 skeleton: N候補 candidates per chunk (fan-out — механика
|
||||||
|
// раннера, N — конфиг).
|
||||||
|
type Fanout struct {
|
||||||
|
Candidates int `yaml:"candidates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPipeline reads and validates a pipeline config against the models known
|
||||||
|
// to models.yaml.
|
||||||
|
func LoadPipeline(path string, models *Models) (*Pipeline, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var p Pipeline
|
||||||
|
if err := yaml.Unmarshal(raw, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
// Prompt paths resolve relative to the pipeline file's directory, so a
|
||||||
|
// project works from any CWD.
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
for i := range p.Stages {
|
||||||
|
if pr := p.Stages[i].Prompt; pr != "" && !filepath.IsAbs(pr) {
|
||||||
|
p.Stages[i].Prompt = filepath.Join(dir, pr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var problems []string
|
||||||
|
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
|
||||||
|
|
||||||
|
if p.Core == "" {
|
||||||
|
bad("core is required (C0|C1|C2|C3)")
|
||||||
|
}
|
||||||
|
if len(p.Stages) == 0 {
|
||||||
|
bad("at least one stage is required")
|
||||||
|
}
|
||||||
|
if p.Defaults.MaxOutputRatio <= 0 {
|
||||||
|
p.Defaults.MaxOutputRatio = 2.0
|
||||||
|
}
|
||||||
|
if p.Defaults.MinMaxTokens <= 0 {
|
||||||
|
p.Defaults.MinMaxTokens = 2048
|
||||||
|
}
|
||||||
|
if p.Fanout.Candidates <= 0 {
|
||||||
|
p.Fanout.Candidates = 1
|
||||||
|
}
|
||||||
|
switch p.Context.GlossaryInjection {
|
||||||
|
case "", "selective", "full_prefix":
|
||||||
|
default:
|
||||||
|
bad("context.glossary_injection must be selective|full_prefix, got %q", p.Context.GlossaryInjection)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i, st := range p.Stages {
|
||||||
|
if st.Name == "" {
|
||||||
|
bad("stage %d: name is required", i)
|
||||||
|
}
|
||||||
|
if seen[st.Name] {
|
||||||
|
bad("stage %q: duplicate name", st.Name)
|
||||||
|
}
|
||||||
|
seen[st.Name] = true
|
||||||
|
if st.Role == "" {
|
||||||
|
bad("stage %q: role is required", st.Name)
|
||||||
|
}
|
||||||
|
if st.PromptVersion == "" {
|
||||||
|
bad("stage %q: prompt_version is required (версионирование промптов — Р2)", st.Name)
|
||||||
|
}
|
||||||
|
if _, ok := models.Models[st.Model]; !ok {
|
||||||
|
bad("stage %q: model %q is not defined in models.yaml", st.Name, st.Model)
|
||||||
|
}
|
||||||
|
if st.Prompt == "" {
|
||||||
|
bad("stage %q: prompt template path is required", st.Name)
|
||||||
|
} else if _, err := os.Stat(st.Prompt); err != nil {
|
||||||
|
bad("stage %q: prompt template %s is not readable: %v", st.Name, st.Prompt, err)
|
||||||
|
}
|
||||||
|
switch st.Reasoning {
|
||||||
|
case "", "off", "low", "medium", "high":
|
||||||
|
default:
|
||||||
|
bad("stage %q: reasoning must be off|low|medium|high, got %q", st.Name, st.Reasoning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for chain, ms := range p.Escal.Chains {
|
||||||
|
for _, m := range ms {
|
||||||
|
if _, ok := models.Models[m]; !ok {
|
||||||
|
bad("escalation chain %q: model %q is not defined in models.yaml", chain, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
83
backend/internal/ledger/pricing.go
Normal file
83
backend/internal/ledger/pricing.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Package ledger is the money seam: per-model prices, usage→USD computation,
|
||||||
|
// and reserve/settle bookkeeping against the store. Дисциплина vojo целиком
|
||||||
|
// (Р7): биллинг по usage из ответа API, reserve-before-call / settle-after,
|
||||||
|
// потолки $; расширение Фазы 0 — стоимость записи в кэш (Anthropic ×1.25/×2).
|
||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModelPrice is the per-1M-token USD price for one model, applied to the
|
||||||
|
// API's returned usage so the ceilings track real cost even as prices drift.
|
||||||
|
// Prices live ONLY in configs/models.yaml with a checked-date (Р4/Р5).
|
||||||
|
type ModelPrice struct {
|
||||||
|
InputPerM float64 // non-cached prompt tokens
|
||||||
|
// CachedPerM prices prompt tokens served FROM the cache (cache read).
|
||||||
|
CachedPerM float64
|
||||||
|
// CacheWritePerM prices prompt tokens WRITTEN to the cache. Anthropic bills
|
||||||
|
// writes at ×1.25 (5m TTL) / ×2 (1h) of input; providers without explicit
|
||||||
|
// writes keep 0 and the term vanishes.
|
||||||
|
CacheWritePerM float64
|
||||||
|
OutputPerM float64 // completion + reasoning tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pricer resolves model→price with an explicit default anchor. An unknown
|
||||||
|
// model falls back to the DEFAULT price rather than $0 — a $0 price would
|
||||||
|
// silently blind the ceilings to that call, the one failure mode we never
|
||||||
|
// want (у vojo якорем была XAIModel; здесь мультипровайдерный конфиг требует
|
||||||
|
// явного default_price).
|
||||||
|
type Pricer struct {
|
||||||
|
prices map[string]ModelPrice
|
||||||
|
defaultPrice ModelPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPricer builds a pricer. defaultPrice must be a real (non-zero) price —
|
||||||
|
// enforced at config load (fail-fast), re-checked here defensively.
|
||||||
|
func NewPricer(prices map[string]ModelPrice, defaultPrice ModelPrice) (*Pricer, error) {
|
||||||
|
if defaultPrice.InputPerM <= 0 || defaultPrice.OutputPerM <= 0 {
|
||||||
|
return nil, fmt.Errorf("ledger: default price must be non-zero (a $0 fallback would blind the spend ceilings)")
|
||||||
|
}
|
||||||
|
return &Pricer{prices: prices, defaultPrice: defaultPrice}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriceFor returns the configured price for a model, falling back to the
|
||||||
|
// default anchor for unknown models (never $0).
|
||||||
|
func (p *Pricer) PriceFor(model string) ModelPrice {
|
||||||
|
if mp, ok := p.prices[model]; ok {
|
||||||
|
return mp
|
||||||
|
}
|
||||||
|
return p.defaultPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
// CostUSD prices one completion by its usage. Formula (invariant from
|
||||||
|
// llm.Usage: PromptTokens = total input = uncached + cached + cache-written):
|
||||||
|
//
|
||||||
|
// (prompt − cached − cacheCreation)·in + cached·cacheRead
|
||||||
|
// + cacheCreation·cacheWrite + (completion + reasoning)·out
|
||||||
|
//
|
||||||
|
// For providers without cache-write accounting the third term is 0 and the
|
||||||
|
// formula degrades to vojo's computeUSD.
|
||||||
|
func CostUSD(price ModelPrice, u llm.Usage) float64 {
|
||||||
|
uncached := u.PromptTokens - u.CachedTokens - u.CacheCreationTokens
|
||||||
|
if uncached < 0 {
|
||||||
|
// Defensive: a provider reporting cached > prompt would otherwise
|
||||||
|
// produce a negative term and understate cost.
|
||||||
|
uncached = 0
|
||||||
|
}
|
||||||
|
perTok := func(perM float64) float64 { return perM / 1_000_000 }
|
||||||
|
return float64(uncached)*perTok(price.InputPerM) +
|
||||||
|
float64(u.CachedTokens)*perTok(price.CachedPerM) +
|
||||||
|
float64(u.CacheCreationTokens)*perTok(price.CacheWritePerM) +
|
||||||
|
float64(u.CompletionTokens+u.ReasoningTokens)*perTok(price.OutputPerM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateUSD is the pre-call reservation estimate: assume the whole prompt
|
||||||
|
// misses the cache and the completion runs to maxTokens. Deliberately
|
||||||
|
// pessimistic — the reservation is released down to the real cost at settle.
|
||||||
|
func EstimateUSD(price ModelPrice, promptTokens, maxTokens int) float64 {
|
||||||
|
return float64(promptTokens)*price.InputPerM/1_000_000 +
|
||||||
|
float64(maxTokens)*price.OutputPerM/1_000_000
|
||||||
|
}
|
||||||
72
backend/internal/ledger/pricing_test.go
Normal file
72
backend/internal/ledger/pricing_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func approx(t *testing.T, got, want float64, msg string) {
|
||||||
|
t.Helper()
|
||||||
|
if math.Abs(got-want) > 1e-12 {
|
||||||
|
t.Fatalf("%s: got %v, want %v", msg, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формула денег — ядро юнит-экономики; проверяем каждое слагаемое, включая
|
||||||
|
// новое (cache write), и деградацию к формуле vojo без кэш-полей.
|
||||||
|
func TestCostUSD(t *testing.T) {
|
||||||
|
price := ModelPrice{InputPerM: 2.0, CachedPerM: 0.2, CacheWritePerM: 2.5, OutputPerM: 10.0}
|
||||||
|
|
||||||
|
// Anthropic-подобный вызов: 10k всего входа = 6k некэш + 3k cache-read +
|
||||||
|
// 1k cache-write; 2k выхода.
|
||||||
|
u := llm.Usage{PromptTokens: 10_000, CachedTokens: 3_000, CacheCreationTokens: 1_000, CompletionTokens: 2_000}
|
||||||
|
want := 6_000*2.0/1e6 + 3_000*0.2/1e6 + 1_000*2.5/1e6 + 2_000*10.0/1e6
|
||||||
|
approx(t, CostUSD(price, u), want, "anthropic-style usage")
|
||||||
|
|
||||||
|
// OpenAI-совместимый вызов без кэш-полей — формула vojo.
|
||||||
|
u2 := llm.Usage{PromptTokens: 10_000, CompletionTokens: 2_000}
|
||||||
|
approx(t, CostUSD(price, u2), 10_000*2.0/1e6+2_000*10.0/1e6, "plain usage")
|
||||||
|
|
||||||
|
// Reasoning-токены биллятся по output-ставке ПОВЕРХ completion (xAI).
|
||||||
|
u3 := llm.Usage{PromptTokens: 1_000, CompletionTokens: 500, ReasoningTokens: 1_500}
|
||||||
|
approx(t, CostUSD(price, u3), 1_000*2.0/1e6+2_000*10.0/1e6, "additive reasoning")
|
||||||
|
|
||||||
|
// Защита от провайдера, отрапортовавшего cached > prompt: некэш-слагаемое
|
||||||
|
// зажимается в 0, а не уходит в минус.
|
||||||
|
u4 := llm.Usage{PromptTokens: 100, CachedTokens: 500, CompletionTokens: 10}
|
||||||
|
want4 := 0*2.0/1e6 + 500*0.2/1e6 + 10*10.0/1e6
|
||||||
|
approx(t, CostUSD(price, u4), want4, "cached > prompt guard")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPricerNeverZero(t *testing.T) {
|
||||||
|
def := ModelPrice{InputPerM: 1, OutputPerM: 2}
|
||||||
|
p, err := NewPricer(map[string]ModelPrice{"known": {InputPerM: 5, CachedPerM: 1, OutputPerM: 10}}, def)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := p.PriceFor("known").InputPerM; got != 5 {
|
||||||
|
t.Fatalf("known model price: got %v", got)
|
||||||
|
}
|
||||||
|
// Неизвестная модель (фактическая после фейловера) — дефолтный якорь, не $0.
|
||||||
|
if got := p.PriceFor("mystery-model").InputPerM; got != 1 {
|
||||||
|
t.Fatalf("unknown model must fall back to default price, got %v", got)
|
||||||
|
}
|
||||||
|
// $0-дефолт ослепил бы потолки — конструктор обязан отказать.
|
||||||
|
if _, err := NewPricer(nil, ModelPrice{}); err == nil {
|
||||||
|
t.Fatal("zero default price must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEstimateUSDIsPessimistic(t *testing.T) {
|
||||||
|
price := ModelPrice{InputPerM: 2.0, CachedPerM: 0.2, OutputPerM: 10.0}
|
||||||
|
est := EstimateUSD(price, 1_000, 2_000)
|
||||||
|
approx(t, est, 1_000*2.0/1e6+2_000*10.0/1e6, "estimate")
|
||||||
|
// Реальный вызов с кэш-хитом обязан стоить не больше оценки — иначе
|
||||||
|
// settle раздует committed выше потолка, пропущенного на допуске.
|
||||||
|
real := CostUSD(price, llm.Usage{PromptTokens: 1_000, CachedTokens: 900, CompletionTokens: 2_000})
|
||||||
|
if real > est {
|
||||||
|
t.Fatalf("real cost %v exceeds reservation estimate %v", real, est)
|
||||||
|
}
|
||||||
|
}
|
||||||
232
backend/internal/llm/failover.go
Normal file
232
backend/internal/llm/failover.go
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// failover.go is the local-first routing decorator over the LLMClient seam:
|
||||||
|
// prefer the (free) home backend while it is reachable, fall back to the cloud
|
||||||
|
// transparently when it is not. Порт vojo failover.go; конструктор принимает
|
||||||
|
// явные параметры вместо толстого *Config, пробер использует no-proxy
|
||||||
|
// транспорт (та же прокси-ловушка, что у provider_local).
|
||||||
|
//
|
||||||
|
// Channel-isolation caveat (03-implementation-notes §3.5): the fallback leg is
|
||||||
|
// wired by the CALLER per role — for NSFW channel B (Фаза 2) the fallback must
|
||||||
|
// itself be a permissive provider or absent (fail loud), otherwise an empty
|
||||||
|
// answer from the local abliterated model would silently ship the flagged
|
||||||
|
// chunk to a restricted cloud provider.
|
||||||
|
//
|
||||||
|
// Health is decided by an ACTIVE background probe plus a request-path circuit
|
||||||
|
// breaker, both feeding one healthy flag:
|
||||||
|
// - unhealthy → requests go straight to the cloud;
|
||||||
|
// - healthy → try local under its own deadline; on transport/5xx/timeout
|
||||||
|
// trip the breaker and retry the SAME request on the cloud;
|
||||||
|
// - recovery is probe-driven ONLY (anti-flapping): a tripped breaker needs
|
||||||
|
// tripProbesToClose consecutive probe successes to close.
|
||||||
|
//
|
||||||
|
// A terminal 4xx from the local leg (except 429) does NOT fall back: that is a
|
||||||
|
// config/request error (wrong model tag, malformed body) and the cloud would
|
||||||
|
// only mask it. 429 (a busy single-slot GPU) falls back like a timeout.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// localProbeTimeout bounds one health probe: a bare GET /models is answered
|
||||||
|
// from the server's process memory, no inference — slower means the path,
|
||||||
|
// not the model, is struggling.
|
||||||
|
localProbeTimeout = 3 * time.Second
|
||||||
|
// tripProbesToClose is how many consecutive probe successes re-close a
|
||||||
|
// breaker tripped by a REQUEST failure (probe can pass while requests fail:
|
||||||
|
// a cold model being loaded, a half-alive path).
|
||||||
|
tripProbesToClose = 2
|
||||||
|
// neverOnlineWarnAfter: consecutive probe failures — without the leg EVER
|
||||||
|
// having come online since boot — that trigger a one-time WARN, so an
|
||||||
|
// enabled-but-dead local leg can't silently serve 100% of traffic from the
|
||||||
|
// paid cloud.
|
||||||
|
neverOnlineWarnAfter = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// FailoverConfig wires the decorator.
|
||||||
|
type FailoverConfig struct {
|
||||||
|
LegTimeout time.Duration // per-request deadline for the local leg
|
||||||
|
ProbeURL string // <local base URL>/models
|
||||||
|
ProbeKey string // optional bearer for the probe (usually empty)
|
||||||
|
ProbeInterval time.Duration // background probe period
|
||||||
|
}
|
||||||
|
|
||||||
|
type failoverClient struct {
|
||||||
|
primary LLMClient // local backend (free, preferred)
|
||||||
|
fallback LLMClient // cloud (paid)
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
legTimeout time.Duration
|
||||||
|
probeURL string
|
||||||
|
probeKey string
|
||||||
|
interval time.Duration
|
||||||
|
httpc *http.Client
|
||||||
|
|
||||||
|
// Breaker state. healthy starts false: until the first probe passes,
|
||||||
|
// traffic is served by the cloud, so a boot with the home box offline never
|
||||||
|
// eats the probe timeout on user requests.
|
||||||
|
mu sync.Mutex
|
||||||
|
healthy bool
|
||||||
|
consecOK int
|
||||||
|
needOK int
|
||||||
|
consecFail int
|
||||||
|
everHealthy bool
|
||||||
|
warnedDown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFailoverClient wraps primary (local) and fallback (cloud) into one
|
||||||
|
// LLMClient and starts the background health probe, which lives until ctx is
|
||||||
|
// cancelled (process lifetime).
|
||||||
|
func NewFailoverClient(ctx context.Context, primary, fallback LLMClient, cfg FailoverConfig, logger *slog.Logger) LLMClient {
|
||||||
|
if cfg.LegTimeout <= 0 {
|
||||||
|
cfg.LegTimeout = 300 * time.Second // полигон: чанки 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
|
||||||
|
}
|
||||||
137
backend/internal/llm/failover_test.go
Normal file
137
backend/internal/llm/failover_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeLLM — дублёр интерфейса (стиль донора): фиксированный ответ или ошибка.
|
||||||
|
type fakeLLM struct {
|
||||||
|
resp *LLMResponse
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLLM) Complete(_ context.Context, _ LLMRequest) (*LLMResponse, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func quietLogger() *slog.Logger { return slog.New(slog.DiscardHandler) }
|
||||||
|
|
||||||
|
// newTestFailover собирает декоратор без пробера (healthy выставляется руками
|
||||||
|
// через noteProbe) — детерминированная машина брейкера, как в failover_test
|
||||||
|
// донора.
|
||||||
|
func newTestFailover(primary, fallback LLMClient) *failoverClient {
|
||||||
|
return &failoverClient{
|
||||||
|
primary: primary, fallback: fallback,
|
||||||
|
log: quietLogger(), legTimeout: 1e9, needOK: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailoverUnhealthyGoesStraightToCloud(t *testing.T) {
|
||||||
|
local := &fakeLLM{resp: &LLMResponse{Text: "local"}}
|
||||||
|
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||||||
|
f := newTestFailover(local, cloud) // healthy=false на старте
|
||||||
|
|
||||||
|
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||||||
|
if err != nil || resp.Text != "cloud" {
|
||||||
|
t.Fatalf("resp=%v err=%v", resp, err)
|
||||||
|
}
|
||||||
|
if local.calls != 0 {
|
||||||
|
t.Fatal("unhealthy leg must not be tried")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailoverHealthyServesLocal(t *testing.T) {
|
||||||
|
local := &fakeLLM{resp: &LLMResponse{Text: "local", Model: "local-model"}}
|
||||||
|
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||||||
|
f := newTestFailover(local, cloud)
|
||||||
|
f.noteProbe(true)
|
||||||
|
|
||||||
|
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||||||
|
if err != nil || resp.Text != "local" {
|
||||||
|
t.Fatalf("resp=%v err=%v", resp, err)
|
||||||
|
}
|
||||||
|
if cloud.calls != 0 {
|
||||||
|
t.Fatal("cloud must not be billed when local serves")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailoverEmptyLocalRetriesOnCloud(t *testing.T) {
|
||||||
|
// Пустой 2xx локальной ноги (thinking съел бюджет) — бесплатный, поэтому
|
||||||
|
// ретраим в облако; брейкер НЕ срабатывает.
|
||||||
|
local := &fakeLLM{resp: &LLMResponse{Text: " "}}
|
||||||
|
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||||||
|
f := newTestFailover(local, cloud)
|
||||||
|
f.noteProbe(true)
|
||||||
|
|
||||||
|
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||||||
|
if err != nil || resp.Text != "cloud" {
|
||||||
|
t.Fatalf("resp=%v err=%v", resp, err)
|
||||||
|
}
|
||||||
|
if !f.isHealthy() {
|
||||||
|
t.Fatal("empty content must not trip the breaker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailoverTerminal4xxFailsLoud(t *testing.T) {
|
||||||
|
// Терминальный 4xx локальной ноги — конфиг-ошибка (неверный тег модели);
|
||||||
|
// облако её маскировать не должно.
|
||||||
|
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 404, Body: "no such model"}}
|
||||||
|
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||||||
|
f := newTestFailover(local, cloud)
|
||||||
|
f.noteProbe(true)
|
||||||
|
|
||||||
|
_, err := f.Complete(context.Background(), LLMRequest{})
|
||||||
|
var se *HTTPStatusError
|
||||||
|
if err == nil || !errors.As(err, &se) || se.Status != 404 {
|
||||||
|
t.Fatalf("want loud 404, got %v", err)
|
||||||
|
}
|
||||||
|
if cloud.calls != 0 {
|
||||||
|
t.Fatal("terminal 4xx must not be masked by the cloud")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailover5xxTripsBreakerAndFallsBack(t *testing.T) {
|
||||||
|
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 502, Body: "boom"}}
|
||||||
|
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||||||
|
f := newTestFailover(local, cloud)
|
||||||
|
f.noteProbe(true)
|
||||||
|
|
||||||
|
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||||||
|
if err != nil || resp.Text != "cloud" {
|
||||||
|
t.Fatalf("resp=%v err=%v", resp, err)
|
||||||
|
}
|
||||||
|
if f.isHealthy() {
|
||||||
|
t.Fatal("5xx must trip the breaker")
|
||||||
|
}
|
||||||
|
// Анти-flapping: после request-trip одной успешной пробы мало.
|
||||||
|
f.noteProbe(true)
|
||||||
|
if f.isHealthy() {
|
||||||
|
t.Fatal("one probe success must not re-close a request-tripped breaker")
|
||||||
|
}
|
||||||
|
f.noteProbe(true)
|
||||||
|
if !f.isHealthy() {
|
||||||
|
t.Fatal("two consecutive probe successes must re-close the breaker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test429FallsBackLikeTimeout(t *testing.T) {
|
||||||
|
// 429 — занятый single-slot GPU, падает в облако как таймаут (не как
|
||||||
|
// терминальный 4xx).
|
||||||
|
local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 429, Body: "busy"}}
|
||||||
|
cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}}
|
||||||
|
f := newTestFailover(local, cloud)
|
||||||
|
f.noteProbe(true)
|
||||||
|
|
||||||
|
resp, err := f.Complete(context.Background(), LLMRequest{})
|
||||||
|
if err != nil || resp.Text != "cloud" {
|
||||||
|
t.Fatalf("resp=%v err=%v", resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
336
backend/internal/llm/httpllm.go
Normal file
336
backend/internal/llm/httpllm.go
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/obs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpllm.go is the shared OpenAI-compatible Chat Completions transport: one
|
||||||
|
// HTTP+retry implementation reused by every OpenAI-compatible adapter
|
||||||
|
// (DeepSeek, GLM, Kimi, xAI, ru-агрегаторы, llama-server). Порт vojo
|
||||||
|
// httpllm.go с правками Фазы 0 (03-implementation-notes §2 п.2):
|
||||||
|
//
|
||||||
|
// - все таймауты/ретраи — ПАРАМЕТРЫ клиента из профиля models.yaml, не
|
||||||
|
// константы: захардкоженный 60-секундный per-attempt дедлайн донора убивал
|
||||||
|
// бы реальные чанки 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)
|
||||||
|
}
|
||||||
226
backend/internal/llm/httpllm_test.go
Normal file
226
backend/internal/llm/httpllm_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сценарные тесты wire-контракта через httptest — стиль донора (реальные
|
||||||
|
// HTTP-раундтрипы, не моки транспорта).
|
||||||
|
|
||||||
|
func fastProfile() RetryProfile {
|
||||||
|
return RetryProfile{AttemptTimeout: 2 * time.Second, MaxAttempts: 3, BackoffBase: time.Millisecond, BackoffCap: 5 * time.Millisecond}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIOK(t *testing.T, w http.ResponseWriter, body string) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if _, err := w.Write([]byte(body)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatHappyPath(t *testing.T) {
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/chat/completions" {
|
||||||
|
t.Errorf("path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer k" {
|
||||||
|
t.Errorf("auth = %q", got)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
openAIOK(t, w, `{"id":"r1","model":"m-actual","choices":[{"message":{"content":"привет"},"finish_reason":"stop"}],
|
||||||
|
"usage":{"prompt_tokens":100,"completion_tokens":20,"prompt_tokens_details":{"cached_tokens":40}}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(),
|
||||||
|
ExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}}}, nil)
|
||||||
|
resp, err := c.Complete(context.Background(), LLMRequest{
|
||||||
|
Model: "m",
|
||||||
|
Messages: []Message{{Role: "system", Content: "s"}, {Role: "user", Content: "u"}},
|
||||||
|
MaxTokens: 256,
|
||||||
|
Temperature: 0.3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Text != "привет" || resp.Model != "m-actual" || resp.FinishReason != FinishStop {
|
||||||
|
t.Fatalf("resp = %+v", resp)
|
||||||
|
}
|
||||||
|
if resp.Usage.PromptTokens != 100 || resp.Usage.CachedTokens != 40 || resp.Usage.CompletionTokens != 20 {
|
||||||
|
t.Fatalf("usage = %+v", resp.Usage)
|
||||||
|
}
|
||||||
|
if resp.Usage.ReasoningTokens != 0 {
|
||||||
|
t.Fatalf("subset semantics must keep reasoning 0, got %d", resp.Usage.ReasoningTokens)
|
||||||
|
}
|
||||||
|
// ExtraBody из models.yaml домержен в тело запроса.
|
||||||
|
if _, ok := gotBody["thinking"]; !ok {
|
||||||
|
t.Fatalf("extra_body not merged into wire body: %v", gotBody)
|
||||||
|
}
|
||||||
|
if gotBody["stream"] != false {
|
||||||
|
t.Fatalf("stream must be false, got %v", gotBody["stream"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeepSeekCacheFieldsVariant(t *testing.T) {
|
||||||
|
// DeepSeek-вариант usage: prompt_cache_hit_tokens вместо
|
||||||
|
// prompt_tokens_details — иначе эксперимент по кэшу покажет ложный ноль.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
openAIOK(t, w, `{"id":"r2","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
|
||||||
|
"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "deepseek", BaseURL: srv.URL, Profile: fastProfile()}, nil)
|
||||||
|
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Usage.CachedTokens != 64 {
|
||||||
|
t.Fatalf("deepseek cache-hit tokens not parsed: %+v", resp.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdditiveReasoningSemantics(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
openAIOK(t, w, `{"id":"r3","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
|
||||||
|
"usage":{"prompt_tokens":10,"completion_tokens":5,"completion_tokens_details":{"reasoning_tokens":50}}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// additive (xAI): reasoning поверх completion.
|
||||||
|
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "xai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditive}, nil)
|
||||||
|
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Usage.ReasoningTokens != 50 {
|
||||||
|
t.Fatalf("additive semantics must surface reasoning tokens, got %+v", resp.Usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// subset: то же тело, но reasoning остаётся 0 (иначе двойной биллинг).
|
||||||
|
c2 := NewOpenAICompatClient(OpenAICompatConfig{Name: "openai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningSubset}, nil)
|
||||||
|
resp2, err := c2.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp2.Usage.ReasoningTokens != 0 {
|
||||||
|
t.Fatalf("subset semantics must keep reasoning 0, got %+v", resp2.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryOn429HonoursRetryAfter(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
var firstRetryAt, secondCallAt time.Time
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := calls.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
firstRetryAt = time.Now()
|
||||||
|
w.Header().Set("Retry-After", "1")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secondCallAt = time.Now()
|
||||||
|
openAIOK(t, w, `{"id":"r","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
|
||||||
|
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Text != "ok" || calls.Load() != 2 {
|
||||||
|
t.Fatalf("text=%q calls=%d", resp.Text, calls.Load())
|
||||||
|
}
|
||||||
|
// Retry-After: 1s должен перебить миллисекундный backoff профиля.
|
||||||
|
if wait := secondCallAt.Sub(firstRetryAt); wait < 900*time.Millisecond {
|
||||||
|
t.Fatalf("Retry-After not honoured: waited only %v", wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminal4xxNoRetry(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"bad model"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
|
||||||
|
_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("terminal 4xx must fail")
|
||||||
|
}
|
||||||
|
var se *HTTPStatusError
|
||||||
|
if !asHTTPStatus(err, &se) || se.Status != 400 {
|
||||||
|
t.Fatalf("want typed 400, got %v", err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("terminal 4xx must not retry, calls=%d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyContent2xxIsSuccess(t *testing.T) {
|
||||||
|
// 2xx с пустым контентом — оплаченный вызов: транспорт возвращает успех,
|
||||||
|
// деньги книжатся, гейты разбираются с пустым текстом.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
openAIOK(t, w, `{"id":"r","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
|
||||||
|
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty-content 2xx must be a success (billed call): %v", err)
|
||||||
|
}
|
||||||
|
if resp.Text != "" || resp.Usage.PromptTokens != 10 {
|
||||||
|
t.Fatalf("resp = %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryOn5xxThenSuccess(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if calls.Add(1) < 3 {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openAIOK(t, w, `{"id":"r","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
|
||||||
|
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
|
||||||
|
if err != nil || resp.Text != "ok" {
|
||||||
|
t.Fatalf("resp=%v err=%v", resp, err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 3 {
|
||||||
|
t.Fatalf("calls = %d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asHTTPStatus(err error, target **HTTPStatusError) bool {
|
||||||
|
for err != nil {
|
||||||
|
if se, ok := err.(*HTTPStatusError); ok {
|
||||||
|
*target = se
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, ok := err.(interface{ Unwrap() error })
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
err = u.Unwrap()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
108
backend/internal/llm/llm.go
Normal file
108
backend/internal/llm/llm.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// Package llm is the provider-neutral seam between the pipeline and concrete
|
||||||
|
// model backends (порт vojo/apps/ai-bot llm.go с расширениями Фазы 0 — см.
|
||||||
|
// docs/architecture/03-implementation-notes.md §3.5). Nothing here names a
|
||||||
|
// vendor: the runner composes context, the ledger prices usage, and thin
|
||||||
|
// adapters (provider_openai.go, provider_anthropic.go, provider_local.go) map
|
||||||
|
// these types to/from each backend's wire format.
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Message is one provider-neutral chat turn.
|
||||||
|
type Message struct {
|
||||||
|
Role string // "system" | "user" | "assistant"
|
||||||
|
Content string
|
||||||
|
// CacheBoundary marks THIS message as the end of a stable prompt prefix
|
||||||
|
// (раскладка Р5: стабильный префикс = system+brief+style sheet+резюме,
|
||||||
|
// волатильный хвост = глоссарий+STM+чанк). The Anthropic adapter attaches
|
||||||
|
// cache_control to the corresponding block (max 4 boundaries per request —
|
||||||
|
// API limit); OpenAI-compatible providers cache by prefix automatically and
|
||||||
|
// ignore the flag. The context assembler decides the layout; adapters never
|
||||||
|
// guess where the boundary is.
|
||||||
|
CacheBoundary bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage is the provider-neutral token accounting returned with a completion.
|
||||||
|
// It drives billing (ledger.CostUSD) — the counts are the API's own,
|
||||||
|
// authoritative even if our price constants drift.
|
||||||
|
//
|
||||||
|
// Invariant: PromptTokens is the TOTAL input (cached + cache-written +
|
||||||
|
// uncached). Anthropic reports the three parts separately (input_tokens is
|
||||||
|
// only the uncached remainder), so its adapter SUMS them into PromptTokens;
|
||||||
|
// OpenAI-compatible providers already report the total in prompt_tokens.
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int
|
||||||
|
// CachedTokens is the subset of PromptTokens served from the provider's
|
||||||
|
// prompt cache (cache READ — billed at the discounted cache-hit rate).
|
||||||
|
CachedTokens int
|
||||||
|
// CacheCreationTokens is the subset of PromptTokens WRITTEN to the cache
|
||||||
|
// this call. Anthropic bills writes at a premium (×1.25 for 5m TTL, ×2 for
|
||||||
|
// 1h — проверено 2026-07-04); dropping this field would undercount spend,
|
||||||
|
// the same failure class as the reasoning-token undercount already paid
|
||||||
|
// for in vojo (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)
|
||||||
|
}
|
||||||
285
backend/internal/llm/provider_anthropic.go
Normal file
285
backend/internal/llm/provider_anthropic.go
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/obs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// provider_anthropic.go is the native Messages API adapter — the first NEW
|
||||||
|
// code on top of the vojo port (Р1: OpenAI-compat слоя недостаточно, нужен
|
||||||
|
// explicit cache_control). It is a thin raw-HTTP adapter like every other
|
||||||
|
// provider here — NOT the official SDK — so retries, timeouts, proxies and
|
||||||
|
// raw usage stay under the one transport discipline the ledger depends on
|
||||||
|
// (обоснование в 03-implementation-notes §3.5).
|
||||||
|
//
|
||||||
|
// Wire facts (проверено 2026-07-04, платформенная документация):
|
||||||
|
// - POST {base}/v1/messages, headers x-api-key + anthropic-version.
|
||||||
|
// - system is a list of text blocks; cache_control {type:"ephemeral",
|
||||||
|
// ttl:"5m"|"1h"} on the LAST block of the stable prefix; max 4 breakpoints.
|
||||||
|
// - usage.input_tokens is ONLY the uncached remainder;
|
||||||
|
// cache_read_input_tokens and cache_creation_input_tokens are separate.
|
||||||
|
// Neutral Usage.PromptTokens is the SUM of the three (см. llm.go invariant).
|
||||||
|
// - cache write bills ×1.25 (5m) / ×2 (1h); read ×0.1. Min cacheable prefix
|
||||||
|
// is model-dependent (короче — кэш молча не создаётся): models.yaml keeps
|
||||||
|
// the per-model value for the assembler.
|
||||||
|
// - thinking bills as output tokens (inside output_tokens, no separate
|
||||||
|
// field) → ReasoningTokens stays 0 here.
|
||||||
|
// - stop_reason: end_turn | max_tokens | stop_sequence | refusal (+ tool_use,
|
||||||
|
// pause_turn — не наши кейсы в Фазе 0).
|
||||||
|
|
||||||
|
const anthropicVersion = "2023-06-01"
|
||||||
|
|
||||||
|
// AnthropicConfig configures the adapter.
|
||||||
|
type AnthropicConfig struct {
|
||||||
|
BaseURL string // default https://api.anthropic.com
|
||||||
|
APIKey string
|
||||||
|
Profile RetryProfile
|
||||||
|
// CacheTTL is the cache_control ttl applied at CacheBoundary blocks:
|
||||||
|
// "" (omit — API default 5m), "5m" or "1h". Per-stage tuning comes from
|
||||||
|
// the pipeline config (TTL 5m может истекать между вызовами стадии —
|
||||||
|
// латентности чанков 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
107
backend/internal/llm/provider_anthropic_test.go
Normal file
107
backend/internal/llm/provider_anthropic_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnthropicWireShapeAndUsage(t *testing.T) {
|
||||||
|
var got map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
t.Errorf("path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("x-api-key") != "k" || r.Header.Get("anthropic-version") == "" {
|
||||||
|
t.Errorf("headers: key=%q version=%q", r.Header.Get("x-api-key"), r.Header.Get("anthropic-version"))
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"id":"msg1","model":"claude-sonnet-5","content":[{"type":"text","text":"пере"},{"type":"text","text":"вод"}],
|
||||||
|
"stop_reason":"end_turn",
|
||||||
|
"usage":{"input_tokens":60,"output_tokens":20,"cache_creation_input_tokens":30,"cache_read_input_tokens":10}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewAnthropicClient(AnthropicConfig{BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(), CacheTTL: "5m"}, nil)
|
||||||
|
resp, err := c.Complete(context.Background(), LLMRequest{
|
||||||
|
Model: "claude-sonnet-5",
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "system", Content: "роль"},
|
||||||
|
{Role: "system", Content: "бриф", CacheBoundary: true},
|
||||||
|
{Role: "user", Content: "чанк"},
|
||||||
|
},
|
||||||
|
MaxTokens: 512,
|
||||||
|
Temperature: 0.4,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire: ведущие system-сообщения стали system-блоками, cache_control — на
|
||||||
|
// помеченном блоке, с TTL.
|
||||||
|
sys, ok := got["system"].([]any)
|
||||||
|
if !ok || len(sys) != 2 {
|
||||||
|
t.Fatalf("system blocks = %v", got["system"])
|
||||||
|
}
|
||||||
|
first := sys[0].(map[string]any)
|
||||||
|
if _, hasCC := first["cache_control"]; hasCC {
|
||||||
|
t.Fatal("unmarked block must not carry cache_control")
|
||||||
|
}
|
||||||
|
second := sys[1].(map[string]any)
|
||||||
|
cc, ok := second["cache_control"].(map[string]any)
|
||||||
|
if !ok || cc["type"] != "ephemeral" || cc["ttl"] != "5m" {
|
||||||
|
t.Fatalf("cache_control = %v", second["cache_control"])
|
||||||
|
}
|
||||||
|
msgs, ok := got["messages"].([]any)
|
||||||
|
if !ok || len(msgs) != 1 {
|
||||||
|
t.Fatalf("messages = %v", got["messages"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage-инвариант: PromptTokens = input + cache_read + cache_creation.
|
||||||
|
if resp.Usage.PromptTokens != 100 || resp.Usage.CachedTokens != 10 || resp.Usage.CacheCreationTokens != 30 {
|
||||||
|
t.Fatalf("usage = %+v", resp.Usage)
|
||||||
|
}
|
||||||
|
if resp.Text != "перевод" || resp.FinishReason != FinishStop || resp.Model != "claude-sonnet-5" {
|
||||||
|
t.Fatalf("resp = %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicStopReasonMapping(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"end_turn": FinishStop,
|
||||||
|
"max_tokens": FinishLength,
|
||||||
|
"refusal": FinishRefusal,
|
||||||
|
"weird": "",
|
||||||
|
}
|
||||||
|
for wire, want := range cases {
|
||||||
|
if got := mapAnthropicStopReason(wire); got != want {
|
||||||
|
t.Errorf("map(%q) = %q, want %q", wire, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicTooManyBoundaries(t *testing.T) {
|
||||||
|
c := NewAnthropicClient(AnthropicConfig{BaseURL: "http://unused", APIKey: "k", Profile: fastProfile()}, nil)
|
||||||
|
msgs := make([]Message, 5)
|
||||||
|
for i := range msgs {
|
||||||
|
msgs[i] = Message{Role: "system", Content: "x", CacheBoundary: true}
|
||||||
|
}
|
||||||
|
_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: msgs, MaxTokens: 16})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("more than 4 cache boundaries must fail loud (API limit)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicJSONOnlyUnsupported(t *testing.T) {
|
||||||
|
c := NewAnthropicClient(AnthropicConfig{BaseURL: "http://unused", APIKey: "k", Profile: fastProfile()}, nil)
|
||||||
|
_, err := c.Complete(context.Background(), LLMRequest{
|
||||||
|
Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16, JSONOnly: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("JSONOnly must fail loud, not silently drop the constraint")
|
||||||
|
}
|
||||||
|
}
|
||||||
113
backend/internal/llm/provider_local.go
Normal file
113
backend/internal/llm/provider_local.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// provider_local.go is the adapter for a self-hosted OpenAI-compatible
|
||||||
|
// inference backend (ollama / llama-server on the owner's GTX 1070). Порт
|
||||||
|
// vojo provider_local.go; главное отличие — явный no-proxy транспорт.
|
||||||
|
//
|
||||||
|
// Прокси-грабли стенда (memory textmachine-local-stand + валидация): в env
|
||||||
|
// прописан webshare-прокси, NO_PROXY=<local> — виндовая нотация, которую Go
|
||||||
|
// не понимает. Go stdlib сам НЕ проксирует loopback (127.0.0.1/localhost), но
|
||||||
|
// llama-server на стенде слушает WSL-gateway-адреса вида 172.18.0.1 — такой
|
||||||
|
// запрос ушёл бы на прокси и получил 403, нога failover навсегда осталась бы
|
||||||
|
// unhealthy, молча оплачивая облако. Поэтому транспорт локального провайдера
|
||||||
|
// (и его health-пробы в failover.go) строится с Proxy: nil безусловно.
|
||||||
|
|
||||||
|
// LocalConfig configures the local backend adapter.
|
||||||
|
type LocalConfig struct {
|
||||||
|
BaseURL string // e.g. http://127.0.0.1:11434/v1 or http://172.18.0.1:11434/v1
|
||||||
|
APIKey string // usually empty — no Authorization header then
|
||||||
|
// Model is the adapter's own tag (e.g. huihui_ai/qwen3-abliterated:8b):
|
||||||
|
// the pipeline asks for role-model names that don't exist locally. The
|
||||||
|
// response's Model reports what actually served, so billing prices it at
|
||||||
|
// the local $0 entry.
|
||||||
|
Model string
|
||||||
|
// Temperature overrides the request's (ollama honours a request temperature
|
||||||
|
// over the Modelfile; passing the cloud role's value through would mistune
|
||||||
|
// the local model). 0 = keep the request's.
|
||||||
|
Temperature float64
|
||||||
|
// MaxTokens overrides the request's: ollama's cap covers thinking AND
|
||||||
|
// answer together (unlike xAI, where thinking bills on top), so a
|
||||||
|
// cloud-sized budget lets a thinking model burn it all on reasoning and
|
||||||
|
// return empty content. 0 = inherit the request's.
|
||||||
|
MaxTokens int
|
||||||
|
Profile RetryProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoProxyClient is an http.Client that bypasses any environment proxy.
|
||||||
|
// Exported so the failover prober uses the same transport discipline.
|
||||||
|
func NoProxyClient() *http.Client {
|
||||||
|
return &http.Client{Transport: &http.Transport{Proxy: nil}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type localClient struct {
|
||||||
|
http *openAIClient
|
||||||
|
model string
|
||||||
|
temp float64
|
||||||
|
maxTok int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalClient builds the local-backend adapter.
|
||||||
|
func NewLocalClient(cfg LocalConfig, logger *slog.Logger) LLMClient {
|
||||||
|
return &localClient{
|
||||||
|
http: newOpenAIClient("local", cfg.BaseURL, cfg.APIKey, cfg.Profile, nil, NoProxyClient(), logger),
|
||||||
|
model: cfg.Model,
|
||||||
|
temp: cfg.Temperature,
|
||||||
|
maxTok: cfg.MaxTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
|
||||||
|
msgs := make([]openAIMessage, len(req.Messages))
|
||||||
|
for i, m := range req.Messages {
|
||||||
|
msgs[i] = openAIMessage{Role: m.Role, Content: m.Content}
|
||||||
|
}
|
||||||
|
maxTok := req.MaxTokens
|
||||||
|
if c.maxTok > 0 {
|
||||||
|
maxTok = c.maxTok
|
||||||
|
}
|
||||||
|
temp := req.Temperature
|
||||||
|
if c.temp > 0 {
|
||||||
|
temp = c.temp
|
||||||
|
}
|
||||||
|
var respFormat any
|
||||||
|
if req.JSONOnly {
|
||||||
|
respFormat = map[string]string{"type": "json_object"}
|
||||||
|
}
|
||||||
|
effort := req.ReasoningEffort
|
||||||
|
if effort == "off" {
|
||||||
|
// ollama's /v1 maps reasoning_effort onto Qwen3 thinking; "none" is its
|
||||||
|
// explicit off-switch (verified in vojo).
|
||||||
|
effort = "none"
|
||||||
|
}
|
||||||
|
resp, err := c.http.complete(ctx, openAIRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Messages: msgs,
|
||||||
|
MaxTokens: maxTok,
|
||||||
|
Temperature: temp,
|
||||||
|
Stream: false,
|
||||||
|
ReasoningEffort: effort,
|
||||||
|
ResponseFormat: respFormat,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &LLMResponse{
|
||||||
|
Text: resp.Text(),
|
||||||
|
Usage: Usage{
|
||||||
|
PromptTokens: resp.Usage.PromptTokens,
|
||||||
|
CachedTokens: resp.Usage.cacheRead(),
|
||||||
|
CompletionTokens: resp.Usage.CompletionTokens,
|
||||||
|
// ReasoningTokens deliberately 0: ollama counts thinking INSIDE
|
||||||
|
// completion_tokens (subset semantics). It's all $0 regardless.
|
||||||
|
},
|
||||||
|
Model: c.model, // the local model answered, whatever the pipeline asked for
|
||||||
|
FinishReason: resp.FinishReason(),
|
||||||
|
ProviderRequestID: resp.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
117
backend/internal/llm/provider_openai.go
Normal file
117
backend/internal/llm/provider_openai.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// provider_openai.go is the generic adapter for every OpenAI-compatible cloud
|
||||||
|
// provider (DeepSeek, GLM/Z.AI, Kimi, xAI, ru-агрегаторы). Vojo had a
|
||||||
|
// per-vendor file each (provider_xai.go и т.п.), but their bodies were
|
||||||
|
// identical shells over the shared transport; TextMachine's differences are
|
||||||
|
// data (base URL, key, usage semantics, extra body), so one adapter
|
||||||
|
// parameterized from models.yaml replaces the family.
|
||||||
|
|
||||||
|
// ReasoningSemantics tells the adapter how a provider accounts thinking
|
||||||
|
// tokens (см. Usage.ReasoningTokens в llm.go).
|
||||||
|
type ReasoningSemantics string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ReasoningSubset: thinking is counted INSIDE completion_tokens (OpenAI
|
||||||
|
// spec, ollama, DeepSeek). ReasoningTokens stays 0 to avoid double-billing.
|
||||||
|
ReasoningSubset ReasoningSemantics = "subset"
|
||||||
|
// ReasoningAdditive: thinking is reported separately and billed ON TOP of
|
||||||
|
// completion_tokens (xAI — verified in vojo against cost_in_usd_ticks;
|
||||||
|
// dropping it undercounted Grok spend by 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
|
||||||
|
}
|
||||||
101
backend/internal/obs/logging.go
Normal file
101
backend/internal/obs/logging.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package obs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewLogger builds the process logger from the environment: LOG_LEVEL
|
||||||
|
// (debug|info|warn|error, default info) and LOG_FORMAT (text|json, default
|
||||||
|
// text). Writes to stderr with UTC timestamps.
|
||||||
|
func NewLogger() *slog.Logger {
|
||||||
|
opts := &slog.HandlerOptions{
|
||||||
|
Level: parseLogLevel(os.Getenv("LOG_LEVEL")),
|
||||||
|
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||||
|
if a.Key == slog.TimeKey && a.Value.Kind() == slog.KindTime {
|
||||||
|
a.Value = slog.TimeValue(a.Value.Time().UTC())
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var h slog.Handler
|
||||||
|
if strings.EqualFold(strings.TrimSpace(os.Getenv("LOG_FORMAT")), "json") {
|
||||||
|
h = slog.NewJSONHandler(os.Stderr, opts)
|
||||||
|
} else {
|
||||||
|
h = slog.NewTextHandler(os.Stderr, opts)
|
||||||
|
}
|
||||||
|
return slog.New(contextHandler{h})
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextHandler wraps a slog.Handler so a record logged with one of the
|
||||||
|
// *Context methods automatically gets the call's trace_id attached — the
|
||||||
|
// userver-style trail, set once at the top of a unit of work and carried
|
||||||
|
// through to the model call. Records logged without a traced context simply
|
||||||
|
// carry no trace_id.
|
||||||
|
type contextHandler struct{ slog.Handler }
|
||||||
|
|
||||||
|
func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||||
|
if id := TraceFromContext(ctx); id != "" {
|
||||||
|
r.AddAttrs(slog.String("trace_id", id))
|
||||||
|
}
|
||||||
|
return h.Handler.Handle(ctx, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAttrs/WithGroup must re-wrap, or the embedded handler's versions would
|
||||||
|
// return a bare handler and silently drop the trace_id injection for any child
|
||||||
|
// logger (типовая ошибка обёрток, поймана ещё в vojo).
|
||||||
|
func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
return contextHandler{h.Handler.WithAttrs(attrs)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h contextHandler) WithGroup(name string) slog.Handler {
|
||||||
|
return contextHandler{h.Handler.WithGroup(name)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogLLMExchange logs the raw request/response bodies of one model call at
|
||||||
|
// DEBUG, but ONLY when the call's context opted in (ReqInfo.LogBodies) —
|
||||||
|
// message content never enters the logs unless an operator explicitly enables
|
||||||
|
// it AND runs at LOG_LEVEL=debug. Only the BODIES are logged — never the URL
|
||||||
|
// or any header — so the API key cannot leak. Bodies are truncated.
|
||||||
|
func LogLLMExchange(ctx context.Context, log *slog.Logger, provider string, reqBody []byte, status int, respBody []byte) {
|
||||||
|
if log == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ri, ok := ReqInfoFromContext(ctx)
|
||||||
|
if !ok || !ri.LogBodies {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.DebugContext(ctx, "llm exchange",
|
||||||
|
"provider", provider,
|
||||||
|
"status", status,
|
||||||
|
"request", truncateForLog(reqBody, llmBodyLogMax),
|
||||||
|
"response", truncateForLog(respBody, llmBodyLogMax),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmBodyLogMax caps each logged model body: keeps a typical prompt/answer
|
||||||
|
// readable while staying bounded. A constant, not a knob — it only bounds log
|
||||||
|
// volume on an opt-in debug path.
|
||||||
|
const llmBodyLogMax = 4096
|
||||||
|
|
||||||
|
func truncateForLog(b []byte, maxBytes int) string {
|
||||||
|
if len(b) > maxBytes {
|
||||||
|
return string(b[:maxBytes]) + "…(truncated)"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLogLevel(s string) slog.Level {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "debug":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case "warn", "warning":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
default:
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/internal/obs/safego.go
Normal file
22
backend/internal/obs/safego.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package obs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SafeGo runs fn in a goroutine with panic recovery: a panic in a background
|
||||||
|
// task (async telemetry write, retention trim) logs an ERROR with the stack
|
||||||
|
// and dies alone instead of taking the process down. Вынесено из vojo bot.go —
|
||||||
|
// без него async-паттерн телеметрии не переносится.
|
||||||
|
func SafeGo(ctx context.Context, log *slog.Logger, name string, fn func()) {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.ErrorContext(ctx, "background task panicked", "task", name, "panic", r, "stack", string(debug.Stack()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
69
backend/internal/obs/trace.go
Normal file
69
backend/internal/obs/trace.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Package obs is the observability seam: trace ids threaded through context,
|
||||||
|
// the slog handler that stamps them on every log line, gated LLM-exchange
|
||||||
|
// logging, and recovered goroutines. Порт vojo trace.go/logging.go +
|
||||||
|
// вынесенный из bot.go safego; ось запроса заменена с (sender, verbose) на
|
||||||
|
// (book, chapter, chunk, stage, role) — домен перевода.
|
||||||
|
package obs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trace.go threads a per-run correlation id and the small request facts the
|
||||||
|
// logger needs through context — the userver / OpenTelemetry idiom: mint once
|
||||||
|
// at the top of a unit of work, and every log line below it (down to the HTTP
|
||||||
|
// call to the model) carries the same trace_id without passing a logger by
|
||||||
|
// hand.
|
||||||
|
//
|
||||||
|
// The id is 16 random bytes rendered as 32 hex chars — the W3C Trace-Context /
|
||||||
|
// OTel trace-id shape — so the field maps straight onto an OTel exporter later.
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const reqInfoKey ctxKey = iota
|
||||||
|
|
||||||
|
// ReqInfo is the per-call data carried in context. Zero-value fields are
|
||||||
|
// simply omitted from logs.
|
||||||
|
type ReqInfo struct {
|
||||||
|
TraceID string
|
||||||
|
Book string
|
||||||
|
Chapter int
|
||||||
|
Chunk int
|
||||||
|
Stage string // pipeline stage: draft | edit | ...
|
||||||
|
Role string // agent role: translator | editor | ...
|
||||||
|
// LogBodies gates raw LLM request/response body logging (privacy by
|
||||||
|
// default; decided once at admission so the transport just reads the flag).
|
||||||
|
LogBodies bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithReqInfo stamps the call's info onto ctx. WithTimeout/WithCancel preserve
|
||||||
|
// values, so it flows down into the model transport.
|
||||||
|
func WithReqInfo(ctx context.Context, ri ReqInfo) context.Context {
|
||||||
|
return context.WithValue(ctx, reqInfoKey, ri)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReqInfoFromContext returns the call info, if any.
|
||||||
|
func ReqInfoFromContext(ctx context.Context) (ReqInfo, bool) {
|
||||||
|
ri, ok := ctx.Value(reqInfoKey).(ReqInfo)
|
||||||
|
return ri, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraceFromContext returns the trace id, or "" when ctx carries none (startup
|
||||||
|
// paths) — the slog handler then simply omits trace_id.
|
||||||
|
func TraceFromContext(ctx context.Context) string {
|
||||||
|
if ri, ok := ReqInfoFromContext(ctx); ok {
|
||||||
|
return ri.TraceID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTraceID mints a random 16-byte id as 32 hex chars (the OTel trace-id
|
||||||
|
// shape). crypto/rand.Read never returns an error on modern Go (an entropy
|
||||||
|
// failure crashes the process rather than returning a short read).
|
||||||
|
func NewTraceID() string {
|
||||||
|
var b [16]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
50
backend/internal/pipeline/clients.go
Normal file
50
backend/internal/pipeline/clients.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/config"
|
||||||
|
"textmachine/backend/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clients.go builds LLMClients from models.yaml. One client per model name
|
||||||
|
// (a model carries its ExtraBody), lazily cached by the runner.
|
||||||
|
|
||||||
|
// BuildClient constructs the adapter for one configured model.
|
||||||
|
func BuildClient(models *config.Models, modelName string, logger *slog.Logger) (llm.LLMClient, error) {
|
||||||
|
mod, ok := models.Models[modelName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("pipeline: model %q is not defined in models.yaml", modelName)
|
||||||
|
}
|
||||||
|
prov := models.Providers[mod.Provider] // существование проверено fail-fast валидацией
|
||||||
|
|
||||||
|
switch prov.Kind {
|
||||||
|
case "openai":
|
||||||
|
return llm.NewOpenAICompatClient(llm.OpenAICompatConfig{
|
||||||
|
Name: mod.Provider,
|
||||||
|
BaseURL: prov.BaseURL,
|
||||||
|
APIKey: prov.APIKey(),
|
||||||
|
Profile: prov.Timeouts.Profile(),
|
||||||
|
Reasoning: llm.ReasoningSemantics(prov.Reasoning),
|
||||||
|
ExtraBody: mod.ExtraBody,
|
||||||
|
}, logger), nil
|
||||||
|
case "anthropic":
|
||||||
|
return llm.NewAnthropicClient(llm.AnthropicConfig{
|
||||||
|
BaseURL: prov.BaseURL,
|
||||||
|
APIKey: prov.APIKey(),
|
||||||
|
Profile: prov.Timeouts.Profile(),
|
||||||
|
CacheTTL: prov.CacheTTL,
|
||||||
|
}, logger), nil
|
||||||
|
case "local":
|
||||||
|
return llm.NewLocalClient(llm.LocalConfig{
|
||||||
|
BaseURL: prov.BaseURL,
|
||||||
|
APIKey: prov.APIKey(),
|
||||||
|
Model: prov.Model,
|
||||||
|
MaxTokens: prov.MaxTokens,
|
||||||
|
Profile: prov.Timeouts.Profile(),
|
||||||
|
}, logger), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("pipeline: provider %q has unknown kind %q", mod.Provider, prov.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
backend/internal/pipeline/render.go
Normal file
164
backend/internal/pipeline/render.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// Package pipeline is the C-core runner: deterministic prompt rendering,
|
||||||
|
// request hashing, chunk checkpoints and the stage loop. Фаза 0 — мини-раннер
|
||||||
|
// C1 (draft→edit, один чанк); циклы по главам/чанкам, гейты и эскалация
|
||||||
|
// нарастают здесь в Фазе 1 (Р2: это код раннера, не конфиг).
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/config"
|
||||||
|
"textmachine/backend/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// render.go enforces the determinism invariant (03-implementation-notes §3.1):
|
||||||
|
// the rendered prompt is a PURE function of (snapshot, committed prior
|
||||||
|
// outputs, chunk). Запрещены timestamps/UUID; никаких map-итераций — только
|
||||||
|
// фиксированный список плейсхолдеров. Проверяется тестом «два рендера
|
||||||
|
// байт-в-байт идентичны». Ломается детерминизм → ломается request-hash →
|
||||||
|
// resume пере-переводит и пере-оплачивает главу, а кэш DeepSeek (byte prefix
|
||||||
|
// match) промахивается.
|
||||||
|
|
||||||
|
// chunkerVersion versions the segmentation rules; it is part of the snapshot,
|
||||||
|
// so re-chunking a book is an explicit re-translation, not a silent cache miss
|
||||||
|
// (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в).
|
||||||
|
const chunkerVersion = "chunker-v0-wholefile"
|
||||||
|
|
||||||
|
// userSeparator splits a prompt template file into the system part and the
|
||||||
|
// user part. Обе части — версионируемые шаблоны в prompts/ («Редакция» позже
|
||||||
|
// правит файлы, не код).
|
||||||
|
const userSeparator = "\n---USER---\n"
|
||||||
|
|
||||||
|
// PromptTemplate is one loaded stage template.
|
||||||
|
type PromptTemplate struct {
|
||||||
|
System string
|
||||||
|
User string
|
||||||
|
SHA256 string // hash of the raw file — участвует в snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPromptTemplate reads and splits a template file.
|
||||||
|
func LoadPromptTemplate(path string) (*PromptTemplate, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pipeline: read prompt %s: %w", path, err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(raw)
|
||||||
|
parts := strings.SplitN(string(raw), userSeparator, 2)
|
||||||
|
t := &PromptTemplate{System: strings.TrimSpace(parts[0]), SHA256: hex.EncodeToString(sum[:])}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
t.User = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator))
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderVars are the ONLY placeholders a template may use. A fixed struct, not
|
||||||
|
// a map: map iteration order would randomize the render.
|
||||||
|
type RenderVars struct {
|
||||||
|
Book *config.Book
|
||||||
|
Text string // the source chunk
|
||||||
|
Draft string // prior stage output ("" on the first stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render substitutes placeholders. Unknown {{…}} markers are a hard error —
|
||||||
|
// молчаливо пустой плейсхолдер в промпте хуже падения.
|
||||||
|
func Render(tpl string, v RenderVars) (string, error) {
|
||||||
|
pairs := [...][2]string{
|
||||||
|
{"{{book_id}}", v.Book.BookID},
|
||||||
|
{"{{title}}", v.Book.Title},
|
||||||
|
{"{{source_lang}}", v.Book.SourceLang},
|
||||||
|
{"{{target_lang}}", v.Book.TargetLang},
|
||||||
|
{"{{genre}}", v.Book.Genre},
|
||||||
|
{"{{audience}}", v.Book.Audience},
|
||||||
|
{"{{venuti}}", strconv.FormatFloat(v.Book.Venuti, 'f', 2, 64)},
|
||||||
|
{"{{honorifics}}", v.Book.Honorifics},
|
||||||
|
{"{{transcription}}", v.Book.Transcription},
|
||||||
|
{"{{footnotes}}", v.Book.Footnotes},
|
||||||
|
{"{{text}}", v.Text},
|
||||||
|
{"{{draft}}", v.Draft},
|
||||||
|
}
|
||||||
|
out := tpl
|
||||||
|
for _, p := range pairs {
|
||||||
|
out = strings.ReplaceAll(out, p[0], p[1])
|
||||||
|
}
|
||||||
|
if i := strings.Index(out, "{{"); i >= 0 {
|
||||||
|
end := strings.Index(out[i:], "}}")
|
||||||
|
if end < 0 {
|
||||||
|
end = len(out) - i
|
||||||
|
} else {
|
||||||
|
end += 2
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("pipeline: unknown placeholder %q in template", out[i:i+end])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages builds the wire-neutral message list for a stage. Layout по Р5:
|
||||||
|
// system (стабильный префикс, кэш-граница на последнем стабильном блоке) →
|
||||||
|
// user (волатильный хвост: чанк/черновик). Инъекция глоссария/STM встанет
|
||||||
|
// между ними в Фазе 1, не меняя схемы.
|
||||||
|
func Messages(tpl *PromptTemplate, v RenderVars) ([]llm.Message, error) {
|
||||||
|
sys, err := Render(tpl.System, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user, err := Render(tpl.User, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []llm.Message{
|
||||||
|
{Role: "system", Content: sys, CacheBoundary: true},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestHash is the checkpoint/resume key (§3.1): a stable hash of everything
|
||||||
|
// that determines the call. The snapshot id freezes the volatile context, so
|
||||||
|
// identical re-renders after a restart find their checkpoint and are neither
|
||||||
|
// repeated nor re-billed.
|
||||||
|
func RequestHash(bookID string, chapter, chunkIdx int, stage, role, model string, temperature float64, reasoning string, jsonOnly bool, maxTokens int, snapshotID string, msgs []llm.Message) string {
|
||||||
|
h := sha256.New()
|
||||||
|
w := func(parts ...string) {
|
||||||
|
for _, p := range parts {
|
||||||
|
h.Write([]byte(p))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w("tm-request-v1", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), stage, role, model,
|
||||||
|
strconv.FormatFloat(temperature, 'f', -1, 64), reasoning, strconv.FormatBool(jsonOnly),
|
||||||
|
strconv.Itoa(maxTokens), snapshotID)
|
||||||
|
for _, m := range msgs {
|
||||||
|
w(m.Role, m.Content, strconv.FormatBool(m.CacheBoundary))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateTokens is a cheap, deterministic token estimate for reservation
|
||||||
|
// sizing and max_tokens derivation (NOT for billing — billing uses the API's
|
||||||
|
// usage). Калибровка полигона: иероглиф ≈0.9 ток., русский/латиница ≈3 симв.
|
||||||
|
// на токен.
|
||||||
|
func EstimateTokens(s string) int {
|
||||||
|
cjk, other := 0, 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
|
||||||
|
cjk++
|
||||||
|
case unicode.IsSpace(r):
|
||||||
|
// whitespace mostly folds into neighbouring tokens
|
||||||
|
default:
|
||||||
|
other++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
est := cjk + other/3
|
||||||
|
if est < 16 {
|
||||||
|
est = 16
|
||||||
|
}
|
||||||
|
return est
|
||||||
|
}
|
||||||
122
backend/internal/pipeline/render_test.go
Normal file
122
backend/internal/pipeline/render_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/config"
|
||||||
|
"textmachine/backend/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBook() *config.Book {
|
||||||
|
return &config.Book{
|
||||||
|
BookID: "b", Title: "T", SourceLang: "ja", TargetLang: "ru",
|
||||||
|
Genre: "ранобэ", Audience: "взрослые", Venuti: 0.6,
|
||||||
|
Honorifics: "keep", Transcription: "polivanov", Footnotes: "minimal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTemplate(t *testing.T, content string) *PromptTemplate {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "tpl.md")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tpl, err := LoadPromptTemplate(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tpl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инвариант §3.1: рендер — чистая функция; два рендера байт-в-байт идентичны.
|
||||||
|
// Без этого request-hash плывёт между запусками, resume пере-переводит и
|
||||||
|
// пере-оплачивает главу, а byte-prefix кэш DeepSeek промахивается.
|
||||||
|
func TestRenderIsDeterministic(t *testing.T) {
|
||||||
|
tpl := writeTemplate(t, "Перевод с {{source_lang}} на {{target_lang}}, жанр {{genre}}, venuti {{venuti}}.\n---USER---\nТекст: {{text}}\nЧерновик: {{draft}}")
|
||||||
|
v := RenderVars{Book: testBook(), Text: "исходник", Draft: "черновик"}
|
||||||
|
|
||||||
|
m1, err := Messages(tpl, v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
m2, err := Messages(tpl, v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(m1) != len(m2) {
|
||||||
|
t.Fatal("length differs")
|
||||||
|
}
|
||||||
|
for j := range m1 {
|
||||||
|
if m1[j] != m2[j] {
|
||||||
|
t.Fatalf("render %d differs at message %d:\n%+v\nvs\n%+v", i, j, m1[j], m2[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h1 := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1)
|
||||||
|
h2 := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1)
|
||||||
|
if h1 != h2 {
|
||||||
|
t.Fatal("request hash is not stable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestHashSensitivity(t *testing.T) {
|
||||||
|
msgs := []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u"}}
|
||||||
|
base := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", msgs)
|
||||||
|
|
||||||
|
variants := map[string]string{
|
||||||
|
"model": RequestHash("b", 1, 0, "draft", "translator", "m2", 0.3, "off", false, 1024, "snap", msgs),
|
||||||
|
"temp": RequestHash("b", 1, 0, "draft", "translator", "m", 0.4, "off", false, 1024, "snap", msgs),
|
||||||
|
"snapshot": RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap2", msgs),
|
||||||
|
"maxTokens": RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 2048, "snap", msgs),
|
||||||
|
"chunk": RequestHash("b", 1, 1, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", msgs),
|
||||||
|
"content": RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u2"}}),
|
||||||
|
}
|
||||||
|
for name, h := range variants {
|
||||||
|
if h == base {
|
||||||
|
t.Errorf("hash must change when %s changes", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конкатенация с разделителем не склеивается: ("ab","c") != ("a","bc").
|
||||||
|
a := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
|
||||||
|
[]llm.Message{{Role: "user", Content: "ab"}, {Role: "user", Content: "c"}})
|
||||||
|
b := RequestHash("b", 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
|
||||||
|
[]llm.Message{{Role: "user", Content: "a"}, {Role: "user", Content: "bc"}})
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("field separator is not injective")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderRejectsUnknownPlaceholder(t *testing.T) {
|
||||||
|
tpl := writeTemplate(t, "Привет {{nonexistent}}\n---USER---\n{{text}}")
|
||||||
|
_, err := Messages(tpl, RenderVars{Book: testBook(), Text: "x"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("unknown placeholder must fail loud, not render empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateRequiresUserSeparator(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "bad.md")
|
||||||
|
if err := os.WriteFile(path, []byte("только системная часть"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadPromptTemplate(path); err == nil {
|
||||||
|
t.Fatal("template without ---USER--- must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEstimateTokens(t *testing.T) {
|
||||||
|
// Порядок величин по калибровке полигона: иероглиф ≈1 токен, кириллица ≈3
|
||||||
|
// символа на токен. Оценка — для резервов, не для биллинга.
|
||||||
|
ja := EstimateTokens("図書館は静寂に包まれていた") // 13 CJK-рун
|
||||||
|
if ja < 10 || ja > 20 {
|
||||||
|
t.Errorf("ja estimate out of range: %d", ja)
|
||||||
|
}
|
||||||
|
ru := EstimateTokens("Библиотека была окутана тишиной") // ~30 не-пробельных
|
||||||
|
if ru < 8 || ru > 16 {
|
||||||
|
t.Errorf("ru estimate out of range: %d", ru)
|
||||||
|
}
|
||||||
|
}
|
||||||
347
backend/internal/pipeline/runner.go
Normal file
347
backend/internal/pipeline/runner.go
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/config"
|
||||||
|
"textmachine/backend/internal/ledger"
|
||||||
|
"textmachine/backend/internal/llm"
|
||||||
|
"textmachine/backend/internal/obs"
|
||||||
|
"textmachine/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runner.go: the Phase-0 mini-runner — one chunk through the configured stage
|
||||||
|
// list (C1: draft→edit) with full money discipline: reserve → call → settle+
|
||||||
|
// checkpoint (одна транзакция) → request_log. Resume: найденный чекпоинт =
|
||||||
|
// вызов не повторяется и не оплачивается. Гейты/эскалация/циклы по главам —
|
||||||
|
// Фаза 1, сюда же (код раннера).
|
||||||
|
|
||||||
|
// Runner executes a book's pipeline.
|
||||||
|
type Runner struct {
|
||||||
|
Book *config.Book
|
||||||
|
Models *config.Models
|
||||||
|
Pipeline *config.Pipeline
|
||||||
|
Store *store.Store
|
||||||
|
Pricer *ledger.Pricer
|
||||||
|
Log *slog.Logger
|
||||||
|
|
||||||
|
clients map[string]llm.LLMClient
|
||||||
|
templates map[string]*PromptTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner loads all three configs rooted at book.yaml and opens the project
|
||||||
|
// store. Caller owns Close.
|
||||||
|
func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
|
||||||
|
book, err := config.LoadBook(bookPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
models, err := config.LoadModels(book.ModelsFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pipe, err := config.LoadPipeline(book.Pipeline, models)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pricer, err := models.Prices()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st, err := store.Open(book.ProjectDB)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := &Runner{
|
||||||
|
Book: book,
|
||||||
|
Models: models,
|
||||||
|
Pipeline: pipe,
|
||||||
|
Store: st,
|
||||||
|
Pricer: pricer,
|
||||||
|
Log: logger,
|
||||||
|
clients: map[string]llm.LLMClient{},
|
||||||
|
templates: map[string]*PromptTemplate{},
|
||||||
|
}
|
||||||
|
if err := r.loadTemplates(); err != nil {
|
||||||
|
st.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Close() error { return r.Store.Close() }
|
||||||
|
|
||||||
|
func (r *Runner) loadTemplates() error {
|
||||||
|
for _, st := range r.Pipeline.Stages {
|
||||||
|
tpl, err := LoadPromptTemplate(st.Prompt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.templates[st.Name] = tpl
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) client(model string) (llm.LLMClient, error) {
|
||||||
|
if c, ok := r.clients[model]; ok {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
c, err := BuildClient(r.Models, model, r.Log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.clients[model] = c
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotID materializes the job-context snapshot (§3.2): brief_hash, chunker
|
||||||
|
// version and the full stage plan (model, prompt version + CONTENT hash,
|
||||||
|
// sampling). Payload is rendered with a fixed field order — the id is a
|
||||||
|
// content hash, so identical context re-upserts idempotently.
|
||||||
|
func (r *Runner) snapshotID() (id, payload string, err error) {
|
||||||
|
type stageSnap struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
PromptVersion string `json:"prompt_version"`
|
||||||
|
PromptSHA256 string `json:"prompt_sha256"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
}
|
||||||
|
snap := struct {
|
||||||
|
BriefHash string `json:"brief_hash"`
|
||||||
|
ChunkerVersion string `json:"chunker_version"`
|
||||||
|
PipelineCore string `json:"pipeline_core"`
|
||||||
|
Stages []stageSnap `json:"stages"`
|
||||||
|
}{
|
||||||
|
BriefHash: r.Book.BriefHash(),
|
||||||
|
ChunkerVersion: chunkerVersion,
|
||||||
|
PipelineCore: r.Pipeline.Core,
|
||||||
|
}
|
||||||
|
for _, st := range r.Pipeline.Stages {
|
||||||
|
snap.Stages = append(snap.Stages, stageSnap{
|
||||||
|
Name: st.Name, Role: st.Role, Model: st.Model,
|
||||||
|
PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256,
|
||||||
|
Temperature: st.Temperature, Reasoning: st.Reasoning,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(snap)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:]), string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StageResult reports one executed stage.
|
||||||
|
type StageResult struct {
|
||||||
|
Stage string
|
||||||
|
Role string
|
||||||
|
Model string // фактически ответившая модель
|
||||||
|
FromResume bool // served from a checkpoint, no call made
|
||||||
|
Usage llm.Usage
|
||||||
|
CostUSD float64
|
||||||
|
LatencyMS int
|
||||||
|
FinishReason string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult is the whole chunk's outcome.
|
||||||
|
type RunResult struct {
|
||||||
|
BookID string
|
||||||
|
Chapter int
|
||||||
|
ChunkIdx int
|
||||||
|
Stages []StageResult
|
||||||
|
FinalText string
|
||||||
|
TotalUSD float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslateOneChunk runs the configured stages over the book's source file as
|
||||||
|
// a single chunk (Фаза 0). Chapter/chunk are fixed at 1/0 until the chunker
|
||||||
|
// lands in Phase 1.
|
||||||
|
func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) {
|
||||||
|
srcRaw, err := os.ReadFile(r.Book.SourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pipeline: read source: %w", err)
|
||||||
|
}
|
||||||
|
source := strings.TrimSpace(string(srcRaw))
|
||||||
|
if source == "" {
|
||||||
|
return nil, fmt.Errorf("pipeline: source file %s is empty", r.Book.SourceFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapID, snapPayload, err := r.snapshotID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.Store.UpsertSnapshot(snapID, r.Book.BriefHash(), snapPayload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapter, chunkIdx = 1, 0
|
||||||
|
res := &RunResult{BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx}
|
||||||
|
prev := ""
|
||||||
|
|
||||||
|
for _, st := range r.Pipeline.Stages {
|
||||||
|
sr, err := r.runStage(ctx, st, snapID, chapter, chunkIdx, source, prev)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.Stages = append(res.Stages, *sr)
|
||||||
|
res.TotalUSD += sr.CostUSD
|
||||||
|
prev = sr.Text
|
||||||
|
}
|
||||||
|
res.FinalText = prev
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, chapter, chunkIdx int, source, prev string) (*StageResult, error) {
|
||||||
|
job, err := r.Store.EnsureJob(r.Book.BookID, chapter, st.Name, snapID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.Store.SetJobStatus(job.ID, "running"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: source, Draft: prev})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// max_tokens: калибровка полигона — русский выход ≈1.9× входа в токенах;
|
||||||
|
// ratio из конфига, floor защищает короткие чанки. Входит в request-hash
|
||||||
|
// (меняет вызов), поэтому считается детерминированно от исходника.
|
||||||
|
inputEst := EstimateTokens(source)
|
||||||
|
maxTokens := int(float64(inputEst) * r.Pipeline.Defaults.MaxOutputRatio)
|
||||||
|
if maxTokens < r.Pipeline.Defaults.MinMaxTokens {
|
||||||
|
maxTokens = r.Pipeline.Defaults.MinMaxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
reqHash := RequestHash(r.Book.BookID, chapter, chunkIdx, st.Name, st.Role, st.Model,
|
||||||
|
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
|
||||||
|
|
||||||
|
ctx = obs.WithReqInfo(ctx, obs.ReqInfo{
|
||||||
|
TraceID: obs.TraceFromContext(ctx), Book: r.Book.BookID,
|
||||||
|
Chapter: chapter, Chunk: chunkIdx, Stage: st.Name, Role: st.Role,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resume path: a checkpoint means the call already happened and was paid —
|
||||||
|
// serve it, log the hit, never re-bill (приёмка: kill -9 теряет ≤1 вызов).
|
||||||
|
if cp, err := r.Store.GetCheckpoint(reqHash); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if cp != nil {
|
||||||
|
var usage llm.Usage
|
||||||
|
_ = json.Unmarshal([]byte(cp.UsageJSON), &usage)
|
||||||
|
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
|
||||||
|
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
|
||||||
|
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: cp.ModelActual,
|
||||||
|
RequestHash: reqHash, TMHit: true, OK: true, FinishReason: cp.FinishReason,
|
||||||
|
})
|
||||||
|
if err := r.Store.SetJobStatus(job.ID, "done"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.Log.InfoContext(ctx, "stage served from checkpoint", "stage", st.Name, "hash", reqHash[:12])
|
||||||
|
return &StageResult{Stage: st.Name, Role: st.Role, Model: cp.ModelActual,
|
||||||
|
FromResume: true, Usage: usage, CostUSD: 0, FinishReason: cp.FinishReason, Text: cp.ResponseText}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
price := r.Pricer.PriceFor(st.Model)
|
||||||
|
promptEst := 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
promptEst += EstimateTokens(m.Content)
|
||||||
|
}
|
||||||
|
estimate := ledger.EstimateUSD(price, promptEst, maxTokens)
|
||||||
|
|
||||||
|
resv, verdict, err := r.Store.Reserve(r.Book.BookID, estimate, store.Ceilings{
|
||||||
|
BookUSD: r.Book.Ceilings.BookUSD, DayUSD: r.Book.Ceilings.DayUSD,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch verdict {
|
||||||
|
case store.ReserveDeniedBook:
|
||||||
|
return nil, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop", r.Book.Ceilings.BookUSD)
|
||||||
|
case store.ReserveDeniedDay:
|
||||||
|
return nil, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := r.client(st.Model)
|
||||||
|
if err != nil {
|
||||||
|
_ = r.Store.Release(resv)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Complete(ctx, llm.LLMRequest{
|
||||||
|
Model: st.Model,
|
||||||
|
Messages: msgs,
|
||||||
|
MaxTokens: maxTokens,
|
||||||
|
Temperature: st.Temperature,
|
||||||
|
ReasoningEffort: st.Reasoning,
|
||||||
|
})
|
||||||
|
latency := int(time.Since(start).Milliseconds())
|
||||||
|
if err != nil {
|
||||||
|
// No 2xx ever arrived: nothing was billed by the provider — release the
|
||||||
|
// reservation and surface the failure with a telemetry row.
|
||||||
|
if rerr := r.Store.Release(resv); rerr != nil {
|
||||||
|
r.Log.ErrorContext(ctx, "release after failed call also failed", "err", rerr)
|
||||||
|
}
|
||||||
|
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
|
||||||
|
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
|
||||||
|
Stage: st.Name, Role: st.Role, ModelRequested: st.Model,
|
||||||
|
RequestHash: reqHash, LatencyMS: latency, Err: err.Error(), OK: false,
|
||||||
|
})
|
||||||
|
_ = r.Store.SetJobStatus(job.ID, "failed")
|
||||||
|
return nil, fmt.Errorf("pipeline: stage %s call: %w", st.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modelActual := resp.Model
|
||||||
|
if modelActual == "" {
|
||||||
|
modelActual = st.Model
|
||||||
|
}
|
||||||
|
cost := ledger.CostUSD(r.Pricer.PriceFor(modelActual), resp.Usage)
|
||||||
|
usageJSON, err := json.Marshal(resp.Usage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Деньги: settle + сырой ответ — одна транзакция (§3.3).
|
||||||
|
if err := r.Store.SettleWithCheckpoint(resv, cost, store.Checkpoint{
|
||||||
|
RequestHash: reqHash, JobID: job.ID, ChunkIdx: chunkIdx, Stage: st.Name, Role: st.Role,
|
||||||
|
ModelRequested: st.Model, ModelActual: modelActual, ResponseText: resp.Text,
|
||||||
|
UsageJSON: string(usageJSON), CostUSD: cost, FinishReason: resp.FinishReason,
|
||||||
|
ProviderRequestID: resp.ProviderRequestID,
|
||||||
|
}); err != nil {
|
||||||
|
// The provider HAS billed this 2xx; failing to persist means the money
|
||||||
|
// state is behind reality — fail loud, never continue on top.
|
||||||
|
return nil, fmt.Errorf("pipeline: settle stage %s: %w", st.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
|
||||||
|
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
|
||||||
|
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: modelActual,
|
||||||
|
RequestHash: reqHash,
|
||||||
|
PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.CachedTokens,
|
||||||
|
CacheCreationTokens: resp.Usage.CacheCreationTokens,
|
||||||
|
CompletionTokens: resp.Usage.CompletionTokens, ReasoningTokens: resp.Usage.ReasoningTokens,
|
||||||
|
CostUSD: cost, LatencyMS: latency, FinishReason: resp.FinishReason, OK: true,
|
||||||
|
})
|
||||||
|
if err := r.Store.SetJobStatus(job.ID, "done"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.Log.InfoContext(ctx, "stage completed", "stage", st.Name, "model", modelActual,
|
||||||
|
"cost_usd", fmt.Sprintf("%.6f", cost), "latency_ms", latency,
|
||||||
|
"prompt_tokens", resp.Usage.PromptTokens, "completion_tokens", resp.Usage.CompletionTokens)
|
||||||
|
|
||||||
|
return &StageResult{Stage: st.Name, Role: st.Role, Model: modelActual,
|
||||||
|
Usage: resp.Usage, CostUSD: cost, LatencyMS: latency,
|
||||||
|
FinishReason: resp.FinishReason, Text: resp.Text}, nil
|
||||||
|
}
|
||||||
197
backend/internal/pipeline/runner_test.go
Normal file
197
backend/internal/pipeline/runner_test.go
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/obs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// e2e-тест мини-раннера: temp-проект книги против httptest-провайдера. Деньги,
|
||||||
|
// чекпоинты и resume проверяются через реальные конфиги/store — то, что
|
||||||
|
// демонстрирует приёмка Фазы 0, только на моке.
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFakeProvider serves OpenAI-совместимые ответы: draft-стадии отвечает
|
||||||
|
// «ЧЕРНОВИК», edit-стадии — «РЕДАКТУРА» (различает по наличию слова ЧЕРНОВИК
|
||||||
|
// в промпте редактора). Считает вызовы.
|
||||||
|
func newFakeProvider(t *testing.T, calls *atomic.Int32) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
text := "ЧЕРНОВИК ПЕРЕВОДА"
|
||||||
|
if bytes.Contains(body, []byte("Черновик перевода для редактуры")) {
|
||||||
|
text = "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"id":"fake-%d","model":"fake-model","choices":[{"message":{"content":"%s"},"finish_reason":"stop"}],
|
||||||
|
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`,
|
||||||
|
calls.Load(), text)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupProject(t *testing.T, providerURL string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(dir, "prompts", "translator.md"),
|
||||||
|
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
|
||||||
|
writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
|
||||||
|
"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
|
||||||
|
prices_checked: %q
|
||||||
|
default_model: fake-model
|
||||||
|
providers:
|
||||||
|
fake:
|
||||||
|
kind: openai
|
||||||
|
base_url: %q
|
||||||
|
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
|
||||||
|
models:
|
||||||
|
fake-model:
|
||||||
|
provider: fake
|
||||||
|
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
|
||||||
|
`, time.Now().UTC().Format("2006-01-02"), providerURL))
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(dir, "pipeline.yaml"), `
|
||||||
|
core: C1
|
||||||
|
version: 1
|
||||||
|
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
|
||||||
|
retries: { regenerate_before_escalate: 1 }
|
||||||
|
stages:
|
||||||
|
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off" }
|
||||||
|
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
|
||||||
|
`)
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。")
|
||||||
|
writeFile(t, filepath.Join(dir, "book.yaml"), `
|
||||||
|
book_id: test-book
|
||||||
|
title: Тест
|
||||||
|
source_lang: ja
|
||||||
|
target_lang: ru
|
||||||
|
genre: ранобэ
|
||||||
|
audience: тест
|
||||||
|
venuti: 0.5
|
||||||
|
honorifics: keep
|
||||||
|
transcription: polivanov
|
||||||
|
footnotes: minimal
|
||||||
|
pipeline: pipeline.yaml
|
||||||
|
models: models.yaml
|
||||||
|
source_file: source.txt
|
||||||
|
ceilings: { book_usd: 1.0, day_usd: 2.0 }
|
||||||
|
`)
|
||||||
|
return filepath.Join(dir, "book.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerEndToEndWithResume(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := newFakeProvider(t, &calls)
|
||||||
|
defer srv.Close()
|
||||||
|
bookPath := setupProject(t, srv.URL)
|
||||||
|
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||||||
|
|
||||||
|
// Прогон 1: два вызова (draft, edit), деньги учтены.
|
||||||
|
r1, err := NewRunner(bookPath, obs.NewLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res1, err := r1.TranslateOneChunk(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("expected 2 provider calls, got %d", calls.Load())
|
||||||
|
}
|
||||||
|
if len(res1.Stages) != 2 || res1.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
|
||||||
|
t.Fatalf("run1 = %+v", res1)
|
||||||
|
}
|
||||||
|
// Стоимость по формуле: (1000-200)*1 + 200*0.1 + 500*2 за 1M — на стадию.
|
||||||
|
wantStage := (800*1.0 + 200*0.1 + 500*2.0) / 1e6
|
||||||
|
if diff := res1.TotalUSD - 2*wantStage; diff > 1e-12 || diff < -1e-12 {
|
||||||
|
t.Fatalf("total = %v, want %v", res1.TotalUSD, 2*wantStage)
|
||||||
|
}
|
||||||
|
committed, reserved, err := r1.Store.SpentUSD("test-book")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reserved != 0 || committed != res1.TotalUSD {
|
||||||
|
t.Fatalf("ledger: committed=%v reserved=%v want committed=%v", committed, reserved, res1.TotalUSD)
|
||||||
|
}
|
||||||
|
r1.Close()
|
||||||
|
|
||||||
|
// Прогон 2 (новый процесс — новый Runner над тем же project.db): оба
|
||||||
|
// вызова обслуживаются чекпоинтами, провайдер не трогается, доплаты нет.
|
||||||
|
r2, err := NewRunner(bookPath, obs.NewLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer r2.Close()
|
||||||
|
res2, err := r2.TranslateOneChunk(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("resume must not call the provider again, calls=%d", calls.Load())
|
||||||
|
}
|
||||||
|
for _, st := range res2.Stages {
|
||||||
|
if !st.FromResume || st.CostUSD != 0 {
|
||||||
|
t.Fatalf("stage %s must be served from checkpoint at $0: %+v", st.Stage, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res2.FinalText != res1.FinalText {
|
||||||
|
t.Fatalf("resume text differs: %q vs %q", res2.FinalText, res1.FinalText)
|
||||||
|
}
|
||||||
|
committed2, _, err := r2.Store.SpentUSD("test-book")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if committed2 != committed {
|
||||||
|
t.Fatalf("resume must not add spend: %v -> %v", committed, committed2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerCeilingDenies(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := newFakeProvider(t, &calls)
|
||||||
|
defer srv.Close()
|
||||||
|
bookPath := setupProject(t, srv.URL)
|
||||||
|
|
||||||
|
// Потолок $0: первый же reserve обязан отказать ДО вызова провайдера.
|
||||||
|
raw, err := os.ReadFile(bookPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
patched := strings.Replace(string(raw), "book_usd: 1.0", "book_usd: 0.0000001", 1)
|
||||||
|
writeFile(t, bookPath, patched)
|
||||||
|
|
||||||
|
r, err := NewRunner(bookPath, obs.NewLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
_, err = r.TranslateOneChunk(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ceiling must deny the run")
|
||||||
|
}
|
||||||
|
if calls.Load() != 0 {
|
||||||
|
t.Fatalf("denied reserve must not reach the provider, calls=%d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/internal/store/jobs.go
Normal file
73
backend/internal/store/jobs.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
115
backend/internal/store/kill9_test.go
Normal file
115
backend/internal/store/kill9_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kill9_test.go — приёмочный тест Р6/Фазы 0: kill -9 посреди работы теряет
|
||||||
|
// максимум один in-flight вызов и НИКОГДА не рассинхронизирует деньги с
|
||||||
|
// чекпоинтами. Настоящий SIGKILL по подпроцессу (helper-process паттерн
|
||||||
|
// стандартной библиотеки), не эмуляция.
|
||||||
|
//
|
||||||
|
// Инвариант после любого убийства: committed_usd == SUM(checkpoints.cost_usd)
|
||||||
|
// — потому что settle и чекпоинт пишутся ОДНОЙ транзакцией. Осиротевшие
|
||||||
|
// reserved_usd снимаются recovery-проходом при открытии.
|
||||||
|
|
||||||
|
const killLoopEnv = "TM_STORE_KILL_LOOP_DB"
|
||||||
|
|
||||||
|
func TestHelperKillLoop(t *testing.T) {
|
||||||
|
path := os.Getenv(killLoopEnv)
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("helper process only")
|
||||||
|
}
|
||||||
|
s, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "helper open:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := s.UpsertSnapshot("snap", "brief", `{}`); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
job, err := s.EnsureJob("book", 1, "draft", "snap")
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Бесконечный цикл reserve → settle+checkpoint; родитель убьёт нас SIGKILL
|
||||||
|
// в случайный момент. Никаких Release — «упавшие» резервы снимет recovery.
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
res, verdict, err := s.Reserve("book", 0.002, Ceilings{BookUSD: 10_000})
|
||||||
|
if err != nil || verdict != ReserveOK {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cp := Checkpoint{
|
||||||
|
RequestHash: fmt.Sprintf("hash-%d-%d", os.Getpid(), i),
|
||||||
|
JobID: job.ID, ChunkIdx: i, Stage: "draft", Role: "translator",
|
||||||
|
ModelRequested: "m", ModelActual: "m",
|
||||||
|
ResponseText: "ответ", UsageJSON: "{}", CostUSD: 0.001,
|
||||||
|
}
|
||||||
|
if err := s.SettleWithCheckpoint(res, 0.001, cp); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKillMinus9LosesAtMostOneCall(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("subprocess test")
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "kill9.db")
|
||||||
|
|
||||||
|
for round := 0; round < 3; round++ {
|
||||||
|
cmd := exec.Command(exe, "-test.run", "^TestHelperKillLoop$", "-test.v")
|
||||||
|
cmd.Env = append(os.Environ(), killLoopEnv+"="+path)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Даём поработать случайное время, затем SIGKILL — никакого graceful.
|
||||||
|
time.Sleep(time.Duration(150+rand.Intn(400)) * time.Millisecond)
|
||||||
|
if err := cmd.Process.Signal(syscall.SIGKILL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = cmd.Wait()
|
||||||
|
|
||||||
|
s, err := Open(path) // recovery-проход снимает зависшие резервы
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("round %d: reopen after kill: %v", round, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkpointSum float64
|
||||||
|
var checkpointCount int
|
||||||
|
if err := s.r.QueryRow(`SELECT COALESCE(SUM(cost_usd),0), COUNT(*) FROM checkpoints`).Scan(&checkpointSum, &checkpointCount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
committed, reserved, err := s.SpentUSD("book")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
|
||||||
|
if reserved != 0 {
|
||||||
|
t.Fatalf("round %d: recovery must clear reservations, reserved=%v", round, reserved)
|
||||||
|
}
|
||||||
|
// Ядро приёмки: деньги и чекпоинты атомарны — расхождение значило бы
|
||||||
|
// оплаченный, но потерянный (или бесплатный, но записанный) вызов.
|
||||||
|
if math.Abs(committed-checkpointSum) > 1e-9 {
|
||||||
|
t.Fatalf("round %d: committed=%v != sum(checkpoints)=%v over %d checkpoints — settle+checkpoint not atomic",
|
||||||
|
round, committed, checkpointSum, checkpointCount)
|
||||||
|
}
|
||||||
|
if checkpointCount == 0 && round > 0 {
|
||||||
|
t.Fatalf("round %d: helper made no progress", round)
|
||||||
|
}
|
||||||
|
t.Logf("round %d: %d checkpoints, committed=$%.6f — consistent after SIGKILL", round, checkpointCount, committed)
|
||||||
|
}
|
||||||
|
}
|
||||||
198
backend/internal/store/ledger.go
Normal file
198
backend/internal/store/ledger.go
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ledger.go: reserve-before-call / settle-after money discipline (порт
|
||||||
|
// семантики vojo Reserve/Settle/ReleaseReservation на SQLite). Потолки — на
|
||||||
|
// книгу (суммарно) и на день (Р7); проверяются по committed + reserved под
|
||||||
|
// immediate-транзакцией write-пула, так что конкурентные вызовы не
|
||||||
|
// проскакивают потолок больше чем на одну максимальную резервацию.
|
||||||
|
|
||||||
|
// ReserveResult is the outcome of a pre-call admission check.
|
||||||
|
type ReserveResult int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReserveOK ReserveResult = iota
|
||||||
|
ReserveDeniedBook // per-book USD ceiling hit
|
||||||
|
ReserveDeniedDay // daily USD ceiling hit
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reservation is the handle Settle/Release need to undo the admission.
|
||||||
|
type Reservation struct {
|
||||||
|
BookID string
|
||||||
|
Date string // UTC day the reservation was booked under
|
||||||
|
Estimate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ceilings are the admission limits. Zero means "no limit" for that axis —
|
||||||
|
// fail-fast in config forbids an all-zero pair for real runs.
|
||||||
|
type Ceilings struct {
|
||||||
|
BookUSD float64
|
||||||
|
DayUSD float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayUTC() string { return time.Now().UTC().Format("2006-01-02") }
|
||||||
|
|
||||||
|
// Reserve books estimate USD against the ceilings BEFORE the call. On success
|
||||||
|
// the estimate is added to reserved_usd; Settle converts it to committed
|
||||||
|
// spend, Release returns it. Denials are results, not errors.
|
||||||
|
func (s *Store) Reserve(bookID string, estimate float64, c Ceilings) (Reservation, ReserveResult, error) {
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
day := todayUTC()
|
||||||
|
res := Reservation{BookID: bookID, Date: day, Estimate: estimate}
|
||||||
|
|
||||||
|
tx, err := s.w.BeginTx(ctx, nil) // write pool: immediate tx, serialized
|
||||||
|
if err != nil {
|
||||||
|
return res, ReserveOK, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var bookTotal, dayTotal float64
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(SUM(committed_usd + reserved_usd), 0) FROM spend WHERE book_id = ?`, bookID,
|
||||||
|
).Scan(&bookTotal); err != nil {
|
||||||
|
return res, ReserveOK, err
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(SUM(committed_usd + reserved_usd), 0) FROM spend WHERE date = ?`, day,
|
||||||
|
).Scan(&dayTotal); err != nil {
|
||||||
|
return res, ReserveOK, err
|
||||||
|
}
|
||||||
|
if c.BookUSD > 0 && bookTotal+estimate > c.BookUSD {
|
||||||
|
return res, ReserveDeniedBook, nil
|
||||||
|
}
|
||||||
|
if c.DayUSD > 0 && dayTotal+estimate > c.DayUSD {
|
||||||
|
return res, ReserveDeniedDay, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO spend (book_id, date, reserved_usd) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT (book_id, date) DO UPDATE SET reserved_usd = reserved_usd + excluded.reserved_usd`,
|
||||||
|
bookID, day, estimate); err != nil {
|
||||||
|
return res, ReserveOK, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return res, ReserveOK, err
|
||||||
|
}
|
||||||
|
return res, ReserveOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release frees a reservation whose call produced no billable spend (transport
|
||||||
|
// exhaustion, terminal 4xx before a 2xx). MAX(0, …) guards a double-release.
|
||||||
|
func (s *Store) Release(res Reservation) error {
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
_, err := s.w.ExecContext(ctx,
|
||||||
|
`UPDATE spend SET reserved_usd = MAX(0, reserved_usd - ?) WHERE book_id = ? AND date = ?`,
|
||||||
|
res.Estimate, res.BookID, res.Date)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkpoint is one persisted raw LLM response (см. migrate.go v1).
|
||||||
|
type Checkpoint struct {
|
||||||
|
RequestHash string
|
||||||
|
JobID int64
|
||||||
|
ChunkIdx int
|
||||||
|
Stage string
|
||||||
|
Role string
|
||||||
|
ModelRequested string
|
||||||
|
ModelActual string
|
||||||
|
ResponseText string
|
||||||
|
UsageJSON string
|
||||||
|
CostUSD float64
|
||||||
|
FinishReason string
|
||||||
|
ProviderRequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettleWithCheckpoint atomically (ONE transaction, one file) converts the
|
||||||
|
// reservation into committed spend AND persists the raw response. Это
|
||||||
|
// закрытие дыры «settle прошёл — kill -9 — чекпоинт не записан = двойная
|
||||||
|
// оплата» (implementation-notes §3.3): после рестарта либо есть и списание, и
|
||||||
|
// чекпоинт (вызов не повторится), либо нет ни того ни другого (recovery
|
||||||
|
// снимет резерв, вызов повторится и оплатится один раз). Неустранимая потеря
|
||||||
|
// — только in-flight вызов, что и есть честные «≤1 вызов» приёмки.
|
||||||
|
//
|
||||||
|
// Idempotent per request_hash: a duplicate settle for the same hash books
|
||||||
|
// nothing and keeps the original checkpoint.
|
||||||
|
func (s *Store) SettleWithCheckpoint(res Reservation, cost float64, cp Checkpoint) error {
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tx, err := s.w.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
ins, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO checkpoints (
|
||||||
|
request_hash, job_id, chunk_idx, stage, role,
|
||||||
|
model_requested, model_actual, response_text, usage_json,
|
||||||
|
cost_usd, finish_reason, provider_request_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (request_hash) DO NOTHING`,
|
||||||
|
cp.RequestHash, cp.JobID, cp.ChunkIdx, cp.Stage, cp.Role,
|
||||||
|
cp.ModelRequested, cp.ModelActual, cp.ResponseText, cp.UsageJSON,
|
||||||
|
cp.CostUSD, cp.FinishReason, cp.ProviderRequestID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: checkpoint insert: %w", err)
|
||||||
|
}
|
||||||
|
inserted, _ := ins.RowsAffected()
|
||||||
|
if inserted == 0 {
|
||||||
|
// Duplicate settle (retried caller): release the reservation, book no
|
||||||
|
// new spend — the original settle already did.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE spend SET reserved_usd = MAX(0, reserved_usd - ?) WHERE book_id = ? AND date = ?`,
|
||||||
|
res.Estimate, res.BookID, res.Date); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO spend (book_id, date, committed_usd, reserved_usd) VALUES (?, ?, ?, 0)
|
||||||
|
ON CONFLICT (book_id, date) DO UPDATE SET
|
||||||
|
committed_usd = committed_usd + excluded.committed_usd,
|
||||||
|
reserved_usd = MAX(0, reserved_usd - ?)`,
|
||||||
|
res.BookID, res.Date, cost, res.Estimate); err != nil {
|
||||||
|
return fmt.Errorf("store: settle spend: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckpoint returns the persisted response for a request hash, if any —
|
||||||
|
// the resume path: a hit means the call is NOT repeated or re-billed.
|
||||||
|
func (s *Store) GetCheckpoint(requestHash string) (*Checkpoint, error) {
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
cp := Checkpoint{RequestHash: requestHash}
|
||||||
|
err := s.r.QueryRowContext(ctx, `
|
||||||
|
SELECT job_id, chunk_idx, stage, role, model_requested, model_actual,
|
||||||
|
response_text, usage_json, cost_usd, finish_reason, provider_request_id
|
||||||
|
FROM checkpoints WHERE request_hash = ?`, requestHash).Scan(
|
||||||
|
&cp.JobID, &cp.ChunkIdx, &cp.Stage, &cp.Role, &cp.ModelRequested, &cp.ModelActual,
|
||||||
|
&cp.ResponseText, &cp.UsageJSON, &cp.CostUSD, &cp.FinishReason, &cp.ProviderRequestID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpentUSD reports (committed, reserved) for a book across all days.
|
||||||
|
func (s *Store) SpentUSD(bookID string) (committed, reserved float64, err error) {
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
err = s.r.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(SUM(committed_usd),0), COALESCE(SUM(reserved_usd),0) FROM spend WHERE book_id = ?`,
|
||||||
|
bookID).Scan(&committed, &reserved)
|
||||||
|
return
|
||||||
|
}
|
||||||
133
backend/internal/store/migrate.go
Normal file
133
backend/internal/store/migrate.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrations are applied in order; schema_version records the highest applied
|
||||||
|
// version so re-runs are no-ops. Every step is also idempotent (CREATE TABLE
|
||||||
|
// IF NOT EXISTS) so a half-applied database still converges. Append-only:
|
||||||
|
// never edit an earlier migration (дисциплина vojo).
|
||||||
|
var migrations = []string{
|
||||||
|
// v1: the Phase-0 operational schema.
|
||||||
|
`
|
||||||
|
-- Snapshot контекста джобы (Р6, implementation-notes §3.2): материализованная
|
||||||
|
-- запись; snapshot_id входит в request-hash, так что волатильность контекста
|
||||||
|
-- заморожена на джобу. payload — JSON (версии промптов per-role, модели,
|
||||||
|
-- версия чанкера, style sheet, резюме на момент старта).
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshots (
|
||||||
|
snapshot_id TEXT PRIMARY KEY,
|
||||||
|
brief_hash TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Durable jobs: джоба = глава×стадия (Р6). Гранулярность СОХРАННОСТИ — чанк
|
||||||
|
-- (checkpoints), джоба лишь группирует их и несёт snapshot_id.
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
book_id TEXT NOT NULL,
|
||||||
|
chapter INTEGER NOT NULL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed
|
||||||
|
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE (book_id, chapter, stage)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Чанк-чекпоинты: сырой ответ LLM персистится атомарно вместе с settle
|
||||||
|
-- (одна транзакция — SettleWithCheckpoint), ключ — request_hash от
|
||||||
|
-- детерминированного рендера (§3.1). kill -9 теряет максимум один
|
||||||
|
-- in-flight вызов. Найденный чекпоинт на resume = вызов не повторяется и
|
||||||
|
-- не оплачивается второй раз.
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||||
|
request_hash TEXT PRIMARY KEY,
|
||||||
|
job_id INTEGER NOT NULL REFERENCES jobs(id),
|
||||||
|
chunk_idx INTEGER NOT NULL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
model_requested TEXT NOT NULL,
|
||||||
|
model_actual TEXT NOT NULL,
|
||||||
|
response_text TEXT NOT NULL,
|
||||||
|
usage_json TEXT NOT NULL,
|
||||||
|
cost_usd REAL NOT NULL,
|
||||||
|
finish_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
provider_request_id TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS checkpoints_job_idx ON checkpoints (job_id, chunk_idx);
|
||||||
|
|
||||||
|
-- Дневной/книжный ledger с резервированием (порт семантики vojo spend):
|
||||||
|
-- потолки считают committed + reserved на допуске, поэтому burst
|
||||||
|
-- конкурентных вызовов не проскакивает потолок (TOCTOU-дисциплина).
|
||||||
|
CREATE TABLE IF NOT EXISTS spend (
|
||||||
|
book_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL, -- UTC YYYY-MM-DD
|
||||||
|
committed_usd REAL NOT NULL DEFAULT 0,
|
||||||
|
reserved_usd REAL NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (book_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- request_log: одна строка на LLM-вызов (Р7: per-книга/глава/чанк/стадия/
|
||||||
|
-- роль/модель, $, latency, cache-поля). Телеметрия, не источник денег —
|
||||||
|
-- деньги живут в spend/checkpoints.
|
||||||
|
CREATE TABLE IF NOT EXISTS request_log (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
trace_id TEXT NOT NULL DEFAULT '',
|
||||||
|
book_id TEXT NOT NULL,
|
||||||
|
chapter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
chunk_idx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stage TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT '',
|
||||||
|
model_requested TEXT NOT NULL DEFAULT '',
|
||||||
|
model_actual TEXT NOT NULL DEFAULT '',
|
||||||
|
request_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cost_usd REAL NOT NULL DEFAULT 0,
|
||||||
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
finish_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
tm_hit INTEGER NOT NULL DEFAULT 0, -- ответ взят из чекпоинта, вызова не было
|
||||||
|
degraded TEXT NOT NULL DEFAULT '',
|
||||||
|
err TEXT NOT NULL DEFAULT '',
|
||||||
|
ok INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts);
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrate runs all pending migrations on the write pool, one transaction per
|
||||||
|
// step, recording each in schema_version.
|
||||||
|
func (s *Store) migrate(ctx context.Context) error {
|
||||||
|
if _, err := s.w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
|
||||||
|
return fmt.Errorf("store: schema_version: %w", err)
|
||||||
|
}
|
||||||
|
var current int
|
||||||
|
if err := s.w.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤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
|
||||||
|
}
|
||||||
89
backend/internal/store/requestlog.go
Normal file
89
backend/internal/store/requestlog.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"textmachine/backend/internal/obs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestLog is one telemetry row (Р7: маршрут, токены с cache-полями, $,
|
||||||
|
// latency per книга/глава/чанк/стадия/роль/модель). Telemetry is strictly off
|
||||||
|
// the money path: деньги живут в spend/checkpoints, а сбой записи телеметрии
|
||||||
|
// логируется и никогда не роняет перевод.
|
||||||
|
type RequestLog struct {
|
||||||
|
TraceID string
|
||||||
|
BookID string
|
||||||
|
Chapter int
|
||||||
|
ChunkIdx int
|
||||||
|
Stage string
|
||||||
|
Role string
|
||||||
|
ModelRequested string
|
||||||
|
ModelActual string
|
||||||
|
RequestHash string
|
||||||
|
PromptTokens int
|
||||||
|
CachedTokens int
|
||||||
|
CacheCreationTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
ReasoningTokens int
|
||||||
|
CostUSD float64
|
||||||
|
LatencyMS int
|
||||||
|
FinishReason string
|
||||||
|
TMHit bool // served from a checkpoint, no call was made
|
||||||
|
Degraded string
|
||||||
|
Err string
|
||||||
|
OK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertRequestLog writes one telemetry row synchronously (a CLI pipeline has
|
||||||
|
// no answer-latency to protect, unlike vojo's chat path; the async+recover
|
||||||
|
// pattern returns if this ever sits on a hot path). Errors are the caller's to
|
||||||
|
// log as WARN — never to fail the pipeline on.
|
||||||
|
func (s *Store) InsertRequestLog(rl RequestLog) error {
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
_, err := s.w.ExecContext(ctx, `
|
||||||
|
INSERT INTO request_log (
|
||||||
|
trace_id, book_id, chapter, chunk_idx, stage, role,
|
||||||
|
model_requested, model_actual, request_hash,
|
||||||
|
prompt_tokens, cached_tokens, cache_creation_tokens,
|
||||||
|
completion_tokens, reasoning_tokens,
|
||||||
|
cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
rl.TraceID, rl.BookID, rl.Chapter, rl.ChunkIdx, rl.Stage, rl.Role,
|
||||||
|
rl.ModelRequested, rl.ModelActual, rl.RequestHash,
|
||||||
|
rl.PromptTokens, rl.CachedTokens, rl.CacheCreationTokens,
|
||||||
|
rl.CompletionTokens, rl.ReasoningTokens,
|
||||||
|
rl.CostUSD, rl.LatencyMS, rl.FinishReason, boolToInt(rl.TMHit), rl.Degraded, rl.Err, boolToInt(rl.OK))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogRequest is the fire-and-forget wrapper the runner uses: insert, WARN on
|
||||||
|
// failure, never propagate.
|
||||||
|
func (s *Store) LogRequest(ctx context.Context, log *slog.Logger, rl RequestLog) {
|
||||||
|
if rl.TraceID == "" {
|
||||||
|
rl.TraceID = obs.TraceFromContext(ctx)
|
||||||
|
}
|
||||||
|
if err := s.InsertRequestLog(rl); err != nil && log != nil {
|
||||||
|
log.WarnContext(ctx, "request_log insert failed (non-fatal)", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestLogRows returns rows for inspection (tmctl report / приёмка Фазы 0).
|
||||||
|
func (s *Store) RequestLogRows(bookID string) (*sql.Rows, error) {
|
||||||
|
ctx, cancel := opContext()
|
||||||
|
defer cancel()
|
||||||
|
return s.r.QueryContext(ctx, `
|
||||||
|
SELECT ts, stage, role, model_actual, prompt_tokens, cached_tokens,
|
||||||
|
cache_creation_tokens, completion_tokens, reasoning_tokens,
|
||||||
|
cost_usd, latency_ms, finish_reason, tm_hit, ok
|
||||||
|
FROM request_log WHERE book_id = ? ORDER BY id`, bookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
109
backend/internal/store/store.go
Normal file
109
backend/internal/store/store.go
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
// Package store is the durable state of a book project: schema migrations,
|
||||||
|
// the spend ledger (reserve/settle), chunk checkpoints and the request_log —
|
||||||
|
// one SQLite file per project (modernc.org/sqlite, CGO-free, no native
|
||||||
|
// extensions — Р3/Р6).
|
||||||
|
//
|
||||||
|
// Семантика денег и миграций портирована из vojo store.go; сам код написан
|
||||||
|
// заново под SQLite (advisory-локов здесь нет — сериализацию даёт write-пул
|
||||||
|
// из одного соединения + BEGIN IMMEDIATE; 03-implementation-notes §2 п.5).
|
||||||
|
//
|
||||||
|
// Рецепт конкурентности (§3.6): на файл БД два пула — write (MaxOpenConns=1,
|
||||||
|
// транзакции immediate) и read (N соединений); прагмы на каждое соединение
|
||||||
|
// через DSN: WAL, busy_timeout, foreign_keys, synchronous(NORMAL — переживает
|
||||||
|
// kill -9; отключение питания вне модели угроз MVP).
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// opTimeout bounds every store operation: SQLite is a local file and
|
||||||
|
// effectively never blocks; the cap keeps a wedged filesystem from hanging a
|
||||||
|
// pipeline goroutine forever.
|
||||||
|
const opTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Store is one project database.
|
||||||
|
type Store struct {
|
||||||
|
w *sql.DB // single-connection write pool; all mutations go through it
|
||||||
|
r *sql.DB // read pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens (or creates) the project database at path, applies pending
|
||||||
|
// migrations and recovers stale reservations from a crashed run.
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
dsn := func(txlock string) string {
|
||||||
|
v := url.Values{}
|
||||||
|
v.Add("_pragma", "journal_mode(WAL)")
|
||||||
|
v.Add("_pragma", "busy_timeout(5000)")
|
||||||
|
v.Add("_pragma", "foreign_keys(1)")
|
||||||
|
v.Add("_pragma", "synchronous(NORMAL)")
|
||||||
|
if txlock != "" {
|
||||||
|
v.Set("_txlock", txlock)
|
||||||
|
}
|
||||||
|
return "file:" + path + "?" + v.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := sql.Open("sqlite", dsn("immediate"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open write pool: %w", err)
|
||||||
|
}
|
||||||
|
// One writer connection: SQLite has a single writer per file anyway;
|
||||||
|
// serializing in Go turns SQLITE_BUSY storms into a plain queue.
|
||||||
|
w.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
r, err := sql.Open("sqlite", dsn(""))
|
||||||
|
if err != nil {
|
||||||
|
w.Close()
|
||||||
|
return nil, fmt.Errorf("store: open read pool: %w", err)
|
||||||
|
}
|
||||||
|
r.SetMaxOpenConns(4)
|
||||||
|
|
||||||
|
s := &Store{w: w, r: r}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.migrate(ctx); err != nil {
|
||||||
|
s.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.recoverReservations(ctx); err != nil {
|
||||||
|
s.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
err1 := s.w.Close()
|
||||||
|
err2 := s.r.Close()
|
||||||
|
if err1 != nil {
|
||||||
|
return err1
|
||||||
|
}
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
|
||||||
|
func opContext() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverReservations zeroes reserved_usd left over by a crashed process. The
|
||||||
|
// project file is owned by ONE process at a time (CLI), so any reservation
|
||||||
|
// present at open belongs to a run that never settled — the recovery pass из
|
||||||
|
// implementation-notes §3.3.
|
||||||
|
func (s *Store) recoverReservations(ctx context.Context) error {
|
||||||
|
res, err := s.w.ExecContext(ctx, `UPDATE spend SET reserved_usd = 0 WHERE reserved_usd > 0`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: recover reservations: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
// The caller's logger isn't wired here; surface via the request_log-less
|
||||||
|
// path is overkill — a plain stderr note keeps it visible.
|
||||||
|
fmt.Printf("store: recovered %d stale reservation row(s) from a previous run\n", n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
190
backend/internal/store/store_test.go
Normal file
190
backend/internal/store/store_test.go
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTemp(t *testing.T) (*Store, string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
s, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s, path
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustSnapshotAndJob(t *testing.T, s *Store) *Job {
|
||||||
|
t.Helper()
|
||||||
|
if err := s.UpsertSnapshot("snap1", "brief1", `{}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
job, err := s.EnsureJob("book", 1, "draft", "snap1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveSettleLifecycle(t *testing.T) {
|
||||||
|
s, _ := openTemp(t)
|
||||||
|
job := mustSnapshotAndJob(t, s)
|
||||||
|
caps := Ceilings{BookUSD: 1.0, DayUSD: 2.0}
|
||||||
|
|
||||||
|
res, verdict, err := s.Reserve("book", 0.10, caps)
|
||||||
|
if err != nil || verdict != ReserveOK {
|
||||||
|
t.Fatalf("reserve: %v %v", verdict, err)
|
||||||
|
}
|
||||||
|
committed, reserved, _ := s.SpentUSD("book")
|
||||||
|
if committed != 0 || reserved != 0.10 {
|
||||||
|
t.Fatalf("after reserve: committed=%v reserved=%v", committed, reserved)
|
||||||
|
}
|
||||||
|
|
||||||
|
cp := Checkpoint{RequestHash: "h1", JobID: job.ID, Stage: "draft", Role: "translator",
|
||||||
|
ModelRequested: "m", ModelActual: "m", ResponseText: "текст", UsageJSON: "{}", CostUSD: 0.04}
|
||||||
|
if err := s.SettleWithCheckpoint(res, 0.04, cp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
committed, reserved, _ = s.SpentUSD("book")
|
||||||
|
if committed != 0.04 || reserved != 0 {
|
||||||
|
t.Fatalf("after settle: committed=%v reserved=%v", committed, reserved)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetCheckpoint("h1")
|
||||||
|
if err != nil || got == nil || got.ResponseText != "текст" {
|
||||||
|
t.Fatalf("checkpoint: %+v err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный settle того же hash (ретрай вызывающего) идемпотентен:
|
||||||
|
// резерв снимается, деньги второй раз не книжатся.
|
||||||
|
res2, verdict, err := s.Reserve("book", 0.10, caps)
|
||||||
|
if err != nil || verdict != ReserveOK {
|
||||||
|
t.Fatalf("reserve2: %v %v", verdict, err)
|
||||||
|
}
|
||||||
|
if err := s.SettleWithCheckpoint(res2, 0.04, cp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
committed, reserved, _ = s.SpentUSD("book")
|
||||||
|
if committed != 0.04 || reserved != 0 {
|
||||||
|
t.Fatalf("duplicate settle must book nothing: committed=%v reserved=%v", committed, reserved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCeilingsDeny(t *testing.T) {
|
||||||
|
s, _ := openTemp(t)
|
||||||
|
mustSnapshotAndJob(t, s)
|
||||||
|
|
||||||
|
// Книжный потолок: committed+reserved+estimate > cap ⇒ отказ.
|
||||||
|
res, verdict, err := s.Reserve("book", 0.9, Ceilings{BookUSD: 1.0})
|
||||||
|
if err != nil || verdict != ReserveOK {
|
||||||
|
t.Fatalf("first reserve: %v %v", verdict, err)
|
||||||
|
}
|
||||||
|
_, verdict, err = s.Reserve("book", 0.2, Ceilings{BookUSD: 1.0})
|
||||||
|
if err != nil || verdict != ReserveDeniedBook {
|
||||||
|
t.Fatalf("second reserve must hit the book ceiling, got %v %v", verdict, err)
|
||||||
|
}
|
||||||
|
// Release возвращает headroom.
|
||||||
|
if err := s.Release(res); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, verdict, err = s.Reserve("book", 0.2, Ceilings{BookUSD: 1.0})
|
||||||
|
if err != nil || verdict != ReserveOK {
|
||||||
|
t.Fatalf("after release: %v %v", verdict, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дневной потолок считается по всем книгам за день.
|
||||||
|
_, verdict, err = s.Reserve("other-book", 0.1, Ceilings{DayUSD: 0.25})
|
||||||
|
if err != nil || verdict != ReserveDeniedDay {
|
||||||
|
t.Fatalf("day ceiling must deny across books, got %v %v", verdict, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentReservesRespectCeiling(t *testing.T) {
|
||||||
|
s, _ := openTemp(t)
|
||||||
|
mustSnapshotAndJob(t, s)
|
||||||
|
caps := Ceilings{BookUSD: 0.5}
|
||||||
|
|
||||||
|
// 20 конкурентных резерваций по 0.1 при потолке 0.5: пройти могут максимум 5.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
admitted := 0
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, verdict, err := s.Reserve("book", 0.1, caps)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if verdict == ReserveOK {
|
||||||
|
mu.Lock()
|
||||||
|
admitted++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if admitted != 5 {
|
||||||
|
t.Fatalf("ceiling 0.5 with 0.1 reserves must admit exactly 5, admitted %d", admitted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryClearsStaleReservations(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
s, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, verdict, err := s.Reserve("book", 0.3, Ceilings{BookUSD: 1}); err != nil || verdict != ReserveOK {
|
||||||
|
t.Fatalf("%v %v", verdict, err)
|
||||||
|
}
|
||||||
|
s.Close() // «упали», не сняв резерв
|
||||||
|
|
||||||
|
s2, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s2.Close()
|
||||||
|
committed, reserved, _ := s2.SpentUSD("book")
|
||||||
|
if committed != 0 || reserved != 0 {
|
||||||
|
t.Fatalf("reopen must clear stale reservations: committed=%v reserved=%v", committed, reserved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobKeepsOriginalSnapshot(t *testing.T) {
|
||||||
|
s, _ := openTemp(t)
|
||||||
|
if err := s.UpsertSnapshot("snapA", "brief", `{}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.UpsertSnapshot("snapB", "brief", `{"v":2}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
j1, err := s.EnsureJob("book", 1, "draft", "snapA")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Повторный EnsureJob с другим snapshot НЕ перепривязывает джобу (Р6:
|
||||||
|
// snapshot фиксируется на джобу до её завершения).
|
||||||
|
j2, err := s.EnsureJob("book", 1, "draft", "snapB")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if j1.ID != j2.ID || j2.SnapshotID != "snapA" {
|
||||||
|
t.Fatalf("job must keep its original snapshot: %+v", j2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateIsIdempotent(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
s, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open #%d: %v", i, err)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/prompts/analyst.md
Normal file
15
backend/prompts/analyst.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
Ты — литературный аналитик (роль Analyst, Р2: вся книга → book brief).
|
||||||
|
Черновик Фазы 0; наполняется сессией «Редакция» и включается в пайплайн Фазой 1
|
||||||
|
(map-reduce по книге).
|
||||||
|
|
||||||
|
Задача: прочитать фрагмент книги «{{title}}» ({{genre}}, язык «{{source_lang}}»)
|
||||||
|
и составить структурированный book brief: сеттинг, тон, стиль автора, главные
|
||||||
|
персонажи с речевыми характеристиками, повторяющиеся мотивы, особенности,
|
||||||
|
критичные для перевода на язык «{{target_lang}}».
|
||||||
|
|
||||||
|
Выведи brief в виде маркированного списка по секциям.
|
||||||
|
|
||||||
|
---USER---
|
||||||
|
Фрагмент книги:
|
||||||
|
|
||||||
|
{{text}}
|
||||||
20
backend/prompts/editor.md
Normal file
20
backend/prompts/editor.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
Ты — литературный редактор художественного перевода на русский язык.
|
||||||
|
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
|
||||||
|
|
||||||
|
Твой мандат УЗКИЙ — художественная редактура черновика перевода:
|
||||||
|
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции.
|
||||||
|
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
|
||||||
|
- Не меняй имена собственные, термины и реалии — их утверждает другой пасс.
|
||||||
|
- Сохраняй разбивку на абзацы.
|
||||||
|
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
|
||||||
|
|
||||||
|
Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений.
|
||||||
|
|
||||||
|
---USER---
|
||||||
|
Исходник (для сверки смысла, язык «{{source_lang}}»):
|
||||||
|
|
||||||
|
{{text}}
|
||||||
|
|
||||||
|
Черновик перевода для редактуры:
|
||||||
|
|
||||||
|
{{draft}}
|
||||||
15
backend/prompts/judge-selector.md
Normal file
15
backend/prompts/judge-selector.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
Ты — судья-селектор переводов (роль Judge, ядро C2; шкала Комиссарова +
|
||||||
|
Nida-вопрос). Скелет Фазы 0 — исполнение и промпт-инженерия придут с Фазой 2
|
||||||
|
(пара судей: монолингвальный на художественность + билингвальный на верность).
|
||||||
|
|
||||||
|
Задача: из N кандидатов перевода выбрать лучший по верности исходнику и
|
||||||
|
художественности русского текста.
|
||||||
|
|
||||||
|
---USER---
|
||||||
|
Исходник (язык «{{source_lang}}»):
|
||||||
|
|
||||||
|
{{text}}
|
||||||
|
|
||||||
|
Кандидаты:
|
||||||
|
|
||||||
|
{{draft}}
|
||||||
16
backend/prompts/terminologist.md
Normal file
16
backend/prompts/terminologist.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
Ты — терминолог художественного перевода (роль Terminologist, Р2: глоссарий +
|
||||||
|
style sheet ДО перевода — паттерн preparation из TransAgents).
|
||||||
|
Черновик Фазы 0; наполняется сессией «Редакция» и включается в пайплайн Фазой 1.
|
||||||
|
|
||||||
|
Задача: из фрагмента книги «{{title}}» (язык «{{source_lang}}») извлечь
|
||||||
|
кандидатов в глоссарий для перевода на язык «{{target_lang}}»: имена
|
||||||
|
собственные, титулы, топонимы, термины мира, устойчивые обращения. Для каждого
|
||||||
|
— предложить перевод с учётом транскрипции «{{transcription}}» и хонорификов
|
||||||
|
«{{honorifics}}», указать род и тип.
|
||||||
|
|
||||||
|
Выведи таблицу: src | предлагаемый dst | тип | род | комментарий.
|
||||||
|
|
||||||
|
---USER---
|
||||||
|
Фрагмент книги:
|
||||||
|
|
||||||
|
{{text}}
|
||||||
17
backend/prompts/translator.md
Normal file
17
backend/prompts/translator.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
Ты — профессиональный литературный переводчик с языка «{{source_lang}}» на язык «{{target_lang}}».
|
||||||
|
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
|
||||||
|
|
||||||
|
Правила перевода (translation brief):
|
||||||
|
- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}.
|
||||||
|
- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}.
|
||||||
|
- Сноски: {{footnotes}}.
|
||||||
|
- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя.
|
||||||
|
- Сохраняй разбивку на абзацы и прямую речь как в оригинале.
|
||||||
|
- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка.
|
||||||
|
|
||||||
|
Выведи ТОЛЬКО перевод, без комментариев, пояснений и заголовков.
|
||||||
|
|
||||||
|
---USER---
|
||||||
|
Переведи следующий фрагмент:
|
||||||
|
|
||||||
|
{{text}}
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
- [ ] Сессия «Редакция» (промпты ролей) — после фиксации формата конфига в Фазе 0.
|
- [ ] Сессия «Редакция» (промпты ролей) — после фиксации формата конфига в Фазе 0.
|
||||||
- [ ] Пилот 4 рук для выбора ядра пайплайна (Фаза 2.5).
|
- [ ] Пилот 4 рук для выбора ядра пайплайна (Фаза 2.5).
|
||||||
- Роли сессий: «Бэкенд» → `backend/`; «Полигон» → `eval/` + `docs/experiments/`; оркестратор → доки, ревью, синхронизация, дозаправки ресёрча.
|
- Роли сессий: «Бэкенд» → `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-моках.
|
Вопросы владельцу: ключи провайдеров для `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] Прогон refusal-бенчмарка (SFW/L1/L2-violence часть) — 66/66 ok, пороги в 02; explicit-часть ждёт фрагментов владельца.
|
||||||
- [x] Эталон DeepSeek для сравнения черновиков — в 03.
|
- [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 — при появлении ключей.
|
- [ ] Прогон Qwen Model Studio (`data_inspection_failed`) и OpenRouter — при появлении ключей.
|
||||||
- [ ] Проверка ru-агрегаторов: проброс cache-hit DeepSeek (два одинаковых запроса, сравнить usage) — разблокирует экономику ru-контура.
|
- [ ] Проверка ru-агрегаторов: проброс cache-hit DeepSeek (два одинаковых запроса, сравнить usage) — разблокирует экономику ru-контура.
|
||||||
- [ ] Довалидация калибровки на 3–5 главах реальных вебновелл (файлы владельца → `eval/data/samples/` + manifest).
|
- [ ] Довалидация калибровки на 3–5 главах реальных вебновелл (файлы владельца → `eval/data/samples/` + manifest).
|
||||||
|
|
|
||||||
|
|
@ -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) — только в контуре прямых ключей |
|
| Редактор (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 как бесплатный гейт |
|
| Судья | Gemini 3.1 Pro (batch −50%) | Claude Opus 4.8 (адресно, только канал A и прямые ключи), локальный POLLUX-judge-7B как бесплатный гейт |
|
||||||
| Эмбеддинги/скрининг/экстракция терминов | локально: bge-m3, Qwen3.5-9B | Qwen3-Embedding-0.6B |
|
| Эмбеддинги/скрининг/экстракция терминов | локально: 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)):
|
Контент-роутинг ([11-gap-2](../research/11-gap-2.md)):
|
||||||
- Локальный классификатор «горячести» 0–3. Уровни 0–1 → канал A. Уровень 2 → канал B (пермиссивный). Уровень 3 (несовершеннолетние в сексуальном контексте) — **жёсткий отказ на уровне чанка**: чанк не переводится, помечается в отчёте, глава и книга продолжают обрабатываться (ложное срабатывание классификатора не должно убивать многочасовую оплаченную джобу).
|
- Локальный классификатор «горячести» 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-тарифу — решить расчётом на прототипе, обе схемы поддержать конфигом.
|
- **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.
|
- **Батчуемость — свойство стадии**: Analyst, Terminologist, судья/оценки, книжный QA — батчуемы (−50%); черновик и редактура чанков — последовательны (STM/резюме создают зависимость, DelTA-паттерн). «С batch ≈$2» относится только к батчуемым стадиям. Batch API — Фаза 2 (к судье), не Фаза 0.
|
||||||
- **Бюджет премиум-токенов на книгу**: одиночный полный Sonnet-проход 500-главной вебновеллы ~$92 — впритык к якорю GlobeScribe $100 и выходит за него с буфером +25%; двойной ~$185 — ломает экономику. Отсюда: дорогая модель — только адресно по сигналу судьи/гейтов, бюджет ≤2× input-объёма книги по премиум-тарифам.
|
- **Бюджет премиум-токенов на книгу**: одиночный полный 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, до тех пор — просто не гнать массовые прогоны в пик).
|
- Буфер +25% к расчётному COGS (ретраи, глоссарий-строительство, регенерации). Цены/имена моделей — только в конфиге с датой проверки. Миграция deepseek-chat → deepseek-v4-flash до 24.07.2026. Для DeepSeek — планировщик внепиковых часов (надбавка ×2 в пик; сам планировщик — Фаза 3, до тех пор — просто не гнать массовые прогоны в пик).
|
||||||
|
|
||||||
## Р6. Хранилище и оркестрация: SQLite + durable jobs + чанк-чекпоинты. **Принято**
|
## Р6. Хранилище и оркестрация: SQLite + durable jobs + чанк-чекпоинты. **Принято**
|
||||||
|
|
@ -84,6 +86,7 @@
|
||||||
- Локальный режим MVP: SQLite (modernc.org/sqlite, CGO-free, без нативных расширений — см. Р3) для проектных данных; отдельный файл БД на книгу для банка памяти. Интерфейс хранилища тонкий — миграция на Postgres (pgx уже в vojo) при появлении сервера.
|
- Локальный режим 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 посреди книги — явная команда с показом стоимости пере-перевода.
|
- Оркестрация — **durable jobs в БД с resume**; джоба = глава×стадия. **Гранулярность сохранности — чанк, не глава** (правка red-team): каждый сырой ответ LLM персистится сразу после settle (ключ: request-hash), отдельно от смысловой TM; kill -9 посреди главы теряет максимум один незавершённый вызов. Из ключа resume исключаются волатильные части контекста (snapshot контекста фиксируется на джобу до её завершения) — иначе любое подтверждение термина инвалидирует TM-хиты уже переведённых чанков. `brief_hash` входит в ключ TM; изменение translation brief посреди книги — явная команда с показом стоимости пере-перевода.
|
||||||
- Проверяется тестом: kill -9 в середине главы → рестарт → доплата только за прерванный вызов.
|
- Проверяется тестом: kill -9 в середине главы → рестарт → доплата только за прерванный вызов.
|
||||||
|
- **Ключ TM подтверждён** (запрос бэкенд-сессии): консервативный вариант `(chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash, context_snapshot_hash)`; перечанкование инвалидирует TM by design (ключ от src-чанка) — это осознанная плата за корректность.
|
||||||
|
|
||||||
## Р7. Телеметрия и качество. **Принято**
|
## Р7. Телеметрия и качество. **Принято**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
Монорепо Go (`backend/`). Портирование ядра из `vojo/apps/ai-bot`:
|
Монорепо 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/ledger`: цены в конфиге (с датой проверки), биллинг по usage (+reasoning-токены), reserve/settle, потолки $ на книгу/день.
|
||||||
- `pkg/obs`: trace_id, структурные логи, request_log (per-книга/глава/чанк/стадия/роль/модель, $, latency, cache-hit, вердикты гейтов).
|
- `pkg/obs`: trace_id, структурные логи, request_log (per-книга/глава/чанк/стадия/роль/модель, $, latency, cache-hit, вердикты гейтов).
|
||||||
- `pkg/store`: SQLite (modernc.org/sqlite, без нативных расширений) + идемпотентные миграции; durable jobs (глава×стадия) + **чанк-чекпоинты** (Р6: каждый сырой ответ LLM персистится после settle; snapshot контекста на джобу; kill -9-тест — часть приёмки).
|
- `pkg/store`: SQLite (modernc.org/sqlite, без нативных расширений) + идемпотентные миграции; durable jobs (глава×стадия) + **чанк-чекпоинты** (Р6: каждый сырой ответ LLM персистится после settle; snapshot контекста на джобу; kill -9-тест — часть приёмки).
|
||||||
|
|
|
||||||
130
docs/architecture/04-unhappy-paths.md
Normal file
130
docs/architecture/04-unhappy-paths.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Карта unhappy paths: режимы отказа AI-перевода → механизмы TextMachine (v1, 2026-07-04)
|
||||||
|
|
||||||
|
Синтез по `research/12-failure-modes-{ja,zh,ru-target}.md`, `12-consumer-feedback.md`, `12-error-taxonomies.md` (+ база 01/02/07). Назначение: (1) валидационная матрица архитектуры — каждый известный режим отказа обязан иметь механизм или честную пометку «дыра»; (2) ТЗ на QA-гейты и поля памяти; (3) источник регрессионных тест-кейсов (в research-доках каждый режим — с примером «оригинал → анти-паттерн MTL → верный перевод»).
|
||||||
|
|
||||||
|
Статусы: ✅ закрыто дизайном v2 · 🔶 закрыто частично / нужно уточнение дизайна · ❌ дыра, требует действия. Фаза — где механизм появляется по плану MVP.
|
||||||
|
|
||||||
|
Опорный факт (LAIT): при переводе **не на английский** читатели предпочитают человеческий перевод в 92.8% случаев и опознают MT по калькам — на ru-выходе паритет ещё никем не достигнут, вся карта ниже и есть план его достижения.
|
||||||
|
|
||||||
|
## 1. Консистентность сущностей — жалоба №1 всех площадок
|
||||||
|
|
||||||
|
64.4% документных ошибок MT вебновелл — инконсистентность (BlonDe); «Император птиц → Птица Император → Воробьиный Император» (DTF о DeepL); Yuewen признаёт проблему публично.
|
||||||
|
|
||||||
|
| Режим отказа | Механизм | Фаза | Статус |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Дрейф имён/терминов между вызовами | Глоссарий + гейт консистентности (по леммам через `decl`) | 1 | ✅ |
|
||||||
|
| Система имён 名/字/号/прозвища: «один персонаж = 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-* имеет пример «оригинал → анти-паттерн → голд» — собрать регрессионный сьют (задача «Полигона», после пилотного корпуса).
|
||||||
|
|
@ -54,9 +54,15 @@
|
||||||
- **Критично для TextMachine — тайминги**: у vojo дедлайн локальной ноги 45 с (под короткие чат-ответы). Наши книжные чанки на локалке занимают **63–278 с** — профиль таймаутов нужен свой (лег-дедлайн ~300–600 с по роли/модели) плюс стриминг в интерфейсе клиента (уже запланирован в Р1), иначе фейловер будет ложно ронять здоровую локалку.
|
- **Критично для TextMachine — тайминги**: у vojo дедлайн локальной ноги 45 с (под короткие чат-ответы). Наши книжные чанки на локалке занимают **63–278 с** — профиль таймаутов нужен свой (лег-дедлайн ~300–600 с по роли/модели) плюс стриминг в интерфейсе клиента (уже запланирован в Р1), иначе фейловер будет ложно ронять здоровую локалку.
|
||||||
- `max_tokens` у ollama покрывает thinking+ответ вместе — для thinking-режимов задавать увеличенный потолок (у vojo 1024; для перевода главы нужно 4–8k) или выключать thinking (`reasoning_effort: none`).
|
- `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~~ — **сделано** (см. выше): разрыв качественный, локалка остаётся «спецслужбой».
|
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 (мини-бенчмарк эмбеддингов).
|
4. Замер bge-m3 (скорость/качество retrieval) — в рамках задачи 5 (мини-бенчмарк эмбеддингов).
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
## TL;DR
|
## 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/)).
|
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)).
|
3. Существует проверенный арсенал стратегий письма «без рода» (перестройка фразы, настоящее время, безличные/номинативные конструкции) — формализован в гайдах ООН по гендерно-нейтральному русскому; он конвертируется в протокол «gender: unknown» для переводчика-агента ([UN Russian guidelines](https://www.un.org/ru/gender-inclusive-language/guidelines.shtml)).
|
||||||
4. Самый дешёвый и самый заметный читателю класс отказов — механика: английские кавычки вместо тире-диалогов, дефис вместо тире, транслит-междометия («Ауч!», «Упс»), непоследовательная ё. Всё это детектится и чинится детерминированно, без LLM (Розенталь §47–52, Лопатин, правила ё на Грамоте.ру).
|
4. Самый дешёвый и самый заметный читателю класс отказов — механика: английские кавычки вместо тире-диалогов, дефис вместо тире, транслит-междометия («Ауч!», «Упс»), непоследовательная ё. Всё это детектится и чинится детерминированно, без LLM (Розенталь §47–52, Лопатин, правила ё на Грамоте.ру).
|
||||||
5. Мат — двусторонний отказ: LLM-санитайзинг даёт «недожим» (злодей ругается «чёрт возьми»), буквализм — «пережим» (мат там, где в оригинале бытовое shit). Русская обсценная лексика табуированнее английских four-letter words, а закон 2014 г. требует маркировки 18+ — это и политика проекта, и двусторонний детерминированный гейт по лексиконам ([Habr/EnglishDom](https://habr.com/ru/companies/englishdom/articles/564036/)).
|
5. Мат — двусторонний отказ: LLM-санитайзинг даёт «недожим» (злодей ругается «чёрт возьми»), буквализм — «пережим» (мат там, где в оригинале бытовое shit). Русская обсценная лексика табуированнее английских four-letter words, а закон 2014 г. требует маркировки 18+ — это и политика проекта, и двусторонний детерминированный гейт по лексиконам ([Habr/EnglishDom](https://habr.com/ru/companies/englishdom/articles/564036/)).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue