"""Central configuration, loaded from environment / .env. Everything has a safe default so the app runs fully offline with zero config. """ from __future__ import annotations import os from functools import lru_cache from pathlib import Path BACKEND_DIR = Path(__file__).resolve().parent.parent # .../backend REPO_ROOT = BACKEND_DIR.parent # Make the repo-root `prototype/` package importable (only the composition layer # imports it, and only in prototype mode — real code never depends on prototype). import sys as _sys # noqa: E402 if str(REPO_ROOT) not in _sys.path: _sys.path.insert(0, str(REPO_ROOT)) try: # load .env deterministically (independent of cwd): backend/.env then repo/.env from dotenv import load_dotenv load_dotenv(BACKEND_DIR / ".env") load_dotenv(REPO_ROOT / ".env") load_dotenv() # finally, anything discoverable from cwd (no override) except Exception: # pragma: no cover - dotenv optional pass # On serverless hosts (Vercel) the bundle is read-only except /tmp. IS_SERVERLESS = bool(os.getenv("VERCEL") or os.getenv("AWS_LAMBDA_FUNCTION_NAME")) def _writable_dir() -> Path: """A directory we can actually write to (metrics DB, eval reports).""" if IS_SERVERLESS: d = Path("/tmp/aperture") else: d = BACKEND_DIR / "data" try: d.mkdir(parents=True, exist_ok=True) except OSError: d = Path("/tmp/aperture") d.mkdir(parents=True, exist_ok=True) return d def _bool(name: str, default: bool) -> bool: val = os.getenv(name) if val is None: return default return val.strip().lower() in {"1", "true", "yes", "on"} def _float(name: str, default: float) -> float: try: return float(os.getenv(name, default)) except (TypeError, ValueError): return default class Settings: """Plain object (not pydantic-settings) to avoid a hard dependency in tests.""" def __init__(self) -> None: # --- hosted models --- self.anthropic_api_key: str | None = os.getenv("ANTHROPIC_API_KEY") or None self.gemini_api_key: str | None = ( os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or None ) self.anthropic_model_smart = os.getenv("ANTHROPIC_MODEL_SMART", "claude-sonnet-4-6") self.anthropic_model_cheap = os.getenv( "ANTHROPIC_MODEL_CHEAP", "claude-haiku-4-5-20251001" ) self.gemini_model = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") # --- local model --- self.ollama_base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") self.local_model = os.getenv("LOCAL_MODEL", "gemma2:2b") self.local_backend = os.getenv("LOCAL_BACKEND", "ollama") # ollama | transformers # --- deployment mode --------------------------------------------------- # real = production-first: real OCR / RAG / LLM / browser only. # prototype = re-enable simulated/offline fallbacks (mock LLM, simulated # browser, sidecar OCR) from the prototype/ package. # Serverless (Vercel) defaults to prototype so the public demo still runs. self.mode = os.getenv("APERTURE_MODE") or ("prototype" if IS_SERVERLESS else "real") self.prototype_mode = self.mode == "prototype" # --- routing / caching --- self.routing_policy = os.getenv("ROUTING_POLICY", "auto") # auto|cheap|smart|offline self.enable_prompt_cache = _bool("ENABLE_PROMPT_CACHE", True) self.enable_semantic_cache = _bool("ENABLE_SEMANTIC_CACHE", True) self.semantic_cache_threshold = _float("SEMANTIC_CACHE_THRESHOLD", 0.92) # --- pipeline --- self.hitl_confidence_threshold = _float("HITL_CONFIDENCE_THRESHOLD", 0.85) # --- storage (always writable; /tmp on serverless) --- self.writable_dir = _writable_dir() db = os.getenv("METRICS_DB_PATH") if db: self.metrics_db_path = Path(db) if Path(db).is_absolute() else BACKEND_DIR / db else: self.metrics_db_path = self.writable_dir / "metrics.db" try: self.metrics_db_path.parent.mkdir(parents=True, exist_ok=True) except OSError: self.metrics_db_path = self.writable_dir / "metrics.db" # eval report: committed copy (read) + writable copy (fresh runs) self.eval_report_committed = BACKEND_DIR / "evals" / "report.json" self.eval_report_writable = self.writable_dir / "report.json" # --- auth (HTTP Basic on /api/*) --- self.auth_user = os.getenv("APERTURE_USER", "demo") self.auth_pass = os.getenv("APERTURE_PASS", "aperture2026") # admin role (observability dashboard, prompt mgmt). Defaults to same creds. self.admin_user = os.getenv("APERTURE_ADMIN_USER", self.auth_user) self.admin_pass = os.getenv("APERTURE_ADMIN_PASS", self.auth_pass) # --- OCR backends ----------------------------------------------------- # selection: auto | minicpm | cohere | llamaparse | tesseract | easyocr | sidecar self.ocr_backend = os.getenv("OCR_BACKEND", "auto") # Option 1 — MiniCPM-V-4.6 via OpenAI-compatible server (vLLM / llama.cpp) self.minicpm_base_url = os.getenv("MINICPM_BASE_URL", "").rstrip("/") or None self.minicpm_api_key = os.getenv("MINICPM_API_KEY") or None self.minicpm_model = os.getenv("MINICPM_MODEL", "MiniCPM-V-4.6-Instruct") # Option 2 — Cohere vision/OCR model from HuggingFace (transformers) self.cohere_ocr_model = os.getenv("COHERE_OCR_MODEL", "CohereLabs/aya-vision-8b") # Option 3 — LlamaParse (LlamaCloud) self.llama_cloud_api_key = os.getenv("LLAMA_CLOUD_API_KEY") or None self.llamaparse_result_type = os.getenv("LLAMAPARSE_RESULT_TYPE", "markdown") # --- model labs (all ≤32B params — "small models") -------------------- # OpenBMB (MiniCPM family) — text/vision reasoning + OCR (via MINICPM_* above) self.openbmb_model = os.getenv("OPENBMB_MODEL", self.minicpm_model) # OpenBMB MiniCPM3-4B — text reasoning / NLQ→SQL / summarization (ERP DocIQ + fine-tune target) self.openbmb_reasoner_model = os.getenv("OPENBMB_REASONER_MODEL", "MiniCPM3-4B") # Black Forest Labs (FLUX) — image GENERATION for synthetic test documents self.bfl_api_key = os.getenv("BFL_API_KEY") or None self.bfl_model = os.getenv("BFL_MODEL", "flux-dev") # api: flux-dev | flux-pro-1.1 | flux-schnell # Cohere hosted API (in addition to the local HF Aya-Vision backend above) self.cohere_api_key = os.getenv("COHERE_API_KEY") or None # --- databases -------------------------------------------------------- appdb = os.getenv("APP_DB_PATH") self.app_db_path = ( (Path(appdb) if Path(appdb).is_absolute() else BACKEND_DIR / appdb) if appdb else self.writable_dir / "aperture.db" ) ragdb = os.getenv("RAG_DB_PATH") self.rag_db_path = ( (Path(ragdb) if Path(ragdb).is_absolute() else BACKEND_DIR / ragdb) if ragdb else self.writable_dir / "rag.db" ) erpdb = os.getenv("ERP_DB_PATH") self.erp_db_path = ( (Path(erpdb) if Path(erpdb).is_absolute() else BACKEND_DIR / erpdb) if erpdb else self.writable_dir / "erp.db" ) # --- browser --- self.playwright_headless = _bool("PLAYWRIGHT_HEADLESS", True) self.demo_portal_url = os.getenv("DEMO_PORTAL_URL", "/portal") # --- paths --- self.samples_dir = REPO_ROOT / "samples" self.evals_dataset_dir = BACKEND_DIR / "evals" / "datasets" self.demo_portal_dir = REPO_ROOT / "demo-portal" self.frontend_dist_dir = REPO_ROOT / "frontend" / "dist" # --- capability detection ------------------------------------------------- def has_anthropic(self) -> bool: return bool(self.anthropic_api_key) and _module_available("anthropic") def has_gemini(self) -> bool: return bool(self.gemini_api_key) and _module_available("google.generativeai") def has_local(self) -> bool: # We can't cheaply ping ollama here; LocalProvider does a lazy health check. # Treat "configured" as available and let the provider degrade if not. return _bool("ENABLE_LOCAL_MODEL", False) or _module_available("transformers") def _module_available(name: str) -> bool: import importlib.util try: return importlib.util.find_spec(name) is not None except (ImportError, ValueError): return False @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings()