#!/usr/bin/env python3 """ Extrage date din bon fiscal / factură / extras de cont via Ollama vision LLM. Usage: python tools/ocr_bon.py [--model minicpm-v] [--host http://10.0.20.161:11434] Modele recomandate (trage cu: ollama pull ): minicpm-v ~5GB, rapid, excelent pentru documente llava:7b ~4GB, clasic, bun pe bonuri llama3.2-vision ~8GB, cel mai precis """ import sys import json import base64 import argparse import tempfile from pathlib import Path import httpx import fitz # pymupdf OLLAMA_HOST = "http://10.0.20.161:11434" DEFAULT_MODEL = "minicpm-v" PROMPT = """Ești un sistem OCR specializat pe documente financiare românești. Extrage TOATE datele vizibile și returnează EXCLUSIV un JSON valid, fără text în afara JSON-ului. Schema JSON: { "document_type": "bon_fiscal|factura|extras_cont|necunoscut", "vendor": { "name": "...", "cui": "...", "address": "..." }, "client": { "name": "...", "cui": "..." }, "document": { "numar": "...", "serie": "...", "data": "YYYY-MM-DD", "ora": "HH:MM:SS" }, "items": [ { "descriere": "...", "cantitate": 0.0, "unitate": "buc|l|kg|...", "pret_unitar": 0.0, "valoare": 0.0 } ], "tva": [ { "cota_litera": "A|B|C", "procent": 21.0, "baza": 0.0, "valoare_tva": 0.0 } ], "total_fara_tva": 0.0, "total_tva": 0.0, "total": 0.0, "plata": { "metoda": "card|numerar|transfer", "tip_card": "...", "pan_masked": "...", "suma": 0.0 }, "note": "orice info suplimentar relevant" } Omite câmpurile care nu există în document. Returnează DOAR JSON.""" def pdf_to_images_b64(pdf_path: Path, dpi: int = 150) -> list[str]: """Convertește fiecare pagină PDF la PNG base64.""" doc = fitz.open(str(pdf_path)) images = [] mat = fitz.Matrix(dpi / 72, dpi / 72) for page in doc: pix = page.get_pixmap(matrix=mat) images.append(base64.b64encode(pix.tobytes("png")).decode()) doc.close() return images def image_to_b64(img_path: Path) -> str: """Citește imagine și returnează base64.""" return base64.b64encode(img_path.read_bytes()).decode() def ask_ollama(model: str, images_b64: list[str], host: str) -> str: """Trimite imaginile la Ollama și returnează răspunsul.""" payload = { "model": model, "prompt": PROMPT, "images": images_b64, "stream": False, "options": { "temperature": 0.1, "num_predict": 2048, } } with httpx.Client(timeout=600) as client: r = client.post(f"{host}/api/generate", json=payload) r.raise_for_status() return r.json()["response"] def extract_json(text: str) -> dict: """Extrage JSON din răspuns (poate fi înconjurat de markdown).""" text = text.strip() # Strip markdown code blocks if "```" in text: start = text.find("{", text.find("```")) end = text.rfind("}") + 1 text = text[start:end] return json.loads(text) def process(file_path: Path, model: str, host: str) -> dict: suffix = file_path.suffix.lower() if suffix == ".pdf": print(f" Conversie PDF → imagini...", file=sys.stderr) images = pdf_to_images_b64(file_path) print(f" {len(images)} pagini extrase", file=sys.stderr) elif suffix in (".jpg", ".jpeg", ".png", ".webp"): images = [image_to_b64(file_path)] else: # Încearcă să detecteze tipul din conținut header = file_path.read_bytes()[:8] if header[:4] == b'%PDF': images = pdf_to_images_b64(file_path) else: images = [image_to_b64(file_path)] print(f" Trimit la Ollama ({model})...", file=sys.stderr) raw = ask_ollama(model, images, host) try: return extract_json(raw) except json.JSONDecodeError: print(f" Răspuns brut (nu e JSON valid):\n{raw}", file=sys.stderr) return {"error": "json_parse_failed", "raw": raw} def main(): parser = argparse.ArgumentParser(description="OCR bon fiscal via Ollama vision") parser.add_argument("file", help="PDF sau imagine (jpg/png)") parser.add_argument("--model", default=DEFAULT_MODEL, help=f"Model Ollama (default: {DEFAULT_MODEL})") parser.add_argument("--host", default=OLLAMA_HOST, help=f"Ollama host (default: {OLLAMA_HOST})") parser.add_argument("--pretty", action="store_true", help="JSON indentat") args = parser.parse_args() file_path = Path(args.file) if not file_path.exists(): print(f"Fișierul nu există: {file_path}", file=sys.stderr) sys.exit(1) print(f"Procesez: {file_path.name}", file=sys.stderr) result = process(file_path, args.model, args.host) indent = 2 if args.pretty else None print(json.dumps(result, ensure_ascii=False, indent=indent)) if __name__ == "__main__": main()