| """Rule-based PO parser — fallback when the 7B model is unavailable or returns bad JSON."""
|
|
|
| import re
|
| from typing import Optional
|
|
|
| from data import SAMPLE_CATALOG
|
|
|
|
|
| PRODUCT_ALIASES: dict[str, str] = {
|
| "kucing hologram": "Holographic Cat Sticker",
|
| "kucing holo": "Holographic Cat Sticker",
|
| "holographic cat": "Holographic Cat Sticker",
|
| "stiker kucing hologram": "Holographic Cat Sticker",
|
| "sakura die-cut": "Sakura Die-Cut Sticker",
|
| "stiker sakura": "Sakura Die-Cut Sticker",
|
| "sakura": "Sakura Die-Cut Sticker",
|
| "cherry blossom": "Cherry Blossom Sticker",
|
| "rainbow unicorn": "Rainbow Unicorn Sticker",
|
| "unicorn": "Rainbow Unicorn Sticker",
|
| "boba tea": "Kawaii Boba Tea Sticker",
|
| "boba": "Kawaii Boba Tea Sticker",
|
| "sushi kawaii": "Kawaii Sushi Sticker",
|
| "sushi": "Kawaii Sushi Sticker",
|
| "ramen kawaii": "Kawaii Ramen Sticker",
|
| "ramen": "Kawaii Ramen Sticker",
|
| "floral wreath": "Floral Wreath Sticker",
|
| "butterfly garden": "Butterfly Garden Sticker",
|
| "vintage bicycle": "Vintage Bicycle Sticker",
|
| "coffee cup": "Coffee Cup Sticker",
|
| "galaxy star": "Galaxy Star Sticker",
|
| "monstera leaf": "Monstera Leaf Sticker",
|
| "monstera": "Monstera Leaf Sticker",
|
| "batik pattern": "Batik Pattern Sticker",
|
| "batik": "Batik Pattern Sticker",
|
| "koi fish": "Koi Fish Sticker",
|
| "koi": "Koi Fish Sticker",
|
| "borobudur": "Borobudur Temple Sticker",
|
| "wayang": "Wayang Shadow Sticker",
|
| "gamelan": "Gamelan Sticker",
|
| "penari jawa": "Javanese Dancer Sticker",
|
| "javanese dancer": "Javanese Dancer Sticker",
|
| "mushroom forest": "Mushroom Forest Sticker",
|
| "space astronaut": "Space Astronaut Sticker",
|
| "mountain landscape": "Mountain Landscape Sticker",
|
| "ocean wave": "Ocean Wave Sticker",
|
| "cactus succulent": "Cactus Succulent Sticker",
|
| "cute dog": "Cute Dog Sticker",
|
| "cute rabbit": "Cute Rabbit Sticker",
|
| "panda bear": "Panda Bear Sticker",
|
| "retro cassette": "Retro Cassette Sticker",
|
| "tropical bird": "Tropical Bird Sticker",
|
| "komodo dragon": "Komodo Dragon Sticker",
|
| }
|
|
|
| _CATALOG_LOWER = {p.lower(): p for p in SAMPLE_CATALOG}
|
|
|
|
|
| def _normalize_product(raw: str) -> str:
|
| cleaned = re.sub(r"^[\-\*\d\.\)\s]+", "", raw.strip())
|
| cleaned = re.sub(r"\s*(pcs|pc|lembar|sheet|sheets|buah|unit)\.?\s*$", "", cleaned, flags=re.I)
|
| cleaned = cleaned.strip(" ,;:")
|
| if not cleaned:
|
| return ""
|
|
|
| key = cleaned.lower()
|
| if key in _CATALOG_LOWER:
|
| return _CATALOG_LOWER[key]
|
|
|
| for alias, canonical in sorted(PRODUCT_ALIASES.items(), key=lambda x: -len(x[0])):
|
| if alias in key or key in alias:
|
| return canonical
|
|
|
| for cat_lower, canonical in _CATALOG_LOWER.items():
|
| if cat_lower in key or key in cat_lower:
|
| return canonical
|
|
|
| return cleaned.title()
|
|
|
|
|
| def _extract_qty(text: str) -> int:
|
| m = re.search(r"(\d+)\s*(?:pcs|pc|lembar|sheet|sheets|buah|unit)?\.?\s*$", text, re.I)
|
| if m:
|
| return int(m.group(1))
|
| m = re.search(r"x\s*(\d+)\s*$", text, re.I)
|
| if m:
|
| return int(m.group(1))
|
| m = re.search(r"\b(\d+)\b", text)
|
| return int(m.group(1)) if m else 0
|
|
|
|
|
| def _parse_line(line: str) -> Optional[dict]:
|
| line = line.strip()
|
| if not line or len(line) < 3:
|
| return None
|
| if re.match(r"^(item|product|qty|quantity|no\.?|#)\b", line, re.I):
|
| return None
|
| if re.match(r"^(pesanan|po\s*#|monthly|tolong|please|note|hai|makasih|thanks)", line, re.I):
|
| return None
|
|
|
|
|
| m = re.match(r"^(.+?)\s*[x×]\s*(\d+)\s*$", line, re.I)
|
| if m:
|
| product = _normalize_product(m.group(1))
|
| return {"product": product, "quantity": int(m.group(2)), "notes": ""} if product else None
|
|
|
| m = re.match(r"^(.+?)\s*\|\s*(\d+)\s*$", line)
|
| if m:
|
| product = _normalize_product(m.group(1))
|
| return {"product": product, "quantity": int(m.group(2)), "notes": ""} if product else None
|
|
|
|
|
| m = re.match(r"^(.+?)[,\s]+(\d+)\s*(?:pcs|pc)?\.?\s*$", line, re.I)
|
| if m:
|
| product = _normalize_product(m.group(1))
|
| return {"product": product, "quantity": int(m.group(2)), "notes": ""} if product else None
|
|
|
|
|
| if "," not in line and re.search(r"\d", line):
|
| qty = _extract_qty(line)
|
| if qty > 0:
|
| product_part = re.sub(r"\d+.*$", "", line).strip(" ,-")
|
| product = _normalize_product(product_part)
|
| if product:
|
| return {"product": product, "quantity": qty, "notes": ""}
|
|
|
| return None
|
|
|
|
|
| def _parse_comma_list(text: str) -> list[dict]:
|
| items: list[dict] = []
|
| for chunk in re.split(r"[,;\n]+", text):
|
| chunk = chunk.strip()
|
| if not chunk:
|
| continue
|
| m = re.match(r"^(.+?)\s+(\d+)\s*$", chunk)
|
| if m:
|
| product = _normalize_product(m.group(1))
|
| if product:
|
| items.append({"product": product, "quantity": int(m.group(2)), "notes": ""})
|
| return items
|
|
|
|
|
| def parse_po_fallback(po_text: str) -> dict:
|
| """Extract items from messy PO text without an LLM."""
|
| items: list[dict] = []
|
| seen: set[tuple[str, int]] = set()
|
| store_notes: list[str] = []
|
|
|
| for line in po_text.splitlines():
|
| parsed = _parse_line(line)
|
| if parsed and parsed["quantity"] > 0:
|
| key = (parsed["product"], parsed["quantity"])
|
| if key not in seen:
|
| seen.add(key)
|
| items.append(parsed)
|
| elif line.strip() and re.match(r"^(note|catatan|tolong|please|ship|kirim)", line.strip(), re.I):
|
| store_notes.append(line.strip())
|
|
|
| if len(items) < 2:
|
| items.extend(_parse_comma_list(po_text))
|
|
|
|
|
| merged: dict[str, dict] = {}
|
| for it in items:
|
| name = it["product"]
|
| if name in merged:
|
| merged[name]["quantity"] += it["quantity"]
|
| else:
|
| merged[name] = dict(it)
|
|
|
| return {
|
| "items": list(merged.values()),
|
| "store_notes": " ".join(store_notes)[:200],
|
| }
|
|
|