diff --git a/src/server.py b/src/server.py index ff302c1..6cb2dc7 100644 --- a/src/server.py +++ b/src/server.py @@ -4,7 +4,7 @@ import re import time from collections import Counter -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.parse import urlparse @@ -24,6 +24,7 @@ Alert, AlertDescription, AlertTitle, + Badge, Card, CardContent, CardHeader, @@ -34,10 +35,11 @@ If, Link, Metric, + Row, Small, Text, ) -from prefab_ui.components.charts import PieChart +from prefab_ui.components.charts import AreaChart, ChartSeries, PieChart from prefab_ui.rx import STATE, Rx from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware @@ -551,6 +553,212 @@ def build_dashboard_app(data: dict[str, Any]) -> PrefabApp: return app +# --------------------------------------------------------------------------- +# Flights-specific App builder +# --------------------------------------------------------------------------- + + +def flights_rows(data: dict[str, Any]) -> list[dict[str, Any]]: + """Flatten best_flights + other_flights into table-ready rows.""" + rows: list[dict[str, Any]] = [] + for section in ("best_flights", "other_flights"): + for itinerary in data.get(section) or []: + segments = itinerary.get("flights") or [] + airlines = sorted({seg.get("airline", "") for seg in segments} - {""}) + departure = segments[0] if segments else {} + arrival = segments[-1] if segments else {} + dep_airport = departure.get("departure_airport") or {} + arr_airport = arrival.get("arrival_airport") or {} + stops = len(itinerary.get("layovers") or []) + carbon = itinerary.get("carbon_emissions") or {} + carbon_pct = carbon.get("difference_percent") + rows.append( + { + "airline": ", ".join(airlines) or "—", + "route": f"{dep_airport.get('id', '?')} → {arr_airport.get('id', '?')}", + "departure": dep_airport.get("time", ""), + "arrival": arr_airport.get("time", ""), + "duration": _format_duration(itinerary.get("total_duration")), + "stops": "Direct" + if stops == 0 + else f"{stops} stop{'s' if stops > 1 else ''}", + "price": itinerary.get("price") or 0, + "price_fmt": f"${itinerary['price']:,}" + if itinerary.get("price") + else "—", + "carbon_delta": carbon_pct, + "carbon_fmt": f"{carbon_pct:+d}% vs typical" + if isinstance(carbon_pct, int) + else "—", + "type": itinerary.get("type", ""), + } + ) + return rows + + +def _format_duration(minutes: int | None) -> str: + if not minutes: + return "—" + h, m = divmod(int(minutes), 60) + return f"{h}h {m}m" if h else f"{m}m" + + +def price_history_points(data: dict[str, Any]) -> list[dict[str, Any]]: + """Convert price_insights.price_history into chart-ready [{date, price}].""" + insights = data.get("price_insights") or {} + history = insights.get("price_history") or [] + points: list[dict[str, Any]] = [] + for entry in history: + if isinstance(entry, list) and len(entry) >= 2: + ts, price = entry[0], entry[1] + points.append( + { + "date": datetime.fromtimestamp(ts, tz=UTC).strftime("%b %d"), + "price": price, + } + ) + return points + + +def flights_price_insights(data: dict[str, Any]) -> dict[str, Any]: + """Extract price intelligence metrics from a flights response.""" + insights = data.get("price_insights") or {} + typical = insights.get("typical_price_range") or [] + return { + "lowest_price": insights.get("lowest_price"), + "price_level": insights.get("price_level", "unknown"), + "typical_low": typical[0] if len(typical) >= 1 else None, + "typical_high": typical[1] if len(typical) >= 2 else None, + } + + +_PRICE_LEVEL_VARIANTS = { + "low": "success", + "typical": "secondary", + "high": "warning", + "very high": "destructive", +} + +_FLIGHTS_COLUMNS = [ + DataTableColumn(key="airline", header="Airline", sortable=True), + DataTableColumn(key="route", header="Route", sortable=True), + DataTableColumn(key="departure", header="Departs", sortable=True), + DataTableColumn(key="arrival", header="Arrives", sortable=True), + DataTableColumn(key="duration", header="Duration", sortable=True), + DataTableColumn(key="stops", header="Stops", sortable=True), + DataTableColumn(key="price", header="Price", sortable=True, format="currency"), +] + + +def build_flights_app(data: dict[str, Any]) -> PrefabApp: + """Compose the flights price intelligence dashboard.""" + insights = flights_price_insights(data) + rows = flights_rows(data) + history = price_history_points(data) + + lowest = insights["lowest_price"] + level = insights["price_level"] + typical_low = insights["typical_low"] + typical_high = insights["typical_high"] + + title = "Flights dashboard" + params = data.get("search_parameters") or {} + dep = params.get("departure_id", "") + arr = params.get("arrival_id", "") + if dep and arr: + title = f"Flights: {dep} → {arr}" + + with PrefabApp(title=title, state={"selected": None}) as app: + with Column(gap=4, css_class="p-4"): + # Metrics row + with Grid(columns=[1, 1, 1, 1], gap=4): + Metric( + label="Lowest price", + value=f"${lowest:,}" if lowest else "—", + ) + Metric( + label="Typical range", + value=( + f"${typical_low:,}–${typical_high:,}" + if typical_low and typical_high + else "—" + ), + ) + Metric( + label="Flights found", + value=str(len(rows)), + ) + with Column(gap=1): + Text(content="Price level") + Badge( + label=level.capitalize(), + variant=_PRICE_LEVEL_VARIANTS.get(level, "outline"), + ) + + # Price history chart + if history: + AreaChart( + data=history, + series=[ChartSeries(data_key="price", label="Price ($)")], + x_axis="date", + height=280, + curve="smooth", + show_dots=False, + ) + + # Flights table + DataTable( + columns=_FLIGHTS_COLUMNS, + rows=rows, + search=True, + paginated=True, + page_size=15, + on_row_click=SetState("selected", Rx("$event")), + ) + + # Detail panel + with If(STATE.selected): + with Card(): + with CardHeader(): + with Row(gap=2): + H3(Rx("selected.airline")) + Badge(label=Rx("selected.stops"), variant="secondary") + Badge(label=Rx("selected.carbon_fmt"), variant="outline") + with CardContent(): + with Grid(columns=[1, 1, 1, 1], gap=4): + with Column(gap=1): + Small(content="Route") + Text(content=Rx("selected.route")) + with Column(gap=1): + Small(content="Departure") + Text(content=Rx("selected.departure")) + with Column(gap=1): + Small(content="Arrival") + Text(content=Rx("selected.arrival")) + with Column(gap=1): + Small(content="Duration") + Text(content=Rx("selected.duration")) + with Grid(columns=[1, 1, 1, 1], gap=4, css_class="mt-2"): + with Column(gap=1): + Small(content="Price") + Text(content=Rx("selected.price_fmt")) + with Column(gap=1): + Small(content="Carbon emissions") + Text(content=Rx("selected.carbon_fmt")) + with Column(gap=1): + Small(content="Trip type") + Text(content=Rx("selected.type")) + + return app + + +# Engine-specific app dispatch: maps engine names to their dedicated builders. +# Falls back to the generic dashboard for unregistered engines. +ENGINE_APP_BUILDERS: dict[str, Any] = { + "google_flights": build_flights_app, +} + + @mcp.tool( app=True, description=( @@ -583,7 +791,9 @@ async def search_table(params: dict[str, Any] = None) -> PrefabApp: "Interactive dashboard variant of `search`: returns summary metrics, a " "source breakdown chart, and a results table with a click-to-expand " "detail panel, all rendered in the conversation. Same params as " - "`search`. Use for a richer visual overview of a query's results." + "`search`. Use for a richer visual overview of a query's results. " + "Automatically selects an engine-specific dashboard when available " + "(e.g. google_flights gets price intelligence charting)." ), annotations=ToolAnnotations( title="SerpApi search (dashboard)", @@ -600,7 +810,9 @@ async def search_dashboard(params: dict[str, Any] = None) -> PrefabApp: return _error_app( str(exc) if isinstance(exc, RuntimeError) else map_search_error(exc) ) - return build_dashboard_app(data) + engine = (params or {}).get("engine", "google_light") + builder = ENGINE_APP_BUILDERS.get(engine, build_dashboard_app) + return builder(data) async def healthcheck_handler(request): diff --git a/tests/test_server.py b/tests/test_server.py index 33c00a8..6153b4f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -447,3 +447,295 @@ async def test_search_dashboard_maps_http_error_to_error_app(monkeypatch): app = await server.search_dashboard(params={"q": "x"}) assert app.title == "Search error" assert "Rate limit exceeded" in ui_json(app) + + +# --- MCP Apps: Flights-specific helpers and builder ------------------------- + +_SAMPLE_FLIGHTS_PAYLOAD = { + "search_parameters": { + "engine": "google_flights", + "departure_id": "SFO", + "arrival_id": "JFK", + }, + "best_flights": [ + { + "flights": [ + { + "departure_airport": { + "name": "San Francisco", + "id": "SFO", + "time": "2026-07-01 08:00", + }, + "arrival_airport": { + "name": "New York JFK", + "id": "JFK", + "time": "2026-07-01 16:30", + }, + "airline": "United", + "flight_number": "UA 123", + "duration": 330, + } + ], + "layovers": [], + "total_duration": 330, + "price": 289, + "type": "One way", + "carbon_emissions": { + "this_flight": 250000, + "typical_for_this_route": 280000, + "difference_percent": -11, + }, + }, + { + "flights": [ + { + "departure_airport": { + "name": "San Francisco", + "id": "SFO", + "time": "2026-07-01 06:00", + }, + "arrival_airport": { + "name": "Denver", + "id": "DEN", + "time": "2026-07-01 09:30", + }, + "airline": "Delta", + "duration": 150, + }, + { + "departure_airport": { + "name": "Denver", + "id": "DEN", + "time": "2026-07-01 10:45", + }, + "arrival_airport": { + "name": "New York JFK", + "id": "JFK", + "time": "2026-07-01 16:00", + }, + "airline": "Delta", + "duration": 195, + }, + ], + "layovers": [ + {"duration": 75, "name": "Denver International Airport", "id": "DEN"} + ], + "total_duration": 420, + "price": 199, + "type": "One way", + "carbon_emissions": { + "this_flight": 310000, + "typical_for_this_route": 280000, + "difference_percent": 11, + }, + }, + ], + "other_flights": [ + { + "flights": [ + { + "departure_airport": { + "name": "San Francisco", + "id": "SFO", + "time": "2026-07-01 14:00", + }, + "arrival_airport": { + "name": "New York JFK", + "id": "JFK", + "time": "2026-07-01 22:45", + }, + "airline": "JetBlue", + "duration": 345, + } + ], + "layovers": [], + "total_duration": 345, + "price": 329, + "type": "One way", + "carbon_emissions": {}, + }, + ], + "price_insights": { + "lowest_price": 199, + "price_level": "low", + "typical_price_range": [250, 420], + "price_history": [ + [1719792000, 310], + [1719878400, 305], + [1719964800, 289], + [1720051200, 275], + [1720137600, 199], + ], + }, +} + + +def test_flights_rows_extracts_all_flights(): + rows = server.flights_rows(_SAMPLE_FLIGHTS_PAYLOAD) + assert len(rows) == 3 + # Direct flight + assert rows[0]["airline"] == "United" + assert rows[0]["route"] == "SFO → JFK" + assert rows[0]["price"] == 289 + assert rows[0]["price_fmt"] == "$289" + assert rows[0]["stops"] == "Direct" + assert rows[0]["departure"] == "2026-07-01 08:00" + assert rows[0]["arrival"] == "2026-07-01 16:30" + assert rows[0]["duration"] == "5h 30m" + assert rows[0]["carbon_delta"] == -11 + assert rows[0]["carbon_fmt"] == "-11% vs typical" + assert rows[0]["type"] == "One way" + # Multi-segment flight + assert rows[1]["airline"] == "Delta" + assert rows[1]["stops"] == "1 stop" + assert rows[1]["price"] == 199 + assert rows[1]["arrival"] == "2026-07-01 16:00" + assert rows[1]["duration"] == "7h 0m" + assert rows[1]["carbon_fmt"] == "+11% vs typical" + # other_flights section + assert rows[2]["airline"] == "JetBlue" + assert rows[2]["carbon_fmt"] == "—" + + +def test_flights_rows_handles_empty_data(): + assert server.flights_rows({}) == [] + assert server.flights_rows({"best_flights": None, "other_flights": None}) == [] + + +def test_flights_rows_handles_missing_airports(): + data = { + "best_flights": [ + {"flights": [], "layovers": [], "total_duration": 0, "price": 100} + ] + } + rows = server.flights_rows(data) + assert len(rows) == 1 + assert rows[0]["route"] == "? → ?" + assert rows[0]["price"] == 100 + assert rows[0]["departure"] == "" + assert rows[0]["arrival"] == "" + + +def test_flights_rows_zero_price_defaults(): + data = {"best_flights": [{"flights": [], "layovers": [], "total_duration": 120}]} + rows = server.flights_rows(data) + assert rows[0]["price"] == 0 + assert rows[0]["price_fmt"] == "—" + + +def test_format_duration(): + assert server._format_duration(330) == "5h 30m" + assert server._format_duration(45) == "45m" + assert server._format_duration(60) == "1h 0m" + assert server._format_duration(None) == "—" + assert server._format_duration(0) == "—" + + +def test_price_history_points_converts_timestamps(): + points = server.price_history_points(_SAMPLE_FLIGHTS_PAYLOAD) + assert len(points) == 5 + assert points[0]["price"] == 310 + assert "date" in points[0] + # Dates should be human-readable month/day format + assert len(points[0]["date"]) > 0 + + +def test_price_history_points_handles_empty(): + assert server.price_history_points({}) == [] + assert server.price_history_points({"price_insights": {}}) == [] + assert server.price_history_points({"price_insights": {"price_history": []}}) == [] + + +def test_price_history_points_skips_malformed_entries(): + data = { + "price_insights": {"price_history": [[1719792000], "bad", [1719878400, 300]]} + } + points = server.price_history_points(data) + assert len(points) == 1 + assert points[0]["price"] == 300 + + +def test_flights_price_insights_extracts_metrics(): + insights = server.flights_price_insights(_SAMPLE_FLIGHTS_PAYLOAD) + assert insights["lowest_price"] == 199 + assert insights["price_level"] == "low" + assert insights["typical_low"] == 250 + assert insights["typical_high"] == 420 + + +def test_flights_price_insights_handles_missing(): + insights = server.flights_price_insights({}) + assert insights["lowest_price"] is None + assert insights["price_level"] == "unknown" + assert insights["typical_low"] is None + assert insights["typical_high"] is None + + +def test_build_flights_app_produces_valid_app(): + app = server.build_flights_app(_SAMPLE_FLIGHTS_PAYLOAD) + assert "SFO → JFK" in app.title + assert app.state == {"selected": None} + body = ui_json(app) + assert "AreaChart" in body + assert "DataTable" in body + assert "United" in body + # Numeric price in table for correct sorting + assert "199" in body + # Carbon and arrival visible in detail panel + assert "carbon_fmt" in body + assert "Arrival" in body + assert "Carbon emissions" in body + + +def test_build_flights_app_without_price_history(): + data = { + "search_parameters": { + "engine": "google_flights", + "departure_id": "LAX", + "arrival_id": "ORD", + }, + "best_flights": [ + { + "flights": [ + { + "departure_airport": {"id": "LAX", "time": "10:00"}, + "arrival_airport": {"id": "ORD", "time": "16:00"}, + "airline": "AA", + } + ], + "layovers": [], + "total_duration": 240, + "price": 350, + } + ], + "price_insights": {}, + } + app = server.build_flights_app(data) + body = ui_json(app) + # Should still render table without crashing, just no chart + assert "DataTable" in body + assert "AreaChart" not in body + + +def test_build_flights_app_generic_title_without_route(): + data = {"search_parameters": {"engine": "google_flights"}, "best_flights": []} + app = server.build_flights_app(data) + assert app.title == "Flights dashboard" + + +async def test_search_dashboard_dispatches_to_flights(monkeypatch): + use_request(monkeypatch, real_request(state={"api_key": "KEY"})) + use_search(monkeypatch, lambda params: serp_results(_SAMPLE_FLIGHTS_PAYLOAD)) + app = await server.search_dashboard( + params={"engine": "google_flights", "departure_id": "SFO", "arrival_id": "JFK"} + ) + assert "SFO → JFK" in app.title + body = ui_json(app) + assert "AreaChart" in body + + +async def test_search_dashboard_falls_back_to_generic(monkeypatch): + use_request(monkeypatch, real_request(state={"api_key": "KEY"})) + use_search(monkeypatch, lambda params: serp_results(_SAMPLE_PAYLOAD)) + app = await server.search_dashboard(params={"q": "coffee"}) + assert app.title == "Search dashboard"