textmachine/eval/tenant_panel/screen.py

277 lines
16 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
"""Ф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
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
import bakeoff as BO # 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"[㐀-鿿]")
# ⚠ ДЕТЕКТОР MARKDOWN ИСПРАВЛЕН ПО ВСКРЫТИЮ КЛЕТОК (правило D39.61: перед сравнительным выводом
# открыть единицы). Первая версия (унаследована из скрина эксп-21) ловила ЛЮБОЕ `**` и потому
# помечала как «протёкшую разметку» строку `***` — стандартный русский типографский разделитель
# сцены, которым модель верно передала смену сцены в исходнике. Из-за этого ПРОВАЛ получали
# `glm-5` (ратифицированный резервный редактор) и `gemini-3.1-flash-lite` — то есть дефект
# детектора менял состав панели.
# Здесь детектор приведён к БОЕВОМУ (`checks/sanitizer.go:320` + D33.2): ATX-заголовок = решётки,
# пробел и БУКВА/ЦИФРА (декоративный разделитель решётками боевой гейт тоже не считает), плюс
# настоящий жирный спан `**текст**`, который боевой санитайзер не ловит, но в прозе он мусор.
# Порог «нарушений более чем в одной клетке» НЕ менялся — менялось только то, что считается
# нарушением.
_RE_MD = re.compile(r"^\s{0,3}#{1,6}\s+[^\W\d_]|\*\*(?=\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:
"""ГОДНЫЙ черновик единицы: последняя попытка, прошедшая гейт. Пусто — годного нет."""
best = ""
for f in sorted(OUT.glob(f"tp1-scr-draft-{uid}*.json")):
c = json.loads(f.read_text(encoding="utf-8")).get("content", "")
if c and not BO.draft_reject_reason(c):
best = c
return best
def cmd_draft() -> None:
"""Черновик под редакторскую роль скрина. Один и тот же для ВСЕХ кандидатов — парный дизайн.
РЕ-ГЕН ПРИ ЭХЕ добавлен ПОСЛЕ первой же покупки: единица `474f822806` вернулась ПОЛНЫМ
китайским исходником (`finish=stop`, ханьцзы 1785, кириллица 0.00) — эхо-мина на плотном CJK,
описанная в quirks 00. Гейт годности и N ре-генов — боевое лечение (ре-ген на flash в 7.6 раза
дешевле эскалации на pro), и цена ре-генов ЛОЖИТСЯ В ЛЕДЖЕР, а не прячется.
Функция гейта берётся из `bakeoff` — та самая, что проверена `checks21.py` на историческом
артефакте; переписывать её здесь значило бы завести вторую, расходящуюся.
"""
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)
for attempt in (1, 2, 3):
tag = f"tp1-scr-draft-{u['uid']}" + ("" if attempt == 1 else f"-r{attempt}")
rec = MONEY.purchase(led, tag, DRAFT_MODEL, client(DRAFT_MODEL),
call_kwargs(DRAFT_MODEL, msgs),
role="draft-for-screen", uid=u["uid"], attempt=attempt)
if rec.get("skipped"):
print("⛔ потолок Ф1 — стоп")
return
bad = BO.draft_reject_reason(rec.get("content", ""))
print(f" черновик {u['uid'][:6]} ({u['kind']}) попытка {attempt} "
f"finish={rec.get('finish')} {len(rec.get('content', '')):5d} зн "
f"${rec.get('cost_usd', 0):.5f}" + (f"{bad}ре-ген" if bad else ""))
if not bad:
break
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__)