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""" {html.escape(title)} · Meeting ASR
ASR 录音工作台 - +
{body}
{script}""".encode("utf-8") def _provider_options(selected: str = "") -> str: - sel = providers.normalize_provider_name(selected or DEFAULT_PROVIDER) + sel = providers.normalize_provider_name(selected or CFG.asr_provider) opts = [] for p in providers.provider_status(): pid = p["id"] is_sel = " selected" if pid == sel else "" - status_note = "" if p["ready"] else " (待配置密钥)" + status_note = "" if p["ready"] else " (待配密钥)" opts.append( f'' @@ -191,10 +309,11 @@ def login_page(err: str = "") -> bytes: return shell("登录", body) -def home() -> bytes: - options = _provider_options(DEFAULT_PROVIDER) - body = 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'
{html.escape(msg)}
' if msg else "" + body = f"""{notice}

上传会议 / 通话录音

+

支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。默认引擎与模型参数均可在 模型与系统配置 中直接修改并落盘至 SQLite。

@@ -209,31 +328,89 @@ def home() -> bytes:

-

最近任务

-
-
读取中…
""" + +
+
+

任务列表

+
+ + + +
+
+
+
+
+ + + +
+
+ +
+
+
读取中…
+
+
""" script = """""" + return shell("模型与系统配置", body, script) + + +def _probe_litellm_models() -> dict[str, Any]: + try: + url = f"{CFG.litellm_base_url}/models" + req = urllib.request.Request( + url, + headers={"Authorization": f"Bearer {CFG.api_key()}", "Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=8) as resp: + data = json.loads(resp.read().decode("utf-8", errors="ignore")) + models = sorted({str(m.get("id")) for m in (data.get("data") or []) if m.get("id")}) + return {"ok": True, "models": models} + except Exception as exc: + return {"ok": False, "error": str(exc), "models": []} class Handler(BaseHTTPRequestHandler): - server_version = "ASRService/2" + server_version = "ASRService/3" def log_message(self, *_): pass @@ -531,6 +1129,11 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(data) + def redirect(self, location: str) -> None: + self.send_response(303) + self.send_header("Location", location) + self.end_headers() + def json(self, obj: object, status: int = 200) -> None: self.send_data( json.dumps(obj, ensure_ascii=False).encode("utf-8"), @@ -563,9 +1166,7 @@ class Handler(BaseHTTPRequestHandler): def need_web_auth(self) -> bool: if self.web_authed(): return True - self.send_response(303) - self.send_header("Location", "/login") - self.end_headers() + self.redirect("/login") return False def need_auth(self) -> bool: @@ -577,6 +1178,11 @@ class Handler(BaseHTTPRequestHandler): def header(self, name: str, limit: int) -> str: return safe(urllib.parse.unquote(self.headers.get(name, "")), limit) + def read_form(self, max_bytes: int = 65536) -> dict[str, list[str]]: + length = min(int(self.headers.get("Content-Length", "0")), max_bytes) + raw = self.rfile.read(length).decode("utf-8", errors="ignore") if length > 0 else "" + return urllib.parse.parse_qs(raw, keep_blank_values=True) + def upload(self, source: str, protected: bool) -> None: if protected and not self.auth(): return self.json({"error": "unauthorized"}, 401) @@ -595,7 +1201,7 @@ class Handler(BaseHTTPRequestHandler): jid = uuid.uuid4().hex[:24] title = self.header("X-Title", 80) prov = providers.normalize_provider_name( - self.header("X-ASR-Provider", 40) or DEFAULT_PROVIDER + self.header("X-ASR-Provider", 40) or CFG.asr_provider ) model = self.header("X-ASR-Model", 80) stored = f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem(title or Path(name).stem)}-{jid[:8]}{suffix}" @@ -691,6 +1297,7 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: parsed = urllib.parse.urlsplit(self.path) path = parsed.path + q = urllib.parse.parse_qs(parsed.query) if path in ("/health", "/healthz"): return self.json({"ok": True, "service": "asr-web"}) if path == "/readyz": @@ -706,18 +1313,28 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/v1/providers": if not self.need_auth(): return - return self.json({"providers": providers.provider_status()}) + return self.json({"providers": providers.provider_status(), "default": CFG.asr_provider}) + if path == "/api/v1/settings": + if not self.need_auth(): + return + raw_s = db.get_all_settings() + redacted = { + k: (_mask_secret(v) if k.endswith("_api_key") else v) + for k, v in raw_s.items() + } + return self.json({"settings": redacted}) if path == "/api/v1/jobs": if not self.need_auth(): return - q = urllib.parse.parse_qs(parsed.query) return self.json({ "jobs": [ public(x) for x in db.list_jobs( - int(q.get("limit", [30])[0]), - int(q.get("offset", [0])[0]), + int(q.get("limit", ["30"])[0]), + int(q.get("offset", ["0"])[0]), q.get("status", [""])[0], + q.get("q", [""])[0], + q.get("provider", [""])[0], ) ] }) @@ -751,83 +1368,139 @@ class Handler(BaseHTTPRequestHandler): }) return self.json({"reviews": rows}) - # Web UI routes below (check optional web password) + # Web UI routes below if not self.need_web_auth(): return if path == "/": - return self.send_data(home()) + return self.send_data(home(q.get("msg", [""])[0])) if path == "/review/": - return self.send_data(review_page(None)) + return self.send_data(review_page(None, q.get("msg", [""])[0])) if path == "/lexicon": - return self.send_data(lexicon_page()) + return self.send_data( + lexicon_page( + tab=q.get("tab", ["aliases"])[0], + q=q.get("q", [""])[0], + msg=q.get("msg", [""])[0], + ) + ) if path == "/settings": - return self.send_data(settings_page()) + return self.send_data(settings_page(q.get("msg", [""])[0])) + if path == "/settings/models-probe": + return self.json(_probe_litellm_models()) if path == "/docs": - return self.send_data(shell( - "Agent API", - "

Agent API

" - "

所有 /api/v1/* 接口使用 Authorization: Bearer $ASR_AGENT_TOKEN。

" - "
", - )) + doc = { + "service": "meeting-asr", + "endpoints": [ + "GET /health, /readyz", + "GET /api/v1/providers", + "GET /api/v1/settings", + "POST /api/v1/settings", + "POST /api/v1/jobs (headers: X-Filename, X-Title, X-Context, X-ASR-Provider, X-ASR-Model)", + "GET /api/v1/jobs?status=&provider=&q=&limit=30&offset=0", + "GET /api/v1/jobs/{job_id}", + "DELETE /api/v1/jobs/{job_id}", + "GET /api/v1/jobs/{job_id}/artifacts/{transcript|draft|result}", + "GET /api/v1/jobs/{job_id}/reviews", + "POST /api/v1/jobs/{job_id}/reviews/{question_id}", + "POST /api/v1/jobs/{job_id}/retry (json: {\"provider\": \"soniox\", \"model\": \"\"})", + "POST /api/v1/jobs/{job_id}/rerefine", + ], + } + return self.send_data(shell("API 文档", f'

API 接口说明

{html.escape(json.dumps(doc, ensure_ascii=False, indent=2))}
')) if path == "/ui/jobs": - q = urllib.parse.parse_qs(parsed.query) - st = q.get("status", [""])[0] - return self.json({"jobs": [public(x) for x in db.list_jobs(50, 0, st)]}) + status_flt = q.get("status", [""])[0] + prov_flt = q.get("provider", [""])[0] + q_flt = q.get("q", [""])[0] + return self.json({ + "jobs": [ + public(x) + for x in db.list_jobs(60, 0, status=status_flt, q=q_flt, provider=prov_flt) + ] + }) m = re.fullmatch(r"/jobs/([0-9a-f]{24})/", path) if m: job = db.get_job(m.group(1)) - return self.send_data(job_page(job)) if job else self.send_data(b"Not found", 404, "text/plain") - m = re.fullmatch(r"/jobs/([0-9a-f]{24})/audio", path) - if m: - return self.stream_audio(db.get_job(m.group(1))) + return self.send_data(job_page(job, q.get("msg", [""])[0])) if job else self.send_data(b"Not found", 404, "text/plain") m = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path) if m: job = db.get_job(m.group(1)) - return self.send_data(review_page(job)) if job else self.send_data(b"Not found", 404, "text/plain") + return self.send_data(review_page(job, q.get("msg", [""])[0])) if job else self.send_data(b"Not found", 404, "text/plain") + m = re.fullmatch(r"/jobs/([0-9a-f]{24})/audio", path) + if m: + return self.stream_audio(db.get_job(m.group(1))) m = re.fullmatch(r"/jobs/([0-9a-f]{24})/artifact/(transcript|draft|result)", path) if m: - return self.artifact(db.get_job(m.group(1)), m.group(2)) + return self.artifact(db.get_job(m.group(1)), m.group(2), False) self.send_data(b"Not found", 404, "text/plain") + def do_DELETE(self) -> None: + path = urllib.parse.urlsplit(self.path).path + m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path) + if m: + if not self.need_auth(): + return + ok = delete_job_and_artifacts(m.group(1), delete_files=True, delete_result=False) + return self.json({"ok": ok}, 200 if ok else 404) + self.json({"error": "not found"}, 404) + def do_POST(self) -> None: path = urllib.parse.urlsplit(self.path).path if path == "/login": - length = min(int(self.headers.get("Content-Length", "0")), 4096) - form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", errors="ignore")) + form = self.read_form(4096) pw = form.get("password", [""])[0] if WEB_PASSWORD and hmac.compare_digest(pw, WEB_PASSWORD): self.send_response(303) + self.send_header("Location", "/") self.send_header( "Set-Cookie", f"asr_session={_session_token()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000", ) - self.send_header("Location", "/") self.end_headers() return - return self.send_data(login_page("口令不正确"), 401) + return self.send_data(login_page("口令错误,请重试"), 401) + if path == "/ui/upload": return self.upload("web", False) if path == "/api/v1/jobs": return self.upload("agent", True) + if path == "/api/v1/settings": + if not self.need_auth(): + return + length = min(int(self.headers.get("Content-Length", "0")), 32768) + body = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + db.save_settings(body) + return self.json({"ok": True}) + m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews/(\d+)", path) if m: if not self.need_auth(): return job = db.get_job(m.group(1)) - length = min(int(self.headers.get("Content-Length", "0")), 4096) - try: - answer = str(json.loads(self.rfile.read(length)).get("answer") or "").strip() - except (ValueError, AttributeError): - answer = "" qid = int(m.group(2)) - if not job or qid not in {x["id"] for x in pending(job)} or not answer: - return self.json({"error": "invalid review answer"}, 400) - return self.json(review.answer(qid, answer[:120])) + if not job or qid not in {x["id"] for x in pending(job)}: + return self.json({"error": "not found"}, 404) + length = min(int(self.headers.get("Content-Length", "0")), 4096) + body = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + ans = str(body.get("answer", "")).strip() + if not ans: + return self.json({"error": "answer required"}, 400) + return self.json(review.answer(qid, ans[:200])) + + m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/rerefine", path) + if m: + api = path.startswith("/api/") + if api and not self.need_auth(): + return + if not api and not self.need_web_auth(): + return + job = db.get_job(m.group(1)) + if not job: + return self.json({"error": "not found"}, 404) + ok = queue_rerefine(job) + if api: + return self.json({"ok": ok}, 202 if ok else 409) + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已加入重新精修队列")) m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/retry", path) if m: @@ -844,7 +1517,7 @@ class Handler(BaseHTTPRequestHandler): return self.json({"error": "cannot retry"}, 409) length = min(int(self.headers.get("Content-Length", "0")), 4096) raw_body = self.rfile.read(length).decode("utf-8", errors="ignore") if length > 0 else "" - new_prov = job.get("asr_provider") or DEFAULT_PROVIDER + new_prov = job.get("asr_provider") or CFG.asr_provider new_model = job.get("asr_model") or "" if raw_body: if api and raw_body.strip().startswith("{"): @@ -865,7 +1538,6 @@ class Handler(BaseHTTPRequestHandler): dest = INBOX / job["stored_name"] if source.resolve() != dest.resolve(): shutil.copyfile(source, dest) if source.parent == PROCESSED else source.replace(dest) - # Touch mtime slightly in the past so worker picks it up without extra wait old_ts = time.time() - 60 os.utime(dest, (old_ts, old_ts)) db.update_job( @@ -874,63 +1546,297 @@ class Handler(BaseHTTPRequestHandler): ) if api: return self.json({"ok": True, "asr_provider": new_prov, "asr_model": new_model}, 202) - self.send_response(303) - self.send_header("Location", f"/jobs/{job['id']}/") - self.end_headers() - return + return self.redirect(f"/jobs/{job['id']}/") if not self.need_web_auth(): return + # ---- Task deletion & metadata editing ---- + if path == "/jobs/batch-delete": + form = self.read_form(65536) + del_files = form.get("delete_files", ["1"])[0] == "1" + if form.get("cleanup_failed"): + failed_jobs = db.list_jobs(200, 0, status="failed") + for fj in failed_jobs: + delete_job_and_artifacts(fj["id"], delete_files=del_files, delete_result=False) + return self.redirect("/?msg=" + urllib.parse.quote(f"已清理 {len(failed_jobs)} 个失败任务")) + ids = form.get("job_id", []) + n_del = 0 + for jid in ids: + if delete_job_and_artifacts(jid, delete_files=del_files, delete_result=False): + n_del += 1 + return self.redirect("/?msg=" + urllib.parse.quote(f"已删除 {n_del} 个任务")) + + m = re.fullmatch(r"/jobs/([0-9a-f]{24})/delete", path) + if m: + form = self.read_form(4096) + del_files = form.get("delete_files", ["0"])[0] == "1" + del_res = form.get("delete_result", ["0"])[0] == "1" + delete_job_and_artifacts(m.group(1), delete_files=del_files, delete_result=del_res) + return self.redirect("/?msg=" + urllib.parse.quote("任务已删除")) + + m = re.fullmatch(r"/jobs/([0-9a-f]{24})/meta", path) + if m: + job = db.get_job(m.group(1)) + if not job: + return self.send_data(b"Not found", 404, "text/plain") + form = self.read_form(32768) + new_title = safe(form.get("title", [""])[0], 80) + new_ctx = safe(form.get("context", [""])[0], 2000) + new_prov = providers.normalize_provider_name(form.get("provider", [CFG.asr_provider])[0]) + new_model = safe(form.get("model", [""])[0], 80) + db.update_job_meta( + job["id"], title=new_title, context=new_ctx, + asr_provider=new_prov, asr_model=new_model, + ) + action = form.get("next_action", ["save"])[0] + updated_job = db.get_job(job["id"]) or job + if action == "rerefine": + queue_rerefine(updated_job) + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已保存并排队重新生成草稿")) + if action == "retry": + source = find_audio_file(updated_job) + if source: + dest = INBOX / updated_job["stored_name"] + if source.resolve() != dest.resolve(): + shutil.copyfile(source, dest) if source.parent == PROCESSED else source.replace(dest) + old_ts = time.time() - 60 + os.utime(dest, (old_ts, old_ts)) + db.update_job( + job["id"], "queued", f"重新排队转写 (ASR: {new_prov})", + error_code="", asr_provider=new_prov, asr_model=new_model, + ) + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已保存并重新排队转写")) + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("任务信息已更新")) + + m = re.fullmatch(r"/jobs/([0-9a-f]{24})/revise", path) + if m: + job = db.get_job(m.group(1)) + if not job: + return self.send_data(b"Not found", 404, "text/plain") + queue_revise_draft(job) + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已触发修订与定稿流程")) + + # ---- Multi-recording review submission ---- if path == "/review/" or re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path): m_job = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path) job = db.get_job(m_job.group(1)) if m_job else None - length = min(int(self.headers.get("Content-Length", "0")), 4096) - form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", errors="ignore")) - qid = int(form.get("id", ["0"])[0]) - answer = form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip() + form = self.read_form(65536) allowed_ids = {x["id"] for x in pending(job)} - if (m_job and not job) or qid not in allowed_ids or not answer: - return self.send_data(b"Invalid answer", 400, "text/plain") - review.answer(qid, answer[:120]) - self.send_response(303) - self.send_header("Location", path) - self.end_headers() - return + answered_count = 0 + + # Case 1: Single preset button clicked (`single_submit` = "qid::answer") + if form.get("single_submit"): + raw_val = form["single_submit"][0] + if "::" in raw_val: + qid_str, ans_val = raw_val.split("::", 1) + if qid_str.isdigit() and int(qid_str) in allowed_ids and ans_val.strip(): + review.answer(int(qid_str), ans_val.strip()[:200]) + answered_count += 1 + # Case 2: Single custom button clicked (`single_custom` = "qid") + elif form.get("single_custom"): + qid_str = form["single_custom"][0] + if qid_str.isdigit() and int(qid_str) in allowed_ids: + ans_val = ( + form.get(f"custom_{qid_str}", [""])[0].strip() + or form.get(f"preset_{qid_str}", [""])[0].strip() + ) + if ans_val: + review.answer(int(qid_str), ans_val[:200]) + answered_count += 1 + # Case 3: Skip all questions in the recording group + elif form.get("batch_skip_all"): + qids = [ + int(x) for x in form.get("question_ids", [""])[0].split(",") + if x.strip().isdigit() and int(x) in allowed_ids + ] + for qid in qids: + review.answer(qid, "无法确认,保留原文") + answered_count += 1 + # Case 4: Batch submit filled questions in the recording group + elif form.get("batch_submit"): + qids = [ + int(x) for x in form.get("question_ids", [""])[0].split(",") + if x.strip().isdigit() and int(x) in allowed_ids + ] + for qid in qids: + ans_val = ( + form.get(f"custom_{qid}", [""])[0].strip() + or form.get(f"preset_{qid}", [""])[0].strip() + ) + if ans_val: + review.answer(qid, ans_val[:200]) + answered_count += 1 + # Legacy fallback (`id` + `answer`/`custom`) + elif form.get("id"): + qid = int(form.get("id", ["0"])[0] or 0) + ans_val = form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip() + if qid in allowed_ids and ans_val: + review.answer(qid, ans_val[:200]) + answered_count += 1 + + return self.redirect(f"{path}?msg=" + urllib.parse.quote(f"已提交 {answered_count} 项确认")) + + if path == "/review/update-answer": + form = self.read_form(8192) + qid = int(form.get("id", ["0"])[0] or 0) + if qid > 0: + if form.get("reopen"): + review.reopen_question(qid) + return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 已撤回至待确认队列")) + new_ans = safe(form.get("answer", [""])[0], 200) + if new_ans: + review.answer(qid, new_ans, allow_update=True) + return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 回答已更新")) + return self.redirect("/review/") m_edit = re.fullmatch(r"/jobs/([0-9a-f]{24})/edit", path) if m_edit: job = db.get_job(m_edit.group(1)) if not job: return self.send_data(b"Not found", 404, "text/plain") - length = min(int(self.headers.get("Content-Length", "0")), 2 * 1024 * 1024) - form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", errors="ignore")) + form = self.read_form(2 * 1024 * 1024) target = form.get("target", [""])[0] content = form.get("content", [""])[0] if target in ("draft", "result") and job.get(target) and content.strip(): tpath = Path(str(job[target])) vault.write_draft_file(tpath, content) - self.send_response(303) - self.send_header("Location", f"/jobs/{job['id']}/") - self.end_headers() - return + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("Markdown 修改已保存")) + # ---- Lexicon CRUD endpoints ---- if path == "/lexicon/learn": - length = min(int(self.headers.get("Content-Length", "0")), 4096) - form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", errors="ignore")) + form = self.read_form(8192) alias_val = safe(form.get("alias", [""])[0], 40) canon_val = safe(form.get("canonical", [""])[0], 40) + kind_val = safe(form.get("kind", [""])[0], 20) note_val = safe(form.get("note", [""])[0], 120) + tab = safe(form.get("tab", ["aliases"])[0], 20) if alias_val and canon_val: - lexicon.learn_alias(alias_val, canon_val, note=note_val) + lexicon.learn_alias(alias_val, canon_val, kind=kind_val, origin="manual", note=note_val) lexicon.learn_correction(alias_val, canon_val, note=note_val) - return self.send_data(lexicon_page(f"已添加映射:{alias_val} → {canon_val}")) - return self.send_data(lexicon_page("请填写完整的别名与规范名"), 400) + return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote(f"已添加映射:{alias_val} → {canon_val}")) + return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote("请填写完整的词条映射")) + + if path == "/lexicon/alias/update": + form = self.read_form(8192) + old_a = safe(form.get("old_alias", [""])[0], 40) + old_c = safe(form.get("old_canonical", [""])[0], 40) + new_a = safe(form.get("alias", [""])[0], 40) + new_c = safe(form.get("canonical", [""])[0], 40) + kind = safe(form.get("kind", ["人名"])[0], 20) + conf = 1 if form.get("confirmed") else 0 + note = safe(form.get("note", [""])[0], 120) + if new_a and new_c: + lexicon.update_alias(old_a or new_a, old_c or new_c, new_a, new_c, kind=kind, confirmed=conf, note=note) + return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已更新别名:{new_a} → {new_c}")) + + if path == "/lexicon/alias/delete": + form = self.read_form(8192) + old_a = safe(form.get("old_alias", form.get("alias", [""]))[0], 40) + old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40) + lexicon.delete_alias(old_a, old_c) + return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已删除别名:{old_a}")) + + if path == "/lexicon/correction/update": + form = self.read_form(8192) + old_s = safe(form.get("old_surface", [""])[0], 40) + old_c = safe(form.get("old_canonical", [""])[0], 40) + new_s = safe(form.get("surface", [""])[0], 40) + new_c = safe(form.get("canonical", [""])[0], 40) + conf = 1 if form.get("confirmed") else 0 + note = safe(form.get("note", [""])[0], 120) + if new_s and new_c: + lexicon.update_correction(old_s or new_s, old_c or new_c, new_s, new_c, confirmed=conf, note=note) + return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已更新纠错:{new_s} → {new_c}")) + + if path == "/lexicon/correction/delete": + form = self.read_form(8192) + old_s = safe(form.get("old_surface", form.get("surface", [""]))[0], 40) + old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40) + lexicon.delete_correction(old_s, old_c) + return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已删除纠错:{old_s}")) + + if path == "/lexicon/entity/update": + form = self.read_form(8192) + old_c = safe(form.get("old_canonical", [""])[0], 60) + new_c = safe(form.get("canonical", [""])[0], 60) + kind = safe(form.get("kind", ["人名"])[0], 20) + comp = safe(form.get("company", [""])[0], 80) + role = safe(form.get("role", [""])[0], 80) + rel = safe(form.get("related", [""])[0], 160) + if new_c: + if old_c: + lexicon.update_entity(old_c, new_c, kind, company=comp, role=role, related=rel) + else: + lexicon.upsert_entity(new_c, kind, company=comp, role=role, related=rel) + return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已保存实体:{new_c}")) + + if path == "/lexicon/entity/delete": + form = self.read_form(8192) + old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 60) + lexicon.delete_entity(old_c) + return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已删除实体:{old_c}")) + + if path == "/lexicon/relation/update": + form = self.read_form(8192) + old_s = safe(form.get("old_subject", [""])[0], 60) + old_p = safe(form.get("old_predicate", [""])[0], 40) + old_o = safe(form.get("old_object", [""])[0], 60) + old_src = safe(form.get("old_source", ["manual"])[0], 120) + subj = safe(form.get("subject", [""])[0], 60) + pred = safe(form.get("predicate", [""])[0], 40) + obj = safe(form.get("object", [""])[0], 60) + try: + conf = float(form.get("confidence", ["0.85"])[0]) + except ValueError: + conf = 0.85 + act = 1 if form.get("active") else 0 + if subj and pred and obj: + if old_s and old_p and old_o: + lexicon.update_relation(old_s, old_p, old_o, old_src, subj, pred, obj, confidence=conf, active=act) + else: + lexicon.upsert_relation(subj, pred, obj, confidence=conf, source="manual") + return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已保存关系:{subj} {pred} {obj}")) + + if path == "/lexicon/relation/delete": + form = self.read_form(8192) + old_s = safe(form.get("old_subject", form.get("subject", [""]))[0], 60) + old_p = safe(form.get("old_predicate", form.get("predicate", [""]))[0], 40) + old_o = safe(form.get("old_object", form.get("object", [""]))[0], 60) + old_src = safe(form.get("old_source", ["manual"])[0], 120) + lexicon.delete_relation(old_s, old_p, old_o, old_src) + return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已删除关系:{old_s} {old_p} {old_o}")) if path == "/lexicon/sync": l_res = lexicon.sync_from_vault(force=True) g_res = graph.sync(force=True) - return self.send_data(lexicon_page(f"同步完成:词库 {l_res},图谱 {g_res}")) + return self.redirect("/lexicon?msg=" + urllib.parse.quote(f"同步完成:词库 {l_res},图谱 {g_res}")) + + # ---- SQLite Settings Save ---- + if path == "/settings": + form = self.read_form(32768) + updates: dict[str, str] = {} + text_keys = ( + "litellm_base_url", "clean_model", "keyword_model", "draft_model", + "asr_provider", "diarize_url", "voiceprint_enroll_url", + "soniox_api_base", "soniox_model", "gemini_asr_model", "openai_asr_model", + "grok_api_base", "grok_asr_model", "hindsight_api", "hindsight_bank", + ) + for k in text_keys: + if k in form: + updates[k] = safe(form[k][0], 200) + if "asr_provider" in updates: + updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"]) + for sk in ("litellm_api_key", "soniox_api_key", "grok_api_key"): + raw_k = form.get(sk, [""])[0].strip() + if raw_k == "CLEAR": + updates[sk] = "" + elif raw_k: + updates[sk] = raw_k + for bk in ("use_hindsight", "use_termbank", "git_sync"): + updates[bk] = "1" if form.get(bk) else "0" + db.save_settings(updates) + return self.redirect("/settings?msg=" + urllib.parse.quote("配置已保存至 SQLite 并立即生效")) self.send_data(b"Not found", 404, "text/plain") diff --git a/servers/tools-pve/asr-pipeline/asrservice/worker.py b/servers/tools-pve/asr-pipeline/asrservice/worker.py index 94315e8..167f30d 100644 --- a/servers/tools-pve/asr-pipeline/asrservice/worker.py +++ b/servers/tools-pve/asr-pipeline/asrservice/worker.py @@ -4,6 +4,7 @@ import fcntl import hashlib import json import os +import re import shutil import sqlite3 import time @@ -11,9 +12,10 @@ import uuid from pathlib import Path from asrpipe import graph, lexicon, providers, refine, review, revise, vault +from asrpipe.config import CFG from . import db from .config import ( - DATA, DEFAULT_PROVIDER, DRAFTS, FAILED, INBOX, LOGS, MAX_ATTEMPTS, + DATA, DRAFTS, FAILED, INBOX, LOGS, MAX_ATTEMPTS, POLL_SECONDS, PROCESSED, PROCESSING, PUBLIC_URL, REVISION_QUEUE, STABLE_SECONDS, STATE, SUPPORTED, TRANSCRIPTS, ensure_dirs, ) @@ -50,7 +52,7 @@ def transcribe( ) -> tuple[Path, providers.TranscribeResult]: res = providers.transcribe_audio( audio, - provider=provider or DEFAULT_PROVIDER, + provider=provider or CFG.asr_provider, model=model, title=title, user_context=user_context, @@ -71,7 +73,7 @@ def transcribe( def process_audio(source: Path, job: dict) -> None: job_id = job["id"] attempts = int(job.get("attempts") or 0) + 1 - prov = providers.normalize_provider_name(job.get("asr_provider") or DEFAULT_PROVIDER) + prov = providers.normalize_provider_name(job.get("asr_provider") or CFG.asr_provider) model = str(job.get("asr_model") or "") digest = sha256(source) prov_label = providers.PROVIDERS.get(prov, {}).get("label", prov) @@ -120,6 +122,47 @@ def process_audio(source: Path, job: dict) -> None: f"{PUBLIC_URL}/jobs/{job_id}/review/") +def rerefine_job(job: dict) -> None: + """Re-run LLM refinement on an existing raw transcript without re-running ASR.""" + job_id = job["id"] + transcript = Path(str(job.get("transcript") or "")) + if not transcript.is_file(): + raise FileNotFoundError(f"transcript file missing for job {job_id}: {transcript}") + raw = transcript.read_text(encoding="utf-8", errors="ignore") + intake = { + "job_id": job_id, + "title": job.get("title", ""), + "context": job.get("context", ""), + "source": job.get("source", ""), + "asr_provider": job.get("asr_provider") or CFG.asr_provider, + "asr_model": job.get("asr_model") or "", + } + intake_line = f"" + if re.match(r"^", raw): + raw = re.sub(r"^", intake_line, raw, count=1) + else: + raw = intake_line + "\n" + raw + transcript.write_text(raw, encoding="utf-8") + + db.update_job(job_id, "refining", "正在基于现有转写重新生成精修草稿") + review.delete_for_job( + transcript_path=str(transcript), + draft_path=str(job.get("draft") or ""), + job_id=job_id, + ) + result = refine.refine( + raw, date=time.strftime("%Y-%m-%d"), + transcript_path=str(transcript), queue_review=True, + ) + draft = DRAFTS / f"{job_id}.md" + vault.write_draft_file(draft, result.draft) + questions = review.enqueue_draft(str(transcript), draft) + db.update_job( + job_id, "review", f"整理稿已重新生成,{questions} 个问题待确认", + transcript=str(transcript), draft=str(draft), + ) + + def claim(path: Path) -> tuple[Path, dict]: job = db.find_by_stored(path.name) if not job: @@ -127,7 +170,7 @@ def claim(path: Path) -> tuple[Path, dict]: job = db.create_job( job_id, path.name, path.name, title=path.stem, size=path.stat().st_size, source="ssh", - asr_provider=DEFAULT_PROVIDER, + asr_provider=CFG.asr_provider, ) target = PROCESSING / path.name if target.exists(): @@ -164,8 +207,15 @@ def process_revisions() -> None: for marker in sorted(REVISION_QUEUE.glob("*.json")): try: payload = json.loads(marker.read_text(encoding="utf-8")) - draft = Path(payload["draft"]) + action = str(payload.get("action") or "revise") job_id = str(payload.get("job_id") or "") + if action == "rerefine": + job = db.get_job(job_id) + if job: + rerefine_job(job) + marker.unlink(missing_ok=True) + continue + draft = Path(payload["draft"]) db.update_job(job_id, "publishing", "正在应用人工确认") revise.revise(draft) current = db.get_job(job_id) or {} @@ -181,6 +231,14 @@ def process_revisions() -> None: marker.unlink(missing_ok=True) except Exception as exc: log(f"revision marker={marker.name} failed type={type(exc).__name__} err={exc}") + try: + payload = json.loads(marker.read_text(encoding="utf-8")) + jid = str(payload.get("job_id") or "") + if jid: + db.update_job(jid, "failed", f"精修/修订失败:{exc}", error_code=type(exc).__name__) + except Exception: + pass + marker.unlink(missing_ok=True) def recover() -> None: diff --git a/servers/tools-pve/asr-pipeline/tests/test_service.py b/servers/tools-pve/asr-pipeline/tests/test_service.py index 27c44b5..569b3cc 100644 --- a/servers/tools-pve/asr-pipeline/tests/test_service.py +++ b/servers/tools-pve/asr-pipeline/tests/test_service.py @@ -5,6 +5,7 @@ import os import tempfile import threading import unittest +import urllib.parse import urllib.request from http.server import ThreadingHTTPServer from pathlib import Path @@ -20,6 +21,7 @@ os.environ["ASR_NOTIFY"] = "0" os.environ["ASR_GIT_SYNC"] = "0" from asrpipe import lexicon, providers, review, segment # noqa: E402 +from asrpipe.config import CFG # noqa: E402 from asrservice import config as svc_cfg, db as svc_db, web as svc_web # noqa: E402 @@ -33,6 +35,28 @@ class ProviderAndServiceTests(unittest.TestCase): def tearDown(self): self.tmp.cleanup() + def test_sqlite_settings_override_config_dynamically(self): + svc_db.save_settings({ + "asr_provider": "soniox", + "litellm_base_url": "https://api.opfo.xyz/v1", + "litellm_api_key": "sk-sqlite-dynamic-key", + "clean_model": "aistudio/gemini-flash-latest", + "draft_model": "openai/gpt-sol-latest", + "soniox_api_key": "soniox-db-secret", + "use_hindsight": "0", + }) + self.assertEqual(CFG.asr_provider, "soniox") + self.assertEqual(CFG.litellm_base_url, "https://api.opfo.xyz/v1") + self.assertEqual(CFG.endpoint, "https://api.opfo.xyz/v1/chat/completions") + self.assertEqual(CFG.transcriptions_endpoint, "https://api.opfo.xyz/v1/audio/transcriptions") + self.assertEqual(CFG.api_key(), "sk-sqlite-dynamic-key") + self.assertEqual(CFG.clean_model, "aistudio/gemini-flash-latest") + self.assertEqual(CFG.draft_model, "openai/gpt-sol-latest") + self.assertEqual(CFG.soniox_api_key, "soniox-db-secret") + self.assertFalse(CFG.use_hindsight) + # Restore default for subsequent tests + svc_db.save_settings({"asr_provider": "qwen3cam", "use_hindsight": "1"}) + def test_soniox_token_diarization_and_segment_parse(self): payload = { "tokens": [ @@ -135,20 +159,76 @@ class ProviderAndServiceTests(unittest.TestCase): updated = draft.read_text(encoding="utf-8") self.assertIn("speaker_1: 张三", updated) - def test_db_and_web_endpoints(self): - job_id = "0123456789abcdef01234567" - job = svc_db.create_job( - job_id=job_id, - original_name="meeting.m4a", - stored_name=f"20261102-{job_id}.m4a", - title="架构周会", - context="验证 Web API", + def test_lexicon_crud_operations(self): + # Entity CRUD + lexicon.upsert_entity("测试生物", "公司", company="测试集团", role="子公司", related="李四") + ents = lexicon.list_entities(q="测试生物") + self.assertEqual(len(ents), 1) + self.assertEqual(ents[0]["company"], "测试集团") + + lexicon.update_entity("测试生物", "测试生物科技", "公司", company="新集团", role="总部", related="李四,王五") + ents2 = lexicon.list_entities(q="测试生物科技") + self.assertEqual(len(ents2), 1) + self.assertEqual(ents2[0]["company"], "新集团") + self.assertTrue(lexicon.delete_entity("测试生物科技")) + self.assertEqual(len(lexicon.list_entities(q="测试生物科技")), 0) + + # Alias & Correction CRUD + lexicon.learn_alias("老测试", "测试人", kind="人名", origin="manual", note="初始") + lexicon.update_alias("老测试", "测试人", "小测试", "测试人", kind="人名", confirmed=1, note="已改") + aliases = lexicon.list_aliases(q="小测试") + self.assertEqual(len(aliases), 1) + self.assertEqual(aliases[0]["note"], "已改") + self.assertTrue(lexicon.delete_alias("小测试", "测试人")) + + lexicon.learn_correction("误识词", "正词", note="c1") + lexicon.update_correction("误识词", "正词", "新误识词", "正词", confirmed=1, note="c2") + cors = lexicon.list_corrections(q="新误识词") + self.assertEqual(len(cors), 1) + self.assertEqual(cors[0]["note"], "c2") + self.assertTrue(lexicon.delete_correction("新误识词", "正词")) + + # Relation CRUD + lexicon.upsert_relation("张三", "任职于", "洛启生物", confidence=0.8, source="manual") + lexicon.update_relation("张三", "任职于", "洛启生物", "manual", "张三", "实控人", "洛启生物", confidence=0.95, active=1) + rels = lexicon.list_relations(q="张三") + self.assertTrue(any(r["predicate"] == "实控人" for r in rels)) + self.assertTrue(lexicon.delete_relation("张三", "实控人", "洛启生物", "manual")) + + def test_db_web_endpoints_multi_review_and_job_delete(self): + job1_id = "0123456789abcdef01234567" + job2_id = "fedcba987654321001234567" + t1 = svc_cfg.TRANSCRIPTS / f"{job1_id}.txt" + t2 = svc_cfg.TRANSCRIPTS / f"{job2_id}.txt" + t1.write_text("【speaker_0】录音一内容\n", encoding="utf-8") + t2.write_text("【speaker_0】录音二内容\n", encoding="utf-8") + + svc_db.create_job( + job_id=job1_id, + original_name="meeting1.m4a", + stored_name=f"20261102-{job1_id}.m4a", + title="第一场尽调会", + context="验证多录音确认", size=2048, asr_provider="soniox", asr_model="stt-async-v5", ) - self.assertEqual(job["asr_provider"], "soniox") - self.assertEqual(job["asr_model"], "stt-async-v5") + svc_db.update_job(job1_id, "review", "待确认", transcript=str(t1)) + + svc_db.create_job( + job_id=job2_id, + original_name="meeting2.m4a", + stored_name=f"20261102-{job2_id}.m4a", + title="第二场投决会", + context="验证批量操作", + size=4096, + asr_provider="qwen3cam", + ) + svc_db.update_job(job2_id, "review", "待确认", transcript=str(t2)) + + q1 = review.enqueue(str(t1), "第一场问题A?", {"kind": "draft_question", "candidates": ["选项A1", "选项A2"]}) + q2 = review.enqueue(str(t1), "第一场问题B?", {"kind": "draft_question", "candidates": ["选项B1"]}) + q3 = review.enqueue(str(t2), "第二场问题C?", {"kind": "draft_question", "candidates": ["选项C1"]}) svc_web.AGENT_TOKEN = "test-agent-token" server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler) @@ -164,6 +244,17 @@ class ProviderAndServiceTests(unittest.TestCase): with urllib.request.urlopen(req, timeout=5) as resp: return resp.status, resp.read().decode("utf-8") + def post_form(path: str, data: dict[str, str]) -> tuple[int, str]: + encoded = urllib.parse.urlencode(data).encode("utf-8") + req = urllib.request.Request( + f"http://127.0.0.1:{port}{path}", + data=encoded, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, resp.read().decode("utf-8") + status, body = fetch("/health") self.assertEqual(status, 200) self.assertEqual(json.loads(body)["ok"], True) @@ -173,22 +264,57 @@ class ProviderAndServiceTests(unittest.TestCase): headers={"Authorization": "Bearer test-agent-token"}, ) self.assertEqual(status, 200) - prov_data = json.loads(body) - prov_ids = [p["id"] for p in prov_data["providers"]] + prov_ids = [p["id"] for p in json.loads(body)["providers"]] self.assertEqual(prov_ids, ["qwen3cam", "soniox", "gemini", "openai", "grok"]) - status, body = fetch(f"/jobs/{job_id}/") + # Multi-recording review page shows both recordings grouped + status, body = fetch("/review/") self.assertEqual(status, 200) - self.assertIn("架构周会", body) - self.assertIn("soniox", body) + self.assertIn("第一场尽调会", body) + self.assertIn("第二场投决会", body) + self.assertIn("第一场问题A?", body) + self.assertIn("第二场问题C?", body) - status, body = fetch("/lexicon") + # Batch submit both questions for recording 1 + status, body = post_form(f"/jobs/{job1_id}/review/", { + "question_ids": f"{q1},{q2}", + f"preset_{q1}": "选项A1", + f"custom_{q2}": "自定义回答B", + "batch_submit": "1", + }) self.assertEqual(status, 200) - self.assertIn("词库", body) + self.assertEqual(len(svc_web.pending(svc_db.get_job(job1_id))), 0) + self.assertEqual(len(svc_web.pending(svc_db.get_job(job2_id))), 1) - status, body = fetch("/settings") + # Edit job metadata + status, body = post_form(f"/jobs/{job1_id}/meta", { + "title": "第一场尽调会(已改名)", + "context": "更新后的背景", + "provider": "openai", + "model": "openai/gpt-4o-transcribe-diarize", + "next_action": "save", + }) self.assertEqual(status, 200) - self.assertIn("LiteLLM", body) + self.assertIn("第一场尽调会(已改名)", body) + + # Save settings via Web UI POST + status, body = post_form("/settings", { + "litellm_base_url": "http://192.168.50.49:4000/v1", + "clean_model": "aistudio/gemini-flash-lite-latest", + "keyword_model": "aistudio/gemini-flash-lite-latest", + "draft_model": "codex/gpt-sol-latest", + "asr_provider": "soniox", + "use_hindsight": "1", + }) + self.assertEqual(status, 200) + self.assertEqual(CFG.asr_provider, "soniox") + + # Delete job 2 and verify its pending review question is also cleaned up + status, _ = post_form(f"/jobs/{job2_id}/delete", {"delete_files": "1"}) + self.assertEqual(status, 200) + self.assertIsNone(svc_db.get_job(job2_id)) + self.assertFalse(t2.exists()) + self.assertNotIn(q3, [r["id"] for r in review.pending(200)]) finally: server.shutdown() server.server_close()