deploy standalone ASR pipeline on tools-pve
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user