textmachine/eval/tenant_panel/screen.py

244 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
"""Ф1 — ПРОФИЛЬ-СКРИН ЖИЛЬЦОВ. Оси = гейты цены и дисциплины, НЕ прокси качества.
Что делает и чего НЕ делает. Скрин отсеивает кандидатов, неработоспособных в роли: рвут формат,
эхают исходник, отказываются, отдают не тот язык, дороги сверх объявленного порога роли или не
управляют размышлением. Он НЕ ранжирует прозу — урок эксп-20 прямой: механический показатель
(число абзацев) оказался не качеством, а слепой суд его опроверг. Спорные по СТИЛЮ доводятся
до Ф2/Ф3 обоими.
Пороги записаны ЗДЕСЬ, в коде, ДО первого замера, и продублированы в пре-реге §0.5.
Скрин ОБЯЗАН убивать: у эксп-21 он убил 2 из 12 и клейм «Ф1 отобрала жильцов» был снят.
Но пороги выводятся из требований пайплайна, а не подгоняются под желаемый состав.
Запуск: --dry план и смета, $0
--draft черновик для редакторской роли на тех же 2 единицах (боевой flash)
--run сам скрин
--score свод по персистированному сырью, $0
"""
from __future__ import annotations
import json
import os
import re
import statistics as st
import sys
import time
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
sys.path.insert(0, str(ZONE))
from dotenv import load_dotenv # noqa: E402
from openai import OpenAI # noqa: E402
import material as M # noqa: E402
import money as MONEY # noqa: E402
import prompts as PR # noqa: E402
from roster import CANDIDATES, billed_output, by_out_price, call_kwargs # noqa: E402
load_dotenv(REPO / "eval" / ".env")
OUT = MONEY.OUT
DRAFT_MODEL = "deepseek-v4-flash"
ROLES = ("translator", "editor")
# Пороги цены роли, объявленные ДО замера. Основание — экономика пака: черновик на книгу в
# 1500 единиц при $0.02/ед стоит $30, что уже вне продуктовой рамки «низкий COGS»; редактор
# оправдан втрое дороже, потому что покупает измеренное качество (эксп-21 §12).
PRICE_CAP = {"translator": 0.02, "editor": 0.06}
CYR_MIN = 0.5 # Г4: доля кириллицы среди букв
ECHO_MAX = 0.15 # Г2: боевой порог disposition.go
_RE_HAN = re.compile(r"[㐀-鿿]")
_RE_MD = re.compile(r"^\s{0,3}#{1,6}\s|\*\*", re.M)
_RE_PREAMBLE = re.compile(r"^\s*(вот|ниже|here is|here's)\b[^\n:]{0,80}:", re.I)
_RE_REFUSAL = re.compile(
r"не могу|не буду|отказ|прости(те)?, но|cannot (assist|help|comply)|i'm sorry", re.I)
# Маркеры насилия — только для ВЫБОРА чувствительной единицы, не для оценки.
VIO = ("", "", "", "", "惨叫", "", "折磨", "残忍", "痛苦")
def units() -> list[dict]:
"""Две боевые единицы: «обычная» (медиана длины) и ЧУВСТВИТЕЛЬНАЯ (максимум насилия).
Вторая нужна гейту Г3: вебновелла гарантированно упирается в насилие, и тихий отказ на главе N
рвёт конвейер. Выбор детерминирован и объявлен до замера.
"""
us = M.units_zh()
by_len = sorted(us, key=lambda u: len(u["source"]))
normal = by_len[len(by_len) // 2]
dens = sorted(us, key=lambda u: (-sum(u["source"].count(v) for v in VIO), u["uid"]))
sens = next(u for u in dens if u["uid"] != normal["uid"])
normal, sens = dict(normal, kind="обычная"), dict(sens, kind="чувствительная")
return [normal, sens]
def client(model: str) -> OpenAI:
base, env, *_ = CANDIDATES[model]
return OpenAI(api_key=os.environ[env], base_url=base, timeout=900)
def draft_of(uid: str) -> str:
f = OUT / f"tp1-scr-draft-{uid}.json"
return json.loads(f.read_text(encoding="utf-8")).get("content", "") if f.exists() else ""
def cmd_draft() -> None:
"""Черновик под редакторскую роль скрина. Один и тот же для ВСЕХ кандидатов — парный дизайн."""
led = MONEY.Guarded("Ф1", default_expect=0.004)
for u in units():
blk = PR.glossary_block(PR.terms_in(u["source"]))
msgs = PR.translator_msgs(u["source"], blk)
rec = MONEY.purchase(led, f"tp1-scr-draft-{u['uid']}", DRAFT_MODEL,
client(DRAFT_MODEL), call_kwargs(DRAFT_MODEL, msgs),
role="draft-for-screen", uid=u["uid"])
if rec.get("skipped"):
print("⛔ потолок Ф1 — стоп")
return
print(f" черновик {u['uid'][:6]} ({u['kind']}) finish={rec.get('finish')} "
f"{len(rec.get('content', '')):5d} зн ${rec.get('cost_usd', 0):.5f}")
print(f"потрачено Ф1 ${led.spent():.6f}")
def msgs_for(role: str, u: dict) -> list[dict]:
src = u["source"]
if role == "translator":
return PR.translator_msgs(src, PR.glossary_block(PR.terms_in(src)))
return PR.editor_msgs(src, draft_of(u["uid"]), PR.law_block(PR.terms_in(src)))
def gates(rec: dict, source: str) -> dict:
t = rec.get("content", "") or ""
letters = [c for c in t if c.isalpha()]
han = len(_RE_HAN.findall(t))
cyr = sum(1 for c in letters if "а" <= c.lower() <= "я" or c.lower() == "ё")
return dict(
empty=not t.strip(),
truncated=rec.get("finish") == "length",
md=bool(_RE_MD.search(t)),
preamble=bool(_RE_PREAMBLE.search(t)),
han_chars=han,
han_share=round(han / max(1, len(letters)), 5),
cyr_share=round(cyr / max(1, len(letters)), 5),
refusal=bool(_RE_REFUSAL.search(t[:400])) or bool(rec.get("no_message")),
no_message=bool(rec.get("no_message")),
reasoning_share=round(rec.get("reasoning_tokens", 0) / max(1, billed_output(
rec["model"], rec.get("completion_tokens", 0), rec.get("reasoning_tokens", 0),
rec.get("total_tokens", 0), rec.get("prompt_tokens", 0))), 3),
cost=rec.get("cost_usd", 0.0), latency=rec.get("latency_s", 0.0), role=rec.get("role", ""),
)
def verdict(rows: list[dict]) -> tuple[str, list[str]]:
"""Пороги применяются как объявлены. Пустой список — НЕ провал, а отсутствие замера."""
if not rows:
return "НЕ ЗАМЕРЕН", ["потолок кончился до этой модели"]
why = []
fmt_bad = sum(1 for r in rows 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"] > ECHO_MAX for r in rows):
why.append("Г2 эхо: клетка выше боевого порога 0.15")
elif all(r["han_chars"] for r in rows):
why.append("Г2 утечка: ханьцзы во ВСЕХ клетках")
if any(r["refusal"] for r in rows):
why.append("Г3 отказ на чувствительном фрагменте")
if any(r["cyr_share"] < CYR_MIN and not r["empty"] for r in rows):
why.append("Г4 выход не на целевом языке")
for role in ROLES:
rr = [r["cost"] for r in rows if r["role"] == role]
if rr and st.median(rr) > PRICE_CAP[role]:
why.append(f"Г5 цена {role}: ${st.median(rr):.4f} > ${PRICE_CAP[role]}")
if any(r["truncated"] and r["empty"] for r in rows):
why.append("Г6 размышление съело бюджет, выход пуст")
return ("ПРОВАЛ" if why else "прошёл"), why
def cmd_run(dry: bool = False) -> None:
us = units()
models = by_out_price()
print(f"кандидатов {len(models)} · единиц {len(us)} · ролей {len(ROLES)} · "
f"вызовов {len(models) * len(us) * len(ROLES)} · потолок Ф1 ${MONEY.CEILINGS['Ф1']}")
for u in us:
print(f" единица {u['uid']} ({u['kind']}, гл.{u['chapter']}) — {len(u['source'])} зн, "
f"черновик {len(draft_of(u['uid']))} зн")
print("порядок — по ВОЗРАСТАНИЮ цены выхода: кончится потолок — непокрытыми останутся дорогие,\n"
"и это будет объявлено числом, а не скрыто")
if dry:
for m in models:
print(f" {m}")
return
if not all(draft_of(u["uid"]) for u in us):
print("⛔ нет черновика для редакторской роли — сначала --draft")
return
led = MONEY.Guarded("Ф1", default_expect=0.02)
for m in models:
for role in ROLES:
for u in us:
rec = MONEY.purchase(led, f"tp1-scr-{m}-{role}-{u['uid'][:6]}", m, client(m),
call_kwargs(m, msgs_for(role, u)),
role=role, uid=u["uid"], kind=u["kind"])
if rec.get("skipped"):
print(f" ⛔ потолок — {m} и дальше НЕ ЗАМЕРЕНЫ")
cmd_score()
return
g = gates(rec, u["source"])
print(f" {m:24s} {role:11s} {u['kind']:14s} finish={str(rec.get('finish')):9s}"
f" {len(rec.get('content', '')):5d} зн ханьцзы={g['han_chars']:3d}"
f" кир={g['cyr_share']:.2f} ${g['cost']:.5f} {g['latency']:.0f}с")
time.sleep(0.2)
cmd_score()
def cmd_score() -> None:
us = {u["uid"]: u for u in units()}
per: dict[str, list[dict]] = {}
for f in sorted(OUT.glob("tp1-scr-*.json")):
r = json.loads(f.read_text(encoding="utf-8"))
if r.get("skipped") or r.get("role") == "draft-for-screen" or r.get("finish") == "error":
continue
u = us.get(r.get("uid"))
if u:
per.setdefault(r["model"], []).append(gates(r, u["source"]))
print(f"\n{'модель':26s}{'вердикт':>11s}{'$/ед transl':>13s}{'$/ед edit':>11s}"
f"{'эхо':>6s}{'размышл':>9s} причины")
print("-" * 116)
res = {}
for m in by_out_price():
rows = per.get(m, [])
v, why = verdict(rows)
pt = [r["cost"] for r in rows if r["role"] == "translator"]
pe = [r["cost"] for r in rows if r["role"] == "editor"]
han = sum(r["han_chars"] for r in rows)
rs = st.median([r["reasoning_share"] for r in rows]) if rows else 0
print(f"{m:26s}{v:>11s}{(st.median(pt) if pt else 0):13.5f}"
f"{(st.median(pe) if pe else 0):11.5f}{han:6d}{rs:9.1%} {'; '.join(why)}")
res[m] = dict(verdict=v, why=why, n=len(rows),
price_translator=st.median(pt) if pt else None,
price_editor=st.median(pe) if pe else None,
han=han, reasoning_share=rs)
(OUT / "screen.json").write_text(json.dumps(res, ensure_ascii=False, indent=1),
encoding="utf-8")
led = MONEY.Guarded("Ф1")
print(f"\nпотрачено Ф1 ${led.spent():.6f} из ${led.ceiling:.2f} · "
f"пак ${MONEY.Pack().spent():.6f} из ${MONEY.PACK_CEILING:.2f}")
print("⚠ числа печатаются по КАЖДОМУ кандидату — и вошедшему, и нет (симметрия промта)")
def cmd_probe_unlisted() -> None:
"""$0-проба существования слагов, которых нет в /models (урок «нет в списке ≠ не работает»)."""
import requests # noqa: PLC0415
key = os.environ.get("ZAI_API_KEY", "")
for slug in ("glm-4.7-flash", "glm-4.7-flashx"):
r = requests.get(f"https://api.z.ai/api/paas/v4/models/{slug}",
headers={"Authorization": f"Bearer {key}"}, timeout=30)
print(f" {slug:18s} HTTP {r.status_code} {r.text[:120]}")
if __name__ == "__main__":
a = sys.argv[1:] or ["--dry"]
fn = {"--dry": lambda: cmd_run(dry=True), "--draft": cmd_draft, "--run": cmd_run,
"--score": cmd_score, "--probe-unlisted": cmd_probe_unlisted}.get(a[0])
fn() if fn else print(__doc__)