100 lines
4.5 KiB
Python
100 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
||
"""Полигон, пакет-7 (A3): recall боевого WHICH-канала против ПОДПИСАННОГО ВЛАДЕЛЬЦЕМ банка.
|
||
|
||
Вопрос, на который отвечает скрипт: из терминов, которые владелец УЖЕ подписал для этой книги и
|
||
которые физически встречаются в срезе, сколько боевой майнер предлагает сам?
|
||
|
||
Дисциплина замера (иначе цифра врёт в свою пользу):
|
||
- знаменатель — только те термины сида, что РЕАЛЬНО встречаются в срезе (иначе меряем не recall,
|
||
а длину среза);
|
||
- майнер прогоняется с ПУСТЫМ сидом, иначе он по построению исключает уже засеянное
|
||
(`miner_emit.go:134` — `seedSurfaces[c.Src]` → continue) и recall выйдет 0 по определению;
|
||
- засчитывается и попадание в алиас-кластер: движок считает такой кандидат «алиасом
|
||
существующего» и владелец видит его на стопе. Обе цифры печатаются раздельно.
|
||
- ключи нормализуются тем же способом, что и в движке (trad→simp, NFKC, lower) — тут повторено
|
||
ЧАСТИЧНО (NFKC+casefold): полный `text.NormalizeSourceKey` — Go. Расхождение помечается.
|
||
|
||
$0, stdlib + PyYAML.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import unicodedata
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
|
||
def nk(s: str) -> str:
|
||
return unicodedata.normalize("NFKC", s).casefold().strip()
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--seed", required=True, type=Path)
|
||
ap.add_argument("--chunks", required=True, type=Path)
|
||
ap.add_argument("--mined-tsv", required=True, type=Path)
|
||
ap.add_argument("--out", type=Path)
|
||
a = ap.parse_args()
|
||
|
||
doc = yaml.safe_load(a.seed.read_text(encoding="utf-8")) or {}
|
||
terms = doc.get("terms") or []
|
||
text = "\n".join(json.loads(l)["source"] for l in a.chunks.open(encoding="utf-8") if l.strip())
|
||
|
||
mined_primary, mined_all = set(), set()
|
||
for i, line in enumerate(a.mined_tsv.open(encoding="utf-8")):
|
||
if i == 0:
|
||
continue
|
||
f = line.rstrip("\n").split("\t")
|
||
if not f or not f[0]:
|
||
continue
|
||
mined_primary.add(nk(f[0]))
|
||
mined_all.add(nk(f[0]))
|
||
if len(f) > 4 and f[4]:
|
||
mined_all.update(nk(x) for x in f[4].split("|") if x)
|
||
|
||
rows = []
|
||
for t in terms:
|
||
src = t.get("src") or ""
|
||
surfaces = [src] + [al.get("alias", "") for al in (t.get("aliases") or [])]
|
||
present = [s for s in surfaces if s and s in text]
|
||
if not present:
|
||
continue
|
||
keys = {nk(s) for s in surfaces if s}
|
||
rows.append({
|
||
"src": src, "type": t.get("type", ""), "dst": t.get("dst", ""),
|
||
"status": t.get("status", ""),
|
||
"occurrences": sum(text.count(s) for s in present),
|
||
"hit_primary": bool(keys & mined_primary),
|
||
"hit_any": bool(keys & mined_all),
|
||
})
|
||
|
||
n = len(rows)
|
||
hp = sum(r["hit_primary"] for r in rows)
|
||
ha = sum(r["hit_any"] for r in rows)
|
||
bytype: dict[str, list[int]] = {}
|
||
for r in rows:
|
||
b = bytype.setdefault(r["type"] or "—", [0, 0])
|
||
b[0] += 1
|
||
b[1] += int(r["hit_any"])
|
||
|
||
print(f"термов сида, встречающихся в срезе: {n}")
|
||
print(f" предложены майнером как ПЕРВИЧНЫЙ терм: {hp} recall={hp / n:.3f}" if n else " —")
|
||
print(f" предложены как терм ИЛИ алиас: {ha} recall={ha / n:.3f}" if n else " —")
|
||
print("по типам (знаменатель / попало):")
|
||
for k in sorted(bytype):
|
||
c, h = bytype[k]
|
||
print(f" {k:8s} {h:3d}/{c:<3d} = {h / c:.3f}")
|
||
print("\nНЕ предложенные (топ-25 по частоте):")
|
||
for r in sorted((r for r in rows if not r["hit_any"]), key=lambda r: -r["occurrences"])[:25]:
|
||
print(f" {r['src']}\t{r['type']}\t{r['occurrences']}×\t{r['dst']}")
|
||
|
||
if a.out:
|
||
a.out.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|