"""Backend configuration via environment variables.""" import importlib.util import json from pathlib import Path from pydantic import Field from pydantic_settings import BaseSettings # Resolve paths relative to the project root (one level up from backend/) _BACKEND_DIR = Path(__file__).resolve().parent _PROJECT_ROOT = _BACKEND_DIR.parent # Load skills manifest once at import time _MANIFEST_PATH = _BACKEND_DIR / "skills_manifest.json" _SKILLS_MANIFEST: dict = ( json.loads(_MANIFEST_PATH.read_text()) if _MANIFEST_PATH.exists() else {} ) class Settings(BaseSettings): # DeepSeek (default provider — OpenAI-compatible Chat Completions) deepseek_api_key: str = "" deepseek_base_url: str = "https://api.deepseek.com" deepseek_model: str = "deepseek-v4-flash" deepseek_vision_model: str = "deepseek-v4-flash-vision-exp" # "enabled" (default) or "disabled". DeepSeek V4 thinks by default; # disable to cut cost on simple mapping calls. deepseek_thinking: str = "enabled" deepseek_reasoning_effort: str = "medium" # PDF ingest: DeepSeek does not accept native PDFs. Text is always # extracted; page images are attached only when the stage model is a # vision model (see model_*_deepseek defaults below). deepseek_pdf_max_chars: int = 500_000 deepseek_pdf_image_pages: int = 32 # Per-stage DeepSeek model overrides (fall back to deepseek_model) model_pintable_deepseek: str = "deepseek-v4-flash-vision-exp" model_pattern_deepseek: str = "deepseek-v4-flash" model_specs_deepseek: str = "deepseek-v4-flash-vision-exp" model_validation_deepseek: str = "deepseek-v4-pro" model_auto_resolve_deepseek: str = "deepseek-v4-flash" model_normalize_deepseek: str = "deepseek-v4-flash" # Anthropic (optional fallback) anthropic_api_key: str = "" anthropic_model: str = "claude-sonnet-4-6" # Per-stage model overrides (fall back to anthropic_model if empty) model_pintable: str = "" model_pattern: str = "" model_specs: str = "" model_validation: str = "claude-sonnet-4-6" model_auto_resolve: str = "claude-haiku-4-5-20251001" model_normalize: str = "claude-sonnet-4-6" # Gemini (set GEMINI_API_KEY to enable) gemini_api_key: str = "" gemini_model: str = "gemini-3.1-pro-preview" # Per-stage Gemini model overrides (fall back to gemini_model if empty) model_validation_gemini: str = "" model_pintable_gemini: str = "" model_pattern_gemini: str = "" model_specs_gemini: str = "" model_auto_resolve_gemini: str = "" model_normalize_gemini: str = "" # Provider routing — provider_default is the global default; per-stage # overrides win when non-empty. Valid values: deepseek | anthropic | gemini. provider_default: str = "deepseek" provider_pintable: str = "" provider_pattern: str = "" provider_specs: str = "" provider_validation: str = "" provider_auto_resolve: str = "" provider_normalize: str = "" # Per-stage fallback provider/model — used if the primary stage call # raises (e.g. DeepSeek 503). Leave empty to disable fallback # for that stage. If fallback_provider_ is set but # fallback_model_ is empty, the fallback uses that provider's # default model (deepseek_model, anthropic_model, or gemini_model). fallback_provider_pintable: str = "" fallback_provider_pattern: str = "" fallback_provider_specs: str = "" fallback_provider_validation: str = "" fallback_provider_auto_resolve: str = "" fallback_provider_normalize: str = "" fallback_model_pintable: str = "" fallback_model_pattern: str = "" fallback_model_specs: str = "" fallback_model_validation: str = "" fallback_model_auto_resolve: str = "" fallback_model_normalize: str = "" # Max parallel IC agents — the single knob controlling concurrency for # BOTH the IC pintable extraction stage and the direct datasheet review # stage. Change this one number (or the IC_CONCURRENCY env var) to scale # how many ICs are processed in parallel. ic_concurrency: int = 6 # Per-IC normalize pass — dedup findings sharing a root cause and # re-grade severity against a fixed rubric. Runs after submit_review. normalize_findings_enabled: bool = True # Cross-IC dedup pass — collapse one physical interface defect reported # from both ICs (e.g. a 5V-into-3V3 net flagged once per endpoint) into a # single finding. Runs once after all per-IC reviews complete. cross_ic_dedup_enabled: bool = True # Paths (relative to project root, used by LocalStorageBackend) data_dir: Path = _PROJECT_ROOT / "data" taxonomy_dir: Path = _PROJECT_ROOT / "taxonomy" skills_dir: Path = _PROJECT_ROOT / "skills" # GCS (if set, use GCSStorageBackend; otherwise LocalStorageBackend) gcs_bucket: str = "" # Clerk authentication clerk_secret_key: str = "" clerk_publishable_key: str = "" clerk_jwks_url: str = "" # DigiKey API (optional — enables auto-fetch datasheets) digikey_client_id: str = "" digikey_client_secret: str = "" digikey_environment: str = "production" digikey_locale_site: str = "US" digikey_locale_language: str = "en" digikey_locale_currency: str = "USD" # Mouser Search API (optional — fourth datasheet source) mouser_api_key: str = "" # Purple Parts API (optional — converts LCSC codes to MPNs before DigiKey) purple_parts_url: str = "" purple_parts_api_key: str = "" # Email notifications (Gmail API via service account) email_sender: str = "" email_frontend_url: str = "" email_admin_notify: str = "" # fixed recipient for pipeline-started alerts contact_recipient: str = "" # where /api/contact submissions are delivered # Stripe billing (pay-as-you-go only — no subscription prices needed) stripe_secret_key: str = "" stripe_webhook_secret: str = "" # Open-core: master switch for the credits/Stripe billing system. # True = credit gating + charges + billing/credits routers. # False = OSS/self-host mode: pipelines run free, billing routes unmounted. # Defaults to whether the private billing modules exist in this checkout # (present in the cloud/gateway repo, absent in the open-source core), so # a bare core checkout runs free with no configuration. An explicit # BILLING_ENABLED env var always wins. billing_enabled: bool = Field( default_factory=lambda: importlib.util.find_spec( "backend.services.stripe_billing" ) is not None ) # Onboarding survey (Google Sheet) survey_sheet_id: str = "" # CORS cors_origins: list[str] = [ "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:18742", "http://127.0.0.1:18742", ] # Cloud Run Job worker (pipeline runner) pipeline_worker_job_name: str = "pinscopex-pipeline-worker" pipeline_worker_region: str = "us-central1" pipeline_worker_project: str = "" # GCP project id; defaults to GOOGLE_CLOUD_PROJECT or metadata pipeline_worker_timeout_seconds: int = 3600 # Sweeper: a "running" project is considered stale if its last update # timestamp is older than this and the worker execution is in a # terminal Cloud Run state (or the executor isn't reachable). pipeline_sweeper_stale_seconds: int = 60 model_config = { "env_file": str(_BACKEND_DIR / ".env"), "env_file_encoding": "utf-8", "extra": "ignore", } @property def use_stripe(self) -> bool: return bool(self.stripe_secret_key) @property def use_digikey(self) -> bool: return bool(self.digikey_client_id and self.digikey_client_secret) @property def use_mouser(self) -> bool: return bool(self.mouser_api_key) @property def use_purple_parts(self) -> bool: return bool(self.purple_parts_url and self.purple_parts_api_key) @property def use_gcs(self) -> bool: return bool(self.gcs_bucket) @property def use_auth(self) -> bool: return bool(self.clerk_secret_key and self.clerk_jwks_url) @property def use_email(self) -> bool: return bool(self.email_sender and self.email_frontend_url) def provider_for_stage(self, stage: str) -> str: """Return the LLM provider name for a pipeline stage.""" override = getattr(self, f"provider_{stage}", "") return override or self.provider_default def model_for_stage(self, stage: str) -> str: """Return the model for a pipeline stage, provider-aware. For DeepSeek: falls back to model__deepseek, then deepseek_model. For Gemini: falls back to model__gemini, then gemini_model. For Anthropic: falls back to model_, then anthropic_model. """ provider = self.provider_for_stage(stage) if provider == "gemini": override = getattr(self, f"model_{stage}_gemini", "") return override or self.gemini_model if provider == "deepseek": override = getattr(self, f"model_{stage}_deepseek", "") return override or self.deepseek_model override = getattr(self, f"model_{stage}", "") return override or self.anthropic_model def fallback_for_stage(self, stage: str) -> tuple[str, str] | None: """Return (provider, model) for the stage's fallback, or None if no fallback is configured. Used by call_with_fallback() to retry once when the primary provider raises. """ fb_provider = getattr(self, f"fallback_provider_{stage}", "") if not fb_provider: return None fb_model = getattr(self, f"fallback_model_{stage}", "") if not fb_model: if fb_provider == "gemini": fb_model = self.gemini_model elif fb_provider == "deepseek": fb_model = self.deepseek_model else: fb_model = self.anthropic_model return (fb_provider, fb_model) def default_model_for_provider(self, provider: str) -> str: if provider == "gemini": return self.gemini_model if provider == "deepseek": return self.deepseek_model return self.anthropic_model def has_llm_credentials(self) -> bool: """True if the configured default provider has an API key.""" name = self.provider_default if name == "deepseek": return bool(self.deepseek_api_key) if name == "gemini": return bool(self.gemini_api_key) if name == "anthropic": return bool(self.anthropic_api_key) return bool( self.deepseek_api_key or self.anthropic_api_key or self.gemini_api_key ) def get_skill_or_none(self, name: str) -> tuple[str | None, str | None]: """Return (skill_id, version) or (None, None) if the Anthropic Console skill is not in the manifest. DeepSeek/Gemini extraction inlines SKILL.md locally and does not need a skill_id.""" entry = _SKILLS_MANIFEST.get(name) if not entry: return None, None return entry.get("skill_id"), entry.get("latest_version") def get_default_model_version(self) -> str: """Return the default model_version for new extractions from skills_manifest.json.""" return _SKILLS_MANIFEST.get("default_model_version", "1.0.0") def get_skill(self, name: str) -> tuple[str, str]: """Return (skill_id, version) from skills_manifest.json or raise.""" entry = _SKILLS_MANIFEST.get(name) if not entry: raise RuntimeError( f"Skill '{name}' not found in {_MANIFEST_PATH}. " f"Run scripts/upload_skills.py to create skills." ) return entry["skill_id"], entry["latest_version"] settings = Settings()