Add UI to trips
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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
@@ -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 }) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))}."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user