config.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672
  1. """Configuration management for STT daemon."""
  2. from dataclasses import dataclass, field
  3. import logging
  4. import tomllib
  5. from pathlib import Path
  6. from typing import Any
  7. logger = logging.getLogger(__name__)
  8. KNOWN_SECTIONS = {
  9. "audio",
  10. "vad",
  11. "whisper",
  12. "output",
  13. "sink",
  14. "daemon",
  15. "events",
  16. "assistant",
  17. "audit",
  18. "llm",
  19. "triage",
  20. "speaker",
  21. "brief",
  22. "debug",
  23. }
  24. @dataclass
  25. class AudioConfig:
  26. """Audio capture configuration."""
  27. backend: str = "parec"
  28. device: str = "VirtualMicSink.monitor"
  29. sample_rate: int = 16000
  30. channels: int = 1
  31. frame_ms: int = 30
  32. gain: float = 1.0
  33. @dataclass
  34. class VadConfig:
  35. """Voice Activity Detection configuration."""
  36. aggressiveness: int = 2
  37. pre_roll_ms: int = 400
  38. speech_start_ms: int = 90
  39. silence_ms: int = 700
  40. min_utterance_ms: int = 300
  41. max_utterance_s: float = 30.0
  42. chunk_pause_ms: int = 350
  43. soft_max_s: float = 10.0
  44. rms_floor: float = 0.008
  45. trailing_trim_ms: int = 200
  46. @dataclass
  47. class WhisperConfig:
  48. """Whisper transcription configuration."""
  49. backend: str = "faster-whisper"
  50. model: str = "small"
  51. device: str = "cpu"
  52. compute_type: str = "int8"
  53. language: str | None = None
  54. beam_size: int = 1
  55. vad_filter: bool = False
  56. cpu_threads: int = 0
  57. no_speech_prob_max: float = 0.6
  58. avg_logprob_min: float = -1.0
  59. language_strategy: str = "static"
  60. sticky_redetect_logprob: float = -0.8
  61. @dataclass
  62. class OutputConfig:
  63. """Output configuration."""
  64. stdout: bool = True
  65. jsonl: bool = False
  66. session_dir: str = "transcripts"
  67. save_audio: bool = False
  68. session_gap_min: float = 5.0
  69. @dataclass
  70. class DaemonConfig:
  71. """Daemon runtime configuration."""
  72. queue_size: int = 50
  73. backlog_warn_at: int = 5
  74. @dataclass
  75. class EventsConfig:
  76. """Publish-only event bus configuration."""
  77. socket: str = ""
  78. heartbeat_ms: int = 1000
  79. ring_size: int = 200
  80. @dataclass
  81. class AssistantConfig:
  82. """Assistant (controller) configuration."""
  83. control_socket: str = ""
  84. stall_timeout_ms: int = 3000
  85. worker_stall_ms: int = 15000
  86. command_arm_timeout_s: float = 10.0
  87. chat_idle_timeout_s: float = 300.0
  88. chat_exit_phrases: list[str] = field(
  89. default_factory=lambda: ["end conversation", "that's all", "stop listening"]
  90. )
  91. require_viewer: bool = True
  92. turn_gap_s: float = 1.5
  93. @dataclass
  94. class AuditConfig:
  95. """Action audit configuration (daily JSONL, like transcripts)."""
  96. enabled: bool = True
  97. dir: str = "troubleshoot/audit"
  98. @dataclass
  99. class LlmConfig:
  100. """llama.cpp (OpenAI-compatible) configuration."""
  101. enabled: bool = True
  102. endpoint: str = "http://localhost:9931/v1/chat/completions"
  103. model: str = ""
  104. api_key_env: str = ""
  105. session_id: str = ""
  106. session_header: str = ""
  107. user_agent: str = ""
  108. reasoning_effort: str = ""
  109. extra_body: dict[str, Any] = field(default_factory=dict)
  110. temperature: float = 0.7
  111. max_tokens: int = 1024
  112. timeout_s: float = 120.0
  113. cache_prompt: bool = True
  114. chat_slot_id: int | None = None
  115. command_slot_id: int | None = None
  116. max_context_tokens: int = 131072
  117. compaction_safety_margin: int = 256
  118. compaction_enabled: bool = True
  119. compaction_trigger_ratio: float = 0.8
  120. compaction_keep_turns: int = 6
  121. compaction_max_tokens: int = 512
  122. compaction_timeout_s: float = 30.0
  123. compaction_input_tokens: int = 8192
  124. compaction_system_prompt: str = (
  125. "You compress a conversation. Preserve names, numbers, decisions, user "
  126. "preferences, and unresolved questions. Omit pleasantries. Output only the summary."
  127. )
  128. brief_compaction_system_prompt: str = (
  129. "You compress a triage briefing conversation. Preserve every opportunity, blocker, "
  130. "owner, responsibility, decision, deadline, and open question; drop pleasantries. "
  131. "Output only the summary."
  132. )
  133. triage_system_prompt: str = (
  134. "You maintain a living brief of a business conversation. The caller sends "
  135. "CURRENT LEDGER plus CURRENT BRIEF plus NEW UTTERANCES. Reply with exactly "
  136. "two sections in this order: NEW FACTS: first, then BRIEF:. Facts go first "
  137. "so a truncated reply still preserves extraction (truncation safety). In "
  138. "NEW FACTS:, emit one fact per line as '- <fact>', or the single line "
  139. "'none' when the new utterances add no new facts. Never repeat facts "
  140. "already listed in CURRENT LEDGER. In BRIEF:, return the UPDATED brief as "
  141. "plain text only, with no markdown fences and no JSON. Open with a Summary "
  142. "section: a rolling overview of the conversation so far, one to a few "
  143. "sentences for a short session and growing to a short paragraph or two as "
  144. "the session extends. Preserve prior content unless the new utterances "
  145. "contradict it; merge duplicates; prune stale items; keep owners, "
  146. "responsibilities, and deadlines explicit. Use short sections: Summary, "
  147. "Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, "
  148. "Deadlines, Open Questions. Never silently delete Action Items, Open "
  149. "Questions, or Decisions lines. When an action completes or a question "
  150. "is answered, do NOT drop the line — move it to Decisions as "
  151. "'Done: <original> — resolved: <what happened>' (or keep it in place "
  152. "with a 'Done (...)' suffix) AND list the resolution as a NEW FACTS "
  153. "bullet. Prune only exact duplicates, never resolved history. "
  154. "Never emit a 'Prior Details:' section. Output "
  155. "the two sections directly; never emit tool calls, function-call syntax, "
  156. "or special wrapper tokens."
  157. )
  158. inference_queue_size: int = 8
  159. trim_block_turns: int = 4
  160. max_tool_iterations: int = 3
  161. system_prompt_chat: str = (
  162. "You are a concise voice assistant. Reply in 1-3 sentences."
  163. )
  164. system_prompt_command: str = (
  165. "You translate a spoken request into a single tool call when one applies."
  166. )
  167. @property
  168. def prompt_budget(self) -> int:
  169. """Prompt tokens available: the context window minus generation and slack."""
  170. return self.max_context_tokens - self.max_tokens - self.compaction_safety_margin
  171. @dataclass
  172. class TriageConfig:
  173. """Laya triage-panel configuration (in-process lane, multilingual pinned)."""
  174. enabled: bool = False
  175. questions_file: str = "questions.json"
  176. window: int = 500
  177. state_char_budget: int = 3000
  178. periodic_s: float = 30.0
  179. predict_max_tokens: int = 1024
  180. confidence_field: str = "answer_confidence"
  181. escalate_threshold: float = 0.6
  182. choice_min_confidence: float = 0.85
  183. extract_enabled: bool = True
  184. extract_max_tokens: int = 3072
  185. extract_timeout_s: float = 30.0
  186. extract_temperature: float = 0.2
  187. brief_slot_id: int | None = None
  188. brief_gate_enabled: bool = True
  189. brief_gate_threshold: float = 0.5
  190. brief_max_chars: int = 4000
  191. brief_pending_char_budget: int = 3000
  192. ledger_max_facts: int = 200
  193. fact_max_chars: int = 240
  194. device: str = ""
  195. preload: list[str] = field(default_factory=lambda: ["multilingual"])
  196. default_checkpoint: str = "multilingual"
  197. dump_io: bool = False
  198. @dataclass
  199. class SpeakerConfig:
  200. """Speaker-identification configuration (sherpa-onnx embedding lane)."""
  201. enabled: bool = False
  202. model_path: str | None = None
  203. provider: str = "cpu"
  204. threshold_id: float = 0.60
  205. threshold_new: float = 0.45
  206. min_enroll_s: float = 2.0
  207. rms_floor: float = 0.0
  208. margin: float = 0.04
  209. mint_cooldown_s: float = 60.0
  210. confirm_s: float = 12.0
  211. max_prototypes: int = 8
  212. provisional_ttl_h: float = 24.0
  213. store_dir: str = "speakers"
  214. join_timeout_s: float = 2.0
  215. @dataclass
  216. class BriefConfig:
  217. """Durable brief document configuration (markdown snapshot written to disk)."""
  218. write: bool = False
  219. @dataclass
  220. class DebugConfig:
  221. """Debug signal-log configuration (JSONL diagnostics, gitignored)."""
  222. enabled: bool = False
  223. dir: str = "troubleshoot/debug"
  224. heartbeat: bool = True
  225. max_bytes_per_day: int = 50000000
  226. @dataclass
  227. class SinkConfig:
  228. """Virtual audio sink configuration."""
  229. sink_name: str = "VirtualMicSink"
  230. remap_source_name: str = "VirtualMic"
  231. loopback_latency_msec: int = 1
  232. monitor_output: bool = False
  233. @dataclass
  234. class Config:
  235. """Complete daemon configuration."""
  236. audio: AudioConfig = field(default_factory=AudioConfig)
  237. vad: VadConfig = field(default_factory=VadConfig)
  238. whisper: WhisperConfig = field(default_factory=WhisperConfig)
  239. output: OutputConfig = field(default_factory=OutputConfig)
  240. sink: SinkConfig = field(default_factory=SinkConfig)
  241. daemon: DaemonConfig = field(default_factory=DaemonConfig)
  242. events: EventsConfig = field(default_factory=EventsConfig)
  243. assistant: AssistantConfig = field(default_factory=AssistantConfig)
  244. audit: AuditConfig = field(default_factory=AuditConfig)
  245. llm: LlmConfig = field(default_factory=LlmConfig)
  246. triage: TriageConfig = field(default_factory=TriageConfig)
  247. speaker: SpeakerConfig = field(default_factory=SpeakerConfig)
  248. brief: BriefConfig = field(default_factory=BriefConfig)
  249. debug: DebugConfig = field(default_factory=DebugConfig)
  250. def _optional_int(value: Any) -> int | None:
  251. """Coerce an optional TOML value to int, degrading to None on bad input.
  252. TOML has no null, so an absent key is "unset" and reaches this helper as
  253. ``None``. ``0`` is a valid slot id and must never be mistaken for unset.
  254. """
  255. if value is None:
  256. return None
  257. try:
  258. return int(value)
  259. except (TypeError, ValueError):
  260. return None
  261. def load_config(path: str | None = None) -> Config:
  262. """Load configuration from TOML file, with defaults.
  263. Args:
  264. path: Path to config.toml. If None, looks for config.toml in cwd,
  265. then uses all defaults.
  266. Returns:
  267. Config with loaded values overlayed on defaults.
  268. """
  269. cfg = Config()
  270. # Try to find and load config file
  271. config_path = None
  272. if path:
  273. config_path = Path(path)
  274. else:
  275. default_path = Path("config.toml")
  276. if default_path.exists():
  277. config_path = default_path
  278. if config_path and config_path.exists():
  279. with open(config_path, "rb") as f:
  280. data = tomllib.load(f)
  281. unknown = set(data) - KNOWN_SECTIONS
  282. if unknown:
  283. logger.warning(
  284. "Ignoring unknown config sections: %s", ", ".join(sorted(unknown))
  285. )
  286. # Audio section
  287. if "audio" in data:
  288. audio_data = data["audio"]
  289. cfg.audio = AudioConfig(
  290. backend=audio_data.get("backend", cfg.audio.backend),
  291. device=audio_data.get("device", cfg.audio.device),
  292. sample_rate=audio_data.get("sample_rate", cfg.audio.sample_rate),
  293. channels=audio_data.get("channels", cfg.audio.channels),
  294. frame_ms=audio_data.get("frame_ms", cfg.audio.frame_ms),
  295. gain=audio_data.get("gain", cfg.audio.gain),
  296. )
  297. # VAD section
  298. if "vad" in data:
  299. vad_data = data["vad"]
  300. cfg.vad = VadConfig(
  301. aggressiveness=vad_data.get("aggressiveness", cfg.vad.aggressiveness),
  302. pre_roll_ms=vad_data.get("pre_roll_ms", cfg.vad.pre_roll_ms),
  303. speech_start_ms=vad_data.get("speech_start_ms", cfg.vad.speech_start_ms),
  304. silence_ms=vad_data.get("silence_ms", cfg.vad.silence_ms),
  305. min_utterance_ms=vad_data.get("min_utterance_ms", cfg.vad.min_utterance_ms),
  306. max_utterance_s=vad_data.get("max_utterance_s", cfg.vad.max_utterance_s),
  307. chunk_pause_ms=vad_data.get("chunk_pause_ms", cfg.vad.chunk_pause_ms),
  308. soft_max_s=vad_data.get("soft_max_s", cfg.vad.soft_max_s),
  309. rms_floor=vad_data.get("rms_floor", cfg.vad.rms_floor),
  310. trailing_trim_ms=vad_data.get("trailing_trim_ms", cfg.vad.trailing_trim_ms),
  311. )
  312. # Whisper section
  313. if "whisper" in data:
  314. whisper_data = data["whisper"]
  315. backend = whisper_data.get("backend", cfg.whisper.backend)
  316. backend = backend.strip().lower().replace("_", "-")
  317. if backend not in ("faster-whisper", "pywhispercpp"):
  318. logger.warning(
  319. "Unknown whisper backend %r; falling back to 'faster-whisper'", backend
  320. )
  321. backend = "faster-whisper"
  322. language = whisper_data.get("language", cfg.whisper.language)
  323. # Normalize language: "", "auto", "none" -> None
  324. if language in ("", "auto", "none"):
  325. language = None
  326. strategy = whisper_data.get("language_strategy", cfg.whisper.language_strategy)
  327. if strategy not in ("static", "sticky"):
  328. logger.warning(
  329. "Unknown language_strategy %r; falling back to 'static'", strategy
  330. )
  331. strategy = "static"
  332. cfg.whisper = WhisperConfig(
  333. backend=backend,
  334. model=whisper_data.get("model", cfg.whisper.model),
  335. device=whisper_data.get("device", cfg.whisper.device),
  336. compute_type=whisper_data.get("compute_type", cfg.whisper.compute_type),
  337. language=language,
  338. beam_size=whisper_data.get("beam_size", cfg.whisper.beam_size),
  339. vad_filter=whisper_data.get("vad_filter", cfg.whisper.vad_filter),
  340. cpu_threads=whisper_data.get("cpu_threads", cfg.whisper.cpu_threads),
  341. no_speech_prob_max=whisper_data.get(
  342. "no_speech_prob_max", cfg.whisper.no_speech_prob_max
  343. ),
  344. avg_logprob_min=whisper_data.get(
  345. "avg_logprob_min", cfg.whisper.avg_logprob_min
  346. ),
  347. language_strategy=strategy,
  348. sticky_redetect_logprob=whisper_data.get(
  349. "sticky_redetect_logprob", cfg.whisper.sticky_redetect_logprob
  350. ),
  351. )
  352. # Output section
  353. if "output" in data:
  354. output_data = data["output"]
  355. cfg.output = OutputConfig(
  356. stdout=output_data.get("stdout", cfg.output.stdout),
  357. jsonl=output_data.get("jsonl", cfg.output.jsonl),
  358. session_dir=output_data.get("session_dir", cfg.output.session_dir),
  359. save_audio=output_data.get("save_audio", cfg.output.save_audio),
  360. session_gap_min=output_data.get(
  361. "session_gap_min", cfg.output.session_gap_min
  362. ),
  363. )
  364. # Sink section
  365. if "sink" in data:
  366. sink_data = data["sink"]
  367. cfg.sink = SinkConfig(
  368. sink_name=sink_data.get("sink_name", cfg.sink.sink_name),
  369. remap_source_name=sink_data.get(
  370. "remap_source_name", cfg.sink.remap_source_name
  371. ),
  372. loopback_latency_msec=sink_data.get(
  373. "loopback_latency_msec", cfg.sink.loopback_latency_msec
  374. ),
  375. monitor_output=sink_data.get("monitor_output", cfg.sink.monitor_output),
  376. )
  377. # Daemon section
  378. if "daemon" in data:
  379. daemon_data = data["daemon"]
  380. cfg.daemon = DaemonConfig(
  381. queue_size=daemon_data.get("queue_size", cfg.daemon.queue_size),
  382. backlog_warn_at=daemon_data.get(
  383. "backlog_warn_at", cfg.daemon.backlog_warn_at
  384. ),
  385. )
  386. # Events section
  387. if "events" in data:
  388. events_data = data["events"]
  389. cfg.events = EventsConfig(
  390. socket=events_data.get("socket", cfg.events.socket),
  391. heartbeat_ms=events_data.get("heartbeat_ms", cfg.events.heartbeat_ms),
  392. ring_size=events_data.get("ring_size", cfg.events.ring_size),
  393. )
  394. # Assistant section
  395. if "assistant" in data:
  396. assistant_data = data["assistant"]
  397. cfg.assistant = AssistantConfig(
  398. control_socket=assistant_data.get(
  399. "control_socket", cfg.assistant.control_socket
  400. ),
  401. stall_timeout_ms=assistant_data.get(
  402. "stall_timeout_ms", cfg.assistant.stall_timeout_ms
  403. ),
  404. worker_stall_ms=assistant_data.get(
  405. "worker_stall_ms", cfg.assistant.worker_stall_ms
  406. ),
  407. command_arm_timeout_s=assistant_data.get(
  408. "command_arm_timeout_s", cfg.assistant.command_arm_timeout_s
  409. ),
  410. chat_idle_timeout_s=assistant_data.get(
  411. "chat_idle_timeout_s", cfg.assistant.chat_idle_timeout_s
  412. ),
  413. chat_exit_phrases=list(
  414. assistant_data.get(
  415. "chat_exit_phrases", cfg.assistant.chat_exit_phrases
  416. )
  417. ),
  418. require_viewer=assistant_data.get(
  419. "require_viewer", cfg.assistant.require_viewer
  420. ),
  421. turn_gap_s=assistant_data.get("turn_gap_s", cfg.assistant.turn_gap_s),
  422. )
  423. # Audit section
  424. if "audit" in data:
  425. audit_data = data["audit"]
  426. cfg.audit = AuditConfig(
  427. enabled=audit_data.get("enabled", cfg.audit.enabled),
  428. dir=audit_data.get("dir", cfg.audit.dir),
  429. )
  430. # LLM section
  431. if "llm" in data:
  432. llm_data = data["llm"]
  433. if "max_prompt_tokens" in llm_data:
  434. logger.warning(
  435. "llm.max_prompt_tokens is deprecated and ignored; the prompt budget is "
  436. "derived as max_context_tokens - max_tokens - compaction_safety_margin"
  437. )
  438. cfg.llm = LlmConfig(
  439. enabled=llm_data.get("enabled", cfg.llm.enabled),
  440. endpoint=llm_data.get("endpoint", cfg.llm.endpoint),
  441. model=llm_data.get("model", cfg.llm.model),
  442. api_key_env=llm_data.get("api_key_env", cfg.llm.api_key_env),
  443. session_id=llm_data.get("session_id", cfg.llm.session_id),
  444. session_header=llm_data.get("session_header", cfg.llm.session_header),
  445. user_agent=llm_data.get("user_agent", cfg.llm.user_agent),
  446. reasoning_effort=llm_data.get(
  447. "reasoning_effort", cfg.llm.reasoning_effort
  448. ),
  449. extra_body=dict(llm_data.get("extra_body", cfg.llm.extra_body)),
  450. temperature=llm_data.get("temperature", cfg.llm.temperature),
  451. max_tokens=llm_data.get("max_tokens", cfg.llm.max_tokens),
  452. timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s),
  453. cache_prompt=llm_data.get("cache_prompt", cfg.llm.cache_prompt),
  454. chat_slot_id=_optional_int(llm_data.get("chat_slot_id")),
  455. command_slot_id=_optional_int(llm_data.get("command_slot_id")),
  456. max_context_tokens=llm_data.get(
  457. "max_context_tokens", cfg.llm.max_context_tokens
  458. ),
  459. compaction_safety_margin=llm_data.get(
  460. "compaction_safety_margin", cfg.llm.compaction_safety_margin
  461. ),
  462. compaction_enabled=llm_data.get(
  463. "compaction_enabled", cfg.llm.compaction_enabled
  464. ),
  465. compaction_trigger_ratio=llm_data.get(
  466. "compaction_trigger_ratio", cfg.llm.compaction_trigger_ratio
  467. ),
  468. compaction_keep_turns=llm_data.get(
  469. "compaction_keep_turns", cfg.llm.compaction_keep_turns
  470. ),
  471. compaction_max_tokens=llm_data.get(
  472. "compaction_max_tokens", cfg.llm.compaction_max_tokens
  473. ),
  474. compaction_timeout_s=llm_data.get(
  475. "compaction_timeout_s", cfg.llm.compaction_timeout_s
  476. ),
  477. compaction_input_tokens=llm_data.get(
  478. "compaction_input_tokens", cfg.llm.compaction_input_tokens
  479. ),
  480. compaction_system_prompt=llm_data.get(
  481. "compaction_system_prompt", cfg.llm.compaction_system_prompt
  482. ),
  483. brief_compaction_system_prompt=llm_data.get(
  484. "brief_compaction_system_prompt", cfg.llm.brief_compaction_system_prompt
  485. ),
  486. triage_system_prompt=llm_data.get(
  487. "triage_system_prompt", cfg.llm.triage_system_prompt
  488. ),
  489. inference_queue_size=llm_data.get(
  490. "inference_queue_size", cfg.llm.inference_queue_size
  491. ),
  492. trim_block_turns=llm_data.get(
  493. "trim_block_turns", cfg.llm.trim_block_turns
  494. ),
  495. max_tool_iterations=llm_data.get(
  496. "max_tool_iterations", cfg.llm.max_tool_iterations
  497. ),
  498. system_prompt_chat=llm_data.get(
  499. "system_prompt_chat", cfg.llm.system_prompt_chat
  500. ),
  501. system_prompt_command=llm_data.get(
  502. "system_prompt_command", cfg.llm.system_prompt_command
  503. ),
  504. )
  505. # Triage section
  506. if "triage" in data:
  507. triage_data = data["triage"]
  508. cfg.triage = TriageConfig(
  509. enabled=triage_data.get("enabled", cfg.triage.enabled),
  510. questions_file=triage_data.get("questions_file", cfg.triage.questions_file),
  511. window=triage_data.get("window", cfg.triage.window),
  512. state_char_budget=triage_data.get(
  513. "state_char_budget", cfg.triage.state_char_budget
  514. ),
  515. periodic_s=triage_data.get("periodic_s", cfg.triage.periodic_s),
  516. predict_max_tokens=triage_data.get(
  517. "predict_max_tokens", cfg.triage.predict_max_tokens
  518. ),
  519. confidence_field=triage_data.get(
  520. "confidence_field", cfg.triage.confidence_field
  521. ),
  522. escalate_threshold=triage_data.get(
  523. "escalate_threshold", cfg.triage.escalate_threshold
  524. ),
  525. choice_min_confidence=triage_data.get(
  526. "choice_min_confidence", cfg.triage.choice_min_confidence
  527. ),
  528. extract_enabled=triage_data.get(
  529. "extract_enabled", cfg.triage.extract_enabled
  530. ),
  531. extract_max_tokens=triage_data.get(
  532. "extract_max_tokens", cfg.triage.extract_max_tokens
  533. ),
  534. extract_timeout_s=triage_data.get(
  535. "extract_timeout_s", cfg.triage.extract_timeout_s
  536. ),
  537. extract_temperature=triage_data.get(
  538. "extract_temperature", cfg.triage.extract_temperature
  539. ),
  540. brief_slot_id=_optional_int(triage_data.get("brief_slot_id")),
  541. brief_gate_enabled=triage_data.get(
  542. "brief_gate_enabled", cfg.triage.brief_gate_enabled
  543. ),
  544. brief_gate_threshold=triage_data.get(
  545. "brief_gate_threshold", cfg.triage.brief_gate_threshold
  546. ),
  547. brief_max_chars=triage_data.get(
  548. "brief_max_chars", cfg.triage.brief_max_chars
  549. ),
  550. brief_pending_char_budget=triage_data.get(
  551. "brief_pending_char_budget", cfg.triage.brief_pending_char_budget
  552. ),
  553. ledger_max_facts=triage_data.get(
  554. "ledger_max_facts", cfg.triage.ledger_max_facts
  555. ),
  556. fact_max_chars=triage_data.get(
  557. "fact_max_chars", cfg.triage.fact_max_chars
  558. ),
  559. device=triage_data.get("device", cfg.triage.device),
  560. preload=list(triage_data.get("preload", cfg.triage.preload)),
  561. default_checkpoint=triage_data.get(
  562. "default_checkpoint", cfg.triage.default_checkpoint
  563. ),
  564. dump_io=triage_data.get("dump_io", cfg.triage.dump_io),
  565. )
  566. # Speaker section
  567. if "speaker" in data:
  568. speaker_data = data["speaker"]
  569. provider = speaker_data.get("provider", cfg.speaker.provider)
  570. provider = provider.strip().lower()
  571. if provider not in ("cpu", "cuda"):
  572. logger.warning(
  573. "Unknown speaker provider %r; falling back to 'cpu'", provider
  574. )
  575. provider = "cpu"
  576. cfg.speaker = SpeakerConfig(
  577. enabled=speaker_data.get("enabled", cfg.speaker.enabled),
  578. model_path=speaker_data.get("model_path", cfg.speaker.model_path),
  579. provider=provider,
  580. threshold_id=speaker_data.get("threshold_id", cfg.speaker.threshold_id),
  581. threshold_new=speaker_data.get("threshold_new", cfg.speaker.threshold_new),
  582. min_enroll_s=speaker_data.get("min_enroll_s", cfg.speaker.min_enroll_s),
  583. rms_floor=speaker_data.get("rms_floor", cfg.speaker.rms_floor),
  584. margin=speaker_data.get("margin", cfg.speaker.margin),
  585. mint_cooldown_s=speaker_data.get(
  586. "mint_cooldown_s", cfg.speaker.mint_cooldown_s
  587. ),
  588. confirm_s=speaker_data.get("confirm_s", cfg.speaker.confirm_s),
  589. max_prototypes=speaker_data.get("max_prototypes", cfg.speaker.max_prototypes),
  590. provisional_ttl_h=speaker_data.get(
  591. "provisional_ttl_h", cfg.speaker.provisional_ttl_h
  592. ),
  593. store_dir=speaker_data.get("store_dir", cfg.speaker.store_dir),
  594. join_timeout_s=speaker_data.get("join_timeout_s", cfg.speaker.join_timeout_s),
  595. )
  596. # Brief section
  597. if "brief" in data:
  598. brief_data = data["brief"]
  599. cfg.brief = BriefConfig(
  600. write=brief_data.get("write", cfg.brief.write),
  601. )
  602. # Debug section
  603. if "debug" in data:
  604. debug_data = data["debug"]
  605. cfg.debug = DebugConfig(
  606. enabled=debug_data.get("enabled", cfg.debug.enabled),
  607. dir=debug_data.get("dir", cfg.debug.dir),
  608. heartbeat=debug_data.get("heartbeat", cfg.debug.heartbeat),
  609. max_bytes_per_day=debug_data.get(
  610. "max_bytes_per_day", cfg.debug.max_bytes_per_day
  611. ),
  612. )
  613. return cfg