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

598 lines
24 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, TripEventUpdate, 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"])
# ---------------------------------------------------------------------------
# Access control helpers
# ---------------------------------------------------------------------------
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
return (link or {}).get("discord_user_id")
def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool:
"""Return True if the caller may read this trip."""
if is_service:
return True # bot sees all; it filters client-side per-user
if trip.get("visibility", "public") == "public":
return True
if not firebase_uid:
return False
# attendees keyed by discord_id — check linked discord_id
if discord_id:
if discord_id in trip.get("attendees", {}):
return True
if discord_id in trip.get("invited_discord_ids", []):
return True
return False
# ---------------------------------------------------------------------------
# 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(decoded: dict = Depends(require_service_or_firebase_token)):
trips = await fstore.collection_list("trips")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)]
@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,
"overlap_tags": body.overlap_tags,
"visibility": body.visibility if body.visibility in ("public", "private") else "public",
"invited_discord_ids": body.invited_discord_ids,
"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, decoded: dict = Depends(require_service_or_firebase_token)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id):
raise HTTPException(403, "This trip is private.")
events = await fstore.collection_list("trip_events", trip_id=trip_id)
events.sort(key=lambda e: (e["date"], e.get("start_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 and overlap-allowed 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]
overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in tags]
await fstore.doc_update("trips", trip_id, {"available_tags": tags, "overlap_tags": overlap})
return {"available_tags": tags, "overlap_tags": overlap}
@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.")
if trip.get("visibility", "public") == "private":
invited = trip.get("invited_discord_ids", [])
attendees_existing = trip.get("attendees", {})
if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing:
raise HTTPException(403, "This trip is private. You need an invite to join.")
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.put("/{trip_id}/visibility")
async def set_visibility(trip_id: str, body: dict, _: 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.")
visibility = body.get("visibility", "public")
if visibility not in ("public", "private"):
raise HTTPException(400, "visibility must be 'public' or 'private'.")
await fstore.doc_update("trips", trip_id, {"visibility": visibility})
return {"visibility": visibility}
@router.post("/{trip_id}/invite/{discord_user_id}")
async def invite_user(trip_id: str, discord_user_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.")
invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id]))
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.delete("/{trip_id}/invite/{discord_user_id}")
async def revoke_invite(trip_id: str, discord_user_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.")
invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id]
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@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.patch("/{trip_id}/events/{event_id}")
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate):
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}'.")
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
updates: dict = {}
if body.title is not None:
updates["title"] = body.title
if body.date is not None:
if not (trip["start_date"] <= body.date <= trip["end_date"]):
raise HTTPException(400, f"Event date {body.date} is outside the trip range.")
updates["date"] = body.date
if body.start_time is not None:
updates["start_time"] = body.start_time or None
if body.end_time is not None:
updates["end_time"] = body.end_time or None
if body.location is not None:
updates["location"] = body.location
updates["location_inherited"] = False
if body.maps_link is not None:
updates["maps_link"] = body.maps_link or None
if body.place_id is not None:
updates["place_id"] = body.place_id or None
if body.notes is not None:
updates["notes"] = body.notes or None
if body.tags is not None:
updates["tags"] = body.tags
if updates:
await fstore.doc_update("trip_events", event_id, updates)
return {**event, **updates}
@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}