textmachine/eval/editor_harness/probe.py

356 lines
22 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
"""ЭКСП 20: контракт редактора, скопированный с харнесов, против full-regen — на нескольких моделях.
Повод (владелец 05.08): эксп-19 намерил «дифф в 2.32.9× дороже full-regen» и объяснил это несъёмным
размышлением. Пере-проверка арифметики дефекта не нашла (0 расхождений из 24 при пересчёте цены с
нуля), но нашла ДЕФЕКТ ДИЗАЙНА: мой дифф-промпт требовал гарантировать уникальность якоря с первого
раза, без обратной связи. Настоящие харнесы так не делают — они ВОЗВРАЩАЮТ ОШИБКУ и дают починить
(opencode `tool/edit.txt`: «Found multiple matches… provide a larger string»; gemini-cli держит под
это отдельный дешёвый `llm-edit-fixer`). То есть эксп-19 померил «дифф без права на ошибку».
Три гипотезы, которые арм-матрица разводит:
H-ФОРМАТ цену держит требование one-shot-уникальности ⇒ харнес-контракт (право на ошибку +
раунд починки) должен быть заметно дешевле строгого.
H-РЕШЕНИЕ цену держит необходимость НАЙТИ дефект; full-regen не ищет — он переводит заново,
попутно исправляя ⇒ арм `locate` (только найди, ничего не чини и не форматируй)
будет стоить как дифф, и тогда формат ни при чём.
H-МОДЕЛЬ цену держит несъёмное размышление DeepSeek ⇒ на glm-5 с ВЫКЛЮЧЕННЫМ мышлением
экономика диффа обязана перевернуться в его пользу.
НОВОЕ ПРОТИВ ЭКСП-19: сохраняется ТЕКСТ размышления (`reasoning_content`), а не только его длина —
без этого нельзя посмотреть, на что оно тратится, и эксп-19 этого сделать не мог.
Гардрейл: у DeepSeek thinking НЕ отключается ни при каких условиях (эхо-мина, CLAUDE.md).
Отключение мышления применяется ТОЛЬКО к glm-5 (`control: extra_body_disable`, models.yaml).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
REPO = Path("/home/ubuntu/projects/textmachine")
HERE = Path(__file__).resolve().parent
RAW = Path.home() / "books" / "gu-zhenren" / "editor-harness"
RAW.mkdir(parents=True, exist_ok=True)
load_dotenv(REPO / "eval" / ".env")
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
sys.path.insert(0, str(REPO / "eval" / "editor_contract"))
import editor_wire_probe as P # noqa: E402 рендер + окна + блок закона
from apply import apply_ops, parse_ops, fold, _fold_map # noqa: E402 аппликатор (самотест 15/15)
from inject_probe import pick_windows, render_translator, block_for # noqa: E402
# ⚠ Потолок поднят 05.08 с $0.60 до $1.00 ДО прогона (объявлено владельцу): добавлен арм с
# ВКЛЮЧЁННЫМ мышлением для glm-5/grok/luna. Без него сравнение несправедливо — deepseek-v4-pro
# мышление выключить не может, и разница «дифф дорог у dp, дёшев у прочих» смешивает модель с режимом.
# Потолок поднят вторично 05.08 с $1.00 до $2.00, объявлено ДО прогона: на $1.00 проекционный гард
# заблокировал РЕШАЮЩИЙ арм (C0+перевёрстка) — тот единственный, который проверяет, покупается ли
# промптом то самое, чем оправдан второй проход. Санкция владельца — $3-5 на модель.
CEILING_USD = 2.00
HARD_STOP = 1.80
# (base_url, env, in, cached, out, как гасить размышление)
MODELS = {
"deepseek-v4-pro": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", 0.435, 0.003625, 0.87, None),
"glm-5": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY", 1.0, 0.2, 3.2, "disable"),
"deepseek-v4-flash": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", 0.14, 0.0028, 0.28, None),
# Съёмное мышление через ЯВНЫЙ reasoning_effort:"none" (models.yaml grok: control effort/off_effort
# none; для gpt-5.6 живой замер пробы C: none → reasoning_tokens 0). Цены — прайс-страница
# вендора, сверена живьём 05.08: изменений против 04.08 нет.
"grok-4.3": ("https://api.x.ai/v1", "XAI_API_KEY", 1.25, 1.25, 2.50, "effort_none"),
"gpt-5.6-luna": ("https://api.openai.com/v1", "OPENAI_API_KEY", 0.20, 0.02, 1.20, "effort_none"),
}
OPENAI_FAMILY = {"gpt-5.6-luna"} # max_completion_tokens вместо max_tokens, temperature не шлём
# Контракт → (файл промпта, откуда). `full` и `diff-strict` берутся ИЗ БОЕВОГО/эксп-19 без правок,
# чтобы сравнение шло против ровно того, что уже намерено.
CONTRACTS = {
"full": REPO / "backend/prompts/zh-ru/editor.md",
"diff-strict": REPO / "eval/editor_contract/prompts/editor-diff.md",
"diff-harness": HERE / "prompts/editor-diff-harness.md",
"locate": HERE / "prompts/editor-locate.md",
"direct": REPO / "backend/prompts/zh-ru/translator.md", # спец-путь, см. messages()
"direct-reflow": HERE / "prompts/translator-reflow.md", # спец-путь, см. messages()
}
# Английская цель. `full` — ПОЛНОЕ зеркало боевого editor.md вместе с блоком дискурс-перевёрстки:
# в эксп-19 зеркало было обеднено ровно на этот блок, и арм E из-за этого не засчитали.
CONTRACTS_EN = {
"full": HERE / "prompts/editor-en.md",
"diff-harness": HERE / "prompts/editor-diff-harness-en.md",
"locate": HERE / "prompts/editor-locate-en.md",
}
EN_FORMS = {"空窍": "Aperture", "真元": "primeval essence", "元石": "primeval stone",
"元海": "sea of primeval essence", "蛊师": "Gu Master", "族长": "clan leader",
"月光蛊": "Moonlight Gu", "春秋蝉": "Spring Autumn Cicada"}
EN_HEADER = ("CANONICAL RENDERINGS of names and terms (in the draft, the source term on the left MUST "
"be rendered exactly by the form on the right — bring any divergence to it, inflecting by "
"context; do not introduce other variants and do not change anything else):")
EN_VARS = dict(P.RENDER_VARS, target_lang="en", audience="adult webnovel readers",
genre="webnovel", transcription="pinyin")
OLD_RAW = Path.home() / "books" / "gu-zhenren" / "bank-arbitration"
def client(model: str) -> OpenAI:
base, env, *_ = MODELS[model]
return OpenAI(api_key=os.environ[env], base_url=base, timeout=900)
def price(model: str, pt: int, cached: int, ct: int) -> float:
_, _, pin, pcache, pout, _ = MODELS[model]
return (pt - cached) / 1e6 * pin + cached / 1e6 * pcache + ct / 1e6 * pout
def render(path: Path, text: str, draft: str, en: bool = False) -> tuple[str, str]:
"""Рендер как у движка. ⚠ ФИКС 05.08 (дефект пойман В ХОДЕ прогона): блок `---FEWSHOT---`
ОБЯЗАН дропаться — в проде `stages[edit].few_shot: false`, и эксп-19 его тоже дропал.
Первая версия резала только по `---USER---`, из-за чего арм `full` уехал с примерами
перевёрстки внутри системного промпта, то есть база сравнения была не боевым контрактом."""
canon = P.strip_comments(path.read_text(encoding="utf-8"))
sys_part, user = canon.split(P.USER_SEP, 1)
core = sys_part.split(P.FEWSHOT_SEP, 1)[0].strip()
old_vars = P.RENDER_VARS
if en:
P.RENDER_VARS = EN_VARS
try:
return P.render(core, text, draft), P.render(user.strip(), text, draft)
finally:
P.RENDER_VARS = old_vars
def en_block(terms: list[str]) -> str:
return EN_HEADER + "\n" + "\n".join(f"- {t} → «{EN_FORMS[t]}»" for t in terms if t in EN_FORMS)
def messages(contract: str, text: str, draft: str, block: str | None,
en: bool = False) -> list[dict]:
# `direct` — БЕЙК-ОФФ (запрос владельца 05.08): перевод исходника БЕЗ черновика, один проход,
# боевым translator.md и боевым глоссарий-блоком транслятора. Сравнивается с `full`, где та же
# модель переписывает черновик flash. Это тот самый бейк-офф, про который конфиг говорит
# «draft качеством не мерен» (D30.6) и который не гонялся ни разу.
if contract in ("direct", "direct-reflow"):
if contract == "direct-reflow":
# РЕШАЮЩИЙ арм: тот же транслятор, но с ДОСЛОВНЫМ блоком перевёрстки редактора.
# Рендер идёт общим путём (плейсхолдеры + срез FEWSHOT), а не render_translator,
# потому что файл собран из двух боевых промптов и лежит в зоне полигона.
sys_msg, user = render(HERE / "prompts" / "translator-reflow.md", text, "")
# ⚠ боевой render_translator обрезает user ПОСЛЕ подстановки, общий рендер — до;
# разница в один перенос строки. Уравниваем, иначе армы отличаются не только лечением.
user = user.strip()
else:
sys_msg, user = render_translator(text)
terms = [t for t in P.TERMS if t in text]
return [{"role": "system", "content": sys_msg},
{"role": "system", "content": block_for(terms, None, False)},
{"role": "user", "content": user}]
tpl = (CONTRACTS_EN if en else CONTRACTS)[contract]
sys_msg, user = render(tpl, text, draft, en)
m = [{"role": "system", "content": sys_msg}]
if block and block.strip():
m.append({"role": "system", "content": block})
m.append({"role": "user", "content": user})
return m
def call(model: str, msgs: list[dict], tag: str, nothink: bool, think_low: bool = False) -> dict:
"""nothink применяется ТОЛЬКО там, где провайдер это умеет; для DeepSeek — запрещено (эхо-мина)."""
f = RAW / f"{tag}.json"
if f.exists():
return json.loads(f.read_text(encoding="utf-8"))
kw: dict = dict(model=model, messages=msgs)
if model in OPENAI_FAMILY:
kw["max_completion_tokens"] = 16000 # quirks 00: не max_tokens у reasoning-моделей
else:
kw["max_tokens"] = 16000
kw["temperature"] = 0.4
ctrl = MODELS[model][5]
extra = {}
if think_low:
# Режим «мышление ВКЛЮЧЕНО, но дёшево»: явный low. У glm выключатель живёт в extra_body,
# поэтому «включено» = просто НЕ слать disable. У grok/luna ручка — reasoning_effort.
if ctrl == "effort_none":
if model in OPENAI_FAMILY:
kw["reasoning_effort"] = "low"
else:
extra["reasoning_effort"] = "low"
elif nothink:
if ctrl == "disable":
extra["thinking"] = {"type": "disabled"}
elif ctrl == "effort_none":
if model in OPENAI_FAMILY:
kw["reasoning_effort"] = "none" # плоский параметр, живой замер пробы C
else:
extra["reasoning_effort"] = "none"
else:
raise SystemExit(f"ОТКАЗ: у {model} мышление гасить нельзя (ctrl={ctrl}); эхо-мина")
if extra:
kw["extra_body"] = extra
t0 = time.time()
r = client(model).chat.completions.create(**kw)
ch = r.choices[0]
u = r.usage
pt = getattr(u, "prompt_tokens", 0) or 0
ct = getattr(u, "completion_tokens", 0) or 0
cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0
reasoning = getattr(ch.message, "reasoning_content", None) or ""
rt = getattr(getattr(u, "completion_tokens_details", None), "reasoning_tokens", 0) or 0
rec = dict(tag=tag, model=model, model_returned=r.model, nothink=nothink,
extra_body=extra, effort_param=kw.get("reasoning_effort"),
reasoning_tokens=rt, finish=ch.finish_reason,
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
prompt_tokens=pt, cached_tokens=cached, completion_tokens=ct,
cost_usd=round(price(model, pt, cached, ct), 6),
latency_s=round(time.time() - t0, 1),
content=ch.message.content or "",
reasoning_chars=len(reasoning),
reasoning=reasoning, # ⚠ НОВОЕ: текст, а не только длина
messages=msgs)
f.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"[{tag}] {model}{' nothink' if nothink else ''} finish={ch.finish_reason} "
f"in={pt}(c{cached}) out={ct} размышл={len(reasoning)}зн текст={len(rec['content'])}зн "
f"${rec['cost_usd']:.6f} {rec['latency_s']}s")
return rec
REPAIR = ("Часть твоих операций НЕ ПРИМЕНИЛАСЬ. Ниже — они и причина отказа.\n"
"Дошли исправленные версии ТОЛЬКО для них, в том же формате SEARCH/REPLACE. "
"Не придумывай новых правок и не меняй смысл замены — чини только якорь SEARCH.\n"
"Если исправить нельзя, выведи NO_CHANGE.\n\n")
def classify_ops(draft: str, reply: str) -> list[tuple[str, str, str]]:
"""[(search, replace, статус)] по тем же правилам, что у аппликатора.
⚠ Своя реализация, а НЕ чтение `ApplyResult.details`: селф-ревью 05.08 показало, что details —
список СТРОК с обрезанным до 60 знаков превью, из него исходный якорь не восстановить, а раунд
починки обязан вернуть модели её собственный текст операции целиком.
`apply.py` при этом не правится — он заморожен и самопроверен 15/15.
"""
ops, malformed = parse_ops(reply)
if malformed:
return []
hay, _ = _fold_map(draft)
out = []
for search, replace in ops:
key = fold(search)
if not key:
out.append((search, replace, "reject_empty"))
continue
n = hay.count(key)
out.append((search, replace,
"ok" if n == 1 else ("reject_notfound" if n == 0 else "reject_ambiguous")))
return out
def repair_round(model: str, base_msgs: list[dict], first: str, draft: str, tag: str,
nothink: bool) -> dict | None:
"""Копия харнес-петли: отклонённые операции возвращаются модели С ТЕКСТОМ ОШИБКИ."""
bad = [(s, r, st) for s, r, st in classify_ops(draft, first) if st != "ok"]
if not bad:
return None
why = {"reject_notfound": "фрагмент не найден в черновике посимвольно",
"reject_ambiguous": "фрагмент встречается несколько раз — нужен более длинный якорь",
"reject_empty": "пустой якорь"}
lines = [f"--- ОШИБКА: {why.get(st, st)}\n<<<<<<< SEARCH\n{s}\n=======\n{r}\n>>>>>>> REPLACE"
for s, r, st in bad]
msgs = base_msgs + [{"role": "assistant", "content": first},
{"role": "user", "content": REPAIR + "\n\n".join(lines)}]
return call(model, msgs, f"{tag}-fix", nothink)
def spent() -> float:
"""Сумма по ЗАПИСЯМ ВЫЗОВОВ. ⚠ Фильтр на dict обязателен: в том же каталоге лежат сводки
армов (`repair-*.json` — список строк), и без фильтра `spent()` падал, блокируя весь риг."""
total = 0.0
for f in RAW.glob("*.json"):
if f.name.endswith(".applied.json"):
continue
try:
d = json.loads(f.read_text(encoding="utf-8"))
except Exception:
continue
if isinstance(d, dict) and "cost_usd" in d:
total += d["cost_usd"]
return total
def plan(models: list[str], contracts: list[str], en: bool = False,
think_on: bool = False) -> list[tuple]:
out = []
for k, (_, buf, terms) in enumerate(pick_windows()):
if en:
f = OLD_RAW / f"fp-draft-E{k}.json"
if not f.exists():
print(f" w{k}: нет английского черновика {f.name}, пропуск")
continue
draft = json.loads(f.read_text(encoding="utf-8"))["content"]
blk = en_block(terms)
else:
draft = P.draft_of(k)
blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None)
for m in models:
# ⚠ ФИКС 05.08 (пойман на первом вызове grok): было `== "disable"`, из-за чего модели
# с механизмом `effort_none` (grok-4.3, gpt-5.6-luna) уезжали С ВКЛЮЧЁННЫМ мышлением —
# арм измерял бы не то, что заявлено. Гасим везде, где провайдер это умеет.
nothink = (not think_on) and MODELS[m][5] in ("disable", "effort_none")
short = {"deepseek-v4-pro": "dp", "glm-5": "gl", "deepseek-v4-flash": "df",
"grok-4.3": "gr", "gpt-5.6-luna": "lu"}[m]
for c in contracts:
pre = "ehen" if en else ("eht" if think_on else "eh")
out.append((f"{pre}-{short}-{c}-w{k}", m, c, buf, draft, blk, nothink))
return out
def cmd_run(models, contracts, dry, en=False, think_on=False):
pl = plan(models, contracts, en, think_on)
total = spent()
print(f"план: {len(pl)} вызовов; уже потрачено ${total:.6f}; потолок ${CEILING_USD}")
if dry:
for tag, m, c, *_ in pl:
print(f" {tag:28s} {m:18s} {c}")
return
for tag, m, c, buf, draft, blk, nothink in pl:
worst = 16000 / 1e6 * MODELS[m][4] + 6000 / 1e6 * MODELS[m][2]
if total + worst > CEILING_USD or total >= HARD_STOP:
print(f"СТОП (проекционный гард): ${total:.4f} + худший ${worst:.4f} > ${CEILING_USD}")
return
msgs = messages(c, buf, draft, blk, en)
rec = call(m, msgs, tag, nothink, think_on)
total = spent()
if c.startswith("diff"):
res = apply_ops(draft, rec["content"])
fix = None
if c == "diff-harness":
fix = repair_round(m, msgs, rec["content"], draft, tag, nothink)
if fix:
res2 = apply_ops(res.text, fix["content"])
res.applied += res2.applied
res.ops_total += res2.ops_total
res.text = res2.text
total = spent()
(RAW / f"{tag}.applied.json").write_text(json.dumps(dict(
tag=tag, ops_total=res.ops_total, applied=res.applied, no_change=res.no_change,
malformed=res.malformed, rejected_notfound=res.rejected_notfound,
rejected_ambiguous=res.rejected_ambiguous, rejected_overlap=res.rejected_overlap,
rejected_empty=res.rejected_empty, repaired=bool(fix), text=res.text),
ensure_ascii=False, indent=1), encoding="utf-8")
print(f" apply: ops={res.ops_total} applied={res.applied} "
f"no_change={res.no_change} починка={'да' if fix else 'нет'}")
time.sleep(0.3)
print(f"ИТОГО эксп 20: ${spent():.6f}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--models", default="deepseek-v4-pro")
ap.add_argument("--contracts", default="full,diff-harness,locate")
ap.add_argument("--dry", action="store_true")
ap.add_argument("--en", action="store_true", help="английская цель (черновики fp-draft-E*)")
ap.add_argument("--think", action="store_true", help="мышление ВКЛЮЧЕНО (low), префикс eht-")
a = ap.parse_args()
cmd_run([m.strip() for m in a.models.split(",")],
[c.strip() for c in a.contracts.split(",")], a.dry, a.en, a.think)