#!/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)