Files
sapl/backend/config.py
T
root 76a505744f feat(embeddings): count-verified batching, bounded concurrency, batched Chroma queries
gemini-embedding-2-preview collapses a plain contents=[...] string list into
one concatenated input (single vector), so naive batching silently drops
vectors. Add two safe, configurable levers to EmbeddingService.embed_texts():

- EMBEDDING_BATCH_SIZE (>1): attempts a real multi-content request, VERIFIES
  the returned embedding count, and falls back to per-item embedding when the
  API collapses or errors. One vector per input, in input order, always.
- EMBEDDING_MAX_CONCURRENCY (>1): bounded ThreadPoolExecutor across items or
  chunks; results re-collected in input order.

Zero-vector degradation is preserved throughout: a failed input yields a zero
vector at its own position, never a dropped or misaligned entry. Defaults
(1/1) reproduce the previous strictly-sequential behavior exactly.

Batched ChromaDB querying:
- New query_similar_by_embeddings(): ONE collection.query() for N vectors,
  results aligned to inputs; zero vectors are excluded (cosine vs zero vector
  is meaningless) and come back empty; falls back to per-vector queries if the
  batched call fails, keeping items independent.
- query_similar_by_embedding() now delegates to it, keeping the nested result
  shape so existing callers are unaffected.
- moodboard _match_library_per_item() uses the batched query: N Chroma calls
  per brief -> 1, and failed embeddings can no longer surface garbage matches.

Focused offline tests in backend/tests/test_embedding_optimization.py
(16 tests: ordering, collapse fallback, zero-vector slots, concurrency,
alignment, backward-compat shape, moodboard adapter).
2026-08-04 13:40:04 +05:30

201 lines
11 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")
# ── Embeddings (Gemini) ──
# gemini-embedding-2-preview collapses a plain string list into ONE
# concatenated input (returns a single vector), so native batching is OFF
# by default. When `embedding_batch_size` > 1, embed_texts() chunks inputs
# and attempts a real multi-content request, VERIFIES the returned
# embedding count, and falls back to per-item embedding / zero vectors if
# the API collapses the batch — one vector per input, in input order, is
# always preserved. Env: EMBEDDING_BATCH_SIZE.
embedding_batch_size: int = 1
# Bounded concurrency for multi-text embedding (e.g. the moodboard
# per-item library matcher). Results keep input order; failed items
# degrade to zero vectors exactly as sequential calls do today.
# 1 = strictly sequential (previous behavior). Env: EMBEDDING_MAX_CONCURRENCY.
embedding_max_concurrency: int = 1
# ── 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
# ── Moodboard PDF page analysis (UAT performance plan) ──
# The old sequential page loop (one Gemini vision call at a time, fixed 5s
# sleep between pages) is replaced by bounded concurrency:
# moodboard_page_concurrency — max parallel page analyses per decode
# (1 = strict sequential; clamped to [1, 8])
# moodboard_page_delay — pacing seconds between page-analysis
# LAUNCHES (0 disables; 5.0 keeps the
# historical request rate)
# moodboard_page_retry_delays — comma-separated backoff seconds for
# transient errors (429/5xx/transport)
# before the per-page fallback
# moodboard_page_attempt_timeout — max seconds per single attempt (0 = none)
# Env: MOODBOARD_PAGE_CONCURRENCY / MOODBOARD_PAGE_DELAY /
# MOODBOARD_PAGE_RETRY_DELAYS / MOODBOARD_PAGE_ATTEMPT_TIMEOUT
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
# ── 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()