Updates on Image search using vectors
This commit is contained in:
@@ -4,8 +4,10 @@ The ladder's decisions are tested in tests/test_product_identify.py and the
|
||||
two arms in tests/test_image_match.py and tests/test_label_match.py. Here
|
||||
the assertions are about the HTTP contract: status codes, the guards the
|
||||
photo route shares with /search/image, the IdentifyOut fields, and - the
|
||||
one that protects the app in the field - that /search/image-vector without
|
||||
`text_fallback` answers exactly as it did before this route existed.
|
||||
one that protects the app in the field - that /search/image-vector with
|
||||
`text_fallback: false` answers exactly as it did before this route existed.
|
||||
Left out, the flag is on whenever `text` is sent: a label naming the product
|
||||
must not lose to a 0.5 cosine (the "Lickables came back as Milk Toned" report).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,7 +46,7 @@ def _row(score: float = 0.631, image_id: str = "britannia_marie_gold_300g") -> D
|
||||
|
||||
|
||||
IMAGE_SEARCH_KEYS = {"results", "total", "detected_brand", "scoped_to_brand", "scope_fallback",
|
||||
"min_score", "top_k", "query_text"}
|
||||
"min_score", "top_k", "query_text", "match_confidence", "margin"}
|
||||
IDENTIFY_KEYS = IMAGE_SEARCH_KEYS | {"matched_by", "ocr_text", "ocr_source", "image_top_score", "fallback_reason"}
|
||||
# Capture-to-catalog fields: always present, None unless discovery ran (see
|
||||
# tests/test_capture_discovery.py). Additive - no existing key changed.
|
||||
@@ -248,21 +250,50 @@ def test_a_confident_image_never_touches_ocr(client, embedder_ok, ladder):
|
||||
# /search/image-vector - text_fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_image_vector_without_the_flag_is_unchanged(client, ladder, fake_identify, monkeypatch):
|
||||
@pytest.fixture
|
||||
def image_only(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_search(vector, text=None, brand=None, category=None, top_k=10, min_score=0.0):
|
||||
seen.append(text)
|
||||
return ImageSearchResult(rows=[_row(0.63)], detected_brand="Britannia", scoped_to_brand=True,
|
||||
min_score=min_score, query_text=text, top_k=top_k)
|
||||
min_score=min_score, query_text=text, top_k=top_k,
|
||||
match_confidence="low", margin=0.02)
|
||||
|
||||
monkeypatch.setattr(search_router, "search_by_vector", fake_search)
|
||||
return seen
|
||||
|
||||
res = client.post("/api/search/image-vector", json={"vector": _unit(), "text": "Marie Gold 300 g"})
|
||||
|
||||
def test_image_vector_with_the_flag_false_is_unchanged(client, ladder, fake_identify, image_only):
|
||||
res = client.post("/api/search/image-vector",
|
||||
json={"vector": _unit(), "text": "Marie Gold 300 g", "text_fallback": False})
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert set(body) == IMAGE_SEARCH_KEYS
|
||||
assert body["match_confidence"] == "low" and body["margin"] == 0.02
|
||||
assert image_only == ["Marie Gold 300 g"] and fake_identify.calls == [] and ladder.text_calls == []
|
||||
|
||||
|
||||
def test_image_vector_without_text_or_flag_is_image_only(client, ladder, fake_identify, image_only):
|
||||
res = client.post("/api/search/image-vector", json={"vector": _unit()})
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
assert set(res.json()) == IMAGE_SEARCH_KEYS
|
||||
assert seen == ["Marie Gold 300 g"] and fake_identify.calls == [] and ladder.text_calls == []
|
||||
assert image_only == [None] and fake_identify.calls == []
|
||||
|
||||
|
||||
def test_image_vector_with_text_and_no_flag_lets_the_label_decide(client, ladder, image_only):
|
||||
"""The field report: the image arm's 0.63 must not outrank a label that
|
||||
names the product."""
|
||||
res = client.post("/api/search/image-vector", json={"vector": _unit(), "text": "Marie Gold 300 g"})
|
||||
|
||||
body = res.json()
|
||||
assert res.status_code == 200, res.text
|
||||
assert set(body) == IDENTIFY_KEYS
|
||||
assert body["matched_by"] == "text" and body["match_confidence"] == "confirmed"
|
||||
assert body["results"][0]["image_id"] == "txt_marie_300" and body["margin"] is None
|
||||
assert image_only == [] # the router's image-only path never ran
|
||||
|
||||
|
||||
def test_image_vector_with_the_flag_runs_the_ladder_and_adds_the_fields(client, ladder):
|
||||
@@ -285,6 +316,7 @@ def test_image_vector_with_the_flag_but_no_text_says_no_text(client, ladder):
|
||||
assert res.status_code == 200, res.text
|
||||
assert body["matched_by"] == "image_vector" and body["fallback_reason"] == "no_text"
|
||||
assert body["results"][0]["score"] == 0.63
|
||||
assert body["match_confidence"] == "low" # shown as a list to pick from
|
||||
|
||||
|
||||
def test_image_vector_with_the_flag_still_validates_the_vector(client, ladder):
|
||||
|
||||
205
tests/test_image_confidence.py
Normal file
205
tests/test_image_confidence.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""When a search-by-photo answer counts as confirmed, and what is logged.
|
||||
|
||||
A photo of the "Cadbury Dairy Milk Lickables" card on a phone screen scored
|
||||
0.507 against its own product and 0.419 against a stranger; the Aachi sambar
|
||||
card 0.560 against 0.541. Those orders flip with a little glare, and the app
|
||||
then showed a different product as THE answer. So an image match is
|
||||
"confirmed" only with a score at the identify floor AND a lead over the best
|
||||
different photo - pack sizes that share one photo never count as rivals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infrastructure import settings
|
||||
from app.services import image_match, image_search_log
|
||||
from app.services import product_identify as pi
|
||||
from app.services.image_match import ImageSearchResult, image_margin, match_confidence
|
||||
|
||||
|
||||
def _rows(*scores: float) -> List[Dict[str, Any]]:
|
||||
return [{"image_id": f"p{i}", "product_name": f"Product {i}", "brand": "Cadbury", "score": s}
|
||||
for i, s in enumerate(scores)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# image_margin / match_confidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_margin_skips_pack_sizes_that_share_the_winners_photo():
|
||||
# 100g and 200g share one photo and tie; the rival is the 0.761.
|
||||
assert image_margin(_rows(0.960, 0.960, 0.9602, 0.761)) == pytest.approx(0.199)
|
||||
|
||||
|
||||
def test_no_rival_means_no_margin():
|
||||
assert image_margin(_rows(0.93, 0.93)) is None
|
||||
assert image_margin([]) is None
|
||||
|
||||
|
||||
def test_a_high_score_with_a_clear_lead_is_confirmed():
|
||||
assert match_confidence(_rows(0.94, 0.58), min_image_score=0.70, min_margin=0.05)[0] == "confirmed"
|
||||
|
||||
|
||||
def test_a_high_score_with_a_rival_close_behind_is_low():
|
||||
label, margin = match_confidence(_rows(0.74, 0.72), min_image_score=0.70, min_margin=0.05)
|
||||
assert label == "low" and margin == pytest.approx(0.02)
|
||||
|
||||
|
||||
def test_a_card_photographed_on_a_screen_is_low_even_when_it_ranks_first():
|
||||
assert match_confidence(_rows(0.507, 0.419), min_image_score=0.70, min_margin=0.05)[0] == "low"
|
||||
|
||||
|
||||
def test_a_high_score_whose_only_neighbours_are_its_own_sizes_is_confirmed():
|
||||
assert match_confidence(_rows(0.96, 0.96), min_image_score=0.70, min_margin=0.05)[0] == "confirmed"
|
||||
|
||||
|
||||
def test_no_rows_is_none():
|
||||
assert match_confidence([]) == ("none", None)
|
||||
|
||||
|
||||
def test_search_by_vector_judges_confidence_on_every_candidate_not_just_top_k(monkeypatch):
|
||||
candidates = [
|
||||
{"image_id": "lickables_20g", "product_name": "Cadbury Dairy Milk Lickables 20g",
|
||||
"brand": "Cadbury", "brand_table": "brand_cadbury", "distance": 0.20},
|
||||
{"image_id": "milk_toned", "product_name": "Milk Toned",
|
||||
"brand": "Own Products", "brand_table": "brand_own_products", "distance": 0.22},
|
||||
]
|
||||
monkeypatch.setattr("app.services.vector_store.image_vector_search",
|
||||
lambda *a, **k: [dict(c) for c in candidates])
|
||||
monkeypatch.setattr(image_match, "_hydrate", lambda rows: rows)
|
||||
|
||||
out = image_match.search_by_vector([1.0] + [0.0] * 1023, top_k=1)
|
||||
|
||||
assert [r["image_id"] for r in out.rows] == ["lickables_20g"]
|
||||
assert out.match_confidence == "low" and out.margin == pytest.approx(0.02)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the identify ladder: an ambiguous image lets the label decide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def ladder(monkeypatch):
|
||||
state: Dict[str, Any] = {"margin": None, "text_calls": 0}
|
||||
|
||||
def image(vector, text=None, brand=None, category=None, top_k=10, min_score=0.0):
|
||||
return ImageSearchResult(rows=_rows(0.74, 0.72), margin=state["margin"], top_k=top_k)
|
||||
|
||||
def text(label, brand=None, category=None, top_k=10):
|
||||
state["text_calls"] += 1
|
||||
return ImageSearchResult(rows=[{"image_id": "by_label", "score": 0.8, "text_overlap": 7.0}])
|
||||
|
||||
monkeypatch.setattr(pi, "search_by_vector", image)
|
||||
monkeypatch.setattr(pi, "resolve_label", text)
|
||||
monkeypatch.setattr(pi, "is_confident", lambda rows: bool(rows))
|
||||
monkeypatch.setattr(pi, "IMAGE_SEARCH_MIN_MARGIN", 0.05)
|
||||
return state
|
||||
|
||||
|
||||
def test_an_image_that_clears_the_floor_but_barely_leads_falls_to_the_label(ladder):
|
||||
ladder["margin"] = 0.02
|
||||
|
||||
out = pi.identify_product(vector=[1.0] + [0.0] * 1023, image_bytes=None,
|
||||
text="Cadbury Dairy Milk Lickables 20g", min_image_score=0.70)
|
||||
|
||||
assert out.matched_by == "text" and out.fallback_reason == "image_ambiguous"
|
||||
assert out.search.rows[0]["image_id"] == "by_label"
|
||||
|
||||
|
||||
def test_an_image_with_a_clear_lead_is_still_the_answer(ladder):
|
||||
ladder["margin"] = 0.2
|
||||
|
||||
out = pi.identify_product(vector=[1.0] + [0.0] * 1023, image_bytes=None,
|
||||
text="Cadbury Dairy Milk Lickables 20g", min_image_score=0.70)
|
||||
|
||||
assert out.matched_by == "image_vector" and out.fallback_reason is None
|
||||
assert ladder["text_calls"] == 0
|
||||
|
||||
|
||||
def test_an_ambiguous_image_with_no_label_is_shown_unconfirmed(ladder):
|
||||
ladder["margin"] = 0.02
|
||||
|
||||
out = pi.identify_product(vector=[1.0] + [0.0] * 1023, image_bytes=None, min_image_score=0.70)
|
||||
|
||||
assert out.matched_by == "image_vector" and out.fallback_reason == "no_text"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# image_search_log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_fingerprint_is_stable_and_short():
|
||||
v = [0.5, -0.25] + [0.0] * 1022
|
||||
assert image_search_log.vector_fingerprint(v) == image_search_log.vector_fingerprint(list(v))
|
||||
assert len(image_search_log.vector_fingerprint(v)) == 10
|
||||
assert image_search_log.vector_fingerprint(None) is None
|
||||
|
||||
|
||||
def test_capture_is_off_by_default(monkeypatch, tmp_path, caplog):
|
||||
monkeypatch.setattr(settings, "IMAGE_SEARCH_CAPTURE_DIR", "")
|
||||
caplog.set_level("INFO", logger="app.services.image_search_log")
|
||||
|
||||
image_search_log.record("image-vector", vector=[1.0] + [0.0] * 1023, text="Aachi Sambar Powder 100g",
|
||||
brand=None, category=None, top_k=5, rows=_rows(0.56, 0.54))
|
||||
|
||||
assert "[IMAGE_SEARCH] route=image-vector" in caplog.text and "Aachi Sambar Powder 100g" in caplog.text
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_capture_writes_a_replayable_request_and_keeps_only_the_newest(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "IMAGE_SEARCH_CAPTURE_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "IMAGE_SEARCH_CAPTURE_MAX", 2)
|
||||
vec = [1.0] + [0.0] * 1023
|
||||
|
||||
for i in range(3):
|
||||
image_search_log.record("identify", vector=vec, text=f"label {i}", brand=None, category=None,
|
||||
top_k=5, rows=_rows(0.5), photo=b"jpegbytes")
|
||||
|
||||
saved = sorted(tmp_path.glob("*.json"))
|
||||
assert len(saved) == 2 and len(list(tmp_path.glob("*.img"))) == 2
|
||||
payload = json.loads(saved[-1].read_text(encoding="utf-8"))
|
||||
assert payload["request"]["vector"] == vec and payload["route"] == "identify"
|
||||
assert (tmp_path / payload["photo"]).read_bytes() == b"jpegbytes"
|
||||
|
||||
|
||||
def test_a_capture_failure_never_raises(monkeypatch, tmp_path):
|
||||
blocker = tmp_path / "not_a_dir"
|
||||
blocker.write_text("x")
|
||||
monkeypatch.setattr(settings, "IMAGE_SEARCH_CAPTURE_DIR", str(blocker))
|
||||
|
||||
image_search_log.record("image", vector=None, text=None, brand=None, category=None,
|
||||
top_k=5, rows=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# size in the row's own name breaks a size_variants tie
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cream(name: str) -> Dict[str, Any]:
|
||||
# Every Amul Cream row carries the SAME size_variants list in the catalogue.
|
||||
return {"product_name": name, "size_variants": ["90g", "1kg"], "score": 0.651,
|
||||
"image_id": name.lower().replace(" ", "_")}
|
||||
|
||||
|
||||
def test_the_label_size_in_the_name_beats_the_same_size_in_shared_size_variants():
|
||||
from app.services.label_match import label_rank_key
|
||||
from app.services.image_match import size_in_name, text_overlap, tokens
|
||||
|
||||
words, sizes = tokens("Amul Cream 1kg")
|
||||
rows = [_cream("Amul Cream 125 ml / 67 g"), _cream("Amul Cream 1kg"), _cream("Amul Cream 250 ml")]
|
||||
for row in rows:
|
||||
row["text_overlap"] = text_overlap(words, sizes, row)
|
||||
row["size_in_name"] = size_in_name(sizes, row)
|
||||
|
||||
assert len({r["text_overlap"] for r in rows}) == 1 # the tie this exists to break
|
||||
assert sorted(rows, key=label_rank_key)[0]["product_name"] == "Amul Cream 1kg"
|
||||
assert sorted(rows, key=image_match.rank_key)[0]["product_name"] == "Amul Cream 1kg"
|
||||
|
||||
|
||||
def test_no_label_size_is_never_a_name_hit():
|
||||
from app.services.image_match import size_in_name
|
||||
|
||||
assert size_in_name(set(), _cream("Amul Cream 1kg")) is False
|
||||
@@ -64,7 +64,8 @@ def fake_search(monkeypatch):
|
||||
|
||||
def test_a_vector_returns_the_product_card_with_its_score(client, fake_search):
|
||||
res = client.post("/api/search/image-vector",
|
||||
json={"vector": _unit(), "text": "Britannia Marie Gold 300 g", "top_k": 5})
|
||||
json={"vector": _unit(), "text": "Britannia Marie Gold 300 g", "top_k": 5,
|
||||
"text_fallback": False})
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
|
||||
83
tests/test_lexical_search_distance.py
Normal file
83
tests/test_lexical_search_distance.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""vector_store.lexical_search when a matching row has no text embedding.
|
||||
|
||||
`embedding <=> q` is NULL for such a row, so its dict carries distance None -
|
||||
the key is present, so `.get("distance", 9.0)` returned None, and sorting a
|
||||
None among floats raised. The label resolver's lexical arm caught that and
|
||||
lost EVERY row of the search: unscoped, one un-embedded product in any brand
|
||||
table was enough to blank "Sambar Powder 100g".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from app.services import vector_store
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, tables: Dict[str, List[Tuple[str, float | None, int]]]):
|
||||
self._tables = tables
|
||||
self._rows: List[tuple] = []
|
||||
self.description = [("product_name",), ("distance",), ("lex_tier",)]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def execute(self, sql: str, params=None) -> None:
|
||||
table = sql.split(" FROM ", 1)[1].split()[0]
|
||||
self._rows = list(self._tables[table])
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _Conn:
|
||||
def __init__(self, cursor: _Cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _patch(monkeypatch, tables):
|
||||
cursor = _Cursor(tables)
|
||||
monkeypatch.setattr(vector_store, "_connect", lambda: _Conn(cursor))
|
||||
monkeypatch.setattr(vector_store, "_list_brand_table_suffixes",
|
||||
lambda cur: [t[len("brand_"):] for t in tables])
|
||||
monkeypatch.setattr(vector_store, "_table_exists", lambda cur, t: True)
|
||||
monkeypatch.setattr(vector_store, "_product_columns", lambda cur, t: "product_name")
|
||||
|
||||
|
||||
def test_a_row_without_an_embedding_sorts_last_in_its_tier_instead_of_raising(monkeypatch):
|
||||
_patch(monkeypatch, {
|
||||
"brand_aachi": [("Aachi Sambar Powder 100g", 0.41, 2)],
|
||||
"brand_zzsmoketest": [("Zzsmoketest Sambar Powder 100g", None, 2)],
|
||||
"brand_sakthi": [("Sambar powder 50g", 0.35, 2)],
|
||||
})
|
||||
|
||||
rows = vector_store.lexical_search(["sambar", "powder"], query_embedding=[0.1] * 384)
|
||||
|
||||
names: List[Any] = [r["product_name"] for r in rows]
|
||||
assert names == ["Sambar powder 50g", "Aachi Sambar Powder 100g", "Zzsmoketest Sambar Powder 100g"]
|
||||
|
||||
|
||||
def test_the_tier_still_outranks_the_distance(monkeypatch):
|
||||
_patch(monkeypatch, {
|
||||
"brand_a": [("exact", None, 0)],
|
||||
"brand_b": [("contains", 0.01, 2)],
|
||||
})
|
||||
|
||||
rows = vector_store.lexical_search(["x"], query_embedding=[0.1] * 384)
|
||||
|
||||
assert [r["product_name"] for r in rows] == ["exact", "contains"]
|
||||
Reference in New Issue
Block a user