Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user