#!/usr/bin/env python3 """S1 — entity/term candidate flagger v3. Local, no LLM cost. Inherits the correct design of the production v2 script (`OBSIDIAN/bin/obsidian-asr-phonetic-correct.py`): phonetics raise a flag, *sentence context decides*, so this stays a high-recall flagger that never rewrites anything. It emits candidates and lets the S2 cleaning model judge each one against the sentence it sits in — the step that correctly rejects 主要(adverb)→朱岩 while accepting 中药("找大涛")→王宗耀. Three changes over v2, each fixing a measured problem: 1. **Speed.** v2 took 31s per transcript: it re-scanned all 349 minutes notes on every run and did a pure-Python O(n·m) syllable DP against every candidate. v3 caches the corpus scan on disk (keyed by directory mtimes) and pre-buckets candidates by (length, first-syllable-initial), so the DP only runs on plausible pairs. Target <5s. 2. **Precision.** v2 offered 「分钟」→冯昆仑 and 「很远」→恒元生物 — ordinary words displaced by entities. v3 consults the term-bank allowlist (1,644 high-frequency general words from THUOCL): a span that IS a common word is demoted, not dropped, and carries an explicit `common_word: true` warning so the model defaults to keeping it. 3. **Coverage.** v2 only searched vault entities. v3 adds the domain term bank (CDMO/CMC jargon, 239 Chinese INN drug names) and INN suffix detection, so it flags 批记录 mis-hearings (披露/P图) and drug names never seen in the vault. Output is JSON: candidates grouped per chunk index, ready to inject into S2. """ from __future__ import annotations import argparse import hashlib import json import math import os import re import sys import time import urllib.request from dataclasses import dataclass, field from pathlib import Path from .config import CFG LAB = CFG.root BANK = CFG.termbank_dir CACHE = CFG.cache_dir / "flagger" VAULT = CFG.vault HS_API = CFG.hindsight_api HS_BANK = CFG.hindsight_bank HS_TIMEOUT = CFG.hindsight_timeout HAN = re.compile(r"[\u4e00-\u9fff]+") HAN_FULL = re.compile(r"^[\u4e00-\u9fff]+$") SPEAKER_TAG = re.compile(r"^\s*[【\[]([^\]】]+)[】\]]\s*") SENT_SPLIT = re.compile(r"[。!?!?…\n;;]+") INITIALS = ["zh", "ch", "sh", "b", "p", "m", "f", "d", "t", "n", "l", "g", "k", "h", "j", "q", "x", "r", "z", "c", "s", "y", "w"] FOLD = {"zh": "z", "ch": "c", "sh": "s"} try: from pypinyin import Style, lazy_pinyin except ImportError: # pragma: no cover - fall back to the vendored copy sys.path.insert(0, str(CFG.obsidian / "bin" / "_vendor")) from pypinyin import Style, lazy_pinyin # ------------------------------------------------------------------ phonetics def norm_syllable(s: str) -> tuple[str, str]: init, fin = "", s for cand in INITIALS: if s.startswith(cand): init, fin = cand, s[len(cand):] break init = FOLD.get(init, init) if fin.endswith("ng"): fin = fin[:-1] # Input-method style equivalences beyond the v2 set: these are the confusions # a Mandarin speaker (and therefore an ASR acoustic model) actually makes. fin = fin.replace("uang", "uan").replace("iang", "ian") if fin in ("en", "eng"): fin = "en" if fin in ("in", "ing"): fin = "in" return init, fin def to_syls(text: str) -> list[tuple[str, str]]: bare = lazy_pinyin(text) toned = lazy_pinyin(text, style=Style.TONE3, neutral_tone_with_five=True) out = [] for b, t in zip(bare, toned): if not re.match(r"[a-z]", b): continue out.append((b, t[len(b):] if t.startswith(b) else "")) return out _PERSONAL_SYL: dict[tuple[tuple[str, str], tuple[str, str]], int] | None = None def personal_syllable_confusions() -> dict: """Learn directional heard->intended syllable errors from confirmed fixes.""" global _PERSONAL_SYL if _PERSONAL_SYL is not None: return _PERSONAL_SYL out: dict[tuple[tuple[str, str], tuple[str, str]], int] = {} try: from . import lexicon for heard, targets in lexicon.confusion_map().items(): hs = to_syls(heard) for intended, n, confirmed in targets: if not confirmed: continue ts = to_syls(intended) if len(hs) != len(ts): continue for h, t in zip(hs, ts): hk, tk = norm_syllable(h[0]), norm_syllable(t[0]) if hk == tk: continue out[(hk, tk)] = out.get((hk, tk), 0) + max(1, n) except Exception: pass _PERSONAL_SYL = out return out def syl_sim(a, b, *, personal: bool = False) -> float: (ap, at), (bp, bt) = a, b ai, af = norm_syllable(ap) bi, bf = norm_syllable(bp) if (ai, af) == (bi, bf): base = 1.0 else: base = 0.0 if ai == bi: base += 0.5 elif {ai, bi} <= {"n", "l"} or {ai, bi} <= {"f", "h"}: base += 0.35 # n/l and f/h merge in many southern accents if af == bf: base += 0.5 elif af and bf and af[0] == bf[0]: base += 0.2 learned_n = (personal_syllable_confusions().get(((ai, af), (bi, bf)), 0) if personal else 0) if learned_n: # Direction matters: this is how the ASR wrote the sound -> what Kai # confirmed. One confirmation is strong evidence, repetition raises it # gradually, and graph/in-play gating still limits which entities are active. return max(base, min(0.98, 0.89 + 0.03 * math.log1p(learned_n))) return base * (1.0 if at == bt and at else 0.94) def seq_score(a, b, *, personal: bool = False) -> float: n, m = len(a), len(b) if not n or not m: return 0.0 dp = [[0.0] * (m + 1) for _ in range(n + 1)] for i in range(n + 1): dp[i][0] = i for j in range(m + 1): dp[0][j] = j for i in range(1, n + 1): ai = a[i - 1] for j in range(1, m + 1): dp[i][j] = min(dp[i - 1][j - 1] + (1 - syl_sim(ai, b[j - 1], personal=personal)), dp[i - 1][j] + 1, dp[i][j - 1] + 1) return 1 - dp[n][m] / max(n, m) # ------------------------------------------------------------ candidate pool @dataclass class Cand: form: str # the surface form to match phonetically canonical: str # what to correct to kind: str # 人名 / 公司 / 工艺术语 / 药品通用名 / … source: str weight: float = 0.0 # in-play priority syls: list = field(default_factory=list) rule: str = "full" # how `form` was derived from `canonical` (see RULE_MIN) owner: str = "" # affiliation (entity.company) used for disambiguation role: str = "" anchor: int = 0 # honorific forms: +1 = leading particle, -1 = trailing # How close a span must sound to a candidate form, per derivation rule. These are # not arbitrary: a rule that carries extra structural evidence can afford a lower # phonetic bar, and a rule with little structure needs a higher one. RULE_MIN = { "full": 0.86, # whole-name homophone, the v3 behaviour "alias": 1.0, # exact card/learned alias — an identity binding, not a guess "org-short": 0.84, # 迈威(生物): the dropped tail is itself evidence "given": 0.88, # 光辉←李光辉: short and riskier, so stricter "honorific": 0.82, # X总/老X: the title is a strong anchor, only X must match "one-off": 0.85, # same length, one syllable substituted "consistency": 0.80, # another spelling of a target already established in-doc "observed": 0.90, # fuzzy neighbour of a confirmed historical ASR error } ORG_TAIL = ("生物医药", "生物科技", "有限公司", "股份公司", "生物", "医药", "制药", "药业", "基金", "资本", "投资", "科技", "集团", "股份", "公司", "银行", "证券", "医院", "研究院", "实验室") HONOR_PREFIX = ("老", "小") HONOR_SUFFIX = ("总", "博", "哥", "姐", "老师", "董") # 博=博士: 张博→张成海, 许博→许俊彦 def org_head(name: str) -> str: """迈威生物→迈威, 宝山基金→宝山. The form people actually say out loud.""" for t in ORG_TAIL: if name.endswith(t) and len(name) - len(t) >= 2: return name[:len(name) - len(t)] return "" def variants(canonical: str, kind: str) -> list[tuple[str, str, int]]: """Spoken surface forms of an entity, as (form, rule). This is the fix for the dominant failure mode measured on 2026-07-21: the matcher only compared whole strings of near-equal length, so every colloquial short form was unreachable — 麦威 could not find 迈威生物, 光辉 could not find 李光辉, and 老万 was actively discarded because it matched an alias *exactly*. """ out: list[tuple[str, str, int]] = [] if kind in ("公司", "机构"): h = org_head(canonical) if 2 <= len(h) <= 5: out.append((h, "org-short", 0)) return out if kind != "人名" or not (2 <= len(canonical) <= 4): return out surname, given = canonical[0], canonical[1:] if len(given) >= 2: out.append((given, "given", 0)) # 光辉 ← 李光辉 for p in HONOR_PREFIX: out.append((p + surname, "honorific", +1)) # 老万 / 小万 for suf in HONOR_SUFFIX: out.append((surname + suf, "honorific", -1)) # 万总 / 万博 if len(given) == 1: out.append((given + suf, "honorific", -1)) # 洵总 ← 邹洵 return out def _dir_sig(paths) -> str: h = hashlib.sha256() for p in paths: p = Path(p) if p.exists(): if p.is_file(): st = p.stat() h.update(f"{p}:{st.st_mtime_ns}:{st.st_size}".encode()) continue files = sorted(p.glob("*.md")) h.update(f"{p}:{len(files)}".encode()) for f in files: st = f.stat() h.update(f"{f.name}:{st.st_mtime_ns}:{st.st_size}".encode()) return h.hexdigest()[:16] def load_pool(use_termbank=True) -> dict[str, Cand]: """Vault entities (cards + glossary) ∪ optional domain term bank.""" CACHE.mkdir(parents=True, exist_ok=True) if CFG.use_lexicon: try: from . import lexicon lexicon.sync_from_vault() except Exception: pass sig = _dir_sig([VAULT / "People", VAULT / "Companies", VAULT / "minutes", CFG.lexicon_db]) cpath = CACHE / f"pool6-{sig}-{int(use_termbank)}.json" if cpath.exists(): d = json.loads(cpath.read_text()) return {k: Cand(**v) for k, v in d.items()} pool: dict[str, Cand] = {} def add(form, canonical, kind, source, rule="full", owner="", role="", anchor=0): """Register a matchable form. Keyed by (form, rule, canonical) rather than form alone: 迈威 is both an exact card alias of 迈威生物 *and* its org-short form. Keeping only the alias entry made 麦威 unmatchable, because the alias rule matches exactly and never phonetically. Both entries must coexist. """ form = (form or "").strip() if not (2 <= len(form) <= 12) or not HAN_FULL.match(form): return key = f"{form}\x00{rule}\x00{canonical}" if key in pool: return pool[key] = Cand(form, canonical, kind, source, syls=to_syls(form), rule=rule, owner=owner, role=role, anchor=anchor) # Entities and their aliases come from the SQLite lexicon, which mirrors the # vault cards (read-only) and additionally holds anything Kai has confirmed. ents: dict = {} if CFG.use_lexicon: try: from . import lexicon ents = lexicon.entities() for name, e in ents.items(): add(name, name, e.kind, "lexicon-entity", "full", e.company, e.role) for al, targets in lexicon.alias_map().items(): # Every target, not just the first. The glossary deliberately keeps # one-to-many rows (许博→许俊彦/许恒皓, 徐总→徐劲前/邹洵) because they # are context-dependent; collapsing them to one silently discarded # the correct reading. for canon, kind, origin, _conf in targets: e = ents.get(canon) add(al, canon, kind, f"alias-{origin}", "alias", e.company if e else "", e.role if e else "") # Exact confirmed errors are already aliases. This second form is # deliberately gated on in-play and allows a nearby decoder variant # (张台海) to recall the same canonical person learned from # 张泰海→张成海 without enabling global fuzzy matching. for heard, targets in lexicon.confusion_map().items(): for canon, _n, confirmed in targets: if not confirmed: continue e = ents.get(canon) add(heard, canon, e.kind if e else lexicon.guess_kind(canon), "observed-correction", "observed", e.company if e else "", e.role if e else "") except Exception: ents = {} if not ents: # lexicon disabled/broken for sub, kind in (("People", "人名"), ("Companies", "公司")): d = VAULT / sub if not d.exists(): continue for f in d.glob("*.md"): add(f.stem, f.stem, kind, f"card-{sub.lower()}") # Spoken short forms. Generated for every entity; only the in-play ones are # activated at match time, so this costs nothing in precision. for c in list(pool.values()): if c.rule != "full" or c.kind not in ("人名", "公司"): continue for form, rule, anchor in variants(c.canonical, c.kind): add(form, c.canonical, c.kind, "variant", rule, c.owner, c.role, anchor) if use_termbank: tb = BANK / "terms.json" if tb.exists(): for term, e in json.loads(tb.read_text()).items(): # Only the on-domain, distinctive slices are worth phonetic search; # 27k THUOCL general words would flood the candidate space with the # very false positives v3 is trying to eliminate. if e["kind"] in ("工艺术语", "药品通用名", "机构", "已确认纠错目标"): add(term, term, e["kind"], e["sources"][0]) cpath.write_text(json.dumps({k: v.__dict__ for k, v in pool.items()}, ensure_ascii=False), encoding="utf-8") return pool def load_allowlist() -> set[str]: p = BANK / "allowlist.txt" return set(p.read_text().split()) if p.exists() else set() # ------------------------------------------------------------------- in-play # A wikilink in a minutes note points at other notes as often as at entities. # Unfiltered, 25 of the 70 in-play slots on 2026-07-21 were note titles, bare dates # and `_glossary` — burning candidate space and prompt budget on non-entities. NOT_ENTITY = re.compile(r"^\d{4}-\d{2}|^_|\.md$|[|/#]|^\d+$") def _is_entity_name(s: str) -> bool: s = s.strip() return bool(s) and 2 <= len(s) <= 12 and not NOT_ENTITY.search(s) def _minutes_entities(text: str) -> set[str]: out = set() for key in ("people", "companies"): m = re.search(rf"^{key}:[ \t]*(.*)$", text, re.M) if m: out |= {x for x in re.findall(r"[^,\[\]\s,、]+", m.group(1))} out |= {w.strip() for w in re.findall(r"\[\[([^\]|#]+)", text)} return {o for o in out if _is_entity_name(o)} def _corpus() -> list[tuple[str, set[str], str]]: CACHE.mkdir(parents=True, exist_ok=True) sig = _dir_sig([VAULT / "minutes"]) cpath = CACHE / f"corpus-{sig}.json" if cpath.exists(): return [(a, set(b), c) for a, b, c in json.loads(cpath.read_text())] out = [] for f in sorted((VAULT / "minutes").glob("*.md")): txt = f.read_text(errors="ignore") m = re.search(r"^date:\s*(\d{4}-\d{2}-\d{2})", txt, re.M) out.append((f.name, _minutes_entities(txt), m.group(1) if m else "")) cpath.write_text(json.dumps([(a, sorted(b), c) for a, b, c in out], ensure_ascii=False), encoding="utf-8") return out def hindsight_recall(seed: list[str], extra_query: str = "") -> tuple[set[str], list[str]]: """Semantic neighbourhood from Hindsight. Returns (entity names, memory texts). The memory texts matter as much as the names: injected into S2/S4 they give the model the *relationship* ("吴楠 is 刘冀's colleague at 跨境资本") that lets it accept or reject a phonetic candidate on business grounds, not just sound. """ if not seed and not extra_query: return set(), [] query = (" ".join(sorted(seed)) + " " + extra_query).strip() or "会议 相关人物 公司" body = json.dumps({"query": query, "max_tokens": 1500}).encode() url = f"{HS_API}/v1/default/banks/{HS_BANK}/memories/recall" try: req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=HS_TIMEOUT) as r: d = json.loads(r.read()) except Exception: return set(), [] names = set(d.get("entities", {}).keys()) texts = [] for r_ in d.get("results", []): names.update(r_.get("entities", [])) if r_.get("text"): texts.append(r_["text"]) return ({n for n in names if 2 <= len(n) <= 8 and HAN_FULL.match(n)}, texts) def _super_nodes(participants: set[str], corpus) -> set[str]: if len(participants) < 2: return set() freq = {p: sum(1 for _, e, _ in corpus if p in e) for p in participants} fmin = max(1, min(freq.values())) hubs = {p for p in participants if freq[p] >= 20 and freq[p] >= 3 * fmin} return hubs if len(hubs) < len(participants) else set() def compute_in_play(participants: set[str], recent_days=90, use_hindsight=True, extra_seed: set[str] | None = None): corpus = _corpus() hubs = _super_nodes(participants, corpus) seed = ((participants - hubs) or participants) | (extra_seed or set()) dates = [d for _, _, d in corpus if d] ref = max(dates) if dates else "" weight = {p: 999.0 for p in participants} for _, ents, d in corpus: if not (ents & seed): continue if ref and d and recent_days: try: from datetime import date a = date.fromisoformat(d) b = date.fromisoformat(ref) if (b - a).days > recent_days: continue except Exception: pass for e in ents: weight[e] = weight.get(e, 0) + 1 try: from . import graph graph.sync() for entity, score in graph.context_weights(seed).items(): weight[entity] = weight.get(entity, 0.0) + score except Exception: pass in_play = set(sorted(weight, key=lambda e: -weight[e])[:60]) hs_names, hs_texts = hindsight_recall(sorted(seed)) if use_hindsight else (set(), []) return in_play | hs_names, {"hubs": sorted(hubs), "hindsight_names": sorted(hs_names), "hindsight_texts": hs_texts, "weight": weight} # ----------------------------------------------------------------- flagging def sentences_of(text: str): for raw in text.splitlines(): line = raw.strip() if not line or line.startswith(("#", "