121 lines
6.7 KiB
Python
121 lines
6.7 KiB
Python
#!/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())
|