Files
server-26/drb-c2-core/app/routers/trips.py
T
2026-06-21 14:31:26 -04:00

423 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": "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"},
},
"required": ["title"],
},
},
},
]
async def _places_search(query: str, near: str) -> list[dict]:
if not settings.google_maps_api_key:
return []
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
"https://maps.googleapis.com/maps/api/place/textsearch/json",
params={"query": f"{query} {near}".strip(), "key": settings.google_maps_api_key},
)
data = r.json()
status = data.get("status")
results = data.get("results", [])
logger.info(f"Places search '{query} {near}': status={status}, count={len(results)}")
if status not in ("OK", "ZERO_RESULTS"):
logger.warning(f"Places API error: {status}{data.get('error_message', '')}")
return [
{
"name": p.get("name"),
"address": p.get("formatted_address"),
"place_id": p.get("place_id"),
"maps_link": f"https://www.google.com/maps/place/?q=place_id:{p.get('place_id')}",
"rating": p.get("rating"),
}
for p in results[: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"
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}
Current itinerary:{itinerary}
Guidelines:
- Be conversational and concise — don't over-explain.
- 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.
- 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}
"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.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,
"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 == "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"
)}
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}