Refine config and runtime flow
This commit is contained in:
parent
85e082dd46
commit
b3be444625
16 changed files with 642 additions and 137 deletions
184
src/config.py
184
src/config.py
|
|
@ -1,16 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from constants import DEFAULT_CONFIG_PATH
|
||||
|
||||
|
||||
DEFAULT_HOTKEY = "Cmd+m"
|
||||
DEFAULT_STT_MODEL = "base"
|
||||
DEFAULT_STT_DEVICE = "cpu"
|
||||
DEFAULT_INJECTION_BACKEND = "clipboard"
|
||||
ALLOWED_INJECTION_BACKENDS = {"clipboard", "injection"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DaemonConfig:
|
||||
hotkey: str = DEFAULT_HOTKEY
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingConfig:
|
||||
input: str | int | None = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SttConfig:
|
||||
model: str = DEFAULT_STT_MODEL
|
||||
device: str = DEFAULT_STT_DEVICE
|
||||
|
||||
|
||||
@dataclass
|
||||
class InjectionConfig:
|
||||
backend: str = DEFAULT_INJECTION_BACKEND
|
||||
|
||||
|
||||
@dataclass
|
||||
class AiConfig:
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoggingConfig:
|
||||
log_transcript: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
daemon: dict = field(default_factory=lambda: {"hotkey": "Cmd+m"})
|
||||
recording: dict = field(default_factory=lambda: {"input": ""})
|
||||
stt: dict = field(default_factory=lambda: {"model": "base", "device": "cpu"})
|
||||
injection: dict = field(default_factory=lambda: {"backend": "clipboard"})
|
||||
daemon: DaemonConfig = field(default_factory=DaemonConfig)
|
||||
recording: RecordingConfig = field(default_factory=RecordingConfig)
|
||||
stt: SttConfig = field(default_factory=SttConfig)
|
||||
injection: InjectionConfig = field(default_factory=InjectionConfig)
|
||||
ai: AiConfig = field(default_factory=AiConfig)
|
||||
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
||||
|
||||
|
||||
def load(path: str | None) -> Config:
|
||||
|
|
@ -18,33 +61,120 @@ def load(path: str | None) -> Config:
|
|||
p = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if any(k in data for k in ("daemon", "recording", "stt", "injection")):
|
||||
for k, v in data.items():
|
||||
if hasattr(cfg, k):
|
||||
setattr(cfg, k, v)
|
||||
else:
|
||||
cfg.daemon["hotkey"] = data.get("hotkey", cfg.daemon["hotkey"])
|
||||
cfg.recording["input"] = data.get("input", cfg.recording["input"])
|
||||
cfg.stt["model"] = data.get("whisper_model", cfg.stt["model"])
|
||||
cfg.stt["device"] = data.get("whisper_device", cfg.stt["device"])
|
||||
cfg.injection["backend"] = data.get("injection_backend", cfg.injection["backend"])
|
||||
|
||||
if not isinstance(cfg.daemon, dict):
|
||||
cfg.daemon = {"hotkey": "Cmd+m"}
|
||||
if not isinstance(cfg.recording, dict):
|
||||
cfg.recording = {"input": ""}
|
||||
if not isinstance(cfg.stt, dict):
|
||||
cfg.stt = {"model": "base", "device": "cpu"}
|
||||
if not isinstance(cfg.injection, dict):
|
||||
cfg.injection = {"backend": "clipboard"}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("config must be a JSON object")
|
||||
cfg = _from_dict(data, cfg)
|
||||
validate(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def redacted_dict(cfg: Config) -> dict:
|
||||
return cfg.__dict__.copy()
|
||||
def redacted_dict(cfg: Config) -> dict[str, Any]:
|
||||
return asdict(cfg)
|
||||
|
||||
|
||||
def validate(cfg: Config) -> None:
|
||||
if not cfg.daemon.get("hotkey"):
|
||||
hotkey = cfg.daemon.hotkey.strip()
|
||||
if not hotkey:
|
||||
raise ValueError("daemon.hotkey cannot be empty")
|
||||
|
||||
if isinstance(cfg.recording.input, bool):
|
||||
raise ValueError("recording.input cannot be boolean")
|
||||
if not isinstance(cfg.recording.input, (str, int)) and cfg.recording.input is not None:
|
||||
raise ValueError("recording.input must be string, integer, or null")
|
||||
|
||||
model = cfg.stt.model.strip()
|
||||
if not model:
|
||||
raise ValueError("stt.model cannot be empty")
|
||||
|
||||
device = cfg.stt.device.strip()
|
||||
if not device:
|
||||
raise ValueError("stt.device cannot be empty")
|
||||
|
||||
backend = cfg.injection.backend.strip().lower()
|
||||
if backend not in ALLOWED_INJECTION_BACKENDS:
|
||||
allowed = ", ".join(sorted(ALLOWED_INJECTION_BACKENDS))
|
||||
raise ValueError(f"injection.backend must be one of: {allowed}")
|
||||
cfg.injection.backend = backend
|
||||
|
||||
if not isinstance(cfg.ai.enabled, bool):
|
||||
raise ValueError("ai.enabled must be boolean")
|
||||
|
||||
if not isinstance(cfg.logging.log_transcript, bool):
|
||||
raise ValueError("logging.log_transcript must be boolean")
|
||||
|
||||
|
||||
def _from_dict(data: dict[str, Any], cfg: Config) -> Config:
|
||||
has_sections = any(
|
||||
key in data for key in ("daemon", "recording", "stt", "injection", "ai", "logging")
|
||||
)
|
||||
if has_sections:
|
||||
daemon = _ensure_dict(data.get("daemon"), "daemon")
|
||||
recording = _ensure_dict(data.get("recording"), "recording")
|
||||
stt = _ensure_dict(data.get("stt"), "stt")
|
||||
injection = _ensure_dict(data.get("injection"), "injection")
|
||||
ai = _ensure_dict(data.get("ai"), "ai")
|
||||
logging_cfg = _ensure_dict(data.get("logging"), "logging")
|
||||
|
||||
if "hotkey" in daemon:
|
||||
cfg.daemon.hotkey = _as_nonempty_str(daemon["hotkey"], "daemon.hotkey")
|
||||
if "input" in recording:
|
||||
cfg.recording.input = _as_recording_input(recording["input"])
|
||||
if "model" in stt:
|
||||
cfg.stt.model = _as_nonempty_str(stt["model"], "stt.model")
|
||||
if "device" in stt:
|
||||
cfg.stt.device = _as_nonempty_str(stt["device"], "stt.device")
|
||||
if "backend" in injection:
|
||||
cfg.injection.backend = _as_nonempty_str(injection["backend"], "injection.backend")
|
||||
if "enabled" in ai:
|
||||
cfg.ai.enabled = _as_bool(ai["enabled"], "ai.enabled")
|
||||
if "log_transcript" in logging_cfg:
|
||||
cfg.logging.log_transcript = _as_bool(logging_cfg["log_transcript"], "logging.log_transcript")
|
||||
return cfg
|
||||
|
||||
if "hotkey" in data:
|
||||
cfg.daemon.hotkey = _as_nonempty_str(data["hotkey"], "hotkey")
|
||||
if "input" in data:
|
||||
cfg.recording.input = _as_recording_input(data["input"])
|
||||
if "whisper_model" in data:
|
||||
cfg.stt.model = _as_nonempty_str(data["whisper_model"], "whisper_model")
|
||||
if "whisper_device" in data:
|
||||
cfg.stt.device = _as_nonempty_str(data["whisper_device"], "whisper_device")
|
||||
if "injection_backend" in data:
|
||||
cfg.injection.backend = _as_nonempty_str(data["injection_backend"], "injection_backend")
|
||||
if "ai_enabled" in data:
|
||||
cfg.ai.enabled = _as_bool(data["ai_enabled"], "ai_enabled")
|
||||
if "log_transcript" in data:
|
||||
cfg.logging.log_transcript = _as_bool(data["log_transcript"], "log_transcript")
|
||||
return cfg
|
||||
|
||||
|
||||
def _ensure_dict(value: Any, field_name: str) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{field_name} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _as_nonempty_str(value: Any, field_name: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field_name} must be a string")
|
||||
if not value.strip():
|
||||
raise ValueError(f"{field_name} cannot be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _as_bool(value: Any, field_name: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{field_name} must be boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _as_recording_input(value: Any) -> str | int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("recording.input cannot be boolean")
|
||||
if isinstance(value, (str, int)):
|
||||
return value
|
||||
raise ValueError("recording.input must be string, integer, or null")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue