#!/usr/bin/env python3 """Ф0.2 — ПРОБА КОНТАМИНАЦИИ СРЕЗА. Методика эксп-21 §0, две поправки. Метод (не менялся): на одном отрывке задаются ДВА безобидных вопроса — УЗНАВАНИЕ «что это за произведение и кто автор» — ловит знание о книге вообще; КЛОУЗ из отрывка вырезано имя собственное, назвать пропущенное — ловит знание КОНКРЕТИКИ. Порог объявлен эксп-21 и здесь не двигается: **узнавание ≥3/5 = материал непригоден**. Проба на ПРОДОЛЖЕНИЕ негодна как класс (эксп-21 сняла две редакции: современные модели не воспроизводят текст дословно даже для заведомо заученной книги). ДВЕ ПОПРАВКИ ПРОТИВ ЭКСП-21: * **две модели, а не одна.** §4 п.9 эксп-21 прямо оставил открытым, что контаминация мерена ОДНОЙ моделью, а армы населяют другие. Здесь проба идёт и на `gpt-5.6-luna` (сопоставимость с эксп-21), и на `deepseek-v4-flash` — боевом жильце черновой роли; * **два опорных контроля.** Кроме положительного (金瓶梅, минский канон) добавлена 蛊真人 — книга эксп-21, получившая 4/5. Она превращает мой результат из «ноль» в «ноль ПРОТИВ 4/5 на том же приборе», то есть отвечает на «а работает ли проба» вторым независимым способом. ⚠ Отрывки в репозиторий и в отчёт не попадают: печатаются вердикты и доли. """ from __future__ import annotations import json import os import re import sys import time 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" / "role_topology")) 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 from roster import CANDIDATES # noqa: E402 load_dotenv(REPO / "eval" / ".env") N_PASSAGES = 5 PRIME_CHARS = 700 PROBES = ("gpt-5.6-luna", "deepseek-v4-flash") BOOKS: dict[str, dict] = { "slice22": dict(kind="СРЕЗ ЭКСП-22", lang="zh", titles=["阴阳天帝诀", "yin yang tian di", "инь ян"]), "gu-zhenren": dict(kind="опора: книга эксп-21 (там 4/5)", lang="zh", path=Path.home() / "books" / "gu-zhenren" / "guzhenren-utf8.txt", titles=["蛊真人", "гу чжэнь", "reverend insanity", "гу чжэньжэнь"]), "jinpingmei": dict(kind="ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ", lang="zh", path=Path.home() / "books" / "jinpingmei" / "jinpingmei-ctext-zhs.txt", titles=["金瓶梅", "цзинь пин мэй", "plum in the golden"]), } def book_text(name: str) -> str: if name == "slice22": return "\n".join(c["text"] for c in M.chapters()) return BOOKS[name]["path"].read_text(encoding="utf-8", errors="ignore") def frequent_names(text: str, top: int = 40) -> list[str]: """Частые CJK-биграммы/триграммы — кандидаты на маскирование. ⚠ Порог частоты масштабируется длиной: у эксп-21 он был константой 20 и на срезе в 40k знаков отсекал ВСЕ имена, то есть проба молча осталась бы без клоуза. """ need = max(4, len(text) // 4000) cand: Counter[str] = Counter() for n in (3, 2): for m in re.finditer(r"[㐀-鿿]{%d}" % n, text): cand[m.group(0)] += 1 return [w for w, c in cand.most_common(top * 4) if c > need][:top] def passages(text: str, names: list[str], n: int) -> list[tuple[str, str]]: body = text[len(text) // 10: -len(text) // 10] out: list[tuple[str, str]] = [] for i in range(n * 8): if len(out) >= n: break pos = len(body) * (i + 1) // (n * 8 + 1) chunk = body[pos:pos + PRIME_CHARS] if len(chunk) < PRIME_CHARS: continue hit = next((w for w in names if w in chunk), None) if not hit: continue out.append((chunk.replace(hit, "[???]"), hit)) return out SYS = ("Тебе дают отрывок художественного текста, в котором одно имя собственное заменено на " "[???]. Ответь РОВНО двумя строками и ничем больше:\n" "ПРОИЗВЕДЕНИЕ: <название и автор, либо НЕ ЗНАЮ>\n" "ИМЯ: <что стоит вместо [???], либо НЕ ЗНАЮ>\n" "Не пересказывай отрывок и не продолжай его.") def client(model: str) -> OpenAI: base, env, *_ = CANDIDATES[model] return OpenAI(api_key=os.environ[env], base_url=base, timeout=300) def ask(led, model: str, prime: str, tag: str) -> dict: msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": prime}] kw: dict = dict(model=model, messages=msgs) if model in ("gpt-5.6-luna",): kw["max_completion_tokens"] = 400 kw["extra_body"] = {"reasoning_effort": "none"} else: # DeepSeek: thinking НЕ гасим (эхо-мина), но эффорт обязателен — иначе стена quirks §10. kw["max_tokens"] = 3000 kw["extra_body"] = {"reasoning_effort": "low"} return MONEY.purchase(led, tag, model, client(model), kw, probe="contamination") def parse(answer: str) -> tuple[str, str]: work = name = "" for line in (answer or "").splitlines(): if line.upper().startswith("ПРОИЗВЕДЕНИЕ"): work = line.split(":", 1)[-1].strip() elif line.upper().startswith("ИМЯ"): name = line.split(":", 1)[-1].strip() return work, name def main() -> None: dry = "--dry" in sys.argv led = MONEY.Guarded("Ф0", default_expect=0.004) rows = [] for book, meta in BOOKS.items(): if book != "slice22" and not meta["path"].exists(): print(f"{book}: файла нет — пропуск") continue text = book_text(book) ps = passages(text, frequent_names(text), N_PASSAGES) print(f"\n{book} ({len(text):,} знаков) — отрывков {len(ps)} · {meta['kind']}") if dry: continue for model in PROBES: rec_ok = cloze_ok = n = 0 for i, (prime, hidden) in enumerate(ps): r = ask(led, model, prime, f"tp0-cont-{book}-{model}-{i}") if r.get("skipped"): print(" ⛔ потолок Ф0 — стоп") return work, name = parse(r.get("content", "")) r_hit = any(t in work.lower() for t in meta["titles"]) c_hit = bool(name) and "НЕ ЗНАЮ" not in name.upper() and \ (hidden in name or name in hidden) rec_ok += r_hit cloze_ok += c_hit n += 1 time.sleep(0.2) rows.append(dict(book=book, model=model, n=n, recognized=rec_ok, cloze=cloze_ok, kind=meta["kind"])) print(f" {model:22s} узнавание {rec_ok}/{n} · клоуз {cloze_ok}/{n}") if dry: return print(f"\n{'книга':14s}{'модель':22s}{'узнано':>8s}{'клоуз':>7s} вердикт (порог: узнавание ≥3/5)") print("-" * 86) for r in rows: v = ("НЕПРИГОДЕН" if r["recognized"] * 5 >= 3 * max(1, r["n"]) else "знает конкретику" if r["cloze"] >= 2 else "следов нет") print(f"{r['book']:14s}{r['model']:22s}{r['recognized']:>4d}/{r['n']:<3d}" f"{r['cloze']:>4d}/{r['n']:<2d} {v}") (MONEY.OUT / "contamination.json").write_text( json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") print(f"\nпотрачено Ф0 ${led.spent():.6f} из ${led.ceiling:.2f}") if __name__ == "__main__": main()