Shaikhsarib commited on
Commit
d31a573
Β·
verified Β·
1 Parent(s): 5326ed3

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +1150 -0
main.py ADDED
@@ -0,0 +1,1150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Only block if truly no text at all β€” let LLM handle ambiguous cases
826
+ # The LLM is smarter than keyword matching for real product labels
827
+ if not label_check["has_label"] and label_check.get("suggestion") == "no_text":
828
+ return JSONResponse({"error":"no_text",
829
+ "message":"No text found in image. Make sure the label is facing the camera.",
830
+ "tip":"flip_product"})
831
+ # For everything else β€” even uncertain detections β€” proceed to LLM analysis
832
+ allergen_warning=""
833
+ try:
834
+ with db_conn() as conn:
835
+ row=conn.execute("SELECT allergens,conditions FROM allergen_profiles WHERE device_key=?",(device_key,)).fetchone()
836
+ if row:
837
+ tl=extracted_text.lower()
838
+ triggered=[a for a in json.loads(row["allergens"] or "[]") if a.lower() in tl]+\
839
+ [c for c in json.loads(row["conditions"] or "[]") if c.lower() in tl]
840
+ if triggered: allergen_warning=f"⚠️ ALLERGEN ALERT β€” may contain: {', '.join(triggered)}"
841
+ except Exception: pass
842
+ web_context = await asyncio.to_thread(_get_live_search, f"health analysis ingredients {extracted_text[:120]}")
843
+ result = await analyse_label(extracted_text, persona, age_group, product_category,
844
+ language, web_context, blur_info, label_check.get("confidence","medium"))
845
+ result["allergen_warning"]=allergen_warning; result["blur_info"]=blur_info; result["scan_meta"]=scan_check
846
+ today=datetime.date.today().isoformat()
847
+ nutr={n["name"].lower():float(n.get("value",0)) for n in result.get("nutrient_breakdown",[]) if isinstance(n.get("value"),(int,float))}
848
+ cal=nutr.get("energy",nutr.get("calories",nutr.get("calorie",0))); prot=nutr.get("protein",0)
849
+ carb=nutr.get("carbohydrate",nutr.get("carbs",0)); fat=nutr.get("fat",0)
850
+ sod=nutr.get("sodium",0); fib=nutr.get("fiber",nutr.get("fibre",0)); sug=nutr.get("sugar",nutr.get("sugars",0))
851
+ owner_id=user["id"] if user else None
852
+ with db_conn() as conn:
853
+ conn.execute("INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
854
+ (owner_id,device_key,today,result.get("product_name","Scanned item"),cal,prot,carb,fat,sod,fib,sug,"scan"))
855
+ 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
856
+ (owner_id,device_key,result.get("product_name","Unknown"),result.get("score",0),result.get("verdict",""),
857
+ cal,prot,carb,fat,sod,fib,sug,persona,language,
858
+ json.dumps({k:v for k,v in result.items() if k not in ("blur_info","scan_meta","allergen_warning")})))
859
+ 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")
860
+ except Exception: pass
861
+ if user: _update_streak_user(user["id"])
862
+ else:
863
+ try:
864
+ _ensure_device(device_key); td=datetime.date.today().isoformat()
865
+ yd=(datetime.date.today()-datetime.timedelta(days=1)).isoformat()
866
+ with db_conn() as conn:
867
+ row=conn.execute("SELECT streak_days,last_scan_date FROM devices WHERE device_key=?",(device_key,)).fetchone()
868
+ if row and row["last_scan_date"]!=td:
869
+ st=(row["streak_days"]+1) if row["last_scan_date"]==yd else 1
870
+ conn.execute("UPDATE devices SET streak_days=?,last_scan_date=? WHERE device_key=?",(st,td,device_key))
871
+ except Exception: pass
872
+ return JSONResponse(result)
873
+ except ValueError as exc: return JSONResponse({"error":str(exc)}, status_code=400)
874
+ except Exception as exc:
875
+ logger.error("Analysis error: %s", exc, exc_info=True)
876
+ return JSONResponse({"error":f"Scan failed: {str(exc)[:140]}. Please try again."})
877
+
878
+ @app.get("/scan-status")
879
+ async def scan_status(request: Request):
880
+ user=_get_request_user(request); device_key=_device_key(request)
881
+ _ensure_device(device_key); month_key=datetime.date.today().isoformat()[:7]
882
+ if user:
883
+ with db_conn() as conn:
884
+ row=conn.execute("SELECT is_pro,scan_month,scan_count_month,streak_days FROM users WHERE id=?",(user["id"],)).fetchone()
885
+ if not row or row["scan_month"]!=month_key:
886
+ return {"scans_used":0,"scans_remaining":FREE_SCAN_LIMIT,"is_pro":False,"limit":FREE_SCAN_LIMIT,"streak":0,"authenticated":True}
887
+ used=row["scan_count_month"]
888
+ return {"scans_used":used,"scans_remaining":9999 if row["is_pro"] else max(0,FREE_SCAN_LIMIT-used),
889
+ "is_pro":bool(row["is_pro"]),"limit":FREE_SCAN_LIMIT,"streak":row["streak_days"],"authenticated":True}
890
+ with db_conn() as conn:
891
+ row=conn.execute("SELECT is_pro,month,scan_count,streak_days FROM devices WHERE device_key=?",(device_key,)).fetchone()
892
+ if not row or row["month"]!=month_key:
893
+ return {"scans_used":0,"scans_remaining":FREE_SCAN_LIMIT,"is_pro":False,"limit":FREE_SCAN_LIMIT,"streak":0,"authenticated":False}
894
+ used=row["scan_count"]
895
+ return {"scans_used":used,"scans_remaining":9999 if row["is_pro"] else max(0,FREE_SCAN_LIMIT-used),
896
+ "is_pro":bool(row["is_pro"]),"limit":FREE_SCAN_LIMIT,"streak":row["streak_days"],"authenticated":False}
897
+
898
+ # ── Auth routes ────────────────────────────────────────────────────────
899
+ @app.post("/auth/request-otp")
900
+ async def request_otp(email: str = Form(...)):
901
+ otp = _send_otp(email)
902
+ return JSONResponse({"sent":True,"message":"OTP sent.","_dev_otp":otp})
903
+
904
+ @app.post("/auth/verify-otp")
905
+ async def verify_otp(request: Request, email: str = Form(...), otp: str = Form(...)):
906
+ user = _verify_otp(email, otp)
907
+ if not user: raise HTTPException(status_code=401, detail="Invalid or expired OTP")
908
+ token = _create_session(user["id"], request.headers.get("user-agent","")[:100])
909
+ return JSONResponse({"token":token,"user_id":user["id"],"email":user.get("email",""),"is_pro":bool(user.get("is_pro",0))})
910
+
911
+ @app.post("/auth/logout")
912
+ async def logout(request: Request):
913
+ auth=request.headers.get("Authorization","")
914
+ token=auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
915
+ if token:
916
+ with db_conn() as conn: conn.execute("DELETE FROM sessions WHERE token=?",(token,))
917
+ return JSONResponse({"logged_out":True})
918
+
919
+ @app.get("/auth/me")
920
+ async def get_me(request: Request):
921
+ user=_get_request_user(request)
922
+ if not user: raise HTTPException(status_code=401, detail="Not authenticated")
923
+ return JSONResponse({"user_id":user["id"],"email":user.get("email",""),"name":user.get("name",""),
924
+ "is_pro":bool(user.get("is_pro",0)),"streak_days":user.get("streak_days",0),
925
+ "persona":user.get("persona","General Adult"),"language":user.get("language","en")})
926
+
927
+ # ── Payment routes ─────────────────────────────────────────────────────
928
+ @app.post("/payments/create-order")
929
+ async def create_order(request: Request):
930
+ user=_get_request_user(request)
931
+ if not user: raise HTTPException(status_code=401, detail="Login required. POST /auth/request-otp first.")
932
+ device_key=_device_key(request)
933
+ try:
934
+ return JSONResponse(_create_razorpay_order(user["id"], device_key))
935
+ except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc))
936
+
937
+ @app.post("/payments/verify")
938
+ async def verify_payment(request: Request, razorpay_order_id: str = Form(...),
939
+ razorpay_payment_id: str = Form(...), razorpay_signature: str = Form(...)):
940
+ try:
941
+ return JSONResponse(_activate_pro_payment(razorpay_order_id, razorpay_payment_id, razorpay_signature))
942
+ except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc))
943
+
944
+ @app.post("/activate-pro")
945
+ async def activate_pro_legacy(request: Request, payment_id: str = Form(...)):
946
+ """Legacy endpoint β€” kept for backward compatibility."""
947
+ device_key=_device_key(request); _ensure_device(device_key)
948
+ with db_conn() as conn: conn.execute("UPDATE devices SET is_pro=1 WHERE device_key=?",(device_key,))
949
+ return {"status":"activated","message":"Pro activated. Use /payments/create-order for real billing."}
950
+
951
+ # ── Food DB routes ─────────────────────────────────────────────────────
952
+ @app.get("/food-search")
953
+ @limiter.limit("30/minute")
954
+ async def food_search(request: Request, q: str = ""):
955
+ if not q or len(q.strip())<2: return {"products":[],"source":"none"}
956
+ with db_conn() as conn:
957
+ 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()
958
+ if rows: return {"products":[dict(r) for r in rows],"source":"eatlytic_db"}
959
+ try:
960
+ import httpx
961
+ async with httpx.AsyncClient(timeout=8) as hc:
962
+ resp=await hc.get("https://world.openfoodfacts.org/cgi/search.pl",
963
+ params={"search_terms":q,"action":"process","json":1,"page_size":10,
964
+ "fields":"product_name,brands,nutriments"})
965
+ products=[]
966
+ for p in resp.json().get("products",[]):
967
+ n=p.get("nutriments",{})
968
+ products.append({"name":p.get("product_name",""),"brand":p.get("brands",""),
969
+ "calories_100g":round(n.get("energy-kcal_100g",0),1),
970
+ "protein_100g":round(n.get("proteins_100g",0),1),
971
+ "carbs_100g":round(n.get("carbohydrates_100g",0),1),
972
+ "fat_100g":round(n.get("fat_100g",0),1),
973
+ "sodium_100g":round(n.get("sodium_100g",0)*1000,1),
974
+ "fiber_100g":round(n.get("fiber_100g",0),1),
975
+ "sugar_100g":round(n.get("sugars_100g",0),1),
976
+ "eatlytic_score":0,"verified":0,"source":"openfoodfacts"})
977
+ return {"products":products,"source":"openfoodfacts"}
978
+ except Exception as exc:
979
+ logger.warning("Food search: %s", exc); return {"products":[],"source":"unavailable"}
980
+
981
+ @app.get("/food-db/stats")
982
+ async def food_db_stats():
983
+ with db_conn() as conn:
984
+ total =conn.execute("SELECT COUNT(*) FROM food_products").fetchone()[0]
985
+ verified=conn.execute("SELECT COUNT(*) FROM food_products WHERE verified=1").fetchone()[0]
986
+ return {"total_products":total,"verified_products":verified,
987
+ "moat_status": "πŸ”΄ Early (<1K)" if total<1000 else "🟑 Growing (1K-10K)" if total<10000 else "🟒 Defensible (10K+)"}
988
+
989
+ # ── Daily tracker routes ───────────────────────────────────────────────
990
+ @app.get("/daily-summary")
991
+ async def daily_summary(request: Request, date: str = None):
992
+ user=_get_request_user(request); device_key=_device_key(request)
993
+ target_date=date or datetime.date.today().isoformat(); user_id=user["id"] if user else None
994
+ with db_conn() as conn:
995
+ 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()
996
+ clause="user_id=?" if user_id else "device_key=?"; param=user_id or device_key
997
+ 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()
998
+ 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()
999
+ tdee=float((dev and dev["tdee"]) or 2000) or 2000
1000
+ totals={k:round(row[k] or 0,1) for k in ("cal","prot","carb","fat","sod","fib","sug")}
1001
+ t={"calories":round(tdee),"protein":56,"carbs":round(tdee*.5/4),"fat":round(tdee*.3/9),"sodium":2300,"fiber":28,"sugar":50}
1002
+ cal_left=max(0,t["calories"]-totals["cal"]); prot_left=max(0,t["protein"]-totals["prot"])
1003
+ suggestion=""
1004
+ if cal_left<200: suggestion="🎯 Almost at your calorie target!"
1005
+ elif prot_left>20: suggestion=f"πŸ’ͺ {round(prot_left)}g protein left. Try: eggs, dal, paneer."
1006
+ elif cal_left>600: suggestion=f"🍽 {round(cal_left)} kcal remaining."
1007
+ return {"date":target_date,"totals":totals,"targets":t,"suggestion":suggestion,
1008
+ "items":row["items"] or 0,"log":[dict(r) for r in log_items]}
1009
+
1010
+ @app.post("/daily-log")
1011
+ @limiter.limit("30/minute")
1012
+ async def daily_log(request: Request, meal_name: str=Form(...), calories: float=Form(0),
1013
+ protein: float=Form(0), carbs: float=Form(0), fat: float=Form(0),
1014
+ sodium: float=Form(0), fiber: float=Form(0), sugar: float=Form(0),
1015
+ source: str=Form("manual"), log_date: str=Form(None)):
1016
+ user=_get_request_user(request); device_key=_device_key(request)
1017
+ target_date=log_date or datetime.date.today().isoformat(); user_id=user["id"] if user else None
1018
+ _ensure_device(device_key)
1019
+ with db_conn() as conn:
1020
+ conn.execute("INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
1021
+ (user_id,device_key,target_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source))
1022
+ return {"status":"logged","date":target_date,"meal":meal_name}
1023
+
1024
+ @app.delete("/daily-log/{log_id}")
1025
+ async def delete_log(request: Request, log_id: int):
1026
+ device_key=_device_key(request)
1027
+ with db_conn() as conn: conn.execute("DELETE FROM daily_logs WHERE id=? AND device_key=?",(log_id,device_key))
1028
+ return {"status":"deleted","id":log_id}
1029
+
1030
+ # ── Profile routes ─────────────────────────────────────────────────────
1031
+ @app.post("/onboarding-complete")
1032
+ async def onboarding_complete(request: Request, persona: str=Form("General Adult"),
1033
+ language: str=Form("en"), tdee: float=Form(0), allergens: str=Form("[]")):
1034
+ user=_get_request_user(request); device_key=_device_key(request)
1035
+ _ensure_device(device_key); user_id=user["id"] if user else None
1036
+ with db_conn() as conn:
1037
+ conn.execute("UPDATE devices SET onboarding_done=1,persona=?,language=?,tdee=? WHERE device_key=?",(persona,language,tdee,device_key))
1038
+ if user_id: conn.execute("UPDATE users SET onboarding_done=1,persona=?,language=?,tdee=? WHERE id=?",(persona,language,tdee,user_id))
1039
+ conn.execute("INSERT OR REPLACE INTO allergen_profiles(device_key,user_id,allergens) VALUES(?,?,?)",(device_key,user_id,allergens))
1040
+ return {"status":"ok"}
1041
+
1042
+ @app.get("/allergen-profile")
1043
+ async def get_allergen_profile(request: Request):
1044
+ device_key=_device_key(request)
1045
+ with db_conn() as conn:
1046
+ row=conn.execute("SELECT allergens,conditions FROM allergen_profiles WHERE device_key=?",(device_key,)).fetchone()
1047
+ if not row: return {"allergens":[],"conditions":[]}
1048
+ return {"allergens":json.loads(row["allergens"] or "[]"),"conditions":json.loads(row["conditions"] or "[]")}
1049
+
1050
+ @app.post("/allergen-profile")
1051
+ async def set_allergen_profile(request: Request, allergens: str=Form("[]"), conditions: str=Form("[]")):
1052
+ device_key=_device_key(request); _ensure_device(device_key)
1053
+ with db_conn() as conn:
1054
+ conn.execute("INSERT OR REPLACE INTO allergen_profiles(device_key,allergens,conditions,updated_at) VALUES(?,?,?,datetime('now'))",(device_key,allergens,conditions))
1055
+ return {"status":"saved"}
1056
+
1057
+ # ── Admin routes ───────────────────────────────────────────────────────
1058
+ @app.get("/admin/analytics")
1059
+ async def admin_analytics(request: Request):
1060
+ token=request.headers.get("X-Admin-Token","")
1061
+ if token != ADMIN_TOKEN: raise HTTPException(status_code=403, detail="Invalid token")
1062
+ today=datetime.date.today().isoformat(); mkey=today[:7]
1063
+ with db_conn() as conn:
1064
+ dau =conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE DATE(scanned_at)=?",(today,)).fetchone()[0]
1065
+ mau =conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE strftime('%Y-%m',scanned_at)=?",(mkey,)).fetchone()[0]
1066
+ tot =conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0]
1067
+ avgs=conn.execute("SELECT AVG(score) FROM scans").fetchone()[0]
1068
+ users=conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
1069
+ food_ct=conn.execute("SELECT COUNT(*) FROM food_products").fetchone()[0]
1070
+ verified=conn.execute("SELECT COUNT(*) FROM food_products WHERE verified=1").fetchone()[0]
1071
+ top=conn.execute("SELECT product_name,COUNT(*) c FROM scans GROUP BY product_name ORDER BY c DESC LIMIT 10").fetchall()
1072
+ return {"dau":dau,"mau":mau,"total_scans":tot,"total_users":users,
1073
+ "avg_score":round(avgs or 0,2),"dau_mau":round(dau/mau*100,1) if mau else 0,
1074
+ "food_db":{"total":food_ct,"verified":verified},
1075
+ "top_products":[{"name":r[0],"scans":r[1]} for r in top]}
1076
+
1077
+ @app.post("/admin/create-api-key")
1078
+ async def create_api_key_endpoint(request: Request, client_name: str=Form(...), plan: str=Form("business")):
1079
+ token=request.headers.get("X-Admin-Token","")
1080
+ if token != ADMIN_TOKEN: raise HTTPException(status_code=403, detail="Invalid admin token")
1081
+ key="eak_"+secrets.token_urlsafe(32)
1082
+ with db_conn() as conn: conn.execute("INSERT INTO api_keys(api_key,client_name,plan) VALUES(?,?,?)",(key,client_name,plan))
1083
+ return {"api_key":key,"client":client_name,"plan":plan}
1084
+
1085
+ # ── Misc routes ────────────────────────────────────────────────────────
1086
+ @app.post("/nps")
1087
+ async def submit_nps(request: Request, score: int=Form(...), comment: str=Form("")):
1088
+ if not 0<=score<=10: return JSONResponse({"error":"Score must be 0-10"},status_code=400)
1089
+ user=_get_request_user(request); device_key=_device_key(request)
1090
+ with db_conn() as conn:
1091
+ conn.execute("INSERT INTO nps_responses(device_key,user_id,score,comment) VALUES(?,?,?,?)",
1092
+ (device_key,user["id"] if user else None,score,comment[:500]))
1093
+ return {"status":"thank_you"}
1094
+
1095
+ @app.post("/generate-share-card")
1096
+ @limiter.limit("20/minute")
1097
+ async def generate_share_card(request: Request, product_name: str=Form(...), score: int=Form(...),
1098
+ verdict: str=Form(...), top_warning: str=Form(""), top_pro: str=Form("")):
1099
+ W,H=1080,1080; img=Image.new("RGB",(W,H),(15,17,23)); draw=ImageDraw.Draw(img)
1100
+ font=ImageFont.load_default()
1101
+ s_rgb=(34,197,94) if score>=7 else (245,158,11) if score>=4 else (239,68,68)
1102
+ def centered(text,y,fill):
1103
+ try: tw=font.getbbox(text)[2]-font.getbbox(text)[0]
1104
+ except: tw=len(text)*6
1105
+ draw.text(((W-tw)//2,y),text,fill=fill,font=font)
1106
+ draw.ellipse([340,160,740,560],outline=s_rgb,width=18)
1107
+ centered(str(score),340,s_rgb); centered("/10",430,(100,116,139))
1108
+ centered(product_name[:38]+("…" if len(product_name)>38 else ""),600,(255,255,255))
1109
+ centered(verdict[:50],650,(148,163,184))
1110
+ if top_pro: draw.rectangle([60,700,1020,760],fill=(15,60,40)); centered(f"βœ“ {top_pro[:65]}",718,(74,222,128))
1111
+ if top_warning: draw.rectangle([60,775,1020,840],fill=(124,29,29)); centered(f"⚠ {top_warning[:65]}",795,(252,165,165))
1112
+ centered("eatlytic.com β€’ scan any food label, no barcode needed",1000,(71,85,105))
1113
+ buf=BytesIO(); img.save(buf,format="PNG",optimize=True); buf.seek(0)
1114
+ return Response(content=buf.getvalue(),media_type="image/png",
1115
+ headers={"Content-Disposition":"attachment; filename=eatlytic-scan.png"})
1116
+
1117
+ @app.post("/export-pdf")
1118
+ @limiter.limit("10/minute")
1119
+ async def export_pdf(request: Request, analysis_json: str=Form(...)):
1120
+ try: data=json.loads(analysis_json)
1121
+ except Exception: return JSONResponse({"error":"Invalid JSON"},status_code=400)
1122
+ try:
1123
+ from reportlab.lib.pagesizes import A4
1124
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
1125
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
1126
+ from reportlab.lib import colors as rl; from reportlab.lib.units import cm
1127
+ except ImportError: return JSONResponse({"error":"reportlab not installed"},status_code=501)
1128
+ buf=BytesIO(); doc=SimpleDocTemplate(buf,pagesize=A4,rightMargin=2*cm,leftMargin=2*cm,topMargin=2*cm,bottomMargin=2*cm)
1129
+ stys=getSampleStyleSheet(); story=[]
1130
+ story.append(Paragraph("Eatlytic Food Label Analysis",stys["Title"]))
1131
+ story.append(Paragraph(f"Product: {data.get('product_name','Unknown')}",stys["Heading2"]))
1132
+ story.append(Paragraph(MEDICAL_DISCLAIMER,ParagraphStyle("d",parent=stys["Normal"],fontSize=8,textColor=rl.grey)))
1133
+ story.append(Spacer(1,.4*cm))
1134
+ score=data.get("score",0); sc="22c55e" if score>=7 else "f59e0b" if score>=4 else "ef4444"
1135
+ story.append(Paragraph(f"<font color='#{sc}'>Health Score: {score}/10 β€” {data.get('verdict','')}</font>",stys["Heading1"]))
1136
+ if data.get("summary"): story.append(Paragraph("Summary",stys["Heading2"])); story.append(Paragraph(data["summary"],stys["Normal"]))
1137
+ nutrients=data.get("nutrient_breakdown",[])
1138
+ if nutrients:
1139
+ story.append(Paragraph("Nutrient Breakdown",stys["Heading2"]))
1140
+ 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]
1141
+ tbl=Table(td,colWidths=[6*cm,4*cm,4*cm])
1142
+ 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)]))
1143
+ story.append(tbl)
1144
+ if data.get("pros"): story.append(Paragraph("Benefits",stys["Heading2"])); [story.append(Paragraph(f"βœ“ {p}",stys["Normal"])) for p in data["pros"]]
1145
+ if data.get("cons"): story.append(Paragraph("Concerns",stys["Heading2"])); [story.append(Paragraph(f"βœ— {c}",stys["Normal"])) for c in data["cons"]]
1146
+ try: doc.build(story)
1147
+ except Exception as exc: return JSONResponse({"error":f"PDF failed: {exc}"},status_code=500)
1148
+ buf.seek(0); safe=data.get("product_name","scan").replace(" ","-")[:40]
1149
+ return Response(content=buf.getvalue(),media_type="application/pdf",
1150
+ headers={"Content-Disposition":f"attachment; filename=eatlytic-{safe}.pdf"})