Files
sapl/backend/config.py
T

224 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
# Resolve .env regardless of where the process is started from
_HERE = Path(__file__).parent # backend/
_ENV_FILE = _HERE / ".env"
class Settings(BaseSettings):
# Mistral AI
mistral_api_key: str
mistral_vision_model: str = "pixtral-large-latest"
mistral_embed_model: str = "mistral-embed"
mistral_agent_model: str = "mistral-large-latest"
# Groq — agent reasoning (faster + free tier). Also powers the PERT narrator.
groq_api_key: str = ""
groq_agent_model: str = "llama-3.3-70b-versatile"
# Gemini — moodboard PDF decoder (vision + synthesis), embeddings, image generation
gemini_api_key: str = ""
gemini_vision_model: str = "gemini-3-flash-preview"
gemini_synthesis_model: str = "gemini-3-flash-preview"
gemini_embed_model: str = "gemini-embedding-2-preview"
gemini_image_model: str = "gemini-3-pro-image"
# OpenCode Go — shared multi-model provider (OpenAI-compatible gateway to a pool
# of ~23 models across families: minimax / deepseek / glm / kimi / qwen / mimo).
# Used as an ADDITIVE second engine alongside Gemini — per-feature model routing
# + the MOA (mixture-of-agents) orchestrator. Gemini paths stay untouched.
opencode_go_api_key: str = ""
opencode_go_base_url: str = "https://opencode.ai/zen/go/v1"
# JSON string of MOA model presets; empty = the hardened default in moa_orchestrator.
moa_presets: str = ""
# JSON string of per-feature model routes (feature → {provider, model, …});
# empty = the defaults in services/llm/routing.py. Lets us point the scraper
# at minimax/deepseek, moodboard at deepseek/glm, etc. without a code change.
feature_model_routes: str = ""
# AIBrandSolutions — SAPL's commissioned visual-AI tools (Swatch→Garment, etc.).
# Web UIs with no published API; we proxy their backend endpoint server-side so
# the feature lives inside our ecosystem (behind our login/RBAC). See routers/concept.py.
swatch_to_garment_base: str = "https://saplswatch2g.aibrandsolutions.in"
# Product Studio Pro (flat garment + prompt → on-model shots). Async backend:
# POST /api/edit-image (image as base64 data-URL) → requestId, poll /api/edit-status.
# SAPL instance is a paid tenant (isTrial:false) so anon X-Browser-ID calls work.
product_studio_base: str = "https://saplproductstudio.aibrandsolutions.in"
# Stitch ERP — TNA integration (existing JWT-based 30-min sync)
stitch_username: str = ""
stitch_password: str = ""
# Stitch ERP — production integration API key (task-template-master,
# style-tna-data, style-plan-data, style-plan-master, client-master)
stitch_api_key: str = ""
stitch_api_host: str = "https://portal2.stitcherp.com"
# Stitch ERP — integration API set (LIVE 2026-07-07): TNA template write-back +
# task/category masters. These run on a SEPARATE host (UAT) from the portal2
# data-pull above, but authenticate with the SAME STITCH-KEY token (stitch_key
# falls back to stitch_api_key — they are currently identical). Paths are
# overridable so a spec change needs no code edit. See services/stitch_tna_api.py.
stitch_integration_host: str = "https://uat.stitcherp.com"
stitch_key: str = "" # STITCH-KEY header (falls back to stitch_api_key)
stitch_tna_api_enabled: bool = False # gate for auto/scheduled use; manual scripts ignore it
stitch_task_master_path: str = "/api/integration/task-master/"
stitch_task_category_master_path: str = "/api/integration/task-category-master/"
stitch_production_template_path: str = "/api/integration/tna-template/" # write path (corrected by Stitch 8 Jul)
@property
def stitch_integration_key(self) -> str:
"""STITCH-KEY for the integration APIs; reuses the portal2 key if unset."""
return self.stitch_key or self.stitch_api_key
@property
def stitch_integration_base(self) -> str:
return self.stitch_integration_host.rstrip("/")
# Some people are seeded in the UAT tenant under a DIFFERENT username than the
# prod (portal2) template master carries — e.g. Deji Das is `saplind-Deji` on
# prod but `saplind-Das` in the UAT seed. Remap owner usernames at the push
# boundary, but ONLY when writing to a UAT integration host — prod keeps the
# original usernames, so the map must be empty there (verified 2026-07-14:
# portal2 template master still uses saplind-Deji).
stitch_user_aliases_uat: dict[str, str] = {"saplind-Deji": "saplind-Das"}
@property
def active_stitch_user_aliases(self) -> dict[str, str]:
"""Owner-username remap for the CURRENT integration host (UAT only)."""
return self.stitch_user_aliases_uat if "uat." in self.stitch_integration_host.lower() else {}
# WFX WebAPI — direct order (OC) pull, TokenID query-param auth.
# Token issued in WFX Admin → API Access Token (Modules=All).
wfx_token_id: str = ""
wfx_token_secret: str = ""
wfx_api_host: str = "https://sapl.worldfashionexchange.com"
# Gmail (SAPL TNA bot) — order-mail ingest (Project P1: Gmail → WFX).
# Auth is the REAL account; bom@ is an alias filed under the "BOM" label.
mail_auth_user: str = "" # login username for SMTP + IMAP (real account)
mail_app_password: str = "" # 16-char Google app password (2SV required)
mail_from: str = "" # send-as alias for outgoing alerts
smtp_server: str = "smtp.gmail.com"
smtp_port: int = 587
imap_server: str = "imap.gmail.com"
imap_port: int = 993
mail_mailbox: str = "BOM" # IMAP folder/label the reader watches (only alias mail)
mail_filter_to: str = "" # extra safety filter inside the folder
# Auth — token signing secret (override in .env for production)
secret_key: str = "sapl-garment-intel-dev-secret-change-in-prod-2026"
# Google SSO — company Workspace sign-in. Set google_client_id (from IT) to
# enable; the "Sign in with Google" button stays hidden until it's present.
# Login is restricted to the company domain (google_allowed_domain). A first-time
# sign-in from that domain is auto-provisioned with google_default_role (a limited
# role); an admin raises their access afterwards in /admin. Set google_default_role
# to "" to revert to invite-only (reject unknown emails).
# Default is SAPL's PUBLIC OAuth client id (already exposed to the browser via
# the GSI button — not a secret). Baked in so prod SSO works without depending
# on env plumbing; override via GOOGLE_CLIENT_ID env for other environments.
google_client_id: str = "800260541735-6o4rechdhoufgjann9scs57gi8a73fas.apps.googleusercontent.com"
google_client_secret: str = "" # not used by the ID-token flow; stored for completeness
google_allowed_domain: str = "saplind.com"
google_default_role: str = "Viewer"
# Hide Stitch demo orders ("*-Demo" styles) from the TNA views. Set false to show them.
hide_demo_orders: bool = True
# App paths (resolved relative to backend/ directory)
database_url: str = f"sqlite:///{_HERE}/sapl_garment_intel.db"
chromadb_path: str = str(_HERE / "data" / "chromadb")
images_path: str = str(_HERE / "data" / "images")
# ── Home event-feed (Design Library Phase-1) ──
# The landing feed surfaces *events* (made things: boards, decodes, sample
# ranges), never lone uploads. A garment/fabric upload cluster only becomes a
# "range added" event when at least `feed_range_min_size` items were created in
# one day-bucket sharing a collection/category — smaller clusters are suppressed.
feed_range_min_size: int = 4 # min items in a day-bucket to count as a "range"
feed_window_days: int = 30 # how far back the feed reaches
feed_page_size: int = 20 # events per page (cursor-paginated)
# KAARIGAR curates the feed: an event only surfaces if it's genuinely interesting.
# Routine uploads (a few swatches/garments) score below this and are suppressed —
# unless people engage with them (reactions/comments lift the score). Keeps the
# home page a curated 'what's exciting', not an upload dump.
feed_interest_min: float = 30.0
# Confidence thresholds
confidence_auto_save_threshold: float = 0.85
confidence_review_threshold: float = 0.65
similarity_duplicate_threshold: float = 0.90
# Minimum cosine similarity for a moodboard→library garment match to be shown.
# Calibrated for gemini-embedding-2 (cosine): unrelated/nonsense queries score
# ~0.51, genuine matches 0.58–0.71 — so 0.58 trims clear non-matches. Env: MOODBOARD_MATCH_MIN_SIM.
moodboard_match_min_similarity: float = 0.58
# ── Embedding optimization knobs (UAT Moodboard performance plan) ──
# Defaults preserve the historical sequential behavior; increase only after UAT validation.
embedding_batch_size: int = 1
embedding_max_concurrency: int = 1
# ── Moodboard page analysis knobs ──
moodboard_page_concurrency: int = 2
moodboard_page_delay: float = 5.0
moodboard_page_retry_delays: str = "15,30,60"
moodboard_page_attempt_timeout: float = 300.0
# ── Moodboard decode performance knobs (UAT Moodboard performance plan) ──
# How the concept/swatch visuals stage runs after the brief is synthesised.
# "sync" → visuals generate inside the synthesis job, before the decode
# completes (the original behaviour — the default).
# "async" → the written brief + library matches complete first; visuals
# generate in a background task and stream in via polling
# (visuals_status/visuals_done/visuals_total on the decode row).
# "off" → skip image generation AND garment artifacts (specs/flats/
# starter kits) entirely — the cheapest path for load/UAT runs.
# Per-deck override: POST /moodboard/decode-pdf/{id}/answers with
# {"visuals_mode": "sync"|"async"|"off"} (validated against the same set).
moodboard_visuals_mode: str = "sync"
# Ground-summary ("what this means for SAPL") call reduction:
# "llm" → deepseek writes the summary (original behaviour — the default)
# "template" → deterministic template from the grounding data; saves one
# LLM call per decode. The template is also the LLM fallback.
moodboard_ground_summary_mode: str = "llm"
# Map-reduce chunk size for the brief synthesis stage. Decks up to this many
# pages cost ONE Gemini synthesis call; longer decks cost ceil(n/chunk) chunk
# calls + 1 merge call. Raise it (e.g. 12–15) to cut Gemini calls on long
# decks — each page is pre-trimmed so chunk size stays token-safe. Env: MOODBOARD_SYNTH_CHUNK_SIZE.
moodboard_synth_chunk_size: int = 10
# Max concurrent image generations inside a visuals batch (Gemini image API
# is rate-limited; 3 has been safe in UAT). Env: MOODBOARD_IMAGE_CONCURRENCY.
moodboard_image_concurrency: int = 3
# ── Postgres connection pool (UAT pool-exhaustion hardening) ──
# QueuePool knobs applied in database.py for non-SQLite engines (SQLite
# keeps its own default pool). Defaults preserve the historical QueuePool
# shape (5 connections + 10 overflow = 15 max) — the real fix for the UAT
# "QueuePool limit reached" timeouts is the session-lifetime work in the
# long-running jobs (no transaction held across external awaits), not a
# bigger pool. Raise these in .env only after that proves insufficient.
# Env: DB_POOL_SIZE / DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE / DB_POOL_USE_LIFO.
db_pool_size: int = 5
db_max_overflow: int = 10
db_pool_timeout: float = 30.0
# Recycle connections every 30 min — comfortably under Postgres's default
# idle timeout so a pooled connection that sat idle is never reaped by the
# server mid-checkout (pool_pre_ping handles the transient case; recycling
# avoids the churn entirely).
db_pool_recycle: int = 1800
# LIFO checkout keeps the most-recently-used connection hot under load,
# which avoids connect-storms when the pool is contended.
db_pool_use_lifo: bool = True
# ── Deployment ──
cors_origins: str = "" # comma-separated extra frontend origins (prod / LAN)
enable_scraper: bool = True # set false in pilot deploy (no browser deps needed)
qr_base_url: str = "http://localhost:5173" # base the QR/hangtag encodes; set to LAN IP / deploy host
model_config = SettingsConfigDict(env_file=str(_ENV_FILE), case_sensitive=False, extra="ignore")
settings = Settings()