Image capturing Flow updates
This commit is contained in:
@@ -2,19 +2,23 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import require_admin
|
||||
from app.api.routers.brands import _row_to_product_out
|
||||
from app.api.schemas import (
|
||||
CaptureJobOut,
|
||||
IdentifyOut,
|
||||
ImageMatchOut,
|
||||
ImageSearchOut,
|
||||
ImageVectorSearchRequest,
|
||||
ProvisionalProductOut,
|
||||
SearchOut,
|
||||
SourceProductOut,
|
||||
)
|
||||
from app.infrastructure import settings
|
||||
from app.infrastructure.settings import (
|
||||
IMAGE_SEARCH_DEFAULT_MIN_SCORE,
|
||||
IMAGE_SEARCH_DEFAULT_TOP_K,
|
||||
@@ -23,7 +27,7 @@ from app.infrastructure.settings import (
|
||||
SEARCH_DEFAULT_TOP_K,
|
||||
SEARCH_MAX_TOP_K,
|
||||
)
|
||||
from app.services import image_embedder
|
||||
from app.services import capture_discovery, image_embedder
|
||||
from app.services.catalog_search import search_catalog
|
||||
from app.services.image_match import (
|
||||
ImageSearchResult,
|
||||
@@ -241,6 +245,7 @@ async def image_search_endpoint(
|
||||
|
||||
@router.post("/search/identify", response_model=IdentifyOut)
|
||||
async def identify_endpoint(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="The product photo (JPEG/PNG/WebP), ideally cropped to the pack"),
|
||||
text: Optional[str] = Form(None, max_length=500,
|
||||
description="OCR text read off the label; when absent the server reads it"),
|
||||
@@ -290,4 +295,91 @@ async def identify_endpoint(
|
||||
"deployment. Send the label as `text`, or embed the photo client-side and POST "
|
||||
"the vector to /api/search/image-vector.",
|
||||
)
|
||||
return _to_identify_out(result)
|
||||
out = _to_identify_out(result)
|
||||
if settings.ENABLE_CAPTURE_DISCOVERY and not capture_discovery.is_confirmed(
|
||||
result.matched_by, result.fallback_reason
|
||||
):
|
||||
outcome = await run_in_threadpool(
|
||||
capture_discovery.handle_miss,
|
||||
label_text=result.ocr_text, image_bytes=content, vector=vector,
|
||||
brand=brand, category=category,
|
||||
client=request.client.host if request.client else "unknown",
|
||||
)
|
||||
_apply_capture_outcome(out, outcome)
|
||||
return out
|
||||
|
||||
|
||||
def _apply_capture_outcome(out: IdentifyOut, outcome: capture_discovery.CaptureOutcome) -> None:
|
||||
"""Fold a capture-to-catalog decision into the identify response.
|
||||
|
||||
The low-confidence rows the ladder returned are dropped whenever discovery
|
||||
has an answer of its own: they are OTHER products (another Godrej line, a
|
||||
rival detergent), and showing them beside "adding it now" invites the
|
||||
colleague to pick the wrong one.
|
||||
"""
|
||||
out.discovery_status = outcome.status
|
||||
out.discovery_job_id = outcome.job_id
|
||||
out.discovery_message = outcome.message
|
||||
out.provisional = ProvisionalProductOut(**outcome.provisional) if outcome.provisional else None
|
||||
if outcome.status == capture_discovery.EXISTS and outcome.existing:
|
||||
row = outcome.existing
|
||||
card = _row_to_product_out(row, row.get("brand") or "")
|
||||
out.results = [ImageMatchOut(**card.model_dump(), score=1.0, text_overlap=1.0)]
|
||||
out.total = 1
|
||||
out.matched_by = capture_discovery.MATCHED_BY_LABEL_EXACT
|
||||
elif outcome.status == capture_discovery.PENDING:
|
||||
out.results = []
|
||||
out.total = 0
|
||||
out.matched_by = capture_discovery.MATCHED_BY_DISCOVERY
|
||||
|
||||
|
||||
@router.get("/search/identify/jobs/{job_id}", response_model=CaptureJobOut)
|
||||
def capture_job_endpoint(job_id: str) -> CaptureJobOut:
|
||||
"""A capture-to-catalog job started by /search/identify.
|
||||
|
||||
Poll until `status` is terminal (done, rejected, failed, interrupted). On
|
||||
done, `product` is the stored catalog row - validation_status needs_review.
|
||||
"""
|
||||
job = capture_discovery.get_job(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="No capture job with that id.")
|
||||
return _to_capture_job_out(job)
|
||||
|
||||
|
||||
def _to_capture_job_out(job: capture_discovery.CaptureJob) -> CaptureJobOut:
|
||||
from app.services.vector_store import get_product_by_image_id
|
||||
|
||||
product = None
|
||||
validation_status = None
|
||||
if job.status == capture_discovery.DONE and job.image_id:
|
||||
row = get_product_by_image_id(job.parent, job.image_id)
|
||||
if row:
|
||||
product = _row_to_product_out(row, row.get("brand") or job.parent)
|
||||
validation_status = row.get("validation_status")
|
||||
return CaptureJobOut(
|
||||
job_id=job.job_id, status=job.status, created_at=job.created_at,
|
||||
updated_at=job.updated_at, provisional=job.provisional, product=product,
|
||||
image_id=job.image_id, disposition=job.disposition,
|
||||
validation_status=validation_status, retail_presence=job.retail_presence,
|
||||
photo_used_as_image=job.photo_used_as_image, detail=job.detail,
|
||||
warnings=job.warnings,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/captures", response_model=list[CaptureJobOut],
|
||||
dependencies=[Depends(require_admin)])
|
||||
def list_capture_jobs_endpoint(limit: int = Query(50, ge=1, le=200)) -> list[CaptureJobOut]:
|
||||
"""Recent capture-to-catalog jobs, newest first - the review list for
|
||||
products colleagues added from the field."""
|
||||
return [_to_capture_job_out(job) for job in capture_discovery.list_jobs(limit)]
|
||||
|
||||
|
||||
@router.get("/search/captures/{name}", include_in_schema=False)
|
||||
def capture_photo_endpoint(name: str):
|
||||
"""A colleague's capture photo - served only because a product with no web
|
||||
image uses it as its image (CAPTURE_PUBLIC_BASE_URL)."""
|
||||
found = capture_discovery.photo_path(name)
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail="No such photo.")
|
||||
path, media_type = found
|
||||
return FileResponse(path, media_type=media_type)
|
||||
|
||||
@@ -313,11 +313,48 @@ class IdentifyOut(ImageSearchOut):
|
||||
"image_vector" with no `fallback_reason`; otherwise `fallback_reason`
|
||||
says why the best effort shown is unconfirmed (see product_identify.py).
|
||||
"""
|
||||
matched_by: str = "none" # "image_vector" | "text" | "none"
|
||||
matched_by: str = "none" # "image_vector" | "text" | "label_exact"
|
||||
# | "discovery_pending" | "none"
|
||||
ocr_text: Optional[str] = None # the label text the ladder used
|
||||
ocr_source: Optional[str] = None # "client" | "server"
|
||||
image_top_score: Optional[float] = None
|
||||
fallback_reason: Optional[str] = None
|
||||
# Capture-to-catalog (ENABLE_CAPTURE_DISCOVERY). All None when the answer
|
||||
# was confirmed or the feature is off. See app/services/capture_discovery.py.
|
||||
discovery_status: Optional[str] = None # "pending" | "exists" | "needs_input" | "busy"
|
||||
discovery_job_id: Optional[str] = None # poll GET /api/search/identify/jobs/{id}
|
||||
discovery_message: Optional[str] = None # one line to show the colleague
|
||||
provisional: Optional["ProvisionalProductOut"] = None
|
||||
|
||||
|
||||
class ProvisionalProductOut(BaseModel):
|
||||
"""What the label says, before the pipeline has run. Not a catalog row."""
|
||||
brand: str
|
||||
product_name: str
|
||||
size: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
hsn_code: Optional[str] = None
|
||||
gst_percent: Optional[float] = None
|
||||
hsn_gst_needs_review: Optional[bool] = None
|
||||
visible_in_search: bool = True # False: brand is not in ACTIVE_BRANDS
|
||||
source: str = "label"
|
||||
|
||||
|
||||
class CaptureJobOut(BaseModel):
|
||||
"""GET /search/identify/jobs/{job_id}: one capture-to-catalog job."""
|
||||
job_id: str
|
||||
status: str # queued | running | done | rejected | failed | interrupted
|
||||
created_at: float
|
||||
updated_at: float
|
||||
provisional: ProvisionalProductOut
|
||||
product: Optional[ProductOut] = None # the stored row, once done
|
||||
image_id: Optional[str] = None
|
||||
disposition: Optional[str] = None # inserted | backfilled | unchanged
|
||||
validation_status: Optional[str] = None
|
||||
retail_presence: Optional[dict] = None
|
||||
photo_used_as_image: bool = False
|
||||
detail: Optional[str] = None
|
||||
warnings: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user