Spaces:
Sleeping
Sleeping
Delete main.py
Browse files
main.py
DELETED
|
@@ -1,723 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Eatlytic v4 — Modular FastAPI Application
|
| 3 |
-
=========================================
|
| 4 |
-
Architecture:
|
| 5 |
-
app/models/db.py — SQLite schema, connection, caching
|
| 6 |
-
app/services/auth.py — User accounts, OTP, sessions
|
| 7 |
-
app/services/payments.py— Razorpay integration (real payments)
|
| 8 |
-
app/services/image.py — Blur detection + Wiener deblurring pipeline
|
| 9 |
-
app/services/ocr.py — EasyOCR wrapper, label detection
|
| 10 |
-
app/services/llm.py — LLM abstraction, food DB population
|
| 11 |
-
app/routes/auth.py — /auth/* endpoints
|
| 12 |
-
app/routes/payments.py — /payments/* endpoints
|
| 13 |
-
app/routes/food_db.py — /food-db/* endpoints (Phase 2 moat)
|
| 14 |
-
app/routes/benchmarks.py— /benchmarks/* endpoints (accuracy system)
|
| 15 |
-
main.py — app factory, legacy routes, startup
|
| 16 |
-
|
| 17 |
-
Run: uvicorn main:app --host 0.0.0.0 --port 7860
|
| 18 |
-
Test: pytest tests/ -v
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
import os
|
| 22 |
-
import re
|
| 23 |
-
import json
|
| 24 |
-
import asyncio
|
| 25 |
-
import logging
|
| 26 |
-
import hashlib
|
| 27 |
-
import datetime
|
| 28 |
-
import secrets
|
| 29 |
-
from io import BytesIO
|
| 30 |
-
|
| 31 |
-
from fastapi import FastAPI, File, UploadFile, Form, Request, HTTPException, Security
|
| 32 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 33 |
-
from fastapi.responses import FileResponse, JSONResponse, Response
|
| 34 |
-
from fastapi.security import APIKeyHeader
|
| 35 |
-
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 36 |
-
from slowapi.util import get_remote_address
|
| 37 |
-
from slowapi.errors import RateLimitExceeded
|
| 38 |
-
|
| 39 |
-
# ── DuckDuckGo: guarded import ─────────────────────────────────────────
|
| 40 |
-
try:
|
| 41 |
-
from duckduckgo_search import DDGS as _DDGS; _DDGS_OK = True
|
| 42 |
-
except Exception:
|
| 43 |
-
_DDGS = None; _DDGS_OK = False
|
| 44 |
-
|
| 45 |
-
logging.basicConfig(level=logging.INFO)
|
| 46 |
-
logger = logging.getLogger(__name__)
|
| 47 |
-
|
| 48 |
-
# ── App factory ────────────────────────────────────────────────────────
|
| 49 |
-
limiter = Limiter(key_func=get_remote_address)
|
| 50 |
-
app = FastAPI(title="Eatlytic v4 — Food Intelligence", version="4.0")
|
| 51 |
-
app.state.limiter = limiter
|
| 52 |
-
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 53 |
-
|
| 54 |
-
_ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
|
| 55 |
-
app.add_middleware(
|
| 56 |
-
CORSMiddleware,
|
| 57 |
-
allow_origins = [_ALLOWED_ORIGIN],
|
| 58 |
-
allow_methods = ["GET", "POST", "DELETE", "PATCH"],
|
| 59 |
-
allow_headers = ["*"],
|
| 60 |
-
allow_credentials = _ALLOWED_ORIGIN != "*",
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
# ── DB init at startup ─────────────────────────────────────────────────
|
| 64 |
-
from app.models.db import init_db, db_conn
|
| 65 |
-
init_db()
|
| 66 |
-
|
| 67 |
-
# ── Import + mount modular routers ────────────────────────────────────
|
| 68 |
-
from app.routes.auth import router as auth_router
|
| 69 |
-
from app.routes.payments import router as payments_router
|
| 70 |
-
from app.routes.food_db import router as food_db_router
|
| 71 |
-
from app.routes.benchmarks import router as benchmarks_router
|
| 72 |
-
|
| 73 |
-
app.include_router(auth_router)
|
| 74 |
-
app.include_router(payments_router)
|
| 75 |
-
app.include_router(food_db_router)
|
| 76 |
-
app.include_router(benchmarks_router)
|
| 77 |
-
|
| 78 |
-
# ── Import services ────────────────────────────────────────────────────
|
| 79 |
-
from app.services.image import (
|
| 80 |
-
validate_image, assess_image_quality, deblur_and_enhance,
|
| 81 |
-
image_to_b64, ocr_quality_score,
|
| 82 |
-
)
|
| 83 |
-
from app.services.ocr import run_ocr, detect_label_presence
|
| 84 |
-
from app.services.llm import (
|
| 85 |
-
analyse_label, upsert_food_product, MEDICAL_DISCLAIMER, LANGUAGE_MAP
|
| 86 |
-
)
|
| 87 |
-
from app.services.auth import (
|
| 88 |
-
get_user_from_token, check_and_increment_scan_user,
|
| 89 |
-
update_streak_user,
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
FREE_SCAN_LIMIT = int(os.environ.get("FREE_SCAN_LIMIT", "10"))
|
| 93 |
-
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 94 |
-
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
# ── Auth helpers ───────────────────────────────────────────────────────
|
| 98 |
-
def _get_request_user(request: Request):
|
| 99 |
-
"""Extract authenticated user from Bearer token (optional)."""
|
| 100 |
-
auth = request.headers.get("Authorization", "")
|
| 101 |
-
token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
| 102 |
-
return get_user_from_token(token) if token else None
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _device_key(request: Request) -> str:
|
| 106 |
-
ip = request.client.host if request.client else "unknown"
|
| 107 |
-
ua = request.headers.get("user-agent", "")
|
| 108 |
-
return hashlib.md5(f"{ip}:{ua}".encode()).hexdigest()[:16]
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def _ensure_device(device_key: str):
|
| 112 |
-
try:
|
| 113 |
-
with db_conn() as conn:
|
| 114 |
-
conn.execute("INSERT OR IGNORE INTO devices(device_key) VALUES(?)", (device_key,))
|
| 115 |
-
except Exception:
|
| 116 |
-
pass
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def _check_scan_quota(user, device_key: str) -> dict:
|
| 120 |
-
"""Check quota for authenticated user; fall back to device for anonymous."""
|
| 121 |
-
if user:
|
| 122 |
-
return check_and_increment_scan_user(user["id"])
|
| 123 |
-
# Legacy anonymous path
|
| 124 |
-
month_key = datetime.date.today().isoformat()[:7]
|
| 125 |
-
_ensure_device(device_key)
|
| 126 |
-
with db_conn() as conn:
|
| 127 |
-
row = conn.execute(
|
| 128 |
-
"SELECT is_pro, month, scan_count FROM devices WHERE device_key=?", (device_key,)
|
| 129 |
-
).fetchone()
|
| 130 |
-
if not row:
|
| 131 |
-
return {"allowed": False, "scans_used": 0, "scans_remaining": 0, "is_pro": False}
|
| 132 |
-
if row["month"] != month_key:
|
| 133 |
-
conn.execute("UPDATE devices SET month=?, scan_count=0 WHERE device_key=?",
|
| 134 |
-
(month_key, device_key))
|
| 135 |
-
count = 0
|
| 136 |
-
else:
|
| 137 |
-
count = row["scan_count"]
|
| 138 |
-
if row["is_pro"]:
|
| 139 |
-
conn.execute("UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?", (device_key,))
|
| 140 |
-
return {"allowed": True, "scans_used": count+1, "scans_remaining": 9999, "is_pro": True}
|
| 141 |
-
if count >= FREE_SCAN_LIMIT:
|
| 142 |
-
return {"allowed": False, "scans_used": count, "scans_remaining": 0, "is_pro": False}
|
| 143 |
-
conn.execute("UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?", (device_key,))
|
| 144 |
-
new = count + 1
|
| 145 |
-
return {"allowed": True, "scans_used": new,
|
| 146 |
-
"scans_remaining": FREE_SCAN_LIMIT - new, "is_pro": False}
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def _get_live_search(query: str) -> str:
|
| 150 |
-
if not _DDGS_OK:
|
| 151 |
-
return "Web search unavailable."
|
| 152 |
-
try:
|
| 153 |
-
with _DDGS() as ddgs:
|
| 154 |
-
results = [f"{r['title']}: {r['body']}" for r in ddgs.text(query, max_results=3)]
|
| 155 |
-
return "\n".join(results) if results else "No web data."
|
| 156 |
-
except Exception as exc:
|
| 157 |
-
logger.warning("Web search: %s", exc)
|
| 158 |
-
return "No web data."
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
def _verify_api_key(api_key: str = Security(api_key_header)):
|
| 162 |
-
if not api_key:
|
| 163 |
-
return None
|
| 164 |
-
with db_conn() as conn:
|
| 165 |
-
row = conn.execute(
|
| 166 |
-
"SELECT * FROM api_keys WHERE api_key=? AND active=1", (api_key,)
|
| 167 |
-
).fetchone()
|
| 168 |
-
return dict(row) if row else None
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
# ══════════════════════════════════════════════════════════════════════
|
| 172 |
-
# CORE ROUTES
|
| 173 |
-
# ══════════════════════════════════════════════════════════════════════
|
| 174 |
-
|
| 175 |
-
@app.get("/")
|
| 176 |
-
async def home():
|
| 177 |
-
return FileResponse("index.html")
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
@app.get("/health")
|
| 181 |
-
async def health():
|
| 182 |
-
return {"status": "ok", "version": "4.0", "db": "sqlite-wal"}
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
@app.post("/check-image")
|
| 186 |
-
@limiter.limit("30/minute")
|
| 187 |
-
async def check_image(request: Request, image: UploadFile = File(...)):
|
| 188 |
-
content = await image.read()
|
| 189 |
-
content = validate_image(content)
|
| 190 |
-
return assess_image_quality(content)
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
@app.post("/enhance-preview")
|
| 194 |
-
@limiter.limit("20/minute")
|
| 195 |
-
async def enhance_preview(request: Request, image: UploadFile = File(...)):
|
| 196 |
-
content = await image.read()
|
| 197 |
-
content = validate_image(content)
|
| 198 |
-
quality = assess_image_quality(content)
|
| 199 |
-
if not quality["is_blurry"]:
|
| 200 |
-
return JSONResponse({"deblurred": False, "message": "Image already clear.", "quality": quality})
|
| 201 |
-
enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
|
| 202 |
-
return JSONResponse({"deblurred": True, "image_b64": image_to_b64(enhanced),
|
| 203 |
-
"method_log": method_log, "quality_before": quality})
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
@app.post("/ocr")
|
| 207 |
-
@limiter.limit("20/minute")
|
| 208 |
-
async def perform_ocr(request: Request, image: UploadFile = File(...),
|
| 209 |
-
language: str = Form("en")):
|
| 210 |
-
content = await image.read()
|
| 211 |
-
content = validate_image(content)
|
| 212 |
-
return run_ocr(content, language)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
@app.post("/analyze")
|
| 216 |
-
@limiter.limit("15/minute")
|
| 217 |
-
async def analyze_product(
|
| 218 |
-
request : Request,
|
| 219 |
-
persona : str = Form(...),
|
| 220 |
-
age_group : str = Form("adult"),
|
| 221 |
-
product_category : str = Form("general"),
|
| 222 |
-
language : str = Form("en"),
|
| 223 |
-
extracted_text : str = Form(None),
|
| 224 |
-
image : UploadFile = File(...),
|
| 225 |
-
):
|
| 226 |
-
if not GROQ_API_KEY:
|
| 227 |
-
return JSONResponse({"error": "Server error: GROQ_API_KEY not set"})
|
| 228 |
-
|
| 229 |
-
user = _get_request_user(request)
|
| 230 |
-
device_key = _device_key(request)
|
| 231 |
-
scan_check = _check_scan_quota(user, device_key)
|
| 232 |
-
|
| 233 |
-
if not scan_check["allowed"]:
|
| 234 |
-
return JSONResponse(status_code=402, content={
|
| 235 |
-
"error" : "scan_limit_reached",
|
| 236 |
-
"message" : f"You've used all {FREE_SCAN_LIMIT} free scans this month.",
|
| 237 |
-
"upgrade_url": "/payments/create-order",
|
| 238 |
-
})
|
| 239 |
-
|
| 240 |
-
try:
|
| 241 |
-
content = await image.read()
|
| 242 |
-
content = validate_image(content) # ← 10MB limit enforcement
|
| 243 |
-
quality = assess_image_quality(content)
|
| 244 |
-
blur_info = {"detected": quality["is_blurry"], "severity": quality["blur_severity"],
|
| 245 |
-
"score": quality["blur_score"], "deblurred": False,
|
| 246 |
-
"method_log": None, "image_b64": None, "ocr_source": "original"}
|
| 247 |
-
working = content
|
| 248 |
-
|
| 249 |
-
if quality["is_blurry"]:
|
| 250 |
-
try:
|
| 251 |
-
enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
|
| 252 |
-
if (ocr_quality_score(run_ocr(enhanced, language)) >=
|
| 253 |
-
ocr_quality_score(run_ocr(content, language)) * 0.85):
|
| 254 |
-
working = enhanced
|
| 255 |
-
blur_info["deblurred"] = True
|
| 256 |
-
blur_info["method_log"] = method_log
|
| 257 |
-
blur_info["image_b64"] = image_to_b64(enhanced)
|
| 258 |
-
blur_info["ocr_source"] = "deblurred"
|
| 259 |
-
extracted_text = None
|
| 260 |
-
except Exception as exc:
|
| 261 |
-
logger.warning("Deblur failed: %s", exc)
|
| 262 |
-
|
| 263 |
-
if not extracted_text:
|
| 264 |
-
ocr_result = run_ocr(working, language)
|
| 265 |
-
extracted_text = ocr_result["text"]
|
| 266 |
-
ocr_word_count = ocr_result["word_count"]
|
| 267 |
-
else:
|
| 268 |
-
ocr_word_count = len(extracted_text.split())
|
| 269 |
-
|
| 270 |
-
if not extracted_text or ocr_word_count == 0:
|
| 271 |
-
return JSONResponse({"error": "no_text",
|
| 272 |
-
"message": "No text found. Make sure the label side is facing the camera.",
|
| 273 |
-
"tip": "flip_product"})
|
| 274 |
-
|
| 275 |
-
label_check = detect_label_presence(extracted_text)
|
| 276 |
-
if not label_check["has_label"]:
|
| 277 |
-
tip = label_check["suggestion"] or "flip_product"
|
| 278 |
-
msg = ("This looks like the front of the product. Flip it over and scan the back label."
|
| 279 |
-
if tip == "wrong_side" else "Could not find nutrition or ingredient information.")
|
| 280 |
-
return JSONResponse({"error": "no_label", "message": msg, "tip": tip,
|
| 281 |
-
"front_words_found": label_check.get("front_hits", [])})
|
| 282 |
-
|
| 283 |
-
# Allergen check
|
| 284 |
-
allergen_warning = ""
|
| 285 |
-
owner_id = user["id"] if user else None
|
| 286 |
-
try:
|
| 287 |
-
with db_conn() as conn:
|
| 288 |
-
row = conn.execute(
|
| 289 |
-
"SELECT allergens,conditions FROM allergen_profiles WHERE device_key=?",
|
| 290 |
-
(device_key,)
|
| 291 |
-
).fetchone()
|
| 292 |
-
if row:
|
| 293 |
-
tl = extracted_text.lower()
|
| 294 |
-
triggered = [a for a in json.loads(row["allergens"] or "[]") if a.lower() in tl] + \
|
| 295 |
-
[c for c in json.loads(row["conditions"] or "[]") if c.lower() in tl]
|
| 296 |
-
if triggered:
|
| 297 |
-
allergen_warning = f"⚠️ ALLERGEN ALERT — may contain: {', '.join(triggered)}"
|
| 298 |
-
except Exception as exc:
|
| 299 |
-
logger.warning("Allergen check: %s", exc)
|
| 300 |
-
|
| 301 |
-
# Web search (non-blocking)
|
| 302 |
-
web_context = await asyncio.to_thread(
|
| 303 |
-
_get_live_search, f"health analysis ingredients {extracted_text[:120]}")
|
| 304 |
-
|
| 305 |
-
# LLM analysis
|
| 306 |
-
result = await analyse_label(
|
| 307 |
-
extracted_text, persona, age_group, product_category,
|
| 308 |
-
language, web_context, blur_info, label_check.get("confidence", "medium")
|
| 309 |
-
)
|
| 310 |
-
result["allergen_warning"] = allergen_warning
|
| 311 |
-
result["blur_info"] = blur_info
|
| 312 |
-
result["scan_meta"] = scan_check
|
| 313 |
-
|
| 314 |
-
# Auto-log to daily tracker
|
| 315 |
-
today = datetime.date.today().isoformat()
|
| 316 |
-
nutr = {n["name"].lower(): float(n.get("value", 0))
|
| 317 |
-
for n in result.get("nutrient_breakdown", [])
|
| 318 |
-
if isinstance(n.get("value"), (int, float))}
|
| 319 |
-
cal = nutr.get("energy", nutr.get("calories", nutr.get("calorie", 0)))
|
| 320 |
-
prot = nutr.get("protein", 0)
|
| 321 |
-
carb = nutr.get("carbohydrate", nutr.get("carbs", 0))
|
| 322 |
-
fat = nutr.get("fat", 0)
|
| 323 |
-
sod = nutr.get("sodium", 0)
|
| 324 |
-
fib = nutr.get("fiber", nutr.get("fibre", 0))
|
| 325 |
-
sug = nutr.get("sugar", nutr.get("sugars", 0))
|
| 326 |
-
|
| 327 |
-
with db_conn() as conn:
|
| 328 |
-
conn.execute(
|
| 329 |
-
"""INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,
|
| 330 |
-
calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 331 |
-
(owner_id, device_key, today, result.get("product_name","Scanned item"),
|
| 332 |
-
cal, prot, carb, fat, sod, fib, sug, "scan")
|
| 333 |
-
)
|
| 334 |
-
conn.execute(
|
| 335 |
-
"""INSERT INTO scans(user_id,device_key,product_name,score,verdict,
|
| 336 |
-
calories,protein,carbs,fat,sodium,fiber,sugar,persona,language,analysis_json)
|
| 337 |
-
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 338 |
-
(owner_id, device_key, result.get("product_name","Unknown"),
|
| 339 |
-
result.get("score", 0), result.get("verdict", ""),
|
| 340 |
-
cal, prot, carb, fat, sod, fib, sug, persona, language,
|
| 341 |
-
json.dumps({k: v for k, v in result.items()
|
| 342 |
-
if k not in ("blur_info","scan_meta","allergen_warning")}))
|
| 343 |
-
)
|
| 344 |
-
|
| 345 |
-
# Phase 2: populate proprietary food database
|
| 346 |
-
try:
|
| 347 |
-
upsert_food_product(
|
| 348 |
-
name=result.get("product_name", ""),
|
| 349 |
-
nutrients=result.get("nutrient_breakdown", []),
|
| 350 |
-
score=result.get("score", 0),
|
| 351 |
-
ingredients_raw=extracted_text,
|
| 352 |
-
category=result.get("product_category", ""),
|
| 353 |
-
source="llm_scan",
|
| 354 |
-
)
|
| 355 |
-
except Exception as exc:
|
| 356 |
-
logger.warning("Food DB upsert: %s", exc)
|
| 357 |
-
|
| 358 |
-
# Update streak
|
| 359 |
-
if user:
|
| 360 |
-
update_streak_user(user["id"])
|
| 361 |
-
else:
|
| 362 |
-
# Legacy device streak
|
| 363 |
-
from app.services.auth import update_streak_user as _upd
|
| 364 |
-
try:
|
| 365 |
-
_ensure_device(device_key)
|
| 366 |
-
today_d = datetime.date.today().isoformat()
|
| 367 |
-
yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
|
| 368 |
-
with db_conn() as conn:
|
| 369 |
-
row = conn.execute(
|
| 370 |
-
"SELECT streak_days, last_scan_date FROM devices WHERE device_key=?",
|
| 371 |
-
(device_key,)
|
| 372 |
-
).fetchone()
|
| 373 |
-
if row and row["last_scan_date"] != today_d:
|
| 374 |
-
streak = (row["streak_days"] + 1) if row["last_scan_date"] == yesterday else 1
|
| 375 |
-
conn.execute(
|
| 376 |
-
"UPDATE devices SET streak_days=?, last_scan_date=? WHERE device_key=?",
|
| 377 |
-
(streak, today_d, device_key)
|
| 378 |
-
)
|
| 379 |
-
except Exception:
|
| 380 |
-
pass
|
| 381 |
-
|
| 382 |
-
return JSONResponse(result)
|
| 383 |
-
|
| 384 |
-
except ValueError as exc:
|
| 385 |
-
return JSONResponse({"error": str(exc)}, status_code=400)
|
| 386 |
-
except Exception as exc:
|
| 387 |
-
logger.error("Analysis error: %s", exc, exc_info=True)
|
| 388 |
-
return JSONResponse({"error": f"Scan failed: {str(exc)[:140]}. Please try again."})
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
@app.get("/scan-status")
|
| 392 |
-
async def scan_status(request: Request):
|
| 393 |
-
user = _get_request_user(request)
|
| 394 |
-
device_key = _device_key(request)
|
| 395 |
-
_ensure_device(device_key)
|
| 396 |
-
month_key = datetime.date.today().isoformat()[:7]
|
| 397 |
-
|
| 398 |
-
if user:
|
| 399 |
-
from app.models.db import db_conn as _db
|
| 400 |
-
with _db() as conn:
|
| 401 |
-
row = conn.execute(
|
| 402 |
-
"SELECT is_pro, scan_month, scan_count_month, streak_days FROM users WHERE id=?",
|
| 403 |
-
(user["id"],)
|
| 404 |
-
).fetchone()
|
| 405 |
-
if not row or row["scan_month"] != month_key:
|
| 406 |
-
return {"scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT,
|
| 407 |
-
"is_pro": False, "limit": FREE_SCAN_LIMIT, "streak": 0}
|
| 408 |
-
used = row["scan_count_month"]
|
| 409 |
-
return {"scans_used": used,
|
| 410 |
-
"scans_remaining": 9999 if row["is_pro"] else max(0, FREE_SCAN_LIMIT - used),
|
| 411 |
-
"is_pro": bool(row["is_pro"]), "limit": FREE_SCAN_LIMIT,
|
| 412 |
-
"streak": row["streak_days"],
|
| 413 |
-
"authenticated": True}
|
| 414 |
-
|
| 415 |
-
with db_conn() as conn:
|
| 416 |
-
row = conn.execute(
|
| 417 |
-
"SELECT is_pro, month, scan_count, streak_days FROM devices WHERE device_key=?",
|
| 418 |
-
(device_key,)
|
| 419 |
-
).fetchone()
|
| 420 |
-
if not row or row["month"] != month_key:
|
| 421 |
-
return {"scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT,
|
| 422 |
-
"is_pro": False, "limit": FREE_SCAN_LIMIT, "streak": 0, "authenticated": False}
|
| 423 |
-
used = row["scan_count"]
|
| 424 |
-
return {"scans_used": used,
|
| 425 |
-
"scans_remaining": 9999 if row["is_pro"] else max(0, FREE_SCAN_LIMIT - used),
|
| 426 |
-
"is_pro": bool(row["is_pro"]), "limit": FREE_SCAN_LIMIT,
|
| 427 |
-
"streak": row["streak_days"], "authenticated": False}
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
@app.post("/activate-pro")
|
| 431 |
-
async def activate_pro_legacy(request: Request, payment_id: str = Form(...)):
|
| 432 |
-
"""
|
| 433 |
-
LEGACY endpoint kept for backward compat.
|
| 434 |
-
Real payment flow: POST /payments/create-order → Razorpay modal → POST /payments/verify
|
| 435 |
-
"""
|
| 436 |
-
if payment_id.startswith("demo_"):
|
| 437 |
-
logger.warning("DEMO payment ID used — no real money charged")
|
| 438 |
-
device_key = _device_key(request)
|
| 439 |
-
_ensure_device(device_key)
|
| 440 |
-
with db_conn() as conn:
|
| 441 |
-
conn.execute("UPDATE devices SET is_pro=1 WHERE device_key=?", (device_key,))
|
| 442 |
-
return {"status": "activated",
|
| 443 |
-
"message": "Pro activated. NOTE: Use /payments/create-order for real billing.",
|
| 444 |
-
"real_billing_endpoint": "/payments/create-order"}
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
@app.post("/onboarding-complete")
|
| 448 |
-
async def onboarding_complete(
|
| 449 |
-
request : Request,
|
| 450 |
-
persona : str = Form("General Adult"),
|
| 451 |
-
language : str = Form("en"),
|
| 452 |
-
tdee : float = Form(0),
|
| 453 |
-
allergens: str = Form("[]"),
|
| 454 |
-
):
|
| 455 |
-
user = _get_request_user(request)
|
| 456 |
-
device_key = _device_key(request)
|
| 457 |
-
_ensure_device(device_key)
|
| 458 |
-
user_id = user["id"] if user else None
|
| 459 |
-
with db_conn() as conn:
|
| 460 |
-
conn.execute("UPDATE devices SET onboarding_done=1,persona=?,language=?,tdee=? WHERE device_key=?",
|
| 461 |
-
(persona, language, tdee, device_key))
|
| 462 |
-
if user_id:
|
| 463 |
-
conn.execute("UPDATE users SET onboarding_done=1,persona=?,language=?,tdee=? WHERE id=?",
|
| 464 |
-
(persona, language, tdee, user_id))
|
| 465 |
-
conn.execute(
|
| 466 |
-
"INSERT OR REPLACE INTO allergen_profiles(device_key,user_id,allergens) VALUES(?,?,?)",
|
| 467 |
-
(device_key, user_id, allergens)
|
| 468 |
-
)
|
| 469 |
-
return {"status": "ok"}
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
@app.get("/daily-summary")
|
| 473 |
-
async def daily_summary(request: Request, date: str = None):
|
| 474 |
-
user = _get_request_user(request)
|
| 475 |
-
device_key = _device_key(request)
|
| 476 |
-
target_date = date or datetime.date.today().isoformat()
|
| 477 |
-
|
| 478 |
-
user_id = user["id"] if user else None
|
| 479 |
-
with db_conn() as conn:
|
| 480 |
-
if user_id:
|
| 481 |
-
dev = conn.execute("SELECT tdee FROM users WHERE id=?", (user_id,)).fetchone()
|
| 482 |
-
else:
|
| 483 |
-
dev = conn.execute("SELECT tdee FROM devices WHERE device_key=?", (device_key,)).fetchone()
|
| 484 |
-
|
| 485 |
-
# Fetch from either user_id or device_key
|
| 486 |
-
clause = "user_id=?" if user_id else "device_key=?"
|
| 487 |
-
param = user_id or device_key
|
| 488 |
-
row = conn.execute(
|
| 489 |
-
f"""SELECT SUM(calories) cal, SUM(protein) prot, SUM(carbs) carb,
|
| 490 |
-
SUM(fat) fat, SUM(sodium) sod, SUM(fiber) fib, SUM(sugar) sug, COUNT(*) items
|
| 491 |
-
FROM daily_logs WHERE {clause} AND log_date=?""",
|
| 492 |
-
(param, target_date)
|
| 493 |
-
).fetchone()
|
| 494 |
-
log_items = conn.execute(
|
| 495 |
-
f"""SELECT id, meal_name, calories, protein, carbs, fat, sodium, source, logged_at
|
| 496 |
-
FROM daily_logs WHERE {clause} AND log_date=? ORDER BY logged_at DESC""",
|
| 497 |
-
(param, target_date)
|
| 498 |
-
).fetchall()
|
| 499 |
-
|
| 500 |
-
tdee = float((dev and dev["tdee"]) or 2000) or 2000
|
| 501 |
-
totals = {k: round(row[k] or 0, 1)
|
| 502 |
-
for k in ("cal","prot","carb","fat","sod","fib","sug")}
|
| 503 |
-
t = {"calories": round(tdee), "protein": 56,
|
| 504 |
-
"carbs": round(tdee*.5/4), "fat": round(tdee*.3/9),
|
| 505 |
-
"sodium": 2300, "fiber": 28, "sugar": 50}
|
| 506 |
-
pct = {k: min(100, round(totals.get(k, 0) / v * 100)) if v else 0
|
| 507 |
-
for k, v in (("cal","calories"),("prot","protein"),
|
| 508 |
-
("carb","carbs"),("fat","fat"))}
|
| 509 |
-
|
| 510 |
-
cal_left = max(0, t["calories"] - totals["cal"])
|
| 511 |
-
prot_left = max(0, t["protein"] - totals["prot"])
|
| 512 |
-
suggestion = ""
|
| 513 |
-
if cal_left < 200: suggestion = "🎯 Almost at your calorie target — great tracking!"
|
| 514 |
-
elif prot_left > 20: suggestion = f"💪 {round(prot_left)}g protein left. Try: eggs, dal, paneer."
|
| 515 |
-
elif cal_left > 600: suggestion = f"🍽 {round(cal_left)} kcal remaining — a balanced meal fits well."
|
| 516 |
-
|
| 517 |
-
return {"date": target_date, "totals": totals, "targets": t, "pct": pct,
|
| 518 |
-
"suggestion": suggestion, "items": row["items"] or 0,
|
| 519 |
-
"log": [dict(r) for r in log_items]}
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
@app.post("/daily-log")
|
| 523 |
-
@limiter.limit("30/minute")
|
| 524 |
-
async def daily_log(
|
| 525 |
-
request : Request,
|
| 526 |
-
meal_name: str = Form(...),
|
| 527 |
-
calories : float = Form(0),
|
| 528 |
-
protein : float = Form(0),
|
| 529 |
-
carbs : float = Form(0),
|
| 530 |
-
fat : float = Form(0),
|
| 531 |
-
sodium : float = Form(0),
|
| 532 |
-
fiber : float = Form(0),
|
| 533 |
-
sugar : float = Form(0),
|
| 534 |
-
source : str = Form("manual"),
|
| 535 |
-
log_date : str = Form(None),
|
| 536 |
-
):
|
| 537 |
-
user = _get_request_user(request)
|
| 538 |
-
device_key = _device_key(request)
|
| 539 |
-
target_date = log_date or datetime.date.today().isoformat()
|
| 540 |
-
user_id = user["id"] if user else None
|
| 541 |
-
_ensure_device(device_key)
|
| 542 |
-
with db_conn() as conn:
|
| 543 |
-
conn.execute(
|
| 544 |
-
"INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
| 545 |
-
(user_id, device_key, target_date, meal_name, calories, protein, carbs, fat, sodium, fiber, sugar, source)
|
| 546 |
-
)
|
| 547 |
-
return {"status": "logged", "date": target_date, "meal": meal_name}
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
@app.delete("/daily-log/{log_id}")
|
| 551 |
-
async def delete_log(request: Request, log_id: int):
|
| 552 |
-
device_key = _device_key(request)
|
| 553 |
-
with db_conn() as conn:
|
| 554 |
-
conn.execute("DELETE FROM daily_logs WHERE id=? AND device_key=?", (log_id, device_key))
|
| 555 |
-
return {"status": "deleted", "id": log_id}
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
@app.get("/allergen-profile")
|
| 559 |
-
async def get_allergen_profile(request: Request):
|
| 560 |
-
device_key = _device_key(request)
|
| 561 |
-
with db_conn() as conn:
|
| 562 |
-
row = conn.execute(
|
| 563 |
-
"SELECT allergens, conditions FROM allergen_profiles WHERE device_key=?", (device_key,)
|
| 564 |
-
).fetchone()
|
| 565 |
-
if not row:
|
| 566 |
-
return {"allergens": [], "conditions": []}
|
| 567 |
-
return {"allergens": json.loads(row["allergens"] or "[]"),
|
| 568 |
-
"conditions": json.loads(row["conditions"] or "[]")}
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
@app.post("/allergen-profile")
|
| 572 |
-
async def set_allergen_profile(request: Request, allergens: str = Form("[]"),
|
| 573 |
-
conditions: str = Form("[]")):
|
| 574 |
-
device_key = _device_key(request)
|
| 575 |
-
_ensure_device(device_key)
|
| 576 |
-
with db_conn() as conn:
|
| 577 |
-
conn.execute(
|
| 578 |
-
"INSERT OR REPLACE INTO allergen_profiles(device_key,allergens,conditions,updated_at) VALUES(?,?,?,datetime('now'))",
|
| 579 |
-
(device_key, allergens, conditions)
|
| 580 |
-
)
|
| 581 |
-
return {"status": "saved"}
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
@app.post("/nps")
|
| 585 |
-
async def submit_nps(request: Request, score: int = Form(...), comment: str = Form("")):
|
| 586 |
-
if not 0 <= score <= 10:
|
| 587 |
-
return JSONResponse({"error": "Score must be 0-10"}, status_code=400)
|
| 588 |
-
user = _get_request_user(request)
|
| 589 |
-
device_key = _device_key(request)
|
| 590 |
-
with db_conn() as conn:
|
| 591 |
-
conn.execute(
|
| 592 |
-
"INSERT INTO nps_responses(device_key,user_id,score,comment) VALUES(?,?,?,?)",
|
| 593 |
-
(device_key, user["id"] if user else None, score, comment[:500])
|
| 594 |
-
)
|
| 595 |
-
return {"status": "thank_you"}
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
@app.get("/admin/analytics")
|
| 599 |
-
async def admin_analytics(admin_token: str = ""):
|
| 600 |
-
if admin_token != os.environ.get("ADMIN_TOKEN", "changeme"):
|
| 601 |
-
raise HTTPException(status_code=403, detail="Invalid token")
|
| 602 |
-
today = datetime.date.today().isoformat()
|
| 603 |
-
mkey = today[:7]
|
| 604 |
-
with db_conn() as conn:
|
| 605 |
-
dau = conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE DATE(scanned_at)=?", (today,)).fetchone()[0]
|
| 606 |
-
mau = conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE strftime('%Y-%m',scanned_at)=?", (mkey,)).fetchone()[0]
|
| 607 |
-
tot = conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0]
|
| 608 |
-
avg_s= conn.execute("SELECT AVG(score) FROM scans").fetchone()[0]
|
| 609 |
-
avg_n= conn.execute("SELECT AVG(score) FROM nps_responses").fetchone()[0]
|
| 610 |
-
npc = conn.execute("SELECT COUNT(*) FROM nps_responses").fetchone()[0]
|
| 611 |
-
users= conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
| 612 |
-
top = conn.execute("SELECT product_name,COUNT(*) c FROM scans GROUP BY product_name ORDER BY c DESC LIMIT 10").fetchall()
|
| 613 |
-
food_ct = conn.execute("SELECT COUNT(*) FROM food_products").fetchone()[0]
|
| 614 |
-
verified= conn.execute("SELECT COUNT(*) FROM food_products WHERE verified=1").fetchone()[0]
|
| 615 |
-
return {"dau": dau, "mau": mau, "total_scans": tot, "total_users": users,
|
| 616 |
-
"avg_score": round(avg_s or 0, 2), "avg_nps": round(avg_n or 0, 2),
|
| 617 |
-
"nps_count": npc, "dau_mau": round(dau/mau*100,1) if mau else 0,
|
| 618 |
-
"food_db": {"total": food_ct, "verified": verified},
|
| 619 |
-
"top_products": [{"name": r[0], "scans": r[1]} for r in top]}
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
@app.post("/admin/create-api-key")
|
| 623 |
-
async def create_api_key_endpoint(admin_token: str = Form(...),
|
| 624 |
-
client_name: str = Form(...),
|
| 625 |
-
plan: str = Form("business")):
|
| 626 |
-
if admin_token != os.environ.get("ADMIN_TOKEN", "changeme"):
|
| 627 |
-
raise HTTPException(status_code=403, detail="Invalid admin token")
|
| 628 |
-
key = "eak_" + secrets.token_urlsafe(32)
|
| 629 |
-
with db_conn() as conn:
|
| 630 |
-
conn.execute("INSERT INTO api_keys(api_key,client_name,plan) VALUES(?,?,?)",
|
| 631 |
-
(key, client_name, plan))
|
| 632 |
-
return {"api_key": key, "client": client_name, "plan": plan}
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
@app.post("/generate-share-card")
|
| 636 |
-
@limiter.limit("20/minute")
|
| 637 |
-
async def generate_share_card(
|
| 638 |
-
request: Request, product_name: str = Form(...),
|
| 639 |
-
score: int = Form(...), verdict: str = Form(...),
|
| 640 |
-
top_warning: str = Form(""), top_pro: str = Form(""),
|
| 641 |
-
):
|
| 642 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 643 |
-
W, H = 1080, 1080
|
| 644 |
-
img = Image.new("RGB", (W, H), (15, 17, 23))
|
| 645 |
-
draw = ImageDraw.Draw(img)
|
| 646 |
-
font = ImageFont.load_default()
|
| 647 |
-
s_rgb = (34,197,94) if score>=7 else (245,158,11) if score>=4 else (239,68,68)
|
| 648 |
-
def centered(text, y, fill):
|
| 649 |
-
try: tw = font.getbbox(text)[2] - font.getbbox(text)[0]
|
| 650 |
-
except: tw = len(text) * 6
|
| 651 |
-
draw.text(((W-tw)//2, y), text, fill=fill, font=font)
|
| 652 |
-
draw.ellipse([340,160,740,560], outline=s_rgb, width=18)
|
| 653 |
-
centered(str(score), 340, s_rgb); centered("/10", 430, (100,116,139))
|
| 654 |
-
pname = product_name[:38] + ("…" if len(product_name)>38 else "")
|
| 655 |
-
centered(pname, 600, (255,255,255)); centered(verdict[:50], 650, (148,163,184))
|
| 656 |
-
if top_pro:
|
| 657 |
-
draw.rectangle([60,700,1020,760], fill=(15,60,40))
|
| 658 |
-
centered(f"✓ {top_pro[:65]}", 718, (74,222,128))
|
| 659 |
-
if top_warning:
|
| 660 |
-
draw.rectangle([60,775,1020,840], fill=(124,29,29))
|
| 661 |
-
centered(f"⚠ {top_warning[:65]}", 795, (252,165,165))
|
| 662 |
-
centered("eatlytic.com • scan any food label, no barcode needed", 1000, (71,85,105))
|
| 663 |
-
draw.text((40, 1050), MEDICAL_DISCLAIMER[:90], fill=(50,50,60), font=font)
|
| 664 |
-
buf = BytesIO(); img.save(buf, format="PNG", optimize=True); buf.seek(0)
|
| 665 |
-
return Response(content=buf.getvalue(), media_type="image/png",
|
| 666 |
-
headers={"Content-Disposition": "attachment; filename=eatlytic-scan.png"})
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
@app.post("/export-pdf")
|
| 670 |
-
@limiter.limit("10/minute")
|
| 671 |
-
async def export_pdf(request: Request, analysis_json: str = Form(...)):
|
| 672 |
-
try:
|
| 673 |
-
data = json.loads(analysis_json)
|
| 674 |
-
except Exception:
|
| 675 |
-
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
| 676 |
-
try:
|
| 677 |
-
from reportlab.lib.pagesizes import A4
|
| 678 |
-
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 679 |
-
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
| 680 |
-
from reportlab.lib import colors as rl; from reportlab.lib.units import cm
|
| 681 |
-
except ImportError:
|
| 682 |
-
return JSONResponse({"error": "reportlab not installed"}, status_code=501)
|
| 683 |
-
buf = BytesIO()
|
| 684 |
-
doc = SimpleDocTemplate(buf, pagesize=A4, rightMargin=2*cm, leftMargin=2*cm,
|
| 685 |
-
topMargin=2*cm, bottomMargin=2*cm)
|
| 686 |
-
stys = getSampleStyleSheet(); story = []
|
| 687 |
-
story.append(Paragraph("Eatlytic Food Label Analysis", stys["Title"]))
|
| 688 |
-
story.append(Paragraph(f"Product: {data.get('product_name','Unknown')}", stys["Heading2"]))
|
| 689 |
-
story.append(Paragraph(MEDICAL_DISCLAIMER,
|
| 690 |
-
ParagraphStyle("d", parent=stys["Normal"], fontSize=8, textColor=rl.grey)))
|
| 691 |
-
story.append(Spacer(1,.4*cm))
|
| 692 |
-
score = data.get("score",0); sc = "22c55e" if score>=7 else "f59e0b" if score>=4 else "ef4444"
|
| 693 |
-
story.append(Paragraph(f"<font color='#{sc}'>Health Score: {score}/10 — {data.get('verdict','')}</font>", stys["Heading1"]))
|
| 694 |
-
if data.get("summary"):
|
| 695 |
-
story.append(Paragraph("Summary", stys["Heading2"]))
|
| 696 |
-
story.append(Paragraph(data["summary"], stys["Normal"]))
|
| 697 |
-
nutrients = data.get("nutrient_breakdown",[])
|
| 698 |
-
if nutrients:
|
| 699 |
-
story.append(Paragraph("Nutrient Breakdown", stys["Heading2"]))
|
| 700 |
-
td = [["Nutrient","Amount","Rating"]] + [
|
| 701 |
-
[str(n.get("name","")), f"{n.get('value','')} {n.get('unit','')}".strip(),
|
| 702 |
-
str(n.get("rating","")).upper()] for n in nutrients]
|
| 703 |
-
tbl = Table(td, colWidths=[6*cm,4*cm,4*cm])
|
| 704 |
-
tbl.setStyle(TableStyle([
|
| 705 |
-
("BACKGROUND",(0,0),(-1,0),rl.HexColor("1D9E75")),("TEXTCOLOR",(0,0),(-1,0),rl.white),
|
| 706 |
-
("FONTSIZE",(0,0),(-1,-1),10),("BACKGROUND",(0,1),(-1,-1),rl.HexColor("f8faf8")),
|
| 707 |
-
("GRID",(0,0),(-1,-1),.4,rl.HexColor("d0d8d4")),
|
| 708 |
-
("TOPPADDING",(0,0),(-1,-1),6),("BOTTOMPADDING",(0,0),(-1,-1),6),
|
| 709 |
-
("LEFTPADDING",(0,0),(-1,-1),8),("RIGHTPADDING",(0,0),(-1,-1),8),
|
| 710 |
-
])); story.append(tbl)
|
| 711 |
-
if data.get("pros"):
|
| 712 |
-
story.append(Paragraph("Benefits", stys["Heading2"]))
|
| 713 |
-
[story.append(Paragraph(f"✓ {p}", stys["Normal"])) for p in data["pros"]]
|
| 714 |
-
if data.get("cons"):
|
| 715 |
-
story.append(Paragraph("Concerns", stys["Heading2"]))
|
| 716 |
-
[story.append(Paragraph(f"✗ {c}", stys["Normal"])) for c in data["cons"]]
|
| 717 |
-
try:
|
| 718 |
-
doc.build(story)
|
| 719 |
-
except Exception as exc:
|
| 720 |
-
return JSONResponse({"error": f"PDF failed: {exc}"}, status_code=500)
|
| 721 |
-
buf.seek(0); safe = data.get("product_name","scan").replace(" ","-")[:40]
|
| 722 |
-
return Response(content=buf.getvalue(), media_type="application/pdf",
|
| 723 |
-
headers={"Content-Disposition": f"attachment; filename=eatlytic-{safe}.pdf"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|