File size: 6,253 Bytes
1a65785 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | """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
# Common Indonesian / informal aliases → canonical catalog names
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
# "Product x25" or "Product | 25"
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
# "name 20 pcs" or "name, 20"
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
# Comma-separated inline: "borobudur 20, wayang 15"
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))
# Deduplicate by product, sum quantities
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],
}
|