#!/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 WHERE card <> ''") 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 # ------------------------------------------------------------------ Web UI CRUD def list_aliases(q: str = "", origin_filter: str = "", limit: int = 200) -> list[dict]: con = connect() sql = "SELECT * FROM alias WHERE 1=1" args: list = [] if q.strip(): pat = f"%{q.strip()}%" sql += " AND (alias LIKE ? OR canonical LIKE ? OR note LIKE ?)" args.extend([pat, pat, pat]) if origin_filter == "learned": sql += " AND origin <> 'card'" elif origin_filter == "card": sql += " AND origin = 'card'" sql += " ORDER BY confirmed DESC, created_at DESC, alias ASC LIMIT ?" args.append(limit) rows = [dict(r) for r in con.execute(sql, args).fetchall()] con.close() return rows def update_alias( old_alias: str, old_canonical: str, new_alias: str, new_canonical: str, *, kind: str = "", confirmed: int = 1, note: str = "", ) -> bool: con = connect() row = con.execute( "SELECT * FROM alias WHERE alias=? AND canonical=?", (old_alias, old_canonical), ).fetchone() origin = row["origin"] if row else "manual" hits = row["hits"] if row else 0 created_at = row["created_at"] if row else time.time() if not kind: er = con.execute("SELECT kind FROM entity WHERE canonical=?", (new_canonical,)).fetchone() kind = er["kind"] if er else (row["kind"] if row else guess_kind(new_canonical)) con.execute( "DELETE FROM alias WHERE alias=? AND canonical=?", (old_alias, old_canonical), ) con.execute( "INSERT OR REPLACE INTO alias(alias,canonical,kind,origin,confirmed,hits,created_at,note) " "VALUES(?,?,?,?,?,?,?,?)", (new_alias, new_canonical, kind, origin, int(bool(confirmed)), hits, created_at, note), ) con.commit() con.close() return True def delete_alias(alias: str, canonical: str) -> bool: con = connect() cur = con.execute("DELETE FROM alias WHERE alias=? AND canonical=?", (alias, canonical)) con.commit() deleted = cur.rowcount > 0 con.close() return deleted def list_corrections(q: str = "", limit: int = 200) -> list[dict]: con = connect() sql = "SELECT * FROM correction WHERE 1=1" args: list = [] if q.strip(): pat = f"%{q.strip()}%" sql += " AND (surface LIKE ? OR canonical LIKE ? OR note LIKE ?)" args.extend([pat, pat, pat]) sql += " ORDER BY confirmed DESC, n DESC, last_seen DESC LIMIT ?" args.append(limit) rows = [dict(r) for r in con.execute(sql, args).fetchall()] con.close() return rows def update_correction( old_surface: str, old_canonical: str, new_surface: str, new_canonical: str, *, confirmed: int = 1, note: str = "", ) -> bool: con = connect() row = con.execute( "SELECT * FROM correction WHERE surface=? AND canonical=?", (old_surface, old_canonical), ).fetchone() n = row["n"] if row else 1 now = time.time() con.execute("DELETE FROM correction WHERE surface=? AND canonical=?", (old_surface, old_canonical)) con.execute( "DELETE FROM phonetic_confusion WHERE heard=? AND intended=?", (old_surface, old_canonical), ) con.execute( "INSERT OR REPLACE INTO correction(surface,canonical,n,confirmed,last_seen,note) " "VALUES(?,?,?,?,?,?)", (new_surface, new_canonical, n, int(bool(confirmed)), now, note), ) con.execute( "INSERT OR REPLACE INTO phonetic_confusion(heard,intended,n,confirmed,last_seen,note) " "VALUES(?,?,?,?,?,?)", (new_surface, new_canonical, n, int(bool(confirmed)), now, note), ) con.commit() con.close() return True def delete_correction(surface: str, canonical: str) -> bool: con = connect() cur = con.execute("DELETE FROM correction WHERE surface=? AND canonical=?", (surface, canonical)) con.execute("DELETE FROM phonetic_confusion WHERE heard=? AND intended=?", (surface, canonical)) con.commit() deleted = cur.rowcount > 0 con.close() return deleted def list_entities(q: str = "", kind: str = "", limit: int = 200) -> list[dict]: con = connect() sql = "SELECT * FROM entity WHERE 1=1" args: list = [] if q.strip(): pat = f"%{q.strip()}%" sql += " AND (canonical LIKE ? OR company LIKE ? OR role LIKE ? OR related LIKE ?)" args.extend([pat, pat, pat, pat]) if kind.strip(): sql += " AND kind=?" args.append(kind.strip()) sql += " ORDER BY (CASE WHEN card='' THEN 0 ELSE 1 END), canonical ASC LIMIT ?" args.append(limit) rows = [dict(r) for r in con.execute(sql, args).fetchall()] con.close() return rows def upsert_entity( canonical: str, kind: str = "人名", *, company: str = "", role: str = "", related: str = "", card: str = "", ) -> None: con = connect() con.execute( "INSERT OR REPLACE INTO entity(canonical,kind,company,role,related,card,synced_at) " "VALUES(?,?,?,?,?,?,?)", (canonical, kind or guess_kind(canonical), company, role, related, card, time.time()), ) con.commit() con.close() def update_entity( old_canonical: str, new_canonical: str, kind: str, *, company: str = "", role: str = "", related: str = "", ) -> bool: con = connect() row = con.execute("SELECT * FROM entity WHERE canonical=?", (old_canonical,)).fetchone() card = row["card"] if row else "" con.execute("DELETE FROM entity WHERE canonical=?", (old_canonical,)) con.execute( "INSERT OR REPLACE INTO entity(canonical,kind,company,role,related,card,synced_at) " "VALUES(?,?,?,?,?,?,?)", (new_canonical, kind or guess_kind(new_canonical), company, role, related, card, time.time()), ) if old_canonical != new_canonical: con.execute("UPDATE alias SET canonical=? WHERE canonical=?", (new_canonical, old_canonical)) con.execute("UPDATE correction SET canonical=? WHERE canonical=?", (new_canonical, old_canonical)) con.commit() con.close() return True def delete_entity(canonical: str) -> bool: con = connect() cur = con.execute("DELETE FROM entity WHERE canonical=?", (canonical,)) con.commit() deleted = cur.rowcount > 0 con.close() return deleted def list_relations(q: str = "", limit: int = 200) -> list[dict]: con = connect() sql = "SELECT * FROM relation WHERE 1=1" args: list = [] if q.strip(): pat = f"%{q.strip()}%" sql += " AND (subject LIKE ? OR predicate LIKE ? OR object LIKE ? OR source LIKE ?)" args.extend([pat, pat, pat, pat]) sql += " ORDER BY active DESC, confidence DESC, last_seen DESC LIMIT ?" args.append(limit) rows = [dict(r) for r in con.execute(sql, args).fetchall()] con.close() return rows def update_relation( old_subject: str, old_predicate: str, old_object: str, old_source: str, subject: str, predicate: str, object_: str, *, confidence: float = 0.8, active: int = 1, ) -> bool: con = connect() row = con.execute( "SELECT * FROM relation WHERE subject=? AND predicate=? AND object=? AND source=?", (old_subject, old_predicate, old_object, old_source), ).fetchone() first_seen = row["first_seen"] if row else time.time() source = old_source or "manual" con.execute( "DELETE FROM relation WHERE subject=? AND predicate=? AND object=? AND source=?", (old_subject, old_predicate, old_object, old_source), ) con.execute( "INSERT OR REPLACE INTO relation(subject,predicate,object,confidence,source,first_seen,last_seen,active) " "VALUES(?,?,?,?,?,?,?,?)", (subject, predicate, object_, float(confidence), source, first_seen, time.time(), int(bool(active))), ) con.commit() con.close() return True def delete_relation(subject: str, predicate: str, object_: str, source: str) -> bool: con = connect() cur = con.execute( "DELETE FROM relation WHERE subject=? AND predicate=? AND object=? AND source=?", (subject, predicate, object_, source), ) con.commit() deleted = cur.rowcount > 0 con.close() return deleted