Adds the merchant authoring flow on top of the existing recommender:
- POST /tna/draft: propose a TNA for a no-TNA order, with optional template
override + fabric-lead-time override (recommender extended for both).
- PUT /tna/recommend/{key}/schedule: persist planner date/owner edits (frozen
once accepted); sets edited flag.
- POST /tna/push/{key}: push an approved plan's template to Stitch, gated on
Template-Readiness — returns blocked + missing tasks when the tenant isn't
seeded (no silent failure). Distinct path avoids the /{decision} route clash.
- TnaRecommendation gains pcd_date/edited/stitch_template_id/push_status/
push_detail (idempotent migration + boot self-heal in services/tna_authoring).
- Frontend: TnaCreate page (proposal + template picker + fabric-lead-time input +
editable milestone table + approve→push with readiness gate), wired from the
"Create TNA" action on the no-TNA cards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
619 lines
28 KiB
Python
619 lines
28 KiB
Python
import sys
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
# On Windows, SelectorEventLoop (uvicorn default) cannot launch subprocesses.
|
|
# Playwright/Camoufox needs subprocess support to launch browsers.
|
|
# ProactorEventLoop is the Windows default since Python 3.8 and is fully compatible with uvicorn.
|
|
if sys.platform == "win32":
|
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
|
|
from fastapi import FastAPI, Depends
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from config import settings
|
|
from database import Base, engine
|
|
from routers import garments, search, review, ingest, moodboard, scraper, tna, auth, admin, collections, plm, mail, users, concept, product_studio, fabrics, hermes
|
|
from routers.auth import require_permission
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Service singletons — initialised once at startup, injected into routers
|
|
# ---------------------------------------------------------------------------
|
|
from models.trend import TrendSearch, ScrapedItem # noqa: F401 — registers tables
|
|
from models.tna_stitch import StitchOrder, StitchMilestone # noqa: F401 — registers tables
|
|
from models.tna_template import TnaTemplate, TnaRecommendation # noqa: F401 — registers TNA recommendation tables
|
|
from models.tna_approval import TnaApprovalEvent # noqa: F401 — registers tna_approval_events table
|
|
from models.user import User # noqa: F401 — registers users table
|
|
from models.collection import Collection, CollectionItem # noqa: F401 — registers boards tables
|
|
from models.moodboard import MoodboardDecode # noqa: F401 — registers moodboard_decodes table
|
|
from models.fabric import Fabric # noqa: F401 — registers fabrics table
|
|
from services.embedding_service import EmbeddingService
|
|
from services.tagging_service import TaggingService
|
|
from services.similarity_service import SimilarityService
|
|
|
|
_embedding_svc: EmbeddingService | None = None
|
|
_tagging_svc: TaggingService | None = None
|
|
_similarity_svc: SimilarityService | None = None
|
|
|
|
# Taxonomy — static data served for frontend dropdowns
|
|
TAXONOMY = {
|
|
"category": [
|
|
"Shirt", "Blouse", "T-Shirt", "Polo", "Sweatshirt", "Hoodie", "Overshirt",
|
|
"Top", "Vest", "Trousers", "Jeans", "Joggers", "Shorts", "Skirt",
|
|
"Outerwear", "Blazer", "Dress", "Jumpsuit", "Co-ord Set",
|
|
"Kurta", "Swimwear", "Nightwear", "Loungewear",
|
|
],
|
|
"group": ["Menswear", "Womenswear", "Boyswear", "Girlswear", "Unisex"],
|
|
"macro_family": ["Topwear", "Bottomwear", "Onepiece", "Outerwear"],
|
|
"fabric_family": ["Woven", "Knit", "Denim", "Non-woven"],
|
|
"silhouette": ["Slim", "Regular", "Relaxed", "Oversized", "Cropped", "Longline"],
|
|
"coloration_method": [
|
|
"Solid dyed", "Yarn-dyed", "Printed — Rotary", "Printed — Digital",
|
|
"Printed — Discharge", "Printed — Pigment", "Printed — Placement",
|
|
"AOP", "Ombré", "Tie-dye", "Raw / Natural",
|
|
],
|
|
"neckline": [
|
|
"Point collar", "Button-down collar", "Spread collar", "Band collar",
|
|
"Camp collar", "Half-placket", "V-neck", "Round neck", "Boat neck",
|
|
"Tie-neck", "Ruffle collar", "Peter Pan collar", "Mandarin collar",
|
|
"Square neck", "Halter", "Off-shoulder", "One-shoulder", "Cowl neck",
|
|
"Crew neck", "Henley", "Polo collar", "Mock neck", "Scoop neck", "none",
|
|
],
|
|
"weave_knit_structure": [
|
|
"Plain weave", "Twill", "Satin", "Dobby", "Jacquard", "Oxford",
|
|
"Chambray", "Seersucker", "Leno", "Fil-à-fil", "Herringbone", "Corduroy",
|
|
"Flannel", "Voile", "Crepe", "Canvas", "Ripstop",
|
|
"Single jersey", "Interlock", "French terry", "Fleece", "Rib", "Piqué",
|
|
"Waffle", "Ponte", "Tricot", "Raschel", "Jacquard knit", "Terry", "Velour",
|
|
"Rigid denim", "Stretch denim", "Chambray denim", "Selvedge", "Bull denim",
|
|
],
|
|
"sleeve": [
|
|
"Long sleeve", "Short sleeve", "Three-quarter sleeve", "Rolled sleeve",
|
|
"Sleeveless", "Cap sleeve", "Flutter sleeve", "Bell sleeve", "Bishop sleeve",
|
|
"Puff sleeve", "Raglan sleeve", "Drop shoulder", "Dolman sleeve",
|
|
],
|
|
"value_addition": [
|
|
"Embroidery — All-over", "Embroidery — Placement", "Embroidery — Appliqué",
|
|
"Embroidery — Schiffli", "Embroidery — Bead/Sequin",
|
|
"Print — Graphic", "Print — Stripe", "Print — Floral", "Print — Abstract",
|
|
"Print — Tropical", "Print — Check/Plaid",
|
|
"Wash — Enzyme", "Wash — Stone", "Wash — Acid", "Wash — Bleach",
|
|
"Wash — Silicone", "Wash — Ozone",
|
|
"Overdye", "Laser treatment", "Smocking", "Pleating", "Quilting",
|
|
"Crochet", "Lace", "Fringe", "Patchwork", "none",
|
|
],
|
|
"construction_details": [
|
|
"Chest pocket — Patch", "Chest pocket — Welt", "Chest pocket — Flap", "No pocket",
|
|
"Button-through", "Half-placket", "Zip-through", "Quarter-zip",
|
|
"Drawstring waist", "Elasticated waist", "Belt loops", "Cargo pockets",
|
|
"Lined", "Unlined", "Side slit", "Back yoke", "Epaulettes", "Hood",
|
|
"Ribbed hem", "Wrap front", "Tiered", "Ruching", "Pintuck",
|
|
],
|
|
"length": [
|
|
"Mini", "Midi", "Maxi", "Knee-length", "Full length",
|
|
"Cropped", "Bermuda", "Short", "Micro short",
|
|
],
|
|
"closure": [
|
|
"button_down_front", "button_down_back", "pullover", "wrap_crossover",
|
|
"tie_front", "tie_back", "zip_back", "zip_side", "lace_up_front", "none",
|
|
],
|
|
"hem": [
|
|
"straight", "curved_high_low", "asymmetric", "elasticated",
|
|
"tiered_ruffle", "raw_frayed", "scallop",
|
|
],
|
|
"fabric_texture": [
|
|
"woven_smooth", "crinkled_crushed", "hammered_satin", "jacquard_textured",
|
|
"sheer_semi_sheer", "knit_jersey", "denim", "linen_look", "lace", "broderie",
|
|
],
|
|
"design_dna": [
|
|
"romantic_feminine", "heritage_check_plaid",
|
|
"animal_elevated", "classic_minimal", "mixed",
|
|
],
|
|
"season": ["AW26", "SS26", "AW25", "SS25"],
|
|
"design_team_city": ["mumbai", "bangalore", "spain", "delhi", "chennai", "kolkata", "unknown"],
|
|
"status": ["prototype", "approved", "in_production", "archived"],
|
|
}
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global _embedding_svc, _tagging_svc, _similarity_svc
|
|
|
|
# Security guard — never let prod run on the forgeable built-in default secret.
|
|
if settings.secret_key == "sapl-garment-intel-dev-secret-change-in-prod-2026":
|
|
logger.critical(
|
|
"SECRET_KEY is the built-in DEV default — auth tokens are FORGEABLE. "
|
|
"Set a strong SECRET_KEY in the environment before any real use."
|
|
)
|
|
|
|
# Create all DB tables on startup
|
|
Base.metadata.create_all(bind=engine)
|
|
# Self-heal RBAC columns on a pre-existing DB (create_all can't ALTER).
|
|
from services.rbac import ensure_rbac_schema
|
|
ensure_rbac_schema(engine)
|
|
from services.fabric_seed import ensure_fabric_schema
|
|
ensure_fabric_schema(engine)
|
|
from services.tna_authoring import ensure_tna_authoring_schema
|
|
ensure_tna_authoring_schema(engine)
|
|
logger.info("Database tables ready")
|
|
|
|
# Seed dummy users if none exist
|
|
from database import SessionLocal as _SL
|
|
from models.user import User as _User
|
|
from services.auth_service import hash_password as _hash
|
|
_db = _SL()
|
|
try:
|
|
if _db.query(_User).count() == 0:
|
|
seed = [
|
|
("pratik@sonalapparels.in", "atelier2026", "Pratik", "admin"),
|
|
("suresh@sonalapparels.in", "planning2026", "Suresh", "planner"),
|
|
("ajay@sonalapparels.in", "dev2026", "Ajay Sable", "admin"),
|
|
("nilesh@sonalapparels.in", "audit2026", "Nilesh", "auditor"),
|
|
]
|
|
for email, pw, name, role in seed:
|
|
_db.add(_User(email=email, name=name, role=role, password_hash=_hash(pw)))
|
|
_db.commit()
|
|
logger.info(f"Seeded {len(seed)} users")
|
|
# RBAC: ensure default departments + roles exist and the seeded users are
|
|
# mapped onto them (idempotent — safe on every boot).
|
|
from services.rbac import seed_rbac
|
|
seed_rbac(_db)
|
|
# Seed the Fabric Library from the committed snapshot (idempotent —
|
|
# only fills an empty fabrics table; restores missing images otherwise).
|
|
from services.fabric_seed import seed_fabrics, backfill_source_hashes
|
|
seed_fabrics(_db)
|
|
backfill_source_hashes(_db)
|
|
finally:
|
|
_db.close()
|
|
|
|
# Ensure image directory exists
|
|
Path(settings.images_path).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Initialise services once — avoids ChromaDB thread-safety race on first request
|
|
logger.info("Initialising services...")
|
|
_embedding_svc = EmbeddingService()
|
|
_tagging_svc = TaggingService()
|
|
_similarity_svc = SimilarityService(_embedding_svc)
|
|
|
|
# Share instances with routers via module-level injection
|
|
garments.set_services(_tagging_svc, _embedding_svc, _similarity_svc)
|
|
search.set_embedding_service(_embedding_svc)
|
|
review.set_embedding_service(_embedding_svc)
|
|
ingest.set_services(_tagging_svc, _embedding_svc, _similarity_svc)
|
|
moodboard.set_services(_tagging_svc, _embedding_svc)
|
|
|
|
logger.info("SAPL Garment Intelligence API started")
|
|
|
|
import asyncio
|
|
# All integration syncs run in the BACKGROUND so the API starts serving at
|
|
# once. Previously the lifespan AWAITED the initial Stitch pull (50+ pages of
|
|
# style-plan-master) before yielding — uvicorn won't accept requests until the
|
|
# lifespan reaches `yield`, so the whole frontend sat empty until it finished.
|
|
# Now startup yields immediately and the data fills in as each sync lands.
|
|
|
|
# Stitch TNA: initial sync, then the 2-min loop.
|
|
if settings.stitch_username and settings.stitch_password:
|
|
async def _stitch_bootstrap():
|
|
from services.stitch_service import stitch_sync_loop, sync_from_stitch
|
|
logger.info("Running initial Stitch TNA sync (background)...")
|
|
try:
|
|
await sync_from_stitch()
|
|
except Exception as e:
|
|
logger.warning(f"Initial Stitch sync failed (non-fatal): {e}")
|
|
await stitch_sync_loop()
|
|
asyncio.create_task(_stitch_bootstrap())
|
|
else:
|
|
logger.warning("Stitch credentials not set — TNA sync disabled.")
|
|
|
|
# TNA template master (the recommendation engine's 17 templates), best-effort.
|
|
if settings.stitch_api_key:
|
|
async def _template_bootstrap():
|
|
from services.tna_recommend import sync_templates
|
|
from database import SessionLocal as _TSL
|
|
try:
|
|
_tdb = _TSL()
|
|
await sync_templates(_tdb)
|
|
_tdb.close()
|
|
except Exception as e:
|
|
logger.warning(f"Initial TNA template sync failed (non-fatal): {e}")
|
|
asyncio.create_task(_template_bootstrap())
|
|
|
|
# WFX order-mirror: the sync is synchronous (urllib), so run it in a worker
|
|
# thread — that keeps it off the event loop entirely (it would otherwise block
|
|
# every request for the call's duration). First sync fires at once, then 30s.
|
|
if settings.wfx_token_id:
|
|
from services.wfx_service import sync_wfx_orders
|
|
from database import SessionLocal as _WSL
|
|
|
|
def _wfx_sync_once():
|
|
d = _WSL()
|
|
try:
|
|
sync_wfx_orders(d)
|
|
finally:
|
|
d.close()
|
|
|
|
async def _wfx_sync_loop():
|
|
while True:
|
|
try:
|
|
await asyncio.to_thread(_wfx_sync_once)
|
|
except Exception as e:
|
|
logger.warning(f"WFX mirror sync failed: {e}")
|
|
await asyncio.sleep(30) # 30s — a new WFX lot shows up near-instantly
|
|
|
|
asyncio.create_task(_wfx_sync_loop())
|
|
logger.info("WFX mirror sync loop started (background).")
|
|
else:
|
|
logger.warning("WFX token not set — WFX mirror disabled.")
|
|
|
|
# Start Gmail order-mail ingest (Project P1: Gmail → WFX). Reads the BOM label,
|
|
# AI-extracts lot + dates. Runs in a thread so blocking IMAP/Gemini calls never
|
|
# stall the event loop. Extraction only fires on NEW mails (deduped), so steady
|
|
# state is a cheap IMAP poll.
|
|
if settings.mail_auth_user and settings.mail_app_password:
|
|
from services.mail_service import sync_mail, idle_listen
|
|
from database import SessionLocal as _MSL
|
|
import asyncio
|
|
import threading
|
|
|
|
def _mail_sync_once():
|
|
d = _MSL()
|
|
try:
|
|
res = sync_mail(d)
|
|
if res.get("new"):
|
|
logger.info(f"Mail ingest: {res['new']} new mail(s) fetched.")
|
|
finally:
|
|
d.close()
|
|
|
|
# Instant push via IMAP IDLE — fires sync the moment mail lands in BOM.
|
|
threading.Thread(target=lambda: idle_listen(_mail_sync_once), daemon=True).start()
|
|
logger.info("Mail IDLE listener thread started (instant ingest).")
|
|
|
|
async def _mail_sync_loop():
|
|
# Small startup delay so the API is serving before the first poll.
|
|
await asyncio.sleep(10)
|
|
while True:
|
|
try:
|
|
def _run():
|
|
d = _MSL()
|
|
try:
|
|
return sync_mail(d)
|
|
finally:
|
|
d.close()
|
|
res = await asyncio.to_thread(_run)
|
|
if res.get("new"):
|
|
logger.info(f"Mail ingest: {res['new']} new mail(s) fetched.")
|
|
except Exception as e:
|
|
logger.warning(f"Mail ingest sync failed: {e}")
|
|
await asyncio.sleep(2 * 60) # 2 minutes
|
|
|
|
asyncio.create_task(_mail_sync_loop())
|
|
logger.info("Mail ingest sync loop started.")
|
|
else:
|
|
logger.warning("Mail credentials not set — Gmail ingest disabled.")
|
|
|
|
yield
|
|
logger.info("Shutting down")
|
|
|
|
|
|
app = FastAPI(
|
|
title="SAPL Garment Intelligence API",
|
|
description="AI-powered garment tagging and semantic search for SAPL AW26",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Local dev origins + any extra origins from settings.cors_origins (prod frontend)
|
|
_cors_origins = ["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"]
|
|
_cors_origins += [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_cors_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Static file serving — ensure the dirs exist before mounting. StaticFiles is
|
|
# constructed at import time (before the lifespan startup hook runs), so a fresh
|
|
# checkout without a data/ dir would otherwise crash on mount.
|
|
from pathlib import Path as _Path
|
|
_Path(settings.images_path).mkdir(parents=True, exist_ok=True)
|
|
app.mount("/images", StaticFiles(directory=settings.images_path), name="images")
|
|
|
|
_scraped_dir = _Path(settings.images_path).parent / "scraped"
|
|
_scraped_dir.mkdir(parents=True, exist_ok=True)
|
|
app.mount("/scraped", StaticFiles(directory=str(_scraped_dir)), name="scraped")
|
|
|
|
_concepts_dir = _Path(settings.images_path).parent / "concepts"
|
|
_concepts_dir.mkdir(parents=True, exist_ok=True)
|
|
app.mount("/concepts", StaticFiles(directory=str(_concepts_dir)), name="concepts")
|
|
|
|
# Routers
|
|
app.include_router(garments.router, prefix="/api/garments", tags=["garments"])
|
|
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
|
app.include_router(review.router, prefix="/api/review", tags=["review"])
|
|
app.include_router(ingest.router, prefix="/api/ingest", tags=["ingest"])
|
|
app.include_router(moodboard.router, prefix="/api/moodboard", tags=["moodboard"])
|
|
if settings.enable_scraper:
|
|
app.include_router(scraper.router, prefix="/api/scraper", tags=["scraper"])
|
|
app.include_router(tna.router, prefix="/api/tna", tags=["tna"])
|
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
|
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
|
app.include_router(users.router, prefix="/api/admin", tags=["user-management"])
|
|
app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
|
|
app.include_router(plm.router)
|
|
app.include_router(mail.router)
|
|
app.include_router(concept.router, prefix="/api/concept", tags=["concept"])
|
|
app.include_router(product_studio.router, prefix="/api/product-studio", tags=["product-studio"])
|
|
app.include_router(fabrics.router, prefix="/api/fabrics", tags=["fabrics"])
|
|
app.include_router(hermes.router, prefix="/api/hermes", tags=["hermes"])
|
|
# prefix /api/plm declared on the router
|
|
|
|
|
|
@app.get("/api/health", tags=["meta"])
|
|
def health():
|
|
return {"status": "ok", "version": "2.0.0", "collection": "SAPL AW26 Womenswear"}
|
|
|
|
|
|
@app.get("/api/taxonomy", tags=["meta"])
|
|
def get_taxonomy():
|
|
return TAXONOMY
|
|
|
|
|
|
@app.get("/api/stats", tags=["meta"])
|
|
def get_stats():
|
|
from sqlalchemy import func, text
|
|
from database import SessionLocal
|
|
from models.garment import Garment, GarmentTag
|
|
from models.review_queue import ReviewQueueItem
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
total = db.query(Garment).filter(Garment.status != "archived").count()
|
|
|
|
# By category
|
|
type_rows = (
|
|
db.query(GarmentTag.field_value, func.count(GarmentTag.id))
|
|
.filter(GarmentTag.field_name == "category")
|
|
.group_by(GarmentTag.field_value)
|
|
.all()
|
|
)
|
|
by_type = {row[0]: row[1] for row in type_rows}
|
|
|
|
# By design_dna
|
|
dna_rows = (
|
|
db.query(GarmentTag.field_value, func.count(GarmentTag.id))
|
|
.filter(GarmentTag.field_name == "design_dna")
|
|
.group_by(GarmentTag.field_value)
|
|
.all()
|
|
)
|
|
by_dna = {row[0]: row[1] for row in dna_rows}
|
|
|
|
# By series
|
|
series_rows = (
|
|
db.query(Garment.series_code, func.count(Garment.id))
|
|
.group_by(Garment.series_code)
|
|
.all()
|
|
)
|
|
by_series = {row[0]: row[1] for row in series_rows}
|
|
|
|
pending_review = db.query(ReviewQueueItem).filter(ReviewQueueItem.status == "pending").count()
|
|
ai_auto = db.query(Garment).filter(Garment.review_status == "ai_tagged_auto").count()
|
|
human_verified = db.query(Garment).filter(Garment.review_status == "human_verified").count()
|
|
|
|
return {
|
|
"total_garments": total,
|
|
"by_category": by_type,
|
|
"by_design_dna": by_dna,
|
|
"by_series": by_series,
|
|
"pending_review_items": pending_review,
|
|
"ai_auto_tagged": ai_auto,
|
|
"human_verified": human_verified,
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@app.get("/api/analytics", tags=["meta"], dependencies=[Depends(require_permission("analytics", "view"))])
|
|
def get_analytics(
|
|
season: str | None = None,
|
|
segment: str | None = None,
|
|
status: str | None = None,
|
|
category: str | None = None, # mens | womens | kids (Garment.category)
|
|
collection: str | None = None,
|
|
fabric_family: str | None = None,
|
|
design_team_city: str | None = None,
|
|
design_dna: str | None = None, # tag-based filter
|
|
):
|
|
import json as _json
|
|
from collections import Counter
|
|
from sqlalchemy import func
|
|
from database import SessionLocal
|
|
from models.garment import Garment, GarmentTag
|
|
from models.review_queue import ReviewQueueItem
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# ── Filter options (over the whole non-archived library, so dropdowns stay stable) ──
|
|
def col_options(attr: str) -> list:
|
|
rows = (
|
|
db.query(getattr(Garment, attr), func.count(Garment.id))
|
|
.filter(Garment.status != "archived")
|
|
.group_by(getattr(Garment, attr))
|
|
.order_by(func.count(Garment.id).desc())
|
|
.all()
|
|
)
|
|
return [{"value": r[0], "count": r[1]} for r in rows if r[0]]
|
|
|
|
dna_opt_rows = (
|
|
db.query(GarmentTag.field_value, func.count(func.distinct(GarmentTag.garment_id)))
|
|
.filter(GarmentTag.field_name == "design_dna")
|
|
.group_by(GarmentTag.field_value)
|
|
.order_by(func.count(func.distinct(GarmentTag.garment_id)).desc())
|
|
.all()
|
|
)
|
|
filter_options = {
|
|
"season": col_options("season"),
|
|
"segment": col_options("segment"),
|
|
"status": col_options("status"),
|
|
"category": col_options("category"),
|
|
"collection": col_options("collection_name"),
|
|
"fabric_family": col_options("fabric_family"),
|
|
"design_team_city": col_options("design_team_city"),
|
|
"design_dna": [{"value": r[0], "count": r[1]} for r in dna_opt_rows if r[0]],
|
|
}
|
|
applied = {
|
|
k: v for k, v in {
|
|
"season": season, "segment": segment, "status": status,
|
|
"category": category, "collection": collection,
|
|
"fabric_family": fabric_family, "design_team_city": design_team_city,
|
|
"design_dna": design_dna,
|
|
}.items() if v
|
|
}
|
|
|
|
# ── Filtered garment set — every distribution below is computed over THIS set ──
|
|
gq = db.query(Garment)
|
|
gq = gq.filter(Garment.status == status) if status else gq.filter(Garment.status != "archived")
|
|
if season: gq = gq.filter(Garment.season == season)
|
|
if segment: gq = gq.filter(Garment.segment == segment)
|
|
if category: gq = gq.filter(Garment.category == category)
|
|
if collection: gq = gq.filter(Garment.collection_name == collection)
|
|
if fabric_family: gq = gq.filter(Garment.fabric_family == fabric_family)
|
|
if design_team_city: gq = gq.filter(Garment.design_team_city == design_team_city)
|
|
if design_dna:
|
|
dna_ids = [r[0] for r in db.query(GarmentTag.garment_id).filter(
|
|
GarmentTag.field_name == "design_dna", GarmentTag.field_value == design_dna).all()]
|
|
gq = gq.filter(Garment.id.in_(dna_ids))
|
|
garments = gq.all()
|
|
gids = [g.id for g in garments]
|
|
total = len(garments)
|
|
|
|
def tag_distribution(field: str) -> dict:
|
|
if not gids:
|
|
return {}
|
|
rows = (
|
|
db.query(GarmentTag.field_value, func.count(GarmentTag.id))
|
|
.filter(GarmentTag.field_name == field, GarmentTag.garment_id.in_(gids))
|
|
.group_by(GarmentTag.field_value)
|
|
.order_by(func.count(GarmentTag.id).desc())
|
|
.all()
|
|
)
|
|
return {r[0]: r[1] for r in rows}
|
|
|
|
def col_distribution(attr: str, default: str = "Unknown") -> dict:
|
|
c = Counter(getattr(g, attr) or default for g in garments)
|
|
return dict(c.most_common())
|
|
|
|
# Colour distribution — field_value is a JSON list
|
|
colour_counts: dict = {}
|
|
if gids:
|
|
colour_rows = (
|
|
db.query(GarmentTag.field_value)
|
|
.filter(GarmentTag.field_name == "colour", GarmentTag.garment_id.in_(gids))
|
|
.all()
|
|
)
|
|
for (val,) in colour_rows:
|
|
try:
|
|
for c in (_json.loads(val) if val else []):
|
|
name = c.get("colour_name", "Unknown")
|
|
key = name
|
|
if key not in colour_counts:
|
|
colour_counts[key] = {"count": 0, "hex": c.get("hex", "#888888"), "hero_count": 0}
|
|
colour_counts[key]["count"] += 1
|
|
if c.get("role", "Hero") == "Hero":
|
|
colour_counts[key]["hero_count"] += 1
|
|
except Exception:
|
|
pass
|
|
colour_list = sorted(
|
|
[{"name": k, **v} for k, v in colour_counts.items()],
|
|
key=lambda x: x["hero_count"], reverse=True,
|
|
)
|
|
|
|
# Confidence by field — avg (over the filtered set)
|
|
confidence_by_field = []
|
|
if gids:
|
|
conf_rows = (
|
|
db.query(GarmentTag.field_name, func.avg(GarmentTag.confidence), func.count(GarmentTag.id))
|
|
.filter(GarmentTag.garment_id.in_(gids))
|
|
.group_by(GarmentTag.field_name)
|
|
.order_by(func.avg(GarmentTag.confidence).asc())
|
|
.all()
|
|
)
|
|
confidence_by_field = [
|
|
{"field": r[0], "avg_confidence": round(float(r[1]), 3), "tag_count": r[2]}
|
|
for r in conf_rows
|
|
]
|
|
|
|
# Library growth over time — garments added per day + cumulative (from created_at)
|
|
day_added = Counter(
|
|
g.created_at.date().isoformat() for g in garments if g.created_at is not None
|
|
)
|
|
growth, _cum = [], 0
|
|
for d in sorted(day_added):
|
|
_cum += day_added[d]
|
|
growth.append({"date": d, "added": day_added[d], "cumulative": _cum})
|
|
|
|
# Review health — scoped to the filtered set
|
|
tq = db.query(GarmentTag).filter(GarmentTag.garment_id.in_(gids)) if gids else db.query(GarmentTag).filter(False)
|
|
total_tags = tq.count()
|
|
auto_saved = tq.filter(GarmentTag.confidence >= 0.85).count()
|
|
needs_review_tags = tq.filter(GarmentTag.confidence >= 0.65, GarmentTag.confidence < 0.85).count()
|
|
low_conf = tq.filter(GarmentTag.confidence < 0.65).count()
|
|
rq = db.query(ReviewQueueItem).filter(ReviewQueueItem.garment_id.in_(gids)) if gids else db.query(ReviewQueueItem).filter(False)
|
|
pending_q = rq.filter(ReviewQueueItem.status == "pending").count()
|
|
approved_q = rq.filter(ReviewQueueItem.status == "approved").count()
|
|
corrected_q = rq.filter(ReviewQueueItem.status == "corrected").count()
|
|
|
|
return {
|
|
"total_garments": total,
|
|
"applied_filters": applied,
|
|
"filter_options": filter_options,
|
|
"growth": growth,
|
|
# tag-based distributions
|
|
"by_design_dna": tag_distribution("design_dna"),
|
|
"by_category": tag_distribution("category"),
|
|
"by_sub_type": tag_distribution("sub_type"),
|
|
"by_coloration_method": tag_distribution("coloration_method"),
|
|
"by_value_addition": tag_distribution("value_addition"),
|
|
"by_silhouette": tag_distribution("silhouette"),
|
|
"by_neckline": tag_distribution("neckline"),
|
|
"by_sleeve": tag_distribution("sleeve"),
|
|
"by_length": tag_distribution("length"),
|
|
"by_hem": tag_distribution("hem"),
|
|
"by_closure": tag_distribution("closure"),
|
|
"by_fabric_texture": tag_distribution("fabric_texture"),
|
|
"by_embellishment": tag_distribution("embellishment"),
|
|
"by_weave": tag_distribution("weave_knit_structure"),
|
|
"by_group": tag_distribution("group"),
|
|
# garment-column distributions
|
|
"by_fabric_family": col_distribution("fabric_family"),
|
|
"by_macro_family": col_distribution("macro_family"),
|
|
"by_design_team_city": col_distribution("design_team_city"),
|
|
"by_status": col_distribution("status"),
|
|
"by_segment": col_distribution("segment"),
|
|
"colour_palette": colour_list,
|
|
"confidence_by_field": confidence_by_field,
|
|
"review_health": {
|
|
"total_tags": total_tags,
|
|
"auto_saved": auto_saved,
|
|
"needs_review": needs_review_tags,
|
|
"low_confidence": low_conf,
|
|
"auto_save_pct": round(auto_saved / total_tags * 100, 1) if total_tags else 0,
|
|
"queue_pending": pending_q,
|
|
"queue_approved": approved_q,
|
|
"queue_corrected": corrected_q,
|
|
},
|
|
}
|
|
finally:
|
|
db.close()
|