Files
platform/gateway/command.py

268 lines
12 KiB
Python
Raw Normal View History

"""
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
})