Production runs a separate SQLite file on a persistent volume that was
never seeded, so /fabrics showed 0 swatches even though 59 were ingested
locally. Ship a committed snapshot (seed/fabrics_seed.json + 118 card/
swatch images) and an idempotent startup seeder:
- inserts the 59 rows only when the fabrics table is empty (never
overwrites edits / human-verified data on a populated prod DB)
- copies swatch images into the images volume only when missing
- coerces ISO datetime strings back to datetime for SQLite
Wired into the lifespan startup right after RBAC seeding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
536 lines
23 KiB
Python
536 lines
23 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
|
|
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.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)
|
|
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
|
|
seed_fabrics(_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")
|
|
|
|
# Start Stitch TNA background sync (every 2 min — near-real-time)
|
|
if settings.stitch_username and settings.stitch_password:
|
|
from services.stitch_service import stitch_sync_loop, sync_from_stitch
|
|
import asyncio
|
|
logger.info("Running initial Stitch TNA sync...")
|
|
try:
|
|
await sync_from_stitch()
|
|
except Exception as e:
|
|
logger.warning(f"Initial Stitch sync failed (non-fatal): {e}")
|
|
asyncio.create_task(stitch_sync_loop())
|
|
logger.info("Stitch sync loop started.")
|
|
else:
|
|
logger.warning("Stitch credentials not set — TNA sync disabled.")
|
|
|
|
# Sync the TNA template master (the 17 templates) for the recommendation engine.
|
|
# Uses the STITCH-KEY integration auth; best-effort, never blocks startup.
|
|
if settings.stitch_api_key:
|
|
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}")
|
|
|
|
# Start WFX order-mirror sync (every 2 min; caches WFX's recent order feed;
|
|
# orders persist past WFX's ~82-order window so coverage accrues over time)
|
|
if settings.wfx_token_id:
|
|
from services.wfx_service import sync_wfx_orders
|
|
from database import SessionLocal as _WSL
|
|
import asyncio
|
|
try:
|
|
_wdb = _WSL()
|
|
sync_wfx_orders(_wdb)
|
|
_wdb.close()
|
|
except Exception as e:
|
|
logger.warning(f"Initial WFX mirror sync failed (non-fatal): {e}")
|
|
|
|
async def _wfx_sync_loop():
|
|
while True:
|
|
await asyncio.sleep(30) # 30s — a new WFX lot shows up near-instantly
|
|
try:
|
|
_d = _WSL()
|
|
sync_wfx_orders(_d)
|
|
_d.close()
|
|
except Exception as e:
|
|
logger.warning(f"WFX mirror sync failed: {e}")
|
|
|
|
asyncio.create_task(_wfx_sync_loop())
|
|
logger.info("WFX mirror sync loop started.")
|
|
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"])
|
|
# 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():
|
|
import json as _json
|
|
from sqlalchemy import func
|
|
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()
|
|
|
|
def tag_distribution(field: str) -> dict:
|
|
rows = (
|
|
db.query(GarmentTag.field_value, func.count(GarmentTag.id))
|
|
.filter(GarmentTag.field_name == field)
|
|
.group_by(GarmentTag.field_value)
|
|
.order_by(func.count(GarmentTag.id).desc())
|
|
.all()
|
|
)
|
|
return {r[0]: r[1] for r in rows}
|
|
|
|
# Colour distribution — field_value is a JSON list
|
|
colour_counts: dict = {}
|
|
colour_rows = (
|
|
db.query(GarmentTag.field_value)
|
|
.filter(GarmentTag.field_name == "colour")
|
|
.all()
|
|
)
|
|
for (val,) in colour_rows:
|
|
try:
|
|
colours = _json.loads(val) if val else []
|
|
for c in colours:
|
|
name = c.get("colour_name", "Unknown")
|
|
hex_val = c.get("hex", "#888888")
|
|
role = c.get("role", "Hero")
|
|
key = name
|
|
if key not in colour_counts:
|
|
colour_counts[key] = {"count": 0, "hex": hex_val, "hero_count": 0}
|
|
colour_counts[key]["count"] += 1
|
|
if role == "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,
|
|
)
|
|
|
|
# Fabric family from garments table
|
|
fabric_rows = (
|
|
db.query(Garment.fabric_family, func.count(Garment.id))
|
|
.filter(Garment.status != "archived")
|
|
.group_by(Garment.fabric_family)
|
|
.all()
|
|
)
|
|
by_fabric_family = {r[0] or "Unknown": r[1] for r in fabric_rows}
|
|
|
|
# Macro family from garments table
|
|
macro_rows = (
|
|
db.query(Garment.macro_family, func.count(Garment.id))
|
|
.filter(Garment.status != "archived")
|
|
.group_by(Garment.macro_family)
|
|
.all()
|
|
)
|
|
by_macro_family = {r[0] or "Unknown": r[1] for r in macro_rows}
|
|
|
|
# Confidence by field — avg + count of auto/review/rejected
|
|
conf_rows = (
|
|
db.query(
|
|
GarmentTag.field_name,
|
|
func.avg(GarmentTag.confidence),
|
|
func.count(GarmentTag.id),
|
|
)
|
|
.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
|
|
]
|
|
|
|
# Review queue health
|
|
total_tags = db.query(GarmentTag).count()
|
|
auto_saved = db.query(GarmentTag).filter(GarmentTag.confidence >= 0.85).count()
|
|
needs_review_tags = db.query(GarmentTag).filter(
|
|
GarmentTag.confidence >= 0.65, GarmentTag.confidence < 0.85
|
|
).count()
|
|
low_conf = db.query(GarmentTag).filter(GarmentTag.confidence < 0.65).count()
|
|
pending_q = db.query(ReviewQueueItem).filter(ReviewQueueItem.status == "pending").count()
|
|
approved_q = db.query(ReviewQueueItem).filter(ReviewQueueItem.status == "approved").count()
|
|
corrected_q = db.query(ReviewQueueItem).filter(ReviewQueueItem.status == "corrected").count()
|
|
|
|
return {
|
|
"total_garments": total,
|
|
"by_design_dna": tag_distribution("design_dna"),
|
|
"by_category": tag_distribution("category"),
|
|
"by_coloration_method": tag_distribution("coloration_method"),
|
|
"by_value_addition": tag_distribution("value_addition"),
|
|
"by_silhouette": tag_distribution("silhouette"),
|
|
"by_fabric_family": by_fabric_family,
|
|
"by_macro_family": by_macro_family,
|
|
"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()
|