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).
201 lines
11 KiB
Python
201 lines
11 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")
|
||
|
||
# ── 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()
|