Files
AV-Watch/avwatch/config.py
T

194 lines
6.6 KiB
Python
Raw Normal View History

"""Configuration loading.
Secrets (SMTP password, API keys) are read from environment variables so the
TOML file can live in version control or a shared drive without leaking
credentials. Env vars win over anything in the file.
"""
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class EmailConfig:
enabled: bool = False
smtp_host: str = ""
smtp_port: int = 587
use_starttls: bool = True
use_ssl: bool = False
username: str = ""
password: str = "" # from AVWATCH_SMTP_PASSWORD
from_addr: str = ""
from_name: str = "AVWatch"
to: list[str] = field(default_factory=list)
cc: list[str] = field(default_factory=list)
subject_prefix: str = "[AVWatch]"
attach_html: bool = True
send_when_nothing_new: bool = True
@dataclass
class RiskConfig:
enabled: bool = True
max_queries_per_item: int = 2
max_evidence_per_item: int = 8
lookback_days: int = 400
use_duckduckgo: bool = True
use_reddit: bool = True
use_github_issues: bool = True
use_hackernews: bool = False
reddit_client_id: str = "" # from AVWATCH_REDDIT_CLIENT_ID
reddit_client_secret: str = "" # from AVWATCH_REDDIT_CLIENT_SECRET
subreddits: list[str] = field(default_factory=lambda: [
"livesound", "VIDEOENGINEERING", "churchtech", "ProPresenter",
"blackmagicdesign", "audioengineering", "shure", "commandcenter",
])
# Only run community scans for items we are actually behind on, to keep
# the query budget sane.
only_when_behind: bool = True
@dataclass
class LLMConfig:
enabled: bool = True
# Set model = "claude-sonnet-5" in config.toml to cut per-run cost roughly
# in half if the Opus verdicts read as more than you need.
model: str = "claude-opus-5"
api_key: str = "" # from ANTHROPIC_API_KEY
max_items_per_run: int = 40
max_output_tokens: int = 1400
# Escalate to the strongest model for show-critical gear.
escalate_model: str = "claude-opus-5"
escalate_for_criticality: list[str] = field(default_factory=lambda: ["showstopper"])
@dataclass
class LookupConfig:
"""Claude-with-web-search version lookup, for the many vendors whose
support sites are JavaScript-rendered and cannot be scraped."""
enabled: bool = True
# A factual retrieval job, not a judgement call, so it runs on the cheaper
# model by default. Raise to claude-opus-5 if you see it getting versions
# wrong on obscure gear.
model: str = "claude-sonnet-5"
api_key: str = "" # from ANTHROPIC_API_KEY
ttl_days: float = 6.0 # Monday pays; mid-week scans reuse
max_items_per_run: int = 45 # hard ceiling on cost per run
max_searches_per_item: int = 4
@dataclass
class PolicyConfig:
"""Local rules that shape recommendations independent of what the
internet says."""
# A release younger than this is 'wait' by default for critical gear.
soak_days_showstopper: int = 30
soak_days_high: int = 21
soak_days_normal: int = 10
# Never recommend installing during these ISO weekday numbers (1=Mon).
blackout_weekdays: list[int] = field(default_factory=lambda: [6, 7])
# Treat a major version jump on critical gear as needing a human.
major_bump_needs_human: bool = True
@dataclass
class Config:
root: Path
data_dir: Path
cache_dir: Path
db_path: Path
report_dir: Path
inventory_path: Path
cache_ttl: int = 3600
http_timeout: int = 30
log_level: str = "INFO"
email: EmailConfig = field(default_factory=EmailConfig)
risk: RiskConfig = field(default_factory=RiskConfig)
llm: LLMConfig = field(default_factory=LLMConfig)
lookup: LookupConfig = field(default_factory=LookupConfig)
policy: PolicyConfig = field(default_factory=PolicyConfig)
def _as_list(v) -> list[str]:
if v is None:
return []
if isinstance(v, str):
return [x.strip() for x in v.split(",") if x.strip()]
return [str(x) for x in v]
def _fill(dc, table: dict) -> None:
"""Copy known keys from a TOML table onto a dataclass instance."""
for key, val in table.items():
if not hasattr(dc, key):
continue
cur = getattr(dc, key)
if isinstance(cur, list):
val = _as_list(val)
elif isinstance(cur, bool):
val = bool(val)
elif isinstance(cur, int) and not isinstance(val, bool):
try:
val = int(val)
except (TypeError, ValueError):
continue
setattr(dc, key, val)
def load(config_path: Path | None = None, root: Path | None = None) -> Config:
root = Path(root or Path(__file__).resolve().parent.parent)
config_path = Path(config_path) if config_path else root / "config.toml"
raw: dict = {}
if config_path.exists():
with open(config_path, "rb") as fh:
raw = tomllib.load(fh)
paths = raw.get("paths", {})
data_dir = Path(paths.get("data_dir") or root / "data")
cfg = Config(
root=root,
data_dir=data_dir,
cache_dir=Path(paths.get("cache_dir") or data_dir / "cache"),
db_path=Path(paths.get("db_path") or data_dir / "avwatch.sqlite3"),
report_dir=Path(paths.get("report_dir") or root / "reports"),
inventory_path=Path(paths.get("inventory") or root / "inventory.yaml"),
)
general = raw.get("general", {})
cfg.cache_ttl = int(general.get("cache_ttl", cfg.cache_ttl))
cfg.http_timeout = int(general.get("http_timeout", cfg.http_timeout))
cfg.log_level = str(general.get("log_level", cfg.log_level)).upper()
_fill(cfg.email, raw.get("email", {}))
_fill(cfg.risk, raw.get("risk", {}))
_fill(cfg.llm, raw.get("llm", {}))
_fill(cfg.lookup, raw.get("lookup", {}))
_fill(cfg.policy, raw.get("policy", {}))
# Secrets: environment always wins.
cfg.email.password = os.environ.get("AVWATCH_SMTP_PASSWORD", cfg.email.password)
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
cfg.llm.api_key = api_key or cfg.llm.api_key
cfg.lookup.api_key = api_key or cfg.lookup.api_key or cfg.llm.api_key
cfg.risk.reddit_client_id = os.environ.get(
"AVWATCH_REDDIT_CLIENT_ID", cfg.risk.reddit_client_id)
cfg.risk.reddit_client_secret = os.environ.get(
"AVWATCH_REDDIT_CLIENT_SECRET", cfg.risk.reddit_client_secret)
if not cfg.llm.api_key:
cfg.llm.enabled = False
if not cfg.lookup.api_key:
cfg.lookup.enabled = False
for d in (cfg.data_dir, cfg.cache_dir, cfg.report_dir):
d.mkdir(parents=True, exist_ok=True)
return cfg