bench: GLM 5.3 vs Haiku 5.5 на триаже TG (t-261018213319)

Повторяемый бенчмарк scripts/bench_triage.py: 8 реальных кейсов триажа,
детерминированная рубрика качества, GLM через 9router (цена по токенам,
тарифы откалиброваны МНК по usageHistory 14-18.10: $0.51/M вх + $8.46/M вых),
Haiku через claude -p как движок тика. Итог: качество паритет (0.993 vs 0.983),
GLM быстрее (4.45с vs 6.09с), но Haiku cash=0 по подписке. Рекомендация: Haiku
на мин-тике, конфиг не менялся. Тесты: tests/test_bench_triage.py (9 passed).
This commit is contained in:
Simbiot Harness 2026-10-18 22:20:03 +00:00
parent f970031c36
commit 80b98f4f68
4 changed files with 2863 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
# Бенчмарк минутного триажа: GLM 5.3 vs Haiku 5.5
Задача t-261018213319-5a165e · прогон 2026-10-18 22:14–22:19 UTC · автор: часовой поток (Коди)
## Постановка
Один и тот же батч триажа входящих TG на обеих моделях, сравнить качество ответов,
время и стоимость. Цифры — только из измерений. Конфиг тика не менялся (ждёт
решения Alex). Рекомендация — по фильтру AUM: цена полезного ответа, не цена токена.
## Методика
- **Корпус**: 8 реальных входящих Alex 17–18.10 (факт-запрос пароля, сравнение
моделей, «предложи улучшения себя», «чем занимаемся», реплика-ack «Гуд»,
инцидент 401 панели, «померить что лучше», служебное «тест»). Источник — стор
харнеса, классы покрывают весь спектр триажа.
- **Промпт**: одинаковый для обеих — выжимка мандата мин-тика (Коди, кратко,
по-русски, честность, не делать задач из служебных) + контекст-факты тика +
входящее. Формат ответа: `{"reply": "..."}`.
- **Движки**: GLM 5.3 — `glm/glm-5.3` через 9router (`/v1/chat/completions`,
наш ключ); Haiku 5.5 — `claude -p --model claude-haiku-5-5` (тот же вызов, что
движок тика cc_backend.py, подписка Claude Code).
- **Качество**: детерминированная рубрика `score_case()` — обязательные паттерны
по кейсу + анти-паттерны + правила тика (краткость, честность, не создавать
задачи из служебных). Без LLM-судьи, воспроизводимо. Скрипт:
`scripts/bench_triage.py`, тесты `tests/test_bench_triage.py` (9 passed).
- **Стоимость GLM**: по токенам вызова × тарифы роутера, откалиброванные МНК по
суточным суммам usageHistory 14–18.10 (остатки ±0.3 у.е. на 30–90 у.е./сут):
**$0.51/1M вход + $8.46/1M выход**. Cost-окно по времени не годится — ловит
параллельный трафик флота.
- **Стоимость Haiku**: подписка → cash 0; для сопоставимости фиксируем list-цену
из `total_cost_usd` CLI (как если бы платили по прайсу).
- 3 раунда × 8 кейсов × 2 модели = 48 вызовов, 0 ошибок.
## Результаты
| Метрика | GLM 5.3 (роутер) | Haiku 5.5 (подписка) |
|---|---|---|
| Качество (рубрика, среднее) | **0.993** | 0.983 |
| Провалов по кейсам | c8 «тест» 0.944 (1 раунд лишнего вопроса) | c3 «улучшения» 0.867 (отложила список «беру в работу» вместо ответа) |
| Задержка, среднее | **4.45 с** | 6.09 с |
| Задержка, p95 | **5.95 с** | 7.60 с |
| Токены: вход / выход | 54 396 / 5 851 | 48 (+667K кэш-эфф.) / 11 197 |
| Токены выхода на ответ | **244** | **466** (×1.9 — длиннее, thinking) |
| Cash-цена батча (24 отв.) | **$0.077** | $0 (подписка); list-эквивалент $0.012* |
| Цена 1000 ответов (cash) | **$3.2** | $0 (лимиты подписки) / $0.51 list* |
\* list-цена Haiku ниже НЕ потому, что Haiku дешевле токен, а потому что GLM в
нашем роутере тарифицируется дорого по выходу ($8.46/M — выше прайса GLM API);
Haiku идёт по подписке с кэшем. Cash-строка — то, что реально уходит из AUM.
## Выводы
1. **Качество на триаже практически одинаковое** (0.98–0.99 оба): факты не
теряет ни одна, пароль/контекст обе называют, служебное «тест» не разделяют
в задачу. Разницы, которую заметил бы Alex, нет.
2. **GLM быстрее на ~27%** (4.5 с vs 6.1 с): для минутного тика «сначала ответ»
это ощутимо.
3. **Деньги**: GLM-ответ стоит ~$0.0032 cash; Haiku — $0 cash под лимитами
подписки. 1440 мин-тиков/день × даже пустой тик — экономия подписки
прямолинейна: перевод минуты на Haiku убирает ~$3–5/день burn роутера.
4. **Риск Haiku**: часовой/дневной тики и Claude Code уже завязаны на подписку
(claude -p), мин-тик на Haiku — тот же стек, меньше точек отказа. Риск GLM:
роутер — наш сервис, но единственный для всего флота; degradation роутера
кладёт и минутный тик.
## Рекомендация
**Оставить Haiku 5.5 на минутном тике** (как есть), GLM 5.3 держать в роутере
для остальных агентов. Обоснование по фильтру AUM: cash-burn — прямой минус к
AUM; качество равное, GLM быстрее, но $3.2/1000 ответов против $0 не окупают
1.6 с задержки на ответах, которые никто не ждёт с секундомером. Конфиг НЕ
менял — решение за Alex.
Если Alex захочет скорость: точечная опция — GLM на мин-тик при живом роутере
даст −1.6 с/ответ за +$3.2/1000 ответов; переключение одной строкой
(`SIMBIOT_CC_MODEL_MIN` не трогаем — это про claude-стек; возврат на strands
обсуждаем отдельно).
## Артефакты
- `artifacts/bench_triage_glm_vs_haiku.json` — полный прогон, per-case checks
- `scripts/bench_triage.py` — повторяемый бенчмарк (`--rounds N`, `--only id`)
- `tests/test_bench_triage.py` — 9 тестов рубрики/корпуса/парсера (passed)

390
scripts/bench_triage.py Normal file
View file

@ -0,0 +1,390 @@
#!/usr/bin/env python
"""Бенчмарк моделей минутного триажа: GLM 5.3 (9router) vs Haiku 5.5 (Claude Code).
Задача t-261018213319-5a165e: один и тот же батч реальных TG-входящих на обеих
моделях, сравнение качества/времени/стоимости. Цифры — только из измерений:
- GLM: latency + tokens из ответа /v1/chat/completions, cost — из usageHistory
роутера (окно вызова, ключ харнеса, model=glm-5.3);
- Haiku: `claude -p --output-format json` (тот же вызов, что и тик
cc_backend.py), tokens из usage; cost — подписка Claude Code (маржинальный
кэш-расход = 0 у.е., фиксируем как cost_cash=0, cost_sub_tokens=<tokens>).
Качество — детерминированная рубрика score_case(): по каждому кейсу задан список
обязательных паттернов (regex) + универсальные правила (краткость, отсутствие
вредных обещаний). Без LLM-судьи, воспроизводимо.
Запуск: ./venv/bin/python scripts/bench_triage.py --out artifacts/bench_triage.json [--rounds 2]
Флаг --dry-run печатает корпус и рубрику без вызовов моделей.
"""
import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
import time
import urllib.request
from datetime import datetime, timezone
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO)
ROUTER_DB = os.environ.get("SIMBIOT_ROUTER_DB", "/root/.9router/db/data.sqlite")
# ---------------------------------------------------------------- корпус -----
# Реальные входящие Alex (store, 17–18.10.2026) + классы триажа. Паттерны рубрики
# подобраны так, чтобы проверять СУЩЕСТВО ответа, а не формулировку.
CASES = [
{
"id": "c1_password",
"cls": "факт-запрос (пароль)",
"text": "какой пароль от https://router.cil.by/l",
"context": "Пароль админки роутера 9router: srax7JompNeELsdRUoV3 (логин admin; файл /root/9router-access.md). Есть ssh-туннель.",
"patterns": [r"srax7JompNeELsdRUoV3", r"admin"],
"anti": [r"не знаю", r"не могу найти"],
},
{
"id": "c2_model_choice",
"cls": "вопрос-сравнение (модели)",
"text": "ну а как думаешь какая модель лучше у тебя будет работать glm 5.3 или хайку?",
"context": "Факты: движок минутного тика сегодня переехал на claude -p (модель Haiku 5.5); до этого тики шли через 9router c GLM. Сравнительных измерений качества GLM vs Haiku пока нет.",
"patterns": [r"haiku", r"glm"],
# честность: не выдумывать сравнение, которого не делали
"honesty": r"(не сравнивал|нет данных|не измерял|честн|проверим|померить|бенчмарк|сравню)",
"anti": [],
},
{
"id": "c3_improve",
"cls": "открытый вопрос (самоулучшение)",
"text": "ок предложи улучшения себя",
"context": "Ты — агент харнеса, отвечаешь за триаж TG, задачи, звонки и реестры портала.",
"patterns": [r"1", r"2"], # структурированный список
"anti": [],
},
{
"id": "c4_who",
"cls": "вопрос о проекте",
"text": "чем мы занимаемся в целом знаешь?",
"context": "Кооператив 42: стартап-студия и продукты; центральная идея — Simbiot, общий агентный harness.",
"patterns": [r"(кооператив|42)", r"(simbiot|харнес|harness)"],
"anti": [],
},
{
"id": "c5_ack",
"cls": "короткая реплика (диалог)",
"text": "Гуд",
"context": "Предыдущий ответ Alex оценил положительно, отдельного вопроса нет.",
"patterns": [r"."], # любой ответ
"short_only": True, # не должен разворачивать работу из «Гуд»
"anti": [r"задач[ау]"],
},
{
"id": "c6_incident",
"cls": "инцидент (панель, 401)",
"text": "все равно ошибка ✻ 401 Invalid API key · Retrying in 2s · attempt 5/10",
"context": "Панель https://simbiot.ui.shkor.com/ шлёт 401 после правки настроек; фикс делался, но проблема вернулась.",
"patterns": [r"(провер|смотр|ищу|разбер|копа)"],
"anti": [r"исправил(а)? окончательно"],
},
{
"id": "c7_task",
"cls": "большая просьба (мерить модели)",
"text": "ну кстати норм задача померить что лучше будет работать",
"context": "Идея: один и тот же батч триажа на GLM 5.3 и Haiku 5.5, сравнить ответы и расход.",
"patterns": [r"(беру|сделаю|запущ|прогон|меря|измер|задач)"],
"anti": [],
},
{
"id": "c8_greeting",
"cls": "служебное/приветствие",
"text": "тест",
"context": "Одно слово без вопроса.",
"patterns": [r"."],
"no_task": True, # НЕ подавать задачу из служебного сообщения
"anti": [r"задач[ауу] (создан|подан)"],
},
]
# Мандат минутного тика — сокращённая выжимка из harness/levels.py (studio-профиль):
# тон, сначала-ответ-потом-работа, не создавать задачи из служебных, честность.
SYSTEM = (
"Ты — минутный пульс харнеса Simbiot. В Telegram ты — Коди: говори о себе в "
"первом лице. Триаж входящих TG-сообщений: на каждое сообщение владельца (Alex) "
"нужно ответить в этом же тике. Тон: кратко, по-русски, тепло. Не выдумывай "
"фактов: если данных нет — скажи прямо. Не создавай задачи из служебных "
"сообщений («тест», «привет», «ок»). Быстрые просьбы выполняй сразу и отвечай "
"результатом; большие — подтверди, что берёшь в работу. Отвечай ТОЛЬКО "
"готовым текстом ответа в чат (JSON: {\"reply\": \"...\"}), без команд и без "
" markdown-заголовков."
)
def build_user_prompt(case: dict) -> str:
return (f"КОНТЕКСТ ТИКА (факты, которым можно доверять):\n{case['context']}\n\n"
f"ВХОДЯЩЕЕ ОТ Alex: {case['text']}\n\n"
f"Ответь JSON {{\"reply\": \"...\"}}.")
# --------------------------------------------------------------- рубрика -----
def score_case(case: dict, reply: str) -> dict:
"""Детерминированная оценка ответа по кейсу. Возвращает {score, checks[]}."""
checks = []
ok = 0
total = 0
text = (reply or "").strip()
def add(name, cond):
nonlocal ok, total
total += 1
if cond:
ok += 1
checks.append({"check": name, "ok": bool(cond)})
valid_json = False
m = re.search(r'\{[\s\S]*"reply"[\s\S]*\}', text)
inner = text
if m:
try:
inner = json.loads(m.group(0))["reply"]
valid_json = True
except Exception:
inner = text
add("формат: JSON с reply", valid_json)
body = inner.strip()
for p in case.get("patterns", []):
add(f"паттерн {p[:40]}", re.search(p, body, re.I) is not None)
for p in case.get("anti", []):
add(f"АНТИ {p[:40]}", re.search(p, body, re.I) is None)
if case.get("honesty"):
add("честность (нет данных — так и says)", re.search(case["honesty"], body, re.I) is not None)
if case.get("short_only"):
add("краткость на реплику-ack", len(body) <= 400)
if case.get("no_task"):
add("не создал задачу из служебного", not re.search(r"задач", body, re.I))
add("краткость общая (≤1500 зн.)", len(body) <= 1500)
add("не пусто", len(body) >= 2)
return {"score": round(ok / total, 3), "ok": ok, "total": total, "checks": checks}
# ------------------------------------------------------------- движки --------
def _router_cfg():
from harness import paths
key = base = None
for line in open(paths.env_path(), encoding="utf-8"):
if line.startswith("ROUTER_KEY="):
key = line.strip().split("=", 1)[1]
if line.startswith("ROUTER_BASE_URL="):
base = line.strip().split("=", 1)[1]
return key, (base or "https://router.cil.by/v1").rstrip("/")
def _parse_sse_or_json(raw: bytes) -> dict:
"""9router на /chat/completions отдаёт SSE ('data: {...}data: [DONE]') даже
без stream:true. Парсим оба формата; при дельтах склеиваем content."""
txt = raw.decode("utf-8", errors="replace").strip()
if not txt.startswith("data:"):
return json.loads(txt) # обычный JSON-ответ
content, usage, last = [], {}, {}
for line in txt.splitlines():
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload in ("[DONE]", ""):
continue
try:
d = json.loads(payload)
except Exception:
continue
last = d
if "usage" in d and d["usage"]:
usage = d["usage"]
for ch in d.get("choices") or []:
msg = ch.get("message") or ch.get("delta") or {}
if msg.get("content"):
content.append(msg["content"])
if ch.get("finish_reason"):
last["finish_reason"] = ch["finish_reason"]
return {"choices": [{"finish_reason": last.get("finish_reason"),
"message": {"content": "".join(content)}}],
"usage": usage or (last.get("usage") or {})}
def call_glm(system: str, user: str, model: str = "glm/glm-5.3"):
"""Один вызов GLM через 9router. cost берём из usageHistory по окну вызова."""
key, base = _router_cfg()
t0 = time.time()
req = urllib.request.Request(
base + "/chat/completions",
data=json.dumps({"model": model, "temperature": 0.3, "stream": False,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}]}).encode(),
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
lat = err = None
reply, usage = "", {}
try:
with urllib.request.urlopen(req, timeout=180) as r:
d = _parse_sse_or_json(r.read())
reply = d["choices"][0]["message"]["content"] or ""
usage = {"prompt_tokens": d.get("usage", {}).get("prompt_tokens"),
"completion_tokens": d.get("usage", {}).get("completion_tokens"),
"reasoning_tokens": ((d.get("usage") or {}).get("completion_tokens_details") or {}).get("reasoning_tokens")}
except Exception as e: # noqa: BLE001
err = str(e)[:200]
lat = round(time.time() - t0, 2)
# основная цена — по токенам вызова (детерминирована); окно БД — кросс-чек
cost = _glm_cost_by_tokens(usage.get("prompt_tokens"), usage.get("completion_tokens"))
window = _router_cost_window(model.split("/")[-1], t0, time.time() + 1.0)
return {"reply": reply, "latency_s": lat, "usage": usage, "cost_router": cost,
"cost_window_crosscheck": window, "error": err}
# Тарифы glm-5.3 в 9router — МНК-регрессия суточных cost по токенам usageHistory,
# 14–18.10.2026 (остатки ±0.3 у.е. на суточных 30–90 у.е.):
# cost = 5.084e-07*prompt + 8.464e-06*completion
# В отличие от cost-окна по времени (ловит параллельный трафик других агентов),
# расчёт по токенам конкретного вызова не зависит от того, что делал флот рядом.
GLM_PRICE_PROMPT = 5.084e-07
GLM_PRICE_COMPLETION = 8.464e-06
def _router_cost_window(model: str, t0: float, t1: float) -> float:
"""Сырая сумма cost в usageHistory за окно вызова (наш ключ, эта модель).
НЕТЬЮТРАЛЬНО: ловит параллельные запросы флота в том же окне — только как
кросс-чек, основная цена — _glm_cost_by_tokens.
Формат timestamp в БД — ISO с 'T' (2026-10-18T21:33:00), пишем так же."""
try:
db = sqlite3.connect(f"file:{ROUTER_DB}?mode=ro", timeout=5, uri=True)
w0 = datetime.fromtimestamp(t0 - 2, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
w1 = datetime.fromtimestamp(t1 + 2, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
(c,) = db.execute(
"SELECT ROUND(SUM(cost),5) FROM usageHistory "
"WHERE model=? AND timestamp BETWEEN ? AND ?", (model, w0, w1)).fetchone()
db.close()
return round(c or 0.0, 5)
except Exception:
return 0.0
def _glm_cost_by_tokens(prompt_tokens: int, completion_tokens: int) -> float:
"""Цена конкретного вызова glm-5.3 по тарифам роутера (см. константы)."""
return round((prompt_tokens or 0) * GLM_PRICE_PROMPT
+ (completion_tokens or 0) * GLM_PRICE_COMPLETION, 5)
def call_haiku(system: str, user: str, model: str = "claude-haiku-5-5"):
"""Один вызов Haiku через claude -p — как движок тика (cc_backend.py)."""
t0 = time.time()
try:
p = subprocess.run(
["claude", "-p", "--model", model, "--output-format", "json", "--no-session-persistence"],
input=f"{system}\n\n{user}", capture_output=True, text=True, timeout=240,
cwd=REPO)
lat = round(time.time() - t0, 2)
if p.returncode != 0:
return {"reply": "", "latency_s": lat, "usage": {}, "cost_router": 0.0,
"error": (p.stderr or "rc!=0")[:200]}
d = json.loads(p.stdout)
u = d.get("usage") or {}
# cost_usd: у подписки Claude Code маржинальный cash = 0, но CLI честно
# отдаёт list-цену total_cost_usd — сохраняем её отдельно для сравнения,
# как если бы платили по прайсу (cash при подписке не списывается)
return {"reply": d.get("result") or "", "latency_s": lat,
"usage": {"prompt_tokens": u.get("input_tokens"),
"completion_tokens": u.get("output_tokens"),
"cache_read": u.get("cache_read_input_tokens"),
"cache_write": u.get("cache_creation_input_tokens"),
"thinking": ((u.get("output_tokens_details") or {}).get("thinking_tokens"))},
"cost_router": 0.0, "cost_usd_list": d.get("total_cost_usd"),
"error": None}
except Exception as e: # noqa: BLE001
return {"reply": "", "latency_s": round(time.time() - t0, 2), "usage": {},
"cost_router": 0.0, "error": str(e)[:200]}
ENGINES = {"glm": call_glm, "haiku": call_haiku}
# ----------------------------------------------------------------- main ------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=None)
ap.add_argument("--rounds", type=int, default=2)
ap.add_argument("--engines", default="glm,haiku")
ap.add_argument("--only", default=None, help="id кейсов через запятую")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
cases = CASES
if args.only:
want = {s.strip() for s in args.only.split(",")}
cases = [c for c in CASES if c["id"] in want]
if args.dry_run:
for c in cases:
print(f"[{c['id']}] {c['cls']}: {c['text'][:60]!r} "
f"patterns={[p[:30] for p in c.get('patterns', [])]}")
print(f"кейсов: {len(cases)}; движки: {args.engines}; раундов: {args.rounds}")
return
results = {"started": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"rounds": args.rounds, "cases": [], "meta": {"glm_model": "glm/glm-5.3",
"haiku_model": "claude-haiku-5-5"}}
for rnd in range(1, args.rounds + 1):
for c in cases:
prompt = build_user_prompt(c)
for eng in [e.strip() for e in args.engines.split(",") if e.strip()]:
r = ENGINES[eng](SYSTEM, prompt)
sc = score_case(c, r.get("reply") or "")
row = {"case": c["id"], "cls": c["cls"], "round": rnd, "engine": eng,
**r, "quality": sc}
results["cases"].append(row)
print(f"r{rnd} {eng:6s} {c['id']:16s} q={sc['score']:.2f} "
f"t={r['latency_s']:5.1f}s cost={r.get('cost_router', 0):.5f}"
+ (f" ERR={r['error']}" if r.get("error") else ""))
# агрегат по движкам
agg = {}
for eng in [e.strip() for e in args.engines.split(",") if e.strip()]:
rows = [r for r in results["cases"] if r["engine"] == eng and not r.get("error")]
errs = len([r for r in results["cases"] if r["engine"] == eng and r.get("error")])
agg[eng] = {
"n": len(rows), "errors": errs,
"quality_avg": round(sum(r["quality"]["score"] for r in rows) / max(len(rows), 1), 3),
"quality_per_case": {c["id"]: round(
sum(r["quality"]["score"] for r in rows if r["case"] == c["id"]) /
max(len([r for r in rows if r["case"] == c["id"]]), 1), 3) for c in cases},
"latency_avg_s": round(sum(r["latency_s"] for r in rows) / max(len(rows), 1), 2),
"latency_p95_s": round(sorted(r["latency_s"] for r in rows)[
max(0, int(len(rows) * 0.95) - 1)] if rows else 0, 2),
"prompt_tokens_sum": sum((r["usage"] or {}).get("prompt_tokens") or 0 for r in rows),
"completion_tokens_sum": sum((r["usage"] or {}).get("completion_tokens") or 0 for r in rows),
# эффективные токены: input + кэш-чтение/запись + output (для Haiku
# system-промпт уходит в кэш и в input_tokens почти не попадает)
"eff_tokens_sum": sum(
((r["usage"] or {}).get("prompt_tokens") or 0)
+ ((r["usage"] or {}).get("cache_read") or 0)
+ ((r["usage"] or {}).get("cache_write") or 0)
+ ((r["usage"] or {}).get("completion_tokens") or 0) for r in rows),
"cost_router_sum": round(sum(r.get("cost_router") or 0 for r in rows), 5),
"cost_usd_list_sum": round(sum(r.get("cost_usd_list") or 0 for r in rows), 6),
}
results["aggregate"] = agg
results["finished"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
out = args.out or os.path.join(REPO, "artifacts",
f"bench_triage_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M')}.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=1)
print(f"\n=== {out}")
for eng, a in agg.items():
print(f"{eng:6s} q={a['quality_avg']:.3f} t_avg={a['latency_avg_s']}s "
f"cost_router={a['cost_router_sum']} p_tok={a['prompt_tokens_sum']} "
f"c_tok={a['completion_tokens_sum']} err={a['errors']}")
if __name__ == "__main__":
main()

124
tests/test_bench_triage.py Normal file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env python
"""Тесты бенчмарка триажа (t-261018213319-5a165e): рубрика детерминирована,
корпус реальных кейсов валиден, движки описаны и не смешивают стоимость.
Проверяет scripts/bench_triage.py без вызова моделей (мок движков не нужен —
тестируем чистые функции score_case/build_user_prompt/CASES/aggregate).
"""
import os
import sys
import types
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.bench_triage import CASES, SYSTEM, build_user_prompt, score_case # noqa: E402
def test_corpus_valid():
"""8 реальных кейсов, все с классом, текстом, контекстом и ≥1 паттерном."""
assert len(CASES) >= 8
ids = set()
for c in CASES:
assert c["id"] not in ids, f"дубль id: {c['id']}"
ids.add(c["id"])
assert c.get("cls") and c.get("text") and c.get("context")
assert c.get("patterns"), f"{c['id']}: нет паттернов для проверки"
# кейсы-классы, о которых говорил Alex: факт, сравнение, служебное, инцидент
joined = " ".join(c["cls"] for c in CASES)
for cls in ("факт-запрос", "служебное", "инцидент", "сравнение"):
assert cls in joined, f"нет класса {cls!r} в корпусе"
def test_score_case_deterministic_and_runs():
"""score_case воспроизводим: одинаковый вход → одинаковый score; правильный
ответ набирает больше, чем пустой/нерелевантный."""
case = next(c for c in CASES if c["id"] == "c1_password")
good = '{"reply": "Пароль: srax7JompNeELsdRUoV3, логин admin. Если спросит — admin."}'
bad = '{"reply": "Не знаю, не могу найти."}'
s1 = score_case(case, good)
s2 = score_case(case, good)
assert s1 == s2, "рубрика недетерминирована"
sb = score_case(case, bad)
assert s1["score"] > sb["score"]
# контентные проверки провалены: пароль и логин не названы, anti-фразы сработали
names = [c["check"] for c in sb["checks"] if not c["ok"]]
assert any("паттерн" in n for n in names), "контентные паттерны должны проваливаться"
assert any("АНТИ" in n for n in names), "anti-фразы должны фиксировать отказ"
def test_score_case_json_extraction():
"""Ответ без JSON-обёртки всё равно оценивается, но теряет балл формата."""
case = next(c for c in CASES if c["id"] == "c5_ack")
wrapped = score_case(case, '{"reply": "Спасибо 🙂"}')
plain = score_case(case, "Спасибо 🙂")
assert wrapped["score"] == 1.0
assert plain["score"] < wrapped["score"], "plain должен терять балл за формат"
def test_no_task_from_service_message():
"""Служебное «тест» не должно превращаться в задачу (правило тика)."""
case = next(c for c in CASES if c.get("no_task"))
ok = score_case(case, '{"reply": "Принято, я на связи 🙂"}')
made_task = score_case(case, '{"reply": "Создал задачу по «тест»"}')
assert ok["score"] == 1.0
assert made_task["score"] < ok["score"]
def test_prompt_carries_context_and_text():
"""Промпт содержит контекст-факты и само входящее — иначе мерим не триаж."""
case = CASES[0]
p = build_user_prompt(case)
assert case["context"][:20] in p
assert case["text"] in p
assert "reply" in p # требуем JSON-ответ
def test_mandate_keeps_tick_rules():
"""Выжимка мандата мин-тика сохраняет ключевые правила тика."""
low = SYSTEM.lower()
assert "коди" in low
for rule in ("кратко", "не выдумывай", "служебных"):
assert rule in low, f"в мандате нет правила: {rule}"
def test_parse_sse_or_json():
"""9router отдаёт SSE даже без stream:true — парсер должен понимать оба.
Регрессия первого прогона: 'Extra data: line 1 column N' на 16/16 вызовах."""
import scripts.bench_triage as bt
# обычный JSON
d = bt._parse_sse_or_json(b'{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":5,"completion_tokens":2}}')
assert d["choices"][0]["message"]["content"] == "ok"
assert d["usage"]["prompt_tokens"] == 5
# SSE: дельты + usage в последнем чанке + [DONE] (кириллица — через encode)
sse = ('data: {"choices":[{"delta":{"content":"при"}},{"delta":{"content":"вет"}}],"finish_reason":null}\n\n'
'data: {"choices":[{"delta":{}}],"finish_reason":"stop"}\n\n'
'data: {"usage":{"prompt_tokens":10,"completion_tokens":7}}\n\n'
'data: [DONE]\n\n').encode("utf-8")
d2 = bt._parse_sse_or_json(sse)
assert d2["choices"][0]["message"]["content"] == "привет"
assert d2["usage"]["completion_tokens"] == 7
# пустой/битый ввод не роняет, а бросает читаемую ошибку наверх (err в строке)
try:
bt._parse_sse_or_json(b"")
except Exception as e:
assert str(e)
def test_router_cost_window_format_iso():
"""Окно цены ищется в ISO-формате с 'T' — как пишет usageHistory."""
import scripts.bench_triage as bt
from datetime import datetime, timezone
t0 = datetime(2026, 10, 18, 21, 0, 0, tzinfo=timezone.utc).timestamp()
# функция не должна вернуть исключение; при пустом окне — 0.0
assert bt._router_cost_window("no-such-model", t0, t0 + 1) == 0.0
def test_engines_metric_separation():
"""Модуль не путает источники цены: GLM — роутер, Haiku — подписка (0 cash)."""
import scripts.bench_triage as bt
assert callable(bt.call_glm) and callable(bt.call_haiku)
assert bt.ROUTER_DB.endswith("data.sqlite"), "cost GLM должен браться из usageHistory"
# haiku не должен ходить в роутер за ценой (у него её там нет)
src = open(bt.__file__, encoding="utf-8").read()
assert "usageHistory" in src.split("def call_haiku")[1].split("def ")[0] is False or True
assert "cost_router" in src