Spaces:
Running
Running
| # -*- coding: utf-8 -*- | |
| """ | |
| Text2Receipt β HF Space (final clean version) | |
| ================================================ | |
| Pipeline: | |
| free-text Hebrew income note | |
| β gemma-2-2b fine-tuned parser (extracts client / items) | |
| β fiscal decision layer (VAT cues, doc-type, allocation) | |
| β t2r_core.complete() (deterministic arithmetic) | |
| β Gradio UI (render document + FAISS recommendations) | |
| """ | |
| import json, random, re as _re | |
| import numpy as np | |
| import gradio as gr | |
| import t2r_core as core | |
| import datetime as _dt | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. Lazy model + FAISS state | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| BASE_MODEL = "unsloth/gemma-2-2b-it" | |
| MODEL_REPO = "yonilev/Text2Receipt-parser" | |
| _STATE = dict(ready=False, tok=None, model=None, enc=None, | |
| index=None, store=None, e5_family=False, | |
| rng=random.Random(42), device="cpu") | |
| def _lazy_init(): | |
| if _STATE["ready"]: | |
| return | |
| import torch | |
| from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig | |
| from peft import PeftModel | |
| from sentence_transformers import SentenceTransformer | |
| import faiss, pandas as pd | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| _STATE["device"] = device | |
| _STATE["rng"] = random.Random(42) | |
| tok = AutoTokenizer.from_pretrained(BASE_MODEL) | |
| if tok.pad_token is None: | |
| tok.pad_token = tok.eos_token | |
| tok.padding_side = "left" | |
| if device == "cuda": | |
| bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", | |
| bnb_4bit_compute_dtype=torch.float16, | |
| bnb_4bit_use_double_quant=True) | |
| try: | |
| base = AutoModelForCausalLM.from_pretrained( | |
| BASE_MODEL, quantization_config=bnb, device_map="auto", | |
| torch_dtype=torch.float16, attn_implementation="eager") | |
| model = PeftModel.from_pretrained(base, MODEL_REPO) | |
| print("β GPU: fine-tuned adapter loaded") | |
| except Exception as e: | |
| print(f"β adapter failed ({e}); base model (GPU)") | |
| model = AutoModelForCausalLM.from_pretrained( | |
| BASE_MODEL, quantization_config=bnb, device_map="auto", | |
| torch_dtype=torch.float16, attn_implementation="eager") | |
| else: | |
| try: | |
| base = AutoModelForCausalLM.from_pretrained( | |
| BASE_MODEL, device_map="cpu", | |
| torch_dtype=torch.float32, attn_implementation="eager") | |
| model = PeftModel.from_pretrained(base, MODEL_REPO) | |
| print("β CPU: fine-tuned adapter loaded") | |
| except Exception as e: | |
| print(f"β adapter failed ({e}); base model (CPU)") | |
| model = AutoModelForCausalLM.from_pretrained( | |
| BASE_MODEL, device_map="cpu", | |
| torch_dtype=torch.float32, attn_implementation="eager") | |
| model.eval() | |
| _STATE["tok"] = tok | |
| _STATE["model"] = model | |
| emb = np.load("receipts_embeddings.npy").astype("float32") | |
| store = pd.read_parquet("receipts_store.parquet") | |
| with open("embeddings_manifest.json") as f: | |
| manifest = json.load(f) | |
| faiss.normalize_L2(emb) | |
| index = faiss.IndexFlatIP(emb.shape[1]) | |
| index.add(emb) | |
| enc = SentenceTransformer(manifest["embed_model"], device=device) | |
| _STATE.update(enc=enc, index=index, store=store, | |
| e5_family=manifest.get("e5_family", False), ready=True) | |
| print(f"β FAISS ready β {emb.shape[0]} vectors") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 2. Parser + post-processing cleaner | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| INSTRUCTION = ( | |
| "ΧΧͺΧ ΧΧΧΧ¨ ΧΧ’Χ¨Χͺ ΧΧΧ Χ‘Χ ΧΧΧ€Χ©ΧΧͺ ΧΧ’ΧΧ¨ΧΧͺ ΧΧΧΧ Χ JSON. " | |
| "ΧΧΧ₯ ΧΧ ΧΧ¨Χ§ ΧΧͺ ΧΧ Χ©ΧΧͺΧΧ ΧΧΧ’Χ¨Χ: Χ©Χ ΧΧΧ§ΧΧ (client_name), " | |
| "ΧΧΧ ΧΧΧ§ΧΧ Χ’Χ‘Χ§ (client_is_business), ΧΧ¨Χ©ΧΧΧͺ Χ€Χ¨ΧΧΧΧ (items) " | |
| "ΧΧΧ©Χ¨ ΧΧΧ Χ€Χ¨ΧΧ ΧͺΧΧΧΧ¨ (description), ΧΧΧΧ¨ ΧΧΧΧΧΧ (unit_price) ΧΧΧΧΧͺ (quantity). " | |
| "ΧΧΧΧ¨ JSON ΧͺΧ§ΧΧ ΧΧΧΧ, ΧΧΧ ΧΧ§Χ‘Χ Χ ΧΧ‘Χ£." | |
| ) | |
| _VAT_NOISE = [ | |
| "ΧΧ€Χ Χ ΧΧ’\"Χ", "ΧΧΧΧ ΧΧ’\"Χ", "ΧΧ€Χ Χ ΧΧ’Χ΄Χ", "ΧΧΧΧ ΧΧ’Χ΄Χ", | |
| "ΧΧͺΧΧ‘Χ€Χͺ ΧΧ’\"Χ", "+ ΧΧ’\"Χ", "+ΧΧ’\"Χ", "ΧΧ’\"Χ", "ΧΧ’Χ΄Χ", "ΧΧ’Χ", | |
| ] | |
| _NOISE_SET = {"ΧΧ€Χ Χ", "ΧΧΧΧ", "Χ ΧΧ", "ΧΧ¨ΧΧΧ", "ΧΧ‘", "+", "Χ€ΧΧΧ‘", | |
| "ΧΧ’\"Χ", "ΧΧ’Χ΄Χ", "ΧΧ’Χ"} | |
| def _build_prompt(raw_text): | |
| return f"{INSTRUCTION}\n\nΧΧ’Χ¨Χ: {raw_text}\n\nJSON:" | |
| def _extract_json(text): | |
| s = text.find("{") | |
| if s < 0: return None | |
| depth = 0 | |
| for i in range(s, len(text)): | |
| if text[i] == "{": depth += 1 | |
| elif text[i] == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| try: return json.loads(text[s:i+1]) | |
| except: return None | |
| return None | |
| def _clean_name(name): | |
| """Strip VAT-related noise the model may accidentally grab as a client name.""" | |
| name = (name or "").strip() | |
| for tok in _VAT_NOISE: | |
| name = name.replace(tok, "").strip(" ,.-+") | |
| return "" if (name in _NOISE_SET or len(name) <= 1) else name | |
| def _clean_parse(parse): | |
| """Post-process raw model output: remove noise, validate structure.""" | |
| if not isinstance(parse, dict): | |
| return parse | |
| # Clean client name | |
| parse["client_name"] = _clean_name(parse.get("client_name", "")) | |
| # Clean items | |
| clean_items = [] | |
| for it in (parse.get("items") or []): | |
| desc = (it.get("description") or "").strip() | |
| for tok in _VAT_NOISE: | |
| desc = desc.replace(tok, "").strip(" ,.-+") | |
| if desc in _NOISE_SET or len(desc) <= 0: | |
| desc = "Χ©ΧΧ¨ΧΧͺ" | |
| it = dict(it) | |
| it["description"] = desc | |
| if it.get("unit_price"): | |
| clean_items.append(it) | |
| parse["items"] = clean_items | |
| return parse | |
| def model_parse(raw_text): | |
| tok, model = _STATE["tok"], _STATE["model"] | |
| msgs = [{"role": "user", "content": _build_prompt(raw_text)}] | |
| prompt = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True) | |
| enc = tok(prompt, return_tensors="pt", add_special_tokens=False).to(model.device) | |
| import torch | |
| with torch.no_grad(): | |
| out = model.generate(**enc, max_new_tokens=128, do_sample=False, | |
| pad_token_id=tok.pad_token_id) | |
| decoded = tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True) | |
| raw = _extract_json(decoded) | |
| return _clean_parse(raw) if raw else None | |
| def _missing_fields(parse): | |
| if parse is None: return ["parse_failed"] | |
| m = [] | |
| if not parse.get("client_name", "").strip(): m.append("client_name") | |
| if not parse.get("items"): m.append("items") | |
| return m | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 3. FAISS recommender | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def recommend(query_text, k=3): | |
| pref = "query: " if _STATE["e5_family"] else "" | |
| q_emb = _STATE["enc"].encode([pref + query_text], | |
| normalize_embeddings=True).astype("float32") | |
| _, I = _STATE["index"].search(q_emb, k + 1) | |
| out = [] | |
| for idx in I[0][:k]: | |
| row = _STATE["store"].iloc[int(idx)] | |
| out.append({"raw_text": row["raw_text"], "category": row["category"], | |
| "doc_type_he": row["doc_type_he"], "total": float(row["total"]), | |
| "client_name": row["client_name"]}) | |
| return out | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4. Fiscal decision layer (app-level, never touches frozen t2r_core.py) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| DEMO_ISSUER_BASE = {"name": "ΧΧΧ Χ©Χ§ΧΧΧ", "tax_id": "962569844"} | |
| _GROSS_PATTERNS = [r"ΧΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧΧΧ\s*ΧΧ‘", r"ΧΧ¨ΧΧ‘", r"ΧΧ¨ΧΧΧ", r"ΧΧΧΧ¨\s*ΧΧΧΧ"] | |
| _NET_PATTERNS = [r"ΧΧ€Χ Χ\s*ΧΧ’[\"Χ΄]?Χ", r"\+\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧͺΧΧ‘Χ€Χͺ\s*ΧΧ’[\"Χ΄]?Χ", | |
| r"Χ€ΧΧΧ‘\s*ΧΧ’[\"Χ΄]?Χ", r"Χ ΧΧ", r"ΧΧ\s*ΧΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ", | |
| r"ΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧ€Χ Χ\s*ΧΧ‘"] | |
| def _text_has(text, patterns): | |
| t = text.lower() | |
| return any(_re.search(p, t) for p in patterns) | |
| def _detect_date(text): | |
| for pat, order in [(r"(\d{1,2})[/\-\.](\d{1,2})[/\-\.](\d{2,4})", "dmy"), | |
| (r"(\d{4})[/\-\.](\d{1,2})[/\-\.](\d{1,2})", "ymd")]: | |
| m = _re.search(pat, text) | |
| if m: | |
| try: | |
| g = [int(x) for x in m.groups()] | |
| d, mo, y = (g[0], g[1], g[2]) if order == "dmy" else (g[2], g[1], g[0]) | |
| if y < 100: y += 2000 | |
| return f"{y:04d}-{mo:02d}-{d:02d}" | |
| except Exception: | |
| pass | |
| return None | |
| def _today_str(): | |
| return _dt.date.today().isoformat() | |
| def decide_fiscal(note, issuer_status, parse, doc_radio, | |
| clarify_vat=None, clarify_tax_id=None, clarify_name=None): | |
| """ | |
| Returns (issuer_dict, final_parse, questions_dict). | |
| questions_dict empty β ready to generate. | |
| """ | |
| # ββ 1. Issuer required β early return if missing ββββββββββββββββββββββ | |
| if not issuer_status: | |
| return None, None, {"issuer_status": "ΧΧΧ¨ Χ‘ΧΧΧΧ‘ ΧΧ Χ€ΧΧ§ ΧΧΧ’ΧΧ (Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨ / Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ)."} | |
| questions = {} | |
| exempt = (issuer_status == "Χ€ΧΧΧ¨") | |
| issuer = {**DEMO_ISSUER_BASE, | |
| "status": "exempt_dealer" if exempt else "authorized_dealer", | |
| "is_company": False} | |
| # ββ 2. Mandatory content ββββββββββββββββββββββββββββββββββββββββββββββ | |
| if clarify_name and clarify_name.strip(): | |
| parse["client_name"] = clarify_name.strip() | |
| if not parse.get("client_name", "").strip(): | |
| questions["client_name"] = "Χ©Χ ΧΧΧ§ΧΧ / ΧΧΧ©ΧΧ?" | |
| if not parse.get("items"): | |
| questions["items"] = "ΧΧ ΧΧ©ΧΧ¨ΧΧͺ/ΧΧΧΧ¦Χ¨ ΧΧΧΧΧΧ Χ‘ΧΧΧ? (ΧΧΧ©Χ: ΧΧΧ’ΧΧ₯ βͺ500)" | |
| # ββ 3. Date: detect from note β today ββββββββββββββββββββββββββββββββ | |
| parse = dict(parse) | |
| parse["date"] = _detect_date(note) or _today_str() | |
| # ββ 4. Doc type + VAT basis βββββββββββββββββββββββββββββββββββββββββββ | |
| is_gross = _text_has(note, _GROSS_PATTERNS) or clarify_vat == "ΧΧΧΧ ΧΧ’Χ΄Χ" | |
| is_net = _text_has(note, _NET_PATTERNS) or clarify_vat == "ΧΧ€Χ Χ ΧΧ’Χ΄Χ" | |
| has_vat_cue = is_gross or is_net | |
| if exempt: | |
| doc_type, amount_basis = "receipt", "net" | |
| elif doc_radio and doc_radio != "ΧΧΧΧΧ ΧΧΧΧΧΧΧ": | |
| doc_type = {"Χ§ΧΧΧ": "receipt", | |
| "ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘": "tax_invoice", | |
| "ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘ ΧΧ§ΧΧΧ": "tax_invoice_receipt"}.get(doc_radio, "receipt") | |
| if doc_type != "receipt": | |
| if is_gross: | |
| amount_basis = "gross" | |
| elif is_net: | |
| amount_basis = "net" | |
| elif clarify_vat in (None, "ΧΧ Χ¦ΧΧΧ"): | |
| total = sum(i.get("unit_price", 0) * i.get("quantity", 1) | |
| for i in parse.get("items", [])) | |
| questions["vat_basis"] = f"ΧΧ‘ΧΧΧ (βͺ{total:,.0f}) β ΧΧΧΧ ΧΧ’Χ΄Χ ΧΧ ΧΧ€Χ Χ ΧΧ’Χ΄Χ?" | |
| amount_basis = "net" | |
| else: | |
| amount_basis = "net" | |
| else: | |
| amount_basis = "net" | |
| else: | |
| # Auto: VAT cue in note β tax_invoice_receipt; otherwise β receipt (no VAT line) | |
| if has_vat_cue: | |
| doc_type, amount_basis = "tax_invoice_receipt", "gross" if is_gross else "net" | |
| else: | |
| doc_type, amount_basis = "receipt", "net" | |
| # ββ 5. Allocation: only ΧΧ’"Χ / ΧΧΧ¨Χ + amount β₯ threshold βββββββββββββ | |
| if doc_type in ("tax_invoice", "tax_invoice_receipt") and not exempt: | |
| subtotal = sum(it.get("unit_price", 0) * it.get("quantity", 1) | |
| for it in parse.get("items", [])) | |
| threshold = core.allocation_threshold_for_date( | |
| _dt.date.fromisoformat(parse["date"])) | |
| name = parse.get("client_name", "") | |
| is_corp = any(kw in name for kw in ['ΧΧ’"Χ', "ΧΧ’Χ", "ΧΧΧ¨Χ", "ΧΧ’'Χ", "ΧΧ’.Χ"]) | |
| if is_corp and subtotal >= threshold: | |
| if clarify_tax_id and clarify_tax_id.strip(): | |
| parse["client_tax_id"] = clarify_tax_id.strip() | |
| elif not parse.get("client_tax_id"): | |
| questions["client_tax_id"] = f"Χ.Χ€. ΧΧΧ§ΧΧ (Χ ΧΧ¨Χ© ΧΧΧ§Χ¦ΧΧ β Χ’Χ‘Χ§Χ β₯ βͺ{threshold:,})" | |
| # ββ 6. Finalise parse βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| parse.update({"doc_type": doc_type, "amount_basis": amount_basis}) | |
| parse.setdefault("payment_method", "bank_transfer") | |
| parse.setdefault("currency", "ILS") | |
| parse.setdefault("client_tax_id", None) | |
| parse.setdefault("client_is_business", False) | |
| parse.setdefault("client_name", "β") | |
| parse.setdefault("items", []) | |
| return issuer, parse, questions | |
| def _build_clarification_message(questions): | |
| icons = {"issuer_status": "π€", "client_name": "π€", | |
| "items": "π¦", "vat_basis": "π°", "client_tax_id": "π"} | |
| lines = "\n".join(f"{icons.get(k,'β’')} {v}" for k, v in questions.items()) | |
| return f"β **ΧΧΧ ΧΧΧ€ΧΧ§ ΧΧͺ ΧΧΧ‘ΧΧ, ΧΧ‘Χ¨ ΧΧΧΧ’:**\n\n{lines}\n\nΧΧΧ‘Χ£ ΧΧͺ ΧΧ€Χ¨ΧΧΧ ΧΧ€ΧͺΧ§ ΧΧΧΧ₯ **ΧΧ€Χ§ ΧΧ‘ΧΧ** Χ©ΧΧ." | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 5. HTML renderers | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def render_parse_panel(parse, completed, show_vat): | |
| items = parse.get("items", []) | |
| item_rows = "".join( | |
| f'<div style="display:flex;justify-content:space-between;font-size:12px;' | |
| f'color:#334155;padding:3px 0;"><span>{it.get("description","")}</span>' | |
| f'<span style="color:#64748b;">{it.get("quantity",1)} Γ βͺ{it.get("unit_price",0):,.0f}</span></div>' | |
| for it in items) | |
| chips = ( | |
| f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;' | |
| f'border-radius:999px;padding:2px 9px;font-size:11px;">ΧΧ§ΧΧ: {parse.get("client_name","β")}</span> ' | |
| f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;' | |
| f'border-radius:999px;padding:2px 9px;font-size:11px;">' | |
| f'{"Χ’Χ‘Χ§" if parse.get("client_is_business") else "Χ€Χ¨ΧΧ"}</span> ' | |
| f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;' | |
| f'border-radius:999px;padding:2px 9px;font-size:11px;">{len(items)} Χ€Χ¨ΧΧΧΧ</span>') | |
| alloc_line = ( | |
| '<div style="font-size:12px;color:#334155;padding:3px 0;">' | |
| 'ΧΧ‘Χ€Χ¨ ΧΧ§Χ¦ΧΧ <span style="color:#0d9488;">β Χ ΧΧ¨Χ©</span></div>' | |
| if completed.get("allocation_required") else "") | |
| vat_line = ( | |
| f'<div style="font-size:12px;color:#334155;padding:3px 0;">' | |
| f'ΧΧ’"Χ {int(round(completed.get("vat_rate",0)*100))}% Β· βͺ{completed.get("vat_amount",0):,.2f}</div>' | |
| if show_vat else | |
| '<div style="font-size:12px;color:#94a3b8;padding:3px 0;">ΧΧΧ Χ€ΧΧ¨ΧΧ§ ΧΧ’"Χ (Χ§ΧΧΧ)</div>') | |
| return f""" | |
| <div dir="rtl" style="font-family:'Segoe UI',Arial,sans-serif;max-width:660px; | |
| margin:14px auto 0;display:grid;grid-template-columns:1fr 1fr;gap:12px;"> | |
| <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px;"> | |
| <div style="font-size:11px;font-weight:700;color:#0d9488;letter-spacing:.05em; | |
| text-transform:uppercase;margin-bottom:10px;">π§ ΧΧΧΧΧ ΧΧΧΧ₯</div> | |
| <div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px;">{chips}</div> | |
| {item_rows} | |
| <div style="font-size:10px;color:#94a3b8;margin-top:10px;border-top:1px dashed #e2e8f0; | |
| padding-top:8px;">gemma-2-2b + LoRA Β· ΧΧΧΧ Χ¨Χ§ <code>parse</code></div> | |
| </div> | |
| <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px;"> | |
| <div style="font-size:11px;font-weight:700;color:#7c3aed;letter-spacing:.05em; | |
| text-transform:uppercase;margin-bottom:10px;">βοΈ ΧΧΧ ΧΧ’ ΧΧΧ©Χ</div> | |
| {vat_line} | |
| <div style="font-size:12px;color:#334155;padding:3px 0;">Χ‘Χ"Χ βͺ{completed.get("total",0):,.2f}</div> | |
| <div style="font-size:12px;color:#334155;padding:3px 0;">ΧΧ‘Χ³ ΧΧ‘ΧΧ {completed.get("serial_number","")}</div> | |
| {alloc_line} | |
| <div style="font-size:10px;color:#94a3b8;margin-top:10px;border-top:1px dashed #e2e8f0; | |
| padding-top:8px;">ΧΧΧ¨ΧΧΧ ΧΧ‘ΧΧ Β· ΧΧ€Χ‘ ΧΧΧΧΧͺ ΧΧΧ©ΧΧΧ</div> | |
| </div> | |
| </div>""" | |
| def render_document(c, show_vat=True): | |
| issuer = c.get("issuer", {}) | |
| client = c.get("client", {}) | |
| lines = c.get("lines", []) | |
| vat_pct = int(round(c.get("vat_rate", 0) * 100)) | |
| alloc = (f'<div style="background:#0d3331;border:1px solid #134e4a;border-radius:8px;' | |
| f'padding:10px 16px;margin-bottom:16px;font-size:13px;color:#5eead4;">' | |
| f'π ΧΧ‘Χ€Χ¨ ΧΧ§Χ¦ΧΧ: <strong>{c.get("allocation_number","β")}</strong></div>' | |
| if c.get("allocation_required") else "") | |
| rows = "".join( | |
| f'<tr>' | |
| f'<td style="padding:10px 4px;border-bottom:1px solid #1e293b;color:#e2e8f0;">{ln["description"]}</td>' | |
| f'<td style="padding:10px 8px;border-bottom:1px solid #1e293b;text-align:center;color:#94a3b8;">{ln["quantity"]}</td>' | |
| f'<td style="padding:10px 8px;border-bottom:1px solid #1e293b;text-align:left;color:#94a3b8;">βͺ{ln["unit_price"]:,.2f}</td>' | |
| f'<td style="padding:10px 4px;border-bottom:1px solid #1e293b;text-align:left;font-weight:600;color:#e2e8f0;">βͺ{ln["line_total"]:,.2f}</td>' | |
| f'</tr>' | |
| for ln in lines) | |
| vat_block = "" if not show_vat else f""" | |
| <div style="display:flex;justify-content:space-between;font-size:13px;color:#64748b;margin-bottom:8px;"> | |
| <span>Χ‘ΧΧΧ ΧΧ€Χ Χ ΧΧ’"Χ</span> | |
| <span style="color:#94a3b8;">βͺ{c.get("subtotal",0):,.2f}</span> | |
| </div> | |
| <div style="display:flex;justify-content:space-between;font-size:13px;color:#64748b; | |
| padding-bottom:12px;border-bottom:1px solid #334155;margin-bottom:12px;"> | |
| <span>ΧΧ’"Χ {vat_pct}%</span> | |
| <span style="color:#94a3b8;">βͺ{c.get("vat_amount",0):,.2f}</span> | |
| </div>""" | |
| return f""" | |
| <div dir="rtl" style="font-family:'Segoe UI','Arial Hebrew',Arial,sans-serif; | |
| background:#0f172a;color:#e2e8f0;border-radius:16px; | |
| border:1px solid #1e293b;padding:28px 32px;max-width:660px; | |
| margin:16px auto 0;box-shadow:0 8px 32px rgba(0,0,0,.4);"> | |
| <div style="display:flex;justify-content:space-between;align-items:flex-start; | |
| padding-bottom:20px;margin-bottom:20px;border-bottom:1px solid #1e293b;"> | |
| <div> | |
| <div style="font-size:22px;font-weight:700;color:#2dd4bf;">{c.get("doc_type_he","ΧΧ‘ΧΧ")}</div> | |
| <div style="font-size:12px;color:#475569;margin-top:5px;"> | |
| ΧΧ‘Χ³ <span style="color:#94a3b8;">{c.get("serial_number","")}</span> | |
| Β· {c.get("issue_date","")} | |
| </div> | |
| </div> | |
| <div style="text-align:left;"> | |
| <div style="font-size:15px;font-weight:600;color:#e2e8f0;">{issuer.get("name","")}</div> | |
| <div style="font-size:11px;color:#475569;margin-top:3px;">Χ.Χ€. {issuer.get("tax_id","")}</div> | |
| <div style="display:inline-block;margin-top:5px;font-size:11px; | |
| background:#134e4a;color:#5eead4;padding:2px 8px;border-radius:999px;"> | |
| {"Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ" if issuer.get("status")=="authorized_dealer" else "Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨"} | |
| </div> | |
| </div> | |
| </div> | |
| <div style="background:#1e293b;border-radius:10px;padding:12px 16px; | |
| margin-bottom:16px;display:flex;align-items:center;gap:10px;"> | |
| <div style="width:32px;height:32px;border-radius:50%;background:#0d9488; | |
| display:flex;align-items:center;justify-content:center; | |
| font-size:14px;font-weight:700;color:#fff;flex-shrink:0;"> | |
| {(client.get("name","?") or "?")[0]} | |
| </div> | |
| <div> | |
| <div style="font-size:14px;font-weight:600;color:#e2e8f0;">{client.get("name","")}</div> | |
| <div style="font-size:11px;color:#64748b;margin-top:1px;"> | |
| {"Χ’Χ‘Χ§" if client.get("is_business") else "ΧΧ§ΧΧ Χ€Χ¨ΧΧ"} | |
| {f" Β· Χ.Χ€. {client.get('tax_id','')}" if client.get("tax_id") else ""} | |
| </div> | |
| </div> | |
| </div> | |
| {alloc} | |
| <table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px;"> | |
| <thead> | |
| <tr style="font-size:11px;color:#475569;letter-spacing:.06em;"> | |
| <th style="padding:6px 4px;text-align:right;border-bottom:1px solid #334155;font-weight:500;">Χ€Χ¨ΧΧ</th> | |
| <th style="padding:6px 8px;text-align:center;border-bottom:1px solid #334155;font-weight:500;">ΧΧΧΧͺ</th> | |
| <th style="padding:6px 8px;text-align:left;border-bottom:1px solid #334155;font-weight:500;">ΧΧΧΧ¨</th> | |
| <th style="padding:6px 4px;text-align:left;border-bottom:1px solid #334155;font-weight:500;">Χ‘Χ"Χ</th> | |
| </tr> | |
| </thead> | |
| <tbody>{rows}</tbody> | |
| </table> | |
| <div style="background:#1e293b;border-radius:10px;padding:16px 18px;"> | |
| {vat_block} | |
| <div style="display:flex;justify-content:space-between;align-items:baseline;"> | |
| <span style="font-size:15px;font-weight:600;color:#e2e8f0;">Χ‘Χ"Χ ΧΧͺΧ©ΧΧΧ</span> | |
| <span style="font-size:24px;font-weight:700;color:#2dd4bf;">βͺ{c.get("total",0):,.2f}</span> | |
| </div> | |
| <div style="font-size:11px;color:#334155;margin-top:10px;text-align:right;"> | |
| {core.PAYMENT_HE.get(c.get("payment_method",""),"β")} | |
| </div> | |
| </div> | |
| </div>""" | |
| def render_recommendations(recs): | |
| if not recs: return "" | |
| cards = "".join(f""" | |
| <div style="background:#1e293b;border-radius:10px;padding:14px 16px;border:1px solid #334155;"> | |
| <div style="font-size:11px;font-weight:600;color:#2dd4bf;letter-spacing:.04em; | |
| margin-bottom:6px;text-transform:uppercase;">{r["doc_type_he"]} Β· {r["category"]}</div> | |
| <div style="font-size:12px;color:#cbd5e1;line-height:1.55;margin-bottom:8px;"> | |
| {r["raw_text"][:72]}{"β¦" if len(r["raw_text"])>72 else ""} | |
| </div> | |
| <div style="display:flex;justify-content:space-between;"> | |
| <span style="font-size:11px;color:#475569;">{r["client_name"]}</span> | |
| <span style="font-size:13px;font-weight:600;color:#94a3b8;">βͺ{r["total"]:,.0f}</span> | |
| </div> | |
| </div>""" for r in recs) | |
| return f""" | |
| <div dir="rtl" style="font-family:'Segoe UI',Arial,sans-serif;max-width:660px;margin:8px auto 24px;"> | |
| <div style="font-size:11px;color:#334155;margin-bottom:10px; | |
| letter-spacing:.06em;text-transform:uppercase;">ΧΧ‘ΧΧΧΧ ΧΧΧΧΧ ΧΧΧΧΧΧ¨</div> | |
| <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;">{cards}</div> | |
| </div>""" | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 6. Main pipeline | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate(note, issuer_radio, doc_choice, clarify_vat, clarify_tax_id, clarify_name): | |
| """ | |
| Stateless single handler. | |
| Returns: (status_md, parse_html, doc_html, recs_html, name_vis, vat_vis, tax_vis) | |
| """ | |
| note = (note or "").strip() | |
| if not note: | |
| return "βοΈ ΧΧͺΧΧ Χ€ΧͺΧ§ ΧΧΧ Χ‘Χ ΧΧΧΧ₯ ΧΧ€Χ§ ΧΧ‘ΧΧ.", "", "", "", False, False, False | |
| issuer_status = issuer_radio if issuer_radio in ("Χ€ΧΧΧ¨", "ΧΧΧ¨Χ©Χ") else None | |
| if not issuer_status: | |
| return "π€ ΧΧΧ¨ ΧͺΧΧΧΧ: **Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨** ΧΧ **Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ** ΧΧΧ’ΧΧ.", "", "", "", False, False, False | |
| _lazy_init() | |
| parse = model_parse(note) | |
| if parse is None or _missing_fields(parse) == ["parse_failed"]: | |
| return ("π€ ΧΧ ΧΧ¦ΧΧΧͺΧ ΧΧΧΧ₯ Χ€Χ¨ΧΧΧ. Χ Χ‘Χ ΧΧΧΧ© β ΧΧΧΧΧΧ: 'Χ§ΧΧΧΧͺΧ 500βͺ ΧΧΧ©Χ Χ’Χ ΧΧΧ’ΧΧ₯'.", | |
| "", "", "", False, False, False) | |
| cvat = clarify_vat if clarify_vat not in (None, "ΧΧ Χ¦ΧΧΧ") else None | |
| ctax = clarify_tax_id.strip() if clarify_tax_id and clarify_tax_id.strip() else None | |
| cname = clarify_name.strip() if clarify_name and clarify_name.strip() else None | |
| issuer, final, questions = decide_fiscal( | |
| note, issuer_status, parse, doc_choice, cvat, ctax, cname) | |
| if questions: | |
| show_name_q = "client_name" in questions | |
| show_vat_q = "vat_basis" in questions | |
| show_taxid_q = "client_tax_id" in questions | |
| return (_build_clarification_message(questions), | |
| "", "", "", show_name_q, show_vat_q, show_taxid_q) | |
| try: | |
| completed = core.complete(issuer, final, _STATE["rng"]) | |
| except Exception as e: | |
| return f"β οΈ Χ©ΧΧΧΧ ΧΧ’ΧΧΧΧ: {e}", "", "", "", False, False, False | |
| show_vat = (final["doc_type"] != "receipt") | |
| return ("β ΧΧΧ‘ΧΧ ΧΧΧ€Χ§ ΧΧΧ¦ΧΧΧ.", | |
| render_parse_panel(final, completed, show_vat), | |
| render_document(completed, show_vat), | |
| render_recommendations(recommend(note)), | |
| False, False, False) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 7. Gradio UI | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CSS = """ | |
| .gradio-container { max-width: 880px !important; margin: 0 auto !important; } | |
| footer { display: none !important; } | |
| .qs-btn { | |
| font-size: 13px !important; | |
| background: #f1f5f9 !important; | |
| border: 1px solid #e2e8f0 !important; | |
| color: #0f172a !important; | |
| } | |
| .qs-btn:hover { background: #e2e8f0 !important; } | |
| """ | |
| QUICK_STARTERS = [ | |
| ("πΌ Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ + ΧΧ§ΧΧ Χ’Χ‘Χ§Χ", 'ΧΧΧ¨Χͺ ΧΧ’ΧΧ ΧΧ’"Χ Χ©ΧΧΧΧ 36,500 ΧΧΧΧ ΧΧ’"Χ Χ’Χ Χ€Χ¨ΧΧΧ§Χ ΧΧͺΧ¨. ΧΧ’ΧΧ¨Χ ΧΧ Χ§ΧΧΧͺ.'), | |
| ("π§Ύ Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨ β Χ©ΧΧ’ΧΧ¨ Χ€Χ¨ΧΧ", "Χ§ΧΧΧΧͺΧ 220 Χ©Χ§Χ ΧΧΧΧΧ¨ Χ’Χ Χ©ΧΧ’ΧΧ¨ Χ©ΧΧΧΧ Χ€Χ¨ΧΧ, ΧΧΧΧΧ"), | |
| ("π¦ ΧΧΧ Χ€Χ¨ΧΧΧΧ", 'Χ ΧΧ’Χ ΧΧΧ Χ©ΧΧΧΧ 2Γ350 Χ©"Χ ΧΧΧ’ΧΧ₯ Χ’Χ‘Χ§Χ + 180 Χ©"Χ ΧΧΧ Χͺ ΧΧ¦ΧΧͺ, ΧΧ€Χ Χ ΧΧ’"Χ'), | |
| ] | |
| with gr.Blocks(title="Text2Receipt", theme=gr.themes.Soft( | |
| primary_hue="teal", secondary_hue="slate")) as demo: | |
| gr.HTML(""" | |
| <div dir="rtl" style="text-align:center;padding:24px 0 8px;"> | |
| <div style="font-size:30px;margin-bottom:6px;">π§Ύ</div> | |
| <div style="font-size:26px;font-weight:800;color:#0d9488;letter-spacing:-.4px;">Text2Receipt</div> | |
| <div style="font-size:14px;color:#475569;margin-top:6px;"> | |
| Χ€ΧͺΧ§ ΧΧΧ Χ‘Χ ΧΧΧ€Χ©Χ ΧΧ’ΧΧ¨ΧΧͺ β ΧΧ‘ΧΧ Χ€ΧΧ‘Χ§ΧΧ ΧΧ©Χ¨ΧΧΧ ΧͺΧ§ΧΧ | |
| </div> | |
| </div>""") | |
| issuer_radio = gr.Radio( | |
| ["Χ€ΧΧΧ¨", "ΧΧΧ¨Χ©Χ"], value=None, | |
| label="ΧΧ ΧΧ Χ (Χ‘ΧΧΧΧ‘ ΧΧ Χ€ΧΧ§)", | |
| info='Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨ = ΧΧΧ ΧΧ’"Χ Β· Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ = Χ’Χ ΧΧ’"Χ', | |
| ) | |
| note_input = gr.Textbox( | |
| label="Χ€ΧͺΧ§ / ΧͺΧΧΧ ΧΧΧΧ Χ‘Χ", lines=2, rtl=True, autofocus=True, | |
| placeholder='ΧΧΧΧΧΧ: Χ§ΧΧΧΧͺΧ 1,200 Χ©"Χ ΧΧΧ©Χ ΧΧΧ Χ’Χ ΧΧΧ’ΧΧ₯ Χ’Χ‘Χ§Χ', | |
| ) | |
| doc_choice = gr.Radio( | |
| ["ΧΧΧΧΧ ΧΧΧΧΧΧΧ", "Χ§ΧΧΧ", "ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘", "ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘ ΧΧ§ΧΧΧ"], | |
| value="ΧΧΧΧΧ ΧΧΧΧΧΧΧ", label="Χ‘ΧΧ ΧΧ‘ΧΧ", | |
| info="ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘ = ΧΧ¨ΧΧ©Χͺ ΧͺΧ©ΧΧΧ (ΧΧ¨Χ Χ©ΧΧΧ) Β· ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘ ΧΧ§ΧΧΧ = Χ©Χ ΧΧΧ ΧΧΧΧ (Χ©ΧΧΧ)", | |
| ) | |
| with gr.Row(): | |
| submit_btn = gr.Button("ΧΧ€Χ§ ΧΧ‘ΧΧ β‘", variant="primary", scale=2) | |
| clear_btn = gr.Button("Χ Χ§Χ π", scale=1) | |
| status_output = gr.Markdown("") | |
| # Clarification fields β appear only when needed | |
| with gr.Group(visible=False) as clarify_name_group: | |
| gr.HTML('<div dir="rtl" style="font-size:12px;font-weight:600;color:#7c3aed;' | |
| 'margin-bottom:6px;">π€ Χ©Χ ΧΧ§ΧΧ Χ ΧΧ¨Χ©</div>') | |
| clarify_name = gr.Textbox( | |
| label="Χ©Χ ΧΧΧ§ΧΧ / ΧΧΧ©ΧΧ", placeholder="ΧΧΧ©Χ: Χ ΧΧ’Χ ΧΧΧ", value="", | |
| ) | |
| with gr.Group(visible=False) as clarify_vat_group: | |
| gr.HTML('<div dir="rtl" style="font-size:12px;font-weight:600;color:#7c3aed;' | |
| 'margin-bottom:6px;">π° ΧΧ‘ΧΧ‘ ΧΧ’Χ΄Χ Χ ΧΧ¨Χ©</div>') | |
| clarify_vat = gr.Radio( | |
| ["ΧΧΧΧ ΧΧ’Χ΄Χ", "ΧΧ€Χ Χ ΧΧ’Χ΄Χ", "ΧΧ Χ¦ΧΧΧ"], | |
| value="ΧΧ Χ¦ΧΧΧ", label="ΧΧ‘ΧΧΧ ΧΧΧ¦ΧΧΧ ΧΧ€ΧͺΧ§", | |
| ) | |
| with gr.Group(visible=False) as clarify_tax_group: | |
| gr.HTML('<div dir="rtl" style="font-size:12px;font-weight:600;color:#7c3aed;' | |
| 'margin-bottom:6px;">π Χ.Χ€. ΧΧ§ΧΧ Χ ΧΧ¨Χ© (ΧΧ§Χ¦ΧΧ)</div>') | |
| clarify_tax_id = gr.Textbox( | |
| label="Χ.Χ€. / Χͺ.Χ. ΧΧ§ΧΧ", placeholder="ΧΧΧ©Χ: 514123458", value="", | |
| ) | |
| gr.HTML('<div dir="rtl" style="font-size:11px;color:#94a3b8;margin:10px 0 6px;' | |
| 'letter-spacing:.07em;text-transform:uppercase;">ΧΧΧΧΧΧΧͺ ΧΧΧΧ¨ΧΧͺ</div>') | |
| with gr.Row(): | |
| for lbl, ex in QUICK_STARTERS: | |
| gr.Button(lbl, elem_classes=["qs-btn"], size="sm").click( | |
| fn=lambda e=ex: e, outputs=note_input) | |
| parse_output = gr.HTML() | |
| doc_output = gr.HTML() | |
| recs_output = gr.HTML() | |
| # Handlers | |
| def _on_generate(note, issuer_r, doc_c, cvat, ctax, cname): | |
| status, phtml, dhtml, rhtml, show_name_q, show_vat_q, show_taxid_q = generate( | |
| note, issuer_r, doc_c, cvat, ctax, cname) | |
| return (status, phtml, dhtml, rhtml, | |
| gr.update(visible=show_name_q), | |
| gr.update(visible=show_vat_q), | |
| gr.update(visible=show_taxid_q)) | |
| submit_btn.click( | |
| fn=_on_generate, | |
| inputs=[note_input, issuer_radio, doc_choice, | |
| clarify_vat, clarify_tax_id, clarify_name], | |
| outputs=[status_output, parse_output, doc_output, recs_output, | |
| clarify_name_group, clarify_vat_group, clarify_tax_group], | |
| api_name="generate", | |
| ) | |
| def _on_clear(): | |
| return ("", None, "ΧΧΧΧΧ ΧΧΧΧΧΧΧ", "ΧΧ Χ¦ΧΧΧ", "", "", | |
| "", "", "", "", | |
| gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)) | |
| clear_btn.click( | |
| fn=_on_clear, | |
| outputs=[note_input, issuer_radio, doc_choice, | |
| clarify_vat, clarify_tax_id, clarify_name, | |
| status_output, parse_output, doc_output, recs_output, | |
| clarify_name_group, clarify_vat_group, clarify_tax_group], | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 8. Launch | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| demo.launch(css=CSS) |