|
|
@@ -4,7 +4,7 @@ import httpx
|
|
|
import bcrypt
|
|
|
from contextlib import asynccontextmanager
|
|
|
from fastapi import FastAPI, HTTPException, Depends, Header
|
|
|
-from database import create_tables, create_user, get_user_by_username, create_session, get_user_from_token, delete_session, search_foods_by_name
|
|
|
+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
|
|
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
from pydantic import BaseModel
|
|
|
@@ -86,12 +86,15 @@ def extract_food_context(messages: list) -> str | None:
|
|
|
|
|
|
# Try each keyword against the local food database, collect unique results
|
|
|
found_items = {}
|
|
|
- for kw in keywords[:5]: # Limit to first 5 keywords for performance
|
|
|
- results = search_foods_by_name(kw, limit=3)
|
|
|
+ # Optimization: Only use the first 2 most relevant keywords to keep context small on CPU
|
|
|
+ for kw in keywords[:2]:
|
|
|
+ results = search_foods_by_name(kw, limit=2)
|
|
|
for item in results:
|
|
|
- if item['name'] not in found_items:
|
|
|
- found_items[item['name']] = item
|
|
|
- if len(found_items) >= 5:
|
|
|
+ # Truncate extremely long USDA names for performance
|
|
|
+ short_name = item['name'][:100] + ("..." if len(item['name']) > 100 else "")
|
|
|
+ if short_name not in found_items:
|
|
|
+ found_items[short_name] = item
|
|
|
+ if len(found_items) >= 3:
|
|
|
break
|
|
|
|
|
|
if not found_items:
|
|
|
@@ -104,12 +107,11 @@ def extract_food_context(messages: list) -> str | None:
|
|
|
"Use ONLY the following data for specific nutritional values (per 100g serving):",
|
|
|
""
|
|
|
]
|
|
|
- for item in found_items.values():
|
|
|
+ for name, item in found_items.items():
|
|
|
line = (
|
|
|
- f"- {item['name']}: {item['calories']} kcal | "
|
|
|
- f"Protein: {item['protein_g']}g | Fat: {item['fat_g']}g | "
|
|
|
- f"Carbs: {item['carbs_g']}g | Fiber: {item['fiber_g']}g | "
|
|
|
- f"Sodium: {item['sodium_mg']}mg"
|
|
|
+ f"- {name}: {item['calories']} kcal | "
|
|
|
+ f"P: {item['protein_g']}g | F: {item['fat_g']}g | "
|
|
|
+ f"C: {item['carbs_g']}g"
|
|
|
)
|
|
|
lines.append(line)
|
|
|
|
|
|
@@ -176,45 +178,70 @@ async def chat_endpoint(request: ChatRequest, current_user: dict = Depends(get_c
|
|
|
"""Proxy chat requests to the local Ollama instance with streaming support.
|
|
|
Automatically enriches prompts with verified local SQLite nutritional data.
|
|
|
"""
|
|
|
- messages = [msg.model_dump() for msg in request.messages]
|
|
|
+ # Keep only the last 6 messages for context window performance on CPU
|
|
|
+ all_messages = [msg.model_dump() for msg in request.messages]
|
|
|
+ messages = all_messages[-6:]
|
|
|
+
|
|
|
+ # Save the latest user message to DB
|
|
|
+ if messages and messages[-1]['role'] == 'user':
|
|
|
+ save_chat_message(current_user['id'], 'user', messages[-1]['content'])
|
|
|
|
|
|
# --- TG-35: Local SQL RAG Enrichment ---
|
|
|
db_context = extract_food_context(messages)
|
|
|
if db_context:
|
|
|
- logger.info(f"[RAG] Injecting local DB context for user '{current_user['username']}'")
|
|
|
# Prepend as a system message so it acts as grounded knowledge
|
|
|
+ # We ensure it's a short, concise instruction to prevent context bloat
|
|
|
messages = [{"role": "system", "content": db_context}] + messages
|
|
|
+
|
|
|
+ logger.info(f"[Chat] User '{current_user['username']}' is chatting. Context items: {'Yes' if db_context else 'No'}. Message count: {len(messages)}")
|
|
|
|
|
|
payload = {
|
|
|
"model": MODEL_NAME,
|
|
|
"messages": messages,
|
|
|
- "stream": True # Enable streaming for a better UI experience
|
|
|
+ "stream": True
|
|
|
}
|
|
|
|
|
|
async def generate_response():
|
|
|
try:
|
|
|
- async with httpx.AsyncClient() as client:
|
|
|
- async with client.stream("POST", OLLAMA_URL, json=payload, timeout=120.0) as response:
|
|
|
+ bot_full_response = ""
|
|
|
+ async with httpx.AsyncClient(timeout=300.0) as client:
|
|
|
+ # Use a combined timeout for the entire request
|
|
|
+ async with client.stream("POST", OLLAMA_URL, json=payload, timeout=300.0) as response:
|
|
|
if response.status_code != 200:
|
|
|
error_detail = await response.aread()
|
|
|
- logger.error(f"Error communicating with Ollama: {error_detail}")
|
|
|
- yield f"data: {json.dumps({'error': 'Error communicating with local LLM.'})}\n\n"
|
|
|
+ logger.error(f"Ollama returned error {response.status_code}: {error_detail}")
|
|
|
+ yield f"data: {json.dumps({'error': f'LLM Error ({response.status_code})'})}\n\n"
|
|
|
return
|
|
|
|
|
|
async for line in response.aiter_lines():
|
|
|
if line:
|
|
|
- data = json.loads(line)
|
|
|
- if "message" in data and "content" in data["message"]:
|
|
|
- content = data["message"]["content"]
|
|
|
- yield f"data: {json.dumps({'content': content})}\n\n"
|
|
|
- if data.get("done"):
|
|
|
- break
|
|
|
+ try:
|
|
|
+ data = json.loads(line)
|
|
|
+ if "message" in data and "content" in data["message"]:
|
|
|
+ content = data["message"]["content"]
|
|
|
+ bot_full_response += content
|
|
|
+ yield f"data: {json.dumps({'content': content})}\n\n"
|
|
|
+ if data.get("done"):
|
|
|
+ break
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Save final bot response to DB
|
|
|
+ if bot_full_response.strip():
|
|
|
+ save_chat_message(current_user['id'], 'assistant', bot_full_response)
|
|
|
+
|
|
|
except Exception as e:
|
|
|
- logger.error(f"Unexpected error during stream: {e}")
|
|
|
- yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
|
|
+ logger.exception(f"Unexpected error during chat stream: {e}")
|
|
|
+ yield f"data: {json.dumps({'error': 'A technical error occurred while generating the response.'})}\n\n"
|
|
|
|
|
|
return StreamingResponse(generate_response(), media_type="text/event-stream")
|
|
|
|
|
|
+@app.get("/api/chat/history")
|
|
|
+async def get_history(current_user: dict = Depends(get_current_user)):
|
|
|
+ """Fetch the chat history for the authenticated user"""
|
|
|
+ history = get_user_chat_history(current_user['id'])
|
|
|
+ return {"history": history}
|
|
|
+
|
|
|
@app.get("/api/food/search")
|
|
|
async def search_food(q: str, current_user: dict = Depends(get_current_user)):
|
|
|
"""API endpoint to search for food items securely using token authentication"""
|