Updates on Image search using vectors
This commit is contained in:
@@ -27,7 +27,7 @@ from app.infrastructure.settings import (
|
||||
SEARCH_DEFAULT_TOP_K,
|
||||
SEARCH_MAX_TOP_K,
|
||||
)
|
||||
from app.services import capture_discovery, image_embedder
|
||||
from app.services import capture_discovery, image_embedder, image_search_log
|
||||
from app.services.catalog_search import search_catalog
|
||||
from app.services.image_match import (
|
||||
ImageSearchResult,
|
||||
@@ -105,12 +105,28 @@ def _to_image_search_out(result: ImageSearchResult) -> ImageSearchOut:
|
||||
min_score=result.min_score,
|
||||
top_k=result.top_k,
|
||||
query_text=result.query_text,
|
||||
match_confidence=result.match_confidence if matches else "none",
|
||||
margin=None if result.margin is None else round(float(result.margin), 4),
|
||||
)
|
||||
|
||||
|
||||
def _identify_confidence(result: IdentifyResult) -> str:
|
||||
"""The ladder's verdict in match_confidence terms: confirmed exactly when
|
||||
product_identify.py's client rule says so."""
|
||||
if not result.search.rows:
|
||||
return "none"
|
||||
if capture_discovery.is_confirmed(result.matched_by, result.fallback_reason):
|
||||
return "confirmed"
|
||||
return "low"
|
||||
|
||||
|
||||
def _to_identify_out(result: IdentifyResult) -> IdentifyOut:
|
||||
fields = _to_image_search_out(result.search).model_dump()
|
||||
fields["match_confidence"] = _identify_confidence(result)
|
||||
if result.matched_by != "image_vector":
|
||||
fields["margin"] = None # text rows: a MiniLM score, no image margin
|
||||
return IdentifyOut(
|
||||
**_to_image_search_out(result.search).model_dump(),
|
||||
**fields,
|
||||
matched_by=result.matched_by,
|
||||
ocr_text=result.ocr_text,
|
||||
ocr_source=result.ocr_source,
|
||||
@@ -119,6 +135,28 @@ def _to_identify_out(result: IdentifyResult) -> IdentifyOut:
|
||||
)
|
||||
|
||||
|
||||
def _log_search(route: str, result: ImageSearchResult, *, vector, text, brand, category, top_k,
|
||||
photo: Optional[bytes] = None, text_fallback: Optional[bool] = None) -> None:
|
||||
image_search_log.record(
|
||||
route, vector=vector, text=text, brand=brand, category=category, top_k=top_k,
|
||||
rows=result.rows, detected_brand=result.detected_brand, scoped_to_brand=result.scoped_to_brand,
|
||||
match_confidence=result.match_confidence, margin=result.margin,
|
||||
matched_by="image_vector" if result.rows else "none", photo=photo, text_fallback=text_fallback,
|
||||
)
|
||||
|
||||
|
||||
def _log_identify(route: str, result: IdentifyResult, *, vector, text, brand, category, top_k,
|
||||
photo: Optional[bytes] = None, text_fallback: Optional[bool] = None) -> None:
|
||||
image_search_log.record(
|
||||
route, vector=vector, text=text, brand=brand, category=category, top_k=top_k,
|
||||
rows=result.search.rows, detected_brand=result.search.detected_brand,
|
||||
scoped_to_brand=result.search.scoped_to_brand, match_confidence=_identify_confidence(result),
|
||||
margin=result.search.margin if result.matched_by == "image_vector" else None,
|
||||
matched_by=result.matched_by, fallback_reason=result.fallback_reason,
|
||||
photo=photo, text_fallback=text_fallback,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search/image-vector", response_model=ImageSearchOut)
|
||||
def image_vector_search_endpoint(body: ImageVectorSearchRequest):
|
||||
"""Products that look like the photo whose embedding is `vector`.
|
||||
@@ -131,20 +169,26 @@ def image_vector_search_endpoint(body: ImageVectorSearchRequest):
|
||||
filter; a brand recognised from `text` falls back to every brand when it
|
||||
finds nothing (`scope_fallback`).
|
||||
|
||||
With `text_fallback: true` the request runs the /search/identify ladder
|
||||
instead: when the best image score is under IMAGE_IDENTIFY_MIN_IMAGE_SCORE
|
||||
the label `text` is resolved against the catalogue, and the response is
|
||||
an IdentifyOut (this response plus matched_by, fallback_reason, ...).
|
||||
Off by default so the app's existing calls are answered exactly as before.
|
||||
With `text_fallback` the request runs the /search/identify ladder
|
||||
instead: when the image match cannot be confirmed (best score under
|
||||
IMAGE_IDENTIFY_MIN_IMAGE_SCORE, or another photo within
|
||||
IMAGE_SEARCH_MIN_MARGIN) the label `text` is resolved against the
|
||||
catalogue, and the response is an IdentifyOut (this response plus
|
||||
matched_by, fallback_reason, ...). Left out, it is on whenever `text` is
|
||||
sent; `false` keeps the image-only ranking, where text only breaks ties.
|
||||
"""
|
||||
ladder = body.text_fallback if body.text_fallback is not None else bool((body.text or "").strip())
|
||||
log_fields = dict(vector=body.vector, text=body.text, brand=body.brand, category=body.category,
|
||||
top_k=body.top_k, text_fallback=body.text_fallback)
|
||||
try:
|
||||
if body.text_fallback:
|
||||
if ladder:
|
||||
identified = identify_product(
|
||||
vector=body.vector, image_bytes=None, text=body.text, brand=body.brand,
|
||||
category=body.category, top_k=body.top_k, min_score=body.min_score,
|
||||
)
|
||||
_log_identify("image-vector", identified, **log_fields)
|
||||
# A Response bypasses response_model, which is the point: the
|
||||
# default path keeps its declared ImageSearchOut contract.
|
||||
# image-only path keeps its declared ImageSearchOut contract.
|
||||
return JSONResponse(content=_to_identify_out(identified).model_dump(mode="json"))
|
||||
result = search_by_vector(
|
||||
body.vector, text=body.text, brand=body.brand, category=body.category,
|
||||
@@ -152,6 +196,7 @@ def image_vector_search_endpoint(body: ImageVectorSearchRequest):
|
||||
)
|
||||
except InvalidVectorError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
_log_search("image-vector", result, **log_fields)
|
||||
return _to_image_search_out(result)
|
||||
|
||||
|
||||
@@ -185,6 +230,8 @@ def image_vector_search_get_endpoint(
|
||||
)
|
||||
except InvalidVectorError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
_log_search("image-vector:get", result, vector=values, text=text, brand=brand,
|
||||
category=category, top_k=top_k)
|
||||
return _to_image_search_out(result)
|
||||
|
||||
|
||||
@@ -230,6 +277,8 @@ async def image_search_endpoint(
|
||||
)
|
||||
except InvalidVectorError as exc: # cannot happen for a model output, but the route must not 500
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
_log_search("image", result, vector=vector, text=text, brand=brand, category=category,
|
||||
top_k=top_k, photo=content)
|
||||
return _to_image_search_out(result)
|
||||
|
||||
|
||||
@@ -295,6 +344,8 @@ 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.",
|
||||
)
|
||||
_log_identify("identify", result, vector=vector, text=text, brand=brand, category=category,
|
||||
top_k=top_k, photo=content)
|
||||
out = _to_identify_out(result)
|
||||
if settings.ENABLE_CAPTURE_DISCOVERY and not capture_discovery.is_confirmed(
|
||||
result.matched_by, result.fallback_reason
|
||||
@@ -327,10 +378,14 @@ def _apply_capture_outcome(out: IdentifyOut, outcome: capture_discovery.CaptureO
|
||||
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
|
||||
out.match_confidence = "confirmed"
|
||||
out.margin = None
|
||||
elif outcome.status == capture_discovery.PENDING:
|
||||
out.results = []
|
||||
out.total = 0
|
||||
out.matched_by = capture_discovery.MATCHED_BY_DISCOVERY
|
||||
out.match_confidence = "none"
|
||||
out.margin = None
|
||||
|
||||
|
||||
@router.get("/search/identify/jobs/{job_id}", response_model=CaptureJobOut)
|
||||
|
||||
@@ -263,15 +263,19 @@ class ImageVectorSearchRequest(BaseModel):
|
||||
top_k: int = Field(IMAGE_SEARCH_DEFAULT_TOP_K, ge=1, le=IMAGE_SEARCH_MAX_TOP_K)
|
||||
min_score: float = Field(IMAGE_SEARCH_DEFAULT_MIN_SCORE, ge=-1.0, le=1.0,
|
||||
description="Drop matches with cosine similarity below this")
|
||||
# Opt-in, so a client that never asked for it gets exactly the response
|
||||
# it always did. With it, the route runs the identify ladder: when the
|
||||
# best image score is under IMAGE_IDENTIFY_MIN_IMAGE_SCORE, `text` is
|
||||
# resolved against the catalogue instead, and the response is an
|
||||
# IdentifyOut (ImageSearchOut plus matched_by / fallback_reason / ...).
|
||||
text_fallback: bool = Field(
|
||||
False,
|
||||
description="Fall back to resolving `text` when the image match is below the identify floor; "
|
||||
"the response then carries the IdentifyOut fields",
|
||||
# With it, the route runs the identify ladder: when the best image score
|
||||
# is under IMAGE_IDENTIFY_MIN_IMAGE_SCORE (or another photo is within
|
||||
# IMAGE_SEARCH_MIN_MARGIN of it), `text` is resolved against the
|
||||
# catalogue instead, and the response is an IdentifyOut (ImageSearchOut
|
||||
# plus matched_by / fallback_reason / ...). Left out, it is ON whenever
|
||||
# `text` is sent: a label that names the product must not lose to a 0.5
|
||||
# cosine, which it did when text only broke exact ties. `false` keeps the
|
||||
# old image-only ranking.
|
||||
text_fallback: Optional[bool] = Field(
|
||||
None,
|
||||
description="Resolve `text` when the image match cannot be confirmed; the response then "
|
||||
"carries the IdentifyOut fields. Default: on when `text` is sent. "
|
||||
"false = image-only ranking, text breaks ties only",
|
||||
)
|
||||
|
||||
@field_validator("vector")
|
||||
@@ -301,6 +305,14 @@ class ImageSearchOut(BaseModel):
|
||||
min_score: float = 0.0
|
||||
top_k: int = 0
|
||||
query_text: Optional[str] = None
|
||||
# "confirmed" only when the best match clears IMAGE_IDENTIFY_MIN_IMAGE_SCORE
|
||||
# AND leads the best different photo by IMAGE_SEARCH_MIN_MARGIN (on
|
||||
# /identify: when the ladder confirmed it). "low": show the results as a
|
||||
# list to pick from, never as the answer. "none": no results.
|
||||
match_confidence: str = "none"
|
||||
# Best score minus the best DIFFERENT photo's; None when there was no rival.
|
||||
# Image space only - None when the rows came from the text rung.
|
||||
margin: Optional[float] = None
|
||||
|
||||
|
||||
class IdentifyOut(ImageSearchOut):
|
||||
|
||||
@@ -432,6 +432,21 @@ IMAGE_SEARCH_DEFAULT_MIN_SCORE = float(os.getenv("IMAGE_SEARCH_DEFAULT_MIN_SCORE
|
||||
# product share an image and tie, so more than top_k must come back), and
|
||||
# the floor for hnsw.ef_search on that query so the index does not drop them.
|
||||
IMAGE_SEARCH_MAX_FETCH_K = int(os.getenv("IMAGE_SEARCH_MAX_FETCH_K", "100"))
|
||||
# How far the best image match must lead the best DIFFERENT photo for the
|
||||
# answer to count as confirmed (`match_confidence`). A phone photo of a card on
|
||||
# a screen measured 0.507 against its own product and 0.419 against a
|
||||
# stranger; with glare that gap closes, and a flipped order is a wrong
|
||||
# product shown with confidence. Pack sizes sharing one photo tie exactly and
|
||||
# are never each other's competitor. Unmeasured start: tune with
|
||||
# scripts/eval_identify.py.
|
||||
IMAGE_SEARCH_MIN_MARGIN = float(os.getenv("IMAGE_SEARCH_MIN_MARGIN", "0.05"))
|
||||
# Diagnosis: when set, every image-search request (vector, text, brand and the
|
||||
# top matches) is written here as one JSON file, for
|
||||
# scripts/replay_image_query.py. Blank = off. At most IMAGE_SEARCH_CAPTURE_MAX
|
||||
# files are kept; the oldest go first. The routes are public, so leave it off
|
||||
# except while chasing a report.
|
||||
IMAGE_SEARCH_CAPTURE_DIR = os.getenv("IMAGE_SEARCH_CAPTURE_DIR", "").strip()
|
||||
IMAGE_SEARCH_CAPTURE_MAX = int(os.getenv("IMAGE_SEARCH_CAPTURE_MAX", "200"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identify a product from a phone photo - POST /api/search/identify
|
||||
|
||||
@@ -53,10 +53,12 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
from app.infrastructure.settings import (
|
||||
IMAGE_IDENTIFY_MIN_IMAGE_SCORE,
|
||||
IMAGE_SEARCH_DEFAULT_MIN_SCORE,
|
||||
IMAGE_SEARCH_DEFAULT_TOP_K,
|
||||
IMAGE_SEARCH_MAX_FETCH_K,
|
||||
IMAGE_SEARCH_MAX_TOP_K,
|
||||
IMAGE_SEARCH_MIN_MARGIN,
|
||||
)
|
||||
from app.services.image_embedder import EMBED_DIM
|
||||
|
||||
@@ -90,6 +92,8 @@ class ImageSearchResult:
|
||||
min_score: float = 0.0
|
||||
query_text: Optional[str] = None
|
||||
top_k: int = 0
|
||||
match_confidence: str = "none" # "confirmed" | "low" | "none"
|
||||
margin: Optional[float] = None # top score minus the best different photo's
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -226,16 +230,102 @@ def text_overlap(words: Set[str], sizes: Set[str], row: Dict[str, Any]) -> float
|
||||
return _SIZE_WEIGHT * len(sizes & row_sizes) + _WORD_WEIGHT * len(words & row_words)
|
||||
|
||||
|
||||
def size_in_name(sizes: Set[str], row: Dict[str, Any]) -> bool:
|
||||
"""Whether a pack size the label printed is in the row's OWN name.
|
||||
|
||||
`text_overlap` also counts `size_variants`, and on some products every
|
||||
pack-size row carries the SAME list (all Amul Cream rows say
|
||||
["90g", "1kg"]), so a "1kg" label ties every sibling and the tie fell to
|
||||
alphabetical order - "Amul Cream 125 ml" was shown, confirmed, for a
|
||||
photo of the 1kg pack. The name is per row; this breaks that tie.
|
||||
"""
|
||||
if not sizes:
|
||||
return False
|
||||
name_sizes = tokens(" ".join([str(row.get("product_name") or ""), str(row.get("title") or "")]))[1]
|
||||
return bool(sizes & name_sizes)
|
||||
|
||||
|
||||
def rank_key(row: Dict[str, Any]) -> tuple:
|
||||
"""Best first. Score rounded to 3 dp so siblings sharing a photo tie."""
|
||||
return (
|
||||
-round(float(row.get("score", 0.0)), 3),
|
||||
-float(row.get("text_overlap", 0.0)),
|
||||
-int(bool(row.get("size_in_name", False))),
|
||||
str(row.get("product_name") or row.get("title") or "").lower(),
|
||||
str(row.get("image_id") or ""),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# confidence
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pack sizes that share one catalog photo score identically (to pgvector's
|
||||
# float precision); anything further apart than this is a different picture.
|
||||
_SAME_PHOTO_EPS = 5e-4
|
||||
|
||||
CONFIRMED = "confirmed"
|
||||
LOW = "low"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
def image_margin(rows: Sequence[Dict[str, Any]]) -> Optional[float]:
|
||||
"""How far the best row leads the best row with a DIFFERENT photo.
|
||||
|
||||
`rows` are best first by score. Siblings sharing the winner's photo are
|
||||
skipped: they are the same product in another size, which the label text
|
||||
separates, not a rival. None when nothing else is in the list - no rival
|
||||
was seen, which is not the same as a clear lead, so the caller treats it
|
||||
as unconfirmable only when the score itself is low.
|
||||
"""
|
||||
if not rows:
|
||||
return None
|
||||
try:
|
||||
top = float(rows[0].get("score", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for row in rows[1:]:
|
||||
try:
|
||||
score = float(row.get("score", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if top - score > _SAME_PHOTO_EPS:
|
||||
return top - score
|
||||
return None
|
||||
|
||||
|
||||
def match_confidence(
|
||||
rows: Sequence[Dict[str, Any]],
|
||||
min_image_score: Optional[float] = None,
|
||||
min_margin: Optional[float] = None,
|
||||
) -> Tuple[str, Optional[float]]:
|
||||
"""("confirmed" | "low" | "none", margin) for image rows, best first.
|
||||
|
||||
Confirmed needs BOTH a score at the identify floor and a lead over the
|
||||
next different photo. A photo of a card on a phone screen scores ~0.5
|
||||
against its own product, and a stranger can sit within a few hundredths
|
||||
of it: shown as the answer, that is how "Dairy Milk Lickables" came back
|
||||
as someone else's product. A client should offer the list, not pick one,
|
||||
when this is not "confirmed". The thresholds default to this module's
|
||||
settings, read at call time so scripts/eval_identify.py can vary them.
|
||||
"""
|
||||
if min_image_score is None:
|
||||
min_image_score = IMAGE_IDENTIFY_MIN_IMAGE_SCORE
|
||||
if min_margin is None:
|
||||
min_margin = IMAGE_SEARCH_MIN_MARGIN
|
||||
if not rows:
|
||||
return NONE, None
|
||||
margin = image_margin(rows)
|
||||
try:
|
||||
top = float(rows[0].get("score", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return LOW, margin
|
||||
if top < min_image_score:
|
||||
return LOW, margin
|
||||
if margin is not None and margin < min_margin:
|
||||
return LOW, margin
|
||||
return CONFIRMED, margin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the search
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -345,7 +435,11 @@ def search_by_vector(
|
||||
words, sizes = tokens(label)
|
||||
for row in rows:
|
||||
row["text_overlap"] = text_overlap(words, sizes, row)
|
||||
row["size_in_name"] = size_in_name(sizes, row)
|
||||
rows.sort(key=rank_key)
|
||||
# Judged on every candidate, not the top_k shown: with top_k=1 the rival
|
||||
# would otherwise never be seen.
|
||||
confidence, margin = match_confidence(rows)
|
||||
winners = _hydrate(rows[:top_k])
|
||||
|
||||
return ImageSearchResult(
|
||||
@@ -356,4 +450,6 @@ def search_by_vector(
|
||||
min_score=min_score,
|
||||
query_text=label,
|
||||
top_k=top_k,
|
||||
match_confidence=confidence,
|
||||
margin=margin,
|
||||
)
|
||||
|
||||
133
app/services/image_search_log.py
Normal file
133
app/services/image_search_log.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""One log line per search-by-photo request, and - when asked - the request itself.
|
||||
|
||||
WHY
|
||||
---
|
||||
A colleague photographed the "Cadbury Dairy Milk Lickables" card and got
|
||||
"Milk Toned"; "Aachi Sambar Powder 100g" came back as Sakthi's "Sambar powder
|
||||
50g". Replayed on the server, both photos rank the right product first, so
|
||||
the difference is in what the app SENDS - the vector (is its on-device model
|
||||
the same as ours?), whether any OCR `text` came with it, which route it hit.
|
||||
None of that was visible. This module makes it visible:
|
||||
|
||||
* `record()` logs, for every /search/image*, /search/identify request: the
|
||||
route, the text, brand, detected brand, a short hash and the norm of the
|
||||
vector, the top three (brand, name, score) and the lead over the best
|
||||
different photo. Always on; it is one INFO line.
|
||||
* With IMAGE_SEARCH_CAPTURE_DIR set, the whole request - the 1024 floats,
|
||||
the fields, the photo when there was one - is also written there, for
|
||||
`python -m scripts.replay_image_query`. Off by default: these routes are
|
||||
public. The newest IMAGE_SEARCH_CAPTURE_MAX requests are kept.
|
||||
|
||||
Never raises: a diagnostic must not fail a search.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from app.infrastructure import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEXT_LOG_CHARS = 120
|
||||
|
||||
|
||||
def vector_fingerprint(vector: Optional[Sequence[float]]) -> Optional[str]:
|
||||
"""10 hex characters identifying a vector: equal for the same float32 values."""
|
||||
if vector is None:
|
||||
return None
|
||||
packed = struct.pack(f"<{len(vector)}f", *[float(v) for v in vector])
|
||||
return hashlib.sha1(packed).hexdigest()[:10]
|
||||
|
||||
|
||||
def _top(rows: Sequence[Dict[str, Any]], n: int = 3) -> List[Dict[str, Any]]:
|
||||
out = []
|
||||
for row in list(rows)[:n]:
|
||||
try:
|
||||
score = round(float(row.get("score", 0.0)), 4)
|
||||
except (TypeError, ValueError):
|
||||
score = None
|
||||
out.append({
|
||||
"brand": row.get("brand") or row.get("brand_table"),
|
||||
"product_name": row.get("product_name") or row.get("title"),
|
||||
"image_id": row.get("image_id"),
|
||||
"score": score,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _prune(folder: Path, keep: int) -> None:
|
||||
files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
||||
for stale in files[:max(0, len(files) - keep)]:
|
||||
for sibling in folder.glob(stale.stem + ".*"):
|
||||
sibling.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def record(
|
||||
route: str,
|
||||
*,
|
||||
vector: Optional[Sequence[float]],
|
||||
text: Optional[str],
|
||||
brand: Optional[str],
|
||||
category: Optional[str],
|
||||
top_k: int,
|
||||
rows: Sequence[Dict[str, Any]],
|
||||
detected_brand: Optional[str] = None,
|
||||
scoped_to_brand: bool = False,
|
||||
match_confidence: Optional[str] = None,
|
||||
margin: Optional[float] = None,
|
||||
matched_by: Optional[str] = None,
|
||||
fallback_reason: Optional[str] = None,
|
||||
text_fallback: Optional[bool] = None,
|
||||
photo: Optional[bytes] = None,
|
||||
) -> None:
|
||||
"""Log the request and its answer; save it when capture is on."""
|
||||
try:
|
||||
norm = math.sqrt(sum(float(v) * float(v) for v in vector)) if vector is not None else None
|
||||
top = _top(rows)
|
||||
logger.info(
|
||||
"[IMAGE_SEARCH] route=%s text=%r brand=%r detected=%r scoped=%s vec=%s norm=%s "
|
||||
"matched_by=%s reason=%s confidence=%s margin=%s top=%s",
|
||||
route, (text or "")[:_TEXT_LOG_CHARS] or None, brand, detected_brand, scoped_to_brand,
|
||||
vector_fingerprint(vector), None if norm is None else round(norm, 4),
|
||||
matched_by, fallback_reason, match_confidence,
|
||||
None if margin is None else round(margin, 4),
|
||||
[(t["brand"], t["product_name"], t["score"]) for t in top],
|
||||
)
|
||||
folder_name = settings.IMAGE_SEARCH_CAPTURE_DIR
|
||||
if not folder_name:
|
||||
return
|
||||
folder = Path(folder_name)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
stem = time.strftime("%Y%m%dT%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
payload = {
|
||||
"route": route,
|
||||
"received_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"request": {
|
||||
"vector": None if vector is None else [float(v) for v in vector],
|
||||
"text": text, "brand": brand, "category": category, "top_k": top_k,
|
||||
"text_fallback": text_fallback,
|
||||
},
|
||||
"vector_fingerprint": vector_fingerprint(vector),
|
||||
"answer": {
|
||||
"matched_by": matched_by, "fallback_reason": fallback_reason,
|
||||
"match_confidence": match_confidence, "margin": margin,
|
||||
"detected_brand": detected_brand, "scoped_to_brand": scoped_to_brand, "top": top,
|
||||
},
|
||||
"photo": None,
|
||||
}
|
||||
if photo:
|
||||
photo_path = folder / f"{stem}.img"
|
||||
photo_path.write_bytes(photo)
|
||||
payload["photo"] = photo_path.name
|
||||
(folder / f"{stem}.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
_prune(folder, max(1, settings.IMAGE_SEARCH_CAPTURE_MAX))
|
||||
except Exception as exc: # noqa: BLE001 - a diagnostic must never fail the search
|
||||
logger.warning("image search log skipped: %s", exc)
|
||||
@@ -59,7 +59,7 @@ from app.infrastructure.settings import (
|
||||
IMAGE_SEARCH_MAX_TOP_K,
|
||||
OCR_MAX_CHARS,
|
||||
)
|
||||
from app.services.image_match import _STOP, ImageSearchResult, _row_text, text_overlap, tokens
|
||||
from app.services.image_match import _STOP, ImageSearchResult, _row_text, size_in_name, text_overlap, tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -197,6 +197,7 @@ def label_rank_key(row: Dict[str, Any]) -> tuple:
|
||||
"""
|
||||
return (
|
||||
-float(row.get("text_overlap", 0.0)),
|
||||
-int(bool(row.get("size_in_name", False))), # the label's size in THIS row's name
|
||||
int(row.get("text_extra", 0)),
|
||||
-round(float(row.get("score", 0.0)), 3),
|
||||
str(row.get("product_name") or row.get("title") or "").lower(),
|
||||
@@ -336,6 +337,7 @@ def resolve_label(
|
||||
for row in rows:
|
||||
row["text_overlap"] = text_overlap(words, sizes, row)
|
||||
row["text_extra"] = text_extra(words, row)
|
||||
row["size_in_name"] = size_in_name(sizes, row)
|
||||
rows.sort(key=label_rank_key)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ THE LADDER
|
||||
`fallback_reason` is the reason for the LAST step the ladder took:
|
||||
|
||||
image_below_threshold the image arm ran but did not clear the floor
|
||||
image_ambiguous it cleared the floor, but another photo scored
|
||||
within IMAGE_SEARCH_MIN_MARGIN of it
|
||||
no_image_match the image arm ran and found nothing at all
|
||||
image_embedder_unavailable no vector could be computed (no model here)
|
||||
no_text no label to fall back to and no photo to read
|
||||
@@ -46,6 +48,7 @@ from app.infrastructure.settings import (
|
||||
IMAGE_IDENTIFY_MIN_IMAGE_SCORE,
|
||||
IMAGE_SEARCH_DEFAULT_MIN_SCORE,
|
||||
IMAGE_SEARCH_DEFAULT_TOP_K,
|
||||
IMAGE_SEARCH_MIN_MARGIN,
|
||||
)
|
||||
from app.services import ocr_service
|
||||
from app.services.image_match import ImageSearchResult, search_by_vector
|
||||
@@ -89,9 +92,11 @@ def _image_rung(
|
||||
top = float(result.rows[0].get("score", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
top = 0.0
|
||||
if top >= min_image_score:
|
||||
return result, top, None
|
||||
return result, top, "image_below_threshold"
|
||||
if top < min_image_score:
|
||||
return result, top, "image_below_threshold"
|
||||
if result.margin is not None and result.margin < IMAGE_SEARCH_MIN_MARGIN:
|
||||
return result, top, "image_ambiguous"
|
||||
return result, top, None
|
||||
|
||||
|
||||
def identify_product(
|
||||
|
||||
@@ -1861,7 +1861,14 @@ def lexical_search(
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
results.sort(key=lambda r: (r.get("lex_tier", 9), r.get("distance", 9.0)))
|
||||
# A row with a NULL `embedding` comes back with distance None (the key IS
|
||||
# present), and one None among floats made this sort raise - which threw
|
||||
# away every row of an unscoped search, the label resolver's lexical arm.
|
||||
def _distance(r: Dict[str, Any]) -> float:
|
||||
d = r.get("distance")
|
||||
return 9.0 if d is None else float(d)
|
||||
|
||||
results.sort(key=lambda r: (r.get("lex_tier", 9), _distance(r)))
|
||||
return results[:limit]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user