179 lines
8.9 KiB
Python
179 lines
8.9 KiB
Python
#!/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()
|