simikkk commited on
Commit
208d2cb
·
verified ·
1 Parent(s): d9eba95

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1316
app.py DELETED
@@ -1,1316 +0,0 @@
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()