#!/usr/bin/env python3 """Экстракция терминов — БЕНЧМАРК локальных vs облачных моделей (write-path банка памяти, реестр G1). Локалка касается банка памяти в 2 точках: эмбеддинги (retrieval_bench.py — bge-m3) и ЭКСТРАКЦИЯ кандидатов в глоссарий из сырого текста (bootstrap write-path). Полигон exp03 мерил локалки как ПЕРЕВОДЧИКОВ (не годятся); экстракция — другая, более лёгкая задача. Здесь мерим её нормально: много моделей, много кейсов, разные языки/эдж-кейсы, и КАЖДЫЙ ответ верифицируется на здоровость (пустой/echo/config-error → ретрай и явная пометка FAILED, НЕ засчитывается как recall 0 — урок предыдущего обрывочного прогона). Запросы к провайдерам — через `refusal_bench.call_provider` + канонический `providers.json` (квирки уже верны: deepseek thinking-ON+max_tokens 8000, kimi temp=1/24k, gpt-5-mini max_completion_tokens+no-temp, gemini/glm thinking-off). Локаль — ollama native (think:false). Плюс вариант deepseek-nothink (`thinking:disabled`) — дешёвая экстракция без reasoning. Метрики (ДЕТЕРМИНИРОВАННЫЕ, без судьи — чтобы «верифицированно и точно»): recall — доля эталонных сущностей, найденных (спот) render_ok — доля найденных gold, где предложенный RU совпал с каноном (Рогов/Поливанов/Палладий/устоявш.) halluc — доля извлечённого, чего НЕТ в исходном тексте (явный FP, детерминированно) health — ok / empty / http / refused / unparseable (ретраи; в агрегат идут только ok) Запуск: eval/.venv/bin/python eval/extract_bench.py [--models a,b] [--cases zh,ja,en] Выход: eval/data/extract_bench/results.json + таблица. """ from __future__ import annotations import argparse import importlib.util import json import os import re import time import unicodedata import urllib.request from pathlib import Path from dotenv import dotenv_values ROOT = Path(__file__).resolve().parent OUT = ROOT / "data" / "extract_bench" ENV = dotenv_values(ROOT / ".env") for k, v in ENV.items(): # call_provider читает ключи из os.environ os.environ.setdefault(k, v or "") NOPROXY = urllib.request.build_opener(urllib.request.ProxyHandler({})) # reuse verified request machinery _spec = importlib.util.spec_from_file_location("rb", ROOT / "refusal_bench.py") rb = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(rb) PROVIDERS = {p["name"]: p for p in json.loads((ROOT / "providers.json").read_text())["providers"]} CLOUD = ["deepseek", "grok", "glm", "kimi", "gemini", "openai"] CLOUD_EXTRA = { # deepseek без thinking — дешёвая экстракция (echo бьёт перевод, не JSON?) "deepseek-nothink": dict(PROVIDERS["deepseek"], name="deepseek-nothink", extra_body={"thinking": {"type": "disabled"}}), } LOCAL = ["huihui_ai/qwen3-abliterated:8b", "huihui_ai/qwen3.5-abliterated:9b", "qwen3:8b", "qwen3-vojo:latest", "ruadapt-qwen3:8b", "huihui_ai/qwen3-abliterated:30b-a3b"] SYS = "Ты — терминолог. Извлекаешь из текста имена собственные и культурные реалии для глоссария художественного перевода на русский." USER = ("Извлеки из фрагмента ВСЕ имена собственные (персонажи, места, организации) и " "культурно-специфичные реалии/титулы. Для каждого верни: src (ровно как в тексте), " "type (character|place|org|realia|title), dst (перевод/транскрипция на русский по канону). " 'Верни СТРОГО JSON без пояснений: {"terms":[{"src":"...","type":"...","dst":"..."}]}.\n\nФрагмент:\n<<>>') # ---- корпус: реальные PD-чанки, разные языки/эдж-кейсы, gold с каноническим RU (accept-регэксп) ---- CASES = [ # zh — имена + реалии + алиас dict(id="zh-ahq", lang="zh", text="我要给阿Q做正传,已经不止一两年了。那是赵太爷的儿子进了秀才的时候,锣声铛铛的报到村里来,阿Q正喝了两碗黄酒,因为他和赵太爷原来是本家。", gold={"阿Q": r"А[-\s]?[Кк]ью", "赵太爷": r"Чжао", "秀才": r"сюцай"}), dict(id="zh-zhufu", lang="zh", text="接着一声钝响,是送灶的爆竹。我是正在这一夜回到我的故乡鲁镇的,暂寓在鲁四老爷的宅子里,应该称之曰「四叔」。家中都在准备着「祝福」,这是鲁镇年终的大典,迎接福神。", gold={"送灶": r"очаг|Цзао", "鲁镇": r"Лучжэн", "鲁四老爷": r"дядюшк|[Лл]у\b|Лу[ -]", "祝福": r"жертвоприношен|благословени|[Мм]олени|счасть"}), dict(id="zh-xianglin", lang="zh", text="况且,一想到昨天遇见祥林嫂的事,也就使我不能安住。我在鲁镇所见的人们中,改变之大,可以说无过于她的了;她分明已经纯乎是一个乞丐了。", gold={"祥林嫂": r"Сянлин", "鲁镇": r"Лучжэн"}), dict(id="zh-wanghu", lang="zh", # алиас: 王胡 = 王癞胡 text="他看见王胡在那里赤着膊捉虱子。这王胡,又癞又胡,别人都叫他王癞胡,阿Q却删去了一个癞字,然而非常渺视他。", gold={"王胡": r"Ван|Бородат", "阿Q": r"А[-\s]?[Кк]ью"}), # ja — имена + реалии dict(id="ja-rashomon", lang="ja", text="一人の下人が、羅生門の下で雨やみを待っていた。羅生門が、朱雀大路にある以上は、この男のほかにも、雨やみをする者がありそうなものである。", gold={"下人": r"слуг|гэнин", "羅生門": r"Рас[её]мон", "朱雀大路": r"Судзаку"}), dict(id="ja-kyoto", lang="ja", text="この二三年、京都には、地震とか辻風とか火事とか饑饉とか云う災がつづいて起った。そこで洛中のさびれ方は一通りではない。", gold={"京都": r"Киото", "洛中": r"Ракутю|Лочжун|столиц|город"}), # ja — западные имена через катакану (translit_policy эдж-кейс) dict(id="ja-melos", lang="ja", text="メロスは激怒した。メロスは、村の牧人である。きょう未明メロスは村を出発し、十里はなれた此のシラクスの市にやって来た。", gold={"メロス": r"Мелос", "シラクス": r"Сиракуз"}), dict(id="ja-seri", lang="ja", # длинное западное имя через катакану text="メロスには竹馬の友があった。セリヌンティウスである。今は此のシラクスの市で、石工をしている。", gold={"セリヌンティウス": r"Селинунти", "メロス": r"Мелос", "シラクス": r"Сиракуз"}), # ja — буддийское имя + топоним (эталон Стругацкого) dict(id="ja-hana", lang="ja", text="禅智内供の鼻と云えば、池尾で知らない者はない。長さは五六寸あって上唇の上から顋の下まで下っている。", gold={"禅智内供": r"Дзэн[тч]и|Найгу", "池尾": r"Икэ"}), # en — западные имена → русская транскрипция (render-эдж) dict(id="en-carter", lang="en", text="My name is John Carter; I am better known as Captain Jack Carter of Virginia. I spent nearly a year prospecting in company with another Confederate officer, Captain James K. Powell of Richmond, in an Arizona cave.", gold={"John Carter": r"Джон\s?Картер", "Virginia": r"Виргини|Вирджини", "Powell": r"Пауэл", "Richmond": r"Ричмонд", "Arizona": r"Аризон"}), dict(id="en-conan", lang="en", text="'I am Conan, a Cimmerian,' he answered. 'I came into Argos seeking employment, but with no wars forward, there was nothing to which I might turn my hand.'", gold={"Conan": r"Конан", "Cimmerian": r"[Кк]иммери", "Argos": r"Аргос"}), dict(id="en-wells", lang="en", text="The Time Traveller (for so it will be convenient to speak of him) was expounding a recondite matter to us. 'You must follow me carefully,' said Filby.", gold={"Time Traveller": r"[Пп]утешественник", "Filby": r"Филби"}), ] def norm(s): return unicodedata.normalize("NFKC", str(s)).lower() def parse_terms(txt): txt = re.sub(r".*?\s*", "", txt or "", flags=re.S) m = re.search(r"\{.*\}|\[.*\]", txt, re.S) if not m: return None # unparseable try: d = json.loads(m.group(0)) except Exception: return None terms = d.get("terms", d) if isinstance(d, dict) else d if not isinstance(terms, list): return [] return [{"src": str(t["src"]), "type": str(t.get("type", "")), "dst": str(t.get("dst", ""))} for t in terms if isinstance(t, dict) and t.get("src")] def call_local(model, text, max_pred=4000): payload = dict(model=model, stream=False, think=False, format="json", options=dict(num_ctx=8192, temperature=0.2, num_predict=max_pred), messages=[{"role": "system", "content": SYS}, {"role": "user", "content": USER.replace("<<>>", text)}]) req = urllib.request.Request("http://localhost:11434/api/chat", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}) with NOPROXY.open(req, timeout=600) as r: d = json.load(r) return (d.get("message", {}) or {}).get("content", "") or "", d.get("done_reason", "") def healthy_extract(kind, model, case): """Возвращает (terms|None, health, dt). Ретраит пустой ответ с бо́льшим бюджетом.""" text = case["text"] for attempt in range(3): t0 = time.time() try: if kind == "local": raw, fin = call_local(model, text, max_pred=4000 * (attempt + 1)) err = None if raw.strip() else f"empty|finish={fin}" else: pmap = CLOUD_EXTRA.get(model) or PROVIDERS[model] if attempt: # ретрай пустого — поднять бюджет pmap = dict(pmap, max_tokens=int(pmap.get("max_tokens", 8000) * 1.7)) raw, err, _ = rb.call_provider(pmap, SYS, USER.replace("<<>>", text)) raw = raw or "" except Exception as e: return None, f"transport:{type(e).__name__}", round(time.time() - t0, 1) dt = round(time.time() - t0, 1) if err: kindn = err.split("|", 1)[0] if kindn == "content_filter": return None, "refused", dt if kindn in ("empty",): continue # ретрай return None, f"http:{err[:40]}", dt terms = parse_terms(raw) if terms is None: if attempt < 2: continue # unparseable → ретрай return None, "unparseable", dt return terms, "ok", dt return None, "empty", dt def score(case, terms): ntext = norm(case["text"]) found, render = {}, {} for g, accept in case["gold"].items(): ng = norm(g) hit = next((t for t in terms if ng in norm(t["src"]) or (norm(t["src"]) in ng and len(norm(t["src"])) >= 2)), None) found[g] = hit is not None render[g] = bool(hit and re.search(accept, hit["dst"])) ex = [t["src"] for t in terms] halluc = [s for s in ex if norm(s) and norm(s) not in ntext] return dict(recall=sum(found.values()) / len(found), render_ok=(sum(render.values()) / max(1, sum(found.values())) if any(found.values()) else 0.0), halluc_rate=round(len(halluc) / len(ex), 2) if ex else 0.0, n=len(terms), miss=[g for g, ok in found.items() if not ok], bad_render=[g for g in found if found[g] and not render[g]], halluc=halluc[:5], raw=[f"{t['src']}→{t['dst']}" for t in terms][:14]) def main(): ap = argparse.ArgumentParser() ap.add_argument("--models", default="") ap.add_argument("--cases", default="") args = ap.parse_args() OUT.mkdir(parents=True, exist_ok=True) cases = [c for c in CASES if not args.cases or c["lang"] in args.cases.split(",")] models = ([("local", m) for m in LOCAL] + [("cloud", m) for m in CLOUD] + [("cloud", "deepseek-nothink")]) if args.models: want = set(args.models.split(",")) models = [(k, m) for k, m in models if m in want] jsonl = OUT / "records.jsonl" done = set() if jsonl.exists(): for ln in jsonl.read_text().splitlines(): if ln.strip(): r = json.loads(ln); done.add((r["model"], r["case"])) fh = jsonl.open("a", encoding="utf-8") records = [json.loads(ln) for ln in (jsonl.read_text().splitlines() if jsonl.exists() else []) if ln.strip()] for kind, model in models: print(f"\n### {model} ({kind})") for case in cases: if (model, case["id"]) in done: continue terms, health, dt = healthy_extract(kind, model, case) rec = dict(model=model, kind=kind, case=case["id"], lang=case["lang"], health=health, dt=dt) if health == "ok": rec.update(score(case, terms)) records.append(rec) fh.write(json.dumps(rec, ensure_ascii=False) + "\n"); fh.flush() if health == "ok": print(f" {case['id']:<12} recall={rec['recall']:.2f} render={rec['render_ok']:.2f} " f"halluc={rec['halluc_rate']} n={rec['n']} {dt}s" + (f" miss={rec['miss']}" if rec['miss'] else "") + (f" bad_render={rec['bad_render']}" if rec['bad_render'] else "")) else: print(f" {case['id']:<12} HEALTH={health} {dt}s") fh.close() # ---- агрегаты: только health==ok идут в recall/render/halluc; health-счётчики отдельно ---- summary = {} for kind, model in models: recs = [r for r in records if r["model"] == model] ok = [r for r in recs if r["health"] == "ok"] health_ct = {} for r in recs: health_ct[r["health"]] = health_ct.get(r["health"], 0) + 1 if ok: summary[model] = dict( kind=kind, n_ok=len(ok), n_total=len(recs), health=health_ct, recall=round(sum(r["recall"] for r in ok) / len(ok), 3), render_ok=round(sum(r["render_ok"] for r in ok) / len(ok), 3), halluc=round(sum(r["halluc_rate"] for r in ok) / len(ok), 3), avg_n=round(sum(r["n"] for r in ok) / len(ok), 1), avg_s=round(sum(r["dt"] for r in ok) / len(ok), 1)) else: summary[model] = dict(kind=kind, n_ok=0, n_total=len(recs), health=health_ct) print("\n=== СВОДКА (экстракция; агрегат по health==ok) ===") print(f" {'model':<34}{'kind':<7}{'recall':>7}{'render':>7}{'halluc':>7}{'n_ex':>5}{'sec':>6}{'ok/all':>8}") for m, s in summary.items(): ratio = f"{s['n_ok']}/{s['n_total']}" if s["n_ok"]: print(f" {m:<34}{s['kind']:<7}{s['recall']:>7}{s['render_ok']:>7}{s['halluc']:>7}" f"{s['avg_n']:>5}{s['avg_s']:>6}{ratio:>8}") else: print(f" {m:<34}{s['kind']:<7}{'—':>7}{'—':>7}{'—':>7}{'—':>5}{'—':>6}{ratio:>8} health={s['health']}") print(" (health!=ok — пустой/echo/http/unparseable — НЕ засчитан как recall 0)") (OUT / "results.json").write_text(json.dumps(dict(summary=summary, records=records), ensure_ascii=False, indent=2), encoding="utf-8") print(f"\nsaved → {OUT/'results.json'}") if __name__ == "__main__": main()