yonilev commited on
Commit
55abd63
ยท
verified ยท
1 Parent(s): d3b7fbd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +253 -192
app.py CHANGED
@@ -5,16 +5,16 @@ Text2Receipt โ€” Hugging Face Gradio Space
5
  Pipeline:
6
  raw Hebrew note
7
  โ†’ fine-tuned parser (gemma-2-2b-it + LoRA)
8
- โ†’ agentic clarifier (+5% bonus โ€” detects legally-ambiguous fields)
9
  โ†’ deterministic complete() (VAT ยท serial ยท allocation number)
10
  โ†’ fiscal document display
11
  โ†’ FAISS recommender (3 similar past receipts)
12
- Repos used:
13
  Dataset : yonilev/Text2Receipt
14
- Model : yonilev/Text2Receipt-parser (LoRA adapter)
15
- Space : yonilev/Text2Receipt (embeddings stored here)
16
  """
17
- import os, json, re, random
18
  import numpy as np
19
  import gradio as gr
20
 
@@ -52,21 +52,27 @@ def _lazy_init():
52
  bnb_4bit_compute_dtype=torch.float16,
53
  bnb_4bit_use_double_quant=True)
54
  try:
55
- base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, quantization_config=bnb,
56
- device_map="auto", torch_dtype=torch.float16, attn_implementation="eager")
 
57
  model = PeftModel.from_pretrained(base, MODEL_REPO)
 
58
  except Exception as e:
59
- print(f"โš  adapter failed ({e}); base model")
60
- model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, quantization_config=bnb,
61
- device_map="auto", torch_dtype=torch.float16, attn_implementation="eager")
 
62
  else:
63
  try:
64
- base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, device_map="cpu",
 
65
  torch_dtype=torch.float32, attn_implementation="eager")
66
  model = PeftModel.from_pretrained(base, MODEL_REPO)
 
67
  except Exception as e:
68
  print(f"โš  adapter failed ({e}); base model (CPU)")
69
- model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, device_map="cpu",
 
70
  torch_dtype=torch.float32, attn_implementation="eager")
71
 
72
  model.eval()
@@ -78,15 +84,14 @@ def _lazy_init():
78
  with open("embeddings_manifest.json") as f:
79
  manifest = json.load(f)
80
 
81
- faiss_mod = faiss
82
- faiss_mod.normalize_L2(emb)
83
- index = faiss_mod.IndexFlatIP(emb.shape[1])
84
  index.add(emb)
85
 
86
  enc = SentenceTransformer(manifest["embed_model"], device=device)
87
  _STATE.update(enc=enc, index=index, store=store,
88
  e5_family=manifest.get("e5_family", False), ready=True)
89
- print("โœ… ready โ€”", emb.shape[0], "vectors")
90
 
91
 
92
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
@@ -108,7 +113,7 @@ def _extract_json(text):
108
  if s < 0: return None
109
  depth = 0
110
  for i in range(s, len(text)):
111
- if text[i] == "{": depth += 1
112
  elif text[i] == "}":
113
  depth -= 1
114
  if depth == 0:
@@ -138,31 +143,32 @@ def _today(): return _dt.date.today()
138
 
139
  def _missing_fields(parse):
140
  if parse is None: return ["parse_failed"]
141
- missing = []
142
- if not parse.get("client_name", "").strip(): missing.append("client_name")
143
- if not parse.get("items"): missing.append("items")
144
- return missing
145
 
146
  def _needs_allocation_clarification(parse):
147
  if not parse: return False
148
- subtotal = sum(it.get("unit_price",0)*it.get("quantity",1) for it in parse.get("items",[]))
 
149
  threshold = core.allocation_threshold_for_date(_today())
150
  return bool(parse.get("client_is_business")) and subtotal >= threshold * 0.8
151
 
152
  def clarification_questions(parse, answers):
153
  missing = _missing_fields(parse)
154
  if "parse_failed" in missing:
155
- return ["ืœื ื”ืฆืœื—ืชื™ ืœื—ืœืฅ ืคืจื˜ื™ื. ื”ืื ืชื•ื›ืœ ืœื ืกื— ืžื—ื“ืฉ? ืœื“ื•ื’ืžื”: 'ืงื™ื‘ืœืชื™ 500โ‚ช ืž[ืฉื] ืขืœ [ืฉื™ืจื•ืช]'"]
156
  qs = []
157
  if "client_name" in missing and "client_name" not in answers:
158
  qs.append("ืžื” ืฉื ื”ืœืงื•ื—?")
159
  if "items" in missing and "items" not in answers:
160
- qs.append("ืžื” ื”ืฉื™ืจื•ืช/ื”ืžื•ืฆืจ ืฉืกื•ืคืง ื•ื‘ืื™ื–ื” ืžื—ื™ืจ?")
161
  if "doc_type" not in answers:
162
  if parse and _needs_allocation_clarification(parse):
163
  qs.append('ื”ืื ืœื”ื ืคื™ืง ื—ืฉื‘ื•ื ื™ืช ืžืก/ืงื‘ืœื” (ืœืขืกืง ืขื ืžืข"ืž) ืื• ืงื‘ืœื” ืคืฉื•ื˜ื”?')
164
  elif parse and not parse.get("doc_type"):
165
- qs.append("ืื™ื–ื” ืžืกืžืš ืœื™ื™ืฆืจ? ืงื‘ืœื” / ื—ืฉื‘ื•ื ื™ืช ืžืก / ื—ืฉื‘ื•ื ื™ืช ืžืก ื•ืงื‘ืœื”?")
166
  return qs[:3]
167
 
168
  def apply_answers(parse, answers):
@@ -188,105 +194,137 @@ def apply_answers(parse, answers):
188
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
189
  def recommend(query_text, k=3):
190
  pref = "query: " if _STATE["e5_family"] else ""
191
- q_emb = _STATE["enc"].encode([pref + query_text], normalize_embeddings=True).astype("float32")
192
- _, I = _STATE["index"].search(q_emb, k+1)
193
- results = []
 
194
  for idx in I[0][:k]:
195
  row = _STATE["store"].iloc[int(idx)]
196
- results.append({"raw_text": row["raw_text"], "category": row["category"],
197
- "doc_type_he": row["doc_type_he"], "total": float(row["total"]),
198
- "client_name": row["client_name"]})
199
- return results
200
 
201
 
202
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
203
- # 5. Renderers
204
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
205
  def render_document(c):
206
- issuer = c.get("issuer", {}); client = c.get("client", {}); lines = c.get("lines", [])
 
 
207
  vat_pct = int(round(c.get("vat_rate", 0) * 100))
208
- alloc_html = (f'<div style="background:#134e4a;border-radius:8px;padding:10px 16px;'
209
- f'margin-bottom:16px;font-size:13px;color:#99f6e4;">'
210
- f'๐Ÿ”– ืžืกืคืจ ื”ืงืฆืื”: <strong>{c.get("allocation_number","โ€”")}</strong></div>'
211
- if c.get("allocation_required") else "")
212
- line_rows = "".join(
213
- f'<tr><td style="padding:8px 4px;border-bottom:1px solid #1e293b;">{ln["description"]}</td>'
214
- f'<td style="padding:8px;border-bottom:1px solid #1e293b;text-align:center;">{ln["quantity"]}</td>'
215
- f'<td style="padding:8px;border-bottom:1px solid #1e293b;text-align:left;">โ‚ช{ln["unit_price"]:,.2f}</td>'
216
- f'<td style="padding:8px 4px;border-bottom:1px solid #1e293b;text-align:left;font-weight:600;">โ‚ช{ln["line_total"]:,.2f}</td></tr>'
 
 
 
 
217
  for ln in lines)
 
218
  return f"""
219
- <div dir="rtl" style="font-family:'Arial Hebrew',Arial,sans-serif;color:#e2e8f0;
220
- border-radius:12px;padding:24px 28px;max-width:640px;margin:8px auto;
221
- background:#0f172a;border:1px solid #1e293b;">
 
 
222
  <div style="display:flex;justify-content:space-between;align-items:flex-start;
223
- border-bottom:1px solid #1e293b;padding-bottom:16px;margin-bottom:20px;">
224
  <div>
225
- <div style="font-size:20px;font-weight:700;color:#2dd4bf;">{c.get("doc_type_he","ืžืกืžืš")}</div>
226
- <div style="font-size:12px;color:#64748b;margin-top:4px;">
227
- ืžืกืณ {c.get("serial_number","")} &nbsp;ยท&nbsp; {c.get("issue_date","")}
 
228
  </div>
229
  </div>
230
  <div style="text-align:left;">
231
- <div style="font-size:14px;font-weight:600;color:#e2e8f0;">{issuer.get("name","")}</div>
232
- <div style="font-size:11px;color:#64748b;">ื—.ืค. {issuer.get("tax_id","")}</div>
233
- <div style="font-size:11px;color:#64748b;">
 
234
  {"ืขื•ืกืง ืžื•ืจืฉื”" if issuer.get("status")=="authorized_dealer" else "ืขื•ืกืง ืคื˜ื•ืจ"}
235
  </div>
236
  </div>
237
  </div>
238
- <div style="background:#1e293b;border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:13px;">
239
- <span style="color:#64748b;">ืœืงื•ื—: </span>
240
- <strong style="color:#e2e8f0;">{client.get("name","")}</strong>
241
- {"&nbsp;<span style='font-size:11px;color:#94a3b8;'>ืขืกืง</span>" if client.get("is_business") else ""}
242
- {f"&nbsp;<span style='font-size:11px;color:#64748b;'>ยท ื—.ืค. {client.get('tax_id','')}</span>" if client.get("tax_id") else ""}
 
 
 
 
 
 
 
 
 
 
243
  </div>
244
- {alloc_html}
245
- <table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">
 
 
246
  <thead>
247
- <tr style="color:#64748b;font-size:11px;text-transform:uppercase;letter-spacing:.05em;">
248
- <th style="padding:6px 4px;text-align:right;border-bottom:1px solid #1e293b;">ืฉื™ืจื•ืช / ืžื•ืฆืจ</th>
249
- <th style="padding:6px 8px;text-align:center;border-bottom:1px solid #1e293b;">ื›ืžื•ืช</th>
250
- <th style="padding:6px 8px;text-align:left;border-bottom:1px solid #1e293b;">ืžื—ื™ืจ ื™ื—ื™ื“ื”</th>
251
- <th style="padding:6px 4px;text-align:left;border-bottom:1px solid #1e293b;">ืกื”"ื›</th>
252
  </tr>
253
  </thead>
254
- <tbody>{line_rows}</tbody>
255
  </table>
256
- <div style="border-top:1px solid #1e293b;padding-top:14px;">
257
- <div style="display:flex;justify-content:space-between;font-size:13px;color:#94a3b8;margin-bottom:6px;">
258
- <span>ืกื›ื•ื ืœืคื ื™ ืžืข"ืž</span><span>โ‚ช{c.get("subtotal",0):,.2f}</span>
 
 
259
  </div>
260
- <div style="display:flex;justify-content:space-between;font-size:13px;color:#94a3b8;margin-bottom:10px;">
261
- <span>ืžืข"ืž {vat_pct}%</span><span>โ‚ช{c.get("vat_amount",0):,.2f}</span>
 
 
262
  </div>
263
- <div style="display:flex;justify-content:space-between;font-size:20px;font-weight:700;color:#2dd4bf;">
264
- <span>ืกื”"ื› ืœืชืฉืœื•ื</span><span>โ‚ช{c.get("total",0):,.2f}</span>
 
265
  </div>
266
- <div style="font-size:11px;color:#475569;margin-top:8px;text-align:right;">
267
  {core.PAYMENT_HE.get(c.get("payment_method",""),"โ€”")}
268
  </div>
269
  </div>
270
  </div>"""
271
 
 
272
  def render_recommendations(recs):
273
  if not recs: return ""
274
  cards = "".join(f"""
275
- <div style="background:#1e293b;border-radius:8px;padding:12px 14px;border:1px solid #334155;">
276
- <div style="font-size:11px;font-weight:600;color:#2dd4bf;margin-bottom:4px;letter-spacing:.03em;">
277
- {r["doc_type_he"]} ยท {r["category"]}
278
- </div>
279
- <div style="font-size:12px;color:#cbd5e1;line-height:1.5;margin-bottom:6px;">
280
- {r["raw_text"][:75]}{"โ€ฆ" if len(r["raw_text"])>75 else ""}
281
- </div>
282
- <div style="font-size:11px;color:#64748b;">{r["client_name"]} ยท โ‚ช{r["total"]:,.0f}</div>
283
- </div>""" for r in recs)
 
 
 
284
  return f"""
285
- <div dir="rtl" style="font-family:Arial,sans-serif;max-width:640px;margin:4px auto 0;">
286
- <div style="font-size:11px;color:#475569;margin-bottom:8px;letter-spacing:.05em;text-transform:uppercase;">
287
- ืงื‘ืœื•ืช ื“ื•ืžื•ืช ืžื”ืžืื’ืจ
288
- </div>
289
- <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;">{cards}</div>
290
  </div>"""
291
 
292
 
@@ -295,183 +333,206 @@ def render_recommendations(recs):
295
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
296
  DEMO_ISSUER = {"name": "ืžื™ื ืฉืงื˜ื™ื", "tax_id": "962569844", "status": "authorized_dealer"}
297
 
298
- def _run_pipeline(raw_text, chat_history, pending_parse, answers):
299
  _lazy_init()
300
- parse = model_parse(raw_text) if pending_parse is None else pending_parse
301
  questions = clarification_questions(parse, answers)
 
302
  if questions:
303
- q_md = "\n".join(f"**{i+1}.** {q}" for i, q in enumerate(questions))
304
- return (chat_history + [{"role": "assistant",
305
  "content": f"ื™ืฉ ืœื™ ื›ืžื” ืฉืืœื•ืช ืœืคื ื™ ืฉืืคื™ืง ืืช ื”ืžืกืžืš:\n\n{q_md}"}],
306
  "", "", parse, answers)
307
- final_parse = apply_answers(parse, answers)
 
308
  try:
309
- completed = core.complete(DEMO_ISSUER, final_parse, _STATE["rng"])
310
  except Exception as e:
311
- return (chat_history + [{"role": "assistant", "content": f"ืฉื’ื™ืื” ื‘ืขื™ื‘ื•ื“: {e}"}],
312
  "", "", None, {})
313
- doc_html = render_document(completed)
314
- recs_html = render_recommendations(recommend(raw_text))
315
- return (chat_history + [{"role": "assistant", "content": "โœ… ื”ืžืกืžืš ื”ื•ืคืง ื‘ื”ืฆืœื—ื”"}],
316
- doc_html, recs_html, None, {})
317
 
318
- def _handle_answer(user_msg, chat_history, pending_parse, answers, raw_text):
319
- a, low = dict(answers), user_msg.strip()
320
- if any(w in low for w in ["ื—ืฉื‘ื•ื ื™ืช ืžืก", "ืžืก ื•ืงื‘ืœื”", "ืžืก/ืงื‘ืœื”"]): a["doc_type"] = low
321
- elif "ืงื‘ืœื”" in low: a["doc_type"] = "receipt"
322
- elif not a.get("client_name") and pending_parse and not pending_parse.get("client_name"):
323
- a["client_name"] = low
324
- return _run_pipeline(raw_text, chat_history + [{"role": "user", "content": user_msg}],
325
- pending_parse, a)
326
 
327
  def on_submit(raw, history, pending, answers):
 
 
328
  return _run_pipeline(raw, history + [{"role": "user", "content": raw}], None, {})
329
 
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
332
- # 7. UI โ€” ChatGPT/Claude style
333
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
334
  CSS = """
335
- * { box-sizing: border-box; }
336
- body, .gradio-container, .main { background: #0a0a0f !important; }
337
- .gradio-container { max-width: 780px !important; margin: 0 auto !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
- /* sidebar-style quick starters */
340
- .qs-btn {
341
  background: #1e293b !important;
 
 
 
 
 
 
342
  border: 1px solid #334155 !important;
343
- color: #cbd5e1 !important;
344
- border-radius: 10px !important;
345
- font-size: 13px !important;
346
- text-align: right !important;
347
- padding: 10px 14px !important;
348
- transition: background .15s, border-color .15s !important;
349
  }
350
- .qs-btn:hover { background: #273549 !important; border-color: #2dd4bf !important; }
351
 
352
- /* main input */
353
- .note-input textarea {
354
  background: #1e293b !important;
355
  color: #e2e8f0 !important;
356
  border: 1px solid #334155 !important;
357
- border-radius: 12px !important;
358
  font-size: 15px !important;
359
- padding: 14px 16px !important;
360
  resize: none !important;
361
  }
362
- .note-input textarea:focus {
363
- border-color: #2dd4bf !important;
364
- box-shadow: 0 0 0 2px rgba(45,212,191,.15) !important;
365
  outline: none !important;
366
  }
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
- /* send button */
369
- .send-btn {
370
  background: #0d9488 !important;
371
  color: #fff !important;
372
  border: none !important;
373
- border-radius: 10px !important;
374
  font-size: 15px !important;
375
  font-weight: 600 !important;
376
- padding: 12px 28px !important;
377
- transition: background .15s !important;
378
  }
379
- .send-btn:hover { background: #0f766e !important; }
380
 
381
- /* clear button */
382
- .clear-btn {
383
  background: transparent !important;
384
- color: #64748b !important;
385
  border: 1px solid #1e293b !important;
386
  border-radius: 10px !important;
387
  font-size: 13px !important;
388
  }
389
- .clear-btn:hover { border-color: #475569 !important; color: #94a3b8 !important; }
390
-
391
- /* chatbot */
392
- .chatbot-wrap .message-wrap { background: transparent !important; }
393
- .chatbot-wrap { background: transparent !important; border: none !important; }
394
- .chatbot-wrap .bot { background: #1e293b !important; border-radius: 12px !important;
395
- color: #e2e8f0 !important; border: 1px solid #334155 !important; }
396
- .chatbot-wrap .user { background: #0d9488 !important; border-radius: 12px !important;
397
- color: #fff !important; }
398
-
399
- /* answer input */
400
- .answer-input textarea {
401
  background: #1e293b !important;
 
402
  color: #e2e8f0 !important;
403
- border: 1px solid #2dd4bf !important;
404
- border-radius: 10px !important;
405
- font-size: 14px !important;
406
  }
407
-
408
- footer { display: none !important; }
409
- label { color: #64748b !important; font-size: 12px !important; }
410
  """
411
 
412
  QUICK_STARTERS = [
413
- ("๐Ÿ’ผ ืขื•ืกืง ืžื•ืจืฉื” + ืœืงื•ื— ืขืกืงื™ ื’ื“ื•ืœ",
414
- 'ื—ื‘ืจืช ืžืขื’ืœ ื‘ืข"ืž ืฉื™ืœืžื” 36,500 ืฉ"ื— ืขืœ ืคืจื•ื™ืงื˜ ืืชืจ ืžืœื. ื”ืขื‘ืจื” ื‘ื ืงืื™ืช.'),
415
- ("๐Ÿงพ ืขื•ืกืง ืคื˜ื•ืจ โ€” ืฉื™ืขื•ืจ ืคืจื˜ื™",
416
- "ืงื™ื‘ืœืชื™ 220 ืฉืงืœ ืžื“ื‘ื™ืจ ืขืœ ืฉื™ืขื•ืจ ืฉื—ื™ื™ื” ืคืจื˜ื™, ืžื–ื•ืžืŸ"),
417
- ("๐Ÿ“ฆ ื—ืฉื‘ื•ื ื™ืช ืžืก โ€” ื›ืžื” ืคืจื™ื˜ื™ื",
418
- 'ื ื•ืขื” ื›ื”ืŸ ืฉื™ืœืžื” 2 ร— 350 ืฉ"ื— ื™ื™ืขื•ืฅ ืขืกืงื™ + 180 ืฉ"ื— ื”ื›ื ืช ืžืฆื’ืช'),
419
  ]
420
 
421
- with gr.Blocks(title="Text2Receipt", css=CSS) as demo:
422
 
423
- # โ”€โ”€ header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
424
  gr.HTML("""
425
- <div dir="rtl" style="text-align:center;padding:32px 0 20px;">
426
- <div style="font-size:32px;margin-bottom:8px;">๐Ÿงพ</div>
427
- <div style="font-size:26px;font-weight:700;color:#2dd4bf;letter-spacing:-.5px;">
428
- Text2Receipt
429
- </div>
430
- <div style="font-size:13px;color:#475569;margin-top:6px;">
431
  ื”ืขืจืช ื”ื›ื ืกื” ื—ื•ืคืฉื™ืช ื‘ืขื‘ืจื™ืช &nbsp;โ†’&nbsp; ืžืกืžืš ืคื™ืกืงืœื™ ื™ืฉืจืืœื™ ืชืงื™ืŸ
432
  </div>
433
  </div>""")
434
 
435
- # โ”€โ”€ state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
436
  st_parse = gr.State(None)
437
  st_answers = gr.State({})
438
 
439
- # โ”€โ”€ chat area โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
440
  chatbot = gr.Chatbot(
441
- label="", height=280, type="messages",
442
- elem_classes=["chatbot-wrap"],
443
- placeholder="<div dir='rtl' style='color:#334155;text-align:center;padding:40px 0;font-size:14px;'>ื”ืงืœื“ ื”ืขืจืช ื”ื›ื ืกื” ื•ืœื—ืฅ ืฉืœื— โ†“</div>",
444
  )
445
 
446
- # โ”€โ”€ answer input (visible only when agent asks) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
447
  answer_input = gr.Textbox(
448
- label="ืชืฉื•ื‘ื” ืœืกื•ื›ืŸ", placeholder="ื”ืงืœื“ ืชืฉื•ื‘ื” ื•ืœื—ืฅ Enterโ€ฆ",
449
- lines=1, elem_classes=["answer-input"],
 
450
  )
451
 
452
- # โ”€โ”€ main input row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
453
  with gr.Row(equal_height=True):
454
  note_input = gr.Textbox(
455
- label="", placeholder='ืœื“ื•ื’ืžื”: ืงื™ื‘ืœืชื™ 1,200 ืฉ"ื— ืžืžืฉื” ื›ื”ืŸ ืขืœ ื™ื™ืขื•ืฅ ืขืกืงื™',
456
- lines=2, scale=5, elem_classes=["note-input"], show_label=False,
457
  )
458
- with gr.Column(scale=1, min_width=110):
459
- submit_btn = gr.Button("ืฉืœื— โšก", variant="primary", elem_classes=["send-btn"])
460
- clear_btn = gr.Button("ื ืงื”", elem_classes=["clear-btn"])
461
 
462
- # โ”€โ”€ quick starters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
463
- gr.HTML('<div dir="rtl" style="font-size:11px;color:#334155;margin:16px 0 8px;'
464
- 'text-transform:uppercase;letter-spacing:.06em;">ื“ื•ื’ืžืื•ืช ืžื”ื™ืจื•ืช</div>')
465
  with gr.Row():
466
- for label, example in QUICK_STARTERS:
467
- gr.Button(label, elem_classes=["qs-btn"]).click(
468
- fn=lambda ex=example: ex, outputs=note_input)
469
 
470
- # โ”€โ”€ output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
471
  doc_output = gr.HTML()
472
  recs_output = gr.HTML()
473
 
474
- # โ”€โ”€ wiring โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
475
  submit_btn.click(
476
  fn=on_submit,
477
  inputs=[note_input, chatbot, st_parse, st_answers],
@@ -483,7 +544,7 @@ with gr.Blocks(title="Text2Receipt", css=CSS) as demo:
483
  outputs=[chatbot, doc_output, recs_output, st_parse, st_answers],
484
  )
485
  answer_input.submit(
486
- fn=_handle_answer,
487
  inputs=[answer_input, chatbot, st_parse, st_answers, note_input],
488
  outputs=[chatbot, doc_output, recs_output, st_parse, st_answers],
489
  )
@@ -493,4 +554,4 @@ with gr.Blocks(title="Text2Receipt", css=CSS) as demo:
493
  )
494
 
495
  if __name__ == "__main__":
496
- demo.launch()
 
5
  Pipeline:
6
  raw Hebrew note
7
  โ†’ fine-tuned parser (gemma-2-2b-it + LoRA)
8
+ โ†’ agentic clarifier (+5% bonus)
9
  โ†’ deterministic complete() (VAT ยท serial ยท allocation number)
10
  โ†’ fiscal document display
11
  โ†’ FAISS recommender (3 similar past receipts)
12
+ Repos:
13
  Dataset : yonilev/Text2Receipt
14
+ Model : yonilev/Text2Receipt-parser
15
+ Space : yonilev/Text2Receipt
16
  """
17
+ import os, json, random
18
  import numpy as np
19
  import gradio as gr
20
 
 
52
  bnb_4bit_compute_dtype=torch.float16,
53
  bnb_4bit_use_double_quant=True)
54
  try:
55
+ base = AutoModelForCausalLM.from_pretrained(
56
+ BASE_MODEL, quantization_config=bnb, device_map="auto",
57
+ torch_dtype=torch.float16, attn_implementation="eager")
58
  model = PeftModel.from_pretrained(base, MODEL_REPO)
59
+ print("โœ… GPU: fine-tuned adapter loaded")
60
  except Exception as e:
61
+ print(f"โš  adapter failed ({e}); base model (GPU)")
62
+ model = AutoModelForCausalLM.from_pretrained(
63
+ BASE_MODEL, quantization_config=bnb, device_map="auto",
64
+ torch_dtype=torch.float16, attn_implementation="eager")
65
  else:
66
  try:
67
+ base = AutoModelForCausalLM.from_pretrained(
68
+ BASE_MODEL, device_map="cpu",
69
  torch_dtype=torch.float32, attn_implementation="eager")
70
  model = PeftModel.from_pretrained(base, MODEL_REPO)
71
+ print("โœ… CPU: fine-tuned adapter loaded")
72
  except Exception as e:
73
  print(f"โš  adapter failed ({e}); base model (CPU)")
74
+ model = AutoModelForCausalLM.from_pretrained(
75
+ BASE_MODEL, device_map="cpu",
76
  torch_dtype=torch.float32, attn_implementation="eager")
77
 
78
  model.eval()
 
84
  with open("embeddings_manifest.json") as f:
85
  manifest = json.load(f)
86
 
87
+ faiss.normalize_L2(emb)
88
+ index = faiss.IndexFlatIP(emb.shape[1])
 
89
  index.add(emb)
90
 
91
  enc = SentenceTransformer(manifest["embed_model"], device=device)
92
  _STATE.update(enc=enc, index=index, store=store,
93
  e5_family=manifest.get("e5_family", False), ready=True)
94
+ print(f"โœ… FAISS ready โ€” {emb.shape[0]} vectors")
95
 
96
 
97
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
 
113
  if s < 0: return None
114
  depth = 0
115
  for i in range(s, len(text)):
116
+ if text[i] == "{": depth += 1
117
  elif text[i] == "}":
118
  depth -= 1
119
  if depth == 0:
 
143
 
144
  def _missing_fields(parse):
145
  if parse is None: return ["parse_failed"]
146
+ m = []
147
+ if not parse.get("client_name", "").strip(): m.append("client_name")
148
+ if not parse.get("items"): m.append("items")
149
+ return m
150
 
151
  def _needs_allocation_clarification(parse):
152
  if not parse: return False
153
+ subtotal = sum(it.get("unit_price", 0) * it.get("quantity", 1)
154
+ for it in parse.get("items", []))
155
  threshold = core.allocation_threshold_for_date(_today())
156
  return bool(parse.get("client_is_business")) and subtotal >= threshold * 0.8
157
 
158
  def clarification_questions(parse, answers):
159
  missing = _missing_fields(parse)
160
  if "parse_failed" in missing:
161
+ return ["ืœื ื”ืฆืœื—ืชื™ ืœื—ืœืฅ ืคืจื˜ื™ื. ื ืกื— ืžื—ื“ืฉ โ€” ืœื“ื•ื’ืžื”: 'ืงื™ื‘ืœืชื™ 500โ‚ช ืž[ืฉื] ืขืœ [ืฉื™ืจื•ืช]'"]
162
  qs = []
163
  if "client_name" in missing and "client_name" not in answers:
164
  qs.append("ืžื” ืฉื ื”ืœืงื•ื—?")
165
  if "items" in missing and "items" not in answers:
166
+ qs.append("ืžื” ื”ืฉื™ืจื•ืช/ื”ืžื•ืฆืจ ื•ื‘ืื™ื–ื” ืžื—ื™ืจ?")
167
  if "doc_type" not in answers:
168
  if parse and _needs_allocation_clarification(parse):
169
  qs.append('ื”ืื ืœื”ื ืคื™ืง ื—ืฉื‘ื•ื ื™ืช ืžืก/ืงื‘ืœื” (ืœืขืกืง ืขื ืžืข"ืž) ืื• ืงื‘ืœื” ืคืฉื•ื˜ื”?')
170
  elif parse and not parse.get("doc_type"):
171
+ qs.append("ืื™ื–ื” ืžืกืžืš? ืงื‘ืœื” / ื—ืฉื‘ื•ื ื™ืช ืžืก / ื—ืฉื‘ื•ื ื™ืช ืžืก ื•ืงื‘ืœื”")
172
  return qs[:3]
173
 
174
  def apply_answers(parse, answers):
 
194
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
195
  def recommend(query_text, k=3):
196
  pref = "query: " if _STATE["e5_family"] else ""
197
+ q_emb = _STATE["enc"].encode([pref + query_text],
198
+ normalize_embeddings=True).astype("float32")
199
+ _, I = _STATE["index"].search(q_emb, k + 1)
200
+ out = []
201
  for idx in I[0][:k]:
202
  row = _STATE["store"].iloc[int(idx)]
203
+ out.append({"raw_text": row["raw_text"], "category": row["category"],
204
+ "doc_type_he": row["doc_type_he"], "total": float(row["total"]),
205
+ "client_name": row["client_name"]})
206
+ return out
207
 
208
 
209
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
210
+ # 5. HTML renderers
211
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
212
  def render_document(c):
213
+ issuer = c.get("issuer", {})
214
+ client = c.get("client", {})
215
+ lines = c.get("lines", [])
216
  vat_pct = int(round(c.get("vat_rate", 0) * 100))
217
+
218
+ alloc = (f'<div style="background:#0d3331;border:1px solid #134e4a;border-radius:8px;'
219
+ f'padding:10px 16px;margin-bottom:16px;font-size:13px;color:#5eead4;">'
220
+ f'๐Ÿ”– ืžืกืคืจ ื”ืงืฆืื”: <strong>{c.get("allocation_number","โ€”")}</strong></div>'
221
+ if c.get("allocation_required") else "")
222
+
223
+ rows = "".join(
224
+ f'<tr>'
225
+ f'<td style="padding:10px 4px;border-bottom:1px solid #1e293b;color:#e2e8f0;">{ln["description"]}</td>'
226
+ f'<td style="padding:10px 8px;border-bottom:1px solid #1e293b;text-align:center;color:#94a3b8;">{ln["quantity"]}</td>'
227
+ f'<td style="padding:10px 8px;border-bottom:1px solid #1e293b;text-align:left;color:#94a3b8;">โ‚ช{ln["unit_price"]:,.2f}</td>'
228
+ f'<td style="padding:10px 4px;border-bottom:1px solid #1e293b;text-align:left;font-weight:600;color:#e2e8f0;">โ‚ช{ln["line_total"]:,.2f}</td>'
229
+ f'</tr>'
230
  for ln in lines)
231
+
232
  return f"""
233
+ <div dir="rtl" style="font-family:'Segoe UI','Arial Hebrew',Arial,sans-serif;
234
+ background:#0f172a;color:#e2e8f0;border-radius:16px;
235
+ border:1px solid #1e293b;padding:28px 32px;max-width:660px;
236
+ margin:16px auto 0;box-shadow:0 8px 32px rgba(0,0,0,.4);">
237
+
238
  <div style="display:flex;justify-content:space-between;align-items:flex-start;
239
+ padding-bottom:20px;margin-bottom:20px;border-bottom:1px solid #1e293b;">
240
  <div>
241
+ <div style="font-size:22px;font-weight:700;color:#2dd4bf;">{c.get("doc_type_he","ืžืกืžืš")}</div>
242
+ <div style="font-size:12px;color:#475569;margin-top:5px;">
243
+ ืžืกืณ <span style="color:#94a3b8;">{c.get("serial_number","")}</span>
244
+ &nbsp;ยท&nbsp; {c.get("issue_date","")}
245
  </div>
246
  </div>
247
  <div style="text-align:left;">
248
+ <div style="font-size:15px;font-weight:600;color:#e2e8f0;">{issuer.get("name","")}</div>
249
+ <div style="font-size:11px;color:#475569;margin-top:3px;">ื—.ืค. {issuer.get("tax_id","")}</div>
250
+ <div style="display:inline-block;margin-top:5px;font-size:11px;
251
+ background:#134e4a;color:#5eead4;padding:2px 8px;border-radius:999px;">
252
  {"ืขื•ืกืง ืžื•ืจืฉื”" if issuer.get("status")=="authorized_dealer" else "ืขื•ืกืง ืคื˜ื•ืจ"}
253
  </div>
254
  </div>
255
  </div>
256
+
257
+ <div style="background:#1e293b;border-radius:10px;padding:12px 16px;
258
+ margin-bottom:16px;display:flex;align-items:center;gap:10px;">
259
+ <div style="width:32px;height:32px;border-radius:50%;background:#0d9488;
260
+ display:flex;align-items:center;justify-content:center;
261
+ font-size:14px;font-weight:700;color:#fff;flex-shrink:0;">
262
+ {(client.get("name","?") or "?")[0]}
263
+ </div>
264
+ <div>
265
+ <div style="font-size:14px;font-weight:600;color:#e2e8f0;">{client.get("name","")}</div>
266
+ <div style="font-size:11px;color:#64748b;margin-top:1px;">
267
+ {"ืขืกืง" if client.get("is_business") else "ืœืงื•ื— ืคืจื˜ื™"}
268
+ {f" ยท ื—.ืค. {client.get('tax_id','')}" if client.get("tax_id") else ""}
269
+ </div>
270
+ </div>
271
  </div>
272
+
273
+ {alloc}
274
+
275
+ <table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px;">
276
  <thead>
277
+ <tr style="font-size:11px;color:#475569;letter-spacing:.06em;">
278
+ <th style="padding:6px 4px;text-align:right;border-bottom:1px solid #334155;font-weight:500;">ืคืจื™ื˜</th>
279
+ <th style="padding:6px 8px;text-align:center;border-bottom:1px solid #334155;font-weight:500;">ื›ืžื•ืช</th>
280
+ <th style="padding:6px 8px;text-align:left;border-bottom:1px solid #334155;font-weight:500;">ืžื—ื™ืจ</th>
281
+ <th style="padding:6px 4px;text-align:left;border-bottom:1px solid #334155;font-weight:500;">ืกื”"ื›</th>
282
  </tr>
283
  </thead>
284
+ <tbody>{rows}</tbody>
285
  </table>
286
+
287
+ <div style="background:#1e293b;border-radius:10px;padding:16px 18px;">
288
+ <div style="display:flex;justify-content:space-between;font-size:13px;color:#64748b;margin-bottom:8px;">
289
+ <span>ืกื›ื•ื ืœืคื ื™ ืžืข"ืž</span>
290
+ <span style="color:#94a3b8;">โ‚ช{c.get("subtotal",0):,.2f}</span>
291
  </div>
292
+ <div style="display:flex;justify-content:space-between;font-size:13px;color:#64748b;
293
+ padding-bottom:12px;border-bottom:1px solid #334155;margin-bottom:12px;">
294
+ <span>ืžืข"ืž {vat_pct}%</span>
295
+ <span style="color:#94a3b8;">โ‚ช{c.get("vat_amount",0):,.2f}</span>
296
  </div>
297
+ <div style="display:flex;justify-content:space-between;align-items:baseline;">
298
+ <span style="font-size:15px;font-weight:600;color:#e2e8f0;">ืกื”"ื› ืœืชืฉืœื•ื</span>
299
+ <span style="font-size:24px;font-weight:700;color:#2dd4bf;">โ‚ช{c.get("total",0):,.2f}</span>
300
  </div>
301
+ <div style="font-size:11px;color:#334155;margin-top:10px;text-align:right;">
302
  {core.PAYMENT_HE.get(c.get("payment_method",""),"โ€”")}
303
  </div>
304
  </div>
305
  </div>"""
306
 
307
+
308
  def render_recommendations(recs):
309
  if not recs: return ""
310
  cards = "".join(f"""
311
+ <div style="background:#1e293b;border-radius:10px;padding:14px 16px;border:1px solid #334155;">
312
+ <div style="font-size:11px;font-weight:600;color:#2dd4bf;letter-spacing:.04em;
313
+ margin-bottom:6px;text-transform:uppercase;">{r["doc_type_he"]} ยท {r["category"]}</div>
314
+ <div style="font-size:12px;color:#cbd5e1;line-height:1.55;margin-bottom:8px;">
315
+ {r["raw_text"][:72]}{"โ€ฆ" if len(r["raw_text"])>72 else ""}
316
+ </div>
317
+ <div style="display:flex;justify-content:space-between;">
318
+ <span style="font-size:11px;color:#475569;">{r["client_name"]}</span>
319
+ <span style="font-size:13px;font-weight:600;color:#94a3b8;">โ‚ช{r["total"]:,.0f}</span>
320
+ </div>
321
+ </div>""" for r in recs)
322
+
323
  return f"""
324
+ <div dir="rtl" style="font-family:'Segoe UI',Arial,sans-serif;max-width:660px;margin:8px auto 24px;">
325
+ <div style="font-size:11px;color:#334155;margin-bottom:10px;
326
+ letter-spacing:.06em;text-transform:uppercase;">ืžืกืžื›ื™ื ื“ื•ืžื™ื ืžื”ืžืื’ืจ</div>
327
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;">{cards}</div>
 
328
  </div>"""
329
 
330
 
 
333
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
334
  DEMO_ISSUER = {"name": "ืžื™ื ืฉืงื˜ื™ื", "tax_id": "962569844", "status": "authorized_dealer"}
335
 
336
+ def _run_pipeline(raw_text, history, pending_parse, answers):
337
  _lazy_init()
338
+ parse = model_parse(raw_text) if pending_parse is None else pending_parse
339
  questions = clarification_questions(parse, answers)
340
+
341
  if questions:
342
+ q_md = "\n".join(f"{i+1}. {q}" for i, q in enumerate(questions))
343
+ return (history + [{"role": "assistant",
344
  "content": f"ื™ืฉ ืœื™ ื›ืžื” ืฉืืœื•ืช ืœืคื ื™ ืฉืืคื™ืง ืืช ื”ืžืกืžืš:\n\n{q_md}"}],
345
  "", "", parse, answers)
346
+
347
+ final = apply_answers(parse, answers)
348
  try:
349
+ completed = core.complete(DEMO_ISSUER, final, _STATE["rng"])
350
  except Exception as e:
351
+ return (history + [{"role": "assistant", "content": f"ืฉื’ื™ืื”: {e}"}],
352
  "", "", None, {})
 
 
 
 
353
 
354
+ return (history + [{"role": "assistant", "content": "โœ… ื”ืžืกืžืš ื”ื•ืคืง ื‘ื”ืฆืœื—ื”"}],
355
+ render_document(completed),
356
+ render_recommendations(recommend(raw_text)),
357
+ None, {})
358
+
 
 
 
359
 
360
  def on_submit(raw, history, pending, answers):
361
+ if not raw.strip():
362
+ return history, "", "", pending, answers
363
  return _run_pipeline(raw, history + [{"role": "user", "content": raw}], None, {})
364
 
365
 
366
+ def on_answer(user_msg, history, pending, answers, raw_text):
367
+ if not user_msg.strip():
368
+ return history, "", "", pending, answers
369
+ a, low = dict(answers), user_msg.strip()
370
+ if any(w in low for w in ["ื—ืฉื‘ื•ื ื™ืช ืžืก", "ืžืก ื•ืงื‘ืœื”", "ืžืก/ืงื‘ืœื”"]):
371
+ a["doc_type"] = low
372
+ elif "ืงื‘ืœื”" in low:
373
+ a["doc_type"] = "receipt"
374
+ elif not a.get("client_name") and pending and not pending.get("client_name"):
375
+ a["client_name"] = low
376
+ return _run_pipeline(raw_text,
377
+ history + [{"role": "user", "content": user_msg}],
378
+ pending, a)
379
+
380
+
381
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
382
+ # 7. UI โ€” Gradio 6.17 compatible, ChatGPT/Claude style
383
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
384
  CSS = """
385
+ *, *::before, *::after { box-sizing: border-box; }
386
+
387
+ .gradio-container {
388
+ max-width: 760px !important;
389
+ margin: 0 auto !important;
390
+ padding: 0 16px !important;
391
+ background: #0a0f1e !important;
392
+ }
393
+ body, .main { background: #0a0f1e !important; }
394
+ footer { display: none !important; }
395
+
396
+ .chatbot-area .bubble-wrap { gap: 12px !important; }
397
+
398
+ .chatbot-area [data-testid="user"] .bubble,
399
+ .chatbot-area .user .bubble {
400
+ background: #0d9488 !important;
401
+ color: #fff !important;
402
+ border-radius: 18px 18px 4px 18px !important;
403
+ padding: 12px 16px !important;
404
+ font-size: 14px !important;
405
+ line-height: 1.6 !important;
406
+ max-width: 80% !important;
407
+ border: none !important;
408
+ }
409
 
410
+ .chatbot-area [data-testid="bot"] .bubble,
411
+ .chatbot-area .bot .bubble {
412
  background: #1e293b !important;
413
+ color: #e2e8f0 !important;
414
+ border-radius: 18px 18px 18px 4px !important;
415
+ padding: 12px 16px !important;
416
+ font-size: 14px !important;
417
+ line-height: 1.6 !important;
418
+ max-width: 80% !important;
419
  border: 1px solid #334155 !important;
 
 
 
 
 
 
420
  }
 
421
 
422
+ .note-box textarea {
 
423
  background: #1e293b !important;
424
  color: #e2e8f0 !important;
425
  border: 1px solid #334155 !important;
426
+ border-radius: 14px !important;
427
  font-size: 15px !important;
428
+ padding: 14px 18px !important;
429
  resize: none !important;
430
  }
431
+ .note-box textarea:focus {
432
+ border-color: #0d9488 !important;
433
+ box-shadow: 0 0 0 3px rgba(13,148,136,.18) !important;
434
  outline: none !important;
435
  }
436
+ .note-box label { display: none !important; }
437
+
438
+ .answer-box textarea {
439
+ background: #1e293b !important;
440
+ color: #e2e8f0 !important;
441
+ border: 1px solid #0d9488 !important;
442
+ border-radius: 10px !important;
443
+ font-size: 14px !important;
444
+ padding: 10px 14px !important;
445
+ resize: none !important;
446
+ }
447
+ .answer-box label { color: #475569 !important; font-size: 11px !important; }
448
 
449
+ .btn-send button {
 
450
  background: #0d9488 !important;
451
  color: #fff !important;
452
  border: none !important;
453
+ border-radius: 12px !important;
454
  font-size: 15px !important;
455
  font-weight: 600 !important;
456
+ height: 52px !important;
 
457
  }
458
+ .btn-send button:hover { background: #0f766e !important; }
459
 
460
+ .btn-clear button {
 
461
  background: transparent !important;
462
+ color: #475569 !important;
463
  border: 1px solid #1e293b !important;
464
  border-radius: 10px !important;
465
  font-size: 13px !important;
466
  }
467
+ .btn-clear button:hover { border-color: #334155 !important; color: #94a3b8 !important; }
468
+
469
+ .qs-btn button {
470
+ background: #111827 !important;
471
+ color: #94a3b8 !important;
472
+ border: 1px solid #1e293b !important;
473
+ border-radius: 10px !important;
474
+ font-size: 12px !important;
475
+ padding: 8px 12px !important;
476
+ width: 100% !important;
477
+ }
478
+ .qs-btn button:hover {
479
  background: #1e293b !important;
480
+ border-color: #0d9488 !important;
481
  color: #e2e8f0 !important;
 
 
 
482
  }
 
 
 
483
  """
484
 
485
  QUICK_STARTERS = [
486
+ ("๐Ÿ’ผ ืขื•ืกืง ืžื•ืจืฉื” + ืœืงื•ื— ืขืกืงื™", 'ื—ื‘ืจืช ืžืขื’ืœ ื‘ืข"ืž ืฉื™ืœืžื” 36,500 ืฉ"ื— ืขืœ ืคืจื•ื™ืงื˜ ืืชืจ. ื”ืขื‘ืจื” ื‘ื ืงืื™ืช.'),
487
+ ("๐Ÿงพ ืขื•ืกืง ืคื˜ื•ืจ โ€” ืฉื™ืขื•ืจ ืคืจื˜ื™", "ืงื™ื‘ืœืชื™ 220 ืฉืงืœ ืžื“ื‘ื™ืจ ืขืœ ืฉื™ืขื•ืจ ืฉื—ื™ื™ื” ืคืจื˜ื™, ืžื–ื•ืžืŸ"),
488
+ ("๐Ÿ“ฆ ื›ืžื” ืคืจื™ื˜ื™ื", 'ื ื•ืขื” ื›ื”ืŸ ืฉื™ืœืžื” 2ร—350 ืฉ"ื— ื™ื™ืขื•ืฅ ืขืกืงื™ + 180 ืฉ"ื— ื”ื›ื ืช ืžืฆื’ืช'),
 
 
 
489
  ]
490
 
491
+ with gr.Blocks(title="Text2Receipt") as demo:
492
 
 
493
  gr.HTML("""
494
+ <div dir="rtl" style="text-align:center;padding:28px 0 16px;">
495
+ <div style="font-size:30px;margin-bottom:8px;">๐Ÿงพ</div>
496
+ <div style="font-size:26px;font-weight:700;color:#2dd4bf;letter-spacing:-.4px;">Text2Receipt</div>
497
+ <div style="font-size:13px;color:#334155;margin-top:6px;">
 
 
498
  ื”ืขืจืช ื”ื›ื ืกื” ื—ื•ืคืฉื™ืช ื‘ืขื‘ืจื™ืช &nbsp;โ†’&nbsp; ืžืกืžืš ืคื™ืกืงืœื™ ื™ืฉืจืืœื™ ืชืงื™ืŸ
499
  </div>
500
  </div>""")
501
 
 
502
  st_parse = gr.State(None)
503
  st_answers = gr.State({})
504
 
 
505
  chatbot = gr.Chatbot(
506
+ label="", height=320, elem_classes=["chatbot-area"], rtl=True,
507
+ placeholder="<div dir='rtl' style='color:#1e293b;text-align:center;"
508
+ "padding:48px 0;font-size:14px;'>ื”ืงืœื“ ื”ืขืจื” ื•ืœื—ืฅ ืฉืœื—</div>",
509
  )
510
 
 
511
  answer_input = gr.Textbox(
512
+ label="ืชืฉื•ื‘ื” ืœืกื•ื›ืŸ โ†ฉ",
513
+ placeholder="ืขื ื” ืœืฉืืœืช ื”ืกื•ื›ืŸ ื•ืœื—ืฅ Enterโ€ฆ",
514
+ lines=1, elem_classes=["answer-box"],
515
  )
516
 
 
517
  with gr.Row(equal_height=True):
518
  note_input = gr.Textbox(
519
+ label="", lines=2, scale=5, elem_classes=["note-box"],
520
+ placeholder='ืœื“ื•ื’ืžื”: ืงื™ื‘ืœืชื™ 1,200 ืฉ"ื— ืžืžืฉื” ื›ื”ืŸ ืขืœ ื™ื™ืขื•ืฅ ืขืกืงื™',
521
  )
522
+ with gr.Column(scale=1, min_width=100):
523
+ submit_btn = gr.Button("ืฉืœื— โšก", elem_classes=["btn-send"])
524
+ clear_btn = gr.Button("ื ืงื” ๐Ÿ—‘", elem_classes=["btn-clear"])
525
 
526
+ gr.HTML('<div dir="rtl" style="font-size:10px;color:#1e293b;margin:14px 0 6px;'
527
+ 'letter-spacing:.07em;text-transform:uppercase;">ื“ื•ื’ืžืื•ืช ืžื”ื™ืจื•ืช</div>')
 
528
  with gr.Row():
529
+ for lbl, ex in QUICK_STARTERS:
530
+ gr.Button(lbl, elem_classes=["qs-btn"]).click(
531
+ fn=lambda e=ex: e, outputs=note_input)
532
 
 
533
  doc_output = gr.HTML()
534
  recs_output = gr.HTML()
535
 
 
536
  submit_btn.click(
537
  fn=on_submit,
538
  inputs=[note_input, chatbot, st_parse, st_answers],
 
544
  outputs=[chatbot, doc_output, recs_output, st_parse, st_answers],
545
  )
546
  answer_input.submit(
547
+ fn=on_answer,
548
  inputs=[answer_input, chatbot, st_parse, st_answers, note_input],
549
  outputs=[chatbot, doc_output, recs_output, st_parse, st_answers],
550
  )
 
554
  )
555
 
556
  if __name__ == "__main__":
557
+ demo.launch(css=CSS)