deploy standalone ASR pipeline on tools-pve

This commit is contained in:
Codex
2026-09-18 02:32:14 -07:00
parent f30f72b295
commit b23f00f41a
38 changed files with 182773 additions and 0 deletions
@@ -0,0 +1,526 @@
#!/usr/bin/env python3
"""Deterministic entity / alias / correction lexicon (SQLite).
Design rule, decided 2026-07-25:
**Obsidian is a read-only source of truth.** Cards supply canonical names and
the `aliases:` / `company:` / `role:` frontmatter. This program pulls from them
and never writes back — no `_glossary.md` edits, no card mutation.
Everything the pipeline *learns* (a confirmed nickname like 老尚=单永强, a
recurring ASR misrecognition like 张泰海→张成海) accumulates here instead. Same
separation as the voiceprint registry: an independent structured store that
grows with use, next to the vault rather than inside it.
Why SQLite and not Hindsight: alias resolution must be an exact, repeatable
key→value lookup. Hindsight stores LLM-extracted memories and recalls them
semantically — excellent for *relationships* ("万亚坤 is 洛启's 实控人", which is
what disambiguates 老万), useless as a guarantee that 老尚 resolves every single
time. The two are complementary and both are used: lexicon decides identity,
Hindsight supplies the context that ranks competing identities.
"""
from __future__ import annotations
import re
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from .config import CFG
SCHEMA = """
CREATE TABLE IF NOT EXISTS entity (
canonical TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- 人名 / 公司
company TEXT DEFAULT '', -- affiliation, for honorific disambiguation
role TEXT DEFAULT '',
related TEXT DEFAULT '', -- related_people, comma-joined
card TEXT DEFAULT '',
synced_at REAL
);
CREATE TABLE IF NOT EXISTS alias (
alias TEXT NOT NULL,
canonical TEXT NOT NULL,
kind TEXT NOT NULL,
origin TEXT NOT NULL, -- card | learned | manual | derived
confirmed INTEGER DEFAULT 0, -- 1 = Kai said so explicitly
hits INTEGER DEFAULT 0,
created_at REAL,
note TEXT DEFAULT '',
PRIMARY KEY (alias, canonical)
);
CREATE INDEX IF NOT EXISTS alias_by_alias ON alias(alias);
CREATE TABLE IF NOT EXISTS correction (
surface TEXT NOT NULL, -- what the ASR wrote
canonical TEXT NOT NULL, -- what it should be
n INTEGER DEFAULT 1, -- times confirmed
confirmed INTEGER DEFAULT 0,
last_seen REAL,
note TEXT DEFAULT '',
PRIMARY KEY (surface, canonical)
);
CREATE TABLE IF NOT EXISTS relation (
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
confidence REAL DEFAULT 0.5,
source TEXT DEFAULT '',
first_seen REAL,
last_seen REAL,
active INTEGER DEFAULT 1,
PRIMARY KEY (subject, predicate, object, source)
);
CREATE INDEX IF NOT EXISTS relation_by_subject ON relation(subject, active, last_seen);
CREATE INDEX IF NOT EXISTS relation_by_object ON relation(object, active, last_seen);
CREATE TABLE IF NOT EXISTS interaction (
entity TEXT NOT NULL,
occurred_at REAL NOT NULL,
kind TEXT DEFAULT 'mention',
source TEXT DEFAULT '',
weight REAL DEFAULT 1.0,
PRIMARY KEY (entity, occurred_at, kind, source)
);
CREATE INDEX IF NOT EXISTS interaction_recent ON interaction(entity, occurred_at DESC);
CREATE TABLE IF NOT EXISTS entity_mention (
entity TEXT NOT NULL,
kind TEXT NOT NULL, -- 人名 / 公司
source TEXT NOT NULL, -- one formal minute; one count per source
occurred_at REAL NOT NULL,
PRIMARY KEY (entity, kind, source)
);
CREATE INDEX IF NOT EXISTS entity_mention_count ON entity_mention(entity,kind);
CREATE TABLE IF NOT EXISTS observation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
surface TEXT NOT NULL,
canonical TEXT DEFAULT '',
source TEXT DEFAULT '',
context TEXT DEFAULT '',
confidence REAL DEFAULT 0.0,
confirmed INTEGER DEFAULT 0,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS observation_surface ON observation(surface, created_at DESC);
CREATE TABLE IF NOT EXISTS phonetic_confusion (
heard TEXT NOT NULL,
intended TEXT NOT NULL,
n INTEGER DEFAULT 1,
confirmed INTEGER DEFAULT 0,
last_seen REAL,
note TEXT DEFAULT '',
PRIMARY KEY (heard, intended)
);
CREATE TABLE IF NOT EXISTS review_question (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recording TEXT NOT NULL,
question TEXT NOT NULL,
payload TEXT DEFAULT '{}',
status TEXT DEFAULT 'pending',
answer TEXT DEFAULT '',
created_at REAL NOT NULL,
answered_at REAL
);
CREATE INDEX IF NOT EXISTS review_pending ON review_question(status, created_at);
CREATE TABLE IF NOT EXISTS source_document (
source TEXT PRIMARY KEY,
signature TEXT NOT NULL,
processed_at REAL NOT NULL,
entity_count INTEGER DEFAULT 0,
relation_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
"""
HAN_FULL = re.compile(r"^[\u4e00-\u9fff]+$")
# Anything that looks like a note title or a date is not an entity. This is what
# polluted in_play: 25 of 70 slots were minutes filenames, dates and _glossary.
JUNK = re.compile(r"^\d{4}-\d{2}|^_|\.md$|[|/#]")
ORG_SUFFIX = ("生物", "医药", "制药", "药业", "基金", "资本", "投资", "科技", "集团",
"股份", "公司", "银行", "证券", "医院", "研究院", "实验室", "中心")
def guess_kind(name: str) -> str:
"""Entity type for a name with no card. Only used for display labelling."""
return "公司" if name.endswith(ORG_SUFFIX) else "人名"
@dataclass
class Entity:
canonical: str
kind: str
company: str = ""
role: str = ""
related: tuple = ()
def connect() -> sqlite3.Connection:
CFG.state_dir.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(CFG.lexicon_db)
con.row_factory = sqlite3.Row
con.executescript(SCHEMA)
# One-time compatibility migration for the 158 corrections learned before the
# personal phonetic-confusion channel existed.
con.execute(
"INSERT OR IGNORE INTO phonetic_confusion(heard,intended,n,confirmed,"
"last_seen,note) SELECT surface,canonical,n,confirmed,last_seen,note "
"FROM correction")
con.commit()
return con
# --------------------------------------------------------------- vault pull
def _fm(text: str, key: str) -> str:
# `[ \t]*` not `\s*`: \s matches newlines, so a blank `role:` field used to
# swallow the next line and report `status: active` as the role.
m = re.search(rf"^{key}:[ \t]*(.*)$", text, re.M)
return m.group(1).strip().strip("\"'") if m else ""
def _list_field(raw: str) -> list[str]:
return [x for x in re.findall(r"[^,\[\]\s,、\"']+", raw or "") if x]
def vault_signature() -> str:
import hashlib
h = hashlib.sha256()
if CFG.glossary.exists():
st = CFG.glossary.stat()
h.update(f"gl:{st.st_mtime_ns}:{st.st_size}".encode())
for sub in ("People", "Companies", "Projects"):
d = CFG.vault / sub
if not d.exists():
continue
for f in sorted(d.glob("*.md")):
st = f.stat()
h.update(f"{f.name}:{st.st_mtime_ns}:{st.st_size}".encode())
return h.hexdigest()[:16]
GL_MAP = re.compile(r"^(?:>\s*)?[-*]\s*(.+?)\s*(?:→|⇒|➔|->)\s*(.+)$")
GL_ENT = re.compile(r"^[-*]\s*([\u4e00-\u9fff]{2,6})\s*[—–]\s*(.+)$")
_PAREN = re.compile(r"[((][^))]*[))]")
def _clean_form(s: str) -> str:
return re.sub(r"\*\*|\[\[|\]\]|[`\"'「」“”]", "", s).strip()
def _split_forms(s: str) -> list[str]:
"""`胡律 / 胡丽 / 胡博士(跨境资本语境)` -> [胡律, 胡丽, 胡博士]."""
s = _PAREN.sub("", s)
return [_clean_form(x) for x in re.split(r"[/、,,]|\s+or\s+", s) if _clean_form(x)]
def _split_targets(rhs: str) -> list[tuple[str, str]]:
"""Right-hand side -> [(canonical, context_hint)].
Handles the one-to-many context-dependent rows the glossary explicitly flags,
e.g. `许博 → **许俊彦**(白帆生物语境) / **许恒皓**(愚公生物语境)`, which must stay
ambiguous: the pipeline offers both and the sentence decides.
"""
parts = re.split(r"\s*/\s*(?=\*\*)", rhs) if rhs.count("**") > 1 else [rhs]
out = []
for it in parts:
ctx = ";".join(x.strip() for x in re.findall(r"[((]([^))]*)[))]", it))
name = _clean_form(_PAREN.sub("", it)).split(";")[0].split(";")[0].strip()
name = name.split(",")[0].strip()
if name:
out.append((name, ctx))
return out
def _import_glossary(con: sqlite3.Connection, now: float) -> int:
"""Read `minutes/_glossary.md` into the lexicon. The file is never modified.
It carries ~130 hand-confirmed mappings, several of which are deliberately
one-to-many. Importing them means the pipeline stops re-deriving what Kai
already settled, while the vault keeps a single human-editable copy.
"""
gl = CFG.glossary
if not gl.exists():
return 0
n = 0
def put(alias: str, canonical: str, ctx: str, origin: str):
nonlocal n
if not (HAN_FULL.match(alias) and HAN_FULL.match(canonical)):
return
if alias == canonical or not (2 <= len(alias) <= 8) or not (2 <= len(canonical) <= 8):
return
r = con.execute("SELECT kind FROM entity WHERE canonical=?", (canonical,)).fetchone()
kind = r["kind"] if r else guess_kind(canonical)
con.execute(
"INSERT OR IGNORE INTO alias(alias,canonical,kind,origin,confirmed,"
"created_at,note) VALUES(?,?,?,?,1,?,?)",
(alias, canonical, kind, origin, now, ctx))
con.execute(
"INSERT OR IGNORE INTO correction(surface,canonical,n,confirmed,last_seen,note)"
" VALUES(?,?,1,1,?,?)", (alias, canonical, now, ctx))
n += 1
for raw in gl.read_text(errors="ignore").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = GL_MAP.match(line)
if m:
for tgt, ctx in _split_targets(m.group(2)):
for form in _split_forms(m.group(1)):
put(form, tgt, ctx, "glossary")
continue
# `- 万亚坤 — 洛启生物实控人(老万;原音 万博/王博/「平台总代理坤」)`
m = GL_ENT.match(line)
if m:
canon, desc = m.group(1), m.group(2)
for grp in re.findall(r"[((]([^))]*)[))]", desc):
for seg in re.split(r"[;;]", grp):
seg = re.sub(r"^\s*(原音|又称|简称|别名)\s*", "", seg.strip())
for form in _split_forms(seg):
put(form, canon, "", "glossary")
return n
def sync_from_vault(force: bool = False) -> dict:
"""Pull entities + card aliases from Obsidian. Read-only on the vault side.
Learned rows are preserved: only `origin='card'` aliases are refreshed, so a
nickname Kai confirmed once is never lost when a card is edited.
"""
con = connect()
sig = vault_signature()
cur = con.execute("SELECT v FROM meta WHERE k='vault_sig'").fetchone()
if not force and cur and cur["v"] == sig:
con.close()
return {"skipped": True, "sig": sig}
now = time.time()
n_ent = n_alias = 0
con.execute("DELETE FROM alias WHERE origin IN ('card','glossary')")
con.execute("DELETE FROM entity")
con.execute("DELETE FROM relation WHERE source LIKE 'card:%'")
for sub, kind in (("People", "人名"), ("Companies", "公司"), ("Projects", "项目")):
d = CFG.vault / sub
if not d.exists():
continue
for f in sorted(d.glob("*.md")):
name = f.stem
if JUNK.search(name):
continue
if kind != "项目" and not HAN_FULL.match(name):
continue
if kind == "项目" and not (2 <= len(name) <= 80):
continue
txt = f.read_text(errors="ignore")
head = txt[:1200]
company = _fm(head, "company")
related = _list_field(_fm(head, "related_people"))
con.execute(
"INSERT OR REPLACE INTO entity(canonical,kind,company,role,related,"
"card,synced_at) VALUES(?,?,?,?,?,?,?)",
(name, kind, company, _fm(head, "role"),
",".join(related), str(f), now))
if company:
con.execute(
"INSERT OR REPLACE INTO relation(subject,predicate,object,"
"confidence,source,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)",
(name, "任职于", company, 1.0, f"card:{f}", now, now))
for other in related:
con.execute(
"INSERT OR REPLACE INTO relation(subject,predicate,object,"
"confidence,source,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)",
(name, "相关", other, 0.9, f"card:{f}", now, now))
n_ent += 1
for a in _list_field(_fm(head, "aliases")):
if a == name or not HAN_FULL.match(a) or len(a) < 2:
continue
con.execute(
"INSERT OR IGNORE INTO alias(alias,canonical,kind,origin,confirmed,"
"created_at) VALUES(?,?,?,'card',1,?)", (a, name, kind, now))
n_alias += 1
# The vault glossary is *imported*, never edited. It holds ~130 corrections Kai
# already confirmed by hand; they become exact-match bindings here so the
# pipeline stops re-deriving them, and the file itself stays untouched.
n_gl = _import_glossary(con, now)
con.execute("INSERT OR REPLACE INTO meta(k,v) VALUES('vault_sig',?)", (sig,))
con.execute("INSERT OR REPLACE INTO meta(k,v) VALUES('synced_at',?)", (str(now),))
con.commit()
con.close()
return {"skipped": False, "entities": n_ent, "card_aliases": n_alias,
"glossary": n_gl, "sig": sig}
# ------------------------------------------------------------------ reading
def entities() -> dict[str, Entity]:
con = connect()
out = {r["canonical"]: Entity(
r["canonical"], r["kind"], r["company"] or "", r["role"] or "",
tuple(x for x in (r["related"] or "").split(",") if x))
for r in con.execute("SELECT * FROM entity")}
con.close()
return out
def alias_map() -> dict[str, list[tuple[str, str, str, int]]]:
"""alias -> [(canonical, kind, origin, confirmed)]. May be ambiguous."""
con = connect()
out: dict[str, list] = {}
for r in con.execute("SELECT * FROM alias ORDER BY confirmed DESC, hits DESC"):
out.setdefault(r["alias"], []).append(
(r["canonical"], r["kind"], r["origin"], r["confirmed"]))
con.close()
return out
def corrections() -> dict[str, list[tuple[str, int]]]:
con = connect()
out: dict[str, list] = {}
for r in con.execute("SELECT * FROM correction ORDER BY n DESC"):
out.setdefault(r["surface"], []).append((r["canonical"], r["n"]))
con.close()
return out
def confusion_map() -> dict[str, list[tuple[str, int, int]]]:
"""Observed ASR surface -> canonical, count, confirmation.
Older databases predate `phonetic_confusion`, so confirmed correction rows are
included as a compatibility source without a destructive migration.
"""
con = connect()
out: dict[str, list[tuple[str, int, int]]] = {}
for r in con.execute(
"SELECT surface AS heard,canonical AS intended,n,confirmed "
"FROM correction UNION ALL "
"SELECT heard,intended,n,confirmed FROM phonetic_confusion"):
item = (r["intended"], r["n"], r["confirmed"])
if item not in out.setdefault(r["heard"], []):
out[r["heard"]].append(item)
con.close()
return out
# ------------------------------------------------------------------ writing
def learn_alias(alias: str, canonical: str, *, kind: str = "", origin: str = "learned",
confirmed: int = 1, note: str = "") -> None:
con = connect()
if not kind:
r = con.execute("SELECT kind FROM entity WHERE canonical=?", (canonical,)).fetchone()
kind = r["kind"] if r else guess_kind(canonical)
con.execute(
"INSERT INTO alias(alias,canonical,kind,origin,confirmed,created_at,note)"
" VALUES(?,?,?,?,?,?,?) ON CONFLICT(alias,canonical) DO UPDATE SET"
" confirmed=max(confirmed,excluded.confirmed), origin=excluded.origin,"
" note=excluded.note",
(alias, canonical, kind, origin, confirmed, time.time(), note))
con.commit()
con.close()
def learn_correction(surface: str, canonical: str, *, confirmed: int = 1,
note: str = "") -> None:
con = connect()
con.execute(
"INSERT INTO correction(surface,canonical,n,confirmed,last_seen,note)"
" VALUES(?,?,1,?,?,?) ON CONFLICT(surface,canonical) DO UPDATE SET"
" n=n+1, confirmed=max(confirmed,excluded.confirmed), last_seen=excluded.last_seen",
(surface, canonical, confirmed, time.time(), note))
con.execute(
"INSERT INTO phonetic_confusion(heard,intended,n,confirmed,last_seen,note)"
" VALUES(?,?,1,?,?,?) ON CONFLICT(heard,intended) DO UPDATE SET"
" n=n+1,confirmed=max(confirmed,excluded.confirmed),"
" last_seen=excluded.last_seen,note=excluded.note",
(surface, canonical, confirmed, time.time(), note))
con.commit()
con.close()
def record_observation(surface: str, canonical: str = "", *, source: str = "",
context: str = "", confidence: float = 0.0,
confirmed: int = 0) -> int:
con = connect()
cur = con.execute(
"INSERT INTO observation(surface,canonical,source,context,confidence,"
"confirmed,created_at) VALUES(?,?,?,?,?,?,?)",
(surface, canonical, source, context[:1000], confidence, confirmed, time.time()))
con.commit()
row_id = int(cur.lastrowid)
con.close()
return row_id
def upsert_relation(subject: str, predicate: str, object_: str, *,
confidence: float = 0.5, source: str = "") -> None:
now = time.time()
con = connect()
con.execute(
"INSERT INTO relation(subject,predicate,object,confidence,source,first_seen,"
"last_seen) VALUES(?,?,?,?,?,?,?) ON CONFLICT(subject,predicate,object,source)"
" DO UPDATE SET confidence=max(confidence,excluded.confidence),"
" last_seen=excluded.last_seen,active=1",
(subject, predicate, object_, confidence, source, now, now))
con.commit()
con.close()
def record_interaction(entity: str, *, occurred_at: float | None = None,
kind: str = "mention", source: str = "",
weight: float = 1.0) -> None:
con = connect()
con.execute(
"INSERT OR IGNORE INTO interaction(entity,occurred_at,kind,source,weight)"
" VALUES(?,?,?,?,?)",
(entity, occurred_at or time.time(), kind, source, weight))
con.commit()
con.close()
def relationship_card(names: set[str], *, limit: int = 30) -> list[str]:
"""Return compact, recent relation facts for other applications and ASR."""
if not names:
return []
con = connect()
marks = ",".join("?" for _ in names)
rows = con.execute(
f"SELECT subject,predicate,object,max(confidence) confidence,"
f"max(last_seen) last_seen,count(*) support FROM relation "
f"WHERE active=1 AND (subject IN ({marks}) OR object IN ({marks})) "
"GROUP BY subject,predicate,object "
"ORDER BY confidence DESC,last_seen DESC,support DESC LIMIT ?",
(*names, *names, limit)).fetchall()
con.close()
return [f"{r['subject']} {r['predicate']} {r['object']}"
f"(置信度 {r['confidence']:.2f},支持 {r['support']} 次)" for r in rows]
def bump_hits(pairs) -> None:
if not pairs:
return
con = connect()
con.executemany("UPDATE alias SET hits=hits+1 WHERE alias=? AND canonical=?",
list(pairs))
con.commit()
con.close()
def stats() -> dict:
con = connect()
q = lambda s: con.execute(s).fetchone()[0]
d = {
"entities": q("SELECT count(*) FROM entity"),
"people": q("SELECT count(*) FROM entity WHERE kind='人名'"),
"companies": q("SELECT count(*) FROM entity WHERE kind='公司'"),
"with_company": q("SELECT count(*) FROM entity WHERE company<>''"),
"with_related": q("SELECT count(*) FROM entity WHERE related<>''"),
"aliases": q("SELECT count(*) FROM alias"),
"aliases_card": q("SELECT count(*) FROM alias WHERE origin='card'"),
"aliases_learned": q("SELECT count(*) FROM alias WHERE origin<>'card'"),
"corrections": q("SELECT count(*) FROM correction"),
"db": str(CFG.lexicon_db),
}
r = con.execute("SELECT v FROM meta WHERE k='synced_at'").fetchone()
d["synced_at"] = r["v"] if r else ""
con.close()
return d