updates on catalog search and suggestions in backend

This commit is contained in:
sriram
2026-08-20 13:03:16 +05:30
parent e224043e26
commit fbb1356e47
14 changed files with 1177 additions and 11 deletions

View File

@@ -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

View 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

View 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"

View 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