main.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import json
  2. import logging
  3. import httpx
  4. import bcrypt
  5. import asyncio
  6. from contextlib import asynccontextmanager
  7. from concurrent.futures import ThreadPoolExecutor
  8. from fastapi import FastAPI, HTTPException, Depends, Header
  9. from database import create_tables, create_user, get_user_by_username, create_session, get_user_from_token, delete_session, search_foods_by_name, save_chat_message, get_user_chat_history, get_user_profile, get_food_by_id, get_foods_by_ids, save_user_meal, update_user_meal, delete_user_meal, get_user_meals
  10. from fastapi.responses import HTMLResponse, StreamingResponse
  11. from fastapi.staticfiles import StaticFiles
  12. from pydantic import BaseModel
  13. from typing import List, Generator, Optional
  14. logging.basicConfig(level=logging.INFO)
  15. logger = logging.getLogger(__name__)
  16. executor = ThreadPoolExecutor(max_workers=4)
  17. @asynccontextmanager
  18. async def lifespan(app: FastAPI):
  19. create_tables()
  20. try:
  21. async with httpx.AsyncClient() as client:
  22. await client.post("http://localhost:11434/api/generate",
  23. json={"model": MODEL_NAME, "prompt": "", "keep_alive": "10m"},
  24. timeout=60.0)
  25. logger.info(f"Modèle {MODEL_NAME} pré-chargé en mémoire")
  26. except Exception as e:
  27. logger.warning(f"Impossible de pré-charger le modèle: {e}")
  28. yield
  29. app = FastAPI(title="LocalFoodAI Chat", lifespan=lifespan)
  30. # Use direct bcrypt for better environment compatibility
  31. def get_password_hash(password: str):
  32. # Hash requires bytes
  33. pwd_bytes = password.encode('utf-8')
  34. salt = bcrypt.gensalt()
  35. hashed = bcrypt.hashpw(pwd_bytes, salt)
  36. return hashed.decode('utf-8')
  37. def verify_password(plain_password: str, hashed_password: str):
  38. # bcrypt.checkpw handles verification
  39. return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
  40. class UserCreate(BaseModel):
  41. username: str
  42. password: str
  43. class UserLogin(BaseModel):
  44. username: str
  45. password: str
  46. async def get_current_user(authorization: Optional[str] = Header(None)):
  47. if not authorization or not authorization.startswith("Bearer "):
  48. raise HTTPException(status_code=401, detail="Authentication required")
  49. token = authorization.split(" ")[1]
  50. user = get_user_from_token(token)
  51. if not user:
  52. raise HTTPException(status_code=401, detail="Invalid or expired session")
  53. return user
  54. OLLAMA_URL = "http://localhost:11434/api/chat"
  55. MODEL_NAME = "qwen3.5:4b"
  56. # Common stopwords to strip before searching the food database
  57. _STOPWORDS = {
  58. 'how', 'many', 'much', 'calories', 'does', 'have', 'has', 'is', 'are',
  59. 'in', 'the', 'a', 'an', 'of', 'for', 'with', 'what', 'tell', 'me',
  60. 'about', 'nutritional', 'value', 'nutrition', 'macro', 'macros',
  61. 'protein', 'fat', 'carbs', 'fiber', 'can', 'you', 'i', 'want', 'need',
  62. 'eat', 'eating', 'food', 'meal', 'diet', 'healthy', 'make', 'cook',
  63. 'recipe', 'per', '100g', 'gram', 'grams', 'serving'
  64. }
  65. def extract_food_context(messages: list) -> str | None:
  66. """Scan the last user message for food keywords and enrich with local DB data."""
  67. # Find the last user message
  68. last_user_msg = None
  69. for msg in reversed(messages):
  70. role = msg.get('role', '') if isinstance(msg, dict) else msg.role
  71. content = msg.get('content', '') if isinstance(msg, dict) else msg.content
  72. if role == 'user':
  73. last_user_msg = content
  74. break
  75. if not last_user_msg:
  76. return None
  77. # Extract meaningful keywords by removing stopwords
  78. words = last_user_msg.lower().replace('?', '').replace(',', '').split()
  79. keywords = [w for w in words if w not in _STOPWORDS and len(w) > 2]
  80. if not keywords:
  81. return None
  82. recipe_keywords = ["recipe", "recette", "cook", "cuisiner", "plat", "dish", "make", "prepare"]
  83. is_recipe_request = any(kw in last_user_msg.lower() for kw in recipe_keywords)
  84. # Try each keyword against the local food database, collect unique results
  85. found_items = {}
  86. # Optimization: Only use the first 2 most relevant keywords to keep context small on CPU
  87. # For recipes, scan more keywords to cover all ingredients
  88. keyword_limit = 10 if is_recipe_request else 2
  89. for kw in keywords[:keyword_limit]:
  90. results = search_foods_by_name(kw, limit=2)
  91. for item in results:
  92. # Truncate extremely long USDA names for performance
  93. short_name = item['name'][:100] + ("..." if len(item['name']) > 100 else "")
  94. if short_name not in found_items:
  95. found_items[short_name] = item
  96. if not is_recipe_request and len(found_items) >= 3:
  97. break
  98. # Build a structured context block for the system prompt
  99. lines = [
  100. "[SYSTEM: NUTRITIONAL ANALYST MODE]",
  101. "You are the LocalFoodAI Analyst. Use ONLY verified local data for values.",
  102. "CRITICAL: Provide direct, concise answers. Skip all internal monologues, <thought> tags, or reasoning steps.",
  103. "If a food item is not found in the local data below, use your training knowledge but clearly mark those values as '(approx.)'.",
  104. ]
  105. if is_recipe_request:
  106. lines += [
  107. "The user wants a recipe. You MUST follow this exact structure:",
  108. "1. ### 🍽️ [Suggested dish name]",
  109. "2. **Total nutritional values of the dish** : a markdown table with Cal, Protein, Fat, Carbs, Fiber.",
  110. "3. **Recipe** : simple and clear steps.",
  111. "4. **Suggestions** : 0 to 2 tips such as 'if you add X, you will get more Y' or 'you can also cook X this way'.",
  112. "Use ONLY the nutritional data provided below to calculate the final values.",
  113. ]
  114. else:
  115. lines += [
  116. "For each food discussed, you MUST follow this structure:",
  117. "1. Header: ### 🥗 [Name] (per 100g)",
  118. "2. Macros: A markdown table for Cal, P, F, C, Fib, Sug, Chol.",
  119. "3. Micros: A bulleted list for Na, Ca, Fe, K, VitA, VitC.",
  120. "4. Insight: A 1-sentence analysis of the food's nutritional profile.",
  121. ]
  122. lines += [
  123. "Always prioritize local data over training memory. If a nutrient is missing, say 'Data not available'.",
  124. ""
  125. ]
  126. if found_items:
  127. for name, item in found_items.items():
  128. # Compact, token-efficient format for the LLM
  129. line = (
  130. f"- {name}: {item['calories']}kcal | P:{item['protein_g']}g | F:{item['fat_g']}g | C:{item['carbs_g']}g | "
  131. f"Fib:{item['fiber_g']}g | Sug:{item['sugar_g']}g | Chol:{item['cholesterol_mg']}mg | "
  132. f"Na:{item['sodium_mg']}mg | Ca:{item['calcium_mg']}mg | Fe:{item['iron_mg']}mg | "
  133. f"K:{item['potassium_mg']}mg | VitA:{item['vitamin_a_iu']}IU | VitC:{item['vitamin_c_mg']}mg"
  134. )
  135. lines.append(line)
  136. return "\n".join(lines)
  137. # Mount static files to serve the frontend
  138. app.mount("/static", StaticFiles(directory="static"), name="static")
  139. class ChatMessage(BaseModel):
  140. role: str
  141. content: str
  142. class ChatRequest(BaseModel):
  143. messages: List[ChatMessage]
  144. class MealItemInput(BaseModel):
  145. food_id: int
  146. amount_g: float
  147. class MealCalculateRequest(BaseModel):
  148. items: List[MealItemInput]
  149. class MealSaveRequest(BaseModel):
  150. name: str
  151. items: List[MealItemInput]
  152. class MealUpdateRequest(BaseModel):
  153. name: str
  154. @app.get("/", response_class=HTMLResponse)
  155. async def read_root():
  156. """Serve the chat interface HTML"""
  157. try:
  158. with open("static/index.html", "r", encoding="utf-8") as f:
  159. return HTMLResponse(content=f.read())
  160. except FileNotFoundError:
  161. return HTMLResponse(content="<h1>Welcome to LocalFoodAI</h1><p>static/index.html not found. Please create the frontend.</p>")
  162. @app.post("/api/register")
  163. async def register_user(user: UserCreate):
  164. if len(user.username.strip()) < 3:
  165. raise HTTPException(status_code=400, detail="Username must be at least 3 characters")
  166. if len(user.password.strip()) < 6:
  167. raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
  168. hashed_password = get_password_hash(user.password)
  169. user_id = create_user(user.username.strip(), hashed_password)
  170. if not user_id:
  171. raise HTTPException(status_code=400, detail="Username already exists")
  172. # Auto-login after registration
  173. token = create_session(user_id)
  174. return {"message": "User registered successfully", "token": token, "username": user.username.strip()}
  175. @app.post("/api/login")
  176. async def login_user(user: UserLogin):
  177. db_user = get_user_by_username(user.username.strip())
  178. if not db_user:
  179. raise HTTPException(status_code=401, detail="Invalid username or password")
  180. if not verify_password(user.password, db_user["password_hash"]):
  181. raise HTTPException(status_code=401, detail="Invalid username or password")
  182. token = create_session(db_user["id"])
  183. return {"status": "success", "username": db_user["username"], "token": token}
  184. @app.post("/api/logout")
  185. async def logout(authorization: Optional[str] = Header(None)):
  186. if authorization and authorization.startswith("Bearer "):
  187. token = authorization.split(" ")[1]
  188. delete_session(token)
  189. return {"message": "Logged out successfully"}
  190. @app.get("/api/macros/targets")
  191. async def get_macro_targets(current_user: dict = Depends(get_current_user)):
  192. """API endpoint to securely fetch the user's current macronutrient targets"""
  193. profile = get_user_profile(current_user['id'])
  194. if not profile:
  195. # Fallback to defaults in case database insertion failed
  196. return {
  197. "calories": 2000,
  198. "protein_g": 150,
  199. "carbs_g": 200,
  200. "fat_g": 65
  201. }
  202. return {
  203. "calories": profile.get("target_calories", 2000),
  204. "protein_g": profile.get("target_protein_g", 150),
  205. "carbs_g": profile.get("target_carbs_g", 200),
  206. "fat_g": profile.get("target_fat_g", 65)
  207. }
  208. @app.post("/chat")
  209. async def chat_endpoint(request: ChatRequest, current_user: dict = Depends(get_current_user)):
  210. """Proxy chat requests to the local Ollama instance with streaming support.
  211. Automatically enriches prompts with verified local SQLite nutritional data.
  212. """
  213. # Keep only the last 6 messages for context window performance on CPU
  214. all_messages = [msg.model_dump() for msg in request.messages]
  215. messages = all_messages[-6:]
  216. if messages and messages[-1]['role'] == 'user':
  217. asyncio.get_event_loop().run_in_executor(
  218. executor, save_chat_message, current_user['id'], 'user', messages[-1]['content']
  219. )
  220. # --- TG-35: Local SQL RAG Enrichment ---
  221. loop = asyncio.get_event_loop()
  222. db_context = await loop.run_in_executor(executor, extract_food_context, messages)
  223. if db_context:
  224. # Prepend as a system message so it acts as grounded knowledge
  225. # We ensure it's a short, concise instruction to prevent context bloat
  226. messages = [{"role": "system", "content": db_context}] + messages
  227. logger.info(f"[Chat] User '{current_user['username']}' is chatting. Context items: {'Yes' if db_context else 'No'}. Message count: {len(messages)}")
  228. payload = {
  229. "model": MODEL_NAME,
  230. "messages": messages,
  231. "stream": True,
  232. "think": False,
  233. "options": {
  234. "num_predict": 512,
  235. "temperature": 0.7,
  236. }
  237. }
  238. async def generate_response():
  239. try:
  240. bot_full_response = ""
  241. async with httpx.AsyncClient(timeout=300.0) as client:
  242. async with client.stream("POST", OLLAMA_URL, json=payload, timeout=300.0) as response:
  243. if response.status_code != 200:
  244. error_detail = await response.aread()
  245. logger.error(f"Ollama returned error {response.status_code}: {error_detail}")
  246. yield f"data: {json.dumps({'error': f'LLM Error ({response.status_code})'})}\n\n"
  247. return
  248. async for line in response.aiter_lines():
  249. if line:
  250. try:
  251. data = json.loads(line)
  252. if "message" in data and "content" in data["message"]:
  253. content = data["message"]["content"]
  254. bot_full_response += content
  255. yield f"data: {json.dumps({'content': content})}\n\n"
  256. if data.get("done"):
  257. break
  258. except json.JSONDecodeError:
  259. continue
  260. if bot_full_response.strip():
  261. asyncio.get_event_loop().run_in_executor(
  262. executor, save_chat_message, current_user['id'], 'assistant', bot_full_response
  263. )
  264. except Exception as e:
  265. logger.exception(f"Unexpected error during chat stream: {e}")
  266. yield f"data: {json.dumps({'error': 'A technical error occurred while generating the response.'})}\n\n"
  267. return StreamingResponse(generate_response(), media_type="text/event-stream")
  268. @app.get("/api/chat/history")
  269. async def get_history(current_user: dict = Depends(get_current_user)):
  270. """Fetch the chat history for the authenticated user"""
  271. history = get_user_chat_history(current_user['id'])
  272. return {"history": history}
  273. @app.get("/api/food/search")
  274. async def search_food(q: str, current_user: dict = Depends(get_current_user)):
  275. """API endpoint to search for food items securely using token authentication"""
  276. if not q or len(q.strip()) < 1:
  277. return {"results": []}
  278. logger.info(f"User {current_user['username']} searched for [{q}]")
  279. results = search_foods_by_name(q.strip(), limit=15)
  280. return {"results": results}
  281. @app.get("/api/food/{food_id}")
  282. async def get_food_detail(food_id: int, current_user: dict = Depends(get_current_user)):
  283. """API endpoint to fetch structured nutritional details for a specific food item"""
  284. food = get_food_by_id(food_id)
  285. if not food:
  286. raise HTTPException(status_code=404, detail="Food item not found")
  287. # Structure the data as proposed in the implementation plan
  288. structured_data = {
  289. "id": food["id"],
  290. "name": food["name"],
  291. "category": food["category"],
  292. "base_weight_g": food["base_weight_g"],
  293. "macros": {
  294. "calories": food["calories"],
  295. "protein_g": food["protein_g"],
  296. "fat_g": food["fat_g"],
  297. "carbs_g": food["carbs_g"]
  298. },
  299. "extended": {
  300. "fiber_g": food["fiber_g"],
  301. "sugar_g": food["sugar_g"],
  302. "cholesterol_mg": food["cholesterol_mg"]
  303. },
  304. "vitamins": {
  305. "vitamin_a_iu": food["vitamin_a_iu"],
  306. "vitamin_c_mg": food["vitamin_c_mg"]
  307. },
  308. "minerals": {
  309. "calcium_mg": food["calcium_mg"],
  310. "iron_mg": food["iron_mg"],
  311. "potassium_mg": food["potassium_mg"],
  312. "sodium_mg": food["sodium_mg"]
  313. },
  314. "source": food["source"]
  315. }
  316. return structured_data
  317. @app.post("/api/meal/calculate")
  318. async def calculate_meal(request: MealCalculateRequest, current_user: dict = Depends(get_current_user)):
  319. """Calculate the total nutritional value for a combined list of foods and their custom weights."""
  320. if not request.items:
  321. return {"error": "Meal is empty"}
  322. # Validation: Cast to floats and ensure > 0
  323. items = []
  324. for item in request.items:
  325. try:
  326. amount = float(item.amount_g)
  327. if amount <= 0:
  328. raise HTTPException(status_code=400, detail="Quantity must be greater than 0g for all items.")
  329. items.append({"food_id": item.food_id, "amount_g": amount})
  330. except ValueError:
  331. raise HTTPException(status_code=400, detail=f"Invalid amount for food ID {item.food_id}")
  332. # Bulk fetch from DB
  333. requested_ids = list(set(item["food_id"] for item in items))
  334. foods_data = get_foods_by_ids(requested_ids)
  335. # Map for easy lookup
  336. foods_map = {food["id"]: food for food in foods_data}
  337. # Fail-fast: Check if all requested IDs exist
  338. found_ids = set(foods_map.keys())
  339. missing_ids = [fid for fid in requested_ids if fid not in found_ids]
  340. if missing_ids:
  341. raise HTTPException(status_code=400, detail=f"Invalid food IDs provided: {missing_ids}")
  342. # Initialize aggregator
  343. totals = {
  344. "total_weight_g": 0.0,
  345. "macros": {"calories": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0},
  346. "extended": {"fiber_g": 0.0, "sugar_g": 0.0, "cholesterol_mg": 0.0},
  347. "vitamins": {"vitamin_a_iu": 0.0, "vitamin_c_mg": 0.0},
  348. "minerals": {"calcium_mg": 0.0, "iron_mg": 0.0, "potassium_mg": 0.0, "sodium_mg": 0.0}
  349. }
  350. def safe_val(val):
  351. return float(val) if val is not None else 0.0
  352. for item in items:
  353. food = foods_map[item["food_id"]]
  354. ratio = item["amount_g"] / 100.0
  355. totals["total_weight_g"] += item["amount_g"]
  356. totals["macros"]["calories"] += safe_val(food.get("calories")) * ratio
  357. totals["macros"]["protein_g"] += safe_val(food.get("protein_g")) * ratio
  358. totals["macros"]["fat_g"] += safe_val(food.get("fat_g")) * ratio
  359. totals["macros"]["carbs_g"] += safe_val(food.get("carbs_g")) * ratio
  360. totals["extended"]["fiber_g"] += safe_val(food.get("fiber_g")) * ratio
  361. totals["extended"]["sugar_g"] += safe_val(food.get("sugar_g")) * ratio
  362. totals["extended"]["cholesterol_mg"] += safe_val(food.get("cholesterol_mg")) * ratio
  363. totals["vitamins"]["vitamin_a_iu"] += safe_val(food.get("vitamin_a_iu")) * ratio
  364. totals["vitamins"]["vitamin_c_mg"] += safe_val(food.get("vitamin_c_mg")) * ratio
  365. totals["minerals"]["calcium_mg"] += safe_val(food.get("calcium_mg")) * ratio
  366. totals["minerals"]["iron_mg"] += safe_val(food.get("iron_mg")) * ratio
  367. totals["minerals"]["potassium_mg"] += safe_val(food.get("potassium_mg")) * ratio
  368. totals["minerals"]["sodium_mg"] += safe_val(food.get("sodium_mg")) * ratio
  369. # Rounding to 2 decimal places
  370. totals["total_weight_g"] = round(totals["total_weight_g"], 2)
  371. for category in ["macros", "extended", "vitamins", "minerals"]:
  372. for key in totals[category]:
  373. totals[category][key] = round(totals[category][key], 2)
  374. return totals
  375. @app.get("/api/meals")
  376. async def get_meals(current_user: dict = Depends(get_current_user)):
  377. """Retrieve all saved meals for the authenticated user"""
  378. meals = get_user_meals(current_user['id'])
  379. return {"status": "success", "meals": meals}
  380. @app.post("/api/meals")
  381. async def save_meal(request: MealSaveRequest, current_user: dict = Depends(get_current_user)):
  382. """Securely save a named meal list for the authenticated user"""
  383. if not request.name.strip():
  384. raise HTTPException(status_code=400, detail="Meal name cannot be empty")
  385. if not request.items:
  386. raise HTTPException(status_code=400, detail="Meal items cannot be empty")
  387. items_list = [item.model_dump() for item in request.items]
  388. meal_id = save_user_meal(current_user['id'], request.name.strip(), items_list)
  389. if meal_id is None:
  390. raise HTTPException(status_code=500, detail="Failed to save meal to database")
  391. return {"status": "success", "meal_id": meal_id, "name": request.name.strip()}
  392. @app.put("/api/meals/{meal_id}")
  393. async def rename_meal(meal_id: int, request: MealUpdateRequest, current_user: dict = Depends(get_current_user)):
  394. """Securely rename a saved meal for the authenticated user"""
  395. if not request.name.strip():
  396. raise HTTPException(status_code=400, detail="Meal name cannot be empty")
  397. success = update_user_meal(current_user['id'], meal_id, request.name.strip())
  398. if not success:
  399. raise HTTPException(status_code=404, detail="Meal not found or unauthorized")
  400. return {"status": "success", "message": "Meal renamed"}
  401. @app.delete("/api/meals/{meal_id}")
  402. async def delete_meal(meal_id: int, current_user: dict = Depends(get_current_user)):
  403. """Securely delete a saved meal for the authenticated user"""
  404. success = delete_user_meal(current_user['id'], meal_id)
  405. if not success:
  406. raise HTTPException(status_code=404, detail="Meal not found or unauthorized")
  407. return {"status": "success", "message": "Meal deleted"}
  408. if __name__ == "__main__":
  409. import uvicorn
  410. uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)