- Switch HTTPServer to ThreadingHTTPServer (concurrent request handling) - Replace SHA-256 password hashing with bcrypt (auth.py, database.py) - Add bcrypt to Dockerfile - Move qBittorrent env vars to config.py - Move _booklore_token state out of config into booklore.py - Remove dead fitness_token variable in command.py - Fix OpenAI call to use default SSL context instead of no-verify ctx - Log swallowed budget fetch error in dashboard.py
268 lines
12 KiB
Python
268 lines
12 KiB
Python
"""
|
|
Platform Gateway — Command bar handler (natural language actions via AI).
|
|
"""
|
|
|
|
import json
|
|
import urllib.request
|
|
from datetime import datetime
|
|
|
|
from config import OPENAI_API_KEY, OPENAI_MODEL, TRIPS_URL
|
|
from sessions import get_service_token
|
|
import proxy as _proxy_module
|
|
from proxy import proxy_request
|
|
|
|
|
|
def handle_command(handler, user, body):
|
|
"""Parse natural language command and execute it against the right service."""
|
|
try:
|
|
data = json.loads(body)
|
|
except Exception as e:
|
|
handler._send_json({"error": "Invalid JSON"}, 400)
|
|
return
|
|
|
|
command = data.get("command", "").strip()
|
|
if not command:
|
|
handler._send_json({"error": "No command provided"}, 400)
|
|
return
|
|
|
|
if not OPENAI_API_KEY:
|
|
handler._send_json({"error": "AI not configured"}, 500)
|
|
return
|
|
|
|
# Get context: user's trips list and today's date
|
|
trips_token = get_service_token(user["id"], "trips")
|
|
|
|
trips_context = ""
|
|
if trips_token:
|
|
s, _, b = proxy_request(f"{TRIPS_URL}/api/trips", "GET",
|
|
{"Authorization": f"Bearer {trips_token['auth_token']}"}, timeout=5)
|
|
if s == 200:
|
|
trips_list = json.loads(b).get("trips", [])
|
|
trips_context = "Available trips: " + ", ".join(
|
|
f'"{t["name"]}" (id={t["id"]}, {t.get("start_date","")} to {t.get("end_date","")})' for t in trips_list
|
|
)
|
|
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
|
|
system_prompt = f"""You are a command executor for a personal platform with two services: Trips and Fitness.
|
|
|
|
Today's date: {today}
|
|
Current user: {user["display_name"]} (id={user["id"]})
|
|
{trips_context}
|
|
|
|
Parse the user's natural language command and return a JSON action to execute.
|
|
|
|
Return ONLY valid JSON with this structure:
|
|
{{
|
|
"service": "trips" | "fitness",
|
|
"action": "description of what was done",
|
|
"api_call": {{
|
|
"method": "POST" | "GET",
|
|
"path": "/api/...",
|
|
"body": {{ ... }}
|
|
}}
|
|
}}
|
|
|
|
AVAILABLE ACTIONS:
|
|
|
|
For Trips service:
|
|
- Add location/activity: POST /api/location with {{"trip_id": "...", "name": "...", "category": "attraction|restaurant|cafe|hike|shopping", "visit_date": "YYYY-MM-DD", "start_time": "YYYY-MM-DDTHH:MM:SS", "description": "..."}}
|
|
- Add lodging: POST /api/lodging with {{"trip_id": "...", "name": "...", "type": "hotel", "check_in": "YYYY-MM-DDTHH:MM:SS", "check_out": "YYYY-MM-DDTHH:MM:SS", "location": "..."}}
|
|
- Add transportation: POST /api/transportation with {{"trip_id": "...", "name": "...", "type": "plane|car|train", "from_location": "...", "to_location": "...", "date": "YYYY-MM-DDTHH:MM:SS", "flight_number": "..."}}
|
|
- Add note: POST /api/note with {{"trip_id": "...", "name": "...", "content": "...", "date": "YYYY-MM-DD"}}
|
|
- Create trip: POST /api/trip with {{"name": "...", "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "description": "..."}}
|
|
|
|
For Fitness service:
|
|
- Log food (quick add): POST /api/entries with {{"entry_type": "quick_add", "meal_type": "breakfast|lunch|dinner|snack", "snapshot_food_name": "...", "snapshot_quantity": number, "snapshot_calories": number, "snapshot_protein": number, "snapshot_carbs": number, "snapshot_fat": number, "date": "YYYY-MM-DD"}}
|
|
IMPORTANT for snapshot_food_name: use just the FOOD NAME without the quantity (e.g. "mini cinnabon" not "1/2 mini cinnabon"). Put the numeric quantity in snapshot_quantity (e.g. 0.5 for "half", 0.75 for "3/4").
|
|
- Search food first then log: If you know exact nutrition, use quick_add. Common foods: banana (105cal, 1g protein, 27g carbs, 0g fat), apple (95cal), egg (78cal, 6g protein, 1g carbs, 5g fat), chicken breast (165cal, 31g protein, 0g carbs, 3.6g fat), rice 1 cup (206cal, 4g protein, 45g carbs, 0g fat), bread slice (79cal, 3g protein, 15g carbs, 1g fat), milk 1 cup (149cal, 8g protein, 12g carbs, 8g fat), coffee black (2cal), oatmeal 1 cup (154cal, 5g protein, 27g carbs, 3g fat).
|
|
|
|
For Inventory service (searches NocoDB):
|
|
- Search items: GET /search-records?q=... (note: NO /api prefix for inventory)
|
|
- If user asks to search inventory, find items, look up orders, check serial numbers -- use service "inventory" with GET method
|
|
|
|
Guidelines:
|
|
- For food logging, default meal_type to "snack" if not specified
|
|
- For food logging, use today's date if not specified
|
|
- For trips, match the trip name fuzzy (e.g. "colorado" matches "Colorado")
|
|
- Use the trip_id from the available trips list
|
|
- Default check-in to 3PM, check-out to 11AM for hotels
|
|
- Estimate reasonable nutrition values for foods you know
|
|
- For inventory searches, use service "inventory" with method GET and path /search-records?q=searchterm
|
|
- IMPORTANT: Inventory paths do NOT start with /api/ -- use bare paths like /search-records"""
|
|
|
|
# Call OpenAI
|
|
try:
|
|
ai_body = json.dumps({
|
|
"model": OPENAI_MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": command}
|
|
],
|
|
"max_completion_tokens": 1000,
|
|
"temperature": 0.1
|
|
}).encode()
|
|
|
|
req = urllib.request.Request(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
data=ai_body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {OPENAI_API_KEY}"
|
|
},
|
|
method="POST"
|
|
)
|
|
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
ai_result = json.loads(resp.read().decode())
|
|
ai_text = ai_result["choices"][0]["message"]["content"]
|
|
|
|
# Parse AI response
|
|
ai_text = ai_text.strip()
|
|
if ai_text.startswith("```json"):
|
|
ai_text = ai_text[7:]
|
|
if ai_text.startswith("```"):
|
|
ai_text = ai_text[3:]
|
|
if ai_text.endswith("```"):
|
|
ai_text = ai_text[:-3]
|
|
|
|
parsed = json.loads(ai_text.strip())
|
|
|
|
except Exception as e:
|
|
handler._send_json({"error": f"AI parsing failed: {e}"}, 500)
|
|
return
|
|
|
|
# Execute the action
|
|
service = parsed.get("service", "")
|
|
api_call = parsed.get("api_call", {})
|
|
action_desc = parsed.get("action", "Unknown action")
|
|
|
|
if not api_call or not api_call.get("path"):
|
|
handler._send_json({"error": "AI could not determine action", "parsed": parsed}, 400)
|
|
return
|
|
|
|
# Get service token
|
|
svc_token = get_service_token(user["id"], service)
|
|
if not svc_token and service not in ("inventory",):
|
|
handler._send_json({"error": f"{service} service not connected"}, 400)
|
|
return
|
|
|
|
# Build request to service
|
|
target = _proxy_module.SERVICE_MAP.get(service)
|
|
if not target:
|
|
handler._send_json({"error": f"Unknown service: {service}"}, 400)
|
|
return
|
|
|
|
method = api_call.get("method", "POST")
|
|
path = api_call.get("path", "")
|
|
body_data = api_call.get("body", {})
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
if svc_token:
|
|
headers["Authorization"] = f"Bearer {svc_token['auth_token']}"
|
|
|
|
# For fitness food logging, use the resolve workflow instead of quick_add
|
|
if service == "fitness" and body_data.get("entry_type") == "quick_add":
|
|
food_name = body_data.get("snapshot_food_name", "")
|
|
meal_type = body_data.get("meal_type", "snack")
|
|
entry_date = body_data.get("date", datetime.now().strftime("%Y-%m-%d"))
|
|
# AI-parsed quantity from the command (more reliable than resolve engine's parser)
|
|
ai_quantity = body_data.get("snapshot_quantity")
|
|
|
|
if food_name and svc_token:
|
|
try:
|
|
# Step 1: Resolve the food through the smart resolution engine
|
|
resolve_body = json.dumps({
|
|
"raw_phrase": food_name,
|
|
"meal_type": meal_type,
|
|
"entry_date": entry_date,
|
|
"source": "command_bar"
|
|
}).encode()
|
|
rs, _, rb = proxy_request(
|
|
f"{target}/api/foods/resolve", "POST",
|
|
{**headers, "Content-Type": "application/json"}, resolve_body, timeout=15
|
|
)
|
|
|
|
if rs == 200:
|
|
resolved = json.loads(rb)
|
|
res_type = resolved.get("resolution_type")
|
|
matched = resolved.get("matched_food")
|
|
parsed_food = resolved.get("parsed", {})
|
|
|
|
if matched and res_type in ("matched", "ai_estimated", "confirm"):
|
|
food_id = matched.get("id")
|
|
# For new foods (ai_estimated): serving was created for the exact request
|
|
# e.g. "3/4 cup biryani" -> serving = "3/4 cup" at 300 cal -> qty = 1.0
|
|
# For existing foods (matched/confirm): use AI quantity as multiplier
|
|
# e.g. "half cinnabon" -> existing "1 mini cinnabon" at 350 cal -> qty = 0.5
|
|
if res_type == "ai_estimated":
|
|
quantity = 1.0
|
|
elif ai_quantity and ai_quantity != 1:
|
|
quantity = ai_quantity
|
|
else:
|
|
quantity = 1.0
|
|
|
|
body_data = {
|
|
"food_id": food_id,
|
|
"meal_type": meal_type,
|
|
"quantity": quantity,
|
|
"date": entry_date,
|
|
"entry_type": "food"
|
|
}
|
|
|
|
# Use matching serving if available
|
|
servings = matched.get("servings", [])
|
|
if servings:
|
|
body_data["serving_id"] = servings[0].get("id")
|
|
|
|
# Use snapshot name override if provided (includes modifiers)
|
|
if resolved.get("snapshot_name_override"):
|
|
body_data["snapshot_food_name_override"] = resolved["snapshot_name_override"]
|
|
if resolved.get("note"):
|
|
body_data["note"] = resolved["note"]
|
|
|
|
action_desc = f"Logged {matched.get('name', food_name)} for {meal_type}"
|
|
if res_type == "ai_estimated":
|
|
action_desc += " (AI estimated, added to food database)"
|
|
|
|
# Auto-fetch image if food doesn't have one
|
|
if not matched.get("image_path") and food_id:
|
|
try:
|
|
img_body = json.dumps({"query": matched.get("name", food_name) + " food"}).encode()
|
|
si, _, sb = proxy_request(
|
|
f"{target}/api/images/search", "POST",
|
|
{**headers, "Content-Type": "application/json"}, img_body, timeout=8
|
|
)
|
|
if si == 200:
|
|
imgs = json.loads(sb).get("images", [])
|
|
if imgs:
|
|
img_url = imgs[0].get("url") or imgs[0].get("thumbnail")
|
|
if img_url:
|
|
proxy_request(
|
|
f"{target}/api/foods/{food_id}/image", "POST",
|
|
{**headers, "Content-Type": "application/json"},
|
|
json.dumps({"url": img_url}).encode(), timeout=10
|
|
)
|
|
except Exception as e:
|
|
print(f"[Command] Auto-image fetch failed: {e}")
|
|
|
|
except Exception as e:
|
|
print(f"[Command] Food resolve failed, falling back to quick_add: {e}")
|
|
|
|
req_body = json.dumps(body_data).encode() if body_data else None
|
|
|
|
status, resp_headers, resp_body = proxy_request(f"{target}{path}", method, headers, req_body, timeout=15)
|
|
|
|
try:
|
|
result = json.loads(resp_body)
|
|
except Exception as e:
|
|
result = {"raw": resp_body.decode()[:200]}
|
|
|
|
handler._send_json({
|
|
"success": status < 400,
|
|
"action": action_desc,
|
|
"service": service,
|
|
"status": status,
|
|
"result": result
|
|
})
|