#!/usr/bin/env python3 """Полигон, пакет-7, фаза B: сборка набора термов со входом терминолога. Вход роли ТЕРМИНОЛОГ по D39.42 п.1 — «смердженный банк + KWIC-контексты вхождений из исходника + варианты черновиков». Здесь это собирается из РЕАЛЬНЫХ артефактов стенда, а не из выдумки: - `books/gu-zhenren/labels/raw/corpus.jsonl` — 91 юнит из ШЕСТИ настоящих прогонов с выровненными source/draft/final (постоянный измерительный стенд, D39.39); - сид книги + `minirun/mined-delta.yaml` — ПОДПИСАННЫЕ владельцем dst (золотой стандарт); - TSV боевого майнера (пакет-7 фаза A) — намайненные термы БЕЗ dst; - JSONL-субстраты остальных книг стенда — для выборок S2–S4. ЧЕСТНОЕ ОГРАНИЧЕНИЕ, зафиксированное ДО трат: черновики существуют ТОЛЬКО у 蛊真人. У 金瓶梅, исэкая и en-книг переводов нет, поэтому на выборках S2–S4 компонента «варианты черновиков» пуста по построению, и арм P1 там вырождается в «только KWIC». Это не правка пре-регистрации, а названное ограничение материала. $0: чтение файлов, ни одного вызова модели. """ from __future__ import annotations import argparse import json import re from pathlib import Path import yaml CORPUS = Path("/home/ubuntu/books/gu-zhenren/labels/raw/corpus.jsonl") def load_jsonl(p: Path) -> list[dict]: return [json.loads(l) for l in p.open(encoding="utf-8") if l.strip()] def kwic(units: list[dict], term: str, field: str, width: int, limit: int) -> list[dict]: out = [] for u in units: txt = u.get(field) or "" pos = 0 while len(out) < limit: i = txt.find(term, pos) if i < 0: break a, b = max(0, i - width), min(len(txt), i + len(term) + width) out.append({"chapter": u.get("chapter"), "run": u.get("run"), "win": txt[a:b].replace("\n", " ")}) pos = i + len(term) return out def aligned_pairs(units: list[dict], term: str, limit: int, src_w: int, dst_w: int) -> list[dict]: """Пары «окно исходника ↔ пропорционально позиционированное окно черновика». Выравнивание ПРОПОРЦИОНАЛЬНОЕ (доля позиции в юните), а не словное: это приближение, и оно названо приближением. Задача пары — показать модели, ЧТО черновик сделал в этом месте, а не дать точную привязку токена. """ out = [] for u in units: src, drf = u.get("source") or "", u.get("draft") or "" if not src or not drf: continue i = src.find(term) if i < 0: continue frac = i / max(len(src), 1) j = int(frac * len(drf)) a, b = max(0, i - src_w), min(len(src), i + len(term) + src_w) c, d = max(0, j - dst_w), min(len(drf), j + dst_w) out.append({"run": u.get("run"), "chapter": u.get("chapter"), "src_win": src[a:b].replace("\n", " "), "draft_win": drf[c:d].replace("\n", " ")}) if len(out) >= limit: break return out def seed_terms(paths: list[Path]) -> dict[str, dict]: out = {} for p in paths: if not p.exists(): continue doc = yaml.safe_load(p.read_text(encoding="utf-8")) or {} for t in doc.get("terms") or []: if t.get("src") and t.get("dst"): out[t["src"]] = {"gold": t["dst"], "type": t.get("type", ""), "note": (t.get("note") or "")[:400]} return out def mined_terms(tsv: Path) -> list[dict]: rows = [] for i, line in enumerate(tsv.open(encoding="utf-8")): if i == 0: continue f = line.rstrip("\n").split("\t") if f and f[0]: rows.append({"src": f[0], "type": f[1] if len(f) > 1 else "", "freq": int(f[2]) if len(f) > 2 and f[2].isdigit() else 0}) return rows def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--out", required=True, type=Path) ap.add_argument("--mined-tsv", type=Path, action="append", default=[]) ap.add_argument("--chunks", action="append", default=[], help="sample=path.jsonl для книг без черновиков (S2..S4)") ap.add_argument("--extra-terms", action="append", default=[], help="sample=терм,терм,... — ручной список (ловушка S5)") ap.add_argument("--n-signed", type=int, default=15) ap.add_argument("--n-mined", type=int, default=15) a = ap.parse_args() units = load_jsonl(CORPUS) gold = seed_terms([Path("/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml"), Path("/home/ubuntu/books/gu-zhenren/minirun/mined-delta.yaml")]) items: list[dict] = [] # --- S1: подписанные термы, реально встречающиеся в корпусе (у них ЕСТЬ эталон) ----------- signed = [] for src, meta in gold.items(): occ = sum((u.get("source") or "").count(src) for u in units) if occ: signed.append((occ, src, meta)) signed.sort(key=lambda x: -x[0]) for occ, src, meta in signed[: a.n_signed]: items.append({ "id": f"S1-signed-{src}", "sample": "S1", "src": src, "lang": "zh", "gold": meta["gold"], "gold_type": meta["type"], "gold_note": meta["note"], "occurrences": occ, "kwic": kwic(units, src, "source", 60, 6), "pairs": aligned_pairs(units, src, 3, 90, 130), }) # --- S1: намайненные БЕЗ dst --------------------------------------------------------------- seen = {i["src"] for i in items} pool = [] for tsv in a.mined_tsv: for r in mined_terms(tsv): if r["src"] in gold or r["src"] in seen: continue occ = sum((u.get("source") or "").count(r["src"]) for u in units) if occ: pool.append((occ, r)) pool.sort(key=lambda x: -x[0]) picked = set() for occ, r in pool: if r["src"] in picked: continue picked.add(r["src"]) items.append({ "id": f"S1-mined-{r['src']}", "sample": "S1", "src": r["src"], "lang": "zh", "gold": None, "gold_type": r["type"], "gold_note": "", "occurrences": occ, "kwic": kwic(units, r["src"], "source", 60, 6), "pairs": aligned_pairs(units, r["src"], 3, 90, 130), }) if len(picked) >= a.n_mined: break # --- S2..S4: книги без черновиков ---------------------------------------------------------- for spec in a.chunks: sample, path = spec.split("=", 1) rows = load_jsonl(Path(path)) us = [{"source": r["source"], "chapter": r["chapter"], "run": sample} for r in rows] for spec2 in a.extra_terms: s2, terms = spec2.split("=", 1) if s2 != sample: continue for t in [x for x in terms.split(",") if x.strip()]: occ = sum(u["source"].count(t) for u in us) items.append({ "id": f"{sample}-{t}", "sample": sample, "src": t, "lang": {"S2": "zh", "S3": "ja", "S4": "en"}.get(sample, "zh"), "gold": None, "gold_type": "", "gold_note": "", "occurrences": occ, "kwic": kwic(us, t, "source", 60, 6), "pairs": [], # черновиков нет по построению — названо в шапке }) # --- S5: ловушка — подписанные dst, НАРУШАЮЩИЕ буквальность (стайл-каноны D39.21) --------- for spec2 in a.extra_terms: s2, terms = spec2.split("=", 1) if s2 != "S5": continue for t in [x for x in terms.split(",") if x.strip()]: occ = sum((u.get("source") or "").count(t) for u in units) items.append({ "id": f"S5-{t}", "sample": "S5", "src": t, "lang": "zh", "gold": gold.get(t, {}).get("gold"), "gold_type": gold.get(t, {}).get("type", ""), "gold_note": gold.get(t, {}).get("note", ""), "occurrences": occ, "kwic": kwic(units, t, "source", 60, 6), "pairs": aligned_pairs(units, t, 3, 90, 130), }) a.out.write_text(json.dumps(items, ensure_ascii=False, indent=1), encoding="utf-8") from collections import Counter c = Counter(i["sample"] for i in items) ng = sum(1 for i in items if i["gold"]) npair = sum(1 for i in items if i["pairs"]) print(f"собрано термов: {len(items)} по выборкам {dict(c)} с эталоном {ng} с парами черновика {npair}") return 0 if __name__ == "__main__": raise SystemExit(main())