brand renaming, scenario reloads flight
This commit is contained in:
183
agents/fce.py
Normal file
183
agents/fce.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""FCE agent — "Behind Every Departure" (Flight Context Engine) passenger notification agent.
|
||||
|
||||
Connects to: shared + passenger MCP servers.
|
||||
When a flight is disrupted, gathers all operational context and generates
|
||||
an empathetic passenger notification.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from agents.shared.mcp_client import MCPMultiClient
|
||||
|
||||
|
||||
async def run_fce(
|
||||
flight_id: str,
|
||||
mcp: MCPMultiClient,
|
||||
on_event: Any = None,
|
||||
) -> dict:
|
||||
"""Run the FCE agent for a single flight.
|
||||
|
||||
Args:
|
||||
flight_id: The flight to generate a notification for.
|
||||
mcp: Connected MCPMultiClient (shared + passenger).
|
||||
on_event: Optional async callback for real-time events.
|
||||
|
||||
Returns:
|
||||
Notification result dict.
|
||||
"""
|
||||
run_start = time.time()
|
||||
errors = []
|
||||
tool_calls = []
|
||||
|
||||
async def emit(event_type: str, **data):
|
||||
event = {"type": event_type, "timestamp": datetime.now(timezone.utc).isoformat(), **data}
|
||||
tool_calls.append(event)
|
||||
if on_event:
|
||||
await on_event(event)
|
||||
|
||||
# ── Node 1: Triage ──
|
||||
|
||||
await emit("node_enter", node="triage")
|
||||
|
||||
t0 = time.time()
|
||||
flight_status = await mcp.call_tool("shared", "get_flight_status", {"flight_id": flight_id})
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
await emit("tool_call_end", tool="get_flight_status", latency_ms=latency, is_live=False)
|
||||
|
||||
if isinstance(flight_status, dict) and "error" in flight_status:
|
||||
return {"error": flight_status["error"], "flight_id": flight_id}
|
||||
|
||||
status = flight_status.get("status", "")
|
||||
delay_minutes = flight_status.get("delay_minutes", 0)
|
||||
should_notify = status in ("DELAYED", "CANCELLED", "DIVERTED") and delay_minutes >= 10
|
||||
|
||||
await emit("node_exit", node="triage", result={"should_notify": should_notify})
|
||||
|
||||
if not should_notify:
|
||||
return {
|
||||
"flight_id": flight_id,
|
||||
"type": "NO_NOTIFICATION",
|
||||
"reason": f"Status {status}, delay {delay_minutes}min — below threshold",
|
||||
"duration_ms": int((time.time() - run_start) * 1000),
|
||||
}
|
||||
|
||||
# ── Node 2: Gather Context (parallel tool calls) ──
|
||||
|
||||
await emit("node_enter", node="gather_context")
|
||||
|
||||
origin = flight_status.get("origin", "")
|
||||
destination = flight_status.get("destination", "")
|
||||
|
||||
async def _call(server, tool, args, is_live=False):
|
||||
t = time.time()
|
||||
try:
|
||||
result = await mcp.call_tool(server, tool, args)
|
||||
lat = int((time.time() - t) * 1000)
|
||||
await emit("tool_call_end", tool=tool, latency_ms=lat, is_live=is_live)
|
||||
return result
|
||||
except Exception as e:
|
||||
lat = int((time.time() - t) * 1000)
|
||||
await emit("tool_call_error", tool=tool, error=str(e), latency_ms=lat)
|
||||
errors.append(f"{tool}: {e}")
|
||||
return None
|
||||
|
||||
# Fire all independent calls in parallel
|
||||
ops_data_task = asyncio.create_task(
|
||||
_call("shared", "get_flight_details", {"flight_id": flight_id})
|
||||
)
|
||||
weather_task = asyncio.create_task(
|
||||
_call("shared", "get_route_weather", {"origin": origin, "destination": destination}, is_live=True)
|
||||
)
|
||||
airport_status_task = asyncio.create_task(
|
||||
_call("shared", "get_airport_status", {"airport_code": origin}, is_live=True)
|
||||
)
|
||||
airport_congestion_task = asyncio.create_task(
|
||||
_call("shared", "get_airport_congestion", {"airport_code": origin}, is_live=True)
|
||||
)
|
||||
crew_notes_task = asyncio.create_task(
|
||||
_call("ops", "get_crew_notes", {"flight_id": flight_id})
|
||||
)
|
||||
|
||||
ops_data, weather, airport_status, airport_congestion, crew_notes = await asyncio.gather(
|
||||
ops_data_task, weather_task, airport_status_task, airport_congestion_task, crew_notes_task
|
||||
)
|
||||
|
||||
await emit("node_exit", node="gather_context")
|
||||
|
||||
# ── Node 3: Synthesize ──
|
||||
|
||||
await emit("node_enter", node="synthesize")
|
||||
|
||||
# Build weather summary
|
||||
weather_summary = ""
|
||||
if weather and isinstance(weather, dict):
|
||||
events = weather.get("significant_events", [])
|
||||
if events:
|
||||
weather_summary = ", ".join(e.get("condition", "") for e in events)
|
||||
else:
|
||||
origin_wp = weather.get("waypoints", {}).get("origin", {})
|
||||
if isinstance(origin_wp, dict) and "weather" in origin_wp:
|
||||
weather_summary = origin_wp["weather"].get("condition", "")
|
||||
|
||||
# Build crew notes summary
|
||||
crew_summary = ""
|
||||
if crew_notes and isinstance(crew_notes, list):
|
||||
# Take first 2 notes for the notification
|
||||
relevant = [n for n in crew_notes if not n.startswith("CANCELLED")][:2]
|
||||
crew_summary = " ".join(relevant)
|
||||
|
||||
context = {
|
||||
"flight_id": flight_id,
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"status": status,
|
||||
"delay_minutes": delay_minutes,
|
||||
"delay_cause": flight_status.get("delay_cause")
|
||||
or (ops_data.get("delay_cause") if isinstance(ops_data, dict) else None),
|
||||
"gate": flight_status.get("gate", ""),
|
||||
"weather_summary": weather_summary,
|
||||
"crew_notes_summary": crew_summary,
|
||||
"get_airport_status": airport_status,
|
||||
"get_airport_congestion": airport_congestion,
|
||||
}
|
||||
|
||||
t0 = time.time()
|
||||
notification_text = await mcp.call_tool("passenger", "generate_notification", {"context": context})
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
await emit("tool_call_end", tool="generate_notification", latency_ms=latency, is_live=False)
|
||||
|
||||
await emit("node_exit", node="synthesize")
|
||||
|
||||
# ── Node 4: Format Output ──
|
||||
|
||||
await emit("node_enter", node="format_output")
|
||||
|
||||
data_sources = ["flight_ops"]
|
||||
if weather:
|
||||
data_sources.append("weather_live")
|
||||
if airport_status:
|
||||
data_sources.append("faa_status_live")
|
||||
if crew_notes:
|
||||
data_sources.append("get_crew_notes")
|
||||
|
||||
notification = {
|
||||
"flight_id": flight_id,
|
||||
"type": "DELAY_NOTIFICATION" if status == "DELAYED" else f"{status}_NOTIFICATION",
|
||||
"status": status,
|
||||
"delay_minutes": delay_minutes,
|
||||
"notification_text": notification_text if isinstance(notification_text, str) else str(notification_text),
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"data_sources": data_sources,
|
||||
"human_approved": True, # auto-approve in demo
|
||||
"errors": errors,
|
||||
"duration_ms": int((time.time() - run_start) * 1000),
|
||||
}
|
||||
|
||||
await emit("node_exit", node="format_output")
|
||||
await emit("agent_end", output_summary=f"{status} notification for {flight_id}")
|
||||
|
||||
return notification
|
||||
Reference in New Issue
Block a user