Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,37 +1,31 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
"""
|
| 3 |
-
Text2Receipt β
|
| 4 |
-
=========================================
|
| 5 |
Pipeline:
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
β FAISS recommender (3 similar past receipts)
|
| 12 |
-
Repos:
|
| 13 |
-
Dataset : yonilev/Text2Receipt
|
| 14 |
-
Model : yonilev/Text2Receipt-parser
|
| 15 |
-
Space : yonilev/Text2Receipt
|
| 16 |
"""
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
# On HF Spaces, Gradio runs in SSR mode by default. In Gradio 6.17 this breaks
|
| 20 |
-
# value-passing from Textbox inputs (raw_text arrives empty). Force CSR.
|
| 21 |
-
os.environ["GRADIO_SSR_MODE"] = "false"
|
| 22 |
import numpy as np
|
| 23 |
import gradio as gr
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
-
# 1. Lazy init
|
| 33 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
-
_STATE: dict = {"ready": False}
|
| 35 |
|
| 36 |
def _lazy_init():
|
| 37 |
if _STATE["ready"]:
|
|
@@ -98,9 +92,9 @@ def _lazy_init():
|
|
| 98 |
print(f"β
FAISS ready β {emb.shape[0]} vectors")
|
| 99 |
|
| 100 |
|
| 101 |
-
#
|
| 102 |
-
# 2. Parser
|
| 103 |
-
#
|
| 104 |
INSTRUCTION = (
|
| 105 |
"ΧΧͺΧ ΧΧΧΧ¨ ΧΧ’Χ¨Χͺ ΧΧΧ Χ‘Χ ΧΧΧ€Χ©ΧΧͺ ΧΧ’ΧΧ¨ΧΧͺ ΧΧΧΧ Χ JSON. "
|
| 106 |
"ΧΧΧ₯ ΧΧ ΧΧ¨Χ§ ΧΧͺ ΧΧ Χ©ΧΧͺΧΧ ΧΧΧ’Χ¨Χ: Χ©Χ ΧΧΧ§ΧΧ (client_name), "
|
|
@@ -109,9 +103,18 @@ INSTRUCTION = (
|
|
| 109 |
"ΧΧΧΧ¨ JSON ΧͺΧ§ΧΧ ΧΧΧΧ, ΧΧΧ ΧΧ§Χ‘Χ Χ ΧΧ‘Χ£."
|
| 110 |
)
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
def _build_prompt(raw_text):
|
| 113 |
return f"{INSTRUCTION}\n\nΧΧ’Χ¨Χ: {raw_text}\n\nJSON:"
|
| 114 |
|
|
|
|
| 115 |
def _extract_json(text):
|
| 116 |
s = text.find("{")
|
| 117 |
if s < 0: return None
|
|
@@ -125,6 +128,37 @@ def _extract_json(text):
|
|
| 125 |
except: return None
|
| 126 |
return None
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
def model_parse(raw_text):
|
| 129 |
tok, model = _STATE["tok"], _STATE["model"]
|
| 130 |
msgs = [{"role": "user", "content": _build_prompt(raw_text)}]
|
|
@@ -135,15 +169,9 @@ def model_parse(raw_text):
|
|
| 135 |
out = model.generate(**enc, max_new_tokens=128, do_sample=False,
|
| 136 |
pad_token_id=tok.pad_token_id)
|
| 137 |
decoded = tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
|
| 141 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 142 |
-
# 3. Agentic clarification
|
| 143 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 144 |
-
import datetime as _dt
|
| 145 |
-
|
| 146 |
-
def _today(): return _dt.date.today()
|
| 147 |
|
| 148 |
def _missing_fields(parse):
|
| 149 |
if parse is None: return ["parse_failed"]
|
|
@@ -152,50 +180,10 @@ def _missing_fields(parse):
|
|
| 152 |
if not parse.get("items"): m.append("items")
|
| 153 |
return m
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
threshold = core.allocation_threshold_for_date(_today())
|
| 160 |
-
return bool(parse.get("client_is_business")) and subtotal >= threshold * 0.8
|
| 161 |
-
|
| 162 |
-
def clarification_questions(parse, answers):
|
| 163 |
-
missing = _missing_fields(parse)
|
| 164 |
-
if "parse_failed" in missing:
|
| 165 |
-
return ["ΧΧ ΧΧ¦ΧΧΧͺΧ ΧΧΧΧ₯ Χ€Χ¨ΧΧΧ. Χ Χ‘Χ ΧΧΧΧ© β ΧΧΧΧΧΧ: 'Χ§ΧΧΧΧͺΧ 500βͺ Χ[Χ©Χ] Χ’Χ [Χ©ΧΧ¨ΧΧͺ]'"]
|
| 166 |
-
qs = []
|
| 167 |
-
if "client_name" in missing and "client_name" not in answers:
|
| 168 |
-
qs.append("ΧΧ Χ©Χ ΧΧΧ§ΧΧ?")
|
| 169 |
-
if "items" in missing and "items" not in answers:
|
| 170 |
-
qs.append("ΧΧ ΧΧ©ΧΧ¨ΧΧͺ/ΧΧΧΧ¦Χ¨ ΧΧΧΧΧΧ ΧΧΧΧ¨?")
|
| 171 |
-
if "doc_type" not in answers:
|
| 172 |
-
if parse and _needs_allocation_clarification(parse):
|
| 173 |
-
qs.append('ΧΧΧ ΧΧΧ Χ€ΧΧ§ ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘/Χ§ΧΧΧ (ΧΧ’Χ‘Χ§ Χ’Χ ΧΧ’"Χ) ΧΧ Χ§ΧΧΧ Χ€Χ©ΧΧΧ?')
|
| 174 |
-
elif parse and not parse.get("doc_type"):
|
| 175 |
-
qs.append("ΧΧΧΧ ΧΧ‘ΧΧ? Χ§ΧΧΧ / ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘ / ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘ ΧΧ§ΧΧΧ")
|
| 176 |
-
return qs[:3]
|
| 177 |
-
|
| 178 |
-
def apply_answers(parse, answers):
|
| 179 |
-
p = dict(parse or {})
|
| 180 |
-
if "client_name" in answers: p["client_name"] = answers["client_name"]
|
| 181 |
-
if "doc_type" in answers:
|
| 182 |
-
raw = answers["doc_type"].strip()
|
| 183 |
-
p["doc_type"] = ("tax_invoice_receipt" if "ΧΧ‘/Χ§ΧΧΧ" in raw or "ΧΧ‘ ΧΧ§ΧΧΧ" in raw
|
| 184 |
-
else "tax_invoice" if "ΧΧ‘" in raw else "receipt")
|
| 185 |
-
p.setdefault("doc_type", "receipt")
|
| 186 |
-
p.setdefault("date", _today().isoformat())
|
| 187 |
-
p.setdefault("payment_method", "bank_transfer")
|
| 188 |
-
p.setdefault("amount_basis", "net")
|
| 189 |
-
p.setdefault("currency", "ILS")
|
| 190 |
-
p.setdefault("client_tax_id", None)
|
| 191 |
-
p.setdefault("client_is_business", False)
|
| 192 |
-
p.setdefault("items", [])
|
| 193 |
-
return p
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
-
# 4. FAISS recommender
|
| 198 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 199 |
def recommend(query_text, k=3):
|
| 200 |
pref = "query: " if _STATE["e5_family"] else ""
|
| 201 |
q_emb = _STATE["enc"].encode([pref + query_text],
|
|
@@ -210,9 +198,186 @@ def recommend(query_text, k=3):
|
|
| 210 |
return out
|
| 211 |
|
| 212 |
|
| 213 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
# 5. HTML renderers
|
| 215 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
def render_document(c, show_vat=True):
|
| 217 |
issuer = c.get("issuer", {})
|
| 218 |
client = c.get("client", {})
|
|
@@ -233,6 +398,17 @@ def render_document(c, show_vat=True):
|
|
| 233 |
f'</tr>'
|
| 234 |
for ln in lines)
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
return f"""
|
| 237 |
<div dir="rtl" style="font-family:'Segoe UI','Arial Hebrew',Arial,sans-serif;
|
| 238 |
background:#0f172a;color:#e2e8f0;border-radius:16px;
|
|
@@ -289,16 +465,7 @@ def render_document(c, show_vat=True):
|
|
| 289 |
</table>
|
| 290 |
|
| 291 |
<div style="background:#1e293b;border-radius:10px;padding:16px 18px;">
|
| 292 |
-
{
|
| 293 |
-
<div style="display:flex;justify-content:space-between;font-size:13px;color:#64748b;margin-bottom:8px;">
|
| 294 |
-
<span>Χ‘ΧΧΧ ΧΧ€Χ Χ ΧΧ’"Χ</span>
|
| 295 |
-
<span style="color:#94a3b8;">βͺ{c.get("subtotal",0):,.2f}</span>
|
| 296 |
-
</div>
|
| 297 |
-
<div style="display:flex;justify-content:space-between;font-size:13px;color:#64748b;
|
| 298 |
-
padding-bottom:12px;border-bottom:1px solid #334155;margin-bottom:12px;">
|
| 299 |
-
<span>ΧΧ’"Χ {vat_pct}%</span>
|
| 300 |
-
<span style="color:#94a3b8;">βͺ{c.get("vat_amount",0):,.2f}</span>
|
| 301 |
-
</div>'''}
|
| 302 |
<div style="display:flex;justify-content:space-between;align-items:baseline;">
|
| 303 |
<span style="font-size:15px;font-weight:600;color:#e2e8f0;">Χ‘Χ"Χ ΧΧͺΧ©ΧΧΧ</span>
|
| 304 |
<span style="font-size:24px;font-weight:700;color:#2dd4bf;">βͺ{c.get("total",0):,.2f}</span>
|
|
@@ -333,253 +500,60 @@ def render_recommendations(recs):
|
|
| 333 |
</div>"""
|
| 334 |
|
| 335 |
|
| 336 |
-
#
|
| 337 |
-
# 6.
|
| 338 |
-
#
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
_GROSS_PATTERNS = [r"ΧΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧΧΧ\s*ΧΧ‘", r"ΧΧ¨ΧΧ‘", r"ΧΧ¨ΧΧΧ",
|
| 345 |
-
r"ΧΧΧΧ¨\s*ΧΧΧΧ"]
|
| 346 |
-
_NET_PATTERNS = [r"ΧΧ€Χ Χ\s*ΧΧ’[\"Χ΄]?Χ", r"\+\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧͺΧΧ‘Χ€Χͺ\s*ΧΧ’[\"Χ΄]?Χ",
|
| 347 |
-
r"Χ€ΧΧΧ‘\s*ΧΧ’[\"Χ΄]?Χ", r"Χ ΧΧ", r"ΧΧ\s*ΧΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ",
|
| 348 |
-
r"ΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧ€Χ Χ\s*ΧΧ‘"]
|
| 349 |
-
_EXEMPT_PATTERNS= [r"Χ’ΧΧ‘Χ§\s*Χ€ΧΧΧ¨", r"Χ€ΧΧΧ¨\s*ΧΧΧ’[\"Χ΄]?Χ", r"ΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ"]
|
| 350 |
-
_PAYMENT_RECV = [r"Χ§ΧΧΧΧͺΧ", r"Χ©ΧΧΧ", r"ΧΧ’ΧΧΧ¨", r"Χ©ΧΧΧ", r"Χ ΧΧ Χ‘", r"ΧΧΧ’ΧΧ¨",
|
| 351 |
-
r"ΧΧΧ", r"Χ€ΧΧ", r"ΧΧΧΧΧ"]
|
| 352 |
-
|
| 353 |
-
def _text_has(text, patterns):
|
| 354 |
-
t = text.lower()
|
| 355 |
-
return any(_re.search(p, t) for p in patterns)
|
| 356 |
-
|
| 357 |
-
def _detect_date_from_text(text):
|
| 358 |
-
"""Try to extract a date from free text. Returns ISO string or None."""
|
| 359 |
-
patterns = [
|
| 360 |
-
(r"(\d{1,2})[/\-\.](\d{1,2})[/\-\.](\d{2,4})", "dmy"),
|
| 361 |
-
(r"(\d{4})[/\-\.](\d{1,2})[/\-\.](\d{1,2})", "ymd"),
|
| 362 |
-
]
|
| 363 |
-
for pat, order in patterns:
|
| 364 |
-
m = _re.search(pat, text)
|
| 365 |
-
if m:
|
| 366 |
-
try:
|
| 367 |
-
g = [int(x) for x in m.groups()]
|
| 368 |
-
if order == "dmy":
|
| 369 |
-
d, mo, y = g
|
| 370 |
-
if y < 100: y += 2000
|
| 371 |
-
else:
|
| 372 |
-
y, mo, d = g
|
| 373 |
-
return f"{y:04d}-{mo:02d}-{d:02d}"
|
| 374 |
-
except Exception:
|
| 375 |
-
pass
|
| 376 |
-
return None
|
| 377 |
-
|
| 378 |
-
def render_parse_panel(parse, completed, show_vat=True):
|
| 379 |
-
"""'Behind the scenes' panel β model extraction vs deterministic computation."""
|
| 380 |
-
items = parse.get("items", [])
|
| 381 |
-
item_rows = "".join(
|
| 382 |
-
f'<div style="display:flex;justify-content:space-between;font-size:12px;'
|
| 383 |
-
f'color:#334155;padding:3px 0;"><span>{it.get("description","")}</span>'
|
| 384 |
-
f'<span style="color:#64748b;">{it.get("quantity",1)} Γ βͺ{it.get("unit_price",0):,.0f}</span></div>'
|
| 385 |
-
for it in items)
|
| 386 |
-
|
| 387 |
-
chips = (
|
| 388 |
-
f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;'
|
| 389 |
-
f'border-radius:999px;padding:2px 9px;font-size:11px;">ΧΧ§ΧΧ: {parse.get("client_name","β")}</span>'
|
| 390 |
-
f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;'
|
| 391 |
-
f'border-radius:999px;padding:2px 9px;font-size:11px;">'
|
| 392 |
-
f'{"Χ’Χ‘Χ§" if parse.get("client_is_business") else "Χ€Χ¨ΧΧ"}</span>'
|
| 393 |
-
f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;'
|
| 394 |
-
f'border-radius:999px;padding:2px 9px;font-size:11px;">{len(items)} Χ€Χ¨ΧΧΧΧ</span>')
|
| 395 |
-
|
| 396 |
-
alloc_line = ""
|
| 397 |
-
if completed.get("allocation_required"):
|
| 398 |
-
alloc_line = ('<div style="font-size:12px;color:#334155;padding:3px 0;">'
|
| 399 |
-
'ΧΧ‘Χ€Χ¨ ΧΧ§Χ¦ΧΧ <span style="color:#0d9488;">β Χ ΧΧ¨Χ©</span></div>')
|
| 400 |
-
|
| 401 |
-
vat_line = (f'<div style="font-size:12px;color:#334155;padding:3px 0;">'
|
| 402 |
-
f'ΧΧ’"Χ {int(round(completed.get("vat_rate",0)*100))}% Β· '
|
| 403 |
-
f'βͺ{completed.get("vat_amount",0):,.2f}</div>'
|
| 404 |
-
if show_vat else
|
| 405 |
-
'<div style="font-size:12px;color:#94a3b8;padding:3px 0;">ΧΧΧ Χ€ΧΧ¨ΧΧ§ ΧΧ’"Χ (Χ§ΧΧΧ)</div>')
|
| 406 |
-
|
| 407 |
-
return f"""
|
| 408 |
-
<div dir="rtl" style="font-family:'Segoe UI',Arial,sans-serif;max-width:660px;
|
| 409 |
-
margin:14px auto 0;display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
| 410 |
-
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px;">
|
| 411 |
-
<div style="font-size:11px;font-weight:700;color:#0d9488;letter-spacing:.05em;
|
| 412 |
-
text-transform:uppercase;margin-bottom:10px;">π§ ΧΧΧΧΧ ΧΧΧΧ₯</div>
|
| 413 |
-
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px;">{chips}</div>
|
| 414 |
-
{item_rows}
|
| 415 |
-
<div style="font-size:10px;color:#94a3b8;margin-top:10px;border-top:1px dashed #e2e8f0;
|
| 416 |
-
padding-top:8px;">gemma-2-2b + LoRA Β· ΧΧΧΧ Χ¨Χ§ <code>parse</code></div>
|
| 417 |
-
</div>
|
| 418 |
-
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px;">
|
| 419 |
-
<div style="font-size:11px;font-weight:700;color:#7c3aed;letter-spacing:.05em;
|
| 420 |
-
text-transform:uppercase;margin-bottom:10px;">βοΈ ΧΧΧ ΧΧ’ ΧΧΧ©Χ</div>
|
| 421 |
-
{vat_line}
|
| 422 |
-
<div style="font-size:12px;color:#334155;padding:3px 0;">Χ‘Χ"Χ βͺ{completed.get("total",0):,.2f}</div>
|
| 423 |
-
<div style="font-size:12px;color:#334155;padding:3px 0;">ΧΧ‘Χ³ ΧΧ‘ΧΧ {completed.get("serial_number","")}</div>
|
| 424 |
-
{alloc_line}
|
| 425 |
-
<div style="font-size:10px;color:#94a3b8;margin-top:10px;border-top:1px dashed #e2e8f0;
|
| 426 |
-
padding-top:8px;">ΧΧΧ¨ΧΧΧ ΧΧ‘ΧΧ Β· ΧΧ€Χ‘ ΧΧΧΧΧͺ ΧΧΧ©ΧΧΧ</div>
|
| 427 |
-
</div>
|
| 428 |
-
</div>"""
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
def decide_fiscal(text, issuer_status, parse, explicit_doc_choice,
|
| 432 |
-
clarify_vat_basis=None, clarify_client_tax_id=None):
|
| 433 |
-
"""Returns (issuer_dict, final_parse, questions_dict)."""
|
| 434 |
-
today = _today()
|
| 435 |
-
|
| 436 |
-
# ββ 1. Issuer required first β no model run needed if missing ββββββββββββ
|
| 437 |
-
if not issuer_status:
|
| 438 |
-
return None, None, {"issuer_status": "ΧΧΧ¨ Χ‘ΧΧΧΧ‘ ΧΧ Χ€ΧΧ§ ΧΧΧ’ΧΧ (Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨ / Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ)."}
|
| 439 |
-
|
| 440 |
-
questions = {}
|
| 441 |
-
exempt = (issuer_status == "Χ€ΧΧΧ¨")
|
| 442 |
-
issuer = {**DEMO_ISSUER_BASE,
|
| 443 |
-
"status": "exempt_dealer" if exempt else "authorized_dealer",
|
| 444 |
-
"is_company": False}
|
| 445 |
-
|
| 446 |
-
# ββ 2. Mandatory content ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 447 |
-
if not parse.get("client_name", "").strip():
|
| 448 |
-
questions["client_name"] = "Χ©Χ ΧΧΧ§ΧΧ / ΧΧΧ©ΧΧ?"
|
| 449 |
-
if not parse.get("items"):
|
| 450 |
-
questions["items"] = "ΧΧ ΧΧ©ΧΧ¨ΧΧͺ/ΧΧΧΧ¦Χ¨ ΧΧΧΧΧΧ Χ‘ΧΧΧ? (ΧΧΧ©Χ: ΧΧΧ’ΧΧ₯ Χ’Χ‘Χ§Χ βͺ500)"
|
| 451 |
-
|
| 452 |
-
# ββ 3. Date βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 453 |
-
parse = dict(parse)
|
| 454 |
-
parse.setdefault("date", _detect_date_from_text(text) or today.isoformat())
|
| 455 |
-
|
| 456 |
-
# ββ 4. Doc type + VAT basis βββββββββββββββββββββββββββββββββββοΏ½οΏ½βββββββββββ
|
| 457 |
-
is_gross = _text_has(text, _GROSS_PATTERNS) or clarify_vat_basis == "ΧΧΧΧ ΧΧ’Χ΄Χ"
|
| 458 |
-
is_net = _text_has(text, _NET_PATTERNS) or clarify_vat_basis == "ΧΧ€Χ Χ ΧΧ’Χ΄Χ"
|
| 459 |
-
has_vat_cue = is_gross or is_net
|
| 460 |
-
|
| 461 |
-
if exempt:
|
| 462 |
-
doc_type, amount_basis = "receipt", "net"
|
| 463 |
-
|
| 464 |
-
elif explicit_doc_choice and explicit_doc_choice != "ΧΧΧΧΧ ΧΧΧΧΧΧΧ":
|
| 465 |
-
doc_type = {"Χ§ΧΧΧ": "receipt", "ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘": "tax_invoice",
|
| 466 |
-
"ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘ ΧΧ§ΧΧΧ": "tax_invoice_receipt"}.get(explicit_doc_choice, "receipt")
|
| 467 |
-
if doc_type != "receipt":
|
| 468 |
-
if is_gross:
|
| 469 |
-
amount_basis = "gross"
|
| 470 |
-
elif is_net:
|
| 471 |
-
amount_basis = "net"
|
| 472 |
-
elif clarify_vat_basis in (None, "ΧΧ Χ¦ΧΧΧ"):
|
| 473 |
-
total = sum(i.get("unit_price",0)*i.get("quantity",1) for i in parse.get("items",[]))
|
| 474 |
-
questions["vat_basis"] = f"ΧΧ‘ΧΧΧ (βͺ{total:,.0f}) β ΧΧΧΧ ΧΧ’Χ΄Χ ΧΧ ΧΧ€Χ Χ ΧΧ’Χ΄Χ?"
|
| 475 |
-
amount_basis = "net"
|
| 476 |
-
else:
|
| 477 |
-
amount_basis = "net"
|
| 478 |
-
else:
|
| 479 |
-
amount_basis = "net"
|
| 480 |
-
else:
|
| 481 |
-
# Auto: VAT cue β tax_invoice_receipt; no cue β receipt (no VAT)
|
| 482 |
-
if has_vat_cue:
|
| 483 |
-
doc_type, amount_basis = "tax_invoice_receipt", "gross" if is_gross else "net"
|
| 484 |
-
else:
|
| 485 |
-
doc_type, amount_basis = "receipt", "net"
|
| 486 |
-
|
| 487 |
-
# ββ 5. Allocation: Χ.Χ€. only for ΧΧ’"Χ/ΧΧΧ¨Χ clients + large amounts βββββββ
|
| 488 |
-
if doc_type in ("tax_invoice", "tax_invoice_receipt") and not exempt:
|
| 489 |
-
subtotal = sum(it.get("unit_price",0)*it.get("quantity",1) for it in parse.get("items",[]))
|
| 490 |
-
threshold = core.allocation_threshold_for_date(_dt.date.fromisoformat(parse["date"]))
|
| 491 |
-
name = parse.get("client_name", "")
|
| 492 |
-
is_corp = any(kw in name for kw in ['ΧΧ’"Χ', "ΧΧ’Χ", "ΧΧΧ¨Χ", "ΧΧ’'Χ", "ΧΧ’.Χ"])
|
| 493 |
-
if is_corp and subtotal >= threshold:
|
| 494 |
-
if clarify_client_tax_id and clarify_client_tax_id.strip():
|
| 495 |
-
parse["client_tax_id"] = clarify_client_tax_id.strip()
|
| 496 |
-
elif not parse.get("client_tax_id"):
|
| 497 |
-
questions["client_tax_id"] = f"Χ.Χ€. ΧΧΧ§ΧΧ (Χ ΧΧ¨Χ© ΧΧΧ§Χ¦ΧΧ β Χ’Χ‘Χ§Χ β₯ βͺ{threshold:,})"
|
| 498 |
-
|
| 499 |
-
parse.update({"doc_type": doc_type, "amount_basis": amount_basis})
|
| 500 |
-
parse.setdefault("payment_method", "bank_transfer")
|
| 501 |
-
parse.setdefault("currency", "ILS")
|
| 502 |
-
parse.setdefault("client_tax_id", None)
|
| 503 |
-
parse.setdefault("client_is_business", False)
|
| 504 |
-
parse.setdefault("client_name", "β")
|
| 505 |
-
parse.setdefault("items", [])
|
| 506 |
-
|
| 507 |
-
return issuer, parse, questions
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
def _build_clarification_message(questions: dict, note_text: str) -> str:
|
| 511 |
-
"""Build ONE unified clarification message listing all missing fields."""
|
| 512 |
-
lines = []
|
| 513 |
-
for key, q in questions.items():
|
| 514 |
-
if key == "issuer_status":
|
| 515 |
-
lines.append(f"π€ {q}")
|
| 516 |
-
elif key == "client_name":
|
| 517 |
-
lines.append(f"π€ **Χ©Χ ΧΧ§ΧΧ:** {q}")
|
| 518 |
-
elif key == "items":
|
| 519 |
-
lines.append(f"π¦ **Χ€Χ¨ΧΧΧΧ:** {q}")
|
| 520 |
-
elif key == "vat_basis":
|
| 521 |
-
lines.append(f"π° **ΧΧ‘ΧΧ‘ ΧΧ’Χ΄Χ:** {q}")
|
| 522 |
-
elif key == "client_tax_id":
|
| 523 |
-
lines.append(f"π **ΧΧ§Χ¦ΧΧ:** {q}")
|
| 524 |
-
else:
|
| 525 |
-
lines.append(f"β’ {q}")
|
| 526 |
-
|
| 527 |
-
fields_text = "\n".join(lines)
|
| 528 |
-
return (f"β **ΧΧΧ ΧΧΧ€ΧΧ§ ΧΧͺ ΧΧΧ‘ΧΧ, ΧΧ‘Χ¨ ΧΧΧΧ’:**\n\n"
|
| 529 |
-
f"{fields_text}\n\n"
|
| 530 |
-
f"ΧΧΧ‘Χ£ ΧΧͺ ΧΧ€Χ¨ΧΧΧ ΧΧ€ΧͺΧ§ ΧΧΧΧ₯ **ΧΧ€Χ§ ΧΧ‘ΧΧ** Χ©ΧΧ.")
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
def generate(note, issuer_radio, doc_choice,
|
| 534 |
-
clarify_vat, clarify_tax_id):
|
| 535 |
-
"""Single-shot, stateless pipeline."""
|
| 536 |
note = (note or "").strip()
|
| 537 |
if not note:
|
| 538 |
-
return
|
| 539 |
-
"", "", "", "", False)
|
| 540 |
|
| 541 |
issuer_status = issuer_radio if issuer_radio in ("Χ€ΧΧΧ¨", "ΧΧΧ¨Χ©Χ") else None
|
| 542 |
|
| 543 |
-
#
|
| 544 |
if not issuer_status:
|
| 545 |
-
return
|
| 546 |
-
"", "", "", "", False)
|
| 547 |
|
| 548 |
_lazy_init()
|
| 549 |
parse = model_parse(note)
|
| 550 |
|
| 551 |
if parse is None or _missing_fields(parse) == ["parse_failed"]:
|
| 552 |
-
return ("π€ ΧΧ ΧΧ¦ΧΧΧͺΧ ΧΧΧΧ₯ Χ€Χ¨ΧΧΧ. Χ Χ‘Χ ΧΧΧΧ© β ΧΧΧΧΧΧ: "
|
| 553 |
-
"
|
| 554 |
-
"", "", "", "", False)
|
| 555 |
|
| 556 |
-
cvat = clarify_vat
|
| 557 |
ctax = clarify_tax_id.strip() if clarify_tax_id and clarify_tax_id.strip() else None
|
| 558 |
|
| 559 |
issuer, final, questions = decide_fiscal(
|
| 560 |
note, issuer_status, parse, doc_choice, cvat, ctax)
|
| 561 |
|
| 562 |
if questions:
|
| 563 |
-
|
| 564 |
-
|
|
|
|
|
|
|
| 565 |
|
| 566 |
try:
|
| 567 |
completed = core.complete(issuer, final, _STATE["rng"])
|
| 568 |
except Exception as e:
|
| 569 |
-
return
|
| 570 |
|
| 571 |
show_vat = (final["doc_type"] != "receipt")
|
| 572 |
|
| 573 |
return ("β
ΧΧΧ‘ΧΧ ΧΧΧ€Χ§ ΧΧΧ¦ΧΧΧ.",
|
| 574 |
-
"",
|
| 575 |
render_parse_panel(final, completed, show_vat),
|
| 576 |
render_document(completed, show_vat),
|
| 577 |
render_recommendations(recommend(note)),
|
| 578 |
-
False)
|
|
|
|
| 579 |
|
| 580 |
-
#
|
| 581 |
-
# 7.
|
| 582 |
-
#
|
| 583 |
CSS = """
|
| 584 |
.gradio-container { max-width: 880px !important; margin: 0 auto !important; }
|
| 585 |
footer { display: none !important; }
|
|
@@ -590,13 +564,12 @@ footer { display: none !important; }
|
|
| 590 |
color: #0f172a !important;
|
| 591 |
}
|
| 592 |
.qs-btn:hover { background: #e2e8f0 !important; }
|
| 593 |
-
.btn-send { font-weight: 600 !important; }
|
| 594 |
"""
|
| 595 |
|
| 596 |
QUICK_STARTERS = [
|
| 597 |
-
("πΌ Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ + ΧΧ§ΧΧ Χ’Χ‘Χ§Χ", 'ΧΧΧ¨Χͺ ΧΧ’ΧΧ ΧΧ’"Χ Χ©ΧΧΧΧ 36,500
|
| 598 |
("π§Ύ Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨ β Χ©ΧΧ’ΧΧ¨ Χ€Χ¨ΧΧ", "Χ§ΧΧΧΧͺΧ 220 Χ©Χ§Χ ΧΧΧΧΧ¨ Χ’Χ Χ©ΧΧ’ΧΧ¨ Χ©ΧΧΧΧ Χ€Χ¨ΧΧ, ΧΧΧΧΧ"),
|
| 599 |
-
("π¦ ΧΧΧ Χ€Χ¨ΧΧΧΧ", 'Χ ΧΧ’Χ ΧΧΧ Χ©ΧΧΧΧ 2Γ350 Χ©"Χ ΧΧΧ’ΧΧ₯ Χ’Χ‘Χ§Χ + 180 Χ©"Χ ΧΧΧ Χͺ ΧΧ¦ΧΧͺ'),
|
| 600 |
]
|
| 601 |
|
| 602 |
with gr.Blocks(title="Text2Receipt", theme=gr.themes.Soft(
|
|
@@ -611,17 +584,15 @@ with gr.Blocks(title="Text2Receipt", theme=gr.themes.Soft(
|
|
| 611 |
</div>
|
| 612 |
</div>""")
|
| 613 |
|
| 614 |
-
# ββ Who am I (required, no default) βββββββββββββββββββββββββββββββββββββ
|
| 615 |
issuer_radio = gr.Radio(
|
| 616 |
["Χ€ΧΧΧ¨", "ΧΧΧ¨Χ©Χ"], value=None,
|
| 617 |
label="ΧΧ ΧΧ Χ (Χ‘ΧΧΧΧ‘ ΧΧ Χ€ΧΧ§)",
|
| 618 |
-
info=
|
| 619 |
)
|
| 620 |
|
| 621 |
note_input = gr.Textbox(
|
| 622 |
-
label="Χ€ΧͺΧ§ / ΧͺΧΧΧ ΧΧΧΧ Χ‘Χ", lines=2,
|
| 623 |
placeholder='ΧΧΧΧΧΧ: Χ§ΧΧΧΧͺΧ 1,200 Χ©"Χ ΧΧΧ©Χ ΧΧΧ Χ’Χ ΧΧΧ’ΧΧ₯ Χ’Χ‘Χ§Χ',
|
| 624 |
-
rtl=True, autofocus=True,
|
| 625 |
)
|
| 626 |
|
| 627 |
doc_choice = gr.Radio(
|
|
@@ -636,18 +607,20 @@ with gr.Blocks(title="Text2Receipt", theme=gr.themes.Soft(
|
|
| 636 |
|
| 637 |
status_output = gr.Markdown("")
|
| 638 |
|
| 639 |
-
#
|
| 640 |
-
with gr.Group(visible=False) as
|
| 641 |
gr.HTML('<div dir="rtl" style="font-size:12px;font-weight:600;color:#7c3aed;'
|
| 642 |
-
'margin-bottom:
|
| 643 |
clarify_vat = gr.Radio(
|
| 644 |
["ΧΧΧΧ ΧΧ’Χ΄Χ", "ΧΧ€Χ Χ ΧΧ’Χ΄Χ", "ΧΧ Χ¦ΧΧΧ"],
|
| 645 |
value="ΧΧ Χ¦ΧΧΧ", label="ΧΧ‘ΧΧΧ ΧΧΧ¦ΧΧΧ ΧΧ€ΧͺΧ§",
|
| 646 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
clarify_tax_id = gr.Textbox(
|
| 648 |
-
label="Χ.Χ€. / Χͺ.Χ. ΧΧ§ΧΧ
|
| 649 |
-
placeholder="ΧΧΧ©Χ: 514123458",
|
| 650 |
-
value="",
|
| 651 |
)
|
| 652 |
|
| 653 |
gr.HTML('<div dir="rtl" style="font-size:11px;color:#94a3b8;margin:10px 0 6px;'
|
|
@@ -661,153 +634,34 @@ with gr.Blocks(title="Text2Receipt", theme=gr.themes.Soft(
|
|
| 661 |
doc_output = gr.HTML()
|
| 662 |
recs_output = gr.HTML()
|
| 663 |
|
| 664 |
-
#
|
| 665 |
def _on_generate(note, issuer_r, doc_c, cvat, ctax):
|
| 666 |
-
status,
|
| 667 |
note, issuer_r, doc_c, cvat, ctax)
|
| 668 |
return (status, phtml, dhtml, rhtml,
|
| 669 |
-
gr.update(visible=
|
|
|
|
| 670 |
|
| 671 |
submit_btn.click(
|
| 672 |
fn=_on_generate,
|
| 673 |
inputs=[note_input, issuer_radio, doc_choice, clarify_vat, clarify_tax_id],
|
| 674 |
-
outputs=[status_output, parse_output, doc_output, recs_output,
|
|
|
|
| 675 |
)
|
| 676 |
|
| 677 |
def _on_clear():
|
| 678 |
return ("", None, "ΧΧΧΧΧ ΧΧΧΧΧΧΧ", "ΧΧ Χ¦ΧΧΧ", "", "", "", "", "",
|
| 679 |
-
gr.update(visible=False))
|
| 680 |
|
| 681 |
clear_btn.click(
|
| 682 |
fn=_on_clear,
|
| 683 |
outputs=[note_input, issuer_radio, doc_choice, clarify_vat, clarify_tax_id,
|
| 684 |
-
status_output, parse_output, doc_output, recs_output,
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 688 |
-
# 8. Discord bot β runs as a background thread inside the Space (+5% bonus)
|
| 689 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 690 |
-
# The bot shares the SAME model loaded by _lazy_init(), so no extra memory.
|
| 691 |
-
# Token is read from the DISCORD_TOKEN secret (Space β Settings β Secrets).
|
| 692 |
-
# If the secret is absent, the bot is silently skipped and the UI still works.
|
| 693 |
-
import threading, asyncio
|
| 694 |
-
|
| 695 |
-
def _format_doc_for_discord(completed: dict) -> str:
|
| 696 |
-
"""Render a completed fiscal document as Discord-friendly monospace text."""
|
| 697 |
-
c = completed
|
| 698 |
-
issuer = c.get("issuer", {})
|
| 699 |
-
client = c.get("client", {})
|
| 700 |
-
lines = c.get("lines", [])
|
| 701 |
-
vat_pct = int(round(c.get("vat_rate", 0) * 100))
|
| 702 |
-
|
| 703 |
-
line_rows = "\n".join(
|
| 704 |
-
f" β’ {ln['description']} Γ {ln['quantity']} β βͺ{ln['line_total']:,.2f}"
|
| 705 |
-
for ln in lines
|
| 706 |
)
|
| 707 |
-
alloc = (f"\nπ ΧΧ‘Χ€Χ¨ ΧΧ§Χ¦ΧΧ: {c.get('allocation_number')}"
|
| 708 |
-
if c.get("allocation_required") else "")
|
| 709 |
-
|
| 710 |
-
return (
|
| 711 |
-
f"```\n"
|
| 712 |
-
f"{'β'*40}\n"
|
| 713 |
-
f" {c.get('doc_type_he','ΧΧ‘ΧΧ')} | ΧΧ‘Χ³ {c.get('serial_number','')}\n"
|
| 714 |
-
f" {c.get('issue_date','')}\n"
|
| 715 |
-
f"{'β'*40}\n"
|
| 716 |
-
f" ΧΧ Χ€ΧΧ§ : {issuer.get('name','')} (Χ.Χ€. {issuer.get('tax_id','')})\n"
|
| 717 |
-
f" ΧΧ§ΧΧ : {client.get('name','')}"
|
| 718 |
-
f"{' [Χ’Χ‘Χ§]' if client.get('is_business') else ''}\n"
|
| 719 |
-
f"{'β'*40}\n"
|
| 720 |
-
f"{line_rows}\n"
|
| 721 |
-
f"{'β'*40}\n"
|
| 722 |
-
f" Χ‘ΧΧΧ Χ ΧΧ : βͺ{c.get('subtotal',0):>12,.2f}\n"
|
| 723 |
-
f" ΧΧ’\"Χ {vat_pct:2d}% : βͺ{c.get('vat_amount',0):>12,.2f}\n"
|
| 724 |
-
f" Χ‘Χ\"Χ : βͺ{c.get('total',0):>12,.2f}\n"
|
| 725 |
-
f" ΧͺΧ©ΧΧΧ : {core.PAYMENT_HE.get(c.get('payment_method',''),'β')}\n"
|
| 726 |
-
f"{'β'*40}\n"
|
| 727 |
-
f"```"
|
| 728 |
-
f"{alloc}"
|
| 729 |
-
)
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
def _discord_pipeline(raw_text: str) -> str:
|
| 733 |
-
"""Full pipeline for Discord: raw Hebrew note β formatted document string.
|
| 734 |
-
Uses safe defaults (no agentic loop in chat β Discord is single-shot)."""
|
| 735 |
-
_lazy_init()
|
| 736 |
-
parse = model_parse(raw_text)
|
| 737 |
-
if parse is None:
|
| 738 |
-
return "β ΧΧ ΧΧ¦ΧΧΧͺΧ ΧΧΧΧ₯ ΧΧͺ ΧΧ€Χ¨ΧΧΧ. Χ Χ‘Χ ΧΧ Χ‘Χ ΧΧΧΧ©."
|
| 739 |
-
final = apply_answers(parse, {}) # apply_answers fills safe defaults
|
| 740 |
-
try:
|
| 741 |
-
completed = core.complete(
|
| 742 |
-
{**DEMO_ISSUER_BASE, "status": "authorized_dealer", "is_company": False},
|
| 743 |
-
final, _STATE["rng"])
|
| 744 |
-
except Exception as e:
|
| 745 |
-
return f"β Χ©ΧΧΧΧ ΧΧ’ΧΧΧΧ: {e}"
|
| 746 |
-
return _format_doc_for_discord(completed)
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
def _start_discord_bot():
|
| 750 |
-
"""Launch the Discord bot in a background thread if DISCORD_TOKEN is set."""
|
| 751 |
-
token = os.environ.get("DISCORD_TOKEN", "").strip()
|
| 752 |
-
if not token:
|
| 753 |
-
print("βΉ DISCORD_TOKEN not set β Discord bot skipped (UI still runs).")
|
| 754 |
-
return
|
| 755 |
-
|
| 756 |
-
try:
|
| 757 |
-
import discord
|
| 758 |
-
from discord.ext import commands
|
| 759 |
-
except ImportError:
|
| 760 |
-
print("β discord.py not installed β add 'discord.py' to requirements.txt")
|
| 761 |
-
return
|
| 762 |
-
|
| 763 |
-
intents = discord.Intents.default()
|
| 764 |
-
intents.message_content = True
|
| 765 |
-
bot = commands.Bot(command_prefix="!", intents=intents)
|
| 766 |
-
|
| 767 |
-
@bot.event
|
| 768 |
-
async def on_ready():
|
| 769 |
-
print(f"β
Discord bot online: {bot.user}")
|
| 770 |
-
|
| 771 |
-
@bot.command(name="receipt")
|
| 772 |
-
async def receipt_cmd(ctx, *, note: str):
|
| 773 |
-
await ctx.message.add_reaction("β³")
|
| 774 |
-
try:
|
| 775 |
-
loop = asyncio.get_event_loop()
|
| 776 |
-
result = await loop.run_in_executor(None, _discord_pipeline, note)
|
| 777 |
-
await ctx.reply(result)
|
| 778 |
-
except Exception as e:
|
| 779 |
-
await ctx.reply(f"β Χ©ΧΧΧΧ: {e}")
|
| 780 |
-
finally:
|
| 781 |
-
try:
|
| 782 |
-
await ctx.message.remove_reaction("β³", bot.user)
|
| 783 |
-
await ctx.message.add_reaction("β
")
|
| 784 |
-
except Exception:
|
| 785 |
-
pass
|
| 786 |
-
|
| 787 |
-
@bot.command(name="help_t2r")
|
| 788 |
-
async def help_cmd(ctx):
|
| 789 |
-
await ctx.reply(
|
| 790 |
-
"**Text2Receipt Bot** π§Ύ\n"
|
| 791 |
-
"ΧΧΧ¨ ΧΧ’Χ¨Χͺ ΧΧΧ Χ‘Χ Χ’ΧΧ¨ΧΧͺ ΧΧΧ‘ΧΧ Χ€ΧΧ‘Χ§ΧΧ ΧΧ©Χ¨ΧΧΧ.\n\n"
|
| 792 |
-
"Χ©ΧΧΧΧ©: `!receipt <ΧΧ’Χ¨Χ ΧΧ’ΧΧ¨ΧΧͺ>`\n"
|
| 793 |
-
"ΧΧΧΧΧ: `!receipt Χ§ΧΧΧΧͺΧ 500 Χ©Χ§Χ ΧΧΧ©Χ Χ’Χ ΧΧΧ’ΧΧ₯`"
|
| 794 |
-
)
|
| 795 |
-
|
| 796 |
-
def _run():
|
| 797 |
-
try:
|
| 798 |
-
asyncio.run(bot.start(token))
|
| 799 |
-
except Exception as e:
|
| 800 |
-
print(f"βΉ Discord bot could not connect "
|
| 801 |
-
f"(expected on HF Spaces β outbound to discord.com is blocked): "
|
| 802 |
-
f"{type(e).__name__}. UI is unaffected.", flush=True)
|
| 803 |
-
|
| 804 |
-
threading.Thread(target=_run, daemon=True).start()
|
| 805 |
-
print("π€ Discord bot thread started.")
|
| 806 |
-
|
| 807 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 808 |
-
# 9. Launch
|
| 809 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 810 |
-
_start_discord_bot()
|
| 811 |
|
|
|
|
|
|
|
|
|
|
| 812 |
if __name__ == "__main__":
|
| 813 |
demo.launch(css=CSS)
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
"""
|
| 3 |
+
Text2Receipt β HF Space (final clean version)
|
| 4 |
+
================================================
|
| 5 |
Pipeline:
|
| 6 |
+
free-text Hebrew income note
|
| 7 |
+
β gemma-2-2b fine-tuned parser (extracts client / items)
|
| 8 |
+
β fiscal decision layer (VAT cues, doc-type, allocation)
|
| 9 |
+
β t2r_core.complete() (deterministic arithmetic)
|
| 10 |
+
β Gradio UI (render document + FAISS recommendations)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
+
|
| 13 |
+
import json, random, re as _re
|
|
|
|
|
|
|
|
|
|
| 14 |
import numpy as np
|
| 15 |
import gradio as gr
|
| 16 |
+
import t2r_core as core
|
| 17 |
+
import datetime as _dt
|
| 18 |
|
| 19 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
# 1. Lazy model + FAISS state
|
| 21 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
BASE_MODEL = "unsloth/gemma-2-2b-it"
|
| 23 |
+
MODEL_REPO = "yonilev/Text2Receipt-parser"
|
| 24 |
|
| 25 |
+
_STATE = dict(ready=False, tok=None, model=None, enc=None,
|
| 26 |
+
index=None, store=None, e5_family=False,
|
| 27 |
+
rng=random.Random(42), device="cpu")
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
def _lazy_init():
|
| 31 |
if _STATE["ready"]:
|
|
|
|
| 92 |
print(f"β
FAISS ready β {emb.shape[0]} vectors")
|
| 93 |
|
| 94 |
|
| 95 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
# 2. Parser + post-processing cleaner
|
| 97 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
INSTRUCTION = (
|
| 99 |
"ΧΧͺΧ ΧΧΧΧ¨ ΧΧ’Χ¨Χͺ ΧΧΧ Χ‘Χ ΧΧΧ€Χ©ΧΧͺ ΧΧ’ΧΧ¨ΧΧͺ ΧΧΧΧ Χ JSON. "
|
| 100 |
"ΧΧΧ₯ ΧΧ ΧΧ¨Χ§ ΧΧͺ ΧΧ Χ©ΧΧͺΧΧ ΧΧΧ’Χ¨Χ: Χ©Χ ΧΧΧ§ΧΧ (client_name), "
|
|
|
|
| 103 |
"ΧΧΧΧ¨ JSON ΧͺΧ§ΧΧ ΧΧΧΧ, ΧΧΧ ΧΧ§Χ‘Χ Χ ΧΧ‘Χ£."
|
| 104 |
)
|
| 105 |
|
| 106 |
+
_VAT_NOISE = [
|
| 107 |
+
"ΧΧ€Χ Χ ΧΧ’\"Χ", "ΧΧΧΧ ΧΧ’\"Χ", "ΧΧ€Χ Χ ΧΧ’Χ΄Χ", "ΧΧΧΧ ΧΧ’Χ΄Χ",
|
| 108 |
+
"ΧΧͺΧΧ‘Χ€Χͺ ΧΧ’\"Χ", "+ ΧΧ’\"Χ", "+ΧΧ’\"Χ", "ΧΧ’\"Χ", "ΧΧ’Χ΄Χ", "ΧΧ’Χ",
|
| 109 |
+
]
|
| 110 |
+
_NOISE_SET = {"ΧΧ€Χ Χ", "ΧΧΧΧ", "Χ ΧΧ", "ΧΧ¨ΧΧΧ", "ΧΧ‘", "+", "Χ€ΧΧΧ‘",
|
| 111 |
+
"ΧΧ’\"Χ", "ΧΧ’Χ΄Χ", "ΧΧ’Χ"}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
def _build_prompt(raw_text):
|
| 115 |
return f"{INSTRUCTION}\n\nΧΧ’Χ¨Χ: {raw_text}\n\nJSON:"
|
| 116 |
|
| 117 |
+
|
| 118 |
def _extract_json(text):
|
| 119 |
s = text.find("{")
|
| 120 |
if s < 0: return None
|
|
|
|
| 128 |
except: return None
|
| 129 |
return None
|
| 130 |
|
| 131 |
+
|
| 132 |
+
def _clean_name(name):
|
| 133 |
+
"""Strip VAT-related noise the model may accidentally grab as a client name."""
|
| 134 |
+
name = (name or "").strip()
|
| 135 |
+
for tok in _VAT_NOISE:
|
| 136 |
+
name = name.replace(tok, "").strip(" ,.-+")
|
| 137 |
+
return "" if (name in _NOISE_SET or len(name) <= 1) else name
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _clean_parse(parse):
|
| 141 |
+
"""Post-process raw model output: remove noise, validate structure."""
|
| 142 |
+
if not isinstance(parse, dict):
|
| 143 |
+
return parse
|
| 144 |
+
# Clean client name
|
| 145 |
+
parse["client_name"] = _clean_name(parse.get("client_name", ""))
|
| 146 |
+
# Clean items
|
| 147 |
+
clean_items = []
|
| 148 |
+
for it in (parse.get("items") or []):
|
| 149 |
+
desc = (it.get("description") or "").strip()
|
| 150 |
+
for tok in _VAT_NOISE:
|
| 151 |
+
desc = desc.replace(tok, "").strip(" ,.-+")
|
| 152 |
+
if desc in _NOISE_SET or len(desc) <= 0:
|
| 153 |
+
desc = "Χ©ΧΧ¨ΧΧͺ"
|
| 154 |
+
it = dict(it)
|
| 155 |
+
it["description"] = desc
|
| 156 |
+
if it.get("unit_price"):
|
| 157 |
+
clean_items.append(it)
|
| 158 |
+
parse["items"] = clean_items
|
| 159 |
+
return parse
|
| 160 |
+
|
| 161 |
+
|
| 162 |
def model_parse(raw_text):
|
| 163 |
tok, model = _STATE["tok"], _STATE["model"]
|
| 164 |
msgs = [{"role": "user", "content": _build_prompt(raw_text)}]
|
|
|
|
| 169 |
out = model.generate(**enc, max_new_tokens=128, do_sample=False,
|
| 170 |
pad_token_id=tok.pad_token_id)
|
| 171 |
decoded = tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 172 |
+
raw = _extract_json(decoded)
|
| 173 |
+
return _clean_parse(raw) if raw else None
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
def _missing_fields(parse):
|
| 177 |
if parse is None: return ["parse_failed"]
|
|
|
|
| 180 |
if not parse.get("items"): m.append("items")
|
| 181 |
return m
|
| 182 |
|
| 183 |
+
|
| 184 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 185 |
+
# 3. FAISS recommender
|
| 186 |
+
# ββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
def recommend(query_text, k=3):
|
| 188 |
pref = "query: " if _STATE["e5_family"] else ""
|
| 189 |
q_emb = _STATE["enc"].encode([pref + query_text],
|
|
|
|
| 198 |
return out
|
| 199 |
|
| 200 |
|
| 201 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 202 |
+
# 4. Fiscal decision layer (app-level, never touches frozen t2r_core.py)
|
| 203 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 204 |
+
DEMO_ISSUER_BASE = {"name": "ΧΧΧ Χ©Χ§ΧΧΧ", "tax_id": "962569844"}
|
| 205 |
+
|
| 206 |
+
_GROSS_PATTERNS = [r"ΧΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧΧΧ\s*ΧΧ‘", r"ΧΧ¨ΧΧ‘", r"ΧΧ¨ΧΧΧ", r"ΧΧΧΧ¨\s*ΧΧΧΧ"]
|
| 207 |
+
_NET_PATTERNS = [r"ΧΧ€Χ Χ\s*ΧΧ’[\"Χ΄]?Χ", r"\+\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧͺΧΧ‘Χ€Χͺ\s*ΧΧ’[\"Χ΄]?Χ",
|
| 208 |
+
r"Χ€ΧΧΧ‘\s*ΧΧ’[\"Χ΄]?Χ", r"Χ ΧΧ", r"ΧΧ\s*ΧΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ",
|
| 209 |
+
r"ΧΧΧ\s*ΧΧ’[\"Χ΄]?Χ", r"ΧΧ€Χ Χ\s*ΧΧ‘"]
|
| 210 |
+
|
| 211 |
+
def _text_has(text, patterns):
|
| 212 |
+
t = text.lower()
|
| 213 |
+
return any(_re.search(p, t) for p in patterns)
|
| 214 |
+
|
| 215 |
+
def _detect_date(text):
|
| 216 |
+
for pat, order in [(r"(\d{1,2})[/\-\.](\d{1,2})[/\-\.](\d{2,4})", "dmy"),
|
| 217 |
+
(r"(\d{4})[/\-\.](\d{1,2})[/\-\.](\d{1,2})", "ymd")]:
|
| 218 |
+
m = _re.search(pat, text)
|
| 219 |
+
if m:
|
| 220 |
+
try:
|
| 221 |
+
g = [int(x) for x in m.groups()]
|
| 222 |
+
d, mo, y = (g[0], g[1], g[2]) if order == "dmy" else (g[2], g[1], g[0])
|
| 223 |
+
if y < 100: y += 2000
|
| 224 |
+
return f"{y:04d}-{mo:02d}-{d:02d}"
|
| 225 |
+
except Exception:
|
| 226 |
+
pass
|
| 227 |
+
return None
|
| 228 |
+
|
| 229 |
+
def _today_str():
|
| 230 |
+
return _dt.date.today().isoformat()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def decide_fiscal(note, issuer_status, parse, doc_radio,
|
| 234 |
+
clarify_vat=None, clarify_tax_id=None):
|
| 235 |
+
"""
|
| 236 |
+
Returns (issuer_dict, final_parse, questions_dict).
|
| 237 |
+
questions_dict empty β ready to generate.
|
| 238 |
+
"""
|
| 239 |
+
# ββ 1. Issuer required β early return if missing ββββββββββββββββββββββ
|
| 240 |
+
if not issuer_status:
|
| 241 |
+
return None, None, {"issuer_status": "ΧΧΧ¨ Χ‘ΧΧΧΧ‘ ΧΧ Χ€ΧΧ§ ΧΧΧ’ΧΧ (Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨ / Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ)."}
|
| 242 |
+
|
| 243 |
+
questions = {}
|
| 244 |
+
exempt = (issuer_status == "Χ€ΧΧΧ¨")
|
| 245 |
+
issuer = {**DEMO_ISSUER_BASE,
|
| 246 |
+
"status": "exempt_dealer" if exempt else "authorized_dealer",
|
| 247 |
+
"is_company": False}
|
| 248 |
+
|
| 249 |
+
# ββ 2. Mandatory content ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 250 |
+
if not parse.get("client_name", "").strip():
|
| 251 |
+
questions["client_name"] = "Χ©Χ ΧΧΧ§ΧΧ / ΧΧΧ©ΧΧ?"
|
| 252 |
+
if not parse.get("items"):
|
| 253 |
+
questions["items"] = "ΧΧ ΧΧ©ΧΧ¨ΧΧͺ/ΧΧΧΧ¦Χ¨ ΧΧΧΧΧΧ Χ‘ΧΧΧ? (ΧΧΧ©Χ: ΧΧΧ’ΧΧ₯ βͺ500)"
|
| 254 |
+
|
| 255 |
+
# ββ 3. Date: detect from note β today ββββββββββββββββββββββββββββββββ
|
| 256 |
+
parse = dict(parse)
|
| 257 |
+
parse["date"] = _detect_date(note) or _today_str()
|
| 258 |
+
|
| 259 |
+
# ββ 4. Doc type + VAT basis βββββββββββββββββββββββββββββββββββββββββββ
|
| 260 |
+
is_gross = _text_has(note, _GROSS_PATTERNS) or clarify_vat == "ΧΧΧΧ ΧΧ’Χ΄Χ"
|
| 261 |
+
is_net = _text_has(note, _NET_PATTERNS) or clarify_vat == "ΧΧ€Χ Χ ΧΧ’Χ΄Χ"
|
| 262 |
+
has_vat_cue = is_gross or is_net
|
| 263 |
+
|
| 264 |
+
if exempt:
|
| 265 |
+
doc_type, amount_basis = "receipt", "net"
|
| 266 |
+
|
| 267 |
+
elif doc_radio and doc_radio != "ΧΧΧΧΧ ΧΧΧΧΧΧΧ":
|
| 268 |
+
doc_type = {"Χ§ΧΧΧ": "receipt",
|
| 269 |
+
"ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘": "tax_invoice",
|
| 270 |
+
"ΧΧ©ΧΧΧ ΧΧͺ ΧΧ‘ ΧΧ§ΧΧΧ": "tax_invoice_receipt"}.get(doc_radio, "receipt")
|
| 271 |
+
if doc_type != "receipt":
|
| 272 |
+
if is_gross:
|
| 273 |
+
amount_basis = "gross"
|
| 274 |
+
elif is_net:
|
| 275 |
+
amount_basis = "net"
|
| 276 |
+
elif clarify_vat in (None, "ΧΧ Χ¦ΧΧΧ"):
|
| 277 |
+
total = sum(i.get("unit_price", 0) * i.get("quantity", 1)
|
| 278 |
+
for i in parse.get("items", []))
|
| 279 |
+
questions["vat_basis"] = f"ΧΧ‘ΧΧΧ (βͺ{total:,.0f}) β ΧΧΧΧ ΧΧ’Χ΄Χ ΧΧ ΧΧ€Χ Χ ΧΧ’Χ΄Χ?"
|
| 280 |
+
amount_basis = "net"
|
| 281 |
+
else:
|
| 282 |
+
amount_basis = "net"
|
| 283 |
+
else:
|
| 284 |
+
amount_basis = "net"
|
| 285 |
+
|
| 286 |
+
else:
|
| 287 |
+
# Auto: VAT cue in note β tax_invoice_receipt; otherwise β receipt (no VAT line)
|
| 288 |
+
if has_vat_cue:
|
| 289 |
+
doc_type, amount_basis = "tax_invoice_receipt", "gross" if is_gross else "net"
|
| 290 |
+
else:
|
| 291 |
+
doc_type, amount_basis = "receipt", "net"
|
| 292 |
+
|
| 293 |
+
# ββ 5. Allocation: only ΧΧ’"Χ / ΧΧΧ¨Χ + amount β₯ threshold βββββββββββββ
|
| 294 |
+
if doc_type in ("tax_invoice", "tax_invoice_receipt") and not exempt:
|
| 295 |
+
subtotal = sum(it.get("unit_price", 0) * it.get("quantity", 1)
|
| 296 |
+
for it in parse.get("items", []))
|
| 297 |
+
threshold = core.allocation_threshold_for_date(
|
| 298 |
+
_dt.date.fromisoformat(parse["date"]))
|
| 299 |
+
name = parse.get("client_name", "")
|
| 300 |
+
is_corp = any(kw in name for kw in ['ΧΧ’"Χ', "ΧΧ’Χ", "ΧΧΧ¨Χ", "ΧΧ’'Χ", "ΧΧ’.Χ"])
|
| 301 |
+
if is_corp and subtotal >= threshold:
|
| 302 |
+
if clarify_tax_id and clarify_tax_id.strip():
|
| 303 |
+
parse["client_tax_id"] = clarify_tax_id.strip()
|
| 304 |
+
elif not parse.get("client_tax_id"):
|
| 305 |
+
questions["client_tax_id"] = f"Χ.Χ€. ΧΧΧ§ΧΧ (Χ ΧΧ¨Χ© ΧΧΧ§Χ¦ΧΧ β Χ’Χ‘Χ§Χ β₯ βͺ{threshold:,})"
|
| 306 |
+
|
| 307 |
+
# ββ 6. Finalise parse βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 308 |
+
parse.update({"doc_type": doc_type, "amount_basis": amount_basis})
|
| 309 |
+
parse.setdefault("payment_method", "bank_transfer")
|
| 310 |
+
parse.setdefault("currency", "ILS")
|
| 311 |
+
parse.setdefault("client_tax_id", None)
|
| 312 |
+
parse.setdefault("client_is_business", False)
|
| 313 |
+
parse.setdefault("client_name", "β")
|
| 314 |
+
parse.setdefault("items", [])
|
| 315 |
+
|
| 316 |
+
return issuer, parse, questions
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _build_clarification_message(questions):
|
| 320 |
+
icons = {"issuer_status": "π€", "client_name": "π€",
|
| 321 |
+
"items": "π¦", "vat_basis": "π°", "client_tax_id": "π"}
|
| 322 |
+
lines = "\n".join(f"{icons.get(k,'β’')} {v}" for k, v in questions.items())
|
| 323 |
+
return f"β **ΧΧΧ ΧΧΧ€ΧΧ§ ΧΧͺ ΧΧΧ‘ΧΧ, ΧΧ‘Χ¨ ΧΧΧΧ’:**\n\n{lines}\n\nΧΧΧ‘Χ£ ΧΧͺ ΧΧ€Χ¨ΧΧΧ ΧΧ€ΧͺΧ§ ΧΧΧΧ₯ **ΧΧ€Χ§ ΧΧ‘ΧΧ** Χ©ΧΧ."
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 327 |
# 5. HTML renderers
|
| 328 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 329 |
+
def render_parse_panel(parse, completed, show_vat):
|
| 330 |
+
items = parse.get("items", [])
|
| 331 |
+
item_rows = "".join(
|
| 332 |
+
f'<div style="display:flex;justify-content:space-between;font-size:12px;'
|
| 333 |
+
f'color:#334155;padding:3px 0;"><span>{it.get("description","")}</span>'
|
| 334 |
+
f'<span style="color:#64748b;">{it.get("quantity",1)} Γ βͺ{it.get("unit_price",0):,.0f}</span></div>'
|
| 335 |
+
for it in items)
|
| 336 |
+
|
| 337 |
+
chips = (
|
| 338 |
+
f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;'
|
| 339 |
+
f'border-radius:999px;padding:2px 9px;font-size:11px;">ΧΧ§ΧΧ: {parse.get("client_name","β")}</span> '
|
| 340 |
+
f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;'
|
| 341 |
+
f'border-radius:999px;padding:2px 9px;font-size:11px;">'
|
| 342 |
+
f'{"Χ’Χ‘Χ§" if parse.get("client_is_business") else "Χ€Χ¨ΧΧ"}</span> '
|
| 343 |
+
f'<span style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;'
|
| 344 |
+
f'border-radius:999px;padding:2px 9px;font-size:11px;">{len(items)} Χ€Χ¨ΧΧΧΧ</span>')
|
| 345 |
+
|
| 346 |
+
alloc_line = (
|
| 347 |
+
'<div style="font-size:12px;color:#334155;padding:3px 0;">'
|
| 348 |
+
'ΧΧ‘Χ€Χ¨ ΧΧ§Χ¦ΧΧ <span style="color:#0d9488;">β Χ ΧΧ¨Χ©</span></div>'
|
| 349 |
+
if completed.get("allocation_required") else "")
|
| 350 |
+
|
| 351 |
+
vat_line = (
|
| 352 |
+
f'<div style="font-size:12px;color:#334155;padding:3px 0;">'
|
| 353 |
+
f'ΧΧ’"Χ {int(round(completed.get("vat_rate",0)*100))}% Β· βͺ{completed.get("vat_amount",0):,.2f}</div>'
|
| 354 |
+
if show_vat else
|
| 355 |
+
'<div style="font-size:12px;color:#94a3b8;padding:3px 0;">ΧΧΧ Χ€ΧΧ¨ΧΧ§ ΧΧ’"Χ (Χ§ΧΧΧ)</div>')
|
| 356 |
+
|
| 357 |
+
return f"""
|
| 358 |
+
<div dir="rtl" style="font-family:'Segoe UI',Arial,sans-serif;max-width:660px;
|
| 359 |
+
margin:14px auto 0;display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
| 360 |
+
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px;">
|
| 361 |
+
<div style="font-size:11px;font-weight:700;color:#0d9488;letter-spacing:.05em;
|
| 362 |
+
text-transform:uppercase;margin-bottom:10px;">π§ ΧΧΧΧΧ ΧΧΧΧ₯</div>
|
| 363 |
+
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px;">{chips}</div>
|
| 364 |
+
{item_rows}
|
| 365 |
+
<div style="font-size:10px;color:#94a3b8;margin-top:10px;border-top:1px dashed #e2e8f0;
|
| 366 |
+
padding-top:8px;">gemma-2-2b + LoRA Β· ΧΧΧΧ Χ¨Χ§ <code>parse</code></div>
|
| 367 |
+
</div>
|
| 368 |
+
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px;">
|
| 369 |
+
<div style="font-size:11px;font-weight:700;color:#7c3aed;letter-spacing:.05em;
|
| 370 |
+
text-transform:uppercase;margin-bottom:10px;">βοΈ ΧΧΧ ΧΧ’ ΧΧΧ©Χ</div>
|
| 371 |
+
{vat_line}
|
| 372 |
+
<div style="font-size:12px;color:#334155;padding:3px 0;">Χ‘Χ"Χ βͺ{completed.get("total",0):,.2f}</div>
|
| 373 |
+
<div style="font-size:12px;color:#334155;padding:3px 0;">ΧΧ‘Χ³ ΧΧ‘ΧΧ {completed.get("serial_number","")}</div>
|
| 374 |
+
{alloc_line}
|
| 375 |
+
<div style="font-size:10px;color:#94a3b8;margin-top:10px;border-top:1px dashed #e2e8f0;
|
| 376 |
+
padding-top:8px;">ΧΧΧ¨ΧΧΧ ΧΧ‘ΧΧ Β· ΧΧ€Χ‘ ΧΧΧΧΧͺ ΧΧΧ©ΧΧΧ</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>"""
|
| 379 |
+
|
| 380 |
+
|
| 381 |
def render_document(c, show_vat=True):
|
| 382 |
issuer = c.get("issuer", {})
|
| 383 |
client = c.get("client", {})
|
|
|
|
| 398 |
f'</tr>'
|
| 399 |
for ln in lines)
|
| 400 |
|
| 401 |
+
vat_block = "" if not show_vat else f"""
|
| 402 |
+
<div style="display:flex;justify-content:space-between;font-size:13px;color:#64748b;margin-bottom:8px;">
|
| 403 |
+
<span>Χ‘ΧΧΧ ΧΧ€Χ Χ ΧΧ’"Χ</span>
|
| 404 |
+
<span style="color:#94a3b8;">βͺ{c.get("subtotal",0):,.2f}</span>
|
| 405 |
+
</div>
|
| 406 |
+
<div style="display:flex;justify-content:space-between;font-size:13px;color:#64748b;
|
| 407 |
+
padding-bottom:12px;border-bottom:1px solid #334155;margin-bottom:12px;">
|
| 408 |
+
<span>ΧΧ’"Χ {vat_pct}%</span>
|
| 409 |
+
<span style="color:#94a3b8;">βͺ{c.get("vat_amount",0):,.2f}</span>
|
| 410 |
+
</div>"""
|
| 411 |
+
|
| 412 |
return f"""
|
| 413 |
<div dir="rtl" style="font-family:'Segoe UI','Arial Hebrew',Arial,sans-serif;
|
| 414 |
background:#0f172a;color:#e2e8f0;border-radius:16px;
|
|
|
|
| 465 |
</table>
|
| 466 |
|
| 467 |
<div style="background:#1e293b;border-radius:10px;padding:16px 18px;">
|
| 468 |
+
{vat_block}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
<div style="display:flex;justify-content:space-between;align-items:baseline;">
|
| 470 |
<span style="font-size:15px;font-weight:600;color:#e2e8f0;">Χ‘Χ"Χ ΧΧͺΧ©ΧΧΧ</span>
|
| 471 |
<span style="font-size:24px;font-weight:700;color:#2dd4bf;">βͺ{c.get("total",0):,.2f}</span>
|
|
|
|
| 500 |
</div>"""
|
| 501 |
|
| 502 |
|
| 503 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 504 |
+
# 6. Main pipeline
|
| 505 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 506 |
+
def generate(note, issuer_radio, doc_choice, clarify_vat, clarify_tax_id):
|
| 507 |
+
"""
|
| 508 |
+
Stateless single handler.
|
| 509 |
+
Returns: (status_md, parse_html, doc_html, recs_html, vat_group_visible, tax_group_visible)
|
| 510 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
note = (note or "").strip()
|
| 512 |
if not note:
|
| 513 |
+
return "βοΈ ΧΧͺΧΧ Χ€ΧͺΧ§ ΧΧΧ Χ‘Χ ΧΧΧΧ₯ ΧΧ€Χ§ ΧΧ‘ΧΧ.", "", "", "", False, False
|
|
|
|
| 514 |
|
| 515 |
issuer_status = issuer_radio if issuer_radio in ("Χ€ΧΧΧ¨", "ΧΧΧ¨Χ©Χ") else None
|
| 516 |
|
| 517 |
+
# Issuer check BEFORE model (saves ~90s on CPU)
|
| 518 |
if not issuer_status:
|
| 519 |
+
return "π€ ΧΧΧ¨ ΧͺΧΧΧΧ: **Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨** ΧΧ **Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ** ΧΧΧ’ΧΧ.", "", "", "", False, False
|
|
|
|
| 520 |
|
| 521 |
_lazy_init()
|
| 522 |
parse = model_parse(note)
|
| 523 |
|
| 524 |
if parse is None or _missing_fields(parse) == ["parse_failed"]:
|
| 525 |
+
return ("π€ ΧΧ ΧΧ¦ΧΧΧͺΧ ΧΧΧΧ₯ Χ€Χ¨ΧΧΧ. Χ Χ‘Χ ΧΧΧΧ© β ΧΧΧΧΧΧ: 'Χ§ΧΧΧΧͺΧ 500βͺ ΧΧΧ©Χ Χ’Χ ΧΧΧ’ΧΧ₯'.",
|
| 526 |
+
"", "", "", False, False)
|
|
|
|
| 527 |
|
| 528 |
+
cvat = clarify_vat if clarify_vat not in (None, "ΧΧ Χ¦ΧΧΧ") else None
|
| 529 |
ctax = clarify_tax_id.strip() if clarify_tax_id and clarify_tax_id.strip() else None
|
| 530 |
|
| 531 |
issuer, final, questions = decide_fiscal(
|
| 532 |
note, issuer_status, parse, doc_choice, cvat, ctax)
|
| 533 |
|
| 534 |
if questions:
|
| 535 |
+
show_vat_q = "vat_basis" in questions
|
| 536 |
+
show_taxid_q = "client_tax_id" in questions
|
| 537 |
+
return (_build_clarification_message(questions),
|
| 538 |
+
"", "", "", show_vat_q, show_taxid_q)
|
| 539 |
|
| 540 |
try:
|
| 541 |
completed = core.complete(issuer, final, _STATE["rng"])
|
| 542 |
except Exception as e:
|
| 543 |
+
return f"β οΈ Χ©ΧΧΧΧ ΧΧ’ΧΧΧΧ: {e}", "", "", "", False, False
|
| 544 |
|
| 545 |
show_vat = (final["doc_type"] != "receipt")
|
| 546 |
|
| 547 |
return ("β
ΧΧΧ‘ΧΧ ΧΧΧ€Χ§ ΧΧΧ¦ΧΧΧ.",
|
|
|
|
| 548 |
render_parse_panel(final, completed, show_vat),
|
| 549 |
render_document(completed, show_vat),
|
| 550 |
render_recommendations(recommend(note)),
|
| 551 |
+
False, False)
|
| 552 |
+
|
| 553 |
|
| 554 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 555 |
+
# 7. Gradio UI
|
| 556 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 557 |
CSS = """
|
| 558 |
.gradio-container { max-width: 880px !important; margin: 0 auto !important; }
|
| 559 |
footer { display: none !important; }
|
|
|
|
| 564 |
color: #0f172a !important;
|
| 565 |
}
|
| 566 |
.qs-btn:hover { background: #e2e8f0 !important; }
|
|
|
|
| 567 |
"""
|
| 568 |
|
| 569 |
QUICK_STARTERS = [
|
| 570 |
+
("πΌ Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ + ΧΧ§ΧΧ Χ’Χ‘Χ§Χ", 'ΧΧΧ¨Χͺ ΧΧ’ΧΧ ΧΧ’"Χ Χ©ΧΧΧΧ 36,500 ΧΧΧΧ ΧΧ’"Χ Χ’Χ Χ€Χ¨ΧΧΧ§Χ ΧΧͺΧ¨. ΧΧ’ΧΧ¨Χ ΧΧ Χ§ΧΧΧͺ.'),
|
| 571 |
("π§Ύ Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨ β Χ©ΧΧ’ΧΧ¨ Χ€Χ¨ΧΧ", "Χ§ΧΧΧΧͺΧ 220 Χ©Χ§Χ ΧΧΧΧΧ¨ Χ’Χ Χ©ΧΧ’ΧΧ¨ Χ©ΧΧΧΧ Χ€Χ¨ΧΧ, ΧΧΧΧΧ"),
|
| 572 |
+
("π¦ ΧΧΧ Χ€Χ¨ΧΧΧΧ", 'Χ ΧΧ’Χ ΧΧΧ Χ©ΧΧΧΧ 2Γ350 Χ©"Χ ΧΧΧ’ΧΧ₯ Χ’Χ‘Χ§Χ + 180 Χ©"Χ ΧΧΧ Χͺ ΧΧ¦ΧΧͺ, ΧΧ€Χ Χ ΧΧ’"Χ'),
|
| 573 |
]
|
| 574 |
|
| 575 |
with gr.Blocks(title="Text2Receipt", theme=gr.themes.Soft(
|
|
|
|
| 584 |
</div>
|
| 585 |
</div>""")
|
| 586 |
|
|
|
|
| 587 |
issuer_radio = gr.Radio(
|
| 588 |
["Χ€ΧΧΧ¨", "ΧΧΧ¨Χ©Χ"], value=None,
|
| 589 |
label="ΧΧ ΧΧ Χ (Χ‘ΧΧΧΧ‘ ΧΧ Χ€ΧΧ§)",
|
| 590 |
+
info='Χ’ΧΧ‘Χ§ Χ€ΧΧΧ¨ = ΧΧΧ ΧΧ’"Χ Β· Χ’ΧΧ‘Χ§ ΧΧΧ¨Χ©Χ = Χ’Χ ΧΧ’"Χ',
|
| 591 |
)
|
| 592 |
|
| 593 |
note_input = gr.Textbox(
|
| 594 |
+
label="Χ€ΧͺΧ§ / ΧͺΧΧΧ ΧΧΧΧ Χ‘Χ", lines=2, rtl=True, autofocus=True,
|
| 595 |
placeholder='ΧΧΧΧΧΧ: Χ§ΧΧΧΧͺΧ 1,200 Χ©"Χ ΧΧΧ©Χ ΧΧΧ Χ’Χ ΧΧΧ’ΧΧ₯ Χ’Χ‘Χ§Χ',
|
|
|
|
| 596 |
)
|
| 597 |
|
| 598 |
doc_choice = gr.Radio(
|
|
|
|
| 607 |
|
| 608 |
status_output = gr.Markdown("")
|
| 609 |
|
| 610 |
+
# Clarification fields β appear only when needed
|
| 611 |
+
with gr.Group(visible=False) as clarify_vat_group:
|
| 612 |
gr.HTML('<div dir="rtl" style="font-size:12px;font-weight:600;color:#7c3aed;'
|
| 613 |
+
'margin-bottom:6px;">π° ΧΧ‘ΧΧ‘ ΧΧ’Χ΄Χ Χ ΧΧ¨Χ©</div>')
|
| 614 |
clarify_vat = gr.Radio(
|
| 615 |
["ΧΧΧΧ ΧΧ’Χ΄Χ", "ΧΧ€Χ Χ ΧΧ’Χ΄Χ", "ΧΧ Χ¦ΧΧΧ"],
|
| 616 |
value="ΧΧ Χ¦ΧΧΧ", label="ΧΧ‘ΧΧΧ ΧΧΧ¦ΧΧΧ ΧΧ€ΧͺΧ§",
|
| 617 |
)
|
| 618 |
+
|
| 619 |
+
with gr.Group(visible=False) as clarify_tax_group:
|
| 620 |
+
gr.HTML('<div dir="rtl" style="font-size:12px;font-weight:600;color:#7c3aed;'
|
| 621 |
+
'margin-bottom:6px;">π Χ.Χ€. ΧΧ§ΧΧ Χ ΧΧ¨Χ© (ΧΧ§Χ¦ΧΧ)</div>')
|
| 622 |
clarify_tax_id = gr.Textbox(
|
| 623 |
+
label="Χ.Χ€. / Χͺ.Χ. ΧΧ§ΧΧ", placeholder="ΧΧΧ©Χ: 514123458", value="",
|
|
|
|
|
|
|
| 624 |
)
|
| 625 |
|
| 626 |
gr.HTML('<div dir="rtl" style="font-size:11px;color:#94a3b8;margin:10px 0 6px;'
|
|
|
|
| 634 |
doc_output = gr.HTML()
|
| 635 |
recs_output = gr.HTML()
|
| 636 |
|
| 637 |
+
# Handlers
|
| 638 |
def _on_generate(note, issuer_r, doc_c, cvat, ctax):
|
| 639 |
+
status, phtml, dhtml, rhtml, show_vat_q, show_taxid_q = generate(
|
| 640 |
note, issuer_r, doc_c, cvat, ctax)
|
| 641 |
return (status, phtml, dhtml, rhtml,
|
| 642 |
+
gr.update(visible=show_vat_q),
|
| 643 |
+
gr.update(visible=show_taxid_q))
|
| 644 |
|
| 645 |
submit_btn.click(
|
| 646 |
fn=_on_generate,
|
| 647 |
inputs=[note_input, issuer_radio, doc_choice, clarify_vat, clarify_tax_id],
|
| 648 |
+
outputs=[status_output, parse_output, doc_output, recs_output,
|
| 649 |
+
clarify_vat_group, clarify_tax_group],
|
| 650 |
)
|
| 651 |
|
| 652 |
def _on_clear():
|
| 653 |
return ("", None, "ΧΧΧΧΧ ΧΧΧΧΧΧΧ", "ΧΧ Χ¦ΧΧΧ", "", "", "", "", "",
|
| 654 |
+
gr.update(visible=False), gr.update(visible=False))
|
| 655 |
|
| 656 |
clear_btn.click(
|
| 657 |
fn=_on_clear,
|
| 658 |
outputs=[note_input, issuer_radio, doc_choice, clarify_vat, clarify_tax_id,
|
| 659 |
+
status_output, parse_output, doc_output, recs_output,
|
| 660 |
+
clarify_vat_group, clarify_tax_group],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
|
| 663 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 664 |
+
# 8. Launch
|
| 665 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 666 |
if __name__ == "__main__":
|
| 667 |
demo.launch(css=CSS)
|