#!/usr/bin/env python3
"""exp15 — trap mining (kimi-k2.6). Design §D1: candidates are LLM-mined then MANUALLY curated; the
miner is family-isolated (Moonshot != DeepSeek/GLM arms != xAI/Mistral judges) so traps aren't preloaded
toward any arm or judge. temp MUST be 1 (kimi), verbose reasoning billed as output.

Mines the 8 trap types over S2' in seam-centred windows (the cohesion locus). Each candidate carries an
EXACT source span, the antecedent location + distance (for the K=3 stratification), the phenomenon, and
the correct-RU expectation. SELECTION (which candidates straddle an A0 greedy boundary = common
denominator) and the min-N quota are enforced AFTER A0 boundaries are known, in trap_curate.py.

Per-call predicted-cost gate; usage/cost persisted (D30.10). Self-review: prints per-window yield + cost.
Run: python trap_mine.py --run     (paid, ~$0.3)   |   --dry   (print prompts only, $0)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp15_llm as L

S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt")
SEAMS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seams.json")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_candidates.json")
LEDGER = Path("/home/ubuntu/books/gu-zhenren/exp15/mine_costs.jsonl")
MINER = "kimi-k2.6"
WINDOW = 900   # runes each side of a seam

TAXONOMY = (
    "T-ana: анафора НАЗАД через границу (китайское нулевое подлежащее/местоимение; антецедент раньше, "
    "реализация позже — по-русски нужно эксплицитно назвать деятеля/род).\n"
    "T-cat: катафора ВПЕРЁД (референт назван позже — «это был…» → имя дальше).\n"
    "T-ell: эллипсис/восстановление сказуемого или объекта через границу.\n"
    "T-ent: сущность/термин/титул введён раньше, повторён позже через границу (нужна консистентность).\n"
    "T-gen: род говорящего/деятеля виден только из контекста (по-русски прошедшее время маркирует род: "
    "пошёл/пошла) — если разрыв отрезает контекст, род можно перепутать.\n"
    "T-tense: единство нарративного времени внутри сцены через разрез.\n"
    "T-reg: единство регистра/вежливости/голоса персонажа через разрез.\n"
)

SYS = (
    "Ты — эксперт по китайско-русскому художественному переводу и лингвистике связности. Тебе дают фрагмент "
    "китайского исходника (ранобэ 蛊真人). Найди КОНКРЕТНЫЕ места, где верность перевода на русский зависит от "
    "КОНТЕКСТА, который может оказаться по ДРУГУЮ сторону границы нарезки на чанки (т.е. если текст разрезать "
    "между двумя предложениями, переводчик второго куска может ошибиться: перепутать род/референт, потерять "
    "антецедент нулевого местоимения, сменить время или регистр, рассогласовать термин).\n\n"
    f"ТИПЫ ФЕНОМЕНОВ:\n{TAXONOMY}\n"
    "Для КАЖДОГО найденного кандидата верни объект:\n"
    '{"type":"T-ana|T-cat|T-ell|T-ent|T-gen|T-tense|T-reg",'
    '"anchor_span":"ТОЧНАЯ цитата предложения(й) с АНТЕЦЕДЕНТОМ/источником (из исходника)",'
    '"dependent_span":"ТОЧНАЯ цитата предложения с ЗАВИСИМЫМ элементом (местоимение/глагол/повтор)",'
    '"antecedent_distance_sentences":ЦЕЛОЕ (сколько предложений между антецедентом и зависимым),'
    '"phenomenon":"кратко: какая зависимость и почему разрез её ломает",'
    '"expected_ru":"что ДОЛЖЕН сделать верный русский перевод (напр. явно назвать Фан Юаня; муж. род; то же время)",'
    '"failure_if_split":"как именно ошибётся перевод, если контекст отрезан"}\n'
    "Верни СТРОГО JSON-массив таких объектов (только реально присутствующие в тексте случаи; цитаты — дословно "
    "из исходника; 3–8 кандидатов на фрагмент). Без пояснений вне JSON."
)


def windows():
    text = S2.read_text(encoding="utf-8")
    seams = json.load(open(SEAMS))["seams"]
    out = []
    for s in seams:
        off = s["rune_offset"]
        lo, hi = max(0, off - WINDOW), min(len(text), off + WINDOW)
        out.append({"seam_id": s["seam_id"], "prev_sec": s["prev_sec"], "next_sec": s["next_sec"],
                    "seam_offset": off, "window_lo": lo, "window_hi": hi, "text": text[lo:hi]})
    return out


def parse_array(text):
    import re
    m = re.search(r"\[.*\]", text or "", re.S)
    if not m:
        return None
    try:
        return json.loads(m.group(0))
    except Exception:
        return None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--run", action="store_true")
    ap.add_argument("--dry", action="store_true")
    ap.add_argument("--per-call-cap", type=float, default=0.28)  # kimi reasoning uncapped by max_tokens
    a = ap.parse_args()
    wins = windows()
    if a.dry:
        print(f"{len(wins)} seam windows, ~{WINDOW*2} runes each. Miner={MINER} temp=1.")
        print("SYS prompt bytes:", len(SYS))
        print("sample window (seam 4):", wins[3]["text"][:200])
        return
    if not a.run:
        ap.print_help(); return
    sp = L.Spender(str(LEDGER), per_call_cap=a.per_call_cap, hard_cap=3.0)
    results = []
    for w in wins:
        msgs = [{"role": "system", "content": SYS},
                {"role": "user", "content": f"Фрагмент (шов {w['seam_id']}, 节{w['prev_sec']}→节{w['next_sec']}):\n\n{w['text']}"}]
        in_est = (len(SYS) + len(w["text"])) // 2
        ok, pred, why = sp.gate(MINER, in_est, L.MODELS[MINER]["max_tokens"])
        print(f"[gate] seam {w['seam_id']} pred=${pred:.4f} {'OK' if ok else 'BLOCK: '+why}")
        if not ok:
            results.append({"seam_id": w["seam_id"], "err": why, "candidates": []})
            continue
        # kimi-k2.6 over-reasons: with max_tokens 16000 the reasoning can consume the whole budget and
        # leave content EMPTY (seen: reasoning 15999, content ""). 24000 gives content headroom after
        # reasoning; 600s covers the latency. NEVER re-feed kimi's (possibly empty) reply as an assistant
        # turn (400 "assistant must not be empty") — re-ask FRESH with a "reason briefly, emit JSON" nudge.
        NUDGE = {"role": "user", "content": "Не рассуждай долго. Сразу выведи ТОЛЬКО компактный валидный JSON-массив кандидатов (3–6 шт.), без markdown-ограды."}
        r = L.call(MINER, msgs, temp=1.0, max_tokens=24000, timeout=600)
        sp.record({"seam_id": w["seam_id"], "usage": r.get("usage"), "cost": r.get("cost", 0.0),
                   "err": r.get("err"), "latency_s": r.get("latency_s"), "finish": r.get("finish")})
        cands = parse_array(r.get("text", ""))
        err = r.get("err") or ""
        need_retry = bool(err) or cands is None   # timeout, empty content, or unparseable
        if need_retry:
            r2 = L.call(MINER, msgs + [NUDGE], temp=1.0, max_tokens=24000, timeout=600)
            sp.record({"seam_id": w["seam_id"], "retry": True, "usage": r2.get("usage"),
                       "cost": r2.get("cost", 0.0), "err": r2.get("err"), "latency_s": r2.get("latency_s"),
                       "finish": r2.get("finish")})
            c2 = parse_array(r2.get("text", "")) if not r2.get("err") else None
            if c2 is not None:
                cands = c2
        cands = cands or []
        for c in cands:
            c["seam_id"] = w["seam_id"]
            c["seam_offset"] = w["seam_offset"]
        row = {"seam_id": w["seam_id"], "n": len(cands), "cost": r.get("cost"),
               "err": r.get("err"), "candidates": cands}
        results.append(row)
        # incremental dump so a background run can be inspected mid-flight
        with open(str(OUT) + ".partial.jsonl", "a", encoding="utf-8") as pf:
            pf.write(json.dumps(row, ensure_ascii=False) + "\n")
        print(f"  seam {w['seam_id']}: {len(cands)} candidates  cost=${r.get('cost',0):.4f}  "
              f"reasoning_tok={r.get('usage',{}).get('reasoning_tokens')}  err={r.get('err')}")
    OUT.write_text(json.dumps({"miner": MINER, "window_runes": WINDOW, "results": results},
                              ensure_ascii=False, indent=2), encoding="utf-8")
    total = sum(x.get("cost") or 0 for x in results)
    ncand = sum(len(x["candidates"]) for x in results)
    from collections import Counter
    by_type = Counter(c["type"] for x in results for c in x["candidates"] if c.get("type"))
    print(f"\nmined {ncand} candidates over {len(wins)} windows; by type: {dict(by_type)}")
    print(f"total mining spend: ${total:.4f} -> {OUT}")


if __name__ == "__main__":
    main()
