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