Text2Receipt / app.py
yonilev's picture
Update app.py
53a2107 verified
Raw
History Blame Contribute Delete
34.3 kB
# -*- 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>בכום ΧœΧ€Χ Χ™ מג&quot;מ</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>מג&quot;מ {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>
&nbsp;Β·&nbsp; {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;">
Χ€ΧͺΧ§ Χ”Χ›Χ Χ‘Χ” Χ—Χ•Χ€Χ©Χ™ Χ‘Χ’Χ‘Χ¨Χ™Χͺ &nbsp;β†’&nbsp; מבמך Χ€Χ™Χ‘Χ§ΧœΧ™ Χ™Χ©Χ¨ΧΧœΧ™ ΧͺΧ§Χ™ΧŸ
</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)