356 lines
22 KiB
Python
356 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
"""ЭКСП 20: контракт редактора, скопированный с харнесов, против full-regen — на нескольких моделях.
|
||
|
||
Повод (владелец 05.08): эксп-19 намерил «дифф в 2.3–2.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)
|