Upload folder using huggingface_hub

#1
Files changed (14) hide show
  1. .gitattributes +35 -35
  2. .gitignore +28 -0
  3. HACKATHON_GUIDE.md +488 -0
  4. README.md +120 -15
  5. app.py +13 -0
  6. catalog_resolver.py +64 -0
  7. config.py +54 -0
  8. data.py +65 -0
  9. parser_fallback.py +174 -0
  10. po_normalizer.py +57 -0
  11. prompts.py +49 -0
  12. requirements.txt +3 -0
  13. services.py +366 -0
  14. 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: Piper Assistant
3
- emoji: 📈
4
- colorFrom: indigo
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- license: apache-2.0
12
- short_description: AI assistant specially built for small business
13
- ---
14
-
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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