#!/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=). Качество — детерминированная рубрика 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()