Electronics Catalog: API, MCP server, frontend and deployment
Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
188
backend/app/api/routers/elec.py
Normal file
188
backend/app/api/routers/elec.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Read-only catalogue API: category -> brand -> product -> per-platform offers.
|
||||
|
||||
Only VERIFIED products are served (see repository.refresh_verification), and
|
||||
every price is returned with the site, URL, source type and time it was seen.
|
||||
Money is returned as a decimal string, never a float.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.electronics.db.connection import connect
|
||||
from app.electronics.db.repository import product_rating_and_reviews
|
||||
from app.electronics.reviews import select_reviews
|
||||
|
||||
router = APIRouter(prefix="/elec", tags=["electronics"])
|
||||
|
||||
|
||||
def _money(value: Optional[Decimal]) -> Optional[str]:
|
||||
return None if value is None else format(value, "f")
|
||||
|
||||
|
||||
def _clean(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = {}
|
||||
for k, v in row.items():
|
||||
if isinstance(v, Decimal):
|
||||
out[k] = _money(v) if k in ("price", "mrp", "best_price", "min_price", "max_price") else float(v)
|
||||
elif hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
def categories() -> List[dict]:
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT c.slug, c.name, coalesce(sum(s.product_count), 0)::int AS product_count "
|
||||
"FROM elec.category c LEFT JOIN elec.v_brand_summary s ON s.category = c.slug "
|
||||
"GROUP BY c.slug, c.name ORDER BY c.name"
|
||||
).fetchall()
|
||||
return [_clean(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/brands")
|
||||
def brands(category: str = Query(...)) -> List[dict]:
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT b.name AS brand, b.slug AS brand_slug,
|
||||
coalesce(s.product_count, 0)::int AS product_count, s.min_price, s.max_price,
|
||||
(SELECT image_url FROM elec.v_brand_catalog v
|
||||
WHERE v.brand_slug = b.slug AND v.category = %(c)s AND v.image_url IS NOT NULL
|
||||
ORDER BY v.platform_count DESC LIMIT 1) AS sample_image
|
||||
FROM elec.brand b
|
||||
JOIN elec.brand_category bc ON bc.brand_id = b.id
|
||||
JOIN elec.category c ON c.id = bc.category_id AND c.slug = %(c)s
|
||||
LEFT JOIN elec.v_brand_summary s ON s.brand_slug = b.slug AND s.category = %(c)s
|
||||
ORDER BY coalesce(s.product_count, 0) DESC, b.name
|
||||
""",
|
||||
{"c": category},
|
||||
).fetchall()
|
||||
return [_clean(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/products")
|
||||
def products(
|
||||
category: Optional[str] = None,
|
||||
brand: Optional[str] = None,
|
||||
q: Optional[str] = Query(None, max_length=100),
|
||||
min_price: Optional[Decimal] = None,
|
||||
max_price: Optional[Decimal] = None,
|
||||
in_stock: bool = False,
|
||||
site: Optional[str] = None,
|
||||
tn_only: bool = False,
|
||||
limit: int = Query(48, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict:
|
||||
where, params = ["TRUE"], {}
|
||||
if category:
|
||||
where.append("v.category = %(category)s"); params["category"] = category
|
||||
if brand:
|
||||
where.append("v.brand_slug = %(brand)s"); params["brand"] = brand
|
||||
if q:
|
||||
where.append("(v.display_name ILIKE %(q)s OR v.brand ILIKE %(q)s)"); params["q"] = f"%{q}%"
|
||||
if min_price is not None:
|
||||
where.append("v.best_price >= %(min_price)s"); params["min_price"] = min_price
|
||||
if max_price is not None:
|
||||
where.append("v.best_price <= %(max_price)s"); params["max_price"] = max_price
|
||||
if in_stock:
|
||||
where.append("EXISTS (SELECT 1 FROM elec.v_product_availability a WHERE a.product_id = v.product_id AND a.in_stock)")
|
||||
if site:
|
||||
where.append("EXISTS (SELECT 1 FROM elec.v_product_availability a WHERE a.product_id = v.product_id AND a.domain = %(site)s)")
|
||||
params["site"] = site
|
||||
if tn_only:
|
||||
where.append("v.sold_by_tn_retailer")
|
||||
sql_where = " AND ".join(where)
|
||||
with connect() as conn:
|
||||
total = conn.execute(f"SELECT count(*) AS n FROM elec.v_brand_catalog v WHERE {sql_where}", params).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
f"SELECT v.* FROM elec.v_brand_catalog v WHERE {sql_where} "
|
||||
f"ORDER BY v.platform_count DESC, v.best_price NULLS LAST, v.display_name "
|
||||
f"LIMIT %(limit)s OFFSET %(offset)s",
|
||||
{**params, "limit": limit, "offset": offset},
|
||||
).fetchall()
|
||||
return {"total": total, "products": [_clean(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/products/{product_id}")
|
||||
def product(product_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM elec.v_brand_catalog WHERE product_id = %s", (product_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Product not found or not verified")
|
||||
specs = conn.execute("SELECT spec_sources FROM elec.product WHERE id = %s", (product_id,)).fetchone()
|
||||
offers = conn.execute(
|
||||
"SELECT * FROM elec.v_product_availability WHERE product_id = %s "
|
||||
"ORDER BY (price IS NULL), (source_type = 'search_snippet'), price, site",
|
||||
(product_id,),
|
||||
).fetchall()
|
||||
images = conn.execute(
|
||||
"SELECT i.url, i.source_type, s.name AS site, l.source_url AS found_on "
|
||||
"FROM elec.product_image i JOIN elec.source_listing l ON l.id = i.source_listing_id "
|
||||
"JOIN elec.site s ON s.id = l.site_id WHERE i.product_id = %s ORDER BY i.rank, i.id",
|
||||
(product_id,),
|
||||
).fetchall()
|
||||
rated = product_rating_and_reviews(conn, product_id)
|
||||
result = _clean(row)
|
||||
result["spec_sources"] = specs["spec_sources"] if specs else {}
|
||||
result["offers"] = [_clean(o) for o in offers]
|
||||
result["images"] = [dict(i) for i in images]
|
||||
result["rating"] = _overall_rating(rated["sources"])
|
||||
overall = result["rating"]["value"] if result["rating"] else None
|
||||
result["reviews"] = [_clean(r) for r in select_reviews(overall, rated["reviews"])]
|
||||
return result
|
||||
|
||||
|
||||
def _overall_rating(sources: List[dict]) -> Optional[dict]:
|
||||
"""The product's rating across the platforms that state one: the mean
|
||||
weighted by each platform's rating count (a platform that states no count
|
||||
weighs as 1). None when no platform states a rating - never a guess."""
|
||||
if not sources:
|
||||
return None
|
||||
weight = lambda s: max(int(s["review_count"] or 0), 1) # noqa: E731
|
||||
total = sum(weight(s) for s in sources)
|
||||
value = sum(Decimal(s["rating"]) * weight(s) for s in sources) / total
|
||||
counts = [s["review_count"] for s in sources if s["review_count"]]
|
||||
return {
|
||||
"value": round(float(value), 1),
|
||||
"count": sum(counts) if counts else None,
|
||||
"sources": [
|
||||
{"site": s["site"], "rating": float(s["rating"]), "review_count": s["review_count"], "source_url": s["source_url"]}
|
||||
for s in sources
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/price-history")
|
||||
def price_history(product_id: int) -> List[dict]:
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT s.name AS site, h.price, h.mrp, h.in_stock, h.source_type, h.observed_at
|
||||
FROM elec.price_history h
|
||||
JOIN elec.product_listing_map m ON m.listing_id = h.listing_id AND m.review_status IN ('auto','approved')
|
||||
JOIN elec.source_listing l ON l.id = h.listing_id
|
||||
JOIN elec.site s ON s.id = l.site_id
|
||||
WHERE m.product_id = %s AND h.price IS NOT NULL
|
||||
ORDER BY h.observed_at
|
||||
""",
|
||||
(product_id,),
|
||||
).fetchall()
|
||||
return [_clean(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/sites")
|
||||
def sites() -> List[dict]:
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT s.name, s.domain, s.kind, s.region, s.policy, s.probe_outcome, s.probed_at, "
|
||||
"s.breaker_until, s.breaker_reason, s.probe_evidence->>'reason' AS probe_reason, "
|
||||
"(SELECT count(*) FROM elec.source_listing l WHERE l.site_id = s.id)::int AS listings "
|
||||
"FROM elec.site s ORDER BY (s.kind = 'brand_official'), s.name"
|
||||
).fetchall()
|
||||
return [_clean(r) for r in rows]
|
||||
Reference in New Issue
Block a user