# ============================================================ # SPID — configuration & paths # ============================================================ from __future__ import annotations import logging import os from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path # project-db/ (this file lives in project-db/spid/config.py) PROJECT_ROOT = Path(__file__).resolve().parent.parent def _load_dotenv() -> None: """Minimal .env loader (no python-dotenv dependency required).""" env_path = PROJECT_ROOT / ".env" if not env_path.exists(): return for line in env_path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") key, val = key.strip(), val.strip().strip('"').strip("'") os.environ.setdefault(key, val) _load_dotenv() # The parent UQO_RESEARCH_DB that holds the parsed corpus. RESEARCH_ROOT = PROJECT_ROOT.parent SOURCE_DATA = RESEARCH_ROOT / "data" def _parse_scope(spec: str) -> dict: out: dict = {} for part in (spec or "").split(";"): part = part.strip() if not part or ":" not in part: continue form, items = part.split(":", 1) out[form.strip()] = [i.strip() for i in items.split(",") if i.strip()] return out @dataclass(frozen=True) class Settings: """Resolved runtime configuration for the SPID platform.""" # --- source corpus (read-only) ------------------------------------- source_data: Path = SOURCE_DATA parquet_dir: Path = SOURCE_DATA / "parquet" processed_dir: Path = SOURCE_DATA / "processed" @property def filings_parquet(self) -> Path: return self.parquet_dir / "filings" / "filings.parquet" @property def sections_parquet(self) -> Path: return self.parquet_dir / "filing_sections" / "filing_sections.parquet" @property def companies_parquet(self) -> Path: return self.parquet_dir / "companies" / "companies.parquet" # --- SPID outputs (writable) --------------------------------------- db_path: Path = PROJECT_ROOT / "data" / "db" / "spid.duckdb" out_dir: Path = PROJECT_ROOT / "data" # --- extraction ---------------------------------------------------- backend: str = field( default_factory=lambda: os.environ.get("SPID_BACKEND", "llm") ) # "llm" (gpt-4o-mini) | "rules" min_confidence: float = 0.45 # which parsed sections feed the extractor, per form (None/empty = every section) section_scope: dict = field(default_factory=lambda: _parse_scope(os.environ.get( "SPID_SECTION_SCOPE", "10-K:business,risk_factors,properties,md_and_a,quantitative_disclosures"))) max_section_chars: int = 600_000 # --- embeddings ---------------------------------------------------- embedding_model: str = field( default_factory=lambda: os.environ.get("SPID_EMBED_MODEL", "all-MiniLM-L6-v2") ) # --- LLM backend (OpenAI-compatible) ------------------------------- llm_base_url: str = field( default_factory=lambda: os.environ.get( "SPID_LLM_BASE_URL", "https://api.openai.com/v1" ) ) llm_model: str = field( default_factory=lambda: os.environ.get("SPID_LLM_MODEL", "gpt-4o-mini") ) llm_api_key: str = field( default_factory=lambda: os.environ.get("OPENAI_API_KEY", "") or os.environ.get("MACLUSTR_API_KEY", "") ) llm_timeout: float = 120.0 def ensure_dirs(self) -> None: self.db_path.parent.mkdir(parents=True, exist_ok=True) (self.out_dir / "exports").mkdir(parents=True, exist_ok=True) @lru_cache(maxsize=1) def get_settings() -> Settings: s = Settings() s.ensure_dirs() return s def get_logger(name: str) -> logging.Logger: logger = logging.getLogger(f"spid.{name}") if not logger.handlers: h = logging.StreamHandler() h.setFormatter( logging.Formatter("%(asctime)s %(levelname)-7s %(name)s | %(message)s", datefmt="%H:%M:%S") ) logger.addHandler(h) logger.setLevel(os.environ.get("SPID_LOG_LEVEL", "INFO")) logger.propagate = False return logger