224 lines
13 KiB
Python
224 lines
13 KiB
Python
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()
|