ERP-DocIQ / backend /app /config.py
kenmandal's picture
Deploy latest: ERP DocIQ NLQ chatbot + reasoning models (MiniCPM3-4B/Command R7B) + ERP fine-tuning + extreme OCR docs
082d661 verified
Raw
History Blame Contribute Delete
8.75 kB
"""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()