Files
nova/agents/handover.py

292 lines
10 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
from agents.shared.tool_runner import build_tool_caller
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")
_call = build_tool_caller(mcp, emit=emit, errors=errors, lf=lf)
# 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