feat(tools-pve/asr-pipeline): SQLite model config, task delete/rerefine, multi-recording review, lexicon CRUD
This commit is contained in:
@@ -1,22 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configuration for the standalone ASR post-processing pipeline and web service.
|
||||
|
||||
This program has **no dependency on Claude Code, opencode, or any agent CLI**.
|
||||
It talks to LiteLLM / model providers over plain HTTPS and does its own file
|
||||
and git handling, so it can run inside Docker (via Arcane), systemd, or a shell.
|
||||
|
||||
Everything is overridable by environment variable.
|
||||
Model endpoints, provider selections, and API keys are stored in SQLite
|
||||
(`app_setting` table in `ASR_STATE_DIR/intake.db`) so they can be managed
|
||||
dynamically from the Web UI without restarting containers. Environment variables
|
||||
serve as initial bootstrap defaults when a setting is not yet in SQLite.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
PKG = Path(__file__).resolve().parent
|
||||
ROOT = PKG.parent
|
||||
OBSIDIAN = Path(os.environ.get("ASR_WORKSPACE", str(ROOT)))
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_SETTINGS_CACHE: dict[str, str] = {}
|
||||
_SETTINGS_TS: float = 0.0
|
||||
_SETTINGS_DB_PATH: Path | None = None
|
||||
_CACHE_TTL = 1.0
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
v = os.environ.get(name)
|
||||
@@ -37,6 +46,165 @@ def _envi(name: str, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _state_db_path() -> Path:
|
||||
state_dir = Path(_env("ASR_STATE_DIR", str(ROOT / "state")))
|
||||
return state_dir / "intake.db"
|
||||
|
||||
|
||||
def _ensure_settings_table(con: sqlite3.Connection) -> None:
|
||||
con.execute("""
|
||||
CREATE TABLE IF NOT EXISTS app_setting(
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def load_db_settings(force: bool = False) -> dict[str, str]:
|
||||
"""Read `app_setting` rows from `intake.db` with a 1s in-memory cache."""
|
||||
global _SETTINGS_CACHE, _SETTINGS_TS, _SETTINGS_DB_PATH
|
||||
db_path = _state_db_path()
|
||||
now = time.time()
|
||||
with _CACHE_LOCK:
|
||||
if (
|
||||
not force
|
||||
and _SETTINGS_DB_PATH == db_path
|
||||
and (now - _SETTINGS_TS) < _CACHE_TTL
|
||||
):
|
||||
return dict(_SETTINGS_CACHE)
|
||||
if not db_path.exists():
|
||||
_SETTINGS_CACHE = {}
|
||||
_SETTINGS_TS = now
|
||||
_SETTINGS_DB_PATH = db_path
|
||||
return {}
|
||||
try:
|
||||
con = sqlite3.connect(db_path, timeout=5)
|
||||
con.row_factory = sqlite3.Row
|
||||
_ensure_settings_table(con)
|
||||
rows = con.execute("SELECT key, value FROM app_setting").fetchall()
|
||||
con.close()
|
||||
_SETTINGS_CACHE = {str(r["key"]): str(r["value"]) for r in rows}
|
||||
_SETTINGS_TS = now
|
||||
_SETTINGS_DB_PATH = db_path
|
||||
return dict(_SETTINGS_CACHE)
|
||||
except Exception:
|
||||
return dict(_SETTINGS_CACHE)
|
||||
|
||||
|
||||
def get_setting(key: str, default: str = "") -> str:
|
||||
data = load_db_settings()
|
||||
val = data.get(key)
|
||||
return val if val not in (None, "") else default
|
||||
|
||||
|
||||
def set_settings(updates: dict[str, Any]) -> None:
|
||||
"""Persist multiple settings into `intake.db` and invalidate cache."""
|
||||
global _SETTINGS_TS
|
||||
db_path = _state_db_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
con = sqlite3.connect(db_path, timeout=15)
|
||||
try:
|
||||
con.execute("PRAGMA journal_mode=WAL")
|
||||
_ensure_settings_table(con)
|
||||
for k, v in updates.items():
|
||||
if v is None:
|
||||
continue
|
||||
con.execute(
|
||||
"INSERT INTO app_setting(key, value, updated_at) VALUES(?,?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||||
(str(k), str(v), now),
|
||||
)
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
with _CACHE_LOCK:
|
||||
_SETTINGS_TS = 0.0
|
||||
if "CFG" in globals():
|
||||
for k in updates:
|
||||
CFG.__dict__.pop(f"_override_{k}", None)
|
||||
load_db_settings(force=True)
|
||||
|
||||
|
||||
def bootstrap_default_settings() -> None:
|
||||
"""Seed `app_setting` from environment variables for keys not yet stored."""
|
||||
db_path = _state_db_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
defaults = {
|
||||
"asr_provider": _env("ASR_PROVIDER", _env("MEETING_ASR_BACKEND", "qwen3cam")).lower(),
|
||||
"diarize_url": _env("ASR_DIARIZE_URL", _env("MEETING_DIARIZE_URL", "http://192.168.50.100:10099/asr")),
|
||||
"voiceprint_enroll_url": _env("ASR_VOICEPRINT_ENROLL_URL", "http://192.168.50.100:10099/voiceprints/enroll"),
|
||||
"litellm_base_url": _env("LITELLM_BASE_URL", _env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/"),
|
||||
"litellm_api_key": _env("LITELLM_API_KEY", _env("ASR_LLM_API_KEY", _env("OPENAI_API_KEY", ""))),
|
||||
"clean_model": _env("ASR_CLEAN_MODEL", "aistudio/gemini-flash-lite-latest"),
|
||||
"keyword_model": _env("ASR_KEYWORD_MODEL", "aistudio/gemini-flash-lite-latest"),
|
||||
"draft_model": _env("ASR_DRAFT_MODEL", "codex/gpt-sol-latest"),
|
||||
"gemini_asr_model": _env("ASR_GEMINI_MODEL", "aistudio/gemini-3.5-transcribe"),
|
||||
"openai_asr_model": _env("ASR_OPENAI_MODEL", "openai/gpt-4o-transcribe-diarize"),
|
||||
"soniox_api_base": _env("SONIOX_API_BASE", "https://api.soniox.com/v1").rstrip("/"),
|
||||
"soniox_api_key": _env("SONIOX_API_KEY", ""),
|
||||
"soniox_model": _env("SONIOX_MODEL", "stt-async-v5"),
|
||||
"grok_api_base": _env("XAI_API_BASE", "https://api.x.ai/v1").rstrip("/"),
|
||||
"grok_api_key": _env("XAI_API_KEY", _env("GROK_API_KEY", "")),
|
||||
"grok_asr_model": _env("ASR_GROK_MODEL", "grok-voice-transcribe-2.0"),
|
||||
"hindsight_api": _env("HINDSIGHT_API", "http://192.168.50.47:8888").rstrip("/"),
|
||||
"hindsight_bank": _env("HINDSIGHT_BANK", "personal-context"),
|
||||
"use_hindsight": "1" if _env("ASR_HINDSIGHT", "1") != "0" else "0",
|
||||
"use_termbank": "1" if _env("ASR_TERMBANK", "1") != "0" else "0",
|
||||
"git_sync": "1" if _env("ASR_GIT_SYNC", "0") != "0" else "0",
|
||||
}
|
||||
con = sqlite3.connect(db_path, timeout=15)
|
||||
try:
|
||||
con.execute("PRAGMA journal_mode=WAL")
|
||||
_ensure_settings_table(con)
|
||||
for k, v in defaults.items():
|
||||
con.execute(
|
||||
"INSERT OR IGNORE INTO app_setting(key, value, updated_at) VALUES(?,?,?)",
|
||||
(k, str(v), now),
|
||||
)
|
||||
# If key was seeded empty earlier but env now provides a non-empty secret, populate it
|
||||
if v and k.endswith("_api_key"):
|
||||
con.execute(
|
||||
"UPDATE app_setting SET value=?, updated_at=? WHERE key=? AND value=''",
|
||||
(str(v), now, k),
|
||||
)
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
load_db_settings(force=True)
|
||||
|
||||
|
||||
class _SettingAttr:
|
||||
"""Descriptor that resolves from instance override -> SQLite `app_setting` -> env fallback."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
fallback: Callable[[], str],
|
||||
transform: Callable[[str], Any] | None = None,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.fallback = fallback
|
||||
self.transform = transform
|
||||
self.override_attr = f"_override_{key}"
|
||||
|
||||
def __get__(self, instance: Any, owner: Any = None) -> Any:
|
||||
if instance is None:
|
||||
return self
|
||||
if self.override_attr in instance.__dict__:
|
||||
return instance.__dict__[self.override_attr]
|
||||
raw = get_setting(self.key, self.fallback())
|
||||
return self.transform(raw) if self.transform else raw
|
||||
|
||||
def __set__(self, instance: Any, value: Any) -> None:
|
||||
instance.__dict__[self.override_attr] = value
|
||||
|
||||
def __delete__(self, instance: Any) -> None:
|
||||
instance.__dict__.pop(self.override_attr, None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
# ---- paths ----------------------------------------------------------
|
||||
@@ -46,61 +214,130 @@ class Config:
|
||||
_env("ASR_WORKSPACE", str(ROOT))))
|
||||
root: Path = ROOT
|
||||
|
||||
# ---- LiteLLM / LLM routing (ZenMux deprecated) ----------------------
|
||||
litellm_base_url: str = field(default_factory=lambda: _env(
|
||||
"LITELLM_BASE_URL",
|
||||
_env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/"))
|
||||
# ---- SQLite-backed dynamic model & provider settings ----------------
|
||||
litellm_base_url = _SettingAttr(
|
||||
"litellm_base_url",
|
||||
lambda: _env("LITELLM_BASE_URL", _env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")),
|
||||
lambda s: s.rstrip("/"),
|
||||
)
|
||||
clean_model = _SettingAttr(
|
||||
"clean_model",
|
||||
lambda: _env("ASR_CLEAN_MODEL", "aistudio/gemini-flash-lite-latest"),
|
||||
)
|
||||
draft_model = _SettingAttr(
|
||||
"draft_model",
|
||||
lambda: _env("ASR_DRAFT_MODEL", "codex/gpt-sol-latest"),
|
||||
)
|
||||
keyword_model = _SettingAttr(
|
||||
"keyword_model",
|
||||
lambda: _env("ASR_KEYWORD_MODEL", "aistudio/gemini-flash-lite-latest"),
|
||||
)
|
||||
|
||||
clean_model: str = field(default_factory=lambda: _env(
|
||||
"ASR_CLEAN_MODEL", "aistudio/gemini-flash-lite-latest"))
|
||||
draft_model: str = field(default_factory=lambda: _env(
|
||||
"ASR_DRAFT_MODEL", "codex/gpt-sol-latest"))
|
||||
keyword_model: str = field(default_factory=lambda: _env(
|
||||
"ASR_KEYWORD_MODEL", "aistudio/gemini-flash-lite-latest"))
|
||||
asr_provider = _SettingAttr(
|
||||
"asr_provider",
|
||||
lambda: _env("ASR_PROVIDER", _env("MEETING_ASR_BACKEND", "qwen3cam")),
|
||||
lambda s: s.strip().lower() or "qwen3cam",
|
||||
)
|
||||
diarize_url = _SettingAttr(
|
||||
"diarize_url",
|
||||
lambda: _env("ASR_DIARIZE_URL", _env("MEETING_DIARIZE_URL", "http://192.168.50.100:10099/asr")),
|
||||
)
|
||||
voiceprint_enroll_url = _SettingAttr(
|
||||
"voiceprint_enroll_url",
|
||||
lambda: _env("ASR_VOICEPRINT_ENROLL_URL", "http://192.168.50.100:10099/voiceprints/enroll"),
|
||||
)
|
||||
|
||||
endpoint: str = field(default_factory=lambda: _env(
|
||||
"ASR_ENDPOINT",
|
||||
_env("LITELLM_BASE_URL",
|
||||
_env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/")
|
||||
+ "/chat/completions"))
|
||||
transcriptions_endpoint: str = field(default_factory=lambda: _env(
|
||||
"ASR_TRANSCRIPTIONS_ENDPOINT",
|
||||
_env("LITELLM_BASE_URL",
|
||||
_env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/")
|
||||
+ "/audio/transcriptions"))
|
||||
soniox_api_key = _SettingAttr(
|
||||
"soniox_api_key",
|
||||
lambda: _env("SONIOX_API_KEY", ""),
|
||||
)
|
||||
soniox_api_base = _SettingAttr(
|
||||
"soniox_api_base",
|
||||
lambda: _env("SONIOX_API_BASE", "https://api.soniox.com/v1"),
|
||||
lambda s: s.rstrip("/"),
|
||||
)
|
||||
soniox_model = _SettingAttr(
|
||||
"soniox_model",
|
||||
lambda: _env("SONIOX_MODEL", "stt-async-v5"),
|
||||
)
|
||||
|
||||
gemini_asr_model = _SettingAttr(
|
||||
"gemini_asr_model",
|
||||
lambda: _env("ASR_GEMINI_MODEL", "aistudio/gemini-3.5-transcribe"),
|
||||
)
|
||||
openai_asr_model = _SettingAttr(
|
||||
"openai_asr_model",
|
||||
lambda: _env("ASR_OPENAI_MODEL", "openai/gpt-4o-transcribe-diarize"),
|
||||
)
|
||||
|
||||
grok_api_key = _SettingAttr(
|
||||
"grok_api_key",
|
||||
lambda: _env("XAI_API_KEY", _env("GROK_API_KEY", "")),
|
||||
)
|
||||
grok_api_base = _SettingAttr(
|
||||
"grok_api_base",
|
||||
lambda: _env("XAI_API_BASE", "https://api.x.ai/v1"),
|
||||
lambda s: s.rstrip("/"),
|
||||
)
|
||||
grok_asr_model = _SettingAttr(
|
||||
"grok_asr_model",
|
||||
lambda: _env("ASR_GROK_MODEL", "grok-voice-transcribe-2.0"),
|
||||
)
|
||||
|
||||
hindsight_api = _SettingAttr(
|
||||
"hindsight_api",
|
||||
lambda: _env("HINDSIGHT_API", "http://192.168.50.47:8888"),
|
||||
lambda s: s.rstrip("/"),
|
||||
)
|
||||
hindsight_bank = _SettingAttr(
|
||||
"hindsight_bank",
|
||||
lambda: _env("HINDSIGHT_BANK", "personal-context"),
|
||||
)
|
||||
use_hindsight = _SettingAttr(
|
||||
"use_hindsight",
|
||||
lambda: _env("ASR_HINDSIGHT", "1"),
|
||||
lambda s: str(s).lower() not in ("0", "false", "no", "off"),
|
||||
)
|
||||
use_termbank = _SettingAttr(
|
||||
"use_termbank",
|
||||
lambda: _env("ASR_TERMBANK", "1"),
|
||||
lambda s: str(s).lower() not in ("0", "false", "no", "off"),
|
||||
)
|
||||
git_sync = _SettingAttr(
|
||||
"git_sync",
|
||||
lambda: _env("ASR_GIT_SYNC", "1"),
|
||||
lambda s: str(s).lower() not in ("0", "false", "no", "off"),
|
||||
)
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
if "_override_endpoint" in self.__dict__:
|
||||
return self.__dict__["_override_endpoint"]
|
||||
explicit = os.environ.get("ASR_ENDPOINT")
|
||||
if explicit:
|
||||
return explicit
|
||||
return f"{self.litellm_base_url}/chat/completions"
|
||||
|
||||
@endpoint.setter
|
||||
def endpoint(self, value: str) -> None:
|
||||
self.__dict__["_override_endpoint"] = value
|
||||
|
||||
@property
|
||||
def transcriptions_endpoint(self) -> str:
|
||||
if "_override_transcriptions_endpoint" in self.__dict__:
|
||||
return self.__dict__["_override_transcriptions_endpoint"]
|
||||
explicit = os.environ.get("ASR_TRANSCRIPTIONS_ENDPOINT")
|
||||
if explicit:
|
||||
return explicit
|
||||
return f"{self.litellm_base_url}/audio/transcriptions"
|
||||
|
||||
@transcriptions_endpoint.setter
|
||||
def transcriptions_endpoint(self, value: str) -> None:
|
||||
self.__dict__["_override_transcriptions_endpoint"] = value
|
||||
|
||||
concurrency: int = field(default_factory=lambda: _envi("CONCURRENCY", 6))
|
||||
http_timeout: float = field(default_factory=lambda: _envf("HTTP_TIMEOUT", 420.0))
|
||||
max_retries: int = field(default_factory=lambda: _envi("MAX_RETRIES", 5))
|
||||
|
||||
# ---- ASR providers --------------------------------------------------
|
||||
asr_provider: str = field(default_factory=lambda: _env(
|
||||
"ASR_PROVIDER", _env("MEETING_ASR_BACKEND", "qwen3cam")).lower())
|
||||
diarize_url: str = field(default_factory=lambda: _env(
|
||||
"ASR_DIARIZE_URL",
|
||||
_env("MEETING_DIARIZE_URL", "http://192.168.50.100:10099/asr")))
|
||||
voiceprint_enroll_url: str = field(default_factory=lambda: _env(
|
||||
"ASR_VOICEPRINT_ENROLL_URL",
|
||||
"http://192.168.50.100:10099/voiceprints/enroll"))
|
||||
|
||||
soniox_api_key: str = field(default_factory=lambda: _env("SONIOX_API_KEY", ""))
|
||||
soniox_api_base: str = field(default_factory=lambda: _env(
|
||||
"SONIOX_API_BASE", "https://api.soniox.com/v1").rstrip("/"))
|
||||
soniox_model: str = field(default_factory=lambda: _env(
|
||||
"SONIOX_MODEL", "stt-async-v5"))
|
||||
|
||||
gemini_asr_model: str = field(default_factory=lambda: _env(
|
||||
"ASR_GEMINI_MODEL", "aistudio/gemini-3.5-transcribe"))
|
||||
openai_asr_model: str = field(default_factory=lambda: _env(
|
||||
"ASR_OPENAI_MODEL", "openai/gpt-4o-transcribe-diarize"))
|
||||
|
||||
grok_api_key: str = field(default_factory=lambda: _env(
|
||||
"XAI_API_KEY", _env("GROK_API_KEY", "")))
|
||||
grok_api_base: str = field(default_factory=lambda: _env(
|
||||
"XAI_API_BASE", "https://api.x.ai/v1").rstrip("/"))
|
||||
grok_asr_model: str = field(default_factory=lambda: _env(
|
||||
"ASR_GROK_MODEL", "grok-voice-transcribe-2.0"))
|
||||
|
||||
segment_seconds: int = field(default_factory=lambda: _envi(
|
||||
"SEGMENT_SECONDS", int(_env("MEETING_ASR_SEGMENT_SECONDS", "540"))))
|
||||
|
||||
@@ -119,10 +356,6 @@ class Config:
|
||||
default_factory=lambda: _envf("GATE_MAX_RATIO", 1.35))
|
||||
|
||||
# ---- knowledge sources ----------------------------------------------
|
||||
use_termbank: bool = field(
|
||||
default_factory=lambda: _env("ASR_TERMBANK", "1") != "0")
|
||||
use_hindsight: bool = field(
|
||||
default_factory=lambda: _env("ASR_HINDSIGHT", "1") != "0")
|
||||
use_keyword_recall: bool = field(
|
||||
default_factory=lambda: _env("ASR_KEYWORD_RECALL", "1") != "0")
|
||||
use_lexicon: bool = field(
|
||||
@@ -131,10 +364,6 @@ class Config:
|
||||
default_factory=lambda: _env("ASR_NORMALIZE", "1") != "0")
|
||||
use_evidence: bool = field(
|
||||
default_factory=lambda: _env("ASR_EVIDENCE", "1") != "0")
|
||||
hindsight_api: str = field(default_factory=lambda: _env(
|
||||
"HINDSIGHT_API", "http://192.168.50.47:8888"))
|
||||
hindsight_bank: str = field(default_factory=lambda: _env(
|
||||
"HINDSIGHT_BANK", "personal-context"))
|
||||
hindsight_timeout: float = field(
|
||||
default_factory=lambda: _envf("HINDSIGHT_TIMEOUT", 12.0))
|
||||
flag_min_score: float = field(
|
||||
@@ -154,13 +383,17 @@ class Config:
|
||||
default_factory=lambda: _envi("REVIEW_MAX_SECONDS", 7200))
|
||||
|
||||
# ---- behaviour ------------------------------------------------------
|
||||
git_sync: bool = field(
|
||||
default_factory=lambda: _env("ASR_GIT_SYNC", "1") != "0")
|
||||
notify: bool = field(
|
||||
default_factory=lambda: _env("ASR_NOTIFY", "1") != "0")
|
||||
cache_llm: bool = field(
|
||||
default_factory=lambda: _env("ASR_CACHE", "0") != "0")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Clear dataclass-populated default descriptors so __get__ is invoked dynamically
|
||||
for k in list(self.__dict__.keys()):
|
||||
if isinstance(getattr(type(self), k, None), _SettingAttr):
|
||||
del self.__dict__[k]
|
||||
|
||||
@property
|
||||
def inbox(self) -> Path:
|
||||
return self.vault / ".asr-inbox"
|
||||
@@ -217,6 +450,9 @@ class Config:
|
||||
return Path(_env("ASR_ENV_FILE", str(ROOT / ".env")))
|
||||
|
||||
def api_key(self) -> str:
|
||||
db_key = get_setting("litellm_api_key", "")
|
||||
if db_key:
|
||||
return db_key
|
||||
for k in ("LITELLM_API_KEY", "ASR_LLM_API_KEY", "OPENAI_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN"):
|
||||
v = os.environ.get(k)
|
||||
@@ -233,8 +469,7 @@ class Config:
|
||||
if val:
|
||||
return val
|
||||
raise RuntimeError(
|
||||
"no API key: set LITELLM_API_KEY / ASR_LLM_API_KEY / OPENAI_API_KEY "
|
||||
f"or provide {p}")
|
||||
"no API key: configure LiteLLM API Key in /settings or set LITELLM_API_KEY")
|
||||
|
||||
|
||||
CFG = Config()
|
||||
|
||||
@@ -298,7 +298,7 @@ def sync_from_vault(force: bool = False) -> dict:
|
||||
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 entity WHERE card <> ''")
|
||||
con.execute("DELETE FROM relation WHERE source LIKE 'card:%'")
|
||||
for sub, kind in (("People", "人名"), ("Companies", "公司"), ("Projects", "项目")):
|
||||
d = CFG.vault / sub
|
||||
@@ -524,3 +524,261 @@ def stats() -> dict:
|
||||
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
|
||||
|
||||
|
||||
@@ -307,14 +307,14 @@ def _request_revision(draft_path: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def answer(question_id: int, value: str) -> dict:
|
||||
def answer(question_id: int, value: str, *, allow_update: bool = False) -> dict:
|
||||
con = lexicon.connect()
|
||||
row = con.execute("SELECT * FROM review_question WHERE id=?",
|
||||
(question_id,)).fetchone()
|
||||
if not row:
|
||||
con.close()
|
||||
raise KeyError(f"review question {question_id} not found")
|
||||
if row["status"] != "pending":
|
||||
if row["status"] != "pending" and not allow_update:
|
||||
result = {
|
||||
"id": question_id, "answer": row["answer"], "learned": False,
|
||||
"already_answered": True,
|
||||
@@ -376,3 +376,47 @@ def answer(question_id: int, value: str) -> dict:
|
||||
"draft_feedback_saved": saved, "voiceprint_enrolled": voiceprint_enrolled,
|
||||
"revision_started": revision_started,
|
||||
}
|
||||
|
||||
|
||||
def recent_answered(limit: int = 30) -> list[dict]:
|
||||
con = lexicon.connect()
|
||||
rows = con.execute(
|
||||
"SELECT * FROM review_question WHERE status='answered' "
|
||||
"ORDER BY answered_at DESC, id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
con.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def reopen_question(question_id: int) -> bool:
|
||||
con = lexicon.connect()
|
||||
cur = con.execute(
|
||||
"UPDATE review_question SET status='pending', answer='', answered_at=NULL WHERE id=?",
|
||||
(question_id,),
|
||||
)
|
||||
con.commit()
|
||||
ok = cur.rowcount > 0
|
||||
con.close()
|
||||
return ok
|
||||
|
||||
|
||||
def delete_for_job(transcript_path: str = "", draft_path: str = "", job_id: str = "") -> int:
|
||||
con = lexicon.connect()
|
||||
rows = con.execute("SELECT id, recording, payload FROM review_question").fetchall()
|
||||
to_delete: list[int] = []
|
||||
for r in rows:
|
||||
payload = json.loads(r["payload"] or "{}")
|
||||
if (
|
||||
(transcript_path and r["recording"] == transcript_path)
|
||||
or (draft_path and payload.get("draft_path") == draft_path)
|
||||
or (job_id and payload.get("job_id") == job_id)
|
||||
):
|
||||
to_delete.append(int(r["id"]))
|
||||
if to_delete:
|
||||
marks = ",".join("?" for _ in to_delete)
|
||||
con.execute(f"DELETE FROM review_question WHERE id IN ({marks})", to_delete)
|
||||
con.commit()
|
||||
con.close()
|
||||
return len(to_delete)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user