textmachine/eval/dovodka/d1_gemini.py

230 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 — ДОБИВКА `gemini-3.1-pro-preview`: брошенный замер эксп-22 §6/§6.3. Платный.
Что это закрывает. Эксп-22 объявил полную редакторскую панель, но gemini отдал не все клетки, и
пробел остался незакрытым — владелец назвал это «брошенным замером». Гипотеза эксп-04 «проза
gemini — аутлаер» держится на этом замере и без него не закрывается.
⚠ ВЕРДИКТ «МОДЕЛЬ НЕ ОТДАЁТ» ПРИНИМАЕТСЯ ТОЛЬКО С ЖУРНАЛОМ. Заказ формулирует прямо: журнал
КАЖДОЙ попытки (время · код ответа · стоимость) файлом в сырьё, окно ≥24 часов, экспоненциальный
бэкофф на 5xx, поштучно. «После N отказов бросил» = невыполнение заказа. Поэтому здесь журнал
пишется ДО и ПОСЛЕ каждой попытки, а не собирается в память и не теряется при падении процесса.
⚠ СКРЫТАЯ ТАРИФИКАЦИЯ ×4.5. У gemini размышление тарифицируется, но в `completion_tokens` НЕ
видно — разница живёт только в `total_tokens` (`roster.REASONING_BILLING` = `additive_total`).
Потолок клетки считается по total, иначе прибор покажет вчетверо меньше потраченного.
База — та же якорная, что у всех армов эксп-22 (черновик `deepseek-v4-flash`), поэтому клетки
сравнимы с прочей редакторской панелью без поправок.
Запуск: d1_gemini.py --dry план и состояние, $0
d1_gemini.py --run один проход по недостающим клеткам (платный)
d1_gemini.py --status журнал попыток и вердикт
d1_gemini.py --selftest
"""
from __future__ import annotations
import importlib.util
import json
import sys
import time
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"):
sys.path.insert(0, str(REPO / "eval" / d))
def _load(name: str, path: Path):
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
C = _load("contour_d1", ZONE / "contour.py")
MONEY, PR, ROSTER, ED22, P22 = C.MONEY, C.PR, C.ROSTER, C.ED22, C.P22
MODEL = "gemini-3.1-pro-preview"
PHASE = "ФД-E"
PFX = "dv-e"
N_TARGET = 16 # полная панель эксп-22
N_ENOUGH = 12 # ≥12/16 — порог, при котором заказ разрешает судить
CELL_CAP_USD = 0.30 # лимит цены КЛЕТКИ, считается по total_tokens
BACKOFF = (30, 120, 480, 1800) # экспоненциальный бэкофф на 5xx, секунды
JOURNAL = MONEY.OUT / "d1-attempts.jsonl"
def units() -> list[dict]:
"""16 единиц эксп-22 — те же, поверх которых куплена вся редакторская панель."""
return [u for u in P22.M.units_zh() if u["uid"] not in C.EXCLUDED][:N_TARGET]
def tag(uid: str) -> str:
return f"{PFX}-r1-{uid}"
def text_of(uid: str) -> str:
f = MONEY.OUT / f"{tag(uid)}.json"
if not f.exists():
return ""
return json.loads(f.read_text(encoding="utf-8")).get("content") or ""
def log_attempt(rec: dict) -> None:
"""Журнал попытки — ДОПИСЫВАЕТСЯ немедленно. Вердикт без него заказ не принимает.
⚠ ВРЕМЯ ОБЯЗАТЕЛЬНО. Первая редакция писала длительность вызова, но не МЕТКУ ВРЕМЕНИ, и
`--status` считал окно журнала как 0.0 ч на любом числе попыток. Заказ принимает вердикт
«модель не отдаёт» ТОЛЬКО по журналу за ≥24 ч — без метки времени доказать окно нечем,
то есть журнал не выполнял свою единственную задачу.
"""
rec.setdefault("ts", time.time())
JOURNAL.parent.mkdir(parents=True, exist_ok=True)
with JOURNAL.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
def attempts() -> list[dict]:
if not JOURNAL.exists():
return []
return [json.loads(x) for x in JOURNAL.read_text(encoding="utf-8").splitlines() if x.strip()]
def msgs_for(u: dict) -> list[dict]:
"""Редакторский промт поверх ЯКОРНОГО черновика — как у всей панели эксп-22."""
draft = C.base_draft(u["uid"])
law = PR.law_block(PR.terms_in(u["source"]))
return PR.editor_msgs(u["source"], draft, law)
def cmd_run() -> None:
"""Один проход по недостающим клеткам. Повторные проходы набирают окно ≥24 часов."""
led = MONEY.Guarded(PHASE, default_expect=MONEY.model_expect(MODEL))
missing = [u for u in units() if not text_of(u["uid"]).strip()]
print(f"Д1: есть {N_TARGET - len(missing)}/{N_TARGET}, добираю {len(missing)}")
for u in missing:
uid = u["uid"]
draft = C.base_draft(uid)
if not draft.strip():
print(f" {uid}: якорной базы нет — клетка невозможна")
continue
for i, pause in enumerate((0, *BACKOFF)):
if pause:
print(f" {uid}: бэкофф {pause}s перед попыткой {i + 1}")
time.sleep(pause)
t0 = time.time()
try:
rec = MONEY.purchase(led, tag(uid), MODEL, ED22.client(MODEL),
ROSTER.call_kwargs(MODEL, msgs_for(u)),
role="edit", uid=uid, arm="R1")
except SystemExit:
raise
except Exception as e: # noqa: BLE001
code = getattr(getattr(e, "response", None), "status_code", None)
log_attempt(dict(uid=uid, attempt=i + 1, ok=False, code=code,
err=type(e).__name__, msg=str(e)[:200],
secs=round(time.time() - t0, 1), cost_usd=0.0))
print(f" {uid}: попытка {i + 1}{type(e).__name__} код {code}")
# ⚠ Бэкофф только на 5xx и на сетевые: 4xx повтором не лечится, и молотить им
# вендора значит жечь квоту на заведомо неудачных вызовах.
if code and 400 <= code < 500 and code != 429:
break
continue
if rec.get("skipped"):
log_attempt(dict(uid=uid, attempt=i + 1, ok=False, code="GUARD",
secs=round(time.time() - t0, 1), cost_usd=0.0))
raise SystemExit(f"⛔ ГАРД ОТКАЗАЛ на {tag(uid)} — СТОП и вопрос владельцу")
# ⚠ ОТКАЗ, ПОЙМАННЫЙ КАССОЙ, — ТОЖЕ ПОПЫТКА. `purchase` перехватывает ошибку API
# сама и пишет карантинную запись `finish="error"`, поэтому мой `except` не
# срабатывает и журнал показывал «отказов 0» при двух реально ошибившихся клетках.
# Вердикт заказа держится на журнале, значит журнал обязан видеть и этот путь.
if rec.get("finish") == "error" or not (rec.get("content") or "").strip():
log_attempt(dict(uid=uid, attempt=i + 1, ok=False,
code=rec.get("finish") or "empty",
err=str(rec.get("error"))[:200],
secs=round(time.time() - t0, 1),
cost_usd=rec.get("cost_usd", 0.0)))
print(f" {uid}: попытка {i + 1} — касса вернула {rec.get('finish')}")
continue
cost = rec.get("cost_usd", 0.0)
log_attempt(dict(uid=uid, attempt=i + 1, ok=True, code=200,
finish=rec.get("finish"), secs=round(time.time() - t0, 1),
cost_usd=cost, chars=len(rec.get("content") or "")))
if cost > CELL_CAP_USD:
print(f"{uid}: клетка ${cost:.4f} выше лимита ${CELL_CAP_USD} — объявить")
break
MONEY.report()
def cmd_status() -> None:
us = units()
have = [u for u in us if text_of(u["uid"]).strip()]
at = attempts()
ok = [a for a in at if a.get("ok")]
fail = [a for a in at if not a.get("ok")]
print(f"Д1 · собрано {len(have)}/{len(us)} · порог заказа ≥{N_ENOUGH}")
print(f" попыток всего {len(at)} · удачных {len(ok)} · отказов {len(fail)}")
# ⚠ Окно считается ТОЛЬКО по записям с меткой времени. Записи первой редакции её не несли,
# и `.get("ts", 0)` давал минимум 0 — то есть отсчёт от эпохи и «окно 496 315 часов».
# Число выглядело солидно и означало ровно ничего.
ts = [a["ts"] for a in at if a.get("ts")]
if len(ts) >= 2:
print(f" окно журнала: {(max(ts) - min(ts)) / 3600:.2f} ч по {len(ts)} записям с меткой "
f"(заказ требует ≥24 ч ДО вердикта «не отдаёт»)")
elif at:
print(f" окно журнала: записей с меткой времени {len(ts)} — окно не доказуемо")
codes: dict = {}
for a in fail:
codes[str(a.get("code"))] = codes.get(str(a.get("code")), 0) + 1
if codes:
print(f" коды отказов: {codes}")
if len(have) >= N_ENOUGH:
print(" [OK ] порог взят — ось идёт в судейство")
else:
print(f" ⚠ порога нет: вердикт «модель не отдаёт» принимается ТОЛЬКО с журналом ≥24 ч "
f"и снимком вендор-статуса. «После N отказов бросил» = невыполнение заказа.")
def cmd_dry() -> None:
us = units()
have = sum(1 for u in us if text_of(u["uid"]).strip())
exp = MONEY.model_expect(MODEL)
sp = MONEY.Guarded(PHASE).spent()
print(f"Д1 gemini · единиц {len(us)} · есть {have} · надо {len(us) - have}")
print(f" ожидание клетки ${exp:.5f} · лимит клетки ${CELL_CAP_USD}")
print(f" {PHASE}: потрачено ${sp:.4f} · потолок ${MONEY.CEILINGS[PHASE]:.2f} · "
f"смета ${(len(us) - have) * exp:.4f}")
print(f" журнал попыток: {JOURNAL} ({len(attempts())} записей)")
def cmd_selftest() -> int:
bad = 0
def ck(n: str, ok: bool, d: str = "") -> None:
nonlocal bad
bad += not ok
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else ""))
us = units()
ck("единиц ровно 16 — полная панель эксп-22", len(us) == N_TARGET, f"их {len(us)}")
ck("тег покрыт глобом кассы ФД-E", tag("u").startswith("dv-e-"))
ck("база — ЯКОРНАЯ, та же что у всей панели",
all(C.base_draft(u["uid"]).strip() for u in us[:3]))
ck("тарификация gemini объявлена скрытой (additive_total)",
ROSTER.REASONING_BILLING.get(MODEL) == "additive_total",
str(ROSTER.REASONING_BILLING.get(MODEL)))
ck("бэкофф экспоненциальный и длинный", BACKOFF[-1] >= 1800 and len(BACKOFF) >= 4,
str(BACKOFF))
ck("порог заказа ниже полной панели", N_ENOUGH < N_TARGET, f"{N_ENOUGH} < {N_TARGET}")
print(f"\n{'Д1 ГОТОВ' if not bad else f'ПРОВАЛОВ: {bad}'}")
return bad
if __name__ == "__main__":
a = sys.argv[1] if len(sys.argv) > 1 else "--dry"
fn = {"--dry": cmd_dry, "--run": cmd_run, "--status": cmd_status,
"--selftest": lambda: sys.exit(1 if cmd_selftest() else 0)}.get(a)
fn() if fn else print(__doc__)