#!/usr/bin/env python3 """Ф0.5 — БАНК ТЕРМИНОВ СРЕЗА: майнинг кандидатов → терминолог-прогон → ручной канон. Зачем банк нужен ДО первой панели. Закон-блок D39.104 обязателен во всех армах с переводом или редактурой, а метрика ПОКРЫТИЯ КАНОНА — единственный контур, которым эксп-21 поймал, что редактор без банка ломает терминологию черновика (0.917 → 0.566). Без банка эксп-22 остался бы без этого контура на новой книге. ⚠ ГРАНИЦА, объявляю: это НЕ ось эксперимента. Наличие банка как ФАКТОР (строки 5/36б бэклога) — чужой слот и здесь не варьируется: банк собирается один раз и подаётся ВСЕМ армам одинаково. ⚠ «РУЧНОЙ КАНОН ≠ ПОДПИСЬ ВЛАДЕЛЬЦА» (образец D39.47). Терминолог предлагает, сессия правит явные промахи, и получившийся банк помечен `signed: false`. Он годен как ЗАКОН внутри замера (все армы видят одно) и НЕ годен как продуктовый канон книги. Запуск: --mine кандидаты и KWIC, $0 --ask терминолог-прогон (Ф0, платно) --canon свести ответ + ручные правки в bank.json, $0 --show что в банке, $0 """ from __future__ import annotations import json import os import re import sys from collections import Counter from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") ZONE = Path(__file__).resolve().parent sys.path.insert(0, str(ZONE)) sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) from dotenv import load_dotenv # noqa: E402 from openai import OpenAI # noqa: E402 import material as M # noqa: E402 import money as MONEY # noqa: E402 import prompts as PR # noqa: E402 from roster import CANDIDATES # noqa: E402 load_dotenv(REPO / "eval" / ".env") OUT = MONEY.OUT # ⚠ ПАРА — ПАРАМЕТР, а не константа. Умолчание zh-ru сохраняет прежнее поведение побайтно; # `configure(pair)` переводит модуль на другую пару, ничего не ветвя по языку в логике. _PAIR = None # заполняется лениво, чтобы импорт не тянул провайдер def pair(): global _PAIR if _PAIR is None: import importlib.util # noqa: PLC0415 sp = importlib.util.spec_from_file_location( "para", REPO / "eval" / "dovodka" / "para.py") mod = importlib.util.module_from_spec(sp) sys.modules.setdefault("para", mod) sp.loader.exec_module(mod) _PAIR = mod.ZH return _PAIR def configure(p, material=None) -> None: """Перевести банк на пару `p` (объект `para.Pair`); `material` — callable() -> str. ⚠ Материал передаётся ЯВНО. Иначе `source_text()` тихо остаётся на китайских главах, и терминолог получает китайскую книгу под видом английской — банк собрался бы «успешно» и оказался мусором, а покрытие на выходе арма было бы посчитано против него же. """ global _PAIR, TERMINOLOGIST, _MATERIAL _PAIR = p _MATERIAL = material # Порядок поиска: пар-пакет полигона → боевой промт бэкенда. Обратный порядок молча # подсунул бы китайского терминолога английской книге; `backend/` — чужая зона, туда пишет # бэкенд-сессия, поэтому новые пары живут в пакете полигона. cands = [p.prompt_pack / "terminologist.md", REPO / "backend" / "prompts" / p.key / "terminologist.md"] TERMINOLOGIST = next((c for c in cands if c.exists()), None) if TERMINOLOGIST is None: raise SystemExit("⛔ нет промта терминолога для пары " + p.key + ": искал\n " + "\n ".join(str(c) for c in cands)) TERMINOLOGIST = REPO / "backend" / "prompts" / "zh-ru" / "terminologist.md" # Терминолог — вспомогательный инструмент, не арм. Берётся дешёвая модель с управляемым # размышлением: у боевого `deepseek-v4-flash` бэнк-роли ручки эффорта не имеют и упираются в # стену (quirks §10 п.10, 4 батча из 5 пустые), а гасить thinking у DeepSeek запрещено. TERM_MODEL = "gpt-5.6-luna" N_TERMS = 26 MIN_COUNT = 6 KWIC_N, KWIC_W = 3, 46 # Служебные и грамматические знаки: n-грамма, начинающаяся или кончающаяся на них, термином не # бывает. Список закрытый и объявлен ДО майнинга. STOP_EDGE = set("的了是在和有我你他她们不就都而及与也很到说着过又却把被将从对为以之其这那一二三四五" "六七八九十个人上下中大小时年日月来去出可能会要没什么样如此但只还更最") # Начала предложений и служебные слова английского: заглавная буква у них позиционная, а не # именная. Список закрытый и объявлен ДО майнинга, как и китайский. STOP_WORD = {"The", "And", "But", "For", "She", "His", "Her", "They", "That", "This", "With", "When", "What", "Then", "There", "Their", "Was", "Not", "You", "Who", "How", "One", "All", "Now", "Him", "Its", "Had", "Have", "From", "Been", "Would"} STOP_WHOLE = {"自己", "已经", "现在", "眼中", "心中", "一个", "什么", "这样", "那些", "如今", "顿时", "此刻", "随后", "所以", "因为", "如果", "虽然", "而且", "然后", "似乎"} _MATERIAL = None # ⚠ КАССА СБОРКИ. Для zh — прежняя «Ф0» эксп-22, побайтно как было. Для новых пар банк есть # покупка ФАЗЫ Д (пре-рег: «ФД-B — Д3 en→ru: банк, контам, армы, пол»), и биллить её в Ф0 значит # сделать трату невидимой потолку фазы — тот же класс, что увёл 7 клеток пробы в чужой каталог. _PHASE = "Ф0" _TAGPFX = "tp0" def _term_tag() -> str: """Тег покупки терминолога. Несёт пару, иначе кэш `purchase` смешивает пары.""" P = pair() return "tp0-bank-terminolog" if P.key == "zh-ru" else f"{_TAGPFX}-bank-terminolog" def source_text() -> str: return _MATERIAL() if _MATERIAL else "\n".join(c["text"] for c in M.chapters()) def mine() -> list[tuple[str, int]]: """Кандидаты в термины: частые CJK n-граммы, не поглощённые более длинной n-граммой. Метод грубый и объявлен таким: его задача — дать терминологу СПИСОК, решение принимает он плюс ручная правка. Точность майнинга на банк не влияет — влияет полнота. """ text = source_text() cnt: Counter[str] = Counter() if pair().spaced_source: # ⚠ У пробельной письменности n-граммный майнер по иероглифам не находит НИЧЕГО и молча # отдаёт терминологу пустой список — банк вышел бы пустым, а покрытие ложно-стопроцентным. # Кандидаты здесь — заглавные слова и биграммы заглавных: имена, титулы, топонимы. # Диакритика ОБЯЗАТЕЛЬНА в классе: французский слой книги (François ×517, Fabién ×180, # Château) ASCII-классом не майнится вовсе, а это ровно тот слой, ради которого срез # стратифицирован. `\b` с юникодными буквами работает, класс — нет. for m in re.finditer(r"(?= MIN_COUNT][:N_TERMS] for n in (4, 3, 2): for m in re.finditer(r"[%s]{%d}" % (pair().mine_chars, n), text): cnt[m.group(0)] += 1 cand = {w: c for w, c in cnt.items() if c >= MIN_COUNT and w not in STOP_WHOLE and w[0] not in STOP_EDGE and w[-1] not in STOP_EDGE} # Поглощение: короткая n-грамма выбрасывается, если длинная её содержит и почти так же часта. out = {} for w, c in sorted(cand.items(), key=lambda kv: (-len(kv[0]), -kv[1])): if any(w in longer and c <= 1.25 * cc for longer, cc in out.items()): continue out[w] = c return sorted(out.items(), key=lambda kv: -kv[1])[:N_TERMS] def kwic(term: str, n: int = KWIC_N) -> list[str]: text = source_text() out, pos = [], 0 for _ in range(n): i = text.find(term, pos) if i < 0: break out.append(text[max(0, i - KWIC_W):i + len(term) + KWIC_W].replace("\n", " ")) pos = i + len(term) + 200 return out def _payload() -> str: rows = [] for t, c in mine(): rows.append(f"term: {t}\ncount: {c}\n" + "\n".join(f"ctx: {x}" for x in kwic(t))) return "\n\n".join(rows) def cmd_mine() -> None: ms = mine() print(f"кандидатов {len(ms)} (порог частоты {MIN_COUNT}, знаков в срезе {len(source_text()):,})") for t, c in ms: print(f" {t:6s} {c:4d} {kwic(t, 1)[0][:70] if kwic(t, 1) else ''}") def cmd_ask() -> None: core, _few, user = PR.load_template(TERMINOLOGIST) # ⚠ Бриф — ПАРЫ, а не всегда китайский: иначе терминолог en/ja получал китайский бриф # (source_lang zh, чужая книга) при английском пейлоаде и канонизировал бы вслепую. brief = getattr(PR, pair().brief_key) msgs = [{"role": "system", "content": PR.render(core, brief, "", "")}, {"role": "user", "content": PR.render(user, brief, _payload(), "")}] led = MONEY.Guarded(_PHASE, default_expect=0.02) base, env, *_ = CANDIDATES[TERM_MODEL] cl = OpenAI(api_key=os.environ[env], base_url=base, timeout=600) kw = dict(model=TERM_MODEL, messages=msgs, max_completion_tokens=4000, extra_body={"reasoning_effort": "low"}) # ⚠ Тег НЕСЁТ ПАРУ: `purchase` возвращает существующий файл как кэш, и пар-слепой тег # молча отдал бы оплаченный КИТАЙСКИЙ ответ терминолога под видом английского, за $0. rec = MONEY.purchase(led, _term_tag(), TERM_MODEL, cl, kw, role="terminologist") if rec.get("skipped"): print("⛔ потолок Ф0 — терминолог не куплен") return print(rec.get("content", "")[:4000]) print(f"\nfinish={rec.get('finish')} ${rec.get('cost_usd', 0):.6f} · " f"потрачено Ф0 ${led.spent():.6f}") def _rx(dst: str) -> str: """Регекс канонной формы под русское словоизменение. ⚠ Правка ПОСЛЕ фриза Ф0 и ПОСЛЕ покупки терминолога (то есть $0 и на результат прогона не влияет), объявляется как девиация. Первая версия усекала только слова от 6 знаков, и короткие склоняемые слова канона («род Цинь») не находились в форме «рода Цинь» — метрика покрытия занижалась бы на всех армах одинаково, но занижалась. ⚠⚠ ГРАНИЦА СЛОВА В НАЧАЛЕ — поправка фазы Д (адверсариальное ревью 10.08). Без неё усечённый корень ловился В СЕРЕДИНЕ чужого слова: канон «род Цинь» матчился на «Го-РОд Цинчжоу» и «Б-РОвь Цинь», канон «род Су» — на «Пилюлю Кровавой Су». Замер: **30 ложных попаданий из 2113 = 1.4%**; кросс-термовых коллизий (одна форма подходит под ДВА канона) нет ни одной. Влияние на печатные числа: покрытие всех армов падает на 0.003–0.004 — то есть В ОДНУ сторону у всех, и ни один относительный вывод эксп-22 не двигается. Числа в отчёте пере-сняты. `(?= 4 else 1 if len(raw) == 3 else 0 drop = max(0, min(drop, len(raw) - ms)) if ms else drop parts.append(w[:-drop] + r"\w*" if drop else (w + r"\w*" if ms else w)) return r"(? dict[str, tuple[str, str]]: return MANUAL_BY_PAIR.get(pair().key, {}) def _drop() -> set[str]: return DROP_BY_PAIR.get(pair().key, set()) MANUAL: dict[str, tuple[str, str]] = {} # Выброшено из банка: это общеязыковая лексика, а не термины книги. Канон на них сделал бы # метрику покрытия замером словарного совпадения, а не терминологической дисциплины. DROP = {"瞬间", "声音", "仿佛", "庭院"} def cmd_canon() -> None: f = MONEY.OUT / f"{_term_tag()}.json" if not f.exists(): print("нет ответа терминолога — сначала --ask") return ans = json.loads(f.read_text(encoding="utf-8"))["content"] got: dict[str, str] = {} for line in ans.splitlines(): if "\t" not in line: continue # ⚠ ТРИ поля, не два: формат терминолога v3 — термин · перевод · уверенность 0–100. # `split("\t", 1)` уносил хвост `\t95` в перевод и отравлял регекс канонной формы. cols = [x.strip() for x in line.split("\t")] if len(cols) < 2: continue src, dst = cols[0], cols[1] if src and dst and "TM-NO-DST" not in dst: got[src] = dst terms = {} for src, dst in got.items(): if src in _drop(): continue if src in _manual(): dst, why = _manual()[src] else: why = "" terms[src] = dict(dst=dst, rx=_rx(dst), manual=bool(why), why=why) for src, (dst, why) in _manual().items(): terms.setdefault(src, dict(dst=dst, rx=_rx(dst), manual=True, why=why)) # ⚠ Пишем в банк ПАРЫ. Безусловная запись в zh-путь затирала бы китайский банк при сборке # английского, и оба прогона переписывали бы друг друга. P = pair() out_file = PR.BANK_FILE if P.key == "zh-ru" else P.bank_file out_file.parent.mkdir(parents=True, exist_ok=True) out_file.write_text(json.dumps( dict(book=M.BOOK["title"] if P.key == "zh-ru" else P.key, signed=False, note="ручной канон сессии, НЕ подпись владельца (образец D39.47)", model=TERM_MODEL, dropped=sorted(_drop()), terms=terms), ensure_ascii=False, indent=1), encoding="utf-8") print(f"банк собран: {len(terms)} терминов · ручных правок {sum(1 for v in terms.values() if v['manual'])}" f" · выброшено {len(_drop())}") cmd_show() def cmd_show() -> None: # ⚠ Банк ПАРЫ, а не умолчание: после сборки английского банка показывался китайский. P = pair() b = PR.bank(None if P.key == "zh-ru" else P.bank_file) if not b: print("банка нет") return src = source_text() print(f"{'терм':8s}{'канон':34s}{'в срезе':>9s} регекс") for t, v in sorted(b.items(), key=lambda kv: -src.count(kv[0])): print(f"{t:8s}{v['dst']:34s}{src.count(t):9d} {v['rx']}" + (" ← ручная правка" if v.get("manual") else "")) def coverage(source: str, out: str) -> tuple[int, int]: """(попаданий, знаменатель) канонной формы — метрика эксп-21 §16.1, счётная. Знаменатель — упоминания термина В ИСХОДНИКЕ; попадания капятся знаменателем, иначе многословный выход набирал бы покрытие повторами. """ k = s = 0 P = pair() for t, v in PR.bank(P.bank_file if P.key != "zh-ru" else None).items(): # ⚠ Не `source.count(t)`: на пробельной письменности подстрока считает `Dawn` внутри # `Dawnbreaker` и теряет `dawn` со строчной. Знаменатель — по границе слова. n = P.count_in_source(t, source) if n: s += n k += min(len(re.findall(v["rx"], out, re.I)), n) return k, s def selfcheck() -> int: bad = 0 def chk(n: str, ok: bool, d: str = "") -> None: nonlocal bad bad += not ok print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else "")) chk("регекс усекает словоизменение", bool(re.search(_rx("истинная ци"), "истинной ци", re.I))) chk("регекс не ловит чужое слово", not re.search(_rx("апертура"), "аперитив", re.I)) chk("регекс держит многословную форму", bool(re.search(_rx("море истинной ци"), "Море истинной ци", re.I))) # Регрессии на реальные формы банка: короткое склоняемое слово и прилагательное. for canon, form in (("род Цинь", "роду Цинь"), ("род Цинь", "рода Циня"), ("левый канцлер", "левого канцлера"), ("глава рода", "главе рода"), ("Цинь Фэн", "Цинь Фэну"), ("Су Цинсюэ", "Су Цинсюэ"), ("Гу Хэ", "Гу Хэ"), ("Фэн-лао", "Фэн-лао")): chk(f"«{canon}» находится в форме «{form}»", bool(re.search(_rx(canon), form, re.I))) chk("канон не ловит другой род", not re.search(_rx("род Цинь"), "рода Су", re.I)) b = PR.bank() if b: t = next(iter(b)) k, s = coverage(source_text(), b[t]["dst"] * 3) chk("покрытие не превышает знаменатель", k <= s, f"{k}/{s}") chk("покрытие пустого выхода = 0", coverage(source_text(), "")[0] == 0) print(f"\n{'БАНК-ОСНАСТКА ЧИСТА' if not bad else f'ПРОВАЛОВ: {bad}'}") return bad def _select_pair(argv: list[str]) -> None: """`--pair=en-ru` переводит сборку на пару ЦЕЛИКОМ: бриф, тег покупки, банк-файл, материал. Материал берётся из ПРОЗЫ книги пары, а не из отобранных единиц: терминолог по замыслу роли «видит всю книгу сразу», и канон, собранный по 16 отрывкам, не был бы каноном книги. Термин, не встреченный в единице, покрытию не мешает — `coverage` пропускает нулевой знаменатель. """ key = next((a.split("=", 1)[1] for a in argv if a.startswith("--pair=")), "zh-ru") if key == "zh-ru": return import importlib.util # noqa: PLC0415 sp = importlib.util.spec_from_file_location("para", REPO / "eval" / "dovodka" / "para.py") para = importlib.util.module_from_spec(sp) sys.modules["para"] = para sp.loader.exec_module(para) if key == "en-ru": sp2 = importlib.util.spec_from_file_location( "pair_en_b", REPO / "eval" / "role_topology" / "pair_en.py") PE = importlib.util.module_from_spec(sp2) sys.modules["pair_en_b"] = PE sp2.loader.exec_module(PE) mat = lambda: "\n".join(c for c in PE.chunks() if PE.is_prose(c)) # noqa: E731 elif key == "ja-ru": sp2 = importlib.util.spec_from_file_location( "mat_ja_b", REPO / "eval" / "dovodka" / "material_ja.py") MJ = importlib.util.module_from_spec(sp2) sys.modules["mat_ja_b"] = MJ sp2.loader.exec_module(MJ) mat = lambda: MJ.text() # noqa: E731 else: raise SystemExit(f"⛔ пара {key} харнессу неизвестна") # Касса фазы Д. `money_d.configure` перенастраивает ТОТ ЖЕ модуль `money`, который bank.py # держит как MONEY, поэтому после загрузки OUT/TAGS/CEILINGS становятся фазовыми. Проверяем # ассертом, а не верим: молчаливое расхождение здесь стоит невидимых денег. global _PHASE, _TAGPFX spm = importlib.util.spec_from_file_location( "money_d_bank", REPO / "eval" / "dovodka" / "money_d.py") MD = importlib.util.module_from_spec(spm) sys.modules["money_d_bank"] = MD spm.loader.exec_module(MD) _PHASE = {"en-ru": "ФД-B", "ja-ru": "ФД-C"}[key] _TAGPFX = {"en-ru": "dv-b", "ja-ru": "dv-c"}[key] # ⚠ `money_d` настраивает СВОЙ экземпляр кассы (`M22`), а не тот `money`, который держит этот # модуль, — ассерт ниже это и поймал. Настраиваем наш экземпляр явно, тем же приёмом, каким # касса перенастраивается под любой пак, и лишь потом проверяем. import roster as _ROSTER # noqa: PLC0415 # ⚠ ZONE — каталог того, кто ПОКУПАЕТ, а не каталог фазы: фриз-гейт сверяет зону вызывающего # файла, и с `dovodka` он отверг бы сам bank.py. Ограничение при этом не ослаблено — это # исходная зона кассы эксп-22; фриз-проверка чистоты `bank.py` остаётся в силе. MONEY.configure(ZONE=Path(__file__).resolve().parent, OUT=MD.OUT, TAGS=MD.TAGS, CEILINGS=MD.CEILINGS, PACK_CEILING=MD.PACK_CEILING, GLOB_ALL="dv-*.json", ROSTER=_ROSTER) if MONEY.OUT != MD.OUT: raise SystemExit(f"⛔ касса банка {MONEY.OUT} против фазы {MD.OUT} — трата уедет мимо " "потолка фазы, СТОП") configure(para.PAIRS[key], material=mat) print(f"пара: {key} · банк → {para.PAIRS[key].bank_file} · терминолог {TERMINOLOGIST}") if __name__ == "__main__": a = [x for x in sys.argv[1:] if not x.startswith("--pair=")] or ["--mine"] _select_pair(sys.argv[1:]) fn = {"--mine": cmd_mine, "--ask": cmd_ask, "--canon": cmd_canon, "--show": cmd_show, "--selfcheck": lambda: sys.exit(1 if selfcheck() else 0)}.get(a[0]) fn() if fn else print(__doc__)