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>
331 lines
16 KiB
Python
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
|