simikkk commited on
Commit
d9eba95
·
verified ·
1 Parent(s): 4c24d89

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1316 -0
app.py ADDED
@@ -0,0 +1,1316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OmniParse AI — kompletní B2B SaaS pro zpracování faktur.
3
+ Architektura: CORE LOGIC (framework-agnostic, dá se volat i z FastAPI/HTML frontendu)
4
+ + GRADIO UI LAYER (jen volá CORE funkce, žádná business logika v UI kódu).
5
+ Když budeš chtít přejít na vlastní HTML/JS frontend, stačí obalit CORE funkce
6
+ do FastAPI endpointů (viz sekce "CORE LOGIC" níže) — nic se v nich měnit nemusí.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import io
12
+ import json
13
+ import time
14
+ import base64
15
+ import sqlite3
16
+ import secrets
17
+ import hashlib
18
+ import traceback
19
+ from datetime import datetime, timedelta, timezone
20
+
21
+ import gradio as gr
22
+ import requests
23
+ from PIL import Image
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # VOLITELNÉ KNIHOVNY — nikdy nesmí spadnout celá appka, když chybí balíček
27
+ # ---------------------------------------------------------------------------
28
+ try:
29
+ import bcrypt
30
+ BCRYPT_OK = True
31
+ except Exception:
32
+ BCRYPT_OK = False
33
+
34
+ try:
35
+ import pytesseract
36
+ TESSERACT_OK = True
37
+ except Exception:
38
+ TESSERACT_OK = False
39
+
40
+ try:
41
+ from pdf2image import convert_from_bytes
42
+ PDF2IMAGE_OK = True
43
+ except Exception:
44
+ PDF2IMAGE_OK = False
45
+
46
+ try:
47
+ from groq import Groq
48
+ GROQ_SDK_OK = True
49
+ except Exception:
50
+ GROQ_SDK_OK = False
51
+
52
+ try:
53
+ import stripe
54
+ STRIPE_SDK_OK = True
55
+ except Exception:
56
+ STRIPE_SDK_OK = False
57
+
58
+ try:
59
+ from supabase import create_client
60
+ SUPABASE_SDK_OK = True
61
+ except Exception:
62
+ SUPABASE_SDK_OK = False
63
+
64
+
65
+ # ===========================================================================
66
+ # KONFIGURACE
67
+ # ===========================================================================
68
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
69
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
70
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
71
+ SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
72
+ GOOGLE_VISION_KEY = os.environ.get("GOOGLE_VISION_KEY", "")
73
+ STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
74
+ STRIPE_PRICE_BASIC = os.environ.get("STRIPE_PRICE_BASIC", "")
75
+ STRIPE_PRICE_PRO = os.environ.get("STRIPE_PRICE_PRO", "")
76
+ STRIPE_PRICE_ENTERPRISE = os.environ.get("STRIPE_PRICE_ENTERPRISE", "")
77
+ APP_URL = os.environ.get("APP_URL", "http://localhost:7860")
78
+
79
+ MAX_FILE_SIZE_MB = 20
80
+ MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
81
+
82
+ PLAN_LIMITS = {
83
+ "free": 20,
84
+ "basic": 200,
85
+ "pro": 2000,
86
+ "enterprise": float("inf"),
87
+ }
88
+
89
+ PLAN_PRICE_IDS = {
90
+ "basic": STRIPE_PRICE_BASIC,
91
+ "pro": STRIPE_PRICE_PRO,
92
+ "enterprise": STRIPE_PRICE_ENTERPRISE,
93
+ }
94
+
95
+ if STRIPE_SDK_OK and STRIPE_SECRET_KEY:
96
+ try:
97
+ stripe.api_key = STRIPE_SECRET_KEY
98
+ except Exception:
99
+ pass
100
+
101
+ # jednoduchý in-memory rate limiter: {key: [timestamps]}
102
+ _RATE_LIMIT_STORE = {}
103
+
104
+
105
+ def rate_limited(key: str, max_attempts: int = 5, window_seconds: int = 60) -> bool:
106
+ """Vrátí True pokud je klíč (email/IP) aktuálně rate-limitovaný.
107
+ Ochrana proti brute-force na login/signup/API endpointy."""
108
+ try:
109
+ now = time.time()
110
+ attempts = _RATE_LIMIT_STORE.get(key, [])
111
+ attempts = [t for t in attempts if now - t < window_seconds]
112
+ if len(attempts) >= max_attempts:
113
+ _RATE_LIMIT_STORE[key] = attempts
114
+ return True
115
+ attempts.append(now)
116
+ _RATE_LIMIT_STORE[key] = attempts
117
+ return False
118
+ except Exception:
119
+ return False # radši nechat projít než appku spadnout
120
+
121
+
122
+ # ===========================================================================
123
+ # CORE LOGIC — DATABÁZOVÁ VRSTVA (Supabase primárně, SQLite fallback)
124
+ # ===========================================================================
125
+ class SQLiteDB:
126
+ """Fallback databáze, pokud chybí Supabase secrets. Data jsou ephemeral
127
+ (zmizí při restartu HF Space), ale appka díky tomu nikdy nespadne."""
128
+
129
+ def __init__(self, path="omniparse.db"):
130
+ self.path = path
131
+ self.conn = sqlite3.connect(self.path, check_same_thread=False)
132
+ self._init_schema()
133
+
134
+ def _init_schema(self):
135
+ c = self.conn.cursor()
136
+ c.execute("""CREATE TABLE IF NOT EXISTS users (
137
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
138
+ email TEXT UNIQUE NOT NULL,
139
+ name TEXT NOT NULL,
140
+ password TEXT NOT NULL,
141
+ plan TEXT NOT NULL DEFAULT 'free',
142
+ stripe_cid TEXT,
143
+ api_key TEXT,
144
+ created_at TEXT
145
+ )""")
146
+ c.execute("""CREATE TABLE IF NOT EXISTS sessions (
147
+ token TEXT PRIMARY KEY,
148
+ user_id INTEGER NOT NULL,
149
+ expires_at TEXT NOT NULL
150
+ )""")
151
+ c.execute("""CREATE TABLE IF NOT EXISTS invoices (
152
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
153
+ user_id INTEGER NOT NULL,
154
+ filename TEXT,
155
+ vendor TEXT,
156
+ inv_number TEXT,
157
+ inv_date TEXT,
158
+ due_date TEXT,
159
+ amount REAL,
160
+ vat_amount REAL,
161
+ total REAL,
162
+ currency TEXT DEFAULT 'USD',
163
+ status TEXT DEFAULT 'done',
164
+ is_duplicate INTEGER DEFAULT 0,
165
+ confidence REAL,
166
+ raw_json TEXT,
167
+ created_at TEXT
168
+ )""")
169
+ self.conn.commit()
170
+
171
+ def create_user(self, email, name, password_hash, plan="free", api_key=None):
172
+ c = self.conn.cursor()
173
+ c.execute(
174
+ "INSERT INTO users (email, name, password, plan, api_key, created_at) VALUES (?,?,?,?,?,?)",
175
+ (email, name, password_hash, plan, api_key, datetime.now(timezone.utc).isoformat()),
176
+ )
177
+ self.conn.commit()
178
+ return c.lastrowid
179
+
180
+ def get_user_by_email(self, email):
181
+ c = self.conn.cursor()
182
+ c.execute("SELECT id,email,name,password,plan,stripe_cid,api_key FROM users WHERE email=?", (email,))
183
+ row = c.fetchone()
184
+ if not row:
185
+ return None
186
+ keys = ["id", "email", "name", "password", "plan", "stripe_cid", "api_key"]
187
+ return dict(zip(keys, row))
188
+
189
+ def get_user_by_id(self, user_id):
190
+ c = self.conn.cursor()
191
+ c.execute("SELECT id,email,name,password,plan,stripe_cid,api_key FROM users WHERE id=?", (user_id,))
192
+ row = c.fetchone()
193
+ if not row:
194
+ return None
195
+ keys = ["id", "email", "name", "password", "plan", "stripe_cid", "api_key"]
196
+ return dict(zip(keys, row))
197
+
198
+ def update_user_plan(self, user_id, plan, stripe_cid=None):
199
+ c = self.conn.cursor()
200
+ if stripe_cid:
201
+ c.execute("UPDATE users SET plan=?, stripe_cid=? WHERE id=?", (plan, stripe_cid, user_id))
202
+ else:
203
+ c.execute("UPDATE users SET plan=? WHERE id=?", (plan, user_id))
204
+ self.conn.commit()
205
+
206
+ def update_password(self, user_id, password_hash):
207
+ c = self.conn.cursor()
208
+ c.execute("UPDATE users SET password=? WHERE id=?", (password_hash, user_id))
209
+ self.conn.commit()
210
+
211
+ def delete_user(self, user_id):
212
+ c = self.conn.cursor()
213
+ c.execute("DELETE FROM users WHERE id=?", (user_id,))
214
+ c.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
215
+ c.execute("DELETE FROM invoices WHERE user_id=?", (user_id,))
216
+ self.conn.commit()
217
+
218
+ def create_session(self, token, user_id, expires_at):
219
+ c = self.conn.cursor()
220
+ c.execute("INSERT INTO sessions (token,user_id,expires_at) VALUES (?,?,?)", (token, user_id, expires_at))
221
+ self.conn.commit()
222
+
223
+ def get_session(self, token):
224
+ c = self.conn.cursor()
225
+ c.execute("SELECT token,user_id,expires_at FROM sessions WHERE token=?", (token,))
226
+ row = c.fetchone()
227
+ if not row:
228
+ return None
229
+ return {"token": row[0], "user_id": row[1], "expires_at": row[2]}
230
+
231
+ def delete_session(self, token):
232
+ c = self.conn.cursor()
233
+ c.execute("DELETE FROM sessions WHERE token=?", (token,))
234
+ self.conn.commit()
235
+
236
+ def save_invoice(self, user_id, data):
237
+ c = self.conn.cursor()
238
+ c.execute("""INSERT INTO invoices
239
+ (user_id,filename,vendor,inv_number,inv_date,due_date,amount,vat_amount,total,currency,status,is_duplicate,confidence,raw_json,created_at)
240
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
241
+ (user_id, data.get("filename"), data.get("vendor"), data.get("inv_number"),
242
+ data.get("inv_date"), data.get("due_date"), data.get("amount"), data.get("vat_amount"),
243
+ data.get("total"), data.get("currency", "USD"), data.get("status", "done"),
244
+ int(data.get("is_duplicate", False)), data.get("confidence"),
245
+ json.dumps(data.get("raw_json", {})), datetime.now(timezone.utc).isoformat()))
246
+ self.conn.commit()
247
+ return c.lastrowid
248
+
249
+ def get_invoices(self, user_id):
250
+ c = self.conn.cursor()
251
+ c.execute("""SELECT id,filename,vendor,inv_number,inv_date,due_date,amount,vat_amount,total,
252
+ currency,status,is_duplicate,confidence,raw_json,created_at FROM invoices
253
+ WHERE user_id=? ORDER BY created_at DESC""", (user_id,))
254
+ rows = c.fetchall()
255
+ keys = ["id", "filename", "vendor", "inv_number", "inv_date", "due_date", "amount", "vat_amount",
256
+ "total", "currency", "status", "is_duplicate", "confidence", "raw_json", "created_at"]
257
+ return [dict(zip(keys, r)) for r in rows]
258
+
259
+ def delete_invoice(self, invoice_id, user_id):
260
+ c = self.conn.cursor()
261
+ c.execute("DELETE FROM invoices WHERE id=? AND user_id=?", (invoice_id, user_id))
262
+ self.conn.commit()
263
+
264
+ def count_invoices_this_month(self, user_id):
265
+ c = self.conn.cursor()
266
+ start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
267
+ c.execute("SELECT COUNT(*) FROM invoices WHERE user_id=? AND created_at>=?", (user_id, start))
268
+ return c.fetchone()[0]
269
+
270
+ def count_duplicate(self, user_id, vendor, total):
271
+ c = self.conn.cursor()
272
+ start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
273
+ c.execute("""SELECT COUNT(*) FROM invoices WHERE user_id=? AND vendor=? AND ABS(total-?)<0.01
274
+ AND created_at>=?""", (user_id, vendor, total, start))
275
+ return c.fetchone()[0]
276
+
277
+
278
+ class SupabaseDB:
279
+ """Wrapper nad Supabase se stejným rozhraním jako SQLiteDB, aby zbytek
280
+ kódu vůbec nevěděl, která databáze běží pod kapotou."""
281
+
282
+ def __init__(self, url, key):
283
+ self.client = create_client(url, key)
284
+
285
+ def create_user(self, email, name, password_hash, plan="free", api_key=None):
286
+ res = self.client.table("users").insert({
287
+ "email": email, "name": name, "password": password_hash,
288
+ "plan": plan, "api_key": api_key,
289
+ }).execute()
290
+ return res.data[0]["id"]
291
+
292
+ def get_user_by_email(self, email):
293
+ res = self.client.table("users").select("*").eq("email", email).execute()
294
+ return res.data[0] if res.data else None
295
+
296
+ def get_user_by_id(self, user_id):
297
+ res = self.client.table("users").select("*").eq("id", user_id).execute()
298
+ return res.data[0] if res.data else None
299
+
300
+ def update_user_plan(self, user_id, plan, stripe_cid=None):
301
+ payload = {"plan": plan}
302
+ if stripe_cid:
303
+ payload["stripe_cid"] = stripe_cid
304
+ self.client.table("users").update(payload).eq("id", user_id).execute()
305
+
306
+ def update_password(self, user_id, password_hash):
307
+ self.client.table("users").update({"password": password_hash}).eq("id", user_id).execute()
308
+
309
+ def delete_user(self, user_id):
310
+ self.client.table("invoices").delete().eq("user_id", user_id).execute()
311
+ self.client.table("sessions").delete().eq("user_id", user_id).execute()
312
+ self.client.table("users").delete().eq("id", user_id).execute()
313
+
314
+ def create_session(self, token, user_id, expires_at):
315
+ self.client.table("sessions").insert({
316
+ "token": token, "user_id": user_id, "expires_at": expires_at
317
+ }).execute()
318
+
319
+ def get_session(self, token):
320
+ res = self.client.table("sessions").select("*").eq("token", token).execute()
321
+ return res.data[0] if res.data else None
322
+
323
+ def delete_session(self, token):
324
+ self.client.table("sessions").delete().eq("token", token).execute()
325
+
326
+ def save_invoice(self, user_id, data):
327
+ payload = dict(data)
328
+ payload["user_id"] = user_id
329
+ payload["raw_json"] = json.dumps(payload.get("raw_json", {}))
330
+ res = self.client.table("invoices").insert(payload).execute()
331
+ return res.data[0]["id"]
332
+
333
+ def get_invoices(self, user_id):
334
+ res = self.client.table("invoices").select("*").eq("user_id", user_id).order("created_at", desc=True).execute()
335
+ return res.data
336
+
337
+ def delete_invoice(self, invoice_id, user_id):
338
+ self.client.table("invoices").delete().eq("id", invoice_id).eq("user_id", user_id).execute()
339
+
340
+ def count_invoices_this_month(self, user_id):
341
+ start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
342
+ res = self.client.table("invoices").select("id", count="exact").eq("user_id", user_id).gte("created_at", start).execute()
343
+ return res.count or 0
344
+
345
+ def count_duplicate(self, user_id, vendor, total):
346
+ start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
347
+ res = self.client.table("invoices").select("id", count="exact").eq("user_id", user_id).eq("vendor", vendor).gte("created_at", start).execute()
348
+ # Supabase nepodporuje ABS() přes REST snadno -> filtrujeme total v Pythonu
349
+ rows = self.client.table("invoices").select("total").eq("user_id", user_id).eq("vendor", vendor).gte("created_at", start).execute()
350
+ return sum(1 for r in rows.data if abs((r.get("total") or 0) - total) < 0.01)
351
+
352
+
353
+ def init_db():
354
+ """Vybere Supabase pokud jsou secrets nastavené a SDK je dostupné,
355
+ jinak spadne zpátky na SQLite. Appka nikdy nespadne na chybějícím secretu."""
356
+ if SUPABASE_URL and SUPABASE_KEY and SUPABASE_SDK_OK:
357
+ try:
358
+ db = SupabaseDB(SUPABASE_URL, SUPABASE_KEY)
359
+ db.client.table("users").select("id").limit(1).execute()
360
+ print("[DB] Připojeno k Supabase.")
361
+ return db
362
+ except Exception as e:
363
+ print(f"[DB] Supabase selhalo ({e}), padám na SQLite fallback.")
364
+ return SQLiteDB()
365
+ print("[DB] Supabase secrets nenalezeny, používám SQLite fallback (data jsou dočasná).")
366
+ return SQLiteDB()
367
+
368
+
369
+ DB = init_db()
370
+
371
+
372
+ # ===========================================================================
373
+ # CORE LOGIC — HESLA, AUTH, VALIDACE (server-side, nikdy jen client-side)
374
+ # ===========================================================================
375
+ def hash_password(password: str) -> str:
376
+ """Bcrypt pokud je dostupný (moderní, doporučený), jinak PBKDF2-SHA256
377
+ se solí jako bezpečný fallback (rozhodně ne MD5/SHA1)."""
378
+ try:
379
+ if BCRYPT_OK:
380
+ return "bcrypt$" + bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
381
+ salt = secrets.token_hex(16)
382
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
383
+ return f"pbkdf2${salt}${digest}"
384
+ except Exception:
385
+ salt = secrets.token_hex(16)
386
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
387
+ return f"pbkdf2${salt}${digest}"
388
+
389
+
390
+ def verify_password(password: str, stored_hash: str) -> bool:
391
+ try:
392
+ if stored_hash.startswith("bcrypt$") and BCRYPT_OK:
393
+ return bcrypt.checkpw(password.encode(), stored_hash[len("bcrypt$"):].encode())
394
+ if stored_hash.startswith("pbkdf2$"):
395
+ _, salt, digest = stored_hash.split("$")
396
+ check = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
397
+ return secrets.compare_digest(check, digest)
398
+ return False
399
+ except Exception:
400
+ return False
401
+
402
+
403
+ def is_valid_email(email: str) -> bool:
404
+ return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email or ""))
405
+
406
+
407
+ def password_strength_ok(password: str) -> (bool, str):
408
+ if not password or len(password) < 8:
409
+ return False, "Heslo musí mít alespoň 8 znaků."
410
+ if not re.search(r"[A-Za-z]", password) or not re.search(r"[0-9]", password):
411
+ return False, "Heslo musí obsahovat písmena i čísla."
412
+ return True, ""
413
+
414
+
415
+ def is_password_leaked(password: str) -> bool:
416
+ """HaveIBeenPwned Pwned Passwords API — k-anonymity model, zdarma bez klíče.
417
+ Když je síť nedostupná, prostě kontrolu přeskočíme (fail-open, appka nespadne)."""
418
+ try:
419
+ sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
420
+ prefix, suffix = sha1[:5], sha1[5:]
421
+ resp = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=4)
422
+ if resp.status_code == 200:
423
+ return any(line.split(":")[0] == suffix for line in resp.text.splitlines())
424
+ return False
425
+ except Exception:
426
+ return False
427
+
428
+
429
+ def generate_api_key() -> str:
430
+ return "op_live_" + secrets.token_urlsafe(24)
431
+
432
+
433
+ def create_session_token(user_id: int) -> str:
434
+ token = secrets.token_urlsafe(32)
435
+ expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
436
+ try:
437
+ DB.create_session(token, user_id, expires)
438
+ except Exception as e:
439
+ print(f"[AUTH] Session se nepovedlo uložit: {e}")
440
+ return token
441
+
442
+
443
+ def resolve_session(token: str):
444
+ """Vrátí user dict pokud je token platný a nevypršel, jinak None.
445
+ Token žije v gr.State (server-side per-browser-tab paměť), NE v localStorage,
446
+ což řeší XSS riziko klasického 'token v localStorage' problému."""
447
+ if not token:
448
+ return None
449
+ try:
450
+ sess = DB.get_session(token)
451
+ if not sess:
452
+ return None
453
+ expires = datetime.fromisoformat(sess["expires_at"])
454
+ if expires.tzinfo is None:
455
+ expires = expires.replace(tzinfo=timezone.utc)
456
+ if expires < datetime.now(timezone.utc):
457
+ DB.delete_session(token)
458
+ return None
459
+ return DB.get_user_by_id(sess["user_id"])
460
+ except Exception as e:
461
+ print(f"[AUTH] resolve_session chyba: {e}")
462
+ return None
463
+
464
+
465
+ def signup(name, email, password, accepted_terms) -> (bool, str, str):
466
+ """Vrací (success, message, session_token)."""
467
+ try:
468
+ email = (email or "").strip().lower()
469
+ name = (name or "").strip()
470
+ if rate_limited(f"signup:{email}", max_attempts=5, window_seconds=300):
471
+ return False, "Příliš mnoho pokusů o registraci. Zkus to za pár minut.", ""
472
+ if not name:
473
+ return False, "Vyplň prosím jméno.", ""
474
+ if not is_valid_email(email):
475
+ return False, "Zadej platný pracovní e-mail.", ""
476
+ if not accepted_terms:
477
+ return False, "Musíš souhlasit s Terms of Use a Privacy Policy.", ""
478
+ ok, msg = password_strength_ok(password)
479
+ if not ok:
480
+ return False, msg, ""
481
+ if is_password_leaked(password):
482
+ return False, "Toto heslo bylo nalezeno v uniklých databázích. Zvol jiné.", ""
483
+ if DB.get_user_by_email(email):
484
+ return False, "Účet s tímto e-mailem už existuje.", ""
485
+ pw_hash = hash_password(password)
486
+ api_key = generate_api_key()
487
+ user_id = DB.create_user(email, name, pw_hash, plan="free", api_key=api_key)
488
+ token = create_session_token(user_id)
489
+ return True, "Účet vytvořen!", token
490
+ except Exception as e:
491
+ traceback.print_exc()
492
+ return False, f"Chyba při registraci: {e}", ""
493
+
494
+
495
+ def login(email, password) -> (bool, str, str):
496
+ try:
497
+ email = (email or "").strip().lower()
498
+ if rate_limited(f"login:{email}", max_attempts=8, window_seconds=300):
499
+ return False, "Příliš mnoho pokusů o přihlášení. Zkus to za pár minut.", ""
500
+ if not email or not password:
501
+ return False, "Vyplň e-mail i heslo.", ""
502
+ user = DB.get_user_by_email(email)
503
+ if not user or not verify_password(password, user["password"]):
504
+ return False, "Nesprávný e-mail nebo heslo.", ""
505
+ token = create_session_token(user["id"])
506
+ return True, "Přihlášení úspěšné!", token
507
+ except Exception as e:
508
+ traceback.print_exc()
509
+ return False, f"Chyba při přihlašování: {e}", ""
510
+
511
+
512
+ def logout(token):
513
+ try:
514
+ if token:
515
+ DB.delete_session(token)
516
+ except Exception as e:
517
+ print(f"[AUTH] logout chyba: {e}")
518
+
519
+
520
+ def bootstrap_demo_account():
521
+ try:
522
+ if not DB.get_user_by_email("demo@omniparse.ai"):
523
+ pw_hash = hash_password("demo1234")
524
+ api_key = generate_api_key()
525
+ DB.create_user("demo@omniparse.ai", "Demo User", pw_hash, plan="pro", api_key=api_key)
526
+ print("[DEMO] Demo účet vytvořen: demo@omniparse.ai / demo1234")
527
+ except Exception as e:
528
+ print(f"[DEMO] Nepovedlo se vytvořit demo účet: {e}")
529
+
530
+
531
+ bootstrap_demo_account()
532
+
533
+
534
+ # ===========================================================================
535
+ # CORE LOGIC — AI PIPELINE (OCR -> LLM -> regex fallback)
536
+ # ===========================================================================
537
+ def ocr_google_vision(image_bytes: bytes) -> str:
538
+ if not GOOGLE_VISION_KEY:
539
+ return ""
540
+ try:
541
+ b64 = base64.b64encode(image_bytes).decode()
542
+ url = f"https://vision.googleapis.com/v1/images:annotate?key={GOOGLE_VISION_KEY}"
543
+ payload = {"requests": [{"image": {"content": b64}, "features": [{"type": "TEXT_DETECTION"}]}]}
544
+ resp = requests.post(url, json=payload, timeout=15)
545
+ data = resp.json()
546
+ return data["responses"][0].get("fullTextAnnotation", {}).get("text", "")
547
+ except Exception as e:
548
+ print(f"[OCR] Google Vision selhalo: {e}")
549
+ return ""
550
+
551
+
552
+ def ocr_tesseract(image: Image.Image) -> str:
553
+ if not TESSERACT_OK:
554
+ return ""
555
+ try:
556
+ gray = image.convert("L")
557
+ return pytesseract.image_to_string(gray, lang="eng+ces")
558
+ except Exception:
559
+ try:
560
+ return pytesseract.image_to_string(image.convert("L"), lang="eng")
561
+ except Exception as e:
562
+ print(f"[OCR] Tesseract selhalo: {e}")
563
+ return ""
564
+
565
+
566
+ def file_to_images(file_bytes: bytes, filename: str):
567
+ """Vrátí list PIL Image objektů — z PDF všechny stránky, z obrázku jednu."""
568
+ try:
569
+ ext = filename.lower().rsplit(".", 1)[-1] if "." in filename else ""
570
+ if ext == "pdf":
571
+ if not PDF2IMAGE_OK:
572
+ raise RuntimeError("pdf2image / poppler není dostupný na tomto Space.")
573
+ return convert_from_bytes(file_bytes, dpi=200)
574
+ img = Image.open(io.BytesIO(file_bytes))
575
+ img.load()
576
+ return [img]
577
+ except Exception as e:
578
+ print(f"[FILE] Nepodařilo se otevřít soubor {filename}: {e}")
579
+ return []
580
+
581
+
582
+ def extract_text_from_images(images) -> str:
583
+ full_text = ""
584
+ for img in images[:5]: # bezpečnostní limit — max 5 stránek na fakturu
585
+ buf = io.BytesIO()
586
+ img.convert("RGB").save(buf, format="JPEG", quality=85)
587
+ img_bytes = buf.getvalue()
588
+ text = ""
589
+ if GOOGLE_VISION_KEY:
590
+ text = ocr_google_vision(img_bytes)
591
+ if len(text.strip()) < 30:
592
+ text = ocr_tesseract(img)
593
+ full_text += text + "\n"
594
+ return full_text.strip()
595
+
596
+
597
+ AI_SYSTEM_PROMPT = (
598
+ "You are an invoice data extraction engine. Extract structured data from the raw OCR text "
599
+ "of an invoice. Return ONLY valid JSON, no markdown, no explanation, with exactly these keys: "
600
+ 'vendor (string), inv_number (string), inv_date (YYYY-MM-DD or empty string), '
601
+ 'due_date (YYYY-MM-DD or empty string), amount (number, subtotal before tax), '
602
+ 'vat_amount (number), total (number), currency (3-letter code like USD/EUR/CZK), '
603
+ 'line_items (array of {description, quantity, unit_price, total}). '
604
+ "If a field cannot be found, use empty string or 0. Never invent data you cannot find."
605
+ )
606
+
607
+
608
+ def ai_extract_groq(ocr_text: str):
609
+ if not (GROQ_API_KEY and GROQ_SDK_OK):
610
+ return None
611
+ try:
612
+ client = Groq(api_key=GROQ_API_KEY)
613
+ resp = client.chat.completions.create(
614
+ model="llama-3.1-8b-instant",
615
+ messages=[
616
+ {"role": "system", "content": AI_SYSTEM_PROMPT},
617
+ {"role": "user", "content": ocr_text[:3000]},
618
+ ],
619
+ max_tokens=512,
620
+ temperature=0.05,
621
+ timeout=10,
622
+ )
623
+ content = resp.choices[0].message.content
624
+ content = re.sub(r"^```json|```$", "", content.strip(), flags=re.MULTILINE).strip()
625
+ return json.loads(content)
626
+ except Exception as e:
627
+ print(f"[AI] Groq selhalo: {e}")
628
+ return None
629
+
630
+
631
+ def ai_extract_hf(ocr_text: str):
632
+ if not HF_TOKEN:
633
+ return None
634
+ try:
635
+ url = "https://api-inference.huggingface.co/models/mistralai/Mistral-7B-Instruct-v0.3"
636
+ headers = {"Authorization": f"Bearer {HF_TOKEN}"}
637
+ prompt = f"<s>[INST] {AI_SYSTEM_PROMPT}\n\n{ocr_text[:3000]} [/INST]"
638
+ payload = {"inputs": prompt, "parameters": {"max_new_tokens": 512, "temperature": 0.05, "return_full_text": False}}
639
+ resp = requests.post(url, headers=headers, json=payload, timeout=45)
640
+ if resp.status_code == 503:
641
+ time.sleep(25)
642
+ resp = requests.post(url, headers=headers, json=payload, timeout=45)
643
+ data = resp.json()
644
+ text = data[0]["generated_text"] if isinstance(data, list) else data.get("generated_text", "")
645
+ text = re.sub(r"^```json|```$", "", text.strip(), flags=re.MULTILINE).strip()
646
+ match = re.search(r"\{.*\}", text, re.DOTALL)
647
+ return json.loads(match.group(0)) if match else None
648
+ except Exception as e:
649
+ print(f"[AI] HF Inference selhalo: {e}")
650
+ return None
651
+
652
+
653
+ def regex_extract(ocr_text: str):
654
+ try:
655
+ inv_number_m = re.search(r"(?:invoice|inv)[#:\s]+([A-Z0-9\-]{4,24})", ocr_text, re.I)
656
+ dates = re.findall(r"\d{1,2}[\/.\-]\d{1,2}[\/.\-]\d{4}", ocr_text)
657
+ total_m = re.search(r"(?:total|amount due)[\s:$]+([0-9,\.]+)", ocr_text, re.I)
658
+ vendor = next((l.strip() for l in ocr_text.splitlines() if l.strip()), "Unknown vendor")
659
+ total = 0.0
660
+ if total_m:
661
+ try:
662
+ total = float(total_m.group(1).replace(",", ""))
663
+ except Exception:
664
+ total = 0.0
665
+ return {
666
+ "vendor": vendor[:120],
667
+ "inv_number": inv_number_m.group(1) if inv_number_m else "",
668
+ "inv_date": dates[0] if dates else "",
669
+ "due_date": dates[1] if len(dates) > 1 else "",
670
+ "amount": total,
671
+ "vat_amount": 0.0,
672
+ "total": total,
673
+ "currency": "USD",
674
+ "line_items": [],
675
+ }
676
+ except Exception as e:
677
+ print(f"[AI] Regex fallback selhalo: {e}")
678
+ return {"vendor": "Unknown", "inv_number": "", "inv_date": "", "due_date": "",
679
+ "amount": 0, "vat_amount": 0, "total": 0, "currency": "USD", "line_items": []}
680
+
681
+
682
+ def run_ai_pipeline(ocr_text: str):
683
+ if not ocr_text.strip():
684
+ data = regex_extract("")
685
+ data["_ai_source"] = "empty_ocr"
686
+ return data
687
+ data = ai_extract_groq(ocr_text)
688
+ if data:
689
+ data["_ai_source"] = "groq"
690
+ return data
691
+ data = ai_extract_hf(ocr_text)
692
+ if data:
693
+ data["_ai_source"] = "huggingface"
694
+ return data
695
+ data = regex_extract(ocr_text)
696
+ data["_ai_source"] = "regex_fallback"
697
+ return data
698
+
699
+
700
+ def validate_invoice(data: dict) -> list:
701
+ """Cross-field validace — vrací list textových warningů."""
702
+ warnings = []
703
+ try:
704
+ amount = float(data.get("amount") or 0)
705
+ vat = float(data.get("vat_amount") or 0)
706
+ total = float(data.get("total") or 0)
707
+ if total > 0 and abs((amount + vat) - total) > 0.10:
708
+ warnings.append(f"Subtotal + DPH ({amount + vat:.2f}) neodpovídá total ({total:.2f}).")
709
+ if amount > 0 and vat / amount > 0.30:
710
+ warnings.append("DPH sazba vyšší než 30 % — zkontroluj ručně.")
711
+ inv_date, due_date = data.get("inv_date"), data.get("due_date")
712
+ if inv_date and due_date:
713
+ try:
714
+ d1 = datetime.fromisoformat(inv_date)
715
+ d2 = datetime.fromisoformat(due_date)
716
+ if d2 < d1:
717
+ warnings.append("Splatnost je dřív než datum vystavení faktury.")
718
+ except Exception:
719
+ pass
720
+ if not data.get("vendor"):
721
+ warnings.append("Nepodařilo se rozpoznat dodavatele.")
722
+ except Exception as e:
723
+ warnings.append(f"Validace selhala: {e}")
724
+ return warnings
725
+
726
+
727
+ def process_invoice_file(user, file_path, filename) -> dict:
728
+ """Kompletní pipeline pro jeden soubor. Vrací dict se všemi daty + warnings."""
729
+ try:
730
+ with open(file_path, "rb") as f:
731
+ file_bytes = f.read()
732
+ if len(file_bytes) > MAX_FILE_SIZE_BYTES:
733
+ return {"error": f"Soubor {filename} přesahuje limit {MAX_FILE_SIZE_MB}MB."}
734
+
735
+ images = file_to_images(file_bytes, filename)
736
+ if not images:
737
+ return {"error": f"Nepodařilo se otevřít soubor {filename} (nepodporovaný formát nebo poškozený soubor)."}
738
+
739
+ ocr_text = extract_text_from_images(images)
740
+ extracted = run_ai_pipeline(ocr_text)
741
+
742
+ warnings = validate_invoice(extracted)
743
+ plan = user.get("plan", "free")
744
+
745
+ is_dup = False
746
+ if plan in ("pro", "enterprise") and extracted.get("vendor") and extracted.get("total"):
747
+ try:
748
+ dup_count = DB.count_duplicate(user["id"], extracted["vendor"], float(extracted["total"] or 0))
749
+ is_dup = dup_count > 0
750
+ except Exception as e:
751
+ print(f"[DUP] kontrola duplicit selhala: {e}")
752
+
753
+ status = "review" if warnings else "done"
754
+ if is_dup:
755
+ status = "duplicate"
756
+
757
+ record = {
758
+ "filename": filename,
759
+ "vendor": extracted.get("vendor", ""),
760
+ "inv_number": extracted.get("inv_number", ""),
761
+ "inv_date": extracted.get("inv_date", ""),
762
+ "due_date": extracted.get("due_date", ""),
763
+ "amount": float(extracted.get("amount") or 0),
764
+ "vat_amount": float(extracted.get("vat_amount") or 0),
765
+ "total": float(extracted.get("total") or 0),
766
+ "currency": extracted.get("currency", "USD"),
767
+ "status": status,
768
+ "is_duplicate": is_dup,
769
+ "confidence": 0.95 if extracted.get("_ai_source") in ("groq", "huggingface") else 0.55,
770
+ "raw_json": extracted,
771
+ }
772
+ DB.save_invoice(user["id"], record)
773
+ record["warnings"] = warnings
774
+ return record
775
+ except Exception as e:
776
+ traceback.print_exc()
777
+ return {"error": f"Zpracování {filename} selhalo: {e}"}
778
+
779
+
780
+ # ===========================================================================
781
+ # CORE LOGIC — AI CHAT AGENT (Pro+)
782
+ # ===========================================================================
783
+ def ai_chat_answer(user, question: str, history: list) -> str:
784
+ try:
785
+ if not question or not question.strip():
786
+ return "Napiš prosím otázku k tvým fakturám."
787
+ if user.get("plan") not in ("pro", "enterprise"):
788
+ return "AI Chat je dostupný od plánu Pro. Upgraduj v sekci ⚡ Upgrade."
789
+ invoices = DB.get_invoices(user["id"])[:200]
790
+ context_rows = [
791
+ f"- {inv.get('vendor')} | č.{inv.get('inv_number')} | {inv.get('inv_date')} | "
792
+ f"total {inv.get('total')} {inv.get('currency')} | status {inv.get('status')}"
793
+ for inv in invoices
794
+ ]
795
+ context = "\n".join(context_rows) if context_rows else "Uživatel zatím nemá žádné faktury."
796
+ if GROQ_API_KEY and GROQ_SDK_OK:
797
+ client = Groq(api_key=GROQ_API_KEY)
798
+ resp = client.chat.completions.create(
799
+ model="llama-3.1-8b-instant",
800
+ messages=[
801
+ {"role": "system", "content": "You are a helpful assistant answering questions about the user's invoices based ONLY on the data provided below. Be concise."},
802
+ {"role": "user", "content": f"Invoices:\n{context}\n\nQuestion: {question}"},
803
+ ],
804
+ max_tokens=400,
805
+ temperature=0.2,
806
+ timeout=15,
807
+ )
808
+ return resp.choices[0].message.content
809
+ return "AI chat momentálně není dostupný (chybí GROQ_API_KEY). Zkus to prosím později."
810
+ except Exception as e:
811
+ traceback.print_exc()
812
+ return f"Chyba AI chatu: {e}"
813
+
814
+
815
+ # ===========================================================================
816
+ # CORE LOGIC — STRIPE PLATBY (bez webhooků, polling)
817
+ # ===========================================================================
818
+ def create_checkout_url(user, plan: str) -> (bool, str):
819
+ try:
820
+ if not (STRIPE_SDK_OK and STRIPE_SECRET_KEY):
821
+ return False, f"Platby momentálně nejsou nastavené. Napiš prosím na support a domluvíme upgrade na {plan} ručně."
822
+ price_id = PLAN_PRICE_IDS.get(plan)
823
+ if not price_id:
824
+ return False, "Neplatný plán."
825
+ session = stripe.checkout.Session.create(
826
+ payment_method_types=["card"],
827
+ line_items=[{"price": price_id, "quantity": 1}],
828
+ mode="subscription",
829
+ success_url=f"{APP_URL}?checkout=success&session_id={{CHECKOUT_SESSION_ID}}",
830
+ cancel_url=f"{APP_URL}?checkout=cancel",
831
+ customer_email=user["email"],
832
+ metadata={"plan": plan, "user_id": str(user["id"])},
833
+ )
834
+ return True, session.url
835
+ except Exception as e:
836
+ traceback.print_exc()
837
+ return False, f"Chyba Stripe checkoutu: {e}"
838
+
839
+
840
+ def poll_payment_status(session_id: str, user_id: int, max_attempts=12, delay=5) -> str:
841
+ if not (STRIPE_SDK_OK and STRIPE_SECRET_KEY):
842
+ return "Platby nejsou nakonfigurované."
843
+ try:
844
+ for _ in range(max_attempts):
845
+ session = stripe.checkout.Session.retrieve(session_id)
846
+ if session.payment_status == "paid":
847
+ plan = session.metadata.get("plan", "basic")
848
+ DB.update_user_plan(user_id, plan, stripe_cid=session.customer)
849
+ return f"✅ Upgradnuto na {plan}!"
850
+ time.sleep(delay)
851
+ return "⏳ Platba zatím nebyla potvrzena. Pokud jsi zaplatil/a, obnov stránku za chvíli."
852
+ except Exception as e:
853
+ traceback.print_exc()
854
+ return f"Chyba při ověřování platby: {e}"
855
+
856
+
857
+ # ===========================================================================
858
+ # CORE LOGIC — EXPORT
859
+ # ===========================================================================
860
+ def export_csv(user) -> str:
861
+ try:
862
+ invoices = DB.get_invoices(user["id"])
863
+ path = f"/tmp/export_{user['id']}_{int(time.time())}.csv"
864
+ import csv
865
+ with open(path, "w", newline="", encoding="utf-8") as f:
866
+ writer = csv.writer(f)
867
+ writer.writerow(["Vendor", "Invoice#", "Invoice Date", "Due Date", "Amount", "VAT", "Total", "Currency", "Status"])
868
+ for inv in invoices:
869
+ writer.writerow([inv.get("vendor"), inv.get("inv_number"), inv.get("inv_date"),
870
+ inv.get("due_date"), inv.get("amount"), inv.get("vat_amount"),
871
+ inv.get("total"), inv.get("currency"), inv.get("status")])
872
+ return path
873
+ except Exception as e:
874
+ traceback.print_exc()
875
+ raise gr.Error(f"Export CSV selhal: {e}")
876
+
877
+
878
+ def export_json(user) -> str:
879
+ try:
880
+ invoices = DB.get_invoices(user["id"])
881
+ path = f"/tmp/export_{user['id']}_{int(time.time())}.json"
882
+ with open(path, "w", encoding="utf-8") as f:
883
+ json.dump(invoices, f, ensure_ascii=False, indent=2, default=str)
884
+ return path
885
+ except Exception as e:
886
+ traceback.print_exc()
887
+ raise gr.Error(f"Export JSON selhal: {e}")
888
+
889
+
890
+ # ===========================================================================
891
+ # GRADIO UI LAYER — od tohoto místa dolů JEN volání CORE funkcí
892
+ # ===========================================================================
893
+ CUSTOM_CSS = """
894
+ .gradio-container {max-width: 1200px !important; margin: auto;}
895
+ .op-hero {text-align:center; padding: 40px 20px;}
896
+ .op-card {border:1px solid #e5e7eb; border-radius:12px; padding:20px; background:white;}
897
+ footer {visibility:hidden}
898
+ """
899
+
900
+ LANDING_HTML = """
901
+ <div style="font-family:Inter,sans-serif;">
902
+ <div style="display:flex;justify-content:space-between;align-items:center;padding:16px 24px;border-bottom:1px solid #eee;">
903
+ <div style="font-size:22px;font-weight:800;">⚡ OmniParse AI</div>
904
+ <div style="color:#666;font-size:14px;">How it works · Features · Pricing · Legal</div>
905
+ </div>
906
+ <div class="op-hero">
907
+ <h1 style="font-size:42px;font-weight:800;margin-bottom:8px;">Invoice processing in seconds, not hours.</h1>
908
+ <p style="font-size:18px;color:#555;max-width:640px;margin:0 auto 20px;">
909
+ AI extracts vendor, dates, amounts and line items from any PDF or image.
910
+ Export to CSV, JSON or Excel. Connect via API.
911
+ </p>
912
+ <p style="color:#888;">99.2% accuracy · &lt;4s per invoice · 40+ formats</p>
913
+ </div>
914
+
915
+ <div class="op-card" style="margin:20px 0;">
916
+ <h2>How it works</h2>
917
+ <ol>
918
+ <li>Upload PDF or image invoice</li>
919
+ <li>AI extracts all data</li>
920
+ <li>Export wherever you need</li>
921
+ </ol>
922
+ </div>
923
+
924
+ <div class="op-card" style="margin:20px 0;">
925
+ <h2>Features</h2>
926
+ <ul>
927
+ <li>🔍 OCR + LLM — Tesseract + Groq Llama 3.1</li>
928
+ <li>🚫 Duplicate Detection — Pro+, catches double payments</li>
929
+ <li>🤖 AI Chat Agent — ask about your invoices in plain English</li>
930
+ <li>✅ Cross-field Validation — checks totals, dates, tax rates</li>
931
+ <li>👥 Human-in-the-loop — Enterprise, manual review of uncertain invoices</li>
932
+ <li>🔌 REST API — connect to your own ERP</li>
933
+ </ul>
934
+ </div>
935
+
936
+ <div class="op-card" style="margin:20px 0;">
937
+ <h2>FAQ</h2>
938
+ <p><b>Is my data safe?</b> Yes — stored in EU (Frankfurt), encrypted at rest, GDPR compliant.</p>
939
+ <p><b>Does it work on Czech invoices?</b> Yes, OCR supports Czech + English.</p>
940
+ <p><b>How do payments work?</b> Monthly subscription via Stripe, cancel anytime.</p>
941
+ <p><b>Can I cancel anytime?</b> Yes, no lock-in contracts.</p>
942
+ <p><b>Do I get a tax invoice?</b> Yes, automatically generated by Stripe after each payment.</p>
943
+ </div>
944
+
945
+ <div style="text-align:center;color:#999;padding:20px;border-top:1px solid #eee;">
946
+ © 2026 OmniParse AI — Terms · Privacy · Disclaimer (viz Legal tab)
947
+ </div>
948
+ </div>
949
+ """
950
+
951
+ LEGAL_TERMS = """
952
+ ### Terms of Use
953
+ OmniParse AI je nástroj pro automatickou extrakci dat z faktur pomocí AI. Používáním služby souhlasíš,
954
+ ž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
955
+ inženýrství. Platby probíhají měsíčně přes Stripe, zrušení kdykoliv v sekci Profile. Neposkytujeme záruku
956
+ 100% přesnosti extrakce — viz Disclaimer.
957
+ """
958
+
959
+ LEGAL_PRIVACY = """
960
+ ### Privacy Policy / GDPR
961
+ **Co sbíráme:** e-mail, jméno, nahrané faktury a z nich extrahovaná data.
962
+ **Kde je to uloženo:** Supabase, EU region (Frankfurt).
963
+ **Jak dlouho:** faktury 30 dní, účetní/fakturační záznamy 10 let (zákonná lhůta).
964
+ **Tvá práva:** přístup k datům, výmaz (Profile → Delete account), přenositelnost dat (Export).
965
+ **Cookies:** pouze technické (session), žádný marketingový tracking.
966
+ """
967
+
968
+ LEGAL_DISCLAIMER = """
969
+ ### Disclaimer
970
+ AI extrakce není 100% přesná — vždy ověř data před zaúčtováním do tvého účetního systému.
971
+ OmniParse nenese odpovědnost za chyby vzniklé nesprávnou AI extrakcí. Toto je nástroj usnadňující práci,
972
+ nikoliv náhrada za kvalifikovaného účetního.
973
+ """
974
+
975
+
976
+ def status_badge(status):
977
+ return {"done": "✅ Done", "review": "⚠️ Review", "duplicate": "🔴 Duplicate", "processing": "⟳ Processing"}.get(status, status)
978
+
979
+
980
+ def invoices_to_dataframe(invoices):
981
+ rows = []
982
+ for inv in invoices:
983
+ rows.append([
984
+ inv.get("id"), inv.get("vendor"), inv.get("inv_number"), inv.get("inv_date"),
985
+ f"{inv.get('total', 0):.2f} {inv.get('currency', '')}", status_badge(inv.get("status")),
986
+ ])
987
+ return rows
988
+
989
+
990
+ with gr.Blocks(css=CUSTOM_CSS, title="OmniParse AI") as demo:
991
+ session_token = gr.State("") # server-side (NE localStorage) — viz resolve_session()
992
+ current_view = gr.State("landing")
993
+
994
+ # ---- VIEW CONTAINERS ----
995
+ with gr.Column(visible=True) as view_landing:
996
+ gr.HTML(LANDING_HTML)
997
+ with gr.Row():
998
+ btn_landing_start = gr.Button("Start Free — 20 invoices →", variant="primary")
999
+ btn_landing_login = gr.Button("Log In")
1000
+ btn_landing_pricing = gr.Button("Pricing")
1001
+ btn_landing_legal = gr.Button("Legal")
1002
+
1003
+ with gr.Column(visible=False) as view_pricing:
1004
+ gr.Markdown("## Pricing")
1005
+ with gr.Row():
1006
+ with gr.Column():
1007
+ gr.Markdown("### Free — $0/mo\n- 20 invoices/mo\n- CSV export\n- 1 user")
1008
+ with gr.Column():
1009
+ gr.Markdown("### Basic — $29/mo\n- 200 invoices/mo\n- JSON+CSV+Excel export\n- Multi-currency")
1010
+ with gr.Column():
1011
+ gr.Markdown("### Pro — $129/mo\n- 2,000 invoices/mo\n- REST API + AI Chat\n- Duplicate detection\n- 3 users")
1012
+ with gr.Column():
1013
+ gr.Markdown("### Enterprise — $499/mo\n- Unlimited invoices\n- Human-in-the-loop\n- SLA 99.5%\n- Unlimited users")
1014
+ gr.Markdown("_Přihlaš se a v Dashboardu → ⚡ Upgrade vyber plán a zaplať kartou přes Stripe._")
1015
+ btn_pricing_back = gr.Button("← Back")
1016
+
1017
+ with gr.Column(visible=False) as view_legal:
1018
+ gr.Markdown("## Legal")
1019
+ with gr.Tab("Terms of Use"):
1020
+ gr.Markdown(LEGAL_TERMS)
1021
+ with gr.Tab("Privacy Policy"):
1022
+ gr.Markdown(LEGAL_PRIVACY)
1023
+ with gr.Tab("Disclaimer"):
1024
+ gr.Markdown(LEGAL_DISCLAIMER)
1025
+ btn_legal_back = gr.Button("← Back")
1026
+
1027
+ with gr.Column(visible=False) as view_auth:
1028
+ gr.Markdown("## Welcome to OmniParse AI")
1029
+ with gr.Tab("Log In"):
1030
+ login_email = gr.Textbox(label="Email")
1031
+ login_password = gr.Textbox(label="Password", type="password")
1032
+ login_btn = gr.Button("Log In", variant="primary")
1033
+ login_msg = gr.Markdown()
1034
+ gr.Markdown("_Demo účet: `demo@omniparse.ai` / `demo1234` (plán Pro)_")
1035
+ with gr.Tab("Sign Up"):
1036
+ signup_name = gr.Textbox(label="Full Name")
1037
+ signup_email = gr.Textbox(label="Work Email")
1038
+ signup_password = gr.Textbox(label="Password (min. 8 znaků)", type="password")
1039
+ signup_terms = gr.Checkbox(label="Souhlasím s Terms of Use a Privacy Policy")
1040
+ signup_btn = gr.Button("Sign Up", variant="primary")
1041
+ signup_msg = gr.Markdown()
1042
+ btn_auth_back = gr.Button("← Back to landing")
1043
+
1044
+ with gr.Column(visible=False) as view_dashboard:
1045
+ with gr.Row():
1046
+ gr.Markdown("## Dashboard")
1047
+ btn_logout = gr.Button("🚪 Log Out", size="sm")
1048
+ user_info_md = gr.Markdown()
1049
+
1050
+ with gr.Tab("📤 Upload"):
1051
+ upload_files = gr.File(label="Nahraj faktury (PDF/JPG/PNG/TIFF, max 20MB/soubor)", file_count="multiple")
1052
+ upload_btn = gr.Button("Zpracovat faktury", variant="primary")
1053
+ upload_status = gr.Markdown()
1054
+ upload_results = gr.Dataframe(headers=["ID", "Vendor", "Invoice#", "Date", "Total", "Status"], label="Výsledky")
1055
+ upload_raw_json = gr.JSON(label="Raw AI output (poslední soubor)")
1056
+
1057
+ with gr.Tab("📋 My Invoices"):
1058
+ refresh_invoices_btn = gr.Button("🔄 Obnovit")
1059
+ invoices_table = gr.Dataframe(headers=["ID", "Vendor", "Invoice#", "Date", "Total", "Status"], label="Faktury")
1060
+
1061
+ with gr.Tab("🤖 AI Chat (Pro+)"):
1062
+ chat_history = gr.Chatbot(label="Zeptej se na své faktury", type="messages")
1063
+ chat_input = gr.Textbox(label="Otázka", placeholder="What's the total unpaid amount?")
1064
+ chat_send = gr.Button("Odeslat")
1065
+
1066
+ with gr.Tab("📊 Export"):
1067
+ gr.Markdown("CSV export je zdarma pro všechny. JSON od plánu Basic+.")
1068
+ export_csv_btn = gr.Button("Export CSV")
1069
+ export_csv_file = gr.File(label="Stáhnout CSV")
1070
+ export_json_btn = gr.Button("Export JSON (Basic+)")
1071
+ export_json_file = gr.File(label="Stáhnout JSON")
1072
+ gr.Markdown("Excel export a Google Sheets sync: **Coming soon** 🚧")
1073
+
1074
+ with gr.Tab("⚡ Upgrade"):
1075
+ plan_dropdown = gr.Dropdown(["basic", "pro", "enterprise"], label="Vyber plán", value="basic")
1076
+ upgrade_btn = gr.Button("Přejít na platbu (Stripe)", variant="primary")
1077
+ upgrade_link = gr.Markdown()
1078
+ gr.Markdown("Test karta ve Stripe test mode: `4242 4242 4242 4242`, libovolné datum/CVC.")
1079
+ checkout_session_input = gr.Textbox(label="Po zaplacení: vlož session_id z URL a klikni níže", visible=True)
1080
+ confirm_payment_btn = gr.Button("Ověřit platbu")
1081
+ payment_status_md = gr.Markdown()
1082
+
1083
+ with gr.Tab("🔌 API (Pro+)"):
1084
+ api_key_display = gr.Markdown()
1085
+ gr.Markdown("""
1086
+ ```bash
1087
+ curl -X POST https://tvuj-space.hf.space/api/extract \\
1088
+ -H "Authorization: Bearer TVUJ_API_KLIC" \\
1089
+ -F "file=@faktura.pdf"
1090
+ ```
1091
+ _(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()`.)_
1092
+ """)
1093
+
1094
+ with gr.Tab("👤 Profile"):
1095
+ profile_info = gr.Markdown()
1096
+ new_password = gr.Textbox(label="Nové heslo", type="password")
1097
+ change_pw_btn = gr.Button("Změnit heslo")
1098
+ change_pw_msg = gr.Markdown()
1099
+ gr.Markdown("### ⚠️ Danger zone")
1100
+ delete_confirm = gr.Checkbox(label="Ano, opravdu chci smazat účet a všechna data")
1101
+ delete_btn = gr.Button("Smazat účet natrvalo", variant="stop")
1102
+ delete_msg = gr.Markdown()
1103
+
1104
+ ALL_VIEWS = [view_landing, view_pricing, view_legal, view_auth, view_dashboard]
1105
+
1106
+ def switch_view(target):
1107
+ return [gr.update(visible=(v == target)) for v in ["landing", "pricing", "legal", "auth", "dashboard"]]
1108
+
1109
+ # ---- NAVIGACE ----
1110
+ btn_landing_start.click(lambda: switch_view("auth"), outputs=ALL_VIEWS)
1111
+ btn_landing_login.click(lambda: switch_view("auth"), outputs=ALL_VIEWS)
1112
+ btn_landing_pricing.click(lambda: switch_view("pricing"), outputs=ALL_VIEWS)
1113
+ btn_landing_legal.click(lambda: switch_view("legal"), outputs=ALL_VIEWS)
1114
+ btn_pricing_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1115
+ btn_legal_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1116
+ btn_auth_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1117
+
1118
+ # ---- AUTH HANDLERY ----
1119
+ def handle_signup(name, email, password, terms):
1120
+ ok, msg, token = signup(name, email, password, terms)
1121
+ if ok:
1122
+ user = resolve_session(token)
1123
+ info = f"✅ Přihlášen jako **{user['name']}** ({user['email']}) — plán **{user['plan']}**"
1124
+ views = switch_view("dashboard")
1125
+ return [msg, token, info] + views
1126
+ views = switch_view("auth")
1127
+ return [msg, "", ""] + views
1128
+
1129
+ signup_btn.click(
1130
+ handle_signup,
1131
+ inputs=[signup_name, signup_email, signup_password, signup_terms],
1132
+ outputs=[signup_msg, session_token, user_info_md] + ALL_VIEWS,
1133
+ )
1134
+
1135
+ def handle_login(email, password):
1136
+ ok, msg, token = login(email, password)
1137
+ if ok:
1138
+ user = resolve_session(token)
1139
+ info = f"✅ Přihlášen jako **{user['name']}** ({user['email']}) — plán **{user['plan']}**"
1140
+ views = switch_view("dashboard")
1141
+ return [msg, token, info] + views
1142
+ views = switch_view("auth")
1143
+ return [msg, "", ""] + views
1144
+
1145
+ login_btn.click(
1146
+ handle_login,
1147
+ inputs=[login_email, login_password],
1148
+ outputs=[login_msg, session_token, user_info_md] + ALL_VIEWS,
1149
+ )
1150
+
1151
+ def handle_logout(token):
1152
+ logout(token)
1153
+ views = switch_view("landing")
1154
+ return [""] + views
1155
+
1156
+ btn_logout.click(handle_logout, inputs=[session_token], outputs=[session_token] + ALL_VIEWS)
1157
+
1158
+ # ---- UPLOAD ----
1159
+ def handle_upload(token, files):
1160
+ user = resolve_session(token)
1161
+ if not user:
1162
+ return "❌ Nejsi přihlášen/a. Přihlas se prosím znovu.", [], {}
1163
+ if not files:
1164
+ return "⚠️ Nevybral/a jsi žádný soubor.", [], {}
1165
+ used = DB.count_invoices_this_month(user["id"])
1166
+ limit = PLAN_LIMITS.get(user["plan"], 20)
1167
+ if used >= limit:
1168
+ return f"🔴 Vyčerpal/a jsi měsíční limit ({int(limit) if limit != float('inf') else '∞'} faktur). Upgraduj v sekci ⚡ Upgrade.", [], {}
1169
+
1170
+ results, last_json, errors = [], {}, []
1171
+ for f in files:
1172
+ if used >= limit:
1173
+ errors.append(f"Limit dosažen, {os.path.basename(f.name)} přeskočen.")
1174
+ break
1175
+ record = process_invoice_file(user, f.name, os.path.basename(f.name))
1176
+ if "error" in record:
1177
+ errors.append(record["error"])
1178
+ continue
1179
+ used += 1
1180
+ last_json = record.get("raw_json", {})
1181
+ results.append([None, record["vendor"], record["inv_number"], record["inv_date"],
1182
+ f"{record['total']:.2f} {record['currency']}", status_badge(record["status"])])
1183
+
1184
+ msg = f"✅ Zpracováno {len(results)} faktur. Použito {used}/{int(limit) if limit != float('inf') else '∞'} tento měsíc."
1185
+ if errors:
1186
+ msg += "\n\n⚠️ Chyby:\n" + "\n".join(f"- {e}" for e in errors)
1187
+ return msg, results, last_json
1188
+
1189
+ upload_btn.click(handle_upload, inputs=[session_token, upload_files],
1190
+ outputs=[upload_status, upload_results, upload_raw_json])
1191
+
1192
+ # ---- MY INVOICES ----
1193
+ def handle_refresh_invoices(token):
1194
+ user = resolve_session(token)
1195
+ if not user:
1196
+ return []
1197
+ return invoices_to_dataframe(DB.get_invoices(user["id"]))
1198
+
1199
+ refresh_invoices_btn.click(handle_refresh_invoices, inputs=[session_token], outputs=[invoices_table])
1200
+
1201
+ # ---- AI CHAT ----
1202
+ def handle_chat(token, message, history):
1203
+ user = resolve_session(token)
1204
+ if not user:
1205
+ history = history or []
1206
+ history.append({"role": "assistant", "content": "Nejsi přihlášen/a."})
1207
+ return history, ""
1208
+ answer = ai_chat_answer(user, message, history)
1209
+ history = history or []
1210
+ history.append({"role": "user", "content": message})
1211
+ history.append({"role": "assistant", "content": answer})
1212
+ return history, ""
1213
+
1214
+ chat_send.click(handle_chat, inputs=[session_token, chat_input, chat_history], outputs=[chat_history, chat_input])
1215
+
1216
+ # ---- EXPORT ----
1217
+ def handle_export_csv(token):
1218
+ user = resolve_session(token)
1219
+ if not user:
1220
+ raise gr.Error("Nejsi přihlášen/a.")
1221
+ return export_csv(user)
1222
+
1223
+ export_csv_btn.click(handle_export_csv, inputs=[session_token], outputs=[export_csv_file])
1224
+
1225
+ def handle_export_json(token):
1226
+ user = resolve_session(token)
1227
+ if not user:
1228
+ raise gr.Error("Nejsi přihlášen/a.")
1229
+ if user["plan"] == "free":
1230
+ raise gr.Error("JSON export je dostupný od plánu Basic. Upgraduj v sekci ⚡ Upgrade.")
1231
+ return export_json(user)
1232
+
1233
+ export_json_btn.click(handle_export_json, inputs=[session_token], outputs=[export_json_file])
1234
+
1235
+ # ---- UPGRADE / STRIPE ----
1236
+ def handle_upgrade(token, plan):
1237
+ user = resolve_session(token)
1238
+ if not user:
1239
+ return "❌ Nejsi přihlášen/a."
1240
+ ok, result = create_checkout_url(user, plan)
1241
+ if ok:
1242
+ return f"[Klikni pro dokončení platby ve Stripe →]({result})"
1243
+ return f"⚠️ {result}"
1244
+
1245
+ upgrade_btn.click(handle_upgrade, inputs=[session_token, plan_dropdown], outputs=[upgrade_link])
1246
+
1247
+ def handle_confirm_payment(token, session_id):
1248
+ user = resolve_session(token)
1249
+ if not user:
1250
+ return "❌ Nejsi přihlášen/a."
1251
+ if not session_id:
1252
+ return "Vlož prosím session_id z URL po návratu ze Stripe."
1253
+ return poll_payment_status(session_id, user["id"])
1254
+
1255
+ confirm_payment_btn.click(handle_confirm_payment, inputs=[session_token, checkout_session_input], outputs=[payment_status_md])
1256
+
1257
+ # ---- API KEY DISPLAY ----
1258
+ def handle_show_api_key(token):
1259
+ user = resolve_session(token)
1260
+ if not user:
1261
+ return "Nejsi přihlášen/a."
1262
+ if user["plan"] not in ("pro", "enterprise"):
1263
+ return "🔒 API přístup je dostupný od plánu Pro. Upgraduj v sekci ⚡ Upgrade."
1264
+ return f"**Tvůj API klíč:** `{user.get('api_key', 'N/A')}`\n\n⚠️ Nikdy ho nesdílej veřejně."
1265
+
1266
+ # ---- PROFILE ----
1267
+ def handle_change_password(token, new_pw):
1268
+ user = resolve_session(token)
1269
+ if not user:
1270
+ return "❌ Nejsi přihlášen/a."
1271
+ ok, msg = password_strength_ok(new_pw)
1272
+ if not ok:
1273
+ return f"⚠️ {msg}"
1274
+ if is_password_leaked(new_pw):
1275
+ return "⚠️ Toto heslo bylo nalezeno v uniklých databázích. Zvol jiné."
1276
+ try:
1277
+ DB.update_password(user["id"], hash_password(new_pw))
1278
+ return "✅ Heslo změněno."
1279
+ except Exception as e:
1280
+ return f"❌ Chyba: {e}"
1281
+
1282
+ change_pw_btn.click(handle_change_password, inputs=[session_token, new_password], outputs=[change_pw_msg])
1283
+
1284
+ def handle_delete_account(token, confirmed):
1285
+ user = resolve_session(token)
1286
+ if not user:
1287
+ return "❌ Nejsi přihlášen/a.", token
1288
+ if not confirmed:
1289
+ return "⚠️ Zaškrtni prosím potvrzení.", token
1290
+ try:
1291
+ DB.delete_user(user["id"])
1292
+ return "✅ Účet smazán. Sbohem!", ""
1293
+ except Exception as e:
1294
+ return f"❌ Chyba při mazání: {e}", token
1295
+
1296
+ delete_btn.click(handle_delete_account, inputs=[session_token, delete_confirm], outputs=[delete_msg, session_token])
1297
+
1298
+ # ---- Při vstupu do dashboardu doplníme profil / API klíč / faktury ----
1299
+ def on_dashboard_enter(token):
1300
+ user = resolve_session(token)
1301
+ if not user:
1302
+ return "", "", []
1303
+ profile = f"**Jméno:** {user['name']}\n\n**Email:** {user['email']}\n\n**Plán:** {user['plan']}"
1304
+ api_txt = handle_show_api_key(token)
1305
+ invoices = invoices_to_dataframe(DB.get_invoices(user["id"]))
1306
+ return profile, api_txt, invoices
1307
+
1308
+ session_token.change(on_dashboard_enter, inputs=[session_token], outputs=[profile_info, api_key_display, invoices_table])
1309
+
1310
+
1311
+ if __name__ == "__main__":
1312
+ try:
1313
+ demo.queue(max_size=20).launch(server_name="0.0.0.0", server_port=7860)
1314
+ except Exception as e:
1315
+ print(f"[FATAL] Aplikace se nepodařila spustit: {e}")
1316
+ traceback.print_exc()