textmachine/eval/editor_tier/screen.py

235 lines
13 KiB
Python
Raw 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
"""ФАЗА A — СКРИН СИЛЬНОГО ТИРА. Платный (потолок $1.10), идёт через кассу пака.
Пороги НЕ свои: берутся у эксп-22 побайтно (`screen.PRICE_CAP/CYR_MIN/ECHO_MAX`, `gates`,
`verdict`). Порог, взятый из прошлого пака без правки, — единственная защита от подгонки состава
под желаемый результат, а состав здесь и есть предмет спора: пак существует потому, что эксп-22
срезал сильный тир.
⚠ ОСОБЫЙ РЕЖИМ `kimi-k3`, объявлен ДО покупок. По отношению «цена клетки / цена выхода» внутри
Kimi он ожидается ~$0.25/ед — вчетверо выше порога роли, — и `k2.6` уже отдал пустой выход на трёх
клетках из четырёх при 100% доли размышления. Полный скрин был бы покупкой предсказуемого провала
примерно за доллар. Берётся ОДНА клетка редакторской роли с жёстким потолком выхода: этого хватает
и на ценовой вердикт, и на второй замер отказного режима. Отдай он против ожидания годный дешёвый
выход — досняли бы полным скрином отдельным решением.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO / "eval" / "tenant_panel"))
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
def _load(name: str, path: Path):
"""⚠ Модули соседнего пака грузятся ПО ПУТИ, а не `import <имя>`. При прямом запуске своя
папка стоит в sys.path первой, и `import screen` находит ЭТОТ файл, а не скрин эксп-22 —
молчаливый само-импорт. Ловилось трижды подряд (ростер, касса, скрин), поэтому правило одно:
всё чужое — только явной загрузкой."""
import importlib.util # noqa: PLC0415
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
TP = REPO / "eval" / "tenant_panel"
S22 = _load("screen22", TP / "screen.py")
MONEY = _load("money_et", ZONE / "money.py")
ROSTER = MONEY.ROSTER_ET
PHASE = "ФA"
NEW = list(ROSTER.NEW) # gpt-5.6-terra · kimi-k3 · grok-4.5 · glm-5.2
SPECIAL = {"kimi-k3": dict(units=1, roles=("editor",), max_out=4000)}
ROLES = ("translator", "editor")
def client(model: str):
"""Соединение по РАСШИРЕННОЙ таблице. Клиент эксп-22 знает только свои 11 слагов и падает
KeyError на новом кандидате — тот же класс, что уронил его собственный скрин на середине."""
import os # noqa: PLC0415
from dotenv import load_dotenv # noqa: PLC0415
from openai import OpenAI # noqa: PLC0415
load_dotenv(REPO / "eval" / ".env")
base, env, *_ = ROSTER.CANDIDATES[model]
key = os.environ.get(env)
if not key:
raise SystemExit(f"нет ключа {env} для {model}")
return OpenAI(base_url=base, api_key=key, timeout=600.0, max_retries=1)
def tag(model: str, role: str, uid: str) -> str:
return f"et-scr-{model}-{role}-{uid[:6]}"
def plan() -> list[tuple[str, str, dict]]:
"""Что будет куплено. Печатается ДО покупок и лежит во фризе."""
us = S22.units() # те же две единицы, что скринил эксп-22
out = []
for m in ROSTER.by_new_price():
sp = SPECIAL.get(m, {})
for role in sp.get("roles", ROLES):
for u in us[:sp.get("units", len(us))]:
out.append((m, role, u))
return out
def cmd_plan() -> None:
tot = 0.0
print(f"{'модель':16s}{'роль':12s}{'ед.':>4s}{'ожидание $':>12s}")
for m in ROSTER.by_new_price():
sp = SPECIAL.get(m, {})
n = len(sp.get("roles", ROLES)) * sp.get("units", len(S22.units()))
exp = ROSTER.EXPECT_PER_UNIT[m] * n
tot += exp
print(f"{m:16s}{'/'.join(sp.get('roles', ROLES)):12s}{n:4d}{exp:12.3f}"
+ (" ← особый режим" if m in SPECIAL else ""))
print(f"{'ИТОГО':16s}{'':12s}{'':4s}{tot:12.3f} потолок {MONEY.CEILINGS[PHASE]:.2f}")
if tot > MONEY.CEILINGS[PHASE]:
print("⛔ смета выше потолка фазы — СТОП, не резать молча")
raise SystemExit(1)
def cmd_run() -> None:
led = MONEY.Guarded(PHASE)
for m, role, u in plan():
t = tag(m, role, u["uid"])
if (MONEY.OUT / f"{t}.json").exists():
continue
msgs = S22.msgs_for(role, u)
kw = ROSTER.call_kwargs(m, msgs, max_out=SPECIAL.get(m, {}).get("max_out", 16000))
rec = MONEY.purchase(led, t, m, client(m), kw, role=role, uid=u["uid"])
if rec is None:
print(f" {t}: не куплено")
continue
print(f" {t}: ${rec.get('cost_usd', 0):.5f} finish={rec.get('finish')} "
f"знаков={len(rec.get('content') or '')}")
MONEY.report()
def _share(rec: dict) -> float:
"""Доля размышления в ТАРИФИЦИРУЕМОМ выходе — по таблице биллинга ЭТОГО пака.
⚠ Гейты эксп-22 считают знаменатель своей таблицей режимов, где новых слагов нет, и берут
выход без размышления. Для `grok-4.5` (аддитивный биллинг) это давало долю 227.6% — величину,
которой не бывает. На вердикт не влияло (цена берётся из кассы), но печаталось неверное число,
а числа без смысла в отчёте стоять не должны.
"""
out = ROSTER.billed_output(rec["model"], rec.get("completion_tokens", 0),
rec.get("reasoning_tokens", 0), rec.get("total_tokens", 0),
rec.get("prompt_tokens", 0))
return round(rec.get("reasoning_tokens", 0) / max(1, out), 3)
def verdict_editor(rows: list[dict]) -> tuple[str, list[str]]:
"""ПРЕ-РЕГ-критерий этого пака: он скринит РЕДАКТОРОВ.
⚠ Расхождение реализации с пре-регом, объявляю. Пре-рег (план, зафиксирован до покупок)
называет пороги: цена РЕДАКТОРА ≤$0.06/ед · кириллица ≥0.5 · эхо ≤0.15 · формат · непустой
выход. Переводческого порога в нём нет — пак переводчиков не выбирает, эту роль эксп-22 уже
закрыл. Реализация же переиспользовала `verdict` эксп-22 целиком, вместе с переводческим
порогом $0.02, и именно он снял `gpt-5.6-terra` и `grok-4.5` — обоих кандидатов, ради которых
пак и существует. Снимать критерий после того, как увидел результат, — подгонка, поэтому оба
вердикта печатаются РЯДОМ: пре-рег-критерий как несущий, полный критерий эксп-22 как справка.
"""
if not rows:
return "НЕ ЗАМЕРЕН", ["клеток нет"]
why = []
ed = [r for r in rows if r["role"] == "editor"]
fmt_bad = sum(1 for r in ed if r["md"] or r["preamble"] or r["empty"] or r["truncated"])
if fmt_bad > 1:
why.append(f"Г1 формат: нарушений в {fmt_bad} клетках")
if any(r["han_share"] > S22.ECHO_MAX for r in ed):
why.append("Г2 эхо выше боевого порога")
if any(r["refusal"] for r in ed):
why.append("Г3 отказ на чувствительном фрагменте")
if any(r["cyr_share"] < S22.CYR_MIN and not r["empty"] for r in ed):
why.append("Г4 выход не на целевом языке")
import statistics as st # noqa: PLC0415
pe = [r["cost"] for r in ed]
if pe and st.median(pe) > S22.PRICE_CAP["editor"]:
why.append(f"Г5 цена editor: ${st.median(pe):.4f} > ${S22.PRICE_CAP['editor']}")
if any(r["truncated"] and r["empty"] for r in ed):
why.append("Г6 размышление съело бюджет, выход пуст")
return ("ПРОВАЛ" if why else "прошёл"), why
def result() -> dict:
"""Оба вердикта: пре-рег-критерий пака (несущий) и полный критерий эксп-22 (справка)."""
import statistics as st # noqa: PLC0415
res = {}
for m in NEW:
rows = []
for f in MONEY.OUT.glob(f"et-scr-{m}-*.json"):
rec = json.loads(f.read_text(encoding="utf-8"))
u = next((x for x in S22.units() if x["uid"] == rec.get("uid")), None)
g = S22.gates(rec, u["source"] if u else "")
g["reasoning_share"] = _share(rec)
rows.append(g)
v22, why22 = S22.verdict(rows)
ved, whyed = verdict_editor(rows)
pt = [r["cost"] for r in rows if r["role"] == "translator"]
pe = [r["cost"] for r in rows if r["role"] == "editor"]
res[m] = dict(verdict=ved, why=whyed, verdict_full22=v22, why_full22=why22,
n=len(rows),
price_translator=(st.median(pt) if pt else None),
price_editor=(st.median(pe) if pe else None),
reasoning_share=(max((r["reasoning_share"] for r in rows), default=0.0)),
han=sum(r["han_chars"] for r in rows))
return res
def cmd_score() -> None:
rs = result()
print("ВЕРДИКТ ПО ПРЕ-РЕГ-КРИТЕРИЮ ПАКА (редакторская роль) — несущий")
print(f"{'модель':16s}{'вердикт':>10s}{'ед.':>4s}{'$/ед edit':>11s}{'размышл':>9s} причины")
for m, r in rs.items():
print(f"{m:16s}{r['verdict']:>10s}{r['n']:4d}{(r['price_editor'] or 0):11.5f}"
f"{r['reasoning_share']:9.1%} {'; '.join(r['why'])}")
ok = [m for m, r in rs.items() if r["verdict"] == "прошёл"]
print(f"\nпрошли фазу A: {ok or ''}")
print("\nСПРАВКА — полный критерий эксп-22 (обе роли, включая переводческий порог $0.02,")
print("которого в пре-реге этого пака нет; печатается, чтобы разница не пряталась):")
for m, r in rs.items():
print(f" {m:16s}{r['verdict_full22']:>10s} $/ед transl "
f"{(r['price_translator'] or 0):.5f} {'; '.join(r['why_full22'])}")
def selfcheck() -> int:
"""До покупок: пороги те же, план в потолке, теги уникальны."""
bad = 0
if (S22.PRICE_CAP, S22.CYR_MIN, S22.ECHO_MAX) != ({"translator": 0.02, "editor": 0.06},
0.5, 0.15):
print("[ПРОВАЛ] пороги эксп-22 изменились — состав пака стал бы подгонкой")
bad += 1
tags = [tag(m, r, u["uid"]) for m, r, u in plan()]
if len(tags) != len(set(tags)):
print("[ПРОВАЛ] теги покупок не уникальны — клетки перетрут друг друга")
bad += 1
if not all(t.startswith("et-scr-") for t in tags):
print("[ПРОВАЛ] тег вне глоба фазы — расход будет невидим кассе")
bad += 1
for m in NEW:
if m not in ROSTER.THINK_ET:
print(f"[ПРОВАЛ] боевая конфигурация размышления не объявлена: {m}")
bad += 1
if "kimi-k3" not in SPECIAL:
print("[ПРОВАЛ] особый режим kimi-k3 потерян")
bad += 1
print(f"скрин: клеток в плане {len(tags)} · провалов {bad}")
return bad
if __name__ == "__main__":
a = sys.argv[1:] or ["--plan"]
fn = {"--plan": cmd_plan, "--run": cmd_run, "--score": cmd_score,
"--selfcheck": lambda: sys.exit(1 if selfcheck() else 0)}.get(a[0])
if fn:
fn()
else:
print(__doc__)