feat(tools-pve/asr-pipeline): multi-backend ASR web app with LiteLLM routing
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configuration for the standalone ASR post-processing pipeline.
|
||||
"""Configuration for the standalone ASR post-processing pipeline and web service.
|
||||
|
||||
This program deliberately has **no dependency on Claude Code, opencode, or any
|
||||
agent CLI**. It talks to the model provider over plain HTTPS and does its own file
|
||||
and git handling, so it can run from systemd, cron, or a shell with nothing but
|
||||
python3 and a network route.
|
||||
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 so the systemd unit stays the
|
||||
single place where deployment-specific values live. Credentials are read from the
|
||||
same 600-perm env file the legacy unit uses, so token rotation remains a one-file
|
||||
change.
|
||||
Everything is overridable by environment variable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,8 +14,8 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
PKG = Path(__file__).resolve().parent
|
||||
ROOT = PKG.parent # OBSIDIAN/asr-pipeline
|
||||
OBSIDIAN = ROOT.parent # OBSIDIAN project dir
|
||||
ROOT = PKG.parent
|
||||
OBSIDIAN = Path(os.environ.get("ASR_WORKSPACE", str(ROOT)))
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
@@ -46,60 +42,124 @@ class Config:
|
||||
# ---- paths ----------------------------------------------------------
|
||||
vault: Path = field(default_factory=lambda: Path(
|
||||
_env("ASR_VAULT", str(Path.home() / "obsidian-vault"))))
|
||||
obsidian: Path = OBSIDIAN
|
||||
obsidian: Path = field(default_factory=lambda: Path(
|
||||
_env("ASR_WORKSPACE", str(ROOT))))
|
||||
root: Path = ROOT
|
||||
|
||||
# ---- models ---------------------------------------------------------
|
||||
# Cleaning: measured against claude-haiku-4.5 on a 118k-char English-mixed call,
|
||||
# gemini-3.5-flash-lite tripped the fidelity gate 19/87 times vs haiku's 78/87
|
||||
# (haiku's failures were 69x "over-compression" — it behaves like a summarizer
|
||||
# and deleted terms such as AUTOIMMUNE/ADCS/CD), while being 2x faster and
|
||||
# 2.9x cheaper. Hence flash-lite is the default cleaner.
|
||||
clean_model: str = _env("ASR_CLEAN_MODEL", "google/gemini-3.5-flash-lite")
|
||||
# Drafting: one call, needs judgement and structure. sonnet-5.5 replaces the
|
||||
# legacy headless opus-4-8.
|
||||
draft_model: str = _env("ASR_DRAFT_MODEL", "anthropic/claude-sonnet-5.5")
|
||||
keyword_model: str = _env("ASR_KEYWORD_MODEL", "google/gemini-3.5-flash-lite")
|
||||
# ---- 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("/"))
|
||||
|
||||
endpoint: str = _env("ASR_ENDPOINT", "https://zenmux.ai/api/v1/chat/completions")
|
||||
# ZenMux returned HTTP 429 at ~12 concurrent requests and was stable at 6.
|
||||
concurrency: int = _envi("CONCURRENCY", 6)
|
||||
http_timeout: float = _envf("HTTP_TIMEOUT", 420.0)
|
||||
max_retries: int = _envi("MAX_RETRIES", 5)
|
||||
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"))
|
||||
|
||||
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"))
|
||||
|
||||
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"))))
|
||||
|
||||
# ---- chunking -------------------------------------------------------
|
||||
chunk_budget: int = _envi("CHUNK_BUDGET", 1800)
|
||||
chunk_overlap: int = _envi("CHUNK_OVERLAP", 200)
|
||||
collapse_noise: bool = _env("ASR_COLLAPSE_NOISE", "1") != "0"
|
||||
chunk_budget: int = field(default_factory=lambda: _envi("CHUNK_BUDGET", 1800))
|
||||
chunk_overlap: int = field(default_factory=lambda: _envi("CHUNK_OVERLAP", 200))
|
||||
collapse_noise: bool = field(
|
||||
default_factory=lambda: _env("ASR_COLLAPSE_NOISE", "1") != "0")
|
||||
|
||||
# ---- fidelity gate --------------------------------------------------
|
||||
gate_enabled: bool = _env("ASR_GATE", "1") != "0"
|
||||
gate_min_ratio: float = _envf("GATE_MIN_RATIO", 0.55)
|
||||
gate_max_ratio: float = _envf("GATE_MAX_RATIO", 1.35)
|
||||
gate_enabled: bool = field(
|
||||
default_factory=lambda: _env("ASR_GATE", "1") != "0")
|
||||
gate_min_ratio: float = field(
|
||||
default_factory=lambda: _envf("GATE_MIN_RATIO", 0.55))
|
||||
gate_max_ratio: float = field(
|
||||
default_factory=lambda: _envf("GATE_MAX_RATIO", 1.35))
|
||||
|
||||
# ---- knowledge sources ----------------------------------------------
|
||||
use_termbank: bool = _env("ASR_TERMBANK", "1") != "0"
|
||||
use_hindsight: bool = _env("ASR_HINDSIGHT", "1") != "0"
|
||||
use_keyword_recall: bool = _env("ASR_KEYWORD_RECALL", "1") != "0"
|
||||
use_lexicon: bool = _env("ASR_LEXICON", "1") != "0"
|
||||
use_normalize: bool = _env("ASR_NORMALIZE", "1") != "0"
|
||||
use_evidence: bool = _env("ASR_EVIDENCE", "1") != "0"
|
||||
hindsight_api: str = _env("HINDSIGHT_API", "http://192.168.50.47:8888")
|
||||
hindsight_bank: str = _env("HINDSIGHT_BANK", "personal-context")
|
||||
hindsight_timeout: float = _envf("HINDSIGHT_TIMEOUT", 12.0)
|
||||
flag_min_score: float = _envf("FLAG_MIN_SCORE", 0.86)
|
||||
review_min_score: float = _envf("REVIEW_MIN_SCORE", 0.82)
|
||||
review_max_score: float = _envf("REVIEW_MAX_SCORE", 0.96)
|
||||
review_host: str = _env("ASR_REVIEW_HOST", "0.0.0.0")
|
||||
review_public_host: str = _env("ASR_REVIEW_PUBLIC_HOST", "192.168.50.145")
|
||||
review_port: int = _envi("REVIEW_PORT", 8765)
|
||||
review_idle_seconds: int = _envi("REVIEW_IDLE_SECONDS", 2700)
|
||||
review_max_seconds: int = _envi("REVIEW_MAX_SECONDS", 7200)
|
||||
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(
|
||||
default_factory=lambda: _env("ASR_LEXICON", "1") != "0")
|
||||
use_normalize: bool = field(
|
||||
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(
|
||||
default_factory=lambda: _envf("FLAG_MIN_SCORE", 0.86))
|
||||
review_min_score: float = field(
|
||||
default_factory=lambda: _envf("REVIEW_MIN_SCORE", 0.82))
|
||||
review_max_score: float = field(
|
||||
default_factory=lambda: _envf("REVIEW_MAX_SCORE", 0.96))
|
||||
review_host: str = field(
|
||||
default_factory=lambda: _env("ASR_REVIEW_HOST", "0.0.0.0"))
|
||||
review_public_host: str = field(
|
||||
default_factory=lambda: _env("ASR_REVIEW_PUBLIC_HOST", "192.168.50.145"))
|
||||
review_port: int = field(default_factory=lambda: _envi("REVIEW_PORT", 8765))
|
||||
review_idle_seconds: int = field(
|
||||
default_factory=lambda: _envi("REVIEW_IDLE_SECONDS", 2700))
|
||||
review_max_seconds: int = field(
|
||||
default_factory=lambda: _envi("REVIEW_MAX_SECONDS", 7200))
|
||||
|
||||
# ---- behaviour ------------------------------------------------------
|
||||
git_sync: bool = _env("ASR_GIT_SYNC", "1") != "0"
|
||||
notify: bool = _env("ASR_NOTIFY", "1") != "0"
|
||||
cache_llm: bool = _env("ASR_CACHE", "0") != "0" # off in production
|
||||
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")
|
||||
|
||||
@property
|
||||
def inbox(self) -> Path:
|
||||
@@ -120,7 +180,6 @@ class Config:
|
||||
|
||||
@property
|
||||
def pending_index(self) -> Path:
|
||||
# Legacy compatibility only; the Web queue replaced the Vault draft index.
|
||||
return self.vault / "minutes" / "_drafts" / "_待确认.md"
|
||||
|
||||
@property
|
||||
@@ -150,15 +209,16 @@ class Config:
|
||||
|
||||
@property
|
||||
def notifier(self) -> Path:
|
||||
return self.obsidian / "bin" / "meeting-status-notify.py"
|
||||
return Path(_env("ASR_NOTIFIER", str(ROOT / "scripts" / "meeting-status-notify.py")))
|
||||
|
||||
@property
|
||||
def env_file(self) -> Path:
|
||||
"""600-perm credentials file, shared with the legacy systemd unit."""
|
||||
return Path(_env("ASR_ENV_FILE", str(self.obsidian / "bin" / ".asr-refine.env")))
|
||||
"""600-perm credentials file."""
|
||||
return Path(_env("ASR_ENV_FILE", str(ROOT / ".env")))
|
||||
|
||||
def api_key(self) -> str:
|
||||
for k in ("ZENMUX_API_KEY", "ANTHROPIC_AUTH_TOKEN"):
|
||||
for k in ("LITELLM_API_KEY", "ASR_LLM_API_KEY", "OPENAI_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN"):
|
||||
v = os.environ.get(k)
|
||||
if v:
|
||||
return v
|
||||
@@ -166,13 +226,15 @@ class Config:
|
||||
if p.exists():
|
||||
for line in p.read_text(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("ANTHROPIC_AUTH_TOKEN="):
|
||||
val = line.split("=", 1)[1].strip().strip("'\"")
|
||||
if val:
|
||||
return val
|
||||
for prefix in ("LITELLM_API_KEY=", "ASR_LLM_API_KEY=",
|
||||
"OPENAI_API_KEY=", "ANTHROPIC_AUTH_TOKEN="):
|
||||
if line.startswith(prefix):
|
||||
val = line.split("=", 1)[1].strip().strip("'\"")
|
||||
if val:
|
||||
return val
|
||||
raise RuntimeError(
|
||||
"no API key: set ZENMUX_API_KEY/ANTHROPIC_AUTH_TOKEN or provide "
|
||||
f"{p} with ANTHROPIC_AUTH_TOKEN=…")
|
||||
"no API key: set LITELLM_API_KEY / ASR_LLM_API_KEY / OPENAI_API_KEY "
|
||||
f"or provide {p}")
|
||||
|
||||
|
||||
CFG = Config()
|
||||
|
||||
Reference in New Issue
Block a user