Files
loyaly-catalogue/backend/tests/test_elec_parsers.py
sriram c7e4d59188 Electronics Catalog: API, MCP server, frontend and deployment
Verified catalogue of mobiles and laptops sold in India, collected from
real retail listings (FastAPI backend, React frontend, Postgres/pgvector).

- REST API under /api/elec (read-only catalogue; admin endpoints need login)
- MCP server (FastMCP) at /mcp/ with list_categories, search_products,
  get_product and price_history tools
- Real ratings and reviews read from product pages and search results
- Production Dockerfile (requirements-api.txt, no PyTorch) and
  .env.production.example; remote database only via an explicit
  ELEC_ALLOW_REMOTE_DB host/name allowlist
- docs/API.md: endpoint and MCP reference with live examples

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:17:42 +05:30

331 lines
16 KiB
Python

"""Offline tests: title parsing, SERP price reading, spec normalisation,
grounding and the LLM guard. No network, no database."""
from __future__ import annotations
from decimal import Decimal
import pytest
from app.electronics.extract.serp_parser import clean_result_title, read_price, read_stock
from app.electronics.normalise.brand_alias import looks_like_device_title, resolve_brand
from app.electronics.normalise.grounding import value_in_source
from app.electronics.normalise.llm_fill import fill_missing
from app.electronics.normalise.spec_normaliser import normalise_specs
from app.electronics.normalise.title_parser import parse_title, variant_key
# ---------------------------------------------------------------------------
# Titles: the same variant written the way different sites write it must give
# the same key; different variants must not.
# ---------------------------------------------------------------------------
SAME_VARIANT = [
("mobiles", "samsung|mobiles|galaxy s24|8|256", [
"Samsung Galaxy S24 5G (Onyx Black, 8GB RAM, 256GB Storage)",
"SAMSUNG Galaxy S24 5G (Onyx Black, 256 GB) (8 GB RAM)",
"Samsung Galaxy S24 5G (8GB RAM, 256GB, Onyx Black)",
"Samsung Galaxy S24 8GB 256GB Onyx Black",
]),
("mobiles", "xiaomi|mobiles|redmi note 13 pro|8|256", [
"Redmi Note 13 Pro 5G (8GB + 256GB)",
"Xiaomi Redmi Note 13 Pro 5G (Arctic White, 8GB RAM, 256GB Storage)",
"REDMI Note 13 Pro 5G (Arctic White, 256 GB) (8 GB RAM)",
]),
("mobiles", "apple|mobiles|iphone 15|na|128", [
"Apple iPhone 15 (128 GB) - Black",
"Apple iPhone 15 128GB Black",
"iPhone 15 128 GB: 5G Smartphone with Dynamic Island",
]),
("mobiles", "nothing|mobiles|2a|8|128", [
"Nothing Phone (2a) 5G (Black, 8GB RAM, 128GB)",
"Nothing Phone (2a) 5G (Black, 128 GB) (8 GB RAM)",
]),
("laptops", "hp|laptops|15s|i5-1334u|16|512", [
"HP 15s, 13th Gen Intel Core i5-1334U, 16GB DDR4, 512GB SSD, (Win 11, Office 21, Silver, 1.69kg), "
"15.6-inch(39.6 cm) FHD, Intel Iris Xe Graphics, fd0112TU",
"HP 15s Laptop fd0112TU Intel Core i5 1334U 16GB 512GB SSD",
]),
("laptops", "dell|laptops|inspiron 3520|i5-1235u|16|512", [
"Dell Inspiron 3520 Laptop, Intel Core i5-1235U, 16GB, 512GB SSD, 15.6\" FHD",
"Dell Inspiron 3520 Intel Core i5-1235U 16GB RAM 512GB SSD 15.6 inch",
]),
]
@pytest.mark.parametrize("category,key,titles", SAME_VARIANT)
def test_same_variant_same_key(category, key, titles):
for title in titles:
assert variant_key(parse_title(title, category), category) == key, title
@pytest.mark.parametrize("a,b", [
("Samsung Galaxy S24 5G (8GB RAM, 256GB)", "Samsung Galaxy S24 Ultra 5G (12GB RAM, 256GB)"),
("Samsung Galaxy S24 5G (8GB RAM, 128GB)", "Samsung Galaxy S24 5G (8GB RAM, 256GB)"),
("Redmi Note 13 5G (8GB + 256GB)", "Redmi Note 13 Pro 5G (8GB + 256GB)"),
("Apple iPhone 15 (128 GB)", "Apple iPhone 15 Plus (128 GB)"),
])
def test_different_variants_differ(a, b):
assert variant_key(parse_title(a, "mobiles"), "mobiles") != variant_key(parse_title(b, "mobiles"), "mobiles")
def test_title_without_storage_has_no_variant():
# A title that does not say which variant it is cannot be linked to one.
assert variant_key(parse_title("Samsung Galaxy S24 5G price in India", "mobiles"), "mobiles") is None
@pytest.mark.parametrize("title,brand,family", [
("Google Pixel 8a (Obsidian, 128GB)", "google", "Pixel"),
("iQOO Z9 5G (Brushed Green, 8GB RAM, 128GB Storage)", "vivo", "iQOO"),
("POCO X6 Pro 5G (Racing Grey, 12GB RAM, 512GB)", "xiaomi", "POCO"),
("Lenovo IdeaPad Slim 3 Intel Core i5", "lenovo", "IdeaPad"),
])
def test_sub_brands_resolve_to_parent(title, brand, family):
m = resolve_brand(title)
assert m.brand_slug == brand and m.family == family
def test_brand_outside_allow_list_is_rejected():
assert resolve_brand("Nokia G42 5G (6GB RAM, 128GB)") is None
# The brand must lead the title - an accessory "for Samsung" is not Samsung.
assert resolve_brand("Spigen case for Samsung Galaxy S24") is None
def test_accessories_are_not_devices():
assert not looks_like_device_title("Samsung Galaxy S24 Back Cover Case")
assert not looks_like_device_title("Samsung Galaxy S24 vs iPhone 15 comparison")
assert looks_like_device_title("Samsung Galaxy S24 5G (8GB RAM, 256GB)")
def test_laptop_fields():
p = parse_title("ASUS Vivobook 15, Intel Core i3-1215U 12th Gen, 8GB RAM, 512GB SSD, 15.6\" FHD, "
"Windows 11, X1504ZA-NJ321WS", "laptops")
assert (p.model, p.processor, p.ram_gb, p.storage_gb, p.mpn) == (
"Vivobook 15", "i3-1215u", Decimal(8), Decimal(512), "X1504ZA-NJ321WS")
mac = parse_title("Apple MacBook Air Laptop: Apple M2 chip, 13.6-inch Liquid Retina Display, "
"8GB Unified Memory, 256GB SSD Storage - Midnight", "laptops")
assert (mac.model, mac.processor, mac.ram_gb, mac.storage_gb) == ("MacBook Air", "m2", Decimal(8), Decimal(256))
def test_terabyte_storage():
p = parse_title("Samsung Galaxy S24 Ultra 5G (Titanium Black, 12GB RAM, 1TB Storage)", "mobiles")
assert p.storage_gb == Decimal(1024)
# ---------------------------------------------------------------------------
# SERP snippet prices: only an unambiguous selling price is read.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text,price", [
("Buy Samsung Galaxy S24 5G for ₹74,999 online", Decimal("74999")),
("Price: ₹1,29,999.00 Free delivery", Decimal("129999.00")),
("Rs. 12,999 only", Decimal("12999")),
("Rs.12999", Decimal("12999")),
("INR 45,490", Decimal("45490")),
("₹64,999 M.R.P: ₹79,999 (19% off)", Decimal("64999")),
("Deal price ₹15,499. No Cost EMI from ₹2,583/month.", Decimal("15499")),
("₹18,999 Save ₹4,000 with bank offers", Decimal("18999")),
("Get ₹3,000 off. Now at ₹21,999", Decimal("21999")),
("Price ₹74,999 . Up to ₹5,000 cashback", Decimal("74999")),
("₹74999 ₹74,999 in stock", Decimal("74999")), # same price twice is still one price
])
def test_snippet_price_read(text, price):
assert read_price(text).price == price
@pytest.mark.parametrize("text", [
"No Cost EMI starting from ₹2,583/month",
"Save ₹4,000 on exchange",
"Exchange offer up to ₹12,000",
"Price range ₹10,999 - ₹12,999",
"Starting from ₹9,999",
"Get extra ₹2,000 off with HDFC Bank cards",
"Samsung Galaxy S24 5G 8GB 256GB", # no amount at all
"₹299 screen guard", # outside plausible device range
"8GB+128GB ₹17,999 | 8GB+256GB ₹19,999", # two variants, two prices: ambiguous
"₹2,583 per month EMI",
"Flat ₹3,000 discount",
"Delivery charges ₹49",
])
def test_snippet_non_prices_rejected(text):
assert read_price(text).price is None
def test_mrp_is_separate_and_never_below_price():
r = read_price("₹64,999 MRP ₹79,999")
assert (r.price, r.mrp) == (Decimal("64999"), Decimal("79999"))
assert read_price("₹64,999 MRP ₹59,999").mrp is None
def test_stock_phrases():
assert read_stock("Currently unavailable. We don't know when") is False
assert read_stock("Out of Stock") is False
assert read_stock("In stock. Delivery by tomorrow") is True
assert read_stock("Samsung Galaxy S24 8GB 256GB") is None
def test_clean_result_title():
assert clean_result_title("Samsung Galaxy S24 5G (Onyx Black, 8GB RAM, 256GB) : Amazon.in: Electronics") \
== "Samsung Galaxy S24 5G (Onyx Black, 8GB RAM, 256GB)"
assert clean_result_title("Buy Apple iPhone 15 (128 GB) - Black Online at Best Price | Croma") \
== "Apple iPhone 15 (128 GB) - Black"
# ---------------------------------------------------------------------------
# Specs and grounding
# ---------------------------------------------------------------------------
def test_spec_normalisation_units_and_ranges():
specs, sources = normalise_specs("mobiles", {
"Battery Capacity": "5000 mAh",
"Display Size": "15.49 cm (6.1 inch)",
"RAM": "8 GB",
"Internal Storage": "1 TB",
"Primary Camera": "50MP + 12MP + 10MP",
"Network Type": "5G, 4G VOLTE",
"Operating System": "Android 14",
"Refresh Rate": "120 Hz",
"Unrelated Label": "whatever",
"Screen Size": "600 inch", # later duplicate key; also out of range
})
assert specs == {"battery_mah": 5000, "display_inch": 6.1, "ram_gb": 8, "storage_gb": 1024,
"rear_camera_mp": 50, "network": "5G", "os": "Android", "refresh_hz": 120}
assert sources["battery_mah"].startswith("Battery Capacity")
def test_spec_out_of_range_dropped():
specs, _ = normalise_specs("mobiles", {"Battery Capacity": "50000 mAh", "RAM": "512 GB"})
assert specs == {}
def test_laptop_weight_in_grams():
specs, _ = normalise_specs("laptops", {"Weight": "1690 g"})
assert specs == {"weight_kg": 1.69}
@pytest.mark.parametrize("value,source,ok", [
("5000 mAh", "Battery: 5,000mAh Li-ion", True),
(5000, "Battery 5000 mAh", True),
("6000 mAh", "Battery 5000 mAh", False),
("Snapdragon 8 Gen 3", "Processor: Qualcomm Snapdragon 8 Gen 3 for Galaxy", True),
("Snapdragon 8 Gen 2", "Processor: Qualcomm Snapdragon 8 Gen 3 for Galaxy", False),
("AMOLED", "6.2-inch Dynamic AMOLED 2X display", True),
("OLED", "6.2-inch LCD display", False),
(None, "anything", False),
])
def test_value_in_source(value, source, ok):
assert value_in_source(value, source) is ok
def test_llm_values_not_in_source_are_discarded(monkeypatch):
"""The guard that stops a small model's guess becoming a stored fact."""
import app.electronics.normalise.llm_fill as llm_fill
monkeypatch.setattr(llm_fill, "ELEC_USE_LLM", True)
source = "Display: 6.2 inch Dynamic AMOLED 2X, 120Hz. Battery 4000 mAh. Processor: Exynos 2400"
def fake_generate(system, prompt):
return {
"battery_mah": "4000 mAh", # stated -> kept
"refresh_hz": "120Hz", # stated -> kept
"rear_camera_mp": "200 MP", # invented -> dropped
"processor": "Snapdragon 8 Gen 3", # invented -> dropped
"os": "Android", # not in the text -> dropped
}
specs, sources = fill_missing("mobiles", source,
["battery_mah", "refresh_hz", "rear_camera_mp", "processor", "os"],
generate=fake_generate)
assert specs == {"battery_mah": 4000, "refresh_hz": 120}
assert all(v.startswith("llm-extracted") for v in sources.values())
def test_llm_disabled_returns_nothing():
called = []
specs, _ = fill_missing("mobiles", "Battery 5000 mAh", ["battery_mah"],
generate=lambda *a: called.append(1) or {"battery_mah": "5000 mAh"})
assert specs == {} and called == [] # ELEC_USE_LLM=false in the test environment
# ---------------------------------------------------------------------------
# Laptops: real titles from the pilot run that previously failed to group.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("title,cpu", [
("HP 15 (2024) AMD Ryzen 3 Quad Core 7320U - (8 GB/512 GB SSD", "ryzen 3 7320u"),
("HP 15 AMD Ryzen R3 7320U Windows 11 Home Laptop, 15-fc0500AU ( Natural Silver, 8GB-512GB )", "ryzen 3 7320u"),
("HP 15s AMD Ryzen 5 Hexa Core 5500U - (16 GB/512 GB SSD", "ryzen 5 5500u"),
("HP 15 (2026), AMD Athlon Dual Core 7120U - (8 GB DDR5/512 GB", "athlon 7120u"),
("HP 15 Laptop, Intel Core 5-120U, 16GB DDR4 RAM, 512GB SSD", "core 5 120u"),
("Lenovo IdeaPad Slim 3 Intel Core i5 13th Gen 13420H - (16 GB/512 GB SSD", "i5-13420h"),
("Lenovo IdeaPad Slim 3 15IPH11 Intel Core Ultra 5 125H (16GB RAM/ 512GB SSD)", "core ultra 5 125h"),
])
def test_laptop_processors(title, cpu):
assert parse_title(title, "laptops").processor == cpu
def test_cpu_slash_is_not_a_ram_storage_pair():
p = parse_title("Lenovo IdeaPad Slim 3 15IPH11 Laptop (Intel Core Ultra 5/ 16GB RAM/ 512GB SSD/ 15.3 Inch)",
"laptops")
assert (p.ram_gb, p.storage_gb) == (Decimal(16), Decimal(512))
@pytest.mark.parametrize("category,key,titles", [
("laptops", "hp|laptops|15|ryzen 3 7320u|8|512", [
"HP 15 (2024) AMD Ryzen 3 Quad Core 7320U - (8 GB/512 GB SSD/Windows 11 Home)",
"HP 15 AMD Ryzen R3 7320U Windows 11 Home Laptop, 15-fc0500AU ( Natural Silver, 8GB-512GB )",
"Buy HP 15-FC0500AU AMD Ryzen 3 7320U 8GB RAM 512GB SSD Windows 11 Home Silver Laptop",
]),
("laptops", "lenovo|laptops|ideapad slim 3|i5-13420h|16|512", [
"Lenovo IdeaPad Slim 3 Intel Core i5 13th Gen 13420H - (16 GB/512 GB SSD/Windows 11 Home)",
"Lenovo IdeaPad Slim 3 15IRH10 Intel Core i5-13420H 16GB RAM 512GB SSD 15.3 inch",
]),
])
def test_laptop_configuration_keys(category, key, titles):
for title in titles:
assert variant_key(parse_title(title, category), category) == key, title
def test_laptop_mpn_is_the_fallback_key():
# No CPU model number stated: the part number still identifies it.
p = parse_title("Lenovo IdeaPad Slim 3 Intel Core i3 13th Gen Laptop, 82X700HMIN (Arctic Grey, 8GB-512GB)",
"laptops")
assert variant_key(p, "laptops") == "lenovo|laptops|mpn:82x700hmin"
def test_truncated_title_filled_from_snippet():
from app.electronics.normalise.title_parser import fill_from_context
p = parse_title("Lenovo IdeaPad Slim 5 Intel Core i7 13th Gen 13700H - (16 GB", "laptops")
assert variant_key(p, "laptops") is None
fill_from_context(p, "laptops", snippet="Lenovo IdeaPad Slim 5 Intel Core i7 13th Gen 13700H - (16 GB/512 GB SSD/"
"Windows 11 Home) 14 inch WUXGA OLED")
assert variant_key(p, "laptops") == "lenovo|laptops|ideapad slim 5|i7-13700h|16|512"
def test_ambiguous_snippet_is_not_used():
from app.electronics.normalise.title_parser import fill_from_context
p = parse_title("HP Pavilion Intel Core i5 12th Gen 1240P - (8 GB", "laptops")
fill_from_context(p, "laptops", snippet="Available in 8 GB/256 GB, 16 GB/512 GB and 16 GB/1 TB SSD")
assert p.storage_gb is None
def test_cpu_filled_from_spec_table():
from app.electronics.normalise.title_parser import fill_from_context
p = parse_title("Lenovo IdeaPad Slim 3 Laptop (13th Gen Intel Core i7/ 16GB RAM/ 512GB SSD)", "laptops")
fill_from_context(p, "laptops", spec_texts=("13th Gen Intel Core i7-13620H",))
assert p.processor == "i7-13620h"
def test_merged_search_titles_are_cut():
assert clean_result_title("HP 15 (2026), AMD Athlon Dual Core 7120U - (8 GB DDR5/512 GB ...HP 15 (2026), "
"AMD Ryzen 5 Hexa Core 7535U") == "HP 15 (2026), AMD Athlon Dual Core 7120U - (8 GB DDR5/512 GB"
def test_merged_snippet_never_supplies_a_cpu():
from app.electronics.normalise.title_parser import fill_from_context
p = parse_title("ASUS Vivobook 16, Snapdragon X, 16GB RAM, 512GB SSD, FHD+ 16", "laptops")
fill_from_context(p, "laptops", snippet="ASUS Vivobook 16 (2026),Intel Core Ultra 5 225H ... ASUS Vivobook 16, "
"Intel Core i5-13420H Processor")
assert p.processor != "core ultra 5 225h"
q = parse_title("HP 15 Laptop (16 GB", "laptops")
fill_from_context(q, "laptops", snippet="HP 15 Intel Core i5-1334U or AMD Ryzen 5 7530U variants")
assert q.processor is None # two CPUs named: ambiguous