updates on catalog search and suggestions in backend
This commit is contained in:
@@ -87,5 +87,23 @@ def test_openapi_schema_lists_all_routers(client) -> None:
|
||||
resp = client.get("/openapi.json")
|
||||
assert resp.status_code == 200
|
||||
paths = resp.json()["paths"]
|
||||
for expected in ("/api/health", "/api/brands", "/api/search", "/api/chat", "/api/catalog/generate"):
|
||||
for expected in ("/api/health", "/api/brands", "/api/search", "/api/suggest",
|
||||
"/api/chat", "/api/catalog/generate"):
|
||||
assert expected in paths, f"missing route: {expected}"
|
||||
|
||||
|
||||
def test_suggest_works_without_a_database(client) -> None:
|
||||
"""Autocomplete must degrade, not 500, when the catalog is unreachable.
|
||||
|
||||
This suite runs with no reachable database, so this pins that the search
|
||||
box keeps suggesting from the static brand table instead of erroring - a
|
||||
failing suggest call must never block someone from typing and searching.
|
||||
"""
|
||||
resp = client.get("/api/suggest", params={"q": "cavin"})
|
||||
assert resp.status_code == 200
|
||||
labels = [s["label"] for s in resp.json()["suggestions"]]
|
||||
assert "Cavinkare" in labels
|
||||
|
||||
|
||||
def test_suggest_requires_a_query(client) -> None:
|
||||
assert client.get("/api/suggest").status_code == 422
|
||||
|
||||
91
tests/test_catalog_search_rank.py
Normal file
91
tests/test_catalog_search_rank.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Hybrid merge/dedup/order for the catalog search box.
|
||||
|
||||
Pure-Python over hand-built rows, so no database is involved.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.catalog_search import _hybrid_rank, _row_key
|
||||
|
||||
|
||||
def _row(brand, image_id, name, **extra):
|
||||
row = {"brand": brand, "image_id": image_id, "product_name": name}
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
def test_name_matches_outrank_semantic_only_matches():
|
||||
semantic = [
|
||||
_row("Amul", "s1", "Amul Cheese Cubes", distance=0.10),
|
||||
_row("Amul", "s2", "Amul Fresh Cream", distance=0.12),
|
||||
]
|
||||
lexical = [
|
||||
_row("Amul", "l1", "Amul Butter 100 g", distance=0.40, lex_tier=1),
|
||||
_row("Amul", "l2", "Amul Butter 500 g", distance=0.45, lex_tier=1),
|
||||
]
|
||||
|
||||
ranked = _hybrid_rank(semantic, lexical, limit=10)
|
||||
names = [r["product_name"] for r in ranked]
|
||||
|
||||
# Both butter variants come first even though their vector distance is worse.
|
||||
assert names[:2] == ["Amul Butter 100 g", "Amul Butter 500 g"]
|
||||
assert all(r.get("lex_tier", 9) == 1 for r in ranked[:2])
|
||||
assert all(r.get("lex_tier", 9) == 9 for r in ranked[2:])
|
||||
|
||||
|
||||
def test_lower_lex_tier_wins_within_the_lexical_block():
|
||||
lexical = [
|
||||
_row("Amul", "l2", "Amul Butter Salted", distance=0.20, lex_tier=2),
|
||||
_row("Amul", "l0", "Amul Butter", distance=0.90, lex_tier=0),
|
||||
_row("Amul", "l1", "Amul Butter 500 g", distance=0.50, lex_tier=1),
|
||||
]
|
||||
ranked = _hybrid_rank([], lexical, limit=10)
|
||||
assert [r["lex_tier"] for r in ranked] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_distance_breaks_ties_inside_a_tier():
|
||||
lexical = [
|
||||
_row("Amul", "b", "Amul Butter 500 g", distance=0.50, lex_tier=1),
|
||||
_row("Amul", "a", "Amul Butter 100 g", distance=0.20, lex_tier=1),
|
||||
]
|
||||
ranked = _hybrid_rank([], lexical, limit=10)
|
||||
assert [r["image_id"] for r in ranked] == ["a", "b"]
|
||||
|
||||
|
||||
def test_dedup_keeps_the_real_semantic_distance_and_takes_the_tier():
|
||||
semantic = [_row("Amul", "same", "Amul Butter 100 g", distance=0.11)]
|
||||
lexical = [_row("Amul", "same", "Amul Butter 100 g", distance=None, lex_tier=1)]
|
||||
|
||||
ranked = _hybrid_rank(semantic, lexical, limit=10)
|
||||
|
||||
assert len(ranked) == 1, "the same product must not appear twice"
|
||||
assert ranked[0]["distance"] == 0.11, "the real vector distance must survive"
|
||||
assert ranked[0]["lex_tier"] == 1, "the lexical tier must be applied"
|
||||
|
||||
|
||||
def test_same_image_id_in_different_brands_is_not_deduped():
|
||||
# image_id is UNIQUE per brand table, not globally.
|
||||
semantic = [
|
||||
_row("Amul", "dup", "Amul Butter", distance=0.10),
|
||||
_row("Nestle", "dup", "Nestle Butter", distance=0.20),
|
||||
]
|
||||
ranked = _hybrid_rank(semantic, [], limit=10)
|
||||
assert len(ranked) == 2
|
||||
assert _row_key(semantic[0]) != _row_key(semantic[1])
|
||||
|
||||
|
||||
def test_lexical_only_rows_get_a_synthetic_distance_matching_their_tier():
|
||||
lexical = [
|
||||
_row("Amul", "t0", "Amul Butter", distance=None, lex_tier=0),
|
||||
_row("Amul", "t3", "Butter Amul Pack", distance=None, lex_tier=3),
|
||||
]
|
||||
ranked = _hybrid_rank([], lexical, limit=10)
|
||||
by_id = {r["image_id"]: r for r in ranked}
|
||||
|
||||
# similarity == 1 - distance/2, so an exact name match must read as 1.0.
|
||||
assert by_id["t0"]["distance"] == 0.0
|
||||
assert by_id["t3"]["distance"] > by_id["t0"]["distance"]
|
||||
|
||||
|
||||
def test_limit_is_respected():
|
||||
semantic = [_row("Amul", f"s{i}", f"Product {i}", distance=i / 100) for i in range(50)]
|
||||
assert len(_hybrid_rank(semantic, [], limit=10)) == 10
|
||||
80
tests/test_search_intent.py
Normal file
80
tests/test_search_intent.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Query-shape classification behind the catalog search box.
|
||||
|
||||
The case that matters most is "Amul Butter". brand_registry.BRAND_ALIASES
|
||||
contains "amul butter" -> "amul", so any classifier built on
|
||||
resolve_parent_brand() would call it brand-only and list Amul's entire
|
||||
catalog - the opposite of what the user asked for. These tests pin the
|
||||
distinction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import query_intent
|
||||
from app.services.query_intent import classify_query
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _static_brand_index(monkeypatch):
|
||||
"""Pin the brand index so the tests don't depend on a live database."""
|
||||
known = list(query_intent.KNOWN_BRANDS)
|
||||
mapping = dict(query_intent.BRAND_SEARCH_MAP)
|
||||
monkeypatch.setattr(query_intent, "_brand_index", lambda: (known, mapping))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query,brand", [
|
||||
("Amul", "Amul"),
|
||||
("amul", "Amul"),
|
||||
("AMUL", "Amul"),
|
||||
("Cavinkare", "Cavinkare"),
|
||||
("Milky Mist", "Milky Mist"),
|
||||
("Lion Dates", "Lion Dates"),
|
||||
("Colgate", "Colgate-Palmolive"),
|
||||
("colgate-palmolive", "Colgate-Palmolive"),
|
||||
("coke", "Coca-Cola"),
|
||||
("coca cola", "Coca-Cola"),
|
||||
("hul", "Hindustan Unilever"),
|
||||
("pepsi", "Pepsico"),
|
||||
("show me amul products", "Amul"),
|
||||
("all products from Cadbury", "Cadbury"),
|
||||
("Amul?", "Amul"),
|
||||
])
|
||||
def test_brand_only_queries(query, brand):
|
||||
shape = classify_query(query)
|
||||
assert shape.kind == "brand_only", f"{query!r} -> {shape}"
|
||||
assert shape.brand == brand
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query,brand,residual_contains", [
|
||||
("Amul Butter", "Amul", "butter"),
|
||||
("amul butter", "Amul", "butter"),
|
||||
("Amul Cheese Slices", "Amul", "cheese"),
|
||||
("Cadbury Dairy Milk", "Cadbury", "dairy milk"),
|
||||
("Colgate toothpaste", "Colgate-Palmolive", "toothpaste"),
|
||||
])
|
||||
def test_brand_plus_queries_keep_their_product_terms(query, brand, residual_contains):
|
||||
shape = classify_query(query)
|
||||
assert shape.kind == "brand_plus", f"{query!r} -> {shape}"
|
||||
assert shape.brand == brand
|
||||
assert residual_contains in shape.residual
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", [
|
||||
"low sugar biscuit",
|
||||
"something nobody sells",
|
||||
"",
|
||||
" ",
|
||||
])
|
||||
def test_generic_queries(query):
|
||||
assert classify_query(query).kind == "generic"
|
||||
|
||||
|
||||
def test_explicit_brand_with_empty_query_is_brand_only():
|
||||
shape = classify_query("", explicit_brand="Amul")
|
||||
assert shape.kind == "brand_only"
|
||||
assert shape.brand == "Amul"
|
||||
|
||||
|
||||
def test_bare_category_word_is_not_treated_as_a_brand():
|
||||
# Guards a future brand literally named after a product word.
|
||||
assert classify_query("butter").kind != "brand_only"
|
||||
110
tests/test_suggest_service.py
Normal file
110
tests/test_suggest_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Autocomplete ranking for the catalog search box.
|
||||
|
||||
Runs without a database: the brand index is monkeypatched and the live
|
||||
category/count lookups degrade to empty, which is also how the endpoint
|
||||
behaves in tests/test_api.py where no database is reachable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import query_intent, suggest_service
|
||||
from app.services.suggest_service import suggest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _static_brand_index(monkeypatch):
|
||||
known = list(query_intent.KNOWN_BRANDS)
|
||||
mapping = dict(query_intent.BRAND_SEARCH_MAP)
|
||||
monkeypatch.setattr(query_intent, "_brand_index", lambda: (known, mapping))
|
||||
# No database in unit tests: counts and live categories are unavailable.
|
||||
monkeypatch.setattr(suggest_service, "_brand_counts", lambda: {})
|
||||
monkeypatch.setattr(suggest_service, "_live_categories", lambda: None)
|
||||
|
||||
|
||||
def _labels(query, **kwargs):
|
||||
return [s.label for s in suggest(query, **kwargs)]
|
||||
|
||||
|
||||
def test_partial_brand_suggests_the_full_name():
|
||||
# The two examples from the original request.
|
||||
assert "Cavinkare" in _labels("Cavin")
|
||||
assert "Colgate-Palmolive" in _labels("Colgate")
|
||||
|
||||
|
||||
def test_partial_match_is_case_insensitive():
|
||||
assert "Cavinkare" in _labels("cAvIn")
|
||||
|
||||
|
||||
def test_alias_resolves_to_the_canonical_brand():
|
||||
assert "Coca-Cola" in _labels("coke")
|
||||
assert "Hindustan Unilever" in _labels("hul")
|
||||
assert "Pepsico" in _labels("pepsi")
|
||||
|
||||
|
||||
def test_word_prefix_matches_a_later_word_in_the_name():
|
||||
assert "Colgate-Palmolive" in _labels("palmoliv")
|
||||
|
||||
|
||||
def test_exact_match_outranks_a_mere_prefix():
|
||||
results = suggest("amul")
|
||||
assert results[0].label == "Amul"
|
||||
|
||||
|
||||
def test_typo_still_finds_the_brand():
|
||||
assert "Colgate-Palmolive" in _labels("colgat")
|
||||
|
||||
|
||||
def test_unknown_text_returns_nothing():
|
||||
assert suggest("zzzzqqq") == []
|
||||
|
||||
|
||||
def test_query_below_minimum_length_is_ignored():
|
||||
assert suggest("c") == []
|
||||
|
||||
|
||||
def test_limit_is_respected():
|
||||
assert len(suggest("a", limit=3)) <= 3
|
||||
assert len(suggest("ca", limit=2)) <= 2
|
||||
|
||||
|
||||
def test_categories_are_suggested_by_keyword():
|
||||
labels = _labels("choc")
|
||||
assert "Chocolates" in labels
|
||||
|
||||
|
||||
def test_category_keyword_misspelling_still_matches():
|
||||
# category_registry carries deliberate misspellings.
|
||||
assert "Biscuits & Cookies" in _labels("biskut")
|
||||
|
||||
|
||||
def test_a_brand_wins_a_tie_against_a_category():
|
||||
# Ranking is by match quality; kind only breaks ties, because a bare word
|
||||
# is more often reaching for a brand.
|
||||
results = suggest("ca")
|
||||
for a, b in zip(results, results[1:]):
|
||||
if a.score == b.score and a.type != b.type:
|
||||
assert a.type == "brand"
|
||||
|
||||
|
||||
def test_a_strong_category_match_outranks_a_weak_brand_match():
|
||||
# "choc" must lead with Chocolates, not with a fuzzy brand near-miss.
|
||||
results = suggest("choc")
|
||||
assert results, "expected at least one suggestion"
|
||||
assert results[0].label == "Chocolates"
|
||||
|
||||
|
||||
def test_fuzzy_matches_do_not_pollute_a_precise_match():
|
||||
# "colgat" is a prefix of Colgate-Palmolive, so no fuzzy noise should appear.
|
||||
labels = _labels("colgat")
|
||||
assert labels[0] == "Colgate-Palmolive"
|
||||
assert "Coca-Cola" not in labels
|
||||
|
||||
|
||||
def test_suggestions_are_sorted_by_descending_score_within_each_type():
|
||||
# Brands are grouped ahead of categories on purpose, so the score ordering
|
||||
# holds within each group rather than across the whole list.
|
||||
results = suggest("co")
|
||||
for kind in ("brand", "category"):
|
||||
scores = [s.score for s in results if s.type == kind]
|
||||
assert scores == sorted(scores, reverse=True), kind
|
||||
Reference in New Issue
Block a user