Shaikhsarib commited on
Commit
5326ed3
·
verified ·
1 Parent(s): 96e95e7

Delete main.py

Browse files
Files changed (1) hide show
  1. main.py +0 -1149
main.py DELETED
@@ -1,1149 +0,0 @@
1
- # Eatlytic v4 - Single file for HuggingFace Spaces
2
- # ══════════════════════════════════════════════════════════════════════
3
- # IMPORTS
4
- # ══════════════════════════════════════════════════════════════════════
5
- import os, re, json, asyncio, logging, hashlib, datetime, secrets
6
- import sqlite3, threading, base64, hmac, uuid
7
- from contextlib import contextmanager
8
- from io import BytesIO
9
-
10
- import numpy as np
11
- import cv2
12
- from PIL import Image, ImageDraw, ImageFont
13
- from fastapi import FastAPI, File, UploadFile, Form, Request, HTTPException, Security
14
- from fastapi.middleware.cors import CORSMiddleware
15
- from fastapi.responses import FileResponse, JSONResponse, Response
16
- from fastapi.security import APIKeyHeader
17
- from slowapi import Limiter, _rate_limit_exceeded_handler
18
- from slowapi.util import get_remote_address
19
- from slowapi.errors import RateLimitExceeded
20
-
21
- try:
22
- from duckduckgo_search import DDGS as _DDGS; _DDGS_OK = True
23
- except Exception:
24
- _DDGS = None; _DDGS_OK = False
25
-
26
- logging.basicConfig(level=logging.INFO)
27
- logger = logging.getLogger(__name__)
28
-
29
- # ══════════════════════════════════════════════════════════════════════
30
- # CONFIGURATION
31
- # ══════════════════════════════════════════════════════════════════════
32
- FREE_SCAN_LIMIT = int(os.environ.get("FREE_SCAN_LIMIT", "10"))
33
- GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
34
- ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "changeme")
35
- MAX_IMAGE_BYTES = 10 * 1024 * 1024
36
-
37
- RAZORPAY_KEY_ID = os.environ.get("RAZORPAY_KEY_ID", "")
38
- RAZORPAY_KEY_SECRET = os.environ.get("RAZORPAY_KEY_SECRET", "")
39
-
40
- if ADMIN_TOKEN == "changeme":
41
- logger.warning("⚠️ ADMIN_TOKEN is default — set it in HuggingFace Secrets")
42
- if not GROQ_API_KEY:
43
- logger.warning("⚠️ GROQ_API_KEY missing — all analysis will fail")
44
-
45
- MEDICAL_DISCLAIMER = (
46
- "⚕️ For informational purposes only — not medical advice. "
47
- "Consult a qualified nutritionist or physician before making dietary decisions."
48
- )
49
- LANGUAGE_MAP = {
50
- "en": "English", "zh": "Simplified Chinese", "es": "Spanish",
51
- "ar": "Arabic", "fr": "French", "hi": "Hindi (हिन्दी)",
52
- "pt": "Portuguese", "de": "German",
53
- }
54
-
55
- # ══════════════════════════════════════════════════════════════════════
56
- # DATABASE
57
- # ══════════════════════════════════════════════════════════════════════
58
- DATA_DIR = os.path.join(os.getcwd(), "data")
59
- CACHE_DIR = os.environ.get("HF_HOME", "/app/.cache")
60
- MODEL_DIR = os.path.join(CACHE_DIR, "easyocr_models")
61
- os.makedirs(DATA_DIR, exist_ok=True)
62
- os.makedirs(MODEL_DIR, exist_ok=True)
63
-
64
- DB_FILE = os.path.join(DATA_DIR, "eatlytic.db")
65
-
66
- def _get_connection():
67
- conn = sqlite3.connect(DB_FILE, check_same_thread=False, timeout=15)
68
- conn.row_factory = sqlite3.Row
69
- conn.execute("PRAGMA journal_mode=WAL")
70
- conn.execute("PRAGMA foreign_keys=ON")
71
- conn.execute("PRAGMA synchronous=NORMAL")
72
- return conn
73
-
74
- @contextmanager
75
- def db_conn():
76
- conn = _get_connection()
77
- try:
78
- yield conn; conn.commit()
79
- except Exception:
80
- conn.rollback(); raise
81
- finally:
82
- conn.close()
83
-
84
- def init_db():
85
- with db_conn() as conn:
86
- conn.executescript("""
87
- CREATE TABLE IF NOT EXISTS users (
88
- id TEXT PRIMARY KEY, email TEXT UNIQUE, phone TEXT UNIQUE,
89
- name TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')),
90
- is_pro INTEGER DEFAULT 0, pro_expires TEXT,
91
- scan_count_month INTEGER DEFAULT 0, scan_month TEXT DEFAULT '',
92
- streak_days INTEGER DEFAULT 0, last_scan_date TEXT DEFAULT '',
93
- tdee REAL DEFAULT 0, persona TEXT DEFAULT 'General Adult',
94
- language TEXT DEFAULT 'en', onboarding_done INTEGER DEFAULT 0
95
- );
96
- CREATE TABLE IF NOT EXISTS sessions (
97
- token TEXT PRIMARY KEY, user_id TEXT NOT NULL,
98
- created_at TEXT DEFAULT (datetime('now')),
99
- expires_at TEXT NOT NULL, device_hint TEXT DEFAULT ''
100
- );
101
- CREATE TABLE IF NOT EXISTS devices (
102
- device_key TEXT PRIMARY KEY, user_id TEXT,
103
- created_at TEXT DEFAULT (datetime('now')),
104
- is_pro INTEGER DEFAULT 0, month TEXT DEFAULT '',
105
- scan_count INTEGER DEFAULT 0, streak_days INTEGER DEFAULT 0,
106
- last_scan_date TEXT DEFAULT '', persona TEXT DEFAULT 'General Adult',
107
- language TEXT DEFAULT 'en', tdee REAL DEFAULT 0,
108
- onboarding_done INTEGER DEFAULT 0
109
- );
110
- CREATE TABLE IF NOT EXISTS scans (
111
- id INTEGER PRIMARY KEY AUTOINCREMENT,
112
- user_id TEXT, device_key TEXT,
113
- product_name TEXT DEFAULT 'Unknown', score INTEGER DEFAULT 0,
114
- verdict TEXT DEFAULT '', calories REAL DEFAULT 0,
115
- protein REAL DEFAULT 0, carbs REAL DEFAULT 0, fat REAL DEFAULT 0,
116
- sodium REAL DEFAULT 0, fiber REAL DEFAULT 0, sugar REAL DEFAULT 0,
117
- persona TEXT DEFAULT '', language TEXT DEFAULT 'en',
118
- scanned_at TEXT DEFAULT (datetime('now')), analysis_json TEXT DEFAULT '{}'
119
- );
120
- CREATE TABLE IF NOT EXISTS daily_logs (
121
- id INTEGER PRIMARY KEY AUTOINCREMENT,
122
- user_id TEXT, device_key TEXT, log_date TEXT NOT NULL,
123
- meal_name TEXT DEFAULT '', calories REAL DEFAULT 0,
124
- protein REAL DEFAULT 0, carbs REAL DEFAULT 0, fat REAL DEFAULT 0,
125
- sodium REAL DEFAULT 0, fiber REAL DEFAULT 0, sugar REAL DEFAULT 0,
126
- source TEXT DEFAULT 'scan', logged_at TEXT DEFAULT (datetime('now'))
127
- );
128
- CREATE TABLE IF NOT EXISTS allergen_profiles (
129
- device_key TEXT PRIMARY KEY, user_id TEXT,
130
- allergens TEXT DEFAULT '[]', conditions TEXT DEFAULT '[]',
131
- updated_at TEXT DEFAULT (datetime('now'))
132
- );
133
- CREATE TABLE IF NOT EXISTS food_products (
134
- id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT UNIQUE,
135
- name TEXT NOT NULL, brand TEXT DEFAULT '', category TEXT DEFAULT '',
136
- calories_100g REAL DEFAULT 0, protein_100g REAL DEFAULT 0,
137
- carbs_100g REAL DEFAULT 0, fat_100g REAL DEFAULT 0,
138
- sodium_100g REAL DEFAULT 0, fiber_100g REAL DEFAULT 0,
139
- sugar_100g REAL DEFAULT 0, sat_fat_100g REAL DEFAULT 0,
140
- eatlytic_score INTEGER DEFAULT 0, ingredients_raw TEXT DEFAULT '',
141
- source TEXT DEFAULT 'llm_scan', scan_count INTEGER DEFAULT 0,
142
- verified INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')),
143
- updated_at TEXT DEFAULT (datetime('now'))
144
- );
145
- CREATE TABLE IF NOT EXISTS benchmarks (
146
- id INTEGER PRIMARY KEY AUTOINCREMENT, product_name TEXT NOT NULL,
147
- ground_truth_json TEXT NOT NULL, llm_output_json TEXT DEFAULT '{}',
148
- ocr_text TEXT DEFAULT '', f1_score REAL DEFAULT 0,
149
- score_delta REAL DEFAULT 0, field_accuracy TEXT DEFAULT '{}',
150
- tested_at TEXT DEFAULT (datetime('now')), model_used TEXT DEFAULT ''
151
- );
152
- CREATE TABLE IF NOT EXISTS nps_responses (
153
- id INTEGER PRIMARY KEY AUTOINCREMENT, device_key TEXT, user_id TEXT,
154
- score INTEGER NOT NULL, comment TEXT DEFAULT '',
155
- submitted_at TEXT DEFAULT (datetime('now'))
156
- );
157
- CREATE TABLE IF NOT EXISTS payments (
158
- id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, device_key TEXT,
159
- razorpay_order_id TEXT UNIQUE, razorpay_payment_id TEXT UNIQUE,
160
- razorpay_signature TEXT DEFAULT '', amount_paise INTEGER DEFAULT 19900,
161
- currency TEXT DEFAULT 'INR', status TEXT DEFAULT 'created',
162
- plan TEXT DEFAULT 'pro_monthly',
163
- created_at TEXT DEFAULT (datetime('now')), paid_at TEXT DEFAULT NULL
164
- );
165
- CREATE TABLE IF NOT EXISTS api_keys (
166
- api_key TEXT PRIMARY KEY, client_name TEXT NOT NULL,
167
- plan TEXT DEFAULT 'business', scans_this_month INTEGER DEFAULT 0,
168
- month TEXT DEFAULT '', active INTEGER DEFAULT 1,
169
- created_at TEXT DEFAULT (datetime('now'))
170
- );
171
- CREATE TABLE IF NOT EXISTS ocr_cache (
172
- cache_key TEXT PRIMARY KEY, result_json TEXT NOT NULL,
173
- created_at TEXT DEFAULT (datetime('now'))
174
- );
175
- CREATE TABLE IF NOT EXISTS ai_cache (
176
- cache_key TEXT PRIMARY KEY, result_json TEXT NOT NULL,
177
- created_at TEXT DEFAULT (datetime('now'))
178
- );
179
- CREATE INDEX IF NOT EXISTS idx_scans_device ON scans(device_key);
180
- CREATE INDEX IF NOT EXISTS idx_daily_dev_date ON daily_logs(device_key, log_date);
181
- CREATE INDEX IF NOT EXISTS idx_food_name ON food_products(name);
182
- """)
183
- logger.info("Database ready: %s", DB_FILE)
184
-
185
- def _get_ocr_cache(key):
186
- try:
187
- with db_conn() as c:
188
- row = c.execute("SELECT result_json FROM ocr_cache WHERE cache_key=?", (key,)).fetchone()
189
- return json.loads(row["result_json"]) if row else None
190
- except Exception: return None
191
-
192
- def _set_ocr_cache(key, val):
193
- try:
194
- with db_conn() as c:
195
- c.execute("INSERT OR REPLACE INTO ocr_cache(cache_key,result_json) VALUES(?,?)",
196
- (key, json.dumps(val)))
197
- except Exception as e: logger.warning("ocr_cache set: %s", e)
198
-
199
- def _get_ai_cache(key):
200
- try:
201
- with db_conn() as c:
202
- row = c.execute("SELECT result_json FROM ai_cache WHERE cache_key=?", (key,)).fetchone()
203
- return json.loads(row["result_json"]) if row else None
204
- except Exception: return None
205
-
206
- def _set_ai_cache(key, val):
207
- try:
208
- with db_conn() as c:
209
- c.execute("INSERT OR REPLACE INTO ai_cache(cache_key,result_json) VALUES(?,?)",
210
- (key, json.dumps(val)))
211
- except Exception as e: logger.warning("ai_cache set: %s", e)
212
-
213
- init_db()
214
-
215
- # ══════════════════════════════════════════════════════════════════════
216
- # AUTH
217
- # ══════════════════════════════════════════════════════════════════════
218
- SESSION_TTL_DAYS = 30
219
- _pending_otps: dict = {}
220
-
221
- def _get_or_create_user(email=None, phone=None, name=""):
222
- if not email and not phone:
223
- raise ValueError("email or phone required")
224
- with db_conn() as conn:
225
- row = conn.execute(
226
- "SELECT * FROM users WHERE email=?" if email else "SELECT * FROM users WHERE phone=?",
227
- (email or phone,)
228
- ).fetchone()
229
- if row: return dict(row)
230
- uid = str(uuid.uuid4())
231
- conn.execute("INSERT INTO users(id,email,phone,name) VALUES(?,?,?,?)",
232
- (uid, email, phone, name))
233
- return {"id": uid, "email": email, "phone": phone, "name": name,
234
- "is_pro": 0, "streak_days": 0, "scan_count_month": 0}
235
-
236
- def _create_session(user_id, device_hint=""):
237
- token = "eat_" + secrets.token_urlsafe(40)
238
- expires = (datetime.datetime.utcnow() + datetime.timedelta(days=SESSION_TTL_DAYS)).isoformat()
239
- with db_conn() as conn:
240
- conn.execute("INSERT INTO sessions(token,user_id,expires_at,device_hint) VALUES(?,?,?,?)",
241
- (token, user_id, expires, device_hint))
242
- return token
243
-
244
- def _get_user_from_token(token):
245
- if not token: return None
246
- with db_conn() as conn:
247
- row = conn.execute(
248
- "SELECT u.* FROM sessions s JOIN users u ON s.user_id=u.id WHERE s.token=? AND s.expires_at>datetime('now')",
249
- (token,)
250
- ).fetchone()
251
- return dict(row) if row else None
252
-
253
- def _send_otp(email):
254
- otp = str(secrets.randbelow(900000) + 100000)
255
- expires = datetime.datetime.utcnow() + datetime.timedelta(minutes=10)
256
- _pending_otps[email.lower()] = (otp, expires)
257
- logger.info("OTP for %s: %s (dev mode)", email, otp)
258
- return otp
259
-
260
- def _verify_otp(email, otp):
261
- key = email.lower()
262
- entry = _pending_otps.get(key)
263
- if not entry: return None
264
- stored, expires = entry
265
- if datetime.datetime.utcnow() > expires:
266
- del _pending_otps[key]; return None
267
- if stored != otp.strip(): return None
268
- del _pending_otps[key]
269
- return _get_or_create_user(email=email)
270
-
271
- def _check_scan_quota_user(user_id):
272
- month_key = datetime.date.today().isoformat()[:7]
273
- with db_conn() as conn:
274
- row = conn.execute(
275
- "SELECT is_pro, scan_month, scan_count_month FROM users WHERE id=?", (user_id,)
276
- ).fetchone()
277
- if not row: return {"allowed": False, "scans_used": 0, "scans_remaining": 0, "is_pro": False}
278
- if row["scan_month"] != month_key:
279
- conn.execute("UPDATE users SET scan_month=?, scan_count_month=0 WHERE id=?", (month_key, user_id))
280
- count = 0
281
- else: count = row["scan_count_month"]
282
- if row["is_pro"]:
283
- conn.execute("UPDATE users SET scan_count_month=scan_count_month+1 WHERE id=?", (user_id,))
284
- return {"allowed": True, "scans_used": count+1, "scans_remaining": 9999, "is_pro": True}
285
- if count >= FREE_SCAN_LIMIT:
286
- return {"allowed": False, "scans_used": count, "scans_remaining": 0, "is_pro": False}
287
- conn.execute("UPDATE users SET scan_count_month=scan_count_month+1 WHERE id=?", (user_id,))
288
- new = count + 1
289
- return {"allowed": True, "scans_used": new, "scans_remaining": FREE_SCAN_LIMIT - new, "is_pro": False}
290
-
291
- def _update_streak_user(user_id):
292
- today = datetime.date.today().isoformat()
293
- yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
294
- with db_conn() as conn:
295
- row = conn.execute("SELECT streak_days, last_scan_date FROM users WHERE id=?", (user_id,)).fetchone()
296
- if not row or row["last_scan_date"] == today: return
297
- streak = (row["streak_days"] + 1) if row["last_scan_date"] == yesterday else 1
298
- conn.execute("UPDATE users SET streak_days=?, last_scan_date=? WHERE id=?", (streak, today, user_id))
299
-
300
- # ══════════════════════════════════════════════════════════════════════
301
- # IMAGE PROCESSING
302
- # ══════════════════════════════════════════════════════════════════════
303
- def validate_image(content):
304
- if len(content) > MAX_IMAGE_BYTES:
305
- raise ValueError(f"Image too large ({len(content)//1024}KB). Max 10MB.")
306
- try:
307
- img = Image.open(BytesIO(content)).convert("RGB")
308
- except Exception:
309
- raise ValueError("Invalid image format. Upload JPEG, PNG, or WebP.")
310
- w, h = img.size
311
- if max(w, h) > 2048:
312
- ratio = 2048 / max(w, h)
313
- img = img.resize((int(w*ratio), int(h*ratio)), Image.LANCZOS)
314
- buf = BytesIO(); img.save(buf, format="JPEG", quality=92)
315
- return buf.getvalue()
316
- return content
317
-
318
- def assess_image_quality(content):
319
- try:
320
- img_np = np.array(Image.open(BytesIO(content)).convert("RGB"))
321
- gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
322
- lap = float(cv2.Laplacian(gray, cv2.CV_64F).var())
323
- gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
324
- gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
325
- ten = float(np.mean(gx**2 + gy**2))
326
- diff = gray[:, 2:].astype(np.float64) - gray[:, :-2].astype(np.float64)
327
- bren = float(np.mean(diff**2))
328
- h, w = gray.shape
329
- scores = [cv2.Laplacian(gray[y:y+64, x:x+64], cv2.CV_64F).var()
330
- for y in range(0, h-64, 64) for x in range(0, w-64, 64)]
331
- loc = float(np.median(scores)) if scores else 0.0
332
- comp = (0.25*min(lap/300*100,100) + 0.20*min(ten/500*100,100) +
333
- 0.20*min(bren/200*100,100) + 0.35*min(loc/300*100,100))
334
- if comp < 15: sev, blur = "severe", True
335
- elif comp < 35: sev, blur = "moderate", True
336
- elif comp < 55: sev, blur = "mild", True
337
- else: sev, blur = "none", False
338
- return {"blur_score": round(comp,2), "is_blurry": blur, "blur_severity": sev,
339
- "quality": "poor" if comp<35 else ("fair" if comp<55 else "good")}
340
- except Exception as e:
341
- logger.error("Blur detection: %s", e)
342
- return {"blur_score": 999, "is_blurry": False, "blur_severity": "unknown", "quality": "unknown"}
343
-
344
- def deblur_and_enhance(content, severity="moderate"):
345
- img_np = np.array(Image.open(BytesIO(content)).convert("RGB"))
346
- log = []
347
- h, w = img_np.shape[:2]
348
- if min(h, w) < 1200:
349
- s = 1200/min(h,w)
350
- img_np = cv2.resize(img_np, (int(w*s), int(h*s)), interpolation=cv2.INTER_LANCZOS4)
351
- log.append("upscale")
352
- if severity in ("severe","moderate"):
353
- bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
354
- img_np = cv2.cvtColor(cv2.fastNlMeansDenoisingColored(bgr, None, 8 if severity=="severe" else 5, 8 if severity=="severe" else 5, 7, 21), cv2.COLOR_BGR2RGB)
355
- log.append("NLM")
356
- if severity != "mild":
357
- gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
358
- psf_s = 9 if severity=="severe" else 5
359
- kr = 0.01 if severity=="severe" else 0.025
360
- psf = cv2.getGaussianKernel(psf_s, psf_s/3.0); psf = psf@psf.T; psf/=psf.sum()
361
- pad = np.zeros_like(gray, dtype=np.float64)
362
- pad[:psf.shape[0],:psf.shape[1]] = psf
363
- pad = np.roll(np.roll(pad, -psf.shape[0]//2, 0), -psf.shape[1]//2, 1)
364
- Y = np.fft.fft2(gray.astype(np.float64)/255.0); H = np.fft.fft2(pad)
365
- W = np.conj(H)/(np.abs(H)**2+kr)
366
- rest = np.clip(np.real(np.fft.ifft2(W*Y))*255.0, 0, 255).astype(np.uint8)
367
- lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB); lab[:,:,0]=rest
368
- img_np = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB); log.append(f"Wiener(psf={psf_s})")
369
- sm = {"severe":2.2,"moderate":1.8,"mild":1.2}; rm = {"severe":4,"moderate":3,"mild":2}
370
- s = sm.get(severity,1.8); r = rm.get(severity,3)
371
- blurred = cv2.GaussianBlur(img_np,(r*2+1,r*2+1),0)
372
- mask = cv2.subtract(img_np.astype(np.int16), blurred.astype(np.int16))
373
- img_np = np.clip(img_np.astype(np.float32)+s*mask,0,255).astype(np.uint8); log.append("unsharp")
374
- lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB)
375
- cl = cv2.createCLAHE(clipLimit={"severe":3.0,"moderate":2.5,"mild":1.8}.get(severity,2.5), tileGridSize=(8,8))
376
- lab[:,:,0]=cl.apply(lab[:,:,0]); img_np=cv2.cvtColor(lab,cv2.COLOR_LAB2RGB); log.append("CLAHE")
377
- buf = BytesIO(); Image.fromarray(img_np).save(buf, format="JPEG", quality=92)
378
- return buf.getvalue(), " → ".join(log)
379
-
380
- def image_to_b64(content):
381
- return "data:image/jpeg;base64," + base64.b64encode(content).decode()
382
-
383
- def ocr_quality_score(r):
384
- return r.get("word_count",0)*0.6 + r.get("avg_confidence",0)*100*0.4
385
-
386
- # ══════════════════════════════════════════════════════════════════════
387
- # OCR
388
- # ══════════════════════════════════════════════════════════════════════
389
- _LANG_READERS = {}; _READERS_LOCK = threading.Lock()
390
- _EASYOCR_LANG_MAP = {
391
- "en":["en"],"hi":["en","hi"],"zh":["en","ch_sim"],
392
- "ta":["en","ta"],"te":["en","te"],"bn":["en","bn"],
393
- }
394
-
395
- def _get_reader(lang_hint):
396
- langs = _EASYOCR_LANG_MAP.get(lang_hint, ["en"])
397
- key = "_".join(sorted(langs))
398
- if key not in _LANG_READERS:
399
- with _READERS_LOCK:
400
- if key not in _LANG_READERS:
401
- import easyocr as _easyocr
402
- logger.info("Loading EasyOCR for %s", langs)
403
- _LANG_READERS[key] = _easyocr.Reader(langs, gpu=False, model_storage_directory=MODEL_DIR)
404
- return _LANG_READERS[key]
405
-
406
- def run_ocr(content, lang_hint="en"):
407
- cache_key = f"{hashlib.md5(content).hexdigest()}_{lang_hint}"
408
- cached = _get_ocr_cache(cache_key)
409
- if cached: return cached
410
- img = Image.open(BytesIO(content)).convert("RGB"); img.thumbnail((1200,1200))
411
- results = _get_reader(lang_hint).readtext(np.array(img), detail=1)
412
- words = [r[1] for r in results]
413
- confidences = [r[2] for r in results]
414
- avg_conf = sum(confidences)/len(confidences) if confidences else 0.0
415
- result = {"text": " ".join(words), "word_count": len(words),
416
- "avg_confidence": round(avg_conf,3),
417
- "is_readable": len(words)>=3 and avg_conf>0.15}
418
- _set_ocr_cache(cache_key, result)
419
- return result
420
-
421
- LABEL_KEYWORDS = [
422
- 'ingredients','nutrition','nutritional','calories','calorie','protein','fat',
423
- 'carbohydrate','carbs','sodium','sugar','sugars','fiber','fibre','serving',
424
- 'cholesterol','saturated','trans','vitamin','calcium','iron','per 100g',
425
- 'per 100 g','daily value','daily values','amount per','total fat','contains',
426
- 'may contain','preservative','flavour','flavor','emulsifier','mg','mcg','kcal',
427
- 'kj','% dv','%dv','g per','per serving','fssai','best before','mfg','mrp',
428
- 'net wt','manufactured','packed','allergen','gluten','lactose','nuts',
429
- 'energy','carbohydrates','dietary','mineral','zinc','phosphorus',
430
- # Indian label specific
431
- 'veg','non-veg','vegetarian','lic no','batch no','shelf life',
432
- 'use before','consume before','store in','keep dry',
433
- ]
434
-
435
- # ONLY strong marketing-only phrases that NEVER appear on back labels
436
- # Removed: natural, organic, light, baked, roasted, flavoured — all appear in ingredient lists
437
- FRONT_PACK_SIGNALS = [
438
- 'new improved','now better','great taste','loved by','award winning',
439
- 'no.1 brand','number 1','trusted brand',
440
- ]
441
-
442
- NUTRITION_TABLE_ANCHORS = [
443
- 'per 100g','per 100 g','per serving','serving size','amount per','daily value',
444
- 'daily values','% dv','%dv','calories','calorie','kcal','kj','energy',
445
- 'nutrition facts','nutritional information','nutritional value','total fat',
446
- 'saturated fat','trans fat','total carbohydrate','dietary fiber','ingredients:',
447
- 'ingredients list','fssai','best before','mfg','mrp','net wt','net weight',
448
- # Indian label specific anchors
449
- 'veg ','non-veg','licence no','lic. no','batch no','mfg date',
450
- ]
451
-
452
-
453
- def detect_label_presence(ocr_text):
454
- """
455
- Detect whether OCR text is from a nutrition/ingredients label (back of pack)
456
- vs front-of-pack marketing content.
457
-
458
- Fixed false-positive rate for Indian food labels:
459
- - Removed common ingredient words (natural, organic, baked) from FRONT_PACK_SIGNALS
460
- - Lowered anchor threshold: only 1 anchor needed if label keywords are strong
461
- - Front-of-pack rejection only when there are ZERO nutrition keywords AND
462
- the text is dominated by marketing language
463
- - Added India-specific anchors (FSSAI, MFG, batch no, veg/non-veg)
464
- """
465
- if not ocr_text:
466
- return {'has_label': False, 'confidence': 'high',
467
- 'label_hits': [], 'front_hits': [], 'suggestion': 'no_text'}
468
-
469
- tl = ocr_text.lower()
470
- label_hits = [kw for kw in LABEL_KEYWORDS if kw in tl]
471
- front_hits = [kw for kw in FRONT_PACK_SIGNALS if kw in tl]
472
- anchor_hits = [kw for kw in NUTRITION_TABLE_ANCHORS if kw in tl]
473
- ls = len(label_hits)
474
- fs = len(front_hits)
475
- num_anchors = len(anchor_hits)
476
-
477
- # ── PASS: strong nutrition evidence regardless of front signals ──────
478
- # If we have multiple nutrition anchors + multiple label keywords → definite label
479
- if num_anchors >= 2 and ls >= 3:
480
- return {'has_label': True,
481
- 'confidence': 'high' if ls >= 6 else 'medium',
482
- 'label_hits': label_hits[:5], 'front_hits': front_hits[:3],
483
- 'suggestion': None}
484
-
485
- # ── PASS: moderate evidence — 1 anchor + any label keyword ──────────
486
- # Covers Indian labels with non-standard formatting
487
- if num_anchors >= 1 and ls >= 2:
488
- return {'has_label': True, 'confidence': 'medium',
489
- 'label_hits': label_hits[:5], 'front_hits': front_hits[:3],
490
- 'suggestion': None}
491
-
492
- # ── PASS: weak evidence but plausible — several label keywords ───────
493
- # e.g. label says "protein 8g fat 5g sugar 3g" without explicit headers
494
- if ls >= 4 and fs == 0:
495
- return {'has_label': True, 'confidence': 'low',
496
- 'label_hits': label_hits, 'front_hits': [],
497
- 'suggestion': None}
498
-
499
- # ── PASS: any single anchor (fssai, best before, mfg) ───────────────
500
- # These never appear on front-of-pack — their presence confirms back label
501
- strong_anchors = ['fssai', 'best before', 'mfg', 'mrp', 'net wt', 'net weight',
502
- 'batch no', 'lic no', 'manufactured', 'packed by']
503
- if any(sa in tl for sa in strong_anchors):
504
- return {'has_label': True, 'confidence': 'medium',
505
- 'label_hits': label_hits, 'front_hits': front_hits,
506
- 'suggestion': None}
507
-
508
- # ── FAIL: only reject if truly no nutrition evidence ────────────────
509
- # Must have: zero anchors AND less than 2 label keywords AND
510
- # either dominated by marketing OR truly empty
511
- if ls >= 2:
512
- # Still has some label words — give benefit of the doubt, try analysis
513
- return {'has_label': True, 'confidence': 'low',
514
- 'label_hits': label_hits, 'front_hits': front_hits,
515
- 'suggestion': 'partial'}
516
-
517
- # Genuine front-of-pack: marketing words but zero nutrition content
518
- sug = 'wrong_side' if fs > 0 else 'no_label'
519
- return {'has_label': False, 'confidence': 'high',
520
- 'label_hits': label_hits, 'front_hits': front_hits[:3],
521
- 'suggestion': sug}
522
-
523
- # ══════════════════════════════════════════════════════════════════════
524
- # LLM
525
- # ══════════════════════════════════════════════════════════════════════
526
- _groq_client = None
527
- if GROQ_API_KEY:
528
- from groq import Groq
529
- _groq_client = Groq(api_key=GROQ_API_KEY)
530
-
531
- def call_llm(prompt, max_tokens=2500):
532
- if not _groq_client: raise RuntimeError("GROQ_API_KEY not set")
533
- for model in ["llama-3.3-70b-versatile","llama-3.1-8b-instant"]:
534
- try:
535
- comp = _groq_client.chat.completions.create(
536
- model=model, messages=[{"role":"user","content":prompt}],
537
- temperature=0.1, max_tokens=max_tokens,
538
- response_format={"type":"json_object"})
539
- return comp.choices[0].message.content
540
- except Exception as exc:
541
- logger.warning("LLM %s failed: %s", model, exc)
542
- raise RuntimeError("All LLM models failed")
543
-
544
- def _sanitise_result(result):
545
- cd = result.get("chart_data")
546
- if isinstance(cd,list) and len(cd)==3 and all(isinstance(x,(int,float)) for x in cd):
547
- total = sum(cd)
548
- if total > 0 and total != 100:
549
- scaled = [round(v*100/total) for v in cd]
550
- scaled[scaled.index(max(scaled))] += 100 - sum(scaled)
551
- result["chart_data"] = scaled
552
- else:
553
- result["chart_data"] = [70,20,10]
554
- for n in result.get("nutrient_breakdown",[]):
555
- m = re.search(r"[\d]+\.?[\d]*", str(n.get("value","")).replace(",","."))
556
- if m: n["value"] = float(m.group())
557
- result.setdefault("score",5); result.setdefault("verdict","Analyzed")
558
- result.setdefault("product_name","Unknown Product")
559
- result.setdefault("nutrient_breakdown",[]); result.setdefault("pros",[])
560
- result.setdefault("cons",[]); result.setdefault("age_warnings",[])
561
- result.setdefault("is_low_confidence",False)
562
- return result
563
-
564
- async def analyse_label(extracted_text, persona, age_group, product_category,
565
- language, web_context, blur_info, label_confidence):
566
- cache_key = f"v4:{language}:{persona}:{age_group}:{extracted_text[:80]}"
567
- cached = _get_ai_cache(cache_key)
568
- if cached: return cached
569
- lang_name = LANGUAGE_MAP.get(language, "English")
570
- conf_note = ("⚠️ Label text may be partial — only list nutrients you can read confidently."
571
- if label_confidence == "low" else "")
572
- blur_ctx = ""
573
- if blur_info.get("detected"):
574
- verb = "enhanced via Wiener deconvolution" if blur_info.get("deblurred") else "blurry, used original"
575
- blur_ctx = f"IMAGE: {blur_info['severity']}ly blurry ({verb}). Only report confident values."
576
- prompt = f"""[INST]
577
- You are an expert nutritional scientist and food safety auditor.
578
- CRITICAL: Respond ENTIRELY in {lang_name}. Every text field MUST be in {lang_name}.
579
- Persona: {persona} | Age: {age_group} | Category: {product_category}
580
- {conf_note}
581
- {blur_ctx}
582
- Label Text: "{extracted_text}"
583
- Web Context: "{web_context}"
584
-
585
- Return ONLY valid JSON — no markdown, no preamble:
586
- {{
587
- "product_name" : "Short name from label",
588
- "product_category" : "Snack|Dairy|Beverage|Cereal|Supplement|etc.",
589
- "score" : <INTEGER 1-10 per SCORING RUBRIC — never default to 6 or 7>,
590
- "verdict" : "Two-word verdict in {lang_name}",
591
- "chart_data" : [<Safe%>, <Moderate%>, <Risky%>],
592
- "summary" : "2-sentence professional summary in {lang_name}.",
593
- "eli5_explanation" : "Child-friendly explanation with emojis in {lang_name}.",
594
- "molecular_insight" : "1-2 sentences on biochemical impact in {lang_name}.",
595
- "paragraph_benefits": "Full paragraph on genuine benefits in {lang_name}.",
596
- "paragraph_uniqueness": "Unique characteristics OR 2 better alternatives in {lang_name}.",
597
- "is_unique" : true,
598
- "nutrient_breakdown": [
599
- {{"name":"Protein","value":<ACTUAL g>,"unit":"g","rating":"good","impact":"brief note in {lang_name}"}},
600
- {{"name":"Sugar","value":<ACTUAL g>,"unit":"g","rating":"moderate","impact":"brief note"}},
601
- {{"name":"Fat","value":<ACTUAL g>,"unit":"g","rating":"good","impact":"brief note"}},
602
- {{"name":"Sodium","value":<ACTUAL mg>,"unit":"mg","rating":"caution","impact":"brief note"}},
603
- {{"name":"Fiber","value":<ACTUAL g>,"unit":"g","rating":"good","impact":"brief note"}}
604
- ],
605
- "pros" : ["Benefit 1 in {lang_name}", "Benefit 2", "Benefit 3"],
606
- "cons" : ["Risk 1 in {lang_name}", "Risk 2"],
607
- "age_warnings" : [
608
- {{"group":"Children","emoji":"👶","status":"warning","message":"in {lang_name}"}},
609
- {{"group":"Adults","emoji":"🧑","status":"good","message":"in {lang_name}"}},
610
- {{"group":"Seniors","emoji":"👴","status":"caution","message":"in {lang_name}"}},
611
- {{"group":"Pregnant","emoji":"🤰","status":"caution","message":"in {lang_name}"}}
612
- ],
613
- "better_alternative": "A specific healthier alternative in {lang_name}.",
614
- "is_low_confidence" : false
615
- }}
616
- SCORING RUBRIC — MANDATORY, never use 6 or 7 as defaults:
617
- 9-10: Whole food, no added sugar, low sodium, high fibre/protein
618
- 7-8 : Mildly processed, sugar <5g/100g, reasonable sodium
619
- 5-6 : Processed, sugar 5-15g/100g OR sodium 400-700mg/100g
620
- 3-4 : High sugar >15g/100g OR sodium >700mg/100g OR poor profile
621
- 1-2 : Ultra-processed, very high sugar/sodium/sat-fat
622
- RULES: chart_data sums to 100 | rating: good|moderate|caution|bad | status: good|caution|warning
623
- [/INST]"""
624
- raw = await asyncio.to_thread(call_llm, prompt, 2500)
625
- result = _sanitise_result(json.loads(raw))
626
- result["disclaimer"] = MEDICAL_DISCLAIMER
627
- cacheable = {k:v for k,v in result.items() if k not in ("blur_info","scan_meta","allergen_warning")}
628
- _set_ai_cache(cache_key, cacheable)
629
- return result
630
-
631
- def upsert_food_product(name, nutrients, score, ingredients_raw="",
632
- barcode=None, brand="", category="", source="llm_scan"):
633
- def _get(key):
634
- for n in nutrients:
635
- if key in n.get("name","").lower():
636
- v = n.get("value",0)
637
- return float(v) if isinstance(v,(int,float)) else 0
638
- return 0
639
- cal=_get("calorie") or _get("energy"); prot=_get("protein")
640
- carb=_get("carb"); fat=_get("fat"); sod=_get("sodium")
641
- fib=_get("fiber") or _get("fibre"); sug=_get("sugar"); sat=_get("saturated")
642
- with db_conn() as conn:
643
- existing = conn.execute(
644
- "SELECT id FROM food_products WHERE barcode=?" if barcode else "SELECT id FROM food_products WHERE name=? AND brand=?",
645
- (barcode,) if barcode else (name.strip(), brand.strip())
646
- ).fetchone()
647
- if existing:
648
- conn.execute("UPDATE food_products SET scan_count=scan_count+1, updated_at=datetime('now') WHERE id=?",
649
- (existing["id"],)); return existing["id"]
650
- cursor = conn.execute(
651
- "INSERT INTO food_products(name,brand,category,barcode,calories_100g,protein_100g,carbs_100g,fat_100g,sodium_100g,fiber_100g,sugar_100g,sat_fat_100g,eatlytic_score,ingredients_raw,source,scan_count) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)",
652
- (name.strip(),brand,category,barcode,cal,prot,carb,fat,sod,fib,sug,sat,score,ingredients_raw,source))
653
- return cursor.lastrowid
654
-
655
- # ══════════════════════════════════════════════════════════════════════
656
- # PAYMENTS
657
- # ══════════════════════════════════════════════════════════════════════
658
- PRO_AMOUNT_PAISE = 19900
659
-
660
- def _create_razorpay_order(user_id, device_key=""):
661
- if not RAZORPAY_KEY_ID or not RAZORPAY_KEY_SECRET:
662
- raise RuntimeError("RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET env vars required")
663
- try:
664
- import razorpay
665
- client = razorpay.Client(auth=(RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET))
666
- except ImportError:
667
- raise RuntimeError("razorpay package not installed")
668
- order = client.order.create({
669
- "amount": PRO_AMOUNT_PAISE, "currency": "INR",
670
- "receipt": f"eat_{user_id[:8]}_{datetime.datetime.utcnow().strftime('%Y%m%d%H%M%S')}",
671
- "notes": {"user_id": user_id, "product": "eatlytic_pro"},
672
- })
673
- with db_conn() as conn:
674
- conn.execute("INSERT INTO payments(user_id,device_key,razorpay_order_id,amount_paise,status) VALUES(?,?,?,?,?)",
675
- (user_id, device_key, order["id"], PRO_AMOUNT_PAISE, "created"))
676
- return {"order_id": order["id"], "amount": PRO_AMOUNT_PAISE, "currency": "INR",
677
- "key_id": RAZORPAY_KEY_ID}
678
-
679
- def _verify_razorpay_payment(order_id, payment_id, signature):
680
- expected = hmac.new(RAZORPAY_KEY_SECRET.encode(),
681
- f"{order_id}|{payment_id}".encode(), hashlib.sha256).hexdigest()
682
- return hmac.compare_digest(expected, signature)
683
-
684
- def _activate_pro_payment(order_id, payment_id, signature):
685
- if not _verify_razorpay_payment(order_id, payment_id, signature):
686
- raise ValueError("Invalid payment signature — possible tampering")
687
- expires = (datetime.datetime.utcnow() + datetime.timedelta(days=31)).isoformat()
688
- with db_conn() as conn:
689
- row = conn.execute("SELECT user_id, device_key FROM payments WHERE razorpay_order_id=?", (order_id,)).fetchone()
690
- if not row: raise ValueError(f"Order {order_id} not found")
691
- user_id = row["user_id"]
692
- device_key = row["device_key"]
693
- conn.execute("UPDATE payments SET razorpay_payment_id=?,razorpay_signature=?,status='paid',paid_at=datetime('now') WHERE razorpay_order_id=?",
694
- (payment_id, signature, order_id))
695
- if user_id:
696
- conn.execute("UPDATE users SET is_pro=1, pro_expires=? WHERE id=?", (expires, user_id))
697
- if device_key:
698
- conn.execute("UPDATE devices SET is_pro=1 WHERE device_key=?", (device_key,))
699
- return {"success": True, "user_id": user_id, "expires": expires}
700
-
701
- # ══════════════════════════════════════════════════════════════════════
702
- # FASTAPI APP
703
- # ══════════════════════════════════════════════════════════════════════
704
- limiter = Limiter(key_func=get_remote_address)
705
- app = FastAPI(title="Eatlytic v4 — Food Intelligence", version="4.0")
706
- app.state.limiter = limiter
707
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
708
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["GET","POST","DELETE","PATCH"], allow_headers=["*"])
709
- api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
710
-
711
- def _get_request_user(request):
712
- auth = request.headers.get("Authorization","")
713
- token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
714
- return _get_user_from_token(token) if token else None
715
-
716
- def _device_key(request):
717
- ip = request.client.host if request.client else "unknown"
718
- ua = request.headers.get("user-agent","")
719
- return hashlib.md5(f"{ip}:{ua}".encode()).hexdigest()[:16]
720
-
721
- def _ensure_device(dk):
722
- try:
723
- with db_conn() as conn:
724
- conn.execute("INSERT OR IGNORE INTO devices(device_key) VALUES(?)", (dk,))
725
- except Exception: pass
726
-
727
- def _check_scan_quota(user, device_key):
728
- if user: return _check_scan_quota_user(user["id"])
729
- month_key = datetime.date.today().isoformat()[:7]
730
- _ensure_device(device_key)
731
- with db_conn() as conn:
732
- row = conn.execute("SELECT is_pro, month, scan_count FROM devices WHERE device_key=?", (device_key,)).fetchone()
733
- if not row: return {"allowed":False,"scans_used":0,"scans_remaining":0,"is_pro":False}
734
- if row["month"] != month_key:
735
- conn.execute("UPDATE devices SET month=?, scan_count=0 WHERE device_key=?", (month_key, device_key))
736
- count = 0
737
- else: count = row["scan_count"]
738
- if row["is_pro"]:
739
- conn.execute("UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?", (device_key,))
740
- return {"allowed":True,"scans_used":count+1,"scans_remaining":9999,"is_pro":True}
741
- if count >= FREE_SCAN_LIMIT:
742
- return {"allowed":False,"scans_used":count,"scans_remaining":0,"is_pro":False}
743
- conn.execute("UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?", (device_key,))
744
- new = count + 1
745
- return {"allowed":True,"scans_used":new,"scans_remaining":FREE_SCAN_LIMIT-new,"is_pro":False}
746
-
747
- def _get_live_search(query):
748
- if not _DDGS_OK: return "Web search unavailable."
749
- try:
750
- with _DDGS() as ddgs:
751
- results = [f"{r['title']}: {r['body']}" for r in ddgs.text(query, max_results=3)]
752
- return "\n".join(results) if results else "No web data."
753
- except Exception as exc:
754
- logger.warning("Web search: %s", exc); return "No web data."
755
-
756
- # ── Core routes ────────────────────────────────────────────────────────
757
- @app.get("/")
758
- async def home(): return FileResponse("index.html")
759
-
760
- @app.get("/health")
761
- async def health(): return {"status":"ok","version":"4.0","db":"sqlite-wal"}
762
-
763
- @app.post("/check-image")
764
- @limiter.limit("30/minute")
765
- async def check_image(request: Request, image: UploadFile = File(...)):
766
- content = validate_image(await image.read())
767
- return assess_image_quality(content)
768
-
769
- @app.post("/enhance-preview")
770
- @limiter.limit("20/minute")
771
- async def enhance_preview(request: Request, image: UploadFile = File(...)):
772
- content = validate_image(await image.read())
773
- quality = assess_image_quality(content)
774
- if not quality["is_blurry"]:
775
- return JSONResponse({"deblurred":False,"message":"Image already clear.","quality":quality})
776
- enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
777
- return JSONResponse({"deblurred":True,"image_b64":image_to_b64(enhanced),
778
- "method_log":method_log,"quality_before":quality})
779
-
780
- @app.post("/ocr")
781
- @limiter.limit("20/minute")
782
- async def perform_ocr(request: Request, image: UploadFile = File(...), language: str = Form("en")):
783
- content = validate_image(await image.read())
784
- return run_ocr(content, language)
785
-
786
- @app.post("/analyze")
787
- @limiter.limit("15/minute")
788
- async def analyze_product(
789
- request: Request, persona: str = Form(...),
790
- age_group: str = Form("adult"), product_category: str = Form("general"),
791
- language: str = Form("en"), extracted_text: str = Form(None),
792
- image: UploadFile = File(...),
793
- ):
794
- if not GROQ_API_KEY:
795
- return JSONResponse({"error":"Server error: GROQ_API_KEY not set in Secrets"})
796
- user = _get_request_user(request)
797
- device_key = _device_key(request)
798
- scan_check = _check_scan_quota(user, device_key)
799
- if not scan_check["allowed"]:
800
- return JSONResponse(status_code=402, content={
801
- "error":"scan_limit_reached",
802
- "message":f"You've used all {FREE_SCAN_LIMIT} free scans this month.",
803
- "upgrade_url":"/payments/create-order"})
804
- try:
805
- content = validate_image(await image.read())
806
- quality = assess_image_quality(content)
807
- blur_info = {"detected":quality["is_blurry"],"severity":quality["blur_severity"],
808
- "score":quality["blur_score"],"deblurred":False,
809
- "method_log":None,"image_b64":None,"ocr_source":"original"}
810
- working = content
811
- if quality["is_blurry"]:
812
- try:
813
- enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
814
- if ocr_quality_score(run_ocr(enhanced,language)) >= ocr_quality_score(run_ocr(content,language))*0.85:
815
- working=enhanced; blur_info["deblurred"]=True
816
- blur_info["method_log"]=method_log; blur_info["image_b64"]=image_to_b64(enhanced)
817
- blur_info["ocr_source"]="deblurred"; extracted_text=None
818
- except Exception as exc: logger.warning("Deblur: %s", exc)
819
- if not extracted_text:
820
- ocr_result=run_ocr(working,language); extracted_text=ocr_result["text"]; ocr_wc=ocr_result["word_count"]
821
- else: ocr_wc=len(extracted_text.split())
822
- if not extracted_text or ocr_wc==0:
823
- return JSONResponse({"error":"no_text","message":"No text found. Make sure the label is facing the camera.","tip":"flip_product"})
824
- label_check = detect_label_presence(extracted_text)
825
- if not label_check["has_label"]:
826
- tip = label_check["suggestion"] or "flip_product"
827
- msg = ("This looks like the front of the product. Flip it over and scan the back label."
828
- if tip=="wrong_side" else "Could not find nutrition or ingredient information.")
829
- return JSONResponse({"error":"no_label","message":msg,"tip":tip,
830
- "front_words_found":label_check.get("front_hits",[])})
831
- allergen_warning=""
832
- try:
833
- with db_conn() as conn:
834
- row=conn.execute("SELECT allergens,conditions FROM allergen_profiles WHERE device_key=?",(device_key,)).fetchone()
835
- if row:
836
- tl=extracted_text.lower()
837
- triggered=[a for a in json.loads(row["allergens"] or "[]") if a.lower() in tl]+\
838
- [c for c in json.loads(row["conditions"] or "[]") if c.lower() in tl]
839
- if triggered: allergen_warning=f"⚠️ ALLERGEN ALERT — may contain: {', '.join(triggered)}"
840
- except Exception: pass
841
- web_context = await asyncio.to_thread(_get_live_search, f"health analysis ingredients {extracted_text[:120]}")
842
- result = await analyse_label(extracted_text, persona, age_group, product_category,
843
- language, web_context, blur_info, label_check.get("confidence","medium"))
844
- result["allergen_warning"]=allergen_warning; result["blur_info"]=blur_info; result["scan_meta"]=scan_check
845
- today=datetime.date.today().isoformat()
846
- nutr={n["name"].lower():float(n.get("value",0)) for n in result.get("nutrient_breakdown",[]) if isinstance(n.get("value"),(int,float))}
847
- cal=nutr.get("energy",nutr.get("calories",nutr.get("calorie",0))); prot=nutr.get("protein",0)
848
- carb=nutr.get("carbohydrate",nutr.get("carbs",0)); fat=nutr.get("fat",0)
849
- sod=nutr.get("sodium",0); fib=nutr.get("fiber",nutr.get("fibre",0)); sug=nutr.get("sugar",nutr.get("sugars",0))
850
- owner_id=user["id"] if user else None
851
- with db_conn() as conn:
852
- conn.execute("INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
853
- (owner_id,device_key,today,result.get("product_name","Scanned item"),cal,prot,carb,fat,sod,fib,sug,"scan"))
854
- conn.execute("INSERT INTO scans(user_id,device_key,product_name,score,verdict,calories,protein,carbs,fat,sodium,fiber,sugar,persona,language,analysis_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
855
- (owner_id,device_key,result.get("product_name","Unknown"),result.get("score",0),result.get("verdict",""),
856
- cal,prot,carb,fat,sod,fib,sug,persona,language,
857
- json.dumps({k:v for k,v in result.items() if k not in ("blur_info","scan_meta","allergen_warning")})))
858
- try: upsert_food_product(name=result.get("product_name",""), nutrients=result.get("nutrient_breakdown",[]), score=result.get("score",0), ingredients_raw=extracted_text, category=result.get("product_category",""), source="llm_scan")
859
- except Exception: pass
860
- if user: _update_streak_user(user["id"])
861
- else:
862
- try:
863
- _ensure_device(device_key); td=datetime.date.today().isoformat()
864
- yd=(datetime.date.today()-datetime.timedelta(days=1)).isoformat()
865
- with db_conn() as conn:
866
- row=conn.execute("SELECT streak_days,last_scan_date FROM devices WHERE device_key=?",(device_key,)).fetchone()
867
- if row and row["last_scan_date"]!=td:
868
- st=(row["streak_days"]+1) if row["last_scan_date"]==yd else 1
869
- conn.execute("UPDATE devices SET streak_days=?,last_scan_date=? WHERE device_key=?",(st,td,device_key))
870
- except Exception: pass
871
- return JSONResponse(result)
872
- except ValueError as exc: return JSONResponse({"error":str(exc)}, status_code=400)
873
- except Exception as exc:
874
- logger.error("Analysis error: %s", exc, exc_info=True)
875
- return JSONResponse({"error":f"Scan failed: {str(exc)[:140]}. Please try again."})
876
-
877
- @app.get("/scan-status")
878
- async def scan_status(request: Request):
879
- user=_get_request_user(request); device_key=_device_key(request)
880
- _ensure_device(device_key); month_key=datetime.date.today().isoformat()[:7]
881
- if user:
882
- with db_conn() as conn:
883
- row=conn.execute("SELECT is_pro,scan_month,scan_count_month,streak_days FROM users WHERE id=?",(user["id"],)).fetchone()
884
- if not row or row["scan_month"]!=month_key:
885
- return {"scans_used":0,"scans_remaining":FREE_SCAN_LIMIT,"is_pro":False,"limit":FREE_SCAN_LIMIT,"streak":0,"authenticated":True}
886
- used=row["scan_count_month"]
887
- return {"scans_used":used,"scans_remaining":9999 if row["is_pro"] else max(0,FREE_SCAN_LIMIT-used),
888
- "is_pro":bool(row["is_pro"]),"limit":FREE_SCAN_LIMIT,"streak":row["streak_days"],"authenticated":True}
889
- with db_conn() as conn:
890
- row=conn.execute("SELECT is_pro,month,scan_count,streak_days FROM devices WHERE device_key=?",(device_key,)).fetchone()
891
- if not row or row["month"]!=month_key:
892
- return {"scans_used":0,"scans_remaining":FREE_SCAN_LIMIT,"is_pro":False,"limit":FREE_SCAN_LIMIT,"streak":0,"authenticated":False}
893
- used=row["scan_count"]
894
- return {"scans_used":used,"scans_remaining":9999 if row["is_pro"] else max(0,FREE_SCAN_LIMIT-used),
895
- "is_pro":bool(row["is_pro"]),"limit":FREE_SCAN_LIMIT,"streak":row["streak_days"],"authenticated":False}
896
-
897
- # ── Auth routes ────────────────────────────────────────────────────────
898
- @app.post("/auth/request-otp")
899
- async def request_otp(email: str = Form(...)):
900
- otp = _send_otp(email)
901
- return JSONResponse({"sent":True,"message":"OTP sent.","_dev_otp":otp})
902
-
903
- @app.post("/auth/verify-otp")
904
- async def verify_otp(request: Request, email: str = Form(...), otp: str = Form(...)):
905
- user = _verify_otp(email, otp)
906
- if not user: raise HTTPException(status_code=401, detail="Invalid or expired OTP")
907
- token = _create_session(user["id"], request.headers.get("user-agent","")[:100])
908
- return JSONResponse({"token":token,"user_id":user["id"],"email":user.get("email",""),"is_pro":bool(user.get("is_pro",0))})
909
-
910
- @app.post("/auth/logout")
911
- async def logout(request: Request):
912
- auth=request.headers.get("Authorization","")
913
- token=auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
914
- if token:
915
- with db_conn() as conn: conn.execute("DELETE FROM sessions WHERE token=?",(token,))
916
- return JSONResponse({"logged_out":True})
917
-
918
- @app.get("/auth/me")
919
- async def get_me(request: Request):
920
- user=_get_request_user(request)
921
- if not user: raise HTTPException(status_code=401, detail="Not authenticated")
922
- return JSONResponse({"user_id":user["id"],"email":user.get("email",""),"name":user.get("name",""),
923
- "is_pro":bool(user.get("is_pro",0)),"streak_days":user.get("streak_days",0),
924
- "persona":user.get("persona","General Adult"),"language":user.get("language","en")})
925
-
926
- # ── Payment routes ─────────────────────────────────────────────────────
927
- @app.post("/payments/create-order")
928
- async def create_order(request: Request):
929
- user=_get_request_user(request)
930
- if not user: raise HTTPException(status_code=401, detail="Login required. POST /auth/request-otp first.")
931
- device_key=_device_key(request)
932
- try:
933
- return JSONResponse(_create_razorpay_order(user["id"], device_key))
934
- except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc))
935
-
936
- @app.post("/payments/verify")
937
- async def verify_payment(request: Request, razorpay_order_id: str = Form(...),
938
- razorpay_payment_id: str = Form(...), razorpay_signature: str = Form(...)):
939
- try:
940
- return JSONResponse(_activate_pro_payment(razorpay_order_id, razorpay_payment_id, razorpay_signature))
941
- except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc))
942
-
943
- @app.post("/activate-pro")
944
- async def activate_pro_legacy(request: Request, payment_id: str = Form(...)):
945
- """Legacy endpoint — kept for backward compatibility."""
946
- device_key=_device_key(request); _ensure_device(device_key)
947
- with db_conn() as conn: conn.execute("UPDATE devices SET is_pro=1 WHERE device_key=?",(device_key,))
948
- return {"status":"activated","message":"Pro activated. Use /payments/create-order for real billing."}
949
-
950
- # ── Food DB routes ─────────────────────────────────────────────────────
951
- @app.get("/food-search")
952
- @limiter.limit("30/minute")
953
- async def food_search(request: Request, q: str = ""):
954
- if not q or len(q.strip())<2: return {"products":[],"source":"none"}
955
- with db_conn() as conn:
956
- rows=conn.execute("SELECT name,brand,category,calories_100g,protein_100g,carbs_100g,fat_100g,sodium_100g,fiber_100g,sugar_100g,eatlytic_score,verified FROM food_products WHERE (name LIKE ? OR brand LIKE ?) AND verified=1 ORDER BY scan_count DESC LIMIT 10",(f"%{q}%",f"%{q}%")).fetchall()
957
- if rows: return {"products":[dict(r) for r in rows],"source":"eatlytic_db"}
958
- try:
959
- import httpx
960
- async with httpx.AsyncClient(timeout=8) as hc:
961
- resp=await hc.get("https://world.openfoodfacts.org/cgi/search.pl",
962
- params={"search_terms":q,"action":"process","json":1,"page_size":10,
963
- "fields":"product_name,brands,nutriments"})
964
- products=[]
965
- for p in resp.json().get("products",[]):
966
- n=p.get("nutriments",{})
967
- products.append({"name":p.get("product_name",""),"brand":p.get("brands",""),
968
- "calories_100g":round(n.get("energy-kcal_100g",0),1),
969
- "protein_100g":round(n.get("proteins_100g",0),1),
970
- "carbs_100g":round(n.get("carbohydrates_100g",0),1),
971
- "fat_100g":round(n.get("fat_100g",0),1),
972
- "sodium_100g":round(n.get("sodium_100g",0)*1000,1),
973
- "fiber_100g":round(n.get("fiber_100g",0),1),
974
- "sugar_100g":round(n.get("sugars_100g",0),1),
975
- "eatlytic_score":0,"verified":0,"source":"openfoodfacts"})
976
- return {"products":products,"source":"openfoodfacts"}
977
- except Exception as exc:
978
- logger.warning("Food search: %s", exc); return {"products":[],"source":"unavailable"}
979
-
980
- @app.get("/food-db/stats")
981
- async def food_db_stats():
982
- with db_conn() as conn:
983
- total =conn.execute("SELECT COUNT(*) FROM food_products").fetchone()[0]
984
- verified=conn.execute("SELECT COUNT(*) FROM food_products WHERE verified=1").fetchone()[0]
985
- return {"total_products":total,"verified_products":verified,
986
- "moat_status": "🔴 Early (<1K)" if total<1000 else "🟡 Growing (1K-10K)" if total<10000 else "🟢 Defensible (10K+)"}
987
-
988
- # ── Daily tracker routes ───────────────────────────────────────────────
989
- @app.get("/daily-summary")
990
- async def daily_summary(request: Request, date: str = None):
991
- user=_get_request_user(request); device_key=_device_key(request)
992
- target_date=date or datetime.date.today().isoformat(); user_id=user["id"] if user else None
993
- with db_conn() as conn:
994
- dev=conn.execute("SELECT tdee FROM users WHERE id=?" if user_id else "SELECT tdee FROM devices WHERE device_key=?",(user_id or device_key,)).fetchone()
995
- clause="user_id=?" if user_id else "device_key=?"; param=user_id or device_key
996
- row=conn.execute(f"SELECT SUM(calories) cal, SUM(protein) prot, SUM(carbs) carb, SUM(fat) fat, SUM(sodium) sod, SUM(fiber) fib, SUM(sugar) sug, COUNT(*) items FROM daily_logs WHERE {clause} AND log_date=?",(param,target_date)).fetchone()
997
- log_items=conn.execute(f"SELECT id,meal_name,calories,protein,carbs,fat,sodium,source,logged_at FROM daily_logs WHERE {clause} AND log_date=? ORDER BY logged_at DESC",(param,target_date)).fetchall()
998
- tdee=float((dev and dev["tdee"]) or 2000) or 2000
999
- totals={k:round(row[k] or 0,1) for k in ("cal","prot","carb","fat","sod","fib","sug")}
1000
- t={"calories":round(tdee),"protein":56,"carbs":round(tdee*.5/4),"fat":round(tdee*.3/9),"sodium":2300,"fiber":28,"sugar":50}
1001
- cal_left=max(0,t["calories"]-totals["cal"]); prot_left=max(0,t["protein"]-totals["prot"])
1002
- suggestion=""
1003
- if cal_left<200: suggestion="🎯 Almost at your calorie target!"
1004
- elif prot_left>20: suggestion=f"💪 {round(prot_left)}g protein left. Try: eggs, dal, paneer."
1005
- elif cal_left>600: suggestion=f"🍽 {round(cal_left)} kcal remaining."
1006
- return {"date":target_date,"totals":totals,"targets":t,"suggestion":suggestion,
1007
- "items":row["items"] or 0,"log":[dict(r) for r in log_items]}
1008
-
1009
- @app.post("/daily-log")
1010
- @limiter.limit("30/minute")
1011
- async def daily_log(request: Request, meal_name: str=Form(...), calories: float=Form(0),
1012
- protein: float=Form(0), carbs: float=Form(0), fat: float=Form(0),
1013
- sodium: float=Form(0), fiber: float=Form(0), sugar: float=Form(0),
1014
- source: str=Form("manual"), log_date: str=Form(None)):
1015
- user=_get_request_user(request); device_key=_device_key(request)
1016
- target_date=log_date or datetime.date.today().isoformat(); user_id=user["id"] if user else None
1017
- _ensure_device(device_key)
1018
- with db_conn() as conn:
1019
- conn.execute("INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
1020
- (user_id,device_key,target_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source))
1021
- return {"status":"logged","date":target_date,"meal":meal_name}
1022
-
1023
- @app.delete("/daily-log/{log_id}")
1024
- async def delete_log(request: Request, log_id: int):
1025
- device_key=_device_key(request)
1026
- with db_conn() as conn: conn.execute("DELETE FROM daily_logs WHERE id=? AND device_key=?",(log_id,device_key))
1027
- return {"status":"deleted","id":log_id}
1028
-
1029
- # ── Profile routes ─────────────────────────────────────────────────────
1030
- @app.post("/onboarding-complete")
1031
- async def onboarding_complete(request: Request, persona: str=Form("General Adult"),
1032
- language: str=Form("en"), tdee: float=Form(0), allergens: str=Form("[]")):
1033
- user=_get_request_user(request); device_key=_device_key(request)
1034
- _ensure_device(device_key); user_id=user["id"] if user else None
1035
- with db_conn() as conn:
1036
- conn.execute("UPDATE devices SET onboarding_done=1,persona=?,language=?,tdee=? WHERE device_key=?",(persona,language,tdee,device_key))
1037
- if user_id: conn.execute("UPDATE users SET onboarding_done=1,persona=?,language=?,tdee=? WHERE id=?",(persona,language,tdee,user_id))
1038
- conn.execute("INSERT OR REPLACE INTO allergen_profiles(device_key,user_id,allergens) VALUES(?,?,?)",(device_key,user_id,allergens))
1039
- return {"status":"ok"}
1040
-
1041
- @app.get("/allergen-profile")
1042
- async def get_allergen_profile(request: Request):
1043
- device_key=_device_key(request)
1044
- with db_conn() as conn:
1045
- row=conn.execute("SELECT allergens,conditions FROM allergen_profiles WHERE device_key=?",(device_key,)).fetchone()
1046
- if not row: return {"allergens":[],"conditions":[]}
1047
- return {"allergens":json.loads(row["allergens"] or "[]"),"conditions":json.loads(row["conditions"] or "[]")}
1048
-
1049
- @app.post("/allergen-profile")
1050
- async def set_allergen_profile(request: Request, allergens: str=Form("[]"), conditions: str=Form("[]")):
1051
- device_key=_device_key(request); _ensure_device(device_key)
1052
- with db_conn() as conn:
1053
- conn.execute("INSERT OR REPLACE INTO allergen_profiles(device_key,allergens,conditions,updated_at) VALUES(?,?,?,datetime('now'))",(device_key,allergens,conditions))
1054
- return {"status":"saved"}
1055
-
1056
- # ── Admin routes ───────────────────────────────────────────────────────
1057
- @app.get("/admin/analytics")
1058
- async def admin_analytics(request: Request):
1059
- token=request.headers.get("X-Admin-Token","")
1060
- if token != ADMIN_TOKEN: raise HTTPException(status_code=403, detail="Invalid token")
1061
- today=datetime.date.today().isoformat(); mkey=today[:7]
1062
- with db_conn() as conn:
1063
- dau =conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE DATE(scanned_at)=?",(today,)).fetchone()[0]
1064
- mau =conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE strftime('%Y-%m',scanned_at)=?",(mkey,)).fetchone()[0]
1065
- tot =conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0]
1066
- avgs=conn.execute("SELECT AVG(score) FROM scans").fetchone()[0]
1067
- users=conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
1068
- food_ct=conn.execute("SELECT COUNT(*) FROM food_products").fetchone()[0]
1069
- verified=conn.execute("SELECT COUNT(*) FROM food_products WHERE verified=1").fetchone()[0]
1070
- top=conn.execute("SELECT product_name,COUNT(*) c FROM scans GROUP BY product_name ORDER BY c DESC LIMIT 10").fetchall()
1071
- return {"dau":dau,"mau":mau,"total_scans":tot,"total_users":users,
1072
- "avg_score":round(avgs or 0,2),"dau_mau":round(dau/mau*100,1) if mau else 0,
1073
- "food_db":{"total":food_ct,"verified":verified},
1074
- "top_products":[{"name":r[0],"scans":r[1]} for r in top]}
1075
-
1076
- @app.post("/admin/create-api-key")
1077
- async def create_api_key_endpoint(request: Request, client_name: str=Form(...), plan: str=Form("business")):
1078
- token=request.headers.get("X-Admin-Token","")
1079
- if token != ADMIN_TOKEN: raise HTTPException(status_code=403, detail="Invalid admin token")
1080
- key="eak_"+secrets.token_urlsafe(32)
1081
- with db_conn() as conn: conn.execute("INSERT INTO api_keys(api_key,client_name,plan) VALUES(?,?,?)",(key,client_name,plan))
1082
- return {"api_key":key,"client":client_name,"plan":plan}
1083
-
1084
- # ── Misc routes ────────────────────────────────────────────────────────
1085
- @app.post("/nps")
1086
- async def submit_nps(request: Request, score: int=Form(...), comment: str=Form("")):
1087
- if not 0<=score<=10: return JSONResponse({"error":"Score must be 0-10"},status_code=400)
1088
- user=_get_request_user(request); device_key=_device_key(request)
1089
- with db_conn() as conn:
1090
- conn.execute("INSERT INTO nps_responses(device_key,user_id,score,comment) VALUES(?,?,?,?)",
1091
- (device_key,user["id"] if user else None,score,comment[:500]))
1092
- return {"status":"thank_you"}
1093
-
1094
- @app.post("/generate-share-card")
1095
- @limiter.limit("20/minute")
1096
- async def generate_share_card(request: Request, product_name: str=Form(...), score: int=Form(...),
1097
- verdict: str=Form(...), top_warning: str=Form(""), top_pro: str=Form("")):
1098
- W,H=1080,1080; img=Image.new("RGB",(W,H),(15,17,23)); draw=ImageDraw.Draw(img)
1099
- font=ImageFont.load_default()
1100
- s_rgb=(34,197,94) if score>=7 else (245,158,11) if score>=4 else (239,68,68)
1101
- def centered(text,y,fill):
1102
- try: tw=font.getbbox(text)[2]-font.getbbox(text)[0]
1103
- except: tw=len(text)*6
1104
- draw.text(((W-tw)//2,y),text,fill=fill,font=font)
1105
- draw.ellipse([340,160,740,560],outline=s_rgb,width=18)
1106
- centered(str(score),340,s_rgb); centered("/10",430,(100,116,139))
1107
- centered(product_name[:38]+("…" if len(product_name)>38 else ""),600,(255,255,255))
1108
- centered(verdict[:50],650,(148,163,184))
1109
- if top_pro: draw.rectangle([60,700,1020,760],fill=(15,60,40)); centered(f"✓ {top_pro[:65]}",718,(74,222,128))
1110
- if top_warning: draw.rectangle([60,775,1020,840],fill=(124,29,29)); centered(f"⚠ {top_warning[:65]}",795,(252,165,165))
1111
- centered("eatlytic.com • scan any food label, no barcode needed",1000,(71,85,105))
1112
- buf=BytesIO(); img.save(buf,format="PNG",optimize=True); buf.seek(0)
1113
- return Response(content=buf.getvalue(),media_type="image/png",
1114
- headers={"Content-Disposition":"attachment; filename=eatlytic-scan.png"})
1115
-
1116
- @app.post("/export-pdf")
1117
- @limiter.limit("10/minute")
1118
- async def export_pdf(request: Request, analysis_json: str=Form(...)):
1119
- try: data=json.loads(analysis_json)
1120
- except Exception: return JSONResponse({"error":"Invalid JSON"},status_code=400)
1121
- try:
1122
- from reportlab.lib.pagesizes import A4
1123
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
1124
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
1125
- from reportlab.lib import colors as rl; from reportlab.lib.units import cm
1126
- except ImportError: return JSONResponse({"error":"reportlab not installed"},status_code=501)
1127
- buf=BytesIO(); doc=SimpleDocTemplate(buf,pagesize=A4,rightMargin=2*cm,leftMargin=2*cm,topMargin=2*cm,bottomMargin=2*cm)
1128
- stys=getSampleStyleSheet(); story=[]
1129
- story.append(Paragraph("Eatlytic Food Label Analysis",stys["Title"]))
1130
- story.append(Paragraph(f"Product: {data.get('product_name','Unknown')}",stys["Heading2"]))
1131
- story.append(Paragraph(MEDICAL_DISCLAIMER,ParagraphStyle("d",parent=stys["Normal"],fontSize=8,textColor=rl.grey)))
1132
- story.append(Spacer(1,.4*cm))
1133
- score=data.get("score",0); sc="22c55e" if score>=7 else "f59e0b" if score>=4 else "ef4444"
1134
- story.append(Paragraph(f"<font color='#{sc}'>Health Score: {score}/10 — {data.get('verdict','')}</font>",stys["Heading1"]))
1135
- if data.get("summary"): story.append(Paragraph("Summary",stys["Heading2"])); story.append(Paragraph(data["summary"],stys["Normal"]))
1136
- nutrients=data.get("nutrient_breakdown",[])
1137
- if nutrients:
1138
- story.append(Paragraph("Nutrient Breakdown",stys["Heading2"]))
1139
- td=[["Nutrient","Amount","Rating"]]+[[str(n.get("name","")),f"{n.get('value','')} {n.get('unit','')}".strip(),str(n.get("rating","")).upper()] for n in nutrients]
1140
- tbl=Table(td,colWidths=[6*cm,4*cm,4*cm])
1141
- tbl.setStyle(TableStyle([("BACKGROUND",(0,0),(-1,0),rl.HexColor("1D9E75")),("TEXTCOLOR",(0,0),(-1,0),rl.white),("FONTSIZE",(0,0),(-1,-1),10),("BACKGROUND",(0,1),(-1,-1),rl.HexColor("f8faf8")),("GRID",(0,0),(-1,-1),.4,rl.HexColor("d0d8d4")),("TOPPADDING",(0,0),(-1,-1),6),("BOTTOMPADDING",(0,0),(-1,-1),6),("LEFTPADDING",(0,0),(-1,-1),8),("RIGHTPADDING",(0,0),(-1,-1),8)]))
1142
- story.append(tbl)
1143
- if data.get("pros"): story.append(Paragraph("Benefits",stys["Heading2"])); [story.append(Paragraph(f"✓ {p}",stys["Normal"])) for p in data["pros"]]
1144
- if data.get("cons"): story.append(Paragraph("Concerns",stys["Heading2"])); [story.append(Paragraph(f"✗ {c}",stys["Normal"])) for c in data["cons"]]
1145
- try: doc.build(story)
1146
- except Exception as exc: return JSONResponse({"error":f"PDF failed: {exc}"},status_code=500)
1147
- buf.seek(0); safe=data.get("product_name","scan").replace(" ","-")[:40]
1148
- return Response(content=buf.getvalue(),media_type="application/pdf",
1149
- headers={"Content-Disposition":f"attachment; filename=eatlytic-{safe}.pdf"})