textmachine/eval/dovodka/contour.py

298 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
"""Д4 — EDIT-КОНТУР НА zh. Покупает армы A1A4 и A6 на 32 единицах. Платный.
Вопрос фазы (ставка владельца H-2б): нужен ли ПОЛНЫЙ ПЕРЕПИС, или дешевле и лучше найти
проблемные места и починить точечно. Армы объявлены в пре-реге Д0.5 и здесь не меняются:
A0 боевой full-regen (deepseek-v4-pro поверх якорного черновика) КУПЛЕН обоими паками, $0
A1 черновик + ДЕТЕРМИНИРОВАННЫЕ флаги (батарея + канон-гейт) → точечный фиксер
A2 dspro-однопроходка УРАВНЕННОГО мандата (строка 153 бэклога) — редактора нет вовсе
A3 черновик + СМЫСЛОВОЙ LLM-критик («прочитай, пойми, найди всё проблемное») → фиксер
A4 A0 + канон-фиксер ПОСЛЕ (полировка: чинит потери канона боевого редактора)
A6 ДРУГАЯ БАЗА: черновик dspro эксп-22 + оба контура — прямой носитель H-1
⚠ ПОЧЕМУ ФИКСЕР ВИДИТ ТОЛЬКО МЕСТА, А НЕ ВЕСЬ ТЕКСТ. Если дать фиксеру полный мандат, он станет
редактором, и контраст «точечно против переписа» исчезнет. Поэтому вход фиксера — черновик плюс
СПИСОК МЕСТ, и в промте прямо запрещено трогать что-либо ещё. Это и есть измеряемая позиция
контура; нарушение её — не оптимизация, а подмена арма.
⚠ ЧТО ДЕЛАЕТ КАНОН-ГЕЙТ. Детерминированная сверка покрытия банка после КАЖДОГО арма: он и
флагует потери для фиксера (вход A1/A4), и печатается отдельной осью результата. Политика
владельца 10.08: потеря канона = дефект безусловно; художественность — отдельная ось.
Запуск: contour.py --plan | --selftest | --run [A1 A2 A3 A4 A6] | --floor
"""
from __future__ import annotations
import importlib.util
import json
import sys
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
MONEY = _load("money_d", ZONE / "money_d.py")
PAN23 = _load("panel23", REPO / "eval" / "editor_tier" / "panel.py")
P22, MAT = PAN23.P22, PAN23.MAT
PR = _load("prompts22", REPO / "eval" / "tenant_panel" / "prompts.py")
BANK = _load("bank22", REPO / "eval" / "tenant_panel" / "bank.py")
ROSTER = _load("roster22", REPO / "eval" / "tenant_panel" / "roster.py")
ED22 = _load("editors22", REPO / "eval" / "tenant_panel" / "editors.py")
# ⚠⚠ КАССУ ПЕРЕ-НАСТРАИВАЕМ ПОСЛЕ ВСЕХ ЧУЖИХ ЗАГРУЗОК, И ЭТО НЕ ПЕДАНТИЗМ.
# Модуль `buy` — СИНГЛТОН в `sys.modules`, и побеждает тот, кто настроил его последним. `money_d`
# ставил `BUY.OUT` на каталог фазы, а грузившийся следом `panel.py` пака 23 тянул его кассу и
# перебивал обратно на `~/books/editor-tier`. Итог был бесшумным и дорогим: кэш-чек, замок и
# `finally` смотрели в каталог фазы, а РЕЗУЛЬТАТ писался в каталог чужого пака. Семь оплаченных
# клеток ушли туда, касса фазы показывала $0, а цикл покупки 20 минут ждал появления файла,
# который в её каталоге не появился бы никогда. Найдено многоагентным разбирательством 10.08.
MONEY.M22.configure(ZONE=ZONE.parent / "dovodka", OUT=MONEY.OUT, TAGS=MONEY.TAGS,
CEILINGS=MONEY.CEILINGS, PACK_CEILING=MONEY.PACK_CEILING,
GLOB_ALL="dv-*.json", ROSTER=ROSTER)
if MONEY.M22.BUY.OUT != MONEY.OUT: # ассерт, а не надежда
raise SystemExit(f"⛔ buy.OUT={MONEY.M22.BUY.OUT} против кассы {MONEY.OUT}"
"покупки уедут в чужой каталог, СТОП")
OUT = MONEY.OUT
ANCHOR = "deepseek-v4-flash" # якорный черновик
FIXER = "deepseek-v4-pro" # фиксер и критик — боевой редакторский движок
DRAFT2 = "deepseek-v4-pro" # «качественный черновик» H-1 (арм D2 эксп-22)
ARMS = ("A1", "A2", "A3", "A4", "A6")
# ── единицы: 16 эксп-22 + 16 эксп-23, база у каждой своя и уже куплена ────────────────────────
def units() -> list[dict]:
return P22.M.units_zh() + MAT.units_new()
def base_draft(uid: str) -> str:
"""Якорный черновик единицы. У старых — из сырья эксп-22, у новых — из фазы B эксп-23."""
t = P22.draft_text(ANCHOR, uid)
return t if t.strip() else PAN23.draft_b(uid) # новые единицы — фаза B пака 23
def base_a0(uid: str) -> str:
"""Боевой full-regen над якорной базой — арм A0, куплен, $0."""
t = P22.edit_text(ANCHOR, uid)
return t if t.strip() else PAN23.text_b("R0", uid)
def base_draft2(uid: str) -> str:
"""«Качественный черновик» H-1: черновик dspro эксп-22. Есть только у 16 старых единиц."""
return P22.draft_text(DRAFT2, uid)
# ── флаги: детерминированные места, которые фиксер обязан починить ────────────────────────────
def canon_gaps(src: str, out: str) -> list[str]:
"""Термины банка, чья канонная форма В ВЫХОДЕ встречается реже, чем в исходнике."""
import re # noqa: PLC0415
gaps = []
for t, v in BANK.PR.bank().items():
n = src.count(t)
if not n:
continue
if min(len(re.findall(v["rx"], out, re.I)), n) < n:
gaps.append(f"«{v['dst']}» (в исходнике {t} встречается {n} раз)")
return gaps
def flags_of(u: dict, draft: str) -> list[str]:
"""Список мест для A1. Только ДЕТЕРМИНИРОВАННОЕ: канон-гейт + батарея, без судьи и без LLM."""
import battery as B # noqa: PLC0415
out = []
for g in canon_gaps(u["source"], draft):
out.append(f"КАНОН: термин {g} передан не канонной формой либо пропущен")
try:
r = B.run_unit(dict(uid=u["uid"], source=u["source"], text=draft))
for k, v in (r or {}).items():
if isinstance(v, (int, float)) and v and k not in ("uid", "len"):
out.append(f"БАТАРЕЯ: проверка «{k}» дала {v}")
except Exception: # noqa: BLE001, S110
pass # батарея не обязана знать наш вход
return out
# ── промты армов: данные, не код ──────────────────────────────────────────────────────────────
FIX_HEAD = (
"Ты — редактор-корректор. Тебе дан ИСХОДНИК, его ЧЕРНОВОЙ ПЕРЕВОД и СПИСОК МЕСТ, где перевод "
"неверен.\n\n"
"Почини ТОЛЬКО перечисленные места. Всё остальное оставь ПОБАЙТНО как есть: не переписывай "
"фразы, не улучшай стиль, не меняй вёрстку, не трогай пунктуацию там, где о ней не сказано. "
"Если место починить нельзя, не трогая соседнего текста, — почини минимально возможной "
"правкой.\n\nВыведи ТОЛЬКО полный текст перевода после правок, без преамбул и комментариев.")
CRIT_HEAD = (
"Ты — литературный редактор-эксперт. Тебе дан ИСХОДНИК и его ЧЕРНОВОЙ ПЕРЕВОД.\n\n"
"ПРОЧИТАЙ оба текста и найди ВСЁ проблемное в переводе: искажения и потери смысла, неверно "
"понятые реалии, кальки, канцелярит, несуществующие слова, рассогласование, сбитую вёрстку "
"реплик, непоследовательную передачу имён и терминов.\n\n"
"Выведи СПИСОК МЕСТ, по одному в строке, в формате:\n"
"- <цитата из перевода> → <в чём ошибка>\n\n"
"Только список. Не переписывай текст, не предлагай готовый перевод целиком.")
def fix_msgs(src: str, draft: str, places: list[str]) -> list[dict]:
law = PR.law_block(PR.terms_in(src))
body = (f"{FIX_HEAD}\n\n{law}\n\n---USER---\nИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}\n\n"
"МЕСТА, КОТОРЫЕ НАДО ПОЧИНИТЬ:\n" + "\n".join(f"- {p}" for p in places))
s, u = body.split("---USER---", 1)
return [{"role": "system", "content": s.strip()}, {"role": "user", "content": u.strip()}]
def crit_msgs(src: str, draft: str) -> list[dict]:
# Критику банк-закон нужен не меньше фиксера: без него он назовёт «непоследовательным» то,
# что канону как раз отвечает, и наоборот. Банк-закон D39.104 — на ВСЕХ армах, без изъятий.
law = PR.law_block(PR.terms_in(src))
return [{"role": "system", "content": f"{CRIT_HEAD}\n\n{law}"},
{"role": "user", "content": f"ИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}"}]
def tag(arm: str, uid: str, part: str = "") -> str:
ph = "f" if arm == "A6" else "a"
return f"dv-{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}"
def text_of(arm: str, uid: str) -> str:
f = OUT / f"{tag(arm, uid)}.json"
if f.exists():
return json.loads(f.read_text(encoding="utf-8")).get("content") or ""
return ""
def cmd_plan() -> None:
us = units()
old = {u["uid"] for u in P22.M.units_zh()}
print(f"единиц {len(us)} (старых {len(old)} + новых {len(us) - len(old)})")
have = {a: sum(1 for u in us if text_of(a, u["uid"]).strip()) for a in ARMS}
for a in ARMS:
n = len(old) if a == "A6" else len(us)
print(f" {a}: куплено {have[a]:2d} из {n}")
base = sum(1 for u in us if base_draft(u["uid"]).strip())
a0 = sum(1 for u in us if base_a0(u["uid"]).strip())
d2 = sum(1 for u in us if base_draft2(u["uid"]).strip())
print(f"базы (уже куплены, $0): черновик {base}/{len(us)} · A0 {a0}/{len(us)} · "
f"черновик-dspro {d2}/{len(old)}")
MONEY.report()
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("единиц ровно 32", len(us) == 32, str(len(us)))
ck("uid уникальны", len({u["uid"] for u in us}) == len(us))
miss = [u["uid"] for u in us if not base_draft(u["uid"]).strip()]
ck("якорный черновик есть у всех", not miss, f"нет у {miss[:3]}")
miss0 = [u["uid"] for u in us if not base_a0(u["uid"]).strip()]
ck("арм A0 (боевой перепис) есть у всех", not miss0, f"нет у {miss0[:3]}")
old = P22.M.units_zh()
miss2 = [u["uid"] for u in old if not base_draft2(u["uid"]).strip()]
ck("черновик-dspro для H-1 есть у всех 16 старых", not miss2, f"нет у {miss2[:3]}")
# ИЗОЛЯЦИЯ: у A1 и A3 вход ОДИН И ТОТ ЖЕ черновик; различаться обязан только список мест
u = us[0]
d = base_draft(u["uid"])
m1 = fix_msgs(u["source"], d, ["КАНОН: тест"])
m3 = fix_msgs(u["source"], d, ["СМЫСЛ: тест"])
ck("A1 и A3 делят вход, различаясь только списком мест",
m1[0]["content"] == m3[0]["content"] and m1[1]["content"] != m3[1]["content"])
ck("в промте фиксера стоит запрет трогать остальное",
"ПОБАЙТНО как есть" in m1[0]["content"])
# ⚠ Банк-закон D39.104 обязателен на ВСЕХ армах с переводом/редактурой. Проверяется ДОСЛОВНО
# по заголовку блока, а не по слову «закон»: эксп-22 отступил от него на 72 en-клетках.
ck("банк-закон D39.104 в промте фиксера есть",
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in m1[0]["content"], m1[0]["content"][-90:].replace("\n", " "))
ck("банк-закон есть и в промте КРИТИКА — иначе он флагует канон не по банку",
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in crit_msgs(u["source"], d)[0]["content"])
fl = flags_of(u, d)
ck("канон-гейт выдаёт места (иначе A1 нечего чинить)", isinstance(fl, list), f"мест {len(fl)}")
# теги армов не пересекаются между собой и с чужими паками
tags = {tag(a, u["uid"]) for a in ARMS}
ck("теги армов различны", len(tags) == len(ARMS))
ck("теги покрыты глобом кассы", all(t.startswith(("dv-a-", "dv-f-")) for t in tags))
# ⚠ Главный ассерт этого харнесса: кэш-чек и ЗАПИСЬ результата смотрят в ОДИН каталог.
ck("buy.OUT совпадает с кассой фазы", MONEY.M22.BUY.OUT == MONEY.OUT,
f"{MONEY.M22.BUY.OUT} против {MONEY.OUT}")
print(f"\n{'ХАРНЕСС ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}")
return bad
def cmd_run(which: list[str]) -> None:
us = units()
old = {u["uid"] for u in P22.M.units_zh()}
for arm in which:
led = MONEY.Guarded("ФД-F" if arm == "A6" else "ФД-A")
for u in us:
uid, src = u["uid"], u["source"]
if arm == "A6" and uid not in old:
continue
if text_of(arm, uid).strip():
continue
draft = base_draft2(uid) if arm == "A6" else base_draft(uid)
if not draft.strip():
print(f" {uid}: базы нет — пропуск")
continue
if arm == "A2": # однопроходка: редактора нет
msgs = PR.translator_msgs(src) if hasattr(PR, "translator_msgs") else None
if msgs is None:
print(" A2: сборки однопроходки нет — арм пропущен, объявить в отчёте")
break
MONEY.purchase(led, tag(arm, uid), FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, msgs), role="onepass", uid=uid, arm=arm)
continue
if arm in ("A1", "A4"):
target = base_a0(uid) if arm == "A4" else draft
places = [f"КАНОН: термин {g} передан не канонной формой либо пропущен"
for g in canon_gaps(src, target)] if arm == "A4" else flags_of(u, draft)
if not places:
print(f" {uid}/{arm}: мест нет — чинить нечего, клетка не покупается")
continue
MONEY.purchase(led, tag(arm, uid), FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, fix_msgs(src, target, places)),
role="fixer", uid=uid, arm=arm, places=len(places))
continue
# A3 и A6: критик, затем фиксер по его списку
ct = tag(arm, uid, "crit")
if not (OUT / f"{ct}.json").exists():
MONEY.purchase(led, ct, FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, crit_msgs(src, draft)),
role="critic", uid=uid, arm=arm)
cf = OUT / f"{ct}.json"
if not cf.exists(): # оплачено, но файла нет — не падать
print(f" {uid}/{arm}: критик не отдал файл — арм пропущен, объявить в отчёте")
continue
found = json.loads(cf.read_text(encoding="utf-8")).get("content") or ""
places = [ln.strip("- ").strip() for ln in found.splitlines() if ln.strip().startswith("-")]
if not places:
print(f" {uid}/{arm}: критик не нашёл мест — фиксер не зовётся")
continue
MONEY.purchase(led, tag(arm, uid), FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, fix_msgs(src, draft, places[:40])),
role="fixer", uid=uid, arm=arm, places=len(places))
MONEY.report()
if __name__ == "__main__":
a = sys.argv[1:] or ["--plan"]
if a[0] == "--selftest":
sys.exit(1 if cmd_selftest() else 0)
if a[0] == "--run":
cmd_run([x for x in a[1:] if x in ARMS] or list(ARMS))
else:
cmd_plan()