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.
|
||||
- [ ] Пилот 4 рук для выбора ядра пайплайна (Фаза 2.5).
|
||||
- Роли сессий: «Бэкенд» → `backend/`; «Полигон» → `eval/` + `docs/experiments/`; оркестратор → доки, ревью, синхронизация, дозаправки ресёрча.
|
||||
- [ ] **Дозаправка ресёрча запущена (04.07, оркестратор)** по запросу владельца: каталог режимов отказа по парам (ja→ru, zh→ru, ru-сторона), систематический сбор отзывов потребителей по конкретным продуктам, академические таксономии ошибок → `research/12-*.md`; затем оркестратор соберёт карту «проблема → решение в бэкенде» → `architecture/04-unhappy-paths.md`. Бэкенд-сессии Фазы 0 это не блокирует; карта понадобится к Фазе 1 (гейты, поля памяти) и «Редакции» (промпты).
|
||||
- [x] **Дозаправка ресёрча выполнена (04.07, оркестратор)**: 5 документов `research/12-*.md` (~70 режимов отказа: ja, zh, ru-сторона; отзывы потребителей по продуктам; академические таксономии DITING/BlonDe/LitEval/LAIT) + верификация (3 minor: неточная ссылка поправлена; 2 пробела — числительные/меры CJK и западные имена через катакану — включены в карту как дыры с действиями). Итоговая карта «проблема → механизм бэкенда» → `architecture/04-unhappy-paths.md`; в ней раздел «Следствия для плана» — 7 изменений (пре-пасс Annotator в Фазе 2, расширение схемы глоссария, 4 новых дешёвых гейта Фазы 1, судья-анкета с MQM-весами, alignment-защита рефренов, эскалация по цене правки, регрессионный сьют из примеров).
|
||||
|
||||
## Бэкенд
|
||||
|
||||
|
|
@ -65,6 +65,14 @@
|
|||
|
||||
Вопросы владельцу: ключи провайдеров для `backend/.env` (DeepSeek минимум — на нём приёмка Фазы 0 с реальным вызовом; Anthropic — для проверки cache_control). Не блокируюсь: тесты на httptest-моках.
|
||||
|
||||
> **Ответ оркестратора на [НУЖНО РЕШЕНИЕ] (04.07).** Отличный шаг 0 — фактчек Grok и cache-write Anthropic особенно ценны. Решения:
|
||||
> 1. **Премиум-бюджет**: подтверждаю $-потолок; формула зафиксирована в Р5 (`clamp(price_target × (1 − target_margin) − est_base_cogs, 0, hard_cap)`, дефолты $5/том, $30/вебновелла-500). «≤2×» — только прикидочная эвристика доков.
|
||||
> 2. **TM-инвалидация**: консервативный ключ подтверждён и зафиксирован в Р6: `(chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash, context_snapshot_hash)`; перечанкование инвалидирует by design.
|
||||
> 3. **Канал B**: Р4 обновлён — интерим до Фазы 2: DeepSeek офиц. + локальная abliterated; Grok 4.3 только как дорогая эскалация; перевыбор дешёвого пермиссивного тира — по результатам refusal-бенчмарка полигона.
|
||||
> 4. **Стриминг**: перенос одобрен — транспортный keep-alive-стриминг в Фазу 1–2 (без SSE-фронта), зафиксировано в плане; в Фазе 0 — длинные per-роль таймауты.
|
||||
> 5. **Р5 обновлён** сноской на токен-калибровку (×1.4–1.75 для zh→ru); задача «cache-hit агрегаторов» снята с Фазы 0 — она в бэклоге полигона.
|
||||
> **Новая вводная**: готова карта режимов отказа [architecture/04-unhappy-paths.md](architecture/04-unhappy-paths.md) (~70 режимов → механизмы). Перед фиксацией схемы store/глоссария Фазы 1 прочитай раздел «Следствия для плана»: расширение полей глоссария (alias-граф, ranked-set, gender=hidden, first_person, translit_policy), ruby-парсер в спеку импорта, дешёвые детерминированные гейты Фазы 1 (линтер прямой речи, блок-лист транслит-междометий, ёфикатор, число-гейт 万/億), словарь тегов ошибок с MQM-весами в request_log с первого дня. Пороги coverage-гейта уже сняты полигоном (см. его секцию, эксперимент 02).
|
||||
|
||||
## Полигон
|
||||
(секция параллельной сессии — записи добавлять сюда)
|
||||
|
||||
|
|
@ -87,7 +95,7 @@
|
|||
### Следующие шаги полигона
|
||||
- [x] Прогон refusal-бенчмарка (SFW/L1/L2-violence часть) — 66/66 ok, пороги в 02; explicit-часть ждёт фрагментов владельца.
|
||||
- [x] Эталон DeepSeek для сравнения черновиков — в 03.
|
||||
- [ ] llama.cpp `--n-cpu-moe` для 30b-a3b (сборка в фоне; после рестарта WSL с memory=26GB — замер).
|
||||
- [ ] llama.cpp `--n-cpu-moe` для 30b-a3b — **сборка готова** (CUDA 12.6 user-space, пути/флаги в 03), замер после рестарта WSL с memory=26GB. Пул моделей для сравнения поколений/абляции/ru-адаптации докачан: qwen3:8b, qwen3.5:9b (+abliterated), ruadapt-qwen3:8b.
|
||||
- [ ] Прогон Qwen Model Studio (`data_inspection_failed`) и OpenRouter — при появлении ключей.
|
||||
- [ ] Проверка ru-агрегаторов: проброс cache-hit DeepSeek (два одинаковых запроса, сравнить usage) — разблокирует экономику ru-контура.
|
||||
- [ ] Довалидация калибровки на 3–5 главах реальных вебновелл (файлы владельца → `eval/data/samples/` + manifest).
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@
|
|||
|
||||
| Роль | Основная модель | Альтернативы/эскалация |
|
||||
|---|---|---|
|
||||
| Черновик | DeepSeek V4 Flash ($0.14/$0.28; cache-hit input $0.0028; лучший zh-токенизатор) | Qwen-Flash, GPT-5 nano, Grok 4.1 Fast |
|
||||
| Черновик | DeepSeek V4 Flash ($0.14/$0.28; cache-hit input $0.0028; лучший zh-токенизатор) | Qwen-Flash, GPT-5 nano |
|
||||
| Редактор (ru-стиль) | GLM-5 / Kimi K2.6 (доступны из РФ напрямую) | Claude Sonnet 5 (промо $2/$10 до 31.08.2026) — только в контуре прямых ключей |
|
||||
| Судья | Gemini 3.1 Pro (batch −50%) | Claude Opus 4.8 (адресно, только канал A и прямые ключи), локальный POLLUX-judge-7B как бесплатный гейт |
|
||||
| Эмбеддинги/скрининг/экстракция терминов | локально: bge-m3, Qwen3.5-9B | Qwen3-Embedding-0.6B |
|
||||
| 18+ (канал B) | Grok 4.1 Fast / Grok 4.x | open-weights через OpenRouter (Chutes/Venice), DeepSeek офиц. API (3-я линия), локальная abliterated Qwen3.5 |
|
||||
| 18+ (канал B) | Интерим до Фазы 2: DeepSeek офиц. API + локальная abliterated Qwen3.5 | open-weights через OpenRouter (Chutes/Venice); Grok 4.3 — дорогая эскалация. ⚠ Grok 4.1 Fast retired 15.05.2026, слаги молча редиректят на grok-4.3 с ценой ×9 (фактчек бэкенд-сессии) — перевыбор дешёвой модели канала B по итогам refusal-бенчмарка полигона |
|
||||
|
||||
Контент-роутинг ([11-gap-2](../research/11-gap-2.md)):
|
||||
- Локальный классификатор «горячести» 0–3. Уровни 0–1 → канал A. Уровень 2 → канал B (пермиссивный). Уровень 3 (несовершеннолетние в сексуальном контексте) — **жёсткий отказ на уровне чанка**: чанк не переводится, помечается в отчёте, глава и книга продолжают обрабатываться (ложное срабатывание классификатора не должно убивать многочасовую оплаченную джобу).
|
||||
|
|
@ -77,6 +77,8 @@
|
|||
- **Prompt caching — главный рычаг**, и раскладка промпта фиксируется под него (разрешение конфликта Р3↔Р5 из ревью): **стабильный кэшируемый префикс** = системный промпт роли + book brief + style sheet + резюме книги/арок (меняется редко, раз в главу-арку); **волатильный хвост после кэш-брейкпоинта** = селективный глоссарий + STM + чанк. На стадии edit кэшу ловить почти нечего (уникальный черновик в промпте) — ожидаемый cache-hit считать по стадиям, а не «≥50% в среднем». Альтернатива для DeepSeek (cache-hit $0.0028/M): полный глоссарий в стабильном префиксе может оказаться дешевле селективной инъекции по miss-тарифу — решить расчётом на прототипе, обе схемы поддержать конфигом.
|
||||
- **Батчуемость — свойство стадии**: Analyst, Terminologist, судья/оценки, книжный QA — батчуемы (−50%); черновик и редактура чанков — последовательны (STM/резюме создают зависимость, DelTA-паттерн). «С batch ≈$2» относится только к батчуемым стадиям. Batch API — Фаза 2 (к судье), не Фаза 0.
|
||||
- **Бюджет премиум-токенов на книгу**: одиночный полный Sonnet-проход 500-главной вебновеллы ~$92 — впритык к якорю GlobeScribe $100 и выходит за него с буфером +25%; двойной ~$185 — ломает экономику. Отсюда: дорогая модель — только адресно по сигналу судьи/гейтов, бюджет ≤2× input-объёма книги по премиум-тарифам.
|
||||
- **Формула премиум-бюджета** (уточнение по запросу бэкенд-сессии, заменяет эвристику «≤2×»): в коде — конфигурируемый $-потолок на книгу, `premium_budget_usd = clamp(price_target × (1 − target_margin) − est_base_cogs, 0, hard_cap)`; для локального режима без прайса — дефолты $5/том ранобэ, $30/вебновелла-500. «≤2× input-объёма» остаётся прикидочной эвристикой в доках, не контрактом кода.
|
||||
- ⚠ **Абсолютные COGS этого раздела устарели после токен-калибровки** (эксперимент 01 полигона): zh→ru дороже в ~1.4–1.75 раза (премиум-микс вебновеллы ≈$49, с batch ≈$26; ранобэ ja→ru, наоборот, дешевле — ≈$3.1). Структура выводов не меняется; актуальные множители — `docs/experiments/01-token-calibration.md`.
|
||||
- Буфер +25% к расчётному COGS (ретраи, глоссарий-строительство, регенерации). Цены/имена моделей — только в конфиге с датой проверки. Миграция deepseek-chat → deepseek-v4-flash до 24.07.2026. Для DeepSeek — планировщик внепиковых часов (надбавка ×2 в пик; сам планировщик — Фаза 3, до тех пор — просто не гнать массовые прогоны в пик).
|
||||
|
||||
## Р6. Хранилище и оркестрация: SQLite + durable jobs + чанк-чекпоинты. **Принято**
|
||||
|
|
@ -84,6 +86,7 @@
|
|||
- Локальный режим MVP: SQLite (modernc.org/sqlite, CGO-free, без нативных расширений — см. Р3) для проектных данных; отдельный файл БД на книгу для банка памяти. Интерфейс хранилища тонкий — миграция на Postgres (pgx уже в vojo) при появлении сервера.
|
||||
- Оркестрация — **durable jobs в БД с resume**; джоба = глава×стадия. **Гранулярность сохранности — чанк, не глава** (правка red-team): каждый сырой ответ LLM персистится сразу после settle (ключ: request-hash), отдельно от смысловой TM; kill -9 посреди главы теряет максимум один незавершённый вызов. Из ключа resume исключаются волатильные части контекста (snapshot контекста фиксируется на джобу до её завершения) — иначе любое подтверждение термина инвалидирует TM-хиты уже переведённых чанков. `brief_hash` входит в ключ TM; изменение translation brief посреди книги — явная команда с показом стоимости пере-перевода.
|
||||
- Проверяется тестом: kill -9 в середине главы → рестарт → доплата только за прерванный вызов.
|
||||
- **Ключ TM подтверждён** (запрос бэкенд-сессии): консервативный вариант `(chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash, context_snapshot_hash)`; перечанкование инвалидирует TM by design (ключ от src-чанка) — это осознанная плата за корректность.
|
||||
|
||||
## Р7. Телеметрия и качество. **Принято**
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
Монорепо Go (`backend/`). Портирование ядра из `vojo/apps/ai-bot`:
|
||||
|
||||
- `pkg/llm`: интерфейс LLMClient, OpenAI-совместимый транспорт (retry/backoff), адаптеры DeepSeek/Qwen/GLM/xAI/Gemini/llama-server; **новое**: нативный Anthropic-адаптер (`cache_control`), поддержка prompt caching в структуре запроса. ~~Batch API~~ → Фаза 2; ~~SSE-стриминг~~ → Фаза 3 (нужен только IDE-фронту).
|
||||
- `pkg/llm`: интерфейс LLMClient, OpenAI-совместимый транспорт (retry/backoff), адаптеры DeepSeek/Qwen/GLM/xAI/Gemini/llama-server; **новое**: нативный Anthropic-адаптер (`cache_control`), поддержка prompt caching в структуре запроса. ~~Batch API~~ → Фаза 2. Стриминг: в Фазе 0 — длинные per-роль таймауты (чанки на локальной модели идут 63–278 с); **транспортный стриминг как keep-alive** (агрегация на бэкенде, без SSE-фронта) — Фаза 1–2 по предложению бэкенд-сессии; SSE для IDE — Фаза 3.
|
||||
- `pkg/ledger`: цены в конфиге (с датой проверки), биллинг по usage (+reasoning-токены), reserve/settle, потолки $ на книгу/день.
|
||||
- `pkg/obs`: trace_id, структурные логи, request_log (per-книга/глава/чанк/стадия/роль/модель, $, latency, cache-hit, вердикты гейтов).
|
||||
- `pkg/store`: SQLite (modernc.org/sqlite, без нативных расширений) + идемпотентные миграции; durable jobs (глава×стадия) + **чанк-чекпоинты** (Р6: каждый сырой ответ LLM персистится после settle; snapshot контекста на джобу; kill -9-тест — часть приёмки).
|
||||
|
|
|
|||
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), иначе фейловер будет ложно ронять здоровую локалку.
|
||||
- `max_tokens` у ollama покрывает thinking+ответ вместе — для thinking-режимов задавать увеличенный потолок (у vojo 1024; для перевода главы нужно 4–8k) или выключать thinking (`reasoning_effort: none`).
|
||||
|
||||
## Подготовка ко второму замеру (выполнено 2026-07-04, ~06:30)
|
||||
|
||||
- **llama.cpp собран с CUDA**: `/home/ubuntu/llama.cpp/build/bin/llama-server` (+`llama-cli`; commit 2d973636). Без root: user-space CUDA toolkit 12.6 в `/home/ubuntu/cuda-12.6` (nvcc 12.6.85 из .deb-распаковки + cublas из PyPI-wheel, всего ~450MB загрузки), арх sm_61, rpath вшит — `LD_LIBRARY_PATH` не нужен. CPU-fallback: `build-cpu/bin/`. **GTX 1070 (Pascal) поддерживается CUDA 12.x, на CUDA 13 не обновлять** (Pascal выпилен). Флаги пересборки — в scratchpad-логах и здесь: `cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_COMPILER=$HOME/cuda-12.6/bin/nvcc -DCMAKE_CUDA_ARCHITECTURES=61 -DCMAKE_BUILD_RPATH=$L -DCMAKE_EXE_LINKER_FLAGS="-Wl,-rpath-link,$L -Wl,-rpath,$L"`, где `L=$HOME/cuda-12.6/targets/x86_64-linux/lib`. На GPU ещё не запускался (шёл бенчмарк) — первый прогон проверит подхват `libcuda.so.1` из `/usr/lib/wsl/lib`.
|
||||
- **Пул моделей расширен** (план выше выполнен досрочно): `qwen3:8b` (ваниль, 5.2GB), **`qwen3.5:9b`** (есть в реестре ollama; 9.7B, arch qwen35, vision+thinking; дефолт-сэмплеры реестра temp 1/top_p 0.95/presence 1.5 — для честного сравнения выровнять с qwen3), `huihui_ai/qwen3.5-abliterated:9b` (6.6GB), `ruadapt-qwen3:8b` (создан из официального RefalMachine/RuadaptQwen3-8B-**Hybrid**-GGUF Q4_K_M — Instruct-версии 8B не существует, hybrid = instruct с переключаемым thinking).
|
||||
- **WSL RAM поднят 20→26GB** (`C:\Users\lager\.wslconfig`, бэкап рядом) — вступит после `wsl --shutdown`.
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. Поставить llama.cpp (llama-server), перемерить 30b-a3b с `--n-cpu-moe` — проверка обещанных 25–30 tok/s (главное условие полезности «тяжёлой» локалки).
|
||||
1. **[после рестарта WSL]** Перемерить 30b-a3b: llama-server с `--n-cpu-moe` (веса можно грузить прямо из ollama blob-store — блобы это GGUF; путь через `ollama show`) против ollama при 26GB RAM — проверка обещанных 25–30 tok/s. Добавить в `local_bench.py` ветку llama-server (он OpenAI-совместим по /v1).
|
||||
2. ~~Снять эталон DeepSeek~~ — **сделано** (см. выше): разрыв качественный, локалка остаётся «спецслужбой».
|
||||
3. Расширить пул по плану выше (ваниль 8b → Qwen3.5 → RuadaptQwen3), NSFW-фрагменты владельца — для проверки единственной переводческой роли локалки.
|
||||
3. ~~Расширить пул~~ — **скачано** (см. выше); прогнать `local_bench.py` по четырём новым моделям: цена абляции (qwen3 ваниль vs abliterated), поколение (qwen3 vs 3.5), ru-адаптация (ruadapt). NSFW-фрагменты владельца — для проверки единственной переводческой роли локалки.
|
||||
4. Замер bge-m3 (скорость/качество retrieval) — в рамках задачи 5 (мини-бенчмарк эмбеддингов).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
## TL;DR
|
||||
|
||||
1. Свежий WMT 2025 test-suite по en→ru (465 заданий, 55 явлений, 13 категорий, 10 топ-систем) подтверждает: даже лучшие LLM-системы систематически дают «literal word order carry-overs, misused verb forms, rigid phrase translations» — т.е. три наших режима (тема-рема, вид глагола, кальки конструкций) — это измеренная, а не гипотетическая проблема ([ACL 2025.wmt-1.61](https://aclanthology.org/2025.wmt-1.61/)).
|
||||
2. Русский — уникально «неудобный» целевой язык по роду: в исследованиях гендер-переноса доля «unknown» (род не выражен и его нельзя проверить) минимальна для всех языков, **кроме русского** — прошедшее время и согласования принудительно требуют решения о роде там, где en/zh/ja молчат ([WMT 2020 gender coreference](https://arxiv.org/pdf/2010.06018), [TACL: Gender Bias in MT](https://direct.mit.edu/tacl/article/doi/10.1162/tacl_a_00401/106991/Gender-Bias-in-Machine-Translation)).
|
||||
2. Русский — уникально «неудобный» целевой язык по роду: прошедшее время и согласования принудительно требуют решения о роде там, где en/zh/ja молчат ([TACL: Gender Bias in MT](https://direct.mit.edu/tacl/article/doi/10.1162/tacl_a_00401/106991/Gender-Bias-in-Machine-Translation)). *(Правка верификатора: ранее тезис ссылался и на [WMT 2020](https://arxiv.org/pdf/2010.06018) — там у русского действительно наибольшая доля «unknown»-случаев (12.9–13.5% против 1.3–1.8% у cs/de/pl), но статья объясняет её пробелами морфоанализатора, а не грамматикой, поэтому как опору для этого тезиса ссылку сняли.)*
|
||||
3. Существует проверенный арсенал стратегий письма «без рода» (перестройка фразы, настоящее время, безличные/номинативные конструкции) — формализован в гайдах ООН по гендерно-нейтральному русскому; он конвертируется в протокол «gender: unknown» для переводчика-агента ([UN Russian guidelines](https://www.un.org/ru/gender-inclusive-language/guidelines.shtml)).
|
||||
4. Самый дешёвый и самый заметный читателю класс отказов — механика: английские кавычки вместо тире-диалогов, дефис вместо тире, транслит-междометия («Ауч!», «Упс»), непоследовательная ё. Всё это детектится и чинится детерминированно, без LLM (Розенталь §47–52, Лопатин, правила ё на Грамоте.ру).
|
||||
5. Мат — двусторонний отказ: LLM-санитайзинг даёт «недожим» (злодей ругается «чёрт возьми»), буквализм — «пережим» (мат там, где в оригинале бытовое shit). Русская обсценная лексика табуированнее английских four-letter words, а закон 2014 г. требует маркировки 18+ — это и политика проекта, и двусторонний детерминированный гейт по лексиконам ([Habr/EnglishDom](https://habr.com/ru/companies/englishdom/articles/564036/)).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue