Spaces:
Sleeping
Sleeping
| """ | |
| GIVA product classification & cleaning helpers. | |
| `primary_category(sku)` is a faithful Python port of GIVA's canonical BigQuery | |
| CASE statement over guts.atoms — the authoritative Gold / Silver / Accessories / | |
| Other classifier. Validated 1:1 against the goldDeets signal on the sample export. | |
| """ | |
| import re | |
| import json | |
| # --------------------------------------------------------------------------- | |
| # 1. Material classification (canonical — from guts.atoms CASE statement) | |
| # --------------------------------------------------------------------------- | |
| _JEWEL = ['BR', 'CH', 'CO', 'ER', 'MN', 'NE', 'NP', 'PD', 'pd', | |
| 'R', 'HE', 'SH', 'GENMTO', 'MT', 'SET'] | |
| _SILVER_PREFIX = ['AVA', 'A0', 'PX', 'BH', 'CF', 'HP', 'ID', 'PF', 'WC'] | |
| _ACCESSORY = ['GC', 'GW', 'PM', 'CA', 'PU'] | |
| def primary_category(sku: str) -> str: | |
| """Return 'Gold' | 'Silver' | 'Accessories' | 'Other' for a SKU. | |
| Port of the BigQuery CASE on guts.atoms. Order is significant — first | |
| matching branch wins, exactly as in SQL. | |
| """ | |
| if not sku: | |
| return 'Other' | |
| s = str(sku) | |
| gd = s.startswith('GD') | |
| has = lambda p: re.search(p, s) is not None | |
| # --- Gold --- | |
| if gd and any(has(p) for p in _JEWEL): | |
| return 'Gold' | |
| if has('MNT'): | |
| return 'Gold' | |
| # --- Silver --- | |
| if (not gd) and any(has(p) for p in _JEWEL): | |
| return 'Silver' | |
| for p in _SILVER_PREFIX: | |
| if has(p): | |
| return 'Silver' | |
| # --- Accessories --- | |
| for p in _ACCESSORY: | |
| if has(p): | |
| return 'Accessories' | |
| return 'Other' | |
| def is_solid_gold(sku: str) -> bool: | |
| """True only for real gold pieces (drives the 9KT/14KT UI filter).""" | |
| return primary_category(sku) == 'Gold' | |
| # --------------------------------------------------------------------------- | |
| # 2. Tag cleaning — strip operational / boilerplate noise before embedding | |
| # --------------------------------------------------------------------------- | |
| # Tag KEY prefixes to drop. Two groups: | |
| # (a) STRUCTURED — already extracted cleanly from `properties`, so redundant. | |
| # (b) OPERATIONAL — dimensions, promos, campaign/ops flags: pure noise. | |
| _DROP_PREFIXES = { | |
| # (a) structured — captured from properties instead | |
| 'color', 'metal', 'subcategory', 'category', 'style', 'collectionname', | |
| 'motifs', 'carat', 'stone', 'stonecolor', 'stoneshape', 'stonesize', | |
| 'stonesetting', 'settingtype', 'component type', 'plating', 'platingcolor', | |
| 'shopfor', 'type', 'font', | |
| # (b) operational / dimensional | |
| 'height', 'weight', 'width', 'thickness', 'diameter', 'avggrossweight', | |
| 'grossweight', 'goldweight', 'chainlength', 'ringsize', 'diameter', | |
| 'locktype', 'adjustable', 'withchain', 'source', 'brand', '925hallmark', | |
| '925', '999', 'referencesku', 'solitaire', 'weight reduction', | |
| 'product comes with attached chain', 'sku of attached chain', | |
| # (b) promo / campaign / ops flags | |
| 'goat', 'boss', 'bfs', 'sfs', 'ndd', 'npd', 'block', 'deal', 'flash', | |
| 'lifetime', 'pm', 'px', 'b2g1', 'bdaysale2025', 'gifts', 'jep', 'dotd', | |
| 'diwali', 'dhanteras', 'karwachauth', 'no', 'aadi', 'personalised', | |
| } | |
| # Whole tags (no underscore) that are still junk. | |
| _JUNK_TAGS_EXACT = { | |
| 'jep', 'hidden recommendation', 'recommendations disabled', | |
| 'authentication certificate', 'hallmarked jewellery', | |
| '30 days easy return', 'goat_sale', 'flash_sale', | |
| 'best suited for women', 'best suited for men', | |
| } | |
| def clean_tags(tags_raw: str) -> list[str]: | |
| """Split the comma-separated tag soup and return only genuinely useful, | |
| non-redundant descriptive tags. | |
| Structured attributes (colour/metal/motif/etc.) come from `properties`, so | |
| this drops those prefixes as redundant, plus all operational/promo noise, | |
| plus anything containing a digit (promo codes, dimensions, sizes). | |
| """ | |
| if not tags_raw: | |
| return [] | |
| out = [] | |
| for t in str(tags_raw).split(','): | |
| t = t.strip() | |
| if not t or any(ch.isdigit() for ch in t): | |
| continue | |
| low = t.lower() | |
| if low in _JUNK_TAGS_EXACT: | |
| continue | |
| # Every meaningful attribute already comes from `properties`; the only | |
| # tags worth keeping are clean descriptive phrases. Underscore tags are | |
| # all Key_Value ops/promo/campaign flags -> drop them wholesale. | |
| if '_' in t: | |
| continue | |
| if low in _DROP_PREFIXES: | |
| continue | |
| out.append(t) | |
| return out | |
| # --------------------------------------------------------------------------- | |
| # 3. Normalization helpers | |
| # --------------------------------------------------------------------------- | |
| def normalize_colour(raw: str) -> str: | |
| """Map GIVA's inconsistent colour strings to a clean set.""" | |
| if not raw: | |
| return 'Unknown' | |
| c = str(raw).strip().lower().replace(' ', '') | |
| return { | |
| 'gold': 'Gold', 'yellowgold': 'Yellow Gold', | |
| 'rosegold': 'Rose Gold', 'silver': 'Silver', | |
| 'white': 'White', 'black': 'Black', | |
| }.get(c, str(raw).strip().title()) | |
| def normalize_category(raw: str) -> str: | |
| """Lowercase + de-pluralize category tags (Bracelets -> bracelet).""" | |
| if not raw: | |
| return 'unknown' | |
| c = str(raw).strip().lower() | |
| return c[:-1] if c.endswith('s') and not c.endswith('ss') else c | |
| # Product type derived from the TITLE (more reliable than SKU prefix, which | |
| # mixes types). Ordered — first match wins. `earrings` before `ring` so the | |
| # "ring" inside "earring" never wins. | |
| _TYPE_PATTERNS = [ | |
| ("earrings", r"ear\s?ring|\bstud|jhumk|\bhoop|\bbali\b"), | |
| ("nosepin", r"nose\s?pin|nosepin|\bnath\b"), | |
| ("mangalsutra", r"mangal\s?sutra"), | |
| ("anklet", r"anklet|payal"), | |
| ("toe ring", r"toe\s?ring"), | |
| ("bracelet", r"bracelet|bangle|\bkada\b|kada"), | |
| ("rakhi", r"rakhi"), | |
| ("ring", r"\bring\b|\brings\b|\bband\b"), | |
| ("pendant", r"pendant|locket"), | |
| ("necklace", r"necklace|choker|\bhaar\b"), | |
| ("chain", r"\bchain\b"), | |
| ("charm", r"\bcharm"), | |
| ("coin", r"\bcoin|\bbar\b"), | |
| ] | |
| _TYPE_RE = [(t, re.compile(p, re.I)) for t, p in _TYPE_PATTERNS] | |
| def product_type(title: str) -> str: | |
| """Coarse product type from the title: ring/earrings/pendant/…/other.""" | |
| if not title: | |
| return "other" | |
| for t, rx in _TYPE_RE: | |
| if rx.search(title): | |
| return t | |
| return "other" | |
| def has_stone(props: dict) -> bool: | |
| """True if the piece has a stone/diamond (for 'without stones' queries).""" | |
| if not props: | |
| return False | |
| for key in ("Stone", "stone"): | |
| v = props.get(key) | |
| if isinstance(v, list): | |
| v = v[0] if v else None | |
| if v and str(v).strip().lower() not in ("none", "no", ""): | |
| return True | |
| if props.get("diamonds"): | |
| return True | |
| if str(props.get("Solitaire", "")).lower() == "yes": | |
| return True | |
| return False | |
| def parse_price(raw) -> float | None: | |
| """Parse GIVA's price strings, which use comma thousands separators. | |
| e.g. '5,999' -> 5999.0. Returns None for blank/unparseable values. | |
| IMPORTANT: a plain float() silently fails on the comma — always use this. | |
| """ | |
| if raw is None: | |
| return None | |
| s = str(raw).replace(',', '').strip() | |
| if not s: | |
| return None | |
| try: | |
| v = float(s) | |
| return v if v > 0 else None | |
| except ValueError: | |
| return None | |
| def parse_properties(props_raw) -> dict: | |
| """Safely parse the properties JSON column into a dict.""" | |
| if not props_raw: | |
| return {} | |
| if isinstance(props_raw, dict): | |
| return props_raw | |
| try: | |
| return json.loads(props_raw) | |
| except (ValueError, TypeError): | |
| return {} | |
| # --------------------------------------------------------------------------- | |
| # Quick self-test on the known sample SKUs | |
| # --------------------------------------------------------------------------- | |
| if __name__ == '__main__': | |
| samples = { | |
| 'GDMT0816': 'Gold', 'PX167': 'Silver', 'PD03055': 'Silver', | |
| 'R02652': 'Silver', 'PX313': 'Silver', | |
| } | |
| ok = all(primary_category(k) == v for k, v in samples.items()) | |
| for k, v in samples.items(): | |
| print(f'{k:12} -> {primary_category(k):12} (expected {v})') | |
| print('SELF-TEST:', 'PASS' if ok else 'FAIL') | |