Add FastAPI endpoint and catalog loader

This commit is contained in:
Fiden committed 2026-08-07 13:04:18 +00:00
1 parent 033036db76
commit daf107feed
10 files changed
+694

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Offline product matching service."""
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
import uvicorn
def main() -> None:
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=False)
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import csv
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
@dataclass(frozen=True, slots=True)
class RawCatalogItem:
sku: str
name: str
unit: str
price: Decimal
def load_raw_catalog(path: Path) -> tuple[RawCatalogItem, ...]:
if not path.is_file():
raise FileNotFoundError(f"Catalog file does not exist: {path}")
items: list[RawCatalogItem] = []
with path.open("r", encoding="utf-8-sig", newline="") as file:
reader = csv.DictReader(file)
expected = {"sku", "name", "unit", "price"}
if set(reader.fieldnames or ()) != expected:
raise ValueError(
f"Unexpected catalog columns: {reader.fieldnames!r}; expected {sorted(expected)!r}"
)
seen_skus: set[str] = set()
for line_number, row in enumerate(reader, start=2):
sku = (row.get("sku") or "").strip()
name = (row.get("name") or "").strip()
unit = (row.get("unit") or "").strip()
price_text = (row.get("price") or "").strip()
if not sku or not name or not unit or not price_text:
raise ValueError(f"Catalog line {line_number} contains an empty required field")
if sku in seen_skus:
raise ValueError(f"Duplicate SKU at catalog line {line_number}: {sku}")
try:
price = Decimal(price_text)
except InvalidOperation as exc:
raise ValueError(
f"Invalid price at catalog line {line_number}: {price_text!r}"
) from exc
seen_skus.add(sku)
items.append(RawCatalogItem(sku=sku, name=name, unit=unit, price=price))
if not items:
raise ValueError("Catalog is empty")
return tuple(items)
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI
from app.catalog import load_raw_catalog
from app.models import MatchRequest, MatchResponse, MatchResult
BASE_DIR = Path(__file__).resolve().parent.parent
CATALOG_PATH = Path(os.environ.get("CATALOG_PATH", BASE_DIR / "catalog_excel.csv"))
RAW_CATALOG = load_raw_catalog(CATALOG_PATH)
app = FastAPI(title="Offline catalog matcher", version="0.1.0")
@app.get("/health")
def health() -> dict[str, object]:
return {"status": "ok", "catalog_items": len(RAW_CATALOG)}
@app.post("/match", response_model=MatchResponse)
def match(request: MatchRequest) -> MatchResponse:
return MatchResponse(
results=[
MatchResult(message=message, status="not_found", candidates=[])
for message in request.messages
]
)
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class MatchRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
messages: list[str] = Field(default_factory=list)
class Candidate(BaseModel):
model_config = ConfigDict(extra="forbid")
sku: str
confidence: float = Field(ge=0.0, le=1.0)
class MatchResult(BaseModel):
model_config = ConfigDict(extra="forbid")
message: str
status: Literal["matched", "ambiguous", "not_found"]
candidates: list[Candidate]
class MatchResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
results: list[MatchResult]