backend apis updation

This commit is contained in:
sriram
2026-08-13 15:54:29 +05:30
parent b8d93fbbf2
commit 241fd237f8
13 changed files with 973 additions and 105 deletions

View File

@@ -1,12 +1,22 @@
from __future__ import annotations
import re
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from app.api.schemas import AllProductsOut, BrandsOut, CategoriesOut, ProductListOut, ProductOut
from app.api.schemas import (
AllProductsOut,
BrandCardOut,
BrandCardsOut,
BrandsOut,
CategoriesOut,
ProductListOut,
ProductOut,
)
from app.services.s3_service import s3_service
from app.services.vector_store import (
get_brand_overview,
list_available_brands,
list_categories_for_brand,
get_products_by_brand,
@@ -106,6 +116,53 @@ def get_brands() -> BrandsOut:
return BrandsOut(brands=list_available_brands())
def _initials(name: str) -> str:
"""Monogram for the card's image fallback - there are no logo assets."""
words = [w for w in re.split(r"[^A-Za-z0-9]+", name) if w]
if not words:
return "?"
if len(words) == 1:
return words[0][:2].upper()
return (words[0][0] + words[1][0]).upper()
# Declared before /brands/{brand}/... so "overview" is never read as a brand
# name. The path-segment counts differ, so this is belt-and-braces.
@router.get("/brands/overview", response_model=BrandCardsOut)
def get_brand_cards(
refresh: bool = Query(False, description="Bypass the short-lived overview cache"),
) -> BrandCardsOut:
"""Per-brand summaries for the home page card grid.
Additive: GET /brands keeps returning a plain list of names, which the
sidebar and the admin/user pages rely on.
"""
rows = get_brand_overview(force_refresh=refresh)
cards = []
for row in rows:
name = row["display_name"]
image_url = _clean_url(row.get("sample_image_url"))
if not image_url and s3_service.enabled and row.get("sample_image_id"):
image_url = s3_service.get_product_image_url(name, row["sample_image_id"])
cards.append(BrandCardOut(
name=name,
slug=row["suffix"],
product_count=row["product_count"],
category_count=row["category_count"],
categories=list(row.get("categories") or []),
image_url=image_url,
initials=_initials(name),
))
return BrandCardsOut(
total_brands=len(cards),
total_products=sum(c.product_count for c in cards),
brands=cards,
)
@router.get("/brands/{brand}/categories", response_model=CategoriesOut)
def get_brand_categories(brand: str) -> CategoriesOut:
return CategoriesOut(brand=brand, categories=list_categories_for_brand(brand))

View File

@@ -48,6 +48,26 @@ def _run_background_auto_seed():
logger.error("Background Auto-Init error: %s", e)
def _run_background_brand_sync() -> None:
"""Reconcile brand tables against data/seed_catalogs/ in both directions.
Complements the auto-seed above rather than replacing it: that one only
fires against a completely empty database, so without this a brand table
created after first boot never gets a seed file, and a seed file added
after first boot is never loaded.
"""
try:
from app.services.brand_sync import reconcile_brand_catalogs
summary = reconcile_brand_catalogs()
logger.info("🔁 Brand catalog reconcile: %s", summary)
except Exception as e:
logger.error("Brand catalog reconcile error: %s", e)
class BrandSyncRequest(BaseModel):
dry_run: bool = False
@router.get("/system/status", response_model=SystemStatusOut)
def get_system_status() -> SystemStatusOut:
"""Return unified status of database, vector store, stores, and frontend build."""
@@ -89,3 +109,23 @@ def initialize_system(background_tasks: BackgroundTasks) -> Dict[str, Any]:
"status": "started",
"message": "Background initialization triggered. Check /api/system/status for progress.",
}
@router.post("/system/brand-sync", dependencies=[Depends(require_admin)])
def sync_brand_catalogs(payload: BrandSyncRequest, background_tasks: BackgroundTasks) -> Dict[str, Any]:
"""Reconcile brand tables with their seed catalog files.
`dry_run` answers inline - it is a handful of count queries and writes
nothing, so it is safe to poke at. A real run is backgrounded because
exporting a large brand serialises thousands of 384-float embeddings.
"""
from app.services.brand_sync import reconcile_brand_catalogs
if payload.dry_run:
return {"status": "ok", "summary": reconcile_brand_catalogs(dry_run=True)}
background_tasks.add_task(_run_background_brand_sync)
return {
"status": "started",
"message": "Brand catalog reconcile triggered. Check /api/system/status for progress.",
}

View File

@@ -1,8 +1,6 @@
import io
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, Field
@@ -16,14 +14,13 @@ from app.services.vector_store import (
_sanitize_name,
get_products_by_brand,
)
from app.services.brand_sync import upsert_products_into_catalog_file
from app.services.embeddings_service import embed_texts
from app.services.s3_service import s3_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user/products", tags=["user_products"])
SEED_DIR = Path(__file__).resolve().parents[3] / "data" / "seed_catalogs"
class AddProductRequest(BaseModel):
brand: str = Field(..., description="Brand name, e.g. Lion Dates")
@@ -174,50 +171,14 @@ def _enrich_and_save_product(req: AddProductRequest) -> Dict[str, Any]:
def _update_json_catalog_file(brand: str, product_dict: Dict[str, Any]) -> None:
SEED_DIR.mkdir(parents=True, exist_ok=True)
"""Append/update one product in the brand's seed catalog.
# Determine seed file name (e.g. brand_catalog_lion_dates.json)
brand_slug = _sanitize_name(resolve_parent_brand(brand))
file_path = SEED_DIR / f"brand_catalog_{brand_slug}.json"
# Strip embedding before saving to JSON file for clean JSON size
clean_dict = {k: v for k, v in product_dict.items() if k != "embedding"}
if file_path.exists():
try:
data = json.loads(file_path.read_text(encoding="utf-8-sig"))
except Exception as e:
logger.warning("Could not read existing catalog JSON %s: %s", file_path.name, e)
data = {"brand": brand, "products": []}
else:
data = {
"brand": brand.lower(),
"search_query": f"{brand} products catalog",
"generation_timestamp": str(Path(__file__).resolve()),
"total_products": 0,
"total_images": 0,
"products": [],
}
products_list = data.get("products", [])
# Replace existing or append new product
updated = False
for i, p in enumerate(products_list):
if p.get("image_id") == clean_dict["image_id"] or p.get("product_name") == clean_dict["product_name"]:
products_list[i] = clean_dict
updated = True
break
if not updated:
products_list.append(clean_dict)
data["products"] = products_list
data["total_products"] = len(products_list)
data["total_images"] = sum(len(p.get("image_urls") or []) for p in products_list)
file_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
logger.info("✅ Updated JSON seed file '%s' (total products: %d)", file_path.name, data["total_products"])
Delegates to brand_sync so this shares the file-resolution rules with the
startup reconcile. That also fixes a mis-targeting bug this used to have:
picking the file by sanitised slug wrote P&G products to a new
brand_catalog_p_g.json instead of the real brand_catalog_p_and_g.json.
"""
upsert_products_into_catalog_file(brand, [product_dict])
@router.post("/add", status_code=201, dependencies=[Depends(require_permission("add_product"))])