textmachine/eval/pkg7/role_probe.py

121 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Полигон, пакет-8: драйвер БОЕВОГО запроса роли терминолога.
Отправляет ровно то, что собрал `termharness -mode build` (системный промт роли + якорь ⟦TM-CANON⟧ +
блок кандидатов) — ни одного своего слова в промте нет. Полигон отвечает только за сетку параметров,
повторы и деньги.
Дисциплина (нормы D39.46/47):
- СМЕТА печатается ДО первого вызова; `--dry-run` даёт её без единой траты;
- сырьё каждого вызова сохраняется целиком (сообщения, ответ, usage, finish_reason) — выводы потом
пересчитываются ИЗ сырья, а не из памяти прогона;
- деньги считаются из фактического usage по `backend/configs/models.yaml` (read-only), с учётом
кэш-цены prompt_tokens_details.cached_tokens;
- max_tokens = 8000 — не круглое число, а пол `min_max_tokens` deepseek-v4-flash из models.yaml,
к которому боевой `terminologyCallBudget` и приводит батч такого размера (est/2+256 < 8000);
- thinking у DeepSeek НЕ отключается (гардрейл проекта: эхо-мина).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
import yaml
from openai import OpenAI
REPO = Path(__file__).resolve().parents[2]
def load_prices(models_yaml: Path) -> dict:
doc = yaml.safe_load(models_yaml.read_text(encoding="utf-8")) or {}
out = {}
for name, m in (doc.get("models") or {}).items():
p = (m or {}).get("price") or {}
out[name] = (p.get("input_per_m", 0.0), p.get("output_per_m", 0.0), p.get("cached_per_m", 0.0))
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--req", required=True, type=Path, help="выход termharness -mode build")
ap.add_argument("--raw-dir", required=True, type=Path)
ap.add_argument("--tag", required=True, help="метка точки сетки (идёт в имена файлов сырья)")
ap.add_argument("--repeats", type=int, default=1)
ap.add_argument("--model", default="deepseek-v4-flash")
ap.add_argument("--base-url", default="https://api.deepseek.com/v1")
ap.add_argument("--key-env", default="DEEPSEEK_API_KEY")
ap.add_argument("--max-tokens", type=int, default=8000)
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
req = json.loads(a.req.read_text(encoding="utf-8"))
batches = req["batches"]
prices = load_prices(REPO / "backend/configs/models.yaml")
pin, pout, pcached = prices.get(a.model, (0.0, 0.0, 0.0))
# Оценка входа: тот же грубый делитель, что в пакете-7 (симв./3.2). Смета — верхняя граница по
# выходу (max_tokens), как и боевая terminologyEstimateUSD, поэтому факт всегда ниже.
chars = sum(len(m["content"]) for b in batches for m in b["messages"])
est_in = chars / 3.2 * a.repeats
est_out = len(batches) * a.max_tokens * a.repeats
print(f"ПЛАН [{a.tag}]: батчей {len(batches)} × повторов {a.repeats} = {len(batches)*a.repeats} вызовов; "
f"термов {req['candidates']}; kwic {req['kwic_per']}×{req['kwic_width']}")
print(f"СМЕТА ДО ВЫЗОВОВ: вход ≈{est_in/1000:.1f}k ток → ${est_in/1e6*pin:.4f}; "
f"выход ≤{est_out/1000:.1f}k ток → ≤${est_out/1e6*pout:.4f}; "
f"ИТОГО ≤ ${est_in/1e6*pin + est_out/1e6*pout:.4f}")
if a.dry_run:
return 0
key = os.environ.get(a.key_env)
if not key:
print(f"нет ключа {a.key_env}", file=sys.stderr)
return 2
client = OpenAI(api_key=key, base_url=a.base_url)
a.raw_dir.mkdir(parents=True, exist_ok=True)
total = 0.0
for rep in range(a.repeats):
for b in batches:
# $0-резюм по образцу боевого чекпойнта: уже оплаченный батч не перепокупается. Без этого
# любой таймаут харнесса означал бы повторную оплату всей точки сетки.
done = a.raw_dir / f"{a.tag}_r{rep}_b{b['index']}.json"
if done.exists() and "error" not in json.loads(done.read_text(encoding="utf-8")):
continue
msgs = [{"role": m["role"], "content": m["content"]} for m in b["messages"]]
t0 = time.time()
try:
resp = client.chat.completions.create(model=a.model, messages=msgs, max_tokens=a.max_tokens)
except Exception as e: # noqa: BLE001 — ошибка вызова тоже улика, не повод падать молча
print(f"[{a.tag} r{rep} b{b['index']}] ОШИБКА: {e}", file=sys.stderr)
(a.raw_dir / f"{a.tag}_r{rep}_b{b['index']}.json").write_text(
json.dumps({"tag": a.tag, "rep": rep, "batch": b["index"], "error": str(e)},
ensure_ascii=False, indent=1), encoding="utf-8")
continue
dt = time.time() - t0
txt = resp.choices[0].message.content or ""
u = resp.usage
cin = getattr(u, "prompt_tokens", 0)
cout = getattr(u, "completion_tokens", 0)
cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0
usd = (cin - cached) / 1e6 * pin + cached / 1e6 * pcached + cout / 1e6 * pout
total += usd
(a.raw_dir / f"{a.tag}_r{rep}_b{b['index']}.json").write_text(
json.dumps({"tag": a.tag, "rep": rep, "batch": b["index"], "keys": b["keys"],
"messages": msgs, "response": txt,
"finish": resp.choices[0].finish_reason,
"usage": {"in": cin, "out": cout, "cached": cached},
"usd": usd, "seconds": round(dt, 1),
"kwic_per": req["kwic_per"], "kwic_width": req["kwic_width"]},
ensure_ascii=False, indent=1), encoding="utf-8")
print(f"[{a.tag} r{rep} b{b['index']}] in={cin} (cached {cached}) out={cout} "
f"finish={resp.choices[0].finish_reason} ${usd:.5f} {dt:.0f}s")
print(f"ИТОГО ФАКТ [{a.tag}]: ${total:.5f}")
return 0
if __name__ == "__main__":
sys.exit(main())