textmachine/eval/adaptive_levers.py

170 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Детерминированные рычаги канон-консистентности — кривая L1→L3 на ТРУДНОМ чанке.
Follow-up к adaptive_incontext/adaptive_reask (по запросу владельца: «раз не идём в гибрид —
долбить детерминированную сторону; замерить, сколько добивает каждый рычаг»). Лёгкий чанк
насыщал глоссарий до 1.0 → рычаги не было видно. Здесь — ТРУДНЫЙ чанк (1600 знаков, ~11
рекуррентных сущностей, плотно) + контролируемый recall-тест, где точный матч промахивается.
Три рычага (все ДЕТЕРМИНИРОВАННЫЕ, 0 ML, 0 VRAM):
L1 post-check ЛЕММАТИЗИРУЮЩИЙ (pymorphy3), а не регэксп → убирает ложные флаги/undercount
(мой промах 王胡→«Бородатого Вана» на регэкспе `Ван\b`). Меряем false-flag rate.
L2 RECALL матчера: точный матч → +нормализация(trad→simp/NFKC) → +alias-граф → +sticky.
Детерминированный (без LLM): сколько «должных» записей вообще инъектится.
L3 post-check + точечный RE-ASK на РЕАЛЬНЫЕ промахи (verifier-gated correction) → прирост
adherence на трудном чанке.
deepseek-v4-flash + grok-4.3. INDICATIVE (малый N). Usage: eval/.venv/bin/python eval/adaptive_levers.py
Output: stdout + eval/data/adaptive_levers.json
"""
from __future__ import annotations
import json, re, sys, unicodedata
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import refusal_bench as rb
import pymorphy3
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "data" / "samples" / "zh" / "luxun-ah-q-ch5-9.txt"
OUT = ROOT / "data" / "adaptive_levers.json"
MORPH = pymorphy3.MorphAnalyzer()
# Трудный чанк: 1600 знаков, ~11 рекуррентных сущностей (плотно → adherence проседает).
HARD = SRC.read_text(encoding="utf-8")[6450:6450 + 1600]
# entity → (canon dst для глоссария, СТРОГИЙ регэксп [наивный post-check], лемма-ключ[и] для L1)
ENT = {
"阿Q": ("А-кью", r"А-кью\b", ["а-кью"]), # дефис → hyph-режим
"未庄": ("Вэйчжуан", r"Вэйчжуан\b", ["вэйчжуан"]),
"赵太爷": ("почтенный Чжао", r"Чжао\b", ["чжао"]),
"王胡": ("Бородатый Ван", r"Ван\b", ["ван"]),
"小D": ("Маленький Дэн", r"Дэн\b", ["дэн"]),
"假洋鬼子": ("Поддельный заморский чёрт", r"заморский чёрт\b", ["заморский", "чёрт"]),
"尼姑": ("монашка", r"монашка\b", ["монашка"]),
"吴妈": ("У-ма", r"У-ма\b", ["у-ма"]),
"邹七嫂": ("тётушка Цзоу Седьмая", r"Цзоу\b", ["цзоу"]),
"秀才": ("сюцай", r"сюцай\b", ["сюцай"]),
"": ("Цянь", r"Цянь\b", ["цянь"]),
}
HYPH = {"а-кью": r"А-?кью", "у-ма": r"У-?ма"} # дефисные translit — регэксп, не лемма
SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент на русский язык. "
"Сохрани все реплики и детали без пропусков; стиль — живой литературный русский. Выведи ТОЛЬКО перевод.")
GLOSS = "ГЛОССАРИЙ (используй утверждённые переводы последовательно):\n" + \
"\n".join(f"{z}{v[0]}" for z, v in ENT.items())
PRESENT = [z for z in ENT if z in HARD]
# ---------- матчеры post-check (L1) ----------
def match_strict(out, z):
"""Наивный регэксп-post-check (как в adaptive_incontext) — ломается на склонении."""
return bool(re.search(ENT[z][1], out))
def lemmas_of(out):
return {MORPH.parse(w)[0].normal_form for w in re.findall(r"[А-Яа-яЁё]+", out)}
def match_lemma(out, z, lem_cache):
"""L1: лемматизирующий post-check. Для дефисных translit — регэксп-фолбэк."""
keys = ENT[z][2]
if keys[0] in HYPH:
return bool(re.search(HYPH[keys[0]], out))
return all(k in lem_cache for k in keys)
# ---------- матчер РЕКОЛА (L2), детерминированный ----------
TRAD2SIMP = {"": "", "": "", "": "", "": "", "": "", "": "", "": ""}
def normalize(s):
s = unicodedata.normalize("NFKC", s)
return "".join(TRAD2SIMP.get(c, c) for c in s)
ALIASES = {"王胡": ["王癞胡", "癞胡"], "赵太爷": ["赵老太爷"]}
def recall_config(chunks, keys_present, *, norm=False, alias=False, sticky=False):
"""Сколько записей из keys_present инъектится по цепочке чанков при данной конфигурации."""
active_prev, injected_ever = set(), set()
for ch in chunks:
text = normalize(ch) if norm else ch
hit = set()
for z in keys_present:
pats = [z] + (ALIASES.get(z, []) if alias else [])
pats = [normalize(p) if norm else p for p in pats]
if any(p in text for p in pats):
hit.add(z)
if sticky:
hit |= (active_prev & set(keys_present)) # scene-inertia
active_prev = hit
injected_ever |= hit
return injected_ever
def translate(model, base, key_env, user):
t, _, _ = rb.call_provider({"name": model, "base_url": base, "model": model,
"api_key_env": key_env, "max_tokens": 8000,
**({"temperature": 0} if "grok" in model else {})}, SYSTEM, user, timeout=200)
return t or ""
def main():
res = {"present": PRESENT, "hard_chunk_len": len(HARD)}
print(f"ТРУДНЫЙ чанк: {len(HARD)} знаков, present entities ({len(PRESENT)}): {PRESENT}\n")
# ===== L2: детерминированный RECALL-curve (без LLM) =====
print("=== L2 · RECALL матчера (детерминированный, без LLM) ===")
# 3 чанка, где точный матч промахивается: trad-вариант, alias, местоименный (sticky)
recall_chunks = [
"趙太爺同錢老爺在未莊很有威。王癞胡也常來。", # trad + alias 王癞胡(=王胡)
"他慢慢站起来,看了看四周。", # местоименный — 王胡 из sticky
]
keys = ["赵太爷", "", "未庄", "王胡"]
configs = [("точный", {}), ("+норм", {"norm": True}),
("+alias", {"norm": True, "alias": True}),
("+sticky", {"norm": True, "alias": True, "sticky": True})]
l2 = {}
for name, kw in configs:
got = recall_config(recall_chunks, keys, **kw)
l2[name] = sorted(got)
print(f" {name:<9} recall {len(got)}/{len(keys)} = {len(got)/len(keys):.2f} инъектировано: {sorted(got)}")
res["L2_recall"] = l2
# ===== L1 + L3: adherence + re-ask на трудном чанке (LLM) =====
print("\n=== L1 (лемма-post-check) + L3 (re-ask) на трудном чанке ===")
res["models"] = {}
for model, base, kenv in [("deepseek-v4-flash", "https://api.deepseek.com/v1", "DEEPSEEK_API_KEY"),
("grok-4.3", "https://api.x.ai/v1", "XAI_API_KEY")]:
out = translate(model, base, kenv, GLOSS + "\n\nФРАГМЕНТ:\n" + HARD)
if not out:
print(f"\n[{model}] пустой ответ — пропуск"); continue
lem = lemmas_of(out)
strict = [z for z in PRESENT if match_strict(out, z)]
lemma = [z for z in PRESENT if match_lemma(out, z, lem)]
false_flags = [z for z in lemma if z not in strict] # L1 чинит именно их
real_miss = [z for z in PRESENT if z not in lemma] # настоящие провалы adherence
print(f"\n[{model}] строгий-регэксп {len(strict)}/{len(PRESENT)}={len(strict)/len(PRESENT):.2f}"
f" | L1 лемма-post-check {len(lemma)}/{len(PRESENT)}={len(lemma)/len(PRESENT):.2f}")
print(f" L1 убрал ЛОЖНЫХ флагов: {len(false_flags)} {false_flags}")
print(f" РЕАЛЬНЫЕ промахи adherence (по лемме): {real_miss}")
rec = {"strict": len(strict)/len(PRESENT), "lemma": len(lemma)/len(PRESENT),
"false_flags": false_flags, "real_miss": real_miss}
# L3: re-ask на реальные промахи
if real_miss:
terms = "; ".join(f"{z} = {ENT[z][0]}" for z in real_miss)
out2 = translate(model, base, kenv, GLOSS + "\n\nФРАГМЕНТ:\n" + HARD +
f"\n\nВАЖНО: обязательно используй утверждённые формы: {terms}. Перепиши перевод с ними.")
lem2 = lemmas_of(out2)
lemma2 = [z for z in PRESENT if match_lemma(out2, z, lem2)]
print(f" L3 re-ask по {real_miss}: adherence {len(lemma)/len(PRESENT):.2f}{len(lemma2)/len(PRESENT):.2f}")
rec["after_reask"] = len(lemma2)/len(PRESENT)
rec["still_missing"] = [z for z in PRESENT if z not in lemma2]
else:
print(" L3 re-ask не нужен (реальных промахов нет)")
rec["after_reask"] = rec["lemma"]
res["models"][model] = rec
OUT.write_text(json.dumps(res, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\nsaved → {OUT}")
print("\nЧитается: L2 — рекол точного матча растёт с норм/alias/sticky; L1 — сколько ложных\n"
"флагов убирает лемматизация (риск research/13 §2); L3 — сколько adherence добивает re-ask.")
if __name__ == "__main__":
main()