piper-assistant / services.py
indirapravianti's picture
Deploy Piper sticker restock manager Gradio app
1a65785
Raw
History Blame Contribute Delete
13.5 kB
import json
import math
import os
import tempfile
from datetime import datetime
import pandas as pd
from catalog_resolver import resolve_items
from config import call_llm, llm_available
from data import SAMPLE_CATALOG
from parser_fallback import parse_po_fallback
from po_normalizer import normalize_po_text
from prompts import DELIVERY_SYSTEM, BESTSELLER_SYSTEM, build_parse_po_system
def _extract_json_from_llm(raw: str) -> dict | None:
json_str = raw.strip()
if "```" in json_str:
json_str = "\n".join(
line for line in json_str.split("\n")
if not line.strip().startswith("```")
)
try:
return json.loads(json_str)
except json.JSONDecodeError:
start, end = raw.find("{"), raw.rfind("}") + 1
if start != -1 and end > start:
try:
return json.loads(raw[start:end])
except json.JSONDecodeError:
return None
return None
def _validate_po_data(data: dict) -> dict:
"""Stage 3a — enforce structured schema before catalog anchoring."""
items = data.get("items") or []
clean = []
for it in items:
if not isinstance(it, dict):
continue
qty = it.get("quantity", 0)
try:
qty = int(qty)
except (TypeError, ValueError):
qty = 0
product = str(it.get("product", "")).strip()
if product and qty > 0:
clean.append({
"product": product,
"quantity": qty,
"notes": str(it.get("notes", "")).strip(),
})
return {"items": clean, "store_notes": str(data.get("store_notes", "")).strip()}
def parse_po(store_name: str, po_text: str, all_pos: dict) -> tuple:
if not po_text.strip():
return pd.DataFrame(columns=["Product", "Qty", "Notes"]), "Please paste a PO first.", all_pos
used_fallback = False
data = None
# Stage 1: normalize Indonesian/English code-mixed slang
normalized, slang_fixes = normalize_po_text(po_text)
# Stage 2: Qwen extracts strict JSON (temperature=0.1 in config)
raw = call_llm(
f"Store: {store_name}\n\nPO text:\n{normalized}",
system=build_parse_po_system(SAMPLE_CATALOG),
)
if not raw.startswith("[LLM Error"):
data = _extract_json_from_llm(raw)
if data is None:
data = parse_po_fallback(normalized)
used_fallback = True
if not data.get("items"):
err = raw[:300] if raw.startswith("[LLM Error") else "No line items detected."
return (
pd.DataFrame(columns=["Product", "Qty", "Notes"]),
f"Could not parse PO. {err}",
all_pos,
)
data = _validate_po_data(data)
# Stage 3b: anchor product names to official catalog (fuzzy match)
raw_items = data.get("items", [])
items = resolve_items(raw_items, SAMPLE_CATALOG)
catalog_hits = sum(
1 for before, after in zip(raw_items, items)
if before.get("product", "").strip().lower() != after.get("product", "").strip().lower()
)
store_notes = data.get("store_notes", "")
rows = [
{
"Product": it.get("product", ""),
"Qty": it.get("quantity", 0),
"Notes": it.get("notes", ""),
}
for it in items
]
df = pd.DataFrame(rows) if rows else pd.DataFrame(columns=["Product", "Qty", "Notes"])
all_pos[store_name] = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"items": rows,
"store_notes": store_notes,
}
total_qty = sum(r["Qty"] for r in rows)
engine = "offline rules" if used_fallback else "Qwen 2.5-7B"
pipeline = "normalize → extract JSON → catalog anchor"
status = (
f"Parsed {len(rows)} items ({total_qty} stickers) from {store_name} | "
f"Pipeline: {pipeline} | Engine: {engine}"
)
if slang_fixes:
status += f" | Slang normalized: {', '.join(slang_fixes[:4])}"
if len(slang_fixes) > 4:
status += f" +{len(slang_fixes) - 4} more"
if catalog_hits:
status += f" | Catalog matched: {catalog_hits} name(s)"
if store_notes:
status += f" | Notes: {store_notes}"
return df, status, all_pos
def save_po(store_name: str, edited_table, all_pos: dict) -> tuple:
if edited_table is None or (isinstance(edited_table, pd.DataFrame) and edited_table.empty):
return all_pos, "Nothing to save."
if isinstance(edited_table, pd.DataFrame):
rows = edited_table.to_dict("records")
else:
rows = [{"Product": r[0], "Qty": r[1], "Notes": r[2]} for r in edited_table]
all_pos[store_name] = all_pos.get(
store_name,
{"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), "store_notes": ""},
)
all_pos[store_name]["items"] = rows
return all_pos, f"Saved {len(rows)} items for {store_name}."
def build_demand_table(all_pos: dict, stock_df) -> tuple:
if isinstance(stock_df, list):
stock_df = pd.DataFrame(stock_df, columns=["Product", "In Stock"])
demand: dict[str, int] = {}
stores_with_po: list[str] = []
for store, po_data in all_pos.items():
stores_with_po.append(store)
for item in po_data.get("items", []):
qty = item["Qty"] if isinstance(item["Qty"], (int, float)) else 0
demand[item["Product"]] = demand.get(item["Product"], 0) + int(qty)
if not demand:
return (
pd.DataFrame(columns=["Product", "Total Demand", "In Stock", "Shortage"]),
"No POs parsed yet. Go to the PO Intake tab first.",
)
stock_map = {}
if stock_df is not None and not stock_df.empty:
stock_map = {row["Product"]: int(row["In Stock"]) for _, row in stock_df.iterrows()}
rows = [
{
"Product": p,
"Total Demand": d,
"In Stock": stock_map.get(p, 0),
"Shortage": max(0, d - stock_map.get(p, 0)),
}
for p, d in sorted(demand.items())
]
df = pd.DataFrame(rows)
shortage_total = sum(r["Shortage"] for r in rows)
summary = (
f"POs from {len(stores_with_po)} stores | "
f"{len(rows)} products | "
f"Shortage: {shortage_total} stickers to produce"
)
return df, summary
def calculate_printing(demand_df) -> tuple:
if demand_df is None or (isinstance(demand_df, pd.DataFrame) and demand_df.empty):
return (
pd.DataFrame(columns=["Product", "Qty to Print", "A3 Sheets (8 per sheet)"]),
"No shortage data. Check the Demand tab first.",
None,
)
if isinstance(demand_df, list):
demand_df = pd.DataFrame(demand_df, columns=["Product", "Total Demand", "In Stock", "Shortage"])
rows: list[dict] = []
total_sheets = 0
for _, row in demand_df.iterrows():
shortage = int(row.get("Shortage", 0))
if shortage > 0:
sheets = math.ceil(shortage / 8)
total_sheets += sheets
rows.append({
"Product": row["Product"],
"Qty to Print": shortage,
"A3 Sheets (8 per sheet)": sheets,
})
if not rows:
return (
pd.DataFrame(columns=["Product", "Qty to Print", "A3 Sheets (8 per sheet)"]),
"No printing needed -- stock covers all demand!",
None,
)
df = pd.DataFrame(rows)
path = os.path.join(tempfile.gettempdir(), f"print_order_{datetime.now():%Y%m%d_%H%M%S}.csv")
df.to_csv(path, index=False)
total_qty = sum(r["Qty to Print"] for r in rows)
summary = f"Print {total_qty} stickers across {len(rows)} varieties | Total A3 sheets: {total_sheets}"
return df, summary, path
def _delivery_doc_fallback(store_name: str, items: list, language: str) -> str:
lang = "id" if language.lower().startswith("indo") else "en"
date_str = datetime.now().strftime("%d %B %Y")
lines = items if lang == "en" else items
header = (
f"PAPERAIN STUDIO — DELIVERY NOTE\n{'=' * 40}\n"
f"{'Store' if lang == 'en' else 'Toko'}: {store_name}\n"
f"{'Date' if lang == 'en' else 'Tanggal'}: {date_str}\n\n"
f"{'Items' if lang == 'en' else 'Daftar barang'}:\n"
)
body = ""
total = 0
for i, it in enumerate([x for x in items if x.get("Qty", 0) > 0], 1):
qty = int(it.get("Qty", 0))
total += qty
body += f" {i}. {it['Product']}{qty} pcs\n"
footer = (
f"\n{'Total stickers' if lang == 'en' else 'Total stiker'}: {total} pcs\n\n"
+ (
"Please verify receipt and contact us if anything is missing.\n— Paperain Studio, Yogyakarta"
if lang == "en"
else "Mohon periksa barang saat diterima. Hubungi kami jika ada yang kurang.\n— Paperain Studio, Yogyakarta"
)
)
return header + body + footer
def generate_delivery_doc(store_name: str, all_pos: dict, language: str) -> str:
if store_name not in all_pos or not all_pos[store_name].get("items"):
return f"No PO data for {store_name}. Parse their PO first in the PO Intake tab."
items = all_pos[store_name]["items"]
items_text = "\n".join(
f"- {it['Product']}: {it['Qty']} pcs"
for it in items if it.get("Qty", 0) > 0
)
total = sum(it.get("Qty", 0) for it in items)
prompt = (
f"Store: {store_name}\n"
f"Date: {datetime.now():%B %d, %Y}\n"
f"Language: {language}\n"
f"Total items: {total}\n\n"
f"Items to deliver:\n{items_text}"
)
raw = call_llm(prompt, system=DELIVERY_SYSTEM)
if raw.startswith("[LLM Error"):
return _delivery_doc_fallback(store_name, items, language)
return raw
def generate_bestseller_report(all_pos: dict, language: str) -> str:
if not all_pos:
return "No PO data yet. Parse some store POs first to generate recommendations."
demand: dict[str, int] = {}
store_demand: dict[str, dict[str, int]] = {}
for store, po_data in all_pos.items():
store_demand[store] = {}
for item in po_data.get("items", []):
qty = int(item.get("Qty", 0)) if isinstance(item.get("Qty", 0), (int, float)) else 0
demand[item["Product"]] = demand.get(item["Product"], 0) + qty
store_demand[store][item["Product"]] = qty
if not demand:
return "No order data to analyze."
sales_text = "Aggregated demand across all stores:\n"
for product, total in sorted(demand.items(), key=lambda x: x[1], reverse=True):
stores = [
f"{s} ({store_demand[s].get(product, 0)})"
for s in store_demand if store_demand[s].get(product, 0) > 0
]
sales_text += f"- {product}: {total} total ({', '.join(stores)})\n"
raw = call_llm(f"Language: {language}\n\n{sales_text}", system=BESTSELLER_SYSTEM)
if raw.startswith("[LLM Error"):
return _bestseller_fallback(demand, store_demand, language)
return raw
def _bestseller_fallback(demand: dict, store_demand: dict, language: str) -> str:
lang = "id" if language.lower().startswith("indo") else "en"
top = sorted(demand.items(), key=lambda x: x[1], reverse=True)[:8]
title = "Piper's Best Seller Report" if lang == "en" else "Laporan Best Seller dari Piper"
lines = [f"{title}\n{'=' * 36}\n"]
for rank, (product, total) in enumerate(top, 1):
stores = [s for s, d in store_demand.items() if d.get(product, 0) > 0]
if lang == "en":
lines.append(f"{rank}. {product}{total} ordered across {len(stores)} store(s)")
else:
lines.append(f"{rank}. {product}{total} dipesan dari {len(stores)} toko")
lines.append(
"\nThese designs sell consistently — great for restock and new partner onboarding."
if lang == "en"
else "\nDesain ini laris — cocok untuk restock dan rekomendasi ke toko baru."
)
return "\n".join(lines)
def run_full_workflow(all_pos: dict, stock_df) -> tuple:
"""Demand + print in one click (uses existing PO state)."""
demand_df, demand_summary = build_demand_table(all_pos, stock_df)
print_df, print_summary, print_csv = calculate_printing(demand_df)
combined = f"{demand_summary}\n\n{print_summary}"
return demand_df, combined, print_df, print_summary, print_csv
def format_po_log(all_pos: dict) -> str:
if not all_pos:
return "*No POs parsed yet.*"
md = ""
for store, data in all_pos.items():
md += f"### {store} ({data.get('timestamp', '')})\n"
for it in data.get("items", []):
md += f"- {it.get('Product', '')} x{it.get('Qty', 0)}\n"
if data.get("store_notes"):
md += f"\n*Notes: {data['store_notes']}*\n"
md += "\n---\n\n"
return md
def export_all_pos_csv(all_pos: dict):
if not all_pos:
return None
rows = []
for store, data in all_pos.items():
for item in data.get("items", []):
rows.append({
"Store": store,
"Product": item.get("Product", ""),
"Qty": item.get("Qty", 0),
"Notes": item.get("Notes", ""),
"Parsed At": data.get("timestamp", ""),
})
if not rows:
return None
df = pd.DataFrame(rows)
path = os.path.join(tempfile.gettempdir(), f"all_pos_{datetime.now():%Y%m%d_%H%M%S}.csv")
df.to_csv(path, index=False)
return path