1"""Unit normalisation for grocery listings (CAD; g / ml / unit).23Re-implements and extends Food-Ka's parser (data/raw/foodka-src/foodka/normalize.py) so that the research4pipeline does not depend on production code. Differences are documented in reports/phase1_data_audit.md.5"""6from __future__ import annotations7import re, unicodedata8from dataclasses import dataclass910_UNITS = {"kg": (1000.0, "g"), "g": (1.0, "g"), "mg": (0.001, "g"), "lb": (453.592, "g"), "lbs": (453.592, "g"),11 "oz": (28.3495, "g"), "l": (1000.0, "ml"), "ml": (1.0, "ml"), "cl": (10.0, "ml"),12 "un": (1.0, "un"), "unite": (1.0, "un"), "unites": (1.0, "un"), "ea": (1.0, "un"), "each": (1.0, "un"),13 "pk": (1.0, "un"), "ch": (1.0, "un"), "pc": (1.0, "un"), "pcs": (1.0, "un"), "units": (1.0, "un"), "unit": (1.0, "un")}14_SIZE_RE = re.compile(r"(?:(\d+)\s*[x×*]\s*)?(\d+(?:[.,]\d+)?)\s*(kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk|ch|pcs?|units?)\b", re.I)15_RANGE_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:-|–|a|à)\s*(\d+(?:[.,]\d+)?)\s*(kg|g|lb|lbs|oz|ml|l)\b", re.I)16_PERKG_RE = re.compile(r"/\s*(kg|lb|100\s*g|100\s*ml|l|ch|un)\b", re.I)17_MULTI_RE = re.compile(r"^\s*(\d+)\s*(?:/|pour|for)\s*(?:(\d{1,4}(?:[.,]\d{1,2})?)\s*\$)?")18_APPROX_RE = re.compile(r"environ|approx|moyenne|avg|about|~", re.I)19_SOLD_INDIV_RE = re.compile(r"vendu individuellement|sold individually|a l'unite|à l'unité|l'unite|unite$", re.I)2021def strip_accents(s: str) -> str:22 return "".join(c for c in unicodedata.normalize("NFKD", s or "") if not unicodedata.combining(c))2324@dataclass25class Size:26 qty: float # in base unit (g, ml, un)27 base: str # 'g' | 'ml' | 'un'28 approx: bool = False29 source: str = "size_label" # where it was parsed from3031def parse_size(label: str | None, name: str | None = None) -> Size | None:32 """Parse '450 g', '2 L', '12 x 355 ml', '6 un', 'Vendu individuellement', '1,36 kg'; falls back to the name."""33 for text, src in ((label, "size_label"), (name, "name")):34 if not text:35 continue36 t = strip_accents(text).lower().replace("\u00a0", " ")37 m = _RANGE_RE.search(t)38 if m: # "1,2 - 1,5 kg" -> midpoint, approximate (checked before the simple pattern)39 a, b = float(m.group(1).replace(",", ".")), float(m.group(2).replace(",", "."))40 factor, base = _UNITS[m.group(3).lower()]41 return Size((a + b) / 2 * factor, base, True, src)42 m = _SIZE_RE.search(t)43 if m:44 mult = int(m.group(1)) if m.group(1) else 145 qty = float(m.group(2).replace(",", "."))46 factor, base = _UNITS[m.group(3).lower()]47 total = mult * qty * factor48 if total > 0:49 return Size(total, base, bool(_APPROX_RE.search(t)), src)50 if src == "size_label" and _SOLD_INDIV_RE.search(t):51 return Size(1.0, "un", False, src)52 return None5354@dataclass55class PriceLabel:56 per_weight_unit: str | None = None # 'kg','lb','100 g','100 ml','l' when the displayed price is per weight/volume57 multibuy_n: int | None = None # 'n / x $'58 multibuy_total: float | None = None5960def parse_price_label(label: str | None) -> PriceLabel:61 pl = PriceLabel()62 if not label:63 return pl64 t = strip_accents(label).lower().replace(" ", " ")65 m = _PERKG_RE.search(t)66 if m:67 pl.per_weight_unit = re.sub(r"\s+", " ", m.group(1))68 m = _MULTI_RE.match(t)69 if m:70 pl.multibuy_n = int(m.group(1))71 if m.group(2):72 pl.multibuy_total = float(m.group(2).replace(",", "."))73 return pl7475def unit_price(price: float | None, size: Size | None, label: PriceLabel | None = None) -> tuple[float, str] | None:76 """Comparable price: $/100 g, $/100 ml or $/unit. Returns None when not computable."""77 if price is None or price <= 0:78 return None79 if label and label.per_weight_unit:80 u = label.per_weight_unit81 if u == "kg": return (round(price / 10.0, 6), "100 g")82 if u == "lb": return (round(price / 4.53592, 6), "100 g")83 if u == "100 g": return (price, "100 g")84 if u == "100 ml": return (price, "100 ml")85 if u == "l": return (round(price / 10.0, 6), "100 ml")86 if size is None:87 return None88 if size.base == "un":89 return (round(price / size.qty, 6), "un")90 return (round(price / size.qty * 100.0, 6), f"100 {size.base}")91