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