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