diff --git a/servers/tools-pve/asr-pipeline/asrpipe/config.py b/servers/tools-pve/asr-pipeline/asrpipe/config.py index 822f940..58a72b8 100644 --- a/servers/tools-pve/asr-pipeline/asrpipe/config.py +++ b/servers/tools-pve/asr-pipeline/asrpipe/config.py @@ -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() diff --git a/servers/tools-pve/asr-pipeline/asrpipe/lexicon.py b/servers/tools-pve/asr-pipeline/asrpipe/lexicon.py index e0b0282..614240e 100644 --- a/servers/tools-pve/asr-pipeline/asrpipe/lexicon.py +++ b/servers/tools-pve/asr-pipeline/asrpipe/lexicon.py @@ -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 + diff --git a/servers/tools-pve/asr-pipeline/asrpipe/review.py b/servers/tools-pve/asr-pipeline/asrpipe/review.py index b185026..df13f61 100644 --- a/servers/tools-pve/asr-pipeline/asrpipe/review.py +++ b/servers/tools-pve/asr-pipeline/asrpipe/review.py @@ -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) + diff --git a/servers/tools-pve/asr-pipeline/asrservice/db.py b/servers/tools-pve/asr-pipeline/asrservice/db.py index 176a488..d409870 100644 --- a/servers/tools-pve/asr-pipeline/asrservice/db.py +++ b/servers/tools-pve/asr-pipeline/asrservice/db.py @@ -4,15 +4,19 @@ import sqlite3 import time from typing import Any -from .config import DB, DEFAULT_PROVIDER, ensure_dirs +from asrpipe.config import CFG, bootstrap_default_settings, get_setting, load_db_settings, set_settings +from .config import DB, ensure_dirs STATUSES = { "uploading", "queued", "transcribing", "refining", "review", "publishing", "complete", "failed", } +_BOOTSTRAPPED_DB: str = "" + def connect() -> sqlite3.Connection: + global _BOOTSTRAPPED_DB ensure_dirs() con = sqlite3.connect(DB, timeout=30) con.row_factory = sqlite3.Row @@ -37,8 +41,14 @@ def connect() -> sqlite3.Connection: status TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL ); CREATE INDEX IF NOT EXISTS ix_job_event_job ON job_event(job_id,id); + CREATE TABLE IF NOT EXISTS app_setting( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at REAL NOT NULL + ); """) cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")} + default_prov = CFG.asr_provider or "qwen3cam" additions = { "draft": "TEXT NOT NULL DEFAULT ''", "result": "TEXT NOT NULL DEFAULT ''", @@ -46,13 +56,19 @@ def connect() -> sqlite3.Connection: "sha256": "TEXT NOT NULL DEFAULT ''", "attempts": "INTEGER NOT NULL DEFAULT 0", "error_code": "TEXT NOT NULL DEFAULT ''", - "asr_provider": f"TEXT NOT NULL DEFAULT '{DEFAULT_PROVIDER}'", + "asr_provider": f"TEXT NOT NULL DEFAULT '{default_prov}'", "asr_model": "TEXT NOT NULL DEFAULT ''", } for name, ddl in additions.items(): if name not in cols: con.execute(f"ALTER TABLE intake_job ADD COLUMN {name} {ddl}") con.commit() + if _BOOTSTRAPPED_DB != str(DB): + _BOOTSTRAPPED_DB = str(DB) + try: + bootstrap_default_settings() + except Exception: + pass return con @@ -69,7 +85,7 @@ def create_job( asr_model: str = "", ) -> dict[str, Any]: now = time.time() - prov = (asr_provider or DEFAULT_PROVIDER or "qwen3cam").strip().lower() + prov = (asr_provider or CFG.asr_provider or "qwen3cam").strip().lower() with connect() as con: con.execute( "INSERT INTO intake_job(id,original_name,stored_name,title,context,size," @@ -88,7 +104,7 @@ def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> boo raise ValueError(f"invalid status: {status}") allowed = { "transcript", "draft", "result", "sha256", "attempts", - "error_code", "asr_provider", "asr_model", + "error_code", "asr_provider", "asr_model", "title", "context", } changes = {k: v for k, v in fields.items() if k in allowed and v is not None} sets = ["status=?", "updated_at=?", "detail=?"] @@ -106,6 +122,44 @@ def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> boo return bool(cur.rowcount) +def update_job_meta( + job_id: str, + *, + title: str | None = None, + context: str | None = None, + asr_provider: str | None = None, + asr_model: str | None = None, +) -> bool: + sets = ["updated_at=?"] + values: list[Any] = [time.time()] + if title is not None: + sets.append("title=?") + values.append(title[:120]) + if context is not None: + sets.append("context=?") + values.append(context[:2000]) + if asr_provider is not None: + sets.append("asr_provider=?") + values.append(asr_provider[:40]) + if asr_model is not None: + sets.append("asr_model=?") + values.append(asr_model[:80]) + values.append(job_id) + with connect() as con: + cur = con.execute(f"UPDATE intake_job SET {','.join(sets)} WHERE id=?", values) + return bool(cur.rowcount) + + +def delete_job(job_id: str) -> dict[str, Any] | None: + job = get_job(job_id) + if not job: + return None + with connect() as con: + con.execute("DELETE FROM job_event WHERE job_id=?", (job_id,)) + con.execute("DELETE FROM intake_job WHERE id=?", (job_id,)) + return job + + def get_job(job_id: str) -> dict[str, Any] | None: with connect() as con: row = con.execute("SELECT * FROM intake_job WHERE id=?", (job_id,)).fetchone() @@ -120,13 +174,27 @@ def find_by_stored(stored_name: str) -> dict[str, Any] | None: return dict(row) if row else None -def list_jobs(limit: int = 30, offset: int = 0, status: str = "") -> list[dict[str, Any]]: +def list_jobs( + limit: int = 30, + offset: int = 0, + status: str = "", + q: str = "", + provider: str = "", +) -> list[dict[str, Any]]: limit = min(max(limit, 1), 200) offset = max(offset, 0) - sql, args = "SELECT * FROM intake_job", [] + sql = "SELECT * FROM intake_job WHERE 1=1" + args: list[Any] = [] if status: - sql += " WHERE status=?" + sql += " AND status=?" args.append(status) + if provider: + sql += " AND asr_provider=?" + args.append(provider) + if q.strip(): + pat = f"%{q.strip()}%" + sql += " AND (title LIKE ? OR original_name LIKE ? OR context LIKE ? OR detail LIKE ?)" + args.extend([pat, pat, pat, pat]) sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?" args.extend((limit, offset)) with connect() as con: @@ -145,3 +213,32 @@ def pending_recovery() -> list[dict[str, Any]]: return [dict(r) for r in con.execute( "SELECT * FROM intake_job WHERE status IN ('queued','transcribing','refining','publishing') " "ORDER BY created_at").fetchall()] + + +def get_all_settings() -> dict[str, str]: + connect().close() + return load_db_settings(force=True) + + +def save_settings(updates: dict[str, Any]) -> dict[str, str]: + connect().close() + set_settings(updates) + return load_db_settings(force=True) + + +__all__ = [ + "STATUSES", + "connect", + "create_job", + "update_job", + "update_job_meta", + "delete_job", + "get_job", + "find_by_stored", + "list_jobs", + "events", + "pending_recovery", + "get_all_settings", + "save_settings", + "get_setting", +] diff --git a/servers/tools-pve/asr-pipeline/asrservice/web.py b/servers/tools-pve/asr-pipeline/asrservice/web.py index 9a71e75..46acf21 100644 --- a/servers/tools-pve/asr-pipeline/asrservice/web.py +++ b/servers/tools-pve/asr-pipeline/asrservice/web.py @@ -16,12 +16,13 @@ import uuid from http import cookies from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from typing import Any from asrpipe import graph, lexicon, providers, review, vault from asrpipe.config import CFG from . import db from .config import ( - AGENT_TOKEN, DEFAULT_PROVIDER, DRAFTS, FAILED, INBOX, MAX_UPLOAD, + AGENT_TOKEN, DRAFTS, FAILED, INBOX, MAX_UPLOAD, PROCESSED, PROCESSING, PUBLIC_URL, RESULTS, REVISION_QUEUE, STATE, SUPPORTED, TRANSCRIPTS, WEB_PASSWORD, ensure_dirs, ) @@ -37,6 +38,16 @@ LABELS = { "failed": "处理失败", } +KIND_LABELS = { + "background_check": "背景核对", + "voiceprint": "说话人身份", + "draft_question": "正文存疑", + "entity_card": "建卡建议", + "draft_overall": "整体评估", + "publish_approval": "定稿发布", + "low_confidence": "专有名词确认", +} + def safe(value: str, limit: int) -> str: return re.sub( @@ -71,6 +82,66 @@ def find_audio_file(job: dict) -> Path | None: return None +def delete_job_and_artifacts( + job_id: str, + *, + delete_files: bool = True, + delete_result: bool = False, +) -> bool: + job = db.delete_job(job_id) + if not job: + return False + review.delete_for_job( + transcript_path=str(job.get("transcript") or ""), + draft_path=str(job.get("draft") or ""), + job_id=job_id, + ) + for marker in REVISION_QUEUE.glob(f"*{job_id}*.json"): + marker.unlink(missing_ok=True) + if delete_files: + audio = find_audio_file(job) + if audio and audio.is_file(): + audio.unlink(missing_ok=True) + for p in ( + TRANSCRIPTS / f"{job_id}.txt", + TRANSCRIPTS / f"{job_id}.asr.json", + DRAFTS / f"{job_id}.md", + Path(str(job.get("transcript") or "")) if job.get("transcript") else None, + Path(str(job.get("draft") or "")) if job.get("draft") else None, + ): + if p and p.is_file(): + p.unlink(missing_ok=True) + if delete_result and job.get("result"): + rpath = Path(str(job["result"])) + if rpath.is_file(): + rpath.unlink(missing_ok=True) + return True + + +def queue_rerefine(job: dict) -> bool: + if not job.get("transcript"): + return False + REVISION_QUEUE.mkdir(parents=True, exist_ok=True) + jid = job["id"] + marker = REVISION_QUEUE / f"rerefine-{jid}.json" + tmp = REVISION_QUEUE / f".rerefine-{jid}.{os.getpid()}.tmp" + tmp.write_text( + json.dumps({"action": "rerefine", "job_id": jid, "draft": str(job.get("draft") or "")}, ensure_ascii=False), + encoding="utf-8", + ) + tmp.replace(marker) + db.update_job(jid, "refining", "已排队重新生成精修草稿(免重跑 ASR)") + return True + + +def queue_revise_draft(job: dict) -> bool: + draft_str = str(job.get("draft") or "") + if not draft_str: + return False + review._persist_draft_feedback(str(job.get("transcript") or ""), draft_str) + return review._request_revision(draft_str) + + def public(row: dict, detailed: bool = False) -> dict: keys = ( "id", "original_name", "title", "size", "status", "detail", "source", @@ -78,10 +149,11 @@ def public(row: dict, detailed: bool = False) -> dict: "created_at", "updated_at", ) out = {k: row.get(k) for k in keys} - out["asr_provider"] = out.get("asr_provider") or DEFAULT_PROVIDER + out["asr_provider"] = out.get("asr_provider") or CFG.asr_provider out["status_label"] = LABELS.get(row["status"], row["status"]) out["web_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/" out["review_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/review/" + out["pending_reviews"] = len(pending(row)) out["artifacts"] = { name: f"/api/v1/jobs/{row['id']}/artifacts/{name}" for name in ("transcript", "draft", "result") @@ -93,19 +165,52 @@ def public(row: dict, detailed: bool = False) -> dict: return out +def _match_question_to_job(row: dict, job: dict) -> bool: + payload = json.loads(row.get("payload") or "{}") + return bool( + (job.get("transcript") and row.get("recording") == job["transcript"]) + or (job.get("draft") and payload.get("draft_path") == job["draft"]) + or payload.get("job_id") == job["id"] + or (payload.get("draft_path") and Path(str(payload["draft_path"])).stem == job["id"]) + or (row.get("recording") and Path(str(row["recording"])).stem == job["id"]) + ) + + def pending(job: dict | None = None) -> list[dict]: + all_rows = review.pending(1000) if not job: - return review.pending(200) - found = [] - for row in review.pending(1000): - payload = json.loads(row["payload"] or "{}") - if ( - (job.get("transcript") and row["recording"] == job["transcript"]) - or (job.get("draft") and payload.get("draft_path") == job["draft"]) - or payload.get("job_id") == job["id"] - ): - found.append(row) - return found + return all_rows + return [row for row in all_rows if _match_question_to_job(row, job)] + + +def _group_pending_by_job() -> list[tuple[dict | None, list[dict]]]: + """Group all pending review questions by their parent recording job.""" + all_rows = review.pending(1000) + if not all_rows: + return [] + jobs = db.list_jobs(200) + grouped: dict[str, list[dict]] = {} + job_map: dict[str, dict] = {j["id"]: j for j in jobs} + unmatched: list[dict] = [] + + for row in all_rows: + matched_id = "" + for j in jobs: + if _match_question_to_job(row, j): + matched_id = j["id"] + break + if matched_id: + grouped.setdefault(matched_id, []).append(row) + else: + unmatched.append(row) + + out: list[tuple[dict | None, list[dict]]] = [] + for j in jobs: + if j["id"] in grouped: + out.append((j, grouped[j["id"]])) + if unmatched: + out.append((None, unmatched)) + return out def _session_token() -> str: @@ -114,6 +219,11 @@ def _session_token() -> str: def shell(title: str, body: str, script: str = "") -> bytes: + pend_count = len(review.pending(500)) + pend_badge = ( + f' {pend_count}' + if pend_count else "" + ) return f"""
支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。支持内网 Qwen3-ASR+CAM++ 及线上 Soniox / Gemini 3.5 / OpenAI / Grok。
+def home(msg: str = "") -> bytes: + options = _provider_options(CFG.asr_provider) + notice = f'支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。默认引擎与模型参数均可在 模型与系统配置 中直接修改并落盘至 SQLite。