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