yonilev commited on
Commit
1ee8917
·
verified ·
1 Parent(s): 5b52edc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +55 -70
app.py CHANGED
@@ -9,11 +9,10 @@ Pipeline:
9
  → deterministic complete() (VAT · serial · allocation number)
10
  → fiscal document display
11
  → FAISS recommender (3 similar past receipts)
12
-
13
  Repos used:
14
  Dataset : yonilev/Text2Receipt
15
- Model : yonilev/Text2Receipt-parser (LoRA adapter + embeddings)
16
- Space : yonilev/Text2Receipt
17
  """
18
  import os, json, re, random
19
  import numpy as np
@@ -39,54 +38,71 @@ def _lazy_init():
39
  from peft import PeftModel
40
  from sentence_transformers import SentenceTransformer
41
  import faiss
42
- from huggingface_hub import hf_hub_download
43
  import pandas as pd
44
 
45
  device = "cuda" if torch.cuda.is_available() else "cpu"
46
  _STATE["device"] = device
47
  _STATE["rng"] = random.Random(42)
48
 
49
- # ── parser model ──────────────────────────────────────────────────────────
50
- bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
51
- bnb_4bit_compute_dtype=torch.float16,
52
- bnb_4bit_use_double_quant=True)
53
  tok = AutoTokenizer.from_pretrained(BASE_MODEL)
54
  if tok.pad_token is None:
55
  tok.pad_token = tok.eos_token
56
  tok.padding_side = "left"
57
 
58
- try:
59
- base = AutoModelForCausalLM.from_pretrained(
60
- BASE_MODEL, quantization_config=bnb, device_map="auto",
61
- torch_dtype=torch.float16, attn_implementation="eager")
62
- model = PeftModel.from_pretrained(base, MODEL_REPO)
63
- print("✅ loaded fine-tuned adapter from", MODEL_REPO)
64
- except Exception as e:
65
- print(f"⚠ adapter load failed ({e}); falling back to base model")
66
- model = AutoModelForCausalLM.from_pretrained(
67
- BASE_MODEL, quantization_config=bnb, device_map="auto",
68
- torch_dtype=torch.float16, attn_implementation="eager")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  model.eval()
71
  _STATE["tok"] = tok
72
  _STATE["model"] = model
73
 
74
- # ── embeddings + FAISS ────────────────────────────────────────────────────
75
- emb_path = hf_hub_download(MODEL_REPO, "receipts_embeddings.npy", repo_type="model")
76
- store_path = hf_hub_download(MODEL_REPO, "receipts_store.parquet", repo_type="model")
77
- manifest_path = hf_hub_download(MODEL_REPO, "embeddings_manifest.json", repo_type="model")
 
78
 
79
- emb = np.load(emb_path).astype("float32")
80
- store = pd.read_parquet(store_path)
81
- with open(manifest_path) as f:
82
  manifest = json.load(f)
83
 
84
  faiss.normalize_L2(emb)
85
  index = faiss.IndexFlatIP(emb.shape[1])
86
  index.add(emb)
87
 
88
- enc = SentenceTransformer(manifest["embed_model"],
89
- device=device)
90
  _STATE["enc"] = enc
91
  _STATE["index"] = index
92
  _STATE["store"] = store
@@ -149,7 +165,6 @@ def _today() -> _dt.date:
149
  return _dt.date.today()
150
 
151
  def _missing_fields(parse: dict | None) -> list[str]:
152
- """Return list of missing mandatory fields that affect the document legally."""
153
  missing = []
154
  if parse is None:
155
  return ["parse_failed"]
@@ -157,11 +172,9 @@ def _missing_fields(parse: dict | None) -> list[str]:
157
  missing.append("client_name")
158
  if not parse.get("items"):
159
  missing.append("items")
160
- # doc_type not extractable from notes — always needs clarification for allocation
161
  return missing
162
 
163
  def _needs_allocation_clarification(parse: dict) -> bool:
164
- """Return True when the doc_type choice changes whether allocation is required."""
165
  if parse is None:
166
  return False
167
  subtotal = sum(
@@ -170,15 +183,9 @@ def _needs_allocation_clarification(parse: dict) -> bool:
170
  )
171
  threshold = core.allocation_threshold_for_date(_today())
172
  is_biz = parse.get("client_is_business", False)
173
- # allocation only matters for authorized dealer + biz client + subtotal threshold
174
- # but we don't know issuer status here; ask when subtotal is near or above threshold
175
- return bool(is_biz) and subtotal >= threshold * 0.8 # 80% buffer
176
 
177
  def clarification_questions(parse: dict | None, answers: dict) -> list[str]:
178
- """
179
- Agentic loop: generate next question(s) given current parse + prior answers.
180
- Returns [] when all necessary info is available.
181
- """
182
  questions = []
183
  missing = _missing_fields(parse)
184
 
@@ -191,7 +198,6 @@ def clarification_questions(parse: dict | None, answers: dict) -> list[str]:
191
  if "items" in missing and "items" not in answers:
192
  questions.append("מה השירות/המוצר שסופק ובאיזה מחיר?")
193
 
194
- # ask doc_type only if not already answered and allocation is relevant
195
  if "doc_type" not in answers:
196
  if parse and _needs_allocation_clarification(parse):
197
  questions.append(
@@ -202,11 +208,10 @@ def clarification_questions(parse: dict | None, answers: dict) -> list[str]:
202
  "איזה מסמך לייצר? קבלה / חשבונית מס / חשבונית מס וקבלה?"
203
  )
204
 
205
- return questions[:3] # max 3 questions per round
206
 
207
 
208
  def apply_answers(parse: dict | None, answers: dict) -> dict:
209
- """Merge user answers into parse dict."""
210
  if parse is None:
211
  parse = {}
212
  p = dict(parse)
@@ -220,7 +225,6 @@ def apply_answers(parse: dict | None, answers: dict) -> dict:
220
  p["doc_type"] = "tax_invoice"
221
  else:
222
  p["doc_type"] = "receipt"
223
- # set safe defaults for missing required fields
224
  p.setdefault("doc_type", "receipt")
225
  p.setdefault("date", _today().isoformat())
226
  p.setdefault("payment_method", "bank_transfer")
@@ -236,14 +240,13 @@ def apply_answers(parse: dict | None, answers: dict) -> dict:
236
  # 4. FAISS recommender
237
  # ═════════════════════════════════════════════════════════════════════════════
238
  def recommend(query_text: str, k: int = 3) -> list[dict]:
239
- """Return k most similar receipts from the full corpus store."""
240
  enc = _STATE["enc"]
241
  index = _STATE["index"]
242
  store = _STATE["store"]
243
  pref = "query: " if _STATE["e5_family"] else ""
244
  q_emb = enc.encode([pref + query_text],
245
  normalize_embeddings=True).astype("float32")
246
- _, I = index.search(q_emb, k + 1) # +1 in case query is in corpus
247
  results = []
248
  for idx in I[0][:k]:
249
  row = store.iloc[int(idx)]
@@ -288,8 +291,6 @@ def render_document(completed: dict) -> str:
288
  background:#0f1117; color:#e8eaf6; border-radius:16px;
289
  padding:28px 32px; max-width:680px; margin:0 auto;
290
  border:1px solid #2a2d3e; box-shadow:0 4px 32px rgba(0,0,0,.4);">
291
-
292
- <!-- header -->
293
  <div style="display:flex;justify-content:space-between;align-items:flex-start;
294
  border-bottom:2px solid #0d9488;padding-bottom:16px;margin-bottom:20px;">
295
  <div>
@@ -308,8 +309,6 @@ def render_document(completed: dict) -> str:
308
  </div>
309
  </div>
310
  </div>
311
-
312
- <!-- client -->
313
  <div style="background:#1a1d2e;border-radius:10px;padding:14px 18px;
314
  margin-bottom:20px;font-size:14px;">
315
  <span style="color:#9e9e9e;">לקוח: </span>
@@ -317,10 +316,7 @@ def render_document(completed: dict) -> str:
317
  {'<span style="margin-right:10px;font-size:12px;color:#9e9e9e;">עסק</span>' if client.get('is_business') else ''}
318
  {f'<span style="font-size:12px;color:#9e9e9e;"> · ח.פ. {client.get("tax_id","")}</span>' if client.get('tax_id') else ''}
319
  </div>
320
-
321
  {alloc_html}
322
-
323
- <!-- line items -->
324
  <table style="width:100%;border-collapse:collapse;font-size:14px;
325
  margin-bottom:18px;">
326
  <thead>
@@ -335,8 +331,6 @@ def render_document(completed: dict) -> str:
335
  {line_rows}
336
  </tbody>
337
  </table>
338
-
339
- <!-- totals -->
340
  <div style="border-top:1px solid #2a2d3e;padding-top:14px;
341
  font-size:14px;text-align:left;">
342
  <div style="display:flex;justify-content:space-between;margin-bottom:4px;">
@@ -438,19 +432,13 @@ DEMO_ISSUER = {
438
 
439
  def _run_pipeline(raw_text: str, chat_history: list,
440
  pending_parse: dict | None, answers: dict):
441
- """
442
- Main pipeline step.
443
- Returns: (chat_history, doc_html, recs_html, pending_parse, answers)
444
- """
445
  _lazy_init()
446
 
447
- # Step 1 — parse
448
  if pending_parse is None:
449
  parse = model_parse(raw_text)
450
  else:
451
  parse = pending_parse
452
 
453
- # Step 2 — check for questions
454
  questions = clarification_questions(parse, answers)
455
  if questions:
456
  q_text = "\n".join(f"• {q}" for q in questions)
@@ -460,7 +448,6 @@ def _run_pipeline(raw_text: str, chat_history: list,
460
  ]
461
  return chat_history, "", "", parse, answers
462
 
463
- # Step 3 — apply answers + complete
464
  final_parse = apply_answers(parse, answers)
465
  try:
466
  completed = core.complete(DEMO_ISSUER, final_parse, _STATE["rng"])
@@ -469,7 +456,6 @@ def _run_pipeline(raw_text: str, chat_history: list,
469
  "content": f"שגיאה בעיבוד: {e}"}],
470
  "", "", None, {})
471
 
472
- # Step 4 — render
473
  doc_html = render_document(completed)
474
  recs = recommend(raw_text)
475
  recs_html = render_recommendations(recs)
@@ -484,11 +470,9 @@ def _run_pipeline(raw_text: str, chat_history: list,
484
  def _handle_answer(user_msg: str, chat_history: list,
485
  pending_parse: dict | None, answers: dict,
486
  raw_text: str):
487
- """Parse a free-text answer and route back through pipeline."""
488
  a = dict(answers)
489
  low = user_msg.strip()
490
 
491
- # simple intent detection for clarification answers
492
  if any(w in low for w in ["חשבונית מס", "מס וקבלה", "מס/קבלה"]):
493
  a["doc_type"] = low
494
  elif "קבלה" in low:
@@ -501,7 +485,7 @@ def _handle_answer(user_msg: str, chat_history: list,
501
 
502
 
503
  # ── build UI ─────────────────────────────────────────────────────────────────
504
- with gr.Blocks(title="Text2Receipt") as demo:
505
 
506
  gr.HTML("""
507
  <div dir="rtl" style="text-align:center;padding:24px 0 8px;">
@@ -512,7 +496,6 @@ with gr.Blocks(title="Text2Receipt") as demo:
512
  </div>
513
  </div>""")
514
 
515
- # state
516
  st_parse = gr.State(None)
517
  st_answers = gr.State({})
518
  st_raw = gr.State("")
@@ -522,7 +505,8 @@ with gr.Blocks(title="Text2Receipt") as demo:
522
  note_input = gr.Textbox(
523
  label="הערת הכנסה (עברית חופשית)",
524
  placeholder="לדוגמה: קיבלתי 1,200 ש\"ח ממשה כהן על ייעוץ עסקי",
525
- lines=3, )
 
526
  with gr.Row():
527
  submit_btn = gr.Button("⚡ הפק מסמך", variant="primary")
528
  clear_btn = gr.Button("🗑 נקה")
@@ -536,17 +520,18 @@ with gr.Blocks(title="Text2Receipt") as demo:
536
 
537
  with gr.Column(scale=3):
538
  chatbot = gr.Chatbot(
539
- label="סוכן הבהרה", height=220,
 
540
  )
541
  answer_input = gr.Textbox(
542
  label="תשובה לשאלת הסוכן",
543
  placeholder="הקלד תשובה ולחץ Enter...",
544
- visible=True, )
 
545
 
546
  doc_output = gr.HTML(label="מסמך פיסקלי")
547
  recs_output = gr.HTML(label="קבלות דומות")
548
 
549
- # ── event wiring ──────────────────────────────────────────────────────────
550
  def on_submit(raw, history, pending, answers):
551
  history = history + [{"role": "user", "content": raw}]
552
  return _run_pipeline(raw, history, None, {})
@@ -572,4 +557,4 @@ with gr.Blocks(title="Text2Receipt") as demo:
572
  )
573
 
574
  if __name__ == "__main__":
575
- demo.launch(css=CSS)
 
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
 
38
  from peft import PeftModel
39
  from sentence_transformers import SentenceTransformer
40
  import faiss
 
41
  import pandas as pd
42
 
43
  device = "cuda" if torch.cuda.is_available() else "cpu"
44
  _STATE["device"] = device
45
  _STATE["rng"] = random.Random(42)
46
 
47
+ # ── parser model — CPU-safe loading ──────────────────────────────────────
 
 
 
48
  tok = AutoTokenizer.from_pretrained(BASE_MODEL)
49
  if tok.pad_token is None:
50
  tok.pad_token = tok.eos_token
51
  tok.padding_side = "left"
52
 
53
+ if device == "cuda":
54
+ # GPU path: 4-bit quantization
55
+ bnb = BitsAndBytesConfig(
56
+ load_in_4bit=True,
57
+ bnb_4bit_quant_type="nf4",
58
+ bnb_4bit_compute_dtype=torch.float16,
59
+ bnb_4bit_use_double_quant=True,
60
+ )
61
+ try:
62
+ base = AutoModelForCausalLM.from_pretrained(
63
+ BASE_MODEL, quantization_config=bnb, device_map="auto",
64
+ torch_dtype=torch.float16, attn_implementation="eager")
65
+ model = PeftModel.from_pretrained(base, MODEL_REPO)
66
+ print("✅ GPU: loaded fine-tuned adapter from", MODEL_REPO)
67
+ except Exception as e:
68
+ print(f"⚠ adapter load failed ({e}); falling back to base model (GPU)")
69
+ model = AutoModelForCausalLM.from_pretrained(
70
+ BASE_MODEL, quantization_config=bnb, device_map="auto",
71
+ torch_dtype=torch.float16, attn_implementation="eager")
72
+ else:
73
+ # CPU path: fp32, no quantization (bitsandbytes not supported on CPU)
74
+ try:
75
+ base = AutoModelForCausalLM.from_pretrained(
76
+ BASE_MODEL, device_map="cpu",
77
+ torch_dtype=torch.float32, attn_implementation="eager")
78
+ model = PeftModel.from_pretrained(base, MODEL_REPO)
79
+ print("✅ CPU: loaded fine-tuned adapter from", MODEL_REPO)
80
+ except Exception as e:
81
+ print(f"⚠ adapter load failed ({e}); falling back to base model (CPU)")
82
+ model = AutoModelForCausalLM.from_pretrained(
83
+ BASE_MODEL, device_map="cpu",
84
+ torch_dtype=torch.float32, attn_implementation="eager")
85
 
86
  model.eval()
87
  _STATE["tok"] = tok
88
  _STATE["model"] = model
89
 
90
+ # ── embeddings + FAISS — load from Space files (co-located) ─────────────
91
+ # Files are uploaded directly to the Space repo, so they live at ./
92
+ SPACE_EMB_PATH = "receipts_embeddings.npy"
93
+ SPACE_STORE_PATH = "receipts_store.parquet"
94
+ SPACE_MANIFEST_PATH = "embeddings_manifest.json"
95
 
96
+ emb = np.load(SPACE_EMB_PATH).astype("float32")
97
+ store = pd.read_parquet(SPACE_STORE_PATH)
98
+ with open(SPACE_MANIFEST_PATH) as f:
99
  manifest = json.load(f)
100
 
101
  faiss.normalize_L2(emb)
102
  index = faiss.IndexFlatIP(emb.shape[1])
103
  index.add(emb)
104
 
105
+ enc = SentenceTransformer(manifest["embed_model"], device=device)
 
106
  _STATE["enc"] = enc
107
  _STATE["index"] = index
108
  _STATE["store"] = store
 
165
  return _dt.date.today()
166
 
167
  def _missing_fields(parse: dict | None) -> list[str]:
 
168
  missing = []
169
  if parse is None:
170
  return ["parse_failed"]
 
172
  missing.append("client_name")
173
  if not parse.get("items"):
174
  missing.append("items")
 
175
  return missing
176
 
177
  def _needs_allocation_clarification(parse: dict) -> bool:
 
178
  if parse is None:
179
  return False
180
  subtotal = sum(
 
183
  )
184
  threshold = core.allocation_threshold_for_date(_today())
185
  is_biz = parse.get("client_is_business", False)
186
+ return bool(is_biz) and subtotal >= threshold * 0.8
 
 
187
 
188
  def clarification_questions(parse: dict | None, answers: dict) -> list[str]:
 
 
 
 
189
  questions = []
190
  missing = _missing_fields(parse)
191
 
 
198
  if "items" in missing and "items" not in answers:
199
  questions.append("מה השירות/המוצר שסופק ובאיזה מחיר?")
200
 
 
201
  if "doc_type" not in answers:
202
  if parse and _needs_allocation_clarification(parse):
203
  questions.append(
 
208
  "איזה מסמך לייצר? קבלה / חשבונית מס / חשבונית מס וקבלה?"
209
  )
210
 
211
+ return questions[:3]
212
 
213
 
214
  def apply_answers(parse: dict | None, answers: dict) -> dict:
 
215
  if parse is None:
216
  parse = {}
217
  p = dict(parse)
 
225
  p["doc_type"] = "tax_invoice"
226
  else:
227
  p["doc_type"] = "receipt"
 
228
  p.setdefault("doc_type", "receipt")
229
  p.setdefault("date", _today().isoformat())
230
  p.setdefault("payment_method", "bank_transfer")
 
240
  # 4. FAISS recommender
241
  # ═════════════════════════════════════════════════════════════════════════════
242
  def recommend(query_text: str, k: int = 3) -> list[dict]:
 
243
  enc = _STATE["enc"]
244
  index = _STATE["index"]
245
  store = _STATE["store"]
246
  pref = "query: " if _STATE["e5_family"] else ""
247
  q_emb = enc.encode([pref + query_text],
248
  normalize_embeddings=True).astype("float32")
249
+ _, I = index.search(q_emb, k + 1)
250
  results = []
251
  for idx in I[0][:k]:
252
  row = store.iloc[int(idx)]
 
291
  background:#0f1117; color:#e8eaf6; border-radius:16px;
292
  padding:28px 32px; max-width:680px; margin:0 auto;
293
  border:1px solid #2a2d3e; box-shadow:0 4px 32px rgba(0,0,0,.4);">
 
 
294
  <div style="display:flex;justify-content:space-between;align-items:flex-start;
295
  border-bottom:2px solid #0d9488;padding-bottom:16px;margin-bottom:20px;">
296
  <div>
 
309
  </div>
310
  </div>
311
  </div>
 
 
312
  <div style="background:#1a1d2e;border-radius:10px;padding:14px 18px;
313
  margin-bottom:20px;font-size:14px;">
314
  <span style="color:#9e9e9e;">לקוח: </span>
 
316
  {'<span style="margin-right:10px;font-size:12px;color:#9e9e9e;">עסק</span>' if client.get('is_business') else ''}
317
  {f'<span style="font-size:12px;color:#9e9e9e;"> · ח.פ. {client.get("tax_id","")}</span>' if client.get('tax_id') else ''}
318
  </div>
 
319
  {alloc_html}
 
 
320
  <table style="width:100%;border-collapse:collapse;font-size:14px;
321
  margin-bottom:18px;">
322
  <thead>
 
331
  {line_rows}
332
  </tbody>
333
  </table>
 
 
334
  <div style="border-top:1px solid #2a2d3e;padding-top:14px;
335
  font-size:14px;text-align:left;">
336
  <div style="display:flex;justify-content:space-between;margin-bottom:4px;">
 
432
 
433
  def _run_pipeline(raw_text: str, chat_history: list,
434
  pending_parse: dict | None, answers: dict):
 
 
 
 
435
  _lazy_init()
436
 
 
437
  if pending_parse is None:
438
  parse = model_parse(raw_text)
439
  else:
440
  parse = pending_parse
441
 
 
442
  questions = clarification_questions(parse, answers)
443
  if questions:
444
  q_text = "\n".join(f"• {q}" for q in questions)
 
448
  ]
449
  return chat_history, "", "", parse, answers
450
 
 
451
  final_parse = apply_answers(parse, answers)
452
  try:
453
  completed = core.complete(DEMO_ISSUER, final_parse, _STATE["rng"])
 
456
  "content": f"שגיאה בעיבוד: {e}"}],
457
  "", "", None, {})
458
 
 
459
  doc_html = render_document(completed)
460
  recs = recommend(raw_text)
461
  recs_html = render_recommendations(recs)
 
470
  def _handle_answer(user_msg: str, chat_history: list,
471
  pending_parse: dict | None, answers: dict,
472
  raw_text: str):
 
473
  a = dict(answers)
474
  low = user_msg.strip()
475
 
 
476
  if any(w in low for w in ["חשבונית מס", "מס וקבלה", "מס/קבלה"]):
477
  a["doc_type"] = low
478
  elif "קבלה" in low:
 
485
 
486
 
487
  # ── build UI ─────────────────────────────────────────────────────────────────
488
+ with gr.Blocks(title="Text2Receipt", css=CSS) as demo:
489
 
490
  gr.HTML("""
491
  <div dir="rtl" style="text-align:center;padding:24px 0 8px;">
 
496
  </div>
497
  </div>""")
498
 
 
499
  st_parse = gr.State(None)
500
  st_answers = gr.State({})
501
  st_raw = gr.State("")
 
505
  note_input = gr.Textbox(
506
  label="הערת הכנסה (עברית חופשית)",
507
  placeholder="לדוגמה: קיבלתי 1,200 ש\"ח ממשה כהן על ייעוץ עסקי",
508
+ lines=3,
509
+ )
510
  with gr.Row():
511
  submit_btn = gr.Button("⚡ הפק מסמך", variant="primary")
512
  clear_btn = gr.Button("🗑 נקה")
 
520
 
521
  with gr.Column(scale=3):
522
  chatbot = gr.Chatbot(
523
+ label="סוכן הבהרה",
524
+ height=220,
525
  )
526
  answer_input = gr.Textbox(
527
  label="תשובה לשאלת הסוכן",
528
  placeholder="הקלד תשובה ולחץ Enter...",
529
+ visible=True,
530
+ )
531
 
532
  doc_output = gr.HTML(label="מסמך פיסקלי")
533
  recs_output = gr.HTML(label="קבלות דומות")
534
 
 
535
  def on_submit(raw, history, pending, answers):
536
  history = history + [{"role": "user", "content": raw}]
537
  return _run_pipeline(raw, history, None, {})
 
557
  )
558
 
559
  if __name__ == "__main__":
560
+ demo.launch(css=CSS)