# -*- 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'
parse| פריט | כמות | מחיר | סה"כ |
|---|