| """Configuration loading. |
| |
| Loads ``config/config.yaml`` plus the human-readable ``config/profile.md`` and resolves |
| the project root. Environment variables (e.g. API keys) are loaded from a local ``.env`` |
| when present so dev runs work without exporting anything. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| from functools import lru_cache |
| from pathlib import Path |
| from typing import Any |
|
|
| import yaml |
|
|
| try: |
| from dotenv import load_dotenv |
| except ImportError: |
| load_dotenv = None |
|
|
|
|
| def project_root() -> Path: |
| """Repo root = two levels up from this file (src/wam/config.py).""" |
| return Path(__file__).resolve().parents[2] |
|
|
|
|
| class Config: |
| """Thin wrapper over the parsed config dict with dotted-path access.""" |
|
|
| def __init__(self, data: dict[str, Any], root: Path): |
| self._data = data |
| self.root = root |
|
|
| def get(self, path: str, default: Any = None) -> Any: |
| """Fetch a nested value by dotted path, e.g. ``config.get("models.tiers.cheap")``.""" |
| node: Any = self._data |
| for part in path.split("."): |
| if not isinstance(node, dict) or part not in node: |
| return default |
| node = node[part] |
| return node |
|
|
| def __getitem__(self, key: str) -> Any: |
| return self._data[key] |
|
|
| @property |
| def data(self) -> dict[str, Any]: |
| return self._data |
|
|
| |
| def path(self, key: str) -> Path: |
| """Resolve a configured relative path (under ``paths.*``) against the repo root.""" |
| rel = self.get(f"paths.{key}") |
| if rel is None: |
| raise KeyError(f"paths.{key} not configured") |
| return (self.root / rel).resolve() |
|
|
| def require_env(self, env_var: str) -> str: |
| val = os.environ.get(env_var) |
| if not val: |
| raise RuntimeError(f"Required environment variable {env_var} is not set") |
| return val |
|
|
| def profile_text(self) -> str: |
| """The research-interest profile fed to relevance/scoring prompts.""" |
| return (self.root / "config" / "profile.md").read_text(encoding="utf-8") |
|
|
| def model_for_stage(self, stage: str, fallback_tier: str = "mid") -> str: |
| """Resolve the OpenRouter model id for a pipeline stage via stage_tiers -> tiers.""" |
| tier = self.get(f"models.stage_tiers.{stage}", fallback_tier) |
| model = self.get(f"models.tiers.{tier}") |
| if not model: |
| raise KeyError(f"No model configured for tier '{tier}'") |
| return model |
|
|
|
|
| @lru_cache(maxsize=1) |
| def load_config(config_path: str | None = None) -> Config: |
| """Load and cache the project config. Also loads ``.env`` if available.""" |
| root = project_root() |
| if load_dotenv is not None: |
| load_dotenv(root / ".env") |
| path = Path(config_path) if config_path else root / "config" / "config.yaml" |
| data = yaml.safe_load(path.read_text(encoding="utf-8")) |
| return Config(data, root) |
|
|