#!/usr/bin/env python3 """Полигон, пакет-7 (A3): майнинг кандидатов-терминов для НЕ-ханьских книг (en, ja). Зачем отдельный скрипт. Боевой `internal/miner` строит кандидатов ТОЛЬКО из ханьских рун (`miner_substrate.go:44-63`, `isMinerHan`), поэтому на английском он по построению отдаёт пустую дельту, а на японском видит лишь кандзи — катакана, где у исэкая живёт большая часть жанровых терминов, ему невидима. Этот скрипт — не замена движку, а СБОР ФАКТУРЫ ТРЕБОВАНИЙ к будущему generic-майнеру (хвост №8 D39.37): какие каналы нужны, что каждый ловит, чем платим. Три НЕЗАВИСИМЫХ канала (считаются и отчитываются раздельно — их согласие не подмешивается в один балл, чтобы не собрать ложную сходимость): ORTHO орфографическая улика языка: en — заглавная НЕ в начале предложения; ja — руны катаканы, n-граммы кандзи, «одинокая короткая строка» (уплощённая рубя/эмфаза дампа syosetu). DISP рассеяние: в скольких главах встречается (термин книги живёт по всей книге, реплика — нет). CONTRAST над-представленность против ОПОРНОГО корпуса. en — опора берётся из ДРУГОЙ английской книги стенда (кросс-книжный контраст: дешёвая и честная замена «общего корпуса»); ja/кандзи — опоры НЕТ. Первая версия скрипта пробовала подставить сюда jieba-словарь (общий zh) и получила вырожденное ранжирование: для слова, которого в опоре нет, лапласова вероятность постоянна, поэтому «над-представленность» вырождается в log2(частоты) и наверх всплывают 彼女/見/気 — обычная японская лексика. Канал кандзи поэтому отдан БОЕВОМУ майнеру (он считает ожидание по независимости символов, `miner_substrate.go:197 charIndepRel`, а это работает и на японском), а здесь остаётся ИНВЕНТАРЁМ класса — частоты без ранга, чтобы видеть объём, а не выдавать порядок за результат. Катаканы в zh-опоре нет вообще, у неё канала CONTRAST не существует. $0: ни одного сетевого вызова, ни одной модели, ни одной внешней зависимости (только stdlib). """ from __future__ import annotations import argparse import json import math import re import sys from collections import Counter, defaultdict from pathlib import Path # --- алфавиты ---------------------------------------------------------------------------------- KATAKANA = re.compile(r"[ァ-ヺヽヾㇰ-ㇿ][ァ-ヺーヽヾㇰ-ㇿ・]*") HAN_RUN = re.compile(r"[㐀-䶿一-鿿豈-﫿]+") HIRAGANA = re.compile(r"[ぁ-ゟ]") # Латиница с диакритикой: François, Ilsæ, Ashdrinker — обрезать по [A-Za-z] значит плодить огрызки # («jean-fran»), что первый прогон и сделал. EN_TOKEN = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿÆæŒœ][A-Za-zÀ-ÖØ-öø-ÿÆæŒœ'’\-]*") POSSESSIVE = re.compile(r"[’']s$") EN_INFIX = {"of", "the", "de", "du", "la", "le", "von", "van", "des", "di", "da", "and", "à", "san"} # Английские местоимения и стяжения пишутся с заглавной по грамматике, а не потому, что это имя. # Первый прогон без этого списка склеил «Miss Steele He», «Anastasia I’m», «Kate I» — заглавное # местоимение приклеилось к настоящему имени и породило фантомный многословный «термин». EN_PRONOUN = { "i", "he", "she", "it", "we", "they", "you", "him", "her", "his", "hers", "them", "their", "me", "my", "mine", "our", "ours", "your", "yours", "its", "i'm", "i’m", "i've", "i’ve", "i'd", "i’d", "i'll", "i’ll", } EN_STOP = { "i", "a", "an", "and", "but", "or", "so", "then", "there", "this", "that", "these", "those", "he", "she", "it", "we", "you", "they", "his", "her", "my", "your", "our", "their", "what", "when", "where", "why", "how", "who", "if", "as", "at", "by", "for", "from", "in", "into", "of", "on", "to", "with", "yes", "no", "oh", "ok", "okay", "well", "not", "do", "did", "does", "is", "are", "was", "were", "be", "been", "am", "have", "has", "had", "will", "would", "can", "could", "should", "shall", "may", "might", "must", "one", "two", "all", "some", "any", "very", "just", "now", "here", "after", "before", "again", "still", "even", "only", "also", "like", "up", "down", "out", "over", "under", "about", "because", "while", "though", "although", "however", "maybe", "mr", "mrs", "ms", "dr", "sir", "madam", "s", "t", "est", } | EN_INFIX | EN_PRONOUN def load_chunks(path: Path) -> list[dict]: rows = [] with path.open(encoding="utf-8") as f: for line in f: line = line.strip() if line: rows.append(json.loads(line)) return rows def load_zh_contrast(path: Path) -> Counter: """jieba `word freq POS` → Counter. Тот же артефакт, что читает боевой майнер.""" c: Counter = Counter() with path.open(encoding="utf-8", errors="replace") as f: for line in f: parts = line.split() if len(parts) >= 2 and parts[1].isdigit(): c[parts[0]] = max(c[parts[0]], int(parts[1])) return c # --- канал ORTHO: английский -------------------------------------------------------------------- def en_candidates(rows: list[dict]) -> tuple[Counter, dict[str, set[int]], Counter, Counter]: """(cap_mid, главы, multiword-freq в том же счётчике, tok_total). Улика — заглавная НЕ в начале предложения/строки. Строки, набранные ЦЕЛИКОМ прописными (колонтитул, шмуцтитул, эпиграф капслоком), из улики исключены: там заглавная ничего не значит, и первый прогон именно на них поймал «forever/dead/god» в верхушку списка. """ cap_mid: Counter = Counter() tok_total: Counter = Counter() chapters: dict[str, set[int]] = defaultdict(set) multi: Counter = Counter() multi_ch: dict[str, set[int]] = defaultdict(set) for r in rows: for raw_line in r["source"].split("\n"): letters = [c for c in raw_line if c.isalpha()] all_caps_line = bool(letters) and all(c.isupper() for c in letters) for sent in re.split(r"(?<=[.!?])\s+", raw_line): toks = [POSSESSIVE.sub("", m.group(0)) for m in EN_TOKEN.finditer(sent)] toks = [t for t in toks if t] if not toks: continue for i, t in enumerate(toks): low = t.lower() tok_total[low] += 1 if i > 0 and t[0].isupper() and not all_caps_line: cap_mid[low] += 1 chapters[low].add(r["chapter"]) if all_caps_line: continue i = 0 while i < len(toks): if i > 0 and toks[i][0].isupper() and toks[i].lower() not in EN_PRONOUN: j, last_cap = i, i while j + 1 < len(toks): nxt = toks[j + 1] if nxt[0].isupper() and nxt.lower() not in EN_PRONOUN: j += 1 last_cap = j elif (nxt.lower() in EN_INFIX and j + 2 <= len(toks) - 1 and toks[j + 2][0].isupper()): j += 1 else: break if last_cap > i: phrase = " ".join(toks[i:last_cap + 1]) multi[phrase] += 1 multi_ch[phrase].add(r["chapter"]) i = last_cap + 1 else: i += 1 for p, c in multi.items(): cap_mid[p] = c tok_total[p] = c chapters[p] = multi_ch[p] return cap_mid, chapters, cap_mid, tok_total # --- канал ORTHO: японский ---------------------------------------------------------------------- def ja_candidates(rows: list[dict], ngram_max: int = 6): freq: dict[str, Counter] = {"katakana": Counter(), "kanji": Counter(), "loneline": Counter()} chaps: dict[str, dict[str, set[int]]] = {k: defaultdict(set) for k in freq} for r in rows: src = r["source"] for m in KATAKANA.finditer(src): w = m.group(0).strip("・ー") if len(w) >= 2: freq["katakana"][w] += 1 chaps["katakana"][w].add(r["chapter"]) for m in HAN_RUN.finditer(src): run = m.group(0) L = len(run) for n in range(1, ngram_max + 1): for i in range(0, L - n + 1): g = run[i:i + n] freq["kanji"][g] += 1 chaps["kanji"][g].add(r["chapter"]) for line in src.split("\n"): s = line.strip() if 1 <= len(s) <= 8 and not HIRAGANA.search(s) and HAN_RUN.fullmatch(s): freq["loneline"][s] += 1 chaps["loneline"][s].add(r["chapter"]) return freq, chaps # --- канал CONTRAST ------------------------------------------------------------------------------ def contrast_scores(book: Counter, ref: Counter) -> dict[str, float]: """log2 над-представленности с лапласовым сглаживанием; опора — частоты опорного корпуса.""" nb, nr = sum(book.values()) or 1, sum(ref.values()) or 1 denom = nr + len(ref) + 1 out = {} for w, f in book.items(): pb = f / nb pr = (ref.get(w, 0) + 1) / denom out[w] = math.log2(pb / pr) return out def emit(rows_out: list[dict], out: Path, note: str) -> None: out.parent.mkdir(parents=True, exist_ok=True) with out.open("w", encoding="utf-8") as f: for r in rows_out: f.write(json.dumps(r, ensure_ascii=False) + "\n") print(f"{out}: {len(rows_out)} кандидатов [{note}]", file=sys.stderr) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--in", dest="inp", required=True, type=Path) ap.add_argument("--lang", required=True, choices=["en", "ja"]) ap.add_argument("--ref", type=Path, help="JSONL опорной книги (кросс-книжный контраст, en)") ap.add_argument("--zh-contrast", type=Path, help="jieba dict как приблизительная опора для кандзи (ja)") ap.add_argument("--min-freq", type=int, default=5) ap.add_argument("--min-chapters", type=int, default=2) ap.add_argument("--top", type=int, default=200, help="ТОП НА КАНАЛ, не суммарно") ap.add_argument("--out", required=True, type=Path) a = ap.parse_args() rows = load_chunks(a.inp) result: list[dict] = [] if a.lang == "en": cap_mid, chapters, _, tok_total = en_candidates(rows) ref_cap: Counter = Counter() if a.ref: ref_cap, _, _, _ = en_candidates(load_chunks(a.ref)) cs = contrast_scores(cap_mid, ref_cap) if ref_cap else {} for w, f in cap_mid.items(): if f < a.min_freq or w in EN_STOP: continue nch = len(chapters[w]) capratio = f / max(tok_total[w], 1) if nch < a.min_chapters and f < a.min_freq * 3: continue if capratio < 0.5: continue ch_list = ["ORTHO"] if nch >= a.min_chapters: ch_list.append("DISP") if cs.get(w, 0) >= 3: ch_list.append("CONTRAST") result.append({ "src": w, "freq": f, "chapters": nch, "cap_ratio": round(capratio, 3), "contrast_log2": round(cs[w], 2) if w in cs else None, "channels": ch_list, "words": len(w.split()), "channel": "en-proper", }) result.sort(key=lambda r: (-(r["contrast_log2"] or 0), -r["freq"], r["src"])) result = result[: a.top] if a.top else result else: freq, chaps = ja_candidates(rows) # Канал кандзи здесь СОЗНАТЕЛЬНО без контраста (см. шапку): ранг по нему не выдаём. cs: dict[str, float] = {} per_channel: dict[str, list[dict]] = {} for channel in ("katakana", "kanji", "loneline"): fl = a.min_freq if channel != "loneline" else 2 bucket = [] for w, f in freq[channel].items(): if f < fl: continue nch = len(chaps[channel][w]) if nch < a.min_chapters: continue ch_list = ["ORTHO:" + channel] if nch >= a.min_chapters: ch_list.append("DISP") score = cs.get(w) if channel == "kanji" else None if score is not None and score >= 3: ch_list.append("CONTRAST") bucket.append({ "src": w, "freq": f, "chapters": nch, "channel": channel, "len": len(w), "contrast_log2": round(score, 2) if score is not None else None, "channels": ch_list, }) key = (lambda r: (-(r["contrast_log2"] or -99), -r["freq"], r["src"])) if channel == "kanji" \ else (lambda r: (-r["freq"], r["src"])) bucket.sort(key=key) per_channel[channel] = bucket[: a.top] if a.top else bucket print(f" канал {channel}: всего {len(bucket)} прошло пороги, взято {len(per_channel[channel])}", file=sys.stderr) for channel in ("katakana", "kanji", "loneline"): result.extend(per_channel[channel]) note = (f"lang={a.lang} chunks={len(rows)}" + (f" ref={a.ref.name}" if a.ref else "") + (f" zh-contrast={a.zh_contrast.name}" if a.zh_contrast else " zh-contrast=НЕТ")) emit(result, a.out, note) return 0 if __name__ == "__main__": raise SystemExit(main())