Spaces:
Sleeping
Sleeping
| """Universal structured-catalog query engine. | |
| A product catalog is a TABLE. "How many sharpeners", "cheapest gel pen", | |
| "products under 500", "do you sell backpacks", "list kuromi items" are all the | |
| SAME operation — filter rows, then aggregate — differing only in the aggregation | |
| and the wording of the reply. So instead of a separate hand-written answerer per | |
| question shape (the count/bounds/rank/absence pile this replaces), we parse the | |
| question into an orthogonal (filter, aggregation) spec and execute it | |
| deterministically against the product rows. New question shapes that combine the | |
| same dimensions (e.g. "cheapest in-stock gel pen between 100 and 200") work for | |
| free — no new code. | |
| Per-tenant vocabulary (what counts as an accessory/refill that should not inflate | |
| a count, e.g. "Stapler Pin", ink "Cartridge") stays in the DB config | |
| (`count_exclude_terms`), never hardcoded here — the engine is domain-agnostic. A | |
| term is only excluded when the user did not explicitly ask for it. | |
| """ | |
| from __future__ import annotations | |
| import difflib | |
| import json | |
| import re | |
| import time | |
| # Per-DB parsed-row cache. load_rows() does a FULL Chroma scan + parse on every | |
| # query, which serializes badly under concurrent multi-user load. Cache the parsed | |
| # rows keyed on (collection, row-count): a re-ingest changes the count → auto-refresh; | |
| # otherwise reuse for _ROW_CACHE_TTL. Rows are read-only downstream, so sharing is safe. | |
| _ROW_CACHE: dict = {} | |
| _ROW_CACHE_TTL = 300.0 | |
| def _row_cache_key(db, total: int): | |
| db_name = ( | |
| getattr(db, "_db_name", None) | |
| or getattr(db, "db_name", None) | |
| or getattr(db, "name", None) | |
| or getattr(getattr(db, "_client", None), "_db_name", None) | |
| ) | |
| collection = getattr(db, "_collection", None) | |
| collection_name = getattr(collection, "name", None) | |
| collection_id = getattr(collection, "id", None) or id(collection) | |
| return (str(db_name) if db_name else id(db), str(collection_name or ""), str(collection_id), total) | |
| from dataclasses import dataclass, field | |
| # Universal question scaffolding — these words are never product-category anchors. | |
| _STOP = { | |
| "how", "many", "number", "count", "of", "do", "does", "did", "you", "your", "yours", "we", | |
| "our", "ours", "they", "them", "the", "a", "an", "store", "shop", "site", "sell", "sells", | |
| "sold", "selling", "stock", "stocked", "carry", "carries", "carried", "have", "has", "had", | |
| "offer", "offers", "offered", "got", "keep", "keeps", "any", "some", "are", "is", "was", | |
| "were", "there", "here", "available", "availability", "in", "on", "at", "for", "with", "and", | |
| "or", "to", "us", "me", "my", "mine", "it", "its", "this", "that", "these", "those", "all", | |
| # possessive "their/theirs" — "list all the gaming toys and THEIR prices" must | |
| # anchor on "gaming toys", not leak "their" into the set and break the EXACT | |
| # collection-membership match ({gaming,toys,their} != {gaming,toys}). Deliberately | |
| # NOT his/her/hers — those are real product names ("Burberry Her") in other tenants. | |
| "their", "theirs", | |
| "products", "product", "items", "item", "thing", "things", "stuff", "one", "ones", | |
| "ok", "okay", "different", "types", "type", "kinds", "kind", | |
| "total", "catalog", "catalogue", "inventory", "range", "selection", "what", "whats", "which", | |
| "show", "list", "tell", "give", "display", "price", "prices", "priced", "pricing", "cost", | |
| "costs", "costing", "much", "cheap", "cheaper", "cheapest", "lowest", "least", "expensive", | |
| "priciest", "costliest", "dearest", "highest", "most", "affordable", "budget", "under", | |
| "below", "less", "than", "within", "max", "maximum", "minimum", "min", "over", "above", | |
| "more", "greater", "between", "up", "starting", "rs", "pkr", "rupees", "rupee", "dollar", | |
| "dollars", "currently", "entire", "whole", "absolute", "absolutely", "overall", "anything", | |
| "everything", | |
| # indefinite pronouns — a closed grammatical class, never a product anchor | |
| # ("show me SOMETHING between 600-800" must list the range, not search for a | |
| # product literally named "something"). anything/everything were here; the rest | |
| # of the class (something/nothing/someone/...) were the gap. | |
| "something", "somethin", "sumthin", "nothing", "someone", "anyone", "everyone", "none", | |
| "sale", "best", "top", "good", "quality", "options", "option", "can", "could", | |
| "would", "buy", "get", "from", "want", "need", "needed", "looking", "find", "please", "name", | |
| "actually", "right", "now", "among", "only", "ignoring", "ignore", "excluding", "exclude", "except", | |
| "out", "im", | |
| # price/value filler — never product-name anchors (else "what's the going RATE | |
| # for X" makes going/rate junk anchors that crowd out X's distinctive words) | |
| "going", "rate", "rates", "run", "runs", "damage", "charge", "charges", "charged", | |
| "worth", "value", "asking", "ask", "pay", "paying", "spend", "deal", "going-rate", | |
| # query scaffolding for category / name-substring / list filters — never anchors | |
| # ("most expensive in the Models CATEGORY", "products WHOSE name CONTAINS scale", | |
| # "include EVERY EXACT price"). Without these the engine matches rows against | |
| # 'category'/'contains'/'exact' and finds nothing → false "no such category". | |
| "category", "categories", "categorized", "categorised", "section", "department", | |
| # "collection"/"collections" is storefront-taxonomy scaffolding ("Bikes COLLECTION | |
| # cheapest model") — the collection NAME is the anchor, never the literal word, which | |
| # matches no title/tag and zeroes an all-anchor _hit. | |
| "collection", "collections", | |
| # availability-split scaffolding — "bike availability SUMMARY", "stock BREAKDOWN", | |
| # "available VS UNAVAILABLE" must anchor on the category, not these report words. | |
| "summary", "breakdown", "overview", "unavailable", "vs", "versus", "status", | |
| # list-verb inflections — "products LISTED in", "items LISTING under", "what LISTS | |
| # under X" must anchor on X, not keep "listed"/"listing" (which match no category | |
| # → false "no such category" and a fall-through to fuzzy RAG). "list" was already | |
| # a stop-word; its inflections were the gap that broke "products listed in <cat>". | |
| "listed", "listing", "lists", | |
| "whose", "contains", "contain", "containing", "include", "includes", "including", | |
| "included", "exact", "exactly", "every", "each", "named", "calling", "called", | |
| "answer", "yes", "no", # "...carry X? answer yes or no" — keep X, drop the rest | |
| # conversational greeting/filler — "hi, do you guys have the X" must anchor on X, | |
| # not echo "hi guys" into the product ("we carry hi guys wall climbing car"). | |
| "hi", "hey", "hello", "hiya", "guys", "oh", "um", "umm", "hmm", "thanks", "thank", | |
| # follow-up adverbs — "do you ALSO stock X", "have X TOO" must anchor on X, not | |
| # fold "also"/"too" into the name ("also wish women by chopard" → false absence, | |
| # which then breaks a pronoun follow-up that has no antecedent to bind). | |
| "also", "too", "aswell", "additionally", "again", "cool", "nice", "great", "wow", | |
| # stock/list filler — "which RC construction toys are still in stock" must | |
| # anchor on "rc construction", not "still/toys" which are presentation words. | |
| "still", | |
| # "toy/toys" intentionally remain anchors for bare category requests such as | |
| # "show toys"; _selective_anchors drops generic terms when stronger ones exist. | |
| # advisory persona words — express who-it's-for, never a product type, and never | |
| # appear in a real title ("BEGINNER ke liye cheap car", "for a NEWBIE"). (Purpose | |
| # words like "gift" are NOT globally stopped — they ARE real giftable-set titles in | |
| # other tenants; they're dropped conditionally in _selective_anchors instead.) | |
| "beginner", "beginners", "newbie", "liye", "lye", | |
| # texting abbreviations of stop-words — "do u HV mgahribi" must not keep "hv" as a | |
| # junk anchor (it dragged query-coverage below the floor and sank the real match). | |
| "hv", "hav", "ur", "pls", "plz", "thru", "abt", "coz", "cuz", "wanna", "gimme", "lemme", | |
| # availability / buy-intent / negation framing — these express the STOCK dimension | |
| # or question scaffolding, never a product NAME, yet they leaked as hard anchors: | |
| # "give UNAVAILABLE rc construction", "rc construction WHETHER available or sold | |
| # out", "F1 READY to buy", "figures that CANNOT BE BOUGHT" → an all-anchor _hit no | |
| # title satisfies → false abstain. Availability is already captured separately | |
| # (in_stock / out_of_stock), so the words that voice it must not also filter names. | |
| # ("sold"/"out"/"available"/"buy"/"order" were already stops; these were the gaps.) | |
| "unavailable", "availble", "avilable", "avalable", "availabe", "avaliable", | |
| "soldout", "outofstock", | |
| "buyable", "sellable", "purchasable", "orderable", "ready", | |
| "whether", "not", "cannot", "cant", "bought", "be", "ship", "ships", "shipped", | |
| # Roman-Urdu/Hinglish function & intent words — grammatical glue, never a product | |
| # name in any catalog, so they must never become anchors ("action figures MEIN AUR | |
| # DONO BATAO" must anchor on "action figures"). Universal language knowledge, not | |
| # per-store config. (English-ambiguous words like "do"/"me" are deliberately omitted.) | |
| "hai", "hain", "kya", "kia", "mein", "mai", "aur", "ya", "phir", "konsa", "kaunsa", | |
| "konsi", "kaunsi", "kaun", "kis", "sasta", "sasti", "mehnga", "mehngi", "mehnge", | |
| "zyada", "ziada", "chahiye", "chaiye", "chahye", "milega", "milegi", "mil", "jayega", | |
| "jaega", "jayegi", "hoga", "hogi", "sakta", "sakti", "sakte", "hun", "kitna", "kitni", | |
| "kitny", "kitne", "kitnay", "wala", "wali", "wale", "abhi", "karo", "batao", "bata", | |
| "bta", "dedo", "dikhao", "dikha", "dikhaiye", "acha", "achha", "theek", "yaar", "dono", | |
| "rakhte", "rakha", "nahi", "nai", "nhi", "ko", "ki", "ka", "ke", "se", | |
| # Roman-Urdu "including / combined with" connectives — "unavailable MILA KAR highest | |
| # price" = "highest price INCLUDING unavailable"; glue, never a product name. | |
| "mila", "kar", "milakar", "milakr", "samet", "sameth", "saath", "sath", | |
| # Roman-Urdu superlative/comparative & "even if" framing — "SABSE mehnga CHAHE | |
| # khatam HO CHUKA ho" (= priciest even if sold out); function words, never a name. | |
| "sabse", "sab", "chahe", "chahy", "chuka", "chuki", "ho", "counting", "even", | |
| # Roman-Urdu stock-state words — captured as in/out-of-stock signals elsewhere, | |
| # never product names ("kitne HAAZIR kitne GHAYAB", "KHATAM ho chuka"). | |
| "haazir", "hazir", "ghayab", "ghaib", "mojood", "maujood", "khatam", "khtm", | |
| } | |
| _NUM = r"(?:rs\.?\s*|pkr\s*|[\$£€])?\s*([\d][\d,]*(?:\.\d{1,2})?)" | |
| def _num(s: str): | |
| try: | |
| return float(str(s).replace(",", "")) | |
| except (TypeError, ValueError): | |
| return None | |
| class Row: | |
| title: str | |
| price: float | None | |
| availability: str | |
| source: str | |
| hay: str # lowercased title + categories + body head, for anchor matching | |
| currency: str = "Rs." # detected per-row; engine output stays currency-agnostic | |
| cats: str = "" # structured categories only (product_type + tags) for tag-precise counting | |
| colls: str = "" # "|name|name|" of EXACT storefront collections (sidebar groups) for membership listing | |
| colls_disp: str = "" # original-case collection titles ("A | B") for "which section is X in" answers | |
| original_price: float | None = None # compare-at / regular price when on sale (else None) | |
| class Spec: | |
| agg: str # count | exists | min | max | list | none | |
| anchors: list = field(default_factory=list) | |
| price_min: float | None = None | |
| price_max: float | None = None | |
| in_stock: bool = False | |
| structured: bool = True | |
| strict_min: bool = False # "strictly above/over X" → exclude p == X | |
| strict_max: bool = False # "strictly below/under X" → exclude p == X | |
| title_only: bool = False # "whose product NAME contains X" → match title, not body | |
| out_of_stock: bool = False | |
| include_oos: bool = False # "list X incl. unavailable / overall / available OR sold out" → no stock filter, show status, true extremes | |
| alt_groups: list = field(default_factory=list) # slash-joined synonyms ("motorcycle/bike") → match EITHER, not both | |
| lock_only: bool = False # anchors emptied to stop-words but raw query may verbatim-name a collection ("All Products"); answer ONLY via collection lock, else RAG | |
| price_intent: bool = False # query explicitly asked a price ("price of X", "how much"); if resolved rows carry NO price, defer to RAG/LLM instead of listing bare names | |
| # Universal ENGLISH category synonyms — a customer's everyday word for a product | |
| # TYPE vs the merchant's tag for the same thing. This is language knowledge, NOT a | |
| # per-store word list: it holds in any catalog, so a store that tags its products | |
| # "Bikes" still resolves a "motorcycle" query (and vice-versa). Bidirectional; each | |
| # entry is matched as a phrase so multi-word values work. Kept deliberately tiny and | |
| # only for genuinely universal type-synonyms validated against the live catalogs — | |
| # brand/model words are never synonyms and must not go here. | |
| _CAT_SYN: dict[str, set[str]] = { | |
| "motorcycle": {"bike"}, "motorbike": {"bike"}, "bike": {"motorcycle", "motorbike"}, | |
| } | |
| def _inflect(s: str) -> set[str]: | |
| """Singular<->plural forms of one term (the -y/-ies and -s/-es rules).""" | |
| vs = {s} | |
| if len(s) < 3: | |
| return vs | |
| if s.endswith("ies"): | |
| vs.add(s[:-3] + "y") # batteries -> battery | |
| elif s.endswith("y"): | |
| vs.add(s[:-1] + "ies") # battery -> batteries | |
| if s.endswith("es") and len(s) > 4: | |
| vs.add(s[:-2]) # boxes -> box | |
| if s.endswith("s"): | |
| vs.add(s[:-1]) # pens -> pen | |
| else: | |
| vs.add(s + "s") # pen -> pens | |
| vs.add(s + "es") # box -> boxes | |
| return vs | |
| def _variants(a: str) -> set[str]: | |
| """Singular<->plural so 'pens'~'pen' and 'battery'~'batteries', PLUS universal | |
| category synonyms ('motorcycle'~'bike') so a query word resolves against the | |
| merchant's actual tag. (rc is handled in _hit.)""" | |
| vs = _inflect(a) | |
| for syn in _CAT_SYN.get(a, ()): # 'motorcycle' -> 'bike(s)' | |
| vs |= _inflect(syn) | |
| return vs | |
| def _hit(row: Row, anchors: list[str], title_only: bool = False) -> bool: | |
| """ALL anchors must appear in the row (a 'gel pen' query needs both words). | |
| title_only restricts the match to the product title — a "whose name contains | |
| X" query must not match a product whose body/description merely mentions X | |
| (e.g. a swim ring whose description cross-sells a 'vest').""" | |
| if not anchors: | |
| return True | |
| h = row.title.lower() if title_only else row.hay | |
| for a in anchors: | |
| if a == "rc": | |
| if re.search(r"\brc\b", h) or "remote control" in h or ( | |
| re.search(r"\bremote\b", h) and re.search(r"\bcontrol", h)): | |
| continue | |
| return False | |
| if not any(re.search(rf"\b{re.escape(v)}\b", h) for v in _variants(a)): | |
| return False | |
| return True | |
| def _cats_hit(row: Row, anchors: list[str]) -> bool: | |
| """Like _hit but against the STRUCTURED categories field only (product_type + | |
| tags), never the title or body. A count of 'claws' should be the products | |
| tagged as claws — not every title that happens to contain the substring.""" | |
| c = row.cats | |
| if not anchors or not c: | |
| return False | |
| for a in anchors: | |
| if a == "rc": | |
| if re.search(r"\brc\b", c) or "remote control" in c: | |
| continue | |
| return False | |
| if not any(re.search(rf"\b{re.escape(v)}\b", c) for v in _variants(a)): | |
| return False | |
| return True | |
| def _anchor_set(anchors: list[str]) -> set[str]: | |
| s: set[str] = set() | |
| for a in anchors: | |
| s |= _variants(a) | |
| return s | |
| def _resolve_collection(anchors: list[str], rows: list[Row]) -> list[Row] | None: | |
| """UNIVERSAL category resolution — map a customer's category phrasing onto the | |
| store's OWN storefront collections (shipped at ingest from /collections.json), | |
| instead of a hand-maintained per-store synonym map. This is the durable answer to | |
| the synonym treadmill: the merchant's taxonomy + this catalog's token frequency | |
| resolve "f1 cars"->"formula one f1", "formula one"->"formula one f1", | |
| "rc constraction"(typo)->"rc construction", "bike models"->"bikes" with no code | |
| change per store, and new stores work automatically. | |
| Each collection is scored by how well it covers the query's SELECTIVE (low | |
| document-frequency) anchors: a collection-name token match (full weight), a | |
| typo-tolerant fuzzy name match (0.8), or a MAJORITY of the collection's members' | |
| titles carrying the token (0.5, e.g. "excavator" lives in titles, not the | |
| "rc construction" name). Selectivity weighting (inverse-DF) makes the rare anchor | |
| decide, so "f1 cars" follows "f1" (rare) not "cars" (generic). At least one | |
| *selective* anchor must be covered → a generic phrase ("cheapest car") never locks | |
| onto a random collection. Returns the best collection's members, or None.""" | |
| colls: dict[str, list[Row]] = {} | |
| for r in rows: | |
| for el in r.colls.split("|"): | |
| if el: | |
| colls.setdefault(el, []).append(r) | |
| if not colls: | |
| return None | |
| toks = [t for t in dict.fromkeys(anchors) if len(t) >= 2 and t not in _STOP] | |
| if not toks: | |
| return None | |
| # Per-token weight (inverse document-frequency) + selectivity, computed ONCE. | |
| tinfo = [] | |
| for t in toks: | |
| df = _anchor_df(t, rows) | |
| tinfo.append((t, _variants(t), 1.0 / max(df, 0.02), df <= _GENERIC_DF)) | |
| # Per-token member-title hit ratio, computed once per (token, collection) lazily. | |
| n_sel = sum(1 for _t, _tv, _w, _sel in tinfo if _sel) | |
| best: tuple[float, list[Row]] | None = None | |
| for cnm, mem in colls.items(): | |
| # Scaffolding words in a collection NAME ("Collars Harness AND Leashes") are | |
| # never what a customer means — and they fuzzy-collide with real words | |
| # ("land"~"and" = 0.857), letting a foreign-product query lock a random | |
| # collection. Anchors are already _STOP-filtered, so dropping them here | |
| # cannot lose a legitimate exact match. | |
| cwords = [w for w in re.sub(r"[^a-z0-9]+", " ", cnm.lower()).split() if w not in _STOP] | |
| cset = set(cwords) | |
| score = 0.0 | |
| cov_sel = 0 | |
| for t, tv, w, sel in tinfo: | |
| if tv & cset: # exact/plural token in collection name | |
| score += w | |
| cov_sel += sel | |
| elif any(difflib.SequenceMatcher(None, t, cw).ratio() >= 0.84 for cw in cwords): | |
| score += 0.8 * w # typo-tolerant ("constraction"~"construction") | |
| cov_sel += sel | |
| else: # token lives in MEMBER titles, not the name | |
| pat = re.compile(rf"\b{re.escape(t)}\b") | |
| hits = sum(1 for m in mem if pat.search(m.hay)) | |
| if hits / len(mem) >= 0.5: | |
| score += 0.5 * w | |
| cov_sel += sel | |
| # A lock must cover a MAJORITY of the selective anchors, not just one: a typo | |
| # query is fully covered ("rc constraction" 2/2) while a fully-named FOREIGN | |
| # product ("land rover defender 24" in a pet store) at best grazes one word — | |
| # locking on that single hit is how customers got collars for a Land Rover. | |
| if score > 0 and cov_sel and 2 * cov_sel >= n_sel and (best is None or score > best[0]): | |
| best = (score, mem) | |
| return best[1] if best else None | |
| # A token present in a large share of the catalog is a generic TYPE/category word | |
| # ("car" in a car store, "pen" in a stationery store) — it carries no selectivity, | |
| # so demanding it as a HARD anchor wrongly collapses an aggregation set (a "cheapest | |
| # RC drift car" then ranks only the lone product whose tags literally spell "car"). | |
| _GENERIC_DF = 0.15 | |
| def _anchor_df(tok: str, rows: list[Row]) -> float: | |
| """Document frequency of an anchor across the catalog (fraction of product rows | |
| whose hay contains the token or a plural/singular variant).""" | |
| if not rows: | |
| return 0.0 | |
| pats = [re.compile(rf"\b{re.escape(v)}\b") for v in _variants(tok)] | |
| n = sum(1 for r in rows if any(p.search(r.hay) for p in pats)) | |
| return n / len(rows) | |
| # Occasion + recipient-persona words: they name WHO/WHAT-FOR, never a product type. | |
| # Dropped from a multi-anchor match when a real product-type anchor remains, so | |
| # "gift car for kids" resolves on "car" (not a coincidental "gift"/"kids"-tagged row). | |
| _PURPOSE = {"gift", "gifts", "present", "presents", | |
| "kid", "kids", "child", "children", "toddler", "toddlers", "baby", "babies", | |
| "boy", "boys", "girl", "girls", "teen", "teens", | |
| "bacha", "bache", "bachay", "bachi", "bachon", "bache"} | |
| def _selective_anchors(anchors: list[str], rows: list[Row], agg: str | None = None) -> list[str]: | |
| """Drop generic catalog-wide type-nouns from the MATCHING anchor set so they no | |
| longer over-constrain count/min/max/list — but only when a more specific (low | |
| document-frequency) anchor remains, so a query that is genuinely about the whole | |
| type ("cheapest car", "how many models") keeps every anchor. 'rc' is a feature the | |
| engine already treats specially everywhere (never a generic type word), so it is | |
| never dropped. The display label still uses the full anchor list. Data-driven from | |
| THIS catalog's own distribution → no per-store word lists.""" | |
| if len(anchors) <= 1 or not rows: | |
| return anchors | |
| # Purpose words ("gift", "present") name the OCCASION, not a product type, yet some | |
| # stores tag random items "gift" → a 2-anchor strict hit collapses to that one | |
| # coincidental row (a Suzuki bike tagged "gift") instead of the asked-for category. | |
| # Drop them when a real (non-purpose) anchor remains ("decor gift" → "decor"); a lone | |
| # "gift" browse keeps it. Done here (not _STOP) so "Kuromi Gift Set" titles still match. | |
| _nonpurpose = [a for a in anchors if a not in _PURPOSE] | |
| if _nonpurpose and len(_nonpurpose) < len(anchors): | |
| anchors = _nonpurpose | |
| if len(anchors) <= 1: | |
| return anchors | |
| df = {a: _anchor_df(a, rows) for a in anchors if a != "rc"} | |
| # A token NO product contains (df == 0) can only zero-out an all-anchor match — a | |
| # descriptor the catalog doesn't use ("SUPERHERO action figures" where they're | |
| # tagged just "action figures"). For a browse LIST, drop it when a matching anchor | |
| # remains so the category still resolves instead of abstaining. NOT for count/exists | |
| # (a 0 is a meaningful precise answer) and NOT for min/max — there a stray unknown | |
| # word ("most COSTLY remote control car") is better left as a safe abstain→RAG than | |
| # silently broadened into a wrong extreme over an unintended subset. | |
| if agg in ("list", "stock"): | |
| # "stock" (status of a NAMED product) is included so a hard typo the corrector | |
| # can't fix ("hulk action FIGER stock?") drops out when a selective anchor | |
| # ("hulk") survives → we still report that product's availability instead of | |
| # abstaining. Like "list", never for count/exists/min/max (a 0 is meaningful). | |
| positive = [a for a in anchors if a == "rc" or df.get(a, 0.0) > 0.0] | |
| # Only drop the unknown when a genuinely SELECTIVE anchor (0 < df < generic) | |
| # survives, so "SUPERHERO action figures" resolves to the figures — but a query | |
| # of mostly unknown tokens (two typos: "rc CONSTRACTION AVAILBLE") doesn't strip | |
| # down to a lone generic ("rc") and dump the whole type; it stays unmatched → | |
| # graceful fuzzy/RAG instead of a confidently-wrong list. | |
| _surv_selective = any(0.0 < df.get(a, 0.0) < _GENERIC_DF for a in positive) | |
| # Majority rule (mirrors _resolve_collection's a78d0ef gate): the survivors | |
| # must be MOST of the query. A fully-named FOREIGN product ("Land Rover | |
| # Defender 1:24" at a skin store) is all zero-DF except one incidental | |
| # token ("24" ~ "24 patches") — dropping 3 of 4 anchors and listing the | |
| # residue hijacks an honest absent answer. Typo repair keeps majority | |
| # ("SUPERHERO action figures" 2/3, "hulk action FIGER" 2/3). | |
| if positive and len(positive) < len(anchors) and _surv_selective \ | |
| and 2 * len(positive) >= len(anchors): | |
| anchors = positive | |
| df = {a: v for a, v in df.items() if v > 0.0} | |
| if len(anchors) <= 1: | |
| return anchors | |
| selective = [a for a in df if df[a] < _GENERIC_DF] | |
| if not selective: | |
| return anchors | |
| return [a for a in anchors if a == "rc" or df.get(a, 0.0) < _GENERIC_DF] | |
| def _title_cover(title: str, anchor_set: set[str], anchor_toks: list[str] | None = None) -> float: | |
| """How much of THIS product's title the query names. Catches a query that adds | |
| descriptor words beyond the stored (canonical) title — e.g. asking for | |
| 'Pop N Play - Quick Push Pop Game' when the row title is just 'Pop N Play'. | |
| The strict all-anchor _hit fails there (extra words absent from the title); | |
| title-coverage resolves it by measuring the other direction. A title word also | |
| counts when it only fuzzy-matches a query word (typo tolerance: 'leanring' names | |
| 'learning'), so one misspelling can't sink an otherwise fully-named product.""" | |
| tt = [t for t in re.findall(r"[a-z0-9]+", (title or "").lower()) if len(t) > 1] | |
| if len(tt) < 2: | |
| return 0.0 | |
| return sum(1 for t in tt if t in anchor_set | |
| or (anchor_toks and _fuzzy_in(t, anchor_toks))) / len(tt) | |
| def _query_cover(title: str, anchor_toks: list[str] | None) -> float: | |
| """Reverse of _title_cover: fraction of the QUERY's anchor words this title names. | |
| High when the customer typed a SUBSET of a long SEO title — "Rasha For Women By | |
| Al" for stored "Rasha For Women By Al Rehab EDP" — where the title's extra words | |
| sink one-directional title-coverage below the floor. Fuzzy so a typo'd query word | |
| ("Rsaha") still counts. Anchors are already stop-stripped (distinctive tokens), | |
| so a sibling sharing only generic words can't reach the floor.""" | |
| aw = [t for t in (anchor_toks or []) if len(t) > 1] | |
| if not aw: | |
| return 0.0 | |
| tt = list({t for t in re.findall(r"[a-z0-9]+", (title or "").lower()) if len(t) > 1}) | |
| return sum(1 for a in aw if a in tt or _fuzzy_in(a, tt)) / len(aw) | |
| _ONES = {"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, | |
| "six": 6, "seven": 7, "eight": 8, "nine": 9} | |
| _TEENS = {"ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, | |
| "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19} | |
| _TENS = {"twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, | |
| "seventy": 70, "eighty": 80, "ninety": 90} | |
| def _normalize_numwords(ql: str) -> str: | |
| """Spelled-out numbers → digits so a model/scale typed in words resolves like its | |
| numeric form: 'm eight one twenty four' → 'm8 1 24' → matches 'BMW M8 1:24'. | |
| Universal language normalization (a tens+ones pair combines: 'twenty four'→24), | |
| then a single letter glued to a following digit forms a model code ('m 8'→'m8').""" | |
| toks = ql.split() | |
| out = [] | |
| i = 0 | |
| while i < len(toks): | |
| t = toks[i] | |
| if t in _TENS: | |
| v = _TENS[t] | |
| if i + 1 < len(toks) and toks[i + 1] in _ONES and _ONES[toks[i + 1]] != 0: | |
| v += _ONES[toks[i + 1]]; i += 1 | |
| out.append(str(v)) | |
| elif t in _TEENS: | |
| out.append(str(_TEENS[t])) | |
| elif t in _ONES: | |
| out.append(str(_ONES[t])) | |
| else: | |
| out.append(t) | |
| i += 1 | |
| s = " ".join(out) | |
| return re.sub(r"\b([a-z])\s+(\d)", r"\1\2", s) | |
| # Shipping / delivery / returns / refund / warranty / payment / order-status questions | |
| # are POLICY prose, not catalog lookups. Used both to make parse() decline AND to | |
| # short-circuit answer_catalog_query BEFORE the LLM router (which would otherwise | |
| # fabricate a product plan for "used opened PRODUCT return" and answer with a catalog | |
| # non-answer instead of handing to the policy/RAG path). "return" alone is excluded so | |
| # the catalog verb "return every available item under 500" is never read as policy. | |
| _POLICY_Q_RE = re.compile( | |
| r"\b(shipping|delivery|deliver(?:ed|ing)?|postage|courier|dispatch|" | |
| r"returns\b|return\s+polic(?:y|ies)|return\s+(?:an?\s+)?(?:item|product|order|purchase)s?\b|" | |
| r"(?:item|product|order|purchase)s?\s+(?:ko\s+|wapas\s+)?returns?\b|" | |
| r"returns?\s+(?:ho|kar|kr|kiya|kiye)\w*\b|wapas\s+(?:kar|ho)\w*|" | |
| r"refunds?|warranty|guarantee|" | |
| r"payment\s+method|cod|cash\s+on\s+delivery|installments?|" | |
| r"track(?:ing)?\s+(?:my\s+)?order|order\s+status|cancel(?:lation|\s+(?:my\s+)?order)?)\b", re.I) | |
| def parse(q: str) -> Spec: | |
| """Extract orthogonal dimensions (aggregation + price bounds + stock + anchors) | |
| from the question. Each dimension is detected independently, so any | |
| combination composes — that is what makes the engine universal.""" | |
| ql = (q or "").lower() | |
| # Normalize texting abbreviations BEFORE intent detection. These were only in | |
| # _STOP (anchor removal), so "do u hv X" kept the right anchors but the intent | |
| # regexes (which look for literal "have"/"you") never fired → the whole query | |
| # fell through to RAG. Expanding the verbs here routes SMS-speak ("do u hv", | |
| # "wat do u sell", "u got any") to the catalog engine like its spelled-out twin. | |
| ql = re.sub(r"\b(u|ur|hv|hav|pls|plz|wat|wats|abt|thru|coz|cuz|wanna|gimme|lemme|gud|gng)\b", | |
| lambda m: {"u": "you", "ur": "your", "hv": "have", "hav": "have", | |
| "pls": "please", "plz": "please", "wat": "what", "wats": "whats", | |
| "abt": "about", "thru": "through", "coz": "because", "cuz": "because", | |
| "wanna": "want to", "gimme": "give me", "lemme": "let me", | |
| "gud": "good", "gng": "going"}[m.group(0)], ql) | |
| # Spelled-out numbers → digits ("m eight one twenty four" → "m8 1 24") so a model | |
| # or scale typed in words matches its numeric title form. Done before anchoring. | |
| ql = _normalize_numwords(ql) | |
| # --- Split-count detection (report BOTH stock sides together) -------------------- | |
| # Customers ask for "the available vs sold-out counts" in unbounded ways ("stock | |
| # divided: total, purchasable, unavailable", "count split karo", "stock ratio with | |
| # counts", "total available sold count", "availability breakdown"). Enumerating each | |
| # phrasing is the recurring brittleness — detect the SEMANTIC class instead: | |
| # (a) two OPPOSITE stock states joined by a connector ("available aur sold out", | |
| # slash-joined "total/in-stock/OOS"), OR | |
| # (b) the two opposite states present together under an explicit COUNTING intent | |
| # (count/total/how many/kitne) — covers comma/adjacent phrasings with no | |
| # connector ("total available sold count"), OR | |
| # (c) a split/breakdown/ratio/divided/report word in a stock/count context. | |
| # Any → count_split, so the engine reports total + available + sold-out. The count | |
| # gate in (b) keeps a casual "available … sold" (no counting intent) from triggering, | |
| # and a "not sold out" double-negative (handled below) carries neither a connector | |
| # nor a count cue, so it stays an in-stock filter. | |
| _AVAIL = (r"\b(?:available|in[\s-]?stock|purchasable|buyable|sellable|live|" | |
| r"up\s+for\s+grabs|ready[\s-]?to[\s-]?ship|in[\s-]?hand|on[\s-]?hand|" | |
| r"mojood|maujood|haazir|hazir|mil\s+sakt\w*|milt\w*|mileg\w*)\b") | |
| _SOLD = (r"\b(?:sold[\s-]?out|sold|out\s+of\s+stock|unavailable|oos|gone|done|finished|" | |
| r"back[\s-]?order(?:ed|s)?|backorder\w*|out[\s-]?the[\s-]?door|" | |
| r"khatam|khtm|ghayab|ghaib)\b") | |
| _CONN = r"(?:\b(?:and|or|vs\.?|versus|aur|ya)\b|[&/])" | |
| _CNT = r"\b(?:count|counts|how\s+many|number\s+of|numbers?|tally|total|kitne|kitni|kitny|kitna)\b" | |
| _SPLITW = r"\b(?:split|divided|breakdown|break[\s-]?up|breakup|ratio|report)\b" | |
| _two_conn = bool(re.search(_AVAIL + r"[^.?!]{0,40}" + _CONN + r"[^.?!]{0,40}" + _SOLD, ql) | |
| or re.search(_SOLD + r"[^.?!]{0,40}" + _CONN + r"[^.?!]{0,40}" + _AVAIL, ql)) | |
| _two_adj = bool(re.search(_AVAIL + r"[^.?!]{0,40}" + _SOLD, ql) | |
| or re.search(_SOLD + r"[^.?!]{0,40}" + _AVAIL, ql)) | |
| _has_cnt = bool(re.search(_CNT, ql)) | |
| split_report = bool(re.search(_SPLITW, ql) and re.search( | |
| r"\b(?:stock|count|counts|availab\w*|collection|inventory|status|numbers?)\b", ql)) | |
| # "both sides"/"each side"/"dono side" idiom for the two stock states together. | |
| both_sides = bool(re.search(r"\b(?:both|each|dono)\s+sides?\b", ql) | |
| and re.search(r"\b(?:availab\w*|stock|count|sold)\b", ql)) | |
| # Roman-Urdu "kitne bik gaye, kitne bache" idiom = how many SOLD vs how many LEFT. | |
| # Requires BOTH the sold root (bik-) and the remaining root (bach-) so the bare | |
| # "bachon ke liye" (= for children) never reads as a stock split. | |
| _urdu_split = bool(re.search(r"\bbik\w*\b", ql) and re.search(r"\bbach\w*\b", ql)) | |
| # Roman-Urdu PARALLEL count "kitne X kitne Y" (= how many ... how many ...) — two | |
| # kitne/kitni clauses is itself a two-way count split ("kitne mil sakte kitne nahi" | |
| # = how many available vs how many not). Gated on a get/stock/availability verb so a | |
| # non-stock "kitne red kitne blue" doesn't fire. | |
| _kitne_split = bool(len(re.findall(r"\bkitn[aeiouy]\w*\b", ql)) >= 2 | |
| and re.search(r"\b(?:mil|milt\w*|mileg\w*|sakt\w*|stock|availab\w*" | |
| r"|bik\w*|bach\w*|nahi|nai|khatam|order\w*)\b", ql)) | |
| # "in/out" (or "in vs out") slash/connector dichotomy carrying a count cue — | |
| # "RC cars in/out tally", "stock in vs out count" — wants the both-sides split. | |
| _inout = bool(re.search(r"\bin\s*/\s*out\b|\bin\s+(?:vs\.?|versus|and|or)\s+out\b", ql) | |
| and re.search(r"\b(?:tally|count|counts|numbers?|stock|status|breakdown)\b", ql)) | |
| both_states = (_two_conn or (_has_cnt and _two_adj) or split_report or both_sides | |
| or _urdu_split or _inout or _kitne_split) | |
| # Comparisons ("compare A and B", "X vs Y") are NOT a single structured | |
| # aggregation — they need the retrieval fan-out (which matches each named | |
| # product in both name directions) + LLM synthesis. Hand them off; otherwise a | |
| # "compare the price of A and B" would be caught by the price-of list path and | |
| # return whichever product's title the query happens to fully name. A stock-state | |
| # split ("available vs unavailable") is NOT a product comparison → excluded. | |
| if (not both_states | |
| and re.search(r"\b(compare|compared|comparison|versus|vs\.?|difference\s+between)\b", ql) | |
| and re.search(r"\b(and|or|vs\.?|versus|than)\b", ql)): | |
| return Spec("none", structured=False) | |
| # Advisory / recommendation intent ("which toy is best for a 2 year old", | |
| # "recommend a gift for a toddler", "what's good for sensitive skin") is a | |
| # SUBJECTIVE judgement, not a table filter — let RAG/LLM answer. Without this | |
| # the structured path fires on incidental tokens ("2 year" matched a mic's | |
| # "2 Year warranty") and dumps an irrelevant product as a confident answer. | |
| # Excludes the structured superlatives (cheapest/most expensive) — those have | |
| # their own price words and never carry these advisory cues. | |
| if re.search( | |
| r"\b(recommend\w*|suggest\w*|advise|advice)\b" | |
| # "best/good/ideal … for <recipient/use>" — recipient list avoids catching | |
| # the structured "best PRICE for this pen" (→ stays a price lookup). | |
| r"|\b(?:best|good|ideal|suitable|appropriate|perfect|great|right)\s+(?:\w+\s+){0,2}?for\s+" | |
| r"(?:a\b|an\b|my\b|your\b|kids?|children|child|toddler|baby|babies|boys?|girls?|teens?|" | |
| r"men|women|him|her|sensitive|oily|dry|daily|everyday|beginners?|gift|travel|school|office)" | |
| r"|\bgift\s+for\b|\bfor\s+(?:a|an|my)\s+\d+[\s-]*(?:year|yr|month|mo)s?[\s-]*old\b" | |
| r"|\bwhich\s+(?:one\s+)?(?:should|would|do)\s+(?:i|you)\b", ql): | |
| return Spec("none", structured=False) | |
| # Shipping / delivery / returns / payment / order questions are POLICY prose, | |
| # not product-catalog lookups — but "how much is delivery" trips the price-of | |
| # detector and "shipping charges" trips the list path, dumping a random product. | |
| # Hand to normal RAG so it answers from the policy page (or says it doesn't have | |
| # the info) rather than returning an unrelated product. | |
| # NB: "return" alone is excluded — the catalog verb ("return every available | |
| # item under 500") must not read as a returns-policy question; only plural | |
| # "returns", "return policy", or "return an item/order" count as policy. | |
| if _POLICY_Q_RE.search(ql): | |
| return Spec("none", structured=False) | |
| # Variant questions — "what COLOURS/SIZES/OPTIONS does X come in", "what sizes are | |
| # available" — are answered from the product chunk's Options/Variants breakdown by | |
| # RAG. The structured aggregator would resolve the product and return a bare | |
| # "<name> - Rs.X - available" line without ever naming the colours/sizes. | |
| if (re.search(r"\b(colou?rs?|sizes?|shades?|variants?|versions?|options?)\b", ql) | |
| and re.search(r"\bcome\s+in\b|\bcomes\s+in\b|\bavailable\s+in\b|\boffered\s+in\b" | |
| r"|\bdoes\s+(?:it|this|that|the)\b|\bwhat\s+(?:colou?rs?|sizes?|variants?|options?)\b", ql)): | |
| return Spec("none", structured=False) | |
| # "which/what brands do you carry", "list the categories" = ENUMERATION of | |
| # brands/categories, not a product lookup — "do you carry" trips the existence | |
| # branch and would dump products as if they were brands. Hand to RAG so it | |
| # answers from the store's brands/category page. | |
| if re.search(r"\b(which|what|list|name|tell\s+me\s+(?:the|your))\b[\w\s]*\b" | |
| r"(brands?|designers?|makes?|labels?|categor(?:y|ies)|collections?)\b", ql): | |
| return Spec("none", structured=False) | |
| pmin = pmax = None | |
| m = re.search(r"between\s+" + _NUM + r"\s*(?:and|to|-|–)\s*" + _NUM, ql) | |
| if m: | |
| a, b = _num(m.group(1)), _num(m.group(2)) | |
| if a is not None and b is not None: | |
| pmin, pmax = min(a, b), max(a, b) | |
| if pmax is None: | |
| m = re.search(r"(?:under|below|less\s+than|within|max(?:imum)?|budget(?:\s+of)?|up\s+to|cheaper\s+than|no\s+more\s+than)\s+" + _NUM, ql) | |
| if m: | |
| pmax = _num(m.group(1)) | |
| if pmin is None: | |
| m = re.search(r"(?:over|above|more\s+than|greater\s+than|at\s+least|starting\s+(?:at|from)|priced\s+from)\s+" + _NUM, ql) | |
| if m: | |
| pmin = _num(m.group(1)) | |
| # OUT-OF-STOCK intent, including negated availability ("not available", "isn't in | |
| # stock", "can't be bought") — without these "which figures are NOT available" | |
| # read as an IN-stock filter and returned the available ones (inverted answer). | |
| out_of_stock = bool(re.search( | |
| r"\b(out[\s-]*of[\s-]*stock|sold[\s-]*out|unavailable|" | |
| r"not\s+(?:available|in\s+stock)|n'?t\s+(?:available|in\s+stock)|" | |
| r"no\s+longer\s+(?:available|in\s+stock|sold)|" | |
| r"can'?t\s+be\s+(?:bought|purchased|ordered)|cannot\s+be\s+(?:bought|purchased|ordered)|" | |
| r"can'?t\s+(?:buy|order|get)|cannot\s+(?:buy|order|get|be\s+bought))\b", ql)) | |
| # IN-STOCK intent, including buy-readiness phrasings ("ready to buy", "can I buy", | |
| # "buyable") that customers use instead of the literal "in stock". | |
| in_stock = bool(re.search( | |
| r"\b(in[\s-]?stock|in\s+stock|available|buyable|" | |
| r"ready\s+to\s+(?:buy|order|ship)|can\s+(?:i|we|you)\s+(?:\w+ly\s+)?(?:buy|order|get)|" | |
| # pronoun-BEFORE-can, allowing an adverb the customer drops in ("toys I can | |
| # ACTUALLY buy", "ones you can REALLY get") + bare "buy/order it now" | |
| r"(?:i|we|you)\s+can\s+(?:actually|really|even|still|just|also|now|finally|\w+ly\s+)?\s*(?:buy|order|get)|" | |
| r"(?:buy|order|purchase)\s+(?:it\s+)?(?:right\s+)?now|" | |
| r"still\s+(?:available|in\s+stock))\b", ql)) and not out_of_stock | |
| # Exclusion phrasing reverses the literal sold-out token: "ignoring sold-out | |
| # items" and "without unavailable products" mean available-only, not OOS-only. | |
| if re.search( | |
| r"\b(?:ignore|ignoring|exclude|excluding|except|without)\b" | |
| r"[^.?!]{0,24}\b(?:sold[\s-]?out|out[\s-]?of[\s-]?stock|unavailable|oos)\b", | |
| ql): | |
| out_of_stock = False | |
| in_stock = True | |
| # "include unavailable / overall / available OR sold out / whether … sold out" = | |
| # the customer wants the WHOLE set with its status, or the true extreme across all | |
| # stock — not an in/out filter. Overrides the stock flags so a "list X including | |
| # the sold-out ones" returns every X (with availability), and "highest X overall" | |
| # ranks past out-of-stock instead of stopping at the priciest in-stock item. | |
| include_oos = bool(re.search( | |
| r"\bincl(?:uding|ude|\.|udes)?\s+(?:the\s+)?(?:unavailable|sold[\s-]?out|out[\s-]?of[\s-]?stock|oos)" | |
| r"|\bregardless\s+of\s+(?:stock|availability)" | |
| r"|\b(?:available|in\s+stock)\b[^.?!]{0,24}\b(?:or|and|/|&|vs\.?)\b[^.?!]{0,24}\b(?:sold[\s-]?out|out\s+of\s+stock|unavailable)" | |
| r"|\bwhether\b[^.?!]{0,40}\b(?:available|in\s+stock|sold[\s-]?out)" | |
| # "full availability list", "stock status (list)", "availability breakdown/ | |
| # rundown/status" = report the WHOLE category with each item's status, not an | |
| # in/out filter (these read "available" yet want the sold-out ones shown too). | |
| # Roman-Urdu "including unavailable": "unavailable mila kar / samet" or | |
| # "mila kar … sold out" = rank/list ACROSS the sold-out ones, not filter to them. | |
| r"|\b(?:unavailable|sold[\s-]?out|out\s+of\s+stock|oos)\b[^.?!]{0,12}\b(?:bhi\s+)?(?:mila\s*kar|milakr|samet|sameth)\b" | |
| r"|\b(?:mila\s*kar|milakr|samet|sameth)\b[^.?!]{0,12}\b(?:unavailable|sold[\s-]?out|out\s+of\s+stock)\b" | |
| # "even if sold out", "chahe khatam/sold ho", "counting the sold ones too" = | |
| # rank ACROSS the sold-out ones. | |
| r"|\b(?:even\s+if|chahe)\b[^.?!]{0,20}\b(?:khatam|sold|out\s+of\s+stock|unavailable|bik\w*)\b" | |
| r"|\bcounting\b[^.?!]{0,20}\b(?:sold|unavailable|out\s+of\s+stock|gone)\b" | |
| r"|\b(?:sold\s+ones?|sold[\s-]?out\s+ones?)\b[^.?!]{0,8}\btoo\b" | |
| r"|\bfull\s+availabilit\w*" | |
| r"|\bstock\s+status\b" | |
| r"|\bavailabilit\w*\s+(?:list|status|breakdown|overview|rundown)\b" | |
| r"|\b(?:overall|of\s+all|in\s+total|in\s+the\s+(?:whole|entire)\s+(?:catalog|store))\b", ql)) | |
| if include_oos: | |
| out_of_stock = False | |
| in_stock = False | |
| # Double negative: "available … NOT sold out" / "not out of stock" is an IN-stock | |
| # filter, not OOS — but the out_of_stock regex above matched the bare "sold out" | |
| # inside "not sold out" and inverted the answer. Override to in-stock. | |
| if not both_states and not include_oos and re.search( | |
| r"\b(?:not|n'?t|never)\s+(?:sold[\s-]?out|out\s+of\s+stock)\b", ql): | |
| out_of_stock = False | |
| in_stock = True | |
| # both_states (computed up front) wants BOTH stock sides → don't filter to one. | |
| if both_states: | |
| out_of_stock = False | |
| in_stock = False | |
| include_oos = True | |
| # Slash-joined alternatives ("motorcycle/bike", "F1 / Formula One", "superhero/ | |
| # action") are SYNONYMS — the customer wants EITHER, but the all-anchor _hit would | |
| # demand BOTH and under-return (or abstain). Record the groups; execution keeps | |
| # only the catalog's actual term (highest doc-frequency) per group. | |
| alt_groups = [tuple(sorted({m.group(1), m.group(2)})) | |
| for m in re.finditer(r"\b([a-z]{2,})\s*/\s*([a-z]{2,})\b", ql)] | |
| # Exclusive bounds: "strictly below/under" and "more/greater than", "below", | |
| # "under", "above", "over" exclude the boundary value. Inclusive operators | |
| # ("up to", "no more than", "at most", "at least", "within", "max") keep it. | |
| _strict = bool(re.search(r"\bstrict(?:ly)?\b", ql)) | |
| strict_max = pmax is not None and (_strict or bool( | |
| re.search(r"\b(below|under|less\s+than|cheaper\s+than|fewer\s+than)\b", ql))) | |
| strict_min = pmin is not None and (_strict or bool( | |
| re.search(r"\b(above|over|more\s+than|greater\s+than)\b", ql))) | |
| count_q = bool(re.search(r"\b(how\s+many|number\s+of|count\s+of|count)\b", ql)) | |
| exist_q = bool(re.search( | |
| r"\bdo(?:es)?\s+(?:you|we|they|the\s+store)\b.*\b(?:sell|stock|carry|carries|have|has|offer|got|keep)\b" | |
| r"|\b(?:are|is)\s+there\s+(?:any|some)\b" | |
| r"|\bdo\s+(?:you|u)\s+(?:sell|stock|carry|have|offer|got)\b" | |
| r"|\b(?:have\s+(?:you|we)|(?:you|we)\s+have)\s+got\b" | |
| r"|\bgot\s+(?:any|some)\b", ql)) | |
| cheapest = bool(re.search( | |
| r"\b(cheap|cheapest|lowest[\s-]+priced?|least\s+expensive|most\s+affordable|lowest\s+cost|" | |
| r"costs?\s+the\s+least|" | |
| # Roman-Urdu/Hinglish "cheapest" — "sab se sasta", bare "sasta/sasti" | |
| r"sab\s+se\s+sast[aie]|sast[aie])\b", ql)) | |
| priciest = bool(re.search( | |
| r"\b(most\s+expensive|highest[\s-]+priced?|priciest|costliest|most\s+costly|dearest|" | |
| r"highest\s+cost|costs?\s+the\s+most|" | |
| # Roman-Urdu/Hinglish "most expensive" — "sab se mehnga", bare "mehnga/mehngi" | |
| r"sab\s+se\s+mehng[aie]|mehng[aie])\b", ql)) | |
| list_q = bool(re.search(r"\b(show|list|which|what'?s?|give|display|products?|items?|sell|have|available|unavailable|sold\s+out|out\s+of\s+stock)\b", ql)) | |
| # Single-product price/availability lookup ("price of X", "how much is X", | |
| # "is X available"). Routed to the list path so the full-catalog scan (with the | |
| # title-coverage fallback) resolves the EXACT named product instead of the | |
| # similarity top-N returning same-prefix siblings. Bare price queries with no | |
| # product fall through the no-anchor guard below to ordinary RAG. | |
| priceof_q = bool(re.search(r"\b(price|prices|priced|pricing|cost|costs|costing|how\s+much|rate|rates)\b", ql)) | |
| # Stock-STATUS question about a named product ("is X in stock?", "is X available?", | |
| # "do you have X in stock?"). Distinct from the in_stock FILTER ("show in-stock X"): | |
| # the answer must REPORT the product's availability, not silently drop it when OOS. | |
| stock_status_q = bool(re.search( | |
| r"\b(?:is|are|r)\b[^?]*\b(?:in[\s-]?stock|in\s+stock|avail\w*|sold\s+out|out\s+of\s+stock)\b" | |
| r"|\b(?:do|does)\s+(?:you|we|they)\b[^?]*\bin\s+stock\b" | |
| r"|\bavailabilit\w*\b" | |
| r"|\bstock\s+status\b" # "<product> stock status", "what's the stock status of X" | |
| r"|\bavail\w*\s*\?\s*$" # bare "<product> availble?" — a typo'd availability ask | |
| r"|\bstock\s*\?\s*$" # bare "<product> stock?" — elliptical "in stock?" ask | |
| # "can I order/buy/get <named product>?" presupposes the product exists and asks | |
| # whether it's purchasable → report its availability (incl. OOS), never abstain | |
| # because an in-stock filter dropped the sold-out row. | |
| r"|\bcan\s+(?:i|we|you)\s+(?:still\s+)?(?:order|buy|get|purchase)\b" | |
| r"|\bstill\s+(?:available|in\s+stock)\b", ql)) | |
| if count_q: | |
| agg = "count" | |
| elif cheapest: | |
| agg = "min" | |
| elif priciest: | |
| agg = "max" | |
| elif pmax is not None or pmin is not None: | |
| # A price bound makes it a bounds/list query even when phrased "do you | |
| # have any X under 50" — the incidental "do you have" must not route it to | |
| # the (subject-less) existence branch. | |
| agg = "list" | |
| elif stock_status_q: | |
| # Resolve the named product and report its availability (handles OOS too). | |
| agg = "stock" | |
| elif exist_q: | |
| agg = "exists" | |
| elif list_q or out_of_stock or in_stock: | |
| # A bare stock filter with no list verb ("action figures customers cannot | |
| # buy", "available BMWs") is still a filtered-list request — without this it | |
| # fell through to RAG and dumped the whole catalog. Single-product stock | |
| # lookups ("is X available") are caught earlier by stock_status_q, so this | |
| # only fires on category/filter phrasings. | |
| agg = "list" | |
| elif priceof_q: | |
| agg = "list" | |
| elif both_states: | |
| # A split request with no list/count verb ("Action figures stock split | |
| # summary?") still wants the both-sides count — route it here rather than | |
| # falling through to RAG (which dumped a one-sided list). | |
| agg = "count_split" | |
| else: | |
| return Spec("none", structured=False) | |
| # A category query naming both stock states → split count (overrides the bare | |
| # count/list/stock agg the chain picked). Needs a category anchor (below). | |
| if both_states and agg in ("count", "list", "stock"): | |
| agg = "count_split" | |
| # Anchors include digit tokens (a model/size/style number disambiguates a | |
| # specific SKU — "Spiral Notebook Style 43" must not match every style, "Paint | |
| # 500ml" / "Set of 48" pin the variant). Drop numbers already consumed as price | |
| # bounds so "products under 500" doesn't anchor on 500. | |
| _price_tokens = set() | |
| for _v in (pmin, pmax): | |
| if _v is not None: | |
| _price_tokens.add(f"{_v:.0f}") | |
| _price_tokens.add(str(_v)) | |
| # Thousands-separated prices ("Rs.3,025") tokenize to a digit fragment | |
| # ("025") that survived the bare integer filter and leaked as an anchor. | |
| for _part in f"{_v:,.0f}".split(","): | |
| _price_tokens.add(_part) | |
| # A quoted span is the reliable discriminator: the user (and the hard quiz) | |
| # quote the exact category / name-substring being asked about ("Ball Point" | |
| # category, name contains "fresh"). Anchor on the quoted content so question | |
| # scaffolding ("Using the full current catalog, count all items in …") can | |
| # never leak into the filter — the stop-word treadmill never catches every | |
| # phrasing ("using"/"full"/"current"/"across"/"enumerate"/…). Inside explicit | |
| # quotes we trust the user and skip _STOP (drop only price-bound fragments). | |
| # No quotes → fall back to stop-word subtraction over the whole question. | |
| _quoted = re.findall(r'["“”]([^"“”]{1,80})["“”]', q or "") | |
| if _quoted: | |
| _qtext = " ".join(_quoted).lower() | |
| anchors = [w for w in re.findall(r"[a-z0-9]{2,}", _qtext) | |
| if w not in _price_tokens][:8] | |
| else: | |
| anchors = [w for w in re.findall(r"[a-z0-9]{2,}", ql) | |
| if w not in _STOP and w not in _price_tokens][:8] | |
| # In a split count the only anchor is the CATEGORY; the stock-state/report words | |
| # that voice the split ("split", "stock", "available", Roman-Urdu "aur"/"mein"/ | |
| # "batao") must not survive as product anchors or they zero the all-anchor _hit. | |
| if agg == "count_split": | |
| _split_report_words = {"split", "stock", "available", "availability", "unavailable", | |
| "summary", "breakdown", "overview", "status", "aur", "ya", | |
| "mein", "main", "batao", "bata", "btao", "bta", "dono", "vs", "versus", | |
| # breakdown/count vocabulary the semantic detector now admits — | |
| # strip so only the CATEGORY anchor survives for membership lookup. | |
| "ratio", "count", "counts", "report", "divided", "numbers", "number", | |
| "form", "purchasable", "sold", "soldout", "oos", "total", "live", | |
| "kitne", "kitni", "kitny", "kitna", "karo", "chahiye", "do", | |
| # broadened stock-state / count synonyms + Roman-Urdu fillers — | |
| # strip so only the CATEGORY anchor survives membership lookup. | |
| "gone", "sellable", "tally", "buyable", "style", "in", "out", | |
| "frame", "frames", "item", "items", "piece", "pieces", | |
| "bik", "bika", "bikay", "gaye", "gaya", "bache", "bachay", "bacha", | |
| "kaise", "kaisa", "kaisi", "hua", "hui", "bata", "btao", "bata", | |
| # Roman-Urdu stock-state synonyms now admitted to the detector | |
| "haazir", "hazir", "ghayab", "ghaib", "mojood", "maujood", | |
| "khatam", "khtm", "finish", "finished", "headcount", "ready", | |
| # english-creative + Roman-Urdu availability idioms the detector | |
| # now admits — strip every token so only the CATEGORY survives: | |
| # "up for grabs", "ready to ship", "in/on hand", "back-ordered", | |
| # "out the door", "done", "dead", "sellable headcount", inventory, | |
| # "mil sakte / milega", "nahi". | |
| "up", "for", "grabs", "ship", "hand", "on", "to", "the", "door", | |
| "back", "order", "ordered", "done", "dead", "inventory", "breakup", | |
| "mil", "milt", "milta", "milte", "milti", "mileg", "milega", | |
| "milegi", "milenge", "sakta", "sakte", "sakti", "nahi", "nai"} | |
| # "hand" is report noise only inside stock idioms such as "in-hand" or | |
| # "on hand". It is a real category token in storefront names like | |
| # "Hand Bags", so never strip it merely because the query is a split count. | |
| if not re.search(r"\b(?:in|on)[\s-]+hand\b", ql): | |
| _split_report_words.discard("hand") | |
| anchors = [a for a in anchors if a not in _split_report_words] | |
| # A bare "list / what products do you sell" with no category and no price | |
| # bound is an open-ended browse — leave that to normal RAG, don't dump a | |
| # price-sorted slice of the whole catalog. EXCEPT: anchors can empty out on a | |
| # stop-word-only collection name ("All Products stock breakdown") while the | |
| # raw query still verbatim-names a storefront collection. parse can't see the | |
| # catalog, so stay structured with lock_only=True — _execute_spec answers ONLY | |
| # if its verbatim-collection lock fires, else returns None (→ RAG, unchanged). | |
| if agg == "list" and not anchors and pmin is None and pmax is None: | |
| return Spec("list", [], in_stock=in_stock, out_of_stock=out_of_stock, | |
| include_oos=include_oos, lock_only=True) | |
| # Likewise a bare existence question with no subject is not answerable here. | |
| if agg == "exists" and not anchors: | |
| return Spec("none", structured=False) | |
| # A split count needs a category to split — without one it's an open browse → RAG. | |
| if agg == "count_split" and not anchors: | |
| return Spec("count_split", [], in_stock=in_stock, out_of_stock=out_of_stock, | |
| include_oos=include_oos, lock_only=True) | |
| # A stock-status question needs a named product; "what's available" with no | |
| # subject is an open browse → RAG. The status report must NOT filter by stock | |
| # (it has to surface an OOS product to report it), so clear the in_stock filter. | |
| if agg == "stock": | |
| if not anchors: | |
| # Same stop-word-collection escape as list/count_split above: a "show | |
| # …in stock" phrasing whose subject normalized away ("All Products") | |
| # may still verbatim-name a collection — try the lock as a list. | |
| if re.search(r"\b(which|what'?s?|list|show|give|display)\b", ql): | |
| return Spec("list", [], in_stock=not (out_of_stock or include_oos), | |
| out_of_stock=out_of_stock, include_oos=include_oos, lock_only=True) | |
| return Spec("none", structured=False) | |
| if re.search(r"\b(which|what'?s?|list|show|give|display)\b", ql): | |
| agg = "list" | |
| # "list X" defaults to in-stock — UNLESS the customer asked for the whole | |
| # set with its status ("…whether available or sold out"), where include_oos | |
| # must keep both states. Don't re-impose the filter it just cleared. | |
| if not out_of_stock and not include_oos: | |
| in_stock = True | |
| else: | |
| in_stock = False | |
| # "whose product name contains/includes X", "named X", "titled X" → the user | |
| # is FILTERING on the title, so don't let a body mention of X pull in an | |
| # unrelated product. Must require a filter verb after "name": the bare phrase | |
| # "include each complete product name and exact price" is an OUTPUT instruction | |
| # (it appears in every list template, including category queries) and must NOT | |
| # flip a category query to title matching. (Plain category queries keep body | |
| # matching + category-tag precision.) | |
| title_only = bool(re.search( | |
| r"\b(?:whose\s+)?(?:product\s+)?name\s+(?:contain\w*|includ\w*|has\b|having\b|with\b|matching\b|" | |
| r"that\s+(?:contain\w*|includ\w*|has\b))" | |
| r"|\bnamed\b|\btitled?\b|\bcalled\b", ql)) | |
| return Spec(agg, anchors, pmin, pmax, in_stock, | |
| strict_min=strict_min, strict_max=strict_max, title_only=title_only, | |
| out_of_stock=out_of_stock, include_oos=include_oos, alt_groups=alt_groups, | |
| price_intent=priceof_q) | |
| def load_rows(db, limit: int = 12000) -> list[Row]: | |
| """One full scan of the product rows — deduped by normalized title. Prices come | |
| from structured metadata or a labeled 'Price:' line only (never a body-text | |
| scan, which manufactures phantom prices from model numbers). Result is cached | |
| per-DB keyed on row count (auto-invalidates after a re-ingest).""" | |
| try: | |
| total = min(int(db._collection.count() or 0), limit) | |
| except Exception: | |
| total = -1 | |
| _ck = _row_cache_key(db, total) | |
| _now = time.time() | |
| _hit = _ROW_CACHE.get(_ck) | |
| if _hit is not None and _hit[1] == total and (_now - _hit[2]) < _ROW_CACHE_TTL: | |
| return _hit[0] | |
| try: | |
| raw = db._collection.get(limit=max(total, 0), include=["documents", "metadatas"]) | |
| except Exception: | |
| return [] | |
| rows: list[Row] = [] | |
| seen: dict[str, Row] = {} | |
| for doc, meta in zip(raw.get("documents", []) or [], raw.get("metadatas", []) or []): | |
| meta = meta or {} | |
| doc = str(doc or "") | |
| if not doc: | |
| continue | |
| src = str(meta.get("source") or meta.get("source_canonical") or "") | |
| src_l = src.lower() | |
| kind = str(meta.get("chunk_kind") or "").lower() | |
| ctype = str(meta.get("content_type") or "").lower() | |
| # Structured-shape signal: a positive `price` meta + a product title carrier | |
| # (name/title meta or a "Product:" line). Catches per-product chunks tagged | |
| # with a category/listing URL and no chunk_kind (webscraper.io-style ingests) | |
| # that the URL/kind tests miss. Listing/nav chunks lack a single price meta. | |
| try: | |
| _has_price_meta = meta.get("price") is not None and float(meta.get("price")) > 0 | |
| except (TypeError, ValueError): | |
| _has_price_meta = False | |
| _has_title_meta = any(str(meta.get(k) or "").strip() for k in ("name", "product_title", "canonical_product_title")) | |
| _has_prod_line = bool(re.search(r"(?im)^product:\s*\S", doc)) | |
| is_prod = ( | |
| bool(re.search(r"/(?:products?|items?)(?:/|$|#)", src_l)) | |
| or (kind == "product" and ctype == "product" and "/collections/" not in src_l) | |
| or (_has_price_meta and (_has_title_meta or _has_prod_line) and "/collections/" not in src_l) | |
| ) | |
| if not is_prod: | |
| continue | |
| title = "" | |
| # `name` (webscraper.io-style) and a leading "Product:" doc line are the | |
| # universal title carriers when a crawl/ingest didn't set *_product_title. | |
| for k in ("canonical_product_title", "product_title", "page_title", "name"): | |
| t = str(meta.get(k) or "").strip() | |
| if t: | |
| title = re.sub(r"\s+", " ", t).strip(" -:|") | |
| break | |
| if not title: | |
| m = re.search(r"(?im)^product:\s*([^\n]{3,120})", doc) | |
| if m: | |
| title = re.sub(r"\s+", " ", m.group(1)).strip(" -:|") | |
| if not title or len(title) < 4: | |
| continue | |
| tl = title.lower() | |
| # Junk-title guards apply only to chunks WITHOUT a /products/ handle: a | |
| # product URL is stronger evidence than the title's shape (candlecompany | |
| # sells candles literally named "Home", "Motia", "Bakhoor", "Mulberry", | |
| # "Memories" — one-word titles the shape check read as nav junk, making | |
| # 8 of 78 products invisible to counts/lists/min). | |
| if "/products/" not in src_l: | |
| if tl in {"fun", "home", "shop", "catalog", "catalogue", "products", "collection", "page"}: | |
| continue | |
| if len(re.findall(r"[a-zA-Z0-9]+", title)) <= 1 and not re.search(r"\d", title): | |
| continue | |
| key = re.sub(r"[^a-z0-9]+", " ", tl).strip() | |
| # DISTINCT products can share a title (theduskstore has two "Gift Bag" | |
| # products, Rs.1400 and Rs.1650) — title-only dedup silently dropped one, | |
| # so "cheapest gift" answered with the wrong (pricier) product. Scope the | |
| # key by the product handle from the source URL: the SAME product's | |
| # crawl-chunk + ingest-chunk share a handle (the intended merge still | |
| # happens); different products have different handles and both survive. | |
| _hm = re.search(r"/products/([^/?#]+)", src) | |
| if _hm: | |
| key = f"{key}#{_hm.group(1)}" | |
| if not key: | |
| continue | |
| price = None | |
| try: | |
| mv = meta.get("price") | |
| if mv is not None and float(mv) > 0: | |
| price = float(mv) | |
| except (TypeError, ValueError): | |
| pass | |
| if price is None: | |
| m = re.search(r"(?im)^price:\s*(?:rs\.?|pkr|[\$£€])?\s*([\d,]+(?:\.\d{1,2})?)", doc) | |
| if m: | |
| v = _num(m.group(1)) | |
| price = v if (v and v > 0) else None | |
| # Original (compare-at) price when the product is on sale — from structured | |
| # metadata, else the "(originally Rs.X…)" the ingest renders into the chunk. | |
| orig_p = None | |
| try: | |
| ov = meta.get("original_price") | |
| if ov is not None and float(ov) > 0: | |
| orig_p = float(ov) | |
| except (TypeError, ValueError): | |
| orig_p = None | |
| if orig_p is None: | |
| mo = re.search(r"(?i)originally\s+(?:rs\.?|pkr|[\$£€])?\s*([\d,]+(?:\.\d{1,2})?)", doc) | |
| if mo: | |
| v = _num(mo.group(1)) | |
| orig_p = v if (v and v > 0) else None | |
| if orig_p is not None and price is not None and orig_p <= price: | |
| orig_p = None | |
| # Currency follows the data, not a hardcoded "Rs." — read the symbol off the | |
| # labeled Price: line (then any body symbol) so $/£/€ stores render correctly. | |
| cur = "Rs." | |
| cm = re.search(r"(?im)^price:\s*(Rs\.?|PKR|[\$£€])", doc) or re.search(r"([\$£€])\s*[\d]", doc) | |
| if cm: | |
| sym = cm.group(1) | |
| cur = sym if sym in ("$", "£", "€") else "Rs." | |
| avail = str(meta.get("availability") or "").strip().lower() | |
| if not avail: | |
| # Prefer the chunk's structured schema.org marker ("Avail: InStock/ | |
| # OutOfStock") over a loose body scan, which false-flags a product OOS | |
| # on any stray "out of stock" (variant badge / related-product card). | |
| am = re.search(r"(?i)\bavail(?:ability)?:\s*(\S+)", doc) | |
| if am: | |
| tok = am.group(1).lower() | |
| if re.search(r"outofstock|out[_\s]?of[_\s]?stock|soldout|sold[_\s]?out|discontinued|backorder", tok): | |
| avail = "out of stock" | |
| elif re.search(r"instock|in[_\s]?stock|preorder|pre[_\s-]?order|available", tok): | |
| avail = "available" | |
| if not avail: | |
| avail = "out of stock" if re.search(r"(?i)\b(out of stock|sold out|currently unavailable)\b", doc) else "available" | |
| # Membership matches title + curated categories only — NOT the body | |
| # description. A prose mention ("pairs well with our sharpener") must not | |
| # count an unrelated product into the "sharpener" category. | |
| cats_l = str(meta.get("categories") or "").lower() | |
| # Exact storefront-collection membership, normalized to "|name|name|" so a | |
| # category query can test `|<phrase>|` for precise membership — collection | |
| # names often collide with title/tag words ("Scissors", "Fujifilm"). | |
| _colls_raw = str(meta.get("collections") or "") | |
| colls_l = "" | |
| _coll_parts = [] | |
| if _colls_raw: | |
| if _colls_raw.lstrip().startswith("["): | |
| try: | |
| parsed_collections = json.loads(_colls_raw) | |
| if isinstance(parsed_collections, list): | |
| _coll_parts = [str(part).strip() for part in parsed_collections if str(part).strip()] | |
| except Exception: | |
| _coll_parts = [] | |
| if not _coll_parts: | |
| _coll_parts = [part.strip() for part in _colls_raw.split(" | ") if part.strip()] | |
| _parts = [re.sub(r"[^a-z0-9]+", " ", p.lower()).strip() for p in _coll_parts] | |
| _parts = [p for p in _parts if p] | |
| if _parts: | |
| colls_l = "|" + "|".join(_parts) + "|" | |
| hay = " ".join([tl, cats_l]) | |
| _colls_disp = " | ".join(_coll_parts) | |
| row = Row(title, price, avail, src, hay, cur, cats_l, colls_l, _colls_disp, orig_p) | |
| # Dedup by normalized title, but PREFER the priced chunk: a catalog that was | |
| # both crawled and catalog-ingested has two chunks per product (a crawl chunk | |
| # often without a structured price + a priced ingest chunk). Keeping whichever | |
| # came first dropped the price for products whose unpriced chunk sorted first | |
| # (then compare/basket couldn't use them). Upgrade in place when a later | |
| # duplicate carries a price the kept one lacks; also fill missing categories. | |
| prev = seen.get(key) | |
| if prev is None: | |
| seen[key] = row | |
| rows.append(row) | |
| elif prev.price is None and price is not None: | |
| prev.price = price | |
| prev.currency = cur | |
| if prev.original_price is None and orig_p is not None: | |
| prev.original_price = orig_p | |
| if avail and not re.search(r"out\s+of\s+stock|sold\s+out", prev.availability): | |
| prev.availability = avail | |
| if not prev.cats and cats_l: | |
| prev.cats = cats_l | |
| prev.hay = " ".join([prev.title.lower(), cats_l]) | |
| if not prev.colls and colls_l: | |
| prev.colls = colls_l | |
| prev.colls_disp = _colls_disp | |
| _ROW_CACHE[_ck] = (rows, total, _now) | |
| return rows | |
| def _excl_re(cfg: dict | None, anchors: list[str]): | |
| aset = set(anchors) | {a.rstrip("s") for a in anchors} | |
| terms = [str(t).lower().strip() for t in ((cfg or {}).get("count_exclude_terms") or []) | |
| if str(t).lower().strip() and str(t).lower().strip().rstrip("s") not in aset] | |
| if not terms: | |
| return None | |
| return re.compile(r"\b(?:" + "|".join(re.escape(t) for t in terms) + r")\b", re.I) | |
| def _dedup(xs): | |
| return list(dict.fromkeys(x for x in xs if x)) | |
| _QUOTED_RE = re.compile(r'["“”]([^"“”]{4,80})["“”]') | |
| def _extract_quoted(q: str) -> str | None: | |
| """Pull a quoted span out of the query — customers (and this eval) often | |
| paste/quote the exact product title verbatim: 'what is the price of "X"?'.""" | |
| m = _QUOTED_RE.search(q or "") | |
| return m.group(1).strip() if m else None | |
| def _price_s(p: float, cur: str = "Rs.") -> str: | |
| body = f"{p:,.0f}" if float(p).is_integer() else f"{p:,.2f}" | |
| return f"{cur}{body}" | |
| def _price_disp(r: "Row") -> str: | |
| """Price for display — appends the struck-through original when on sale so a | |
| structured price/list answer also surfaces the discount ("Rs.3,284 (was Rs.5,120)").""" | |
| if r.price is None: | |
| return "" | |
| s = _price_s(r.price, r.currency) | |
| if r.original_price is not None and r.original_price > r.price: | |
| s += f" (was {_price_s(r.original_price, r.currency)})" | |
| return s | |
| # DB-type gate thresholds — a product catalog is a corpus where product rows are a | |
| # MEANINGFUL SHARE of the chunks, not a handful of accidental matches inside a docs | |
| # corpus. A docs/RAG DB (e.g. agentfactory: 7.5k doc chunks) must never trip the | |
| # catalog engine just because a lesson contains a "$9" example or a chapter-ingest | |
| # mislabeled sections as products. | |
| _MIN_CATALOG_ROWS = 5 # absolute floor of distinct product rows | |
| _MIN_CATALOG_FRAC = 0.10 # product rows must be >=10% of all chunks | |
| _DOCS_DBTYPES = {"docs", "documentation", "document", "text", "knowledge", "kb", | |
| "rag", "article", "articles", "blog", "wiki", "manual", "guide"} | |
| _PRODUCT_DBTYPES = {"product", "products", "catalog", "catalogue", "store", "shop", | |
| "ecommerce", "e-commerce", "retail", "inventory"} | |
| def is_catalog_db(db, rows: list, cfg: dict | None = None) -> bool: | |
| """Is this DB a product catalog (a TABLE of priced items) or a text/docs corpus? | |
| The catalog engine only owns the former. Order of authority: explicit config | |
| flag → docs/product type keyword → docs_path_hints (a docs-site marker) → | |
| corpus-share heuristic. Returning False sends the question to ordinary RAG/LLM. | |
| """ | |
| cfg = cfg or {} | |
| flag = cfg.get("is_product_db") | |
| if flag is None: | |
| flag = cfg.get("product_db") | |
| if flag is True: | |
| return True | |
| if flag is False: | |
| return False | |
| db_type = str(cfg.get("db_type") or cfg.get("mode") or cfg.get("catalog_mode") or "").lower().strip() | |
| if db_type in _DOCS_DBTYPES: | |
| return False | |
| if db_type in _PRODUCT_DBTYPES: | |
| return True | |
| # A configured docs-site (per-language doc paths) is a documentation corpus. | |
| if cfg.get("docs_path_hints"): | |
| return False | |
| # Heuristic: product rows must be both numerous AND a real share of the corpus. | |
| n = len(rows) | |
| if n < _MIN_CATALOG_ROWS: | |
| return False | |
| try: | |
| total = int(db._collection.count() or 0) | |
| except Exception: | |
| total = 0 | |
| if total > 0 and (n / total) < _MIN_CATALOG_FRAC: | |
| return False | |
| return True | |
| def _norm(s: str) -> str: | |
| return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip() | |
| _NAME_BOUNDARY = re.compile(r'["“”]\s*(?:and|with|or|versus|vs\.?|plus|,)\s*["“”]', re.I) | |
| def _extract_names(q: str) -> list[str]: | |
| """Pull the individual product names a multi-product question references. | |
| Quoted names are the reliable signal (the hard quiz quotes each product, and | |
| real users naming a specific product tend to quote/capitalise it). Falls back | |
| to splitting on connectors only when there are no quotes.""" | |
| # A product name can itself contain quotes (inch marks: a pool sized | |
| # 9'8"X6'3"), which defeats naive "([^"]+)" pairing and mis-splits the names | |
| # → a basket resolves the wrong product. Split instead on the QUOTE-delimited | |
| # connector BETWEEN names ('" and "', '", "'): inner inch-quotes never form | |
| # that boundary, so each name survives intact. | |
| if _NAME_BOUNDARY.search(q): | |
| tmp = _NAME_BOUNDARY.sub("\x00", q) | |
| m = re.search(r'["“”](.+)["“”]', tmp, re.S) # first quote … last quote | |
| if m: | |
| names = [n.strip(" .\"'“”") for n in m.group(1).split("\x00")] | |
| names = [n for n in names if len(n) >= 3] | |
| if len(names) >= 2: | |
| return names[:5] | |
| names = [n.strip() for n in re.findall(r'[\"“”]([^\"“”]{3,})[\"“”]', q) if n.strip()] | |
| if len(names) >= 2: | |
| return names[:5] | |
| # A colon can introduce the names ("Compare on price: A vs B") OR trail them with a | |
| # question clause ("A vs B: which can I buy?"). Use the post-colon segment ONLY when | |
| # the connectors (and therefore the names) are there; otherwise keep the full string | |
| # and let the trailing clause be stripped below — so "A vs B: which can I buy?" still | |
| # yields [A, B] instead of the question. | |
| colon = re.search(r":\s+", q) | |
| seg = q | |
| if colon and not re.search(r"\d\s*:\s*$", q[:colon.start() + 1]): | |
| after = q[colon.end():] | |
| if re.search(r"\b(?:versus|vs\.?|and|or|against)\b|,", after): | |
| seg = after | |
| # A trailing interrogative clause introduced by a COMMA ("X or Y, which can I buy | |
| # today?") would be comma-split into a junk name ("today") and block resolution of | |
| # the real names; strip it from the segment before the connector split. | |
| seg = re.sub(r"(?i),\s*(?:which|what|who|can|should|is|are|do(?:es)?|will|how|kis|kon?sa|kaun?sa|kaun)\b.*$", "", seg) | |
| parts = re.split(r"\s+(?:versus|vs\.?|v\.?|and|or|aur|ya|plus|against|compared\s+(?:to|with))\s+|\s*\+\s*|,\s*", seg) | |
| # leading ordering verb ("Sort Mini Can, ...", "rank these A, B by price") the | |
| # connector-split leaves on the first name — drop it so the bare product resolves. | |
| parts = [re.sub(r"(?i)^\s*(?:sort|order|rank|arrange)\s+(?:these\s+|the\s+)?", "", p) for p in parts] | |
| # leading comparison scaffolding the connector-split leaves on the first name | |
| # ("availability comparison FOR Aston Martin F1 1/18" → "Aston Martin F1 1/18") | |
| parts = [re.sub(r"(?i)^\s*(?:availability|stock|price|cost)?\s*compar(?:e|ing|ison)\s+(?:for|of|between|with)?\s*", "", p) for p in parts] | |
| parts = [re.sub(r"(?i)^\s*(?:compare|which\s+(?:one\s+)?(?:is|costs?|should\s+i\s+buy|can\s+i\s+buy)|is)\s+", "", p) for p in parts] | |
| parts = [re.sub(r"(?i)^\s*(?:more|less|cheaper|pricier|dearer|expensive)\s+", "", p) for p in parts] | |
| parts = [re.sub(r"(?i)\s+(?:on|for)\s+(?:price|prices|cost|stock|availability)(?:\s+and\s+(?:price|prices|cost|stock|availability))*.*$", "", p) for p in parts] | |
| # a bare trailing dimension clause the connector-split couldn't separate from the | |
| # LAST name ("Big RC Excavator PRICE AND STOCK", "Iron Man action figure PRICE | |
| # DIFFERENCE") — strip the comparison axis words so the product name resolves. | |
| parts = [re.sub(r"(?i)\s+(?:price|prices|cost|costs|stock|stock\s+status|availability)" | |
| r"(?:\s+(?:and|&|,|or)\s+(?:price|prices|cost|costs|stock|availability|difference))*" | |
| r"(?:\s+difference)?\s*$", "", p) for p in parts] | |
| # trailing "stock compare"/"comparison"/"compared" the connector-split leaves on the | |
| # last name ("Lamborghini URUS stock compare") — the dimension strip above stops at | |
| # "stock" because "compare" follows it, so the verb survives and breaks resolution. | |
| parts = [re.sub(r"(?i)\s+(?:stock|price|cost|availability)?\s*(?:compare|comparison|compared)\s*[?.!]*$", "", p) for p in parts] | |
| # trailing question/compare clause a name carries when the tail wasn't split off | |
| # ("Mini Can RC Drift Cars: which can I buy?", "Iron Man action figure — which costs more") | |
| parts = [re.sub(r"(?i)\s*[:—–-]\s*(?:which|what|who|can|should|is|are|do(?:es)?|will|how)\b.*$", "", p) for p in parts] | |
| # Roman-Urdu trailing interrogative the connector-split leaves on the last name | |
| # ("iron man MEIN MEHNGA KONSA HAI?", "X SASTA KAUNSA") → strip from the first | |
| # Roman-Urdu locative/compare marker so the bare product resolves. These tokens never | |
| # appear inside an English product title, so English names are untouched. | |
| parts = [re.sub(r"(?i)\s+(?:mein|konsa|konsi|kaunsa|kaunsi|kon?s[ai]|kaun?s[ai]|kis|sasta|sasti|" | |
| r"mehng[ai]|zyada|sab\s+se|dono|available|availab\w*|buyable)\b.*$", "", p) for p in parts] | |
| # trailing ordering/dimension clause the connector-split couldn't separate from the | |
| # LAST name ("BMW 760li CHEAPEST TO EXPENSIVE SORT KARO", "1:64 Micro Drift BY PRICE | |
| # LOW TO HIGH", "Ferrari Laferrari TOTAL PRICE") — strip the ranking/basket axis so | |
| # the bare product resolves. The "X to Y" range and "by price" phrasings never occur | |
| # inside a real product title, so genuine names are untouched. | |
| parts = [re.sub(r"(?i)\s+(?:cheap\w*|low\w*|least|most|high\w*|expensive|priciest|dearest)\s+to\s+\w+.*$", "", p) for p in parts] | |
| # trailing "<pole> first/last" the connector-split leaves on the LAST name | |
| # ("Ferrari Laferrari CHEAPEST FIRST", "M8 PRICIEST LAST") — strip the ranking | |
| # direction so the bare product resolves. Pole words never occur in a real title. | |
| parts = [re.sub(r"(?i)\s+(?:cheap\w*|low\w*|least|most\s+expensive|priciest|dearest|" | |
| r"high\w*|expensive|pric\w*|costl\w*)\s+(?:priced\s+)?(?:first|last)\b.*$", "", p) for p in parts] | |
| parts = [re.sub(r"(?i)\s+by\s+(?:price|prices|cost|costs|stock|availability)\b.*$", "", p) for p in parts] | |
| parts = [re.sub(r"(?i)\s+(?:ascending|descending|increasing|decreasing)\b.*$", "", p) for p in parts] | |
| parts = [re.sub(r"(?i)\s+(?:sort|order|rank|arrange)(?:\s+(?:kar\w*|kr\w*|kren|karen|do))?\s*[?.!]*$", "", p) for p in parts] | |
| parts = [re.sub(r"(?i)\s+(?:total|combined|altogether|together|sum)(?:\s+(?:price|prices|cost|costs))?\s*[?.!]*$", "", p) for p in parts] | |
| parts = [re.sub(r"[?.\"]+$", "", p).strip(" .\"") for p in parts] | |
| # Keep multi-word names; ALSO a lone distinctive word (a one-word product name like | |
| # "Deadpool" in "Deadpool vs Iron Man action figure", where the shared type-noun | |
| # attaches only to the other side). Single words must be >=5 chars and not stop-words | |
| # so connector-split junk ("the", "and") is excluded — and _answer_multi's resolution | |
| # floor rejects any one-worder that isn't a real product anyway. | |
| parts = [p for p in parts | |
| if (len(p) >= 4 and len(p.split()) >= 2) | |
| or (len(p.split()) == 1 and len(p) >= 5 and p.lower() not in _STOP)] | |
| return parts[:5] if len(parts) >= 2 else names | |
| _CMP_RE = re.compile( | |
| r"\bcompar(?:e|es|ing|ison)\b|\bcheaper\b|\bpricier\b|\bdearer\b|\bmore\s+expensive\b|\bless\s+expensive\b" | |
| r"|\bdifference\s+between\b|\bby\s+how\s+much\b|\bhow\s+much\s+(?:more|less|cheaper)\b" | |
| r"|\bwhich\s+(?:one\s+)?(?:is|costs?)\b|\bversus\b|\bvs\.?\b|\bagainst\b|\bcosts?\s+(?:less|more)\b", re.I) | |
| _ORDER_RE = re.compile(r"\b(order|rank|sort|arrange|list\s+these)\b", re.I) | |
| _PRICE_DIM_RE = re.compile( | |
| r"\b(expensive|cheap(?:er|est)?|price[ds]?|cost(?:s|ly)?|least|most|" | |
| r"low(?:est)?\s+to\s+high(?:est)?|high(?:est)?\s+to\s+low(?:est)?|" | |
| r"ascending|descending|increasing|decreasing)\b", re.I) | |
| # Basket = "buy/total/combined" of several named items. Broad on purpose: customers | |
| # phrase it many ways ("total cost of buying X and Y", "buys one X plus one Y, what | |
| # combined amount should they pay", "X together with Y"). Compare/order are detected | |
| # first and win, so these verbs can't hijack a comparison/ranking. NOT 'cost' alone | |
| # (that word appears in "which costs more" comparisons). | |
| _BASKET_RE = re.compile( | |
| r"\b(total|sum|altogether|combined|basket|plus|together|spend|spends|owe|" | |
| r"purchas\w*|buy|buys|buying|bought|pay|pays|paying)\b", re.I) | |
| # Ascending = cheapest-first. Catch every natural phrasing: a cheap/low pole word | |
| # appearing before an expensive/high pole word across a "to" ("cheapest to most | |
| # expensive", "low to high", "least to most expensive"), plus the explicit single | |
| # forms. "most expensive to cheapest" can't match (poles reversed) → descending. | |
| _ASC_RE = re.compile( | |
| r"\b(?:cheap(?:est)?|low(?:est)?|least|lower)\b[^.?!]*?\bto\b[^.?!]*?" | |
| r"\b(?:most|expensive|high(?:est)?|pric\w*|dear\w*|cost\w*)" | |
| r"|\b(?:cheap(?:est)?|low(?:est)?)\s+first\b" | |
| r"|\bleast\s+to\s+most\b|\blow(?:est)?\s+to\s+high(?:est)?\b" | |
| r"|\b(?:ascending|increasing)\b", re.I) | |
| # Descending = priciest-first, stated explicitly. Used to disambiguate the order | |
| # op when no ascending cue is present (default stays descending either way). | |
| _DESC_RE = re.compile( | |
| r"\b(?:most\s+expensive|priciest|dearest|highest|expensive)\b[^.?!]*?\bto\b[^.?!]*?" | |
| r"\b(?:cheap\w*|low\w*|least)" | |
| r"|\b(?:most\s+expensive|priciest|dearest|highest)\s+first\b" | |
| r"|\bhigh(?:est)?\s+to\s+low(?:est)?\b|\b(?:descending|decreasing)\b", re.I) | |
| def _is_oos(r: Row) -> bool: | |
| return bool(re.search(r"out\s+of\s+stock|sold\s+out|unavailable|not\s+available", r.availability or "")) | |
| def _parse_multi(q: str) -> dict | None: | |
| """Detect a multi-product reasoning question (compare / order / basket) and the | |
| product names it spans. Returns None for everything else.""" | |
| ql = (q or "").lower() | |
| is_order = bool(_ORDER_RE.search(ql) and _PRICE_DIM_RE.search(ql)) | |
| is_cmp = bool(_CMP_RE.search(ql)) or bool(_CMP_MARK.search(ql)) or bool(re.search(r"\bwhich\b.*\bbuy\b", ql) and re.search(r"\bor\b|\bvs\b|\bversus\b", ql)) | |
| # Buy-choice: two named products joined by or/vs with an availability/buy cue | |
| # ("need the available option: X or Y?", "X or Y — which can I get?"). Report each | |
| # one's stock and recommend the buyable one. Resolves like a stock comparison; if | |
| # the names don't resolve to specific products the caller falls back to the list | |
| # spec, so a category "or" query is never hijacked. | |
| is_choice = bool(re.search(r"\b(?:or|vs\.?|versus|ya)\b", ql) | |
| and re.search(r"\b(available|availab\w*|in[\s-]?stock|buy|buyable|purchase|order|get|kaunsi|kaunsa|konsi|konsa)\b", ql)) | |
| is_cmp = is_cmp or is_choice | |
| is_basket = bool(_BASKET_RE.search(ql)) and not bool(re.search(r"\bwhich\b|\bcompare\b|\bin[\s-]?stock\b|\bavailable\b", ql)) | |
| if not (is_order or is_cmp or is_basket): | |
| return None | |
| names = _extract_names(q) | |
| if len(names) < 2: | |
| return None | |
| # order and compare have distinct, specific verbs; basket is the remaining | |
| # multi-product intent. Compare wins over basket so "which costs more" is never | |
| # summed. | |
| op = "order" if is_order else ("compare" if is_cmp else "basket") | |
| # Compare direction: "which costs more / more expensive" names the dearer item; | |
| # default ("cheaper / costs less") names the cheaper. Either way both prices and | |
| # the exact gap are stated, so the answer carries the fact regardless. | |
| cmp_more = bool(re.search(r"\bmore\s+expensive\b|\bcosts?\s+more\b|\bpricier\b|\bdearer\b|\bhigher\s+price", ql)) or bool(_MORE_MARK.search(ql)) | |
| cmp_stock = bool(re.search(r"\bstock\b|\bavailable\b|\bavailability\b|\bbuy\b", ql)) or is_choice | |
| return {"op": op, "names": names, "asc": bool(_ASC_RE.search(ql)), "cmp_more": cmp_more, | |
| "stock": cmp_stock, "buy_choice": is_choice} | |
| def _raw(s: str) -> str: | |
| return re.sub(r"\s+", " ", (s or "").strip().lower()) | |
| def _edit_le(a: str, b: str, k: int) -> bool: | |
| """True if a within k edits of b, counting an adjacent transposition as ONE | |
| (Damerau) so 'leanring'~'learning'. Bounded DP — tokens are short.""" | |
| la, lb = len(a), len(b) | |
| if abs(la - lb) > k: | |
| return False | |
| prev2 = None | |
| prev = list(range(lb + 1)) | |
| for i in range(1, la + 1): | |
| cur = [i] + [0] * lb | |
| for j in range(1, lb + 1): | |
| cost = 0 if a[i - 1] == b[j - 1] else 1 | |
| cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost) | |
| if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]: | |
| cur[j] = min(cur[j], prev2[j - 2] + 1) | |
| prev2, prev = prev, cur | |
| return prev[lb] <= k | |
| def _one_transpose(a: str, b: str) -> bool: | |
| """True iff b differs from a by exactly ONE adjacent transposition (same length). | |
| Distinct from a substitution: 'cute'~'cuet' (swap) is True, 'card'~'cart' (sub) is | |
| False — so short-word fuzz can recover an end-transposition typo without collapsing | |
| two different real words.""" | |
| if len(a) != len(b) or a == b: | |
| return False | |
| diff = [i for i in range(len(a)) if a[i] != b[i]] | |
| return (len(diff) == 2 and diff[1] == diff[0] + 1 | |
| and a[diff[0]] == b[diff[1]] and a[diff[1]] == b[diff[0]]) | |
| def _fuzzy_in(tok: str, toks: list[str]) -> bool: | |
| """tok matches some word in toks within a length-scaled edit budget. Conservative | |
| on short words (where one edit flips a different real word, card↔cart): exact only | |
| under 5 chars, 1 edit for 5-7, 2 for 8+. Used only as a fallback when the exact / | |
| singular-plural match already failed, so the 0.85 coverage floor still guards.""" | |
| for u in toks: | |
| if u == tok: | |
| return True | |
| n = min(len(tok), len(u)) | |
| # A single adjacent transposition is the most common typo ("cuet"→"cute", | |
| # "Candels"→"Candles", "Hamzasotrepk"→"Hamzastorepk") and counts as ONE edit | |
| # at ANY length — plain Levenshtein scores it as 2, so the k-budget (1 for | |
| # 5-7 chars) would wrongly reject mid-length transpositions. _one_transpose | |
| # excludes substitutions, so 4-char near-pairs (card/cart) stay distinct. | |
| if len(tok) == len(u) and _one_transpose(tok, u): | |
| return True | |
| k = 0 if n < 5 else (1 if n < 8 else 2) | |
| if k and _edit_le(tok, u, k): | |
| return True | |
| return False | |
| _VOCAB_CACHE: dict = {} | |
| def _catalog_vocab(rows: list[Row]) -> set[str]: | |
| """Distinctive words the catalog actually uses (title + structured category | |
| tokens, length >= 5). Cached per row-list identity. Used to repair a typo'd | |
| anchor against real merchant vocabulary.""" | |
| key = id(rows) | |
| v = _VOCAB_CACHE.get(key) | |
| if v is None: | |
| v = set() | |
| for r in rows: | |
| for t in re.findall(r"[a-z]{5,}", (r.title + " " + r.cats).lower()): | |
| v.add(t) | |
| _VOCAB_CACHE.clear() # only the current row-list matters | |
| _VOCAB_CACHE[key] = v | |
| return v | |
| def _correct_anchors(anchors: list[str], rows: list[Row]) -> list[str]: | |
| """Repair anchors the catalog does NOT contain (document-frequency 0) by mapping | |
| each to the nearest real vocabulary word within the SAME edit budget the rest of | |
| the engine uses (_fuzzy_in: 1 edit for 5-7 chars, 2 for 8+). Only fires on absent | |
| tokens, so it can never alter a query whose words all exist — a misspelled CATEGORY | |
| ('construccion'->'construction') resolves while a genuinely absent product still | |
| matches nothing. 'rc' is engine-special and never touched.""" | |
| if not rows: | |
| return anchors | |
| vocab = None | |
| out = [] | |
| for a in anchors: | |
| if a == "rc" or len(a) < 5 or _anchor_df(a, rows) > 0.0: | |
| out.append(a) | |
| continue | |
| if vocab is None: | |
| vocab = _catalog_vocab(rows) | |
| best, best_k = None, 99 | |
| for v in vocab: | |
| if v[0] != a[0] or abs(len(v) - len(a)) > 2: | |
| continue | |
| n = min(len(a), len(v)) | |
| k = 1 if n < 8 else 2 | |
| if _edit_le(a, v, k): | |
| # nearest by edit distance; ties -> the more frequent catalog word | |
| d = 0 if _edit_le(a, v, 0) else (1 if _edit_le(a, v, 1) else 2) | |
| score = (d, -_anchor_df(v, rows)) | |
| if best is None or score < best_k: | |
| best, best_k = v, score | |
| out.append(best or a) | |
| return out | |
| def _tie_break_key(r: Row, raw_name: str): | |
| """Deterministic ranking among rows that resolve EQUALLY well to a name, so a | |
| duplicate/near-duplicate listing never gets picked at random. Prefer the row | |
| whose raw title (punctuation+case) matches the query — e.g. a query naming | |
| 'Pool ( 34" X 10" )' picks that exact listing over the 'Pool 34" x 10"' twin — | |
| then an in-stock row, then the closest title length, then a stable URL.""" | |
| raw = _raw(r.title) | |
| in_stock = 1 if _is_oos(r) else 0 | |
| return (0 if raw == _raw(raw_name) else 1, in_stock, abs(len(raw) - len(_raw(raw_name))), r.source or "") | |
| def _resolve_detail(name: str, rows: list[Row]) -> tuple[Row | None, list[Row]]: | |
| """Resolve ONE named product. Returns (chosen_row, equally-good candidates). | |
| Exact normalized-title matches win; otherwise a bidirectional coverage match | |
| with a high floor so same-category siblings are rejected. When several rows | |
| tie, _tie_break_key chooses deterministically and `candidates` exposes the | |
| tie so the caller can clarify a genuine ambiguity (identical listings).""" | |
| key = _norm(name) | |
| qtoks = [t for t in key.split() if len(t) > 1] | |
| if not qtoks: | |
| return None, [] | |
| qset: set[str] = set() | |
| for t in qtoks: | |
| qset |= _variants(t) | |
| exact = [r for r in rows if _norm(r.title) == key] | |
| if exact: | |
| best = min(exact, key=lambda r: _tie_break_key(r, name)) | |
| return best, exact | |
| best = None | |
| best_key = (-1.0, -1.0, -1e9) | |
| tied: list[Row] = [] | |
| for r in rows: | |
| rtoks = [t for t in _norm(r.title).split() if len(t) > 1] | |
| if not rtoks: | |
| continue | |
| rset: set[str] = set() | |
| for t in rtoks: | |
| rset |= _variants(t) | |
| # Fuzzy fallback (only when the exact/variant match misses) tolerates a typo'd | |
| # query word — "leanring" still names "learning" — so a misspelling can't drop | |
| # the right product below a clean sibling. | |
| cover = sum(1 for t in rtoks if t in qset or _fuzzy_in(t, qtoks)) / len(rtoks) # stored title named by query | |
| qcover = sum(1 for t in qtoks if t in rset or _fuzzy_in(t, rtoks)) / len(qtoks) # query named by stored title | |
| if not (cover >= 0.85 or qcover >= 0.85) or (cover + qcover) / 2 < 0.5: | |
| continue | |
| # Rank by the WEAKER direction first, not the stronger one. A short generic | |
| # sibling ("Maybelline Lash Sensational Mascara") trivially scores cover=1.0 | |
| # against a long specific query ("...Sky Tubes(TM) Tubing Mascara...") since | |
| # every one of its few words appears in the query — but it only explains a | |
| # fraction of what the query actually named (low qcover). Requiring both | |
| # directions to be strong before the strong one counts stops a generic title | |
| # from outranking the real, more specific match. | |
| sk = (min(cover, qcover), max(cover, qcover), -abs(len(rtoks) - len(qtoks))) | |
| if sk > best_key: | |
| best_key, best, tied = sk, r, [r] | |
| elif sk == best_key: | |
| tied.append(r) | |
| if best is not None and len(tied) > 1: | |
| best = min(tied, key=lambda r: _tie_break_key(r, name)) | |
| return best, (tied if best is not None else []) | |
| def _resolve_one(name: str, rows: list[Row]) -> Row | None: | |
| return _resolve_detail(name, rows)[0] | |
| def _ambiguous_alts(name: str, rows: list[Row]) -> list[Row]: | |
| """Return the price-distinct candidates when a name resolves to TWO listings | |
| that the tie-break cannot separate (same rank key) — a genuine ambiguity the | |
| bot must surface rather than guess between (e.g. two identical product | |
| listings at different prices). Empty when resolution is unambiguous.""" | |
| best, cands = _resolve_detail(name, rows) | |
| if best is None or len(cands) < 2: | |
| return [] | |
| # Compare on the MEANINGFUL rank fields (raw title, stock, length), not the | |
| # URL tiebreaker — two identical listings differ only by URL, so including it | |
| # would make them look distinguishable and suppress the clarification. | |
| bk = _tie_break_key(best, name)[:-1] | |
| top = [c for c in cands if _tie_break_key(c, name)[:-1] == bk and c.price is not None] | |
| if len({c.price for c in top}) > 1: | |
| return top | |
| return [] | |
| def _answer_multi(mp: dict, rows: list[Row]) -> tuple[str, list[str]] | None: | |
| """Resolve every named product, then compute the comparison / ordering / total. | |
| Returns None (→ RAG) unless EVERY named product resolves to a priced row, so a | |
| partial resolution never produces a confidently-wrong half-answer.""" | |
| found = [] | |
| for name in mp["names"]: | |
| # Genuine ambiguity: the name matches two indistinguishable listings at | |
| # different prices. Don't guess one into a basket/comparison — surface the | |
| # choice so the answer is never confidently wrong. | |
| alts = _ambiguous_alts(name, rows) | |
| if len(alts) > 1: | |
| opts = "; ".join(f"{_price_s(a.price, a.currency)}" for a in sorted(alts, key=lambda r: r.price)) | |
| return (f'There are {len(alts)} listings for "{name}" at different prices ({opts}). ' | |
| f"Which one did you mean?"), _dedup(a.source for a in alts) | |
| r = _resolve_one(name, rows) | |
| if r is None or r.price is None: | |
| return None | |
| found.append(r) | |
| # Dedup by product URL: a name containing embedded quotes (e.g. a pool sized | |
| # 9'8"X6'3") mis-splits into fragments that resolve to the SAME row, which | |
| # would otherwise double-count it in the basket total. Genuine multi-product | |
| # questions name distinct products → distinct sources, so this is lossless. | |
| _seen, _uniq = set(), [] | |
| for r in found: | |
| k = r.source or r.title | |
| if k in _seen: | |
| continue | |
| _seen.add(k) | |
| _uniq.append(r) | |
| found = _uniq | |
| if len(found) < 2: | |
| return None | |
| cur = found[0].currency | |
| srcs = _dedup(r.source for r in found) | |
| op = mp["op"] | |
| if op == "basket": | |
| total = sum(r.price for r in found) | |
| lines = ["Based on our catalog:"] | |
| lines += [f"- {r.title}: {_price_s(r.price, r.currency)} ({'out of stock' if _is_oos(r) else 'available'})" for r in found] | |
| lines.append(f"\nTotal cost: {_price_s(total, cur)}") | |
| return "\n".join(lines), srcs | |
| if op == "order": | |
| # stable sort by price ALONE keeps equal-priced items in the order the customer | |
| # named them ("BMW M8, BMW M4, BMW 760li" → M8 before 760li at the same Rs.4,950), | |
| # which is the natural reading; an alphabetical tiebreak would reorder them. | |
| ordered = sorted(found, key=lambda r: r.price if mp["asc"] else -r.price) | |
| head = "From least to most expensive:" if mp["asc"] else "From most to least expensive:" | |
| lines = [head] + [f"{i}. {r.title} — {_price_s(r.price, r.currency)}" for i, r in enumerate(ordered, 1)] | |
| return "\n".join(lines), _dedup(r.source for r in ordered) | |
| # compare: state both prices and the exact difference; name the product the | |
| # question asked about (dearer for "which costs more", else the cheaper). | |
| pair = sorted(found, key=lambda r: r.price) | |
| cheaper, dearer = pair[0], pair[-1] | |
| diff = dearer.price - cheaper.price | |
| if mp.get("buy_choice"): | |
| # Recommend by availability, not price: name the buyable option(s) and flag the | |
| # out-of-stock one explicitly ("X is available; Y is out of stock"). | |
| _av = [r for r in found if not _is_oos(r)] | |
| _so = [r for r in found if _is_oos(r)] | |
| if _av and _so: | |
| verdict = (f"{', '.join(r.title for r in _av)} is available; " | |
| f"{', '.join(r.title for r in _so)} is out of stock.") | |
| elif _av: | |
| verdict = f"Both are available — {cheaper.title} is the cheaper at {_price_s(cheaper.price, cur)}." | |
| else: | |
| verdict = "Both are currently out of stock." | |
| else: | |
| verdict = (f"{dearer.title} is more expensive by {_price_s(diff, cur)}." | |
| if mp.get("cmp_more") | |
| else f"{cheaper.title} is cheaper by {_price_s(diff, cur)}.") | |
| def _cmp_line(r): | |
| suffix = f" ({'out of stock' if _is_oos(r) else 'available'})" if mp.get("stock") else "" | |
| return f"- {r.title}: {_price_s(r.price, r.currency)}{suffix}" | |
| txt = (f"{_cmp_line(cheaper)}\n" | |
| f"{_cmp_line(dearer)}\n\n{verdict}") | |
| return txt, srcs | |
| # Coarse "is this plausibly a product question?" gate for the LLM net. Broad on | |
| # purpose (a quoted span or any price/buy/compare signal); the LLM does the precise | |
| # classification. Keeps the extra call off pure policy/greeting questions. | |
| _PRODUCTISH = re.compile( | |
| r'["“”]|\b(price[ds]?|cost(?:s|ly)?|how\s+much|cheap(?:er|est)?|expensive|afford|' | |
| r'total|combined|altogether|buy|buys|buying|purchas\w*|pay|spend|stock|stocked|' | |
| r'carry|carries|available|availability|sell|sells|which|compare|versus|vs\.?|' | |
| r'than|set\s+of|pack\s+of|do\s+you\s+have|in\s+stock)\b', re.I) | |
| # Greeting / smalltalk that is never a catalog question — used to skip the LLM | |
| # router for trivial turns so the understanding layer fires on everything ELSE | |
| # without us hand-coding a surface regex per new phrasing (SMS-speak, pronouns, | |
| # price-ranges). The router itself returns "other" for any non-catalog question, | |
| # so a missed skip is harmless (falls through to RAG), just one wasted call. | |
| _ROUTER_SKIP = re.compile( | |
| r'^\s*(hi|hey+|hello|yo|sup|as?salaa?m\w*|salaa?m\w*|' | |
| r'thanks?|thank\s+you|thx|ty|ok(?:ay)?|cool|nice|great|' | |
| r'good\s+(?:morning|evening|afternoon|day)|bye|goodbye|see\s+ya|' | |
| r'how\s+are\s+you|who\s+are\s+you|what\s+can\s+you\s+do)\b[\s!.?]*$', re.I) | |
| def _anchors_from(text: str) -> list: | |
| return [w for w in re.findall(r"[a-z0-9]{2,}", (text or "").lower()) if w not in _STOP][:8] | |
| def _grounded_in_q(name: str, q: str) -> bool: | |
| """A router-extracted product/category must actually appear in the question. | |
| The LLM router is instructed to copy names the customer *literally wrote*, but | |
| a weak model sometimes echoes a few-shot EXAMPLE product instead — e.g. for a | |
| content-free follow-up ("is it in stock?", "how much is it?") it copies the | |
| prompt's sample item. Un-grounded, that becomes a confidently-wrong "we don't | |
| carry X" for an X the customer never named (and, across tenants, masquerades as | |
| a data leak). Require at least one distinctive (3+ char, non-stop) token of the | |
| name to be present in the query.""" | |
| qtoks = {w for w in re.findall(r"[a-z0-9]{3,}", (q or "").lower()) if w not in _STOP} | |
| ntoks = [w for w in re.findall(r"[a-z0-9]{3,}", (name or "").lower()) if w not in _STOP] | |
| return bool(ntoks) and any(t in qtoks for t in ntoks) | |
| def _ground_plan(plan: dict | None, q: str) -> dict | None: | |
| """Keep only the parts of an LLM-extracted plan that are grounded in q, so a | |
| few-shot echo can never drive execution. Returns the cleaned plan, or None when | |
| the intent needed named products / a category and none survived (→ hand to RAG).""" | |
| if not plan: | |
| return plan | |
| plan = dict(plan) | |
| plan["products"] = [p for p in (plan.get("products") or []) if _grounded_in_q(p, q)] | |
| cat = plan.get("category") | |
| if cat and not _grounded_in_q(cat, q): | |
| plan["category"] = None | |
| kind = plan.get("kind") | |
| prods, cat = plan["products"], plan.get("category") | |
| if kind in ("lookup", "exists") and not prods: | |
| return None | |
| if kind in ("compare", "order", "basket") and len(prods) < 2: | |
| return None | |
| if (kind in ("count", "cheapest", "priciest", "filter") | |
| and not prods and not cat | |
| and plan.get("price_min") is None and plan.get("price_max") is None): | |
| return None | |
| return plan | |
| # Common Roman-Urdu / Hinglish function & intent words. Used ONLY to DETECT that a | |
| # question is code-mixed (not English) so the LLM understanding layer is made primary | |
| # instead of the English regex mis-routing it. Detection needs just one hit, so spelling | |
| # variants don't have to be exhaustive — the LLM handles the actual understanding. This | |
| # is universal language knowledge, not per-store/per-phrasing config. | |
| # Deliberately EXCLUDES tokens that are also ordinary English ("me", "do", "stock", | |
| # "kar"~car) so a pure-English question never trips the gate (it stays on the fast, | |
| # free regex path). Any single hit from this set means the question is code-mixed. | |
| _URDU_MARKERS = frozenset( | |
| "hai hain kya kia mein mai se kam zyada ziada aur ya phir nahi nai nhi " | |
| "konsa kaunsa konsi kaunsi kaun kis sasta sasti mehnga mehngi mehnge " | |
| "chahiye chaiye chahye milega milegi jayega jaega jayegi hoga hogi sakta sakti " | |
| "sakte hun kitna kitni kitny kitne kitnay wala wali wale abhi karo " | |
| "batao bata bta dedo dikhao dikha dikhaiye acha achha theek yaar " | |
| "dono rakhte rakha".split() | |
| ) | |
| def _is_code_mixed(q: str) -> bool: | |
| """True when the question carries Roman-Urdu/Hindi markers → route understanding | |
| through the LLM (which reads any language) instead of the English-only regex.""" | |
| return any(t in _URDU_MARKERS for t in re.findall(r"[a-z]+", (q or "").lower())) | |
| _AVAIL_MARK = re.compile(r"\b(available|in[\s-]?stock|stock\s+mein|mileg[ai]|mil\s+jay\w*|buyable|in\s+stock)\b", re.I) | |
| _OOS_MARK = re.compile(r"\b(sold[\s-]?out|out\s+of\s+stock|unavailable|stock\s+mein\s+nahi|nahi\s+hai)\b", re.I) | |
| _BOTH_MARK = re.compile(r"\b(dono|both|available\s+(?:and|aur|or|ya|&|vs)\s+(?:sold|out|unavailable))\b" | |
| # Roman-Urdu "kitne bik gaye … kitne bache" = sold vs remaining split | |
| r"|bik\w*[^.?!]{0,30}bach\w*|bach\w*[^.?!]{0,30}bik\w*", re.I) | |
| _CMP_MARK = re.compile(r"\b(which|konsa|kaunsa|kon?sa|kaun?sa|kis|cheaper|sasta|sasti|" | |
| r"mehng[ai]|zyada|expensive|pricier|dearer|better|costs?\s+(?:more|less))\b", re.I) | |
| _MORE_MARK = re.compile(r"\b(mehng[ai]|zyada|expensive|pricier|dearer|costs?\s+more|higher)\b", re.I) | |
| # "Buyable / advisory" intent — the customer wants something they can PURCHASE now | |
| # (a gift, a recommendation, "one I can get", "jo mil jaye / buy ho sake"). Mirrors | |
| # the advisory rule in _refine_plan but is applied to the deterministic REGEX spec too, | |
| # so a sold-out item never leaks into a budget/recommendation list when the LLM router | |
| # didn't fire. Language-agnostic markers; universal across stores, no per-phrasing config. | |
| _BUYABLE_INTENT = re.compile( | |
| r"\b(available|in[\s-]?stock|buyable|mileg[ai]|mil\s+jay\w*|mil\s+sak\w*|" | |
| r"buy\s+(?:ho|kar)\s+sak\w*|khareed\w*|kharid\w*|le\s+sak\w*|order\s+kar\s+sak\w*|" | |
| r"jo\s+(?:mil|buy|le)\b|recommend\w*|suggest\w*|gift|gifts|present|presents|tohfa|tuhfa)\b", re.I) | |
| def _refine_plan(plan: dict, q: str) -> dict: | |
| """Deterministically correct the dimensions a small LLM most often drops, using | |
| language-agnostic markers (English + Roman-Urdu). The LLM still does the hard part | |
| (understanding + naming products); this only enforces UNAMBIGUOUS signals it missed, | |
| so it's a universal safety net, not per-store config. Only ADDS signal, never erases | |
| a product the LLM found.""" | |
| if not plan: | |
| return plan | |
| ql = q or "" | |
| av, oos = bool(_AVAIL_MARK.search(ql)), bool(_OOS_MARK.search(ql)) | |
| # stock dimension: both states, or a clear out-only / in-only ask the LLM left null | |
| if not plan.get("stock_query"): | |
| if bool(_BOTH_MARK.search(ql)) or (av and oos): | |
| plan["stock_query"] = "both" | |
| elif oos and not av: | |
| plan["stock_query"] = "out" | |
| elif av and not oos: | |
| plan["stock_query"] = "in" | |
| # two named products + a comparison cue → it's a comparison, even if the LLM called | |
| # it a lookup/filter (Roman-Urdu "X ya Y konsa..." is easy to mis-tag). | |
| if len([p for p in (plan.get("products") or []) if p]) >= 2 and _CMP_MARK.search(ql): | |
| if plan.get("kind") in ("lookup", "filter", "exists", "browse"): | |
| plan["kind"] = "compare" | |
| if not plan.get("compare_dir"): | |
| plan["compare_dir"] = "more" if _MORE_MARK.search(ql) else "cheaper" | |
| # An availability/buy comparison ("stock kis ka hai", "konsa buy kar sakta hun") | |
| # must report each side's status — carry the stock dimension even when no explicit | |
| # available/sold-out word was used. | |
| if plan.get("kind") == "compare" and not plan.get("stock_query") and re.search( | |
| r"\b(stock|available|availab\w*|buy|buyable|kharid|le\s+sakta|kar\s+sakta)\b", ql, re.I): | |
| plan["stock_query"] = "in" | |
| # Advisory / gift-recommendation intent ("recommend a gift under 4000", "bachay ke | |
| # liye koi acha gift suggest karo") = the customer wants something they can BUY now — | |
| # never surface a sold-out item as a recommendation. Force the in-stock filter unless | |
| # they explicitly asked for out/both stock states. | |
| if (not plan.get("stock_query") and not plan.get("in_stock") | |
| and re.search(r"\b(recommend\w*|suggest\w*|advise|advice|gift|gifts|present|presents" | |
| r"|tohfa|tuhfa|acha|accha|achi|acchi|behtar|behtreen)\b", ql, re.I)): | |
| plan["in_stock"] = True | |
| return plan | |
| def _plan_to_specs(plan: dict) -> tuple[dict | None, "Spec | None"]: | |
| """Map an LLM-extracted plan onto the engine's existing (mp | Spec) execution. | |
| Returns (mp, spec) with at most one non-None. None,None = not executable here.""" | |
| kind = plan.get("kind") | |
| names = [n for n in (plan.get("products") or []) if n] | |
| sq = plan.get("stock_query") # "in" | "out" | "both" | None | |
| if kind in ("compare", "order", "basket"): | |
| if len(names) < 2: | |
| return None, None | |
| # An availability comparison ("kaunsa available?", "stock kis ka hai") → carry | |
| # stock so each side's status shows and the buyable one is recommended. | |
| _stockish = sq in ("in", "out", "both") | |
| return {"op": kind, "names": names[:6], | |
| "asc": plan.get("order_dir") == "asc", | |
| "cmp_more": plan.get("compare_dir") == "more", | |
| "stock": _stockish, "buy_choice": _stockish and kind == "compare"}, None | |
| pmin, pmax, in_stock = plan.get("price_min"), plan.get("price_max"), bool(plan.get("in_stock")) | |
| out_of_stock = sq == "out" | |
| if in_stock and out_of_stock: | |
| in_stock = out_of_stock = False | |
| cat = plan.get("category") or "" | |
| # Both states asked together ("available aur sold out dono") → split count. | |
| if sq == "both": | |
| anch = _anchors_from(cat or (names[0] if names else "")) | |
| if not anch: | |
| return None, None | |
| return None, Spec("count_split", anch, pmin, pmax, include_oos=True) | |
| if kind == "count": | |
| return None, Spec("count", _anchors_from(cat or (names[0] if names else "")), pmin, pmax, in_stock, out_of_stock=out_of_stock) | |
| if kind == "cheapest": | |
| return None, Spec("min", _anchors_from(cat or (names[0] if names else "")), pmin, pmax, in_stock, out_of_stock=out_of_stock) | |
| if kind == "priciest": | |
| return None, Spec("max", _anchors_from(cat or (names[0] if names else "")), pmin, pmax, in_stock, out_of_stock=out_of_stock) | |
| if kind == "exists": | |
| # "do you have X?" / Roman-Urdu "X hai?" — report availability, not just yes/no: | |
| # the stock reporter confirms it's carried AND states in/out of stock (one product | |
| # or a small category), so an OOS item is never answered as a bare "yes we carry". | |
| anch = _anchors_from(names[0] if names else cat) | |
| if not anch: | |
| return None, None | |
| return None, Spec("stock", anch, pmin, pmax) | |
| if kind == "lookup": | |
| # Single named product: the STOCK reporter answers both "how much" (it prints the | |
| # price) and "is it available" (it prints status, even when out of stock) — so a | |
| # Roman-Urdu "kitny ka hai"/"mil jayega" both resolve correctly. | |
| if not names: | |
| anch = _anchors_from(cat) | |
| return (None, Spec("list", anch, pmin, pmax, in_stock, out_of_stock=out_of_stock)) if anch or pmin is not None or pmax is not None else (None, None) | |
| return None, Spec("stock", _anchors_from(names[0]), pmin, pmax) | |
| if kind in ("filter", "browse"): | |
| anch = _anchors_from(names[0] if names else cat) | |
| if not anch and pmin is None and pmax is None: | |
| return None, None | |
| return None, Spec("list", anch, pmin, pmax, in_stock, out_of_stock=out_of_stock) | |
| return None, None | |
| _LOCATE_CAT_RE = re.compile( | |
| r"(?i)^\s*(?:which|what|in\s+what|under\s+what)\s+" | |
| r"(?:categor(?:y|ies)|section|collection|department|aisle|group)\s+" | |
| r"(?:is|are|does|do|would)\s+(.+?)" | |
| r"(?:\s+(?:in|under|listed|placed|found|located|belong(?:s)?(?:\s+to)?|part\s+of))?\s*\??$") | |
| _LOCATE_WHERE_RE = re.compile( | |
| r"(?i)^\s*where\s+(?:can|do|would|should|will|might)?\s*(?:i|you|we|one)?\s*" | |
| r"(?:find|locate|get|see|buy|browse)\s+(.+?)\s*\??$") | |
| _VARIANT_Q_RE = re.compile( | |
| r"\b(colou?rs?|sizes?|shades?|variants?|versions?|options?)\b.*" | |
| r"(?:\bcome\s+in\b|\bcomes\s+in\b|\bavailable\b|\boffered\b|\bdoes\s+(?:it|this|that|the)\b|\bhave\b)" | |
| r"|\bwhat\s+(?:colou?rs?|sizes?|variants?|options?)\b", re.I) | |
| def _is_variant_q(q: str) -> bool: | |
| """A 'what colours/sizes does X come in' question — answered by RAG from the | |
| product chunk's Options/Variants lines, not the structured aggregator (which | |
| would return a bare price/stock line). Must short-circuit BEFORE the LLM router, | |
| or the router re-resolves it to a catalog listing.""" | |
| return bool(q) and bool(_VARIANT_Q_RE.search(q)) | |
| def _locate_target(q: str) -> str | None: | |
| """Extract the PRODUCT name from a 'which category/section is X in' / 'where do I | |
| find X' question. Returns None when the question isn't a reverse-location query.""" | |
| if not q: | |
| return None | |
| m = _LOCATE_CAT_RE.match(q.strip()) or _LOCATE_WHERE_RE.match(q.strip()) | |
| return m.group(1).strip() if m else None | |
| def _answer_locate(target: str, rows: list[Row]) -> tuple[str, list[str]] | None: | |
| """Resolve `target` to a product and report the storefront collection(s) it sits in.""" | |
| anchors = [t for t in re.sub(r"[^a-z0-9 ]", " ", target.lower()).split() | |
| if t not in _STOP and len(t) > 1] | |
| if not anchors: | |
| return None | |
| cand = [r for r in rows if _hit(r, anchors, title_only=True)] | |
| if not cand: | |
| aset = _anchor_set(anchors) | |
| scored = [(_title_cover(r.title, aset, anchors), r) for r in rows] | |
| scored = sorted([(c, r) for c, r in scored if c >= 0.85], key=lambda x: -x[0]) | |
| cand = [scored[0][1]] if scored else [] | |
| if not cand: | |
| return None | |
| # Prefer a resolved product that actually carries collection membership. | |
| r = next((x for x in cand if x.colls_disp), cand[0]) | |
| cols = [c for c in r.colls_disp.split(" | ") if c] | |
| if not cols: | |
| return (f"{r.title} is in our catalog, but I don't have a specific category " | |
| f"listed for it."), ([r.source] if r.source else []) | |
| joined = ", ".join(cols) | |
| word = "category" if len(cols) == 1 else "categories" | |
| return (f"{r.title} is listed under the {joined} {word}.", [r.source] if r.source else []) | |
| def answer_catalog_query(q: str, db, cfg: dict | None = None, max_list: int = 12) -> tuple[str | None, list[str]]: | |
| """Single front-door for structured catalog questions. Returns (text, sources), | |
| or (None, []) when the question is not a structured-catalog question (caller | |
| then falls back to ordinary retrieval/LLM). | |
| Deterministic regex parser runs first (exact, no LLM). If it declines AND the DB | |
| is a real catalog, ONE structured LLM extraction (the router) is tried as a net, | |
| so unusual phrasings still resolve — but execution stays fully deterministic, so | |
| a number is never invented; an unresolved entity falls through to RAG.""" | |
| try: | |
| if not q or db is None: | |
| return None, [] | |
| # Variant questions ("what colours/sizes does X come in") → RAG reads the | |
| # chunk's Options/Variants. Must return BEFORE the LLM router, which would | |
| # otherwise re-resolve to a bare catalog listing without the colours/sizes. | |
| if _is_variant_q(q): | |
| return None, [] | |
| # Freebie / giveaway ask ("what can I get for free?", "any freebies?", "koi cheez | |
| # free milegi?") — NEVER let fuzzy matching list PRICED products as if they were | |
| # free (reads as broken/misleading to the merchant). Answer honestly + pivot to | |
| # deals. Does NOT fire on free-SHIPPING / return-policy asks (those → RAG/policy). | |
| _fq = (q or "").lower() | |
| _is_freebie = bool(re.search(r"\b(freebie|freebies|giveaway|give\s*away)\b", _fq) | |
| or (("free" in _fq or "muft" in _fq or "mufat" in _fq) | |
| and re.search(r"\b(product|products|item|items|thing|things|stuff|" | |
| r"anything|something|gift|gifts|get|got|receive|grab|" | |
| r"claim|cheez|cheezein|koi)\b", _fq))) | |
| if _is_freebie and not re.search(r"\b(shipping|delivery|deliver|ship|postage|courier|" | |
| r"return|returns|exchange|order|orders)\b", _fq): | |
| _biz = (cfg or {}).get("business_name", "") or "our store" | |
| if re.search(r"\b(muft|mufat|cheez|cheezein|koi|milegi|milega|kya|hai)\b", _fq): | |
| return (f"Filhaal hum koi free products ya giveaways offer nahi karte — {_biz} ka " | |
| f"saara catalog sale ke liye hai. Lekin main aap ko sab se kam price wale " | |
| f"items aur koi current discount/deals dikha sakta hoon. Dikhaoon?"), [] | |
| return (f"We don't currently offer free products or giveaways — everything at {_biz} " | |
| f"is for sale. But I can show you our best-priced items and any current " | |
| f"discounts or deals. Would you like that?"), [] | |
| # Policy intent (shipping/returns/refund/warranty/payment/order-status) → hand | |
| # straight to RAG/policy BEFORE the LLM router. parse() already declines these, | |
| # but a code-mixed policy query ("used opened product return ho sakta hai?") | |
| # sets _force_llm, and the router would fabricate a product plan and answer with | |
| # a catalog non-answer instead of letting the policy page text answer. | |
| if _POLICY_Q_RE.search((q or "").lower()): | |
| return None, [] | |
| # Reverse lookup — "which section/category is X in", "where do I find X". | |
| # The mirror of category listing: name a product, get the storefront | |
| # collection(s) it lives under (answers the customer browsing the sidebar). | |
| _loc = _locate_target(q) | |
| if _loc: | |
| _lrows = load_rows(db) | |
| if _lrows and is_catalog_db(db, _lrows, cfg): | |
| _ans = _answer_locate(_loc, _lrows) | |
| if _ans: | |
| return _ans | |
| # Exact quoted product name ("what is the exact price of \"X\"?") — a long, | |
| # specific title tokenizes into many anchor words, and the anchor/list machinery | |
| # below OR-matches on those anchors and can surface unrelated products instead | |
| # of the one actually named. Resolve the quoted span directly against the | |
| # deterministic title matcher first; only a confident, unambiguous single row | |
| # short-circuits to an answer, so this never overrides a genuine listing query. | |
| _quoted = _extract_quoted(q) | |
| if _quoted and re.search(r"\b(price|cost|how much)\b", q, re.I): | |
| _qrows = load_rows(db) | |
| if _qrows and is_catalog_db(db, _qrows, cfg): | |
| _qbest, _qcands = _resolve_detail(_quoted, _qrows) | |
| if _qbest is not None and len(_ambiguous_alts(_quoted, _qrows)) <= 1: | |
| if _qbest.price is not None: | |
| return (f"{_qbest.title} — {_price_disp(_qbest)} — {_qbest.availability}", | |
| [_qbest.source] if _qbest.source else []) | |
| spec = parse(q) | |
| mp = _parse_multi(q) | |
| # A confident deterministic multi-product parse (explicit "X or/ya/vs Y" choice, | |
| # compare, basket with >=2 named products) is high-precision — the LLM router | |
| # below must not be allowed to downgrade it to a single product. Without this, | |
| # code-mixed "BMW S1000RR ya Kawasaki H2R available kaunsi?" set _force_llm and | |
| # the non-deterministic router sometimes replaced both names with one → flake. | |
| _det_mp = mp if (isinstance(mp, dict) and len(mp.get("names") or []) >= 2) else None | |
| # Same principle for a confident deterministic structured spec the small router | |
| # routinely degrades on code-mixed input: a count_split (both-stock-states, incl. | |
| # Roman-Urdu "kitne bik gaye kitne bache"), OR a min/max that carries a STOCK | |
| # dimension (include_oos / out_of_stock — e.g. "clearance mein unavailable mila | |
| # kar highest price"). The router drops that dimension and re-ranks the whole | |
| # catalog, so keep the regex spec when it confidently resolved one. | |
| _det_split = spec if (getattr(spec, "structured", False) and spec.anchors and ( | |
| spec.agg == "count_split" | |
| or (spec.agg in ("min", "max") | |
| and (getattr(spec, "include_oos", False) or spec.out_of_stock)))) else None | |
| rows = None | |
| _from_router = False | |
| # Make the LLM understanding layer PRIMARY when the question is code-mixed | |
| # (Roman-Urdu/Hinglish) — the English regex would mis-route it. Also fire it, | |
| # as before, whenever the regex fully declined. English structured questions | |
| # skip it entirely → fast, free, unchanged. | |
| _force_llm = _is_code_mixed(q) | |
| if (not spec.structured and not mp) or _force_llm: | |
| # Deterministic router declined (or non-English input). Fire the LLM extractor | |
| # on ANY non-trivial question so unusual phrasings — SMS-speak, Roman-Urdu, | |
| # pronoun ranges, paraphrase — are understood instead of needing a hand-coded | |
| # regex per phrasing. The router returns "other" for genuine non-catalog | |
| # questions (safe → RAG); skip only obvious greetings/smalltalk. | |
| _wc = len((q or "").split()) | |
| _skip = bool(_ROUTER_SKIP.match(q)) or ( | |
| not _force_llm and not _PRODUCTISH.search(q) and (_wc < 2 or _wc > 30)) | |
| plan = None | |
| if not _skip: | |
| rows = load_rows(db) | |
| if rows and is_catalog_db(db, rows, cfg): | |
| try: | |
| from services.catalog_router import extract_plan, heuristic_plan | |
| # LLM extractor first; deterministic backstop on LLM outage. Ground | |
| # the result in q so a weak model echoing a few-shot product on a | |
| # content-free follow-up never becomes a wrong/cross-tenant anchor. | |
| plan = _refine_plan(_ground_plan(extract_plan(q) or heuristic_plan(q), q), q) | |
| except Exception: | |
| plan = None | |
| # "browse" falls through to RAG unless it carries a price bound. | |
| _k = plan.get("kind") if plan else None | |
| _has_bound = bool(plan) and (plan.get("price_min") is not None or plan.get("price_max") is not None) | |
| if plan and _k not in (None, "other") and not (_k == "browse" and not _has_bound): | |
| _mp2, _spec2 = _plan_to_specs(plan) | |
| if _mp2 is not None or (_spec2 is not None and getattr(_spec2, "structured", False)): | |
| # Keep the confident deterministic multi-product parse / count_split | |
| # if present; only take the router's when the deterministic one was absent. | |
| mp = _det_mp if _det_mp is not None else _mp2 | |
| spec = _det_split if _det_split is not None else _spec2 | |
| _from_router = True | |
| # If the LLM produced nothing usable, fall back to the regex parse when it | |
| # had one (so a partial English structure still answers); else hand to RAG. | |
| if not _from_router and not spec.structured and not mp: | |
| return None, [] | |
| if rows is None: | |
| rows = load_rows(db) | |
| if not rows: | |
| return None, [] | |
| # DB-type gate: only a real product catalog gets the structured engine; a | |
| # text/docs corpus (with stray priced rows) falls through to RAG/LLM. | |
| if not is_catalog_db(db, rows, cfg): | |
| return None, [] | |
| # Deterministic "buyable/advisory" refinement — apply the in-stock filter to a | |
| # budget/list/min/max spec when the customer signalled they want something | |
| # PURCHASABLE ("X gift under N", "under N jo mil jaye / buy ho sake", "available | |
| # …"). This mirrors _refine_plan for the REGEX path so a sold-out item never | |
| # leaks when the LLM router didn't fire. Only ADDS the filter (never overrides an | |
| # explicit out/both/include-oos request). Universal, language-agnostic. | |
| if (getattr(spec, "structured", False) and spec.agg in ("list", "min", "max") | |
| and not spec.in_stock and not spec.out_of_stock | |
| and not getattr(spec, "include_oos", False) | |
| and _BUYABLE_INTENT.search(q or "")): | |
| spec.in_stock = True | |
| # Multi-product reasoning (compare / order / basket): fan out to resolve each | |
| # named product, then compute. Owns the question outright — a correct answer | |
| # or hand to RAG; never a single-product dump masquerading as a comparison. | |
| if mp: | |
| _mres = _answer_multi(mp, rows) | |
| if _mres: | |
| return _mres | |
| # Multi resolution failed (e.g. a broad "X or Y" that isn't two specific | |
| # products) → fall back to the single-spec parse rather than dropping to RAG, | |
| # so a valid category/list answer isn't lost to a buy-choice misfire. | |
| if not getattr(spec, "structured", False): | |
| return None, [] | |
| return _execute_spec(spec, rows, cfg, max_list, from_router=_from_router, raw_q=q) | |
| except Exception: | |
| return None, [] | |
| def _execute_spec(spec: "Spec", rows: list[Row], cfg: dict | None, max_list: int, | |
| from_router: bool = False, raw_q: str = "") -> tuple[str | None, list[str]]: | |
| """Deterministic execution of a (filter + aggregation) spec against the rows.""" | |
| try: | |
| excl = _excl_re(cfg, spec.anchors) | |
| # Slash-joined synonyms ("motorcycle/bike") mean EITHER, but _hit demands ALL — | |
| # collapse each group to the catalog's actual term (highest doc-frequency) so a | |
| # synonym the store doesn't tag can't zero the set. Applied before generic relax. | |
| _anchors = list(spec.anchors) | |
| for grp in getattr(spec, "alt_groups", None) or []: | |
| present = [a for a in _anchors if a in grp] | |
| if len(present) > 1: | |
| keep = max(present, key=lambda a: _anchor_df(a, rows)) | |
| _anchors = [a for a in _anchors if a == keep or a not in present] | |
| # Repair anchors the catalog doesn't contain (typos) against real merchant | |
| # vocabulary before matching, so "construccion"->"construction" resolves. Only | |
| # touches absent (df 0) tokens, so existing exact matches are untouched. | |
| _anchors = _correct_anchors(_anchors, rows) | |
| # Generic type-nouns ("car"/"model") demanded as hard anchors collapse an | |
| # aggregation set; relax them for matching while the label keeps full anchors. | |
| _m_anchors = _selective_anchors(_anchors, rows, spec.agg) | |
| _base = [r for r in rows if _hit(r, _m_anchors, spec.title_only)] | |
| _strict_hit = bool(_base) # did strict all-anchor matching resolve anything? | |
| # LOST-CATEGORY RECOVERY: when the matching anchors are empty, the _hit above | |
| # matched the WHOLE catalog. For a PRICE-BOUNDED aggregation this would dump every | |
| # cheap item — the failure mode when the LLM router drops the category or returns | |
| # None under load ("F1 …under 9000" → empty spec + price bound). If the RAW query | |
| # actually names a category resolvable to a storefront collection, answer THAT set | |
| # instead of the catalog. Additive + universal: fires only on empty anchors, so a | |
| # real category query (has anchors) and a genuine price-only browse (no resolvable | |
| # category) are both untouched; needs a price bound so open min/max stay catalog-wide. | |
| _recovered_lock = False | |
| if (not _m_anchors and raw_q and spec.agg in ("list", "min", "max", "count", "count_split") | |
| and (spec.price_min is not None or spec.price_max is not None)): | |
| _rec = _resolve_collection(_anchors_from(raw_q), rows) | |
| if _rec: | |
| _base = _rec | |
| _strict_hit = True | |
| _recovered_lock = True | |
| if not _base and spec.anchors: | |
| # Strict all-anchor match found nothing — fall back to title-coverage so | |
| # a fully-named product still resolves. Only triggers when strict yields | |
| # 0, so a non-empty count can never be inflated. | |
| # The fallback's real job is the SUPERSET case: the query names the whole | |
| # stored title plus extra descriptors (coverage→1.0, e.g. "Pop N Play - | |
| # Quick Push Pop Game" → row "Pop N Play"). A loose 0.7 floor admitted | |
| # same-category SIBLINGS sharing only generic words ("Goldfish Fountain | |
| # Pen Single" for a query naming a DIFFERENT fountain pen) → a | |
| # confidently-wrong product. Require near-full coverage AND keep only the | |
| # best-covered rows, so an exact superset wins outright and a query for an | |
| # absent product yields nothing (→ graceful RAG/IDK, not a wrong sibling). | |
| # Match BIDIRECTIONALLY (this fallback only fires when the strict all-anchor | |
| # _hit found nothing, i.e. a specific NAMED product that didn't exact-match): | |
| # - title-coverage catches a query that NAMES a stored title plus extras; | |
| # - query-coverage catches a query that is a SUBSET of a long SEO title | |
| # ("Rasha For Women By Al" → "...Rehab EDP") and, crucially, prefers a | |
| # typo'd DISTINCTIVE token ("mgahribi"→"Maghribi") over a generic one | |
| # ("by al"), so a misspelled brand resolves to its products instead of an | |
| # unrelated sibling — and a truly absent name ("kawaii") matches nothing. | |
| # The avg-coverage floor rejects siblings sharing only generic words. Applies | |
| # to every aggregation: category queries match via _hit and never reach here. | |
| _aset = _anchor_set(spec.anchors) | |
| _scored = [] | |
| for r in rows: | |
| tc = _title_cover(r.title, _aset, spec.anchors) | |
| qc = _query_cover(r.title, spec.anchors) | |
| if (tc >= 0.85 or qc >= 0.85) and (tc + qc) / 2 >= 0.5: | |
| _scored.append((max(tc, qc), r)) | |
| _scored.sort(key=lambda x: -x[0]) | |
| _top = _scored[0][0] if _scored else 0.0 | |
| _base = [r for c, r in _scored if c >= _top - 1e-9] | |
| # Collection-exact override: when the anchor names a STOREFRONT COLLECTION | |
| # (sidebar/menu group) verbatim, the authoritative member set is that | |
| # collection — never title/tag substring matches. "Fujifilm" the collection | |
| # is 2 cameras; "fujifilm" as a brand tag is on every film. Exact membership | |
| # returns the real group the customer is browsing, not coincidental name hits. | |
| _coll_locked = _recovered_lock # recovered collection is authoritative → skip tag-narrowing | |
| _locked_collection_name = "" | |
| # VERBATIM-COLLECTION LOCK: when the customer's words contain a storefront | |
| # collection's FULL name (normalized: case/punctuation-free, digits KEPT — | |
| # "2-3 Years" → "2 3 years", "Age 3+" → "age 3"), that collection is the | |
| # authoritative answer set. The signature tier below drops 1-char digit | |
| # tokens, so every "0-1/1-2/2-3 Years" collection collides into {years} and | |
| # counts merge across ranges; contiguous-phrase matching on the normalized | |
| # raw query keeps ranges distinct and also survives all-stop-word names | |
| # ("All Cat Products" → anchors reduce to the generic "cat" and never lock). | |
| # Longest matching name wins. Fires only on a verbatim naming; otherwise | |
| # every existing tier runs unchanged. | |
| if not _coll_locked and raw_q and spec.agg in ("count", "exists", "list", "count_split", "min", "max"): | |
| _qn = " " + " ".join(re.findall(r"[a-z0-9]+", raw_q.lower())) + " " | |
| _cmap: dict[str, list[Row]] = {} | |
| for _r in rows: | |
| for _el in _r.colls.split("|"): | |
| _el = _el.strip() | |
| if _el: | |
| _cmap.setdefault(_el, []).append(_r) | |
| _bestc = None | |
| # The locked name must cover EVERY significant anchor token: "quilted | |
| # tote bags" contains the collection name "Tote bags" verbatim, but | |
| # locking to it would silently DROP the 'quilted' anchor and answer | |
| # with non-quilted totes. A partial-phrase name never locks; the | |
| # specific query falls through to strict title matching instead. | |
| _lksig = frozenset(t for t in re.findall(r"[a-z0-9]{2,}", " ".join(spec.anchors).lower()) if t not in _STOP) | |
| for _cnm, _mem in _cmap.items(): | |
| _cn = " ".join(re.findall(r"[a-z0-9]+", _cnm.lower())) | |
| if _cn and f" {_cn} " in _qn and (_bestc is None or len(_cn) > _bestc[0]): | |
| if _lksig and not _lksig <= frozenset(t for t in re.findall(r"[a-z0-9]{2,}", _cn) if t not in _STOP): | |
| continue | |
| _bestc = (len(_cn), _mem, _cnm) | |
| if _bestc: | |
| _base = _bestc[1] | |
| _locked_collection_name = _bestc[2] | |
| _strict_hit = True | |
| _coll_locked = True | |
| # lock_only spec (anchors emptied to stop-words): the verbatim lock was its | |
| # only permitted resolution — without it, answering would dump the whole | |
| # catalog for an open browse. Hand to RAG exactly as the old guard did. | |
| if getattr(spec, "lock_only", False) and not _coll_locked: | |
| return None, [] | |
| if not _coll_locked and spec.anchors and spec.agg in ("count", "exists", "list", "count_split"): | |
| def _sig(toks): | |
| return frozenset(t for t in toks if len(t) >= 2 and t not in _STOP) | |
| _A = _sig(re.sub(r"[^a-z0-9]+", " ", " ".join(spec.anchors).lower()).split()) | |
| if _A: | |
| # A row is a collection member iff one of its collection names has the | |
| # SAME significant-token set as the query (order-free, ignoring 1-char | |
| # tokens like the "z" in "Cra-Z-Art" and scaffolding words like "in"). | |
| def _coll_match(r): | |
| for el in r.colls.split("|"): | |
| if el and _sig(el.split()) == _A: | |
| return True | |
| return False | |
| _members = [r for r in rows if _coll_match(r)] | |
| if _members: | |
| _base = _members | |
| _coll_locked = True | |
| # TYPE-WORD COLLECTION UNION: a verbatim/sig lock on "Tote bags" (8 members) | |
| # is the wrong set when the store's own taxonomy uses the term as a TYPE | |
| # word — rtwcreation files ~150 totes across 40+ collections named | |
| # "<style> tote bags" PLUS a micro-collection literally named "Tote bags". | |
| # When >=3 collection names token-contain the anchor term, the term is a | |
| # shared type, not one browseable group (unlike brand locks: "Fujifilm" | |
| # names exactly ONE collection, so its 2 cameras stay authoritative) — | |
| # answer the union of all type-matching collections' members instead. | |
| if _coll_locked and not _recovered_lock and spec.anchors: | |
| _tsig = frozenset(t for t in re.findall(r"[a-z0-9]{2,}", " ".join(spec.anchors).lower()) if t not in _STOP) | |
| if _tsig: | |
| _tnames, _tmem, _tseen = set(), [], set() | |
| for _r in rows: | |
| _match = False | |
| for _el in _r.colls.split("|"): | |
| _el = _el.strip() | |
| if not _el: | |
| continue | |
| if _tsig <= frozenset(t for t in re.findall(r"[a-z0-9]{2,}", _el.lower()) if t not in _STOP): | |
| _tnames.add(_el) | |
| _match = True | |
| if _match and id(_r) not in _tseen: | |
| _tseen.add(id(_r)) | |
| _tmem.append(_r) | |
| if len(_tnames) >= 3 and len(_tmem) > len(_base): | |
| _base = _tmem | |
| # Fuzzy/subset collection resolution — the universal tier that ends the synonym | |
| # treadmill. When the exact same-token-set match above didn't lock (the common | |
| # case: "f1 cars" vs collection "formula one f1", "formula one" subset of it, a | |
| # typo, or a member-title word), resolve the phrasing onto the store's own | |
| # collection vocabulary. The storefront collection is authoritative for a | |
| # category query, so its members override title-substring _hit guesses. | |
| # Gated to strict-miss only: when strict all-anchor _hit DID resolve products, | |
| # that exact match stands (no hijacking "is <Product> available?" into a whole | |
| # collection). Fires only for phrasings strict matching couldn't resolve — the | |
| # cases that today fall to weak title-coverage or empty → near-zero regression. | |
| if (not _coll_locked and not _strict_hit and spec.anchors and not spec.title_only | |
| and spec.agg in ("count", "exists", "list", "count_split", "min", "max")): | |
| _rmem = _resolve_collection(_anchors, rows) | |
| if _rmem: | |
| _base = _rmem | |
| _coll_locked = True | |
| sel = [] | |
| _oos = [] # matched the query but excluded only by the in-stock filter | |
| for r in _base: | |
| if excl and excl.search(r.title): | |
| continue | |
| # Only exclude clearly out-of-stock rows. Availability strings vary by | |
| # source ("available", "in stock", ""), so treat anything not explicitly | |
| # out-of-stock as in stock rather than demanding an exact "available". | |
| if spec.in_stock and re.search(r"out\s+of\s+stock|sold\s+out|unavailable|not\s+available", r.availability): | |
| _oos.append(r) | |
| continue | |
| if spec.out_of_stock and not _is_oos(r): | |
| continue | |
| sel.append(r) | |
| # Price bounds apply to every aggregation when present (count of X under Y, | |
| # cheapest X over Y, …) and require a known price. | |
| _collection_title_supplies_bound = False | |
| if _locked_collection_name and re.search( | |
| r"\b(?:under|below|less|over|above|more|between|up\s+to|rs|pkr)\b", | |
| _locked_collection_name, | |
| re.I): | |
| _title_amounts = [float(re.sub(r"[,\s]", "", value)) for value in re.findall( | |
| r"\d(?:[\d,\s]*\d)?", _locked_collection_name | |
| )] | |
| _collection_title_supplies_bound = any( | |
| bound is not None and any(abs(bound - amount) < 0.001 for amount in _title_amounts) | |
| for bound in (spec.price_min, spec.price_max) | |
| ) | |
| if (spec.price_min is not None or spec.price_max is not None) and not _collection_title_supplies_bound: | |
| def _in_bounds(p): | |
| if p is None: | |
| return False | |
| if spec.price_min is not None: | |
| if (p <= spec.price_min) if spec.strict_min else (p < spec.price_min): | |
| return False | |
| if spec.price_max is not None: | |
| if (p >= spec.price_max) if spec.strict_max else (p > spec.price_max): | |
| return False | |
| return True | |
| sel = [r for r in sel if _in_bounds(r.price)] | |
| # Category precision (shared by count/min/max/list): in a tag-bearing catalog, | |
| # narrow to rows whose STRUCTURED categories match the anchor (GT/attribute | |
| # semantics) so "most expensive in the Models category" / "how many in Nautica" | |
| # rank the real category, not every title that happens to contain the word. | |
| # SKIPPED for name-substring queries (title_only): "every item whose NAME | |
| # contains monochrome" must keep ALL title matches — narrowing to the rows | |
| # that happen to also carry a coincidental "monochrome" category tag (e.g. | |
| # one marker set) silently drops the products the user actually asked for. | |
| # Also skipped for untagged/HTML-crawl DBs (tagged ratio < 50%). | |
| if not _coll_locked and spec.anchors and not spec.title_only and spec.agg in ("count", "min", "max", "list", "count_split"): | |
| _tagged = sum(1 for r in rows if r.cats.strip()) | |
| if rows and (_tagged / len(rows)) >= 0.5: | |
| # Tags only override titles for a term the store ITSELF uses as a | |
| # category label (same significant-token set as some category name, | |
| # order-free — the collection lock's equality). Otherwise tag hits are | |
| # coincidental and narrowing undercounts: rtwcreation's "tote bags" | |
| # (156 title matches, tags say "totes"/"belted tote bag", never | |
| # "tote bag") collapsed to the 5 coincidentally-tagged ones. | |
| _asig = frozenset(t for t in re.findall(r"[a-z0-9]{2,}", " ".join(spec.anchors).lower()) if t not in _STOP) | |
| _vocab = _asig and any( | |
| frozenset(t for t in re.findall(r"[a-z0-9]{2,}", c.lower()) if t not in _STOP) == _asig | |
| for r in rows for c in r.cats.split(",") if c.strip()) | |
| if _vocab: | |
| _tag_sel = [r for r in sel if _cats_hit(r, _m_anchors)] | |
| if _tag_sel: | |
| sel = _tag_sel | |
| # Single named-product precision: when the query FULLY NAMES a specific | |
| # product (every significant word of a matched title is in the anchor set), | |
| # drop same-prefix SIBLINGS that only add descriptors — a lookup for | |
| # "BMW M8 1:24" must not also list "RC BMW M8 Diecast 1:24" or the "...Frame". | |
| # Parenthetical variant suffixes ("( 12 inches )") are ignored so an exact | |
| # name still resolves. Needs >=2 real anchors (a one-word category like | |
| # "bikes" must never collapse to a coincidental exact title) and is skipped | |
| # for name-substring (title_only) and price-bounded browses where the whole | |
| # matched set is the point. | |
| # Skipped when a collection lock resolved the set: the storefront collection | |
| # is authoritative membership, and members whose titles happen to be covered | |
| # by the anchors ("Set 1" ⊂ {cookware, sets}) must not shrink it — pakmitti's | |
| # "Cookware Sets" (27 members) collapsed to the 10 title-matching "Set N"s. | |
| _real_anchors = [a for a in spec.anchors if a not in _STOP] | |
| if (sel and not _coll_locked and not spec.title_only and len(_real_anchors) >= 2 | |
| and spec.price_min is None and spec.price_max is None): | |
| _aset_fn = _anchor_set(spec.anchors) | |
| def _fully_named(r): | |
| _t = re.sub(r"\([^)]*\)", " ", (r.title or "").lower()) | |
| toks = [t for t in re.findall(r"[a-z0-9]{2,}", _t) if t not in _STOP] | |
| return bool(toks) and all(t in _aset_fn for t in toks) | |
| _named = [r for r in sel if _fully_named(r)] | |
| if _named and len(_named) < len(sel): | |
| sel = _named | |
| label = " ".join(spec.anchors) if spec.anchors else "products" | |
| if spec.agg == "stock": | |
| if not sel: | |
| # A stock-status question presupposes the product exists. If our exact | |
| # match can't resolve it, abstain to RAG (embeddings / honest "no info") | |
| # rather than assert absence — never a confident false "we don't carry". | |
| return None, [] | |
| _oos_re = re.compile(r"out\s+of\s+stock|sold\s+out|unavailable|not\s+available") | |
| def _stk(r): | |
| st = "out of stock" if _oos_re.search(r.availability or "") else "in stock" | |
| pr = f" ({_price_disp(r)})" if r.price is not None else "" | |
| return r.title, st, pr | |
| if len(sel) == 1: | |
| t, st, pr = _stk(sel[0]) | |
| msg = (f"Yes — {t} is available and in stock{pr}." if st == "in stock" | |
| else f"{t} is currently out of stock{pr}.") | |
| return msg, _dedup([sel[0].source])[:max_list] | |
| lines = [f"- {t} — {st}{pr}" for t, st, pr in (_stk(r) for r in sel[:max_list])] | |
| if len(sel) > max_list: | |
| lines.append(f"…and {len(sel) - max_list} more — narrow it down (e.g. \"only in-stock\").") | |
| return "Here's the current availability:\n" + "\n".join(lines), _dedup(r.source for r in sel)[:max_list] | |
| if spec.agg == "count_split": | |
| if not sel: | |
| if from_router: | |
| return None, [] | |
| return (f"No — we don't currently carry any {label}. Is there something " | |
| f"else I can help you find?"), [] | |
| _av = [r for r in sel if not _is_oos(r)] | |
| _so = [r for r in sel if _is_oos(r)] | |
| n = len(sel) | |
| def _il(r): | |
| return f"- {r.title} — {_price_disp(r)}" if r.price is not None else f"- {r.title}" | |
| parts = [f"Of {n} {label} product{'s' if n != 1 else ''}: " | |
| f"{len(_av)} available, {len(_so)} out of stock (sold out)."] | |
| if _av: | |
| parts.append("**Available:**\n" + "\n".join(_il(r) for r in _av[:max_list])) | |
| if _so: | |
| parts.append("**Out of stock / sold out:**\n" + "\n".join(_il(r) for r in _so[:max_list])) | |
| return "\n\n".join(parts), _dedup(r.source for r in sel)[:max_list] | |
| if spec.agg in ("count", "exists"): | |
| n = len(sel) | |
| if n == 0: | |
| # Found it, but the "in stock" filter removed it → it IS carried, just | |
| # out of stock. Don't mislead the customer into thinking we never have it. | |
| if spec.in_stock and _oos: | |
| o = _oos[0] | |
| pr = f" (normally {_price_s(o.price, o.currency)})" if o.price is not None else "" | |
| return (f"We do carry {o.title}{pr}, but it's currently out of stock. " | |
| f"Would you like me to suggest similar in-stock options?"), _dedup(r.source for r in _oos)[:max_list] | |
| # Router-sourced plan that resolved to nothing = LOW CONFIDENCE (a weak | |
| # LLM may have mis-classified a policy/shipping/smalltalk question as a | |
| # product existence query). Abstain to RAG instead of asserting a | |
| # hardcoded "we don't carry X" — never confidently wrong on a guess. | |
| # The high-precision regex parser keeps its crisp negative. | |
| if from_router: | |
| return None, [] | |
| if spec.agg == "exists": | |
| return (f"No — we don't currently carry any {label}. I couldn't find any " | |
| f"in our catalog. Is there something else I can help you find?"), [] | |
| return (f"No — we don't currently carry any {label}. Is there something else " | |
| f"I can help you find?"), [] | |
| # Show the price alongside each item when known — answers the common | |
| # "do you have X and its price" multi-intent in one shot. When the query | |
| # explicitly FILTERED by stock ("count the sold-out bikes", "how many | |
| # available X"), state each item's status so an OOS-only / in-stock-only | |
| # list never reads like a neutral catalog dump. | |
| _stk_suffix = (" - out of stock" if spec.out_of_stock | |
| else " - available" if spec.in_stock else "") | |
| def _il(r): | |
| base = f"- {r.title} — {_price_disp(r)}" if r.price is not None else f"- {r.title}" | |
| return base + _stk_suffix | |
| body = "\n".join(_il(r) for r in sel[:max_list]) | |
| more = f"\n…and {n - max_list} more." if n > max_list else "" | |
| if spec.agg == "count": | |
| _kind = ("sold-out " if spec.out_of_stock else "available " if spec.in_stock else "") | |
| head = (f"We have {n} {_kind}{label} product{'s' if n != 1 else ''}:" | |
| if spec.anchors else f"We have {n} {_kind}products in our catalog:") | |
| else: | |
| head = f"Yes — we carry {label}. We have {n} option{'s' if n != 1 else ''}:" | |
| return f"{head}\n{body}{more}", _dedup(r.source for r in sel)[:max_list] | |
| # min / max / list — all need a price. | |
| priced = [r for r in sel if r.price is not None] | |
| if not priced: | |
| # A PRICE intent that resolved only to price-less rows (e.g. a listing/ | |
| # collection chunk shadowing the product-detail chunk) must NOT answer with | |
| # bare names — that silently drops the price the customer asked for. Defer to | |
| # RAG/LLM, which reads the price from the product page text. A plain browse | |
| # ("list your X", no price word) still gets the name list. | |
| if not spec.price_intent and spec.agg == "list" and sel: | |
| body = "\n".join(f"- {r.title}" for r in sel[:max_list]) | |
| if len(sel) > max_list: | |
| body += f"\n…and {len(sel) - max_list} more — narrow it down (e.g. \"only in-stock\")." | |
| return f"Here are matching products from the catalog:\n{body}", _dedup(r.source for r in sel)[:max_list] | |
| return None, [] | |
| # Extremes (cheapest / most expensive) should name something a customer can | |
| # actually buy: prefer in-stock rows, falling back to all only if EVERY priced | |
| # match is out of stock (so we still answer rather than claim absence). | |
| # SKIPPED when the question wants the true extreme across all stock ("highest | |
| # priced X overall") — then a pricier out-of-stock item is the right answer. | |
| if spec.agg in ("min", "max") and not spec.include_oos: | |
| _avail = [r for r in priced if not re.search( | |
| r"out\s+of\s+stock|sold\s+out|unavailable|not\s+available", r.availability or "")] | |
| if _avail: | |
| priced = _avail | |
| priced.sort(key=lambda r: (-r.price if spec.agg == "max" else r.price, r.title.lower())) | |
| n_show = max_list if spec.agg == "list" else 5 | |
| items = priced[:n_show] | |
| # Name what was matched so a category/collection browse echoes the group the | |
| # customer asked for ("the Diecast Decor products"), not an anonymous header. | |
| head = (f"Here are the matching {label} products:" | |
| if spec.agg == "list" and spec.anchors | |
| else "Here are matching products from the catalog:") | |
| lines = [head] | |
| for i, r in enumerate(items, 1): | |
| lines.append(f"{i}. {r.title} - {_price_disp(r)} - {r.availability}") | |
| # Never truncate silently: a "show me all X" answer that drops items reads | |
| # as the full catalog — say how many more exist so the customer can ask on. | |
| if len(priced) > n_show: | |
| lines.append(f"…and {len(priced) - n_show} more — narrow it down (e.g. \"only in-stock\" or \"under Rs.5000\").") | |
| return "\n".join(lines), _dedup(r.source for r in items)[:max_list] | |
| except Exception: | |
| return None, [] | |