268 lines
19 KiB
Python
268 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""КОНТАМИНАЦИОННАЯ ПРОБА ФАЗЫ Д — ЖИЛЬЦАМИ и на книгах, которые фаза реально покупает. Платный.
|
||
|
||
Заказ (Д3 и Д6) требует пробу «ЖИЛЬЦАМИ» — правило эксп-22 §1.1: гонять теми, кто работает,
|
||
а не удобной моделью. Непригоден по пробе → СТОП и пинг, замену без слова владельца не искать.
|
||
|
||
Риг НЕ пере-изобретается: берётся `eval/role_topology/contamination.py` и настраивается через
|
||
свои глобали — тем же приёмом, каким `money_d` настраивает кассу, а `sud` — судейский риг. Метод
|
||
пробы остаётся его: УЗНАВАНИЕ («что за произведение и кто автор») плюс КЛОУЗ ПО ИМЕНИ (из отрывка
|
||
вырезано имя собственное, модель называет пропущенное). Пороги — его же, объявленные до данных:
|
||
узнавание ≥3/5 = «знает о книге», клоуз ≥2/5 = «знает конкретику, то есть текст был в обучении».
|
||
|
||
⚠ ЧТО ЗДЕСЬ ДОБАВЛЕНО И ЗАЧЕМ.
|
||
|
||
1. **Книги фазы.** Прошлый прогон мерил `gu-zhenren`, `jinpingmei`, `fifty_shades`, `kristoff` и
|
||
СТАРУЮ японскую книгу. Новая японская (`enkan_no_hate`, замена по слову владельца после
|
||
срабатывания гардрейла 18+) не мерена ВООБЩЕ, а Д6 покупается именно на ней.
|
||
2. **Жильцы вместо удобной модели.** Прошлый прогон гнался `gpt-5.6-luna`. Это не жилец: черновики
|
||
делает `deepseek-v4-flash`, редактуру `deepseek-v4-pro`. Знание книги ИМИ и есть то, что
|
||
завышает качество перевода, а знание посторонней модели не значит ничего.
|
||
3. **Бюджет вывода поднят.** Редакция 1 прошлой пробы звала flash с 16000 токенов и получала
|
||
0–46 знаков: у DeepSeek размышление тарифицируется и считается ВНУТРИ бюджета, поэтому на
|
||
короткий ответ ничего не оставалось (стена quirks §10; ровно эта же стена сегодня съела 14
|
||
клеток арма A1B). Ответ здесь нужен КОРОТКИЙ, а думать модель будет много — значит бюджет
|
||
должен вмещать размышление ЦЕЛИКОМ, иначе «НЕ ЗНАЮ» будет означать «не успел сказать».
|
||
⚠ Если и при поднятом бюджете жилец возвращает пустоту — это НЕ «книга чиста», а непригодность
|
||
пробы на этом жильце, и она объявляется отдельно, а не растворяется в нуле.
|
||
4. **Положительный контроль обязателен на КАЖДОМ жильце.** Нулевой результат неинтерпретируем:
|
||
«модель не знает книгу» и «проба не работает на этой модели» дают одну картину. Контроль —
|
||
`jinpingmei` (минский канон, public domain, заведомо в претрейне). Не сработал контроль —
|
||
аннулируется весь прогон этого жильца, а не отдельная книга.
|
||
|
||
Запуск: contam_d.py --dry # план, $0
|
||
contam_d.py # платный
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
REPO = Path("/home/ubuntu/projects/textmachine")
|
||
ZONE = Path(__file__).resolve().parent
|
||
for d in ("dovodka", "role_topology", "tenant_panel"):
|
||
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")
|
||
CT = _load("contam21", REPO / "eval" / "role_topology" / "contamination.py")
|
||
ROSTER = _load("roster_c", REPO / "eval" / "tenant_panel" / "roster.py")
|
||
ED = _load("editors_c", REPO / "eval" / "tenant_panel" / "editors.py")
|
||
|
||
# ⚠⚠ КАССУ ПЕРЕ-НАСТРАИВАЕМ ПОСЛЕ ВСЕХ ЧУЖИХ ЗАГРУЗОК — та же мина, что описана в `contour.py`.
|
||
# `buy` — СИНГЛТОН в `sys.modules`, и побеждает тот, кто настроил его последним: `contamination`
|
||
# и `editors` тянут кассу эксп-21/22 и перебивают `BUY.OUT` обратно на `~/books/tenant-panel`.
|
||
# Отказ бесшумный и дорогой — проверено исполнением 14.08: 7 клеток пробы ушли в ЧУЖОЙ каталог,
|
||
# касса фазы показывала $0, а гейт потолка сторожил границу, к которой покупки не относились.
|
||
MONEY.M22.configure(ZONE=ZONE, 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} — "
|
||
"покупки уедут в чужой каталог, СТОП")
|
||
|
||
# ⚠ БЮДЖЕТ ВЫВОДА. У DeepSeek размышление тарифицируется и считается ВНУТРИ бюджета, поэтому
|
||
# короткий ответ при тесном капе не помещается вовсе: прошлая редакция звала flash с 16000 и
|
||
# получала 0–46 знаков. Ответ здесь нужен в две строки, думать модель будет много — кап должен
|
||
# вмещать размышление ЦЕЛИКОМ, иначе пустота будет означать «не успел сказать», а не «не знаю».
|
||
MAX_OUT = 32000
|
||
# ⚠ Кап ДО-КУПКИ усечённых клеток. У `deepseek-v4-pro` две клетки английской книги вернулись
|
||
# пустыми с `finish=length`: размышление съело все 32000 и на две строки ответа не осталось.
|
||
# Пустая клетка — не «книга чиста», а ОТСУТСТВИЕ данных (правило шапки), и решение о покупке
|
||
# несущей оси по 3 клеткам из 5 принимать нельзя. Усечённые уводятся в карантин `.LENGTH.json`
|
||
# (деньги остаются видимы кассе) и покупаются заново с этим капом. Девиация объявлена.
|
||
MAX_OUT_REFILL = 60000
|
||
# Глушить thinking у DeepSeek ЗАПРЕЩЕНО (эхо-мина, гардрейл проекта). Конфигурацию на модель
|
||
# даёт `roster.call_kwargs` — своих `reasoning_effort` здесь не ставим НИКОГДА.
|
||
SYS = ("Тебе дают отрывок художественного текста, в котором одно имя собственное заменено на "
|
||
"[???]. Ответь РОВНО двумя строками и ничем больше:\n"
|
||
"ПРОИЗВЕДЕНИЕ: <название и автор, либо НЕ ЗНАЮ>\n"
|
||
"ИМЯ: <что стоит вместо [???], либо НЕ ЗНАЮ>\n"
|
||
"Не пересказывай отрывок и не продолжай его.")
|
||
|
||
# Жильцы: те, кто реально работает на парах. Проба на каждом отдельно.
|
||
RESIDENTS = ("deepseek-v4-flash", "deepseek-v4-pro")
|
||
|
||
# Книги, которые фаза Д покупает, плюс обязательный положительный контроль.
|
||
BOOKS_D = {
|
||
"enkan-ja": dict(path=Path.home() / "books" / "enkan_no_hate_ja.txt", lang="ja",
|
||
titles=["円環の果てに", "enkan", "円環", "круг", "предел круга"],
|
||
note="ja-ось Д6; первая публикация 2026-07-30, 18 глав — НЕ мерена ни разу"),
|
||
"kristoff-en": dict(path=Path.home() / "books" /
|
||
"Kristoff_Jay_-_Empire_of_the_Vampire_3_-_Empire_of_the_Dawn.epub",
|
||
lang="en", titles=["empire of the dawn", "empire of the vampire",
|
||
"кристофф", "kristoff", "империя вампиров",
|
||
"империя рассвета", "империя зари"],
|
||
note="en-ось Д3; luna узнала СЕРИЮ 1 раз из 5 — жильцами не мерена"),
|
||
"jinpingmei": dict(path=Path.home() / "books" / "jinpingmei" / "jinpingmei-ctext-zhs.txt",
|
||
lang="zh", titles=["金瓶梅", "золотой лотос", "jin ping mei", "цзинь пин мэй",
|
||
"цветы сливы", "ланьлин", "насмешник"],
|
||
note="ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ — обязателен на каждом жильце"),
|
||
}
|
||
|
||
|
||
# Книга → фаза кассы. Положительный контроль относим к ФД-B: он покупается один раз, а прогон
|
||
# Д6 возьмёт те же теги из кэша за $0.
|
||
PHASE = {"kristoff-en": "ФД-B", "jinpingmei": "ФД-B", "enkan-ja": "ФД-C"}
|
||
|
||
# ⚠ СЧЁТЧИК СВЕРЯЛ НЕ ТО. Найдено ЧТЕНИЕМ всех 30 ответов 14.08 — прибор печатал числа, которые
|
||
# не соответствуют содержимому клеток. Причина: сверка шла подстрокой со списком названий на
|
||
# языке оригинала, а модели отвечают ПО-РУССКИ. Цена ошибки была двусторонней и обе стороны
|
||
# опасны: контроль pro на «Цзинь пин мэй» — 5 верных ответов из 5 — печатался как 1/5 (то есть
|
||
# проба выглядела бы неработающей и аннулировала бы жильца), а на английской книге ТОЧНОЕ
|
||
# попадание «Империя вампиров, Джей Кристофф» + «Габриэль де Леон» при вырезанном `Gabriel`
|
||
# печаталось как клоуз-промах, потому что `Gabriel` не подстрока «Габриэль». Второе страшнее:
|
||
# так контаминация несущей оси спряталась бы под нулём.
|
||
# ПОРОГИ НЕ ТРОНУТЫ (узнавание ≥3/5, клоуз ≥2/5) — чинится только сопоставление.
|
||
_RU2LAT = {"щ": "sch", "ш": "sh", "ч": "ch", "ж": "zh", "ю": "yu", "я": "ya", "ё": "yo",
|
||
"э": "e", "х": "h", "ц": "c", "а": "a", "б": "b", "в": "v", "г": "g", "д": "d",
|
||
"е": "e", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m", "н": "n",
|
||
"о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "ы": "y",
|
||
"ь": "", "ъ": ""}
|
||
|
||
|
||
def _lat(s: str) -> str:
|
||
return "".join(_RU2LAT.get(c, c) for c in s.lower())
|
||
|
||
|
||
def same_name(gold: str, ans: str) -> bool:
|
||
"""Назвал ли ответ то же имя, что вырезано. Терпит русскую передачу латинского имени.
|
||
|
||
⚠ Не «похожие строки», а именно передача: сравниваются ЛАТИНСКИЕ скелеты, и порог сходства
|
||
держится высоким, чтобы «Габриэль»≈`Gabriel` проходило, а «Габриэль»≈`Gabrio` — нет.
|
||
"""
|
||
import difflib # noqa: PLC0415
|
||
g, a = gold.strip().lower(), (ans or "").strip().lower()
|
||
if not g or not a or _is_refusal(a):
|
||
return False
|
||
if g in a: # точное вхождение (в т.ч. CJK)
|
||
return True
|
||
gl = _lat(g)
|
||
return any(difflib.SequenceMatcher(None, gl, _lat(tok)).ratio() >= 0.75
|
||
for tok in re.findall(r"[\w\u0400-\u04ff]+", a))
|
||
|
||
|
||
def _is_refusal(a: str) -> bool:
|
||
"""⚠ «не знаю» ОТВЕТОМ, а не оговоркой. Проверка подстрокой отбрасывала верный ответ
|
||
«金瓶梅, автор Ланьлинский насмешник (не знаю точно)»: книга названа правильно, неуверенность
|
||
относится к автору. Так контроль занижался, а занижённый контроль аннулирует жильца."""
|
||
return a.startswith("не знаю") or a.strip("«»\"'. ") == "не знаю"
|
||
|
||
|
||
def same_work(titles: list[str], ans: str) -> bool:
|
||
a = (ans or "").strip().lower()
|
||
if not a or _is_refusal(a):
|
||
return False
|
||
return any(t.lower() in a for t in titles)
|
||
PREFIX = {"ФД-B": "dv-b", "ФД-C": "dv-c"}
|
||
|
||
|
||
def probe(res: str, book: str, dry: bool, refill: bool = False) -> dict:
|
||
"""Проба одного жильца на одной книге. Возвращает счёт узнавания и клоуза."""
|
||
m = BOOKS_D[book]
|
||
text = CT.read_book(m["path"])
|
||
names = CT.frequent_names(text, m["lang"])
|
||
ps = CT.passages(text, names, CT.N_PASSAGES)
|
||
ph = PHASE[book]
|
||
led = MONEY.Guarded(ph, default_expect=0.01)
|
||
rec_hits = cloze_hits = empty = 0
|
||
for i, (prime, gold) in enumerate(ps, 1):
|
||
tg = f"{PREFIX[ph]}-contam-{res.replace('.', '')}-{book}-{i}"
|
||
if dry:
|
||
continue
|
||
cap = MAX_OUT
|
||
f = MONEY.OUT / f"{tg}.json"
|
||
if refill and f.exists():
|
||
rec0 = json.loads(f.read_text(encoding="utf-8"))
|
||
if rec0.get("finish") == "length" and not (rec0.get("content") or "").strip():
|
||
f.rename(MONEY.OUT / f"{tg}.LENGTH.json")
|
||
cap = MAX_OUT_REFILL
|
||
print(f" до-купка {tg}: усечённая клетка в карантин, кап {cap}")
|
||
msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": prime}]
|
||
r = MONEY.purchase(led, tg, res, ED.client(res),
|
||
ROSTER.call_kwargs(res, msgs, max_out=cap))
|
||
if r.get("skipped"):
|
||
raise SystemExit(f"⛔ ГАРД ОТКАЗАЛ на {tg} — СТОП и вопрос владельцу")
|
||
ans = r.get("content") or ""
|
||
if not ans.strip():
|
||
empty += 1
|
||
continue
|
||
work, name = CT.parse(ans)
|
||
rec_hits += same_work(m["titles"], work)
|
||
cloze_hits += same_name(gold, name)
|
||
return dict(recognition=rec_hits, cloze=cloze_hits, empty=empty, n=len(ps))
|
||
|
||
|
||
def main() -> int:
|
||
dry = "--dry" in sys.argv
|
||
print("КОНТАМИНАЦИОННАЯ ПРОБА ФАЗЫ Д — жильцами\n")
|
||
print(f"{'книга':14s}{'язык':>6s} что это")
|
||
for b, m in BOOKS_D.items():
|
||
ok = "" if m["path"].exists() else " ⛔ ФАЙЛА НЕТ"
|
||
print(f"{b:14s}{m['lang']:>6s} {m['note']}{ok}")
|
||
print(f"\nжильцы: {' · '.join(RESIDENTS)}")
|
||
print("пороги (объявлены ДО данных, взяты из рига эксп-21): "
|
||
"узнавание ≥3/5 = знает о книге · клоуз ≥2/5 = знает конкретику")
|
||
only = [a.split("=", 1)[1] for a in sys.argv if a.startswith("--books=")]
|
||
books = only[0].split(",") if only else list(BOOKS_D)
|
||
print(f"\nкниги прогона: {' · '.join(books)}")
|
||
if dry:
|
||
print("--dry: покупок нет; каждая книга = 5 отрывков × 2 жильца")
|
||
return 0
|
||
|
||
res_tbl: dict[tuple[str, str], dict] = {}
|
||
for res in RESIDENTS:
|
||
for b in books:
|
||
res_tbl[(res, b)] = probe(res, b, dry=False, refill="--refill" in sys.argv)
|
||
|
||
print(f"\n{'жилец':20s}{'книга':14s}{'узнавание':>11s}{'клоуз':>8s}{'пусто':>7s} вердикт")
|
||
bad = 0
|
||
for (res, b), v in res_tbl.items():
|
||
known = v["recognition"] >= 3
|
||
concrete = v["cloze"] >= 2
|
||
verdict = ("ЗНАЕТ ТЕКСТ" if concrete else "знает о книге" if known else "чисто")
|
||
print(f" {res:18s}{b:14s}{v['recognition']:>7d}/{v['n']}{v['cloze']:>6d}/{v['n']}"
|
||
f"{v['empty']:>7d} {verdict}")
|
||
if b != "jinpingmei" and concrete:
|
||
bad += 1
|
||
|
||
print("\n=== ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ (без него нулевой результат неинтерпретируем) ===")
|
||
for res in RESIDENTS:
|
||
c = res_tbl.get((res, "jinpingmei"))
|
||
if not c:
|
||
print(f" {res}: контроль не гнался — прогон этого жильца НЕИНТЕРПРЕТИРУЕМ")
|
||
bad += 1
|
||
continue
|
||
ok = c["recognition"] >= 3
|
||
# ⚠ Пустота при поднятом капе — НЕ «книга чиста», а непригодность пробы на этом жильце.
|
||
if c["empty"] == c["n"]:
|
||
print(f" {res}: ПУСТЫЕ ОТВЕТЫ на контроле — проба на этом жильце НЕПРИГОДНА, "
|
||
"нули по остальным книгам не читать")
|
||
bad += 1
|
||
elif not ok:
|
||
print(f" {res}: контроль ПРОВАЛЕН ({c['recognition']}/{c['n']}) — прогон жильца "
|
||
"аннулируется целиком, а не отдельная книга")
|
||
bad += 1
|
||
else:
|
||
print(f" {res}: контроль сработал ({c['recognition']}/{c['n']}) — нули значимы")
|
||
|
||
if bad:
|
||
print("\n⛔ проба даёт СТОП: см. строки выше. Замену материала без слова владельца "
|
||
"не ищем (заказ Д3/Д6).")
|
||
else:
|
||
print("\n[OK ] материал фазы пригоден: жильцы конкретики книг не знают")
|
||
return 1 if bad else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|