324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""Shift Handover agent — compiles all active ops state into a prioritized brief.
|
|
|
|
Connects to: shared + ops MCP servers.
|
|
Gathers data across all hubs, triages by severity and time sensitivity,
|
|
generates a structured handover brief.
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from agents.shared.mcp_client import MCPMultiClient
|
|
|
|
ALL_HUBS = ["ORD", "EWR", "IAH", "SFO", "DEN"]
|
|
|
|
# WMO weather severity mapping
|
|
WMO_SEVERITY = {
|
|
95: 9, 96: 9, 99: 10, # thunderstorms
|
|
65: 6, 75: 7, 82: 7, 86: 8, # heavy precip
|
|
45: 5, 48: 5, # fog
|
|
}
|
|
|
|
|
|
async def run_handover(
|
|
hubs: list[str] | None = None,
|
|
mcp: MCPMultiClient | None = None,
|
|
on_event: Any = None,
|
|
lf: Any = None,
|
|
) -> dict:
|
|
target_hubs = hubs or ALL_HUBS
|
|
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: Gather All (parallel tool calls across hubs) ──
|
|
|
|
await emit("node_enter", node="gather_all")
|
|
|
|
async def _call(server, tool, args, is_live=False, timeout=15.0):
|
|
t = time.time()
|
|
ctx = lf.start_as_current_observation(
|
|
name=tool, as_type="tool", input=args,
|
|
metadata={"server": server, "is_live": is_live},
|
|
) if lf else None
|
|
if ctx:
|
|
ctx.__enter__()
|
|
try:
|
|
result = await asyncio.wait_for(
|
|
mcp.call_tool(server, tool, args), timeout=timeout,
|
|
)
|
|
lat = int((time.time() - t) * 1000)
|
|
if ctx:
|
|
lf.update_current_span(output=result, metadata={"latency_ms": lat})
|
|
ctx.__exit__(None, None, None)
|
|
await emit("tool_call_end", tool=tool, latency_ms=lat, is_live=is_live)
|
|
return result
|
|
except asyncio.TimeoutError:
|
|
lat = int((time.time() - t) * 1000)
|
|
if ctx:
|
|
lf.update_current_span(output={"error": "timeout"}, level="ERROR")
|
|
ctx.__exit__(None, None, None)
|
|
await emit("tool_call_error", tool=tool, error="timeout", latency_ms=lat)
|
|
errors.append(f"{tool}: timeout after {timeout}s")
|
|
return None
|
|
except Exception as e:
|
|
lat = int((time.time() - t) * 1000)
|
|
if ctx:
|
|
lf.update_current_span(output={"error": str(e)}, level="ERROR")
|
|
ctx.__exit__(None, None, None)
|
|
await emit("tool_call_error", tool=tool, error=str(e), latency_ms=lat)
|
|
errors.append(f"{tool}: {e}")
|
|
return None
|
|
|
|
# Per-hub calls
|
|
hub_tasks = {}
|
|
for hub in target_hubs:
|
|
hub_tasks[hub] = {
|
|
"irrops": asyncio.create_task(
|
|
_call("shared", "get_irregular_ops", {"hub": hub})
|
|
),
|
|
"airport": asyncio.create_task(
|
|
_call("shared", "get_airport_status", {"airport_code": hub}, is_live=True)
|
|
),
|
|
"rebookings": asyncio.create_task(
|
|
_call("ops", "get_pending_rebookings", {"hub": hub})
|
|
),
|
|
}
|
|
|
|
# Global calls
|
|
weather_task = asyncio.create_task(
|
|
_call("shared", "get_hub_forecasts", {}, is_live=True)
|
|
)
|
|
|
|
# Gather all hub results
|
|
hub_data = {}
|
|
for hub in target_hubs:
|
|
hub_data[hub] = {
|
|
"irrops": await hub_tasks[hub]["irrops"],
|
|
"airport": await hub_tasks[hub]["airport"],
|
|
"rebookings": await hub_tasks[hub]["rebookings"],
|
|
}
|
|
|
|
weather_forecast = await weather_task
|
|
|
|
# Get crew duty status for all crew in disrupted flights
|
|
all_crew_ids = []
|
|
for hub in target_hubs:
|
|
irrops = hub_data[hub].get("irrops")
|
|
if isinstance(irrops, list):
|
|
for irrop in irrops:
|
|
if isinstance(irrop, dict):
|
|
# Get full flight data to get crew IDs
|
|
flight_data = await _call(
|
|
"shared", "get_flight_details",
|
|
{"flight_id": irrop.get("flight_id", "")}
|
|
)
|
|
if isinstance(flight_data, dict):
|
|
all_crew_ids.extend(flight_data.get("crew_ids", []))
|
|
|
|
crew_status = None
|
|
if all_crew_ids:
|
|
crew_status = await _call("ops", "get_crew_duty_status", {"crew_ids": all_crew_ids})
|
|
|
|
# Get maintenance flags for disrupted aircraft
|
|
maintenance_data = {}
|
|
for hub in target_hubs:
|
|
irrops = hub_data[hub].get("irrops")
|
|
if isinstance(irrops, list):
|
|
for irrop in irrops:
|
|
if isinstance(irrop, dict):
|
|
flight_data = await _call(
|
|
"shared", "get_flight_details",
|
|
{"flight_id": irrop.get("flight_id", "")}
|
|
)
|
|
if isinstance(flight_data, dict):
|
|
tail = flight_data.get("aircraft_tail", "")
|
|
if tail and tail not in maintenance_data:
|
|
mel = await _call("shared", "get_maintenance_flags", {"aircraft_tail": tail})
|
|
if mel:
|
|
maintenance_data[tail] = mel
|
|
|
|
await emit("node_exit", node="gather_all")
|
|
|
|
# ── Node 2: Triage ──
|
|
|
|
await emit("node_enter", node="triage")
|
|
|
|
immediate = []
|
|
monitor = []
|
|
fyi = []
|
|
|
|
# Crew at risk
|
|
if isinstance(crew_status, list):
|
|
for crew in crew_status:
|
|
if isinstance(crew, dict) and crew.get("at_risk"):
|
|
hours_left = crew.get("hours_until_limit", 0)
|
|
immediate.append(
|
|
f"{crew.get('next_scheduled_flight', '?')} — "
|
|
f"{crew.get('name', '?')} ({crew.get('role', '?')}) "
|
|
f"duty limit in {hours_left:.1f}h. "
|
|
f"Swap required if departure slips."
|
|
)
|
|
|
|
# IROPs
|
|
for hub in target_hubs:
|
|
irrops = hub_data[hub].get("irrops")
|
|
if isinstance(irrops, list) and irrops:
|
|
total_pax = sum(
|
|
i.get("affected_pax_count", 0) for i in irrops if isinstance(i, dict)
|
|
)
|
|
cancelled = [i for i in irrops if isinstance(i, dict) and i.get("irrop_type") == "CANCELLED"]
|
|
|
|
if cancelled:
|
|
for c in cancelled:
|
|
immediate.append(
|
|
f"{c.get('flight_id', '?')} ({hub}→{c.get('destination', '?')}) — "
|
|
f"CANCELLED ({c.get('delay_cause', '?')}). "
|
|
f"{c.get('affected_pax_count', 0)} pax need rebooking."
|
|
)
|
|
elif total_pax > 100:
|
|
monitor.append(
|
|
f"{hub}: {len(irrops)} flights disrupted, {total_pax} pax affected."
|
|
)
|
|
|
|
# Rebookings
|
|
rebookings = hub_data[hub].get("rebookings")
|
|
if isinstance(rebookings, list) and rebookings:
|
|
high_priority = [r for r in rebookings if isinstance(r, dict) and r.get("urgency") == "HIGH"]
|
|
if high_priority:
|
|
immediate.append(
|
|
f"{hub}: {len(rebookings)} pax awaiting rebooking "
|
|
f"({len(high_priority)} HIGH priority)."
|
|
)
|
|
|
|
# Weather risks
|
|
if isinstance(weather_forecast, dict):
|
|
for hub_code, forecast in weather_forecast.get("hubs", {}).items():
|
|
if isinstance(forecast, dict) and forecast.get("risk_flag"):
|
|
monitor.append(
|
|
f"Weather risk at {hub_code}: convective activity or low visibility "
|
|
f"forecast in next 4 hours."
|
|
)
|
|
|
|
# MEL items
|
|
for tail, items in maintenance_data.items():
|
|
if isinstance(items, list):
|
|
for item in items:
|
|
if isinstance(item, dict) and item.get("restriction"):
|
|
monitor.append(
|
|
f"MEL {item.get('mel_id', '?')} on {tail}: "
|
|
f"{item.get('system', '?')} — {item.get('restriction', '')}"
|
|
)
|
|
|
|
# Airport status (live FAA)
|
|
for hub in target_hubs:
|
|
airport = hub_data[hub].get("airport")
|
|
if isinstance(airport, dict) and airport.get("has_delays"):
|
|
for delay in airport.get("delays", []):
|
|
if isinstance(delay, dict):
|
|
if delay.get("type") == "ground_stop":
|
|
immediate.append(
|
|
f"{hub} GROUND STOP: {delay.get('reason', 'unknown')}. "
|
|
f"End time: {delay.get('end_time', 'TBD')}."
|
|
)
|
|
elif delay.get("type") == "ground_delay_program":
|
|
monitor.append(
|
|
f"{hub} GDP: {delay.get('reason', 'unknown')}. "
|
|
f"Avg delay: {delay.get('average_delay', 'unknown')}."
|
|
)
|
|
|
|
# Nominal hubs go to FYI
|
|
for hub in target_hubs:
|
|
irrops = hub_data[hub].get("irrops")
|
|
airport = hub_data[hub].get("airport")
|
|
has_issues = (isinstance(irrops, list) and len(irrops) > 0) or (
|
|
isinstance(airport, dict) and airport.get("has_delays")
|
|
)
|
|
if not has_issues:
|
|
fyi.append(f"{hub} fully nominal. No open items.")
|
|
|
|
await emit("node_exit", node="triage")
|
|
|
|
# ── Node 3: Synthesize ──
|
|
|
|
await emit("node_enter", node="synthesize")
|
|
|
|
shift_time = datetime.now(timezone.utc).strftime("%H:%M UTC")
|
|
hub_label = ", ".join(target_hubs) if len(target_hubs) < 5 else "ALL HUBS"
|
|
|
|
t0 = time.time()
|
|
raw_result = await mcp.call_tool("ops", "generate_narrative", {
|
|
"context": {
|
|
"hub": hub_label,
|
|
"shift_time": shift_time,
|
|
"immediate": immediate,
|
|
"monitor": monitor,
|
|
"fyi": fyi,
|
|
}
|
|
})
|
|
latency = int((time.time() - t0) * 1000)
|
|
await emit("tool_call_end", tool="generate_narrative", latency_ms=latency, is_live=False)
|
|
|
|
# Parse structured response
|
|
import json as _json
|
|
llm_provider = "template"
|
|
if isinstance(raw_result, dict) and "text" in raw_result:
|
|
brief_text = raw_result["text"]
|
|
llm_provider = raw_result.get("provider", "unknown")
|
|
elif isinstance(raw_result, str):
|
|
try:
|
|
parsed = _json.loads(raw_result)
|
|
brief_text = parsed.get("text", raw_result)
|
|
llm_provider = parsed.get("provider", "unknown")
|
|
except (_json.JSONDecodeError, TypeError):
|
|
brief_text = raw_result
|
|
else:
|
|
brief_text = str(raw_result)
|
|
|
|
await emit("node_exit", node="synthesize")
|
|
|
|
# ── Node 4: Format Output ──
|
|
|
|
await emit("node_enter", node="format_output")
|
|
|
|
result = {
|
|
"type": "HANDOVER_BRIEF",
|
|
"hubs": target_hubs,
|
|
"brief_text": brief_text,
|
|
"llm_provider": llm_provider,
|
|
"summary": {
|
|
"immediate_count": len(immediate),
|
|
"monitor_count": len(monitor),
|
|
"fyi_count": len(fyi),
|
|
},
|
|
"items": {
|
|
"immediate": immediate,
|
|
"monitor": monitor,
|
|
"fyi": fyi,
|
|
},
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"errors": errors,
|
|
"duration_ms": int((time.time() - run_start) * 1000),
|
|
"tool_calls": tool_calls,
|
|
}
|
|
|
|
await emit("node_exit", node="format_output")
|
|
await emit("agent_end", output_summary=f"Handover brief for {hub_label}")
|
|
|
|
# Store the brief for the ops://handover/latest resource
|
|
try:
|
|
from mcp_servers.ops.server import store_handover_brief
|
|
store_handover_brief(result)
|
|
except Exception:
|
|
pass # not critical
|
|
|
|
return result
|