init commit

This commit is contained in:
2026-04-12 07:19:48 -03:00
commit 9dbf89da02
111 changed files with 14925 additions and 0 deletions

14
.env.example Normal file
View 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
View 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
View 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
View File

183
agents/efhas.py Normal file
View 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
View 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

View File

24
agents/shared/llm.py Normal file
View 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
View 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
View File

261
api/main.py Normal file
View 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
View File

15
ctrl/Dockerfile.api Normal file
View 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
View 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
View 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
View 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
View 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

View 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"

View 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

View 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

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: unt

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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&#45;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&#45;&gt;t_weather -->
<g id="edge1" class="edge">
<title>openmeteo&#45;&gt;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&#45;&gt;t_airport -->
<g id="edge2" class="edge">
<title>faa&#45;&gt;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&#45;&gt;flights -->
<g id="edge3" class="edge">
<title>sc_mgr&#45;&gt;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&#45;&gt;crew -->
<g id="edge4" class="edge">
<title>sc_mgr&#45;&gt;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&#45;&gt;pax -->
<g id="edge5" class="edge">
<title>sc_mgr&#45;&gt;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&#45;&gt;mel -->
<g id="edge6" class="edge">
<title>sc_mgr&#45;&gt;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&#45;&gt;notes -->
<g id="edge7" class="edge">
<title>sc_mgr&#45;&gt;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&#45;&gt;rebook -->
<g id="edge8" class="edge">
<title>sc_mgr&#45;&gt;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&#45;&gt;t_flight -->
<g id="edge9" class="edge">
<title>flights&#45;&gt;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&#45;&gt;t_crew -->
<g id="edge10" class="edge">
<title>crew&#45;&gt;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&#45;&gt;t_pax -->
<g id="edge13" class="edge">
<title>pax&#45;&gt;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&#45;&gt;t_maint -->
<g id="edge12" class="edge">
<title>mel&#45;&gt;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&#45;&gt;t_crew -->
<g id="edge11" class="edge">
<title>notes&#45;&gt;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&#45;&gt;t_pax -->
<g id="edge14" class="edge">
<title>rebook&#45;&gt;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&#45;&gt;notif -->
<g id="edge15" class="edge">
<title>t_weather&#45;&gt;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&#45;&gt;brief -->
<g id="edge19" class="edge">
<title>t_weather&#45;&gt;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&#45;&gt;notif -->
<g id="edge16" class="edge">
<title>t_airport&#45;&gt;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&#45;&gt;brief -->
<g id="edge20" class="edge">
<title>t_airport&#45;&gt;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&#45;&gt;notif -->
<g id="edge17" class="edge">
<title>t_flight&#45;&gt;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&#45;&gt;brief -->
<g id="edge21" class="edge">
<title>t_flight&#45;&gt;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&#45;&gt;notif -->
<g id="edge18" class="edge">
<title>t_crew&#45;&gt;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&#45;&gt;brief -->
<g id="edge22" class="edge">
<title>t_crew&#45;&gt;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&#45;&gt;brief -->
<g id="edge23" class="edge">
<title>t_maint&#45;&gt;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&#45;&gt;brief -->
<g id="edge24" class="edge">
<title>t_pax&#45;&gt;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

View 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
View 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 &#160;(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&#45;&gt;ui_svc -->
<g id="edge1" class="edge">
<title>user&#45;&gt;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&#45;&gt;ext_kong -->
<g id="edge12" class="edge">
<title>user&#45;&gt;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&#45;&gt;api_svc -->
<g id="edge3" class="edge">
<title>ui&#45;&gt;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&#45;&gt;ui -->
<g id="edge2" class="edge">
<title>ui_svc&#45;&gt;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&#45;&gt;langfuse_svc -->
<g id="edge8" class="edge">
<title>api&#45;&gt;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&#45;&gt;ext_weather -->
<g id="edge5" class="edge">
<title>api&#45;&gt;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&#45;&gt;ext_faa -->
<g id="edge6" class="edge">
<title>api&#45;&gt;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&#45;&gt;ext_bedrock -->
<g id="edge7" class="edge">
<title>api&#45;&gt;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&#45;&gt;api -->
<g id="edge4" class="edge">
<title>api_svc&#45;&gt;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&#45;&gt;pg_svc -->
<g id="edge10" class="edge">
<title>langfuse&#45;&gt;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&#45;&gt;langfuse -->
<g id="edge9" class="edge">
<title>langfuse_svc&#45;&gt;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&#45;&gt;pg -->
<g id="edge11" class="edge">
<title>pg_svc&#45;&gt;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&#45;&gt;ui_svc -->
<g id="edge13" class="edge">
<title>ext_kong&#45;&gt;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

View 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
View 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 &#160;(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 &#160;(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 &lt; 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&#45;&gt;triage -->
<g id="edge1" class="edge">
<title>start&#45;&gt;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&#45;&gt;end_skip -->
<g id="edge2" class="edge">
<title>triage&#45;&gt;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&#45;&gt;gather_label -->
<g id="edge3" class="edge">
<title>triage&#45;&gt;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&#45;&gt;g1 -->
<g id="edge4" class="edge">
<title>gather_label&#45;&gt;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&#45;&gt;g2 -->
<g id="edge5" class="edge">
<title>gather_label&#45;&gt;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&#45;&gt;g3 -->
<g id="edge6" class="edge">
<title>gather_label&#45;&gt;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&#45;&gt;g4 -->
<g id="edge7" class="edge">
<title>gather_label&#45;&gt;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&#45;&gt;g5 -->
<g id="edge8" class="edge">
<title>gather_label&#45;&gt;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&#45;&gt;synth -->
<!-- g1&#45;&gt;synth -->
<g id="edge14" class="edge">
<title>g1&#45;&gt;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&#45;&gt;synth -->
<!-- g2&#45;&gt;synth -->
<g id="edge15" class="edge">
<title>g2&#45;&gt;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&#45;&gt;synth -->
<!-- g3&#45;&gt;synth -->
<g id="edge16" class="edge">
<title>g3&#45;&gt;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&#45;&gt;synth -->
<!-- g4&#45;&gt;synth -->
<g id="edge17" class="edge">
<title>g4&#45;&gt;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&#45;&gt;synth -->
<!-- g5&#45;&gt;synth -->
<g id="edge18" class="edge">
<title>g5&#45;&gt;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&#45;&gt;format -->
<g id="edge19" class="edge">
<title>synth&#45;&gt;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&#45;approve in demo)</text>
</g>
<!-- format&#45;&gt;review -->
<g id="edge20" class="edge">
<title>format&#45;&gt;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&#45;&gt;end_done -->
<g id="edge21" class="edge">
<title>review&#45;&gt;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

View 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
}

View 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 &#160;(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&#45;&gt;gather_point -->
<g id="edge1" class="edge">
<title>start&#45;&gt;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&#45;&gt;h1 -->
<g id="edge2" class="edge">
<title>gather_point&#45;&gt;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&#45;&gt;h2 -->
<g id="edge3" class="edge">
<title>gather_point&#45;&gt;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&#45;&gt;h3 -->
<g id="edge4" class="edge">
<title>gather_point&#45;&gt;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&#45;&gt;g1 -->
<g id="edge5" class="edge">
<title>gather_point&#45;&gt;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&#45;&gt;g2 -->
<g id="edge6" class="edge">
<title>gather_point&#45;&gt;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&#45;&gt;g3 -->
<g id="edge7" class="edge">
<title>gather_point&#45;&gt;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 &gt; 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 &gt; 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&#45;&gt;triage -->
<g id="edge8" class="edge">
<title>h1&#45;&gt;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&#45;&gt;triage -->
<g id="edge9" class="edge">
<title>h2&#45;&gt;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&#45;&gt;triage -->
<g id="edge10" class="edge">
<title>h3&#45;&gt;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&#45;&gt;triage -->
<g id="edge11" class="edge">
<title>g1&#45;&gt;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&#45;&gt;triage -->
<g id="edge12" class="edge">
<title>g2&#45;&gt;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&#45;&gt;triage -->
<g id="edge13" class="edge">
<title>g3&#45;&gt;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&#45;&gt;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&#45;&gt;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&#45;&gt;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&#45;&gt;synth -->
<g id="edge17" class="edge">
<title>imm&#45;&gt;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&#45;&gt;synth -->
<g id="edge18" class="edge">
<title>mon&#45;&gt;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&#45;&gt;synth -->
<g id="edge19" class="edge">
<title>fyi&#45;&gt;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&#45;&gt;format -->
<g id="edge20" class="edge">
<title>synth&#45;&gt;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&#45;&gt;end_done -->
<g id="edge21" class="edge">
<title>format&#45;&gt;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
View 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
View 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&#45;ops&#45;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&#45;ops&#45;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&#45;ops&#45;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&#45;&gt;st1 -->
<g id="edge1" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;st2 -->
<g id="edge2" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;st4 -->
<g id="edge3" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;st6 -->
<g id="edge4" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;st7 -->
<g id="edge5" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;st8 -->
<g id="edge6" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;sr1 -->
<g id="edge8" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;pt1 -->
<g id="edge7" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;pp1 -->
<g id="edge9" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;st3 -->
<g id="edge10" class="edge">
<title>handover&#45;&gt;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&#45;&gt;st5 -->
<g id="edge11" class="edge">
<title>handover&#45;&gt;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&#45;&gt;st6 -->
<g id="edge12" class="edge">
<title>handover&#45;&gt;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&#45;&gt;st8 -->
<g id="edge13" class="edge">
<title>handover&#45;&gt;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&#45;&gt;ot1 -->
<g id="edge14" class="edge">
<title>handover&#45;&gt;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&#45;&gt;ot2 -->
<g id="edge15" class="edge">
<title>handover&#45;&gt;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&#45;&gt;ot3 -->
<g id="edge16" class="edge">
<title>handover&#45;&gt;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&#45;&gt;ot4 -->
<g id="edge17" class="edge">
<title>handover&#45;&gt;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&#45;&gt;or1 -->
<g id="edge18" class="edge">
<title>handover&#45;&gt;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&#45;&gt;or2 -->
<g id="edge19" class="edge">
<title>handover&#45;&gt;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&#45;&gt;op1 -->
<g id="edge20" class="edge">
<title>handover&#45;&gt;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

View 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
}

View 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&#45;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&#45;&gt;mcp -->
<g id="edge1" class="edge">
<title>root&#45;&gt;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&#45;&gt;agents -->
<g id="edge2" class="edge">
<title>root&#45;&gt;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&#45;&gt;irrop -->
<g id="edge3" class="edge">
<title>root&#45;&gt;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&#45;&gt;api -->
<g id="edge4" class="edge">
<title>root&#45;&gt;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&#45;&gt;ui_root -->
<g id="edge5" class="edge">
<title>root&#45;&gt;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&#45;&gt;ctrl -->
<g id="edge6" class="edge">
<title>root&#45;&gt;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&#45;&gt;docs -->
<g id="edge7" class="edge">
<title>root&#45;&gt;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&#45;&gt;mcp_shared -->
<g id="edge8" class="edge">
<title>mcp&#45;&gt;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&#45;&gt;mcp_ops -->
<g id="edge9" class="edge">
<title>mcp&#45;&gt;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&#45;&gt;mcp_pax -->
<g id="edge10" class="edge">
<title>mcp&#45;&gt;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&#45;&gt;mcp_data -->
<g id="edge11" class="edge">
<title>mcp&#45;&gt;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&#45;&gt;ag_efhas -->
<g id="edge12" class="edge">
<title>agents&#45;&gt;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&#45;&gt;ag_handover -->
<g id="edge13" class="edge">
<title>agents&#45;&gt;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&#45;&gt;ag_shared -->
<g id="edge14" class="edge">
<title>agents&#45;&gt;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&#45;&gt;ir_models -->
<g id="edge15" class="edge">
<title>irrop&#45;&gt;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&#45;&gt;ir_rules -->
<g id="edge16" class="edge">
<title>irrop&#45;&gt;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&#45;&gt;ir_pipeline -->
<g id="edge17" class="edge">
<title>irrop&#45;&gt;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&#45;&gt;api_main -->
<g id="edge18" class="edge">
<title>api&#45;&gt;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&#45;&gt;api_routes -->
<g id="edge19" class="edge">
<title>api&#45;&gt;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&#45;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&#45;&gt;ui_fw -->
<g id="edge20" class="edge">
<title>ui_root&#45;&gt;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&#45;tokens.css</text>
</g>
<!-- ui_root&#45;&gt;ui_app -->
<g id="edge21" class="edge">
<title>ui_root&#45;&gt;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&#45;compose.yml</text>
</g>
<!-- ctrl&#45;&gt;ctrl_docker -->
<g id="edge22" class="edge">
<title>ctrl&#45;&gt;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&#45;config.yaml</text>
</g>
<!-- ctrl&#45;&gt;ctrl_k8s -->
<g id="edge23" class="edge">
<title>ctrl&#45;&gt;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&#45;&gt;ctrl_tilt -->
<g id="edge24" class="edge">
<title>ctrl&#45;&gt;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

View 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"]
}

View 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&#45;&gt;fastapi -->
<g id="edge2" class="edge">
<title>kong&#45;&gt;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&#45;&gt;kong -->
<g id="edge1" class="edge">
<title>ui&#45;&gt;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&#45;&gt;fastapi -->
<g id="edge3" class="edge">
<title>ui&#45;&gt;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&#45;&gt;efhas -->
<g id="edge4" class="edge">
<title>fastapi&#45;&gt;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&#45;&gt;handover -->
<g id="edge5" class="edge">
<title>fastapi&#45;&gt;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&#45;&gt;langfuse -->
<g id="edge19" class="edge">
<title>fastapi&#45;&gt;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&#45;&gt;shared -->
<g id="edge6" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;passenger -->
<g id="edge7" class="edge">
<title>efhas&#45;&gt;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&#45;&gt;shared -->
<g id="edge8" class="edge">
<title>handover&#45;&gt;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&#45;&gt;ops -->
<g id="edge9" class="edge">
<title>handover&#45;&gt;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&#45;&gt;openmeteo -->
<g id="edge10" class="edge">
<title>shared&#45;&gt;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&#45;&gt;faa -->
<g id="edge11" class="edge">
<title>shared&#45;&gt;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&#45;&gt;scenarios -->
<g id="edge12" class="edge">
<title>shared&#45;&gt;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&#45;&gt;mock -->
<g id="edge13" class="edge">
<title>shared&#45;&gt;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&#45;&gt;bedrock -->
<g id="edge16" class="edge">
<title>ops&#45;&gt;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&#45;&gt;scenarios -->
<g id="edge15" class="edge">
<title>ops&#45;&gt;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&#45;&gt;mock -->
<g id="edge14" class="edge">
<title>ops&#45;&gt;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&#45;&gt;bedrock -->
<g id="edge17" class="edge">
<title>passenger&#45;&gt;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&#45;&gt;postgres -->
<g id="edge18" class="edge">
<title>langfuse&#45;&gt;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
View 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
View File

0
irrop/models/__init__.py Normal file
View File

View File

0
irrop/rules/__init__.py Normal file
View File

0
mcp_servers/__init__.py Normal file
View File

View File

View File

137
mcp_servers/data/models.py Normal file
View 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,
),
}

View File

View 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",
}

View 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"}

View File

View 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] = []

View 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] = []

View 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()

View 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] = []

View 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"),
]

View File

View 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
View 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."
)

View File

View 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()

View 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."
)

View File

View 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()

View 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
View 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
View File

14
ui/app/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

93
ui/app/src/App.vue Normal file
View 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>

View 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>

View 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>

View 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
View 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')

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>,
}
}

View 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,
}
}

View 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,
}
}

View 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))
}
}

View 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()
}
}
}

View 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'
}
}

View 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
View 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'

View 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)
}
}

Some files were not shown because too many files have changed in this diff Show More