init commit
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# AWS Bedrock (production pattern)
|
||||||
|
AWS_ACCESS_KEY_ID=AKIA...
|
||||||
|
AWS_SECRET_ACCESS_KEY=...
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
USE_BEDROCK=false
|
||||||
|
|
||||||
|
# Anthropic (fallback for local dev)
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
|
||||||
|
# Kong Konnect (optional)
|
||||||
|
KONG_PROXY_URL=
|
||||||
|
|
||||||
|
# App
|
||||||
|
DEFAULT_SCENARIO=weather_disruption_ord
|
||||||
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
def
|
||||||
|
.spr
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
ui/app/dist/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
16
.mcp.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"united-ops-shared": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["run", "python", "-m", "mcp_servers.shared"]
|
||||||
|
},
|
||||||
|
"united-ops-internal": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["run", "python", "-m", "mcp_servers.ops"]
|
||||||
|
},
|
||||||
|
"united-ops-passenger": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["run", "python", "-m", "mcp_servers.passenger"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
agents/__init__.py
Normal file
183
agents/efhas.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_efhas(
|
||||||
|
flight_id: str,
|
||||||
|
mcp: MCPMultiClient,
|
||||||
|
on_event: Any = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Run the EFHaS 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
|
||||||
293
agents/handover.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""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,
|
||||||
|
) -> dict:
|
||||||
|
"""Run the Shift Handover agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hubs: Hubs to cover (default: all 5).
|
||||||
|
mcp: Connected MCPMultiClient (shared + ops).
|
||||||
|
on_event: Optional async callback for real-time events.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Handover brief result 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):
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
brief_text = 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)
|
||||||
|
|
||||||
|
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 if isinstance(brief_text, str) else str(brief_text),
|
||||||
|
"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
|
||||||
0
agents/shared/__init__.py
Normal file
24
agents/shared/llm.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""LLM factory — Bedrock Converse API (production) or direct Anthropic SDK (local dev)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_llm():
|
||||||
|
"""Returns a LangChain chat model for agent orchestration."""
|
||||||
|
if os.getenv("USE_BEDROCK", "").lower() == "true":
|
||||||
|
from langchain_aws import ChatBedrockConverse
|
||||||
|
|
||||||
|
return ChatBedrockConverse(
|
||||||
|
model=os.getenv("BEDROCK_MODEL_ID", "anthropic.claude-sonnet-4-20250514-v1:0"),
|
||||||
|
region_name=os.getenv("AWS_DEFAULT_REGION", "us-east-1"),
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=4096,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from langchain_anthropic import ChatAnthropic
|
||||||
|
|
||||||
|
return ChatAnthropic(
|
||||||
|
model=os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"),
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=4096,
|
||||||
|
)
|
||||||
137
agents/shared/mcp_client.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Multi-server MCP client using fastmcp composition.
|
||||||
|
|
||||||
|
Composes the three domain-scoped MCP servers into namespaced configurations
|
||||||
|
that agents connect to as a single client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastmcp import Client
|
||||||
|
|
||||||
|
|
||||||
|
# Server configurations for stdio transport
|
||||||
|
SERVERS = {
|
||||||
|
"shared": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["run", "python", "-m", "mcp_servers.shared"],
|
||||||
|
},
|
||||||
|
"ops": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["run", "python", "-m", "mcp_servers.ops"],
|
||||||
|
},
|
||||||
|
"passenger": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["run", "python", "-m", "mcp_servers.passenger"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agent profiles — which servers each agent connects to
|
||||||
|
AGENT_PROFILES = {
|
||||||
|
"efhas": ["shared", "ops", "passenger"],
|
||||||
|
"handover": ["shared", "ops"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MCPMultiClient:
|
||||||
|
"""Manages connections to multiple MCP servers via fastmcp Client."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._clients: dict[str, Client] = {}
|
||||||
|
|
||||||
|
async def connect(self, server_names: list[str]) -> None:
|
||||||
|
"""Connect to the specified MCP servers."""
|
||||||
|
for name in server_names:
|
||||||
|
if name not in SERVERS:
|
||||||
|
raise ValueError(f"Unknown server: {name}. Available: {list(SERVERS.keys())}")
|
||||||
|
config = {"mcpServers": {"default": SERVERS[name]}}
|
||||||
|
client = Client(config)
|
||||||
|
await client.__aenter__()
|
||||||
|
self._clients[name] = client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close all server connections."""
|
||||||
|
for client in self._clients.values():
|
||||||
|
try:
|
||||||
|
await client.__aexit__(None, None, None)
|
||||||
|
except (Exception, BaseException):
|
||||||
|
pass
|
||||||
|
self._clients.clear()
|
||||||
|
|
||||||
|
async def call_tool(self, server: str, tool_name: str, arguments: dict) -> Any:
|
||||||
|
"""Call a tool on a specific server. Returns parsed result."""
|
||||||
|
client = self._clients.get(server)
|
||||||
|
if not client:
|
||||||
|
raise ValueError(f"Not connected to server: {server}")
|
||||||
|
|
||||||
|
result = await client.call_tool(tool_name, arguments)
|
||||||
|
|
||||||
|
# Parse the result content
|
||||||
|
if isinstance(result, list):
|
||||||
|
texts = [c.text for c in result if hasattr(c, "text")]
|
||||||
|
elif hasattr(result, "content"):
|
||||||
|
texts = [c.text for c in result.content if hasattr(c, "text")]
|
||||||
|
else:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if len(texts) == 1:
|
||||||
|
try:
|
||||||
|
return json.loads(texts[0])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return texts[0]
|
||||||
|
elif len(texts) > 1:
|
||||||
|
parsed = []
|
||||||
|
for t in texts:
|
||||||
|
try:
|
||||||
|
parsed.append(json.loads(t))
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
parsed.append(t)
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def read_resource(self, server: str, uri: str) -> Any:
|
||||||
|
"""Read a resource from a specific server."""
|
||||||
|
client = self._clients.get(server)
|
||||||
|
if not client:
|
||||||
|
raise ValueError(f"Not connected to server: {server}")
|
||||||
|
|
||||||
|
result = await client.read_resource(uri)
|
||||||
|
if isinstance(result, str):
|
||||||
|
try:
|
||||||
|
return json.loads(result)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return result
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_prompt(self, server: str, prompt_name: str, arguments: dict) -> str:
|
||||||
|
"""Get a rendered prompt from a specific server."""
|
||||||
|
client = self._clients.get(server)
|
||||||
|
if not client:
|
||||||
|
raise ValueError(f"Not connected to server: {server}")
|
||||||
|
|
||||||
|
result = await client.get_prompt(prompt_name, arguments)
|
||||||
|
if isinstance(result, str):
|
||||||
|
return result
|
||||||
|
# Handle structured prompt response
|
||||||
|
texts = []
|
||||||
|
if hasattr(result, "messages"):
|
||||||
|
for msg in result.messages:
|
||||||
|
if hasattr(msg.content, "text"):
|
||||||
|
texts.append(msg.content.text)
|
||||||
|
elif isinstance(msg.content, list):
|
||||||
|
for c in msg.content:
|
||||||
|
if hasattr(c, "text"):
|
||||||
|
texts.append(c.text)
|
||||||
|
return "\n".join(texts) if texts else str(result)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def connect_servers(server_names: list[str]):
|
||||||
|
"""Context manager for multi-server MCP connections."""
|
||||||
|
client = MCPMultiClient()
|
||||||
|
try:
|
||||||
|
await client.connect(server_names)
|
||||||
|
yield client
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
0
api/__init__.py
Normal file
261
api/main.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""FastAPI application — triggers agents, exposes scenarios, streams events."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from agents.efhas import run_efhas
|
||||||
|
from agents.handover import run_handover
|
||||||
|
from agents.shared.mcp_client import connect_servers
|
||||||
|
from mcp_servers.data.scenarios.manager import scenario_manager
|
||||||
|
|
||||||
|
|
||||||
|
# ── WebSocket event hub ──
|
||||||
|
|
||||||
|
class EventHub:
|
||||||
|
"""Fans out agent events to connected WebSocket clients."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._clients: set[WebSocket] = set()
|
||||||
|
|
||||||
|
async def connect(self, ws: WebSocket):
|
||||||
|
await ws.accept()
|
||||||
|
self._clients.add(ws)
|
||||||
|
|
||||||
|
def disconnect(self, ws: WebSocket):
|
||||||
|
self._clients.discard(ws)
|
||||||
|
|
||||||
|
async def broadcast(self, event: dict):
|
||||||
|
dead = set()
|
||||||
|
for ws in self._clients:
|
||||||
|
try:
|
||||||
|
await ws.send_json(event)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
self._clients -= dead
|
||||||
|
|
||||||
|
|
||||||
|
event_hub = EventHub()
|
||||||
|
|
||||||
|
# ── In-memory run store ──
|
||||||
|
|
||||||
|
runs: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── App lifecycle ──
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(title="United Ops MCP Demo", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Request/Response models ──
|
||||||
|
|
||||||
|
class EFHaSRequest(BaseModel):
|
||||||
|
flight_id: str
|
||||||
|
|
||||||
|
class HandoverRequest(BaseModel):
|
||||||
|
hubs: list[str] | None = None
|
||||||
|
|
||||||
|
class ScenarioUpdate(BaseModel):
|
||||||
|
scenario_id: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Agent routes ──
|
||||||
|
|
||||||
|
@app.post("/agents/efhas")
|
||||||
|
async def trigger_efhas(req: EFHaSRequest):
|
||||||
|
run_id = str(uuid.uuid4())[:8]
|
||||||
|
runs[run_id] = {"status": "running", "agent": "efhas", "flight_id": req.flight_id}
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
await event_hub.broadcast({
|
||||||
|
"type": "agent_start", "run_id": run_id,
|
||||||
|
"agent": "efhas", "flight_id": req.flight_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def on_event(event):
|
||||||
|
await event_hub.broadcast({"run_id": run_id, **event})
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with connect_servers(["shared", "ops", "passenger"]) as mcp:
|
||||||
|
result = await run_efhas(req.flight_id, mcp, on_event=on_event)
|
||||||
|
runs[run_id] = {"status": "completed", "agent": "efhas", "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
runs[run_id] = {"status": "error", "agent": "efhas", "error": str(e)}
|
||||||
|
|
||||||
|
asyncio.create_task(_run())
|
||||||
|
return {"run_id": run_id, "status": "running"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/agents/handover")
|
||||||
|
async def trigger_handover(req: HandoverRequest):
|
||||||
|
run_id = str(uuid.uuid4())[:8]
|
||||||
|
runs[run_id] = {"status": "running", "agent": "handover", "hubs": req.hubs}
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
await event_hub.broadcast({
|
||||||
|
"type": "agent_start", "run_id": run_id,
|
||||||
|
"agent": "handover", "hubs": req.hubs,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def on_event(event):
|
||||||
|
await event_hub.broadcast({"run_id": run_id, **event})
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with connect_servers(["shared", "ops"]) as mcp:
|
||||||
|
result = await run_handover(hubs=req.hubs, mcp=mcp, on_event=on_event)
|
||||||
|
runs[run_id] = {"status": "completed", "agent": "handover", "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
runs[run_id] = {"status": "error", "agent": "handover", "error": str(e)}
|
||||||
|
|
||||||
|
asyncio.create_task(_run())
|
||||||
|
return {"run_id": run_id, "status": "running"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agents/runs/{run_id}")
|
||||||
|
async def get_run(run_id: str):
|
||||||
|
if run_id not in runs:
|
||||||
|
return {"error": f"Run {run_id} not found"}
|
||||||
|
return runs[run_id]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agents/runs")
|
||||||
|
async def list_runs():
|
||||||
|
return [
|
||||||
|
{"run_id": rid, "status": r["status"], "agent": r.get("agent")}
|
||||||
|
for rid, r in sorted(runs.items(), reverse=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scenario routes ──
|
||||||
|
|
||||||
|
@app.get("/scenarios")
|
||||||
|
async def list_scenarios():
|
||||||
|
return scenario_manager.list_scenarios()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scenarios/active")
|
||||||
|
async def get_active_scenario():
|
||||||
|
return scenario_manager.get_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/scenarios/active")
|
||||||
|
async def set_active_scenario(req: ScenarioUpdate):
|
||||||
|
try:
|
||||||
|
return scenario_manager.set_active(req.scenario_id)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scenario data routes ──
|
||||||
|
|
||||||
|
@app.get("/scenarios/data/flights")
|
||||||
|
async def get_scenario_flights():
|
||||||
|
return [f.model_dump(mode="json") for f in scenario_manager.flights]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scenarios/data/crew")
|
||||||
|
async def get_scenario_crew():
|
||||||
|
crew = []
|
||||||
|
for c in scenario_manager.crew:
|
||||||
|
d = c.model_dump(mode="json")
|
||||||
|
d["hours_until_limit"] = round(c.duty_hours_limit - c.duty_hours_elapsed, 2)
|
||||||
|
d["at_risk"] = d["hours_until_limit"] <= 2.0
|
||||||
|
crew.append(d)
|
||||||
|
return crew
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scenarios/data/crew-notes")
|
||||||
|
async def get_scenario_crew_notes():
|
||||||
|
return scenario_manager.crew_notes
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scenarios/data/maintenance")
|
||||||
|
async def get_scenario_maintenance():
|
||||||
|
result = {}
|
||||||
|
for tail, items in scenario_manager.maintenance.items():
|
||||||
|
result[tail] = [i.model_dump(mode="json") for i in items]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scenarios/data/rebookings")
|
||||||
|
async def get_scenario_rebookings():
|
||||||
|
return [r.model_dump(mode="json") for r in scenario_manager.rebookings]
|
||||||
|
|
||||||
|
|
||||||
|
class FlightPatch(BaseModel):
|
||||||
|
delay_minutes: int | None = None
|
||||||
|
status: str | None = None
|
||||||
|
gate: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/scenarios/data/flights/{flight_id}")
|
||||||
|
async def patch_flight(flight_id: str, patch: FlightPatch):
|
||||||
|
for f in scenario_manager.flights:
|
||||||
|
if f.flight_id == flight_id:
|
||||||
|
if patch.delay_minutes is not None:
|
||||||
|
f.delay_minutes = patch.delay_minutes
|
||||||
|
if patch.status is not None:
|
||||||
|
f.status = patch.status
|
||||||
|
if patch.gate is not None:
|
||||||
|
f.gate = patch.gate
|
||||||
|
return f.model_dump(mode="json")
|
||||||
|
return {"error": f"Flight {flight_id} not found"}
|
||||||
|
|
||||||
|
|
||||||
|
class CrewPatch(BaseModel):
|
||||||
|
duty_hours_elapsed: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/scenarios/data/crew/{crew_id}")
|
||||||
|
async def patch_crew(crew_id: str, patch: CrewPatch):
|
||||||
|
for c in scenario_manager.crew:
|
||||||
|
if c.crew_id == crew_id:
|
||||||
|
if patch.duty_hours_elapsed is not None:
|
||||||
|
c.duty_hours_elapsed = patch.duty_hours_elapsed
|
||||||
|
d = c.model_dump(mode="json")
|
||||||
|
d["hours_until_limit"] = round(c.duty_hours_limit - c.duty_hours_elapsed, 2)
|
||||||
|
d["at_risk"] = d["hours_until_limit"] <= 2.0
|
||||||
|
return d
|
||||||
|
return {"error": f"Crew {crew_id} not found"}
|
||||||
|
|
||||||
|
|
||||||
|
class CrewNotesPatch(BaseModel):
|
||||||
|
notes: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/scenarios/data/crew-notes/{flight_id}")
|
||||||
|
async def put_crew_notes(flight_id: str, patch: CrewNotesPatch):
|
||||||
|
scenario_manager.crew_notes[flight_id] = patch.notes
|
||||||
|
return {"flight_id": flight_id, "notes": patch.notes}
|
||||||
|
|
||||||
|
|
||||||
|
# ── WebSocket ──
|
||||||
|
|
||||||
|
@app.websocket("/ws/agent-events")
|
||||||
|
async def agent_events_ws(ws: WebSocket):
|
||||||
|
await event_hub.connect(ws)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Keep connection alive — client can send pings
|
||||||
|
await ws.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
event_hub.disconnect(ws)
|
||||||
0
api/routes/__init__.py
Normal file
15
ctrl/Dockerfile.api
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install uv
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN uv sync --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY mcp_servers/ mcp_servers/
|
||||||
|
COPY agents/ agents/
|
||||||
|
COPY api/ api/
|
||||||
|
COPY irrop/ irrop/
|
||||||
|
|
||||||
|
CMD ["uv", "run", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
19
ctrl/Dockerfile.ui
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-slim AS build
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY ui/framework/ framework/
|
||||||
|
COPY ui/app/ app/
|
||||||
|
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
WORKDIR /ui/framework
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
WORKDIR /ui/app
|
||||||
|
RUN pnpm install && pnpm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /ui/app/dist /usr/share/nginx/html
|
||||||
|
COPY ctrl/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
54
ctrl/Tiltfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# UNT — Tilt development environment
|
||||||
|
# Usage: cd ctrl && tilt up
|
||||||
|
# Cluster: kind (name: unt)
|
||||||
|
# Entry point: http://localhost:8040
|
||||||
|
|
||||||
|
allow_k8s_contexts('kind-unt')
|
||||||
|
|
||||||
|
# Create namespace first to avoid race conditions
|
||||||
|
local('kubectl create namespace unt --dry-run=client -o yaml | kubectl apply -f -')
|
||||||
|
|
||||||
|
# Apply k8s manifests via kustomize (dev overlay)
|
||||||
|
k8s_yaml(kustomize('k8s/overlays/dev'))
|
||||||
|
|
||||||
|
# --- Images ---
|
||||||
|
|
||||||
|
# FastAPI + MCP servers + agents (Python backend)
|
||||||
|
docker_build(
|
||||||
|
'unt-api',
|
||||||
|
context='..',
|
||||||
|
dockerfile='Dockerfile.api',
|
||||||
|
ignore=['.git', 'def', 'ui', '.claude', 'tests', '.venv', 'node_modules'],
|
||||||
|
live_update=[
|
||||||
|
sync('../mcp_servers', '/app/mcp_servers'),
|
||||||
|
sync('../agents', '/app/agents'),
|
||||||
|
sync('../api', '/app/api'),
|
||||||
|
sync('../irrop', '/app/irrop'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vue UI — context is project root so framework link resolves
|
||||||
|
docker_build(
|
||||||
|
'unt-ui',
|
||||||
|
context='..',
|
||||||
|
dockerfile='Dockerfile.ui',
|
||||||
|
live_update=[
|
||||||
|
sync('../ui/app/src', '/ui/app/src'),
|
||||||
|
sync('../ui/app/index.html', '/ui/app/index.html'),
|
||||||
|
sync('../ui/app/vite.config.ts', '/ui/app/vite.config.ts'),
|
||||||
|
sync('../ui/framework/src', '/ui/framework/src'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Resources ---
|
||||||
|
|
||||||
|
k8s_resource('postgres')
|
||||||
|
k8s_resource('langfuse', resource_deps=['postgres'], port_forwards=['3000:3000'])
|
||||||
|
k8s_resource('api', resource_deps=['langfuse'])
|
||||||
|
k8s_resource('ui', resource_deps=['api'], port_forwards=['8040:80'])
|
||||||
|
|
||||||
|
# Group infra resources
|
||||||
|
k8s_resource(
|
||||||
|
objects=['unt:namespace', 'unt-config:configmap'],
|
||||||
|
new_name='infra',
|
||||||
|
)
|
||||||
50
ctrl/docker-compose.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: ctrl/Dockerfile.api
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
||||||
|
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
||||||
|
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-east-1}
|
||||||
|
- USE_BEDROCK=${USE_BEDROCK:-false}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- LANGFUSE_HOST=http://langfuse:3000
|
||||||
|
- DEFAULT_SCENARIO=${DEFAULT_SCENARIO:-weather_disruption_ord}
|
||||||
|
depends_on:
|
||||||
|
- langfuse
|
||||||
|
|
||||||
|
ui:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: ctrl/Dockerfile.ui
|
||||||
|
ports:
|
||||||
|
- "8040:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
langfuse:
|
||||||
|
image: langfuse/langfuse:2
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://langfuse:langfuse@db:5432/langfuse
|
||||||
|
- NEXTAUTH_SECRET=unt-dev-secret
|
||||||
|
- NEXTAUTH_URL=http://localhost:3000
|
||||||
|
- SALT=unt-dev-salt-not-for-production-use
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=langfuse
|
||||||
|
- POSTGRES_PASSWORD=langfuse
|
||||||
|
- POSTGRES_DB=langfuse
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
48
ctrl/k8s/base/api.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: api
|
||||||
|
namespace: unt
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: api
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: api
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: api
|
||||||
|
image: unt-api
|
||||||
|
command: ["uv", "run", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: unt-config
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /scenarios
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 500m
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: api
|
||||||
|
namespace: unt
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: api
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
9
ctrl/k8s/base/configmap.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: unt-config
|
||||||
|
namespace: unt
|
||||||
|
data:
|
||||||
|
DEFAULT_SCENARIO: "weather_disruption_ord"
|
||||||
|
USE_BEDROCK: "false"
|
||||||
|
LANGFUSE_HOST: "http://langfuse:3000"
|
||||||
12
ctrl/k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: unt
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- configmap.yaml
|
||||||
|
- postgres.yaml
|
||||||
|
- langfuse.yaml
|
||||||
|
- api.yaml
|
||||||
|
- ui.yaml
|
||||||
55
ctrl/k8s/base/langfuse.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: langfuse
|
||||||
|
namespace: unt
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: langfuse
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: langfuse
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: langfuse
|
||||||
|
image: langfuse/langfuse:2
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
value: "postgresql://langfuse:langfuse@postgres:5432/langfuse"
|
||||||
|
- name: NEXTAUTH_SECRET
|
||||||
|
value: "unt-dev-secret"
|
||||||
|
- name: NEXTAUTH_URL
|
||||||
|
value: "http://localhost:3000"
|
||||||
|
- name: SALT
|
||||||
|
value: "unt-dev-salt-not-for-production-use"
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/public/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 256Mi
|
||||||
|
cpu: 200m
|
||||||
|
limits:
|
||||||
|
memory: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: langfuse
|
||||||
|
namespace: unt
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: langfuse
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
targetPort: 3000
|
||||||
4
ctrl/k8s/base/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: unt
|
||||||
50
ctrl/k8s/base/postgres.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: unt
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: "langfuse"
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
value: "langfuse"
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: "langfuse"
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "langfuse"]
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 128Mi
|
||||||
|
cpu: 100m
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: unt
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
44
ctrl/k8s/base/ui.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
namespace: unt
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ui
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ui
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ui
|
||||||
|
image: unt-ui
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 64Mi
|
||||||
|
cpu: 50m
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
namespace: unt
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: ui
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
16
ctrl/k8s/kind-config.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
kind: Cluster
|
||||||
|
apiVersion: kind.x-k8s.io/v1alpha4
|
||||||
|
name: unt
|
||||||
|
nodes:
|
||||||
|
- role: control-plane
|
||||||
|
extraPortMappings:
|
||||||
|
# UI — entry point for the app
|
||||||
|
- containerPort: 30040
|
||||||
|
hostPort: 8040
|
||||||
|
listenAddress: "127.0.0.1"
|
||||||
|
protocol: TCP
|
||||||
|
# Langfuse observability
|
||||||
|
- containerPort: 30030
|
||||||
|
hostPort: 3000
|
||||||
|
listenAddress: "127.0.0.1"
|
||||||
|
protocol: TCP
|
||||||
30
ctrl/k8s/overlays/dev/kustomization.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- ../../base
|
||||||
|
|
||||||
|
patches:
|
||||||
|
# UI service → NodePort on 30040 (mapped to host 8040 via Kind)
|
||||||
|
- target:
|
||||||
|
kind: Service
|
||||||
|
name: ui
|
||||||
|
patch: |
|
||||||
|
- op: replace
|
||||||
|
path: /spec/type
|
||||||
|
value: NodePort
|
||||||
|
- op: add
|
||||||
|
path: /spec/ports/0/nodePort
|
||||||
|
value: 30040
|
||||||
|
|
||||||
|
# Langfuse service → NodePort on 30030 (mapped to host 3000 via Kind)
|
||||||
|
- target:
|
||||||
|
kind: Service
|
||||||
|
name: langfuse
|
||||||
|
patch: |
|
||||||
|
- op: replace
|
||||||
|
path: /spec/type
|
||||||
|
value: NodePort
|
||||||
|
- op: add
|
||||||
|
path: /spec/ports/0/nodePort
|
||||||
|
value: 30030
|
||||||
23
ctrl/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /agents {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /scenarios {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
5
ctrl/tilt_config.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"kind_cluster_name": "unt",
|
||||||
|
"kind_config": "k8s/kind-config.yaml",
|
||||||
|
"default_registry": ""
|
||||||
|
}
|
||||||
92
docs/graphs/data_flow.dot
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
digraph data_flow {
|
||||||
|
rankdir=LR
|
||||||
|
bgcolor="#0a0e17"
|
||||||
|
fontname="Helvetica"
|
||||||
|
node [fontname="Helvetica" fontsize=10 style=filled color="#1e2a4a" fontcolor="#e8eaf0"]
|
||||||
|
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
|
||||||
|
|
||||||
|
label="Data Flow — Real vs Mock"
|
||||||
|
labelloc=t
|
||||||
|
fontsize=14
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
subgraph cluster_real {
|
||||||
|
label="LIVE DATA (no API key)"
|
||||||
|
color="#00c853"
|
||||||
|
fontcolor="#00c853"
|
||||||
|
style=rounded
|
||||||
|
|
||||||
|
openmeteo [label="OpenMeteo API\napi.open-meteo.com\n\nWeather at waypoints\nHub forecasts" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
faa [label="FAA NASSTATUS\nnasstatus.faa.gov\n\nGround stops\nGround delay programs\nClosures" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_mock {
|
||||||
|
label="SCENARIO DATA (switchable)"
|
||||||
|
color="#ffc107"
|
||||||
|
fontcolor="#ffc107"
|
||||||
|
style=rounded
|
||||||
|
|
||||||
|
sc_mgr [label="Scenario Manager\n\nnormal_ops\nweather_disruption_ord\nmaintenance_delay_sfo\ncrew_swap_ewr" fillcolor="#2a2a0d" shape=box fontcolor="#ffc107"]
|
||||||
|
|
||||||
|
flights [label="FlightData\n7 flights/scenario\nstatus · delay · gate\ncrew · passengers" fillcolor="#121829" shape=box]
|
||||||
|
crew [label="CrewMember\n~12/scenario\nduty hours · rest\nPart 117 state" fillcolor="#121829" shape=box]
|
||||||
|
pax [label="Passenger\nMP status · connections\nspecial needs" fillcolor="#121829" shape=box]
|
||||||
|
mel [label="MELItem\nsystem · restriction\nexpiry" fillcolor="#121829" shape=box]
|
||||||
|
notes [label="Crew Notes\nfree text per flight" fillcolor="#121829" shape=box]
|
||||||
|
rebook [label="RebookingCase\nurgency · next option" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_mcp {
|
||||||
|
label="MCP Tools"
|
||||||
|
color="#0066ff"
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
t_weather [label="get_route_weather\nget_hub_forecasts" fillcolor="#0d1a33" shape=box]
|
||||||
|
t_airport [label="get_airport_status\nget_airport_congestion" fillcolor="#0d1a33" shape=box]
|
||||||
|
t_flight [label="get_flight_status\nget_flight_details\nget_irregular_ops" fillcolor="#0d1a33" shape=box]
|
||||||
|
t_crew [label="get_crew_notes\nget_crew_duty_status" fillcolor="#0d1a33" shape=box]
|
||||||
|
t_maint [label="get_maintenance_flags" fillcolor="#0d1a33" shape=box]
|
||||||
|
t_pax [label="get_pending_rebookings" fillcolor="#0d1a33" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_output {
|
||||||
|
label="Agent Output"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
|
||||||
|
notif [label="Passenger\nNotification" fillcolor="#1a3a1a" shape=box fontcolor="#00c853"]
|
||||||
|
brief [label="Handover\nBrief" fillcolor="#3a1a0d" shape=box fontcolor="#ff3d00"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real data flow
|
||||||
|
openmeteo -> t_weather [color="#00c853" penwidth=2]
|
||||||
|
faa -> t_airport [color="#00c853" penwidth=2]
|
||||||
|
|
||||||
|
// Mock data flow
|
||||||
|
sc_mgr -> flights [color="#ffc107"]
|
||||||
|
sc_mgr -> crew [color="#ffc107"]
|
||||||
|
sc_mgr -> pax [color="#ffc107"]
|
||||||
|
sc_mgr -> mel [color="#ffc107"]
|
||||||
|
sc_mgr -> notes [color="#ffc107"]
|
||||||
|
sc_mgr -> rebook [color="#ffc107"]
|
||||||
|
|
||||||
|
flights -> t_flight [color="#4a5568"]
|
||||||
|
crew -> t_crew [color="#4a5568"]
|
||||||
|
notes -> t_crew [color="#4a5568"]
|
||||||
|
mel -> t_maint [color="#4a5568"]
|
||||||
|
pax -> t_pax [color="#4a5568"]
|
||||||
|
rebook -> t_pax [color="#4a5568"]
|
||||||
|
|
||||||
|
// To agents
|
||||||
|
t_weather -> notif [color="#0066ff"]
|
||||||
|
t_airport -> notif [color="#0066ff"]
|
||||||
|
t_flight -> notif [color="#0066ff"]
|
||||||
|
t_crew -> notif [color="#0066ff" style=dashed]
|
||||||
|
|
||||||
|
t_weather -> brief [color="#0066ff"]
|
||||||
|
t_airport -> brief [color="#0066ff"]
|
||||||
|
t_flight -> brief [color="#0066ff"]
|
||||||
|
t_crew -> brief [color="#0066ff"]
|
||||||
|
t_maint -> brief [color="#0066ff"]
|
||||||
|
t_pax -> brief [color="#0066ff"]
|
||||||
|
}
|
||||||
310
docs/graphs/data_flow.svg
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Generated by graphviz version 14.1.2 (0)
|
||||||
|
-->
|
||||||
|
<!-- Title: data_flow Pages: 1 -->
|
||||||
|
<svg width="661pt" height="685pt"
|
||||||
|
viewBox="0.00 0.00 661.00 685.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 681.25)">
|
||||||
|
<title>data_flow</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-681.25 657.38,-681.25 657.38,4 -4,4"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="326.69" y="-659.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">Data Flow — Real vs Mock</text>
|
||||||
|
<g id="clust1" class="cluster">
|
||||||
|
<title>cluster_real</title>
|
||||||
|
<path fill="#0a0e17" stroke="#00c853" d="M188.62,-430C188.62,-430 339.62,-430 339.62,-430 345.62,-430 351.62,-436 351.62,-442 351.62,-442 351.62,-632 351.62,-632 351.62,-638 345.62,-644 339.62,-644 339.62,-644 188.62,-644 188.62,-644 182.62,-644 176.62,-638 176.62,-632 176.62,-632 176.62,-442 176.62,-442 176.62,-436 182.62,-430 188.62,-430"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="264.12" y="-626.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#00c853">LIVE DATA (no API key)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust2" class="cluster">
|
||||||
|
<title>cluster_mock</title>
|
||||||
|
<path fill="#0a0e17" stroke="#ffc107" d="M20,-8C20,-8 325.38,-8 325.38,-8 331.38,-8 337.38,-14 337.38,-20 337.38,-20 337.38,-410 337.38,-410 337.38,-416 331.38,-422 325.38,-422 325.38,-422 20,-422 20,-422 14,-422 8,-416 8,-410 8,-410 8,-20 8,-20 8,-14 14,-8 20,-8"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="172.69" y="-404.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#ffc107">SCENARIO DATA (switchable)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust3" class="cluster">
|
||||||
|
<title>cluster_mcp</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#0066ff" points="371.62,-115 371.62,-472 522.88,-472 522.88,-115 371.62,-115"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-454.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">MCP Tools</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust4" class="cluster">
|
||||||
|
<title>cluster_output</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="542.88,-255 542.88,-386 653.38,-386 653.38,-255 542.88,-255"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="598.12" y="-368.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Agent Output</text>
|
||||||
|
</g>
|
||||||
|
<!-- openmeteo -->
|
||||||
|
<g id="node1" class="node">
|
||||||
|
<title>openmeteo</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="324.88,-509.5 202.38,-509.5 202.38,-438.5 324.88,-438.5 324.88,-509.5"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-496" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">OpenMeteo API</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-483.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">api.open-meteo.com</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-458.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">Weather at waypoints</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-445.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">Hub forecasts</text>
|
||||||
|
</g>
|
||||||
|
<!-- t_weather -->
|
||||||
|
<g id="node10" class="node">
|
||||||
|
<title>t_weather</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="501,-385 393.5,-385 393.5,-349 501,-349 501,-385"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-370.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_route_weather</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-357.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_hub_forecasts</text>
|
||||||
|
</g>
|
||||||
|
<!-- openmeteo->t_weather -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>openmeteo->t_weather</title>
|
||||||
|
<path fill="none" stroke="#00c853" stroke-width="2" d="M324.96,-446.03C334.46,-440.17 343.73,-433.47 351.62,-426 363.8,-414.47 358.58,-404.54 371.62,-394 374.84,-391.4 378.33,-389.04 381.98,-386.9"/>
|
||||||
|
<polygon fill="#00c853" stroke="#00c853" stroke-width="2" points="383.3,-390.15 390.61,-382.49 380.12,-383.92 383.3,-390.15"/>
|
||||||
|
</g>
|
||||||
|
<!-- faa -->
|
||||||
|
<g id="node2" class="node">
|
||||||
|
<title>faa</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="329,-610.88 198.25,-610.88 198.25,-527.12 329,-527.12 329,-610.88"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-597.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">FAA NASSTATUS</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-584.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">nasstatus.faa.gov</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-559.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">Ground stops</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-547.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">Ground delay programs</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-534.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">Closures</text>
|
||||||
|
</g>
|
||||||
|
<!-- t_airport -->
|
||||||
|
<g id="node11" class="node">
|
||||||
|
<title>t_airport</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="511.12,-439 383.38,-439 383.38,-403 511.12,-403 511.12,-439"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-424.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_airport_status</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-411.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_airport_congestion</text>
|
||||||
|
</g>
|
||||||
|
<!-- faa->t_airport -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>faa->t_airport</title>
|
||||||
|
<path fill="none" stroke="#00c853" stroke-width="2" d="M329.16,-534.67C336.95,-529.73 344.62,-524.46 351.62,-519 378.27,-498.22 404.65,-470.12 422.75,-449.33"/>
|
||||||
|
<polygon fill="#00c853" stroke="#00c853" stroke-width="2" points="425.37,-451.66 429.22,-441.79 420.05,-447.11 425.37,-451.66"/>
|
||||||
|
</g>
|
||||||
|
<!-- sc_mgr -->
|
||||||
|
<g id="node3" class="node">
|
||||||
|
<title>sc_mgr</title>
|
||||||
|
<polygon fill="#2a2a0d" stroke="#1e2a4a" points="148.25,-227.88 16,-227.88 16,-144.12 148.25,-144.12 148.25,-227.88"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="82.12" y="-214.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">Scenario Manager</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="82.12" y="-189.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">normal_ops</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="82.12" y="-176.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">weather_disruption_ord</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="82.12" y="-164.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">maintenance_delay_sfo</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="82.12" y="-151.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">crew_swap_ewr</text>
|
||||||
|
</g>
|
||||||
|
<!-- flights -->
|
||||||
|
<g id="node4" class="node">
|
||||||
|
<title>flights</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="320,-388.5 207.25,-388.5 207.25,-329.5 320,-329.5 320,-388.5"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-375" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">FlightData</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-362.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">7 flights/scenario</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-349.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">status · delay · gate</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-336.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">crew · passengers</text>
|
||||||
|
</g>
|
||||||
|
<!-- sc_mgr->flights -->
|
||||||
|
<g id="edge3" class="edge">
|
||||||
|
<title>sc_mgr->flights</title>
|
||||||
|
<path fill="none" stroke="#ffc107" d="M104.19,-228.06C120.6,-257.31 145.75,-295.49 176.62,-321 182.77,-326.08 189.69,-330.6 196.88,-334.59"/>
|
||||||
|
<polygon fill="#ffc107" stroke="#ffc107" points="194.96,-337.54 205.45,-339.02 198.18,-331.32 194.96,-337.54"/>
|
||||||
|
</g>
|
||||||
|
<!-- crew -->
|
||||||
|
<g id="node5" class="node">
|
||||||
|
<title>crew</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="311.38,-311.5 215.88,-311.5 215.88,-252.5 311.38,-252.5 311.38,-311.5"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-298" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">CrewMember</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-285.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">~12/scenario</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-272.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">duty hours · rest</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-259.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Part 117 state</text>
|
||||||
|
</g>
|
||||||
|
<!-- sc_mgr->crew -->
|
||||||
|
<g id="edge4" class="edge">
|
||||||
|
<title>sc_mgr->crew</title>
|
||||||
|
<path fill="none" stroke="#ffc107" d="M148.69,-227.34C157.97,-232.79 167.47,-238.15 176.62,-243 185.78,-247.84 195.65,-252.67 205.33,-257.19"/>
|
||||||
|
<polygon fill="#ffc107" stroke="#ffc107" points="203.78,-260.34 214.33,-261.34 206.71,-253.98 203.78,-260.34"/>
|
||||||
|
</g>
|
||||||
|
<!-- pax -->
|
||||||
|
<g id="node6" class="node">
|
||||||
|
<title>pax</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="329.38,-116.12 197.88,-116.12 197.88,-69.88 329.38,-69.88 329.38,-116.12"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-102.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Passenger</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-89.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">MP status · connections</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-77.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">special needs</text>
|
||||||
|
</g>
|
||||||
|
<!-- sc_mgr->pax -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>sc_mgr->pax</title>
|
||||||
|
<path fill="none" stroke="#ffc107" d="M143.83,-143.64C154.52,-136.99 165.72,-130.5 176.62,-125 180.07,-123.26 183.64,-121.57 187.27,-119.92"/>
|
||||||
|
<polygon fill="#ffc107" stroke="#ffc107" points="188.46,-123.22 196.24,-116.02 185.67,-116.8 188.46,-123.22"/>
|
||||||
|
</g>
|
||||||
|
<!-- mel -->
|
||||||
|
<g id="node7" class="node">
|
||||||
|
<title>mel</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="318.88,-180.12 208.38,-180.12 208.38,-133.88 318.88,-133.88 318.88,-180.12"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-166.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">MELItem</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-153.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">system · restriction</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-141.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">expiry</text>
|
||||||
|
</g>
|
||||||
|
<!-- sc_mgr->mel -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>sc_mgr->mel</title>
|
||||||
|
<path fill="none" stroke="#ffc107" d="M148.69,-175.41C164.41,-172.87 181.19,-170.16 196.91,-167.62"/>
|
||||||
|
<polygon fill="#ffc107" stroke="#ffc107" points="197.33,-171.09 206.65,-166.04 196.22,-164.18 197.33,-171.09"/>
|
||||||
|
</g>
|
||||||
|
<!-- notes -->
|
||||||
|
<g id="node8" class="node">
|
||||||
|
<title>notes</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="315.88,-234 211.38,-234 211.38,-198 315.88,-198 315.88,-234"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-219.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Crew Notes</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-206.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">free text per flight</text>
|
||||||
|
</g>
|
||||||
|
<!-- sc_mgr->notes -->
|
||||||
|
<g id="edge7" class="edge">
|
||||||
|
<title>sc_mgr->notes</title>
|
||||||
|
<path fill="none" stroke="#ffc107" d="M148.69,-196.96C165.37,-199.75 183.26,-202.74 199.8,-205.5"/>
|
||||||
|
<polygon fill="#ffc107" stroke="#ffc107" points="199.07,-208.93 209.51,-207.12 200.22,-202.02 199.07,-208.93"/>
|
||||||
|
</g>
|
||||||
|
<!-- rebook -->
|
||||||
|
<g id="node9" class="node">
|
||||||
|
<title>rebook</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="323.38,-52 203.88,-52 203.88,-16 323.38,-16 323.38,-52"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-37.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">RebookingCase</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="263.62" y="-24.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">urgency · next option</text>
|
||||||
|
</g>
|
||||||
|
<!-- sc_mgr->rebook -->
|
||||||
|
<g id="edge8" class="edge">
|
||||||
|
<title>sc_mgr->rebook</title>
|
||||||
|
<path fill="none" stroke="#ffc107" d="M105.49,-143.7C121.86,-116.5 146.44,-82.39 176.62,-61 181.65,-57.44 187.17,-54.33 192.9,-51.63"/>
|
||||||
|
<polygon fill="#ffc107" stroke="#ffc107" points="194.17,-54.89 202.01,-47.77 191.44,-48.45 194.17,-54.89"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_flight -->
|
||||||
|
<g id="node12" class="node">
|
||||||
|
<title>t_flight</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="497.62,-331.12 396.88,-331.12 396.88,-284.88 497.62,-284.88 497.62,-331.12"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-317.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_flight_status</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-304.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_flight_details</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-292.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_irregular_ops</text>
|
||||||
|
</g>
|
||||||
|
<!-- flights->t_flight -->
|
||||||
|
<g id="edge9" class="edge">
|
||||||
|
<title>flights->t_flight</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M320.11,-343.42C340.89,-337.58 364.61,-330.93 385.82,-324.97"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="386.52,-328.41 395.2,-322.33 384.63,-321.67 386.52,-328.41"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_crew -->
|
||||||
|
<g id="node13" class="node">
|
||||||
|
<title>t_crew</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="508.5,-267 386,-267 386,-231 508.5,-231 508.5,-267"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-252.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_crew_notes</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-239.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_crew_duty_status</text>
|
||||||
|
</g>
|
||||||
|
<!-- crew->t_crew -->
|
||||||
|
<g id="edge10" class="edge">
|
||||||
|
<title>crew->t_crew</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M311.87,-273.42C330.94,-269.95 353.36,-265.88 374.37,-262.06"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="374.83,-265.53 384.04,-260.3 373.58,-258.65 374.83,-265.53"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_pax -->
|
||||||
|
<g id="node15" class="node">
|
||||||
|
<title>t_pax</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="514.88,-159 379.62,-159 379.62,-123 514.88,-123 514.88,-159"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-137.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_pending_rebookings</text>
|
||||||
|
</g>
|
||||||
|
<!-- pax->t_pax -->
|
||||||
|
<g id="edge13" class="edge">
|
||||||
|
<title>pax->t_pax</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M329.68,-110.19C342.25,-113.51 355.53,-117.02 368.45,-120.44"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="367.17,-123.72 377.73,-122.89 368.96,-116.95 367.17,-123.72"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_maint -->
|
||||||
|
<g id="node14" class="node">
|
||||||
|
<title>t_maint</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="511.88,-213 382.62,-213 382.62,-177 511.88,-177 511.88,-213"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="447.25" y="-191.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_maintenance_flags</text>
|
||||||
|
</g>
|
||||||
|
<!-- mel->t_maint -->
|
||||||
|
<g id="edge12" class="edge">
|
||||||
|
<title>mel->t_maint</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M319.12,-168.4C335.56,-171.84 353.9,-175.68 371.42,-179.34"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="370.22,-182.67 380.72,-181.29 371.65,-175.82 370.22,-182.67"/>
|
||||||
|
</g>
|
||||||
|
<!-- notes->t_crew -->
|
||||||
|
<g id="edge11" class="edge">
|
||||||
|
<title>notes->t_crew</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M316.19,-225.37C334.25,-228.65 354.87,-232.4 374.29,-235.93"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="373.6,-239.36 384.07,-237.7 374.86,-232.47 373.6,-239.36"/>
|
||||||
|
</g>
|
||||||
|
<!-- rebook->t_pax -->
|
||||||
|
<g id="edge14" class="edge">
|
||||||
|
<title>rebook->t_pax</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M323.51,-48.73C333.18,-52.14 342.89,-56.21 351.62,-61 377.56,-75.22 402.94,-97.24 420.87,-114.58"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="418.4,-117.06 427.97,-121.6 423.32,-112.08 418.4,-117.06"/>
|
||||||
|
</g>
|
||||||
|
<!-- notif -->
|
||||||
|
<g id="node16" class="node">
|
||||||
|
<title>notif</title>
|
||||||
|
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="633.75,-353 561.5,-353 561.5,-317 633.75,-317 633.75,-353"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="597.62" y="-338.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">Passenger</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="597.62" y="-325.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">Notification</text>
|
||||||
|
</g>
|
||||||
|
<!-- t_weather->notif -->
|
||||||
|
<g id="edge15" class="edge">
|
||||||
|
<title>t_weather->notif</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M501.4,-355.54C517.32,-352.1 534.66,-348.36 550.12,-345.03"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="550.48,-348.53 559.52,-343 549.01,-341.69 550.48,-348.53"/>
|
||||||
|
</g>
|
||||||
|
<!-- brief -->
|
||||||
|
<g id="node17" class="node">
|
||||||
|
<title>brief</title>
|
||||||
|
<polygon fill="#3a1a0d" stroke="#1e2a4a" points="629.25,-299 566,-299 566,-263 629.25,-263 629.25,-299"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="597.62" y="-284.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ff3d00">Handover</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="597.62" y="-271.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ff3d00">Brief</text>
|
||||||
|
</g>
|
||||||
|
<!-- t_weather->brief -->
|
||||||
|
<g id="edge19" class="edge">
|
||||||
|
<title>t_weather->brief</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M501.19,-352.89C508.93,-349.44 516.45,-345.2 522.88,-340 535.92,-329.46 530.48,-319.29 542.88,-308 546.67,-304.54 550.99,-301.42 555.49,-298.63"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="557.15,-301.71 564.18,-293.78 553.74,-295.6 557.15,-301.71"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_airport->notif -->
|
||||||
|
<g id="edge16" class="edge">
|
||||||
|
<title>t_airport->notif</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M505.35,-402.56C511.4,-399.96 517.35,-397.1 522.88,-394 539.14,-384.88 555.52,-372.22 568.69,-360.96"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="570.9,-363.67 576.12,-354.45 566.29,-358.41 570.9,-363.67"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_airport->brief -->
|
||||||
|
<g id="edge20" class="edge">
|
||||||
|
<title>t_airport->brief</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M511.57,-403.67C515.74,-400.91 519.58,-397.71 522.88,-394 548.93,-364.66 518.6,-338.83 542.88,-308 546.46,-303.45 551.01,-299.6 555.94,-296.37"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="557.41,-299.56 564.43,-291.62 553.99,-293.45 557.41,-299.56"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_flight->notif -->
|
||||||
|
<g id="edge17" class="edge">
|
||||||
|
<title>t_flight->notif</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M498.07,-317.07C514.9,-320.13 533.61,-323.53 550.16,-326.55"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="549.28,-329.94 559.74,-328.29 550.53,-323.05 549.28,-329.94"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_flight->brief -->
|
||||||
|
<g id="edge21" class="edge">
|
||||||
|
<title>t_flight->brief</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M498.07,-298.93C516.44,-295.59 537.05,-291.84 554.66,-288.64"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="555.04,-292.12 564.25,-286.89 553.79,-285.24 555.04,-292.12"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_crew->notif -->
|
||||||
|
<g id="edge18" class="edge">
|
||||||
|
<title>t_crew->notif</title>
|
||||||
|
<path fill="none" stroke="#0066ff" stroke-dasharray="5,2" d="M508.82,-266.83C513.82,-269.5 518.59,-272.54 522.88,-276 535.92,-286.54 530.48,-296.71 542.88,-308 545.46,-310.35 548.28,-312.55 551.24,-314.59"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="549.37,-317.55 559.72,-319.8 553.03,-311.59 549.37,-317.55"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_crew->brief -->
|
||||||
|
<g id="edge22" class="edge">
|
||||||
|
<title>t_crew->brief</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M508.6,-262.02C524.05,-265.35 540.32,-268.86 554.62,-271.94"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="553.62,-275.31 564.13,-273.99 555.09,-268.46 553.62,-275.31"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_maint->brief -->
|
||||||
|
<g id="edge23" class="edge">
|
||||||
|
<title>t_maint->brief</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M505.35,-213.44C511.4,-216.04 517.35,-218.9 522.88,-222 539.14,-231.12 555.52,-243.78 568.69,-255.04"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="566.29,-257.59 576.12,-261.55 570.9,-252.33 566.29,-257.59"/>
|
||||||
|
</g>
|
||||||
|
<!-- t_pax->brief -->
|
||||||
|
<g id="edge24" class="edge">
|
||||||
|
<title>t_pax->brief</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M509.5,-159.32C514.24,-161.87 518.77,-164.75 522.88,-168 551.32,-190.53 572.34,-227.15 584.51,-252.58"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="581.18,-253.72 588.54,-261.34 587.54,-250.79 581.18,-253.72"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 21 KiB |
82
docs/graphs/deployment.dot
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
digraph deployment {
|
||||||
|
rankdir=TB
|
||||||
|
bgcolor="#0a0e17"
|
||||||
|
fontname="Helvetica"
|
||||||
|
node [fontname="Helvetica" fontsize=10 style=filled color="#1e2a4a" fontcolor="#e8eaf0"]
|
||||||
|
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
|
||||||
|
|
||||||
|
label="Deployment — Kind Cluster (dev) / EC2 (prod)"
|
||||||
|
labelloc=t
|
||||||
|
fontsize=14
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
user [label="Browser\nlocalhost:8040" fillcolor="#243056" shape=box]
|
||||||
|
|
||||||
|
subgraph cluster_kind {
|
||||||
|
label="Kind Cluster: unt (namespace: unt)"
|
||||||
|
color="#0066ff"
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
style=rounded
|
||||||
|
|
||||||
|
subgraph cluster_frontend_pod {
|
||||||
|
label="Pod: ui"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
ui [label="nginx\n:80\n\nVue SPA\nProxy → api:8000" fillcolor="#121829" shape=box]
|
||||||
|
ui_svc [label="Service: ui\nNodePort 30040" fillcolor="#0d1a33" shape=diamond fontsize=9]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_api_pod {
|
||||||
|
label="Pod: api"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
api [label="uvicorn\n:8000\n\nFastAPI\nMCP clients (stdio)\nLangGraph agents" fillcolor="#121829" shape=box]
|
||||||
|
api_svc [label="Service: api\nClusterIP" fillcolor="#0d1a33" shape=diamond fontsize=9]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_langfuse_pod {
|
||||||
|
label="Pod: langfuse"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
langfuse [label="Langfuse\n:3000\n\nTrace viewer" fillcolor="#121829" shape=box]
|
||||||
|
langfuse_svc [label="Service: langfuse\nNodePort 30030" fillcolor="#0d1a33" shape=diamond fontsize=9]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_pg_pod {
|
||||||
|
label="Pod: postgres"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
pg [label="PostgreSQL\n:5432\n\nLangfuse data" fillcolor="#121829" shape=cylinder]
|
||||||
|
pg_svc [label="Service: postgres\nClusterIP" fillcolor="#0d1a33" shape=diamond fontsize=9]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_external {
|
||||||
|
label="External APIs"
|
||||||
|
color="#00c853"
|
||||||
|
fontcolor="#00c853"
|
||||||
|
style=dashed
|
||||||
|
ext_weather [label="OpenMeteo" fillcolor="#0d2a0d" shape=octagon fontcolor="#00c853"]
|
||||||
|
ext_faa [label="FAA" fillcolor="#0d2a0d" shape=octagon fontcolor="#00c853"]
|
||||||
|
ext_bedrock [label="AWS Bedrock" fillcolor="#243056" shape=octagon]
|
||||||
|
ext_kong [label="Kong Konnect\n(optional)" fillcolor="#243056" shape=octagon style="filled,dashed"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port mappings
|
||||||
|
user -> ui_svc [label="host:8040 → 30040" color="#0066ff"]
|
||||||
|
ui_svc -> ui
|
||||||
|
ui -> api_svc [label="proxy"]
|
||||||
|
api_svc -> api
|
||||||
|
|
||||||
|
api -> ext_weather [label="HTTP" color="#00c853"]
|
||||||
|
api -> ext_faa [label="HTTP" color="#00c853"]
|
||||||
|
api -> ext_bedrock [label="Converse API" style=dashed]
|
||||||
|
api -> langfuse_svc [label="traces" style=dotted]
|
||||||
|
|
||||||
|
langfuse_svc -> langfuse
|
||||||
|
langfuse -> pg_svc
|
||||||
|
pg_svc -> pg
|
||||||
|
|
||||||
|
user -> ext_kong [style=dashed label="(optional)" color="#4a5568"]
|
||||||
|
ext_kong -> ui_svc [style=dashed color="#4a5568"]
|
||||||
|
}
|
||||||
225
docs/graphs/deployment.svg
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Generated by graphviz version 14.1.2 (0)
|
||||||
|
-->
|
||||||
|
<!-- Title: deployment Pages: 1 -->
|
||||||
|
<svg width="748pt" height="1083pt"
|
||||||
|
viewBox="0.00 0.00 748.00 1083.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1079.34)">
|
||||||
|
<title>deployment</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-1079.34 744,-1079.34 744,4 -4,4"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="370" y="-1058.04" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">Deployment — Kind Cluster (dev) / EC2 (prod)</text>
|
||||||
|
<g id="clust1" class="cluster">
|
||||||
|
<title>cluster_kind</title>
|
||||||
|
<path fill="#0a0e17" stroke="#0066ff" d="M20,-8C20,-8 260,-8 260,-8 266,-8 272,-14 272,-20 272,-20 272,-964.84 272,-964.84 272,-970.84 266,-976.84 260,-976.84 260,-976.84 20,-976.84 20,-976.84 14,-976.84 8,-970.84 8,-964.84 8,-964.84 8,-20 8,-20 8,-14 14,-8 20,-8"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="140" y="-959.54" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">Kind Cluster: unt  (namespace: unt)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust2" class="cluster">
|
||||||
|
<title>cluster_frontend_pod</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M80,-733.34C80,-733.34 252,-733.34 252,-733.34 258,-733.34 264,-739.34 264,-745.34 264,-745.34 264,-931.59 264,-931.59 264,-937.59 258,-943.59 252,-943.59 252,-943.59 80,-943.59 80,-943.59 74,-943.59 68,-937.59 68,-931.59 68,-931.59 68,-745.34 68,-745.34 68,-739.34 74,-733.34 80,-733.34"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="166" y="-926.29" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Pod: ui</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust3" class="cluster">
|
||||||
|
<title>cluster_api_pod</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M122,-481.09C122,-481.09 252,-481.09 252,-481.09 258,-481.09 264,-487.09 264,-493.09 264,-493.09 264,-692.09 264,-692.09 264,-698.09 258,-704.09 252,-704.09 252,-704.09 122,-704.09 122,-704.09 116,-704.09 110,-698.09 110,-692.09 110,-692.09 110,-493.09 110,-493.09 110,-487.09 116,-481.09 122,-481.09"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="187" y="-686.79" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Pod: api</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust4" class="cluster">
|
||||||
|
<title>cluster_langfuse_pod</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M74,-254.34C74,-254.34 252,-254.34 252,-254.34 258,-254.34 264,-260.34 264,-266.34 264,-266.34 264,-439.84 264,-439.84 264,-445.84 258,-451.84 252,-451.84 252,-451.84 74,-451.84 74,-451.84 68,-451.84 62,-445.84 62,-439.84 62,-439.84 62,-266.34 62,-266.34 62,-260.34 68,-254.34 74,-254.34"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="163" y="-434.54" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Pod: langfuse</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust5" class="cluster">
|
||||||
|
<title>cluster_pg_pod</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M72,-16C72,-16 252,-16 252,-16 258,-16 264,-22 264,-28 264,-28 264,-223.34 264,-223.34 264,-229.34 258,-235.34 252,-235.34 252,-235.34 72,-235.34 72,-235.34 66,-235.34 60,-229.34 60,-223.34 60,-223.34 60,-28 60,-28 60,-22 66,-16 72,-16"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-218.04" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Pod: postgres</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust6" class="cluster">
|
||||||
|
<title>cluster_external</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#00c853" stroke-dasharray="5,2" points="280,-354.45 280,-446.98 732,-446.98 732,-354.45 280,-354.45"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="506" y="-429.68" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#00c853">External APIs</text>
|
||||||
|
</g>
|
||||||
|
<!-- user -->
|
||||||
|
<g id="node1" class="node">
|
||||||
|
<title>user</title>
|
||||||
|
<polygon fill="#243056" stroke="#1e2a4a" points="465.62,-1050.09 378.38,-1050.09 378.38,-1014.09 465.62,-1014.09 465.62,-1050.09"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="422" y="-1035.34" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Browser</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="422" y="-1022.59" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">localhost:8040</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui_svc -->
|
||||||
|
<g id="node3" class="node">
|
||||||
|
<title>ui_svc</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="166,-910.34 75.75,-879.84 166,-849.34 256.25,-879.84 166,-910.34"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="166" y="-882.54" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">Service: ui</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="166" y="-871.29" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">NodePort 30040</text>
|
||||||
|
</g>
|
||||||
|
<!-- user->ui_svc -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>user->ui_svc</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M392.38,-1013.71C346.98,-987.06 259.97,-936 208.05,-905.52"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="209.86,-902.53 199.46,-900.48 206.31,-908.56 209.86,-902.53"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405.13" y="-987.54" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">host:8040 → 30040</text>
|
||||||
|
</g>
|
||||||
|
<!-- ext_kong -->
|
||||||
|
<g id="node13" class="node">
|
||||||
|
<title>ext_kong</title>
|
||||||
|
<polygon fill="#243056" stroke="#1e2a4a" stroke-dasharray="5,2" points="723.76,-377.47 723.76,-398.71 687,-413.73 635,-413.73 598.24,-398.71 598.24,-377.47 635,-362.45 687,-362.45 723.76,-377.47"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="661" y="-391.34" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Kong Konnect</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="661" y="-378.59" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">(optional)</text>
|
||||||
|
</g>
|
||||||
|
<!-- user->ext_kong -->
|
||||||
|
<g id="edge12" class="edge">
|
||||||
|
<title>user->ext_kong</title>
|
||||||
|
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M465.88,-1022.95C532.38,-1007.78 651,-968.46 651,-880.84 651,-880.84 651,-880.84 651,-529.97 651,-494.35 654.22,-453.8 657,-425.38"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="660.48,-425.8 658.01,-415.5 653.52,-425.1 660.48,-425.8"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="672.75" y="-714.79" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">(optional)</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui -->
|
||||||
|
<g id="node2" class="node">
|
||||||
|
<title>ui</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="229,-812.34 129,-812.34 129,-741.34 229,-741.34 229,-812.34"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="179" y="-798.84" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">nginx</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="179" y="-786.09" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">:80</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="179" y="-761.34" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Vue SPA</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="179" y="-748.59" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Proxy → api:8000</text>
|
||||||
|
</g>
|
||||||
|
<!-- api_svc -->
|
||||||
|
<g id="node5" class="node">
|
||||||
|
<title>api_svc</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="187,-670.84 118.5,-640.34 187,-609.84 255.5,-640.34 187,-670.84"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="187" y="-643.04" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">Service: api</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="187" y="-631.79" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">ClusterIP</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui->api_svc -->
|
||||||
|
<g id="edge3" class="edge">
|
||||||
|
<title>ui->api_svc</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M181.08,-740.86C182.16,-722.68 183.49,-700.38 184.61,-681.5"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="188.09,-681.96 185.19,-671.77 181.1,-681.54 188.09,-681.96"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="195.51" y="-714.79" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">proxy</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui_svc->ui -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>ui_svc->ui</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M169.69,-850.17C170.75,-841.97 171.92,-832.82 173.08,-823.86"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="176.52,-824.56 174.32,-814.19 169.57,-823.66 176.52,-824.56"/>
|
||||||
|
</g>
|
||||||
|
<!-- api -->
|
||||||
|
<g id="node4" class="node">
|
||||||
|
<title>api</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="255.75,-572.84 148.25,-572.84 148.25,-489.09 255.75,-489.09 255.75,-572.84"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="202" y="-559.34" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">uvicorn</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="202" y="-546.59" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">:8000</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="202" y="-521.84" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">FastAPI</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="202" y="-509.09" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">MCP clients (stdio)</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="202" y="-496.34" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">LangGraph agents</text>
|
||||||
|
</g>
|
||||||
|
<!-- langfuse_svc -->
|
||||||
|
<g id="node7" class="node">
|
||||||
|
<title>langfuse_svc</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="163,-418.59 69.75,-388.09 163,-357.59 256.25,-388.09 163,-418.59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="163" y="-390.79" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">Service: langfuse</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="163" y="-379.54" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">NodePort 30030</text>
|
||||||
|
</g>
|
||||||
|
<!-- api->langfuse_svc -->
|
||||||
|
<g id="edge8" class="edge">
|
||||||
|
<title>api->langfuse_svc</title>
|
||||||
|
<path fill="none" stroke="#4a5568" stroke-dasharray="1,5" d="M190.61,-488.84C185.23,-469.38 178.83,-446.29 173.56,-427.22"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="176.96,-426.4 170.92,-417.69 170.21,-428.27 176.96,-426.4"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="198.71" y="-462.54" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">traces</text>
|
||||||
|
</g>
|
||||||
|
<!-- ext_weather -->
|
||||||
|
<g id="node10" class="node">
|
||||||
|
<title>ext_weather</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="383.85,-380.64 383.85,-395.55 355.82,-406.09 316.18,-406.09 288.15,-395.55 288.15,-380.64 316.18,-370.09 355.82,-370.09 383.85,-380.64"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="336" y="-384.97" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">OpenMeteo</text>
|
||||||
|
</g>
|
||||||
|
<!-- api->ext_weather -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>api->ext_weather</title>
|
||||||
|
<path fill="none" stroke="#00c853" d="M241.12,-488.84C263.97,-464.82 292.08,-435.27 311.75,-414.59"/>
|
||||||
|
<polygon fill="#00c853" stroke="#00c853" points="314.22,-417.07 318.58,-407.41 309.15,-412.24 314.22,-417.07"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="276.23" y="-462.54" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">HTTP</text>
|
||||||
|
</g>
|
||||||
|
<!-- ext_faa -->
|
||||||
|
<g id="node11" class="node">
|
||||||
|
<title>ext_faa</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="456,-380.64 456,-395.55 440.18,-406.09 417.82,-406.09 402,-395.55 402,-380.64 417.82,-370.09 440.18,-370.09 456,-380.64"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="429" y="-384.97" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">FAA</text>
|
||||||
|
</g>
|
||||||
|
<!-- api->ext_faa -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>api->ext_faa</title>
|
||||||
|
<path fill="none" stroke="#00c853" d="M256.12,-518.06C297.44,-506.72 353.78,-486.18 393,-451.84 403.89,-442.31 412.27,-428.64 418.19,-416.5"/>
|
||||||
|
<polygon fill="#00c853" stroke="#00c853" points="421.23,-418.29 422.15,-407.74 414.84,-415.41 421.23,-418.29"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="391.29" y="-462.54" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">HTTP</text>
|
||||||
|
</g>
|
||||||
|
<!-- ext_bedrock -->
|
||||||
|
<g id="node12" class="node">
|
||||||
|
<title>ext_bedrock</title>
|
||||||
|
<polygon fill="#243056" stroke="#1e2a4a" points="580.31,-380.64 580.31,-395.55 549.08,-406.09 504.92,-406.09 473.69,-395.55 473.69,-380.64 504.92,-370.09 549.08,-370.09 580.31,-380.64"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="527" y="-384.97" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">AWS Bedrock</text>
|
||||||
|
</g>
|
||||||
|
<!-- api->ext_bedrock -->
|
||||||
|
<g id="edge7" class="edge">
|
||||||
|
<title>api->ext_bedrock</title>
|
||||||
|
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M256.09,-522.53C311.82,-512.97 399.56,-492.5 465,-451.84 480.6,-442.15 495.18,-427.78 506.29,-415.23"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="508.91,-417.54 512.74,-407.66 503.59,-413 508.91,-417.54"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="475.05" y="-462.54" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">Converse API</text>
|
||||||
|
</g>
|
||||||
|
<!-- api_svc->api -->
|
||||||
|
<g id="edge4" class="edge">
|
||||||
|
<title>api_svc->api</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M190.94,-611.13C192.09,-602.92 193.38,-593.66 194.67,-584.46"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="198.1,-585.21 196.01,-574.82 191.16,-584.24 198.1,-585.21"/>
|
||||||
|
</g>
|
||||||
|
<!-- langfuse -->
|
||||||
|
<g id="node6" class="node">
|
||||||
|
<title>langfuse</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="200.75,-320.59 123.25,-320.59 123.25,-262.34 200.75,-262.34 200.75,-320.59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-307.09" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Langfuse</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-294.34" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">:3000</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-269.59" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Trace viewer</text>
|
||||||
|
</g>
|
||||||
|
<!-- pg_svc -->
|
||||||
|
<g id="node9" class="node">
|
||||||
|
<title>pg_svc</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="162,-202.09 68,-171.59 162,-141.09 256,-171.59 162,-202.09"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-174.29" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">Service: postgres</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-163.04" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">ClusterIP</text>
|
||||||
|
</g>
|
||||||
|
<!-- langfuse->pg_svc -->
|
||||||
|
<g id="edge10" class="edge">
|
||||||
|
<title>langfuse->pg_svc</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M162,-261.93C162,-247.46 162,-229.62 162,-213.69"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="165.5,-214.02 162,-204.02 158.5,-214.02 165.5,-214.02"/>
|
||||||
|
</g>
|
||||||
|
<!-- langfuse_svc->langfuse -->
|
||||||
|
<g id="edge9" class="edge">
|
||||||
|
<title>langfuse_svc->langfuse</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M162.69,-357.41C162.6,-349.53 162.51,-340.88 162.42,-332.55"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="165.92,-332.55 162.32,-322.59 158.92,-332.62 165.92,-332.55"/>
|
||||||
|
</g>
|
||||||
|
<!-- pg -->
|
||||||
|
<g id="node8" class="node">
|
||||||
|
<title>pg</title>
|
||||||
|
<path fill="#121829" stroke="#1e2a4a" d="M204.5,-96.81C204.5,-100.83 185.45,-104.09 162,-104.09 138.55,-104.09 119.5,-100.83 119.5,-96.81 119.5,-96.81 119.5,-31.28 119.5,-31.28 119.5,-27.26 138.55,-24 162,-24 185.45,-24 204.5,-27.26 204.5,-31.28 204.5,-31.28 204.5,-96.81 204.5,-96.81"/>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M204.5,-96.81C204.5,-92.79 185.45,-89.53 162,-89.53 138.55,-89.53 119.5,-92.79 119.5,-96.81"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-79.67" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">PostgreSQL</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-66.92" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">:5432</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="162" y="-42.17" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Langfuse data</text>
|
||||||
|
</g>
|
||||||
|
<!-- pg_svc->pg -->
|
||||||
|
<g id="edge11" class="edge">
|
||||||
|
<title>pg_svc->pg</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M162,-140.63C162,-132.92 162,-124.41 162,-115.96"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="165.5,-116.07 162,-106.07 158.5,-116.07 165.5,-116.07"/>
|
||||||
|
</g>
|
||||||
|
<!-- ext_kong->ui_svc -->
|
||||||
|
<g id="edge13" class="edge">
|
||||||
|
<title>ext_kong->ui_svc</title>
|
||||||
|
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M640.9,-413.88C621.16,-440.78 594,-485.82 594,-529.97 594,-777.84 594,-777.84 594,-777.84 594,-846.35 383.67,-868.44 257.9,-875.53"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="258.06,-872.01 248.27,-876.05 258.44,-879 258.06,-872.01"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 17 KiB |
85
docs/graphs/efhas_agent.dot
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
digraph efhas_agent {
|
||||||
|
rankdir=TB
|
||||||
|
bgcolor="#0a0e17"
|
||||||
|
fontname="Helvetica"
|
||||||
|
node [fontname="Helvetica" fontsize=10 style=filled color="#1e2a4a" fontcolor="#e8eaf0"]
|
||||||
|
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
|
||||||
|
|
||||||
|
label="FCE Agent — Behind Every Departure"
|
||||||
|
labelloc=t
|
||||||
|
fontsize=14
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
start [label="START" fillcolor="#0066ff" shape=circle fontcolor="white" width=0.5]
|
||||||
|
end_skip [label="END\n(no notification)" fillcolor="#4a5568" shape=doublecircle fontcolor="#e8eaf0" width=0.6]
|
||||||
|
end_done [label="END" fillcolor="#00c853" shape=doublecircle fontcolor="white" width=0.5]
|
||||||
|
|
||||||
|
subgraph cluster_triage {
|
||||||
|
label="Node: triage"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
triage [label="get_flight_status()\n\ndelay < 10min → skip\nON_TIME → skip" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_gather {
|
||||||
|
label="Node: gather_context (parallel)"
|
||||||
|
color="#0066ff"
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
gather_label [label="" shape=point width=0]
|
||||||
|
|
||||||
|
subgraph cluster_gather_tools {
|
||||||
|
rank=same
|
||||||
|
g1 [label="get_flight_\ndetails\n[shared]" fillcolor="#0d1a33" shape=box]
|
||||||
|
g2 [label="get_route_\nweather\n[shared] ★ LIVE" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
g3 [label="get_airport_\nstatus\n[shared] ★ LIVE" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
g4 [label="get_airport_\ncongestion\n[shared] ★ HYBRID" fillcolor="#0d2a0d" shape=box fontcolor="#ffc107"]
|
||||||
|
g5 [label="get_crew_\nnotes\n[ops]" fillcolor="#0d1a33" shape=box]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_synth {
|
||||||
|
label="Node: synthesize"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
synth [label="generate_notification()\n[passenger server]\n\nContext → empathetic text\nMissing data → omit section" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_format {
|
||||||
|
label="Node: format_output"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
format [label="Structure notification JSON\nAttach data sources\nRecord timing" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_review {
|
||||||
|
label="Node: human_review"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
review [label="Human approval gate\n(auto-approve in demo)" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow
|
||||||
|
start -> triage
|
||||||
|
|
||||||
|
triage -> end_skip [label="should_notify = false" style=dashed color="#ff3d00" fontcolor="#ff3d00"]
|
||||||
|
triage -> gather_label [label="should_notify = true"]
|
||||||
|
|
||||||
|
gather_label -> g1 [color="#0066ff"]
|
||||||
|
gather_label -> g2 [color="#0066ff"]
|
||||||
|
gather_label -> g3 [color="#0066ff"]
|
||||||
|
gather_label -> g4 [color="#0066ff"]
|
||||||
|
gather_label -> g5 [color="#0066ff"]
|
||||||
|
|
||||||
|
g1 -> synth [style=invis]
|
||||||
|
g2 -> synth [style=invis]
|
||||||
|
g3 -> synth [style=invis]
|
||||||
|
g4 -> synth [style=invis]
|
||||||
|
g5 -> synth [style=invis]
|
||||||
|
|
||||||
|
{g1 g2 g3 g4 g5} -> synth [label="" color="#4a5568" constraint=true]
|
||||||
|
|
||||||
|
synth -> format
|
||||||
|
format -> review
|
||||||
|
review -> end_done
|
||||||
|
}
|
||||||
245
docs/graphs/efhas_agent.svg
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Generated by graphviz version 14.1.2 (0)
|
||||||
|
-->
|
||||||
|
<!-- Title: efhas_agent Pages: 1 -->
|
||||||
|
<svg width="696pt" height="932pt"
|
||||||
|
viewBox="0.00 0.00 696.00 932.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 927.94)">
|
||||||
|
<title>efhas_agent</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-927.94 691.94,-927.94 691.94,4 -4,4"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="343.97" y="-906.64" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">FCE Agent — Behind Every Departure</text>
|
||||||
|
<g id="clust1" class="cluster">
|
||||||
|
<title>cluster_triage</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="181.94,-715.9 181.94,-815.4 319.94,-815.4 319.94,-715.9 181.94,-715.9"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="250.94" y="-798.1" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Node: triage</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust2" class="cluster">
|
||||||
|
<title>cluster_gather</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#0066ff" points="147.94,-414.28 147.94,-640.33 679.94,-640.33 679.94,-414.28 147.94,-414.28"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="413.94" y="-623.03" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">Node: gather_context  (parallel)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust3" class="cluster">
|
||||||
|
<title>cluster_gather_tools</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#0066ff" points="155.94,-422.28 155.94,-509.78 671.94,-509.78 671.94,-422.28 155.94,-422.28"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="413.94" y="-492.48" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">Node: gather_context  (parallel)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust4" class="cluster">
|
||||||
|
<title>cluster_synth</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="321.94,-285.03 321.94,-397.28 491.94,-397.28 491.94,-285.03 321.94,-285.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-379.98" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Node: synthesize</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust5" class="cluster">
|
||||||
|
<title>cluster_format</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="325.94,-178.53 325.94,-266.03 487.94,-266.03 487.94,-178.53 325.94,-178.53"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-248.73" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Node: format_output</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust6" class="cluster">
|
||||||
|
<title>cluster_review</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="325.94,-82.28 325.94,-159.53 487.94,-159.53 487.94,-82.28 325.94,-82.28"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-142.23" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Node: human_review</text>
|
||||||
|
</g>
|
||||||
|
<!-- start -->
|
||||||
|
<g id="node1" class="node">
|
||||||
|
<title>start</title>
|
||||||
|
<ellipse fill="#0066ff" stroke="#1e2a4a" cx="250.94" cy="-870.55" rx="28.15" ry="28.15"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="250.94" y="-867.42" font-family="Helvetica,sans-Serif" font-size="10.00" fill="white">START</text>
|
||||||
|
</g>
|
||||||
|
<!-- triage -->
|
||||||
|
<g id="node4" class="node">
|
||||||
|
<title>triage</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="312.19,-782.15 189.69,-782.15 189.69,-723.9 312.19,-723.9 312.19,-782.15"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="250.94" y="-768.65" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_flight_status()</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="250.94" y="-743.9" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">delay < 10min → skip</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="250.94" y="-731.15" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ON_TIME → skip</text>
|
||||||
|
</g>
|
||||||
|
<!-- start->triage -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>start->triage</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M250.94,-842.17C250.94,-827.68 250.94,-809.61 250.94,-793.62"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="254.44,-793.93 250.94,-783.93 247.44,-793.93 254.44,-793.93"/>
|
||||||
|
</g>
|
||||||
|
<!-- end_skip -->
|
||||||
|
<g id="node2" class="node">
|
||||||
|
<title>end_skip</title>
|
||||||
|
<ellipse fill="#4a5568" stroke="#1e2a4a" cx="69.94" cy="-606.72" rx="65.94" ry="65.94"/>
|
||||||
|
<ellipse fill="none" stroke="#1e2a4a" cx="69.94" cy="-606.72" rx="69.94" ry="69.94"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="69.94" y="-609.97" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">END</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="69.94" y="-597.22" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">(no notification)</text>
|
||||||
|
</g>
|
||||||
|
<!-- end_done -->
|
||||||
|
<g id="node3" class="node">
|
||||||
|
<title>end_done</title>
|
||||||
|
<ellipse fill="#00c853" stroke="#1e2a4a" cx="406.94" cy="-26.64" rx="22.64" ry="22.64"/>
|
||||||
|
<ellipse fill="none" stroke="#1e2a4a" cx="406.94" cy="-26.64" rx="26.64" ry="26.64"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-23.51" font-family="Helvetica,sans-Serif" font-size="10.00" fill="white">END</text>
|
||||||
|
</g>
|
||||||
|
<!-- triage->end_skip -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>triage->end_skip</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" stroke-dasharray="5,2" d="M210.23,-723.52C201.94,-717.7 193.28,-711.61 185.19,-705.9 166.82,-692.95 161.37,-690.84 143.94,-676.65 138.88,-672.53 133.73,-668.16 128.62,-663.69"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="131.02,-661.14 121.22,-657.11 126.37,-666.37 131.02,-661.14"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="232.06" y="-697.35" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#ff3d00">should_notify = false</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_label -->
|
||||||
|
<g id="node5" class="node">
|
||||||
|
<title>gather_label</title>
|
||||||
|
<ellipse fill="#1e2a4a" stroke="#1e2a4a" cx="350.94" cy="-606.72" rx="0.36" ry="0.36"/>
|
||||||
|
</g>
|
||||||
|
<!-- triage->gather_label -->
|
||||||
|
<g id="edge3" class="edge">
|
||||||
|
<title>triage->gather_label</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M270.69,-723.52C293.55,-690.53 329.93,-638.04 344.55,-616.94"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="347.22,-619.23 350.04,-609.01 341.47,-615.24 347.22,-619.23"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="335.17" y="-697.35" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">should_notify = true</text>
|
||||||
|
</g>
|
||||||
|
<!-- g1 -->
|
||||||
|
<g id="node6" class="node">
|
||||||
|
<title>g1</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="231.44,-476.53 164.44,-476.53 164.44,-430.28 231.44,-430.28 231.44,-476.53"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="197.94" y="-463.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_flight_</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="197.94" y="-450.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">details</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="197.94" y="-437.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">[shared]</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_label->g1 -->
|
||||||
|
<g id="edge4" class="edge">
|
||||||
|
<title>gather_label->g1</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M350.89,-605.68C348.9,-604.19 284.79,-556.2 240.94,-509.78 233.97,-502.4 226.98,-493.95 220.72,-485.92"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="223.51,-483.81 214.67,-477.97 217.94,-488.05 223.51,-483.81"/>
|
||||||
|
</g>
|
||||||
|
<!-- g2 -->
|
||||||
|
<g id="node7" class="node">
|
||||||
|
<title>g2</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="342.19,-476.53 249.69,-476.53 249.69,-430.28 342.19,-430.28 342.19,-476.53"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="295.94" y="-463.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">get_route_</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="295.94" y="-450.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">weather</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="295.94" y="-437.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">[shared] ★ LIVE</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_label->g2 -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>gather_label->g2</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M350.9,-605.61C349.74,-602.43 323.75,-530.93 307.93,-487.41"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="311.28,-486.36 304.57,-478.16 304.7,-488.76 311.28,-486.36"/>
|
||||||
|
</g>
|
||||||
|
<!-- g3 -->
|
||||||
|
<g id="node8" class="node">
|
||||||
|
<title>g3</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="453.19,-476.53 360.69,-476.53 360.69,-430.28 453.19,-430.28 453.19,-476.53"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-463.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">get_airport_</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-450.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">status</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-437.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">[shared] ★ LIVE</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_label->g3 -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>gather_label->g3</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M350.98,-605.61C352.15,-602.43 378.62,-530.93 394.72,-487.41"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="397.96,-488.75 398.15,-478.16 391.39,-486.32 397.96,-488.75"/>
|
||||||
|
</g>
|
||||||
|
<!-- g4 -->
|
||||||
|
<g id="node9" class="node">
|
||||||
|
<title>g4</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="580.44,-476.53 471.44,-476.53 471.44,-430.28 580.44,-430.28 580.44,-476.53"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="525.94" y="-463.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">get_airport_</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="525.94" y="-450.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">congestion</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="525.94" y="-437.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">[shared] ★ HYBRID</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_label->g4 -->
|
||||||
|
<g id="edge7" class="edge">
|
||||||
|
<title>gather_label->g4</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M350.98,-605.68C352.86,-604.05 413.39,-551.76 461.94,-509.78 471.55,-501.46 481.98,-492.44 491.57,-484.15"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="493.68,-486.95 498.95,-477.76 489.1,-481.66 493.68,-486.95"/>
|
||||||
|
</g>
|
||||||
|
<!-- g5 -->
|
||||||
|
<g id="node10" class="node">
|
||||||
|
<title>g5</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="663.69,-476.53 598.19,-476.53 598.19,-430.28 663.69,-430.28 663.69,-476.53"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="630.94" y="-463.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_crew_</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="630.94" y="-450.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">notes</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="630.94" y="-437.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">[ops]</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_label->g5 -->
|
||||||
|
<g id="edge8" class="edge">
|
||||||
|
<title>gather_label->g5</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M351.05,-605.7C355.56,-604.89 500.76,-578.64 589.94,-509.78 598.26,-503.35 605.7,-494.81 611.89,-486.36"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="614.69,-488.45 617.46,-478.23 608.92,-484.5 614.69,-488.45"/>
|
||||||
|
</g>
|
||||||
|
<!-- synth -->
|
||||||
|
<g id="node11" class="node">
|
||||||
|
<title>synth</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="483.56,-364.03 330.31,-364.03 330.31,-293.03 483.56,-293.03 483.56,-364.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-350.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">generate_notification()</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-337.78" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">[passenger server]</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-313.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Context → empathetic text</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-300.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Missing data → omit section</text>
|
||||||
|
</g>
|
||||||
|
<!-- g1->synth -->
|
||||||
|
<!-- g1->synth -->
|
||||||
|
<g id="edge14" class="edge">
|
||||||
|
<title>g1->synth</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M229.87,-429.9C236.34,-424.46 243.18,-418.89 249.94,-414.28 274.01,-397.86 301.68,-382.5 327.06,-369.5"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="328.34,-372.77 335.68,-365.13 325.17,-366.53 328.34,-372.77"/>
|
||||||
|
</g>
|
||||||
|
<!-- g2->synth -->
|
||||||
|
<!-- g2->synth -->
|
||||||
|
<g id="edge15" class="edge">
|
||||||
|
<title>g2->synth</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M321.66,-429.87C337.31,-414 357.33,-392.27 374.09,-373.08"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="376.64,-375.47 380.54,-365.62 371.35,-370.89 376.64,-375.47"/>
|
||||||
|
</g>
|
||||||
|
<!-- g3->synth -->
|
||||||
|
<!-- g3->synth -->
|
||||||
|
<g id="edge16" class="edge">
|
||||||
|
<title>g3->synth</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M412.3,-429.87C413.56,-414.79 413.96,-394.41 413.51,-375.95"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="417,-375.85 413.15,-365.98 410.01,-376.1 417,-375.85"/>
|
||||||
|
</g>
|
||||||
|
<!-- g4->synth -->
|
||||||
|
<!-- g4->synth -->
|
||||||
|
<g id="edge17" class="edge">
|
||||||
|
<title>g4->synth</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M509.48,-429.87C495.31,-413.86 474.39,-391.87 455.09,-372.56"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="457.57,-370.1 448.01,-365.54 452.64,-375.07 457.57,-370.1"/>
|
||||||
|
</g>
|
||||||
|
<!-- g5->synth -->
|
||||||
|
<!-- g5->synth -->
|
||||||
|
<g id="edge18" class="edge">
|
||||||
|
<title>g5->synth</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M618.14,-430.06C612.46,-424.5 605.72,-418.83 598.94,-414.28 566.8,-392.7 528.29,-373.95 494.18,-359.61"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="495.72,-356.46 485.14,-355.88 493.05,-362.93 495.72,-356.46"/>
|
||||||
|
</g>
|
||||||
|
<!-- format -->
|
||||||
|
<g id="node12" class="node">
|
||||||
|
<title>format</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="479.44,-232.78 334.44,-232.78 334.44,-186.53 479.44,-186.53 479.44,-232.78"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-219.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Structure notification JSON</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-206.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Attach data sources</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-193.78" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Record timing</text>
|
||||||
|
</g>
|
||||||
|
<!-- synth->format -->
|
||||||
|
<g id="edge19" class="edge">
|
||||||
|
<title>synth->format</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M406.94,-292.78C406.94,-277.49 406.94,-259.59 406.94,-244.41"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="410.44,-244.44 406.94,-234.44 403.44,-244.44 410.44,-244.44"/>
|
||||||
|
</g>
|
||||||
|
<!-- review -->
|
||||||
|
<g id="node13" class="node">
|
||||||
|
<title>review</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="472.69,-126.28 341.19,-126.28 341.19,-90.28 472.69,-90.28 472.69,-126.28"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-111.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Human approval gate</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="406.94" y="-98.78" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">(auto-approve in demo)</text>
|
||||||
|
</g>
|
||||||
|
<!-- format->review -->
|
||||||
|
<g id="edge20" class="edge">
|
||||||
|
<title>format->review</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M406.94,-186.1C406.94,-171.82 406.94,-153.23 406.94,-137.92"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="410.44,-137.99 406.94,-127.99 403.44,-137.99 410.44,-137.99"/>
|
||||||
|
</g>
|
||||||
|
<!-- review->end_done -->
|
||||||
|
<g id="edge21" class="edge">
|
||||||
|
<title>review->end_done</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M406.94,-90C406.94,-82.62 406.94,-73.7 406.94,-64.94"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="410.44,-65.18 406.94,-55.18 403.44,-65.18 410.44,-65.18"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
101
docs/graphs/handover_agent.dot
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
digraph handover_agent {
|
||||||
|
rankdir=TB
|
||||||
|
bgcolor="#0a0e17"
|
||||||
|
fontname="Helvetica"
|
||||||
|
node [fontname="Helvetica" fontsize=10 style=filled color="#1e2a4a" fontcolor="#e8eaf0"]
|
||||||
|
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
|
||||||
|
|
||||||
|
label="Shift Handover Agent"
|
||||||
|
labelloc=t
|
||||||
|
fontsize=14
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
start [label="START" fillcolor="#0066ff" shape=circle fontcolor="white" width=0.5]
|
||||||
|
end_done [label="END" fillcolor="#00c853" shape=doublecircle fontcolor="white" width=0.5]
|
||||||
|
|
||||||
|
subgraph cluster_gather {
|
||||||
|
label="Node: gather_all (parallel across 5 hubs)"
|
||||||
|
color="#0066ff"
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
gather_point [label="" shape=point width=0]
|
||||||
|
|
||||||
|
subgraph cluster_per_hub {
|
||||||
|
label="Per hub (ORD, EWR, IAH, SFO, DEN)"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
h1 [label="get_irregular_ops\n[shared]" fillcolor="#0d1a33" shape=box]
|
||||||
|
h2 [label="get_airport_status\n[shared] ★ LIVE" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
h3 [label="get_pending_rebookings\n[ops]" fillcolor="#0d1a33" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_global {
|
||||||
|
label="Global"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
g1 [label="get_hub_forecasts\n[shared] ★ LIVE" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
g2 [label="get_crew_duty_status\n[ops]" fillcolor="#0d1a33" shape=box]
|
||||||
|
g3 [label="get_maintenance_flags\n[shared]" fillcolor="#0d1a33" shape=box]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_triage {
|
||||||
|
label="Node: triage"
|
||||||
|
color="#ff3d00"
|
||||||
|
fontcolor="#ff3d00"
|
||||||
|
|
||||||
|
triage [label="Score each issue:\nseverity × time_sensitivity\n\nCategorize:\nIMMEDIATE (score > 50)\nMONITOR (score > 20)\nFYI (rest)" fillcolor="#121829" shape=box]
|
||||||
|
|
||||||
|
subgraph cluster_items {
|
||||||
|
rank=same
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
label="Triage Categories"
|
||||||
|
imm [label="IMMEDIATE\n▶ crew limits\n▶ cancellations\n▶ ground stops" fillcolor="#3a0d0d" shape=box fontcolor="#ff3d00"]
|
||||||
|
mon [label="MONITOR\n⚠ weather risk\n⚠ GDP active\n⚠ MEL restrictions" fillcolor="#3a2a0d" shape=box fontcolor="#ffc107"]
|
||||||
|
fyi [label="FYI\nℹ nominal hubs\nℹ resolved items" fillcolor="#1a1a2a" shape=box fontcolor="#8892a8"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_synth {
|
||||||
|
label="Node: synthesize"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
synth [label="generate_narrative()\n[ops server]\n\nStructured brief:\nheader → immediate → monitor → fyi" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_format {
|
||||||
|
label="Node: format_output"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
format [label="Structure brief JSON\nStore as ops://handover/latest\nRecord timing" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow
|
||||||
|
start -> gather_point
|
||||||
|
|
||||||
|
gather_point -> h1 [color="#0066ff"]
|
||||||
|
gather_point -> h2 [color="#0066ff"]
|
||||||
|
gather_point -> h3 [color="#0066ff"]
|
||||||
|
gather_point -> g1 [color="#0066ff"]
|
||||||
|
gather_point -> g2 [color="#0066ff"]
|
||||||
|
gather_point -> g3 [color="#0066ff"]
|
||||||
|
|
||||||
|
h1 -> triage [color="#4a5568"]
|
||||||
|
h2 -> triage [color="#4a5568"]
|
||||||
|
h3 -> triage [color="#4a5568"]
|
||||||
|
g1 -> triage [color="#4a5568"]
|
||||||
|
g2 -> triage [color="#4a5568"]
|
||||||
|
g3 -> triage [color="#4a5568"]
|
||||||
|
|
||||||
|
triage -> imm [style=invis]
|
||||||
|
triage -> mon [style=invis]
|
||||||
|
triage -> fyi [style=invis]
|
||||||
|
|
||||||
|
imm -> synth [color="#ff3d00"]
|
||||||
|
mon -> synth [color="#ffc107"]
|
||||||
|
fyi -> synth [color="#8892a8"]
|
||||||
|
|
||||||
|
synth -> format
|
||||||
|
format -> end_done
|
||||||
|
}
|
||||||
274
docs/graphs/handover_agent.svg
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Generated by graphviz version 14.1.2 (0)
|
||||||
|
-->
|
||||||
|
<!-- Title: handover_agent Pages: 1 -->
|
||||||
|
<svg width="851pt" height="776pt"
|
||||||
|
viewBox="0.00 0.00 851.00 776.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 771.79)">
|
||||||
|
<title>handover_agent</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-771.79 847,-771.79 847,4 -4,4"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="421.5" y="-750.49" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">Shift Handover Agent</text>
|
||||||
|
<g id="clust1" class="cluster">
|
||||||
|
<title>cluster_gather</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#0066ff" points="8,-551.03 8,-678.25 835,-678.25 835,-551.03 8,-551.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="421.5" y="-660.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">Node: gather_all  (parallel across 5 hubs)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust2" class="cluster">
|
||||||
|
<title>cluster_per_hub</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="16,-559.03 16,-636.28 409,-636.28 409,-559.03 16,-559.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="212.5" y="-618.98" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Per hub (ORD, EWR, IAH, SFO, DEN)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust3" class="cluster">
|
||||||
|
<title>cluster_global</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="417,-559.03 417,-636.28 827,-636.28 827,-559.03 417,-559.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="622" y="-618.98" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Global</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust4" class="cluster">
|
||||||
|
<title>cluster_triage</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#ff3d00" points="226,-297.03 226,-543.03 588,-543.03 588,-297.03 226,-297.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="407" y="-525.73" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#ff3d00">Node: triage</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust5" class="cluster">
|
||||||
|
<title>cluster_items</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="234,-305.03 234,-405.28 580,-405.28 580,-305.03 234,-305.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="407" y="-387.98" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Triage Categories</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust6" class="cluster">
|
||||||
|
<title>cluster_synth</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="299,-176.78 299,-289.03 511,-289.03 511,-176.78 299,-176.78"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-271.73" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Node: synthesize</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust7" class="cluster">
|
||||||
|
<title>cluster_format</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" points="316,-81.28 316,-168.78 494,-168.78 494,-81.28 316,-81.28"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-151.48" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Node: format_output</text>
|
||||||
|
</g>
|
||||||
|
<!-- start -->
|
||||||
|
<g id="node1" class="node">
|
||||||
|
<title>start</title>
|
||||||
|
<ellipse fill="#0066ff" stroke="#1e2a4a" cx="405" cy="-714.39" rx="28.15" ry="28.15"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-711.27" font-family="Helvetica,sans-Serif" font-size="10.00" fill="white">START</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_point -->
|
||||||
|
<g id="node3" class="node">
|
||||||
|
<title>gather_point</title>
|
||||||
|
<ellipse fill="#1e2a4a" stroke="#1e2a4a" cx="405" cy="-644.64" rx="0.36" ry="0.36"/>
|
||||||
|
</g>
|
||||||
|
<!-- start->gather_point -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>start->gather_point</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M405,-685.83C405,-675.75 405,-664.9 405,-657.02"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="408.5,-657.21 405,-647.21 401.5,-657.21 408.5,-657.21"/>
|
||||||
|
</g>
|
||||||
|
<!-- end_done -->
|
||||||
|
<g id="node2" class="node">
|
||||||
|
<title>end_done</title>
|
||||||
|
<ellipse fill="#00c853" stroke="#1e2a4a" cx="405" cy="-26.64" rx="22.64" ry="22.64"/>
|
||||||
|
<ellipse fill="none" stroke="#1e2a4a" cx="405" cy="-26.64" rx="26.64" ry="26.64"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-23.51" font-family="Helvetica,sans-Serif" font-size="10.00" fill="white">END</text>
|
||||||
|
</g>
|
||||||
|
<!-- h1 -->
|
||||||
|
<g id="node4" class="node">
|
||||||
|
<title>h1</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="124.38,-603.03 23.62,-603.03 23.62,-567.03 124.38,-567.03 124.38,-603.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="74" y="-588.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_irregular_ops</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="74" y="-575.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">[shared]</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_point->h1 -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>gather_point->h1</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M404.3,-644.29C390.03,-644.43 161.28,-646.65 134,-636.28 120.11,-631 107.14,-621.06 96.83,-611.4"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="99.48,-609.1 89.92,-604.53 94.54,-614.06 99.48,-609.1"/>
|
||||||
|
</g>
|
||||||
|
<!-- h2 -->
|
||||||
|
<g id="node5" class="node">
|
||||||
|
<title>h2</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="247.25,-603.03 142.75,-603.03 142.75,-567.03 247.25,-567.03 247.25,-603.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="195" y="-588.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">get_airport_status</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="195" y="-575.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">[shared] ★ LIVE</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_point->h2 -->
|
||||||
|
<g id="edge3" class="edge">
|
||||||
|
<title>gather_point->h2</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M404.62,-644.27C396.77,-644.15 270.97,-642.11 256,-636.28 242.02,-630.83 228.85,-620.87 218.35,-611.24"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="220.9,-608.84 211.28,-604.39 216.03,-613.86 220.9,-608.84"/>
|
||||||
|
</g>
|
||||||
|
<!-- h3 -->
|
||||||
|
<g id="node6" class="node">
|
||||||
|
<title>h3</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="400.62,-603.03 265.38,-603.03 265.38,-567.03 400.62,-567.03 400.62,-603.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="333" y="-588.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_pending_rebookings</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="333" y="-575.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">[ops]</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_point->h3 -->
|
||||||
|
<g id="edge4" class="edge">
|
||||||
|
<title>gather_point->h3</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M404.79,-644.11C402.53,-642.28 381.98,-625.66 363.45,-610.66"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="365.75,-608.03 355.78,-604.46 361.35,-613.47 365.75,-608.03"/>
|
||||||
|
</g>
|
||||||
|
<!-- g1 -->
|
||||||
|
<g id="node7" class="node">
|
||||||
|
<title>g1</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="530.62,-603.03 425.38,-603.03 425.38,-567.03 530.62,-567.03 530.62,-603.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="478" y="-588.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">get_hub_forecasts</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="478" y="-575.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">[shared] ★ LIVE</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_point->g1 -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>gather_point->g1</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M405.21,-644.11C407.51,-642.28 428.34,-625.66 447.13,-610.66"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="449.28,-613.42 454.91,-604.45 444.91,-607.95 449.28,-613.42"/>
|
||||||
|
</g>
|
||||||
|
<!-- g2 -->
|
||||||
|
<g id="node8" class="node">
|
||||||
|
<title>g2</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="671.25,-603.03 548.75,-603.03 548.75,-567.03 671.25,-567.03 671.25,-603.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="610" y="-588.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_crew_duty_status</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="610" y="-575.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">[ops]</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_point->g2 -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>gather_point->g2</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M405.68,-644.29C414.83,-644.44 513,-645.85 540,-636.28 555.73,-630.7 571.04,-620.44 583.32,-610.64"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="585.48,-613.39 590.9,-604.29 580.99,-608.02 585.48,-613.39"/>
|
||||||
|
</g>
|
||||||
|
<!-- g3 -->
|
||||||
|
<g id="node9" class="node">
|
||||||
|
<title>g3</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="818.62,-603.03 689.38,-603.03 689.38,-567.03 818.62,-567.03 818.62,-603.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="754" y="-588.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_maintenance_flags</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="754" y="-575.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">[shared]</text>
|
||||||
|
</g>
|
||||||
|
<!-- gather_point->g3 -->
|
||||||
|
<g id="edge7" class="edge">
|
||||||
|
<title>gather_point->g3</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M405.71,-644.28C420.16,-644.36 651.82,-645.39 680,-636.28 696.65,-630.89 712.97,-620.52 726.04,-610.59"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="728.1,-613.42 733.75,-604.45 723.74,-607.94 728.1,-613.42"/>
|
||||||
|
</g>
|
||||||
|
<!-- triage -->
|
||||||
|
<g id="node10" class="node">
|
||||||
|
<title>triage</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="479.38,-509.78 330.62,-509.78 330.62,-413.28 479.38,-413.28 479.38,-509.78"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-496.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Score each issue:</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-483.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">severity × time_sensitivity</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-458.78" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Categorize:</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-446.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">IMMEDIATE (score > 50)</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-433.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">MONITOR (score > 20)</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-420.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">FYI (rest)</text>
|
||||||
|
</g>
|
||||||
|
<!-- h1->triage -->
|
||||||
|
<g id="edge8" class="edge">
|
||||||
|
<title>h1->triage</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M103.05,-566.62C112.7,-561.22 123.64,-555.52 134,-551.03 194.43,-524.82 265.27,-501.94 319.36,-486.02"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="320.22,-489.41 328.84,-483.25 318.26,-482.69 320.22,-489.41"/>
|
||||||
|
</g>
|
||||||
|
<!-- h2->triage -->
|
||||||
|
<g id="edge9" class="edge">
|
||||||
|
<title>h2->triage</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M225.09,-566.62C250.07,-552.17 286.85,-530.89 320.66,-511.33"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="322.36,-514.39 329.26,-506.35 318.85,-508.33 322.36,-514.39"/>
|
||||||
|
</g>
|
||||||
|
<!-- h3->triage -->
|
||||||
|
<g id="edge10" class="edge">
|
||||||
|
<title>h3->triage</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M343.32,-566.62C350.7,-554.16 361.09,-536.63 371.24,-519.5"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="373.97,-521.75 376.06,-511.37 367.95,-518.19 373.97,-521.75"/>
|
||||||
|
</g>
|
||||||
|
<!-- g1->triage -->
|
||||||
|
<g id="edge11" class="edge">
|
||||||
|
<title>g1->triage</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M467.54,-566.62C460.06,-554.16 449.52,-536.63 439.23,-519.5"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="442.49,-518.13 434.34,-511.36 436.49,-521.74 442.49,-518.13"/>
|
||||||
|
</g>
|
||||||
|
<!-- g2->triage -->
|
||||||
|
<g id="edge12" class="edge">
|
||||||
|
<title>g2->triage</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M580.63,-566.62C556.75,-552.47 521.82,-531.76 489.38,-512.54"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="491.55,-509.76 481.17,-507.67 487.98,-515.78 491.55,-509.76"/>
|
||||||
|
</g>
|
||||||
|
<!-- g3->triage -->
|
||||||
|
<g id="edge13" class="edge">
|
||||||
|
<title>g3->triage</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M717.06,-566.61C705.33,-561.34 692.22,-555.7 680,-551.03 617.42,-527.09 545.27,-504.03 490.57,-487.46"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="491.91,-484.2 481.32,-484.67 489.88,-490.91 491.91,-484.2"/>
|
||||||
|
</g>
|
||||||
|
<!-- imm -->
|
||||||
|
<g id="node11" class="node">
|
||||||
|
<title>imm</title>
|
||||||
|
<polygon fill="#3a0d0d" stroke="#1e2a4a" points="333.5,-372.03 242.5,-372.03 242.5,-313.03 333.5,-313.03 333.5,-372.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="288" y="-358.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ff3d00">IMMEDIATE</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="288" y="-345.78" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ff3d00">▶ crew limits</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="288" y="-333.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ff3d00">▶ cancellations</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="288" y="-320.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ff3d00">▶ ground stops</text>
|
||||||
|
</g>
|
||||||
|
<!-- triage->imm -->
|
||||||
|
<!-- mon -->
|
||||||
|
<g id="node12" class="node">
|
||||||
|
<title>mon</title>
|
||||||
|
<polygon fill="#3a2a0d" stroke="#1e2a4a" points="458,-372.03 352,-372.03 352,-313.03 458,-313.03 458,-372.03"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-358.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">MONITOR</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-345.78" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">⚠ weather risk</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-333.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">⚠ GDP active</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-320.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">⚠ MEL restrictions</text>
|
||||||
|
</g>
|
||||||
|
<!-- triage->mon -->
|
||||||
|
<!-- fyi -->
|
||||||
|
<g id="node13" class="node">
|
||||||
|
<title>fyi</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="571.75,-365.65 476.25,-365.65 476.25,-319.4 571.75,-319.4 571.75,-365.65"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="524" y="-352.15" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#8892a8">FYI</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="524" y="-339.4" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#8892a8">ℹ nominal hubs</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="524" y="-326.65" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#8892a8">ℹ resolved items</text>
|
||||||
|
</g>
|
||||||
|
<!-- triage->fyi -->
|
||||||
|
<!-- synth -->
|
||||||
|
<g id="node14" class="node">
|
||||||
|
<title>synth</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="503,-255.78 307,-255.78 307,-184.78 503,-184.78 503,-255.78"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-242.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">generate_narrative()</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-229.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">[ops server]</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-204.78" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Structured brief:</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-192.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">header → immediate → monitor → fyi</text>
|
||||||
|
</g>
|
||||||
|
<!-- imm->synth -->
|
||||||
|
<g id="edge17" class="edge">
|
||||||
|
<title>imm->synth</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" d="M316.02,-312.73C330.19,-298.17 347.65,-280.22 363.35,-264.08"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="365.85,-266.53 370.32,-256.92 360.84,-261.65 365.85,-266.53"/>
|
||||||
|
</g>
|
||||||
|
<!-- mon->synth -->
|
||||||
|
<g id="edge18" class="edge">
|
||||||
|
<title>mon->synth</title>
|
||||||
|
<path fill="none" stroke="#ffc107" d="M405,-312.73C405,-299.1 405,-282.51 405,-267.21"/>
|
||||||
|
<polygon fill="#ffc107" stroke="#ffc107" points="408.5,-267.35 405,-257.35 401.5,-267.35 408.5,-267.35"/>
|
||||||
|
</g>
|
||||||
|
<!-- fyi->synth -->
|
||||||
|
<g id="edge19" class="edge">
|
||||||
|
<title>fyi->synth</title>
|
||||||
|
<path fill="none" stroke="#8892a8" d="M501.62,-318.91C486.3,-303.43 465.52,-282.43 447.15,-263.88"/>
|
||||||
|
<polygon fill="#8892a8" stroke="#8892a8" points="450.04,-261.82 440.52,-257.17 445.07,-266.74 450.04,-261.82"/>
|
||||||
|
</g>
|
||||||
|
<!-- format -->
|
||||||
|
<g id="node15" class="node">
|
||||||
|
<title>format</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="485.75,-135.53 324.25,-135.53 324.25,-89.28 485.75,-89.28 485.75,-135.53"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-122.03" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Structure brief JSON</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-109.28" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Store as ops://handover/latest</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="405" y="-96.53" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Record timing</text>
|
||||||
|
</g>
|
||||||
|
<!-- synth->format -->
|
||||||
|
<g id="edge20" class="edge">
|
||||||
|
<title>synth->format</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M405,-184.32C405,-172.39 405,-159.08 405,-147.25"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="408.5,-147.52 405,-137.52 401.5,-147.52 408.5,-147.52"/>
|
||||||
|
</g>
|
||||||
|
<!-- format->end_done -->
|
||||||
|
<g id="edge21" class="edge">
|
||||||
|
<title>format->end_done</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M405,-88.96C405,-81.58 405,-73.17 405,-64.99"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="408.5,-65.23 405,-55.23 401.5,-65.23 408.5,-65.23"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 18 KiB |
144
docs/graphs/mcp_servers.dot
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
digraph mcp_servers {
|
||||||
|
rankdir=LR
|
||||||
|
bgcolor="#0a0e17"
|
||||||
|
fontname="Helvetica"
|
||||||
|
node [fontname="Helvetica" fontsize=10 style=filled color="#1e2a4a" fontcolor="#e8eaf0"]
|
||||||
|
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
|
||||||
|
|
||||||
|
label="MCP Server Topology — Tools · Resources · Prompts"
|
||||||
|
labelloc=t
|
||||||
|
fontsize=14
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
// Clients
|
||||||
|
subgraph cluster_clients {
|
||||||
|
label="Agent Clients"
|
||||||
|
style=dashed
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
efhas [label="FCE\nAgent" fillcolor="#1a1a3a" shape=box]
|
||||||
|
handover [label="Handover\nAgent" fillcolor="#1a1a3a" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared server
|
||||||
|
subgraph cluster_shared {
|
||||||
|
label="united-ops-shared"
|
||||||
|
color="#0066ff"
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
style=rounded
|
||||||
|
|
||||||
|
subgraph cluster_shared_tools {
|
||||||
|
label="Tools"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
st1 [label="get_flight_status" fillcolor="#0d1a33" shape=box]
|
||||||
|
st2 [label="get_flight_details" fillcolor="#0d1a33" shape=box]
|
||||||
|
st3 [label="get_irregular_ops" fillcolor="#0d1a33" shape=box]
|
||||||
|
st4 [label="get_route_weather\n★ LIVE" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
st5 [label="get_hub_forecasts\n★ LIVE" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
st6 [label="get_airport_status\n★ LIVE" fillcolor="#0d2a0d" shape=box fontcolor="#00c853"]
|
||||||
|
st7 [label="get_airport_congestion\n★ HYBRID" fillcolor="#0d2a0d" shape=box fontcolor="#ffc107"]
|
||||||
|
st8 [label="get_maintenance_flags" fillcolor="#0d1a33" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_shared_res {
|
||||||
|
label="Resources"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
sr1 [label="ops://hubs/{code}" fillcolor="#1a1a2a" shape=note]
|
||||||
|
sr2 [label="ops://scenarios/active" fillcolor="#1a1a2a" shape=note]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_shared_prompts {
|
||||||
|
label="Prompts"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
sp1 [label="delay_explainer\n(cause_code, audience)" fillcolor="#2a1a2a" shape=cds]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ops server
|
||||||
|
subgraph cluster_ops {
|
||||||
|
label="united-ops-internal"
|
||||||
|
color="#ff3d00"
|
||||||
|
fontcolor="#ff3d00"
|
||||||
|
style=rounded
|
||||||
|
|
||||||
|
subgraph cluster_ops_tools {
|
||||||
|
label="Tools"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
ot1 [label="get_crew_notes" fillcolor="#0d1a33" shape=box]
|
||||||
|
ot2 [label="get_crew_duty_status" fillcolor="#0d1a33" shape=box]
|
||||||
|
ot3 [label="get_pending_rebookings" fillcolor="#0d1a33" shape=box]
|
||||||
|
ot4 [label="generate_narrative" fillcolor="#0d1a33" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_ops_res {
|
||||||
|
label="Resources"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
or1 [label="ops://crew/roster" fillcolor="#1a1a2a" shape=note]
|
||||||
|
or2 [label="ops://handover/latest" fillcolor="#1a1a2a" shape=note]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_ops_prompts {
|
||||||
|
label="Prompts"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
op1 [label="handover_brief\n(hub, shift_time)" fillcolor="#2a1a2a" shape=cds]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passenger server
|
||||||
|
subgraph cluster_pax {
|
||||||
|
label="united-ops-passenger"
|
||||||
|
color="#00c853"
|
||||||
|
fontcolor="#00c853"
|
||||||
|
style=rounded
|
||||||
|
|
||||||
|
subgraph cluster_pax_tools {
|
||||||
|
label="Tools"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
pt1 [label="generate_notification" fillcolor="#0d1a33" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_pax_res {
|
||||||
|
label="Resources"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
pr1 [label="ops://flights/{id}/manifest" fillcolor="#1a1a2a" shape=note]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_pax_prompts {
|
||||||
|
label="Prompts"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
pp1 [label="passenger_notification\n(tone)" fillcolor="#2a1a2a" shape=cds]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connections
|
||||||
|
efhas -> st1 [color="#0066ff"]
|
||||||
|
efhas -> st2 [color="#0066ff"]
|
||||||
|
efhas -> st4 [color="#0066ff"]
|
||||||
|
efhas -> st6 [color="#0066ff"]
|
||||||
|
efhas -> st7 [color="#0066ff"]
|
||||||
|
efhas -> st8 [color="#0066ff"]
|
||||||
|
efhas -> pt1 [color="#00c853"]
|
||||||
|
efhas -> sr1 [color="#0066ff" style=dashed]
|
||||||
|
efhas -> pp1 [color="#00c853" style=dotted]
|
||||||
|
|
||||||
|
handover -> st3 [color="#0066ff"]
|
||||||
|
handover -> st5 [color="#0066ff"]
|
||||||
|
handover -> st6 [color="#0066ff"]
|
||||||
|
handover -> st8 [color="#0066ff"]
|
||||||
|
handover -> ot1 [color="#ff3d00"]
|
||||||
|
handover -> ot2 [color="#ff3d00"]
|
||||||
|
handover -> ot3 [color="#ff3d00"]
|
||||||
|
handover -> ot4 [color="#ff3d00"]
|
||||||
|
handover -> or1 [color="#ff3d00" style=dashed]
|
||||||
|
handover -> or2 [color="#ff3d00" style=dashed]
|
||||||
|
handover -> op1 [color="#ff3d00" style=dotted]
|
||||||
|
}
|
||||||
356
docs/graphs/mcp_servers.svg
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Generated by graphviz version 14.1.2 (0)
|
||||||
|
-->
|
||||||
|
<!-- Title: mcp_servers Pages: 1 -->
|
||||||
|
<svg width="381pt" height="1577pt"
|
||||||
|
viewBox="0.00 0.00 381.00 1577.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1573.25)">
|
||||||
|
<title>mcp_servers</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-1573.25 377,-1573.25 377,4 -4,4"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="186.5" y="-1551.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">MCP Server Topology — Tools · Resources · Prompts</text>
|
||||||
|
<g id="clust1" class="cluster">
|
||||||
|
<title>cluster_clients</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="34.62,-851 34.62,-982 143.62,-982 143.62,-851 34.62,-851"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="89.12" y="-964.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Agent Clients</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust2" class="cluster">
|
||||||
|
<title>cluster_shared</title>
|
||||||
|
<path fill="#0a0e17" stroke="#0066ff" d="M173.62,-816C173.62,-816 312.38,-816 312.38,-816 318.38,-816 324.38,-822 324.38,-828 324.38,-828 324.38,-1524 324.38,-1524 324.38,-1530 318.38,-1536 312.38,-1536 312.38,-1536 173.62,-1536 173.62,-1536 167.62,-1536 161.62,-1530 161.62,-1524 161.62,-1524 161.62,-828 161.62,-828 161.62,-822 167.62,-816 173.62,-816"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1518.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">united-ops-shared</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust3" class="cluster">
|
||||||
|
<title>cluster_shared_tools</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M182.38,-824C182.38,-824 303.62,-824 303.62,-824 309.62,-824 315.62,-830 315.62,-836 315.62,-836 315.62,-1267 315.62,-1267 315.62,-1273 309.62,-1279 303.62,-1279 303.62,-1279 182.38,-1279 182.38,-1279 176.38,-1279 170.38,-1273 170.38,-1267 170.38,-1267 170.38,-836 170.38,-836 170.38,-830 176.38,-824 182.38,-824"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1261.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Tools</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust4" class="cluster">
|
||||||
|
<title>cluster_shared_res</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M186.12,-1287C186.12,-1287 299.88,-1287 299.88,-1287 305.88,-1287 311.88,-1293 311.88,-1299 311.88,-1299 311.88,-1406 311.88,-1406 311.88,-1412 305.88,-1418 299.88,-1418 299.88,-1418 186.12,-1418 186.12,-1418 180.12,-1418 174.12,-1412 174.12,-1406 174.12,-1406 174.12,-1299 174.12,-1299 174.12,-1293 180.12,-1287 186.12,-1287"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1400.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Resources</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust5" class="cluster">
|
||||||
|
<title>cluster_shared_prompts</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M181.62,-1426C181.62,-1426 304.38,-1426 304.38,-1426 310.38,-1426 316.38,-1432 316.38,-1438 316.38,-1438 316.38,-1491 316.38,-1491 316.38,-1497 310.38,-1503 304.38,-1503 304.38,-1503 181.62,-1503 181.62,-1503 175.62,-1503 169.62,-1497 169.62,-1491 169.62,-1491 169.62,-1438 169.62,-1438 169.62,-1432 175.62,-1426 181.62,-1426"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1485.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Prompts</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust6" class="cluster">
|
||||||
|
<title>cluster_ops</title>
|
||||||
|
<path fill="#0a0e17" stroke="#ff3d00" d="M171.38,-8C171.38,-8 314.62,-8 314.62,-8 320.62,-8 326.62,-14 326.62,-20 326.62,-20 326.62,-500 326.62,-500 326.62,-506 320.62,-512 314.62,-512 314.62,-512 171.38,-512 171.38,-512 165.38,-512 159.38,-506 159.38,-500 159.38,-500 159.38,-20 159.38,-20 159.38,-14 165.38,-8 171.38,-8"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-494.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#ff3d00">united-ops-internal</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust7" class="cluster">
|
||||||
|
<title>cluster_ops_tools</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M179.38,-16C179.38,-16 306.62,-16 306.62,-16 312.62,-16 318.62,-22 318.62,-28 318.62,-28 318.62,-243 318.62,-243 318.62,-249 312.62,-255 306.62,-255 306.62,-255 179.38,-255 179.38,-255 173.38,-255 167.38,-249 167.38,-243 167.38,-243 167.38,-28 167.38,-28 167.38,-22 173.38,-16 179.38,-16"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-237.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Tools</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust8" class="cluster">
|
||||||
|
<title>cluster_ops_res</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M187.62,-263C187.62,-263 298.38,-263 298.38,-263 304.38,-263 310.38,-269 310.38,-275 310.38,-275 310.38,-382 310.38,-382 310.38,-388 304.38,-394 298.38,-394 298.38,-394 187.62,-394 187.62,-394 181.62,-394 175.62,-388 175.62,-382 175.62,-382 175.62,-275 175.62,-275 175.62,-269 181.62,-263 187.62,-263"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-376.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Resources</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust9" class="cluster">
|
||||||
|
<title>cluster_ops_prompts</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M198.88,-402C198.88,-402 287.12,-402 287.12,-402 293.12,-402 299.12,-408 299.12,-414 299.12,-414 299.12,-467 299.12,-467 299.12,-473 293.12,-479 287.12,-479 287.12,-479 198.88,-479 198.88,-479 192.88,-479 186.88,-473 186.88,-467 186.88,-467 186.88,-414 186.88,-414 186.88,-408 192.88,-402 198.88,-402"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-461.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Prompts</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust10" class="cluster">
|
||||||
|
<title>cluster_pax</title>
|
||||||
|
<path fill="#0a0e17" stroke="#00c853" d="M167.62,-520C167.62,-520 318.38,-520 318.38,-520 324.38,-520 330.38,-526 330.38,-532 330.38,-532 330.38,-796 330.38,-796 330.38,-802 324.38,-808 318.38,-808 318.38,-808 167.62,-808 167.62,-808 161.62,-808 155.62,-802 155.62,-796 155.62,-796 155.62,-532 155.62,-532 155.62,-526 161.62,-520 167.62,-520"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-790.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#00c853">united-ops-passenger</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust11" class="cluster">
|
||||||
|
<title>cluster_pax_tools</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M187.25,-528C187.25,-528 298.75,-528 298.75,-528 304.75,-528 310.75,-534 310.75,-540 310.75,-540 310.75,-593 310.75,-593 310.75,-599 304.75,-605 298.75,-605 298.75,-605 187.25,-605 187.25,-605 181.25,-605 175.25,-599 175.25,-593 175.25,-593 175.25,-540 175.25,-540 175.25,-534 181.25,-528 187.25,-528"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-587.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Tools</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust12" class="cluster">
|
||||||
|
<title>cluster_pax_res</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M175.62,-613C175.62,-613 310.38,-613 310.38,-613 316.38,-613 322.38,-619 322.38,-625 322.38,-625 322.38,-678 322.38,-678 322.38,-684 316.38,-690 310.38,-690 310.38,-690 175.62,-690 175.62,-690 169.62,-690 163.62,-684 163.62,-678 163.62,-678 163.62,-625 163.62,-625 163.62,-619 169.62,-613 175.62,-613"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-672.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Resources</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust13" class="cluster">
|
||||||
|
<title>cluster_pax_prompts</title>
|
||||||
|
<path fill="#0a0e17" stroke="#1e2a4a" d="M183.88,-698C183.88,-698 302.12,-698 302.12,-698 308.12,-698 314.12,-704 314.12,-710 314.12,-710 314.12,-763 314.12,-763 314.12,-769 308.12,-775 302.12,-775 302.12,-775 183.88,-775 183.88,-775 177.88,-775 171.88,-769 171.88,-763 171.88,-763 171.88,-710 171.88,-710 171.88,-704 177.88,-698 183.88,-698"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-757.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4a5568">Prompts</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas -->
|
||||||
|
<g id="node1" class="node">
|
||||||
|
<title>efhas</title>
|
||||||
|
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="115.62,-949 61.62,-949 61.62,-913 115.62,-913 115.62,-949"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="88.62" y="-934.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">FCE</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="88.62" y="-921.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Agent</text>
|
||||||
|
</g>
|
||||||
|
<!-- st1 -->
|
||||||
|
<g id="node3" class="node">
|
||||||
|
<title>st1</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="291.5,-1192 194.5,-1192 194.5,-1156 291.5,-1156 291.5,-1192"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1170.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_flight_status</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->st1 -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>efhas->st1</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M89.78,-949.5C91.16,-990.83 100.35,-1091.37 155.62,-1147 163.27,-1154.69 173.07,-1160.2 183.31,-1164.14"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="182.04,-1167.41 192.63,-1167.23 184.24,-1160.76 182.04,-1167.41"/>
|
||||||
|
</g>
|
||||||
|
<!-- st2 -->
|
||||||
|
<g id="node4" class="node">
|
||||||
|
<title>st2</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="293,-1246 193,-1246 193,-1210 293,-1210 293,-1246"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1224.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_flight_details</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->st2 -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>efhas->st2</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M91.75,-949.27C98.43,-1003.77 120.7,-1163.79 155.62,-1201 162.81,-1208.66 172.13,-1214.15 181.98,-1218.1"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="180.78,-1221.39 191.37,-1221.31 183.04,-1214.76 180.78,-1221.39"/>
|
||||||
|
</g>
|
||||||
|
<!-- st4 -->
|
||||||
|
<g id="node6" class="node">
|
||||||
|
<title>st4</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="296.75,-1084 189.25,-1084 189.25,-1048 296.75,-1048 296.75,-1084"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1069.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">get_route_weather</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1056.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">★ LIVE</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->st4 -->
|
||||||
|
<g id="edge3" class="edge">
|
||||||
|
<title>efhas->st4</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M96.16,-949.49C105.6,-973.56 125.31,-1015.34 155.62,-1039 162.34,-1044.24 170.09,-1048.47 178.13,-1051.89"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="176.8,-1055.13 187.39,-1055.39 179.28,-1048.58 176.8,-1055.13"/>
|
||||||
|
</g>
|
||||||
|
<!-- st6 -->
|
||||||
|
<g id="node8" class="node">
|
||||||
|
<title>st6</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="295.25,-976 190.75,-976 190.75,-940 295.25,-940 295.25,-976"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-961.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">get_airport_status</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-948.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">★ LIVE</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->st6 -->
|
||||||
|
<g id="edge4" class="edge">
|
||||||
|
<title>efhas->st6</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M115.82,-935.64C133.29,-938.74 157.04,-942.95 179.22,-946.88"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="178.35,-950.28 188.8,-948.57 179.57,-943.38 178.35,-950.28"/>
|
||||||
|
</g>
|
||||||
|
<!-- st7 -->
|
||||||
|
<g id="node9" class="node">
|
||||||
|
<title>st7</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="306.88,-1138 179.12,-1138 179.12,-1102 306.88,-1102 306.88,-1138"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1123.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">get_airport_congestion</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1110.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#ffc107">★ HYBRID</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->st7 -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>efhas->st7</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M91.97,-949.44C97.2,-982.89 112.92,-1053.74 155.62,-1093 159.61,-1096.66 164.08,-1099.83 168.84,-1102.56"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="167.09,-1105.6 177.6,-1106.92 170.21,-1099.33 167.09,-1105.6"/>
|
||||||
|
</g>
|
||||||
|
<!-- st8 -->
|
||||||
|
<g id="node10" class="node">
|
||||||
|
<title>st8</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="307.62,-1030 178.38,-1030 178.38,-994 307.62,-994 307.62,-1030"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1008.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_maintenance_flags</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->st8 -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>efhas->st8</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M108.13,-949.43C120.64,-961.02 138.03,-975.52 155.62,-985 159.48,-987.08 163.52,-989.03 167.67,-990.84"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="166.13,-994 176.71,-994.53 168.77,-987.51 166.13,-994"/>
|
||||||
|
</g>
|
||||||
|
<!-- sr1 -->
|
||||||
|
<g id="node11" class="node">
|
||||||
|
<title>sr1</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="288.88,-1331 191.12,-1331 191.12,-1295 294.88,-1295 294.88,-1325 288.88,-1331"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="288.88,-1331 288.88,-1325"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="294.88,-1325 288.88,-1325"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1309.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ops://hubs/{code}</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->sr1 -->
|
||||||
|
<g id="edge8" class="edge">
|
||||||
|
<title>efhas->sr1</title>
|
||||||
|
<path fill="none" stroke="#0066ff" stroke-dasharray="5,2" d="M90.69,-949.46C94.79,-1014.51 111.6,-1231.88 155.62,-1283 162.32,-1290.77 171.14,-1296.53 180.58,-1300.8"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="179.01,-1303.95 189.59,-1304.32 181.55,-1297.42 179.01,-1303.95"/>
|
||||||
|
</g>
|
||||||
|
<!-- pt1 -->
|
||||||
|
<g id="node21" class="node">
|
||||||
|
<title>pt1</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="302.75,-572 183.25,-572 183.25,-536 302.75,-536 302.75,-572"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-550.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">generate_notification</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->pt1 -->
|
||||||
|
<g id="edge7" class="edge">
|
||||||
|
<title>efhas->pt1</title>
|
||||||
|
<path fill="none" stroke="#00c853" d="M115.89,-924.23C126.32,-920.14 137.31,-913.77 143.62,-904 161.44,-876.45 139.26,-637.43 155.62,-609 162.83,-596.48 174.17,-586.36 186.29,-578.39"/>
|
||||||
|
<polygon fill="#00c853" stroke="#00c853" points="188.07,-581.4 194.81,-573.23 184.45,-575.41 188.07,-581.4"/>
|
||||||
|
</g>
|
||||||
|
<!-- pp1 -->
|
||||||
|
<g id="node23" class="node">
|
||||||
|
<title>pp1</title>
|
||||||
|
<polygon fill="#2a1a2a" stroke="#1e2a4a" points="294.12,-736 179.88,-736 179.88,-712 294.12,-712 306.12,-724 294.12,-736"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-727.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">passenger_notification</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-714.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">(tone)</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->pp1 -->
|
||||||
|
<g id="edge9" class="edge">
|
||||||
|
<title>efhas->pp1</title>
|
||||||
|
<path fill="none" stroke="#00c853" stroke-dasharray="1,5" d="M115.98,-923.68C126.16,-919.54 136.94,-913.27 143.62,-904 165.68,-873.41 139.65,-854.16 155.62,-820 168.39,-792.7 191.56,-767.6 210.69,-749.92"/>
|
||||||
|
<polygon fill="#00c853" stroke="#00c853" points="212.91,-752.63 218.02,-743.35 208.24,-747.42 212.91,-752.63"/>
|
||||||
|
</g>
|
||||||
|
<!-- handover -->
|
||||||
|
<g id="node2" class="node">
|
||||||
|
<title>handover</title>
|
||||||
|
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="120.25,-895 57,-895 57,-859 120.25,-859 120.25,-895"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="88.62" y="-880.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Handover</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="88.62" y="-867.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Agent</text>
|
||||||
|
</g>
|
||||||
|
<!-- st3 -->
|
||||||
|
<g id="node5" class="node">
|
||||||
|
<title>st3</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="293.38,-922 192.62,-922 192.62,-886 293.38,-886 293.38,-922"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-900.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_irregular_ops</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->st3 -->
|
||||||
|
<g id="edge10" class="edge">
|
||||||
|
<title>handover->st3</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M120.46,-882.46C137.93,-885.56 160.4,-889.54 181.26,-893.24"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="180.37,-896.63 190.82,-894.93 181.59,-889.74 180.37,-896.63"/>
|
||||||
|
</g>
|
||||||
|
<!-- st5 -->
|
||||||
|
<g id="node7" class="node">
|
||||||
|
<title>st5</title>
|
||||||
|
<polygon fill="#0d2a0d" stroke="#1e2a4a" points="295.62,-868 190.38,-868 190.38,-832 295.62,-832 295.62,-868"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-853.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">get_hub_forecasts</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-840.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#00c853">★ LIVE</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->st5 -->
|
||||||
|
<g id="edge11" class="edge">
|
||||||
|
<title>handover->st5</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M120.46,-871.54C137.19,-868.57 158.49,-864.8 178.58,-861.24"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="179.18,-864.69 188.41,-859.5 177.96,-857.79 179.18,-864.69"/>
|
||||||
|
</g>
|
||||||
|
<!-- handover->st6 -->
|
||||||
|
<g id="edge12" class="edge">
|
||||||
|
<title>handover->st6</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M120.64,-888.01C128.88,-892.06 137.25,-897.33 143.62,-904 152.7,-913.49 145.82,-922.26 155.62,-931 162.55,-937.17 170.87,-941.93 179.57,-945.6"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="178.3,-948.86 188.9,-949.03 180.72,-942.29 178.3,-948.86"/>
|
||||||
|
</g>
|
||||||
|
<!-- handover->st8 -->
|
||||||
|
<g id="edge13" class="edge">
|
||||||
|
<title>handover->st8</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M120.75,-886.43C129.4,-890.51 137.97,-896.19 143.62,-904 164.98,-933.47 131.97,-957.34 155.62,-985 159.2,-989.18 163.42,-992.73 168.03,-995.73"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="166.19,-998.71 176.65,-1000.43 169.55,-992.57 166.19,-998.71"/>
|
||||||
|
</g>
|
||||||
|
<!-- ot1 -->
|
||||||
|
<g id="node14" class="node">
|
||||||
|
<title>ot1</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="289.25,-168 196.75,-168 196.75,-132 289.25,-132 289.25,-168"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-146.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_crew_notes</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->ot1 -->
|
||||||
|
<g id="edge14" class="edge">
|
||||||
|
<title>handover->ot1</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" d="M90.15,-858.84C93.34,-754.24 111.39,-231.6 155.62,-177 163.19,-167.66 173.96,-161.49 185.33,-157.43"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="186.3,-160.79 194.89,-154.58 184.3,-154.08 186.3,-160.79"/>
|
||||||
|
</g>
|
||||||
|
<!-- ot2 -->
|
||||||
|
<g id="node15" class="node">
|
||||||
|
<title>ot2</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="304.25,-222 181.75,-222 181.75,-186 304.25,-186 304.25,-222"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-200.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_crew_duty_status</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->ot2 -->
|
||||||
|
<g id="edge15" class="edge">
|
||||||
|
<title>handover->ot2</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" d="M89.49,-858.71C89.11,-767.6 91.15,-362.97 155.62,-259 163.46,-246.36 175.41,-236.11 187.93,-228.03"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="189.48,-231.18 196.27,-223.05 185.89,-225.17 189.48,-231.18"/>
|
||||||
|
</g>
|
||||||
|
<!-- ot3 -->
|
||||||
|
<g id="node16" class="node">
|
||||||
|
<title>ot3</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="310.62,-60 175.38,-60 175.38,-24 310.62,-24 310.62,-60"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-38.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">get_pending_rebookings</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->ot3 -->
|
||||||
|
<g id="edge16" class="edge">
|
||||||
|
<title>handover->ot3</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" d="M89.91,-858.71C91.86,-744.55 104.63,-132.75 155.62,-69 158.47,-65.44 161.78,-62.35 165.41,-59.65"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="167.04,-62.76 173.72,-54.53 163.37,-56.79 167.04,-62.76"/>
|
||||||
|
</g>
|
||||||
|
<!-- ot4 -->
|
||||||
|
<g id="node17" class="node">
|
||||||
|
<title>ot4</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="297.5,-114 188.5,-114 188.5,-78 297.5,-78 297.5,-114"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-92.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">generate_narrative</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->ot4 -->
|
||||||
|
<g id="edge17" class="edge">
|
||||||
|
<title>handover->ot4</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" d="M90.03,-858.52C92.59,-748.47 108.05,-182.12 155.62,-123 161.44,-115.78 169.16,-110.45 177.6,-106.53"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="178.72,-109.85 186.77,-102.96 176.18,-103.33 178.72,-109.85"/>
|
||||||
|
</g>
|
||||||
|
<!-- or1 -->
|
||||||
|
<g id="node18" class="node">
|
||||||
|
<title>or1</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="285.5,-307 194.5,-307 194.5,-271 291.5,-271 291.5,-301 285.5,-307"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="285.5,-307 285.5,-301"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="291.5,-301 285.5,-301"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-285.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ops://crew/roster</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->or1 -->
|
||||||
|
<g id="edge18" class="edge">
|
||||||
|
<title>handover->or1</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" stroke-dasharray="5,2" d="M89.21,-858.63C87.59,-771.57 85.03,-401.1 155.62,-316 162.85,-307.29 172.84,-301.35 183.47,-297.3"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="184.34,-300.7 192.8,-294.31 182.21,-294.03 184.34,-300.7"/>
|
||||||
|
</g>
|
||||||
|
<!-- or2 -->
|
||||||
|
<g id="node19" class="node">
|
||||||
|
<title>or2</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="296.38,-361 183.62,-361 183.62,-325 302.38,-325 302.38,-355 296.38,-361"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="296.38,-361 296.38,-355"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="302.38,-355 296.38,-355"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-339.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ops://handover/latest</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->or2 -->
|
||||||
|
<g id="edge19" class="edge">
|
||||||
|
<title>handover->or2</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" stroke-dasharray="5,2" d="M90.13,-858.58C92.57,-780.41 105.2,-476.61 155.62,-398 163.66,-385.48 175.66,-375.27 188.19,-367.19"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="189.74,-370.35 196.52,-362.21 186.14,-364.34 189.74,-370.35"/>
|
||||||
|
</g>
|
||||||
|
<!-- op1 -->
|
||||||
|
<g id="node20" class="node">
|
||||||
|
<title>op1</title>
|
||||||
|
<polygon fill="#2a1a2a" stroke="#1e2a4a" points="279.12,-440 194.88,-440 194.88,-416 279.12,-416 291.12,-428 279.12,-440"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-431.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">handover_brief</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-418.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">(hub, shift_time)</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->op1 -->
|
||||||
|
<g id="edge20" class="edge">
|
||||||
|
<title>handover->op1</title>
|
||||||
|
<path fill="none" stroke="#ff3d00" stroke-dasharray="1,5" d="M89.63,-858.66C90.34,-804.5 97.51,-641.67 155.62,-524 168.97,-496.98 192.11,-471.87 211.09,-454.11"/>
|
||||||
|
<polygon fill="#ff3d00" stroke="#ff3d00" points="213.3,-456.83 218.34,-447.51 208.59,-451.65 213.3,-456.83"/>
|
||||||
|
</g>
|
||||||
|
<!-- sr2 -->
|
||||||
|
<g id="node12" class="node">
|
||||||
|
<title>sr2</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="297.88,-1385 182.12,-1385 182.12,-1349 303.88,-1349 303.88,-1379 297.88,-1385"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="297.88,-1385 297.88,-1379"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="303.88,-1379 297.88,-1379"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1363.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ops://scenarios/active</text>
|
||||||
|
</g>
|
||||||
|
<!-- sp1 -->
|
||||||
|
<g id="node13" class="node">
|
||||||
|
<title>sp1</title>
|
||||||
|
<polygon fill="#2a1a2a" stroke="#1e2a4a" points="296.38,-1464 177.62,-1464 177.62,-1440 296.38,-1440 308.38,-1452 296.38,-1464"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1455.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">delay_explainer</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-1442.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">(cause_code, audience)</text>
|
||||||
|
</g>
|
||||||
|
<!-- pr1 -->
|
||||||
|
<g id="node22" class="node">
|
||||||
|
<title>pr1</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="308.38,-657 171.62,-657 171.62,-621 314.38,-621 314.38,-651 308.38,-657"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="308.38,-657 308.38,-651"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="314.38,-651 308.38,-651"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="243" y="-635.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ops://flights/{id}/manifest</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 25 KiB |
83
docs/graphs/repo_structure.dot
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
digraph repo_structure {
|
||||||
|
rankdir=TB
|
||||||
|
bgcolor="#0a0e17"
|
||||||
|
fontname="Helvetica"
|
||||||
|
node [fontname="Helvetica" fontsize=10 style=filled color="#1e2a4a" fontcolor="#e8eaf0" shape=folder]
|
||||||
|
edge [color="#1e2a4a" arrowsize=0.5]
|
||||||
|
|
||||||
|
label="Repository Structure"
|
||||||
|
labelloc=t
|
||||||
|
fontsize=14
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
root [label="united-ops/" fillcolor="#0066ff" fontcolor="white"]
|
||||||
|
|
||||||
|
mcp [label="mcp_servers/" fillcolor="#121829"]
|
||||||
|
agents [label="agents/" fillcolor="#121829"]
|
||||||
|
irrop [label="irrop/" fillcolor="#121829"]
|
||||||
|
api [label="api/" fillcolor="#121829"]
|
||||||
|
ui_root [label="ui/" fillcolor="#121829"]
|
||||||
|
ctrl [label="ctrl/" fillcolor="#121829"]
|
||||||
|
docs [label="docs/" fillcolor="#121829"]
|
||||||
|
|
||||||
|
// MCP subtree
|
||||||
|
mcp_shared [label="shared/\nserver.py\ntools/ resources/ prompts/" fillcolor="#0d1a33" shape=box]
|
||||||
|
mcp_ops [label="ops/\nserver.py\ntools/ resources/ prompts/" fillcolor="#0d1a33" shape=box]
|
||||||
|
mcp_pax [label="passenger/\nserver.py\ntools/ resources/ prompts/" fillcolor="#0d1a33" shape=box]
|
||||||
|
mcp_data [label="data/\nmodels.py\nreal/ (openmeteo, faa)\nmock/\nscenarios/ (4 scenarios)" fillcolor="#0d1a33" shape=box]
|
||||||
|
|
||||||
|
// Agents subtree
|
||||||
|
ag_efhas [label="efhas.py\nFCE agent" fillcolor="#1a1a3a" shape=box]
|
||||||
|
ag_handover [label="handover.py\nHandover agent" fillcolor="#1a1a3a" shape=box]
|
||||||
|
ag_shared [label="shared/\nmcp_client.py\nllm.py" fillcolor="#1a1a3a" shape=box]
|
||||||
|
|
||||||
|
// IRROP subtree
|
||||||
|
ir_models [label="models/\nflight, passenger\ncrew, recovery" fillcolor="#1a2a1a" shape=box]
|
||||||
|
ir_rules [label="rules/\nfaa_part117\nrebooking\ncompensation" fillcolor="#1a2a1a" shape=box]
|
||||||
|
ir_pipeline [label="pipeline/\ningest → triage →\nrebook → compensate" fillcolor="#1a2a1a" shape=box]
|
||||||
|
|
||||||
|
// API subtree
|
||||||
|
api_main [label="main.py\nFastAPI + WebSocket" fillcolor="#2a1a1a" shape=box]
|
||||||
|
api_routes [label="routes/\nagents, scenarios, ws" fillcolor="#2a1a1a" shape=box]
|
||||||
|
|
||||||
|
// UI subtree
|
||||||
|
ui_fw [label="framework/\nsoleprint-ui\n(shared component lib)" fillcolor="#2a2a0d" shape=box]
|
||||||
|
ui_app [label="app/\nVue 3 SPA\npages/ components/\nmars-tokens.css" fillcolor="#2a2a0d" shape=box]
|
||||||
|
|
||||||
|
// Ctrl subtree
|
||||||
|
ctrl_docker [label="Dockerfile.api\nDockerfile.ui\nnginx.conf\ndocker-compose.yml" fillcolor="#1a1a2a" shape=box]
|
||||||
|
ctrl_k8s [label="k8s/\nbase/ overlays/dev/\nkind-config.yaml" fillcolor="#1a1a2a" shape=box]
|
||||||
|
ctrl_tilt [label="Tiltfile\ntilt_config.json" fillcolor="#1a1a2a" shape=box]
|
||||||
|
|
||||||
|
// Edges
|
||||||
|
root -> mcp
|
||||||
|
root -> agents
|
||||||
|
root -> irrop
|
||||||
|
root -> api
|
||||||
|
root -> ui_root
|
||||||
|
root -> ctrl
|
||||||
|
root -> docs
|
||||||
|
|
||||||
|
mcp -> mcp_shared
|
||||||
|
mcp -> mcp_ops
|
||||||
|
mcp -> mcp_pax
|
||||||
|
mcp -> mcp_data
|
||||||
|
|
||||||
|
agents -> ag_efhas
|
||||||
|
agents -> ag_handover
|
||||||
|
agents -> ag_shared
|
||||||
|
|
||||||
|
irrop -> ir_models
|
||||||
|
irrop -> ir_rules
|
||||||
|
irrop -> ir_pipeline
|
||||||
|
|
||||||
|
api -> api_main
|
||||||
|
api -> api_routes
|
||||||
|
|
||||||
|
ui_root -> ui_fw
|
||||||
|
ui_root -> ui_app
|
||||||
|
|
||||||
|
ctrl -> ctrl_docker
|
||||||
|
ctrl -> ctrl_k8s
|
||||||
|
ctrl -> ctrl_tilt
|
||||||
|
}
|
||||||
342
docs/graphs/repo_structure.svg
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Generated by graphviz version 14.1.2 (0)
|
||||||
|
-->
|
||||||
|
<!-- Title: repo_structure Pages: 1 -->
|
||||||
|
<svg width="2207pt" height="249pt"
|
||||||
|
viewBox="0.00 0.00 2207.00 249.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 245)">
|
||||||
|
<title>repo_structure</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-245 2203,-245 2203,4 -4,4"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1099.5" y="-223.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#0066ff">Repository Structure</text>
|
||||||
|
<!-- root -->
|
||||||
|
<g id="node1" class="node">
|
||||||
|
<title>root</title>
|
||||||
|
<polygon fill="#0066ff" stroke="#1e2a4a" points="1454.75,-215.75 1451.75,-219.75 1430.75,-219.75 1427.75,-215.75 1384,-215.75 1384,-179.75 1454.75,-179.75 1454.75,-215.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1419.38" y="-194.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="white">united-ops/</text>
|
||||||
|
</g>
|
||||||
|
<!-- mcp -->
|
||||||
|
<g id="node2" class="node">
|
||||||
|
<title>mcp</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="434,-143.75 431,-147.75 410,-147.75 407,-143.75 352.75,-143.75 352.75,-107.75 434,-107.75 434,-143.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="393.38" y="-122.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">mcp_servers/</text>
|
||||||
|
</g>
|
||||||
|
<!-- root->mcp -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>root->mcp</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1383.76,-194.32C1229.15,-183.77 616.05,-141.94 440.54,-129.97"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="440.85,-128.24 435.75,-129.64 440.62,-131.73 440.85,-128.24"/>
|
||||||
|
</g>
|
||||||
|
<!-- agents -->
|
||||||
|
<g id="node3" class="node">
|
||||||
|
<title>agents</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="843.38,-143.75 840.38,-147.75 819.38,-147.75 816.38,-143.75 789.38,-143.75 789.38,-107.75 843.38,-107.75 843.38,-143.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="816.38" y="-122.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">agents/</text>
|
||||||
|
</g>
|
||||||
|
<!-- root->agents -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>root->agents</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1383.91,-192.63C1276.34,-180.15 954.77,-142.82 849.87,-130.64"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="850.24,-128.92 845.07,-130.08 849.84,-132.4 850.24,-128.92"/>
|
||||||
|
</g>
|
||||||
|
<!-- irrop -->
|
||||||
|
<g id="node4" class="node">
|
||||||
|
<title>irrop</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="1177.38,-143.75 1174.38,-147.75 1153.38,-147.75 1150.38,-143.75 1123.38,-143.75 1123.38,-107.75 1177.38,-107.75 1177.38,-143.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1150.38" y="-122.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">irrop/</text>
|
||||||
|
</g>
|
||||||
|
<!-- root->irrop -->
|
||||||
|
<g id="edge3" class="edge">
|
||||||
|
<title>root->irrop</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1383.65,-187.45C1331.44,-173.87 1234.95,-148.76 1183.97,-135.49"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1184.43,-133.8 1179.15,-134.24 1183.54,-137.19 1184.43,-133.8"/>
|
||||||
|
</g>
|
||||||
|
<!-- api -->
|
||||||
|
<g id="node5" class="node">
|
||||||
|
<title>api</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="1446.38,-143.75 1443.38,-147.75 1422.38,-147.75 1419.38,-143.75 1392.38,-143.75 1392.38,-107.75 1446.38,-107.75 1446.38,-143.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1419.38" y="-122.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">api/</text>
|
||||||
|
</g>
|
||||||
|
<!-- root->api -->
|
||||||
|
<g id="edge4" class="edge">
|
||||||
|
<title>root->api</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1419.38,-179.45C1419.38,-170.63 1419.38,-159.78 1419.38,-150.22"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1421.13,-150.37 1419.38,-145.37 1417.63,-150.37 1421.13,-150.37"/>
|
||||||
|
</g>
|
||||||
|
<!-- ui_root -->
|
||||||
|
<g id="node6" class="node">
|
||||||
|
<title>ui_root</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="1658.38,-143.75 1655.38,-147.75 1634.38,-147.75 1631.38,-143.75 1604.38,-143.75 1604.38,-107.75 1658.38,-107.75 1658.38,-143.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1631.38" y="-122.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ui/</text>
|
||||||
|
</g>
|
||||||
|
<!-- root->ui_root -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>root->ui_root</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1454.86,-185.03C1494.69,-171.88 1558.84,-150.7 1597.85,-137.82"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1598.25,-139.53 1602.45,-136.3 1597.15,-136.21 1598.25,-139.53"/>
|
||||||
|
</g>
|
||||||
|
<!-- ctrl -->
|
||||||
|
<g id="node7" class="node">
|
||||||
|
<title>ctrl</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="1932.38,-143.75 1929.38,-147.75 1908.38,-147.75 1905.38,-143.75 1878.38,-143.75 1878.38,-107.75 1932.38,-107.75 1932.38,-143.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1905.38" y="-122.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ctrl/</text>
|
||||||
|
</g>
|
||||||
|
<!-- root->ctrl -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>root->ctrl</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1455.11,-191.6C1545.68,-178.56 1783.28,-144.34 1871.64,-131.61"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1871.79,-133.36 1876.49,-130.91 1871.29,-129.89 1871.79,-133.36"/>
|
||||||
|
</g>
|
||||||
|
<!-- docs -->
|
||||||
|
<g id="node8" class="node">
|
||||||
|
<title>docs</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="2004.38,-143.75 2001.38,-147.75 1980.38,-147.75 1977.38,-143.75 1950.38,-143.75 1950.38,-107.75 2004.38,-107.75 2004.38,-143.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1977.38" y="-122.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">docs/</text>
|
||||||
|
</g>
|
||||||
|
<!-- root->docs -->
|
||||||
|
<g id="edge7" class="edge">
|
||||||
|
<title>root->docs</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1454.96,-197.25C1540.48,-197.56 1763.66,-193.16 1941.38,-143.75 1942.29,-143.5 1943.21,-143.22 1944.14,-142.92"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1944.69,-144.59 1948.8,-141.25 1943.51,-141.29 1944.69,-144.59"/>
|
||||||
|
</g>
|
||||||
|
<!-- mcp_shared -->
|
||||||
|
<g id="node9" class="node">
|
||||||
|
<title>mcp_shared</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="142.75,-59 0,-59 0,-12.75 142.75,-12.75 142.75,-59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="71.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">shared/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="71.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">server.py</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="71.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">tools/ resources/ prompts/</text>
|
||||||
|
</g>
|
||||||
|
<!-- mcp->mcp_shared -->
|
||||||
|
<g id="edge8" class="edge">
|
||||||
|
<title>mcp->mcp_shared</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M352.67,-118.24C304.25,-109.93 221.29,-93.94 152.38,-71.75 143.53,-68.9 134.32,-65.44 125.43,-61.82"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="126.32,-60.3 121.03,-60.01 124.98,-63.53 126.32,-60.3"/>
|
||||||
|
</g>
|
||||||
|
<!-- mcp_ops -->
|
||||||
|
<g id="node10" class="node">
|
||||||
|
<title>mcp_ops</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="303.75,-59 161,-59 161,-12.75 303.75,-12.75 303.75,-59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="232.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ops/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="232.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">server.py</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="232.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">tools/ resources/ prompts/</text>
|
||||||
|
</g>
|
||||||
|
<!-- mcp->mcp_ops -->
|
||||||
|
<g id="edge9" class="edge">
|
||||||
|
<title>mcp->mcp_ops</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M361.57,-107.39C338.04,-94.55 305.62,-76.85 279.15,-62.4"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="280.15,-60.96 274.92,-60.1 278.47,-64.03 280.15,-60.96"/>
|
||||||
|
</g>
|
||||||
|
<!-- mcp_pax -->
|
||||||
|
<g id="node11" class="node">
|
||||||
|
<title>mcp_pax</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="464.75,-59 322,-59 322,-12.75 464.75,-12.75 464.75,-59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="393.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">passenger/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="393.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">server.py</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="393.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">tools/ resources/ prompts/</text>
|
||||||
|
</g>
|
||||||
|
<!-- mcp->mcp_pax -->
|
||||||
|
<g id="edge10" class="edge">
|
||||||
|
<title>mcp->mcp_pax</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M393.38,-107.39C393.38,-95.42 393.38,-79.23 393.38,-65.38"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="395.13,-65.72 393.38,-60.72 391.63,-65.72 395.13,-65.72"/>
|
||||||
|
</g>
|
||||||
|
<!-- mcp_data -->
|
||||||
|
<g id="node12" class="node">
|
||||||
|
<title>mcp_data</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="614.12,-71.75 482.62,-71.75 482.62,0 614.12,0 614.12,-71.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="548.38" y="-58.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">data/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="548.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">models.py</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="548.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">real/ (openmeteo, faa)</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="548.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">mock/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="548.38" y="-7.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">scenarios/ (4 scenarios)</text>
|
||||||
|
</g>
|
||||||
|
<!-- mcp->mcp_data -->
|
||||||
|
<g id="edge11" class="edge">
|
||||||
|
<title>mcp->mcp_data</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M424,-107.39C440.21,-98.2 460.8,-86.52 480.54,-75.33"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="481.28,-76.93 484.77,-72.94 479.55,-73.88 481.28,-76.93"/>
|
||||||
|
</g>
|
||||||
|
<!-- ag_efhas -->
|
||||||
|
<g id="node13" class="node">
|
||||||
|
<title>ag_efhas</title>
|
||||||
|
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="698.12,-53.88 632.62,-53.88 632.62,-17.88 698.12,-17.88 698.12,-53.88"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="665.38" y="-39.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">efhas.py</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="665.38" y="-26.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">FCE agent</text>
|
||||||
|
</g>
|
||||||
|
<!-- agents->ag_efhas -->
|
||||||
|
<g id="edge12" class="edge">
|
||||||
|
<title>agents->ag_efhas</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M788.98,-113.62C766.33,-103.91 733.72,-88.74 707.38,-71.75 701.15,-67.74 694.82,-62.93 689.02,-58.18"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="690.4,-57.06 685.44,-55.19 688.16,-59.74 690.4,-57.06"/>
|
||||||
|
</g>
|
||||||
|
<!-- ag_handover -->
|
||||||
|
<g id="node14" class="node">
|
||||||
|
<title>ag_handover</title>
|
||||||
|
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="810.38,-53.88 716.38,-53.88 716.38,-17.88 810.38,-17.88 810.38,-53.88"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="763.38" y="-39.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">handover.py</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="763.38" y="-26.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Handover agent</text>
|
||||||
|
</g>
|
||||||
|
<!-- agents->ag_handover -->
|
||||||
|
<g id="edge13" class="edge">
|
||||||
|
<title>agents->ag_handover</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M805.9,-107.39C797.63,-93.67 786.01,-74.41 777.01,-59.48"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="778.69,-58.87 774.61,-55.5 775.69,-60.68 778.69,-58.87"/>
|
||||||
|
</g>
|
||||||
|
<!-- ag_shared -->
|
||||||
|
<g id="node15" class="node">
|
||||||
|
<title>ag_shared</title>
|
||||||
|
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="912.5,-59 828.25,-59 828.25,-12.75 912.5,-12.75 912.5,-59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="870.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">shared/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="870.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">mcp_client.py</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="870.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">llm.py</text>
|
||||||
|
</g>
|
||||||
|
<!-- agents->ag_shared -->
|
||||||
|
<g id="edge14" class="edge">
|
||||||
|
<title>agents->ag_shared</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M827.04,-107.39C834.53,-95.2 844.71,-78.64 853.32,-64.64"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="854.73,-65.68 855.86,-60.5 851.75,-63.84 854.73,-65.68"/>
|
||||||
|
</g>
|
||||||
|
<!-- ir_models -->
|
||||||
|
<g id="node16" class="node">
|
||||||
|
<title>ir_models</title>
|
||||||
|
<polygon fill="#1a2a1a" stroke="#1e2a4a" points="1027.88,-59 930.88,-59 930.88,-12.75 1027.88,-12.75 1027.88,-59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="979.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">models/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="979.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">flight, passenger</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="979.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">crew, recovery</text>
|
||||||
|
</g>
|
||||||
|
<!-- irrop->ir_models -->
|
||||||
|
<g id="edge15" class="edge">
|
||||||
|
<title>irrop->ir_models</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1123.23,-113.17C1099.86,-102.96 1065.42,-87.32 1036.38,-71.75 1031.08,-68.91 1025.6,-65.8 1020.22,-62.63"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1021.38,-61.29 1016.19,-60.23 1019.59,-64.29 1021.38,-61.29"/>
|
||||||
|
</g>
|
||||||
|
<!-- ir_rules -->
|
||||||
|
<g id="node17" class="node">
|
||||||
|
<title>ir_rules</title>
|
||||||
|
<polygon fill="#1a2a1a" stroke="#1e2a4a" points="1130.88,-65.38 1045.88,-65.38 1045.88,-6.38 1130.88,-6.38 1130.88,-65.38"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1088.38" y="-51.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">rules/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1088.38" y="-39.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">faa_part117</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1088.38" y="-26.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">rebooking</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1088.38" y="-13.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">compensation</text>
|
||||||
|
</g>
|
||||||
|
<!-- irrop->ir_rules -->
|
||||||
|
<g id="edge16" class="edge">
|
||||||
|
<title>irrop->ir_rules</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1138.13,-107.39C1130.78,-96.97 1121.18,-83.36 1112.37,-70.88"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1113.84,-69.93 1109.53,-66.85 1110.98,-71.95 1113.84,-69.93"/>
|
||||||
|
</g>
|
||||||
|
<!-- ir_pipeline -->
|
||||||
|
<g id="node18" class="node">
|
||||||
|
<title>ir_pipeline</title>
|
||||||
|
<polygon fill="#1a2a1a" stroke="#1e2a4a" points="1273.38,-59 1149.38,-59 1149.38,-12.75 1273.38,-12.75 1273.38,-59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1211.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">pipeline/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1211.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">ingest → triage →</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1211.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">rebook → compensate</text>
|
||||||
|
</g>
|
||||||
|
<!-- irrop->ir_pipeline -->
|
||||||
|
<g id="edge17" class="edge">
|
||||||
|
<title>irrop->ir_pipeline</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1162.43,-107.39C1170.96,-95.09 1182.58,-78.35 1192.36,-64.27"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1193.59,-65.56 1195.01,-60.45 1190.72,-63.56 1193.59,-65.56"/>
|
||||||
|
</g>
|
||||||
|
<!-- api_main -->
|
||||||
|
<g id="node19" class="node">
|
||||||
|
<title>api_main</title>
|
||||||
|
<polygon fill="#2a1a1a" stroke="#1e2a4a" points="1409.75,-53.88 1291,-53.88 1291,-17.88 1409.75,-17.88 1409.75,-53.88"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1350.38" y="-39.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">main.py</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1350.38" y="-26.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">FastAPI + WebSocket</text>
|
||||||
|
</g>
|
||||||
|
<!-- api->api_main -->
|
||||||
|
<g id="edge18" class="edge">
|
||||||
|
<title>api->api_main</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1405.74,-107.39C1394.87,-93.55 1379.58,-74.07 1367.82,-59.09"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1369.38,-58.24 1364.91,-55.39 1366.63,-60.4 1369.38,-58.24"/>
|
||||||
|
</g>
|
||||||
|
<!-- api_routes -->
|
||||||
|
<g id="node20" class="node">
|
||||||
|
<title>api_routes</title>
|
||||||
|
<polygon fill="#2a1a1a" stroke="#1e2a4a" points="1548.88,-53.88 1427.88,-53.88 1427.88,-17.88 1548.88,-17.88 1548.88,-53.88"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1488.38" y="-39.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">routes/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1488.38" y="-26.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">agents, scenarios, ws</text>
|
||||||
|
</g>
|
||||||
|
<!-- api->api_routes -->
|
||||||
|
<g id="edge19" class="edge">
|
||||||
|
<title>api->api_routes</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1433.01,-107.39C1443.88,-93.55 1459.17,-74.07 1470.93,-59.09"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1472.12,-60.4 1473.84,-55.39 1469.37,-58.24 1472.12,-60.4"/>
|
||||||
|
</g>
|
||||||
|
<!-- ui_fw -->
|
||||||
|
<g id="node21" class="node">
|
||||||
|
<title>ui_fw</title>
|
||||||
|
<polygon fill="#2a2a0d" stroke="#1e2a4a" points="1696,-59 1566.75,-59 1566.75,-12.75 1696,-12.75 1696,-59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1631.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">framework/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1631.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">soleprint-ui</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1631.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">(shared component lib)</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui_root->ui_fw -->
|
||||||
|
<g id="edge20" class="edge">
|
||||||
|
<title>ui_root->ui_fw</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1631.38,-107.39C1631.38,-95.42 1631.38,-79.23 1631.38,-65.38"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1633.13,-65.72 1631.38,-60.72 1629.63,-65.72 1633.13,-65.72"/>
|
||||||
|
</g>
|
||||||
|
<!-- ui_app -->
|
||||||
|
<g id="node22" class="node">
|
||||||
|
<title>ui_app</title>
|
||||||
|
<polygon fill="#2a2a0d" stroke="#1e2a4a" points="1828.5,-65.38 1714.25,-65.38 1714.25,-6.38 1828.5,-6.38 1828.5,-65.38"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1771.38" y="-51.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">app/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1771.38" y="-39.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Vue 3 SPA</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1771.38" y="-26.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">pages/ components/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1771.38" y="-13.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">mars-tokens.css</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui_root->ui_app -->
|
||||||
|
<g id="edge21" class="edge">
|
||||||
|
<title>ui_root->ui_app</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1658.7,-107.6C1676.09,-96.69 1699.15,-82.21 1719.88,-69.2"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1720.79,-70.69 1724.1,-66.55 1718.93,-67.73 1720.79,-70.69"/>
|
||||||
|
</g>
|
||||||
|
<!-- ctrl_docker -->
|
||||||
|
<g id="node23" class="node">
|
||||||
|
<title>ctrl_docker</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="1964.38,-65.38 1846.38,-65.38 1846.38,-6.38 1964.38,-6.38 1964.38,-65.38"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1905.38" y="-51.88" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Dockerfile.api</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1905.38" y="-39.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Dockerfile.ui</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1905.38" y="-26.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">nginx.conf</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1905.38" y="-13.62" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">docker-compose.yml</text>
|
||||||
|
</g>
|
||||||
|
<!-- ctrl->ctrl_docker -->
|
||||||
|
<g id="edge22" class="edge">
|
||||||
|
<title>ctrl->ctrl_docker</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1905.38,-107.39C1905.38,-97.25 1905.38,-84.09 1905.38,-71.89"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="1907.13,-72.13 1905.38,-67.13 1903.63,-72.13 1907.13,-72.13"/>
|
||||||
|
</g>
|
||||||
|
<!-- ctrl_k8s -->
|
||||||
|
<g id="node24" class="node">
|
||||||
|
<title>ctrl_k8s</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="2094,-59 1982.75,-59 1982.75,-12.75 2094,-12.75 2094,-59"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="2038.38" y="-45.5" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">k8s/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="2038.38" y="-32.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">base/ overlays/dev/</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="2038.38" y="-20" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">kind-config.yaml</text>
|
||||||
|
</g>
|
||||||
|
<!-- ctrl->ctrl_k8s -->
|
||||||
|
<g id="edge23" class="edge">
|
||||||
|
<title>ctrl->ctrl_k8s</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1931.65,-107.39C1950.91,-94.66 1977.39,-77.17 1999.15,-62.79"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="2000.1,-64.26 2003.31,-60.04 1998.17,-61.34 2000.1,-64.26"/>
|
||||||
|
</g>
|
||||||
|
<!-- ctrl_tilt -->
|
||||||
|
<g id="node25" class="node">
|
||||||
|
<title>ctrl_tilt</title>
|
||||||
|
<polygon fill="#1a1a2a" stroke="#1e2a4a" points="2199,-53.88 2111.75,-53.88 2111.75,-17.88 2199,-17.88 2199,-53.88"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="2155.38" y="-39.12" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Tiltfile</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="2155.38" y="-26.38" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">tilt_config.json</text>
|
||||||
|
</g>
|
||||||
|
<!-- ctrl->ctrl_tilt -->
|
||||||
|
<g id="edge24" class="edge">
|
||||||
|
<title>ctrl->ctrl_tilt</title>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M1932.63,-111.11C1935.55,-109.88 1938.5,-108.74 1941.38,-107.75 2011.15,-83.86 2035.58,-100.79 2103.38,-71.75 2111.9,-68.1 2120.52,-63.01 2128.22,-57.84"/>
|
||||||
|
<polygon fill="#1e2a4a" stroke="#1e2a4a" points="2129.03,-59.41 2132.15,-55.13 2127.04,-56.53 2129.03,-59.41"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 23 KiB |
104
docs/graphs/system_architecture.dot
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
digraph system_architecture {
|
||||||
|
rankdir=TB
|
||||||
|
bgcolor="#0a0e17"
|
||||||
|
fontname="Helvetica"
|
||||||
|
node [fontname="Helvetica" fontsize=11 style=filled color="#1e2a4a" fontcolor="#e8eaf0"]
|
||||||
|
edge [fontname="Helvetica" fontsize=9 fontcolor="#8892a8" color="#4a5568"]
|
||||||
|
|
||||||
|
label="System Architecture"
|
||||||
|
labelloc=t
|
||||||
|
fontsize=16
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
|
||||||
|
subgraph cluster_external {
|
||||||
|
label="External"
|
||||||
|
style=dashed
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
|
||||||
|
kong [label="Kong Konnect\n(API Gateway)" fillcolor="#243056" shape=octagon]
|
||||||
|
bedrock [label="AWS Bedrock\n(Claude Sonnet)" fillcolor="#243056" shape=octagon]
|
||||||
|
openmeteo [label="OpenMeteo\n(Live Weather)" fillcolor="#1a3a1a" shape=octagon fontcolor="#00c853"]
|
||||||
|
faa [label="FAA NASSTATUS\n(Live Airport Status)" fillcolor="#1a3a1a" shape=octagon fontcolor="#00c853"]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_app {
|
||||||
|
label="Application (EC2 / Kind Cluster)"
|
||||||
|
style=dashed
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#8892a8"
|
||||||
|
|
||||||
|
subgraph cluster_frontend {
|
||||||
|
label="Frontend"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
ui [label="Vue 3 SPA\n(NOVA UI)\nPort 8040" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_api {
|
||||||
|
label="API Layer"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
fastapi [label="FastAPI\n+ WebSocket\nPort 8000" fillcolor="#121829" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_agents {
|
||||||
|
label="Agent Clients"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
efhas [label="FCE Agent\n(Passenger)" fillcolor="#1a1a3a" shape=box]
|
||||||
|
handover [label="Handover Agent\n(Ops)" fillcolor="#1a1a3a" shape=box]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_mcp {
|
||||||
|
label="MCP Servers (stdio)"
|
||||||
|
color="#0066ff"
|
||||||
|
fontcolor="#0066ff"
|
||||||
|
shared [label="shared\nflights · weather · airport\nmaintenance" fillcolor="#0d1a33" shape=component]
|
||||||
|
ops [label="ops\ncrew · rebookings\nnarrative" fillcolor="#0d1a33" shape=component]
|
||||||
|
passenger [label="passenger\nnotification" fillcolor="#0d1a33" shape=component]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_data {
|
||||||
|
label="Data Layer"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
scenarios [label="Scenario Manager\n4 scenarios" fillcolor="#121829" shape=cylinder]
|
||||||
|
mock [label="Mock Data\n(Pydantic models)" fillcolor="#121829" shape=cylinder]
|
||||||
|
}
|
||||||
|
|
||||||
|
subgraph cluster_obs {
|
||||||
|
label="Observability"
|
||||||
|
color="#1e2a4a"
|
||||||
|
fontcolor="#4a5568"
|
||||||
|
langfuse [label="Langfuse\nPort 3000" fillcolor="#121829" shape=box]
|
||||||
|
postgres [label="PostgreSQL" fillcolor="#121829" shape=cylinder]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connections
|
||||||
|
ui -> kong [label="HTTP" style=dashed]
|
||||||
|
kong -> fastapi [label="proxy"]
|
||||||
|
ui -> fastapi [label="WebSocket" color="#0066ff"]
|
||||||
|
|
||||||
|
fastapi -> efhas [label="trigger"]
|
||||||
|
fastapi -> handover [label="trigger"]
|
||||||
|
|
||||||
|
efhas -> shared [label="MCP" color="#0066ff"]
|
||||||
|
efhas -> passenger [label="MCP" color="#0066ff"]
|
||||||
|
handover -> shared [label="MCP" color="#0066ff"]
|
||||||
|
handover -> ops [label="MCP" color="#0066ff"]
|
||||||
|
|
||||||
|
shared -> openmeteo [label="HTTP" color="#00c853"]
|
||||||
|
shared -> faa [label="HTTP" color="#00c853"]
|
||||||
|
shared -> scenarios
|
||||||
|
shared -> mock
|
||||||
|
|
||||||
|
ops -> mock
|
||||||
|
ops -> scenarios
|
||||||
|
ops -> bedrock [label="Converse API" style=dashed]
|
||||||
|
passenger -> bedrock [label="Converse API" style=dashed]
|
||||||
|
|
||||||
|
langfuse -> postgres
|
||||||
|
fastapi -> langfuse [label="traces" style=dotted color="#8892a8"]
|
||||||
|
}
|
||||||
299
docs/graphs/system_architecture.svg
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Generated by graphviz version 14.1.2 (0)
|
||||||
|
-->
|
||||||
|
<!-- Title: system_architecture Pages: 1 -->
|
||||||
|
<svg width="1298pt" height="662pt"
|
||||||
|
viewBox="0.00 0.00 1298.00 662.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 657.85)">
|
||||||
|
<title>system_architecture</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="none" points="-4,4 -4,-657.85 1294,-657.85 1294,4 -4,4"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="645" y="-634.65" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#0066ff">System Architecture</text>
|
||||||
|
<g id="clust1" class="cluster">
|
||||||
|
<title>cluster_external</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="561,-13.27 561,-110.35 1282,-110.35 1282,-13.27 561,-13.27"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="921.5" y="-91.15" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">External</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust2" class="cluster">
|
||||||
|
<title>cluster_app</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="8,-8 8,-618.35 553,-618.35 553,-8 8,-8"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="280.5" y="-599.15" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#8892a8">Application (EC2 / Kind Cluster)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust3" class="cluster">
|
||||||
|
<title>cluster_frontend</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="449,-490.85 449,-582.85 536,-582.85 536,-490.85 449,-490.85"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="492.5" y="-563.65" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#4a5568">Frontend</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust4" class="cluster">
|
||||||
|
<title>cluster_api</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="428,-369.6 428,-461.6 534,-461.6 534,-369.6 428,-369.6"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="481" y="-442.4" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#4a5568">API Layer</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust5" class="cluster">
|
||||||
|
<title>cluster_agents</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="183,-260.85 183,-340.35 404,-340.35 404,-260.85 183,-260.85"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="293.5" y="-321.15" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#4a5568">Agent Clients</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust6" class="cluster">
|
||||||
|
<title>cluster_mcp</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#0066ff" stroke-dasharray="5,2" points="16,-139.6 16,-231.6 414,-231.6 414,-139.6 16,-139.6"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="215" y="-212.4" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#0066ff">MCP Servers (stdio)</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust7" class="cluster">
|
||||||
|
<title>cluster_data</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="19,-16 19,-107.62 284,-107.62 284,-16 19,-16"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="151.5" y="-88.42" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#4a5568">Data Layer</text>
|
||||||
|
</g>
|
||||||
|
<g id="clust8" class="cluster">
|
||||||
|
<title>cluster_obs</title>
|
||||||
|
<polygon fill="#0a0e17" stroke="#1e2a4a" stroke-dasharray="5,2" points="422,-145.85 422,-340.35 545,-340.35 545,-145.85 422,-145.85"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="483.5" y="-321.15" font-family="Helvetica,sans-Serif" font-size="16.00" fill="#4a5568">Observability</text>
|
||||||
|
</g>
|
||||||
|
<!-- kong -->
|
||||||
|
<g id="node1" class="node">
|
||||||
|
<title>kong</title>
|
||||||
|
<polygon fill="#243056" stroke="#1e2a4a" points="1273.52,-36.97 1273.52,-59.16 1231.04,-74.85 1170.96,-74.85 1128.48,-59.16 1128.48,-36.97 1170.96,-21.27 1231.04,-21.27 1273.52,-36.97"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1201" y="-51.11" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Kong Konnect</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1201" y="-37.61" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(API Gateway)</text>
|
||||||
|
</g>
|
||||||
|
<!-- fastapi -->
|
||||||
|
<g id="node6" class="node">
|
||||||
|
<title>fastapi</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="525.75,-426.1 436.25,-426.1 436.25,-377.6 525.75,-377.6 525.75,-426.1"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="481" y="-411.65" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">FastAPI</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="481" y="-398.15" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">+ WebSocket</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="481" y="-384.65" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Port 8000</text>
|
||||||
|
</g>
|
||||||
|
<!-- kong->fastapi -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>kong->fastapi</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M1174.51,-75.16C1159.72,-88.07 1140.23,-102.52 1120,-110.35 1083.37,-124.52 1069.72,-107.41 1032,-118.35 830.03,-176.91 614.99,-310.93 524.79,-370.89"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="523.06,-367.84 516.69,-376.3 526.95,-373.66 523.06,-367.84"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="752.89" y="-242.3" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">proxy</text>
|
||||||
|
</g>
|
||||||
|
<!-- bedrock -->
|
||||||
|
<g id="node2" class="node">
|
||||||
|
<title>bedrock</title>
|
||||||
|
<polygon fill="#243056" stroke="#1e2a4a" points="896.98,-36.97 896.98,-59.16 850.13,-74.85 783.87,-74.85 737.02,-59.16 737.02,-36.97 783.87,-21.27 850.13,-21.27 896.98,-36.97"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="817" y="-51.11" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">AWS Bedrock</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="817" y="-37.61" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(Claude Sonnet)</text>
|
||||||
|
</g>
|
||||||
|
<!-- openmeteo -->
|
||||||
|
<g id="node3" class="node">
|
||||||
|
<title>openmeteo</title>
|
||||||
|
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="718.81,-36.97 718.81,-59.16 674.99,-74.85 613.01,-74.85 569.19,-59.16 569.19,-36.97 613.01,-21.27 674.99,-21.27 718.81,-36.97"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="644" y="-51.11" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">OpenMeteo</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="644" y="-37.61" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">(Live Weather)</text>
|
||||||
|
</g>
|
||||||
|
<!-- faa -->
|
||||||
|
<g id="node4" class="node">
|
||||||
|
<title>faa</title>
|
||||||
|
<polygon fill="#1a3a1a" stroke="#1e2a4a" points="1110.78,-36.97 1110.78,-59.16 1053.5,-74.85 972.5,-74.85 915.22,-59.16 915.22,-36.97 972.5,-21.27 1053.5,-21.27 1110.78,-36.97"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1013" y="-51.11" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">FAA NASSTATUS</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1013" y="-37.61" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#00c853">(Live Airport Status)</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui -->
|
||||||
|
<g id="node5" class="node">
|
||||||
|
<title>ui</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="527.38,-547.35 456.62,-547.35 456.62,-498.85 527.38,-498.85 527.38,-547.35"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="492" y="-532.9" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Vue 3 SPA</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="492" y="-519.4" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(NOVA UI)</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="492" y="-505.9" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Port 8040</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui->kong -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>ui->kong</title>
|
||||||
|
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M527.5,-522.07C649.77,-521 1046,-509.35 1046,-402.85 1046,-402.85 1046,-402.85 1046,-170.85 1046,-167.26 1112.7,-115.97 1158.59,-81.11"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="1160.69,-83.91 1166.54,-75.08 1156.46,-78.33 1160.69,-83.91"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="1057.25" y="-283.93" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">HTTP</text>
|
||||||
|
</g>
|
||||||
|
<!-- ui->fastapi -->
|
||||||
|
<g id="edge3" class="edge">
|
||||||
|
<title>ui->fastapi</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M489.83,-498.54C488.22,-481.15 486.01,-457.19 484.21,-437.6"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="487.71,-437.5 483.31,-427.87 480.74,-438.15 487.71,-437.5"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="513.25" y="-472.3" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">WebSocket</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas -->
|
||||||
|
<g id="node7" class="node">
|
||||||
|
<title>efhas</title>
|
||||||
|
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="395.62,-304.85 314.38,-304.85 314.38,-268.85 395.62,-268.85 395.62,-304.85"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="355" y="-289.9" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">FCE Agent</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="355" y="-276.4" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(Passenger)</text>
|
||||||
|
</g>
|
||||||
|
<!-- fastapi->efhas -->
|
||||||
|
<g id="edge4" class="edge">
|
||||||
|
<title>fastapi->efhas</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M454.59,-377.16C433.53,-358.28 404.13,-331.91 382.73,-312.72"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="385.3,-310.32 375.51,-306.25 380.62,-315.53 385.3,-310.32"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="448.37" y="-351.05" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">trigger</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover -->
|
||||||
|
<g id="node8" class="node">
|
||||||
|
<title>handover</title>
|
||||||
|
<polygon fill="#1a1a3a" stroke="#1e2a4a" points="296.62,-304.85 191.38,-304.85 191.38,-268.85 296.62,-268.85 296.62,-304.85"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="244" y="-289.9" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Handover Agent</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="244" y="-276.4" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(Ops)</text>
|
||||||
|
</g>
|
||||||
|
<!-- fastapi->handover -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>fastapi->handover</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M435.98,-390.44C399.18,-380.76 346.62,-364.23 305,-340.35 292.03,-332.91 279.24,-322.51 268.75,-312.93"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="271.3,-310.52 261.63,-306.2 266.49,-315.61 271.3,-310.52"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="356.75" y="-351.05" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">trigger</text>
|
||||||
|
</g>
|
||||||
|
<!-- langfuse -->
|
||||||
|
<g id="node14" class="node">
|
||||||
|
<title>langfuse</title>
|
||||||
|
<polygon fill="#121829" stroke="#1e2a4a" points="515.25,-304.85 446.75,-304.85 446.75,-268.85 515.25,-268.85 515.25,-304.85"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="481" y="-289.9" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Langfuse</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="481" y="-276.4" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Port 3000</text>
|
||||||
|
</g>
|
||||||
|
<!-- fastapi->langfuse -->
|
||||||
|
<g id="edge19" class="edge">
|
||||||
|
<title>fastapi->langfuse</title>
|
||||||
|
<path fill="none" stroke="#8892a8" stroke-dasharray="1,5" d="M481,-377.16C481,-359.54 481,-335.39 481,-316.65"/>
|
||||||
|
<polygon fill="#8892a8" stroke="#8892a8" points="484.5,-316.75 481,-306.75 477.5,-316.75 484.5,-316.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="494.88" y="-351.05" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">traces</text>
|
||||||
|
</g>
|
||||||
|
<!-- shared -->
|
||||||
|
<g id="node9" class="node">
|
||||||
|
<title>shared</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="310,-196.1 156,-196.1 156,-192.1 152,-192.1 152,-188.1 156,-188.1 156,-155.6 152,-155.6 152,-151.6 156,-151.6 156,-147.6 310,-147.6 310,-196.1"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="156,-192.1 160,-192.1 160,-188.1 156,-188.1"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="156,-155.6 160,-155.6 160,-151.6 156,-151.6"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="233" y="-181.65" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">shared</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="233" y="-168.15" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">flights · weather · airport</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="233" y="-154.65" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">maintenance</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->shared -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>efhas->shared</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M336.2,-268.44C317.71,-251.31 289.07,-224.79 266.65,-204.02"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="269.22,-201.63 259.5,-197.4 264.46,-206.76 269.22,-201.63"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="324.91" y="-242.3" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">MCP</text>
|
||||||
|
</g>
|
||||||
|
<!-- passenger -->
|
||||||
|
<g id="node11" class="node">
|
||||||
|
<title>passenger</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="405.75,-189.85 328.25,-189.85 328.25,-185.85 324.25,-185.85 324.25,-181.85 328.25,-181.85 328.25,-161.85 324.25,-161.85 324.25,-157.85 328.25,-157.85 328.25,-153.85 405.75,-153.85 405.75,-189.85"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="328.25,-185.85 332.25,-185.85 332.25,-181.85 328.25,-181.85"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="328.25,-161.85 332.25,-161.85 332.25,-157.85 328.25,-157.85"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="367" y="-174.9" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">passenger</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="367" y="-161.4" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">notification</text>
|
||||||
|
</g>
|
||||||
|
<!-- efhas->passenger -->
|
||||||
|
<g id="edge7" class="edge">
|
||||||
|
<title>efhas->passenger</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M356.82,-268.69C358.72,-250.85 361.71,-222.63 363.97,-201.42"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="367.42,-201.99 365,-191.68 360.46,-201.25 367.42,-201.99"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="368.95" y="-242.3" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">MCP</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->shared -->
|
||||||
|
<g id="edge8" class="edge">
|
||||||
|
<title>handover->shared</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M242.33,-268.69C240.76,-252.59 238.37,-228.02 236.4,-207.79"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="239.9,-207.6 235.45,-197.99 232.93,-208.28 239.9,-207.6"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="249.82" y="-242.3" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">MCP</text>
|
||||||
|
</g>
|
||||||
|
<!-- ops -->
|
||||||
|
<g id="node10" class="node">
|
||||||
|
<title>ops</title>
|
||||||
|
<polygon fill="#0d1a33" stroke="#1e2a4a" points="137.75,-196.1 24.25,-196.1 24.25,-192.1 20.25,-192.1 20.25,-188.1 24.25,-188.1 24.25,-155.6 20.25,-155.6 20.25,-151.6 24.25,-151.6 24.25,-147.6 137.75,-147.6 137.75,-196.1"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="24.25,-192.1 28.25,-192.1 28.25,-188.1 24.25,-188.1"/>
|
||||||
|
<polyline fill="none" stroke="#1e2a4a" points="24.25,-155.6 28.25,-155.6 28.25,-151.6 24.25,-151.6"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="81" y="-181.65" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">ops</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="81" y="-168.15" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">crew · rebookings</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="81" y="-154.65" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">narrative</text>
|
||||||
|
</g>
|
||||||
|
<!-- handover->ops -->
|
||||||
|
<g id="edge9" class="edge">
|
||||||
|
<title>handover->ops</title>
|
||||||
|
<path fill="none" stroke="#0066ff" d="M208.77,-268.52C189.86,-258.64 166.49,-245.48 147,-231.6 135.48,-223.4 123.7,-213.49 113.34,-204.16"/>
|
||||||
|
<polygon fill="#0066ff" stroke="#0066ff" points="115.85,-201.72 106.12,-197.53 111.12,-206.87 115.85,-201.72"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="184.55" y="-242.3" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">MCP</text>
|
||||||
|
</g>
|
||||||
|
<!-- shared->openmeteo -->
|
||||||
|
<g id="edge10" class="edge">
|
||||||
|
<title>shared->openmeteo</title>
|
||||||
|
<path fill="none" stroke="#00c853" d="M291.6,-147.2C300.66,-144.23 310,-141.56 319,-139.6 373.9,-127.68 390.33,-142.51 445,-129.6 496.17,-117.52 551.37,-94.3 590.62,-75.88"/>
|
||||||
|
<polygon fill="#00c853" stroke="#00c853" points="592.11,-79.05 599.64,-71.6 589.1,-72.73 592.11,-79.05"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="495.7" y="-121.05" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">HTTP</text>
|
||||||
|
</g>
|
||||||
|
<!-- shared->faa -->
|
||||||
|
<g id="edge11" class="edge">
|
||||||
|
<title>shared->faa</title>
|
||||||
|
<path fill="none" stroke="#00c853" d="M291.08,-147.13C300.3,-144.13 309.81,-141.47 319,-139.6 402.74,-122.6 426.12,-139.41 511,-129.6 539.04,-126.36 545.42,-121.2 573.5,-118.35 610.27,-114.62 870.4,-120.28 906,-110.35 927.85,-104.25 949.94,-92.7 968.33,-81.26"/>
|
||||||
|
<polygon fill="#00c853" stroke="#00c853" points="970.07,-84.3 976.59,-75.95 966.29,-78.41 970.07,-84.3"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="584.75" y="-121.05" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">HTTP</text>
|
||||||
|
</g>
|
||||||
|
<!-- scenarios -->
|
||||||
|
<g id="node12" class="node">
|
||||||
|
<title>scenarios</title>
|
||||||
|
<path fill="#121829" stroke="#1e2a4a" d="M142.5,-67.75C142.5,-70.16 116.73,-72.12 85,-72.12 53.27,-72.12 27.5,-70.16 27.5,-67.75 27.5,-67.75 27.5,-28.38 27.5,-28.38 27.5,-25.96 53.27,-24 85,-24 116.73,-24 142.5,-25.96 142.5,-28.38 142.5,-28.38 142.5,-67.75 142.5,-67.75"/>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M142.5,-67.75C142.5,-65.34 116.73,-63.38 85,-63.38 53.27,-63.38 27.5,-65.34 27.5,-67.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="85" y="-51.11" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Scenario Manager</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="85" y="-37.61" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">4 scenarios</text>
|
||||||
|
</g>
|
||||||
|
<!-- shared->scenarios -->
|
||||||
|
<g id="edge12" class="edge">
|
||||||
|
<title>shared->scenarios</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M199.23,-147.25C184.17,-136.39 166.39,-123.11 151,-110.35 139.6,-100.9 127.6,-90.06 117.01,-80.15"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="119.63,-77.8 109.96,-73.48 114.82,-82.89 119.63,-77.8"/>
|
||||||
|
</g>
|
||||||
|
<!-- mock -->
|
||||||
|
<g id="node13" class="node">
|
||||||
|
<title>mock</title>
|
||||||
|
<path fill="#121829" stroke="#1e2a4a" d="M275.88,-67.75C275.88,-70.16 249.93,-72.12 218,-72.12 186.07,-72.12 160.12,-70.16 160.12,-67.75 160.12,-67.75 160.12,-28.38 160.12,-28.38 160.12,-25.96 186.07,-24 218,-24 249.93,-24 275.88,-25.96 275.88,-28.38 275.88,-28.38 275.88,-67.75 275.88,-67.75"/>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M275.88,-67.75C275.88,-65.34 249.93,-63.38 218,-63.38 186.07,-63.38 160.12,-65.34 160.12,-67.75"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="218" y="-51.11" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">Mock Data</text>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="218" y="-37.61" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">(Pydantic models)</text>
|
||||||
|
</g>
|
||||||
|
<!-- shared->mock -->
|
||||||
|
<g id="edge13" class="edge">
|
||||||
|
<title>shared->mock</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M230.11,-147.37C227.89,-129.34 224.79,-104.17 222.28,-83.81"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="225.76,-83.47 221.07,-73.97 218.82,-84.33 225.76,-83.47"/>
|
||||||
|
</g>
|
||||||
|
<!-- ops->bedrock -->
|
||||||
|
<g id="edge16" class="edge">
|
||||||
|
<title>ops->bedrock</title>
|
||||||
|
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M125.64,-147.16C132.64,-144.2 139.91,-141.53 147,-139.6 248.39,-111.96 277.58,-124.29 382.5,-118.35 420.84,-116.18 691.47,-122.18 728,-110.35 746.48,-104.37 764.56,-93.2 779.55,-82.03"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="781.51,-84.93 787.27,-76.04 777.22,-79.4 781.51,-84.93"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="411.75" y="-121.05" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">Converse API</text>
|
||||||
|
</g>
|
||||||
|
<!-- ops->scenarios -->
|
||||||
|
<g id="edge15" class="edge">
|
||||||
|
<title>ops->scenarios</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M81.77,-147.37C82.36,-129.34 83.19,-104.17 83.86,-83.81"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="87.35,-84.09 84.18,-73.99 80.35,-83.86 87.35,-84.09"/>
|
||||||
|
</g>
|
||||||
|
<!-- ops->mock -->
|
||||||
|
<g id="edge14" class="edge">
|
||||||
|
<title>ops->mock</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M109.23,-147.27C122.34,-136.19 138.09,-122.73 152,-110.35 162.88,-100.67 174.57,-89.93 185.05,-80.17"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="187.14,-83.02 192.05,-73.63 182.36,-77.9 187.14,-83.02"/>
|
||||||
|
</g>
|
||||||
|
<!-- passenger->bedrock -->
|
||||||
|
<g id="edge17" class="edge">
|
||||||
|
<title>passenger->bedrock</title>
|
||||||
|
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M389.94,-153.46C398.34,-148.03 408.19,-142.66 418,-139.6 456.67,-127.54 560.16,-136.89 600,-129.6 616.28,-126.62 619.27,-121.58 635.5,-118.35 675.97,-110.29 689.16,-124.29 728,-110.35 745.99,-103.89 763.75,-92.87 778.62,-81.94"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="780.44,-84.95 786.28,-76.11 776.2,-79.38 780.44,-84.95"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="664.75" y="-121.05" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">Converse API</text>
|
||||||
|
</g>
|
||||||
|
<!-- postgres -->
|
||||||
|
<g id="node15" class="node">
|
||||||
|
<title>postgres</title>
|
||||||
|
<path fill="#121829" stroke="#1e2a4a" d="M520.5,-186.58C520.5,-188.38 502.8,-189.85 481,-189.85 459.2,-189.85 441.5,-188.38 441.5,-186.58 441.5,-186.58 441.5,-157.12 441.5,-157.12 441.5,-155.32 459.2,-153.85 481,-153.85 502.8,-153.85 520.5,-155.32 520.5,-157.12 520.5,-157.12 520.5,-186.58 520.5,-186.58"/>
|
||||||
|
<path fill="none" stroke="#1e2a4a" d="M520.5,-186.58C520.5,-184.77 502.8,-183.3 481,-183.3 459.2,-183.3 441.5,-184.77 441.5,-186.58"/>
|
||||||
|
<text xml:space="preserve" text-anchor="middle" x="481" y="-168.15" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e8eaf0">PostgreSQL</text>
|
||||||
|
</g>
|
||||||
|
<!-- langfuse->postgres -->
|
||||||
|
<g id="edge18" class="edge">
|
||||||
|
<title>langfuse->postgres</title>
|
||||||
|
<path fill="none" stroke="#4a5568" d="M481,-268.69C481,-250.85 481,-222.63 481,-201.42"/>
|
||||||
|
<polygon fill="#4a5568" stroke="#4a5568" points="484.5,-201.69 481,-191.69 477.5,-201.69 484.5,-201.69"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 22 KiB |
254
docs/index.html
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Stellar Air — NOVA Platform Architecture</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #0a0e17;
|
||||||
|
color: #e8eaf0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #1e2a4a;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: #0066ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4a5568;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #121829;
|
||||||
|
border-right: 1px solid #1e2a4a;
|
||||||
|
padding: 8px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8892a8;
|
||||||
|
text-decoration: none;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover { color: #e8eaf0; background: #1a2340; }
|
||||||
|
nav a.active { color: #0066ff; border-left-color: #0066ff; background: #0d1a33; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 32px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-section {
|
||||||
|
display: none;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-section.active { display: block; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-section h2 {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #8892a8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-section p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-container {
|
||||||
|
background: #0a0e17;
|
||||||
|
border: 1px solid #1e2a4a;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend span::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend .live::before { background: #00c853; }
|
||||||
|
.legend .mock::before { background: #ffc107; }
|
||||||
|
.legend .mcp::before { background: #0066ff; }
|
||||||
|
.legend .ops::before { background: #ff3d00; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>STELLAR AIR</h1>
|
||||||
|
<span class="subtitle">NOVA Operations Platform — Architecture</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a class="active" onclick="show('system')">System</a>
|
||||||
|
<a onclick="show('mcp')">MCP Servers</a>
|
||||||
|
<a onclick="show('efhas')">FCE Agent</a>
|
||||||
|
<a onclick="show('handover')">Handover Agent</a>
|
||||||
|
<a onclick="show('data')">Data Flow</a>
|
||||||
|
<a onclick="show('deploy')">Deployment</a>
|
||||||
|
<a onclick="show('repo')">Repository</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section id="system" class="graph-section active">
|
||||||
|
<h2>SYSTEM ARCHITECTURE</h2>
|
||||||
|
<p>End-to-end view: Vue UI → Kong gateway → FastAPI → MCP servers → live and scenario data sources. Langfuse traces every agent run.</p>
|
||||||
|
<div class="graph-container">
|
||||||
|
<img src="graphs/system_architecture.svg" alt="System Architecture">
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="live">Live API</span>
|
||||||
|
<span class="mock">Scenario data</span>
|
||||||
|
<span class="mcp">MCP protocol</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="mcp" class="graph-section">
|
||||||
|
<h2>MCP SERVER TOPOLOGY</h2>
|
||||||
|
<p>Three servers scoped by access domain. Each exposes tools, resources, and prompts. FCE connects to shared + passenger. Handover connects to shared + ops.</p>
|
||||||
|
<div class="graph-container">
|
||||||
|
<img src="graphs/mcp_servers.svg" alt="MCP Servers">
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="mcp">Shared server</span>
|
||||||
|
<span class="ops">Ops server</span>
|
||||||
|
<span class="live">Passenger server</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="efhas" class="graph-section">
|
||||||
|
<h2>FCE AGENT — BEHIND EVERY DEPARTURE</h2>
|
||||||
|
<p>Passenger notification agent. Triages flight status, gathers context from 5 parallel tool calls (including live weather and FAA data), synthesizes an empathetic notification.</p>
|
||||||
|
<div class="graph-container">
|
||||||
|
<img src="graphs/efhas_agent.svg" alt="FCE Agent">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="handover" class="graph-section">
|
||||||
|
<h2>SHIFT HANDOVER AGENT</h2>
|
||||||
|
<p>Ops briefing agent. Scans all hubs in parallel, scores issues by severity × time sensitivity, categorizes into IMMEDIATE / MONITOR / FYI, generates a structured brief.</p>
|
||||||
|
<div class="graph-container">
|
||||||
|
<img src="graphs/handover_agent.svg" alt="Handover Agent">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="data" class="graph-section">
|
||||||
|
<h2>DATA FLOW — REAL vs MOCK</h2>
|
||||||
|
<p>Weather and FAA airport status are live (no API key). Flight, crew, passenger, and maintenance data are scenario-based fixtures switchable from the UI.</p>
|
||||||
|
<div class="graph-container">
|
||||||
|
<img src="graphs/data_flow.svg" alt="Data Flow">
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="live">Live data (no API key)</span>
|
||||||
|
<span class="mock">Scenario data (switchable)</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="deploy" class="graph-section">
|
||||||
|
<h2>DEPLOYMENT</h2>
|
||||||
|
<p>Kind cluster for dev (Tilt), docker-compose for quick start, EC2 for production demo. Entry point: localhost:8040.</p>
|
||||||
|
<div class="graph-container">
|
||||||
|
<img src="graphs/deployment.svg" alt="Deployment">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="repo" class="graph-section">
|
||||||
|
<h2>REPOSITORY STRUCTURE</h2>
|
||||||
|
<p>Monorepo: MCP servers, agents, IRROP engine, API, Vue UI (with shared component framework), and deployment configs.</p>
|
||||||
|
<div class="graph-container">
|
||||||
|
<img src="graphs/repo_structure.svg" alt="Repository Structure">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function show(id) {
|
||||||
|
document.querySelectorAll('.graph-section').forEach(s => s.classList.remove('active'));
|
||||||
|
document.querySelectorAll('nav a').forEach(a => a.classList.remove('active'));
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
event.currentTarget.classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
irrop/__init__.py
Normal file
0
irrop/models/__init__.py
Normal file
0
irrop/pipeline/__init__.py
Normal file
0
irrop/rules/__init__.py
Normal file
0
mcp_servers/__init__.py
Normal file
0
mcp_servers/data/__init__.py
Normal file
0
mcp_servers/data/mock/__init__.py
Normal file
137
mcp_servers/data/models.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Pydantic models for all operational data."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class FlightStatus(str, Enum):
|
||||||
|
ON_TIME = "ON_TIME"
|
||||||
|
DELAYED = "DELAYED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
DIVERTED = "DIVERTED"
|
||||||
|
|
||||||
|
|
||||||
|
class DelayCause(str, Enum):
|
||||||
|
WEATHER = "WEATHER"
|
||||||
|
MAINTENANCE = "MAINTENANCE"
|
||||||
|
CREW = "CREW"
|
||||||
|
ATC = "ATC"
|
||||||
|
LATE_AIRCRAFT = "LATE_AIRCRAFT"
|
||||||
|
|
||||||
|
|
||||||
|
class CrewRole(str, Enum):
|
||||||
|
CAPTAIN = "CAPTAIN"
|
||||||
|
FIRST_OFFICER = "FIRST_OFFICER"
|
||||||
|
FA = "FA"
|
||||||
|
|
||||||
|
|
||||||
|
class MPStatus(str, Enum):
|
||||||
|
GLOBAL_SERVICES = "GLOBAL_SERVICES"
|
||||||
|
K1 = "1K"
|
||||||
|
PLATINUM = "PLATINUM"
|
||||||
|
GOLD = "GOLD"
|
||||||
|
SILVER = "SILVER"
|
||||||
|
GENERAL = "GENERAL"
|
||||||
|
|
||||||
|
|
||||||
|
class FlightData(BaseModel):
|
||||||
|
flight_id: str
|
||||||
|
origin: str
|
||||||
|
destination: str
|
||||||
|
scheduled_departure: datetime
|
||||||
|
actual_departure: datetime | None = None
|
||||||
|
scheduled_arrival: datetime
|
||||||
|
actual_arrival: datetime | None = None
|
||||||
|
status: FlightStatus
|
||||||
|
delay_minutes: int = 0
|
||||||
|
delay_cause: DelayCause | None = None
|
||||||
|
aircraft_tail: str
|
||||||
|
gate: str
|
||||||
|
inbound_flight: str | None = None
|
||||||
|
crew_ids: list[str] = []
|
||||||
|
passenger_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CrewMember(BaseModel):
|
||||||
|
crew_id: str
|
||||||
|
name: str
|
||||||
|
role: CrewRole
|
||||||
|
duty_hours_elapsed: float
|
||||||
|
duty_hours_limit: float
|
||||||
|
rest_hours_since_last: float
|
||||||
|
next_scheduled_flight: str | None = None
|
||||||
|
base_hub: str
|
||||||
|
|
||||||
|
|
||||||
|
class Passenger(BaseModel):
|
||||||
|
pax_id: str
|
||||||
|
name: str
|
||||||
|
mileage_plus_status: MPStatus
|
||||||
|
flight_id: str
|
||||||
|
destination: str
|
||||||
|
connection_flight: str | None = None
|
||||||
|
connection_deadline: datetime | None = None
|
||||||
|
special_needs: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MELItem(BaseModel):
|
||||||
|
mel_id: str
|
||||||
|
aircraft_tail: str
|
||||||
|
system: str
|
||||||
|
description: str
|
||||||
|
restriction: str | None = None
|
||||||
|
expires: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RebookingCase(BaseModel):
|
||||||
|
pax_id: str
|
||||||
|
name: str
|
||||||
|
original_flight: str
|
||||||
|
mileage_plus_status: MPStatus
|
||||||
|
destination: str
|
||||||
|
next_available: str | None = None
|
||||||
|
urgency: str # HIGH | MEDIUM | LOW
|
||||||
|
|
||||||
|
|
||||||
|
class HubInfo(BaseModel):
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
city: str
|
||||||
|
timezone: str
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
terminals: int
|
||||||
|
gates: int
|
||||||
|
runways: int
|
||||||
|
|
||||||
|
|
||||||
|
# Hub reference data
|
||||||
|
HUBS: dict[str, HubInfo] = {
|
||||||
|
"ORD": HubInfo(
|
||||||
|
code="ORD", name="O'Hare International", city="Chicago",
|
||||||
|
timezone="America/Chicago", latitude=41.9742, longitude=-87.9073,
|
||||||
|
terminals=4, gates=191, runways=8,
|
||||||
|
),
|
||||||
|
"EWR": HubInfo(
|
||||||
|
code="EWR", name="Newark Liberty International", city="Newark",
|
||||||
|
timezone="America/New_York", latitude=40.6895, longitude=-74.1745,
|
||||||
|
terminals=3, gates=120, runways=3,
|
||||||
|
),
|
||||||
|
"IAH": HubInfo(
|
||||||
|
code="IAH", name="George Bush Intercontinental", city="Houston",
|
||||||
|
timezone="America/Chicago", latitude=29.9902, longitude=-95.3368,
|
||||||
|
terminals=5, gates=130, runways=5,
|
||||||
|
),
|
||||||
|
"SFO": HubInfo(
|
||||||
|
code="SFO", name="San Francisco International", city="San Francisco",
|
||||||
|
timezone="America/Los_Angeles", latitude=37.6213, longitude=-122.3790,
|
||||||
|
terminals=4, gates=115, runways=4,
|
||||||
|
),
|
||||||
|
"DEN": HubInfo(
|
||||||
|
code="DEN", name="Denver International", city="Denver",
|
||||||
|
timezone="America/Denver", latitude=39.8561, longitude=-104.6737,
|
||||||
|
terminals=1, gates=95, runways=6,
|
||||||
|
),
|
||||||
|
}
|
||||||
0
mcp_servers/data/real/__init__.py
Normal file
124
mcp_servers/data/real/faa.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""FAA NASSTATUS API client — live airport delay/status data, no API key required.
|
||||||
|
|
||||||
|
Endpoint: https://nasstatus.faa.gov/api/airport-status-information
|
||||||
|
Returns XML with ground stops, ground delay programs, arrival/departure delays, closures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "https://nasstatus.faa.gov/api/airport-status-information"
|
||||||
|
|
||||||
|
# Cache parsed data to avoid hitting the API for every airport query
|
||||||
|
_cache: dict | None = None
|
||||||
|
_cache_raw: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_all_status() -> dict[str, list[dict]]:
|
||||||
|
"""Fetch and parse all airport status information from FAA."""
|
||||||
|
global _cache, _cache_raw
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(BASE_URL)
|
||||||
|
resp.raise_for_status()
|
||||||
|
xml_text = resp.text
|
||||||
|
|
||||||
|
# Don't re-parse if unchanged
|
||||||
|
if xml_text == _cache_raw and _cache is not None:
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
_cache_raw = xml_text
|
||||||
|
_cache = _parse_status_xml(xml_text)
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"_error": [{"error": str(e)}]}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status_xml(xml_text: str) -> dict[str, list[dict]]:
|
||||||
|
"""Parse FAA airport status XML into a dict of airport → delays."""
|
||||||
|
delays_by_airport: dict[str, list[dict]] = {}
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
|
||||||
|
for delay_type in root.findall("Delay_type"):
|
||||||
|
name = delay_type.findtext("Name", "").strip()
|
||||||
|
|
||||||
|
# Ground Stop Programs
|
||||||
|
for program in delay_type.findall(".//Program"):
|
||||||
|
arpt = program.findtext("ARPT", "").strip()
|
||||||
|
if not arpt:
|
||||||
|
continue
|
||||||
|
delays_by_airport.setdefault(arpt, []).append({
|
||||||
|
"type": "ground_stop",
|
||||||
|
"reason": program.findtext("Reason", "unknown").strip(),
|
||||||
|
"end_time": program.findtext("End_Time", "").strip() or None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Ground Delay Programs
|
||||||
|
for gdp in delay_type.findall(".//Ground_Delay"):
|
||||||
|
arpt = gdp.findtext("ARPT", "").strip()
|
||||||
|
if not arpt:
|
||||||
|
continue
|
||||||
|
delays_by_airport.setdefault(arpt, []).append({
|
||||||
|
"type": "ground_delay_program",
|
||||||
|
"reason": gdp.findtext("Reason", "unknown").strip(),
|
||||||
|
"average_delay": gdp.findtext("Avg", "").strip() or None,
|
||||||
|
"max_delay": gdp.findtext("Max", "").strip() or None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Arrival/Departure Delays
|
||||||
|
for delay in delay_type.findall(".//Delay"):
|
||||||
|
arpt = delay.findtext("ARPT", "").strip()
|
||||||
|
if not arpt:
|
||||||
|
continue
|
||||||
|
delays_by_airport.setdefault(arpt, []).append({
|
||||||
|
"type": name.lower().replace(" ", "_") if name else "delay",
|
||||||
|
"reason": delay.findtext("Reason", "unknown").strip(),
|
||||||
|
"average_delay": delay.findtext("Avg", "").strip() or None,
|
||||||
|
"max_delay": delay.findtext("Max", "").strip() or None,
|
||||||
|
"trend": delay.findtext("Trend", "").strip() or None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Closures
|
||||||
|
for closure in delay_type.findall(".//Closure"):
|
||||||
|
arpt = closure.findtext("ARPT", "").strip()
|
||||||
|
if not arpt:
|
||||||
|
continue
|
||||||
|
delays_by_airport.setdefault(arpt, []).append({
|
||||||
|
"type": "closure",
|
||||||
|
"reason": closure.findtext("Reason", "unknown").strip(),
|
||||||
|
"begin": closure.findtext("Begin", "").strip() or None,
|
||||||
|
"end": closure.findtext("End", "").strip() or None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return delays_by_airport
|
||||||
|
|
||||||
|
|
||||||
|
async def get_airport_status(airport_code: str) -> dict:
|
||||||
|
"""Fetch live FAA airport status for a single airport.
|
||||||
|
|
||||||
|
Returns ground delay programs, ground stops, closures.
|
||||||
|
Falls back to 'status_unavailable' on any error.
|
||||||
|
"""
|
||||||
|
all_status = await _fetch_all_status()
|
||||||
|
|
||||||
|
if "_error" in all_status:
|
||||||
|
return {
|
||||||
|
"airport": airport_code.upper(),
|
||||||
|
"status": "status_unavailable",
|
||||||
|
"error": all_status["_error"][0]["error"],
|
||||||
|
"source": "faa_nasstatus_live",
|
||||||
|
}
|
||||||
|
|
||||||
|
airport = airport_code.upper()
|
||||||
|
delays = all_status.get(airport, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"airport": airport,
|
||||||
|
"has_delays": len(delays) > 0,
|
||||||
|
"status": "delays_active" if delays else "normal_operations",
|
||||||
|
"delays": delays,
|
||||||
|
"source": "faa_nasstatus_live",
|
||||||
|
}
|
||||||
197
mcp_servers/data/real/openmeteo.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""OpenMeteo API client — live weather data, no API key required."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
|
||||||
|
# United hub coordinates
|
||||||
|
HUB_COORDS: dict[str, tuple[float, float]] = {
|
||||||
|
"ORD": (41.9742, -87.9073), # Chicago O'Hare
|
||||||
|
"EWR": (40.6895, -74.1745), # Newark
|
||||||
|
"IAH": (29.9902, -95.3368), # Houston Intercontinental
|
||||||
|
"SFO": (37.6213, -122.3790), # San Francisco
|
||||||
|
"DEN": (39.8561, -104.6737), # Denver
|
||||||
|
"LAX": (33.9425, -118.4081), # Los Angeles
|
||||||
|
"IAD": (38.9531, -77.4565), # Washington Dulles
|
||||||
|
}
|
||||||
|
|
||||||
|
# WMO weather interpretation codes
|
||||||
|
WMO_CODES: dict[int, str] = {
|
||||||
|
0: "Clear sky",
|
||||||
|
1: "Mainly clear",
|
||||||
|
2: "Partly cloudy",
|
||||||
|
3: "Overcast",
|
||||||
|
45: "Fog",
|
||||||
|
48: "Depositing rime fog",
|
||||||
|
51: "Light drizzle",
|
||||||
|
53: "Moderate drizzle",
|
||||||
|
55: "Dense drizzle",
|
||||||
|
61: "Slight rain",
|
||||||
|
63: "Moderate rain",
|
||||||
|
65: "Heavy rain",
|
||||||
|
71: "Slight snow",
|
||||||
|
73: "Moderate snow",
|
||||||
|
75: "Heavy snow",
|
||||||
|
77: "Snow grains",
|
||||||
|
80: "Slight rain showers",
|
||||||
|
81: "Moderate rain showers",
|
||||||
|
82: "Violent rain showers",
|
||||||
|
85: "Slight snow showers",
|
||||||
|
86: "Heavy snow showers",
|
||||||
|
95: "Thunderstorm",
|
||||||
|
96: "Thunderstorm with slight hail",
|
||||||
|
99: "Thunderstorm with heavy hail",
|
||||||
|
}
|
||||||
|
|
||||||
|
SIGNIFICANT_CODES = {45, 48, 65, 75, 82, 86, 95, 96, 99}
|
||||||
|
|
||||||
|
|
||||||
|
def _interpolate_waypoints(
|
||||||
|
origin: tuple[float, float], dest: tuple[float, float], n: int = 2
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""Generate n intermediate waypoints along a great-circle approximation."""
|
||||||
|
points = []
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
frac = i / (n + 1)
|
||||||
|
lat = origin[0] + frac * (dest[0] - origin[0])
|
||||||
|
lon = origin[1] + frac * (dest[1] - origin[1])
|
||||||
|
points.append((round(lat, 4), round(lon, 4)))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _interpret_weather(code: int) -> dict:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"condition": WMO_CODES.get(code, f"Unknown ({code})"),
|
||||||
|
"is_significant": code in SIGNIFICANT_CODES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_weather_at_point(
|
||||||
|
lat: float, lon: float, client: httpx.AsyncClient
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch current weather at a single point."""
|
||||||
|
params = {
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"current": "temperature_2m,wind_speed_10m,wind_direction_10m,weather_code,visibility,precipitation",
|
||||||
|
"timezone": "UTC",
|
||||||
|
}
|
||||||
|
resp = await client.get(BASE_URL, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
current = data["current"]
|
||||||
|
weather = _interpret_weather(current["weather_code"])
|
||||||
|
return {
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"temperature_c": current["temperature_2m"],
|
||||||
|
"wind_speed_kmh": current["wind_speed_10m"],
|
||||||
|
"wind_direction_deg": current["wind_direction_10m"],
|
||||||
|
"visibility_m": current.get("visibility"),
|
||||||
|
"precipitation_mm": current.get("precipitation", 0),
|
||||||
|
"weather": weather,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather_along_route(origin: str, destination: str) -> dict:
|
||||||
|
"""Fetch weather at origin, destination, and en-route waypoints."""
|
||||||
|
origin_coords = HUB_COORDS.get(origin.upper())
|
||||||
|
dest_coords = HUB_COORDS.get(destination.upper())
|
||||||
|
|
||||||
|
if not origin_coords or not dest_coords:
|
||||||
|
return {
|
||||||
|
"error": f"Unknown airport code. Known: {', '.join(HUB_COORDS.keys())}",
|
||||||
|
"origin": origin,
|
||||||
|
"destination": destination,
|
||||||
|
}
|
||||||
|
|
||||||
|
waypoints = _interpolate_waypoints(origin_coords, dest_coords)
|
||||||
|
all_points = [
|
||||||
|
("origin", origin.upper(), origin_coords),
|
||||||
|
*[(f"waypoint_{i+1}", f"WP{i+1}", wp) for i, wp in enumerate(waypoints)],
|
||||||
|
("destination", destination.upper(), dest_coords),
|
||||||
|
]
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
significant_events = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
for label, name, (lat, lon) in all_points:
|
||||||
|
try:
|
||||||
|
weather = await fetch_weather_at_point(lat, lon, client)
|
||||||
|
weather["label"] = label
|
||||||
|
weather["name"] = name
|
||||||
|
results[label] = weather
|
||||||
|
if weather["weather"]["is_significant"]:
|
||||||
|
significant_events.append(
|
||||||
|
{
|
||||||
|
"location": name,
|
||||||
|
"condition": weather["weather"]["condition"],
|
||||||
|
"code": weather["weather"]["code"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
results[label] = {"label": label, "name": name, "error": str(e)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"origin": origin.upper(),
|
||||||
|
"destination": destination.upper(),
|
||||||
|
"waypoints": results,
|
||||||
|
"significant_events": significant_events,
|
||||||
|
"source": "openmeteo_live",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather_forecast_hubs() -> dict:
|
||||||
|
"""Fetch 4-hour forecast for all United hubs."""
|
||||||
|
hubs = ["ORD", "EWR", "IAH", "SFO", "DEN"]
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
for hub in hubs:
|
||||||
|
lat, lon = HUB_COORDS[hub]
|
||||||
|
params = {
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"hourly": "temperature_2m,wind_speed_10m,weather_code,visibility,precipitation_probability",
|
||||||
|
"forecast_hours": 4,
|
||||||
|
"timezone": "UTC",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = await client.get(BASE_URL, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
hourly = data["hourly"]
|
||||||
|
|
||||||
|
hours = []
|
||||||
|
risk_flag = False
|
||||||
|
for i in range(len(hourly["time"])):
|
||||||
|
code = hourly["weather_code"][i]
|
||||||
|
weather = _interpret_weather(code)
|
||||||
|
if weather["is_significant"]:
|
||||||
|
risk_flag = True
|
||||||
|
hours.append(
|
||||||
|
{
|
||||||
|
"time": hourly["time"][i],
|
||||||
|
"temperature_c": hourly["temperature_2m"][i],
|
||||||
|
"wind_speed_kmh": hourly["wind_speed_10m"][i],
|
||||||
|
"weather": weather,
|
||||||
|
"visibility_m": hourly.get("visibility", [None])[i]
|
||||||
|
if "visibility" in hourly
|
||||||
|
else None,
|
||||||
|
"precipitation_probability": hourly[
|
||||||
|
"precipitation_probability"
|
||||||
|
][i],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
results[hub] = {
|
||||||
|
"hub": hub,
|
||||||
|
"forecast": hours,
|
||||||
|
"risk_flag": risk_flag,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
results[hub] = {"hub": hub, "error": str(e)}
|
||||||
|
|
||||||
|
return {"hubs": results, "source": "openmeteo_live"}
|
||||||
0
mcp_servers/data/scenarios/__init__.py
Normal file
123
mcp_servers/data/scenarios/crew_swap_ewr.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Scenario: EWR Crew Duty Limit — complex Part 117 crew swap needed.
|
||||||
|
|
||||||
|
Captain hitting duty limit in 2h, backup crew needed. Tests Handover IMMEDIATE action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from mcp_servers.data.models import (
|
||||||
|
CrewMember,
|
||||||
|
CrewRole,
|
||||||
|
DelayCause,
|
||||||
|
FlightData,
|
||||||
|
FlightStatus,
|
||||||
|
MELItem,
|
||||||
|
MPStatus,
|
||||||
|
Passenger,
|
||||||
|
RebookingCase,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCENARIO_ID = "crew_swap_ewr"
|
||||||
|
SCENARIO_NAME = "EWR Crew Duty Limit"
|
||||||
|
SCENARIO_DESCRIPTION = (
|
||||||
|
"Captain on UA2180 hitting Part 117 duty limit in 1h 45min. "
|
||||||
|
"Delay cascading. 2 crew swaps needed."
|
||||||
|
)
|
||||||
|
SCENARIO_HUBS = ["EWR"]
|
||||||
|
|
||||||
|
FLIGHTS: list[FlightData] = [
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA2180", origin="EWR", destination="LAX",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 20, 0, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 23, 30, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.DELAYED,
|
||||||
|
delay_minutes=90,
|
||||||
|
delay_cause=DelayCause.CREW,
|
||||||
|
aircraft_tail="N81201",
|
||||||
|
gate="C72",
|
||||||
|
crew_ids=["CR-3001", "CR-3002", "CR-3010", "CR-3011"],
|
||||||
|
passenger_count=210,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA2244", origin="EWR", destination="ORD",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 22, 0, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.DELAYED,
|
||||||
|
delay_minutes=45,
|
||||||
|
delay_cause=DelayCause.LATE_AIRCRAFT,
|
||||||
|
aircraft_tail="N81202",
|
||||||
|
gate="C87",
|
||||||
|
crew_ids=["CR-3003", "CR-3004", "CR-3012"],
|
||||||
|
passenger_count=178,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA2310", origin="EWR", destination="SFO",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
|
||||||
|
scheduled_arrival=datetime(2026, 4, 12, 0, 15, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.ON_TIME,
|
||||||
|
aircraft_tail="N81203",
|
||||||
|
gate="C90",
|
||||||
|
crew_ids=["CR-3005", "CR-3006"],
|
||||||
|
passenger_count=195,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
CREW: list[CrewMember] = [
|
||||||
|
# UA2180 — captain at limit
|
||||||
|
CrewMember(crew_id="CR-3001", name="Capt. Mitchell", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=12.25, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
|
||||||
|
CrewMember(crew_id="CR-3002", name="FO Vasquez", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=11.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=11.0, next_scheduled_flight="UA2180", base_hub="EWR"),
|
||||||
|
# UA2244
|
||||||
|
CrewMember(crew_id="CR-3003", name="Capt. Ali", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=14.0, next_scheduled_flight="UA2244", base_hub="EWR"),
|
||||||
|
CrewMember(crew_id="CR-3004", name="FO Johansson", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=13.0, next_scheduled_flight="UA2244", base_hub="EWR"),
|
||||||
|
# UA2310
|
||||||
|
CrewMember(crew_id="CR-3005", name="Capt. Reed", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=20.0, next_scheduled_flight="UA2310", base_hub="EWR"),
|
||||||
|
CrewMember(crew_id="CR-3006", name="FO Torres", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=18.0, next_scheduled_flight="UA2310", base_hub="EWR"),
|
||||||
|
# FAs
|
||||||
|
CrewMember(crew_id="CR-3010", name="FA Collins", role=CrewRole.FA,
|
||||||
|
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
|
||||||
|
CrewMember(crew_id="CR-3011", name="FA Yamamoto", role=CrewRole.FA,
|
||||||
|
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
|
||||||
|
CrewMember(crew_id="CR-3012", name="FA Petrov", role=CrewRole.FA,
|
||||||
|
duty_hours_elapsed=7.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=15.0, next_scheduled_flight="UA2244", base_hub="EWR"),
|
||||||
|
# Backup crew
|
||||||
|
CrewMember(crew_id="CR-8812", name="Capt. Foster", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=28.0, next_scheduled_flight=None, base_hub="EWR"),
|
||||||
|
CrewMember(crew_id="CR-8813", name="FO Chang", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=24.0, next_scheduled_flight=None, base_hub="EWR"),
|
||||||
|
]
|
||||||
|
|
||||||
|
CREW_NOTES: dict[str, list[str]] = {
|
||||||
|
"UA2180": [
|
||||||
|
"Capt. Mitchell duty limit approaching — 1h 45min remaining.",
|
||||||
|
"If departure slips past 22:15 ET, mandatory crew swap per Part 117 §117.19.",
|
||||||
|
"Backup Capt. Foster (CR-8812) on standby at crew lounge, cleared and rested.",
|
||||||
|
"FO Vasquez also approaching limit but has buffer until 23:00.",
|
||||||
|
],
|
||||||
|
"UA2244": [
|
||||||
|
"Delay is cascading from late inbound aircraft, not crew-related.",
|
||||||
|
"Gate conflict resolved — moved to C87.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
MAINTENANCE: dict[str, list[MELItem]] = {}
|
||||||
|
REBOOKINGS: list[RebookingCase] = []
|
||||||
|
PASSENGERS: list[Passenger] = []
|
||||||
117
mcp_servers/data/scenarios/maintenance_delay_sfo.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Scenario: SFO Maintenance Delay — MEL issue cascading to 2 flights.
|
||||||
|
|
||||||
|
Aircraft N82301 has APU MEL, delay cascading. Tests FCE maintenance-caused
|
||||||
|
delay explanation and Handover MEL flag section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from mcp_servers.data.models import (
|
||||||
|
CrewMember,
|
||||||
|
CrewRole,
|
||||||
|
DelayCause,
|
||||||
|
FlightData,
|
||||||
|
FlightStatus,
|
||||||
|
MELItem,
|
||||||
|
MPStatus,
|
||||||
|
Passenger,
|
||||||
|
RebookingCase,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCENARIO_ID = "maintenance_delay_sfo"
|
||||||
|
SCENARIO_NAME = "SFO MEL Issue"
|
||||||
|
SCENARIO_DESCRIPTION = (
|
||||||
|
"Aircraft N82301 APU inoperative (MEL). Delay cascading to 2 flights. "
|
||||||
|
"Route restriction if divert needed."
|
||||||
|
)
|
||||||
|
SCENARIO_HUBS = ["SFO"]
|
||||||
|
|
||||||
|
FLIGHTS: list[FlightData] = [
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA712", origin="SFO", destination="EWR",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 12, 3, 30, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.DELAYED,
|
||||||
|
delay_minutes=65,
|
||||||
|
delay_cause=DelayCause.MAINTENANCE,
|
||||||
|
aircraft_tail="N82301",
|
||||||
|
gate="G4",
|
||||||
|
crew_ids=["CR-4001", "CR-4002", "CR-4010"],
|
||||||
|
passenger_count=198,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA724", origin="SFO", destination="DEN",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 23, 45, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.DELAYED,
|
||||||
|
delay_minutes=30,
|
||||||
|
delay_cause=DelayCause.LATE_AIRCRAFT,
|
||||||
|
aircraft_tail="N82302",
|
||||||
|
gate="G8",
|
||||||
|
crew_ids=["CR-4003", "CR-4004"],
|
||||||
|
passenger_count=165,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA760", origin="SFO", destination="LAX",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 19, 45, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.ON_TIME,
|
||||||
|
aircraft_tail="N82303",
|
||||||
|
gate="G12",
|
||||||
|
crew_ids=["CR-4005", "CR-4006"],
|
||||||
|
passenger_count=140,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
CREW: list[CrewMember] = [
|
||||||
|
CrewMember(crew_id="CR-4001", name="Capt. Novak", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=16.0, next_scheduled_flight="UA712", base_hub="SFO"),
|
||||||
|
CrewMember(crew_id="CR-4002", name="FO Agrawal", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=15.0, next_scheduled_flight="UA712", base_hub="SFO"),
|
||||||
|
CrewMember(crew_id="CR-4003", name="Capt. Svensson", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=18.0, next_scheduled_flight="UA724", base_hub="SFO"),
|
||||||
|
CrewMember(crew_id="CR-4004", name="FO Rivera", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=17.0, next_scheduled_flight="UA724", base_hub="SFO"),
|
||||||
|
CrewMember(crew_id="CR-4005", name="Capt. Wallace", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=22.0, next_scheduled_flight="UA760", base_hub="SFO"),
|
||||||
|
CrewMember(crew_id="CR-4006", name="FO Zhao", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=20.0, next_scheduled_flight="UA760", base_hub="SFO"),
|
||||||
|
CrewMember(crew_id="CR-4010", name="FA Douglas", role=CrewRole.FA,
|
||||||
|
duty_hours_elapsed=5.5, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=16.0, next_scheduled_flight="UA712", base_hub="SFO"),
|
||||||
|
]
|
||||||
|
|
||||||
|
CREW_NOTES: dict[str, list[str]] = {
|
||||||
|
"UA712": [
|
||||||
|
"MEL item #47-3: APU inoperative on N82301.",
|
||||||
|
"Maintenance team inspecting — estimated release in 45 min.",
|
||||||
|
"APU MEL restricts routing to airports with ground power only.",
|
||||||
|
"Current SFO→EWR route unaffected, but flag if divert to BDL or HPN needed.",
|
||||||
|
],
|
||||||
|
"UA724": [
|
||||||
|
"Delay cascading from UA712 — shared gate G8 not available until UA712 pushes.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
MAINTENANCE: dict[str, list[MELItem]] = {
|
||||||
|
"N82301": [
|
||||||
|
MELItem(
|
||||||
|
mel_id="MEL-SFO-473",
|
||||||
|
aircraft_tail="N82301",
|
||||||
|
system="APU",
|
||||||
|
description="Auxiliary Power Unit inoperative. Aircraft requires external ground power for engine start and gate operations.",
|
||||||
|
restriction="Routing restricted to airports with ground power availability. Cannot divert to airports without ground power units (e.g., BDL, HPN).",
|
||||||
|
expires=datetime(2026, 4, 18, 0, 0, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
REBOOKINGS: list[RebookingCase] = []
|
||||||
|
PASSENGERS: list[Passenger] = []
|
||||||
85
mcp_servers/data/scenarios/manager.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Scenario manager — loads and switches between operational scenarios."""
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SCENARIO_MODULES = {
|
||||||
|
"normal_ops": "mcp_servers.data.scenarios.normal_ops",
|
||||||
|
"weather_disruption_ord": "mcp_servers.data.scenarios.weather_disruption_ord",
|
||||||
|
"maintenance_delay_sfo": "mcp_servers.data.scenarios.maintenance_delay_sfo",
|
||||||
|
"crew_swap_ewr": "mcp_servers.data.scenarios.crew_swap_ewr",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioManager:
|
||||||
|
"""Manages the active scenario. Singleton — all MCP servers share one instance."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._active_id: str = "weather_disruption_ord"
|
||||||
|
self._cache: dict[str, Any] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_id(self) -> str:
|
||||||
|
return self._active_id
|
||||||
|
|
||||||
|
def set_active(self, scenario_id: str) -> dict:
|
||||||
|
if scenario_id not in SCENARIO_MODULES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown scenario: {scenario_id}. "
|
||||||
|
f"Available: {', '.join(SCENARIO_MODULES.keys())}"
|
||||||
|
)
|
||||||
|
self._active_id = scenario_id
|
||||||
|
return self.get_metadata()
|
||||||
|
|
||||||
|
def get_metadata(self, scenario_id: str | None = None) -> dict:
|
||||||
|
mod = self._load(scenario_id or self._active_id)
|
||||||
|
return {
|
||||||
|
"scenario_id": mod.SCENARIO_ID,
|
||||||
|
"name": mod.SCENARIO_NAME,
|
||||||
|
"description": mod.SCENARIO_DESCRIPTION,
|
||||||
|
"hubs": mod.SCENARIO_HUBS,
|
||||||
|
"flight_count": len(mod.FLIGHTS),
|
||||||
|
"disrupted_flights": sum(
|
||||||
|
1 for f in mod.FLIGHTS if f.status != "ON_TIME"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_scenarios(self) -> list[dict]:
|
||||||
|
return [self.get_metadata(sid) for sid in SCENARIO_MODULES]
|
||||||
|
|
||||||
|
def _load(self, scenario_id: str) -> Any:
|
||||||
|
if scenario_id not in self._cache:
|
||||||
|
self._cache[scenario_id] = import_module(SCENARIO_MODULES[scenario_id])
|
||||||
|
return self._cache[scenario_id]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _module(self) -> Any:
|
||||||
|
return self._load(self._active_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def flights(self):
|
||||||
|
return self._module.FLIGHTS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def crew(self):
|
||||||
|
return self._module.CREW
|
||||||
|
|
||||||
|
@property
|
||||||
|
def crew_notes(self) -> dict[str, list[str]]:
|
||||||
|
return self._module.CREW_NOTES
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maintenance(self):
|
||||||
|
return self._module.MAINTENANCE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rebookings(self):
|
||||||
|
return self._module.REBOOKINGS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passengers(self):
|
||||||
|
return self._module.PASSENGERS
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
scenario_manager = ScenarioManager()
|
||||||
72
mcp_servers/data/scenarios/normal_ops.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Scenario: Normal Operations — all flights on time, clear weather, full crew.
|
||||||
|
|
||||||
|
Baseline scenario. Agents should produce minimal/calm output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from mcp_servers.data.models import (
|
||||||
|
CrewMember,
|
||||||
|
CrewRole,
|
||||||
|
FlightData,
|
||||||
|
FlightStatus,
|
||||||
|
MPStatus,
|
||||||
|
Passenger,
|
||||||
|
RebookingCase,
|
||||||
|
MELItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCENARIO_ID = "normal_ops"
|
||||||
|
SCENARIO_NAME = "Normal Operations"
|
||||||
|
SCENARIO_DESCRIPTION = "All flights on time at ORD. Clear weather. No disruptions."
|
||||||
|
SCENARIO_HUBS = ["ORD"]
|
||||||
|
|
||||||
|
FLIGHTS: list[FlightData] = [
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA1440", origin="ORD", destination="SFO",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 18, 0, tzinfo=timezone.utc),
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.ON_TIME, aircraft_tail="N79001", gate="H10",
|
||||||
|
crew_ids=["CR-2001", "CR-2002"], passenger_count=175,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA1552", origin="ORD", destination="EWR",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 21, 45, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.ON_TIME, aircraft_tail="N79002", gate="B12",
|
||||||
|
crew_ids=["CR-2003", "CR-2004"], passenger_count=190,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA1678", origin="ORD", destination="DEN",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
|
||||||
|
status=FlightStatus.ON_TIME, aircraft_tail="N79003", gate="C8",
|
||||||
|
crew_ids=["CR-2005", "CR-2006"], passenger_count=160,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
CREW: list[CrewMember] = [
|
||||||
|
CrewMember(crew_id="CR-2001", name="Capt. Hayes", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=18.0, next_scheduled_flight="UA1440", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-2002", name="FO Park", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=16.0, next_scheduled_flight="UA1440", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-2003", name="Capt. Lewis", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=15.0, next_scheduled_flight="UA1552", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-2004", name="FO Sharma", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=14.0, next_scheduled_flight="UA1552", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-2005", name="Capt. Brown", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=20.0, next_scheduled_flight="UA1678", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-2006", name="FO Nguyen", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=19.0, next_scheduled_flight="UA1678", base_hub="ORD"),
|
||||||
|
]
|
||||||
|
|
||||||
|
CREW_NOTES: dict[str, list[str]] = {}
|
||||||
|
MAINTENANCE: dict[str, list[MELItem]] = {}
|
||||||
|
REBOOKINGS: list[RebookingCase] = []
|
||||||
|
PASSENGERS: list[Passenger] = []
|
||||||
281
mcp_servers/data/scenarios/weather_disruption_ord.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""Scenario: ORD Thunderstorms — active disruption at Chicago O'Hare.
|
||||||
|
|
||||||
|
Thunderstorm line causing GDP, 4 flights delayed, 1 cancelled.
|
||||||
|
Tests FCE multi-flight notifications and Handover weather risk section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from mcp_servers.data.models import (
|
||||||
|
CrewMember,
|
||||||
|
CrewRole,
|
||||||
|
DelayCause,
|
||||||
|
FlightData,
|
||||||
|
FlightStatus,
|
||||||
|
MELItem,
|
||||||
|
MPStatus,
|
||||||
|
Passenger,
|
||||||
|
RebookingCase,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCENARIO_ID = "weather_disruption_ord"
|
||||||
|
SCENARIO_NAME = "ORD Thunderstorms"
|
||||||
|
SCENARIO_DESCRIPTION = (
|
||||||
|
"Active thunderstorm line at ORD. GDP in effect. "
|
||||||
|
"4 delays, 1 cancellation. 47 passengers need rebooking."
|
||||||
|
)
|
||||||
|
SCENARIO_HUBS = ["ORD"]
|
||||||
|
|
||||||
|
# --- Flights ---
|
||||||
|
|
||||||
|
FLIGHTS: list[FlightData] = [
|
||||||
|
# Delayed flights
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA432",
|
||||||
|
origin="ORD", destination="SFO",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 17, 45, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 20, 15, tzinfo=timezone.utc),
|
||||||
|
actual_arrival=None,
|
||||||
|
status=FlightStatus.DELAYED,
|
||||||
|
delay_minutes=55,
|
||||||
|
delay_cause=DelayCause.WEATHER,
|
||||||
|
aircraft_tail="N78501",
|
||||||
|
gate="H14",
|
||||||
|
inbound_flight="UA219",
|
||||||
|
crew_ids=["CR-1001", "CR-1002", "CR-1010", "CR-1011"],
|
||||||
|
passenger_count=186,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA881",
|
||||||
|
origin="ORD", destination="LAX",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
|
||||||
|
actual_arrival=None,
|
||||||
|
status=FlightStatus.DELAYED,
|
||||||
|
delay_minutes=40,
|
||||||
|
delay_cause=DelayCause.WEATHER,
|
||||||
|
aircraft_tail="N78502",
|
||||||
|
gate="H22",
|
||||||
|
crew_ids=["CR-1003", "CR-1004", "CR-1012", "CR-1013"],
|
||||||
|
passenger_count=204,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA233",
|
||||||
|
origin="ORD", destination="DEN",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 18, 0, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 19, 45, tzinfo=timezone.utc),
|
||||||
|
actual_arrival=None,
|
||||||
|
status=FlightStatus.DELAYED,
|
||||||
|
delay_minutes=70,
|
||||||
|
delay_cause=DelayCause.WEATHER,
|
||||||
|
aircraft_tail="N78503",
|
||||||
|
gate="C12",
|
||||||
|
crew_ids=["CR-1005", "CR-1006", "CR-1014"],
|
||||||
|
passenger_count=152,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA094",
|
||||||
|
origin="ORD", destination="EWR",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 17, 15, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
|
||||||
|
actual_arrival=None,
|
||||||
|
status=FlightStatus.DELAYED,
|
||||||
|
delay_minutes=35,
|
||||||
|
delay_cause=DelayCause.WEATHER,
|
||||||
|
aircraft_tail="N78504",
|
||||||
|
gate="B8",
|
||||||
|
crew_ids=["CR-1007", "CR-1008", "CR-1015"],
|
||||||
|
passenger_count=178,
|
||||||
|
),
|
||||||
|
# Cancelled flight
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA517",
|
||||||
|
origin="ORD", destination="IAH",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 21, 30, tzinfo=timezone.utc),
|
||||||
|
actual_arrival=None,
|
||||||
|
status=FlightStatus.CANCELLED,
|
||||||
|
delay_minutes=0,
|
||||||
|
delay_cause=DelayCause.WEATHER,
|
||||||
|
aircraft_tail="N78505",
|
||||||
|
gate="C18",
|
||||||
|
crew_ids=["CR-1009", "CR-1016"],
|
||||||
|
passenger_count=47,
|
||||||
|
),
|
||||||
|
# On-time flights (normal ops happening alongside disruption)
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA1220",
|
||||||
|
origin="ORD", destination="IAD",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 23, 15, tzinfo=timezone.utc),
|
||||||
|
actual_arrival=None,
|
||||||
|
status=FlightStatus.ON_TIME,
|
||||||
|
delay_minutes=0,
|
||||||
|
delay_cause=None,
|
||||||
|
aircraft_tail="N78506",
|
||||||
|
gate="B15",
|
||||||
|
crew_ids=["CR-1017", "CR-1018"],
|
||||||
|
passenger_count=145,
|
||||||
|
),
|
||||||
|
FlightData(
|
||||||
|
flight_id="UA788",
|
||||||
|
origin="ORD", destination="SFO",
|
||||||
|
scheduled_departure=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
|
||||||
|
actual_departure=None,
|
||||||
|
scheduled_arrival=datetime(2026, 4, 11, 23, 30, tzinfo=timezone.utc),
|
||||||
|
actual_arrival=None,
|
||||||
|
status=FlightStatus.ON_TIME,
|
||||||
|
delay_minutes=0,
|
||||||
|
delay_cause=None,
|
||||||
|
aircraft_tail="N78507",
|
||||||
|
gate="H18",
|
||||||
|
crew_ids=["CR-1019", "CR-1020"],
|
||||||
|
passenger_count=192,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Crew ---
|
||||||
|
|
||||||
|
CREW: list[CrewMember] = [
|
||||||
|
# UA432 crew
|
||||||
|
CrewMember(crew_id="CR-1001", name="Capt. Rodriguez", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=10.5, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=12.0, next_scheduled_flight="UA432", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-1002", name="FO Chen", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=10.5, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=11.0, next_scheduled_flight="UA432", base_hub="ORD"),
|
||||||
|
# UA881 crew — captain approaching duty limit
|
||||||
|
CrewMember(crew_id="CR-1003", name="Capt. Williams", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=12.25, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=10.0, next_scheduled_flight="UA881", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-1004", name="FO Patel", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=9.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=14.0, next_scheduled_flight="UA881", base_hub="ORD"),
|
||||||
|
# UA233 crew
|
||||||
|
CrewMember(crew_id="CR-1005", name="Capt. Johnson", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=16.0, next_scheduled_flight="UA233", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-1006", name="FO Kim", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=7.5, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=15.0, next_scheduled_flight="UA233", base_hub="ORD"),
|
||||||
|
# UA094 crew
|
||||||
|
CrewMember(crew_id="CR-1007", name="Capt. Davis", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=18.0, next_scheduled_flight="UA094", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-1008", name="FO Martinez", role=CrewRole.FIRST_OFFICER,
|
||||||
|
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=17.0, next_scheduled_flight="UA094", base_hub="ORD"),
|
||||||
|
# UA517 crew (cancelled flight — available for reassignment)
|
||||||
|
CrewMember(crew_id="CR-1009", name="Capt. Thompson", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=20.0, next_scheduled_flight=None, base_hub="ORD"),
|
||||||
|
# Flight attendants
|
||||||
|
CrewMember(crew_id="CR-1010", name="FA Brooks", role=CrewRole.FA,
|
||||||
|
duty_hours_elapsed=10.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=12.0, next_scheduled_flight="UA432", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-1011", name="FA Lee", role=CrewRole.FA,
|
||||||
|
duty_hours_elapsed=10.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=13.0, next_scheduled_flight="UA432", base_hub="ORD"),
|
||||||
|
CrewMember(crew_id="CR-1012", name="FA Garcia", role=CrewRole.FA,
|
||||||
|
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=10.0, next_scheduled_flight="UA881", base_hub="ORD"),
|
||||||
|
# Backup crew
|
||||||
|
CrewMember(crew_id="CR-4421", name="Capt. Okafor", role=CrewRole.CAPTAIN,
|
||||||
|
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
|
||||||
|
rest_hours_since_last=24.0, next_scheduled_flight=None, base_hub="ORD"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Crew Notes ---
|
||||||
|
|
||||||
|
CREW_NOTES: dict[str, list[str]] = {
|
||||||
|
"UA432": [
|
||||||
|
"Ground stop at ORD — thunderstorm line moving NE across field.",
|
||||||
|
"Inbound aircraft N78501 on ground, boarding paused at gate H14.",
|
||||||
|
"Fresh crew on board, catering complete. Ready to board on release.",
|
||||||
|
"Gate hold — no gate change expected per ops.",
|
||||||
|
],
|
||||||
|
"UA881": [
|
||||||
|
"Capt. Williams approaching duty limit — monitor closely.",
|
||||||
|
"If departure slips past 22:15 CT, mandatory crew swap required.",
|
||||||
|
"Backup crew CR-4421 (Capt. Okafor) on standby, cleared and rested.",
|
||||||
|
],
|
||||||
|
"UA233": [
|
||||||
|
"Extended delay due to weather at ORD and congestion at DEN.",
|
||||||
|
"8 connecting passengers with tight connections at DEN — rebooking may be needed.",
|
||||||
|
],
|
||||||
|
"UA094": [
|
||||||
|
"Moderate delay. EWR reporting clear conditions, delay is ORD-side only.",
|
||||||
|
],
|
||||||
|
"UA517": [
|
||||||
|
"CANCELLED — thunderstorm forecast through departure window.",
|
||||||
|
"47 passengers need rebooking. 8 Global Services pax — priority handling.",
|
||||||
|
"Rebooking options loaded in system — supervisor approval pending.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Maintenance ---
|
||||||
|
|
||||||
|
MAINTENANCE: dict[str, list[MELItem]] = {
|
||||||
|
"N78501": [
|
||||||
|
MELItem(
|
||||||
|
mel_id="MEL-ORD-001",
|
||||||
|
aircraft_tail="N78501",
|
||||||
|
system="WEATHER_RADAR",
|
||||||
|
description="Weather radar intermittent — functions normally after reset cycle.",
|
||||||
|
restriction=None,
|
||||||
|
expires=datetime(2026, 4, 15, 0, 0, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Passengers needing rebooking (from cancelled UA517) ---
|
||||||
|
|
||||||
|
REBOOKINGS: list[RebookingCase] = [
|
||||||
|
RebookingCase(pax_id="PAX-00341", name="J. Morrison",
|
||||||
|
original_flight="UA517", mileage_plus_status=MPStatus.GLOBAL_SERVICES,
|
||||||
|
destination="IAH", next_available="UA519", urgency="HIGH"),
|
||||||
|
RebookingCase(pax_id="PAX-00342", name="S. Nakamura",
|
||||||
|
original_flight="UA517", mileage_plus_status=MPStatus.GLOBAL_SERVICES,
|
||||||
|
destination="IAH", next_available="UA519", urgency="HIGH"),
|
||||||
|
RebookingCase(pax_id="PAX-00355", name="R. Okonkwo",
|
||||||
|
original_flight="UA517", mileage_plus_status=MPStatus.K1,
|
||||||
|
destination="IAH", next_available="UA519", urgency="HIGH"),
|
||||||
|
RebookingCase(pax_id="PAX-00360", name="M. Fernandez",
|
||||||
|
original_flight="UA517", mileage_plus_status=MPStatus.PLATINUM,
|
||||||
|
destination="IAH", next_available=None, urgency="MEDIUM"),
|
||||||
|
RebookingCase(pax_id="PAX-00371", name="K. Singh",
|
||||||
|
original_flight="UA517", mileage_plus_status=MPStatus.GOLD,
|
||||||
|
destination="IAH", next_available=None, urgency="MEDIUM"),
|
||||||
|
RebookingCase(pax_id="PAX-00380", name="L. Anderson",
|
||||||
|
original_flight="UA517", mileage_plus_status=MPStatus.GENERAL,
|
||||||
|
destination="IAH", next_available=None, urgency="LOW"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Passengers on disrupted flights (sample) ---
|
||||||
|
|
||||||
|
PASSENGERS: list[Passenger] = [
|
||||||
|
# UA432 passengers
|
||||||
|
Passenger(pax_id="PAX-10001", name="A. Kowalski",
|
||||||
|
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA432",
|
||||||
|
destination="SFO", connection_flight="UA1892",
|
||||||
|
connection_deadline=datetime(2026, 4, 11, 21, 30, tzinfo=timezone.utc)),
|
||||||
|
Passenger(pax_id="PAX-10002", name="B. Tanaka",
|
||||||
|
mileage_plus_status=MPStatus.K1, flight_id="UA432",
|
||||||
|
destination="SFO"),
|
||||||
|
Passenger(pax_id="PAX-10003", name="C. Dubois",
|
||||||
|
mileage_plus_status=MPStatus.GENERAL, flight_id="UA432",
|
||||||
|
destination="SFO", special_needs=["WCHR"]),
|
||||||
|
# UA517 passengers (cancelled)
|
||||||
|
Passenger(pax_id="PAX-00341", name="J. Morrison",
|
||||||
|
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA517",
|
||||||
|
destination="IAH"),
|
||||||
|
Passenger(pax_id="PAX-00342", name="S. Nakamura",
|
||||||
|
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA517",
|
||||||
|
destination="IAH"),
|
||||||
|
]
|
||||||
0
mcp_servers/ops/__init__.py
Normal file
5
mcp_servers/ops/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Entry point for running the ops MCP server as a module."""
|
||||||
|
|
||||||
|
from mcp_servers.ops.server import mcp
|
||||||
|
|
||||||
|
mcp.run()
|
||||||
204
mcp_servers/ops/server.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Ops MCP server — tools, resources, and prompts for the Handover agent only.
|
||||||
|
|
||||||
|
Covers: crew duty status, crew notes, pending rebookings, ops narrative,
|
||||||
|
crew roster, last handover brief, and handover brief prompt template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from mcp_servers.data.scenarios.manager import scenario_manager
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
"united-ops-internal",
|
||||||
|
instructions=(
|
||||||
|
"Internal operations tools — crew duty status, pending rebookings, "
|
||||||
|
"and ops-audience narrative generation. Restricted to ops-facing clients."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# In-memory store for the last generated handover brief
|
||||||
|
_last_handover: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def store_handover_brief(brief: dict) -> None:
|
||||||
|
"""Called by the handover agent after generating a brief."""
|
||||||
|
global _last_handover
|
||||||
|
_last_handover = {
|
||||||
|
**brief,
|
||||||
|
"stored_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tools ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_crew_notes(flight_id: str) -> list[str]:
|
||||||
|
"""Free-text notes logged by crew or ops team for a flight.
|
||||||
|
|
||||||
|
Includes: maintenance write-ups, catering delays, gate conflicts,
|
||||||
|
passenger incidents, operational remarks.
|
||||||
|
"""
|
||||||
|
return scenario_manager.crew_notes.get(flight_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_crew_duty_status(crew_ids: list[str]) -> list[dict]:
|
||||||
|
"""Duty hours and rest state for each crew member.
|
||||||
|
|
||||||
|
Returns per crew: crew_id, name, role, duty_hours_elapsed,
|
||||||
|
duty_hours_limit, hours_until_limit, rest_hours_since_last,
|
||||||
|
at_risk (true if within 2h of FAA Part 117 limit).
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
crew_map = {c.crew_id: c for c in scenario_manager.crew}
|
||||||
|
|
||||||
|
for crew_id in crew_ids:
|
||||||
|
crew = crew_map.get(crew_id)
|
||||||
|
if not crew:
|
||||||
|
results.append({"crew_id": crew_id, "error": "not_found"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
hours_until_limit = crew.duty_hours_limit - crew.duty_hours_elapsed
|
||||||
|
results.append({
|
||||||
|
"crew_id": crew.crew_id,
|
||||||
|
"name": crew.name,
|
||||||
|
"role": crew.role.value,
|
||||||
|
"duty_hours_elapsed": crew.duty_hours_elapsed,
|
||||||
|
"duty_hours_limit": crew.duty_hours_limit,
|
||||||
|
"hours_until_limit": round(hours_until_limit, 2),
|
||||||
|
"rest_hours_since_last": crew.rest_hours_since_last,
|
||||||
|
"at_risk": hours_until_limit <= 2.0,
|
||||||
|
"base_hub": crew.base_hub,
|
||||||
|
"next_scheduled_flight": crew.next_scheduled_flight,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_pending_rebookings(hub: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""Passengers needing rebooking at a hub, sorted by urgency.
|
||||||
|
|
||||||
|
Returns: pax_id, name, mileage_plus_status, original_flight,
|
||||||
|
destination, next_available_option, urgency.
|
||||||
|
"""
|
||||||
|
# Filter rebookings by flights originating from this hub
|
||||||
|
hub_flights = {f.flight_id for f in scenario_manager.flights if f.origin == hub.upper()}
|
||||||
|
rebookings = [
|
||||||
|
r for r in scenario_manager.rebookings
|
||||||
|
if r.original_flight in hub_flights
|
||||||
|
]
|
||||||
|
|
||||||
|
urgency_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
||||||
|
rebookings.sort(key=lambda r: urgency_order.get(r.urgency, 3))
|
||||||
|
|
||||||
|
return [r.model_dump(mode="json") for r in rebookings[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def generate_narrative(context: dict) -> str:
|
||||||
|
"""Synthesizes aggregated operational context into a structured
|
||||||
|
handover brief for ops managers.
|
||||||
|
|
||||||
|
Uses Claude Sonnet via AWS Bedrock Converse API.
|
||||||
|
Output: prioritized, concise, structured by IMMEDIATE / MONITOR / FYI.
|
||||||
|
|
||||||
|
NOTE: In v1, this returns a structured template from the context data.
|
||||||
|
LLM integration will be added when Bedrock is wired up.
|
||||||
|
"""
|
||||||
|
# V1: structured template — will be replaced with Bedrock call
|
||||||
|
sections = []
|
||||||
|
immediate = context.get("immediate", [])
|
||||||
|
monitor = context.get("monitor", [])
|
||||||
|
fyi = context.get("fyi", [])
|
||||||
|
|
||||||
|
hub = context.get("hub", "ALL")
|
||||||
|
shift_time = context.get("shift_time", datetime.now(timezone.utc).strftime("%H:%M UTC"))
|
||||||
|
|
||||||
|
header = f"SHIFT HANDOVER BRIEF — {hub} / {shift_time}"
|
||||||
|
sections.append(header)
|
||||||
|
sections.append(f"Generated: {datetime.now(timezone.utc).strftime('%H:%M UTC')}")
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
|
if immediate:
|
||||||
|
sections.append("━━━ IMMEDIATE ACTION ━━━")
|
||||||
|
for item in immediate:
|
||||||
|
sections.append(f"▶ {item}")
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
|
if monitor:
|
||||||
|
sections.append("━━━ MONITOR ━━━")
|
||||||
|
for item in monitor:
|
||||||
|
sections.append(f"⚠ {item}")
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
|
if fyi:
|
||||||
|
sections.append("━━━ FYI ━━━")
|
||||||
|
for item in fyi:
|
||||||
|
sections.append(f"ℹ {item}")
|
||||||
|
|
||||||
|
return "\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resources ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ops://crew/roster")
|
||||||
|
def crew_roster() -> str:
|
||||||
|
"""Full crew roster for the current scenario.
|
||||||
|
|
||||||
|
Returns per crew: crew_id, name, role, base_hub, duty status summary.
|
||||||
|
"""
|
||||||
|
roster = []
|
||||||
|
for c in scenario_manager.crew:
|
||||||
|
hours_remaining = c.duty_hours_limit - c.duty_hours_elapsed
|
||||||
|
roster.append({
|
||||||
|
"crew_id": c.crew_id,
|
||||||
|
"name": c.name,
|
||||||
|
"role": c.role.value,
|
||||||
|
"base_hub": c.base_hub,
|
||||||
|
"duty_status": "AT_RISK" if hours_remaining <= 2.0 else "OK",
|
||||||
|
"hours_until_limit": round(hours_remaining, 2),
|
||||||
|
"next_flight": c.next_scheduled_flight,
|
||||||
|
})
|
||||||
|
return json.dumps(roster)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ops://handover/latest")
|
||||||
|
def latest_handover() -> str:
|
||||||
|
"""The most recently generated shift handover brief.
|
||||||
|
|
||||||
|
Returns null if no brief has been generated yet.
|
||||||
|
"""
|
||||||
|
if _last_handover is None:
|
||||||
|
return json.dumps(None)
|
||||||
|
return json.dumps(_last_handover)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Prompts ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
def handover_brief(hub: str = "ALL", shift_time: str = "21:00 CT") -> str:
|
||||||
|
"""Template for generating a shift handover brief.
|
||||||
|
|
||||||
|
Defines the structured format: IMMEDIATE / MONITOR / FYI sections,
|
||||||
|
priority scoring expectations, and detail level per item.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
f"Generate a shift handover brief for {hub} at {shift_time}. "
|
||||||
|
"Structure the output as follows:\n\n"
|
||||||
|
"HEADER: Hub, time, outgoing/incoming manager names.\n\n"
|
||||||
|
"IMMEDIATE ACTION: Items requiring action within the next 2 hours. "
|
||||||
|
"Each item: flight/issue ID, what's happening, time until critical, "
|
||||||
|
"specific action needed, and any pre-staged resources.\n\n"
|
||||||
|
"MONITOR: Items that could escalate. Each item: what to watch, "
|
||||||
|
"trigger conditions for escalation, current trajectory.\n\n"
|
||||||
|
"FYI: Resolved items or low-risk situations the incoming shift should know about.\n\n"
|
||||||
|
"Be concise. Ops managers scan, they don't read paragraphs. "
|
||||||
|
"Use the data provided — do not invent details."
|
||||||
|
)
|
||||||
0
mcp_servers/passenger/__init__.py
Normal file
5
mcp_servers/passenger/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Entry point for running the passenger MCP server as a module."""
|
||||||
|
|
||||||
|
from mcp_servers.passenger.server import mcp
|
||||||
|
|
||||||
|
mcp.run()
|
||||||
157
mcp_servers/passenger/server.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""Passenger MCP server — tools, resources, and prompts for the FCE agent only.
|
||||||
|
|
||||||
|
Covers: passenger notification generation, flight manifest, and
|
||||||
|
notification prompt template (multi-tone).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from mcp_servers.data.models import MPStatus
|
||||||
|
from mcp_servers.data.scenarios.manager import scenario_manager
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
"united-ops-passenger",
|
||||||
|
instructions=(
|
||||||
|
"Passenger-facing tools — notification narrative generation "
|
||||||
|
"and flight manifest access. Restricted to customer-facing clients."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tools ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def generate_notification(context: dict) -> str:
|
||||||
|
"""Synthesizes flight disruption context into an empathetic,
|
||||||
|
actionable passenger notification.
|
||||||
|
|
||||||
|
Uses Claude Sonnet via AWS Bedrock Converse API.
|
||||||
|
Output: clear, human, no jargon, includes gate/time/status.
|
||||||
|
|
||||||
|
NOTE: In v1, this returns a structured template from the context data.
|
||||||
|
LLM integration will be added when Bedrock is wired up.
|
||||||
|
"""
|
||||||
|
# V1: structured template — will be replaced with Bedrock call
|
||||||
|
flight_id = context.get("flight_id", "")
|
||||||
|
origin = context.get("origin", "")
|
||||||
|
destination = context.get("destination", "")
|
||||||
|
status = context.get("status", "DELAYED")
|
||||||
|
delay_minutes = context.get("delay_minutes", 0)
|
||||||
|
delay_cause = context.get("delay_cause", "")
|
||||||
|
gate = context.get("gate", "")
|
||||||
|
weather_summary = context.get("weather_summary", "")
|
||||||
|
crew_notes_summary = context.get("crew_notes_summary", "")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"{flight_id} — {origin} → {destination}",
|
||||||
|
f"Status: {status}" + (f" {delay_minutes} minutes" if delay_minutes else ""),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if weather_summary:
|
||||||
|
lines.append(f"Your flight is delayed due to {weather_summary}.")
|
||||||
|
elif delay_cause:
|
||||||
|
cause_text = {
|
||||||
|
"WEATHER": "weather conditions along your route",
|
||||||
|
"MAINTENANCE": "a routine maintenance check on your aircraft",
|
||||||
|
"CREW": "ensuring your crew is fully rested for safe operation",
|
||||||
|
"ATC": "air traffic control restrictions",
|
||||||
|
"LATE_AIRCRAFT": "the late arrival of your inbound aircraft",
|
||||||
|
}.get(delay_cause, f"{delay_cause.lower()}")
|
||||||
|
lines.append(f"Your flight is delayed due to {cause_text}.")
|
||||||
|
|
||||||
|
if crew_notes_summary:
|
||||||
|
lines.append(crew_notes_summary)
|
||||||
|
|
||||||
|
if gate:
|
||||||
|
lines.append(f"\nGate {gate} — no gate change expected.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resources ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ops://flights/{flight_id}/manifest")
|
||||||
|
def flight_manifest(flight_id: str) -> str:
|
||||||
|
"""Passenger manifest summary for a flight.
|
||||||
|
|
||||||
|
Returns: flight_id, passenger count, count by MP status tier,
|
||||||
|
special needs count, connection count.
|
||||||
|
Summary-level — no PII exposed through this resource.
|
||||||
|
"""
|
||||||
|
flight = None
|
||||||
|
for f in scenario_manager.flights:
|
||||||
|
if f.flight_id == flight_id:
|
||||||
|
flight = f
|
||||||
|
break
|
||||||
|
|
||||||
|
if not flight:
|
||||||
|
return json.dumps({"error": f"Flight {flight_id} not found"})
|
||||||
|
|
||||||
|
# Count passengers by status from scenario data
|
||||||
|
pax_on_flight = [
|
||||||
|
p for p in scenario_manager.passengers
|
||||||
|
if p.flight_id == flight_id
|
||||||
|
]
|
||||||
|
|
||||||
|
status_counts = {}
|
||||||
|
special_needs_count = 0
|
||||||
|
connection_count = 0
|
||||||
|
|
||||||
|
for p in pax_on_flight:
|
||||||
|
status = p.mileage_plus_status.value
|
||||||
|
status_counts[status] = status_counts.get(status, 0) + 1
|
||||||
|
if p.special_needs:
|
||||||
|
special_needs_count += 1
|
||||||
|
if p.connection_flight:
|
||||||
|
connection_count += 1
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"flight_id": flight_id,
|
||||||
|
"total_passengers": flight.passenger_count,
|
||||||
|
"by_status": status_counts,
|
||||||
|
"special_needs_count": special_needs_count,
|
||||||
|
"connection_count": connection_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Prompts ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
TONE_INSTRUCTIONS = {
|
||||||
|
"empathetic": (
|
||||||
|
"Write a passenger notification about this flight disruption. "
|
||||||
|
"Be empathetic and human. Explain WHY the delay/cancellation happened "
|
||||||
|
"using the weather, maintenance, or operational data provided. "
|
||||||
|
"Tell the passenger what's happening next: new boarding time, gate, "
|
||||||
|
"rebooking options. End with reassurance. No jargon."
|
||||||
|
),
|
||||||
|
"factual": (
|
||||||
|
"Write a brief, factual notification. Include: flight number, "
|
||||||
|
"route, status, delay duration, cause (one phrase), new departure time, "
|
||||||
|
"gate. No editorial. No reassurance. Just facts."
|
||||||
|
),
|
||||||
|
"brief_sms": (
|
||||||
|
"Write an SMS-length notification (under 160 characters). "
|
||||||
|
"Format: UA{flight} {route}: {status}. {cause}. New dep: {time}. Gate {gate}."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
def passenger_notification(tone: str = "empathetic") -> str:
|
||||||
|
"""Template for generating passenger delay/cancellation notifications.
|
||||||
|
|
||||||
|
tone: empathetic (default) | factual | brief_sms
|
||||||
|
"""
|
||||||
|
instruction = TONE_INSTRUCTIONS.get(tone, TONE_INSTRUCTIONS["empathetic"])
|
||||||
|
return (
|
||||||
|
f"{instruction}\n\n"
|
||||||
|
"Use the operational data provided below. Do not invent details. "
|
||||||
|
"If data is missing for a section, omit that section."
|
||||||
|
)
|
||||||
0
mcp_servers/shared/__init__.py
Normal file
5
mcp_servers/shared/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Entry point for running the shared MCP server as a module."""
|
||||||
|
|
||||||
|
from mcp_servers.shared.server import mcp
|
||||||
|
|
||||||
|
mcp.run()
|
||||||
235
mcp_servers/shared/server.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""Shared MCP server — tools, resources, and prompts used by both agent clients.
|
||||||
|
|
||||||
|
Covers: flight status, weather (live OpenMeteo), airport status (live FAA),
|
||||||
|
maintenance flags, hub reference data, and delay explanation prompts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from mcp_servers.data.models import HUBS, FlightStatus
|
||||||
|
from mcp_servers.data.real import faa, openmeteo
|
||||||
|
from mcp_servers.data.scenarios.manager import scenario_manager
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
"united-ops-shared",
|
||||||
|
instructions=(
|
||||||
|
"Shared operational data tools for Stellar Air operations. "
|
||||||
|
"Covers: flight status, weather (live OpenMeteo), airport status "
|
||||||
|
"(live FAA), and maintenance flags. Used by all agent clients."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tools ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_flight_status(flight_id: str) -> dict:
|
||||||
|
"""Lightweight status check for a single flight.
|
||||||
|
|
||||||
|
Returns: flight_id, status (ON_TIME/DELAYED/CANCELLED/DIVERTED),
|
||||||
|
delay_minutes, gate, last_updated.
|
||||||
|
Use this for quick triage before pulling full operational data.
|
||||||
|
"""
|
||||||
|
for f in scenario_manager.flights:
|
||||||
|
if f.flight_id == flight_id:
|
||||||
|
return {
|
||||||
|
"flight_id": f.flight_id,
|
||||||
|
"status": f.status.value,
|
||||||
|
"delay_minutes": f.delay_minutes,
|
||||||
|
"gate": f.gate,
|
||||||
|
"origin": f.origin,
|
||||||
|
"destination": f.destination,
|
||||||
|
"source": "scenario_mock",
|
||||||
|
}
|
||||||
|
return {"error": f"Flight {flight_id} not found in active scenario"}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_flight_details(flight_id: str) -> dict:
|
||||||
|
"""Full operational context for a flight.
|
||||||
|
|
||||||
|
Returns: scheduled vs actual times, delay cause code, inbound aircraft
|
||||||
|
tail number, gate assignment, crew IDs, passenger count.
|
||||||
|
"""
|
||||||
|
for f in scenario_manager.flights:
|
||||||
|
if f.flight_id == flight_id:
|
||||||
|
return f.model_dump(mode="json")
|
||||||
|
return {"error": f"Flight {flight_id} not found in active scenario"}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_irregular_ops(hub: str) -> list[dict]:
|
||||||
|
"""All flights in irregular operations state at a hub.
|
||||||
|
|
||||||
|
hub: ORD | EWR | IAH | SFO | DEN
|
||||||
|
Returns list of: flight_id, irrop_type, affected_pax_count,
|
||||||
|
delay_cause, delay_minutes.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for f in scenario_manager.flights:
|
||||||
|
if f.origin == hub.upper() and f.status != FlightStatus.ON_TIME:
|
||||||
|
results.append({
|
||||||
|
"flight_id": f.flight_id,
|
||||||
|
"irrop_type": f.status.value,
|
||||||
|
"affected_pax_count": f.passenger_count,
|
||||||
|
"delay_cause": f.delay_cause.value if f.delay_cause else None,
|
||||||
|
"delay_minutes": f.delay_minutes,
|
||||||
|
"origin": f.origin,
|
||||||
|
"destination": f.destination,
|
||||||
|
"gate": f.gate,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_route_weather(origin: str, destination: str) -> dict:
|
||||||
|
"""Live weather at origin, destination, and en-route waypoints.
|
||||||
|
|
||||||
|
Source: OpenMeteo API (real-time, no API key).
|
||||||
|
Returns per waypoint: conditions, temperature, wind, visibility,
|
||||||
|
precipitation, significant events.
|
||||||
|
"""
|
||||||
|
return await openmeteo.get_weather_along_route(origin, destination)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_hub_forecasts() -> dict:
|
||||||
|
"""4-hour weather forecast for all United hubs (ORD, EWR, IAH, SFO, DEN).
|
||||||
|
|
||||||
|
Source: OpenMeteo API.
|
||||||
|
Returns per hub: hourly forecast with conditions, wind, visibility,
|
||||||
|
and a risk_flag if convective activity or low visibility expected.
|
||||||
|
"""
|
||||||
|
return await openmeteo.get_weather_forecast_hubs()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_airport_status(airport_code: str) -> dict:
|
||||||
|
"""Live FAA airport status.
|
||||||
|
|
||||||
|
Source: FAA NASSTATUS API (real-time, no API key).
|
||||||
|
Returns: delay_type, delay_reason, average_delay_minutes,
|
||||||
|
ground_delay_program active/inactive, ground_stop active/inactive.
|
||||||
|
Falls back to 'status_unavailable' if FAA API is unreachable.
|
||||||
|
"""
|
||||||
|
return await faa.get_airport_status(airport_code)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_airport_congestion(airport_code: str) -> dict:
|
||||||
|
"""Gate availability and taxi times at an airport.
|
||||||
|
|
||||||
|
Combines: FAA live status (real) + gate/taxi mock data from scenario.
|
||||||
|
Returns: faa_status, gates_available, gates_total,
|
||||||
|
avg_taxi_out_minutes, avg_taxi_in_minutes.
|
||||||
|
"""
|
||||||
|
faa_status = await faa.get_airport_status(airport_code)
|
||||||
|
hub = HUBS.get(airport_code.upper())
|
||||||
|
total_gates = hub.gates if hub else 100
|
||||||
|
|
||||||
|
# Mock congestion based on disrupted flights in scenario
|
||||||
|
disrupted = sum(
|
||||||
|
1 for f in scenario_manager.flights
|
||||||
|
if f.origin == airport_code.upper() and f.status != FlightStatus.ON_TIME
|
||||||
|
)
|
||||||
|
gates_occupied = min(total_gates, int(total_gates * 0.7) + disrupted * 3)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"airport": airport_code.upper(),
|
||||||
|
"faa_status": faa_status,
|
||||||
|
"gates_available": total_gates - gates_occupied,
|
||||||
|
"gates_total": total_gates,
|
||||||
|
"avg_taxi_out_minutes": 12 + disrupted * 4,
|
||||||
|
"avg_taxi_in_minutes": 8 + disrupted * 2,
|
||||||
|
"source": "faa_live+scenario_mock",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_maintenance_flags(aircraft_tail: str) -> list[dict]:
|
||||||
|
"""Active MEL (Minimum Equipment List) items for an aircraft.
|
||||||
|
|
||||||
|
Returns per item: mel_id, system, description, restriction
|
||||||
|
(route/ops limitations), expiry date.
|
||||||
|
"""
|
||||||
|
items = scenario_manager.maintenance.get(aircraft_tail, [])
|
||||||
|
return [item.model_dump(mode="json") for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resources ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ops://hubs/{hub_code}")
|
||||||
|
def hub_info(hub_code: str) -> str:
|
||||||
|
"""Static reference data for a hub.
|
||||||
|
|
||||||
|
Returns: full name, codes, coordinates, timezone, terminal/gate/runway count.
|
||||||
|
"""
|
||||||
|
hub = HUBS.get(hub_code.upper())
|
||||||
|
if not hub:
|
||||||
|
return f"Unknown hub: {hub_code}. Known: {', '.join(HUBS.keys())}"
|
||||||
|
return hub.model_dump_json()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ops://scenarios/active")
|
||||||
|
def active_scenario() -> str:
|
||||||
|
"""Current active scenario metadata."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
return json.dumps(scenario_manager.get_metadata())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Prompts ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
DELAY_TEMPLATES = {
|
||||||
|
("WEATHER", "passenger"): (
|
||||||
|
"Explain this flight delay to a passenger. The cause is weather-related. "
|
||||||
|
"Be empathetic and transparent. Include what's happening with the weather, "
|
||||||
|
"what the airline is doing about it, and what the passenger should expect next. "
|
||||||
|
"Do not use aviation jargon."
|
||||||
|
),
|
||||||
|
("WEATHER", "ops_manager"): (
|
||||||
|
"Summarize this weather-caused delay for an ops manager. "
|
||||||
|
"Include: delay cause code, affected systems, GDP/ground stop status if applicable, "
|
||||||
|
"forecast window, and recommended next actions. Be concise."
|
||||||
|
),
|
||||||
|
("MAINTENANCE", "passenger"): (
|
||||||
|
"Explain this maintenance-caused delay to a passenger. "
|
||||||
|
"Be reassuring — emphasize safety. Describe what's being checked "
|
||||||
|
"without technical jargon. Give expected timeline if available."
|
||||||
|
),
|
||||||
|
("MAINTENANCE", "ops_manager"): (
|
||||||
|
"Summarize this maintenance delay for an ops manager. "
|
||||||
|
"Include: MEL item, system affected, restriction details, "
|
||||||
|
"estimated release time, and downstream impact."
|
||||||
|
),
|
||||||
|
("CREW", "passenger"): (
|
||||||
|
"Explain this crew-related delay to a passenger. "
|
||||||
|
"Frame it as 'ensuring your crew is fully rested for safe operation.' "
|
||||||
|
"Do not mention specific regulations or duty limits."
|
||||||
|
),
|
||||||
|
("CREW", "ops_manager"): (
|
||||||
|
"Summarize this crew-caused delay for an ops manager. "
|
||||||
|
"Include: Part 117 status, hours remaining, swap options, "
|
||||||
|
"backup crew availability, and timeline."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
def delay_explainer(cause_code: str, audience: str) -> str:
|
||||||
|
"""Turns a raw delay cause code into a human-readable explanation prompt.
|
||||||
|
|
||||||
|
cause_code: WEATHER | MAINTENANCE | CREW | ATC | LATE_AIRCRAFT
|
||||||
|
audience: passenger | ops_manager
|
||||||
|
"""
|
||||||
|
key = (cause_code.upper(), audience.lower())
|
||||||
|
default_key = ("WEATHER", audience.lower())
|
||||||
|
template = DELAY_TEMPLATES.get(key, DELAY_TEMPLATES.get(default_key, ""))
|
||||||
|
return (
|
||||||
|
f"{template}\n\n"
|
||||||
|
"Use the operational data provided below. Do not invent details. "
|
||||||
|
"If data is missing for a section, omit that section."
|
||||||
|
)
|
||||||
36
pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[project]
|
||||||
|
name = "united-ops"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=3.0",
|
||||||
|
"mcp[cli]",
|
||||||
|
"langgraph",
|
||||||
|
"langchain-aws",
|
||||||
|
"langchain-anthropic",
|
||||||
|
"boto3",
|
||||||
|
"anthropic",
|
||||||
|
"fastapi",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
"pydantic>=2.0",
|
||||||
|
"httpx",
|
||||||
|
"langfuse",
|
||||||
|
"websockets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-asyncio",
|
||||||
|
"ruff",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
0
tests/__init__.py
Normal file
14
ui/app/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Stellar Air — NOVA</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
ui/app/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "united-ops-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vue-flow/core": "^1.48.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"soleprint-ui": "link:../framework",
|
||||||
|
"uplot": "^1.6.32",
|
||||||
|
"vue": "^3.5",
|
||||||
|
"vue-router": "^4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5",
|
||||||
|
"typescript": "^5.7",
|
||||||
|
"vite": "^6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1098
ui/app/pnpm-lock.yaml
generated
Normal file
93
ui/app/src/App.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import ScenarioSelector from './components/ScenarioSelector.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-title">
|
||||||
|
<h1>STELLAR AIR</h1>
|
||||||
|
<span class="app-subtitle">NOVA Operations Platform</span>
|
||||||
|
</div>
|
||||||
|
<nav class="app-nav">
|
||||||
|
<router-link to="/" :class="{ active: route.path === '/' }">Operations</router-link>
|
||||||
|
<router-link to="/internals" :class="{ active: route.path === '/internals' }">Internals</router-link>
|
||||||
|
<router-link to="/data" :class="{ active: route.path === '/data' }">Data</router-link>
|
||||||
|
</nav>
|
||||||
|
<ScenarioSelector />
|
||||||
|
</header>
|
||||||
|
<main class="app-main">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title h1 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border: var(--panel-border);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
139
ui/app/src/components/HandoverBrief.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
data: {
|
||||||
|
brief_text: string
|
||||||
|
summary: { immediate_count: number; monitor_count: number; fyi_count: number }
|
||||||
|
items: { immediate: string[]; monitor: string[]; fyi: string[] }
|
||||||
|
generated_at: string
|
||||||
|
duration_ms: number
|
||||||
|
hubs: string[]
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="handover-brief">
|
||||||
|
<div class="brief-header">
|
||||||
|
<span class="brief-title">SHIFT HANDOVER BRIEF</span>
|
||||||
|
<div class="summary-badges">
|
||||||
|
<span v-if="data.summary.immediate_count" class="badge immediate">
|
||||||
|
{{ data.summary.immediate_count }} IMMEDIATE
|
||||||
|
</span>
|
||||||
|
<span v-if="data.summary.monitor_count" class="badge monitor">
|
||||||
|
{{ data.summary.monitor_count }} MONITOR
|
||||||
|
</span>
|
||||||
|
<span v-if="data.summary.fyi_count" class="badge fyi">
|
||||||
|
{{ data.summary.fyi_count }} FYI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="brief-body">
|
||||||
|
<section v-if="data.items.immediate.length" class="section immediate">
|
||||||
|
<h3>━━━ IMMEDIATE ACTION ━━━</h3>
|
||||||
|
<div v-for="(item, i) in data.items.immediate" :key="i" class="item">
|
||||||
|
<span class="marker">▶</span> {{ item }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="data.items.monitor.length" class="section monitor">
|
||||||
|
<h3>━━━ MONITOR ━━━</h3>
|
||||||
|
<div v-for="(item, i) in data.items.monitor" :key="i" class="item">
|
||||||
|
<span class="marker">⚠</span> {{ item }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="data.items.fyi.length" class="section fyi">
|
||||||
|
<h3>━━━ FYI ━━━</h3>
|
||||||
|
<div v-for="(item, i) in data.items.fyi" :key="i" class="item">
|
||||||
|
<span class="marker">ℹ</span> {{ item }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="brief-footer">
|
||||||
|
<span>Hubs: {{ data.hubs.join(', ') }}</span>
|
||||||
|
<span>{{ data.duration_ms }}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.handover-brief {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.immediate { background: var(--status-error); color: var(--surface-0); }
|
||||||
|
.badge.monitor { background: var(--status-warning); color: var(--surface-0); }
|
||||||
|
.badge.fyi { background: var(--surface-3); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.brief-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section.immediate h3 { color: var(--status-error); }
|
||||||
|
.section.monitor h3 { color: var(--status-warning); }
|
||||||
|
.section.fyi h3 { color: var(--text-dim); }
|
||||||
|
|
||||||
|
.item {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 4px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: var(--panel-border);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
ui/app/src/components/NotificationCard.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
data: {
|
||||||
|
flight_id: string
|
||||||
|
status: string
|
||||||
|
delay_minutes: number
|
||||||
|
notification_text: string
|
||||||
|
data_sources: string[]
|
||||||
|
generated_at: string
|
||||||
|
human_approved: boolean
|
||||||
|
duration_ms: number
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const causeColor: Record<string, string> = {
|
||||||
|
DELAYED: 'var(--status-warning)',
|
||||||
|
CANCELLED: 'var(--status-error)',
|
||||||
|
DIVERTED: 'var(--status-error)',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="notification-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="flight-id">{{ data.flight_id }}</span>
|
||||||
|
<span class="status-badge" :style="{ background: causeColor[data.status] || 'var(--status-idle)' }">
|
||||||
|
{{ data.status }}
|
||||||
|
<template v-if="data.delay_minutes"> {{ data.delay_minutes }}min</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<pre class="notification-text">{{ data.notification_text }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="sources">
|
||||||
|
<template v-for="(s, i) in data.data_sources" :key="s">
|
||||||
|
<span :class="['source-tag', s.includes('live') ? 'live' : 'mock']">{{ s }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="meta">
|
||||||
|
{{ data.duration_ms }}ms
|
||||||
|
<span v-if="data.human_approved" class="approved">approved</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flight-id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
color: var(--surface-0);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: var(--panel-border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-tag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-tag.live {
|
||||||
|
border-color: var(--status-live);
|
||||||
|
color: var(--status-live);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approved {
|
||||||
|
color: var(--status-live);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
ui/app/src/components/ScenarioSelector.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const scenarios = ref<any[]>([])
|
||||||
|
const active = ref('')
|
||||||
|
|
||||||
|
async function loadScenarios() {
|
||||||
|
const res = await fetch('/scenarios')
|
||||||
|
scenarios.value = await res.json()
|
||||||
|
const activeRes = await fetch('/scenarios/active')
|
||||||
|
const activeData = await activeRes.json()
|
||||||
|
active.value = activeData.scenario_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchScenario(id: string) {
|
||||||
|
await fetch('/scenarios/active', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ scenario_id: id }),
|
||||||
|
})
|
||||||
|
active.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadScenarios)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="scenario-selector">
|
||||||
|
<label>Scenario:</label>
|
||||||
|
<select :value="active" @change="switchScenario(($event.target as HTMLSelectElement).value)">
|
||||||
|
<option v-for="s in scenarios" :key="s.scenario_id" :value="s.scenario_id">
|
||||||
|
{{ s.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scenario-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: var(--panel-border);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
ui/app/src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import 'soleprint-ui/src/tokens.css'
|
||||||
|
import './styles/mars-tokens.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import OpsNotifications from './pages/OpsNotifications.vue'
|
||||||
|
import AgentInternals from './pages/AgentInternals.vue'
|
||||||
|
import ScenarioData from './pages/ScenarioData.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: OpsNotifications },
|
||||||
|
{ path: '/internals', component: AgentInternals },
|
||||||
|
{ path: '/data', component: ScenarioData },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
236
ui/app/src/pages/AgentInternals.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { Panel, SplitPane, LogRenderer } from 'soleprint-ui'
|
||||||
|
import type { LogEntry } from 'soleprint-ui'
|
||||||
|
|
||||||
|
const agentStatus = ref<'idle' | 'live' | 'processing' | 'error'>('idle')
|
||||||
|
const entries = ref<LogEntry[]>([])
|
||||||
|
const graphNodes = ref<{ id: string; status: string }[]>([])
|
||||||
|
const currentRun = ref<{ agent: string; run_id: string } | null>(null)
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
|
||||||
|
function connectWs() {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
ws = new WebSocket(`${protocol}//${location.host}/ws/agent-events`)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
agentStatus.value = 'live'
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const event = JSON.parse(e.data)
|
||||||
|
handleEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
agentStatus.value = 'idle'
|
||||||
|
setTimeout(connectWs, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
agentStatus.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEvent(event: any) {
|
||||||
|
const ts = event.timestamp || new Date().toISOString()
|
||||||
|
const time = ts.split('T')[1]?.split('.')[0] || ts
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'agent_start':
|
||||||
|
currentRun.value = { agent: event.agent, run_id: event.run_id }
|
||||||
|
agentStatus.value = 'processing'
|
||||||
|
graphNodes.value = []
|
||||||
|
entries.value = [{
|
||||||
|
level: 'info',
|
||||||
|
stage: 'system',
|
||||||
|
msg: `Agent ${event.agent} started (${event.run_id})`,
|
||||||
|
ts: time,
|
||||||
|
}]
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'node_enter':
|
||||||
|
graphNodes.value.push({ id: event.node, status: 'processing' })
|
||||||
|
entries.value.push({
|
||||||
|
level: 'info',
|
||||||
|
stage: event.node,
|
||||||
|
msg: `→ entering ${event.node}`,
|
||||||
|
ts: time,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'node_exit':
|
||||||
|
const node = graphNodes.value.find(n => n.id === event.node)
|
||||||
|
if (node) node.status = 'done'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tool_call_end':
|
||||||
|
const liveTag = event.is_live ? ' (live)' : ' (mock)'
|
||||||
|
entries.value.push({
|
||||||
|
level: 'info',
|
||||||
|
stage: '',
|
||||||
|
msg: `${event.tool} — ${event.latency_ms}ms ✓${liveTag}`,
|
||||||
|
ts: time,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tool_call_error':
|
||||||
|
entries.value.push({
|
||||||
|
level: 'error',
|
||||||
|
stage: '',
|
||||||
|
msg: `${event.tool} — FAILED: ${event.error}`,
|
||||||
|
ts: time,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'agent_end':
|
||||||
|
agentStatus.value = 'live'
|
||||||
|
entries.value.push({
|
||||||
|
level: 'info',
|
||||||
|
stage: 'system',
|
||||||
|
msg: `Agent complete: ${event.output_summary}`,
|
||||||
|
ts: time,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(connectWs)
|
||||||
|
onUnmounted(() => { ws?.close() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="internals-page">
|
||||||
|
<SplitPane direction="horizontal" :initial-size="350" size-mode="px" :min="250" :max="500">
|
||||||
|
<template #first>
|
||||||
|
<Panel title="Agent Graph" :status="agentStatus">
|
||||||
|
<div class="graph-container">
|
||||||
|
<div v-if="graphNodes.length === 0" class="empty">
|
||||||
|
Waiting for agent run...
|
||||||
|
</div>
|
||||||
|
<div v-else class="graph-nodes">
|
||||||
|
<div
|
||||||
|
v-for="(node, i) in graphNodes"
|
||||||
|
:key="node.id"
|
||||||
|
:class="['graph-node', node.status]"
|
||||||
|
>
|
||||||
|
<div class="node-dot"></div>
|
||||||
|
<span class="node-label">{{ node.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="graphNodes.length" class="graph-edge-line"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
<template #second>
|
||||||
|
<Panel title="Tool Call Stream" :status="agentStatus">
|
||||||
|
<LogRenderer :entries="entries" :auto-scroll="true" />
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
</SplitPane>
|
||||||
|
|
||||||
|
<Panel title="Run Summary" status="idle" class="summary-panel">
|
||||||
|
<div v-if="currentRun" class="summary">
|
||||||
|
<span>Agent: {{ currentRun.agent }}</span>
|
||||||
|
<span>Run: {{ currentRun.run_id }}</span>
|
||||||
|
<span>Events: {{ entries.length }}</span>
|
||||||
|
<span>Nodes: {{ graphNodes.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty">
|
||||||
|
Trigger an agent from the Operations page to see internals here.
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.internals-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.internals-page > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-container {
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-nodes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-edge-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 17px;
|
||||||
|
top: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: var(--panel-border);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-idle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node.processing .node-dot {
|
||||||
|
background: var(--status-processing);
|
||||||
|
box-shadow: 0 0 8px var(--status-processing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node.done .node-dot {
|
||||||
|
background: var(--status-live);
|
||||||
|
box-shadow: 0 0 8px var(--status-live);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
175
ui/app/src/pages/OpsNotifications.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Panel } from 'soleprint-ui'
|
||||||
|
import NotificationCard from '../components/NotificationCard.vue'
|
||||||
|
import HandoverBrief from '../components/HandoverBrief.vue'
|
||||||
|
|
||||||
|
const flights = ref<any[]>([])
|
||||||
|
const selectedFlight = ref('')
|
||||||
|
const efhasStatus = ref<'idle' | 'processing' | 'live' | 'error'>('idle')
|
||||||
|
const handoverStatus = ref<'idle' | 'processing' | 'live' | 'error'>('idle')
|
||||||
|
const notification = ref<any>(null)
|
||||||
|
const handoverBrief = ref<any>(null)
|
||||||
|
|
||||||
|
async function loadFlights() {
|
||||||
|
// Load flights from active scenario via a simple status check on known IDs
|
||||||
|
const res = await fetch('/scenarios/active')
|
||||||
|
const scenario = await res.json()
|
||||||
|
// Fetch flights by triggering the shared MCP server isn't exposed directly
|
||||||
|
// so we'll use a hardcoded list per scenario for now
|
||||||
|
// TODO: expose flight list via API
|
||||||
|
flights.value = [
|
||||||
|
{ id: 'UA432', label: 'UA432 ORD→SFO' },
|
||||||
|
{ id: 'UA881', label: 'UA881 ORD→LAX' },
|
||||||
|
{ id: 'UA233', label: 'UA233 ORD→DEN' },
|
||||||
|
{ id: 'UA094', label: 'UA094 ORD→EWR' },
|
||||||
|
{ id: 'UA517', label: 'UA517 ORD→IAH (CANCELLED)' },
|
||||||
|
{ id: 'UA1220', label: 'UA1220 ORD→IAD (on-time)' },
|
||||||
|
]
|
||||||
|
selectedFlight.value = flights.value[0]?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runEfhas() {
|
||||||
|
if (!selectedFlight.value) return
|
||||||
|
efhasStatus.value = 'processing'
|
||||||
|
notification.value = null
|
||||||
|
|
||||||
|
const res = await fetch('/agents/efhas', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ flight_id: selectedFlight.value }),
|
||||||
|
})
|
||||||
|
const { run_id } = await res.json()
|
||||||
|
|
||||||
|
// Poll for result
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
const r = await fetch(`/agents/runs/${run_id}`)
|
||||||
|
const data = await r.json()
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
clearInterval(poll)
|
||||||
|
notification.value = data.result
|
||||||
|
efhasStatus.value = 'live'
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
clearInterval(poll)
|
||||||
|
efhasStatus.value = 'error'
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHandover() {
|
||||||
|
handoverStatus.value = 'processing'
|
||||||
|
handoverBrief.value = null
|
||||||
|
|
||||||
|
const res = await fetch('/agents/handover', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
const { run_id } = await res.json()
|
||||||
|
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
const r = await fetch(`/agents/runs/${run_id}`)
|
||||||
|
const data = await r.json()
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
clearInterval(poll)
|
||||||
|
handoverBrief.value = data.result
|
||||||
|
handoverStatus.value = 'live'
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
clearInterval(poll)
|
||||||
|
handoverStatus.value = 'error'
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadFlights)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ops-page">
|
||||||
|
<Panel title="FCE — Behind Every Departure" :status="efhasStatus">
|
||||||
|
<template #actions>
|
||||||
|
<select v-model="selectedFlight" class="flight-select">
|
||||||
|
<option v-for="f in flights" :key="f.id" :value="f.id">{{ f.label }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="run-btn" @click="runEfhas" :disabled="efhasStatus === 'processing'">
|
||||||
|
{{ efhasStatus === 'processing' ? 'Running...' : 'Run FCE' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="notification" class="result-area">
|
||||||
|
<NotificationCard :data="notification" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="efhasStatus === 'processing'" class="loading">
|
||||||
|
Running agent... gathering flight data, weather, crew notes...
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty">
|
||||||
|
Select a flight and click Run FCE to generate a notification.
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Shift Handover Brief" :status="handoverStatus">
|
||||||
|
<template #actions>
|
||||||
|
<button class="run-btn" @click="runHandover" :disabled="handoverStatus === 'processing'">
|
||||||
|
{{ handoverStatus === 'processing' ? 'Running...' : 'Run Handover' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="handoverBrief" class="result-area">
|
||||||
|
<HandoverBrief :data="handoverBrief" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="handoverStatus === 'processing'" class="loading">
|
||||||
|
Running agent... scanning all hubs for active issues...
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty">
|
||||||
|
Click Run Handover to generate a shift handover brief.
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ops-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flight-select {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: var(--panel-border);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-btn:hover { background: var(--accent-dim); }
|
||||||
|
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.result-area {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
387
ui/app/src/pages/ScenarioData.vue
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { Panel } from 'soleprint-ui'
|
||||||
|
|
||||||
|
const activeTab = ref<'flights' | 'crew' | 'notes' | 'maintenance' | 'rebookings'>('flights')
|
||||||
|
const flights = ref<any[]>([])
|
||||||
|
const crew = ref<any[]>([])
|
||||||
|
const crewNotes = ref<Record<string, string[]>>({})
|
||||||
|
const maintenance = ref<Record<string, any[]>>({})
|
||||||
|
const rebookings = ref<any[]>([])
|
||||||
|
|
||||||
|
const editingFlight = ref<string | null>(null)
|
||||||
|
const editingCrew = ref<string | null>(null)
|
||||||
|
const editingNotes = ref<string | null>(null)
|
||||||
|
const editNotesText = ref('')
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
const [f, c, n, m, r] = await Promise.all([
|
||||||
|
fetch('/scenarios/data/flights').then(r => r.json()),
|
||||||
|
fetch('/scenarios/data/crew').then(r => r.json()),
|
||||||
|
fetch('/scenarios/data/crew-notes').then(r => r.json()),
|
||||||
|
fetch('/scenarios/data/maintenance').then(r => r.json()),
|
||||||
|
fetch('/scenarios/data/rebookings').then(r => r.json()),
|
||||||
|
])
|
||||||
|
flights.value = f
|
||||||
|
crew.value = c
|
||||||
|
crewNotes.value = n
|
||||||
|
maintenance.value = m
|
||||||
|
rebookings.value = r
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchFlight(flight: any) {
|
||||||
|
await fetch(`/scenarios/data/flights/${flight.flight_id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
delay_minutes: flight.delay_minutes,
|
||||||
|
status: flight.status,
|
||||||
|
gate: flight.gate,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
editingFlight.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchCrew(c: any) {
|
||||||
|
const res = await fetch(`/scenarios/data/crew/${c.crew_id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ duty_hours_elapsed: c.duty_hours_elapsed }),
|
||||||
|
})
|
||||||
|
const updated = await res.json()
|
||||||
|
const idx = crew.value.findIndex(x => x.crew_id === c.crew_id)
|
||||||
|
if (idx >= 0) crew.value[idx] = updated
|
||||||
|
editingCrew.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditNotes(flightId: string) {
|
||||||
|
editingNotes.value = flightId
|
||||||
|
editNotesText.value = (crewNotes.value[flightId] || []).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNotes(flightId: string) {
|
||||||
|
const notes = editNotesText.value.split('\n').filter(l => l.trim())
|
||||||
|
await fetch(`/scenarios/data/crew-notes/${flightId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ notes }),
|
||||||
|
})
|
||||||
|
crewNotes.value[flightId] = notes
|
||||||
|
editingNotes.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
ON_TIME: 'var(--status-live)',
|
||||||
|
DELAYED: 'var(--status-warning)',
|
||||||
|
CANCELLED: 'var(--status-error)',
|
||||||
|
DIVERTED: 'var(--status-error)',
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="data-page">
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button v-for="tab in ['flights', 'crew', 'notes', 'maintenance', 'rebookings']"
|
||||||
|
:key="tab" :class="{ active: activeTab === tab }" @click="activeTab = tab as any">
|
||||||
|
{{ tab }}
|
||||||
|
</button>
|
||||||
|
<button class="reload-btn" @click="loadAll">reload</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flights -->
|
||||||
|
<Panel v-if="activeTab === 'flights'" title="Flights" status="idle">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Flight</th><th>Route</th><th>Status</th><th>Delay</th>
|
||||||
|
<th>Gate</th><th>Aircraft</th><th>Pax</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="f in flights" :key="f.flight_id">
|
||||||
|
<td class="mono">{{ f.flight_id }}</td>
|
||||||
|
<td class="mono">{{ f.origin }}→{{ f.destination }}</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="editingFlight === f.flight_id">
|
||||||
|
<select v-model="f.status" class="inline-input">
|
||||||
|
<option v-for="s in ['ON_TIME','DELAYED','CANCELLED','DIVERTED']" :key="s">{{ s }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
<span v-else class="status-dot" :style="{ color: statusColors[f.status] }">{{ f.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="editingFlight === f.flight_id">
|
||||||
|
<input v-model.number="f.delay_minutes" type="number" class="inline-input num" />
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ f.delay_minutes }}min</template>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="editingFlight === f.flight_id">
|
||||||
|
<input v-model="f.gate" class="inline-input short" />
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ f.gate }}</template>
|
||||||
|
</td>
|
||||||
|
<td class="mono dim">{{ f.aircraft_tail }}</td>
|
||||||
|
<td class="dim">{{ f.passenger_count }}</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="editingFlight === f.flight_id" class="save-btn" @click="patchFlight(f)">save</button>
|
||||||
|
<button v-else class="edit-btn" @click="editingFlight = f.flight_id">edit</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Crew -->
|
||||||
|
<Panel v-if="activeTab === 'crew'" title="Crew" status="idle">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th><th>Name</th><th>Role</th><th>Duty Elapsed</th>
|
||||||
|
<th>Limit</th><th>Remaining</th><th>Status</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="c in crew" :key="c.crew_id" :class="{ 'at-risk': c.at_risk }">
|
||||||
|
<td class="mono dim">{{ c.crew_id }}</td>
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td class="mono">{{ c.role }}</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="editingCrew === c.crew_id">
|
||||||
|
<input v-model.number="c.duty_hours_elapsed" type="number" step="0.5" class="inline-input num" />
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ c.duty_hours_elapsed }}h</template>
|
||||||
|
</td>
|
||||||
|
<td class="dim">{{ c.duty_hours_limit }}h</td>
|
||||||
|
<td :style="{ color: c.at_risk ? 'var(--status-error)' : 'var(--text-secondary)' }">
|
||||||
|
{{ c.hours_until_limit }}h
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="c.at_risk" class="risk-badge">AT RISK</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="editingCrew === c.crew_id" class="save-btn" @click="patchCrew(c)">save</button>
|
||||||
|
<button v-else class="edit-btn" @click="editingCrew = c.crew_id">edit</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Crew Notes -->
|
||||||
|
<Panel v-if="activeTab === 'notes'" title="Crew Notes" status="idle">
|
||||||
|
<div v-if="Object.keys(crewNotes).length === 0" class="empty">No crew notes in this scenario.</div>
|
||||||
|
<div v-for="(notes, flightId) in crewNotes" :key="flightId" class="notes-block">
|
||||||
|
<div class="notes-header">
|
||||||
|
<span class="mono">{{ flightId }}</span>
|
||||||
|
<button v-if="editingNotes === flightId" class="save-btn" @click="saveNotes(flightId as string)">save</button>
|
||||||
|
<button v-else class="edit-btn" @click="startEditNotes(flightId as string)">edit</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="editingNotes === flightId">
|
||||||
|
<textarea v-model="editNotesText" class="notes-editor" rows="5"></textarea>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<ul class="notes-list">
|
||||||
|
<li v-for="(note, i) in notes" :key="i">{{ note }}</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Maintenance -->
|
||||||
|
<Panel v-if="activeTab === 'maintenance'" title="Maintenance (MEL Items)" status="idle">
|
||||||
|
<div v-if="Object.keys(maintenance).length === 0" class="empty">No MEL items in this scenario.</div>
|
||||||
|
<div v-for="(items, tail) in maintenance" :key="tail" class="notes-block">
|
||||||
|
<div class="notes-header"><span class="mono">{{ tail }}</span></div>
|
||||||
|
<div v-for="item in items" :key="item.mel_id" class="mel-item">
|
||||||
|
<div><span class="mono dim">{{ item.mel_id }}</span> — <strong>{{ item.system }}</strong></div>
|
||||||
|
<div class="dim">{{ item.description }}</div>
|
||||||
|
<div v-if="item.restriction" class="restriction">{{ item.restriction }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Rebookings -->
|
||||||
|
<Panel v-if="activeTab === 'rebookings'" title="Pending Rebookings" status="idle">
|
||||||
|
<div v-if="rebookings.length === 0" class="empty">No pending rebookings in this scenario.</div>
|
||||||
|
<table v-else class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Pax</th><th>Name</th><th>Status</th><th>Flight</th><th>Dest</th><th>Next</th><th>Urgency</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in rebookings" :key="r.pax_id">
|
||||||
|
<td class="mono dim">{{ r.pax_id }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td class="mono">{{ r.mileage_plus_status }}</td>
|
||||||
|
<td class="mono">{{ r.original_flight }}</td>
|
||||||
|
<td class="mono">{{ r.destination }}</td>
|
||||||
|
<td class="mono">{{ r.next_available || '—' }}</td>
|
||||||
|
<td :style="{ color: r.urgency === 'HIGH' ? 'var(--status-error)' : r.urgency === 'MEDIUM' ? 'var(--status-warning)' : 'var(--text-dim)' }">
|
||||||
|
{{ r.urgency }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.data-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar button {
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: var(--panel-border);
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar button:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||||
|
.tab-bar button.active { background: var(--accent-dim); color: var(--text-primary); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.reload-btn {
|
||||||
|
margin-left: auto !important;
|
||||||
|
color: var(--text-dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid rgba(30, 42, 74, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover { background: var(--surface-2); }
|
||||||
|
.data-table tr.at-risk { background: rgba(255, 61, 0, 0.08); }
|
||||||
|
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.dim { color: var(--text-dim); }
|
||||||
|
|
||||||
|
.status-dot { font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
|
||||||
|
|
||||||
|
.inline-input {
|
||||||
|
background: var(--surface-0);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-input.num { width: 60px; }
|
||||||
|
.inline-input.short { width: 50px; }
|
||||||
|
|
||||||
|
.edit-btn, .save-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
border: var(--panel-border);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.edit-btn:hover { background: var(--surface-3); }
|
||||||
|
.save-btn:hover { background: var(--accent-dim); }
|
||||||
|
|
||||||
|
.notes-block {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-list li {
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-left: 2px solid var(--surface-3);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-editor {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-0);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
padding: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mel-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid rgba(30, 42, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restriction {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--status-warning);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--status-error);
|
||||||
|
color: var(--surface-0);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
ui/app/src/styles/mars-tokens.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/* MARS theme — overrides framework tokens for United Ops aesthetic */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Surfaces — deep navy */
|
||||||
|
--surface-0: #0a0e17;
|
||||||
|
--surface-1: #121829;
|
||||||
|
--surface-2: #1a2340;
|
||||||
|
--surface-3: #243056;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #e8eaf0;
|
||||||
|
--text-secondary: #8892a8;
|
||||||
|
--text-dim: #4a5568;
|
||||||
|
|
||||||
|
/* Accent — United blue */
|
||||||
|
--accent: #0066ff;
|
||||||
|
--accent-dim: #003d99;
|
||||||
|
--accent-glow: rgba(0, 102, 255, 0.15);
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--status-idle: #4a5568;
|
||||||
|
--status-live: #00c853;
|
||||||
|
--status-processing: #0066ff;
|
||||||
|
--status-error: #ff3d00;
|
||||||
|
--status-warning: #ffc107;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
--font-ui: 'Inter', sans-serif;
|
||||||
|
|
||||||
|
/* Panel */
|
||||||
|
--panel-border: 1px solid #1e2a4a;
|
||||||
|
--panel-radius: 0px;
|
||||||
|
--panel-glow: 0 0 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--surface-0);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
14
ui/app/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
17
ui/app/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/agents': 'http://localhost:8000',
|
||||||
|
'/scenarios': 'http://localhost:8000',
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8000',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
25
ui/framework/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "soleprint-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vue-flow/core": "^1.48.2",
|
||||||
|
"pinia": "^2.2",
|
||||||
|
"uplot": "^1.6",
|
||||||
|
"vue": "^3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5",
|
||||||
|
"typescript": "^5.6",
|
||||||
|
"vite": "^6",
|
||||||
|
"vitest": "^2",
|
||||||
|
"vue-tsc": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
1692
ui/framework/pnpm-lock.yaml
generated
Normal file
32
ui/framework/src/components/LayoutGrid.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
columns?: number
|
||||||
|
rows?: number
|
||||||
|
gap?: string
|
||||||
|
}>(), {
|
||||||
|
columns: 2,
|
||||||
|
rows: 2,
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="layout-grid"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
|
||||||
|
gap: props.gap,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-grid {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
ui/framework/src/components/Panel.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">{{ title }}</span>
|
||||||
|
<span class="panel-actions"><slot name="actions" /></span>
|
||||||
|
<span class="panel-status" :class="status ?? 'idle'" />
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="panel-overlay">
|
||||||
|
<slot name="overlay" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: var(--panel-border);
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: var(--panel-header-height);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.panel-status.idle { background: var(--status-idle); }
|
||||||
|
.panel-status.live { background: var(--status-live); }
|
||||||
|
.panel-status.processing { background: var(--status-processing); }
|
||||||
|
.panel-status.error { background: var(--status-error); }
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-2);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: var(--panel-header-height) 0 0 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.panel-overlay > :deep(*) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
145
ui/framework/src/components/ParameterEditor.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
export interface ConfigField {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
default: unknown
|
||||||
|
description: string
|
||||||
|
min: number | null
|
||||||
|
max: number | null
|
||||||
|
options: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fields: ConfigField[]
|
||||||
|
values: Record<string, unknown>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update': [name: string, value: unknown]
|
||||||
|
'reset': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const numericFields = computed(() => props.fields.filter(f => f.type === 'int' || f.type === 'float'))
|
||||||
|
const boolFields = computed(() => props.fields.filter(f => f.type === 'bool'))
|
||||||
|
|
||||||
|
function onInput(name: string, value: unknown) {
|
||||||
|
emit('update', name, value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="param-editor">
|
||||||
|
<!-- Boolean fields -->
|
||||||
|
<label v-for="f in boolFields" :key="f.name" class="param-field bool-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="!!values[f.name]"
|
||||||
|
@change="(e) => onInput(f.name, (e.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Numeric fields (range sliders) -->
|
||||||
|
<div v-for="f in numericFields" :key="f.name" class="param-field">
|
||||||
|
<div class="field-header">
|
||||||
|
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||||
|
<span class="field-value">{{ values[f.name] }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:min="f.min ?? 0"
|
||||||
|
:max="f.max ?? 500"
|
||||||
|
:step="f.type === 'float' ? 0.01 : 1"
|
||||||
|
:value="values[f.name] as number"
|
||||||
|
@input="(e) => onInput(f.name, Number((e.target as HTMLInputElement).value))"
|
||||||
|
/>
|
||||||
|
<div class="field-range">
|
||||||
|
<span>{{ f.min ?? 0 }}</span>
|
||||||
|
<span>{{ f.max ?? 500 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.param-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bool-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-range {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
accent-color: #00bcd4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
70
ui/framework/src/components/ResizeHandle.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
direction: 'horizontal' | 'vertical'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
resize: [delta: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dragging = ref(false)
|
||||||
|
let startPos = 0
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
dragging.value = true
|
||||||
|
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
el.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging.value) return
|
||||||
|
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||||
|
const delta = currentPos - startPos
|
||||||
|
startPos = currentPos
|
||||||
|
emit('resize', delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
dragging.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="resize-handle"
|
||||||
|
:class="[direction, { dragging }]"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resize-handle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover,
|
||||||
|
.resize-handle.dragging {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.horizontal {
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
margin: 0 -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.vertical {
|
||||||
|
height: 4px;
|
||||||
|
cursor: row-resize;
|
||||||
|
margin: -2px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
157
ui/framework/src/components/SplitPane.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Split direction */
|
||||||
|
direction?: 'horizontal' | 'vertical'
|
||||||
|
/** Initial size of the sized pane (px or flex ratio) */
|
||||||
|
initialSize?: number
|
||||||
|
/** Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio */
|
||||||
|
sizeMode?: 'px' | 'ratio'
|
||||||
|
/** Which pane is sized: 'first' or 'second'. Default: 'first'. */
|
||||||
|
anchor?: 'first' | 'second'
|
||||||
|
/** Min size (px in px-mode, ratio in ratio-mode) */
|
||||||
|
min?: number
|
||||||
|
/** Max size (px in px-mode, ratio in ratio-mode) */
|
||||||
|
max?: number
|
||||||
|
/** Whether the divider is draggable */
|
||||||
|
resizable?: boolean
|
||||||
|
}>(), {
|
||||||
|
direction: 'horizontal',
|
||||||
|
initialSize: 1,
|
||||||
|
sizeMode: 'ratio',
|
||||||
|
anchor: 'first',
|
||||||
|
min: 0.1,
|
||||||
|
max: 10,
|
||||||
|
resizable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const size = ref(props.initialSize)
|
||||||
|
const dragging = ref(false)
|
||||||
|
let startPos = 0
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (!props.resizable) return
|
||||||
|
dragging.value = true
|
||||||
|
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
el.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging.value) return
|
||||||
|
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||||
|
let delta = currentPos - startPos
|
||||||
|
startPos = currentPos
|
||||||
|
|
||||||
|
// Dragging right/down grows first pane, shrinks second.
|
||||||
|
// If anchor is 'second', invert so dragging grows the second pane.
|
||||||
|
if (props.anchor === 'second') delta = -delta
|
||||||
|
|
||||||
|
if (props.sizeMode === 'px') {
|
||||||
|
size.value = Math.max(props.min, Math.min(props.max, size.value + delta))
|
||||||
|
} else {
|
||||||
|
const scale = props.direction === 'horizontal' ? 0.01 : 0.02
|
||||||
|
size.value = Math.max(props.min, Math.min(props.max, size.value + delta * scale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
dragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHorizontal = computed(() => props.direction === 'horizontal')
|
||||||
|
|
||||||
|
const sizedStyle = computed(() => {
|
||||||
|
if (props.sizeMode === 'px') {
|
||||||
|
const sizeStr = size.value + 'px'
|
||||||
|
const minStr = props.min + 'px'
|
||||||
|
return isHorizontal.value
|
||||||
|
? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
|
||||||
|
: { height: sizeStr, minHeight: minStr, flexShrink: '0' }
|
||||||
|
}
|
||||||
|
return { flex: String(size.value) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const flexStyle = computed(() => ({ flex: '1' }))
|
||||||
|
|
||||||
|
const firstStyle = computed(() => props.anchor === 'first' ? sizedStyle.value : flexStyle.value)
|
||||||
|
const secondStyle = computed(() => props.anchor === 'second' ? sizedStyle.value : flexStyle.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="split-pane" :class="[direction]">
|
||||||
|
<div class="split-first" :style="firstStyle">
|
||||||
|
<slot name="first" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="resizable"
|
||||||
|
class="split-divider"
|
||||||
|
:class="[direction, { dragging }]"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
/>
|
||||||
|
<div class="split-second" :style="secondStyle">
|
||||||
|
<slot name="second" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.split-pane {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-first,
|
||||||
|
.split-second {
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Children must fill their pane */
|
||||||
|
.split-first > :deep(*),
|
||||||
|
.split-second > :deep(*) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-divider {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-divider:hover,
|
||||||
|
.split-divider.dragging {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-divider.horizontal {
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
margin: 0 -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-divider.vertical {
|
||||||
|
height: 4px;
|
||||||
|
cursor: row-resize;
|
||||||
|
margin: -2px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
ui/framework/src/composables/useDataSource.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
import { DataSource, type DataSourceStatus } from '../datasources/DataSource'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable that connects a component to a DataSource.
|
||||||
|
*
|
||||||
|
* Connects on mount, disconnects on unmount.
|
||||||
|
* Returns reactive refs for data, status, and error.
|
||||||
|
*/
|
||||||
|
export function useDataSource<T = unknown>(source: DataSource<T>): {
|
||||||
|
data: Ref<T | null>
|
||||||
|
status: Ref<DataSourceStatus>
|
||||||
|
error: Ref<string | null>
|
||||||
|
} {
|
||||||
|
onMounted(() => source.connect())
|
||||||
|
onUnmounted(() => source.disconnect())
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: source.data as Ref<T | null>,
|
||||||
|
status: source.status,
|
||||||
|
error: source.error as Ref<string | null>,
|
||||||
|
}
|
||||||
|
}
|
||||||
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface EditorExecutionOptions {
|
||||||
|
/** Debounce delay in ms for auto-apply. Default: 150 */
|
||||||
|
debounceMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic editor execution pattern — debounced apply with auto-apply toggle,
|
||||||
|
* loading/error/timing state tracking.
|
||||||
|
*
|
||||||
|
* The caller provides the actual execution function. This composable handles
|
||||||
|
* the orchestration: debounce, auto-apply, loading state, timing.
|
||||||
|
*/
|
||||||
|
export function useEditorExecution(
|
||||||
|
executeFn: () => Promise<void>,
|
||||||
|
options: EditorExecutionOptions = {},
|
||||||
|
) {
|
||||||
|
const debounceMs = options.debounceMs ?? 150
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const autoApply = ref(true)
|
||||||
|
const execTimeMs = ref<number | null>(null)
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
execTimeMs.value = null
|
||||||
|
const t0 = performance.now()
|
||||||
|
try {
|
||||||
|
await executeFn()
|
||||||
|
execTimeMs.value = Math.round(performance.now() - t0)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onParameterChange() {
|
||||||
|
if (!autoApply.value) return
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => apply(), debounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
autoApply,
|
||||||
|
execTimeMs,
|
||||||
|
apply,
|
||||||
|
onParameterChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ui/framework/src/composables/useRegistry.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic registry composable — fetches typed data from a URL, caches it,
|
||||||
|
* exposes it reactively.
|
||||||
|
*
|
||||||
|
* Use for any data that is loaded once at app init and rarely changes:
|
||||||
|
* stage definitions, config schemas, available models, etc.
|
||||||
|
*
|
||||||
|
* The registry is shared across all consumers (singleton per URL).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cache = new Map<string, { data: Ref<any>; loading: Ref<boolean>; error: Ref<string | null>; promise: Promise<void> | null }>()
|
||||||
|
|
||||||
|
export function useRegistry<T>(url: string): {
|
||||||
|
data: Ref<T[]>
|
||||||
|
loading: Ref<boolean>
|
||||||
|
error: Ref<string | null>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
} {
|
||||||
|
if (!cache.has(url)) {
|
||||||
|
const data = ref<T[]>([]) as Ref<T[]>
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const entry = { data, loading, error, promise: null as Promise<void> | null }
|
||||||
|
cache.set(url, entry)
|
||||||
|
|
||||||
|
async function doFetch() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url)
|
||||||
|
if (!resp.ok) {
|
||||||
|
error.value = `Failed to fetch registry: ${resp.status}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.value = await resp.json()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.promise = doFetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = cache.get(url)!
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const data = entry.data
|
||||||
|
const loading = entry.loading
|
||||||
|
const error = entry.error
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url)
|
||||||
|
if (!resp.ok) {
|
||||||
|
error.value = `Failed to fetch registry: ${resp.status}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.value = await resp.json()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: entry.data as Ref<T[]>,
|
||||||
|
loading: entry.loading,
|
||||||
|
error: entry.error,
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ui/framework/src/datasources/DataSource.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { type Ref, ref } from 'vue'
|
||||||
|
|
||||||
|
export type DataSourceStatus = 'idle' | 'connecting' | 'live' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all data sources.
|
||||||
|
*
|
||||||
|
* A DataSource connects to some event stream, exposes reactive state,
|
||||||
|
* and lets consumers subscribe to typed events. Panels read from these
|
||||||
|
* reactively — they never touch the transport layer directly.
|
||||||
|
*/
|
||||||
|
export abstract class DataSource<T = unknown> {
|
||||||
|
readonly id: string
|
||||||
|
readonly data: Ref<T | null> = ref(null) as Ref<T | null>
|
||||||
|
readonly status: Ref<DataSourceStatus> = ref('idle')
|
||||||
|
readonly error: Ref<string | null> = ref(null) as Ref<string | null>
|
||||||
|
|
||||||
|
private listeners = new Map<string, Set<(payload: any) => void>>()
|
||||||
|
|
||||||
|
constructor(id: string) {
|
||||||
|
this.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract connect(): void
|
||||||
|
abstract disconnect(): void
|
||||||
|
|
||||||
|
/** Subscribe to a specific event type */
|
||||||
|
on<P = unknown>(eventType: string, handler: (payload: P) => void): () => void {
|
||||||
|
if (!this.listeners.has(eventType)) {
|
||||||
|
this.listeners.set(eventType, new Set())
|
||||||
|
}
|
||||||
|
this.listeners.get(eventType)!.add(handler)
|
||||||
|
return () => this.listeners.get(eventType)?.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit an event to subscribers (called by subclasses) */
|
||||||
|
protected emit(eventType: string, payload: unknown): void {
|
||||||
|
this.listeners.get(eventType)?.forEach((fn) => fn(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ui/framework/src/datasources/SSEDataSource.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { DataSource } from './DataSource'
|
||||||
|
|
||||||
|
export interface SSEDataSourceOptions {
|
||||||
|
/** Unique identifier for this source */
|
||||||
|
id: string
|
||||||
|
/** SSE endpoint URL (e.g. '/api/detect/stream/job-123') */
|
||||||
|
url: string
|
||||||
|
/** Event types to listen for. Each is dispatched to subscribers via on(). */
|
||||||
|
eventTypes: string[]
|
||||||
|
/** Max reconnection attempts before giving up. Default: 10 */
|
||||||
|
maxRetries?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSource backed by native EventSource (Server-Sent Events).
|
||||||
|
*
|
||||||
|
* Connects to a single SSE endpoint and demultiplexes events by type.
|
||||||
|
* Multiple panels can subscribe to different event types from the same source.
|
||||||
|
*/
|
||||||
|
export class SSEDataSource extends DataSource {
|
||||||
|
private es: EventSource | null = null
|
||||||
|
private url: string
|
||||||
|
private eventTypes: string[]
|
||||||
|
private maxRetries: number
|
||||||
|
private retryCount = 0
|
||||||
|
|
||||||
|
constructor(opts: SSEDataSourceOptions) {
|
||||||
|
super(opts.id)
|
||||||
|
this.url = opts.url
|
||||||
|
this.eventTypes = opts.eventTypes
|
||||||
|
this.maxRetries = opts.maxRetries ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.es) return
|
||||||
|
this.status.value = 'connecting'
|
||||||
|
this.error.value = null
|
||||||
|
|
||||||
|
this.es = new EventSource(this.url)
|
||||||
|
|
||||||
|
this.es.onopen = () => {
|
||||||
|
this.status.value = 'live'
|
||||||
|
this.retryCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
this.es.onerror = () => {
|
||||||
|
if (this.es?.readyState === EventSource.CLOSED) {
|
||||||
|
this.retryCount++
|
||||||
|
if (this.retryCount >= this.maxRetries) {
|
||||||
|
this.status.value = 'error'
|
||||||
|
this.error.value = `Connection lost after ${this.maxRetries} retries`
|
||||||
|
this.disconnect()
|
||||||
|
} else {
|
||||||
|
this.status.value = 'connecting'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a listener for each event type
|
||||||
|
for (const eventType of this.eventTypes) {
|
||||||
|
this.es.addEventListener(eventType, (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.data)
|
||||||
|
this.data.value = parsed
|
||||||
|
this.emit(eventType, parsed)
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal event — pipeline finished (success, failure, or cancel)
|
||||||
|
this.es.addEventListener('done', () => {
|
||||||
|
this.status.value = 'idle'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.es) {
|
||||||
|
this.es.close()
|
||||||
|
this.es = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the URL (e.g. when job ID changes) and reconnect */
|
||||||
|
setUrl(url: string): void {
|
||||||
|
this.url = url
|
||||||
|
if (this.status.value === 'live' || this.status.value === 'connecting') {
|
||||||
|
this.disconnect()
|
||||||
|
this.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
ui/framework/src/datasources/StaticDataSource.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { DataSource } from './DataSource'
|
||||||
|
|
||||||
|
export interface StaticEvent {
|
||||||
|
type: string
|
||||||
|
data: unknown
|
||||||
|
/** Delay in ms before emitting this event (relative to previous). Default: 0 */
|
||||||
|
delay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSource that replays a fixture array of events.
|
||||||
|
*
|
||||||
|
* Used for development and testing without a running backend.
|
||||||
|
* Events are emitted in sequence with optional delays.
|
||||||
|
*/
|
||||||
|
export class StaticDataSource extends DataSource {
|
||||||
|
private events: StaticEvent[]
|
||||||
|
private timeouts: ReturnType<typeof setTimeout>[] = []
|
||||||
|
|
||||||
|
constructor(id: string, events: StaticEvent[]) {
|
||||||
|
super(id)
|
||||||
|
this.events = events
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
this.status.value = 'live'
|
||||||
|
this.error.value = null
|
||||||
|
|
||||||
|
let cumDelay = 0
|
||||||
|
for (const event of this.events) {
|
||||||
|
cumDelay += event.delay ?? 0
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.data.value = event.data
|
||||||
|
this.emit(event.type, event.data)
|
||||||
|
}, cumDelay)
|
||||||
|
this.timeouts.push(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
for (const t of this.timeouts) clearTimeout(t)
|
||||||
|
this.timeouts = []
|
||||||
|
this.status.value = 'idle'
|
||||||
|
}
|
||||||
|
}
|
||||||
103
ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { StaticDataSource } from '../StaticDataSource'
|
||||||
|
|
||||||
|
describe('StaticDataSource', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits events in order', async () => {
|
||||||
|
const source = new StaticDataSource('test', [
|
||||||
|
{ type: 'log', data: { msg: 'first' } },
|
||||||
|
{ type: 'log', data: { msg: 'second' } },
|
||||||
|
{ type: 'stats', data: { count: 42 } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: { type: string; data: unknown }[] = []
|
||||||
|
source.on('log', (d) => received.push({ type: 'log', data: d }))
|
||||||
|
source.on('stats', (d) => received.push({ type: 'stats', data: d }))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
|
||||||
|
// Events with delay=0 fire on next microtask via setTimeout(0)
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(source.status.value).toBe('live')
|
||||||
|
expect(received).toHaveLength(3)
|
||||||
|
expect(received[0]).toEqual({ type: 'log', data: { msg: 'first' } })
|
||||||
|
expect(received[1]).toEqual({ type: 'log', data: { msg: 'second' } })
|
||||||
|
expect(received[2]).toEqual({ type: 'stats', data: { count: 42 } })
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
expect(source.status.value).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects delays between events', async () => {
|
||||||
|
const source = new StaticDataSource('test-delay', [
|
||||||
|
{ type: 'a', data: 1 },
|
||||||
|
{ type: 'b', data: 2, delay: 50 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
source.on('a', (d) => received.push(d))
|
||||||
|
source.on('b', (d) => received.push(d))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
expect(received).toHaveLength(1) // only 'a' so far
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
|
expect(received).toHaveLength(2) // 'b' arrived after delay
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates data ref with latest event payload', async () => {
|
||||||
|
const source = new StaticDataSource('test-data', [
|
||||||
|
{ type: 'x', data: { v: 1 } },
|
||||||
|
{ type: 'x', data: { v: 2 } },
|
||||||
|
])
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(source.data.value).toEqual({ v: 2 })
|
||||||
|
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans up on disconnect', async () => {
|
||||||
|
const source = new StaticDataSource('test-cleanup', [
|
||||||
|
{ type: 'a', data: 1 },
|
||||||
|
{ type: 'b', data: 2, delay: 100 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
source.on('b', (d) => received.push(d))
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
source.disconnect()
|
||||||
|
|
||||||
|
// 'b' should never fire since we disconnected before its delay
|
||||||
|
await new Promise((r) => setTimeout(r, 150))
|
||||||
|
expect(received).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unsubscribe removes listener', async () => {
|
||||||
|
const source = new StaticDataSource('test-unsub', [
|
||||||
|
{ type: 'x', data: 1 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
const unsub = source.on('x', (d) => received.push(d))
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
source.connect()
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
|
||||||
|
expect(received).toHaveLength(0)
|
||||||
|
source.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
38
ui/framework/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Framework public API
|
||||||
|
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
||||||
|
export { SSEDataSource } from './datasources/SSEDataSource'
|
||||||
|
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||||
|
export { useDataSource } from './composables/useDataSource'
|
||||||
|
export { useRegistry } from './composables/useRegistry'
|
||||||
|
export { useEditorExecution } from './composables/useEditorExecution'
|
||||||
|
export type { EditorExecutionOptions } from './composables/useEditorExecution'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { default as Panel } from './components/Panel.vue'
|
||||||
|
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||||
|
export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
||||||
|
export { default as SplitPane } from './components/SplitPane.vue'
|
||||||
|
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
||||||
|
export type { ConfigField } from './components/ParameterEditor.vue'
|
||||||
|
|
||||||
|
// Renderers
|
||||||
|
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||||
|
export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue'
|
||||||
|
export { default as GraphRenderer } from './renderers/GraphRenderer.vue'
|
||||||
|
export { default as FrameRenderer } from './renderers/FrameRenderer.vue'
|
||||||
|
export { default as TableRenderer } from './renderers/TableRenderer.vue'
|
||||||
|
|
||||||
|
// Renderer types
|
||||||
|
export type { FrameBBox, FrameOverlay } from './renderers/FrameRenderer.vue'
|
||||||
|
export type { LogEntry } from './renderers/LogRenderer.vue'
|
||||||
|
export type { GraphNode, GraphMode } from './renderers/GraphRenderer.vue'
|
||||||
|
export type { TableColumn } from './renderers/TableRenderer.vue'
|
||||||
|
export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.vue'
|
||||||
|
|
||||||
|
// Interaction plugins
|
||||||
|
export type { InteractionPlugin, PluginContext } from './plugins/InteractionPlugin'
|
||||||
|
export { BBoxDrawPlugin } from './plugins/BBoxDrawPlugin'
|
||||||
|
export type { BBoxResult, BBoxCallback } from './plugins/BBoxDrawPlugin'
|
||||||
|
export { CrosshairPlugin } from './plugins/CrosshairPlugin'
|
||||||
|
export type { CrosshairCallback } from './plugins/CrosshairPlugin'
|
||||||
|
|
||||||
88
ui/framework/src/plugins/BBoxDrawPlugin.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* BBoxDrawPlugin — draw bounding boxes on the frame viewer.
|
||||||
|
*
|
||||||
|
* User drags on the canvas to draw a rectangle.
|
||||||
|
* On pointer up, emits the bbox coordinates via the callback.
|
||||||
|
* The frame viewer panel feeds this into the selection store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
|
||||||
|
|
||||||
|
export interface BBoxResult {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BBoxCallback = (bbox: BBoxResult) => void
|
||||||
|
|
||||||
|
export class BBoxDrawPlugin implements InteractionPlugin {
|
||||||
|
name = 'bbox-draw'
|
||||||
|
|
||||||
|
private ctx: CanvasRenderingContext2D | null = null
|
||||||
|
private drawing = false
|
||||||
|
private startX = 0
|
||||||
|
private startY = 0
|
||||||
|
private currentBox: BBoxResult | null = null
|
||||||
|
private callback: BBoxCallback
|
||||||
|
|
||||||
|
constructor(callback: BBoxCallback) {
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(context: PluginContext): void {
|
||||||
|
this.ctx = context.ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmount(): void {
|
||||||
|
this.ctx = null
|
||||||
|
this.drawing = false
|
||||||
|
this.currentBox = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(e: PointerEvent): void {
|
||||||
|
this.drawing = true
|
||||||
|
this.startX = e.offsetX
|
||||||
|
this.startY = e.offsetY
|
||||||
|
this.currentBox = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(e: PointerEvent): void {
|
||||||
|
if (!this.drawing) return
|
||||||
|
|
||||||
|
const x = Math.min(this.startX, e.offsetX)
|
||||||
|
const y = Math.min(this.startY, e.offsetY)
|
||||||
|
const w = Math.abs(e.offsetX - this.startX)
|
||||||
|
const h = Math.abs(e.offsetY - this.startY)
|
||||||
|
|
||||||
|
this.currentBox = { x, y, w, h }
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(_e: PointerEvent): void {
|
||||||
|
if (!this.drawing) return
|
||||||
|
this.drawing = false
|
||||||
|
|
||||||
|
if (this.currentBox && this.currentBox.w > 5 && this.currentBox.h > 5) {
|
||||||
|
this.callback(this.currentBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentBox = null
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx: CanvasRenderingContext2D): void {
|
||||||
|
if (!this.currentBox) return
|
||||||
|
|
||||||
|
const box = this.currentBox
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#4f9cf9'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([6, 3])
|
||||||
|
ctx.strokeRect(box.x, box.y, box.w, box.h)
|
||||||
|
ctx.setLineDash([])
|
||||||
|
|
||||||
|
// Semi-transparent fill
|
||||||
|
ctx.fillStyle = 'rgba(79, 156, 249, 0.1)'
|
||||||
|
ctx.fillRect(box.x, box.y, box.w, box.h)
|
||||||
|
}
|
||||||
|
}
|
||||||