| """ | |
| OmniParse AI — kompletní B2B SaaS pro zpracování faktur. | |
| Architektura: CORE LOGIC (framework-agnostic, dá se volat i z FastAPI/HTML frontendu) | |
| + GRADIO UI LAYER (jen volá CORE funkce, žádná business logika v UI kódu). | |
| Když budeš chtít přejít na vlastní HTML/JS frontend, stačí obalit CORE funkce | |
| do FastAPI endpointů (viz sekce "CORE LOGIC" níže) — nic se v nich měnit nemusí. | |
| """ | |
| import os | |
| import re | |
| import io | |
| import json | |
| import time | |
| import base64 | |
| import sqlite3 | |
| import secrets | |
| import hashlib | |
| import traceback | |
| from datetime import datetime, timedelta, timezone | |
| import gradio as gr | |
| import requests | |
| from PIL import Image | |
| # --------------------------------------------------------------------------- | |
| # VOLITELNÉ KNIHOVNY — nikdy nesmí spadnout celá appka, když chybí balíček | |
| # --------------------------------------------------------------------------- | |
| try: | |
| import bcrypt | |
| BCRYPT_OK = True | |
| except Exception: | |
| BCRYPT_OK = False | |
| try: | |
| import pytesseract | |
| TESSERACT_OK = True | |
| except Exception: | |
| TESSERACT_OK = False | |
| try: | |
| from pdf2image import convert_from_bytes | |
| PDF2IMAGE_OK = True | |
| except Exception: | |
| PDF2IMAGE_OK = False | |
| try: | |
| from groq import Groq | |
| GROQ_SDK_OK = True | |
| except Exception: | |
| GROQ_SDK_OK = False | |
| try: | |
| import stripe | |
| STRIPE_SDK_OK = True | |
| except Exception: | |
| STRIPE_SDK_OK = False | |
| try: | |
| from supabase import create_client | |
| SUPABASE_SDK_OK = True | |
| except Exception: | |
| SUPABASE_SDK_OK = False | |
| # =========================================================================== | |
| # KONFIGURACE | |
| # =========================================================================== | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") | |
| SUPABASE_URL = os.environ.get("SUPABASE_URL", "") | |
| SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "") | |
| GOOGLE_VISION_KEY = os.environ.get("GOOGLE_VISION_KEY", "") | |
| STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "") | |
| STRIPE_PRICE_BASIC = os.environ.get("STRIPE_PRICE_BASIC", "") | |
| STRIPE_PRICE_PRO = os.environ.get("STRIPE_PRICE_PRO", "") | |
| STRIPE_PRICE_ENTERPRISE = os.environ.get("STRIPE_PRICE_ENTERPRISE", "") | |
| APP_URL = os.environ.get("APP_URL", "http://localhost:7860") | |
| MAX_FILE_SIZE_MB = 20 | |
| MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 | |
| PLAN_LIMITS = { | |
| "free": 20, | |
| "basic": 200, | |
| "pro": 2000, | |
| "enterprise": float("inf"), | |
| } | |
| PLAN_PRICE_IDS = { | |
| "basic": STRIPE_PRICE_BASIC, | |
| "pro": STRIPE_PRICE_PRO, | |
| "enterprise": STRIPE_PRICE_ENTERPRISE, | |
| } | |
| if STRIPE_SDK_OK and STRIPE_SECRET_KEY: | |
| try: | |
| stripe.api_key = STRIPE_SECRET_KEY | |
| except Exception: | |
| pass | |
| # jednoduchý in-memory rate limiter: {key: [timestamps]} | |
| _RATE_LIMIT_STORE = {} | |
| def rate_limited(key: str, max_attempts: int = 5, window_seconds: int = 60) -> bool: | |
| """Vrátí True pokud je klíč (email/IP) aktuálně rate-limitovaný. | |
| Ochrana proti brute-force na login/signup/API endpointy.""" | |
| try: | |
| now = time.time() | |
| attempts = _RATE_LIMIT_STORE.get(key, []) | |
| attempts = [t for t in attempts if now - t < window_seconds] | |
| if len(attempts) >= max_attempts: | |
| _RATE_LIMIT_STORE[key] = attempts | |
| return True | |
| attempts.append(now) | |
| _RATE_LIMIT_STORE[key] = attempts | |
| return False | |
| except Exception: | |
| return False # radši nechat projít než appku spadnout | |
| # =========================================================================== | |
| # CORE LOGIC — DATABÁZOVÁ VRSTVA (Supabase primárně, SQLite fallback) | |
| # =========================================================================== | |
| class SQLiteDB: | |
| """Fallback databáze, pokud chybí Supabase secrets. Data jsou ephemeral | |
| (zmizí při restartu HF Space), ale appka díky tomu nikdy nespadne.""" | |
| def __init__(self, path="omniparse.db"): | |
| self.path = path | |
| self.conn = sqlite3.connect(self.path, check_same_thread=False) | |
| self._init_schema() | |
| def _init_schema(self): | |
| c = self.conn.cursor() | |
| c.execute("""CREATE TABLE IF NOT EXISTS users ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| email TEXT UNIQUE NOT NULL, | |
| name TEXT NOT NULL, | |
| password TEXT NOT NULL, | |
| plan TEXT NOT NULL DEFAULT 'free', | |
| stripe_cid TEXT, | |
| api_key TEXT, | |
| created_at TEXT | |
| )""") | |
| c.execute("""CREATE TABLE IF NOT EXISTS sessions ( | |
| token TEXT PRIMARY KEY, | |
| user_id INTEGER NOT NULL, | |
| expires_at TEXT NOT NULL | |
| )""") | |
| c.execute("""CREATE TABLE IF NOT EXISTS invoices ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| user_id INTEGER NOT NULL, | |
| filename TEXT, | |
| vendor TEXT, | |
| inv_number TEXT, | |
| inv_date TEXT, | |
| due_date TEXT, | |
| amount REAL, | |
| vat_amount REAL, | |
| total REAL, | |
| currency TEXT DEFAULT 'USD', | |
| status TEXT DEFAULT 'done', | |
| is_duplicate INTEGER DEFAULT 0, | |
| confidence REAL, | |
| raw_json TEXT, | |
| created_at TEXT | |
| )""") | |
| self.conn.commit() | |
| def create_user(self, email, name, password_hash, plan="free", api_key=None): | |
| c = self.conn.cursor() | |
| c.execute( | |
| "INSERT INTO users (email, name, password, plan, api_key, created_at) VALUES (?,?,?,?,?,?)", | |
| (email, name, password_hash, plan, api_key, datetime.now(timezone.utc).isoformat()), | |
| ) | |
| self.conn.commit() | |
| return c.lastrowid | |
| def get_user_by_email(self, email): | |
| c = self.conn.cursor() | |
| c.execute("SELECT id,email,name,password,plan,stripe_cid,api_key FROM users WHERE email=?", (email,)) | |
| row = c.fetchone() | |
| if not row: | |
| return None | |
| keys = ["id", "email", "name", "password", "plan", "stripe_cid", "api_key"] | |
| return dict(zip(keys, row)) | |
| def get_user_by_id(self, user_id): | |
| c = self.conn.cursor() | |
| c.execute("SELECT id,email,name,password,plan,stripe_cid,api_key FROM users WHERE id=?", (user_id,)) | |
| row = c.fetchone() | |
| if not row: | |
| return None | |
| keys = ["id", "email", "name", "password", "plan", "stripe_cid", "api_key"] | |
| return dict(zip(keys, row)) | |
| def update_user_plan(self, user_id, plan, stripe_cid=None): | |
| c = self.conn.cursor() | |
| if stripe_cid: | |
| c.execute("UPDATE users SET plan=?, stripe_cid=? WHERE id=?", (plan, stripe_cid, user_id)) | |
| else: | |
| c.execute("UPDATE users SET plan=? WHERE id=?", (plan, user_id)) | |
| self.conn.commit() | |
| def update_password(self, user_id, password_hash): | |
| c = self.conn.cursor() | |
| c.execute("UPDATE users SET password=? WHERE id=?", (password_hash, user_id)) | |
| self.conn.commit() | |
| def delete_user(self, user_id): | |
| c = self.conn.cursor() | |
| c.execute("DELETE FROM users WHERE id=?", (user_id,)) | |
| c.execute("DELETE FROM sessions WHERE user_id=?", (user_id,)) | |
| c.execute("DELETE FROM invoices WHERE user_id=?", (user_id,)) | |
| self.conn.commit() | |
| def create_session(self, token, user_id, expires_at): | |
| c = self.conn.cursor() | |
| c.execute("INSERT INTO sessions (token,user_id,expires_at) VALUES (?,?,?)", (token, user_id, expires_at)) | |
| self.conn.commit() | |
| def get_session(self, token): | |
| c = self.conn.cursor() | |
| c.execute("SELECT token,user_id,expires_at FROM sessions WHERE token=?", (token,)) | |
| row = c.fetchone() | |
| if not row: | |
| return None | |
| return {"token": row[0], "user_id": row[1], "expires_at": row[2]} | |
| def delete_session(self, token): | |
| c = self.conn.cursor() | |
| c.execute("DELETE FROM sessions WHERE token=?", (token,)) | |
| self.conn.commit() | |
| def save_invoice(self, user_id, data): | |
| c = self.conn.cursor() | |
| c.execute("""INSERT INTO invoices | |
| (user_id,filename,vendor,inv_number,inv_date,due_date,amount,vat_amount,total,currency,status,is_duplicate,confidence,raw_json,created_at) | |
| VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", | |
| (user_id, data.get("filename"), data.get("vendor"), data.get("inv_number"), | |
| data.get("inv_date"), data.get("due_date"), data.get("amount"), data.get("vat_amount"), | |
| data.get("total"), data.get("currency", "USD"), data.get("status", "done"), | |
| int(data.get("is_duplicate", False)), data.get("confidence"), | |
| json.dumps(data.get("raw_json", {})), datetime.now(timezone.utc).isoformat())) | |
| self.conn.commit() | |
| return c.lastrowid | |
| def get_invoices(self, user_id): | |
| c = self.conn.cursor() | |
| c.execute("""SELECT id,filename,vendor,inv_number,inv_date,due_date,amount,vat_amount,total, | |
| currency,status,is_duplicate,confidence,raw_json,created_at FROM invoices | |
| WHERE user_id=? ORDER BY created_at DESC""", (user_id,)) | |
| rows = c.fetchall() | |
| keys = ["id", "filename", "vendor", "inv_number", "inv_date", "due_date", "amount", "vat_amount", | |
| "total", "currency", "status", "is_duplicate", "confidence", "raw_json", "created_at"] | |
| return [dict(zip(keys, r)) for r in rows] | |
| def delete_invoice(self, invoice_id, user_id): | |
| c = self.conn.cursor() | |
| c.execute("DELETE FROM invoices WHERE id=? AND user_id=?", (invoice_id, user_id)) | |
| self.conn.commit() | |
| def count_invoices_this_month(self, user_id): | |
| c = self.conn.cursor() | |
| start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat() | |
| c.execute("SELECT COUNT(*) FROM invoices WHERE user_id=? AND created_at>=?", (user_id, start)) | |
| return c.fetchone()[0] | |
| def count_duplicate(self, user_id, vendor, total): | |
| c = self.conn.cursor() | |
| start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat() | |
| c.execute("""SELECT COUNT(*) FROM invoices WHERE user_id=? AND vendor=? AND ABS(total-?)<0.01 | |
| AND created_at>=?""", (user_id, vendor, total, start)) | |
| return c.fetchone()[0] | |
| class SupabaseDB: | |
| """Wrapper nad Supabase se stejným rozhraním jako SQLiteDB, aby zbytek | |
| kódu vůbec nevěděl, která databáze běží pod kapotou.""" | |
| def __init__(self, url, key): | |
| self.client = create_client(url, key) | |
| def create_user(self, email, name, password_hash, plan="free", api_key=None): | |
| res = self.client.table("users").insert({ | |
| "email": email, "name": name, "password": password_hash, | |
| "plan": plan, "api_key": api_key, | |
| }).execute() | |
| return res.data[0]["id"] | |
| def get_user_by_email(self, email): | |
| res = self.client.table("users").select("*").eq("email", email).execute() | |
| return res.data[0] if res.data else None | |
| def get_user_by_id(self, user_id): | |
| res = self.client.table("users").select("*").eq("id", user_id).execute() | |
| return res.data[0] if res.data else None | |
| def update_user_plan(self, user_id, plan, stripe_cid=None): | |
| payload = {"plan": plan} | |
| if stripe_cid: | |
| payload["stripe_cid"] = stripe_cid | |
| self.client.table("users").update(payload).eq("id", user_id).execute() | |
| def update_password(self, user_id, password_hash): | |
| self.client.table("users").update({"password": password_hash}).eq("id", user_id).execute() | |
| def delete_user(self, user_id): | |
| self.client.table("invoices").delete().eq("user_id", user_id).execute() | |
| self.client.table("sessions").delete().eq("user_id", user_id).execute() | |
| self.client.table("users").delete().eq("id", user_id).execute() | |
| def create_session(self, token, user_id, expires_at): | |
| self.client.table("sessions").insert({ | |
| "token": token, "user_id": user_id, "expires_at": expires_at | |
| }).execute() | |
| def get_session(self, token): | |
| res = self.client.table("sessions").select("*").eq("token", token).execute() | |
| return res.data[0] if res.data else None | |
| def delete_session(self, token): | |
| self.client.table("sessions").delete().eq("token", token).execute() | |
| def save_invoice(self, user_id, data): | |
| payload = dict(data) | |
| payload["user_id"] = user_id | |
| payload["raw_json"] = json.dumps(payload.get("raw_json", {})) | |
| res = self.client.table("invoices").insert(payload).execute() | |
| return res.data[0]["id"] | |
| def get_invoices(self, user_id): | |
| res = self.client.table("invoices").select("*").eq("user_id", user_id).order("created_at", desc=True).execute() | |
| return res.data | |
| def delete_invoice(self, invoice_id, user_id): | |
| self.client.table("invoices").delete().eq("id", invoice_id).eq("user_id", user_id).execute() | |
| def count_invoices_this_month(self, user_id): | |
| start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat() | |
| res = self.client.table("invoices").select("id", count="exact").eq("user_id", user_id).gte("created_at", start).execute() | |
| return res.count or 0 | |
| def count_duplicate(self, user_id, vendor, total): | |
| start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat() | |
| res = self.client.table("invoices").select("id", count="exact").eq("user_id", user_id).eq("vendor", vendor).gte("created_at", start).execute() | |
| # Supabase nepodporuje ABS() přes REST snadno -> filtrujeme total v Pythonu | |
| rows = self.client.table("invoices").select("total").eq("user_id", user_id).eq("vendor", vendor).gte("created_at", start).execute() | |
| return sum(1 for r in rows.data if abs((r.get("total") or 0) - total) < 0.01) | |
| def init_db(): | |
| """Vybere Supabase pokud jsou secrets nastavené a SDK je dostupné, | |
| jinak spadne zpátky na SQLite. Appka nikdy nespadne na chybějícím secretu.""" | |
| if SUPABASE_URL and SUPABASE_KEY and SUPABASE_SDK_OK: | |
| try: | |
| db = SupabaseDB(SUPABASE_URL, SUPABASE_KEY) | |
| db.client.table("users").select("id").limit(1).execute() | |
| print("[DB] Připojeno k Supabase.") | |
| return db | |
| except Exception as e: | |
| print(f"[DB] Supabase selhalo ({e}), padám na SQLite fallback.") | |
| return SQLiteDB() | |
| print("[DB] Supabase secrets nenalezeny, používám SQLite fallback (data jsou dočasná).") | |
| return SQLiteDB() | |
| DB = init_db() | |
| # =========================================================================== | |
| # CORE LOGIC — HESLA, AUTH, VALIDACE (server-side, nikdy jen client-side) | |
| # =========================================================================== | |
| def hash_password(password: str) -> str: | |
| """Bcrypt pokud je dostupný (moderní, doporučený), jinak PBKDF2-SHA256 | |
| se solí jako bezpečný fallback (rozhodně ne MD5/SHA1).""" | |
| try: | |
| if BCRYPT_OK: | |
| return "bcrypt$" + bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() | |
| salt = secrets.token_hex(16) | |
| digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex() | |
| return f"pbkdf2${salt}${digest}" | |
| except Exception: | |
| salt = secrets.token_hex(16) | |
| digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex() | |
| return f"pbkdf2${salt}${digest}" | |
| def verify_password(password: str, stored_hash: str) -> bool: | |
| try: | |
| if stored_hash.startswith("bcrypt$") and BCRYPT_OK: | |
| return bcrypt.checkpw(password.encode(), stored_hash[len("bcrypt$"):].encode()) | |
| if stored_hash.startswith("pbkdf2$"): | |
| _, salt, digest = stored_hash.split("$") | |
| check = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex() | |
| return secrets.compare_digest(check, digest) | |
| return False | |
| except Exception: | |
| return False | |
| def is_valid_email(email: str) -> bool: | |
| return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email or "")) | |
| def password_strength_ok(password: str) -> (bool, str): | |
| if not password or len(password) < 8: | |
| return False, "Heslo musí mít alespoň 8 znaků." | |
| if not re.search(r"[A-Za-z]", password) or not re.search(r"[0-9]", password): | |
| return False, "Heslo musí obsahovat písmena i čísla." | |
| return True, "" | |
| def is_password_leaked(password: str) -> bool: | |
| """HaveIBeenPwned Pwned Passwords API — k-anonymity model, zdarma bez klíče. | |
| Když je síť nedostupná, prostě kontrolu přeskočíme (fail-open, appka nespadne).""" | |
| try: | |
| sha1 = hashlib.sha1(password.encode()).hexdigest().upper() | |
| prefix, suffix = sha1[:5], sha1[5:] | |
| resp = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=4) | |
| if resp.status_code == 200: | |
| return any(line.split(":")[0] == suffix for line in resp.text.splitlines()) | |
| return False | |
| except Exception: | |
| return False | |
| def generate_api_key() -> str: | |
| return "op_live_" + secrets.token_urlsafe(24) | |
| def create_session_token(user_id: int) -> str: | |
| token = secrets.token_urlsafe(32) | |
| expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat() | |
| try: | |
| DB.create_session(token, user_id, expires) | |
| except Exception as e: | |
| print(f"[AUTH] Session se nepovedlo uložit: {e}") | |
| return token | |
| def resolve_session(token: str): | |
| """Vrátí user dict pokud je token platný a nevypršel, jinak None. | |
| Token žije v gr.State (server-side per-browser-tab paměť), NE v localStorage, | |
| což řeší XSS riziko klasického 'token v localStorage' problému.""" | |
| if not token: | |
| return None | |
| try: | |
| sess = DB.get_session(token) | |
| if not sess: | |
| return None | |
| expires = datetime.fromisoformat(sess["expires_at"]) | |
| if expires.tzinfo is None: | |
| expires = expires.replace(tzinfo=timezone.utc) | |
| if expires < datetime.now(timezone.utc): | |
| DB.delete_session(token) | |
| return None | |
| return DB.get_user_by_id(sess["user_id"]) | |
| except Exception as e: | |
| print(f"[AUTH] resolve_session chyba: {e}") | |
| return None | |
| def signup(name, email, password, accepted_terms) -> (bool, str, str): | |
| """Vrací (success, message, session_token).""" | |
| try: | |
| email = (email or "").strip().lower() | |
| name = (name or "").strip() | |
| if rate_limited(f"signup:{email}", max_attempts=5, window_seconds=300): | |
| return False, "Příliš mnoho pokusů o registraci. Zkus to za pár minut.", "" | |
| if not name: | |
| return False, "Vyplň prosím jméno.", "" | |
| if not is_valid_email(email): | |
| return False, "Zadej platný pracovní e-mail.", "" | |
| if not accepted_terms: | |
| return False, "Musíš souhlasit s Terms of Use a Privacy Policy.", "" | |
| ok, msg = password_strength_ok(password) | |
| if not ok: | |
| return False, msg, "" | |
| if is_password_leaked(password): | |
| return False, "Toto heslo bylo nalezeno v uniklých databázích. Zvol jiné.", "" | |
| if DB.get_user_by_email(email): | |
| return False, "Účet s tímto e-mailem už existuje.", "" | |
| pw_hash = hash_password(password) | |
| api_key = generate_api_key() | |
| user_id = DB.create_user(email, name, pw_hash, plan="free", api_key=api_key) | |
| token = create_session_token(user_id) | |
| return True, "Účet vytvořen!", token | |
| except Exception as e: | |
| traceback.print_exc() | |
| return False, f"Chyba při registraci: {e}", "" | |
| def login(email, password) -> (bool, str, str): | |
| try: | |
| email = (email or "").strip().lower() | |
| if rate_limited(f"login:{email}", max_attempts=8, window_seconds=300): | |
| return False, "Příliš mnoho pokusů o přihlášení. Zkus to za pár minut.", "" | |
| if not email or not password: | |
| return False, "Vyplň e-mail i heslo.", "" | |
| user = DB.get_user_by_email(email) | |
| if not user or not verify_password(password, user["password"]): | |
| return False, "Nesprávný e-mail nebo heslo.", "" | |
| token = create_session_token(user["id"]) | |
| return True, "Přihlášení úspěšné!", token | |
| except Exception as e: | |
| traceback.print_exc() | |
| return False, f"Chyba při přihlašování: {e}", "" | |
| def logout(token): | |
| try: | |
| if token: | |
| DB.delete_session(token) | |
| except Exception as e: | |
| print(f"[AUTH] logout chyba: {e}") | |
| def bootstrap_demo_account(): | |
| try: | |
| if not DB.get_user_by_email("demo@omniparse.ai"): | |
| pw_hash = hash_password("demo1234") | |
| api_key = generate_api_key() | |
| DB.create_user("demo@omniparse.ai", "Demo User", pw_hash, plan="pro", api_key=api_key) | |
| print("[DEMO] Demo účet vytvořen: demo@omniparse.ai / demo1234") | |
| except Exception as e: | |
| print(f"[DEMO] Nepovedlo se vytvořit demo účet: {e}") | |
| bootstrap_demo_account() | |
| # =========================================================================== | |
| # CORE LOGIC — AI PIPELINE (OCR -> LLM -> regex fallback) | |
| # =========================================================================== | |
| def ocr_google_vision(image_bytes: bytes) -> str: | |
| if not GOOGLE_VISION_KEY: | |
| return "" | |
| try: | |
| b64 = base64.b64encode(image_bytes).decode() | |
| url = f"https://vision.googleapis.com/v1/images:annotate?key={GOOGLE_VISION_KEY}" | |
| payload = {"requests": [{"image": {"content": b64}, "features": [{"type": "TEXT_DETECTION"}]}]} | |
| resp = requests.post(url, json=payload, timeout=15) | |
| data = resp.json() | |
| return data["responses"][0].get("fullTextAnnotation", {}).get("text", "") | |
| except Exception as e: | |
| print(f"[OCR] Google Vision selhalo: {e}") | |
| return "" | |
| def ocr_tesseract(image: Image.Image) -> str: | |
| if not TESSERACT_OK: | |
| return "" | |
| try: | |
| gray = image.convert("L") | |
| return pytesseract.image_to_string(gray, lang="eng+ces") | |
| except Exception: | |
| try: | |
| return pytesseract.image_to_string(image.convert("L"), lang="eng") | |
| except Exception as e: | |
| print(f"[OCR] Tesseract selhalo: {e}") | |
| return "" | |
| def file_to_images(file_bytes: bytes, filename: str): | |
| """Vrátí list PIL Image objektů — z PDF všechny stránky, z obrázku jednu.""" | |
| try: | |
| ext = filename.lower().rsplit(".", 1)[-1] if "." in filename else "" | |
| if ext == "pdf": | |
| if not PDF2IMAGE_OK: | |
| raise RuntimeError("pdf2image / poppler není dostupný na tomto Space.") | |
| return convert_from_bytes(file_bytes, dpi=200) | |
| img = Image.open(io.BytesIO(file_bytes)) | |
| img.load() | |
| return [img] | |
| except Exception as e: | |
| print(f"[FILE] Nepodařilo se otevřít soubor {filename}: {e}") | |
| return [] | |
| def extract_text_from_images(images) -> str: | |
| full_text = "" | |
| for img in images[:5]: # bezpečnostní limit — max 5 stránek na fakturu | |
| buf = io.BytesIO() | |
| img.convert("RGB").save(buf, format="JPEG", quality=85) | |
| img_bytes = buf.getvalue() | |
| text = "" | |
| if GOOGLE_VISION_KEY: | |
| text = ocr_google_vision(img_bytes) | |
| if len(text.strip()) < 30: | |
| text = ocr_tesseract(img) | |
| full_text += text + "\n" | |
| return full_text.strip() | |
| AI_SYSTEM_PROMPT = ( | |
| "You are an invoice data extraction engine. Extract structured data from the raw OCR text " | |
| "of an invoice. Return ONLY valid JSON, no markdown, no explanation, with exactly these keys: " | |
| 'vendor (string), inv_number (string), inv_date (YYYY-MM-DD or empty string), ' | |
| 'due_date (YYYY-MM-DD or empty string), amount (number, subtotal before tax), ' | |
| 'vat_amount (number), total (number), currency (3-letter code like USD/EUR/CZK), ' | |
| 'line_items (array of {description, quantity, unit_price, total}). ' | |
| "If a field cannot be found, use empty string or 0. Never invent data you cannot find." | |
| ) | |
| def ai_extract_groq(ocr_text: str): | |
| if not (GROQ_API_KEY and GROQ_SDK_OK): | |
| return None | |
| try: | |
| client = Groq(api_key=GROQ_API_KEY) | |
| resp = client.chat.completions.create( | |
| model="llama-3.1-8b-instant", | |
| messages=[ | |
| {"role": "system", "content": AI_SYSTEM_PROMPT}, | |
| {"role": "user", "content": ocr_text[:3000]}, | |
| ], | |
| max_tokens=512, | |
| temperature=0.05, | |
| timeout=10, | |
| ) | |
| content = resp.choices[0].message.content | |
| content = re.sub(r"^```json|```$", "", content.strip(), flags=re.MULTILINE).strip() | |
| return json.loads(content) | |
| except Exception as e: | |
| print(f"[AI] Groq selhalo: {e}") | |
| return None | |
| def ai_extract_hf(ocr_text: str): | |
| if not HF_TOKEN: | |
| return None | |
| try: | |
| url = "https://api-inference.huggingface.co/models/mistralai/Mistral-7B-Instruct-v0.3" | |
| headers = {"Authorization": f"Bearer {HF_TOKEN}"} | |
| prompt = f"<s>[INST] {AI_SYSTEM_PROMPT}\n\n{ocr_text[:3000]} [/INST]" | |
| payload = {"inputs": prompt, "parameters": {"max_new_tokens": 512, "temperature": 0.05, "return_full_text": False}} | |
| resp = requests.post(url, headers=headers, json=payload, timeout=45) | |
| if resp.status_code == 503: | |
| time.sleep(25) | |
| resp = requests.post(url, headers=headers, json=payload, timeout=45) | |
| data = resp.json() | |
| text = data[0]["generated_text"] if isinstance(data, list) else data.get("generated_text", "") | |
| text = re.sub(r"^```json|```$", "", text.strip(), flags=re.MULTILINE).strip() | |
| match = re.search(r"\{.*\}", text, re.DOTALL) | |
| return json.loads(match.group(0)) if match else None | |
| except Exception as e: | |
| print(f"[AI] HF Inference selhalo: {e}") | |
| return None | |
| def regex_extract(ocr_text: str): | |
| try: | |
| inv_number_m = re.search(r"(?:invoice|inv)[#:\s]+([A-Z0-9\-]{4,24})", ocr_text, re.I) | |
| dates = re.findall(r"\d{1,2}[\/.\-]\d{1,2}[\/.\-]\d{4}", ocr_text) | |
| total_m = re.search(r"(?:total|amount due)[\s:$]+([0-9,\.]+)", ocr_text, re.I) | |
| vendor = next((l.strip() for l in ocr_text.splitlines() if l.strip()), "Unknown vendor") | |
| total = 0.0 | |
| if total_m: | |
| try: | |
| total = float(total_m.group(1).replace(",", "")) | |
| except Exception: | |
| total = 0.0 | |
| return { | |
| "vendor": vendor[:120], | |
| "inv_number": inv_number_m.group(1) if inv_number_m else "", | |
| "inv_date": dates[0] if dates else "", | |
| "due_date": dates[1] if len(dates) > 1 else "", | |
| "amount": total, | |
| "vat_amount": 0.0, | |
| "total": total, | |
| "currency": "USD", | |
| "line_items": [], | |
| } | |
| except Exception as e: | |
| print(f"[AI] Regex fallback selhalo: {e}") | |
| return {"vendor": "Unknown", "inv_number": "", "inv_date": "", "due_date": "", | |
| "amount": 0, "vat_amount": 0, "total": 0, "currency": "USD", "line_items": []} | |
| def run_ai_pipeline(ocr_text: str): | |
| if not ocr_text.strip(): | |
| data = regex_extract("") | |
| data["_ai_source"] = "empty_ocr" | |
| return data | |
| data = ai_extract_groq(ocr_text) | |
| if data: | |
| data["_ai_source"] = "groq" | |
| return data | |
| data = ai_extract_hf(ocr_text) | |
| if data: | |
| data["_ai_source"] = "huggingface" | |
| return data | |
| data = regex_extract(ocr_text) | |
| data["_ai_source"] = "regex_fallback" | |
| return data | |
| def validate_invoice(data: dict) -> list: | |
| """Cross-field validace — vrací list textových warningů.""" | |
| warnings = [] | |
| try: | |
| amount = float(data.get("amount") or 0) | |
| vat = float(data.get("vat_amount") or 0) | |
| total = float(data.get("total") or 0) | |
| if total > 0 and abs((amount + vat) - total) > 0.10: | |
| warnings.append(f"Subtotal + DPH ({amount + vat:.2f}) neodpovídá total ({total:.2f}).") | |
| if amount > 0 and vat / amount > 0.30: | |
| warnings.append("DPH sazba vyšší než 30 % — zkontroluj ručně.") | |
| inv_date, due_date = data.get("inv_date"), data.get("due_date") | |
| if inv_date and due_date: | |
| try: | |
| d1 = datetime.fromisoformat(inv_date) | |
| d2 = datetime.fromisoformat(due_date) | |
| if d2 < d1: | |
| warnings.append("Splatnost je dřív než datum vystavení faktury.") | |
| except Exception: | |
| pass | |
| if not data.get("vendor"): | |
| warnings.append("Nepodařilo se rozpoznat dodavatele.") | |
| except Exception as e: | |
| warnings.append(f"Validace selhala: {e}") | |
| return warnings | |
| def process_invoice_file(user, file_path, filename) -> dict: | |
| """Kompletní pipeline pro jeden soubor. Vrací dict se všemi daty + warnings.""" | |
| try: | |
| with open(file_path, "rb") as f: | |
| file_bytes = f.read() | |
| if len(file_bytes) > MAX_FILE_SIZE_BYTES: | |
| return {"error": f"Soubor {filename} přesahuje limit {MAX_FILE_SIZE_MB}MB."} | |
| images = file_to_images(file_bytes, filename) | |
| if not images: | |
| return {"error": f"Nepodařilo se otevřít soubor {filename} (nepodporovaný formát nebo poškozený soubor)."} | |
| ocr_text = extract_text_from_images(images) | |
| extracted = run_ai_pipeline(ocr_text) | |
| warnings = validate_invoice(extracted) | |
| plan = user.get("plan", "free") | |
| is_dup = False | |
| if plan in ("pro", "enterprise") and extracted.get("vendor") and extracted.get("total"): | |
| try: | |
| dup_count = DB.count_duplicate(user["id"], extracted["vendor"], float(extracted["total"] or 0)) | |
| is_dup = dup_count > 0 | |
| except Exception as e: | |
| print(f"[DUP] kontrola duplicit selhala: {e}") | |
| status = "review" if warnings else "done" | |
| if is_dup: | |
| status = "duplicate" | |
| record = { | |
| "filename": filename, | |
| "vendor": extracted.get("vendor", ""), | |
| "inv_number": extracted.get("inv_number", ""), | |
| "inv_date": extracted.get("inv_date", ""), | |
| "due_date": extracted.get("due_date", ""), | |
| "amount": float(extracted.get("amount") or 0), | |
| "vat_amount": float(extracted.get("vat_amount") or 0), | |
| "total": float(extracted.get("total") or 0), | |
| "currency": extracted.get("currency", "USD"), | |
| "status": status, | |
| "is_duplicate": is_dup, | |
| "confidence": 0.95 if extracted.get("_ai_source") in ("groq", "huggingface") else 0.55, | |
| "raw_json": extracted, | |
| } | |
| DB.save_invoice(user["id"], record) | |
| record["warnings"] = warnings | |
| return record | |
| except Exception as e: | |
| traceback.print_exc() | |
| return {"error": f"Zpracování {filename} selhalo: {e}"} | |
| # =========================================================================== | |
| # CORE LOGIC — AI CHAT AGENT (Pro+) | |
| # =========================================================================== | |
| def ai_chat_answer(user, question: str, history: list) -> str: | |
| try: | |
| if not question or not question.strip(): | |
| return "Napiš prosím otázku k tvým fakturám." | |
| if user.get("plan") not in ("pro", "enterprise"): | |
| return "AI Chat je dostupný od plánu Pro. Upgraduj v sekci ⚡ Upgrade." | |
| invoices = DB.get_invoices(user["id"])[:200] | |
| context_rows = [ | |
| f"- {inv.get('vendor')} | č.{inv.get('inv_number')} | {inv.get('inv_date')} | " | |
| f"total {inv.get('total')} {inv.get('currency')} | status {inv.get('status')}" | |
| for inv in invoices | |
| ] | |
| context = "\n".join(context_rows) if context_rows else "Uživatel zatím nemá žádné faktury." | |
| if GROQ_API_KEY and GROQ_SDK_OK: | |
| client = Groq(api_key=GROQ_API_KEY) | |
| resp = client.chat.completions.create( | |
| model="llama-3.1-8b-instant", | |
| messages=[ | |
| {"role": "system", "content": "You are a helpful assistant answering questions about the user's invoices based ONLY on the data provided below. Be concise."}, | |
| {"role": "user", "content": f"Invoices:\n{context}\n\nQuestion: {question}"}, | |
| ], | |
| max_tokens=400, | |
| temperature=0.2, | |
| timeout=15, | |
| ) | |
| return resp.choices[0].message.content | |
| return "AI chat momentálně není dostupný (chybí GROQ_API_KEY). Zkus to prosím později." | |
| except Exception as e: | |
| traceback.print_exc() | |
| return f"Chyba AI chatu: {e}" | |
| # =========================================================================== | |
| # CORE LOGIC — STRIPE PLATBY (bez webhooků, polling) | |
| # =========================================================================== | |
| def create_checkout_url(user, plan: str) -> (bool, str): | |
| try: | |
| if not (STRIPE_SDK_OK and STRIPE_SECRET_KEY): | |
| return False, f"Platby momentálně nejsou nastavené. Napiš prosím na support a domluvíme upgrade na {plan} ručně." | |
| price_id = PLAN_PRICE_IDS.get(plan) | |
| if not price_id: | |
| return False, "Neplatný plán." | |
| session = stripe.checkout.Session.create( | |
| payment_method_types=["card"], | |
| line_items=[{"price": price_id, "quantity": 1}], | |
| mode="subscription", | |
| success_url=f"{APP_URL}?checkout=success&session_id={{CHECKOUT_SESSION_ID}}", | |
| cancel_url=f"{APP_URL}?checkout=cancel", | |
| customer_email=user["email"], | |
| metadata={"plan": plan, "user_id": str(user["id"])}, | |
| ) | |
| return True, session.url | |
| except Exception as e: | |
| traceback.print_exc() | |
| return False, f"Chyba Stripe checkoutu: {e}" | |
| def poll_payment_status(session_id: str, user_id: int, max_attempts=12, delay=5) -> str: | |
| if not (STRIPE_SDK_OK and STRIPE_SECRET_KEY): | |
| return "Platby nejsou nakonfigurované." | |
| try: | |
| for _ in range(max_attempts): | |
| session = stripe.checkout.Session.retrieve(session_id) | |
| if session.payment_status == "paid": | |
| plan = session.metadata.get("plan", "basic") | |
| DB.update_user_plan(user_id, plan, stripe_cid=session.customer) | |
| return f"✅ Upgradnuto na {plan}!" | |
| time.sleep(delay) | |
| return "⏳ Platba zatím nebyla potvrzena. Pokud jsi zaplatil/a, obnov stránku za chvíli." | |
| except Exception as e: | |
| traceback.print_exc() | |
| return f"Chyba při ověřování platby: {e}" | |
| # =========================================================================== | |
| # CORE LOGIC — EXPORT | |
| # =========================================================================== | |
| def export_csv(user) -> str: | |
| try: | |
| invoices = DB.get_invoices(user["id"]) | |
| path = f"/tmp/export_{user['id']}_{int(time.time())}.csv" | |
| import csv | |
| with open(path, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.writer(f) | |
| writer.writerow(["Vendor", "Invoice#", "Invoice Date", "Due Date", "Amount", "VAT", "Total", "Currency", "Status"]) | |
| for inv in invoices: | |
| writer.writerow([inv.get("vendor"), inv.get("inv_number"), inv.get("inv_date"), | |
| inv.get("due_date"), inv.get("amount"), inv.get("vat_amount"), | |
| inv.get("total"), inv.get("currency"), inv.get("status")]) | |
| return path | |
| except Exception as e: | |
| traceback.print_exc() | |
| raise gr.Error(f"Export CSV selhal: {e}") | |
| def export_json(user) -> str: | |
| try: | |
| invoices = DB.get_invoices(user["id"]) | |
| path = f"/tmp/export_{user['id']}_{int(time.time())}.json" | |
| with open(path, "w", encoding="utf-8") as f: | |
| json.dump(invoices, f, ensure_ascii=False, indent=2, default=str) | |
| return path | |
| except Exception as e: | |
| traceback.print_exc() | |
| raise gr.Error(f"Export JSON selhal: {e}") | |
| # =========================================================================== | |
| # GRADIO UI LAYER — od tohoto místa dolů JEN volání CORE funkcí | |
| # =========================================================================== | |
| CUSTOM_CSS = """ | |
| .gradio-container {max-width: 1200px !important; margin: auto;} | |
| .op-hero {text-align:center; padding: 40px 20px;} | |
| .op-card {border:1px solid #e5e7eb; border-radius:12px; padding:20px; background:white;} | |
| footer {visibility:hidden} | |
| """ | |
| LANDING_HTML = """ | |
| <div style="font-family:Inter,sans-serif;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;padding:16px 24px;border-bottom:1px solid #eee;"> | |
| <div style="font-size:22px;font-weight:800;">⚡ OmniParse AI</div> | |
| <div style="color:#666;font-size:14px;">How it works · Features · Pricing · Legal</div> | |
| </div> | |
| <div class="op-hero"> | |
| <h1 style="font-size:42px;font-weight:800;margin-bottom:8px;">Invoice processing in seconds, not hours.</h1> | |
| <p style="font-size:18px;color:#555;max-width:640px;margin:0 auto 20px;"> | |
| AI extracts vendor, dates, amounts and line items from any PDF or image. | |
| Export to CSV, JSON or Excel. Connect via API. | |
| </p> | |
| <p style="color:#888;">99.2% accuracy · <4s per invoice · 40+ formats</p> | |
| </div> | |
| <div class="op-card" style="margin:20px 0;"> | |
| <h2>How it works</h2> | |
| <ol> | |
| <li>Upload PDF or image invoice</li> | |
| <li>AI extracts all data</li> | |
| <li>Export wherever you need</li> | |
| </ol> | |
| </div> | |
| <div class="op-card" style="margin:20px 0;"> | |
| <h2>Features</h2> | |
| <ul> | |
| <li>🔍 OCR + LLM — Tesseract + Groq Llama 3.1</li> | |
| <li>🚫 Duplicate Detection — Pro+, catches double payments</li> | |
| <li>🤖 AI Chat Agent — ask about your invoices in plain English</li> | |
| <li>✅ Cross-field Validation — checks totals, dates, tax rates</li> | |
| <li>👥 Human-in-the-loop — Enterprise, manual review of uncertain invoices</li> | |
| <li>🔌 REST API — connect to your own ERP</li> | |
| </ul> | |
| </div> | |
| <div class="op-card" style="margin:20px 0;"> | |
| <h2>FAQ</h2> | |
| <p><b>Is my data safe?</b> Yes — stored in EU (Frankfurt), encrypted at rest, GDPR compliant.</p> | |
| <p><b>Does it work on Czech invoices?</b> Yes, OCR supports Czech + English.</p> | |
| <p><b>How do payments work?</b> Monthly subscription via Stripe, cancel anytime.</p> | |
| <p><b>Can I cancel anytime?</b> Yes, no lock-in contracts.</p> | |
| <p><b>Do I get a tax invoice?</b> Yes, automatically generated by Stripe after each payment.</p> | |
| </div> | |
| <div style="text-align:center;color:#999;padding:20px;border-top:1px solid #eee;"> | |
| © 2026 OmniParse AI — Terms · Privacy · Disclaimer (viz Legal tab) | |
| </div> | |
| </div> | |
| """ | |
| LEGAL_TERMS = """ | |
| ### Terms of Use | |
| OmniParse AI je nástroj pro automatickou extrakci dat z faktur pomocí AI. Používáním služby souhlasíš, | |
| že ji nebudeš zneužívat k nahrávání nelegálního obsahu, pokusům o přetížení systému (DoS) ani reverznímu | |
| inženýrství. Platby probíhají měsíčně přes Stripe, zrušení kdykoliv v sekci Profile. Neposkytujeme záruku | |
| 100% přesnosti extrakce — viz Disclaimer. | |
| """ | |
| LEGAL_PRIVACY = """ | |
| ### Privacy Policy / GDPR | |
| **Co sbíráme:** e-mail, jméno, nahrané faktury a z nich extrahovaná data. | |
| **Kde je to uloženo:** Supabase, EU region (Frankfurt). | |
| **Jak dlouho:** faktury 30 dní, účetní/fakturační záznamy 10 let (zákonná lhůta). | |
| **Tvá práva:** přístup k datům, výmaz (Profile → Delete account), přenositelnost dat (Export). | |
| **Cookies:** pouze technické (session), žádný marketingový tracking. | |
| """ | |
| LEGAL_DISCLAIMER = """ | |
| ### Disclaimer | |
| AI extrakce není 100% přesná — vždy ověř data před zaúčtováním do tvého účetního systému. | |
| OmniParse nenese odpovědnost za chyby vzniklé nesprávnou AI extrakcí. Toto je nástroj usnadňující práci, | |
| nikoliv náhrada za kvalifikovaného účetního. | |
| """ | |
| def status_badge(status): | |
| return {"done": "✅ Done", "review": "⚠️ Review", "duplicate": "🔴 Duplicate", "processing": "⟳ Processing"}.get(status, status) | |
| def invoices_to_dataframe(invoices): | |
| rows = [] | |
| for inv in invoices: | |
| rows.append([ | |
| inv.get("id"), inv.get("vendor"), inv.get("inv_number"), inv.get("inv_date"), | |
| f"{inv.get('total', 0):.2f} {inv.get('currency', '')}", status_badge(inv.get("status")), | |
| ]) | |
| return rows | |
| with gr.Blocks(css=CUSTOM_CSS, title="OmniParse AI") as demo: | |
| session_token = gr.State("") # server-side (NE localStorage) — viz resolve_session() | |
| current_view = gr.State("landing") | |
| # ---- VIEW CONTAINERS ---- | |
| with gr.Column(visible=True) as view_landing: | |
| gr.HTML(LANDING_HTML) | |
| with gr.Row(): | |
| btn_landing_start = gr.Button("Start Free — 20 invoices →", variant="primary") | |
| btn_landing_login = gr.Button("Log In") | |
| btn_landing_pricing = gr.Button("Pricing") | |
| btn_landing_legal = gr.Button("Legal") | |
| with gr.Column(visible=False) as view_pricing: | |
| gr.Markdown("## Pricing") | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown("### Free — $0/mo\n- 20 invoices/mo\n- CSV export\n- 1 user") | |
| with gr.Column(): | |
| gr.Markdown("### Basic — $29/mo\n- 200 invoices/mo\n- JSON+CSV+Excel export\n- Multi-currency") | |
| with gr.Column(): | |
| gr.Markdown("### Pro — $129/mo\n- 2,000 invoices/mo\n- REST API + AI Chat\n- Duplicate detection\n- 3 users") | |
| with gr.Column(): | |
| gr.Markdown("### Enterprise — $499/mo\n- Unlimited invoices\n- Human-in-the-loop\n- SLA 99.5%\n- Unlimited users") | |
| gr.Markdown("_Přihlaš se a v Dashboardu → ⚡ Upgrade vyber plán a zaplať kartou přes Stripe._") | |
| btn_pricing_back = gr.Button("← Back") | |
| with gr.Column(visible=False) as view_legal: | |
| gr.Markdown("## Legal") | |
| with gr.Tab("Terms of Use"): | |
| gr.Markdown(LEGAL_TERMS) | |
| with gr.Tab("Privacy Policy"): | |
| gr.Markdown(LEGAL_PRIVACY) | |
| with gr.Tab("Disclaimer"): | |
| gr.Markdown(LEGAL_DISCLAIMER) | |
| btn_legal_back = gr.Button("← Back") | |
| with gr.Column(visible=False) as view_auth: | |
| gr.Markdown("## Welcome to OmniParse AI") | |
| with gr.Tab("Log In"): | |
| login_email = gr.Textbox(label="Email") | |
| login_password = gr.Textbox(label="Password", type="password") | |
| login_btn = gr.Button("Log In", variant="primary") | |
| login_msg = gr.Markdown() | |
| gr.Markdown("_Demo účet: `demo@omniparse.ai` / `demo1234` (plán Pro)_") | |
| with gr.Tab("Sign Up"): | |
| signup_name = gr.Textbox(label="Full Name") | |
| signup_email = gr.Textbox(label="Work Email") | |
| signup_password = gr.Textbox(label="Password (min. 8 znaků)", type="password") | |
| signup_terms = gr.Checkbox(label="Souhlasím s Terms of Use a Privacy Policy") | |
| signup_btn = gr.Button("Sign Up", variant="primary") | |
| signup_msg = gr.Markdown() | |
| btn_auth_back = gr.Button("← Back to landing") | |
| with gr.Column(visible=False) as view_dashboard: | |
| with gr.Row(): | |
| gr.Markdown("## Dashboard") | |
| btn_logout = gr.Button("🚪 Log Out", size="sm") | |
| user_info_md = gr.Markdown() | |
| with gr.Tab("📤 Upload"): | |
| upload_files = gr.File(label="Nahraj faktury (PDF/JPG/PNG/TIFF, max 20MB/soubor)", file_count="multiple") | |
| upload_btn = gr.Button("Zpracovat faktury", variant="primary") | |
| upload_status = gr.Markdown() | |
| upload_results = gr.Dataframe(headers=["ID", "Vendor", "Invoice#", "Date", "Total", "Status"], label="Výsledky") | |
| upload_raw_json = gr.JSON(label="Raw AI output (poslední soubor)") | |
| with gr.Tab("📋 My Invoices"): | |
| refresh_invoices_btn = gr.Button("🔄 Obnovit") | |
| invoices_table = gr.Dataframe(headers=["ID", "Vendor", "Invoice#", "Date", "Total", "Status"], label="Faktury") | |
| with gr.Tab("🤖 AI Chat (Pro+)"): | |
| chat_history = gr.Chatbot(label="Zeptej se na své faktury", type="messages") | |
| chat_input = gr.Textbox(label="Otázka", placeholder="What's the total unpaid amount?") | |
| chat_send = gr.Button("Odeslat") | |
| with gr.Tab("📊 Export"): | |
| gr.Markdown("CSV export je zdarma pro všechny. JSON od plánu Basic+.") | |
| export_csv_btn = gr.Button("Export CSV") | |
| export_csv_file = gr.File(label="Stáhnout CSV") | |
| export_json_btn = gr.Button("Export JSON (Basic+)") | |
| export_json_file = gr.File(label="Stáhnout JSON") | |
| gr.Markdown("Excel export a Google Sheets sync: **Coming soon** 🚧") | |
| with gr.Tab("⚡ Upgrade"): | |
| plan_dropdown = gr.Dropdown(["basic", "pro", "enterprise"], label="Vyber plán", value="basic") | |
| upgrade_btn = gr.Button("Přejít na platbu (Stripe)", variant="primary") | |
| upgrade_link = gr.Markdown() | |
| gr.Markdown("Test karta ve Stripe test mode: `4242 4242 4242 4242`, libovolné datum/CVC.") | |
| checkout_session_input = gr.Textbox(label="Po zaplacení: vlož session_id z URL a klikni níže", visible=True) | |
| confirm_payment_btn = gr.Button("Ověřit platbu") | |
| payment_status_md = gr.Markdown() | |
| with gr.Tab("🔌 API (Pro+)"): | |
| api_key_display = gr.Markdown() | |
| gr.Markdown(""" | |
| ```bash | |
| curl -X POST https://tvuj-space.hf.space/api/extract \\ | |
| -H "Authorization: Bearer TVUJ_API_KLIC" \\ | |
| -F "file=@faktura.pdf" | |
| ``` | |
| _(REST endpoint pro přímé API volání se zapojí při přechodu na FastAPI backend — business logika je už připravená v `process_invoice_file()`.)_ | |
| """) | |
| with gr.Tab("👤 Profile"): | |
| profile_info = gr.Markdown() | |
| new_password = gr.Textbox(label="Nové heslo", type="password") | |
| change_pw_btn = gr.Button("Změnit heslo") | |
| change_pw_msg = gr.Markdown() | |
| gr.Markdown("### ⚠️ Danger zone") | |
| delete_confirm = gr.Checkbox(label="Ano, opravdu chci smazat účet a všechna data") | |
| delete_btn = gr.Button("Smazat účet natrvalo", variant="stop") | |
| delete_msg = gr.Markdown() | |
| ALL_VIEWS = [view_landing, view_pricing, view_legal, view_auth, view_dashboard] | |
| def switch_view(target): | |
| return [gr.update(visible=(v == target)) for v in ["landing", "pricing", "legal", "auth", "dashboard"]] | |
| # ---- NAVIGACE ---- | |
| btn_landing_start.click(lambda: switch_view("auth"), outputs=ALL_VIEWS) | |
| btn_landing_login.click(lambda: switch_view("auth"), outputs=ALL_VIEWS) | |
| btn_landing_pricing.click(lambda: switch_view("pricing"), outputs=ALL_VIEWS) | |
| btn_landing_legal.click(lambda: switch_view("legal"), outputs=ALL_VIEWS) | |
| btn_pricing_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS) | |
| btn_legal_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS) | |
| btn_auth_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS) | |
| # ---- AUTH HANDLERY ---- | |
| def handle_signup(name, email, password, terms): | |
| ok, msg, token = signup(name, email, password, terms) | |
| if ok: | |
| user = resolve_session(token) | |
| info = f"✅ Přihlášen jako **{user['name']}** ({user['email']}) — plán **{user['plan']}**" | |
| views = switch_view("dashboard") | |
| return [msg, token, info] + views | |
| views = switch_view("auth") | |
| return [msg, "", ""] + views | |
| signup_btn.click( | |
| handle_signup, | |
| inputs=[signup_name, signup_email, signup_password, signup_terms], | |
| outputs=[signup_msg, session_token, user_info_md] + ALL_VIEWS, | |
| ) | |
| def handle_login(email, password): | |
| ok, msg, token = login(email, password) | |
| if ok: | |
| user = resolve_session(token) | |
| info = f"✅ Přihlášen jako **{user['name']}** ({user['email']}) — plán **{user['plan']}**" | |
| views = switch_view("dashboard") | |
| return [msg, token, info] + views | |
| views = switch_view("auth") | |
| return [msg, "", ""] + views | |
| login_btn.click( | |
| handle_login, | |
| inputs=[login_email, login_password], | |
| outputs=[login_msg, session_token, user_info_md] + ALL_VIEWS, | |
| ) | |
| def handle_logout(token): | |
| logout(token) | |
| views = switch_view("landing") | |
| return [""] + views | |
| btn_logout.click(handle_logout, inputs=[session_token], outputs=[session_token] + ALL_VIEWS) | |
| # ---- UPLOAD ---- | |
| def handle_upload(token, files): | |
| user = resolve_session(token) | |
| if not user: | |
| return "❌ Nejsi přihlášen/a. Přihlas se prosím znovu.", [], {} | |
| if not files: | |
| return "⚠️ Nevybral/a jsi žádný soubor.", [], {} | |
| used = DB.count_invoices_this_month(user["id"]) | |
| limit = PLAN_LIMITS.get(user["plan"], 20) | |
| if used >= limit: | |
| return f"🔴 Vyčerpal/a jsi měsíční limit ({int(limit) if limit != float('inf') else '∞'} faktur). Upgraduj v sekci ⚡ Upgrade.", [], {} | |
| results, last_json, errors = [], {}, [] | |
| for f in files: | |
| if used >= limit: | |
| errors.append(f"Limit dosažen, {os.path.basename(f.name)} přeskočen.") | |
| break | |
| record = process_invoice_file(user, f.name, os.path.basename(f.name)) | |
| if "error" in record: | |
| errors.append(record["error"]) | |
| continue | |
| used += 1 | |
| last_json = record.get("raw_json", {}) | |
| results.append([None, record["vendor"], record["inv_number"], record["inv_date"], | |
| f"{record['total']:.2f} {record['currency']}", status_badge(record["status"])]) | |
| msg = f"✅ Zpracováno {len(results)} faktur. Použito {used}/{int(limit) if limit != float('inf') else '∞'} tento měsíc." | |
| if errors: | |
| msg += "\n\n⚠️ Chyby:\n" + "\n".join(f"- {e}" for e in errors) | |
| return msg, results, last_json | |
| upload_btn.click(handle_upload, inputs=[session_token, upload_files], | |
| outputs=[upload_status, upload_results, upload_raw_json]) | |
| # ---- MY INVOICES ---- | |
| def handle_refresh_invoices(token): | |
| user = resolve_session(token) | |
| if not user: | |
| return [] | |
| return invoices_to_dataframe(DB.get_invoices(user["id"])) | |
| refresh_invoices_btn.click(handle_refresh_invoices, inputs=[session_token], outputs=[invoices_table]) | |
| # ---- AI CHAT ---- | |
| def handle_chat(token, message, history): | |
| user = resolve_session(token) | |
| if not user: | |
| history = history or [] | |
| history.append({"role": "assistant", "content": "Nejsi přihlášen/a."}) | |
| return history, "" | |
| answer = ai_chat_answer(user, message, history) | |
| history = history or [] | |
| history.append({"role": "user", "content": message}) | |
| history.append({"role": "assistant", "content": answer}) | |
| return history, "" | |
| chat_send.click(handle_chat, inputs=[session_token, chat_input, chat_history], outputs=[chat_history, chat_input]) | |
| # ---- EXPORT ---- | |
| def handle_export_csv(token): | |
| user = resolve_session(token) | |
| if not user: | |
| raise gr.Error("Nejsi přihlášen/a.") | |
| return export_csv(user) | |
| export_csv_btn.click(handle_export_csv, inputs=[session_token], outputs=[export_csv_file]) | |
| def handle_export_json(token): | |
| user = resolve_session(token) | |
| if not user: | |
| raise gr.Error("Nejsi přihlášen/a.") | |
| if user["plan"] == "free": | |
| raise gr.Error("JSON export je dostupný od plánu Basic. Upgraduj v sekci ⚡ Upgrade.") | |
| return export_json(user) | |
| export_json_btn.click(handle_export_json, inputs=[session_token], outputs=[export_json_file]) | |
| # ---- UPGRADE / STRIPE ---- | |
| def handle_upgrade(token, plan): | |
| user = resolve_session(token) | |
| if not user: | |
| return "❌ Nejsi přihlášen/a." | |
| ok, result = create_checkout_url(user, plan) | |
| if ok: | |
| return f"[Klikni pro dokončení platby ve Stripe →]({result})" | |
| return f"⚠️ {result}" | |
| upgrade_btn.click(handle_upgrade, inputs=[session_token, plan_dropdown], outputs=[upgrade_link]) | |
| def handle_confirm_payment(token, session_id): | |
| user = resolve_session(token) | |
| if not user: | |
| return "❌ Nejsi přihlášen/a." | |
| if not session_id: | |
| return "Vlož prosím session_id z URL po návratu ze Stripe." | |
| return poll_payment_status(session_id, user["id"]) | |
| confirm_payment_btn.click(handle_confirm_payment, inputs=[session_token, checkout_session_input], outputs=[payment_status_md]) | |
| # ---- API KEY DISPLAY ---- | |
| def handle_show_api_key(token): | |
| user = resolve_session(token) | |
| if not user: | |
| return "Nejsi přihlášen/a." | |
| if user["plan"] not in ("pro", "enterprise"): | |
| return "🔒 API přístup je dostupný od plánu Pro. Upgraduj v sekci ⚡ Upgrade." | |
| return f"**Tvůj API klíč:** `{user.get('api_key', 'N/A')}`\n\n⚠️ Nikdy ho nesdílej veřejně." | |
| # ---- PROFILE ---- | |
| def handle_change_password(token, new_pw): | |
| user = resolve_session(token) | |
| if not user: | |
| return "❌ Nejsi přihlášen/a." | |
| ok, msg = password_strength_ok(new_pw) | |
| if not ok: | |
| return f"⚠️ {msg}" | |
| if is_password_leaked(new_pw): | |
| return "⚠️ Toto heslo bylo nalezeno v uniklých databázích. Zvol jiné." | |
| try: | |
| DB.update_password(user["id"], hash_password(new_pw)) | |
| return "✅ Heslo změněno." | |
| except Exception as e: | |
| return f"❌ Chyba: {e}" | |
| change_pw_btn.click(handle_change_password, inputs=[session_token, new_password], outputs=[change_pw_msg]) | |
| def handle_delete_account(token, confirmed): | |
| user = resolve_session(token) | |
| if not user: | |
| return "❌ Nejsi přihlášen/a.", token | |
| if not confirmed: | |
| return "⚠️ Zaškrtni prosím potvrzení.", token | |
| try: | |
| DB.delete_user(user["id"]) | |
| return "✅ Účet smazán. Sbohem!", "" | |
| except Exception as e: | |
| return f"❌ Chyba při mazání: {e}", token | |
| delete_btn.click(handle_delete_account, inputs=[session_token, delete_confirm], outputs=[delete_msg, session_token]) | |
| # ---- Při vstupu do dashboardu doplníme profil / API klíč / faktury ---- | |
| def on_dashboard_enter(token): | |
| user = resolve_session(token) | |
| if not user: | |
| return "", "", [] | |
| profile = f"**Jméno:** {user['name']}\n\n**Email:** {user['email']}\n\n**Plán:** {user['plan']}" | |
| api_txt = handle_show_api_key(token) | |
| invoices = invoices_to_dataframe(DB.get_invoices(user["id"])) | |
| return profile, api_txt, invoices | |
| session_token.change(on_dashboard_enter, inputs=[session_token], outputs=[profile_info, api_key_display, invoices_table]) | |
| if __name__ == "__main__": | |
| try: | |
| demo.queue(max_size=20).launch(server_name="0.0.0.0", server_port=7860) | |
| except Exception as e: | |
| print(f"[FATAL] Aplikace se nepodařila spustit: {e}") | |
| traceback.print_exc() |