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