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