Files
platform/gateway/command.py
Yusuf Suleman 7cd81181ed Refactor gateway into modular architecture
Split 1878-line server.py into 15 focused modules:
- config.py: all env vars and constants
- database.py: schema, init, seed logic
- sessions.py: session/token CRUD
- proxy.py: proxy_request, SERVICE_MAP, resolve_service
- responses.py: ResponseMixin for handler helpers
- auth.py: login/logout/register handlers
- dashboard.py: dashboard, apps, connections, pinning
- command.py: AI command bar
- integrations/booklore.py: auth, books, cover, import
- integrations/kindle.py: send-to-kindle, file finder
- integrations/karakeep.py: save/delete bookmarks
- integrations/qbittorrent.py: download status
- integrations/image_proxy.py: external image proxy

server.py is now thin routing only (~344 lines).
All routes, methods, status codes, and responses preserved exactly.
Added PYTHONUNBUFFERED=1 to Dockerfile for live logging.
2026-03-29 00:14:46 -05:00

271 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, _ssl_ctx,
)
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")
fitness_token = get_service_token(user["id"], "fitness")
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, context=_ssl_ctx, 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
})