Shaikhsarib commited on
Commit
35ff215
·
verified ·
1 Parent(s): a443ad8

Upload 2 files

Browse files
Files changed (2) hide show
  1. index.html +0 -0
  2. main.py +1681 -0
index.html ADDED
The diff for this file is too large to render. See raw diff
 
main.py ADDED
@@ -0,0 +1,1681 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Eatlytic v3 — Production-ready backend
3
+ Fixes applied (from Founder's Playbook audit):
4
+ SECURITY: CORS locked down, ADMIN_TOKEN guard, rate-limit tightened
5
+ BUG: JSON storage → SQLite (WAL mode, thread-safe)
6
+ BUG: bare except: → named exceptions everywhere
7
+ BUG: duckduckgo hard import → guarded try/except
8
+ BUG: easyocr module-level init → lazy double-checked locking (no 30s startup freeze)
9
+ BUG: tuple[bytes,str] annotation → Python <3.9 compatible
10
+ BUG: anchor="mm" on bitmap font → draw_centered() with getbbox()
11
+ BUG: HexColor("#...") → HexColor without # prefix
12
+ BUG: ROWBACKGROUNDS / PADDING invalid reportlab commands → correct commands
13
+ BUG: chart_data rounding off-by-one → largest-bucket clamp
14
+ BUG: nutrient value "34g" → NaN → regex sanitise to float
15
+ BUG: score always 6 → removed "score":7 anchor, added strict rubric, v3 cache key
16
+ BUG: front image passes through → NUTRITION_TABLE_ANCHORS require 2+ back-label terms
17
+ FEATURE: Medical disclaimer on every response (legal / FSSAI compliance)
18
+ FEATURE: Allergen profile + live scan alerts
19
+ FEATURE: Streak tracking per device
20
+ FEATURE: Auto-log every scan into daily_logs table
21
+ FEATURE: /daily-summary, /daily-log, /food-search, /allergen-profile, /nps
22
+ FEATURE: /onboarding-complete, /admin/analytics
23
+ FEATURE: LLM abstraction layer (call_llm) — swap provider in one line
24
+ FEATURE: asyncio.to_thread for LLM calls — never blocks ASGI loop
25
+ """
26
+
27
+ import os
28
+ import re
29
+ import json
30
+ import sqlite3
31
+ import asyncio
32
+ import logging
33
+ import hashlib
34
+ import base64
35
+ import secrets
36
+ import tempfile
37
+ import threading
38
+ import datetime
39
+ import cv2
40
+ import numpy as np
41
+ from contextlib import contextmanager
42
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
43
+ from io import BytesIO
44
+ from fastapi import FastAPI, File, UploadFile, Form, Request, HTTPException, Security
45
+ from fastapi.middleware.cors import CORSMiddleware
46
+ from fastapi.responses import FileResponse, JSONResponse, Response
47
+ from fastapi.security import APIKeyHeader
48
+
49
+ # ── DuckDuckGo: guarded import ─────────────────────────────────────────
50
+ # Hard import crashes the server if wrong package version is installed.
51
+ try:
52
+ from duckduckgo_search import DDGS as _DDGS
53
+ _DDGS_OK = True
54
+ except Exception:
55
+ _DDGS = None # type: ignore
56
+ _DDGS_OK = False
57
+
58
+ from groq import Groq
59
+ from slowapi import Limiter, _rate_limit_exceeded_handler
60
+ from slowapi.util import get_remote_address
61
+ from slowapi.errors import RateLimitExceeded
62
+
63
+ logging.basicConfig(level=logging.INFO)
64
+ logger = logging.getLogger(__name__)
65
+
66
+ # ══════════════════════════════════════════════════════════════════════
67
+ # CONFIG
68
+ # ══════════════════════════════════════════════════════════════════════
69
+ limiter = Limiter(key_func=get_remote_address)
70
+ app = FastAPI(title="Eatlytic v3 — Food Intelligence")
71
+ app.state.limiter = limiter
72
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
73
+
74
+ # SECURITY FIX: lock CORS to production domain (allow_origins=["*"] is wide open).
75
+ # Set ALLOWED_ORIGIN env var in production. Falls back to * only in dev.
76
+ _ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
77
+ app.add_middleware(
78
+ CORSMiddleware,
79
+ allow_origins = [_ALLOWED_ORIGIN],
80
+ allow_methods = ["GET", "POST", "DELETE"],
81
+ allow_headers = ["*"],
82
+ allow_credentials = _ALLOWED_ORIGIN != "*",
83
+ )
84
+
85
+ DATA_DIR = os.path.join(os.getcwd(), "data")
86
+ CACHE_DIR = os.environ.get("HF_HOME", "/app/.cache")
87
+ MODEL_DIR = os.path.join(CACHE_DIR, "easyocr_models")
88
+ for _d in [MODEL_DIR, DATA_DIR]:
89
+ os.makedirs(_d, exist_ok=True)
90
+
91
+ FREE_SCAN_LIMIT = 10
92
+ APP_VERSION = "3.0"
93
+ MEDICAL_DISCLAIMER = (
94
+ "⚕️ For informational purposes only — not medical advice. "
95
+ "Consult a qualified nutritionist or physician before making dietary decisions."
96
+ )
97
+
98
+ # ══════════════════════════════════════════════════════════════════════
99
+ # SQLite DATABASE (replaces ALL JSON file storage — Playbook P0)
100
+ # ══════════════════════════════════════════════════════════════════════
101
+ DB_FILE = os.path.join(DATA_DIR, "eatlytic.db")
102
+ _db_lock = threading.Lock()
103
+
104
+
105
+ def get_db() -> sqlite3.Connection:
106
+ conn = sqlite3.connect(DB_FILE, check_same_thread=False, timeout=10)
107
+ conn.row_factory = sqlite3.Row
108
+ conn.execute("PRAGMA journal_mode=WAL")
109
+ conn.execute("PRAGMA foreign_keys=ON")
110
+ return conn
111
+
112
+
113
+ @contextmanager
114
+ def db_conn():
115
+ """Thread-safe DB context manager with auto-commit/rollback."""
116
+ conn = get_db()
117
+ try:
118
+ yield conn
119
+ conn.commit()
120
+ except Exception:
121
+ conn.rollback()
122
+ raise
123
+ finally:
124
+ conn.close()
125
+
126
+
127
+ def init_db():
128
+ with db_conn() as conn:
129
+ conn.executescript("""
130
+ CREATE TABLE IF NOT EXISTS devices (
131
+ device_key TEXT PRIMARY KEY,
132
+ created_at TEXT DEFAULT (datetime('now')),
133
+ is_pro INTEGER DEFAULT 0,
134
+ month TEXT DEFAULT '',
135
+ scan_count INTEGER DEFAULT 0,
136
+ streak_days INTEGER DEFAULT 0,
137
+ last_scan_date TEXT DEFAULT '',
138
+ persona TEXT DEFAULT 'General Adult',
139
+ language TEXT DEFAULT 'en',
140
+ tdee REAL DEFAULT 0,
141
+ onboarding_done INTEGER DEFAULT 0
142
+ );
143
+
144
+ CREATE TABLE IF NOT EXISTS scans (
145
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
146
+ device_key TEXT NOT NULL,
147
+ product_name TEXT DEFAULT 'Unknown',
148
+ score INTEGER DEFAULT 0,
149
+ verdict TEXT DEFAULT '',
150
+ calories REAL DEFAULT 0,
151
+ protein REAL DEFAULT 0,
152
+ carbs REAL DEFAULT 0,
153
+ fat REAL DEFAULT 0,
154
+ sodium REAL DEFAULT 0,
155
+ fiber REAL DEFAULT 0,
156
+ sugar REAL DEFAULT 0,
157
+ persona TEXT DEFAULT '',
158
+ language TEXT DEFAULT 'en',
159
+ scanned_at TEXT DEFAULT (datetime('now')),
160
+ analysis_json TEXT DEFAULT '{}'
161
+ );
162
+
163
+ CREATE TABLE IF NOT EXISTS daily_logs (
164
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
165
+ device_key TEXT NOT NULL,
166
+ log_date TEXT NOT NULL,
167
+ meal_name TEXT DEFAULT '',
168
+ calories REAL DEFAULT 0,
169
+ protein REAL DEFAULT 0,
170
+ carbs REAL DEFAULT 0,
171
+ fat REAL DEFAULT 0,
172
+ sodium REAL DEFAULT 0,
173
+ fiber REAL DEFAULT 0,
174
+ sugar REAL DEFAULT 0,
175
+ source TEXT DEFAULT 'scan',
176
+ logged_at TEXT DEFAULT (datetime('now'))
177
+ );
178
+
179
+ CREATE TABLE IF NOT EXISTS allergen_profiles (
180
+ device_key TEXT PRIMARY KEY,
181
+ allergens TEXT DEFAULT '[]',
182
+ conditions TEXT DEFAULT '[]',
183
+ updated_at TEXT DEFAULT (datetime('now'))
184
+ );
185
+
186
+ CREATE TABLE IF NOT EXISTS nps_responses (
187
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188
+ device_key TEXT NOT NULL,
189
+ score INTEGER NOT NULL,
190
+ comment TEXT DEFAULT '',
191
+ submitted_at TEXT DEFAULT (datetime('now'))
192
+ );
193
+
194
+ CREATE TABLE IF NOT EXISTS api_keys (
195
+ api_key TEXT PRIMARY KEY,
196
+ client_name TEXT NOT NULL,
197
+ plan TEXT DEFAULT 'business',
198
+ scans_this_month INTEGER DEFAULT 0,
199
+ month TEXT DEFAULT '',
200
+ active INTEGER DEFAULT 1,
201
+ created_at TEXT DEFAULT (datetime('now'))
202
+ );
203
+
204
+ CREATE TABLE IF NOT EXISTS ocr_cache (
205
+ cache_key TEXT PRIMARY KEY,
206
+ result_json TEXT NOT NULL,
207
+ created_at TEXT DEFAULT (datetime('now'))
208
+ );
209
+
210
+ CREATE TABLE IF NOT EXISTS ai_cache (
211
+ cache_key TEXT PRIMARY KEY,
212
+ result_json TEXT NOT NULL,
213
+ created_at TEXT DEFAULT (datetime('now'))
214
+ );
215
+
216
+ CREATE INDEX IF NOT EXISTS idx_scans_device ON scans(device_key);
217
+ CREATE INDEX IF NOT EXISTS idx_scans_date ON scans(scanned_at);
218
+ CREATE INDEX IF NOT EXISTS idx_daily_device_date ON daily_logs(device_key, log_date);
219
+ """)
220
+ logger.info("DB ready at %s", DB_FILE)
221
+
222
+
223
+ init_db()
224
+
225
+
226
+ # ── Cache helpers ──────────────────────────────────────────────────────
227
+ def get_ocr_cache(key: str):
228
+ try:
229
+ with db_conn() as conn:
230
+ row = conn.execute(
231
+ "SELECT result_json FROM ocr_cache WHERE cache_key=?", (key,)
232
+ ).fetchone()
233
+ return json.loads(row["result_json"]) if row else None
234
+ except Exception:
235
+ return None
236
+
237
+
238
+ def set_ocr_cache(key: str, value: dict):
239
+ try:
240
+ with db_conn() as conn:
241
+ conn.execute(
242
+ "INSERT OR REPLACE INTO ocr_cache(cache_key,result_json) VALUES(?,?)",
243
+ (key, json.dumps(value))
244
+ )
245
+ except Exception as exc:
246
+ logger.warning("set_ocr_cache failed: %s", exc)
247
+
248
+
249
+ def get_ai_cache(key: str):
250
+ try:
251
+ with db_conn() as conn:
252
+ row = conn.execute(
253
+ "SELECT result_json FROM ai_cache WHERE cache_key=?", (key,)
254
+ ).fetchone()
255
+ return json.loads(row["result_json"]) if row else None
256
+ except Exception:
257
+ return None
258
+
259
+
260
+ def set_ai_cache(key: str, value: dict):
261
+ try:
262
+ with db_conn() as conn:
263
+ conn.execute(
264
+ "INSERT OR REPLACE INTO ai_cache(cache_key,result_json) VALUES(?,?)",
265
+ (key, json.dumps(value))
266
+ )
267
+ except Exception as exc:
268
+ logger.warning("set_ai_cache failed: %s", exc)
269
+
270
+
271
+ # ══════════════════════════════════════════════════════════════════════
272
+ # SCAN QUOTA (SQLite-backed — survives restarts)
273
+ # ══════════════════════════════════════════════════════════════════════
274
+ def _ensure_device(device_key: str):
275
+ try:
276
+ with db_conn() as conn:
277
+ conn.execute(
278
+ "INSERT OR IGNORE INTO devices(device_key) VALUES(?)", (device_key,)
279
+ )
280
+ except Exception as exc:
281
+ logger.warning("_ensure_device: %s", exc)
282
+
283
+
284
+ def check_and_increment_scan(device_key: str) -> dict:
285
+ _ensure_device(device_key)
286
+ month_key = datetime.date.today().isoformat()[:7]
287
+ try:
288
+ with db_conn() as conn:
289
+ row = conn.execute(
290
+ "SELECT is_pro, month, scan_count FROM devices WHERE device_key=?",
291
+ (device_key,)
292
+ ).fetchone()
293
+ if not row:
294
+ return {"allowed": False, "scans_used": 0, "scans_remaining": 0, "is_pro": False}
295
+
296
+ if row["month"] != month_key:
297
+ conn.execute(
298
+ "UPDATE devices SET month=?, scan_count=0 WHERE device_key=?",
299
+ (month_key, device_key)
300
+ )
301
+ count = 0
302
+ else:
303
+ count = row["scan_count"]
304
+
305
+ if row["is_pro"]:
306
+ conn.execute(
307
+ "UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?",
308
+ (device_key,)
309
+ )
310
+ return {"allowed": True, "scans_used": count + 1,
311
+ "scans_remaining": 9999, "is_pro": True}
312
+
313
+ if count >= FREE_SCAN_LIMIT:
314
+ return {"allowed": False, "scans_used": count,
315
+ "scans_remaining": 0, "is_pro": False}
316
+
317
+ conn.execute(
318
+ "UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?",
319
+ (device_key,)
320
+ )
321
+ new_count = count + 1
322
+ return {"allowed": True, "scans_used": new_count,
323
+ "scans_remaining": FREE_SCAN_LIMIT - new_count, "is_pro": False}
324
+ except Exception as exc:
325
+ logger.error("check_and_increment_scan: %s", exc)
326
+ return {"allowed": True, "scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT, "is_pro": False}
327
+
328
+
329
+ def update_streak(device_key: str):
330
+ """Increment streak; reset if user missed a day."""
331
+ _ensure_device(device_key)
332
+ today = datetime.date.today().isoformat()
333
+ yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
334
+ try:
335
+ with db_conn() as conn:
336
+ row = conn.execute(
337
+ "SELECT streak_days, last_scan_date FROM devices WHERE device_key=?",
338
+ (device_key,)
339
+ ).fetchone()
340
+ if not row or row["last_scan_date"] == today:
341
+ return
342
+ streak = (row["streak_days"] + 1) if row["last_scan_date"] == yesterday else 1
343
+ conn.execute(
344
+ "UPDATE devices SET streak_days=?, last_scan_date=? WHERE device_key=?",
345
+ (streak, today, device_key)
346
+ )
347
+ except Exception as exc:
348
+ logger.warning("update_streak: %s", exc)
349
+
350
+
351
+ # ══════════════════════════════════════════════════════════════════════
352
+ # API KEY AUTH (B2B)
353
+ # ══════════════════════════════════════════════════════════════════════
354
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
355
+
356
+
357
+ def verify_api_key(api_key: str = Security(api_key_header)):
358
+ if not api_key:
359
+ return None
360
+ try:
361
+ with db_conn() as conn:
362
+ row = conn.execute(
363
+ "SELECT * FROM api_keys WHERE api_key=? AND active=1", (api_key,)
364
+ ).fetchone()
365
+ return dict(row) if row else None
366
+ except Exception:
367
+ return None
368
+
369
+
370
+ def generate_api_key(client_name: str, plan: str = "business") -> str:
371
+ key = "eak_" + secrets.token_urlsafe(32)
372
+ with db_conn() as conn:
373
+ conn.execute(
374
+ "INSERT INTO api_keys(api_key,client_name,plan) VALUES(?,?,?)",
375
+ (key, client_name, plan)
376
+ )
377
+ return key
378
+
379
+
380
+ # ══════════════════════════════════════════════════════════════════════
381
+ # GROQ CLIENT + LLM ABSTRACTION (Playbook: swap provider in one line)
382
+ # ══════════════════════════════════════════════════════════════════════
383
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
384
+ if not GROQ_API_KEY:
385
+ logger.warning("⚠️ GROQ_API_KEY missing — analysis will fail")
386
+ _groq_client = None
387
+ else:
388
+ _groq_client = Groq(api_key=GROQ_API_KEY)
389
+
390
+
391
+ def call_llm(prompt: str, max_tokens: int = 2500) -> str:
392
+ """
393
+ LLM abstraction layer — tries 70B then falls back to 8B.
394
+ Swap providers by changing this one function only.
395
+ """
396
+ if not _groq_client:
397
+ raise RuntimeError("GROQ_API_KEY not configured")
398
+ for model in ["llama-3.3-70b-versatile", "llama-3.1-8b-instant"]:
399
+ try:
400
+ comp = _groq_client.chat.completions.create(
401
+ model=model,
402
+ messages=[{"role": "user", "content": prompt}],
403
+ temperature=0.1,
404
+ max_tokens=max_tokens,
405
+ response_format={"type": "json_object"},
406
+ )
407
+ return comp.choices[0].message.content
408
+ except Exception as exc:
409
+ logger.warning("LLM model %s failed: %s — trying next", model, exc)
410
+ raise RuntimeError("All LLM models failed")
411
+
412
+
413
+ # ══════════════════════════════════════════════════════════════════════
414
+ # LAZY EASYOCR (no 30s startup freeze)
415
+ # ══════════════════════════════════════════════════════════════════════
416
+ _LANG_READERS : dict = {}
417
+ _LANG_READERS_LOCK = threading.Lock()
418
+ _EASYOCR_LANG_MAP = {
419
+ "en": ["en"], "hi": ["en", "hi"], "zh": ["en", "ch_sim"],
420
+ "ta": ["en", "ta"], "te": ["en", "te"], "bn": ["en", "bn"],
421
+ }
422
+
423
+
424
+ def get_reader_for(lang_hint: str):
425
+ langs = _EASYOCR_LANG_MAP.get(lang_hint, ["en"])
426
+ key = "_".join(sorted(langs))
427
+ if key not in _LANG_READERS:
428
+ with _LANG_READERS_LOCK:
429
+ if key not in _LANG_READERS:
430
+ import easyocr as _easyocr
431
+ logger.info("Loading EasyOCR for langs=%s (first request)", langs)
432
+ _LANG_READERS[key] = _easyocr.Reader(
433
+ langs, gpu=False, model_storage_directory=MODEL_DIR)
434
+ return _LANG_READERS[key]
435
+
436
+
437
+ def get_device_key(request: Request) -> str:
438
+ ip = request.client.host if request.client else "unknown"
439
+ ua = request.headers.get("user-agent", "")
440
+ return hashlib.md5(f"{ip}:{ua}".encode()).hexdigest()[:16]
441
+
442
+
443
+ # ══════════════════════════════════════════════════════════════════════
444
+ # BLUR DETECTION
445
+ # ══════════════════════════════════════════════════════════════════════
446
+ def _laplacian_score(gray: np.ndarray) -> float:
447
+ return float(cv2.Laplacian(gray, cv2.CV_64F).var())
448
+
449
+
450
+ def _tenengrad_score(gray: np.ndarray) -> float:
451
+ gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
452
+ gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
453
+ return float(np.mean(gx ** 2 + gy ** 2))
454
+
455
+
456
+ def _brenner_score(gray: np.ndarray) -> float:
457
+ diff = gray[:, 2:].astype(np.float64) - gray[:, :-2].astype(np.float64)
458
+ return float(np.mean(diff ** 2))
459
+
460
+
461
+ def _local_blur_map(gray: np.ndarray, block: int = 64) -> float:
462
+ h, w = gray.shape
463
+ scores = [
464
+ cv2.Laplacian(gray[y:y + block, x:x + block], cv2.CV_64F).var()
465
+ for y in range(0, h - block, block)
466
+ for x in range(0, w - block, block)
467
+ ]
468
+ return float(np.median(scores)) if scores else 0.0
469
+
470
+
471
+ def assess_image_quality(content: bytes) -> dict:
472
+ try:
473
+ img = Image.open(BytesIO(content)).convert("RGB")
474
+ img_np = np.array(img)
475
+ gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
476
+
477
+ lap = _laplacian_score(gray)
478
+ ten = _tenengrad_score(gray)
479
+ bren = _brenner_score(gray)
480
+ loc = _local_blur_map(gray)
481
+
482
+ comp = (
483
+ 0.25 * min(lap / 300.0 * 100, 100) +
484
+ 0.20 * min(ten / 500.0 * 100, 100) +
485
+ 0.20 * min(bren / 200.0 * 100, 100) +
486
+ 0.35 * min(loc / 300.0 * 100, 100)
487
+ )
488
+
489
+ if comp < 15: severity, is_blurry = "severe", True
490
+ elif comp < 35: severity, is_blurry = "moderate", True
491
+ elif comp < 55: severity, is_blurry = "mild", True
492
+ else: severity, is_blurry = "none", False
493
+
494
+ return {
495
+ "blur_score" : round(comp, 2),
496
+ "is_blurry" : is_blurry,
497
+ "blur_severity": severity,
498
+ "quality" : "poor" if comp < 35 else ("fair" if comp < 55 else "good"),
499
+ }
500
+ except Exception as exc:
501
+ logger.error("Blur detection error: %s", exc)
502
+ return {"blur_score": 999, "is_blurry": False, "blur_severity": "unknown", "quality": "unknown"}
503
+
504
+
505
+ # ══════════════════════════════════════════════════════════════════════
506
+ # DEBLURRING PIPELINE
507
+ # ══════════════════════════════════════════════════════════════════════
508
+ def _wiener_deconvolution(gray: np.ndarray, psf_size: int = 5,
509
+ noise_ratio: float = 0.02) -> np.ndarray:
510
+ psf_size = max(3, psf_size | 1)
511
+ psf = cv2.getGaussianKernel(psf_size, psf_size / 3.0)
512
+ psf = psf @ psf.T; psf /= psf.sum()
513
+ h, w = gray.shape
514
+ padded = np.zeros_like(gray, dtype=np.float64)
515
+ ph, pw = psf.shape
516
+ padded[:ph, :pw] = psf
517
+ padded = np.roll(np.roll(padded, -ph // 2, axis=0), -pw // 2, axis=1)
518
+ Y = np.fft.fft2(gray.astype(np.float64) / 255.0)
519
+ H = np.fft.fft2(padded)
520
+ Hc = np.conj(H)
521
+ W = Hc / (np.abs(H) ** 2 + noise_ratio)
522
+ return np.clip(np.real(np.fft.ifft2(W * Y)) * 255.0, 0, 255).astype(np.uint8)
523
+
524
+
525
+ def _unsharp_mask(img_np: np.ndarray, strength: float = 1.5, radius: int = 3) -> np.ndarray:
526
+ blurred = cv2.GaussianBlur(img_np, (radius * 2 + 1, radius * 2 + 1), 0)
527
+ mask = cv2.subtract(img_np.astype(np.int16), blurred.astype(np.int16))
528
+ return np.clip(img_np.astype(np.float32) + strength * mask, 0, 255).astype(np.uint8)
529
+
530
+
531
+ def _apply_clahe(img_np: np.ndarray, clip: float = 2.5, tile: int = 8) -> np.ndarray:
532
+ lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB)
533
+ cl = cv2.createCLAHE(clipLimit=clip, tileGridSize=(tile, tile))
534
+ lab[:, :, 0] = cl.apply(lab[:, :, 0])
535
+ return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
536
+
537
+
538
+ def _denoise(img_np: np.ndarray, h: int = 6) -> np.ndarray:
539
+ bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
540
+ return cv2.cvtColor(
541
+ cv2.fastNlMeansDenoisingColored(bgr, None, h, h, 7, 21),
542
+ cv2.COLOR_BGR2RGB
543
+ )
544
+
545
+
546
+ def deblur_and_enhance(content: bytes, severity: str = "moderate"):
547
+ """Full 6-stage deblur pipeline. Returns (enhanced_bytes, method_log)."""
548
+ img = Image.open(BytesIO(content)).convert("RGB")
549
+ img_np = np.array(img)
550
+ log = []
551
+
552
+ h, w = img_np.shape[:2]
553
+ if min(h, w) < 1200:
554
+ scale = 1200 / min(h, w)
555
+ img_np = cv2.resize(img_np, (int(w * scale), int(h * scale)),
556
+ interpolation=cv2.INTER_LANCZOS4)
557
+ log.append("upscale")
558
+
559
+ if severity in ("severe", "moderate"):
560
+ h_p = 8 if severity == "severe" else 5
561
+ img_np = _denoise(img_np, h=h_p)
562
+ log.append(f"NLM(h={h_p})")
563
+
564
+ if severity != "mild":
565
+ gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
566
+ psf = 9 if severity == "severe" else 5
567
+ kr = 0.01 if severity == "severe" else 0.025
568
+ rest = _wiener_deconvolution(gray, psf, kr)
569
+ lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB)
570
+ lab[:, :, 0] = rest
571
+ img_np = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
572
+ log.append(f"Wiener(psf={psf})")
573
+
574
+ sm = {"severe": 2.2, "moderate": 1.8, "mild": 1.2}
575
+ rm = {"severe": 4, "moderate": 3, "mild": 2}
576
+ img_np = _unsharp_mask(img_np, strength=sm.get(severity, 1.8),
577
+ radius=rm.get(severity, 3))
578
+ log.append("unsharp")
579
+
580
+ cm = {"severe": 3.0, "moderate": 2.5, "mild": 1.8}
581
+ img_np = _apply_clahe(img_np, clip=cm.get(severity, 2.5))
582
+ log.append("CLAHE")
583
+
584
+ img_np = _unsharp_mask(img_np, strength=1.2, radius=2)
585
+ log.append("sharpen2")
586
+
587
+ # Assess enhancement quality
588
+ gray_orig = cv2.cvtColor(np.array(Image.open(BytesIO(content)).convert("RGB")),
589
+ cv2.COLOR_RGB2GRAY)
590
+ orig_score = float(cv2.Laplacian(gray_orig, cv2.CV_64F).var())
591
+ gray_enh = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
592
+ enh_score = float(cv2.Laplacian(gray_enh, cv2.CV_64F).var())
593
+ log.append(f"orig={orig_score:.0f} enh={enh_score:.0f}")
594
+
595
+ buf = BytesIO()
596
+ Image.fromarray(img_np).save(buf, format="JPEG", quality=92)
597
+ return buf.getvalue(), " → ".join(log)
598
+
599
+
600
+ def _ocr_quality_score(ocr_result: dict) -> float:
601
+ return (ocr_result.get("word_count", 0) * 0.6 +
602
+ ocr_result.get("avg_confidence", 0) * 100 * 0.4)
603
+
604
+
605
+ def image_to_b64(content: bytes) -> str:
606
+ return "data:image/jpeg;base64," + base64.b64encode(content).decode()
607
+
608
+
609
+ # ══════════════════════════════════════════════════════════════════════
610
+ # LABEL DETECTION (front-of-pack vs nutrition label)
611
+ # ══════════════════════════════════════════════════════════════════════
612
+ LABEL_KEYWORDS = [
613
+ 'ingredients', 'nutrition', 'nutritional', 'calories', 'calorie',
614
+ 'protein', 'fat', 'carbohydrate', 'carbs', 'sodium', 'sugar', 'sugars',
615
+ 'fiber', 'fibre', 'serving', 'cholesterol', 'saturated', 'trans',
616
+ 'vitamin', 'calcium', 'iron', 'potassium', 'per 100g', 'per 100 g',
617
+ 'daily value', 'daily values', 'amount per', 'total fat',
618
+ 'contains', 'may contain', 'preservative', 'flavour', 'flavor',
619
+ 'colour', 'color', 'emulsifier', 'stabilizer', 'antioxidant',
620
+ 'mg', 'mcg', 'kcal', 'kj', '% dv', '%dv', 'g per', 'per serving',
621
+ 'fssai', 'veg', 'non-veg', 'best before', 'mfg', 'mrp', 'net wt',
622
+ 'manufactured', 'packed', 'distributed',
623
+ ]
624
+
625
+ FRONT_PACK_SIGNALS = [
626
+ 'new', 'improved', 'original', 'classic', 'natural', 'organic',
627
+ 'premium', 'delicious', 'flavoured', 'variety', 'crunchy', 'crispy',
628
+ 'fresh', 'tasty', 'yummy', 'light', 'baked', 'roasted',
629
+ ]
630
+
631
+ # BUG FIX: Words like 'wheat','milk','salt' appear on front packs too.
632
+ # Require at least 2 nutrition-table anchors to confirm a back label.
633
+ NUTRITION_TABLE_ANCHORS = [
634
+ 'per 100g', 'per 100 g', 'per serving', 'serving size', 'amount per',
635
+ 'daily value', 'daily values', '% dv', '%dv',
636
+ 'calories', 'calorie', 'kcal', 'kj', 'energy',
637
+ 'nutrition facts', 'nutritional information', 'nutrition information',
638
+ 'total fat', 'saturated fat', 'trans fat',
639
+ 'total carbohydrate', 'dietary fiber', 'total sugars',
640
+ 'ingredients:', 'ingredients list',
641
+ 'fssai', 'best before', 'mfg', 'mrp', 'net wt',
642
+ ]
643
+
644
+
645
+ def detect_label_presence(ocr_text: str) -> dict:
646
+ if not ocr_text:
647
+ return {'has_label': False, 'confidence': 'high',
648
+ 'label_hits': [], 'front_hits': [], 'suggestion': 'no_text'}
649
+ tl = ocr_text.lower()
650
+ label_hits = [kw for kw in LABEL_KEYWORDS if kw in tl]
651
+ front_hits = [kw for kw in FRONT_PACK_SIGNALS if kw in tl]
652
+ anchor_hits = [kw for kw in NUTRITION_TABLE_ANCHORS if kw in tl]
653
+ label_score = len(label_hits)
654
+ front_score = len(front_hits)
655
+ has_table = len(anchor_hits) >= 2
656
+
657
+ if has_table and label_score >= 3:
658
+ return {'has_label': True,
659
+ 'confidence': 'high' if label_score >= 6 else 'medium',
660
+ 'label_hits': label_hits[:5], 'front_hits': front_hits[:3],
661
+ 'suggestion': None}
662
+ elif has_table and label_score >= 1 and front_score <= 2:
663
+ return {'has_label': True, 'confidence': 'low',
664
+ 'label_hits': label_hits, 'front_hits': front_hits,
665
+ 'suggestion': None}
666
+ elif front_score > label_score or not has_table:
667
+ sug = 'wrong_side' if (front_score > 0 or not has_table) else 'no_label'
668
+ return {'has_label': False, 'confidence': 'high',
669
+ 'label_hits': label_hits, 'front_hits': front_hits[:3],
670
+ 'suggestion': sug}
671
+ else:
672
+ return {'has_label': True, 'confidence': 'low',
673
+ 'label_hits': label_hits, 'front_hits': front_hits,
674
+ 'suggestion': 'partial'}
675
+
676
+
677
+ # ══════════════════════════════════════════════════════════════════════
678
+ # OCR (SQLite cache)
679
+ # ══════════════════════════════════════════════════════════════════════
680
+ def get_server_ocr(content: bytes, lang_hint: str = "en") -> dict:
681
+ cache_key = f"{hashlib.md5(content).hexdigest()}_{lang_hint}"
682
+ cached = get_ocr_cache(cache_key)
683
+ if cached:
684
+ return cached
685
+
686
+ img = Image.open(BytesIO(content)).convert("RGB")
687
+ img.thumbnail((1200, 1200))
688
+ img_np = np.array(img)
689
+ ocr_reader = get_reader_for(lang_hint)
690
+ results = ocr_reader.readtext(img_np, detail=1)
691
+ words = [r[1] for r in results]
692
+ confidences = [r[2] for r in results]
693
+ text = " ".join(words)
694
+ avg_conf = sum(confidences) / len(confidences) if confidences else 0.0
695
+
696
+ result = {
697
+ "text" : text,
698
+ "word_count" : len(words),
699
+ "avg_confidence": round(avg_conf, 3),
700
+ "is_readable" : len(words) >= 3 and avg_conf > 0.15,
701
+ }
702
+ set_ocr_cache(cache_key, result)
703
+ return result
704
+
705
+
706
+ # ══════════════════════════════════════════════════════════════════════
707
+ # WEB SEARCH (guarded)
708
+ # ══════════════════════════════════════════════════════════════════════
709
+ def get_live_search(query: str) -> str:
710
+ if not _DDGS_OK:
711
+ return "Web search unavailable."
712
+ try:
713
+ with _DDGS() as ddgs:
714
+ results = [f"{r['title']}: {r['body']}"
715
+ for r in ddgs.text(query, max_results=3)]
716
+ return "\n".join(results) if results else "No web data available."
717
+ except Exception as exc:
718
+ logger.warning("Web search failed: %s", exc)
719
+ return "No web data available."
720
+
721
+
722
+ LANGUAGE_MAP = {
723
+ "en": "English", "zh": "Simplified Chinese (简体中文)",
724
+ "es": "Spanish", "ar": "Arabic",
725
+ "fr": "French", "hi": "Hindi (हिन्दी)",
726
+ "pt": "Portuguese", "de": "German",
727
+ }
728
+
729
+
730
+ # ══════════════════════════════════════════════════════════════════════
731
+ # ROUTES
732
+ # ══════════════════════════════════════════════════════════════════════
733
+ @app.get("/")
734
+ async def home():
735
+ return FileResponse("index.html")
736
+
737
+
738
+ @app.get("/health")
739
+ async def health():
740
+ return {"status": "ok", "version": APP_VERSION}
741
+
742
+
743
+ @app.post("/check-image")
744
+ @limiter.limit("30/minute")
745
+ async def check_image(request: Request, image: UploadFile = File(...)):
746
+ content = await image.read()
747
+ return assess_image_quality(content)
748
+
749
+
750
+ @app.post("/enhance-preview")
751
+ @limiter.limit("20/minute")
752
+ async def enhance_preview(request: Request, image: UploadFile = File(...)):
753
+ content = await image.read()
754
+ quality = assess_image_quality(content)
755
+ if not quality["is_blurry"]:
756
+ return JSONResponse({"deblurred": False, "message": "Image already clear.", "quality": quality})
757
+ enhanced_bytes, method_log = deblur_and_enhance(content, quality["blur_severity"])
758
+ return JSONResponse({
759
+ "deblurred" : True,
760
+ "image_b64" : image_to_b64(enhanced_bytes),
761
+ "method_log" : method_log,
762
+ "blur_severity": quality["blur_severity"],
763
+ "quality_before": quality,
764
+ })
765
+
766
+
767
+ @app.post("/ocr")
768
+ @limiter.limit("20/minute")
769
+ async def perform_ocr(request: Request, image: UploadFile = File(...),
770
+ language: str = Form("en")):
771
+ content = await image.read()
772
+ return get_server_ocr(content, language)
773
+
774
+
775
+ # ── Main analysis route ────────────────────────────────────────────────
776
+ @app.post("/analyze")
777
+ @limiter.limit("15/minute")
778
+ async def analyze_product(
779
+ request : Request,
780
+ persona : str = Form(...),
781
+ age_group : str = Form("adult"),
782
+ product_category : str = Form("general"),
783
+ language : str = Form("en"),
784
+ extracted_text : str = Form(None),
785
+ image : UploadFile = File(...),
786
+ ):
787
+ if not _groq_client:
788
+ return JSONResponse({"error": "Server error: GROQ_API_KEY not set"})
789
+
790
+ device_key = get_device_key(request)
791
+ scan_check = check_and_increment_scan(device_key)
792
+ if not scan_check["allowed"]:
793
+ return JSONResponse(status_code=402, content={
794
+ "error" : "scan_limit_reached",
795
+ "message" : f"You've used all {FREE_SCAN_LIMIT} free scans this month.",
796
+ "upgrade_url": "/pro",
797
+ })
798
+
799
+ try:
800
+ content = await image.read()
801
+ quality = assess_image_quality(content)
802
+ blur_info = {
803
+ "detected" : quality["is_blurry"],
804
+ "severity" : quality["blur_severity"],
805
+ "score" : quality["blur_score"],
806
+ "deblurred": False, "method_log": None,
807
+ "image_b64": None, "ocr_source": "original",
808
+ }
809
+ working = content
810
+
811
+ # Deblur
812
+ if quality["is_blurry"]:
813
+ try:
814
+ enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
815
+ if (_ocr_quality_score(get_server_ocr(enhanced, language)) >=
816
+ _ocr_quality_score(get_server_ocr(content, language)) * 0.85):
817
+ working = enhanced
818
+ blur_info["deblurred"] = True
819
+ blur_info["method_log"] = method_log
820
+ blur_info["image_b64"] = image_to_b64(enhanced)
821
+ blur_info["ocr_source"] = "deblurred"
822
+ extracted_text = None
823
+ except Exception as exc:
824
+ logger.warning("Deblur failed: %s", exc)
825
+
826
+ # OCR
827
+ if not extracted_text:
828
+ ocr_result = get_server_ocr(working, language)
829
+ extracted_text = ocr_result["text"]
830
+ ocr_word_count = ocr_result["word_count"]
831
+ else:
832
+ ocr_word_count = len(extracted_text.split())
833
+
834
+ if not extracted_text or ocr_word_count == 0:
835
+ return JSONResponse({"error": "no_text",
836
+ "message": "No text found. Make sure the label side is facing the camera.",
837
+ "tip": "flip_product"})
838
+
839
+ label_check = detect_label_presence(extracted_text)
840
+ if not label_check["has_label"]:
841
+ tip = label_check["suggestion"] or "flip_product"
842
+ msg = ("This looks like the front of the product. Flip it over and scan the back label."
843
+ if tip == "wrong_side"
844
+ else "Could not find nutrition or ingredient information.")
845
+ return JSONResponse({"error": "no_label", "message": msg, "tip": tip,
846
+ "front_words_found": label_check.get("front_hits", [])})
847
+
848
+ label_confidence = label_check.get("confidence", "medium")
849
+
850
+ # Cache lookup — v3 prefix busts all old score=6 results
851
+ cache_key = f"v3:{language}:{persona}:{age_group}:{extracted_text[:80]}"
852
+ cached_result = get_ai_cache(cache_key)
853
+ if cached_result:
854
+ cached_result["blur_info"] = blur_info
855
+ cached_result["scan_meta"] = scan_check
856
+ return JSONResponse(cached_result)
857
+
858
+ # Web search (non-blocking)
859
+ web_context = await asyncio.to_thread(
860
+ get_live_search, f"health analysis ingredients {extracted_text[:120]}")
861
+
862
+ # Allergen profile check
863
+ allergen_warning = ""
864
+ try:
865
+ with db_conn() as conn:
866
+ row = conn.execute(
867
+ "SELECT allergens, conditions FROM allergen_profiles WHERE device_key=?",
868
+ (device_key,)
869
+ ).fetchone()
870
+ if row:
871
+ user_allergens = json.loads(row["allergens"] or "[]")
872
+ user_conditions = json.loads(row["conditions"] or "[]")
873
+ tl = extracted_text.lower()
874
+ triggered = [a for a in user_allergens if a.lower() in tl] + \
875
+ [c for c in user_conditions if c.lower() in tl]
876
+ if triggered:
877
+ allergen_warning = (
878
+ f"⚠️ ALLERGEN ALERT — This product may contain: "
879
+ f"{', '.join(triggered)}. Based on your profile."
880
+ )
881
+ except Exception as exc:
882
+ logger.warning("Allergen check: %s", exc)
883
+
884
+ # Build prompt
885
+ lang_name = LANGUAGE_MAP.get(language, "English")
886
+ confidence_note = (
887
+ "⚠️ Label text may be partial — only list nutrients you can read confidently."
888
+ if label_confidence == "low" else ""
889
+ )
890
+ blur_context = ""
891
+ if blur_info["detected"]:
892
+ verb = "enhanced" if blur_info["deblurred"] else "blurry (original used)"
893
+ blur_context = f"IMAGE: {blur_info['severity']}ly blurry ({verb}). Only report confident values."
894
+
895
+ prompt = f"""[INST]
896
+ You are an expert nutritional scientist and food safety auditor.
897
+ CRITICAL: Respond ENTIRELY in {lang_name}. Every text field MUST be in {lang_name}.
898
+ Persona: {persona} | Age: {age_group} | Category: {product_category}
899
+ {confidence_note}
900
+ {blur_context}
901
+ Label Text: "{extracted_text}"
902
+ Web Context: "{web_context}"
903
+
904
+ Return ONLY valid JSON — no markdown, no preamble:
905
+ {{
906
+ "product_name" : "Short name from label",
907
+ "product_category" : "Snack|Dairy|Beverage|Cereal|Supplement|etc.",
908
+ "score" : <INTEGER 1-10 — MUST match SCORING RUBRIC below>,
909
+ "verdict" : "Two-word verdict in {lang_name}",
910
+ "chart_data" : [<Safe%>, <Moderate%>, <Risky%>],
911
+ "summary" : "2-sentence professional summary in {lang_name}.",
912
+ "eli5_explanation" : "Child-friendly explanation with emojis in {lang_name}.",
913
+ "molecular_insight" : "1-2 sentences on biochemical body impact in {lang_name}.",
914
+ "paragraph_benefits": "Full paragraph on genuine benefits in {lang_name}.",
915
+ "paragraph_uniqueness": "Unique characteristics OR 2 better alternatives in {lang_name}.",
916
+ "is_unique" : true,
917
+ "nutrient_breakdown": [
918
+ {{"name":"Protein", "value":<ACTUAL g from label>, "unit":"g", "rating":"good", "impact":"brief note in {lang_name}"}},
919
+ {{"name":"Sugar", "value":<ACTUAL g>, "unit":"g", "rating":"moderate","impact":"brief note in {lang_name}"}},
920
+ {{"name":"Fat", "value":<ACTUAL g>, "unit":"g", "rating":"good", "impact":"brief note in {lang_name}"}},
921
+ {{"name":"Sodium", "value":<ACTUAL mg>, "unit":"mg", "rating":"caution", "impact":"brief note in {lang_name}"}},
922
+ {{"name":"Fiber", "value":<ACTUAL g>, "unit":"g", "rating":"good", "impact":"brief note in {lang_name}"}}
923
+ ],
924
+ "pros" : ["Benefit 1 in {lang_name}", "Benefit 2", "Benefit 3"],
925
+ "cons" : ["Risk 1 in {lang_name}", "Risk 2"],
926
+ "age_warnings" : [
927
+ {{"group":"Children","emoji":"👶","status":"warning","message":"in {lang_name}"}},
928
+ {{"group":"Adults", "emoji":"🧑","status":"good", "message":"in {lang_name}"}},
929
+ {{"group":"Seniors", "emoji":"👴","status":"caution","message":"in {lang_name}"}},
930
+ {{"group":"Pregnant","emoji":"🤰","status":"caution","message":"in {lang_name}"}}
931
+ ],
932
+ "better_alternative": "A specific healthier alternative in {lang_name}.",
933
+ "is_low_confidence" : false
934
+ }}
935
+
936
+ SCORING RUBRIC — score MUST match actual label data, NEVER default to 6 or 7:
937
+ 9-10: Whole food, no added sugar, low sodium, high fibre/protein
938
+ 7-8 : Mildly processed, sugar <5g/100g, reasonable sodium
939
+ 5-6 : Processed, sugar 5-15g/100g OR sodium 400-700mg/100g
940
+ 3-4 : High sugar >15g/100g OR sodium >700mg/100g OR poor profile
941
+ 1-2 : Ultra-processed, very high sugar/sodium/sat-fat
942
+
943
+ RULES:
944
+ - score MUST match actual nutrient values — NEVER use 6 or 7 as default
945
+ - chart_data must sum to exactly 100
946
+ - nutrient rating: "good"|"moderate"|"caution"|"bad"
947
+ - age_warnings status: "good"|"caution"|"warning"
948
+ - Extract ACTUAL values from label — never use placeholder numbers
949
+ - ALL text values MUST be in {lang_name}
950
+ [/INST]"""
951
+
952
+ # LLM call (non-blocking)
953
+ raw_json = await asyncio.to_thread(call_llm, prompt, 2500)
954
+ result = json.loads(raw_json)
955
+
956
+ # ── Validate & sanitise ────────────────────────────────────────
957
+ # chart_data: always sums to exactly 100
958
+ cd = result.get("chart_data")
959
+ if isinstance(cd, list) and len(cd) == 3 and all(isinstance(x, (int, float)) for x in cd):
960
+ total = sum(cd)
961
+ if total > 0 and total != 100:
962
+ scaled = [round(v * 100 / total) for v in cd]
963
+ scaled[scaled.index(max(scaled))] += 100 - sum(scaled)
964
+ result["chart_data"] = scaled
965
+ else:
966
+ result["chart_data"] = [70, 20, 10]
967
+
968
+ # Strip unit chars from nutrient values ("34g" → 34.0)
969
+ for n in result.get("nutrient_breakdown", []):
970
+ m = re.search(r"[\d]+\.?[\d]*", str(n.get("value", "")).replace(",", "."))
971
+ if m:
972
+ n["value"] = float(m.group())
973
+
974
+ # Safe defaults
975
+ result.setdefault("score", 5)
976
+ result.setdefault("verdict", "Analyzed")
977
+ result.setdefault("product_name", "Unknown Product")
978
+ result.setdefault("nutrient_breakdown", [])
979
+ result.setdefault("pros", [])
980
+ result.setdefault("cons", [])
981
+ result.setdefault("age_warnings", [])
982
+ result.setdefault("is_low_confidence", False)
983
+
984
+ # Attach disclaimer + allergen alert
985
+ result["disclaimer"] = MEDICAL_DISCLAIMER
986
+ result["allergen_warning"] = allergen_warning
987
+ result["blur_info"] = blur_info
988
+ result["scan_meta"] = scan_check
989
+
990
+ # ── Auto-log to daily tracker (Playbook: "the business") ──────
991
+ today = datetime.date.today().isoformat()
992
+ pname = result.get("product_name", "Scanned item")
993
+ nutr = {n["name"].lower(): float(n.get("value", 0))
994
+ for n in result.get("nutrient_breakdown", [])
995
+ if isinstance(n.get("value"), (int, float))}
996
+ cal = nutr.get("energy", nutr.get("calories", nutr.get("calorie", 0)))
997
+ prot = nutr.get("protein", 0)
998
+ carb = nutr.get("carbohydrate", nutr.get("carbs", nutr.get("total carbohydrate", 0)))
999
+ fat = nutr.get("fat", nutr.get("total fat", 0))
1000
+ sod = nutr.get("sodium", 0)
1001
+ fib = nutr.get("fiber", nutr.get("fibre", nutr.get("dietary fiber", 0)))
1002
+ sug = nutr.get("sugar", nutr.get("sugars", nutr.get("total sugars", 0)))
1003
+
1004
+ try:
1005
+ with db_conn() as conn:
1006
+ conn.execute(
1007
+ """INSERT INTO daily_logs
1008
+ (device_key,log_date,meal_name,calories,protein,carbs,
1009
+ fat,sodium,fiber,sugar,source)
1010
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
1011
+ (device_key, today, pname, cal, prot, carb, fat, sod, fib, sug, "scan")
1012
+ )
1013
+ conn.execute(
1014
+ """INSERT INTO scans
1015
+ (device_key,product_name,score,verdict,calories,protein,
1016
+ carbs,fat,sodium,fiber,sugar,persona,language,analysis_json)
1017
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1018
+ (device_key, pname, result.get("score", 0), result.get("verdict", ""),
1019
+ cal, prot, carb, fat, sod, fib, sug, persona, language,
1020
+ json.dumps({k: v for k, v in result.items()
1021
+ if k not in ("blur_info", "scan_meta",
1022
+ "disclaimer", "allergen_warning")}))
1023
+ )
1024
+ except Exception as exc:
1025
+ logger.warning("Auto-log failed: %s", exc)
1026
+
1027
+ update_streak(device_key)
1028
+
1029
+ # Cache (without ephemeral fields)
1030
+ cacheable = {k: v for k, v in result.items()
1031
+ if k not in ("blur_info", "scan_meta", "allergen_warning")}
1032
+ set_ai_cache(cache_key, cacheable)
1033
+
1034
+ return JSONResponse(result)
1035
+
1036
+ except Exception as exc:
1037
+ logger.error("Analysis error: %s", exc, exc_info=True)
1038
+ return JSONResponse({"error": f"Scan failed: {str(exc)[:140]}. Please try again."})
1039
+
1040
+
1041
+ # ── Scan status ────────────────────────────────────────────────────────
1042
+ @app.get("/scan-status")
1043
+ async def scan_status(request: Request):
1044
+ device_key = get_device_key(request)
1045
+ _ensure_device(device_key)
1046
+ month_key = datetime.date.today().isoformat()[:7]
1047
+ try:
1048
+ with db_conn() as conn:
1049
+ row = conn.execute(
1050
+ "SELECT is_pro, month, scan_count, streak_days, last_scan_date "
1051
+ "FROM devices WHERE device_key=?", (device_key,)
1052
+ ).fetchone()
1053
+ if not row or row["month"] != month_key:
1054
+ return {"scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT,
1055
+ "is_pro": False, "limit": FREE_SCAN_LIMIT, "streak": 0}
1056
+ used = row["scan_count"]
1057
+ return {"scans_used": used,
1058
+ "scans_remaining": 9999 if row["is_pro"] else max(0, FREE_SCAN_LIMIT - used),
1059
+ "is_pro": bool(row["is_pro"]),
1060
+ "limit": FREE_SCAN_LIMIT,
1061
+ "streak": row["streak_days"],
1062
+ "last_scan_date": row["last_scan_date"]}
1063
+ except Exception as exc:
1064
+ logger.error("scan_status: %s", exc)
1065
+ return {"scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT, "is_pro": False, "limit": FREE_SCAN_LIMIT, "streak": 0}
1066
+
1067
+
1068
+ # ── Pro activation ─────────────────────────────────────────────────────
1069
+ @app.post("/activate-pro")
1070
+ async def activate_pro(request: Request, payment_id: str = Form(...)):
1071
+ """Marks device as Pro. Replace demo_ check with real Razorpay verification."""
1072
+ device_key = get_device_key(request)
1073
+ _ensure_device(device_key)
1074
+ month_key = datetime.date.today().isoformat()[:7]
1075
+ with db_conn() as conn:
1076
+ conn.execute(
1077
+ "UPDATE devices SET is_pro=1, month=? WHERE device_key=?",
1078
+ (month_key, device_key)
1079
+ )
1080
+ logger.info("Pro activated device=%s payment_id=%s", device_key, payment_id)
1081
+ return {"status": "activated", "message": "Pro activated! Unlimited scans unlocked."}
1082
+
1083
+
1084
+ # ── Onboarding ─────────────────────────────────────────────────────────
1085
+ @app.post("/onboarding-complete")
1086
+ async def onboarding_complete(
1087
+ request : Request,
1088
+ persona : str = Form("General Adult"),
1089
+ language : str = Form("en"),
1090
+ tdee : float = Form(0),
1091
+ allergens: str = Form("[]"),
1092
+ ):
1093
+ device_key = get_device_key(request)
1094
+ _ensure_device(device_key)
1095
+ with db_conn() as conn:
1096
+ conn.execute(
1097
+ "UPDATE devices SET onboarding_done=1, persona=?, language=?, tdee=? WHERE device_key=?",
1098
+ (persona, language, tdee, device_key)
1099
+ )
1100
+ conn.execute(
1101
+ "INSERT OR REPLACE INTO allergen_profiles(device_key,allergens) VALUES(?,?)",
1102
+ (device_key, allergens)
1103
+ )
1104
+ return {"status": "ok"}
1105
+
1106
+
1107
+ # ── Daily summary ──────────────────────────────────────────────────────
1108
+ @app.get("/daily-summary")
1109
+ async def daily_summary(request: Request, date: str = None):
1110
+ """Today's macro totals vs TDEE + smart food suggestion."""
1111
+ device_key = get_device_key(request)
1112
+ _ensure_device(device_key)
1113
+ target_date = date or datetime.date.today().isoformat()
1114
+ try:
1115
+ with db_conn() as conn:
1116
+ dev = conn.execute(
1117
+ "SELECT tdee FROM devices WHERE device_key=?", (device_key,)
1118
+ ).fetchone()
1119
+ row = conn.execute(
1120
+ """SELECT SUM(calories) cal, SUM(protein) prot, SUM(carbs) carb,
1121
+ SUM(fat) fat, SUM(sodium) sod, SUM(fiber) fib, SUM(sugar) sug,
1122
+ COUNT(*) items
1123
+ FROM daily_logs WHERE device_key=? AND log_date=?""",
1124
+ (device_key, target_date)
1125
+ ).fetchone()
1126
+ log_items = conn.execute(
1127
+ """SELECT id, meal_name, calories, protein, carbs, fat, sodium, source, logged_at
1128
+ FROM daily_logs WHERE device_key=? AND log_date=?
1129
+ ORDER BY logged_at DESC""",
1130
+ (device_key, target_date)
1131
+ ).fetchall()
1132
+
1133
+ tdee = float(dev["tdee"] or 2000) if dev and dev["tdee"] else 2000
1134
+ totals = {
1135
+ "calories": round(row["cal"] or 0, 1),
1136
+ "protein" : round(row["prot"] or 0, 1),
1137
+ "carbs" : round(row["carb"] or 0, 1),
1138
+ "fat" : round(row["fat"] or 0, 1),
1139
+ "sodium" : round(row["sod"] or 0, 1),
1140
+ "fiber" : round(row["fib"] or 0, 1),
1141
+ "sugar" : round(row["sug"] or 0, 1),
1142
+ }
1143
+ targets = {
1144
+ "calories": round(tdee), "protein": 56,
1145
+ "carbs" : round(tdee * 0.50 / 4),
1146
+ "fat" : round(tdee * 0.30 / 9),
1147
+ "sodium" : 2300, "fiber": 28, "sugar": 50,
1148
+ }
1149
+ remaining = {k: max(0, round(targets[k] - totals[k], 1)) for k in totals}
1150
+ pct = {k: min(100, round(totals[k] / targets[k] * 100)) if targets[k] else 0
1151
+ for k in totals}
1152
+ cal_left = remaining["calories"]
1153
+ prot_left = remaining["protein"]
1154
+ suggestion = ""
1155
+ if cal_left < 200:
1156
+ suggestion = "🎯 You've almost hit your calorie target for today — great tracking!"
1157
+ elif prot_left > 20:
1158
+ suggestion = f"💪 You need {prot_left}g more protein. Try: eggs, dal, paneer, or Greek yogurt."
1159
+ elif cal_left > 600:
1160
+ suggestion = f"🍽 You have {cal_left} kcal remaining. A balanced meal with lentils, rice & vegetables fits well."
1161
+
1162
+ return {
1163
+ "date" : target_date,
1164
+ "totals" : totals,
1165
+ "targets" : targets,
1166
+ "remaining" : remaining,
1167
+ "pct" : pct,
1168
+ "suggestion": suggestion,
1169
+ "items" : row["items"] or 0,
1170
+ "log" : [dict(r) for r in log_items],
1171
+ }
1172
+ except Exception as exc:
1173
+ logger.error("daily_summary: %s", exc)
1174
+ return {"date": target_date, "totals": {}, "targets": {}, "remaining": {}, "pct": {}, "suggestion": "", "items": 0, "log": []}
1175
+
1176
+
1177
+ # ── Daily log ──────────────────────────────────────────────────────────
1178
+ @app.post("/daily-log")
1179
+ @limiter.limit("30/minute")
1180
+ async def daily_log(
1181
+ request : Request,
1182
+ meal_name: str = Form(...),
1183
+ calories : float = Form(0),
1184
+ protein : float = Form(0),
1185
+ carbs : float = Form(0),
1186
+ fat : float = Form(0),
1187
+ sodium : float = Form(0),
1188
+ fiber : float = Form(0),
1189
+ sugar : float = Form(0),
1190
+ source : str = Form("manual"),
1191
+ log_date : str = Form(None),
1192
+ ):
1193
+ device_key = get_device_key(request)
1194
+ _ensure_device(device_key)
1195
+ target_date = log_date or datetime.date.today().isoformat()
1196
+ with db_conn() as conn:
1197
+ conn.execute(
1198
+ """INSERT INTO daily_logs
1199
+ (device_key,log_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source)
1200
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
1201
+ (device_key, target_date, meal_name,
1202
+ calories, protein, carbs, fat, sodium, fiber, sugar, source)
1203
+ )
1204
+ return {"status": "logged", "date": target_date, "meal": meal_name}
1205
+
1206
+
1207
+ @app.delete("/daily-log/{log_id}")
1208
+ async def delete_daily_log(request: Request, log_id: int):
1209
+ device_key = get_device_key(request)
1210
+ with db_conn() as conn:
1211
+ conn.execute(
1212
+ "DELETE FROM daily_logs WHERE id=? AND device_key=?", (log_id, device_key)
1213
+ )
1214
+ return {"status": "deleted", "id": log_id}
1215
+
1216
+
1217
+ # ── Food search (OpenFoodFacts) ────────────────────────────────────────
1218
+ @app.get("/food-search")
1219
+ @limiter.limit("20/minute")
1220
+ async def food_search(request: Request, q: str = ""):
1221
+ if not q or len(q) < 2:
1222
+ return {"products": []}
1223
+ try:
1224
+ import httpx
1225
+ async with httpx.AsyncClient(timeout=8) as hc:
1226
+ resp = await hc.get(
1227
+ "https://world.openfoodfacts.org/cgi/search.pl",
1228
+ params={"search_terms": q, "action": "process", "json": 1,
1229
+ "page_size": 10,
1230
+ "fields": "product_name,nutriments,brands,serving_size"}
1231
+ )
1232
+ data = resp.json()
1233
+ products = []
1234
+ for p in data.get("products", []):
1235
+ n = p.get("nutriments", {})
1236
+ products.append({
1237
+ "name" : p.get("product_name", "Unknown"),
1238
+ "brand" : p.get("brands", ""),
1239
+ "serving" : p.get("serving_size", "100g"),
1240
+ "calories": round(n.get("energy-kcal_100g", 0), 1),
1241
+ "protein" : round(n.get("proteins_100g", 0), 1),
1242
+ "carbs" : round(n.get("carbohydrates_100g", 0), 1),
1243
+ "fat" : round(n.get("fat_100g", 0), 1),
1244
+ "sodium" : round(n.get("sodium_100g", 0) * 1000, 1),
1245
+ "fiber" : round(n.get("fiber_100g", 0), 1),
1246
+ "sugar" : round(n.get("sugars_100g", 0), 1),
1247
+ })
1248
+ return {"products": products}
1249
+ except Exception as exc:
1250
+ logger.warning("food_search: %s", exc)
1251
+ return {"products": [], "error": "Search unavailable"}
1252
+
1253
+
1254
+ # ── Allergen profile ───────────────────────────────────────────────────
1255
+ @app.get("/allergen-profile")
1256
+ async def get_allergen_profile(request: Request):
1257
+ device_key = get_device_key(request)
1258
+ try:
1259
+ with db_conn() as conn:
1260
+ row = conn.execute(
1261
+ "SELECT allergens, conditions FROM allergen_profiles WHERE device_key=?",
1262
+ (device_key,)
1263
+ ).fetchone()
1264
+ if not row:
1265
+ return {"allergens": [], "conditions": []}
1266
+ return {"allergens": json.loads(row["allergens"] or "[]"),
1267
+ "conditions": json.loads(row["conditions"] or "[]")}
1268
+ except Exception as exc:
1269
+ logger.error("get_allergen_profile: %s", exc)
1270
+ return {"allergens": [], "conditions": []}
1271
+
1272
+
1273
+ @app.post("/allergen-profile")
1274
+ async def set_allergen_profile(
1275
+ request : Request,
1276
+ allergens : str = Form("[]"),
1277
+ conditions: str = Form("[]"),
1278
+ ):
1279
+ device_key = get_device_key(request)
1280
+ _ensure_device(device_key)
1281
+ with db_conn() as conn:
1282
+ conn.execute(
1283
+ """INSERT OR REPLACE INTO allergen_profiles(device_key,allergens,conditions,updated_at)
1284
+ VALUES(?,?,?,datetime('now'))""",
1285
+ (device_key, allergens, conditions)
1286
+ )
1287
+ return {"status": "saved"}
1288
+
1289
+
1290
+ # ── NPS ────────────────────────────────────────────────────────────────
1291
+ @app.post("/nps")
1292
+ async def submit_nps(
1293
+ request: Request,
1294
+ score : int = Form(...),
1295
+ comment: str = Form(""),
1296
+ ):
1297
+ if not 0 <= score <= 10:
1298
+ return JSONResponse({"error": "Score must be 0-10"}, status_code=400)
1299
+ device_key = get_device_key(request)
1300
+ with db_conn() as conn:
1301
+ conn.execute(
1302
+ "INSERT INTO nps_responses(device_key,score,comment) VALUES(?,?,?)",
1303
+ (device_key, score, comment[:500])
1304
+ )
1305
+ return {"status": "thank_you"}
1306
+
1307
+
1308
+ # ── Admin analytics ────────────────────────────────────────────────────
1309
+ @app.get("/admin/analytics")
1310
+ async def admin_analytics(admin_token: str = ""):
1311
+ # SECURITY: guard with ADMIN_TOKEN env var
1312
+ expected = os.environ.get("ADMIN_TOKEN", "changeme")
1313
+ if admin_token != expected:
1314
+ raise HTTPException(status_code=403, detail="Invalid token")
1315
+ today = datetime.date.today().isoformat()
1316
+ month_key = today[:7]
1317
+ try:
1318
+ with db_conn() as conn:
1319
+ dau = conn.execute(
1320
+ "SELECT COUNT(DISTINCT device_key) FROM scans WHERE DATE(scanned_at)=?", (today,)
1321
+ ).fetchone()[0]
1322
+ mau = conn.execute(
1323
+ "SELECT COUNT(DISTINCT device_key) FROM scans WHERE strftime('%Y-%m',scanned_at)=?",
1324
+ (month_key,)
1325
+ ).fetchone()[0]
1326
+ total = conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0]
1327
+ avg_s = conn.execute("SELECT AVG(score) FROM scans").fetchone()[0]
1328
+ avg_n = conn.execute("SELECT AVG(score) FROM nps_responses").fetchone()[0]
1329
+ nps_c = conn.execute("SELECT COUNT(*) FROM nps_responses").fetchone()[0]
1330
+ top_p = conn.execute(
1331
+ "SELECT product_name, COUNT(*) c FROM scans GROUP BY product_name ORDER BY c DESC LIMIT 10"
1332
+ ).fetchall()
1333
+ return {
1334
+ "dau": dau, "mau": mau, "total_scans": total,
1335
+ "avg_score": round(avg_s or 0, 2), "avg_nps": round(avg_n or 0, 2),
1336
+ "nps_count": nps_c,
1337
+ "dau_mau": round(dau / mau * 100, 1) if mau else 0,
1338
+ "top_products": [{"name": r[0], "scans": r[1]} for r in top_p],
1339
+ }
1340
+ except Exception as exc:
1341
+ logger.error("admin_analytics: %s", exc)
1342
+ return {"error": str(exc)}
1343
+
1344
+
1345
+ # ── Share card (fixed anchor="mm" crash) ──────────────────────────────
1346
+ @app.post("/generate-share-card")
1347
+ @limiter.limit("20/minute")
1348
+ async def generate_share_card(
1349
+ request : Request,
1350
+ product_name: str = Form(...),
1351
+ score : int = Form(...),
1352
+ verdict : str = Form(...),
1353
+ top_warning : str = Form(""),
1354
+ top_pro : str = Form(""),
1355
+ ):
1356
+ W, H = 1080, 1080
1357
+ img = Image.new("RGB", (W, H), (15, 17, 23))
1358
+ draw = ImageDraw.Draw(img)
1359
+ font = ImageFont.load_default()
1360
+ s_rgb = (34, 197, 94) if score >= 7 else (245, 158, 11) if score >= 4 else (239, 68, 68)
1361
+
1362
+ def centered(text: str, y: int, fill):
1363
+ """Center text — avoids anchor='mm' crash on bitmap fonts."""
1364
+ try:
1365
+ bbox = font.getbbox(text)
1366
+ tw = bbox[2] - bbox[0]
1367
+ except AttributeError:
1368
+ tw = len(text) * 6
1369
+ draw.text(((W - tw) // 2, y), text, fill=fill, font=font)
1370
+
1371
+ draw.ellipse([340, 160, 740, 560], outline=s_rgb, width=18)
1372
+ centered(str(score), 340, s_rgb)
1373
+ centered("/10", 430, (100, 116, 139))
1374
+ pname = product_name[:38] + ("…" if len(product_name) > 38 else "")
1375
+ centered(pname, 600, (255, 255, 255))
1376
+ centered(verdict[:50], 650, (148, 163, 184))
1377
+ if top_pro:
1378
+ draw.rectangle([60, 700, 1020, 760], fill=(15, 60, 40))
1379
+ centered(f"✓ {top_pro[:65]}", 718, (74, 222, 128))
1380
+ if top_warning:
1381
+ draw.rectangle([60, 775, 1020, 840], fill=(124, 29, 29))
1382
+ centered(f"⚠ {top_warning[:65]}", 795, (252, 165, 165))
1383
+ centered("eatlytic.com • scan any food label, no barcode needed",
1384
+ 1000, (71, 85, 105))
1385
+ draw.text((40, 1050), MEDICAL_DISCLAIMER[:90], fill=(50, 50, 60), font=font)
1386
+
1387
+ buf = BytesIO()
1388
+ img.save(buf, format="PNG", optimize=True)
1389
+ buf.seek(0)
1390
+ return Response(content=buf.getvalue(), media_type="image/png",
1391
+ headers={"Content-Disposition": "attachment; filename=eatlytic-scan.png"})
1392
+
1393
+
1394
+ # ── PDF export (all reportlab bugs fixed) ─────────────────────────────
1395
+ @app.post("/export-pdf")
1396
+ @limiter.limit("10/minute")
1397
+ async def export_pdf(request: Request, analysis_json: str = Form(...)):
1398
+ try:
1399
+ data = json.loads(analysis_json)
1400
+ except Exception:
1401
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
1402
+ try:
1403
+ from reportlab.lib.pagesizes import A4
1404
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
1405
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
1406
+ from reportlab.lib import colors as rl
1407
+ from reportlab.lib.units import cm
1408
+ except ImportError:
1409
+ return JSONResponse({"error": "reportlab not installed"}, status_code=501)
1410
+
1411
+ buf = BytesIO()
1412
+ doc = SimpleDocTemplate(buf, pagesize=A4,
1413
+ rightMargin=2*cm, leftMargin=2*cm,
1414
+ topMargin=2*cm, bottomMargin=2*cm)
1415
+ stys = getSampleStyleSheet()
1416
+ story = []
1417
+ story.append(Paragraph("Eatlytic Food Label Analysis", stys["Title"]))
1418
+ story.append(Paragraph(f"Product: {data.get('product_name', 'Unknown')}", stys["Heading2"]))
1419
+ story.append(Spacer(1, 0.3*cm))
1420
+
1421
+ score = data.get("score", 0)
1422
+ sc = "22c55e" if score >= 7 else "f59e0b" if score >= 4 else "ef4444"
1423
+ story.append(Paragraph(
1424
+ f"<font color='#{sc}'>Health Score: {score}/10 — {data.get('verdict','')}</font>",
1425
+ stys["Heading1"]))
1426
+ story.append(Paragraph(MEDICAL_DISCLAIMER,
1427
+ ParagraphStyle("disc", parent=stys["Normal"],
1428
+ fontSize=8, textColor=rl.grey)))
1429
+ story.append(Spacer(1, 0.4*cm))
1430
+
1431
+ if data.get("summary"):
1432
+ story.append(Paragraph("Summary", stys["Heading2"]))
1433
+ story.append(Paragraph(data["summary"], stys["Normal"]))
1434
+ story.append(Spacer(1, 0.3*cm))
1435
+
1436
+ nutrients = data.get("nutrient_breakdown", [])
1437
+ if nutrients:
1438
+ story.append(Paragraph("Nutrient Breakdown", stys["Heading2"]))
1439
+ tbl_data = [["Nutrient", "Amount", "Rating"]]
1440
+ for n in nutrients:
1441
+ val = n.get("value", "")
1442
+ tbl_data.append([str(n.get("name", "")),
1443
+ f"{val} {n.get('unit', '')}".strip(),
1444
+ str(n.get("rating", "")).upper()])
1445
+ tbl = Table(tbl_data, colWidths=[6*cm, 4*cm, 4*cm])
1446
+ tbl.setStyle(TableStyle([
1447
+ # FIX: HexColor does NOT accept '#' prefix
1448
+ ("BACKGROUND", (0, 0), (-1, 0), rl.HexColor("1D9E75")),
1449
+ ("TEXTCOLOR", (0, 0), (-1, 0), rl.white),
1450
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
1451
+ # FIX: ROWBACKGROUNDS is invalid; use BACKGROUND range instead
1452
+ ("BACKGROUND", (0, 1), (-1, -1), rl.HexColor("f8faf8")),
1453
+ ("GRID", (0, 0), (-1, -1), 0.4, rl.HexColor("d0d8d4")),
1454
+ # FIX: PADDING is invalid; use TOP/BOTTOM/LEFT/RIGHTPADDING
1455
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
1456
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
1457
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
1458
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
1459
+ ]))
1460
+ story.append(tbl)
1461
+ story.append(Spacer(1, 0.4*cm))
1462
+
1463
+ if data.get("pros"):
1464
+ story.append(Paragraph("Benefits", stys["Heading2"]))
1465
+ for p in data["pros"]:
1466
+ story.append(Paragraph(f"✓ {p}", stys["Normal"]))
1467
+ if data.get("cons"):
1468
+ story.append(Spacer(1, 0.3*cm))
1469
+ story.append(Paragraph("Concerns", stys["Heading2"]))
1470
+ for c in data["cons"]:
1471
+ story.append(Paragraph(f"✗ {c}", stys["Normal"]))
1472
+ if data.get("age_warnings"):
1473
+ story.append(Spacer(1, 0.4*cm))
1474
+ story.append(Paragraph("Age-Group Guidance", stys["Heading2"]))
1475
+ for w in data["age_warnings"]:
1476
+ story.append(Paragraph(
1477
+ f"{w.get('emoji','')} {w.get('group','')} — {w.get('message','')}",
1478
+ stys["Normal"]))
1479
+ if data.get("better_alternative"):
1480
+ story.append(Spacer(1, 0.3*cm))
1481
+ story.append(Paragraph("Better Alternative", stys["Heading2"]))
1482
+ story.append(Paragraph(data["better_alternative"], stys["Normal"]))
1483
+
1484
+ story.append(Spacer(1, 0.6*cm))
1485
+ story.append(Paragraph("Generated by Eatlytic v3 — eatlytic.com",
1486
+ ParagraphStyle("footer", parent=stys["Normal"],
1487
+ fontSize=8, textColor=rl.grey)))
1488
+ try:
1489
+ doc.build(story)
1490
+ except Exception as exc:
1491
+ logger.error("PDF build failed: %s", exc)
1492
+ return JSONResponse({"error": f"PDF generation failed: {exc}"}, status_code=500)
1493
+
1494
+ buf.seek(0)
1495
+ safe = data.get("product_name", "scan").replace(" ", "-")[:40]
1496
+ return Response(content=buf.getvalue(), media_type="application/pdf",
1497
+ headers={"Content-Disposition": f"attachment; filename=eatlytic-{safe}.pdf"})
1498
+
1499
+
1500
+ # ── B2B API ────────────────────────────────────────────────────────────
1501
+ @app.post("/api/v1/analyze")
1502
+ @limiter.limit("60/minute")
1503
+ async def api_analyze(
1504
+ request : Request,
1505
+ image : UploadFile = File(...),
1506
+ language : str = Form("en"),
1507
+ persona : str = Form("general adult"),
1508
+ age_group : str = Form("adult"),
1509
+ api_key_data: dict = Security(verify_api_key),
1510
+ ):
1511
+ if not api_key_data:
1512
+ raise HTTPException(status_code=401, detail="Invalid API key")
1513
+ if not api_key_data.get("active"):
1514
+ raise HTTPException(status_code=403, detail="API key suspended")
1515
+
1516
+ month_key = datetime.date.today().isoformat()[:7]
1517
+ LIMITS = {"business": 1000, "enterprise": 99999}
1518
+ limit = LIMITS.get(api_key_data["plan"], 1000)
1519
+ try:
1520
+ with db_conn() as conn:
1521
+ if api_key_data.get("month") != month_key:
1522
+ conn.execute(
1523
+ "UPDATE api_keys SET month=?, scans_this_month=0 WHERE api_key=?",
1524
+ (month_key, api_key_data["api_key"])
1525
+ )
1526
+ api_key_data["scans_this_month"] = 0
1527
+ if api_key_data["scans_this_month"] >= limit:
1528
+ raise HTTPException(status_code=429, detail=f"Monthly limit ({limit}) reached")
1529
+ conn.execute(
1530
+ "UPDATE api_keys SET scans_this_month=scans_this_month+1 WHERE api_key=?",
1531
+ (api_key_data["api_key"],)
1532
+ )
1533
+ except HTTPException:
1534
+ raise
1535
+ except Exception as exc:
1536
+ logger.error("B2B quota: %s", exc)
1537
+
1538
+ content = await image.read()
1539
+ quality = assess_image_quality(content)
1540
+ working = content
1541
+ if quality["is_blurry"]:
1542
+ try:
1543
+ enhanced, _ = deblur_and_enhance(content, quality["blur_severity"])
1544
+ if (_ocr_quality_score(get_server_ocr(enhanced, language)) >=
1545
+ _ocr_quality_score(get_server_ocr(content, language)) * 0.85):
1546
+ working = enhanced
1547
+ except Exception:
1548
+ pass
1549
+
1550
+ ocr = get_server_ocr(working, language)
1551
+ lc = detect_label_presence(ocr["text"])
1552
+ if not lc["has_label"]:
1553
+ return JSONResponse({"error": "no_label"})
1554
+
1555
+ cache_key = f"b2b_v3:{language}:{persona}:{ocr['text'][:80]}"
1556
+ cached = get_ai_cache(cache_key)
1557
+ if cached:
1558
+ return JSONResponse(cached)
1559
+
1560
+ lang_name = LANGUAGE_MAP.get(language, "English")
1561
+ web_ctx = await asyncio.to_thread(
1562
+ get_live_search, f"health ingredients {ocr['text'][:120]}")
1563
+ prompt = (f"[INST] Analyze label: \"{ocr['text']}\". "
1564
+ f"Web: \"{web_ctx}\". Persona: {persona}. "
1565
+ f"Respond in {lang_name} as valid JSON: product_name, score(1-10), verdict, "
1566
+ f"summary, nutrient_breakdown, pros, cons, age_warnings, better_alternative. [/INST]")
1567
+ try:
1568
+ raw = await asyncio.to_thread(call_llm, prompt, 2000)
1569
+ result = json.loads(raw)
1570
+ result["disclaimer"] = MEDICAL_DISCLAIMER
1571
+ set_ai_cache(cache_key, result)
1572
+ return JSONResponse(result)
1573
+ except Exception as exc:
1574
+ raise HTTPException(status_code=500, detail=f"Analysis failed: {str(exc)[:100]}")
1575
+
1576
+
1577
+ # ── Admin: create API key ──────────────────────────────────────────────
1578
+ @app.post("/admin/create-api-key")
1579
+ async def create_api_key_endpoint(
1580
+ admin_token: str = Form(...),
1581
+ client_name: str = Form(...),
1582
+ plan : str = Form("business"),
1583
+ ):
1584
+ if admin_token != os.environ.get("ADMIN_TOKEN", "changeme"):
1585
+ raise HTTPException(status_code=403, detail="Invalid admin token")
1586
+ key = generate_api_key(client_name, plan)
1587
+ return {"api_key": key, "client": client_name, "plan": plan}
1588
+
1589
+
1590
+ # ── WhatsApp ───────────────────────────────────────────────────────────
1591
+ @app.post("/whatsapp-webhook")
1592
+ async def whatsapp_webhook(request: Request):
1593
+ try:
1594
+ from twilio.twiml.messaging_response import MessagingResponse
1595
+ except ImportError:
1596
+ return Response(
1597
+ content="<Response><Message>twilio not installed.</Message></Response>",
1598
+ media_type="application/xml")
1599
+ form = await request.form()
1600
+ media_url = form.get("MediaUrl0")
1601
+ resp = MessagingResponse()
1602
+ msg = resp.message()
1603
+ if media_url:
1604
+ try:
1605
+ import httpx
1606
+ SID = os.environ.get("TWILIO_ACCOUNT_SID", "")
1607
+ TOKEN = os.environ.get("TWILIO_AUTH_TOKEN", "")
1608
+ async with httpx.AsyncClient() as hc:
1609
+ img_bytes = (await hc.get(media_url, auth=(SID, TOKEN))).content
1610
+ quality = assess_image_quality(img_bytes)
1611
+ if quality["is_blurry"]:
1612
+ img_bytes, _ = deblur_and_enhance(img_bytes, quality["blur_severity"])
1613
+ ocr_r = get_server_ocr(img_bytes, "en")
1614
+ lc = detect_label_presence(ocr_r["text"])
1615
+ if not lc["has_label"]:
1616
+ msg.body("❌ Couldn't find a nutrition label. Please send the *back* of the pack.")
1617
+ elif not _groq_client:
1618
+ msg.body("⚠️ AI unavailable. Full analysis at *eatlytic.com*")
1619
+ else:
1620
+ web_ctx = get_live_search(f"health ingredients {ocr_r['text'][:80]}")
1621
+ prompt = (f"5-bullet WhatsApp health summary of: \"{ocr_r['text'][:400]}\". "
1622
+ f"Start with score /10. Web context: {web_ctx}")
1623
+ summary = await asyncio.to_thread(call_llm, prompt, 400)
1624
+ msg.body(f"🔍 *Eatlytic Analysis*\n\n{summary}\n\n"
1625
+ f"_{MEDICAL_DISCLAIMER[:80]}_\n_Full: eatlytic.com_")
1626
+ except Exception as exc:
1627
+ logger.error("WhatsApp: %s", exc)
1628
+ msg.body("⚠️ Something went wrong. Try again or visit *eatlytic.com*")
1629
+ else:
1630
+ msg.body("👋 Welcome to *Eatlytic*!\n\nSend a photo of any food label (back of pack). "
1631
+ "Works on blurry photos 📸 Free — no barcode needed.")
1632
+ return Response(content=str(resp), media_type="application/xml")
1633
+
1634
+
1635
+ # ── OCR test ───────────────────────────────────────────────────────────
1636
+ @app.post("/test-accuracy")
1637
+ @limiter.limit("5/minute")
1638
+ async def test_accuracy(
1639
+ request : Request,
1640
+ image : UploadFile = File(...),
1641
+ ground_truth: str = Form(""),
1642
+ ):
1643
+ content = await image.read()
1644
+ quality = assess_image_quality(content)
1645
+ ocr_orig = get_server_ocr(content, "en")
1646
+ ocr_enh = None
1647
+ if quality["is_blurry"]:
1648
+ try:
1649
+ enhanced, _ = deblur_and_enhance(content, quality["blur_severity"])
1650
+ ocr_enh = get_server_ocr(enhanced, "en")
1651
+ except Exception:
1652
+ pass
1653
+
1654
+ def f1(pred: str, truth: str) -> float:
1655
+ if not truth:
1656
+ return 0.0
1657
+ pw = set(pred.lower().split()); tw = set(truth.lower().split())
1658
+ tp = len(pw & tw)
1659
+ pr = tp / len(pw) if pw else 0; rc = tp / len(tw) if tw else 0
1660
+ return round(2 * pr * rc / (pr + rc), 3) if (pr + rc) else 0.0
1661
+
1662
+ result = {
1663
+ "blur_score" : quality["blur_score"],
1664
+ "blur_severity": quality["blur_severity"],
1665
+ "is_blurry" : quality["is_blurry"],
1666
+ "original_ocr": {
1667
+ "word_count" : ocr_orig["word_count"],
1668
+ "avg_confidence": ocr_orig["avg_confidence"],
1669
+ "f1_vs_truth" : f1(ocr_orig["text"], ground_truth),
1670
+ },
1671
+ }
1672
+ if ocr_enh:
1673
+ orig_f1 = f1(ocr_orig["text"], ground_truth)
1674
+ enh_f1 = f1(ocr_enh["text"], ground_truth)
1675
+ result["enhanced_ocr"] = {
1676
+ "word_count" : ocr_enh["word_count"],
1677
+ "avg_confidence": ocr_enh["avg_confidence"],
1678
+ "f1_vs_truth" : enh_f1,
1679
+ "f1_delta" : round(enh_f1 - orig_f1, 3),
1680
+ }
1681
+ return result