import uuid import json import httpx from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel from app.models import TripCreate, TripEventCreate, AttendeeAction from app.internal import firestore as fstore from app.config import settings from app.internal.logger import logger from app.internal.auth import ( require_service_or_firebase_token, require_service_key, require_service_key_or_admin, trip_chat_limiter, ) router = APIRouter(prefix="/trips", tags=["trips"]) # --------------------------------------------------------------------------- # AI assistant — tool definitions # --------------------------------------------------------------------------- _TOOLS = [ { "type": "function", "function": { "name": "search_places", "description": ( "Search Google Maps for places (restaurants, bars, attractions, hotels, venues). " "Use this whenever the user asks about specific places or you need to find options." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "What to search for, e.g. 'rooftop bars', 'Italian restaurants'", }, "near": { "type": "string", "description": "Location to search near, e.g. 'downtown Nashville, TN'", }, }, "required": ["query", "near"], }, }, }, { "type": "function", "function": { "name": "add_tag", "description": ( "Add a new tag to the trip's available tag list so it can be used on events. " "Use this when you want to apply a tag that doesn't exist yet." ), "parameters": { "type": "object", "properties": { "tag": { "type": "string", "description": "Short tag label, e.g. 'must-do', 'nightlife', 'food'", }, }, "required": ["tag"], }, }, }, { "type": "function", "function": { "name": "propose_event", "description": ( "Propose a specific event to add to the itinerary. " "The user will see a card and can approve or dismiss it. " "Call this once per proposed event — do not bundle multiple events into one call." ), "parameters": { "type": "object", "properties": { "title": {"type": "string"}, "date": {"type": "string", "description": "YYYY-MM-DD — must be within the trip date range"}, "start_time": {"type": "string", "description": "HH:MM (24h), e.g. '19:30'"}, "end_time": {"type": "string", "description": "HH:MM (24h), e.g. '22:00'"}, "location": {"type": "string", "description": "Full address or place name"}, "maps_link": {"type": "string", "description": "Google Maps URL"}, "notes": {"type": "string", "description": "Brief tips or reasoning"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply — must be from the trip's available tags list"}, }, "required": ["title"], }, }, }, ] _PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText" _PLACES_FIELDS = "places.id,places.displayName,places.formattedAddress,places.rating,places.googleMapsUri" async def _places_search(query: str, near: str) -> list[dict]: if not settings.google_maps_api_key: return [] full_query = f"{query} {near}".strip() try: async with httpx.AsyncClient(timeout=8) as client: r = await client.post( _PLACES_SEARCH_URL, json={"textQuery": full_query}, headers={ "X-Goog-Api-Key": settings.google_maps_api_key, "X-Goog-FieldMask": _PLACES_FIELDS, }, ) data = r.json() places = data.get("places", []) logger.info(f"Places search '{full_query}': count={len(places)}") if not places and "error" in data: logger.warning(f"Places API error: {data['error'].get('message', '')}") return [ { "name": p.get("displayName", {}).get("text"), "address": p.get("formattedAddress"), "place_id": p.get("id"), "maps_link": p.get("googleMapsUri"), "rating": p.get("rating"), } for p in places[:5] ] except Exception as e: logger.error(f"Places search in assistant failed: {e}") return [] def _build_system_prompt(trip: dict, events: list[dict]) -> str: by_date: dict[str, list] = {} for e in sorted(events, key=lambda x: (x.get("date", ""), x.get("start_time") or "")): by_date.setdefault(e["date"], []).append(e) lines = [] for date, day_events in sorted(by_date.items()): lines.append(f"\n {date}:") for e in day_events: t = "" if e.get("start_time"): t = f" {e['start_time']}" if e.get("end_time"): t += f"–{e['end_time']}" loc = f" @ {e['location']}" if e.get("location") and not e.get("location_inherited") else "" lines.append(f" • {e['title']}{t}{loc}") if e.get("notes"): lines.append(f" Notes: {e['notes']}") itinerary = "".join(lines) if lines else "\n (no events yet)" attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified" available_tags = trip.get("available_tags") or [] tags_section = f"\nAvailable tags: {', '.join(available_tags)}" if available_tags else "" return f"""You are a trip planning assistant for the following trip. Trip: {trip["name"]} Destination: {trip["location"]} Dates: {trip["start_date"]} to {trip["end_date"]} Attendees: {attendees}{tags_section} Current itinerary:{itinerary} Guidelines: - Be conversational and concise — don't over-explain. - Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links. - When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing. - Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one. - When proposing events, apply relevant tags. Before using a tag, check if it exists in the available tags list. If it doesn't, call `add_tag` first to create it, then use it in `propose_event`. - Be mindful of the existing schedule when assigning times. Avoid obvious conflicts. - All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}. - If the user says something like "everyone should be there by 6", factor that into your time proposals. - If you don't know a specific address, search for the place first.""" class ChatMsg(BaseModel): role: str content: str class ChatRequest(BaseModel): message: str history: list[ChatMsg] = [] @router.get("") async def list_trips(): return await fstore.collection_list("trips") @router.post("") async def create_trip(body: TripCreate): if body.end_date < body.start_date: raise HTTPException(400, "end_date must be on or after start_date.") trip_id = str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() doc = { "trip_id": trip_id, "name": body.name, "location": body.location, "maps_link": body.maps_link, "start_date": body.start_date, "end_date": body.end_date, "attendees": {}, # {discord_user_id: discord_username} "available_tags": body.available_tags, "created_at": now, } await fstore.doc_set("trips", trip_id, doc, merge=False) return doc @router.get("/{trip_id}") async def get_trip(trip_id: str): trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") events = await fstore.collection_list("trip_events", trip_id=trip_id) events.sort(key=lambda e: (e["date"], e.get("time") or "")) return {**trip, "events": events} @router.put("/{trip_id}/tags") async def update_trip_tags(trip_id: str, body: dict): """Replace the trip's available tag list.""" trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") tags = [str(t) for t in body.get("available_tags", []) if t] await fstore.doc_update("trips", trip_id, {"available_tags": tags}) return {"available_tags": tags} @router.delete("/{trip_id}") async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)): trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") events = await fstore.collection_list("trip_events", trip_id=trip_id) for e in events: await fstore.doc_delete("trip_events", e["event_id"]) await fstore.doc_delete("trips", trip_id) return {"ok": True} @router.post("/{trip_id}/join") async def join_trip( trip_id: str, body: AttendeeAction, _: dict = Depends(require_service_key), ): """Join a trip as an attendee. Only the Discord bot (service key) may call this.""" trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") attendees = trip.get("attendees", {}) attendees[body.discord_user_id] = body.discord_username or body.discord_user_id await fstore.doc_update("trips", trip_id, {"attendees": attendees}) return {"ok": True, "attendees": attendees} @router.post("/{trip_id}/leave") async def leave_trip( trip_id: str, body: AttendeeAction, _: dict = Depends(require_service_key), ): """Leave a trip. Only the Discord bot (service key) may call this.""" trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") attendees = trip.get("attendees", {}) attendees.pop(body.discord_user_id, None) await fstore.doc_update("trips", trip_id, {"attendees": attendees}) # cascade: remove from all events in this trip events = await fstore.collection_list("trip_events", trip_id=trip_id) for e in events: event_attendees = e.get("attendees", {}) if body.discord_user_id in event_attendees: event_attendees.pop(body.discord_user_id) await fstore.doc_update("trip_events", e["event_id"], {"attendees": event_attendees}) return {"ok": True} @router.post("/{trip_id}/events") async def create_event(trip_id: str, body: TripEventCreate): trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") if not (trip["start_date"] <= body.date <= trip["end_date"]): raise HTTPException( 400, f"Event date {body.date} is outside the trip range " f"{trip['start_date']} – {trip['end_date']}.", ) event_id = str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() doc = { "event_id": event_id, "trip_id": trip_id, "title": body.title, "date": body.date, "start_time": body.start_time, "end_time": body.end_time, "location": body.location if body.location is not None else trip["location"], "location_inherited": body.location is None, "maps_link": body.maps_link, "place_id": body.place_id, "notes": body.notes, "tags": body.tags, "attendees": {}, "created_at": now, } await fstore.doc_set("trip_events", event_id, doc, merge=False) return doc @router.delete("/{trip_id}/events/{event_id}") async def delete_event( trip_id: str, event_id: str, _: dict = Depends(require_service_key_or_admin), ): event = await fstore.doc_get("trip_events", event_id) if not event or event.get("trip_id") != trip_id: raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.") await fstore.doc_delete("trip_events", event_id) return {"ok": True} @router.post("/{trip_id}/events/{event_id}/join") async def join_event( trip_id: str, event_id: str, body: AttendeeAction, _: dict = Depends(require_service_key), ): """Join an event. Only the Discord bot (service key) may call this.""" trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") if body.discord_user_id not in trip.get("attendees", {}): raise HTTPException(403, "You must join the trip before joining an event.") event = await fstore.doc_get("trip_events", event_id) if not event or event.get("trip_id") != trip_id: raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.") attendees = event.get("attendees", {}) attendees[body.discord_user_id] = body.discord_username or body.discord_user_id await fstore.doc_update("trip_events", event_id, {"attendees": attendees}) return {"ok": True, "attendees": attendees} @router.post("/{trip_id}/events/{event_id}/leave") async def leave_event( trip_id: str, event_id: str, body: AttendeeAction, _: dict = Depends(require_service_key), ): """Leave an event. Only the Discord bot (service key) may call this.""" event = await fstore.doc_get("trip_events", event_id) if not event or event.get("trip_id") != trip_id: raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.") attendees = event.get("attendees", {}) attendees.pop(body.discord_user_id, None) await fstore.doc_update("trip_events", event_id, {"attendees": attendees}) return {"ok": True} # --------------------------------------------------------------------------- # AI trip planning assistant # --------------------------------------------------------------------------- @router.post("/{trip_id}/chat") async def trip_chat( trip_id: str, body: ChatRequest, decoded: dict = Depends(require_service_or_firebase_token), ): if not settings.openai_api_key: raise HTTPException(503, "OpenAI not configured.") # Rate limit by caller identity caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown") trip_chat_limiter.check(f"{caller_key}:{trip_id}") trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") events = await fstore.collection_list("trip_events", trip_id=trip_id) from openai import AsyncOpenAI oai = AsyncOpenAI(api_key=settings.openai_api_key) # Strip history to only user/assistant roles to prevent prompt injection safe_history = [ {"role": m.role, "content": m.content} for m in body.history[-20:] if m.role in ("user", "assistant") ] # Truncate message to prevent oversized single requests user_message = body.message[:2000] messages: list[dict] = [ {"role": "system", "content": _build_system_prompt(trip, events)}, *safe_history, {"role": "user", "content": user_message}, ] suggestions: list[dict] = [] reply = "" for _ in range(6): # max tool-call iterations response = await oai.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=_TOOLS, tool_choice="auto", max_tokens=1000, ) msg = response.choices[0].message if not msg.tool_calls: reply = msg.content or "" break # Append assistant message with tool calls messages.append({ "role": "assistant", "content": msg.content, "tool_calls": [tc.model_dump() for tc in msg.tool_calls], }) for tc in msg.tool_calls: args = json.loads(tc.function.arguments) if tc.function.name == "add_tag": new_tag = str(args.get("tag", "")).strip()[:50] if new_tag and new_tag not in trip.get("available_tags", []): updated_tags = list(trip.get("available_tags") or []) + [new_tag] trip["available_tags"] = updated_tags await fstore.doc_update("trips", trip_id, {"available_tags": updated_tags}) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps({"available_tags": trip.get("available_tags", [])}), }) elif tc.function.name == "search_places": # Limit query string lengths before hitting the Maps API query = str(args.get("query", ""))[:200] near = str(args.get("near", ""))[:200] results = await _places_search(query, near) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(results), }) elif tc.function.name == "propose_event": suggestion = {k: args.get(k) for k in ( "title", "date", "start_time", "end_time", "location", "maps_link", "notes", "tags" )} if not isinstance(suggestion.get("tags"), list): suggestion["tags"] = [] suggestions.append(suggestion) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps({"proposed": True, "title": args.get("title")}), }) if not reply: reply = f"Here {'are' if len(suggestions) != 1 else 'is'} {len(suggestions) or 'my'} suggestion{'s' if len(suggestions) != 1 else ''} for your trip." return {"reply": reply, "suggestions": suggestions}