main.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  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, delete_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://127.0.0.1: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", "meal", "repas", "menu"]
  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. "CRITICAL: Do NOT list the nutritional values of each individual ingredient! You MUST ONLY provide the TOTAL combined nutritional values for the entire meal.",
  113. "Use ONLY the nutritional data provided below to calculate the final values.",
  114. ]
  115. else:
  116. lines += [
  117. "STRICT INSTRUCTION: The user is asking for nutritional data. You MUST ONLY return nutritional data.",
  118. "DO NOT include any recipes, cooking instructions, meal ideas, or extra commentary. This is strictly forbidden.",
  119. "For each food discussed, you MUST follow this exact structure and provide NOTHING ELSE:",
  120. "1. Header: ### 🥗 [Name] (per 100g)",
  121. "2. Macros: A markdown table for Cal, P, F, C, Fib, Sug, Chol.",
  122. "3. Micros: A bulleted list for Na, Ca, Fe, K, VitA, VitC.",
  123. ]
  124. lines += [
  125. "Always prioritize local data over training memory. If a nutrient is missing, say 'Data not available'.",
  126. ""
  127. ]
  128. if found_items:
  129. for name, item in found_items.items():
  130. # Compact, token-efficient format for the LLM
  131. line = (
  132. f"- {name}: {item['calories']}kcal | P:{item['protein_g']}g | F:{item['fat_g']}g | C:{item['carbs_g']}g | "
  133. f"Fib:{item['fiber_g']}g | Sug:{item['sugar_g']}g | Chol:{item['cholesterol_mg']}mg | "
  134. f"Na:{item['sodium_mg']}mg | Ca:{item['calcium_mg']}mg | Fe:{item['iron_mg']}mg | "
  135. f"K:{item['potassium_mg']}mg | VitA:{item['vitamin_a_iu']}IU | VitC:{item['vitamin_c_mg']}mg"
  136. )
  137. lines.append(line)
  138. return "\n".join(lines)
  139. # Mount static files to serve the frontend
  140. app.mount("/static", StaticFiles(directory="static"), name="static")
  141. class ChatMessage(BaseModel):
  142. role: str
  143. content: str
  144. class ChatRequest(BaseModel):
  145. messages: List[ChatMessage]
  146. class MealItemInput(BaseModel):
  147. food_id: int
  148. amount_g: float
  149. class MealCalculateRequest(BaseModel):
  150. items: List[MealItemInput]
  151. class MealSaveRequest(BaseModel):
  152. name: str
  153. items: List[MealItemInput]
  154. class MealUpdateRequest(BaseModel):
  155. name: str
  156. items: Optional[List[MealItemInput]] = None
  157. @app.get("/", response_class=HTMLResponse)
  158. async def read_root():
  159. """Serve the chat interface HTML"""
  160. try:
  161. with open("static/index.html", "r", encoding="utf-8") as f:
  162. return HTMLResponse(content=f.read())
  163. except FileNotFoundError:
  164. return HTMLResponse(content="<h1>Welcome to LocalFoodAI</h1><p>static/index.html not found. Please create the frontend.</p>")
  165. @app.post("/api/register")
  166. async def register_user(user: UserCreate):
  167. if len(user.username.strip()) < 3:
  168. raise HTTPException(status_code=400, detail="Username must be at least 3 characters")
  169. if len(user.password.strip()) < 6:
  170. raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
  171. hashed_password = get_password_hash(user.password)
  172. user_id = create_user(user.username.strip(), hashed_password)
  173. if not user_id:
  174. raise HTTPException(status_code=400, detail="Username already exists")
  175. # Auto-login after registration
  176. token = create_session(user_id)
  177. return {"message": "User registered successfully", "token": token, "username": user.username.strip()}
  178. @app.post("/api/login")
  179. async def login_user(user: UserLogin):
  180. db_user = get_user_by_username(user.username.strip())
  181. if not db_user:
  182. raise HTTPException(status_code=401, detail="Invalid username or password")
  183. if not verify_password(user.password, db_user["password_hash"]):
  184. raise HTTPException(status_code=401, detail="Invalid username or password")
  185. token = create_session(db_user["id"])
  186. return {"status": "success", "username": db_user["username"], "token": token}
  187. @app.post("/api/logout")
  188. async def logout(authorization: Optional[str] = Header(None)):
  189. if authorization and authorization.startswith("Bearer "):
  190. token = authorization.split(" ")[1]
  191. delete_session(token)
  192. return {"message": "Logged out successfully"}
  193. @app.get("/api/macros/targets")
  194. async def get_macro_targets(current_user: dict = Depends(get_current_user)):
  195. """API endpoint to securely fetch the user's current macronutrient targets"""
  196. profile = get_user_profile(current_user['id'])
  197. if not profile:
  198. # Fallback to defaults in case database insertion failed
  199. return {
  200. "calories": 2000,
  201. "protein_g": 150,
  202. "carbs_g": 200,
  203. "fat_g": 65
  204. }
  205. return {
  206. "calories": profile.get("target_calories", 2000),
  207. "protein_g": profile.get("target_protein_g", 150),
  208. "carbs_g": profile.get("target_carbs_g", 200),
  209. "fat_g": profile.get("target_fat_g", 65)
  210. }
  211. @app.post("/chat")
  212. async def chat_endpoint(request: ChatRequest, current_user: dict = Depends(get_current_user)):
  213. """Proxy chat requests to the local Ollama instance with streaming support.
  214. Automatically enriches prompts with verified local SQLite nutritional data.
  215. """
  216. # Keep only the last 6 messages for context window performance on CPU
  217. all_messages = [msg.model_dump() for msg in request.messages]
  218. messages = all_messages[-3:]
  219. if messages and messages[-1]['role'] == 'user':
  220. asyncio.get_event_loop().run_in_executor(
  221. executor, save_chat_message, current_user['id'], 'user', messages[-1]['content']
  222. )
  223. # --- TG-35: Local SQL RAG Enrichment ---
  224. loop = asyncio.get_event_loop()
  225. db_context = await loop.run_in_executor(executor, extract_food_context, messages)
  226. if db_context:
  227. # Prepend as a system message so it acts as grounded knowledge
  228. # We ensure it's a short, concise instruction to prevent context bloat
  229. messages = [{"role": "system", "content": db_context}] + messages
  230. logger.info(f"[Chat] User '{current_user['username']}' is chatting. Context items: {'Yes' if db_context else 'No'}. Message count: {len(messages)}")
  231. payload = {
  232. "model": MODEL_NAME,
  233. "messages": messages,
  234. "stream": True,
  235. "think": False,
  236. "options": {
  237. "num_predict": 512,
  238. "temperature": 0.7,
  239. }
  240. }
  241. async def generate_response():
  242. try:
  243. bot_full_response = ""
  244. async with httpx.AsyncClient(timeout=300.0) as client:
  245. async with client.stream("POST", OLLAMA_URL, json=payload, timeout=300.0) as response:
  246. if response.status_code != 200:
  247. error_detail = await response.aread()
  248. logger.error(f"Ollama returned error {response.status_code}: {error_detail}")
  249. yield f"data: {json.dumps({'error': f'LLM Error ({response.status_code})'})}\n\n"
  250. return
  251. bot_full_response = ""
  252. started_thinking = False
  253. async for line in response.aiter_lines():
  254. if line:
  255. try:
  256. data = json.loads(line)
  257. msg = data.get("message", {}).get("content", "")
  258. thinking = data.get("message", {}).get("thinking", "")
  259. chunk = ""
  260. # Stream the thinking process as gray italic text
  261. if thinking:
  262. if not started_thinking:
  263. chunk += "<div style='color: #888; font-style: italic; font-size: 0.9em; padding-left: 10px; border-left: 3px solid #ccc; margin-bottom: 10px;'>"
  264. started_thinking = True
  265. # Replace newlines with <br> for HTML streaming
  266. chunk += thinking.replace('\n', '<br>')
  267. if msg:
  268. # Close the thinking div once the actual message starts
  269. if started_thinking and not bot_full_response:
  270. chunk += "</div><br>"
  271. started_thinking = False
  272. chunk += msg
  273. bot_full_response += msg
  274. if chunk:
  275. yield f"data: {json.dumps({'content': chunk, 'is_thinking': started_thinking})}\n\n"
  276. if data.get("done"):
  277. break
  278. except json.JSONDecodeError:
  279. continue
  280. yield "data: [DONE]\n\n"
  281. except Exception as e:
  282. logger.exception(f"Unexpected error during chat stream: {e}")
  283. yield f"data: {json.dumps({'error': 'A technical error occurred while generating the response.'})}\n\n"
  284. finally:
  285. if bot_full_response.strip():
  286. asyncio.get_event_loop().run_in_executor(
  287. executor, save_chat_message, current_user['id'], 'assistant', bot_full_response
  288. )
  289. return StreamingResponse(generate_response(), media_type="text/event-stream")
  290. @app.get("/api/chat/history")
  291. async def get_history(current_user: dict = Depends(get_current_user)):
  292. """Fetch the chat history for the authenticated user"""
  293. history = get_user_chat_history(current_user['id'])
  294. return {"history": history}
  295. @app.delete("/api/chat/history")
  296. async def delete_history(current_user: dict = Depends(get_current_user)):
  297. """Clear the chat history for the authenticated user"""
  298. success = delete_user_chat_history(current_user['id'])
  299. if not success:
  300. raise HTTPException(status_code=500, detail="Failed to delete chat history")
  301. return {"status": "success", "message": "Chat history cleared"}
  302. @app.get("/api/food/search")
  303. async def search_food(q: str, current_user: dict = Depends(get_current_user)):
  304. """API endpoint to search for food items securely using token authentication"""
  305. if not q or len(q.strip()) < 1:
  306. return {"results": []}
  307. logger.info(f"User {current_user['username']} searched for [{q}]")
  308. results = search_foods_by_name(q.strip(), limit=15)
  309. return {"results": results}
  310. @app.get("/api/food/{food_id}")
  311. async def get_food_detail(food_id: int, current_user: dict = Depends(get_current_user)):
  312. """API endpoint to fetch structured nutritional details for a specific food item"""
  313. food = get_food_by_id(food_id)
  314. if not food:
  315. raise HTTPException(status_code=404, detail="Food item not found")
  316. # Structure the data as proposed in the implementation plan
  317. structured_data = {
  318. "id": food["id"],
  319. "name": food["name"],
  320. "category": food["category"],
  321. "base_weight_g": food["base_weight_g"],
  322. "macros": {
  323. "calories": food["calories"],
  324. "protein_g": food["protein_g"],
  325. "fat_g": food["fat_g"],
  326. "carbs_g": food["carbs_g"]
  327. },
  328. "extended": {
  329. "fiber_g": food["fiber_g"],
  330. "sugar_g": food["sugar_g"],
  331. "cholesterol_mg": food["cholesterol_mg"]
  332. },
  333. "vitamins": {
  334. "vitamin_a_iu": food["vitamin_a_iu"],
  335. "vitamin_c_mg": food["vitamin_c_mg"]
  336. },
  337. "minerals": {
  338. "calcium_mg": food["calcium_mg"],
  339. "iron_mg": food["iron_mg"],
  340. "potassium_mg": food["potassium_mg"],
  341. "sodium_mg": food["sodium_mg"]
  342. },
  343. "source": food["source"]
  344. }
  345. return structured_data
  346. @app.post("/api/meal/calculate")
  347. async def calculate_meal(request: MealCalculateRequest, current_user: dict = Depends(get_current_user)):
  348. """Calculate the total nutritional value for a combined list of foods and their custom weights."""
  349. if not request.items:
  350. return {"error": "Meal is empty"}
  351. # Validation: Cast to floats and ensure > 0
  352. items = []
  353. for item in request.items:
  354. try:
  355. amount = float(item.amount_g)
  356. if amount <= 0:
  357. raise HTTPException(status_code=400, detail="Quantity must be greater than 0g for all items.")
  358. items.append({"food_id": item.food_id, "amount_g": amount})
  359. except ValueError:
  360. raise HTTPException(status_code=400, detail=f"Invalid amount for food ID {item.food_id}")
  361. # Bulk fetch from DB
  362. requested_ids = list(set(item["food_id"] for item in items))
  363. foods_data = get_foods_by_ids(requested_ids)
  364. # Map for easy lookup
  365. foods_map = {food["id"]: food for food in foods_data}
  366. # Fail-fast: Check if all requested IDs exist
  367. found_ids = set(foods_map.keys())
  368. missing_ids = [fid for fid in requested_ids if fid not in found_ids]
  369. if missing_ids:
  370. raise HTTPException(status_code=400, detail=f"Invalid food IDs provided: {missing_ids}")
  371. # Initialize aggregator
  372. totals = {
  373. "total_weight_g": 0.0,
  374. "macros": {"calories": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0},
  375. "extended": {"fiber_g": 0.0, "sugar_g": 0.0, "cholesterol_mg": 0.0},
  376. "vitamins": {"vitamin_a_iu": 0.0, "vitamin_c_mg": 0.0},
  377. "minerals": {"calcium_mg": 0.0, "iron_mg": 0.0, "potassium_mg": 0.0, "sodium_mg": 0.0}
  378. }
  379. def safe_val(val):
  380. return float(val) if val is not None else 0.0
  381. for item in items:
  382. food = foods_map[item["food_id"]]
  383. ratio = item["amount_g"] / 100.0
  384. totals["total_weight_g"] += item["amount_g"]
  385. totals["macros"]["calories"] += safe_val(food.get("calories")) * ratio
  386. totals["macros"]["protein_g"] += safe_val(food.get("protein_g")) * ratio
  387. totals["macros"]["fat_g"] += safe_val(food.get("fat_g")) * ratio
  388. totals["macros"]["carbs_g"] += safe_val(food.get("carbs_g")) * ratio
  389. totals["extended"]["fiber_g"] += safe_val(food.get("fiber_g")) * ratio
  390. totals["extended"]["sugar_g"] += safe_val(food.get("sugar_g")) * ratio
  391. totals["extended"]["cholesterol_mg"] += safe_val(food.get("cholesterol_mg")) * ratio
  392. totals["vitamins"]["vitamin_a_iu"] += safe_val(food.get("vitamin_a_iu")) * ratio
  393. totals["vitamins"]["vitamin_c_mg"] += safe_val(food.get("vitamin_c_mg")) * ratio
  394. totals["minerals"]["calcium_mg"] += safe_val(food.get("calcium_mg")) * ratio
  395. totals["minerals"]["iron_mg"] += safe_val(food.get("iron_mg")) * ratio
  396. totals["minerals"]["potassium_mg"] += safe_val(food.get("potassium_mg")) * ratio
  397. totals["minerals"]["sodium_mg"] += safe_val(food.get("sodium_mg")) * ratio
  398. # Rounding to 2 decimal places
  399. totals["total_weight_g"] = round(totals["total_weight_g"], 2)
  400. for category in ["macros", "extended", "vitamins", "minerals"]:
  401. for key in totals[category]:
  402. totals[category][key] = round(totals[category][key], 2)
  403. return totals
  404. @app.get("/api/meals")
  405. async def get_meals(current_user: dict = Depends(get_current_user)):
  406. """Retrieve all saved meals for the authenticated user"""
  407. meals = get_user_meals(current_user['id'])
  408. return {"status": "success", "meals": meals}
  409. @app.post("/api/meals")
  410. async def save_meal(request: MealSaveRequest, current_user: dict = Depends(get_current_user)):
  411. """Securely save a named meal list for the authenticated user"""
  412. if not request.name.strip():
  413. raise HTTPException(status_code=400, detail="Meal name cannot be empty")
  414. if not request.items:
  415. raise HTTPException(status_code=400, detail="Meal items cannot be empty")
  416. items_list = [item.model_dump() for item in request.items]
  417. meal_id = save_user_meal(current_user['id'], request.name.strip(), items_list)
  418. if meal_id is None:
  419. raise HTTPException(status_code=500, detail="Failed to save meal to database")
  420. return {"status": "success", "meal_id": meal_id, "name": request.name.strip()}
  421. @app.put("/api/meals/{meal_id}")
  422. async def rename_meal(meal_id: int, request: MealUpdateRequest, current_user: dict = Depends(get_current_user)):
  423. """Securely update a saved meal for the authenticated user"""
  424. if not request.name.strip():
  425. raise HTTPException(status_code=400, detail="Meal name cannot be empty")
  426. items_list = None
  427. if request.items is not None:
  428. items_list = [item.model_dump() for item in request.items]
  429. success = update_user_meal(current_user['id'], meal_id, request.name.strip(), items_list)
  430. if not success:
  431. raise HTTPException(status_code=404, detail="Meal not found or unauthorized")
  432. return {"status": "success", "message": "Meal updated"}
  433. @app.delete("/api/meals/{meal_id}")
  434. async def delete_meal(meal_id: int, current_user: dict = Depends(get_current_user)):
  435. """Securely delete a saved meal for the authenticated user"""
  436. success = delete_user_meal(current_user['id'], meal_id)
  437. if not success:
  438. raise HTTPException(status_code=404, detail="Meal not found or unauthorized")
  439. return {"status": "success", "message": "Meal deleted"}
  440. if __name__ == "__main__":
  441. import uvicorn
  442. uvicorn.run("main:app", host="0.0.0.0", port=8000)