Upload folder using huggingface_hub
#1
by indirapravianti - opened
- .gitattributes +35 -35
- .gitignore +28 -0
- HACKATHON_GUIDE.md +488 -0
- README.md +120 -15
- app.py +13 -0
- catalog_resolver.py +64 -0
- config.py +54 -0
- data.py +65 -0
- parser_fallback.py +174 -0
- po_normalizer.py +57 -0
- prompts.py +49 -0
- requirements.txt +3 -0
- services.py +366 -0
- ui.py +1132 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,35 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
env/
|
| 10 |
+
|
| 11 |
+
# Local env / secrets (never commit tokens)
|
| 12 |
+
.env
|
| 13 |
+
.env.*
|
| 14 |
+
|
| 15 |
+
# IDE / OS
|
| 16 |
+
.idea/
|
| 17 |
+
.vscode/
|
| 18 |
+
.cursor/
|
| 19 |
+
.DS_Store
|
| 20 |
+
Thumbs.db
|
| 21 |
+
|
| 22 |
+
# Binary / reference docs (not needed for the app)
|
| 23 |
+
*.pdf
|
| 24 |
+
pdf_pages/
|
| 25 |
+
|
| 26 |
+
# Local design references (Stitch exports — not for deployment)
|
| 27 |
+
private/
|
| 28 |
+
stitch code.txt
|
HACKATHON_GUIDE.md
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build Small Hackathon -- The Complete Winning Guide
|
| 2 |
+
|
| 3 |
+
## Table of Contents
|
| 4 |
+
|
| 5 |
+
1. [Hackathon Overview](#hackathon-overview)
|
| 6 |
+
2. [What the Judges Want](#what-the-judges-want)
|
| 7 |
+
3. [Small Language Models -- Theory & Education](#small-language-models)
|
| 8 |
+
4. [Gradio -- Theory & Education](#gradio)
|
| 9 |
+
5. [Your Real Problem & Winning Strategy](#your-winning-strategy)
|
| 10 |
+
6. [Technical Stack & Hosting](#technical-stack--hosting)
|
| 11 |
+
7. [Bonus Quests to Earn](#bonus-quests)
|
| 12 |
+
8. [Pitch & Demo Tips](#pitch--demo-tips)
|
| 13 |
+
9. [Timeline & Checklist](#timeline--checklist)
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## Hackathon Overview
|
| 18 |
+
|
| 19 |
+
**Name:** Build Small Hackathon
|
| 20 |
+
**Hosted by:** Gradio + Hugging Face
|
| 21 |
+
**URL:** https://huggingface.co/build-small-hackathon
|
| 22 |
+
**Dates:** May 29 -- June 8, 2026 (two weekends to build, ship, and demo)
|
| 23 |
+
**Cash Prizes:** $15,000
|
| 24 |
+
**Registration deadline:** May 27, 2026
|
| 25 |
+
|
| 26 |
+
### The Philosophy
|
| 27 |
+
|
| 28 |
+
The hackathon motto is **"Making AI Fun Again."** The organizers feel AI has become anxiety-inducing -- labs keep releasing bigger and bigger models doing things that feel threatening. This hackathon wants to bring back the 2021 vibe: when models were small enough to tinker with, and building with AI was joyful and personal.
|
| 29 |
+
|
| 30 |
+
The core instruction: **Think small.** Armed with only 32 billion parameters, solve a real problem for someone you know -- or build something whimsical and delightful.
|
| 31 |
+
|
| 32 |
+
### Two Tracks (Pick One)
|
| 33 |
+
|
| 34 |
+
#### Track 1: "Backyard AI" (Chapter One) -- YOUR TRACK
|
| 35 |
+
|
| 36 |
+
> Solve a real problem for someone you actually know. Pick a person -- a neighbor, a parent, a small-business owner on your street -- and build something that makes their day measurably better.
|
| 37 |
+
|
| 38 |
+
**Judged on:**
|
| 39 |
+
- Problem is specific and real
|
| 40 |
+
- The person actually *used* it
|
| 41 |
+
- Honest fit between problem and the small-model constraint
|
| 42 |
+
- Polish of the Gradio app
|
| 43 |
+
|
| 44 |
+
#### Track 2: "An Adventure in Thousand Token Wood" (Chapter Two)
|
| 45 |
+
|
| 46 |
+
> Build something delightful that wouldn't exist without AI. A toy, a tiny game, a strange interactive story, an art experiment. The AI should be doing the fun thing -- not just helping you build it. Strange is good. Joyful is the bar.
|
| 47 |
+
|
| 48 |
+
**Judged on:**
|
| 49 |
+
- Genuinely delightful (would you show a friend?)
|
| 50 |
+
- AI is load-bearing for the experience
|
| 51 |
+
- Originality of concept
|
| 52 |
+
- Polish of the Gradio app
|
| 53 |
+
|
| 54 |
+
### Three Hard Rules ("Pack Light")
|
| 55 |
+
|
| 56 |
+
| # | Rule | What It Means |
|
| 57 |
+
|---|------|---------------|
|
| 58 |
+
| 1 | **Small Models Only** | Total parameters must be **<= 32 billion**. The model must fit on a laptop. |
|
| 59 |
+
| 2 | **Built on Gradio** | Your app must be a **Gradio app**, hosted as a **Hugging Face Space**. |
|
| 60 |
+
| 3 | **Show, Don't Tell** | Submit a short **demo video** and a **social-media post** alongside your Space. |
|
| 61 |
+
|
| 62 |
+
### The Deliverable (What You Actually Submit)
|
| 63 |
+
|
| 64 |
+
1. **A running Gradio app** hosted as a Hugging Face Space under the `build-small-hackathon` org
|
| 65 |
+
2. **A short demo video** (screen recording showing the app in action)
|
| 66 |
+
3. **A social media post** (tweet, LinkedIn, etc.) about your project
|
| 67 |
+
|
| 68 |
+
That's it. No slides. No paper. A working app, a video, and a post.
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## What the Judges Want
|
| 73 |
+
|
| 74 |
+
### Judge Mindset (Track 1 -- Backyard AI)
|
| 75 |
+
|
| 76 |
+
The judges are looking for projects where **a real person has a real problem, and AI genuinely helps.** They are NOT impressed by:
|
| 77 |
+
- Generic productivity tools
|
| 78 |
+
- Yet another RAG chatbot
|
| 79 |
+
- Technical complexity for its own sake
|
| 80 |
+
|
| 81 |
+
They ARE impressed by:
|
| 82 |
+
- **Authenticity** -- "I built this for myself / my own business"
|
| 83 |
+
- **Before vs. After** -- "This used to take 3 hours, now it takes 10 minutes"
|
| 84 |
+
- **Honest constraint fit** -- "I chose a small model because the task is structured extraction, not novel reasoning"
|
| 85 |
+
- **Polish** -- The Gradio app looks finished, not like a prototype
|
| 86 |
+
|
| 87 |
+
### The Secret Scoring Formula
|
| 88 |
+
|
| 89 |
+
| Criteria | Weight | What Wins |
|
| 90 |
+
|----------|--------|-----------|
|
| 91 |
+
| Real problem, real person | HIGH | You ARE the person. Show real documents, real store names. |
|
| 92 |
+
| Honest small-model fit | HIGH | Explain WHY a small model works: parsing structured docs, not writing novels |
|
| 93 |
+
| Gradio polish | HIGH | Beautiful UI, smooth flow, no jank |
|
| 94 |
+
| Actually used | MEDIUM | Demo with your actual PO documents and store data |
|
| 95 |
+
|
| 96 |
+
### The Meta-Game
|
| 97 |
+
|
| 98 |
+
Judges spend **3-10 minutes** per project. Your app must instantly communicate:
|
| 99 |
+
1. What the problem is
|
| 100 |
+
2. What the AI is doing
|
| 101 |
+
3. Why it's cool
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## Small Language Models
|
| 106 |
+
|
| 107 |
+
### What Is a "Small" Model?
|
| 108 |
+
|
| 109 |
+
In this hackathon, "small" means **<= 32 billion parameters**. For context:
|
| 110 |
+
- GPT-4 is rumored to be ~1.8 trillion parameters
|
| 111 |
+
- Claude, Gemini are similarly massive
|
| 112 |
+
- A 3B model is roughly 600x smaller than GPT-4
|
| 113 |
+
|
| 114 |
+
Parameters are the "brain cells" of a neural network. More parameters = more knowledge and reasoning ability, but also more compute, memory, and cost.
|
| 115 |
+
|
| 116 |
+
### Size Tiers and What They're Good For
|
| 117 |
+
|
| 118 |
+
| Size | Examples | RAM Needed | Good For | Limitations |
|
| 119 |
+
|------|----------|-----------|----------|-------------|
|
| 120 |
+
| **0.5B -- 1.5B** | Qwen2.5-0.5B/1.5B, Llama 3.2-1B | 0.5--1 GB | Simple extraction, classification, formatting | Limited knowledge, short context |
|
| 121 |
+
| **3B** | Qwen2.5-3B, Llama 3.2-3B, Phi-3-mini | ~2 GB | Structured extraction, summarization, translation | Struggles with complex multi-step logic |
|
| 122 |
+
| **7B -- 8B** | Llama 3.1-8B, Mistral-7B, Qwen2.5-7B | ~5 GB | Strong extraction, conversation, code generation | Needs 8GB+ RAM machine |
|
| 123 |
+
| **14B** | Qwen2.5-14B, Phi-4-14B | ~10 GB | Good reasoning, strong coding, multi-language | Needs 16GB+ RAM, GPU recommended |
|
| 124 |
+
| **27B -- 32B** | Gemma-2-27B, Qwen2.5-32B | ~20 GB | Near-GPT-3.5 quality for many tasks | Needs 32GB RAM or GPU |
|
| 125 |
+
|
| 126 |
+
### Your Laptop: 6 GB RAM
|
| 127 |
+
|
| 128 |
+
Your AMD Ryzen 7 3700U with 6 GB RAM can run:
|
| 129 |
+
- **Qwen2.5:1.5b** -- comfortably (needs ~1 GB)
|
| 130 |
+
- **Qwen2.5:3b** -- tight but possible (needs ~2 GB)
|
| 131 |
+
- **Qwen2.5:7b** -- NOT possible (needs ~5 GB, your machine only has ~1.5 GB free)
|
| 132 |
+
|
| 133 |
+
**For the hackathon demo on HF Spaces**: The model runs on HF's servers, not your laptop. RAM doesn't matter.
|
| 134 |
+
|
| 135 |
+
**For local development**: Use the HF Inference API (free tier) -- the model runs in the cloud. Your laptop just runs the Gradio UI.
|
| 136 |
+
|
| 137 |
+
### Why Small Models Are GOOD (Not Just "Acceptable")
|
| 138 |
+
|
| 139 |
+
This is key for your pitch:
|
| 140 |
+
|
| 141 |
+
1. **Privacy:** Store sales data, supplier info, pricing -- stays private. Indonesian small businesses don't want data going to OpenAI.
|
| 142 |
+
|
| 143 |
+
2. **Cost:** Zero API fees. No monthly subscription. A small business owner can run this forever for free on a decent laptop.
|
| 144 |
+
|
| 145 |
+
3. **Right-sizing:** Parsing "20 sticker kucing hologram 5x5cm" from a messy PO document is structured extraction -- you DON'T need GPT-4 for this. A 3B model handles it perfectly.
|
| 146 |
+
|
| 147 |
+
4. **Speed:** For structured tasks, small models are often FASTER than cloud APIs because there's no network latency.
|
| 148 |
+
|
| 149 |
+
5. **Accessibility:** Works for Indonesian small businesses who may not have reliable internet or cloud budgets.
|
| 150 |
+
|
| 151 |
+
### What Small Models Do Well (YOUR Use Cases)
|
| 152 |
+
|
| 153 |
+
- **Structured data extraction from messy formats** -- THE core of your app. Different stores send POs in Excel, PDF, handwritten, different formats. LLM normalizes them all into structured data.
|
| 154 |
+
- **Text formatting/templating** -- Generating delivery documents, order summaries
|
| 155 |
+
- **Translation** -- English/Indonesian bilingual output
|
| 156 |
+
- **Summarization** -- Best seller reports, monthly summaries
|
| 157 |
+
- **Classification** -- Categorizing sticker types, matching product names across stores
|
| 158 |
+
|
| 159 |
+
### What Small Models Struggle With
|
| 160 |
+
|
| 161 |
+
- Open-ended creative writing
|
| 162 |
+
- Complex multi-step mathematical reasoning
|
| 163 |
+
- Tasks requiring broad world knowledge
|
| 164 |
+
- Very long context (>4K tokens on smaller models)
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## Gradio
|
| 169 |
+
|
| 170 |
+
### What Is Gradio?
|
| 171 |
+
|
| 172 |
+
Gradio is a **Python library for building web-based UI for machine learning apps.** You write Python, and Gradio generates a full interactive web app.
|
| 173 |
+
|
| 174 |
+
Key facts:
|
| 175 |
+
- Made by Hugging Face (the hackathon host -- using it well MATTERS)
|
| 176 |
+
- Write **only Python** -- no HTML, CSS, or JavaScript needed (though you can add custom CSS)
|
| 177 |
+
- Generates a **shareable web URL** automatically
|
| 178 |
+
- Deploys to **Hugging Face Spaces** with one click
|
| 179 |
+
- Has 30+ built-in components (text boxes, tables, file uploads, buttons, chat interfaces, etc.)
|
| 180 |
+
|
| 181 |
+
### Gradio Core Concepts
|
| 182 |
+
|
| 183 |
+
#### 1. Interface (Simple Mode)
|
| 184 |
+
|
| 185 |
+
The simplest way. Define inputs, outputs, and a function:
|
| 186 |
+
|
| 187 |
+
```python
|
| 188 |
+
import gradio as gr
|
| 189 |
+
|
| 190 |
+
def greet(name):
|
| 191 |
+
return f"Hello, {name}!"
|
| 192 |
+
|
| 193 |
+
demo = gr.Interface(fn=greet, inputs="text", outputs="text")
|
| 194 |
+
demo.launch()
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
#### 2. Blocks (Advanced Mode) -- WHAT YOU SHOULD USE
|
| 198 |
+
|
| 199 |
+
Full layout control with rows, columns, tabs:
|
| 200 |
+
|
| 201 |
+
```python
|
| 202 |
+
import gradio as gr
|
| 203 |
+
|
| 204 |
+
with gr.Blocks(title="My App") as demo:
|
| 205 |
+
gr.Markdown("# My App Title")
|
| 206 |
+
with gr.Row():
|
| 207 |
+
with gr.Column(scale=1):
|
| 208 |
+
input_text = gr.Textbox(label="Input", lines=5)
|
| 209 |
+
submit_btn = gr.Button("Submit", variant="primary")
|
| 210 |
+
with gr.Column(scale=2):
|
| 211 |
+
output_table = gr.Dataframe(label="Results")
|
| 212 |
+
submit_btn.click(fn=process, inputs=input_text, outputs=output_table)
|
| 213 |
+
|
| 214 |
+
demo.launch()
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
#### 3. Key Components You'll Use
|
| 218 |
+
|
| 219 |
+
| Component | What It Does | Your Use Case |
|
| 220 |
+
|-----------|-------------|---------------|
|
| 221 |
+
| `gr.Textbox` | Text input/output | Paste PO text from stores |
|
| 222 |
+
| `gr.Dataframe` | Editable table | Show parsed orders, stock levels |
|
| 223 |
+
| `gr.Markdown` | Rich text display | Delivery documents, reports |
|
| 224 |
+
| `gr.Button` | Clickable button | Submit, Export, Generate |
|
| 225 |
+
| `gr.File` | File download | Export CSV |
|
| 226 |
+
| `gr.Dropdown` | Select from options | Choose store |
|
| 227 |
+
| `gr.Tab` | Tabbed interface | Organize PO intake, stock, print calc, delivery |
|
| 228 |
+
| `gr.Number` | Numeric input | Edit quantities |
|
| 229 |
+
| `gr.State` | Persist data between interactions | All session data |
|
| 230 |
+
|
| 231 |
+
#### 4. Theming & CSS (For Polish)
|
| 232 |
+
|
| 233 |
+
```python
|
| 234 |
+
# Built-in themes available: Soft, Glass, Monochrome, Default
|
| 235 |
+
# Custom CSS also supported for branding
|
| 236 |
+
demo.launch(theme=gr.themes.Soft(), css="custom styles here")
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
#### 5. State Management
|
| 240 |
+
|
| 241 |
+
```python
|
| 242 |
+
# gr.State persists data across interactions within a session
|
| 243 |
+
store_data = gr.State(value={})
|
| 244 |
+
|
| 245 |
+
def update_store(new_data, current_data):
|
| 246 |
+
current_data.update(new_data)
|
| 247 |
+
return current_data
|
| 248 |
+
|
| 249 |
+
button.click(fn=update_store, inputs=[input, store_data], outputs=[store_data])
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
### Gradio + Hugging Face Spaces (Deployment)
|
| 253 |
+
|
| 254 |
+
Your final app must be hosted on Hugging Face Spaces:
|
| 255 |
+
|
| 256 |
+
1. Create a Space under the `build-small-hackathon` org
|
| 257 |
+
2. Choose "Gradio" as the SDK
|
| 258 |
+
3. Push your `app.py` + `requirements.txt` via git
|
| 259 |
+
4. The Space auto-builds and deploys
|
| 260 |
+
5. You get a public URL that judges can visit
|
| 261 |
+
|
| 262 |
+
**Free tier:** Yes, free CPU instances. Good enough for a hackathon demo.
|
| 263 |
+
|
| 264 |
+
**How to push:**
|
| 265 |
+
```bash
|
| 266 |
+
# Clone the space
|
| 267 |
+
git clone https://huggingface.co/spaces/build-small-hackathon/your-app-name
|
| 268 |
+
# Copy your files in
|
| 269 |
+
# Push
|
| 270 |
+
git add . && git commit -m "Initial app" && git push
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
For the model on HF Spaces, use the **Hugging Face Inference API** (free tier) -- the model runs on HF servers, not on the Space's CPU.
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
|
| 277 |
+
## Your Winning Strategy
|
| 278 |
+
|
| 279 |
+
### The REAL Problem (This Is Gold for Hackathon Judges)
|
| 280 |
+
|
| 281 |
+
You run a sticker shop in Jogja, Indonesia. You design and sell ~150 varieties of stickers through **25 offline partner stores** across Java island (Solo, Klaten, Semarang, Jakarta, Bandung, Bali, etc.). Each store gets your stickers on consignment (they pay you a % of sales).
|
| 282 |
+
|
| 283 |
+
**The monthly workflow (currently manual, ~3+ hours):**
|
| 284 |
+
|
| 285 |
+
1. **Receive reports from 25 stores** -- Each store sends a monthly sales report AND a Pre-Order (PO) for restocking. Every store uses a DIFFERENT format: Excel, PDF, handwritten, Indonesian, English, mixed.
|
| 286 |
+
|
| 287 |
+
2. **Parse and normalize** -- You manually read each report/PO and type the data into your own spreadsheet. 25 stores x different formats = tedious.
|
| 288 |
+
|
| 289 |
+
3. **Aggregate demand** -- Combine all 25 POs to see total demand per sticker variety.
|
| 290 |
+
|
| 291 |
+
4. **Check home stock** -- Compare aggregated demand against what you have in stock at home.
|
| 292 |
+
|
| 293 |
+
5. **Calculate printing** -- For stickers you need to produce, calculate how many A3 sheets to order from your printer (each A3 = 8 x A5 stickers, so divide quantity by 8, round up).
|
| 294 |
+
|
| 295 |
+
6. **Place print order** -- Send order to external printing service.
|
| 296 |
+
|
| 297 |
+
7. **Generate delivery documents** -- After printing, create a packing list for each store: "Store X, here are the stickers I'm sending you." This helps them match incoming shipments.
|
| 298 |
+
|
| 299 |
+
8. **Recommend best sellers** -- Some stores ask "What should I order?" You check sales data across all stores and recommend top sellers.
|
| 300 |
+
|
| 301 |
+
### Why This Is a PERFECT Hackathon Entry
|
| 302 |
+
|
| 303 |
+
| Judge Criteria | Your Score | Why |
|
| 304 |
+
|----------------|-----------|-----|
|
| 305 |
+
| Problem is specific and real | 10/10 | You literally do this every month. 25 real stores. Real documents. |
|
| 306 |
+
| Person actually used it | 10/10 | YOU are the person. You can demo with real data. |
|
| 307 |
+
| Honest small-model fit | 10/10 | Parsing messy POs into structured data is EXACTLY what small models excel at. No need for GPT-4. |
|
| 308 |
+
| Gradio polish | High | Multi-tab workflow, clean tables, export buttons, bilingual |
|
| 309 |
+
|
| 310 |
+
### The Pitch (Practice This)
|
| 311 |
+
|
| 312 |
+
> "I run a sticker shop in Jogja, Indonesia. I sell 150 designs through 25 partner stores across Java. Every month, each store sends me sales reports and restock orders -- in 25 different formats. Excel, PDF, handwritten notes, Indonesian, English. I spend 3+ hours every month just copying data into spreadsheets. Then I have to calculate what to print, and create delivery documents for each store.
|
| 313 |
+
>
|
| 314 |
+
> I built an AI assistant that does all of this. Paste in any store's PO -- any format -- and a 3-billion parameter model extracts the structured data. It aggregates across stores, calculates my print order (A3 sheets, 8 stickers per sheet), and generates delivery documents in both English and Indonesian.
|
| 315 |
+
>
|
| 316 |
+
> All running on a tiny model. Because parsing 'butuh 20 stiker kucing hologram' doesn't need GPT-4."
|
| 317 |
+
|
| 318 |
+
### App Features (What We Build)
|
| 319 |
+
|
| 320 |
+
#### Tab 1: PO Intake (Parse Store Orders)
|
| 321 |
+
- Select store from dropdown
|
| 322 |
+
- Paste PO text in any format (messy Excel copy, handwritten transcription, mixed languages)
|
| 323 |
+
- AI extracts: product name, quantity, notes
|
| 324 |
+
- Review and edit parsed data
|
| 325 |
+
- Save to session
|
| 326 |
+
|
| 327 |
+
#### Tab 2: Stock & Demand Dashboard
|
| 328 |
+
- View home stock levels (editable)
|
| 329 |
+
- See aggregated demand from all parsed POs
|
| 330 |
+
- Visual comparison: what you have vs. what stores want
|
| 331 |
+
- Highlight shortages
|
| 332 |
+
|
| 333 |
+
#### Tab 3: Print Calculator
|
| 334 |
+
- Shows what needs to be printed (demand - stock = shortage)
|
| 335 |
+
- Calculates A3 sheets needed (qty / 8, rounded up)
|
| 336 |
+
- Estimated cost
|
| 337 |
+
- Export print order
|
| 338 |
+
|
| 339 |
+
#### Tab 4: Delivery Documents
|
| 340 |
+
- Select a store
|
| 341 |
+
- Generate packing list of what to send them
|
| 342 |
+
- Bilingual (English / Indonesian)
|
| 343 |
+
- Export as text
|
| 344 |
+
|
| 345 |
+
#### Tab 5: Best Seller Report
|
| 346 |
+
- AI analyzes sales data across stores
|
| 347 |
+
- Recommends top sellers to stores that ask
|
| 348 |
+
|
| 349 |
+
---
|
| 350 |
+
|
| 351 |
+
## Technical Stack & Hosting
|
| 352 |
+
|
| 353 |
+
### What You Need
|
| 354 |
+
|
| 355 |
+
| Component | Tool | Why |
|
| 356 |
+
|-----------|------|-----|
|
| 357 |
+
| Language | **Python 3.11** | Only language needed |
|
| 358 |
+
| UI Framework | **Gradio 6.x** | Required by hackathon |
|
| 359 |
+
| LLM (Cloud) | **HF Inference API + Qwen2.5-3B** | Free, runs on HF servers, no local RAM needed |
|
| 360 |
+
| LLM (Local) | **Ollama + Qwen2.5:3b** (optional) | For offline/privacy demo on a machine with 8GB+ RAM |
|
| 361 |
+
| Data | **pandas** | CSV export, data manipulation |
|
| 362 |
+
| Deployment | **Hugging Face Spaces** | Required by hackathon. Free tier. |
|
| 363 |
+
|
| 364 |
+
### How the App Works
|
| 365 |
+
|
| 366 |
+
```
|
| 367 |
+
Store sends PO (messy text) --> You paste into Gradio UI -->
|
| 368 |
+
Python sends to HF Inference API --> Qwen2.5-3B parses it -->
|
| 369 |
+
Structured JSON returned --> Gradio displays editable table -->
|
| 370 |
+
Python aggregates + calculates print needs --> Export
|
| 371 |
+
```
|
| 372 |
+
|
| 373 |
+
### Hosting on Hugging Face Spaces
|
| 374 |
+
|
| 375 |
+
**Cost: FREE** for the basic CPU tier. This is enough for your hackathon demo.
|
| 376 |
+
|
| 377 |
+
**What HF Spaces gives you:**
|
| 378 |
+
- Public URL (e.g., `https://huggingface.co/spaces/build-small-hackathon/your-app`)
|
| 379 |
+
- Auto-builds from your code
|
| 380 |
+
- Judges click the link and use your app instantly
|
| 381 |
+
- The LLM runs via HF Inference API (on HF's powerful servers), not on the Space's tiny CPU
|
| 382 |
+
|
| 383 |
+
**How to deploy (step by step):**
|
| 384 |
+
1. Create a free account on huggingface.co
|
| 385 |
+
2. Get a free API token (Settings > Access Tokens)
|
| 386 |
+
3. Create a new Space under `build-small-hackathon` org
|
| 387 |
+
4. Push your code via git
|
| 388 |
+
5. Set your HF token as a Space Secret (so the Inference API works)
|
| 389 |
+
6. Done -- your app is live
|
| 390 |
+
|
| 391 |
+
**It is NOT like AWS.** It's much simpler:
|
| 392 |
+
- No servers to configure
|
| 393 |
+
- No Docker files needed (Gradio Spaces handle it)
|
| 394 |
+
- No billing surprises (free tier has limits but won't charge you)
|
| 395 |
+
- Judges don't need accounts to use your app
|
| 396 |
+
|
| 397 |
+
---
|
| 398 |
+
|
| 399 |
+
## Bonus Quests
|
| 400 |
+
|
| 401 |
+
| Badge | Requirement | Recommendation |
|
| 402 |
+
|-------|------------|----------------|
|
| 403 |
+
| **Off the Grid** | No cloud APIs, fully local | Can demo this on a machine with 8GB+ RAM using Ollama. Mention it in pitch. |
|
| 404 |
+
| **Off-Brand** | Custom frontend beyond default Gradio | **DO THIS** -- custom CSS with your sticker shop branding |
|
| 405 |
+
| **Well-Tuned** | Fine-tuned model on HF | Skip (not enough time) |
|
| 406 |
+
| **Llama Champion** | Use llama.cpp runtime | Nice-to-have only |
|
| 407 |
+
| **Sharing is Caring** | Share agent trace on Hub | **DO THIS** -- easy, free |
|
| 408 |
+
| **Field Notes** | Blog post about the build | Do if time allows |
|
| 409 |
+
|
| 410 |
+
---
|
| 411 |
+
|
| 412 |
+
## Pitch & Demo Tips
|
| 413 |
+
|
| 414 |
+
### Demo Video Structure (60--90 seconds)
|
| 415 |
+
|
| 416 |
+
1. **Hook (10s):** "I sell stickers through 25 stores across Indonesia. Every month I spend 3 hours processing their orders manually."
|
| 417 |
+
2. **Problem (15s):** Show the messy reality -- different Excel formats, handwritten POs, mixed languages.
|
| 418 |
+
3. **Solution (40s):** Paste a real PO. Watch AI parse it. Show aggregation. Show print calculator. Show delivery document.
|
| 419 |
+
4. **Wow moment (10s):** "3 billion parameters. That's all you need to turn chaos into a clean order."
|
| 420 |
+
5. **Close (10s):** "Built with Gradio + Qwen2.5-3B for the Build Small Hackathon."
|
| 421 |
+
|
| 422 |
+
### What to Highlight
|
| 423 |
+
|
| 424 |
+
- The 25-store scale (real business, not a toy example)
|
| 425 |
+
- Before vs. after (3 hours -> 10 minutes)
|
| 426 |
+
- The variety of input formats (your unique challenge)
|
| 427 |
+
- Bilingual English/Indonesian
|
| 428 |
+
- The print calculator (a practical non-AI feature that adds real value)
|
| 429 |
+
- That 3B parameters is MORE than enough for structured extraction
|
| 430 |
+
|
| 431 |
+
### Common Mistakes to Avoid
|
| 432 |
+
|
| 433 |
+
1. Don't over-engineer -- a clean, working demo beats a half-finished ambitious project
|
| 434 |
+
2. Don't use jargon -- say "Paste your store's order" not "Enter NLP query"
|
| 435 |
+
3. Don't forget examples -- pre-fill sample POs so judges can try instantly
|
| 436 |
+
4. Don't make it generic -- the Indonesia sticker shop angle is your ADVANTAGE
|
| 437 |
+
|
| 438 |
+
---
|
| 439 |
+
|
| 440 |
+
## Timeline & Checklist
|
| 441 |
+
|
| 442 |
+
### Before May 29 (Prep Phase -- NOW)
|
| 443 |
+
|
| 444 |
+
- [x] Read hackathon guidelines
|
| 445 |
+
- [x] Define your idea and track
|
| 446 |
+
- [ ] Register on Hugging Face
|
| 447 |
+
- [ ] Join Gradio Discord
|
| 448 |
+
- [x] Install Python, Gradio locally
|
| 449 |
+
- [x] Build and test prototype locally
|
| 450 |
+
- [ ] Prepare real PO documents from your stores (anonymize if needed)
|
| 451 |
+
- [ ] Get HF API token (free)
|
| 452 |
+
|
| 453 |
+
### May 29 -- June 1 (Weekend 1)
|
| 454 |
+
|
| 455 |
+
- [ ] Create your HF Space under the build-small-hackathon org
|
| 456 |
+
- [ ] Polish the Gradio UI (custom CSS, sticker shop branding)
|
| 457 |
+
- [ ] Add all features (PO parsing, aggregation, print calc, delivery docs)
|
| 458 |
+
- [ ] Test with real PO data from your stores
|
| 459 |
+
- [ ] Deploy to HF Spaces
|
| 460 |
+
|
| 461 |
+
### June 2 -- 5 (Midweek)
|
| 462 |
+
|
| 463 |
+
- [ ] Record demo video
|
| 464 |
+
- [ ] Write social media post
|
| 465 |
+
- [ ] Attend Live AMA if possible
|
| 466 |
+
- [ ] Bug fixes and polish
|
| 467 |
+
|
| 468 |
+
### June 6 -- 8 (Weekend 2)
|
| 469 |
+
|
| 470 |
+
- [ ] Final polish and testing
|
| 471 |
+
- [ ] Submit: Space link + demo video + social post
|
| 472 |
+
|
| 473 |
+
---
|
| 474 |
+
|
| 475 |
+
## Quick Reference Card
|
| 476 |
+
|
| 477 |
+
```
|
| 478 |
+
HACKATHON: Build Small Hackathon
|
| 479 |
+
TRACK: Chapter One -- Backyard AI
|
| 480 |
+
IDEA: Sticker Restock Manager -- AI-powered PO parser & print calculator
|
| 481 |
+
PERSON: You (sticker shop owner in Jogja, 25 partner stores across Java)
|
| 482 |
+
MODEL: Qwen2.5-3B (3B params, via HF Inference API)
|
| 483 |
+
UI: Gradio Blocks with custom CSS
|
| 484 |
+
DEPLOY: Hugging Face Space (free tier)
|
| 485 |
+
LANGUAGES: English + Indonesian
|
| 486 |
+
DEADLINE: June 8, 2026
|
| 487 |
+
PRIZE POOL: $15,000
|
| 488 |
+
```
|
README.md
CHANGED
|
@@ -1,15 +1,120 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Paperain Studio — Sticker Restock Manager
|
| 3 |
+
emoji: 📝
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "6.14.0"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
short_description: Piper turns messy store POs into restock workflows.
|
| 11 |
+
tags:
|
| 12 |
+
- track:backyard
|
| 13 |
+
- achievement:offbrand
|
| 14 |
+
- achievement:bestdemo
|
| 15 |
+
- gradio
|
| 16 |
+
- build-small-hackathon
|
| 17 |
+
- backyard ai
|
| 18 |
+
- backyard-ai
|
| 19 |
+
- off brand
|
| 20 |
+
- off-brand
|
| 21 |
+
- best demo
|
| 22 |
+
- best-demo
|
| 23 |
+
- judges wildcard
|
| 24 |
+
- judges-wildcard
|
| 25 |
+
models:
|
| 26 |
+
- Qwen/Qwen2.5-7B-Instruct
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
# Paperain Studio — Sticker Restock Manager
|
| 30 |
+
|
| 31 |
+
**Piper** is an AI desk assistant for [Paperain Studio](https://paperainstudio.com) — a real sticker business in Yogyakarta, Indonesia selling through **25 partner stores** and **150 designs**.
|
| 32 |
+
|
| 33 |
+
Every month, stores send restock orders as messy Excel copy-pastes, PDFs, or WhatsApp screenshots (Indonesian, English, or both). My family used to spend **3+ hours** manually typing data into spreadsheets. Piper fixes that.
|
| 34 |
+
|
| 35 |
+
**Try it:** [Live Space](https://huggingface.co/spaces/build-small-hackathon/piper-assistant) · [Demo video](#demo-video) *(coming soon)* · [Blog post](https://www.paperainstudio.com/blog/how-we-built-piper-ai-build-small-hackathon) · [Social post](#social-post) *(coming soon)*
|
| 36 |
+
|
| 37 |
+
## TL;DR for Judges
|
| 38 |
+
|
| 39 |
+
- **Track — Backyard AI:** Built for our own family sticker business. 25 real partner stores across Java send monthly restock orders in 25 different formats — Excel, WhatsApp, mixed Indonesian/English. Piper turns that chaos into structured workflows my family can actually use.
|
| 40 |
+
- **Idea:** Paste any store PO → review parsed products → aggregate demand vs home stock → calculate A3 print sheets → generate bilingual delivery docs. Hours of spreadsheet work, compressed into a form-like Gradio app.
|
| 41 |
+
- **Tech:** Python 3.11 · Gradio 6.x · pandas · **Qwen2.5-7B-Instruct** via Hugging Face Inference API (7B params, well under the 32B cap) · 3-stage parsing pipeline (normalize → JSON extract → catalog anchor) · rule-based fallback when the API is cold.
|
| 42 |
+
- **Off Brand:** Custom light cream UI matching paperainstudio.com — not stock Gradio defaults.
|
| 43 |
+
- **Best Demo:** Demo video and social post linked below *(placeholders until published)*.
|
| 44 |
+
- **Judges' Wildcard:** Real small-business ops tool that doesn't fit a neat category — part parser, part print calculator, part bilingual doc generator.
|
| 45 |
+
|
| 46 |
+
## Submission Links
|
| 47 |
+
|
| 48 |
+
| Item | Link |
|
| 49 |
+
|------|------|
|
| 50 |
+
| Live Space | https://huggingface.co/spaces/build-small-hackathon/piper-assistant |
|
| 51 |
+
| Demo video | *TODO: add YouTube or Loom link here* |
|
| 52 |
+
| Blog post (Field Notes) | https://www.paperainstudio.com/blog/how-we-built-piper-ai-build-small-hackathon |
|
| 53 |
+
| Social post | *TODO: add X / Instagram / LinkedIn link here* |
|
| 54 |
+
|
| 55 |
+
### Demo Video
|
| 56 |
+
|
| 57 |
+
*Placeholder — record and paste your demo video URL above before final submission.*
|
| 58 |
+
|
| 59 |
+
Suggested flow: show a messy Indonesian PO → Parse with Piper → Stock & Demand → Print Calculator → Delivery Docs.
|
| 60 |
+
|
| 61 |
+
### Social Post
|
| 62 |
+
|
| 63 |
+
*Placeholder — publish a post showcasing Piper and paste the link above.*
|
| 64 |
+
|
| 65 |
+
## The Problem
|
| 66 |
+
|
| 67 |
+
- 25 stores, each with their own PO format and best sellers
|
| 68 |
+
- Mixed-language, informal orders ("stiker kucing hologram 20 pcs")
|
| 69 |
+
- Manual spreadsheet work that's hard for non-tech-savvy family members
|
| 70 |
+
|
| 71 |
+
## The Solution
|
| 72 |
+
|
| 73 |
+
Powered by **Qwen2.5-7B** (7 billion parameters — well under the 32B hackathon limit):
|
| 74 |
+
|
| 75 |
+
| Tool | What it does |
|
| 76 |
+
|------|-------------|
|
| 77 |
+
| **PO Intake** | Paste any PO format → structured product/qty table |
|
| 78 |
+
| **Stock & Demand** | Aggregate orders vs home inventory, show shortages |
|
| 79 |
+
| **Print Calculator** | A3 sheet math (8 A5 stickers per sheet) |
|
| 80 |
+
| **Delivery Docs** | Bilingual packing lists (EN / ID) |
|
| 81 |
+
| **Best Sellers** | Demand-based recommendations for partner stores |
|
| 82 |
+
|
| 83 |
+
## Why a Small Model?
|
| 84 |
+
|
| 85 |
+
Parsing `"stiker kucing 20 pcs"` into `{product, quantity}` is **structured extraction** — not creative writing. A 7B model handles this perfectly:
|
| 86 |
+
|
| 87 |
+
- Runs on a laptop (no GPT-4 API needed)
|
| 88 |
+
- Zero cost per call
|
| 89 |
+
- Store data stays private
|
| 90 |
+
- Fast enough for monthly restock workflows
|
| 91 |
+
|
| 92 |
+
**7 billion parameters. 25 real stores. 3 hours saved every month.**
|
| 93 |
+
|
| 94 |
+
## How to Run
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
pip install -r requirements.txt
|
| 98 |
+
python app.py
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
Set `HF_TOKEN` for Hugging Face Inference API, or run locally with Ollama (`FORCE_HF=0`).
|
| 102 |
+
|
| 103 |
+
Built for the [Build Small Hackathon 2026](https://huggingface.co/build-small-hackathon) · **Backyard AI** track.
|
| 104 |
+
|
| 105 |
+
## Hackathon Tags
|
| 106 |
+
|
| 107 |
+
| Prize / Badge | Status | Why we hope to qualify |
|
| 108 |
+
| --- | --- | --- |
|
| 109 |
+
| Backyard AI | **Entered** | Real problem for a real family business — 25 stores, actual monthly PO chaos. |
|
| 110 |
+
| Off Brand | **Targeted** | Custom Paperain-branded Gradio UI with warm cream palette, not default components. |
|
| 111 |
+
| Best Demo | **Pending** | Demo video and social post placeholders above — full package once published. |
|
| 112 |
+
| Judges' Wildcard | **Hopeful** | Practical ops tool spanning parsing, inventory, print math, and bilingual docs. |
|
| 113 |
+
|
| 114 |
+
Submission checklist:
|
| 115 |
+
|
| 116 |
+
- **REQ-01 / Stay under 32B:** complete — Qwen2.5-7B-Instruct (7B params).
|
| 117 |
+
- **REQ-02 / Ship a Gradio app:** complete — Gradio Space deployed.
|
| 118 |
+
- **REQ-03 / Record a demo:** pending — video link placeholder above.
|
| 119 |
+
- **REQ-04 / Post it:** pending — social post link placeholder above.
|
| 120 |
+
- **REQ-06 / Tag your README:** complete — track and badge tags in frontmatter.
|
app.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ui import build_ui, THEME, CUSTOM_CSS, APP_HEAD
|
| 2 |
+
|
| 3 |
+
demo = build_ui()
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
demo.launch(
|
| 7 |
+
server_name="0.0.0.0",
|
| 8 |
+
server_port=7860,
|
| 9 |
+
theme=THEME,
|
| 10 |
+
css=CUSTOM_CSS,
|
| 11 |
+
head=APP_HEAD,
|
| 12 |
+
footer_links=["api", "gradio"],
|
| 13 |
+
)
|
catalog_resolver.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Map messy PO product names to the nearest canonical sticker in the catalog."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from difflib import SequenceMatcher, get_close_matches
|
| 6 |
+
|
| 7 |
+
from data import SAMPLE_CATALOG
|
| 8 |
+
from parser_fallback import PRODUCT_ALIASES
|
| 9 |
+
|
| 10 |
+
_CATALOG_LOWER = {p.lower(): p for p in SAMPLE_CATALOG}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def resolve_product(raw: str, catalog: list[str] | None = None) -> str:
|
| 14 |
+
"""Return the closest catalog name for a parsed product string."""
|
| 15 |
+
if not raw or not str(raw).strip():
|
| 16 |
+
return raw
|
| 17 |
+
|
| 18 |
+
catalog = catalog or SAMPLE_CATALOG
|
| 19 |
+
catalog_lower = {p.lower(): p for p in catalog}
|
| 20 |
+
cleaned = str(raw).strip()
|
| 21 |
+
key = cleaned.lower()
|
| 22 |
+
|
| 23 |
+
if key in catalog_lower:
|
| 24 |
+
return catalog_lower[key]
|
| 25 |
+
|
| 26 |
+
for alias, canonical in sorted(PRODUCT_ALIASES.items(), key=lambda x: -len(x[0])):
|
| 27 |
+
if alias in key or key in alias:
|
| 28 |
+
if canonical.lower() in catalog_lower:
|
| 29 |
+
return catalog_lower[canonical.lower()]
|
| 30 |
+
return canonical
|
| 31 |
+
|
| 32 |
+
for cat_lower, canonical in catalog_lower.items():
|
| 33 |
+
if cat_lower in key or key in cat_lower:
|
| 34 |
+
return canonical
|
| 35 |
+
|
| 36 |
+
matches = get_close_matches(key, catalog_lower.keys(), n=1, cutoff=0.55)
|
| 37 |
+
if matches:
|
| 38 |
+
return catalog_lower[matches[0]]
|
| 39 |
+
|
| 40 |
+
best_name, best_score = cleaned, 0.0
|
| 41 |
+
for cat_lower, canonical in catalog_lower.items():
|
| 42 |
+
score = SequenceMatcher(None, key, cat_lower).ratio()
|
| 43 |
+
if score > best_score:
|
| 44 |
+
best_score, best_name = score, canonical
|
| 45 |
+
if best_score >= 0.5:
|
| 46 |
+
return best_name
|
| 47 |
+
|
| 48 |
+
return cleaned
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def resolve_items(items: list[dict], catalog: list[str] | None = None) -> list[dict]:
|
| 52 |
+
"""Normalize product field on each parsed item."""
|
| 53 |
+
out = []
|
| 54 |
+
for it in items:
|
| 55 |
+
row = dict(it)
|
| 56 |
+
raw = row.get("product", "")
|
| 57 |
+
resolved = resolve_product(raw, catalog)
|
| 58 |
+
if resolved != raw and not row.get("notes"):
|
| 59 |
+
row["notes"] = f"matched from: {raw}"
|
| 60 |
+
elif resolved != raw:
|
| 61 |
+
row["notes"] = f"{row['notes']} (matched from: {raw})"
|
| 62 |
+
row["product"] = resolved
|
| 63 |
+
out.append(row)
|
| 64 |
+
return out
|
config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 4 |
+
HF_MODEL = "Qwen/Qwen2.5-7B-Instruct"
|
| 5 |
+
|
| 6 |
+
_use_ollama = False
|
| 7 |
+
_ollama_mod = None
|
| 8 |
+
_hf_client = None
|
| 9 |
+
_llm_available = False
|
| 10 |
+
|
| 11 |
+
if os.environ.get("FORCE_HF", "1") != "1":
|
| 12 |
+
try:
|
| 13 |
+
if not os.environ.get("SPACE_ID"):
|
| 14 |
+
import ollama as _ollama_mod
|
| 15 |
+
_ollama_mod.list()
|
| 16 |
+
_use_ollama = True
|
| 17 |
+
_llm_available = True
|
| 18 |
+
except Exception:
|
| 19 |
+
_use_ollama = False
|
| 20 |
+
|
| 21 |
+
if _use_ollama:
|
| 22 |
+
print("[startup] Using Ollama locally with qwen2.5:1.5b")
|
| 23 |
+
else:
|
| 24 |
+
try:
|
| 25 |
+
from huggingface_hub import InferenceClient
|
| 26 |
+
_hf_client = InferenceClient(model=HF_MODEL, token=HF_TOKEN)
|
| 27 |
+
_llm_available = bool(HF_TOKEN) or bool(os.environ.get("SPACE_ID"))
|
| 28 |
+
print(f"[startup] HF Inference API — model={HF_MODEL}, token={'yes' if HF_TOKEN else 'space/default'}")
|
| 29 |
+
except Exception as exc:
|
| 30 |
+
print(f"[startup] HF client init failed: {exc}")
|
| 31 |
+
_hf_client = None
|
| 32 |
+
_llm_available = False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def llm_available() -> bool:
|
| 36 |
+
return _llm_available and (_use_ollama or _hf_client is not None)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def call_llm(prompt: str, system: str = "") -> str:
|
| 40 |
+
if not llm_available():
|
| 41 |
+
return "[LLM Error: Model unavailable — using rule-based fallback]"
|
| 42 |
+
|
| 43 |
+
messages = []
|
| 44 |
+
if system:
|
| 45 |
+
messages.append({"role": "system", "content": system})
|
| 46 |
+
messages.append({"role": "user", "content": prompt})
|
| 47 |
+
try:
|
| 48 |
+
if _use_ollama:
|
| 49 |
+
resp = _ollama_mod.chat(model="qwen2.5:1.5b", messages=messages)
|
| 50 |
+
return resp["message"]["content"]
|
| 51 |
+
resp = _hf_client.chat_completion(messages, max_tokens=2048, temperature=0.1)
|
| 52 |
+
return resp.choices[0].message.content
|
| 53 |
+
except Exception as e:
|
| 54 |
+
return f"[LLM Error: {e}]"
|
data.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
SAMPLE_CATALOG = [
|
| 5 |
+
"Holographic Cat Sticker", "Sakura Die-Cut Sticker", "Rainbow Unicorn Sticker",
|
| 6 |
+
"Kawaii Sushi Sticker", "Kawaii Ramen Sticker", "Kawaii Boba Tea Sticker",
|
| 7 |
+
"Floral Wreath Sticker", "Mountain Landscape Sticker", "Ocean Wave Sticker",
|
| 8 |
+
"Butterfly Garden Sticker", "Vintage Bicycle Sticker", "Coffee Cup Sticker",
|
| 9 |
+
"Galaxy Star Sticker", "Cactus Succulent Sticker", "Koi Fish Sticker",
|
| 10 |
+
"Cherry Blossom Sticker", "Batik Pattern Sticker", "Wayang Shadow Sticker",
|
| 11 |
+
"Borobudur Temple Sticker", "Komodo Dragon Sticker", "Gamelan Sticker",
|
| 12 |
+
"Javanese Dancer Sticker", "Tropical Bird Sticker", "Monstera Leaf Sticker",
|
| 13 |
+
"Cute Dog Sticker", "Cute Rabbit Sticker", "Panda Bear Sticker",
|
| 14 |
+
"Space Astronaut Sticker", "Retro Cassette Sticker", "Mushroom Forest Sticker",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
SAMPLE_STORES = {
|
| 18 |
+
"Toko Alat Tulis Maju (Solo)": {"city": "Solo", "method": "Hand delivery", "consignment_pct": 30},
|
| 19 |
+
"Art Corner Klaten": {"city": "Klaten", "method": "Hand delivery", "consignment_pct": 25},
|
| 20 |
+
"Semarang Stationery": {"city": "Semarang", "method": "Hand delivery", "consignment_pct": 30},
|
| 21 |
+
"Gramedia Jogja": {"city": "Yogyakarta", "method": "Hand delivery", "consignment_pct": 35},
|
| 22 |
+
"Jakarta Creative Supply": {"city": "Jakarta", "method": "Post/courier", "consignment_pct": 30},
|
| 23 |
+
"Bandung Art Store": {"city": "Bandung", "method": "Post/courier", "consignment_pct": 28},
|
| 24 |
+
"Bali Artisan Shop": {"city": "Bali", "method": "Post/courier", "consignment_pct": 25},
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
STORE_NAMES = list(SAMPLE_STORES.keys())
|
| 28 |
+
|
| 29 |
+
SAMPLE_PO_EXAMPLES = [
|
| 30 |
+
[
|
| 31 |
+
"Toko Alat Tulis Maju (Solo)",
|
| 32 |
+
"Pesanan bulan Juni 2026:\n- Stiker kucing hologram 20 pcs\n- Stiker sakura die-cut 15 pcs\n"
|
| 33 |
+
"- Rainbow unicorn stiker 10 pcs\n- Stiker boba tea kawaii 25 pcs\n"
|
| 34 |
+
"Tolong kirim sebelum tanggal 5 ya kak",
|
| 35 |
+
],
|
| 36 |
+
[
|
| 37 |
+
"Jakarta Creative Supply",
|
| 38 |
+
"PO #JCS-2026-06\nItem | Qty\nHolographic Cat | 30\nGalaxy Star | 20\n"
|
| 39 |
+
"Monstera Leaf | 15\nBatik Pattern | 25\nKoi Fish | 10\nPlease ship via JNE REG",
|
| 40 |
+
],
|
| 41 |
+
[
|
| 42 |
+
"Bali Artisan Shop",
|
| 43 |
+
"hai kak, mau order lagi ya:\nstiker borobudur 20, wayang 15, gamelan 10, "
|
| 44 |
+
"penari jawa 20, batik 30\noh ya ada rekomendasi best seller bulan ini? "
|
| 45 |
+
"mau coba jual juga\nmakasih!",
|
| 46 |
+
],
|
| 47 |
+
[
|
| 48 |
+
"Bandung Art Store",
|
| 49 |
+
"Monthly restock:\n1. Sakura Die-Cut x25\n2. Cherry Blossom x20\n"
|
| 50 |
+
"3. Floral Wreath x15\n4. Butterfly Garden x10\n5. Vintage Bicycle x10\n"
|
| 51 |
+
"6. Coffee Cup x20\nNote: last month's sakura sold out in 2 weeks!",
|
| 52 |
+
],
|
| 53 |
+
[
|
| 54 |
+
"Art Corner Klaten",
|
| 55 |
+
"kak pesan:\nkucing holo 15\nunicorn 10\nsushi kawaii 20\nramen kawaii 20\n"
|
| 56 |
+
"boba 15\nmushroom forest 10\nspace astronaut 5",
|
| 57 |
+
],
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def make_default_stock() -> pd.DataFrame:
|
| 62 |
+
random.seed(42)
|
| 63 |
+
return pd.DataFrame(
|
| 64 |
+
[{"Product": p, "In Stock": random.randint(5, 120)} for p in SAMPLE_CATALOG]
|
| 65 |
+
)
|
parser_fallback.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rule-based PO parser — fallback when the 7B model is unavailable or returns bad JSON."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from data import SAMPLE_CATALOG
|
| 7 |
+
|
| 8 |
+
# Common Indonesian / informal aliases → canonical catalog names
|
| 9 |
+
PRODUCT_ALIASES: dict[str, str] = {
|
| 10 |
+
"kucing hologram": "Holographic Cat Sticker",
|
| 11 |
+
"kucing holo": "Holographic Cat Sticker",
|
| 12 |
+
"holographic cat": "Holographic Cat Sticker",
|
| 13 |
+
"stiker kucing hologram": "Holographic Cat Sticker",
|
| 14 |
+
"sakura die-cut": "Sakura Die-Cut Sticker",
|
| 15 |
+
"stiker sakura": "Sakura Die-Cut Sticker",
|
| 16 |
+
"sakura": "Sakura Die-Cut Sticker",
|
| 17 |
+
"cherry blossom": "Cherry Blossom Sticker",
|
| 18 |
+
"rainbow unicorn": "Rainbow Unicorn Sticker",
|
| 19 |
+
"unicorn": "Rainbow Unicorn Sticker",
|
| 20 |
+
"boba tea": "Kawaii Boba Tea Sticker",
|
| 21 |
+
"boba": "Kawaii Boba Tea Sticker",
|
| 22 |
+
"sushi kawaii": "Kawaii Sushi Sticker",
|
| 23 |
+
"sushi": "Kawaii Sushi Sticker",
|
| 24 |
+
"ramen kawaii": "Kawaii Ramen Sticker",
|
| 25 |
+
"ramen": "Kawaii Ramen Sticker",
|
| 26 |
+
"floral wreath": "Floral Wreath Sticker",
|
| 27 |
+
"butterfly garden": "Butterfly Garden Sticker",
|
| 28 |
+
"vintage bicycle": "Vintage Bicycle Sticker",
|
| 29 |
+
"coffee cup": "Coffee Cup Sticker",
|
| 30 |
+
"galaxy star": "Galaxy Star Sticker",
|
| 31 |
+
"monstera leaf": "Monstera Leaf Sticker",
|
| 32 |
+
"monstera": "Monstera Leaf Sticker",
|
| 33 |
+
"batik pattern": "Batik Pattern Sticker",
|
| 34 |
+
"batik": "Batik Pattern Sticker",
|
| 35 |
+
"koi fish": "Koi Fish Sticker",
|
| 36 |
+
"koi": "Koi Fish Sticker",
|
| 37 |
+
"borobudur": "Borobudur Temple Sticker",
|
| 38 |
+
"wayang": "Wayang Shadow Sticker",
|
| 39 |
+
"gamelan": "Gamelan Sticker",
|
| 40 |
+
"penari jawa": "Javanese Dancer Sticker",
|
| 41 |
+
"javanese dancer": "Javanese Dancer Sticker",
|
| 42 |
+
"mushroom forest": "Mushroom Forest Sticker",
|
| 43 |
+
"space astronaut": "Space Astronaut Sticker",
|
| 44 |
+
"mountain landscape": "Mountain Landscape Sticker",
|
| 45 |
+
"ocean wave": "Ocean Wave Sticker",
|
| 46 |
+
"cactus succulent": "Cactus Succulent Sticker",
|
| 47 |
+
"cute dog": "Cute Dog Sticker",
|
| 48 |
+
"cute rabbit": "Cute Rabbit Sticker",
|
| 49 |
+
"panda bear": "Panda Bear Sticker",
|
| 50 |
+
"retro cassette": "Retro Cassette Sticker",
|
| 51 |
+
"tropical bird": "Tropical Bird Sticker",
|
| 52 |
+
"komodo dragon": "Komodo Dragon Sticker",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
_CATALOG_LOWER = {p.lower(): p for p in SAMPLE_CATALOG}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _normalize_product(raw: str) -> str:
|
| 59 |
+
cleaned = re.sub(r"^[\-\*\d\.\)\s]+", "", raw.strip())
|
| 60 |
+
cleaned = re.sub(r"\s*(pcs|pc|lembar|sheet|sheets|buah|unit)\.?\s*$", "", cleaned, flags=re.I)
|
| 61 |
+
cleaned = cleaned.strip(" ,;:")
|
| 62 |
+
if not cleaned:
|
| 63 |
+
return ""
|
| 64 |
+
|
| 65 |
+
key = cleaned.lower()
|
| 66 |
+
if key in _CATALOG_LOWER:
|
| 67 |
+
return _CATALOG_LOWER[key]
|
| 68 |
+
|
| 69 |
+
for alias, canonical in sorted(PRODUCT_ALIASES.items(), key=lambda x: -len(x[0])):
|
| 70 |
+
if alias in key or key in alias:
|
| 71 |
+
return canonical
|
| 72 |
+
|
| 73 |
+
for cat_lower, canonical in _CATALOG_LOWER.items():
|
| 74 |
+
if cat_lower in key or key in cat_lower:
|
| 75 |
+
return canonical
|
| 76 |
+
|
| 77 |
+
return cleaned.title()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _extract_qty(text: str) -> int:
|
| 81 |
+
m = re.search(r"(\d+)\s*(?:pcs|pc|lembar|sheet|sheets|buah|unit)?\.?\s*$", text, re.I)
|
| 82 |
+
if m:
|
| 83 |
+
return int(m.group(1))
|
| 84 |
+
m = re.search(r"x\s*(\d+)\s*$", text, re.I)
|
| 85 |
+
if m:
|
| 86 |
+
return int(m.group(1))
|
| 87 |
+
m = re.search(r"\b(\d+)\b", text)
|
| 88 |
+
return int(m.group(1)) if m else 0
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _parse_line(line: str) -> Optional[dict]:
|
| 92 |
+
line = line.strip()
|
| 93 |
+
if not line or len(line) < 3:
|
| 94 |
+
return None
|
| 95 |
+
if re.match(r"^(item|product|qty|quantity|no\.?|#)\b", line, re.I):
|
| 96 |
+
return None
|
| 97 |
+
if re.match(r"^(pesanan|po\s*#|monthly|tolong|please|note|hai|makasih|thanks)", line, re.I):
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
# "Product x25" or "Product | 25"
|
| 101 |
+
m = re.match(r"^(.+?)\s*[x×]\s*(\d+)\s*$", line, re.I)
|
| 102 |
+
if m:
|
| 103 |
+
product = _normalize_product(m.group(1))
|
| 104 |
+
return {"product": product, "quantity": int(m.group(2)), "notes": ""} if product else None
|
| 105 |
+
|
| 106 |
+
m = re.match(r"^(.+?)\s*\|\s*(\d+)\s*$", line)
|
| 107 |
+
if m:
|
| 108 |
+
product = _normalize_product(m.group(1))
|
| 109 |
+
return {"product": product, "quantity": int(m.group(2)), "notes": ""} if product else None
|
| 110 |
+
|
| 111 |
+
# "name 20 pcs" or "name, 20"
|
| 112 |
+
m = re.match(r"^(.+?)[,\s]+(\d+)\s*(?:pcs|pc)?\.?\s*$", line, re.I)
|
| 113 |
+
if m:
|
| 114 |
+
product = _normalize_product(m.group(1))
|
| 115 |
+
return {"product": product, "quantity": int(m.group(2)), "notes": ""} if product else None
|
| 116 |
+
|
| 117 |
+
# Comma-separated inline: "borobudur 20, wayang 15"
|
| 118 |
+
if "," not in line and re.search(r"\d", line):
|
| 119 |
+
qty = _extract_qty(line)
|
| 120 |
+
if qty > 0:
|
| 121 |
+
product_part = re.sub(r"\d+.*$", "", line).strip(" ,-")
|
| 122 |
+
product = _normalize_product(product_part)
|
| 123 |
+
if product:
|
| 124 |
+
return {"product": product, "quantity": qty, "notes": ""}
|
| 125 |
+
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _parse_comma_list(text: str) -> list[dict]:
|
| 130 |
+
items: list[dict] = []
|
| 131 |
+
for chunk in re.split(r"[,;\n]+", text):
|
| 132 |
+
chunk = chunk.strip()
|
| 133 |
+
if not chunk:
|
| 134 |
+
continue
|
| 135 |
+
m = re.match(r"^(.+?)\s+(\d+)\s*$", chunk)
|
| 136 |
+
if m:
|
| 137 |
+
product = _normalize_product(m.group(1))
|
| 138 |
+
if product:
|
| 139 |
+
items.append({"product": product, "quantity": int(m.group(2)), "notes": ""})
|
| 140 |
+
return items
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def parse_po_fallback(po_text: str) -> dict:
|
| 144 |
+
"""Extract items from messy PO text without an LLM."""
|
| 145 |
+
items: list[dict] = []
|
| 146 |
+
seen: set[tuple[str, int]] = set()
|
| 147 |
+
store_notes: list[str] = []
|
| 148 |
+
|
| 149 |
+
for line in po_text.splitlines():
|
| 150 |
+
parsed = _parse_line(line)
|
| 151 |
+
if parsed and parsed["quantity"] > 0:
|
| 152 |
+
key = (parsed["product"], parsed["quantity"])
|
| 153 |
+
if key not in seen:
|
| 154 |
+
seen.add(key)
|
| 155 |
+
items.append(parsed)
|
| 156 |
+
elif line.strip() and re.match(r"^(note|catatan|tolong|please|ship|kirim)", line.strip(), re.I):
|
| 157 |
+
store_notes.append(line.strip())
|
| 158 |
+
|
| 159 |
+
if len(items) < 2:
|
| 160 |
+
items.extend(_parse_comma_list(po_text))
|
| 161 |
+
|
| 162 |
+
# Deduplicate by product, sum quantities
|
| 163 |
+
merged: dict[str, dict] = {}
|
| 164 |
+
for it in items:
|
| 165 |
+
name = it["product"]
|
| 166 |
+
if name in merged:
|
| 167 |
+
merged[name]["quantity"] += it["quantity"]
|
| 168 |
+
else:
|
| 169 |
+
merged[name] = dict(it)
|
| 170 |
+
|
| 171 |
+
return {
|
| 172 |
+
"items": list(merged.values()),
|
| 173 |
+
"store_notes": " ".join(store_notes)[:200],
|
| 174 |
+
}
|
po_normalizer.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 1 of the Piper pipeline — normalize Indonesian/English code-mixed PO text."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
# Informal Indonesian + code-mixed phrases seen in real partner-store orders
|
| 6 |
+
SLANG_PATTERNS: list[tuple[str, str, str]] = [
|
| 7 |
+
(r"\bmohon segera\b", "urgent ship request", "mohon segera"),
|
| 8 |
+
(r"\btolong kirim\b", "please ship", "tolong kirim"),
|
| 9 |
+
(r"\bkirim ya\b", "please ship", "kirim ya"),
|
| 10 |
+
(r"\brestock asap\b", "restock segera", "restock asap"),
|
| 11 |
+
(r"\bstok habis\b", "out of stock", "stok habis"),
|
| 12 |
+
(r"\blg diskon\b", "on sale", "lg diskon"),
|
| 13 |
+
(r"\blagi diskon\b", "on sale", "lagi diskon"),
|
| 14 |
+
(r"\bpesenan\b", "order", "pesanan"),
|
| 15 |
+
(r"\bpesan\b", "order", "pesan"),
|
| 16 |
+
(r"\bstiker\b", "sticker", "stiker"),
|
| 17 |
+
(r"\bstik(er)?\b", "sticker", "stiker"),
|
| 18 |
+
(r"\blembar\b", "pcs", "lembar"),
|
| 19 |
+
(r"\bbuah\b", "pcs", "buah"),
|
| 20 |
+
(r"\bunit\b", "pcs", "unit"),
|
| 21 |
+
(r"\bpcs\b", "pcs", "pcs"),
|
| 22 |
+
(r"\bpesanan bulan\b", "monthly order", "pesanan bulan"),
|
| 23 |
+
(r"\bmau order lagi\b", "reorder", "mau order lagi"),
|
| 24 |
+
(r"\bhai kak\b", "", "hai kak"),
|
| 25 |
+
(r"\bmakasih\b", "", "makasih"),
|
| 26 |
+
(r"\bthx\b", "", "thx"),
|
| 27 |
+
(r"\bthanks\b", "", "thanks"),
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
# Quantity patterns: "20 pcs", "x25", "20,"
|
| 31 |
+
QTY_HINT = re.compile(
|
| 32 |
+
r"(\d+)\s*(?:pcs|pc|lembar|buah|unit)?",
|
| 33 |
+
re.I,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def normalize_po_text(raw: str) -> tuple[str, list[str]]:
|
| 38 |
+
"""
|
| 39 |
+
Clean code-mixed PO text before the LLM sees it.
|
| 40 |
+
Returns (normalized_text, list of human-readable fixes applied).
|
| 41 |
+
"""
|
| 42 |
+
text = raw.strip()
|
| 43 |
+
fixes: list[str] = []
|
| 44 |
+
|
| 45 |
+
for pattern, replacement, label in SLANG_PATTERNS:
|
| 46 |
+
if re.search(pattern, text, re.I):
|
| 47 |
+
text = re.sub(pattern, replacement, text, flags=re.I)
|
| 48 |
+
if label and label not in fixes:
|
| 49 |
+
fixes.append(label)
|
| 50 |
+
|
| 51 |
+
# Collapse repeated whitespace but keep line breaks
|
| 52 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 53 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 54 |
+
text = re.sub(r"^[,;\s]+", "", text)
|
| 55 |
+
text = re.sub(r"[,;\s]{2,}", ", ", text)
|
| 56 |
+
|
| 57 |
+
return text, fixes
|
prompts.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from data import SAMPLE_CATALOG
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def build_parse_po_system(catalog: list[str]) -> str:
|
| 5 |
+
catalog_lines = "\n".join(f"- {name}" for name in catalog)
|
| 6 |
+
return (
|
| 7 |
+
"You are Piper, the AI assistant for Paperain Studio, a sticker shop in Indonesia. "
|
| 8 |
+
"Given messy PO text (could be from Excel, handwritten notes, mixed Indonesian/English), "
|
| 9 |
+
"extract ALL sticker items and return ONLY valid JSON:\n"
|
| 10 |
+
'{"items": [{"product": "exact catalog name", '
|
| 11 |
+
'"quantity": number, "notes": "any extra details or empty string"}], '
|
| 12 |
+
'"store_notes": "any general notes from the store"}\n'
|
| 13 |
+
"Rules:\n"
|
| 14 |
+
"- Return ONLY valid JSON, no markdown fences, no explanation\n"
|
| 15 |
+
"- If quantity is unclear, set to 0\n"
|
| 16 |
+
"- product MUST be the closest match from the official catalog below\n"
|
| 17 |
+
"- Indonesian/English/slang OK in input; output the catalog name\n"
|
| 18 |
+
"- Example: 'stiker kucing hologram' -> 'Holographic Cat Sticker'\n"
|
| 19 |
+
"- Keep original wording in notes if you translate it\n"
|
| 20 |
+
"- Be thorough: extract every line item\n\n"
|
| 21 |
+
"Few-shot examples (code-mixed Indonesian/English → JSON):\n"
|
| 22 |
+
'Input: "stiker kucing hologram 20 pcs, sakura 15 lembar"\n'
|
| 23 |
+
'Output: {"items":[{"product":"Holographic Cat Sticker","quantity":20,"notes":"stiker kucing hologram"},'
|
| 24 |
+
'{"product":"Sakura Die-Cut Sticker","quantity":15,"notes":"sakura"}],'
|
| 25 |
+
'"store_notes":""}\n'
|
| 26 |
+
'Input: "restock ASAP: boba 25, unicorn x10 — tolong kirim minggu ini"\n'
|
| 27 |
+
'Output: {"items":[{"product":"Kawaii Boba Tea Sticker","quantity":25,"notes":"restock ASAP"},'
|
| 28 |
+
'{"product":"Rainbow Unicorn Sticker","quantity":10,"notes":""}],'
|
| 29 |
+
'"store_notes":"tolong kirim minggu ini"}\n\n'
|
| 30 |
+
f"Official sticker catalog ({len(catalog)} designs):\n{catalog_lines}"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
PARSE_PO_SYSTEM = build_parse_po_system(SAMPLE_CATALOG)
|
| 35 |
+
|
| 36 |
+
DELIVERY_SYSTEM = (
|
| 37 |
+
"You are Piper, writing a delivery document / packing list for Paperain Studio's sticker shipment. "
|
| 38 |
+
"Given the store name, items, and language preference, write a clean professional document. "
|
| 39 |
+
"Include: header with date and store name, itemized list with quantities, "
|
| 40 |
+
"a total count, and a note asking them to verify receipt. "
|
| 41 |
+
"Keep it concise and professional."
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
BESTSELLER_SYSTEM = (
|
| 45 |
+
"You are Piper, the sales analyst for Paperain Studio. Given sales data across multiple stores, "
|
| 46 |
+
"write a short, friendly recommendation of the top selling sticker varieties. "
|
| 47 |
+
"Explain WHY they sell well and suggest which stores should consider stocking them. "
|
| 48 |
+
"Keep it practical and concise. Write in the requested language."
|
| 49 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0
|
| 2 |
+
huggingface-hub
|
| 3 |
+
pandas
|
services.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import math
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
from catalog_resolver import resolve_items
|
| 10 |
+
from config import call_llm, llm_available
|
| 11 |
+
from data import SAMPLE_CATALOG
|
| 12 |
+
from parser_fallback import parse_po_fallback
|
| 13 |
+
from po_normalizer import normalize_po_text
|
| 14 |
+
from prompts import DELIVERY_SYSTEM, BESTSELLER_SYSTEM, build_parse_po_system
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _extract_json_from_llm(raw: str) -> dict | None:
|
| 18 |
+
json_str = raw.strip()
|
| 19 |
+
if "```" in json_str:
|
| 20 |
+
json_str = "\n".join(
|
| 21 |
+
line for line in json_str.split("\n")
|
| 22 |
+
if not line.strip().startswith("```")
|
| 23 |
+
)
|
| 24 |
+
try:
|
| 25 |
+
return json.loads(json_str)
|
| 26 |
+
except json.JSONDecodeError:
|
| 27 |
+
start, end = raw.find("{"), raw.rfind("}") + 1
|
| 28 |
+
if start != -1 and end > start:
|
| 29 |
+
try:
|
| 30 |
+
return json.loads(raw[start:end])
|
| 31 |
+
except json.JSONDecodeError:
|
| 32 |
+
return None
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _validate_po_data(data: dict) -> dict:
|
| 37 |
+
"""Stage 3a — enforce structured schema before catalog anchoring."""
|
| 38 |
+
items = data.get("items") or []
|
| 39 |
+
clean = []
|
| 40 |
+
for it in items:
|
| 41 |
+
if not isinstance(it, dict):
|
| 42 |
+
continue
|
| 43 |
+
qty = it.get("quantity", 0)
|
| 44 |
+
try:
|
| 45 |
+
qty = int(qty)
|
| 46 |
+
except (TypeError, ValueError):
|
| 47 |
+
qty = 0
|
| 48 |
+
product = str(it.get("product", "")).strip()
|
| 49 |
+
if product and qty > 0:
|
| 50 |
+
clean.append({
|
| 51 |
+
"product": product,
|
| 52 |
+
"quantity": qty,
|
| 53 |
+
"notes": str(it.get("notes", "")).strip(),
|
| 54 |
+
})
|
| 55 |
+
return {"items": clean, "store_notes": str(data.get("store_notes", "")).strip()}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def parse_po(store_name: str, po_text: str, all_pos: dict) -> tuple:
|
| 59 |
+
if not po_text.strip():
|
| 60 |
+
return pd.DataFrame(columns=["Product", "Qty", "Notes"]), "Please paste a PO first.", all_pos
|
| 61 |
+
|
| 62 |
+
used_fallback = False
|
| 63 |
+
data = None
|
| 64 |
+
|
| 65 |
+
# Stage 1: normalize Indonesian/English code-mixed slang
|
| 66 |
+
normalized, slang_fixes = normalize_po_text(po_text)
|
| 67 |
+
|
| 68 |
+
# Stage 2: Qwen extracts strict JSON (temperature=0.1 in config)
|
| 69 |
+
raw = call_llm(
|
| 70 |
+
f"Store: {store_name}\n\nPO text:\n{normalized}",
|
| 71 |
+
system=build_parse_po_system(SAMPLE_CATALOG),
|
| 72 |
+
)
|
| 73 |
+
if not raw.startswith("[LLM Error"):
|
| 74 |
+
data = _extract_json_from_llm(raw)
|
| 75 |
+
|
| 76 |
+
if data is None:
|
| 77 |
+
data = parse_po_fallback(normalized)
|
| 78 |
+
used_fallback = True
|
| 79 |
+
if not data.get("items"):
|
| 80 |
+
err = raw[:300] if raw.startswith("[LLM Error") else "No line items detected."
|
| 81 |
+
return (
|
| 82 |
+
pd.DataFrame(columns=["Product", "Qty", "Notes"]),
|
| 83 |
+
f"Could not parse PO. {err}",
|
| 84 |
+
all_pos,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
data = _validate_po_data(data)
|
| 88 |
+
|
| 89 |
+
# Stage 3b: anchor product names to official catalog (fuzzy match)
|
| 90 |
+
raw_items = data.get("items", [])
|
| 91 |
+
items = resolve_items(raw_items, SAMPLE_CATALOG)
|
| 92 |
+
catalog_hits = sum(
|
| 93 |
+
1 for before, after in zip(raw_items, items)
|
| 94 |
+
if before.get("product", "").strip().lower() != after.get("product", "").strip().lower()
|
| 95 |
+
)
|
| 96 |
+
store_notes = data.get("store_notes", "")
|
| 97 |
+
rows = [
|
| 98 |
+
{
|
| 99 |
+
"Product": it.get("product", ""),
|
| 100 |
+
"Qty": it.get("quantity", 0),
|
| 101 |
+
"Notes": it.get("notes", ""),
|
| 102 |
+
}
|
| 103 |
+
for it in items
|
| 104 |
+
]
|
| 105 |
+
df = pd.DataFrame(rows) if rows else pd.DataFrame(columns=["Product", "Qty", "Notes"])
|
| 106 |
+
|
| 107 |
+
all_pos[store_name] = {
|
| 108 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
| 109 |
+
"items": rows,
|
| 110 |
+
"store_notes": store_notes,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
total_qty = sum(r["Qty"] for r in rows)
|
| 114 |
+
engine = "offline rules" if used_fallback else "Qwen 2.5-7B"
|
| 115 |
+
pipeline = "normalize → extract JSON → catalog anchor"
|
| 116 |
+
status = (
|
| 117 |
+
f"Parsed {len(rows)} items ({total_qty} stickers) from {store_name} | "
|
| 118 |
+
f"Pipeline: {pipeline} | Engine: {engine}"
|
| 119 |
+
)
|
| 120 |
+
if slang_fixes:
|
| 121 |
+
status += f" | Slang normalized: {', '.join(slang_fixes[:4])}"
|
| 122 |
+
if len(slang_fixes) > 4:
|
| 123 |
+
status += f" +{len(slang_fixes) - 4} more"
|
| 124 |
+
if catalog_hits:
|
| 125 |
+
status += f" | Catalog matched: {catalog_hits} name(s)"
|
| 126 |
+
if store_notes:
|
| 127 |
+
status += f" | Notes: {store_notes}"
|
| 128 |
+
return df, status, all_pos
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def save_po(store_name: str, edited_table, all_pos: dict) -> tuple:
|
| 132 |
+
if edited_table is None or (isinstance(edited_table, pd.DataFrame) and edited_table.empty):
|
| 133 |
+
return all_pos, "Nothing to save."
|
| 134 |
+
if isinstance(edited_table, pd.DataFrame):
|
| 135 |
+
rows = edited_table.to_dict("records")
|
| 136 |
+
else:
|
| 137 |
+
rows = [{"Product": r[0], "Qty": r[1], "Notes": r[2]} for r in edited_table]
|
| 138 |
+
all_pos[store_name] = all_pos.get(
|
| 139 |
+
store_name,
|
| 140 |
+
{"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), "store_notes": ""},
|
| 141 |
+
)
|
| 142 |
+
all_pos[store_name]["items"] = rows
|
| 143 |
+
return all_pos, f"Saved {len(rows)} items for {store_name}."
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def build_demand_table(all_pos: dict, stock_df) -> tuple:
|
| 147 |
+
if isinstance(stock_df, list):
|
| 148 |
+
stock_df = pd.DataFrame(stock_df, columns=["Product", "In Stock"])
|
| 149 |
+
|
| 150 |
+
demand: dict[str, int] = {}
|
| 151 |
+
stores_with_po: list[str] = []
|
| 152 |
+
for store, po_data in all_pos.items():
|
| 153 |
+
stores_with_po.append(store)
|
| 154 |
+
for item in po_data.get("items", []):
|
| 155 |
+
qty = item["Qty"] if isinstance(item["Qty"], (int, float)) else 0
|
| 156 |
+
demand[item["Product"]] = demand.get(item["Product"], 0) + int(qty)
|
| 157 |
+
|
| 158 |
+
if not demand:
|
| 159 |
+
return (
|
| 160 |
+
pd.DataFrame(columns=["Product", "Total Demand", "In Stock", "Shortage"]),
|
| 161 |
+
"No POs parsed yet. Go to the PO Intake tab first.",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
stock_map = {}
|
| 165 |
+
if stock_df is not None and not stock_df.empty:
|
| 166 |
+
stock_map = {row["Product"]: int(row["In Stock"]) for _, row in stock_df.iterrows()}
|
| 167 |
+
|
| 168 |
+
rows = [
|
| 169 |
+
{
|
| 170 |
+
"Product": p,
|
| 171 |
+
"Total Demand": d,
|
| 172 |
+
"In Stock": stock_map.get(p, 0),
|
| 173 |
+
"Shortage": max(0, d - stock_map.get(p, 0)),
|
| 174 |
+
}
|
| 175 |
+
for p, d in sorted(demand.items())
|
| 176 |
+
]
|
| 177 |
+
df = pd.DataFrame(rows)
|
| 178 |
+
shortage_total = sum(r["Shortage"] for r in rows)
|
| 179 |
+
summary = (
|
| 180 |
+
f"POs from {len(stores_with_po)} stores | "
|
| 181 |
+
f"{len(rows)} products | "
|
| 182 |
+
f"Shortage: {shortage_total} stickers to produce"
|
| 183 |
+
)
|
| 184 |
+
return df, summary
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def calculate_printing(demand_df) -> tuple:
|
| 188 |
+
if demand_df is None or (isinstance(demand_df, pd.DataFrame) and demand_df.empty):
|
| 189 |
+
return (
|
| 190 |
+
pd.DataFrame(columns=["Product", "Qty to Print", "A3 Sheets (8 per sheet)"]),
|
| 191 |
+
"No shortage data. Check the Demand tab first.",
|
| 192 |
+
None,
|
| 193 |
+
)
|
| 194 |
+
if isinstance(demand_df, list):
|
| 195 |
+
demand_df = pd.DataFrame(demand_df, columns=["Product", "Total Demand", "In Stock", "Shortage"])
|
| 196 |
+
|
| 197 |
+
rows: list[dict] = []
|
| 198 |
+
total_sheets = 0
|
| 199 |
+
for _, row in demand_df.iterrows():
|
| 200 |
+
shortage = int(row.get("Shortage", 0))
|
| 201 |
+
if shortage > 0:
|
| 202 |
+
sheets = math.ceil(shortage / 8)
|
| 203 |
+
total_sheets += sheets
|
| 204 |
+
rows.append({
|
| 205 |
+
"Product": row["Product"],
|
| 206 |
+
"Qty to Print": shortage,
|
| 207 |
+
"A3 Sheets (8 per sheet)": sheets,
|
| 208 |
+
})
|
| 209 |
+
|
| 210 |
+
if not rows:
|
| 211 |
+
return (
|
| 212 |
+
pd.DataFrame(columns=["Product", "Qty to Print", "A3 Sheets (8 per sheet)"]),
|
| 213 |
+
"No printing needed -- stock covers all demand!",
|
| 214 |
+
None,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
df = pd.DataFrame(rows)
|
| 218 |
+
path = os.path.join(tempfile.gettempdir(), f"print_order_{datetime.now():%Y%m%d_%H%M%S}.csv")
|
| 219 |
+
df.to_csv(path, index=False)
|
| 220 |
+
|
| 221 |
+
total_qty = sum(r["Qty to Print"] for r in rows)
|
| 222 |
+
summary = f"Print {total_qty} stickers across {len(rows)} varieties | Total A3 sheets: {total_sheets}"
|
| 223 |
+
return df, summary, path
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _delivery_doc_fallback(store_name: str, items: list, language: str) -> str:
|
| 227 |
+
lang = "id" if language.lower().startswith("indo") else "en"
|
| 228 |
+
date_str = datetime.now().strftime("%d %B %Y")
|
| 229 |
+
lines = items if lang == "en" else items
|
| 230 |
+
header = (
|
| 231 |
+
f"PAPERAIN STUDIO — DELIVERY NOTE\n{'=' * 40}\n"
|
| 232 |
+
f"{'Store' if lang == 'en' else 'Toko'}: {store_name}\n"
|
| 233 |
+
f"{'Date' if lang == 'en' else 'Tanggal'}: {date_str}\n\n"
|
| 234 |
+
f"{'Items' if lang == 'en' else 'Daftar barang'}:\n"
|
| 235 |
+
)
|
| 236 |
+
body = ""
|
| 237 |
+
total = 0
|
| 238 |
+
for i, it in enumerate([x for x in items if x.get("Qty", 0) > 0], 1):
|
| 239 |
+
qty = int(it.get("Qty", 0))
|
| 240 |
+
total += qty
|
| 241 |
+
body += f" {i}. {it['Product']} — {qty} pcs\n"
|
| 242 |
+
footer = (
|
| 243 |
+
f"\n{'Total stickers' if lang == 'en' else 'Total stiker'}: {total} pcs\n\n"
|
| 244 |
+
+ (
|
| 245 |
+
"Please verify receipt and contact us if anything is missing.\n— Paperain Studio, Yogyakarta"
|
| 246 |
+
if lang == "en"
|
| 247 |
+
else "Mohon periksa barang saat diterima. Hubungi kami jika ada yang kurang.\n— Paperain Studio, Yogyakarta"
|
| 248 |
+
)
|
| 249 |
+
)
|
| 250 |
+
return header + body + footer
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def generate_delivery_doc(store_name: str, all_pos: dict, language: str) -> str:
|
| 254 |
+
if store_name not in all_pos or not all_pos[store_name].get("items"):
|
| 255 |
+
return f"No PO data for {store_name}. Parse their PO first in the PO Intake tab."
|
| 256 |
+
|
| 257 |
+
items = all_pos[store_name]["items"]
|
| 258 |
+
items_text = "\n".join(
|
| 259 |
+
f"- {it['Product']}: {it['Qty']} pcs"
|
| 260 |
+
for it in items if it.get("Qty", 0) > 0
|
| 261 |
+
)
|
| 262 |
+
total = sum(it.get("Qty", 0) for it in items)
|
| 263 |
+
|
| 264 |
+
prompt = (
|
| 265 |
+
f"Store: {store_name}\n"
|
| 266 |
+
f"Date: {datetime.now():%B %d, %Y}\n"
|
| 267 |
+
f"Language: {language}\n"
|
| 268 |
+
f"Total items: {total}\n\n"
|
| 269 |
+
f"Items to deliver:\n{items_text}"
|
| 270 |
+
)
|
| 271 |
+
raw = call_llm(prompt, system=DELIVERY_SYSTEM)
|
| 272 |
+
if raw.startswith("[LLM Error"):
|
| 273 |
+
return _delivery_doc_fallback(store_name, items, language)
|
| 274 |
+
return raw
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def generate_bestseller_report(all_pos: dict, language: str) -> str:
|
| 278 |
+
if not all_pos:
|
| 279 |
+
return "No PO data yet. Parse some store POs first to generate recommendations."
|
| 280 |
+
|
| 281 |
+
demand: dict[str, int] = {}
|
| 282 |
+
store_demand: dict[str, dict[str, int]] = {}
|
| 283 |
+
for store, po_data in all_pos.items():
|
| 284 |
+
store_demand[store] = {}
|
| 285 |
+
for item in po_data.get("items", []):
|
| 286 |
+
qty = int(item.get("Qty", 0)) if isinstance(item.get("Qty", 0), (int, float)) else 0
|
| 287 |
+
demand[item["Product"]] = demand.get(item["Product"], 0) + qty
|
| 288 |
+
store_demand[store][item["Product"]] = qty
|
| 289 |
+
|
| 290 |
+
if not demand:
|
| 291 |
+
return "No order data to analyze."
|
| 292 |
+
|
| 293 |
+
sales_text = "Aggregated demand across all stores:\n"
|
| 294 |
+
for product, total in sorted(demand.items(), key=lambda x: x[1], reverse=True):
|
| 295 |
+
stores = [
|
| 296 |
+
f"{s} ({store_demand[s].get(product, 0)})"
|
| 297 |
+
for s in store_demand if store_demand[s].get(product, 0) > 0
|
| 298 |
+
]
|
| 299 |
+
sales_text += f"- {product}: {total} total ({', '.join(stores)})\n"
|
| 300 |
+
|
| 301 |
+
raw = call_llm(f"Language: {language}\n\n{sales_text}", system=BESTSELLER_SYSTEM)
|
| 302 |
+
if raw.startswith("[LLM Error"):
|
| 303 |
+
return _bestseller_fallback(demand, store_demand, language)
|
| 304 |
+
return raw
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _bestseller_fallback(demand: dict, store_demand: dict, language: str) -> str:
|
| 308 |
+
lang = "id" if language.lower().startswith("indo") else "en"
|
| 309 |
+
top = sorted(demand.items(), key=lambda x: x[1], reverse=True)[:8]
|
| 310 |
+
title = "Piper's Best Seller Report" if lang == "en" else "Laporan Best Seller dari Piper"
|
| 311 |
+
lines = [f"{title}\n{'=' * 36}\n"]
|
| 312 |
+
for rank, (product, total) in enumerate(top, 1):
|
| 313 |
+
stores = [s for s, d in store_demand.items() if d.get(product, 0) > 0]
|
| 314 |
+
if lang == "en":
|
| 315 |
+
lines.append(f"{rank}. {product} — {total} ordered across {len(stores)} store(s)")
|
| 316 |
+
else:
|
| 317 |
+
lines.append(f"{rank}. {product} — {total} dipesan dari {len(stores)} toko")
|
| 318 |
+
lines.append(
|
| 319 |
+
"\nThese designs sell consistently — great for restock and new partner onboarding."
|
| 320 |
+
if lang == "en"
|
| 321 |
+
else "\nDesain ini laris — cocok untuk restock dan rekomendasi ke toko baru."
|
| 322 |
+
)
|
| 323 |
+
return "\n".join(lines)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def run_full_workflow(all_pos: dict, stock_df) -> tuple:
|
| 327 |
+
"""Demand + print in one click (uses existing PO state)."""
|
| 328 |
+
demand_df, demand_summary = build_demand_table(all_pos, stock_df)
|
| 329 |
+
print_df, print_summary, print_csv = calculate_printing(demand_df)
|
| 330 |
+
combined = f"{demand_summary}\n\n{print_summary}"
|
| 331 |
+
return demand_df, combined, print_df, print_summary, print_csv
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def format_po_log(all_pos: dict) -> str:
|
| 335 |
+
if not all_pos:
|
| 336 |
+
return "*No POs parsed yet.*"
|
| 337 |
+
md = ""
|
| 338 |
+
for store, data in all_pos.items():
|
| 339 |
+
md += f"### {store} ({data.get('timestamp', '')})\n"
|
| 340 |
+
for it in data.get("items", []):
|
| 341 |
+
md += f"- {it.get('Product', '')} x{it.get('Qty', 0)}\n"
|
| 342 |
+
if data.get("store_notes"):
|
| 343 |
+
md += f"\n*Notes: {data['store_notes']}*\n"
|
| 344 |
+
md += "\n---\n\n"
|
| 345 |
+
return md
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def export_all_pos_csv(all_pos: dict):
|
| 349 |
+
if not all_pos:
|
| 350 |
+
return None
|
| 351 |
+
rows = []
|
| 352 |
+
for store, data in all_pos.items():
|
| 353 |
+
for item in data.get("items", []):
|
| 354 |
+
rows.append({
|
| 355 |
+
"Store": store,
|
| 356 |
+
"Product": item.get("Product", ""),
|
| 357 |
+
"Qty": item.get("Qty", 0),
|
| 358 |
+
"Notes": item.get("Notes", ""),
|
| 359 |
+
"Parsed At": data.get("timestamp", ""),
|
| 360 |
+
})
|
| 361 |
+
if not rows:
|
| 362 |
+
return None
|
| 363 |
+
df = pd.DataFrame(rows)
|
| 364 |
+
path = os.path.join(tempfile.gettempdir(), f"all_pos_{datetime.now():%Y%m%d_%H%M%S}.csv")
|
| 365 |
+
df.to_csv(path, index=False)
|
| 366 |
+
return path
|
ui.py
ADDED
|
@@ -0,0 +1,1132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
|
| 3 |
+
from data import STORE_NAMES, SAMPLE_PO_EXAMPLES, make_default_stock
|
| 4 |
+
from services import (
|
| 5 |
+
parse_po,
|
| 6 |
+
save_po,
|
| 7 |
+
build_demand_table,
|
| 8 |
+
calculate_printing,
|
| 9 |
+
generate_delivery_doc,
|
| 10 |
+
generate_bestseller_report,
|
| 11 |
+
format_po_log,
|
| 12 |
+
export_all_pos_csv,
|
| 13 |
+
run_full_workflow,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
# Design system — Paperain light mode (high contrast, cream + dark text)
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
CUSTOM_CSS = """
|
| 20 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;600;700;800&display=swap');
|
| 21 |
+
|
| 22 |
+
:root {
|
| 23 |
+
--primary: #2D2D2D;
|
| 24 |
+
--primary-container: #FFF4D6;
|
| 25 |
+
--on-primary-container: #1A1A1A;
|
| 26 |
+
--secondary: #5C5C5C;
|
| 27 |
+
--secondary-container: #F0F0EE;
|
| 28 |
+
--on-secondary-container: #1A1A1A;
|
| 29 |
+
--surface: #FFFFFF;
|
| 30 |
+
--surface-low: #F5F4F1;
|
| 31 |
+
--background: #FFFFFF;
|
| 32 |
+
--on-surface: #1A1A1A;
|
| 33 |
+
--on-surface-variant:#3D3D3D;
|
| 34 |
+
--outline: #E0DDD6;
|
| 35 |
+
--honey: #C9A227;
|
| 36 |
+
--radius: 16px;
|
| 37 |
+
--radius-lg: 24px;
|
| 38 |
+
--radius-pill: 999px;
|
| 39 |
+
--shadow-soft: 0 4px 24px rgba(104, 95, 48, 0.08);
|
| 40 |
+
--shadow-card: 0 2px 12px rgba(28, 27, 27, 0.05);
|
| 41 |
+
color-scheme: light !important;
|
| 42 |
+
--body-background-fill: var(--background) !important;
|
| 43 |
+
--background-fill-primary: var(--surface) !important;
|
| 44 |
+
--background-fill-secondary: var(--surface-low) !important;
|
| 45 |
+
--block-background-fill: transparent !important;
|
| 46 |
+
--block-border-color: transparent !important;
|
| 47 |
+
--body-text-color: #1A1A1A !important;
|
| 48 |
+
--body-text-color-subdued: #444444 !important;
|
| 49 |
+
--block-label-text-color: #1A1A1A !important;
|
| 50 |
+
--input-background-fill: #FFFFFF !important;
|
| 51 |
+
--input-text-color: #1A1A1A !important;
|
| 52 |
+
--input-border-color: var(--outline) !important;
|
| 53 |
+
--border-color-primary: var(--outline) !important;
|
| 54 |
+
--table-text-color: #1A1A1A !important;
|
| 55 |
+
--table-background-fill: #FFFFFF !important;
|
| 56 |
+
--table-even-background-fill: #F5F4F1 !important;
|
| 57 |
+
--table-odd-background-fill: #FFFFFF !important;
|
| 58 |
+
--table-border-color: var(--outline) !important;
|
| 59 |
+
--checkbox-label-text-color: #1A1A1A !important;
|
| 60 |
+
--radio-label-text-color: #1A1A1A !important;
|
| 61 |
+
--block-label-background-fill: #F5F4F1 !important;
|
| 62 |
+
--block-title-background-fill: #F5F4F1 !important;
|
| 63 |
+
--checkbox-label-background-fill: #FFFFFF !important;
|
| 64 |
+
--checkbox-label-border-color: #E0DDD6 !important;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* ── Force readable dark text on labels/markdown (not buttons or tables) ── */
|
| 68 |
+
.gradio-container label,
|
| 69 |
+
.gradio-container .label-wrap,
|
| 70 |
+
.gradio-container .label-wrap span,
|
| 71 |
+
.gradio-container .block-label,
|
| 72 |
+
.gradio-container fieldset legend,
|
| 73 |
+
.gradio-container .markdown,
|
| 74 |
+
.gradio-container .prose,
|
| 75 |
+
.gradio-container .prose p,
|
| 76 |
+
.gradio-container .markdown p,
|
| 77 |
+
.gradio-container .tab-hint,
|
| 78 |
+
.gradio-container .tab-hint span {
|
| 79 |
+
color: #1A1A1A !important;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.gradio-container textarea,
|
| 83 |
+
.gradio-container input,
|
| 84 |
+
.gradio-container select,
|
| 85 |
+
.gradio-container .gr-textbox textarea,
|
| 86 |
+
.gradio-container .gr-textbox input,
|
| 87 |
+
.gradio-container [contenteditable] {
|
| 88 |
+
color: #1A1A1A !important;
|
| 89 |
+
-webkit-text-fill-color: #1A1A1A !important;
|
| 90 |
+
background: #FFFFFF !important;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.gradio-container table,
|
| 94 |
+
.gradio-container td,
|
| 95 |
+
.gradio-container th,
|
| 96 |
+
.gradio-container .table-wrap,
|
| 97 |
+
.gradio-container .dataframe {
|
| 98 |
+
color: #1A1A1A !important;
|
| 99 |
+
background-color: #FFFFFF !important;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.gradio-container table thead th {
|
| 103 |
+
background: #F0EDE8 !important;
|
| 104 |
+
color: #1A1A1A !important;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.gradio-container table tbody td {
|
| 108 |
+
background: #FFFFFF !important;
|
| 109 |
+
color: #1A1A1A !important;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.gradio-container table tbody tr:nth-child(even) td {
|
| 113 |
+
background: #F5F4F1 !important;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.gradio-container .wrap > label,
|
| 117 |
+
.gradio-container .form > label,
|
| 118 |
+
.gradio-container .svelte-1gfkn6j {
|
| 119 |
+
color: #1A1A1A !important;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* ── Full-width: kill HF Spaces dark gutters ── */
|
| 123 |
+
html, body, #root, .app, main, .wrap, .contain, footer.svelte-1ax1toq {
|
| 124 |
+
background: var(--background) !important;
|
| 125 |
+
color-scheme: light !important;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.contain {
|
| 129 |
+
max-width: 100% !important;
|
| 130 |
+
padding: 0 !important;
|
| 131 |
+
margin: 0 !important;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.gradio-container {
|
| 135 |
+
max-width: 100% !important;
|
| 136 |
+
width: 100% !important;
|
| 137 |
+
margin: 0 !important;
|
| 138 |
+
padding: 0 0 72px !important;
|
| 139 |
+
font-family: 'Inter', system-ui, sans-serif !important;
|
| 140 |
+
background: var(--background) !important;
|
| 141 |
+
color: var(--on-surface) !important;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.gradio-container .block,
|
| 145 |
+
.gradio-container .form,
|
| 146 |
+
.gradio-container .column,
|
| 147 |
+
.gradio-container .row {
|
| 148 |
+
background: transparent !important;
|
| 149 |
+
border: none !important;
|
| 150 |
+
box-shadow: none !important;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.gradio-container h1, .gradio-container h2, .gradio-container h3,
|
| 154 |
+
.gradio-container .prose h1, .gradio-container .prose h2, .gradio-container .prose h3 {
|
| 155 |
+
font-family: 'Montserrat', sans-serif !important;
|
| 156 |
+
color: var(--on-surface) !important;
|
| 157 |
+
letter-spacing: -0.02em;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.gradio-container p, .gradio-container span, .gradio-container label,
|
| 161 |
+
.gradio-container .markdown, .gradio-container .prose {
|
| 162 |
+
font-family: 'Inter', sans-serif !important;
|
| 163 |
+
color: var(--on-surface-variant) !important;
|
| 164 |
+
line-height: 1.6;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* ── Top nav ── */
|
| 168 |
+
.top-nav {
|
| 169 |
+
position: sticky;
|
| 170 |
+
top: 0;
|
| 171 |
+
z-index: 100;
|
| 172 |
+
background: rgba(250, 250, 248, 0.95);
|
| 173 |
+
backdrop-filter: blur(12px);
|
| 174 |
+
border-bottom: 1px solid var(--outline);
|
| 175 |
+
margin-bottom: 0;
|
| 176 |
+
}
|
| 177 |
+
.top-nav-inner {
|
| 178 |
+
max-width: 1400px;
|
| 179 |
+
margin: 0 auto;
|
| 180 |
+
padding: 14px 40px;
|
| 181 |
+
display: flex;
|
| 182 |
+
align-items: center;
|
| 183 |
+
justify-content: space-between;
|
| 184 |
+
gap: 16px;
|
| 185 |
+
}
|
| 186 |
+
.top-nav-brand {
|
| 187 |
+
font-family: 'Montserrat', sans-serif;
|
| 188 |
+
font-weight: 700;
|
| 189 |
+
font-size: 1.15rem;
|
| 190 |
+
color: var(--primary);
|
| 191 |
+
display: flex;
|
| 192 |
+
align-items: center;
|
| 193 |
+
gap: 8px;
|
| 194 |
+
}
|
| 195 |
+
.top-nav-brand span.icon { font-size: 1.3rem; }
|
| 196 |
+
.top-nav-meta {
|
| 197 |
+
font-size: 0.75rem;
|
| 198 |
+
font-weight: 600;
|
| 199 |
+
color: var(--on-surface-variant);
|
| 200 |
+
letter-spacing: 0.04em;
|
| 201 |
+
text-transform: uppercase;
|
| 202 |
+
}
|
| 203 |
+
@media (max-width: 768px) {
|
| 204 |
+
.top-nav-inner { padding: 12px 16px; }
|
| 205 |
+
.top-nav-meta { display: none; }
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/* ── App shell ── */
|
| 209 |
+
.app-shell {
|
| 210 |
+
max-width: 1400px;
|
| 211 |
+
margin: 0 auto;
|
| 212 |
+
padding: 32px 40px 0;
|
| 213 |
+
width: 100%;
|
| 214 |
+
box-sizing: border-box;
|
| 215 |
+
}
|
| 216 |
+
@media (max-width: 768px) {
|
| 217 |
+
.app-shell { padding: 20px 16px 0; }
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/* ── Hero ── */
|
| 221 |
+
.hero-section {
|
| 222 |
+
background: var(--surface);
|
| 223 |
+
border: 1px solid var(--outline);
|
| 224 |
+
border-radius: var(--radius-lg);
|
| 225 |
+
padding: 40px 44px 36px;
|
| 226 |
+
margin-bottom: 24px;
|
| 227 |
+
box-shadow: var(--shadow-soft);
|
| 228 |
+
}
|
| 229 |
+
.hero-grid {
|
| 230 |
+
display: grid;
|
| 231 |
+
grid-template-columns: 1fr 1fr;
|
| 232 |
+
gap: 32px;
|
| 233 |
+
align-items: start;
|
| 234 |
+
}
|
| 235 |
+
@media (max-width: 900px) {
|
| 236 |
+
.hero-grid { grid-template-columns: 1fr; }
|
| 237 |
+
}
|
| 238 |
+
.hero-section h1 {
|
| 239 |
+
font-size: clamp(2rem, 4vw, 2.75rem);
|
| 240 |
+
margin: 0 0 10px;
|
| 241 |
+
color: var(--on-surface) !important;
|
| 242 |
+
line-height: 1.15;
|
| 243 |
+
}
|
| 244 |
+
.hero-tagline {
|
| 245 |
+
font-size: 1.05rem;
|
| 246 |
+
color: var(--on-surface-variant);
|
| 247 |
+
margin: 0 0 20px;
|
| 248 |
+
line-height: 1.55;
|
| 249 |
+
max-width: 520px;
|
| 250 |
+
}
|
| 251 |
+
.hero-meta {
|
| 252 |
+
display: flex;
|
| 253 |
+
align-items: center;
|
| 254 |
+
gap: 8px;
|
| 255 |
+
flex-wrap: wrap;
|
| 256 |
+
}
|
| 257 |
+
.hero-pill {
|
| 258 |
+
display: inline-flex;
|
| 259 |
+
align-items: center;
|
| 260 |
+
gap: 5px;
|
| 261 |
+
background: var(--primary-container);
|
| 262 |
+
color: var(--on-primary-container);
|
| 263 |
+
font-size: 0.72rem;
|
| 264 |
+
font-weight: 700;
|
| 265 |
+
padding: 6px 14px;
|
| 266 |
+
border-radius: var(--radius-pill);
|
| 267 |
+
letter-spacing: 0.03em;
|
| 268 |
+
text-transform: uppercase;
|
| 269 |
+
white-space: nowrap;
|
| 270 |
+
}
|
| 271 |
+
.hero-pill.blue {
|
| 272 |
+
background: var(--secondary-container);
|
| 273 |
+
color: var(--on-secondary-container);
|
| 274 |
+
}
|
| 275 |
+
.hero-pill.muted {
|
| 276 |
+
background: var(--surface-low);
|
| 277 |
+
color: var(--on-surface-variant);
|
| 278 |
+
font-weight: 600;
|
| 279 |
+
text-transform: none;
|
| 280 |
+
letter-spacing: 0;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.stats-row {
|
| 284 |
+
display: grid;
|
| 285 |
+
grid-template-columns: repeat(2, 1fr);
|
| 286 |
+
gap: 14px;
|
| 287 |
+
}
|
| 288 |
+
@media (min-width: 600px) {
|
| 289 |
+
.stats-row { grid-template-columns: repeat(4, 1fr); }
|
| 290 |
+
}
|
| 291 |
+
.stat {
|
| 292 |
+
background: var(--surface-low);
|
| 293 |
+
border: 1px solid var(--outline);
|
| 294 |
+
border-radius: var(--radius);
|
| 295 |
+
padding: 20px 14px;
|
| 296 |
+
text-align: center;
|
| 297 |
+
transition: transform 0.15s, box-shadow 0.15s;
|
| 298 |
+
}
|
| 299 |
+
.stat:hover {
|
| 300 |
+
transform: translateY(-2px);
|
| 301 |
+
box-shadow: var(--shadow-card);
|
| 302 |
+
}
|
| 303 |
+
.stat:nth-child(1) { border-left: 4px solid var(--honey); background: #FFF8E7; }
|
| 304 |
+
.stat:nth-child(2) { border-left: 4px solid #8B9DAF; background: #F0F4F8; }
|
| 305 |
+
.stat:nth-child(3) { border-left: 4px solid #6B6B6B; background: #F5F4F1; }
|
| 306 |
+
.stat:nth-child(4) { border-left: 4px solid #7CB342; background: #F0F7EA; }
|
| 307 |
+
.stat .number {
|
| 308 |
+
font-family: 'Montserrat', sans-serif;
|
| 309 |
+
font-size: 1.85rem;
|
| 310 |
+
font-weight: 800;
|
| 311 |
+
color: var(--on-surface) !important;
|
| 312 |
+
line-height: 1.1;
|
| 313 |
+
display: block;
|
| 314 |
+
white-space: nowrap;
|
| 315 |
+
}
|
| 316 |
+
.stat .label {
|
| 317 |
+
font-size: 0.68rem;
|
| 318 |
+
font-weight: 700;
|
| 319 |
+
text-transform: uppercase;
|
| 320 |
+
letter-spacing: 0.07em;
|
| 321 |
+
color: var(--on-surface-variant) !important;
|
| 322 |
+
margin-top: 6px;
|
| 323 |
+
display: block;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/* ── Workflow steps ── */
|
| 327 |
+
.step-flow {
|
| 328 |
+
display: flex;
|
| 329 |
+
align-items: center;
|
| 330 |
+
gap: 6px;
|
| 331 |
+
flex-wrap: wrap;
|
| 332 |
+
margin-top: 28px;
|
| 333 |
+
padding-top: 24px;
|
| 334 |
+
border-top: 1px solid var(--outline);
|
| 335 |
+
}
|
| 336 |
+
.step-flow .step {
|
| 337 |
+
background: var(--surface-low);
|
| 338 |
+
border: 1px solid var(--outline);
|
| 339 |
+
border-radius: var(--radius-pill);
|
| 340 |
+
padding: 8px 16px;
|
| 341 |
+
font-size: 0.8rem;
|
| 342 |
+
font-weight: 600;
|
| 343 |
+
color: var(--on-surface-variant);
|
| 344 |
+
}
|
| 345 |
+
.step-flow .step.active {
|
| 346 |
+
background: var(--primary-container);
|
| 347 |
+
border-color: var(--primary);
|
| 348 |
+
color: var(--on-surface);
|
| 349 |
+
}
|
| 350 |
+
.step-flow .arrow {
|
| 351 |
+
color: var(--outline);
|
| 352 |
+
font-size: 0.85rem;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/* ── Tabs (Gradio 6 — force dark labels) ── */
|
| 356 |
+
.tabs, .tab-nav, .tabitem {
|
| 357 |
+
border: none !important;
|
| 358 |
+
background: transparent !important;
|
| 359 |
+
}
|
| 360 |
+
.tab-nav {
|
| 361 |
+
border-bottom: 2px solid #D0D0D0 !important;
|
| 362 |
+
gap: 4px !important;
|
| 363 |
+
margin-bottom: 0 !important;
|
| 364 |
+
padding: 0 !important;
|
| 365 |
+
flex-wrap: wrap !important;
|
| 366 |
+
}
|
| 367 |
+
.gradio-container .tabs button,
|
| 368 |
+
.gradio-container .tab-nav button,
|
| 369 |
+
.gradio-container button[role="tab"],
|
| 370 |
+
.gradio-container .tab-nav > button {
|
| 371 |
+
font-family: 'Inter', sans-serif !important;
|
| 372 |
+
font-weight: 600 !important;
|
| 373 |
+
font-size: 0.9rem !important;
|
| 374 |
+
border-radius: 8px 8px 0 0 !important;
|
| 375 |
+
border: 1px solid #D0D0D0 !important;
|
| 376 |
+
border-bottom: none !important;
|
| 377 |
+
background: #F5F5F5 !important;
|
| 378 |
+
color: #111111 !important;
|
| 379 |
+
-webkit-text-fill-color: #111111 !important;
|
| 380 |
+
padding: 10px 18px !important;
|
| 381 |
+
margin-bottom: -1px !important;
|
| 382 |
+
}
|
| 383 |
+
.gradio-container .tabs button:hover,
|
| 384 |
+
.gradio-container .tab-nav button:hover {
|
| 385 |
+
background: #EEEEEE !important;
|
| 386 |
+
color: #000000 !important;
|
| 387 |
+
}
|
| 388 |
+
.gradio-container .tabs button.selected,
|
| 389 |
+
.gradio-container .tab-nav button.selected,
|
| 390 |
+
.gradio-container button[role="tab"][aria-selected="true"] {
|
| 391 |
+
background: #FFFFFF !important;
|
| 392 |
+
color: #000000 !important;
|
| 393 |
+
-webkit-text-fill-color: #000000 !important;
|
| 394 |
+
border-color: #D0D0D0 !important;
|
| 395 |
+
border-bottom: 2px solid #FFFFFF !important;
|
| 396 |
+
box-shadow: inset 0 -3px 0 #111111 !important;
|
| 397 |
+
}
|
| 398 |
+
.tabitem {
|
| 399 |
+
background: var(--surface) !important;
|
| 400 |
+
border: 1px solid var(--outline) !important;
|
| 401 |
+
border-radius: var(--radius-lg) !important;
|
| 402 |
+
padding: 28px !important;
|
| 403 |
+
margin-top: 4px !important;
|
| 404 |
+
box-shadow: var(--shadow-card) !important;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
/* ── Panels (PO intake split view) ── */
|
| 408 |
+
.panel-input {
|
| 409 |
+
background: rgba(255, 255, 255, 0.7) !important;
|
| 410 |
+
border: 1px solid var(--outline) !important;
|
| 411 |
+
border-radius: var(--radius) !important;
|
| 412 |
+
padding: 20px !important;
|
| 413 |
+
}
|
| 414 |
+
.panel-output {
|
| 415 |
+
background: var(--surface-low) !important;
|
| 416 |
+
border: 1px solid var(--outline) !important;
|
| 417 |
+
border-radius: var(--radius) !important;
|
| 418 |
+
padding: 20px !important;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/* ── Buttons (Gradio 6 variant classes + inner spans) ── */
|
| 422 |
+
.gradio-container button.primary,
|
| 423 |
+
.gradio-container button.secondary,
|
| 424 |
+
.gradio-container .gr-button-primary,
|
| 425 |
+
.gradio-container .gr-button-secondary,
|
| 426 |
+
.gradio-container button[class*="primary"],
|
| 427 |
+
.gradio-container button[class*="secondary"] {
|
| 428 |
+
border-radius: var(--radius-pill) !important;
|
| 429 |
+
font-family: 'Inter', sans-serif !important;
|
| 430 |
+
font-weight: 600 !important;
|
| 431 |
+
font-size: 0.88rem !important;
|
| 432 |
+
padding: 11px 22px !important;
|
| 433 |
+
transition: transform 0.12s, box-shadow 0.12s, background 0.12s !important;
|
| 434 |
+
}
|
| 435 |
+
.gradio-container button.primary,
|
| 436 |
+
.gradio-container .gr-button-primary,
|
| 437 |
+
.gradio-container button[class*="primary"]:not([class*="secondary"]) {
|
| 438 |
+
background: #2D2D2D !important;
|
| 439 |
+
background-color: #2D2D2D !important;
|
| 440 |
+
color: #FFFFFF !important;
|
| 441 |
+
-webkit-text-fill-color: #FFFFFF !important;
|
| 442 |
+
border: none !important;
|
| 443 |
+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12) !important;
|
| 444 |
+
font-weight: 700 !important;
|
| 445 |
+
}
|
| 446 |
+
.gradio-container button.primary *,
|
| 447 |
+
.gradio-container .gr-button-primary *,
|
| 448 |
+
.gradio-container button[class*="primary"]:not([class*="secondary"]) * {
|
| 449 |
+
color: #FFFFFF !important;
|
| 450 |
+
-webkit-text-fill-color: #FFFFFF !important;
|
| 451 |
+
}
|
| 452 |
+
.gradio-container button.primary:hover,
|
| 453 |
+
.gradio-container .gr-button-primary:hover {
|
| 454 |
+
transform: translateY(-1px) !important;
|
| 455 |
+
background: #2D2D2D !important;
|
| 456 |
+
background-color: #2D2D2D !important;
|
| 457 |
+
color: #FFFFFF !important;
|
| 458 |
+
}
|
| 459 |
+
.gradio-container button.secondary,
|
| 460 |
+
.gradio-container .gr-button-secondary,
|
| 461 |
+
.gradio-container button[class*="secondary"] {
|
| 462 |
+
background: #FFFFFF !important;
|
| 463 |
+
background-color: #FFFFFF !important;
|
| 464 |
+
color: #111111 !important;
|
| 465 |
+
-webkit-text-fill-color: #111111 !important;
|
| 466 |
+
border: 1px solid #CCCCCC !important;
|
| 467 |
+
}
|
| 468 |
+
.gradio-container button.secondary *,
|
| 469 |
+
.gradio-container .gr-button-secondary *,
|
| 470 |
+
.gradio-container button[class*="secondary"] * {
|
| 471 |
+
color: #111111 !important;
|
| 472 |
+
-webkit-text-fill-color: #111111 !important;
|
| 473 |
+
}
|
| 474 |
+
.gradio-container button.secondary:hover,
|
| 475 |
+
.gradio-container .gr-button-secondary:hover {
|
| 476 |
+
background: #F5F5F5 !important;
|
| 477 |
+
background-color: #F5F5F5 !important;
|
| 478 |
+
color: #000000 !important;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
/* ── Inputs ── */
|
| 482 |
+
textarea, input[type="text"], input[type="number"], select {
|
| 483 |
+
font-family: 'Inter', sans-serif !important;
|
| 484 |
+
border: 1px solid var(--outline) !important;
|
| 485 |
+
border-radius: var(--radius) !important;
|
| 486 |
+
font-size: 0.92rem !important;
|
| 487 |
+
background: var(--surface) !important;
|
| 488 |
+
}
|
| 489 |
+
textarea:focus, input:focus, select:focus {
|
| 490 |
+
border-color: var(--primary) !important;
|
| 491 |
+
box-shadow: 0 0 0 3px rgba(255, 241, 181, 0.6) !important;
|
| 492 |
+
outline: none !important;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
/* ── Dataframes / tables (standard light spreadsheet) ── */
|
| 496 |
+
.gradio-container .table-wrap,
|
| 497 |
+
.gradio-container .dataframe-wrap,
|
| 498 |
+
.gradio-container [class*="dataframe"] {
|
| 499 |
+
border: 1px solid #CCCCCC !important;
|
| 500 |
+
border-radius: 8px !important;
|
| 501 |
+
overflow: hidden !important;
|
| 502 |
+
background: #FFFFFF !important;
|
| 503 |
+
}
|
| 504 |
+
.gradio-container table,
|
| 505 |
+
.gradio-container table thead,
|
| 506 |
+
.gradio-container table tbody,
|
| 507 |
+
.gradio-container table tr,
|
| 508 |
+
.gradio-container table th,
|
| 509 |
+
.gradio-container table td,
|
| 510 |
+
.gradio-container .table-wrap table *,
|
| 511 |
+
.gradio-container [class*="dataframe"] table * {
|
| 512 |
+
color: #111111 !important;
|
| 513 |
+
-webkit-text-fill-color: #111111 !important;
|
| 514 |
+
font-size: 0.9rem !important;
|
| 515 |
+
}
|
| 516 |
+
.gradio-container table thead th,
|
| 517 |
+
.gradio-container table th {
|
| 518 |
+
font-family: 'Inter', sans-serif !important;
|
| 519 |
+
font-weight: 700 !important;
|
| 520 |
+
background: #E8E8E8 !important;
|
| 521 |
+
background-color: #E8E8E8 !important;
|
| 522 |
+
color: #111111 !important;
|
| 523 |
+
font-size: 0.78rem !important;
|
| 524 |
+
text-transform: uppercase !important;
|
| 525 |
+
letter-spacing: 0.04em !important;
|
| 526 |
+
border-bottom: 1px solid #CCCCCC !important;
|
| 527 |
+
}
|
| 528 |
+
.gradio-container table tbody td,
|
| 529 |
+
.gradio-container table td {
|
| 530 |
+
background: #FFFFFF !important;
|
| 531 |
+
background-color: #FFFFFF !important;
|
| 532 |
+
color: #111111 !important;
|
| 533 |
+
border-bottom: 1px solid #EEEEEE !important;
|
| 534 |
+
}
|
| 535 |
+
.gradio-container table tbody tr:nth-child(even) td {
|
| 536 |
+
background: #F9F9F9 !important;
|
| 537 |
+
background-color: #F9F9F9 !important;
|
| 538 |
+
}
|
| 539 |
+
.gradio-container table tbody tr:hover td {
|
| 540 |
+
background: #F0F4FF !important;
|
| 541 |
+
background-color: #F0F4FF !important;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
/* ── Gradio 6 Dataframe (div cells + CSS variables, incl. dark-mode class) ── */
|
| 545 |
+
.gradio-container,
|
| 546 |
+
.gradio-container .gradio-dataframe,
|
| 547 |
+
.gradio-container .gradio-dataframe-standalone,
|
| 548 |
+
.gradio-container [class*="dataframe"] {
|
| 549 |
+
--gr-df-table-bg-even: #F5F4F1 !important;
|
| 550 |
+
--gr-df-table-bg-odd: #FFFFFF !important;
|
| 551 |
+
--gr-df-table-text: #111111 !important;
|
| 552 |
+
--gr-df-table-border: #E0DDD6 !important;
|
| 553 |
+
--gr-df-accent: #4A6FA5 !important;
|
| 554 |
+
--gr-df-accent-soft: #F0F4FF !important;
|
| 555 |
+
--table-even-background-fill: #F5F4F1 !important;
|
| 556 |
+
--table-odd-background-fill: #FFFFFF !important;
|
| 557 |
+
--table-text-color: #111111 !important;
|
| 558 |
+
--table-row-focus: #F0F4FF !important;
|
| 559 |
+
--table-border-color: #E0DDD6 !important;
|
| 560 |
+
--df-table-even-background-fill: #F5F4F1 !important;
|
| 561 |
+
--df-table-odd-background-fill: #FFFFFF !important;
|
| 562 |
+
--df-body-text-color: #111111 !important;
|
| 563 |
+
--df-background-fill-primary: #FFFFFF !important;
|
| 564 |
+
--df-background-fill-secondary: #F5F4F1 !important;
|
| 565 |
+
--df-block-background-fill: #FFFFFF !important;
|
| 566 |
+
--background-fill-primary: #FFFFFF !important;
|
| 567 |
+
--background-fill-secondary: #F5F4F1 !important;
|
| 568 |
+
--body-text-color: #111111 !important;
|
| 569 |
+
}
|
| 570 |
+
html.dark .gradio-container,
|
| 571 |
+
.dark .gradio-container,
|
| 572 |
+
.gradio-container.dark {
|
| 573 |
+
--gr-df-table-bg-even: #F5F4F1 !important;
|
| 574 |
+
--gr-df-table-bg-odd: #FFFFFF !important;
|
| 575 |
+
--gr-df-table-text: #111111 !important;
|
| 576 |
+
--table-even-background-fill: #F5F4F1 !important;
|
| 577 |
+
--table-odd-background-fill: #FFFFFF !important;
|
| 578 |
+
--table-text-color: #111111 !important;
|
| 579 |
+
--df-table-even-background-fill: #F5F4F1 !important;
|
| 580 |
+
--df-table-odd-background-fill: #FFFFFF !important;
|
| 581 |
+
--df-body-text-color: #111111 !important;
|
| 582 |
+
--df-background-fill-primary: #FFFFFF !important;
|
| 583 |
+
--df-background-fill-secondary: #F5F4F1 !important;
|
| 584 |
+
--background-fill-primary: #FFFFFF !important;
|
| 585 |
+
--background-fill-secondary: #F5F4F1 !important;
|
| 586 |
+
--body-text-color: #111111 !important;
|
| 587 |
+
}
|
| 588 |
+
.gradio-container .cell-wrap,
|
| 589 |
+
.gradio-container .table-wrap .cell-wrap,
|
| 590 |
+
.gradio-container [class*="dataframe"] .cell-wrap {
|
| 591 |
+
color: #111111 !important;
|
| 592 |
+
-webkit-text-fill-color: #111111 !important;
|
| 593 |
+
background-color: #FFFFFF !important;
|
| 594 |
+
}
|
| 595 |
+
.gradio-container .cell-wrap input,
|
| 596 |
+
.gradio-container .cell-wrap textarea {
|
| 597 |
+
color: #111111 !important;
|
| 598 |
+
-webkit-text-fill-color: #111111 !important;
|
| 599 |
+
background: #FFFFFF !important;
|
| 600 |
+
}
|
| 601 |
+
.gradio-container .header-row .cell-wrap,
|
| 602 |
+
.gradio-container thead .cell-wrap,
|
| 603 |
+
.gradio-container [class*="dataframe"] .header-row .cell-wrap {
|
| 604 |
+
background-color: #E8E8E8 !important;
|
| 605 |
+
color: #111111 !important;
|
| 606 |
+
font-weight: 700 !important;
|
| 607 |
+
}
|
| 608 |
+
.gradio-container tr:nth-child(even) .cell-wrap,
|
| 609 |
+
.gradio-container .row-even .cell-wrap {
|
| 610 |
+
background-color: #F5F4F1 !important;
|
| 611 |
+
}
|
| 612 |
+
.gradio-container tr:hover .cell-wrap,
|
| 613 |
+
.gradio-container .row-selected .cell-wrap,
|
| 614 |
+
.gradio-container .selected .cell-wrap {
|
| 615 |
+
background-color: #F0F4FF !important;
|
| 616 |
+
color: #111111 !important;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
/* ── Tab hints ── */
|
| 620 |
+
.tab-hint {
|
| 621 |
+
background: #FFFFFF;
|
| 622 |
+
border: 1px solid var(--outline);
|
| 623 |
+
border-left: 4px solid var(--honey);
|
| 624 |
+
border-radius: 0 var(--radius) var(--radius) 0;
|
| 625 |
+
padding: 14px 18px;
|
| 626 |
+
margin-bottom: 20px;
|
| 627 |
+
font-size: 0.88rem;
|
| 628 |
+
color: #3D3D3D !important;
|
| 629 |
+
}
|
| 630 |
+
.tab-hint strong { color: #1A1A1A !important; }
|
| 631 |
+
|
| 632 |
+
/* ── Block labels, field labels, radio/checkbox pills ── */
|
| 633 |
+
.gradio-container .block-label,
|
| 634 |
+
.gradio-container .block-label span,
|
| 635 |
+
.gradio-container .block-title,
|
| 636 |
+
.gradio-container .label-wrap span:not(.icon) {
|
| 637 |
+
background: #F5F4F1 !important;
|
| 638 |
+
background-color: #F5F4F1 !important;
|
| 639 |
+
color: #1A1A1A !important;
|
| 640 |
+
-webkit-text-fill-color: #1A1A1A !important;
|
| 641 |
+
border-color: #E0DDD6 !important;
|
| 642 |
+
}
|
| 643 |
+
.gradio-container fieldset label,
|
| 644 |
+
.gradio-container .wrap > label,
|
| 645 |
+
.gradio-container .form label {
|
| 646 |
+
background: #FFFFFF !important;
|
| 647 |
+
background-color: #FFFFFF !important;
|
| 648 |
+
color: #1A1A1A !important;
|
| 649 |
+
-webkit-text-fill-color: #1A1A1A !important;
|
| 650 |
+
border-color: #E0DDD6 !important;
|
| 651 |
+
box-shadow: none !important;
|
| 652 |
+
}
|
| 653 |
+
.gradio-container fieldset label:hover,
|
| 654 |
+
.gradio-container .wrap > label:hover {
|
| 655 |
+
background: #F5F4F1 !important;
|
| 656 |
+
background-color: #F5F4F1 !important;
|
| 657 |
+
color: #1A1A1A !important;
|
| 658 |
+
}
|
| 659 |
+
.gradio-container fieldset label.selected,
|
| 660 |
+
.gradio-container .wrap > label.selected {
|
| 661 |
+
background: #FFF4D6 !important;
|
| 662 |
+
background-color: #FFF4D6 !important;
|
| 663 |
+
color: #1A1A1A !important;
|
| 664 |
+
border-color: var(--honey) !important;
|
| 665 |
+
}
|
| 666 |
+
/* ── Accordion ── */
|
| 667 |
+
.gradio-container .label-wrap {
|
| 668 |
+
font-family: 'Montserrat', sans-serif !important;
|
| 669 |
+
font-weight: 600 !important;
|
| 670 |
+
font-size: 0.85rem !important;
|
| 671 |
+
color: var(--on-surface-variant) !important;
|
| 672 |
+
text-transform: uppercase !important;
|
| 673 |
+
letter-spacing: 0.04em !important;
|
| 674 |
+
background: transparent !important;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/* ── Piper mascot ── */
|
| 678 |
+
.piper-container {
|
| 679 |
+
position: fixed !important;
|
| 680 |
+
bottom: 24px !important;
|
| 681 |
+
right: 24px !important;
|
| 682 |
+
z-index: 99999 !important;
|
| 683 |
+
animation: piperFloat 4s ease-in-out infinite;
|
| 684 |
+
}
|
| 685 |
+
@keyframes piperFloat {
|
| 686 |
+
0%, 100% { transform: translateY(0); }
|
| 687 |
+
50% { transform: translateY(-5px); }
|
| 688 |
+
}
|
| 689 |
+
.piper-body {
|
| 690 |
+
width: 60px; height: 68px;
|
| 691 |
+
background: var(--primary-container);
|
| 692 |
+
border-radius: 12px 12px 12px 20px;
|
| 693 |
+
position: relative;
|
| 694 |
+
box-shadow: var(--shadow-soft);
|
| 695 |
+
border: 2px solid var(--primary);
|
| 696 |
+
}
|
| 697 |
+
.piper-face {
|
| 698 |
+
position: absolute; top: 18px; left: 50%;
|
| 699 |
+
transform: translateX(-50%);
|
| 700 |
+
width: 44px; text-align: center;
|
| 701 |
+
}
|
| 702 |
+
.piper-eyes { display: flex; justify-content: center; gap: 9px; margin-bottom: 3px; }
|
| 703 |
+
.piper-eye {
|
| 704 |
+
width: 7px; height: 8px;
|
| 705 |
+
background: var(--on-surface);
|
| 706 |
+
border-radius: 50%;
|
| 707 |
+
animation: piperBlink 4s ease-in-out infinite;
|
| 708 |
+
}
|
| 709 |
+
@keyframes piperBlink {
|
| 710 |
+
0%, 42%, 48%, 100% { transform: scaleY(1); }
|
| 711 |
+
45% { transform: scaleY(0.1); }
|
| 712 |
+
}
|
| 713 |
+
.piper-mouth {
|
| 714 |
+
width: 11px; height: 5px;
|
| 715 |
+
border-bottom: 2px solid var(--on-surface);
|
| 716 |
+
border-radius: 0 0 8px 8px;
|
| 717 |
+
margin: 2px auto 0;
|
| 718 |
+
}
|
| 719 |
+
.piper-cheek {
|
| 720 |
+
position: absolute; top: 28px;
|
| 721 |
+
width: 8px; height: 5px;
|
| 722 |
+
background: #fadadd;
|
| 723 |
+
border-radius: 50%;
|
| 724 |
+
opacity: 0.7;
|
| 725 |
+
}
|
| 726 |
+
.piper-cheek.left { left: 6px; }
|
| 727 |
+
.piper-cheek.right { right: 6px; }
|
| 728 |
+
.piper-nametag {
|
| 729 |
+
text-align: center;
|
| 730 |
+
font-family: 'Inter', sans-serif;
|
| 731 |
+
font-size: 0.65rem;
|
| 732 |
+
font-weight: 800;
|
| 733 |
+
color: #111111 !important;
|
| 734 |
+
margin-top: 5px;
|
| 735 |
+
letter-spacing: 0.08em;
|
| 736 |
+
}
|
| 737 |
+
.piper-bubble {
|
| 738 |
+
position: absolute;
|
| 739 |
+
bottom: 78px; right: 0;
|
| 740 |
+
background: var(--surface);
|
| 741 |
+
border: 1px solid var(--outline);
|
| 742 |
+
border-radius: 14px 14px 4px 14px;
|
| 743 |
+
padding: 8px 13px;
|
| 744 |
+
font-size: 0.74rem;
|
| 745 |
+
font-weight: 600;
|
| 746 |
+
color: var(--on-surface-variant);
|
| 747 |
+
white-space: nowrap;
|
| 748 |
+
box-shadow: var(--shadow-card);
|
| 749 |
+
opacity: 0;
|
| 750 |
+
transform: translateY(4px);
|
| 751 |
+
transition: all 0.25s ease;
|
| 752 |
+
}
|
| 753 |
+
.piper-container:hover .piper-bubble {
|
| 754 |
+
opacity: 1;
|
| 755 |
+
transform: translateY(0);
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
.paperain-footer {
|
| 759 |
+
text-align: center;
|
| 760 |
+
padding: 28px 0 12px;
|
| 761 |
+
font-size: 0.78rem;
|
| 762 |
+
font-weight: 500;
|
| 763 |
+
color: var(--on-surface-variant);
|
| 764 |
+
letter-spacing: 0.02em;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
/* ── Keep light surfaces if Gradio applies .dark (system theme) ── */
|
| 768 |
+
html.dark,
|
| 769 |
+
html.dark body,
|
| 770 |
+
html.dark .gradio-container,
|
| 771 |
+
:root.dark,
|
| 772 |
+
:root.dark .gradio-container {
|
| 773 |
+
color-scheme: light !important;
|
| 774 |
+
background-color: #FAFAF8 !important;
|
| 775 |
+
--body-background-fill: #FAFAF8 !important;
|
| 776 |
+
--block-background-fill: #FFFFFF !important;
|
| 777 |
+
--background-fill-primary: #FFFFFF !important;
|
| 778 |
+
--background-fill-secondary: #F5F4F1 !important;
|
| 779 |
+
--body-text-color: #1A1A1A !important;
|
| 780 |
+
--gr-df-table-bg-even: #F5F4F1 !important;
|
| 781 |
+
--gr-df-table-bg-odd: #FFFFFF !important;
|
| 782 |
+
--gr-df-table-text: #1A1A1A !important;
|
| 783 |
+
--block-label-background-fill: #F5F4F1 !important;
|
| 784 |
+
--block-label-text-color: #1A1A1A !important;
|
| 785 |
+
--checkbox-label-background-fill: #FFFFFF !important;
|
| 786 |
+
--checkbox-label-text-color: #1A1A1A !important;
|
| 787 |
+
}
|
| 788 |
+
.app-shell {
|
| 789 |
+
background: transparent !important;
|
| 790 |
+
}
|
| 791 |
+
"""
|
| 792 |
+
|
| 793 |
+
# One-time head script: prefer light theme, no MutationObserver (avoids layout thrash).
|
| 794 |
+
APP_HEAD = """
|
| 795 |
+
<meta name="color-scheme" content="light">
|
| 796 |
+
<script>
|
| 797 |
+
(function () {
|
| 798 |
+
var root = document.documentElement;
|
| 799 |
+
root.classList.remove('dark');
|
| 800 |
+
root.style.colorScheme = 'light';
|
| 801 |
+
})();
|
| 802 |
+
</script>
|
| 803 |
+
"""
|
| 804 |
+
|
| 805 |
+
THEME = gr.themes.Soft(
|
| 806 |
+
primary_hue=gr.themes.colors.neutral,
|
| 807 |
+
secondary_hue=gr.themes.colors.neutral,
|
| 808 |
+
font=gr.themes.GoogleFont("Inter"),
|
| 809 |
+
).set(
|
| 810 |
+
body_background_fill="#FAFAF8",
|
| 811 |
+
body_text_color="#1A1A1A",
|
| 812 |
+
body_text_color_subdued="#444444",
|
| 813 |
+
block_background_fill="#FFFFFF",
|
| 814 |
+
block_border_width="0px",
|
| 815 |
+
block_shadow="none",
|
| 816 |
+
block_label_background_fill="#F5F4F1",
|
| 817 |
+
block_label_text_color="#1A1A1A",
|
| 818 |
+
block_title_background_fill="#F5F4F1",
|
| 819 |
+
block_title_text_color="#1A1A1A",
|
| 820 |
+
input_background_fill="#FFFFFF",
|
| 821 |
+
input_border_color="#E0DDD6",
|
| 822 |
+
button_primary_background_fill="#2D2D2D",
|
| 823 |
+
button_primary_text_color="#FFFFFF",
|
| 824 |
+
button_secondary_background_fill="#FFFFFF",
|
| 825 |
+
button_secondary_text_color="#1A1A1A",
|
| 826 |
+
checkbox_label_background_fill="#FFFFFF",
|
| 827 |
+
checkbox_label_text_color="#1A1A1A",
|
| 828 |
+
checkbox_label_border_color="#E0DDD6",
|
| 829 |
+
checkbox_label_background_fill_selected="#FFF4D6",
|
| 830 |
+
checkbox_label_text_color_selected="#1A1A1A",
|
| 831 |
+
checkbox_label_border_color_selected="#C9A227",
|
| 832 |
+
table_even_background_fill="#F5F4F1",
|
| 833 |
+
table_odd_background_fill="#FFFFFF",
|
| 834 |
+
table_border_color="#E0DDD6",
|
| 835 |
+
table_row_focus="#F0F4FF",
|
| 836 |
+
)
|
| 837 |
+
|
| 838 |
+
NAV_HTML = """
|
| 839 |
+
<nav class="top-nav">
|
| 840 |
+
<div class="top-nav-inner">
|
| 841 |
+
<div class="top-nav-brand">
|
| 842 |
+
<span class="icon">✨</span>
|
| 843 |
+
<span>Piper · Paperain Studio</span>
|
| 844 |
+
</div>
|
| 845 |
+
<div class="top-nav-meta">Build Small Hackathon 2026 · Qwen 2.5-7B</div>
|
| 846 |
+
</div>
|
| 847 |
+
</nav>
|
| 848 |
+
"""
|
| 849 |
+
|
| 850 |
+
HERO_HTML = """
|
| 851 |
+
<div class="app-shell">
|
| 852 |
+
<div class="hero-section">
|
| 853 |
+
<div class="hero-grid">
|
| 854 |
+
<div class="hero-copy">
|
| 855 |
+
<h1>Paperain Studio</h1>
|
| 856 |
+
<p class="hero-tagline">
|
| 857 |
+
Turn messy purchase orders from 25 partner stores into organised, print-ready sticker restocks — powered by Piper AI.
|
| 858 |
+
</p>
|
| 859 |
+
<div class="hero-meta">
|
| 860 |
+
<span class="hero-pill">Piper AI</span>
|
| 861 |
+
<span class="hero-pill blue">3-Stage Parser</span>
|
| 862 |
+
<span class="hero-pill">Code-Mixed ID/EN</span>
|
| 863 |
+
<span class="hero-pill muted">Qwen 2.5-7B · Yogyakarta</span>
|
| 864 |
+
</div>
|
| 865 |
+
</div>
|
| 866 |
+
<div class="stats-row">
|
| 867 |
+
<div class="stat"><span class="number">25</span><span class="label">Partner Stores</span></div>
|
| 868 |
+
<div class="stat"><span class="number">150</span><span class="label">Sticker Designs</span></div>
|
| 869 |
+
<div class="stat"><span class="number">7B</span><span class="label">Parameters</span></div>
|
| 870 |
+
<div class="stat stat-hours"><span class="number">3-4</span><span class="label">hours saved / Week</span></div>
|
| 871 |
+
</div>
|
| 872 |
+
</div>
|
| 873 |
+
<div class="step-flow">
|
| 874 |
+
<span class="step active">1 · Paste messy PO</span>
|
| 875 |
+
<span class="arrow">→</span>
|
| 876 |
+
<span class="step">2 · Aggregate demand</span>
|
| 877 |
+
<span class="arrow">→</span>
|
| 878 |
+
<span class="step">3 · Calculate print</span>
|
| 879 |
+
<span class="arrow">→</span>
|
| 880 |
+
<span class="step">4 · Delivery docs</span>
|
| 881 |
+
</div>
|
| 882 |
+
</div>
|
| 883 |
+
</div>
|
| 884 |
+
"""
|
| 885 |
+
|
| 886 |
+
PIPER_HTML = """
|
| 887 |
+
<div class="piper-container" title="Piper — your sticker desk assistant">
|
| 888 |
+
<div class="piper-bubble">Hi! Paste a PO and I'll parse it ✨</div>
|
| 889 |
+
<div class="piper-body">
|
| 890 |
+
<div class="piper-face">
|
| 891 |
+
<div class="piper-eyes">
|
| 892 |
+
<div class="piper-eye"></div>
|
| 893 |
+
<div class="piper-eye"></div>
|
| 894 |
+
</div>
|
| 895 |
+
<div class="piper-mouth"></div>
|
| 896 |
+
</div>
|
| 897 |
+
<div class="piper-cheek left"></div>
|
| 898 |
+
<div class="piper-cheek right"></div>
|
| 899 |
+
</div>
|
| 900 |
+
<div class="piper-nametag">PIPER</div>
|
| 901 |
+
</div>
|
| 902 |
+
"""
|
| 903 |
+
|
| 904 |
+
FOOTER_HTML = (
|
| 905 |
+
'<div class="app-shell"><div class="paperain-footer">'
|
| 906 |
+
"Paperain Studio · Build Small Hackathon 2026 · Qwen 2.5-7B · Yogyakarta, Indonesia"
|
| 907 |
+
"</div></div>"
|
| 908 |
+
)
|
| 909 |
+
|
| 910 |
+
TABS_OPEN = '<div class="app-shell">'
|
| 911 |
+
|
| 912 |
+
|
| 913 |
+
def build_ui() -> gr.Blocks:
|
| 914 |
+
with gr.Blocks(
|
| 915 |
+
title="Paperain Studio — Sticker Restock Manager",
|
| 916 |
+
fill_width=True,
|
| 917 |
+
theme=THEME,
|
| 918 |
+
css=CUSTOM_CSS,
|
| 919 |
+
head=APP_HEAD,
|
| 920 |
+
) as demo:
|
| 921 |
+
|
| 922 |
+
all_pos_state = gr.State(value={})
|
| 923 |
+
demand_state = gr.State(value=None)
|
| 924 |
+
|
| 925 |
+
gr.HTML(NAV_HTML)
|
| 926 |
+
gr.HTML(HERO_HTML)
|
| 927 |
+
gr.HTML(TABS_OPEN)
|
| 928 |
+
|
| 929 |
+
# ── Tab 1: PO Intake ──
|
| 930 |
+
with gr.Tab("PO Intake"):
|
| 931 |
+
gr.HTML(
|
| 932 |
+
'<div class="tab-hint"><strong>Step 1:</strong> Paste a store PO below — '
|
| 933 |
+
"any format works. Click a sample to try, then hit <strong>Parse PO with Piper</strong>.</div>"
|
| 934 |
+
)
|
| 935 |
+
with gr.Row(equal_height=True):
|
| 936 |
+
with gr.Column(scale=2, elem_classes=["panel-input"]):
|
| 937 |
+
po_store = gr.Dropdown(
|
| 938 |
+
choices=STORE_NAMES,
|
| 939 |
+
value=STORE_NAMES[0],
|
| 940 |
+
label="Store",
|
| 941 |
+
allow_custom_value=True,
|
| 942 |
+
)
|
| 943 |
+
po_text = gr.Textbox(
|
| 944 |
+
label="Paste PO Text",
|
| 945 |
+
lines=12,
|
| 946 |
+
placeholder=(
|
| 947 |
+
"e.g.\nPesanan bulan Juni:\n"
|
| 948 |
+
"- Stiker kucing hologram 20 pcs\n"
|
| 949 |
+
"- Stiker sakura 15 pcs\n"
|
| 950 |
+
"- Rainbow unicorn 10\n"
|
| 951 |
+
"Tolong kirim sebelum tanggal 5"
|
| 952 |
+
),
|
| 953 |
+
)
|
| 954 |
+
with gr.Row():
|
| 955 |
+
parse_btn = gr.Button("Parse PO with Piper", variant="primary", scale=2)
|
| 956 |
+
save_btn = gr.Button("Save Edits", variant="secondary", scale=1)
|
| 957 |
+
po_status = gr.Textbox(label="Status", interactive=False, max_lines=3)
|
| 958 |
+
with gr.Column(scale=3, elem_classes=["panel-output"]):
|
| 959 |
+
po_table = gr.Dataframe(
|
| 960 |
+
headers=["Product", "Qty", "Notes"],
|
| 961 |
+
datatype=["str", "number", "str"],
|
| 962 |
+
interactive=True,
|
| 963 |
+
label="Piper's Interpretation (editable)",
|
| 964 |
+
)
|
| 965 |
+
|
| 966 |
+
with gr.Accordion("All Parsed POs", open=False):
|
| 967 |
+
po_log_md = gr.Markdown("*No POs parsed yet.*")
|
| 968 |
+
export_all_btn = gr.Button("Export All POs as CSV", variant="secondary")
|
| 969 |
+
export_all_file = gr.File(label="Download CSV")
|
| 970 |
+
|
| 971 |
+
gr.Markdown("#### Try These Sample POs")
|
| 972 |
+
gr.Examples(
|
| 973 |
+
examples=SAMPLE_PO_EXAMPLES,
|
| 974 |
+
inputs=[po_store, po_text],
|
| 975 |
+
label="Click an example, then Parse",
|
| 976 |
+
)
|
| 977 |
+
|
| 978 |
+
# ── Tab 2: Stock & Demand ──
|
| 979 |
+
with gr.Tab("Stock & Demand"):
|
| 980 |
+
gr.HTML(
|
| 981 |
+
'<div class="tab-hint"><strong>Step 2:</strong> After parsing POs, click '
|
| 982 |
+
"<strong>Refresh Demand</strong> to see total demand vs your home stock.</div>"
|
| 983 |
+
)
|
| 984 |
+
with gr.Row(equal_height=True):
|
| 985 |
+
with gr.Column(scale=1, elem_classes=["panel-input"]):
|
| 986 |
+
gr.Markdown("#### Home Stock")
|
| 987 |
+
stock_table = gr.Dataframe(
|
| 988 |
+
value=make_default_stock(),
|
| 989 |
+
headers=["Product", "In Stock"],
|
| 990 |
+
datatype=["str", "number"],
|
| 991 |
+
interactive=True,
|
| 992 |
+
label="Your inventory",
|
| 993 |
+
)
|
| 994 |
+
with gr.Column(scale=1, elem_classes=["panel-output"]):
|
| 995 |
+
gr.Markdown("#### Demand vs Stock")
|
| 996 |
+
refresh_demand_btn = gr.Button("Refresh Demand", variant="primary")
|
| 997 |
+
workflow_btn = gr.Button("Run Full Workflow (Demand + Print)", variant="secondary")
|
| 998 |
+
demand_summary = gr.Textbox(label="Summary", interactive=False, lines=2)
|
| 999 |
+
demand_table = gr.Dataframe(
|
| 1000 |
+
headers=["Product", "Total Demand", "In Stock", "Shortage"],
|
| 1001 |
+
datatype=["str", "number", "number", "number"],
|
| 1002 |
+
interactive=False,
|
| 1003 |
+
label="Aggregated demand",
|
| 1004 |
+
)
|
| 1005 |
+
|
| 1006 |
+
# ── Tab 3: Print Calculator ──
|
| 1007 |
+
with gr.Tab("Print Calculator"):
|
| 1008 |
+
gr.HTML(
|
| 1009 |
+
'<div class="tab-hint"><strong>Step 3:</strong> Each A3 sheet fits 8 A5 stickers. '
|
| 1010 |
+
"Only products with shortages appear here.</div>"
|
| 1011 |
+
)
|
| 1012 |
+
with gr.Column(elem_classes=["panel-output"]):
|
| 1013 |
+
calc_btn = gr.Button("Calculate Print Order", variant="primary")
|
| 1014 |
+
print_summary = gr.Textbox(label="Summary", interactive=False, lines=2)
|
| 1015 |
+
print_table = gr.Dataframe(
|
| 1016 |
+
headers=["Product", "Qty to Print", "A3 Sheets (8 per sheet)"],
|
| 1017 |
+
datatype=["str", "number", "number"],
|
| 1018 |
+
interactive=False,
|
| 1019 |
+
label="Print order",
|
| 1020 |
+
)
|
| 1021 |
+
print_csv = gr.File(label="Download Print Order CSV")
|
| 1022 |
+
|
| 1023 |
+
# ── Tab 4: Delivery Docs ──
|
| 1024 |
+
with gr.Tab("Delivery Docs"):
|
| 1025 |
+
gr.HTML(
|
| 1026 |
+
'<div class="tab-hint"><strong>Step 4:</strong> Generate a packing list for a specific store. '
|
| 1027 |
+
"Parse their PO first in Tab 1.</div>"
|
| 1028 |
+
)
|
| 1029 |
+
with gr.Column(elem_classes=["panel-input"]):
|
| 1030 |
+
with gr.Row():
|
| 1031 |
+
del_store = gr.Dropdown(
|
| 1032 |
+
choices=STORE_NAMES,
|
| 1033 |
+
value=STORE_NAMES[0],
|
| 1034 |
+
label="Store",
|
| 1035 |
+
allow_custom_value=True,
|
| 1036 |
+
)
|
| 1037 |
+
del_lang = gr.Radio(["English", "Indonesian"], value="English", label="Language")
|
| 1038 |
+
del_btn = gr.Button("Generate Delivery Document", variant="primary")
|
| 1039 |
+
del_output = gr.Textbox(label="Delivery Document", lines=16, interactive=False)
|
| 1040 |
+
|
| 1041 |
+
# ── Tab 5: Best Sellers ──
|
| 1042 |
+
with gr.Tab("Best Sellers"):
|
| 1043 |
+
gr.HTML(
|
| 1044 |
+
'<div class="tab-hint">Piper analyses demand across all parsed POs and recommends '
|
| 1045 |
+
"what to stock — share with stores that ask.</div>"
|
| 1046 |
+
)
|
| 1047 |
+
with gr.Column(elem_classes=["panel-output"]):
|
| 1048 |
+
with gr.Row():
|
| 1049 |
+
bs_lang = gr.Radio(["English", "Indonesian"], value="English", label="Language")
|
| 1050 |
+
bs_btn = gr.Button("Generate Report", variant="primary")
|
| 1051 |
+
bs_output = gr.Textbox(label="Best Seller Report", lines=16, interactive=False)
|
| 1052 |
+
|
| 1053 |
+
gr.HTML('</div>') # close app-shell
|
| 1054 |
+
gr.HTML(FOOTER_HTML)
|
| 1055 |
+
gr.HTML(PIPER_HTML)
|
| 1056 |
+
|
| 1057 |
+
# ── Events ──
|
| 1058 |
+
def parse_and_refresh(store, text, all_pos, stock):
|
| 1059 |
+
po_table_out, status, all_pos_out = parse_po(store, text, all_pos)
|
| 1060 |
+
demand_df, demand_sum = build_demand_table(all_pos_out, stock)
|
| 1061 |
+
log_md = format_po_log(all_pos_out)
|
| 1062 |
+
return po_table_out, status, all_pos_out, demand_df, demand_sum, log_md, demand_df
|
| 1063 |
+
|
| 1064 |
+
parse_btn.click(
|
| 1065 |
+
fn=parse_and_refresh,
|
| 1066 |
+
inputs=[po_store, po_text, all_pos_state, stock_table],
|
| 1067 |
+
outputs=[po_table, po_status, all_pos_state, demand_table, demand_summary, po_log_md, demand_state],
|
| 1068 |
+
)
|
| 1069 |
+
|
| 1070 |
+
save_btn.click(
|
| 1071 |
+
fn=save_po,
|
| 1072 |
+
inputs=[po_store, po_table, all_pos_state],
|
| 1073 |
+
outputs=[all_pos_state, po_status],
|
| 1074 |
+
).then(
|
| 1075 |
+
fn=format_po_log,
|
| 1076 |
+
inputs=[all_pos_state],
|
| 1077 |
+
outputs=[po_log_md],
|
| 1078 |
+
).then(
|
| 1079 |
+
fn=build_demand_table,
|
| 1080 |
+
inputs=[all_pos_state, stock_table],
|
| 1081 |
+
outputs=[demand_table, demand_summary],
|
| 1082 |
+
).then(
|
| 1083 |
+
fn=lambda d, s: d,
|
| 1084 |
+
inputs=[demand_table, demand_summary],
|
| 1085 |
+
outputs=[demand_state],
|
| 1086 |
+
)
|
| 1087 |
+
|
| 1088 |
+
export_all_btn.click(
|
| 1089 |
+
fn=export_all_pos_csv,
|
| 1090 |
+
inputs=[all_pos_state],
|
| 1091 |
+
outputs=[export_all_file],
|
| 1092 |
+
)
|
| 1093 |
+
|
| 1094 |
+
refresh_demand_btn.click(
|
| 1095 |
+
fn=build_demand_table,
|
| 1096 |
+
inputs=[all_pos_state, stock_table],
|
| 1097 |
+
outputs=[demand_table, demand_summary],
|
| 1098 |
+
).then(
|
| 1099 |
+
fn=lambda d, s: d,
|
| 1100 |
+
inputs=[demand_table, demand_summary],
|
| 1101 |
+
outputs=[demand_state],
|
| 1102 |
+
)
|
| 1103 |
+
|
| 1104 |
+
workflow_btn.click(
|
| 1105 |
+
fn=run_full_workflow,
|
| 1106 |
+
inputs=[all_pos_state, stock_table],
|
| 1107 |
+
outputs=[demand_table, demand_summary, print_table, print_summary, print_csv],
|
| 1108 |
+
).then(
|
| 1109 |
+
fn=lambda d, *_: d,
|
| 1110 |
+
inputs=[demand_table, demand_summary, print_table, print_summary, print_csv],
|
| 1111 |
+
outputs=[demand_state],
|
| 1112 |
+
)
|
| 1113 |
+
|
| 1114 |
+
calc_btn.click(
|
| 1115 |
+
fn=calculate_printing,
|
| 1116 |
+
inputs=[demand_table],
|
| 1117 |
+
outputs=[print_table, print_summary, print_csv],
|
| 1118 |
+
)
|
| 1119 |
+
|
| 1120 |
+
del_btn.click(
|
| 1121 |
+
fn=generate_delivery_doc,
|
| 1122 |
+
inputs=[del_store, all_pos_state, del_lang],
|
| 1123 |
+
outputs=[del_output],
|
| 1124 |
+
)
|
| 1125 |
+
|
| 1126 |
+
bs_btn.click(
|
| 1127 |
+
fn=generate_bestseller_report,
|
| 1128 |
+
inputs=[all_pos_state, bs_lang],
|
| 1129 |
+
outputs=[bs_output],
|
| 1130 |
+
)
|
| 1131 |
+
|
| 1132 |
+
return demo
|