#!/usr/bin/env python3 """exp16 — A6: local 9B entity spotter over the webnovel source (research/20 §D2 A6, §B4 Z3). $0 (stand). Re-measures the exp06 caveat (exp06 spot recall 0.95-1.0 was on PD-classic; the webnovel-slice re-measure is the eval/README-flagged open question). Prompts a local 9B to spot named entities/terms per source chunk, aggregates across the slice, and measures spot-recall against GT + hallucination. Health-checked per exp06 (empty/echo/unparseable -> retry with bigger budget -> HEALTH=failed, NOT counted as recall 0). Deterministic. localhost ollama needs no-proxy transport (stand quirk: 403 on localhost under the corp proxy). """ from __future__ import annotations import json import re import sys import urllib.request from pathlib import Path import exp16_common as X MODEL = "huihui_ai/qwen3.5-abliterated:9b" # exp06 best local spot recall (1.0) OLLAMA = "http://localhost:11434/api/chat" OUT = Path("/home/ubuntu/books/gu-zhenren/exp16") # no-proxy opener (stand quirk) _opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) SPOT_PROMPT = ( "Ты — экстрактор именованных сущностей из китайского текста веб-новеллы. " "Найди в СЛЕДУЮЩЕМ фрагменте все имена персонажей, топонимы, титулы/ранги и уникальные термины мира " "книги (артефакты, техники, существа). Верни ТОЛЬКО JSON-массив строк — сами китайские подстроки как " "они в тексте, без перевода и пояснений. Пример: [\"方源\",\"蛊师\",\"青茅山\"].\n\nФрагмент:\n") def call_local(prompt: str, num_predict=1024, timeout=120): # think:false + /no_think — spotting is extraction, not reasoning; thinking mode is slow + noisy here body = json.dumps({"model": MODEL, "think": False, "messages": [{"role": "user", "content": prompt + "\n/no_think"}], "stream": False, "options": {"temperature": 0.0, "num_predict": num_predict}}).encode() req = urllib.request.Request(OLLAMA, data=body, headers={"Content-Type": "application/json"}) try: with _opener.open(req, timeout=timeout) as r: d = json.loads(r.read()) return (d.get("message", {}) or {}).get("content", ""), None except Exception as e: return "", f"{type(e).__name__}: {str(e)[:150]}" def parse_entities(text: str): """Extract the JSON array of zh strings; tolerant of blocks and code fences.""" text = re.sub(r".*?", "", text, flags=re.S) m = re.search(r"\[.*\]", text, re.S) if not m: # fallback: any Han runs of length 2-6 in quotes return [q for q in re.findall(r'"([㐀-鿿]{1,8})"', text)] try: arr = json.loads(m.group(0)) return [str(x).strip() for x in arr if isinstance(x, (str,)) and re.search(r"[㐀-鿿]", str(x))] except json.JSONDecodeError: return [q for q in re.findall(r'"([㐀-鿿]{1,8})"', text)] def main(): chunks = X.load_chunks() gt = X.load_gt() limit = int(sys.argv[1]) if len(sys.argv) > 1 else len(chunks) spotted = set() # normalized spotted substrings spotted_raw = [] health = {"ok": 0, "failed": 0, "retried": 0} per_chunk = [] for c in chunks[:limit]: prompt = SPOT_PROMPT + c.source ents, err, npred = [], None, 1024 for attempt in range(2): out, err = call_local(prompt, num_predict=npred) ents = parse_entities(out) if ents and not err: if attempt > 0: health["retried"] += 1 break npred = 2048 # bigger budget on retry if err or not ents: health["failed"] += 1 per_chunk.append({"id": f"{c.chapter}.{c.chunk_idx}", "health": "failed", "err": err}) print(f" ch{c.chapter}.{c.chunk_idx}: HEALTH=failed ({err})") continue health["ok"] += 1 for e in ents: en = X.norm(e) if en: spotted.add(en) spotted_raw.append(e) per_chunk.append({"id": f"{c.chapter}.{c.chunk_idx}", "health": "ok", "n": len(ents)}) print(f" ch{c.chapter}.{c.chunk_idx}: {len(ents)} ents") # recall vs GT: a GT entity is spotted if any of its normalized surfaces is a spotted substring # OR a spotted string contains/is contained in the surface (surface-level match, spot-lenient) def gt_spotted(ent): for ns in ent.norm_surfaces: if ns in spotted: return True for sp in spotted: if ns in sp or sp in ns: return True return False hits = [e for e in gt if gt_spotted(e)] by_stratum = {"f>=10": [], "f3-9": [], "f<3": []} for e in gt: by_stratum[X.freq_stratum(X.gt_occurrences(e, chunks))].append(gt_spotted(e)) recall = len(hits) / len(gt) # hallucination proxy: spotted normalized strings that do NOT occur in ANY source (as substring) allsrc = "".join(c.nsource for c in chunks) halluc = [s for s in spotted if s not in allsrc] print(f"\n=== A6 local 9B spot ({MODEL}) — {limit} chunks ===") print(f"health: {health}") print(f"spot recall vs GT: {len(hits)}/{len(gt)} = {recall:.3f}") for st, v in by_stratum.items(): print(f" {st}: {sum(v)}/{len(v)} = {sum(v)/len(v):.2f}" if v else f" {st}: n/a") print(f"distinct spotted: {len(spotted)}; hallucinated (not in source): {len(halluc)} {list(halluc)[:10]}") miss = [e.src for e in gt if not gt_spotted(e)] print(f"GT misses: {miss}") json.dump({"model": MODEL, "limit": limit, "health": health, "recall": recall, "by_stratum": {k: (sum(v), len(v)) for k, v in by_stratum.items()}, "n_spotted": len(spotted), "halluc": list(halluc), "misses": miss, "spotted": sorted(spotted)}, open(OUT / "a6_local_spot.json", "w"), ensure_ascii=False, indent=1) print(f"[written] {OUT/'a6_local_spot.json'}") if __name__ == "__main__": main()