piper-assistant / services.py
indirapravianti's picture
Upload folder using huggingface_hub
02bc0b6 verified
Raw
History Blame
13.9 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