Files
infra-docker-configs/servers/tools-pve/asr-pipeline/asrpipe/flagger.py
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

959 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
def _vault() -> Path:
return CFG.vault
def _bank() -> Path:
return CFG.termbank_dir
def _cache() -> Path:
return CFG.cache_dir / "flagger"
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"{CFG.hindsight_api}/v1/default/banks/{CFG.hindsight_bank}/memories/recall"
try:
req = urllib.request.Request(url, data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=CFG.hindsight_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(("#", "<!--", "---")):
continue
spk = ""
m = SPEAKER_TAG.match(line)
if m:
spk = m.group(1)
line = SPEAKER_TAG.sub("", line)
for piece in SENT_SPLIT.split(line):
piece = piece.strip()
if piece:
yield spk, piece
def phon_key(syls: list[tuple[str, str]]) -> str:
"""Collapse a word to an accent-folded consonant/vowel skeleton.
This is the input-method trick: 中药 and 宗耀 both become "zonyao", so an exact
dict lookup finds the homophone class instantly and only genuinely ambiguous
pairs need the expensive alignment. Tones are dropped here and used later as a
tiebreak, mirroring how a listener hears a name they半-recognize.
"""
out = []
for bare, _ in syls:
i, f = norm_syllable(bare)
out.append(i + f)
return "".join(out)
def flag_text(text: str, pool: dict[str, Cand], in_play: set[str],
allowlist: set[str], min_score=0.86, use_termbank_global=True,
entity_weights: dict[str, float] | None = None):
"""Find phonetic near-matches. Entity candidates are gated on being in-play;
domain terms (工艺术语/药品通用名) are always searched, since a drug or a
批记录 can appear in a meeting with no prior vault history.
Performance note: the v2 approach (score every span against every candidate
with a Python syllable DP) is O(spans x candidates x syllables^2) and took
>400s once the term bank widened the candidate pool. v3 first buckets
candidates by phonetic key and length, uses rapidfuzz (C) to shortlist keys,
and only then runs the fine-grained tonal alignment on survivors.
"""
from rapidfuzz import process as rf_process
# An in-play name with no candidate entry can never be matched. 宝山基金 has no
# Companies card — it reached in-play only through a minutes filename — so
# 保山 (a perfect homophone of its short form) had nothing to match against.
# Materialise those names, including their spoken short forms.
pool = dict(pool)
# Skip names already reachable as *any* form, not just as a canonical. 迈威 is
# the alias/short form of 迈威生物 and also appears in in-play as a bare name;
# materialising it again produced two competing candidates for one company.
known = {c.canonical for c in pool.values()} | {c.form for c in pool.values()}
for name in sorted(in_play):
if name in known or not _is_entity_name(name) or not HAN_FULL.match(name):
continue
is_org = name.endswith(ORG_TAIL)
kind = "公司" if is_org else "人名"
if not is_org and not (2 <= len(name) <= 4):
continue
# Variants are only derived for names whose type is *known*. An in-play name
# with no card is a guess already; 三金 (short for 桂林三金) was being read as
# a surname and generating 老三, which then matched 老尚. Only an explicit
# organisation suffix is trustworthy enough to expand here.
forms = [(name, "full", 0)] + (variants(name, kind) if is_org else [])
for form, rule, anchor in forms:
if not (2 <= len(form) <= 12 and HAN_FULL.match(form)):
continue
pool.setdefault(f"{form}\x00{rule}\x00{name}",
Cand(form, name, kind, "in-play", syls=to_syls(form),
rule=rule, anchor=anchor))
active: list[Cand] = []
for c in pool.values():
if c.rule == "alias":
# Curated identity bindings (card `aliases:` + imported glossary). These
# are hand-confirmed and matched exactly, so gating them on in-play
# would only lose recall — this is what silently dropped 老万→万亚坤.
active.append(c)
elif c.kind in ("人名", "公司", "项目", "已确认目标"):
if c.canonical in in_play or c.form in in_play:
active.append(c)
elif use_termbank_global:
active.append(c)
# key -> candidates sharing that phonetic skeleton
by_key: dict[str, list[Cand]] = {}
for c in active:
by_key.setdefault(phon_key(c.syls), []).append(c)
keys = list(by_key)
lens = {len(c.form) for c in active} or {2, 3}
widths = sorted({w for L in lens for w in (max(2, L - 1), L, L + 1)})
# Collect every distinct span once; scoring a span is independent of where it
# occurs, so we score uniques and then attribute hits back to sentences.
span_positions: dict[str, tuple[str, str]] = {}
for spk, sent in sentences_of(text):
for run in HAN.findall(sent):
for w in widths:
for i in range(len(run) - w + 1):
span_positions.setdefault(run[i:i + w], (spk, sent))
span_keys: dict[str, tuple[str, list]] = {}
for span in span_positions:
syls = to_syls(span)
if syls:
span_keys[span] = (phon_key(syls), syls)
findings: dict[tuple, dict] = {}
def consider(span, cand, syls):
# Already the canonical name: nothing to say.
if span == cand.canonical:
return
rule = cand.rule
if span == cand.form:
# Exact hit on a non-canonical form. Previously this returned early —
# which is precisely why 老万 (an exact card alias of 万亚坤) and 迈威
# produced no output at all and the drafting model had to guess.
sc = 1.0
else:
if abs(len(span) - len(cand.form)) > 1:
return
if rule == "alias":
return # curated aliases match exactly or not at all
if rule == "honorific":
# 老X / X总 / X博. The particle carries no identity information, so
# it must match *literally*; allowing it to drift turned this rule
# into a generic 2-syllable fuzzy match and produced 想谈→谭凯,
# 理解→李光辉, 就动→刘冀 — 36 junk flags on one transcript.
if len(span) != len(cand.form):
return
if cand.anchor > 0:
if span[0] != cand.form[0]:
return
a, b = span[1:], cand.form[1:]
elif cand.anchor < 0:
if span[-1] != cand.form[-1]:
return
a, b = span[:-1], cand.form[:-1]
else:
return
sc = seq_score(
to_syls(a), to_syls(b),
personal=(cand.kind in ("人名", "公司", "项目")
and cand.canonical in in_play))
if sc < 0.86:
return
else:
sc = seq_score(
syls, cand.syls,
personal=(cand.kind in ("人名", "公司", "项目")
and cand.canonical in in_play))
if sc < RULE_MIN.get(rule, min_score):
return
spk, sent = span_positions[span]
key = (span, cand.canonical)
prev = findings.get(key)
# On a tie, keep the curated alias rather than the phonetic match: both score
# 1.0 for 万金→万津, and losing the alias provenance downgraded a confirmed
# mapping back into a guess.
better = prev is None or sc > prev["score"] or (
sc == prev["score"] and rule == "alias" and prev.get("rule") != "alias")
if better:
findings[key] = {
"span": span, "target": cand.canonical, "kind": cand.kind,
"score": round(sc, 3), "via": cand.form, "speaker": spk,
"sentence": sent, "common_word": span in allowlist,
"source": cand.source, "rule": rule, "owner": cand.owner,
"role": cand.role,
}
# 1. exact skeleton collisions — the high-confidence homophone class
for span, (k, syls) in span_keys.items():
for cand in by_key.get(k, ()):
consider(span, cand, syls)
# 2. near-miss skeletons via rapidfuzz shortlist (handles one-syllable slips
# like 李小雨/李晓宇 that the folded key alone does not merge)
uniq_keys = sorted({k for k, _ in span_keys.values()})
matches = rf_process.cdist(uniq_keys, keys, workers=-1, score_cutoff=78)
shortlist: dict[str, list[str]] = {}
for row_i, row in enumerate(matches):
hits = [keys[j] for j, v in enumerate(row) if v]
if hits:
shortlist[uniq_keys[row_i]] = hits
for span, (k, syls) in span_keys.items():
for tk in shortlist.get(k, ()):
if tk == k:
continue
for cand in by_key[tk]:
consider(span, cand, syls)
# 3. single-character substitution on equal-length names. 张泰海/张成海 scores
# 0.82 phonetically (tai vs cheng is a poor match) yet is obviously the same
# person: the surname and the tail are identical and only one syllable moved.
# Anchoring on shared characters rather than sound catches the whole class.
by_len: dict[int, list[Cand]] = {}
for c in active:
# Canonical names only. Aliases can be arbitrary phrases, and running
# single-character substitution against one produced 我老板/跟老板/那老板/
# 的老板 → 邹洵 (18 junk flags from the alias 林老板 alone).
if c.rule == "full" and c.kind in ("人名", "公司"):
by_len.setdefault(len(c.form), []).append(c)
for span, (k, syls) in span_keys.items():
if len(span) < 3:
continue
for cand in by_len.get(len(span), ()):
same = sum(1 for a, b in zip(span, cand.form) if a == b)
if same != len(span) - 1 or span == cand.form:
continue
# The head must hold. ASR drifts inside a name (张成海→张泰海); a name
# whose first character changed is a different name.
if span[0] != cand.form[0]:
continue
spk, sent = span_positions[span]
key = (span, cand.canonical)
if key not in findings:
findings[key] = {
"span": span, "target": cand.canonical, "kind": cand.kind,
"score": RULE_MIN["one-off"], "via": cand.form, "speaker": spk,
"sentence": sent, "common_word": span in allowlist,
"source": cand.source, "rule": "one-off", "owner": cand.owner,
"role": cand.role,
}
# Group alternatives for the same span so the model sees a real choice set,
# which is the whole point: "这里听到 X,可能是 A 或 B,按句判".
# 4. intra-document consistency. Once a target is firmly established somewhere
# in the transcript, its *other* spellings in the same transcript can be
# accepted at a lower bar — the document itself is the evidence. This is the
# 万金/万津/万杰 pattern: one ASR name drifts across several spellings, and
# only the strongest of them clears the standalone threshold.
firm: dict[str, set[str]] = {}
firm_alias: set[str] = set() # targets established by a confirmed alias
entity_weights = entity_weights or {}
for f in findings.values():
if f["rule"] == "alias" and str(f.get("source", "")).startswith("alias-"):
firm_alias.add(f["target"])
if f["rule"] == "alias" or f["score"] >= 0.95:
# Every established surface form is a comparison anchor, the canonical
# name included. Keeping only the first one made 万杰 unreachable because
# the anchor happened to be 老万 rather than 万津.
firm.setdefault(f["target"], {f["target"]}).add(f["via"])
# Shortlist by phonetic skeleton before running the DP, same trick as pass 2:
# brute-forcing every span against every anchor pushed S1 from 4.7s to 24.5s on
# a 27k transcript.
anchor_keys: dict[str, list[tuple[str, str, list]]] = {}
for target, vias in firm.items():
for v in vias:
anchor_keys.setdefault(phon_key(to_syls(v)), []).append((target, v, to_syls(v)))
if anchor_keys:
akeys = list(anchor_keys)
cm = rf_process.cdist(uniq_keys, akeys, workers=-1, score_cutoff=65)
near: dict[str, list[str]] = {}
for i, row in enumerate(cm):
hits = [akeys[j] for j, v in enumerate(row) if v]
if hits:
near[uniq_keys[i]] = hits
else:
near = {}
plan: dict[str, set[str]] = {}
for span, (k, syls) in span_keys.items():
for ak in near.get(k, ()):
for target, _v, _vs in anchor_keys[ak]:
plan.setdefault(target, set()).add(span)
for target, vias in firm.items():
anchors = [(v, to_syls(v)) for v in vias]
for span in plan.get(target, ()):
k, syls = span_keys[span]
if span == target or (span, target) in findings or span in allowlist:
continue
if len(span) < 2:
continue
best = max(((seq_score(syls, vs), v) for v, vs in anchors
if len(v) == len(span)), default=(0.0, ""))
sc, via = best
if not via or sc < 0.80:
continue
# Below near-homophone level, demand a shared head. A 2-syllable span at
# 0.82 means roughly one syllable matched, which let 总总/全中→邹洵 and
# 接商→宝山基金 through. Either the sound is nearly identical (军实/君实,
# 1.0) or the first character is stable (万杰/万津, 白发/白帆).
if sc < 0.90 and span[0] != via[0]:
continue
spk, sent = span_positions[span]
src = next((c for c in active if c.canonical == target), None)
findings[(span, target)] = {
"span": span, "target": target, "kind": src.kind if src else "人名",
"score": round(sc, 3), "via": via, "speaker": spk, "sentence": sent,
"common_word": False,
"source": "consistency-alias" if target in firm_alias else "consistency",
"rule": "consistency", "owner": src.owner if src else "",
"role": src.role if src else "",
}
# Which organisations are actually being discussed. An honorific like 老万 has
# one candidate per matching surname; the affiliation is what decides between
# them, and the vault already records it (万亚坤.company = 洛启生物, which is
# in play here). This is the cross-referencing step that was missing.
mentioned_orgs = set()
for c in active:
if c.kind == "公司" and (c.canonical in text or (c.form and c.form in text)):
mentioned_orgs.add(c.canonical)
grouped: dict[str, dict] = {}
for f in findings.values():
g = grouped.setdefault(f["span"], {
"span": f["span"], "common_word": f["common_word"],
"sentence": f["sentence"], "speaker": f["speaker"], "candidates": [],
})
owner = f.get("owner") or ""
support = []
if owner and owner in mentioned_orgs:
support.append(f"{owner}(本次在谈)")
elif owner and owner in in_play:
support.append(f"{owner}(近期相关)")
if f.get("role"):
support.append(f["role"])
recent = float(entity_weights.get(f["target"], 0.0))
context_boost = min(1.5, math.log1p(max(0.0, recent)) / 2.5)
affiliation_boost = 2.0 if (owner and owner in mentioned_orgs) else 0.0
boost = affiliation_boost + context_boost
if recent >= 0.5:
support.append(f"近期权重{recent:.1f}")
g["candidates"].append({"target": f["target"], "kind": f["kind"],
"score": f["score"], "via": f["via"],
"source": f["source"], "rule": f.get("rule", "full"),
"owner": owner, "support": "、".join(support),
"boost": round(boost, 3),
"context_weight": round(recent, 3)})
for g in grouped.values():
# Affiliation evidence outranks raw phonetic score: a same-surname person
# from an unrelated company is a worse guess than a slightly weaker match
# from the company under discussion.
g["candidates"].sort(
key=lambda c: (-(c["score"] + 0.20 * c["boost"]), -c["score"]))
# A single, unambiguous, human-confirmed mapping is not a suggestion. Kai's
# reaction to a hedged draft was "肯定是指 李光辉 啊" — treating these as
# optional is what makes the output feel useless. Ambiguous ones (徐总 has
# three possible people) deliberately stay a choice set.
c0 = g["candidates"][0]
src0 = str(c0.get("source", ""))
settled = (c0["rule"] == "alias"
and src0.startswith(("alias-card", "alias-learned", "alias-glossary")))
# A perfect homophone of an entity already pinned down by a confirmed alias,
# in the same recording, is not a separate judgement call: 老善 and 老尚 are
# one word the ASR spelled two ways, and 老尚 is confirmed to be 单永强.
settled = settled or (c0["rule"] == "consistency"
and src0 == "consistency-alias" and c0["score"] >= 0.97)
# Curated evidence outranks generated guesses. 老万 is an explicit card alias
# of 万亚坤; the rival candidates are only honorific forms auto-derived from
# other 万/王-surnamed people, which is far weaker. But two *curated* targets
# (徐总 → 徐劲前 or 邹洵) is real ambiguity and must stay a choice.
n_alias = sum(1 for c in g["candidates"]
if c["rule"] == "alias"
and str(c.get("source", "")).startswith("alias-"))
g["settled"] = bool(settled and n_alias <= 1
and (len(g["candidates"]) == 1 or n_alias == 1))
return sorted(grouped.values(),
key=lambda g: (g["common_word"], -g["candidates"][0]["boost"],
-g["candidates"][0]["score"]))
def analyze(text: str, *, use_hindsight=True, use_termbank=True, min_score=None,
extra_in_play: set[str] | None = None):
"""Flag candidates.
`extra_in_play` carries entity names recovered by the S1c keyword recall. It
matters most for backends that emit no speaker labels: with no participants to
seed from, the co-occurrence graph produces almost nothing, and vault entities
could never enter the candidate space at all.
"""
t0 = time.time()
min_score = CFG.flag_min_score if min_score is None else min_score
pool = load_pool(use_termbank=use_termbank)
allow = load_allowlist()
speakers = {s for s, _ in sentences_of(text) if s and HAN_FULL.match(s)}
extra = {n for n in (extra_in_play or set()) if _is_entity_name(n)}
in_play, ctx = compute_in_play(speakers, use_hindsight=use_hindsight,
extra_seed=extra)
in_play |= extra
flags = flag_text(text, pool, in_play, allow, min_score=min_score,
use_termbank_global=use_termbank,
entity_weights=ctx.get("weight", {}))
return {
"elapsed": round(time.time() - t0, 2),
"pool_size": len(pool),
"speakers": sorted(speakers),
"in_play": sorted(in_play),
"hubs": ctx["hubs"],
"hindsight_names": ctx["hindsight_names"],
"hindsight_texts": ctx["hindsight_texts"],
"flags": flags,
}
RULE_LABEL = {
"alias": "已确认别名",
"full": "整名近音",
"org-short": "机构简称",
"given": "去姓称名",
"honorific": "称谓",
"one-off": "一字之差",
"consistency": "同篇另一写法",
"observed": "历史误识别近音",
}
def render_for_prompt(flags: list[dict], limit=60) -> str:
"""The block injected into an S2 chunk prompt.
Each candidate carries *why* it is a candidate (derivation rule) and *what
supports it* (the affiliation the vault records, and whether that organisation
is being discussed in this very recording). Without that, the model can only
compare sounds, and it hedged on 老万 even though the vault says 万亚坤 is
洛启生物's 实控人 and 洛启 is the subject of the call.
"""
lines = []
must, maybe = [], []
for g in flags[:limit]:
parts = []
for c in g["candidates"][:3]:
tag = RULE_LABEL.get(c.get("rule", "full"), "")
sup = f",{c['support']}" if c.get("support") else ""
parts.append(f"{c['target']}({c['kind']}·{tag}{sup})")
cands = "、".join(parts)
if not g["common_word"]:
warn = ""
elif g["candidates"][0].get("rule") == "alias":
# Both a common word and a confirmed alias (光辉, 公司→工时). The mapping
# is real, so the instruction is to check usage rather than default to
# keeping the original.
warn = "【此词也是常用词:先判断句中是否确实在指人/机构,是则用全名,否则保留原词】"
else:
warn = "【注意:此词本身是常用词,除非句子读不通否则保留原词】"
sent = re.sub(r"\s+", "", g["sentence"])
if len(sent) > 70:
idx = sent.find(g["span"])
lo = max(0, idx - 26)
sent = ("…" if lo else "") + sent[lo:idx + len(g["span"]) + 26] + "…"
if g.get("settled") and not g["common_word"]:
must.append(f"- 「{g['span']}」= {g['candidates'][0]['target']}"
f"|原句: {sent}")
else:
maybe.append(f"- 「{g['span']}」→ 候选: {cands}{warn}|原句: {sent}")
out = []
if must:
out.append("【A. 已确认映射:Kai 已核对过,出现即按右侧替换,不要标疑似】\n"
+ "\n".join(must))
if maybe:
out.append("【B. 待判断候选:按所在句子自行裁决】\n" + "\n".join(maybe))
return "\n\n".join(out)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("transcripts", nargs="+")
ap.add_argument("--no-hindsight", action="store_true")
ap.add_argument("--no-termbank", action="store_true")
ap.add_argument("--min-score", type=float, default=0.86)
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
for p in a.transcripts:
text = Path(p).read_text(errors="ignore")
r = analyze(text, use_hindsight=not a.no_hindsight,
use_termbank=not a.no_termbank, min_score=a.min_score)
if a.json:
print(json.dumps(r, ensure_ascii=False, indent=2))
continue
print("=" * 78)
print(f"{Path(p).parent.name} {r['elapsed']}s pool={r['pool_size']} "
f"in_play={len(r['in_play'])} hs={len(r['hindsight_names'])} "
f"flags={len(r['flags'])}")
print(f"speakers: {'、'.join(r['speakers']) or '(none)'} hubs: {'、'.join(r['hubs'])}")
print(render_for_prompt(r["flags"], 25))