from __future__ import annotations import sys from pathlib import Path from typing import Optional from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict # ── Sub-config models ────────────────────────────────────────────────────────── class RuntimeConfig(BaseModel): timezone: str = "America/Phoenix" headless: bool = True profile_name: str = "JobAgent" slow_mo_ms: int = 0 default_timeout_ms: int = 15000 navigation_timeout_ms: int = 30000 max_pages_per_site: int = 3 min_delay_ms: int = 1200 max_delay_ms: int = 3500 class SearchDefaults(BaseModel): keywords: list[str] = Field(default_factory=lambda: ["orthodontist"]) location: str = "" remote: bool = False date_mode: str = "today_or_last_24h" sort: str = "relevance" max_items_per_site: int = 30 class SummarizationConfig(BaseModel): ollama_base_url: str = "http://127.0.0.1:11434" model: str = "qwen3.5:9b" temperature: float = 0.1 max_input_items: int = 200 class NotificationConfig(BaseModel): provider: str = "kakaotalk" fallback_markdown: bool = True # ── Main global config ───────────────────────────────────────────────────────── class GlobalConfig(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_nested_delimiter="__", extra="ignore", ) runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) search_defaults: SearchDefaults = Field(default_factory=SearchDefaults) summarization: SummarizationConfig = Field(default_factory=SummarizationConfig) notification: NotificationConfig = Field(default_factory=NotificationConfig) # ── Loader functions ────────────────────────────────────────────────────────── def load_global_config() -> GlobalConfig: """Load global config from sites/global.yaml, with env var overrides.""" from gimme_job.utils.json_io import read_yaml from gimme_job.utils.paths import sites_dir yaml_path = sites_dir() / "global.yaml" if yaml_path.exists(): data = read_yaml(yaml_path) return GlobalConfig.model_validate(data) return GlobalConfig() def load_site_manifest(site_id: str): """Load a site manifest from sites/{site_id}.yaml.""" from gimme_job.models.manifest import SiteManifest from gimme_job.utils.paths import sites_dir path = sites_dir() / f"{site_id}.yaml" if not path.exists(): raise FileNotFoundError(f"Site manifest not found: {path}") return SiteManifest.from_yaml(path) def list_enabled_sites() -> list[str]: """Return site_ids of all enabled (non-repair_needed) sites from sites/*.yaml.""" from gimme_job.models.manifest import SiteManifest from gimme_job.utils.paths import sites_dir enabled = [] for yaml_path in sorted(sites_dir().glob("*.yaml")): if yaml_path.stem == "global": continue try: manifest = SiteManifest.from_yaml(yaml_path) if manifest.enabled and not manifest.repair_needed: enabled.append(manifest.site_id) except Exception: pass return enabled def list_all_sites() -> list[str]: """Return all site_ids from sites/*.yaml (including disabled/repair_needed).""" from gimme_job.models.manifest import SiteManifest from gimme_job.utils.paths import sites_dir sites = [] for yaml_path in sorted(sites_dir().glob("*.yaml")): if yaml_path.stem == "global": continue try: manifest = SiteManifest.from_yaml(yaml_path) sites.append(manifest.site_id) except Exception: pass return sites def merge_query( global_config: GlobalConfig, manifest, cli_overrides: Optional[dict] = None, ): """Merge search query with priority: CLI > site override > global defaults.""" from gimme_job.models.dto import SearchQuery # Start from global defaults base = global_config.search_defaults merged = { "keywords": list(base.keywords), "location": base.location, "remote": base.remote, "date_mode": base.date_mode, "sort": base.sort, "max_items": base.max_items_per_site, } # Apply site-level overrides override = manifest.search_override if override.keywords is not None: merged["keywords"] = override.keywords if override.location is not None: merged["location"] = override.location if override.remote is not None: merged["remote"] = override.remote if override.date_mode is not None: merged["date_mode"] = override.date_mode if override.sort is not None: merged["sort"] = override.sort if override.max_items is not None: merged["max_items"] = override.max_items # Apply CLI overrides if cli_overrides: for k, v in cli_overrides.items(): if v is not None and k in merged: merged[k] = v return SearchQuery(**merged)