298 lines
19 KiB
Python
298 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""Д4 — EDIT-КОНТУР НА zh. Покупает армы A1–A4 и 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()
|