#!/usr/bin/env python3 """Configuration for the standalone ASR post-processing pipeline. 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. 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. """ from __future__ import annotations import os 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 def _env(name: str, default: str) -> str: v = os.environ.get(name) return v if v not in (None, "") else default def _envf(name: str, default: float) -> float: try: return float(os.environ["ASR_" + name]) except (KeyError, ValueError): return default def _envi(name: str, default: int) -> int: try: return int(os.environ["ASR_" + name]) except (KeyError, ValueError): return default @dataclass class Config: # ---- paths ---------------------------------------------------------- vault: Path = field(default_factory=lambda: Path( _env("ASR_VAULT", str(Path.home() / "obsidian-vault")))) obsidian: Path = OBSIDIAN 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") 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) # ---- chunking ------------------------------------------------------- chunk_budget: int = _envi("CHUNK_BUDGET", 1800) chunk_overlap: int = _envi("CHUNK_OVERLAP", 200) collapse_noise: bool = _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) # ---- 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) # ---- 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 @property def inbox(self) -> Path: return self.vault / ".asr-inbox" @property def state(self) -> Path: return self.inbox / ".state" @property def drafts(self) -> Path: """Private rough drafts. They are never part of the Obsidian repository.""" return Path(_env("ASR_DRAFTS_DIR", str(self.state_dir / "drafts"))) @property def minutes(self) -> Path: return Path(_env("ASR_RESULTS_DIR", str(self.vault / "minutes"))) @property def pending_index(self) -> Path: # Legacy compatibility only; the Web queue replaced the Vault draft index. return self.vault / "minutes" / "_drafts" / "_待确认.md" @property def glossary(self) -> Path: return self.vault / "minutes" / "_glossary.md" @property def termbank_dir(self) -> Path: return Path(_env("ASR_TERMBANK_DIR", str(self.root / "termbank"))) @property def cache_dir(self) -> Path: return Path(_env("ASR_CACHE_DIR", str(self.root / "cache"))) @property def state_dir(self) -> Path: """Program-owned persistent state. Never inside the vault.""" return Path(_env("ASR_STATE_DIR", str(self.root / "state"))) @property def lexicon_db(self) -> Path: return Path(_env("ASR_LEXICON_DB", str(self.state_dir / "lexicon.db"))) @property def log_file(self) -> Path: return Path(_env("ASR_LOG", str(self.obsidian / "logs" / "asr-pipeline.log"))) @property def notifier(self) -> Path: return self.obsidian / "bin" / "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"))) def api_key(self) -> str: for k in ("ZENMUX_API_KEY", "ANTHROPIC_AUTH_TOKEN"): v = os.environ.get(k) if v: return v p = self.env_file 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 raise RuntimeError( "no API key: set ZENMUX_API_KEY/ANTHROPIC_AUTH_TOKEN or provide " f"{p} with ANTHROPIC_AUTH_TOKEN=…") CFG = Config()