#!/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<<<TEXT>>>')

# ---- корпус: реальные 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"<think>.*?</think>\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>>>", 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>>>", 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()
