598 lines
24 KiB
Python
598 lines
24 KiB
Python
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}
|