textmachine/eval/tenant_panel/panel.py

348 lines
19 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
"""Ф2 — ПАНЕЛЬ ЧЕРНОВИКОВ. Главный рычаг пака.
Почему именно эта роль. Эксп-21 §16.2: тот же редактор над ДРУГИМ черновиком той же длины даёт
7.66 ошибки против 5.91 — это ~70% заголовочного эффекта топологии. Черновиком за всю историю
проекта была одна модель. Самая чувствительная роль ни разу не перебиралась.
Что меряется на КАЖДОМ жильце (заказ промта, стр.55):
(а) черновик как ТЕКСТ — абсолютный агент-суд (`judge.py`, проход `p2zh`/`p2en`);
(б) боевой редактор `A_law`-конфига ПОВЕРХ каждого черновика — доезжает ли качество до финала;
(в) частота эхо-мины — доля единиц, потребовавших ре-гена;
(г) батарея + покрытие канона (детерминированно, $0);
(д) цена/единица и проекция на книгу.
ШУМОВОЙ ПОЛ. Один арм гоняется ДВАЖДЫ end-to-end на тех же единицах — выбран ЯКОРНЫЙ
(`deepseek-v4-flash` → `deepseek-v4-pro`), потому что именно с ним сравниваются все перевесы, и
пол обязан быть измерен на боевой паре, а не на удобной. Пол применяется к Ф2 И Ф3.
Черновики ФРИЗЯТСЯ: Ф3 правит замороженный якорный черновик, клетка «dspro над flash» покупается
здесь ОДИН раз и в Ф3 переиспользуется.
Запуск: --plan состав панели и смета, $0
--drafts черновики всех жильцов (zh + en)
--edit боевой редактор поверх каждого черновика
--floor вторая генерация якорного арма end-to-end
--score батарея, канон, эхо, цена — $0
"""
from __future__ import annotations
import json
import os
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))
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
from dotenv import load_dotenv # noqa: E402
from openai import OpenAI # noqa: E402
import bakeoff as BO # noqa: E402 гейт годности черновика
import bank as BANK # noqa: E402
import battery as B # noqa: E402
import material as M # noqa: E402
import money as MONEY # noqa: E402
import prompts as PR # noqa: E402
from roster import CANDIDATES, call_kwargs # noqa: E402
load_dotenv(REPO / "eval" / ".env")
OUT = MONEY.OUT
ANCHOR = "deepseek-v4-flash" # боевой черновик — якорь панели
EDITOR = "deepseek-v4-pro" # боевой редактор, конфиг `A_law` (full-regen + закон-блок)
MAX_TENANTS = 5 # промт: «до 5 жильцов черновика + `deepseek-v4-flash`
# (якорь)» ⇒ пять СВЕРХ якоря, панель ≤6 клеток
RETRIES = 3
def client(model: str) -> OpenAI:
base, env, *_ = CANDIDATES[model]
return OpenAI(api_key=os.environ[env], base_url=base, timeout=900)
def screen_result() -> dict:
f = OUT / "screen.json"
return json.loads(f.read_text(encoding="utf-8")) if f.exists() else {}
def tenants() -> list[str]:
"""Состав панели по ОБЪЯВЛЕННОМУ правилу (пре-рег §0.6).
Берутся прошедшие скрин кандидаты черновой роли. Если смета не влезает — панель режется так,
чтобы СОХРАНИТЬ ЦЕНОВУЮ ОСЬ: обязательны якорь, самый дешёвый прошедший и самый дорогой
прошедший; середина добирается по возрастанию цены. Причина: вопрос пака — «покупает ли
дорогой черновик качество», и панель, срезанная по цене сверху, на него не отвечает.
"""
res = screen_result()
ok = [m for m, r in res.items()
if r["verdict"] == "прошёл" and r.get("price_translator") is not None]
ok.sort(key=lambda m: res[m]["price_translator"])
if ANCHOR in ok:
ok.remove(ANCHOR)
if len(ok) <= MAX_TENANTS:
return [ANCHOR, *ok]
keep = [ok[0], ok[-1]]
for m in ok[1:-1]:
if len(keep) >= MAX_TENANTS:
break
keep.append(m)
return [ANCHOR, *sorted(keep, key=lambda m: res[m]["price_translator"])]
def units_all(part: str = "") -> list[tuple[dict, bool]]:
"""(единица, en?) — обе оси обязательны.
`part="i/n"` режет СПИСОК единиц на n равных долей для параллельных процессов. Деление
детерминированное (по позиции в уже отсортированном списке), поэтому доли не пересекаются и
ни одна единица не теряется: это проверяется `--plan`, который печатает сумму долей.
"""
us = [(u, False) for u in M.units_zh()] + [(u, True) for u in M.units_en()]
if not part:
return us
i, n = (int(x) for x in part.split("/"))
return [x for k, x in enumerate(us) if k % n == i]
# ⚠ ПОЛ КЛАДЁТСЯ ОТДЕЛЬНОЙ ПАРОЙ, а не «боевая клетка + повтор». Иначе одна половина пола
# побайтно совпала бы с армом `E0` в том же судейском пакете: судья увидел бы один текст дважды,
# а контроль «близнец» сработал бы на боевом арме. Эксп-21 обошёл это тем, что его пол лежал на
# ДРУГОМ входе (§build_unit), — здесь то же достигается двумя независимыми прогонами `p1`/`p2`.
def tag_draft(model: str, uid: str, attempt: int = 1, floor: int = 0) -> str:
base = f"tp2-floor-p{floor}" if floor else "tp2-draft"
return f"{base}-D-{model}-{uid}" + ("" if attempt == 1 else f"-r{attempt}")
def tag_edit(model: str, uid: str, floor: int = 0) -> str:
return f"{f'tp2-floor-p{floor}' if floor else 'tp2-edit'}-E-{model}-{uid}"
def draft_text(model: str, uid: str, floor: int = 0) -> str:
"""ГОДНЫЙ черновик: последняя попытка, прошедшая гейт. Пусто — годного нет."""
pre = tag_draft(model, uid, floor=floor)
best = ""
for f in sorted(OUT.glob(f"{pre}*.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 edit_text(model: str, uid: str, floor: int = 0) -> str:
f = OUT / f"{tag_edit(model, uid, floor)}.json"
return json.loads(f.read_text(encoding="utf-8")).get("content", "") if f.exists() else ""
def edit_ok(model: str, uid: str, floor: int = 0) -> bool:
"""Клетка редактора ГОДНА: вендор закончил сам, а не упёрся в потолок вывода.
⛔ Заведено блокером Б8 ревью 21.08: `edit_text` отдаёт `content` независимо от `finish`, и
оборванная клетка боевой связки уехала армом `A0` в шесть судейских пачек. Потребители текста
обязаны спрашивать годность ОТДЕЛЬНО — менять контракт `edit_text` нельзя, на нём висят паки,
которым нужен текст как есть.
"""
f = OUT / f"{tag_edit(model, uid, floor)}.json"
if not f.exists():
return False
return json.loads(f.read_text(encoding="utf-8")).get("finish") == "stop"
def buy_draft(led, model: str, u: dict, en: bool, floor: int = 0) -> dict:
blk = None if en else PR.glossary_block(PR.terms_in(u["source"]))
msgs = PR.translator_msgs(u["source"], blk, en=en)
rec = {}
for attempt in range(1, RETRIES + 1):
rec = MONEY.purchase(led, tag_draft(model, u["uid"], attempt, floor), model, client(model),
call_kwargs(model, msgs), role="draft", uid=u["uid"], en=en,
tenant=model, attempt=attempt, floor=floor)
if rec.get("skipped") or not BO.draft_reject_reason(rec.get("content", "")):
return rec
print(f"{BO.draft_reject_reason(rec['content'])}ре-ген {attempt + 1}")
return rec
def buy_edit(led, model: str, u: dict, en: bool, floor: int = 0) -> dict:
draft = draft_text(model, u["uid"], floor)
if not draft:
return dict(skipped=False, content="", no_draft=True)
blk = None if en else PR.law_block(PR.terms_in(u["source"]))
msgs = PR.editor_msgs(u["source"], draft, blk, en=en)
return MONEY.purchase(led, tag_edit(model, u["uid"], floor), EDITOR, client(EDITOR),
call_kwargs(EDITOR, msgs), role="edit", uid=u["uid"], en=en,
tenant=model, floor=floor)
def cmd_plan() -> None:
ts = tenants()
res = screen_result()
us = units_all()
print(f"ПАНЕЛЬ Ф2: {len(ts)} жильцов черновой роли · единиц {len(us)} "
f"({sum(1 for _u, e in us if not e)} zh + {sum(1 for _u, e in us if e)} en)")
for m in ts:
r = res.get(m, {})
print(f" {m:26s} скрин={r.get('verdict', ''):10s} $/ед transl="
f"{r.get('price_translator') or 0:.5f} edit={r.get('price_editor') or 0:.5f}")
print("\nне вошли: " + ", ".join(f"{m}({r['verdict']})" for m, r in res.items()
if m not in ts) or "")
n_dr = len(ts) * len(us)
n_ed = len(ts) * len(us)
n_fl = 2 * len(M.units_zh())
est = sum((res.get(m, {}).get("price_translator") or 0.005) * len(us) for m in ts) \
+ (res.get(EDITOR, {}).get("price_editor") or 0.005) * len(ts) * len(us) \
+ ((res.get(ANCHOR, {}).get("price_translator") or 0.001)
+ (res.get(EDITOR, {}).get("price_editor") or 0.005)) * len(M.units_zh())
print(f"\nвызовов: черновики {n_dr} + редактуры {n_ed} + пол {n_fl} = {n_dr + n_ed + n_fl}")
print(f"смета по ФАКТИЧЕСКИМ ценам скрина: ${est:.4f} при потолке ${MONEY.CEILINGS['Ф2']:.2f}")
if est > MONEY.CEILINGS["Ф2"]:
print("СМЕТА НЕ ВЛЕЗАЕТ — резать панель по объявленному правилу или пинг владельцу")
def _selected(argv: list[str]) -> list[str]:
"""Подмножество жильцов для параллельного прогона. Клетки идемпотентны по тегу, поэтому
несколько процессов не мешают друг другу; касса читает диск, так что расход виден всем.
⚠ Гонка ограничена сверху: перерасход не больше (процессов1)×цена одного вызова."""
only = [a.split("=", 1)[1] for a in argv if a.startswith("--only=")]
ts = tenants()
return [m for m in ts if m in only] if only else ts
def _part(argv: list[str]) -> str:
p = [a.split("=", 1)[1] for a in argv if a.startswith("--part=")]
return p[0] if p else ""
def cmd_drafts(argv: list[str] | None = None) -> None:
led = MONEY.Guarded("Ф2", default_expect=0.01)
for m in _selected(argv or []):
for u, en in units_all(_part(argv or [])):
rec = buy_draft(led, m, u, en)
if rec.get("skipped"):
print("⛔ потолок Ф2 — стоп")
return
print(f" {m:24s} {'en' if en else 'zh'} {u['uid'][:6]} "
f"finish={str(rec.get('finish')):8s} {len(rec.get('content', '')):5d} зн "
f"${rec.get('cost_usd', 0):.5f}")
time.sleep(0.15)
print(f"потрачено Ф2 ${led.spent():.6f}")
def cmd_edit(argv: list[str] | None = None) -> None:
led = MONEY.Guarded("Ф2", default_expect=0.01)
for m in _selected(argv or []):
for u, en in units_all(_part(argv or [])):
rec = buy_edit(led, m, u, en)
if rec.get("no_draft"):
print(f" {m:24s} {u['uid'][:6]} — годного черновика нет, редактура пропущена")
continue
if rec.get("skipped"):
print("⛔ потолок Ф2 — стоп")
return
print(f" E[{m:22s}] {'en' if en else 'zh'} {u['uid'][:6]} "
f"finish={str(rec.get('finish')):8s} {len(rec.get('content', '')):5d} зн "
f"${rec.get('cost_usd', 0):.5f}")
time.sleep(0.15)
print(f"потрачено Ф2 ${led.spent():.6f}")
def cmd_floor(argv: list[str] | None = None) -> None:
"""ШУМОВОЙ ПОЛ: ДВА независимых прогона якорного арма end-to-end на тех же zh-единицах.
Истинная разница между `p1` и `p2` — ноль по построению, поэтому измеренный разброс и есть
шум пайплайна (генерация черновика + генерация редактуры + судья). Он применяется к Ф2 И Ф3:
перевес ниже пола печатается вердиктом «ниже порога различимости».
"""
led = MONEY.Guarded("Ф2", default_expect=0.01)
argv = argv or []
part = _part(argv)
passes = [int(a.split("=", 1)[1]) for a in argv if a.startswith("--pass=")] or [1, 2]
zh = M.units_zh()
if part:
i, n = (int(x) for x in part.split("/"))
zh = [u for k, u in enumerate(zh) if k % n == i]
for p in passes:
for u in zh:
d = buy_draft(led, ANCHOR, u, False, floor=p)
if d.get("skipped"):
print("⛔ потолок Ф2 — стоп")
return
e = buy_edit(led, ANCHOR, u, False, floor=p)
if e.get("skipped"):
print("⛔ потолок Ф2 — стоп")
return
print(f" пол p{p} {u['uid'][:6]} черновик {len(d.get('content', '')):5d} зн · "
f"редактура {len(e.get('content', '')):5d} зн")
time.sleep(0.15)
print(f"потрачено Ф2 ${led.spent():.6f}")
def draft_cost(model: str, uid: str, floor: int = 0) -> float:
"""Цена черновика = ВСЕ попытки: ре-ген оплачен, значит он в цене роли."""
pre = tag_draft(model, uid, floor=floor)
return sum(json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0)
for f in OUT.glob(f"{pre}*.json"))
def regen_rate(model: str, en: bool = False) -> tuple[int, int]:
us = [u for u, e in units_all() if e == en]
need = 0
for u in us:
pre = tag_draft(model, u["uid"])
if len(list(OUT.glob(f"{pre}-r*.json"))):
need += 1
return need, len(us)
def cmd_score() -> None:
ts = tenants()
zh = M.units_zh()
cards = B.load_cards()
print("═══ Ф2 (в) ЭХО-МИНА: доля единиц, потребовавших ре-гена ═══")
for m in ts:
nz, tz = regen_rate(m, en=False)
ne, te = regen_rate(m, en=True)
print(f" {m:26s} zh {nz}/{tz} = {nz / max(1, tz):.0%} · en {ne}/{te} = {ne / max(1, te):.0%}")
print("\n═══ Ф2 (г) БАТАРЕЯ И ПОКРЫТИЕ КАНОНА (zh, детерминированно) ═══")
print(f"{'арм':30s}{'ед.':>5s}{'ханьцзы':>9s}{'типогр':>8s}{'потеря':>8s}"
f"{'канон':>14s}{'покрытие':>10s}")
for m in ts:
for kind in ("D", "E"):
rows, k, s = [], 0, 0
for u in zh:
t = draft_text(m, u["uid"]) if kind == "D" else edit_text(m, u["uid"])
if not t.strip():
continue
rows.append(B.run_unit(B.Unit(source=u["source"], draft=t, final=t, cards=cards)))
kk, ss = BANK.coverage(u["source"], t)
k += kk
s += ss
if not rows:
continue
print(f"{kind + '[' + m + ']':30s}{len(rows):5d}"
f"{sum(r['cjk_leak']['han_chars'] for r in rows):9d}"
f"{sum(r['typography']['violations'] for r in rows):8d}"
f"{sum(r['numbers']['lost_n'] for r in rows):8d}"
f"{f'{k}/{s}':>14s}{(k / s if s else 0):>10.3f}")
print("\n═══ Ф2 (д) ЦЕНА/ЕДИНИЦА И ПРОЕКЦИЯ НА КНИГУ ═══")
print(f"{'жилец':26s}{'черновик':>11s}{'редактор':>11s}{'связка':>11s}{'книга 1500 ед.':>16s}")
for m in ts:
dc = [draft_cost(m, u["uid"]) for u in zh]
ec = [json.loads((OUT / f"{tag_edit(m, u['uid'])}.json").read_text(encoding="utf-8"))
.get("cost_usd", 0.0) for u in zh if (OUT / f"{tag_edit(m, u['uid'])}.json").exists()]
if not dc:
continue
d50, e50 = st.median(dc), (st.median(ec) if ec else 0.0)
print(f"{m:26s}{d50:11.5f}{e50:11.5f}{d50 + e50:11.5f}{(d50 + e50) * 1500:16.2f}")
led = MONEY.Guarded("Ф2")
print(f"\nпотрачено Ф2 ${led.spent():.6f} из ${led.ceiling:.2f} · "
f"пак ${MONEY.Pack().spent():.6f} из ${MONEY.PACK_CEILING:.2f}")
if __name__ == "__main__":
a = sys.argv[1:] or ["--plan"]
fn = {"--plan": cmd_plan, "--drafts": lambda: cmd_drafts(a), "--edit": lambda: cmd_edit(a),
"--floor": lambda: cmd_floor(a), "--score": cmd_score}.get(a[0])
fn() if fn else print(__doc__)