Add UI to trips

This commit is contained in:
Logan
2026-06-21 10:12:33 -04:00
parent 8edb717dd2
commit 7b9aefbcc5
8 changed files with 1078 additions and 221 deletions
+2 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places
from app.internal import firestore as fstore
@@ -69,6 +69,7 @@ app.include_router(tokens.router, dependencies=[Depends(require_service_or_fi
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
+4 -2
View File
@@ -150,10 +150,12 @@ class TripCreate(BaseModel):
class TripEventCreate(BaseModel):
title: str
date: str # YYYY-MM-DD, must fall within parent trip range
time: Optional[str] = None # HH:MM (24h)
date: str # YYYY-MM-DD, must fall within parent trip range
start_time: Optional[str] = None # HH:MM (24h)
end_time: Optional[str] = None # HH:MM (24h)
location: Optional[str] = None # inherits trip location if None
maps_link: Optional[str] = None
place_id: Optional[str] = None # Google Place ID
notes: Optional[str] = None
+78
View File
@@ -0,0 +1,78 @@
import httpx
from fastapi import APIRouter, HTTPException, Query
from app.config import settings
from app.internal.logger import logger
router = APIRouter(prefix="/places", tags=["places"])
PLACES_SEARCH_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json"
DIRECTIONS_URL = "https://maps.googleapis.com/maps/api/directions/json"
@router.get("/search")
async def search_places(query: str = Query(...), near: str = Query("")):
if not settings.google_maps_api_key:
raise HTTPException(503, "Google Maps API not configured.")
full_query = f"{query} {near}".strip()
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(
PLACES_SEARCH_URL,
params={"query": full_query, "key": settings.google_maps_api_key},
)
r.raise_for_status()
data = r.json()
except Exception as e:
logger.error(f"Places search failed: {e}")
raise HTTPException(502, "Places search failed.")
return [
{
"name": p.get("name"),
"address": p.get("formatted_address"),
"place_id": p.get("place_id"),
"lat": p.get("geometry", {}).get("location", {}).get("lat"),
"lng": p.get("geometry", {}).get("location", {}).get("lng"),
"maps_link": f"https://www.google.com/maps/place/?q=place_id:{p.get('place_id')}",
"rating": p.get("rating"),
}
for p in data.get("results", [])[:6]
]
@router.get("/directions")
async def get_directions(
origin: str = Query(...),
destination: str = Query(...),
):
if not settings.google_maps_api_key:
raise HTTPException(503, "Google Maps API not configured.")
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(
DIRECTIONS_URL,
params={
"origin": origin,
"destination": destination,
"mode": "driving",
"key": settings.google_maps_api_key,
},
)
r.raise_for_status()
data = r.json()
except Exception as e:
logger.error(f"Directions failed: {e}")
raise HTTPException(502, "Directions request failed.")
routes = data.get("routes", [])
if not routes:
return {"duration_text": None, "duration_seconds": None, "distance_text": None}
leg = routes[0]["legs"][0]
return {
"duration_text": leg["duration"]["text"],
"duration_seconds": leg["duration"]["value"],
"distance_text": leg["distance"]["text"],
}
+215 -1
View File
@@ -1,12 +1,148 @@
import uuid
import json
import httpx
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException
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
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()
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 data.get("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():
@@ -102,10 +238,12 @@ async def create_event(trip_id: str, body: TripEventCreate):
"trip_id": trip_id,
"title": body.title,
"date": body.date,
"time": body.time,
"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,
@@ -148,3 +286,79 @@ async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
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):
if not settings.openai_api_key:
raise HTTPException(503, "OpenAI not configured.")
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)
messages: list[dict] = [
{"role": "system", "content": _build_system_prompt(trip, events)},
*[{"role": m.role, "content": m.content} for m in body.history[-20:]],
{"role": "user", "content": body.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":
results = await _places_search(args.get("query", ""), args.get("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}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -146,6 +146,21 @@ export const c2api = {
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
deleteTripEvent: (tripId: string, eventId: string) =>
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
request<{ reply: string; suggestions: import("@/lib/types").TripEvent[] }>(
`/trips/${tripId}/chat`,
{ method: "POST", body: JSON.stringify({ message, history }) }
),
// Places
searchPlaces: (query: string, near: string) =>
request<import("@/lib/types").PlaceResult[]>(
`/places/search?${new URLSearchParams({ query, near }).toString()}`
),
getDirections: (origin: string, destination: string) =>
request<{ duration_text: string | null; duration_seconds: number | null; distance_text: string | null }>(
`/places/directions?${new URLSearchParams({ origin, destination }).toString()}`
),
// Per-system AI flag overrides
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
+13 -1
View File
@@ -103,15 +103,27 @@ export interface TripEvent {
trip_id: string;
title: string;
date: string;
time: string | null;
start_time: string | null;
end_time: string | null;
location: string;
location_inherited: boolean;
maps_link: string | null;
place_id: string | null;
notes: string | null;
attendees: Record<string, string>;
created_at: string;
}
export interface PlaceResult {
name: string;
address: string;
place_id: string;
lat: number;
lng: number;
maps_link: string;
rating?: number;
}
export interface TripRecord {
trip_id: string;
name: string;
+15 -9
View File
@@ -316,7 +316,8 @@ class TripCommands(commands.Cog):
trip="The trip to add this event to.",
title="Event title",
date="Date of the event (YYYY-MM-DD or MM/DD/YYYY)",
time="Time of the event (e.g. 14:00 or 2:00 PM) — optional",
start_time="Start time (e.g. 14:00 or 2:00 PM) — optional",
end_time="End time (e.g. 16:00 or 4:00 PM) — optional",
location="Location override (optional, inherits trip location if omitted)",
maps_link="Google Maps link for this event (optional)",
notes="Any additional notes (optional)",
@@ -328,7 +329,8 @@ class TripCommands(commands.Cog):
trip: str,
title: str,
date: str,
time: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
location: Optional[str] = None,
maps_link: Optional[str] = None,
notes: Optional[str] = None,
@@ -340,17 +342,21 @@ class TripCommands(commands.Cog):
await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.")
return
parsed_time = _parse_time(time) if time else None
if time and parsed_time is None:
await interaction.followup.send(
"Couldn't parse that time. Try `14:00` or `2:00 PM`."
)
parsed_start = _parse_time(start_time) if start_time else None
parsed_end = _parse_time(end_time) if end_time else None
if start_time and parsed_start is None:
await interaction.followup.send("Couldn't parse start time. Try `14:00` or `2:00 PM`.")
return
if end_time and parsed_end is None:
await interaction.followup.send("Couldn't parse end time. Try `16:00` or `4:00 PM`.")
return
event = await c2.create_trip_event(trip, {
"title": title,
"date": parsed_date.strftime("%Y-%m-%d"),
"time": parsed_time,
"start_time": parsed_start,
"end_time": parsed_end,
"location": location,
"maps_link": maps_link,
"notes": notes,
@@ -362,7 +368,7 @@ class TripCommands(commands.Cog):
)
return
time_display = f" at {_fmt_time(parsed_time)}" if parsed_time else ""
time_display = f" at {_fmt_time(parsed_start)}" if parsed_start else ""
await interaction.followup.send(
f"Added **{title}**{time_display} on {_fmt_date(parsed_date.strftime('%Y-%m-%d'))}."
)