258 lines
8.8 KiB
Python
258 lines
8.8 KiB
Python
"""Pydantic request/response models for the FastAPI layer."""
|
|
from __future__ import annotations
|
|
|
|
from typing import List, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ProductOut(BaseModel):
|
|
image_id: str
|
|
image_url: Optional[str] = None
|
|
image_urls: List[str] = Field(default_factory=list)
|
|
brand: str
|
|
product_name: str
|
|
title: Optional[str] = None
|
|
category: Optional[str] = None
|
|
description: Optional[str] = None
|
|
price_range: Optional[str] = None
|
|
size_variants: List[str] = Field(default_factory=list)
|
|
providers: List[str] = Field(default_factory=list)
|
|
highlights: List[str] = Field(default_factory=list)
|
|
nutrients: List[str] = Field(default_factory=list)
|
|
fssai_license: Optional[str] = None
|
|
product_sku: Optional[str] = None
|
|
sku_source: Optional[str] = None
|
|
hsn_code: Optional[str] = None
|
|
final_selling_price: Optional[float] = None
|
|
selling_price: Optional[float] = None
|
|
barcode: Optional[str] = None
|
|
barcode_type: Optional[str] = None
|
|
# Mirrored from nutrition_insights onto the brand table by
|
|
# nutrition_score_sync. None means "not scored yet", never "scored zero".
|
|
nutrition_score: Optional[float] = None
|
|
health_score: Optional[float] = None
|
|
|
|
|
|
class SourceProductOut(BaseModel):
|
|
image_id: str
|
|
image_url: Optional[str] = None
|
|
image_urls: List[str] = Field(default_factory=list)
|
|
brand: str
|
|
product_name: str
|
|
title: Optional[str] = None
|
|
category: Optional[str] = None
|
|
description: Optional[str] = None
|
|
price_range: Optional[str] = None
|
|
size_variants: List[str] = Field(default_factory=list)
|
|
providers: List[str] = Field(default_factory=list)
|
|
highlights: List[str] = Field(default_factory=list)
|
|
nutrients: List[str] = Field(default_factory=list)
|
|
fssai_license: Optional[str] = None
|
|
product_sku: Optional[str] = None
|
|
sku_source: Optional[str] = None
|
|
hsn_code: Optional[str] = None
|
|
final_selling_price: Optional[float] = None
|
|
selling_price: Optional[float] = None
|
|
barcode: Optional[str] = None
|
|
barcode_type: Optional[str] = None
|
|
# Mirrored from nutrition_insights onto the brand table by
|
|
# nutrition_score_sync. None means "not scored yet", never "scored zero".
|
|
nutrition_score: Optional[float] = None
|
|
health_score: Optional[float] = None
|
|
similarity: float
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Health
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ApiKeyInfoOut(BaseModel):
|
|
"""One configured machine consumer, named but never quoted.
|
|
|
|
`fingerprint` is a truncated digest of name+secret, not the secret. It exists
|
|
so a caller who was issued a key can confirm THAT key is the one this
|
|
deployment loaded - the question a 401 cannot answer, since an undeployed key
|
|
and a wrong key fail identically.
|
|
"""
|
|
|
|
name: str
|
|
role: str
|
|
fingerprint: str
|
|
|
|
|
|
class AuthConfigOut(BaseModel):
|
|
"""
|
|
The effective auth configuration, reported by /api/health.
|
|
|
|
Unauthenticated on purpose. The failure this exists to diagnose is "nobody
|
|
can sign in", so anything gated behind an admin token is unreachable
|
|
exactly when it is needed. Nothing here is a secret: the admin username is
|
|
already the documented one, allow_any_login=true is a fact an operator
|
|
urgently needs (and an attacker discovers with a single login attempt
|
|
anyway), and the fingerprint is a truncated hash of a salted digest, not a
|
|
password. The API key block follows the same rule: it names which consumers
|
|
are configured and fingerprints their keys, so a caller can tell an
|
|
undeployed key from a rejected one, but it never renders a secret. What it buys is a one-command answer to "is this deployment
|
|
running the config I think it is?" - compare the fingerprint here against
|
|
the one printed by scripts/make_auth_secrets.py --fingerprint.
|
|
"""
|
|
|
|
enabled: bool
|
|
allow_any_login: bool
|
|
admin_username: str
|
|
password_hash_valid: bool
|
|
password_hash_iterations: Optional[int] = None
|
|
password_hash_fingerprint: str
|
|
# "process-env" | "env-file" | "default" - which one actually won.
|
|
admin_username_source: str
|
|
password_hash_source: str
|
|
# Machine consumers. Names and fingerprints only - the secrets themselves are
|
|
# never rendered here, and _parse_api_keys enforces enough entropy that the
|
|
# fingerprints do not give them away. Defaulted so a client of this schema
|
|
# still validates against a deployment predating these fields.
|
|
api_keys_count: int = 0
|
|
api_keys: List[ApiKeyInfoOut] = Field(default_factory=list)
|
|
api_keys_source: str = "default"
|
|
|
|
|
|
class HealthOut(BaseModel):
|
|
status: str
|
|
database: bool
|
|
ollama: bool
|
|
ollama_model: str
|
|
embeddings_model: str
|
|
auth: AuthConfigOut
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Brands / catalog browsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class BrandsOut(BaseModel):
|
|
brands: List[str]
|
|
|
|
|
|
class BrandCardOut(BaseModel):
|
|
"""A brand as shown on the home page card grid.
|
|
|
|
`name` is the same string GET /api/brands returns, so the frontend can
|
|
pass it straight back to /api/brands/{brand}/products.
|
|
"""
|
|
name: str
|
|
slug: str
|
|
product_count: int
|
|
category_count: int
|
|
categories: List[str] = Field(default_factory=list)
|
|
image_url: Optional[str] = None
|
|
initials: str
|
|
|
|
|
|
class BrandCardsOut(BaseModel):
|
|
total_brands: int
|
|
total_products: int
|
|
brands: List[BrandCardOut]
|
|
|
|
|
|
class CategoriesOut(BaseModel):
|
|
brand: str
|
|
categories: List[str]
|
|
|
|
|
|
class ProductListOut(BaseModel):
|
|
brand: str
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
products: List[ProductOut]
|
|
|
|
|
|
class AllProductsOut(BaseModel):
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
products: List[ProductOut]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Semantic search (retrieval only, no LLM generation)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SearchOut(BaseModel):
|
|
query: str
|
|
# Echoes the *request* param, as it always has. The brand inferred from the
|
|
# query text goes in `detected_brand` instead - repurposing this field would
|
|
# break any consumer reading it as "the filter I sent".
|
|
brand: Optional[str] = None
|
|
results: List[SourceProductOut]
|
|
# Exact in brand_catalog mode. None in hybrid mode: the merge happens in
|
|
# Python across N brand tables after per-table LIMITs, so there is no cheap
|
|
# exact count and inventing one would misreport how much was found.
|
|
total: Optional[int] = None
|
|
limit: int = 0
|
|
offset: int = 0
|
|
match_mode: str = "hybrid" # "brand_catalog" | "hybrid"
|
|
detected_brand: Optional[str] = None
|
|
detected_category: Optional[str] = None
|
|
|
|
|
|
class SuggestionOut(BaseModel):
|
|
"""One row in the search box's autocomplete dropdown."""
|
|
type: str # "brand" | "category"
|
|
value: str # what the search box / filter should use
|
|
label: str # display text
|
|
sublabel: Optional[str] = None # e.g. "128 products"
|
|
score: float = 0.0
|
|
|
|
|
|
class SuggestOut(BaseModel):
|
|
query: str
|
|
suggestions: List[SuggestionOut]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RAG chat
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ChatTurn(BaseModel):
|
|
role: str = Field(..., description="'user' or 'assistant'")
|
|
content: str
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
query: str = Field(..., min_length=1, max_length=2000)
|
|
brand: Optional[str] = Field(None, description="Restrict retrieval to a single brand")
|
|
category: Optional[str] = Field(None, description="Restrict retrieval to a category")
|
|
top_k: Optional[int] = Field(None, ge=1, le=15)
|
|
history: Optional[List[ChatTurn]] = Field(default=None, description="Prior turns for follow-up questions")
|
|
|
|
|
|
class ChatResponseOut(BaseModel):
|
|
answer: str
|
|
query: str
|
|
brand: Optional[str] = None
|
|
detected_category: Optional[str] = Field(
|
|
None, description="Product category auto-detected from the query and used to scope retrieval, if any"
|
|
)
|
|
sources: List[SourceProductOut]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Catalog generation (admin/ingestion trigger)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class CatalogGenerateRequest(BaseModel):
|
|
brand: str = Field(..., min_length=1, max_length=120)
|
|
max_products: int = Field(50, ge=1, le=300)
|
|
|
|
|
|
class CatalogJobOut(BaseModel):
|
|
job_id: str
|
|
brand: str
|
|
status: str
|
|
detail: Optional[str] = None
|