From 0e711cf4e3bbac04a39c96aa62b7e1c6faa8e0e8 Mon Sep 17 00:00:00 2001 From: Salman Paracha Date: Tue, 23 Dec 2025 13:59:06 -0800 Subject: [PATCH] simplifying the travel agents demo --- demos/use_cases/travel_agents/Dockerfile | 21 + demos/use_cases/travel_agents/README.md | 137 ++- .../use_cases/travel_agents/arch_config.yaml | 34 - demos/use_cases/travel_agents/config.yaml | 57 + .../travel_agents/docker-compose.yaml | 42 +- demos/use_cases/travel_agents/pyproject.toml | 10 +- .../src/travel_agents/__init__.py | 48 - .../src/travel_agents/__main__.py | 4 - .../travel_agents/src/travel_agents/api.py | 36 - .../src/travel_agents/currency_agent.py | 584 --------- .../src/travel_agents/flight_agent.py | 1054 +++++------------ .../src/travel_agents/weather_agent.py | 863 +++++--------- demos/use_cases/travel_agents/start_agents.sh | 45 - demos/use_cases/travel_agents/test.rest | 12 - 14 files changed, 807 insertions(+), 2140 deletions(-) create mode 100644 demos/use_cases/travel_agents/Dockerfile delete mode 100644 demos/use_cases/travel_agents/arch_config.yaml create mode 100644 demos/use_cases/travel_agents/config.yaml delete mode 100644 demos/use_cases/travel_agents/src/travel_agents/__init__.py delete mode 100644 demos/use_cases/travel_agents/src/travel_agents/__main__.py delete mode 100644 demos/use_cases/travel_agents/src/travel_agents/api.py delete mode 100644 demos/use_cases/travel_agents/src/travel_agents/currency_agent.py delete mode 100755 demos/use_cases/travel_agents/start_agents.sh diff --git a/demos/use_cases/travel_agents/Dockerfile b/demos/use_cases/travel_agents/Dockerfile new file mode 100644 index 00000000..a5ff58e9 --- /dev/null +++ b/demos/use_cases/travel_agents/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install uv for faster dependency management +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy dependency files +COPY pyproject.toml README.md ./ + +# Install dependencies (without lock file to resolve fresh) +RUN uv sync --no-dev + +# Copy application code +COPY src/ ./src/ + +# Set environment variables +ENV PYTHONUNBUFFERED=1 + +# Default command (will be overridden in docker-compose) +CMD ["uv", "run", "python", "src/travel_agents/weather_agent.py"] diff --git a/demos/use_cases/travel_agents/README.md b/demos/use_cases/travel_agents/README.md index 67beec04..e658909e 100644 --- a/demos/use_cases/travel_agents/README.md +++ b/demos/use_cases/travel_agents/README.md @@ -1,16 +1,15 @@ # Travel Booking Agent Demo -A production-ready multi-agent travel booking system demonstrating Plano's intelligent agent routing. This demo showcases three specialized agents working together to help users plan trips with weather information, flight searches, and currency exchange rates. +A production-ready multi-agent travel booking system demonstrating Plano's intelligent agent routing. This demo showcases two specialized agents working together to help users plan trips with weather information and flight searches. ## Overview -This demo consists of three intelligent agents that work together seamlessly: +This demo consists of two intelligent agents that work together seamlessly: -- **Weather Agent** - Real-time weather conditions and forecasts for any city worldwide +- **Weather Agent** - Real-time weather conditions for any city worldwide (single-day weather) - **Flight Agent** - Live flight information between airports with real-time tracking -- **Currency Agent** - Real-time currency exchange rates and conversions -All agents use Plano's agent router to intelligently route user requests to the appropriate specialized agent based on conversation context and user intent. +All agents use Plano's agent router to intelligently route user requests to the appropriate specialized agent based on conversation context and user intent. Both agents run as Docker containers for easy deployment. ## Features @@ -22,50 +21,45 @@ All agents use Plano's agent router to intelligently route user requests to the ## Prerequisites -- Python 3.10 or higher -- [UV package manager](https://github.com/astral-sh/uv) (recommended) or pip -- OpenAI API key +- Docker and Docker Compose - [Plano CLI](https://docs.planoai.dev) installed +- OpenAI API key ## Quick Start -### 1. Install Dependencies - -```bash -# Using UV (recommended) -uv sync - -# Or using pip -pip install -e . -``` - -### 2. Set Environment Variables +### 1. Set Environment Variables Create a `.env` file or export environment variables: ```bash -export OPENAI_API_KEY="your-openai-api-key" export AEROAPI_KEY="your-flightaware-api-key" # Optional, demo key included ``` -### 3. Start All Agents +### 2. Start All Agents with Docker ```bash chmod +x start_agents.sh ./start_agents.sh ``` +Or directly: + +```bash +docker compose up --build +``` + This starts: - Weather Agent on port 10510 - Flight Agent on port 10520 -- Currency Agent on port 10530 +- Open WebUI on port 8080 +- SignOz observability stack -### 4. Start Plano Orchestrator +### 3. Start Plano Orchestrator In a new terminal: ```bash -cd /path/to/travel_booking +cd /path/to/travel_agents plano up arch_config.yaml ``` @@ -75,9 +69,11 @@ The gateway will start on port 8001 and route requests to the appropriate agents Send requests to Plano Orchestrator: -```bash -curl -X POST http://localhost:8001/v1/chat/completions \ - -H "Content-Type: application/json" \ +```b4. Test the System + +Option 1: Use Open WebUI at http://localhost:8080 + +Option 2: Send requests directly to Planon" \ -d '{ "model": "gpt-4o", "messages": [ @@ -102,18 +98,11 @@ Assistant: [Flight Agent shows available flights with schedules and status] ### Currency Exchange ``` -User: What's the exchange rate for Turkish Lira to USD? -Assistant: [Currency Agent provides current exchange rate] -``` - -### Multi-Agent Conversation +UserMulti-Agent Conversation ``` User: What's the weather in Istanbul? Assistant: [Weather information] -User: What's their exchange rate? -Assistant: [Currency rate for Turkey] - User: Do they fly out from Seattle? Assistant: [Flight information from Istanbul to Seattle] ``` @@ -130,7 +119,8 @@ Assistant: [Both weather_agent and flight_agent respond simultaneously] The orchestrator can select multiple agents simultaneously for queries containing multiple intents. -## Agent Details +### Learning Exercise +The weather agent currently provides single-day weather only. Want to add multi-day forecasts? Check out the TODO comments in `weather_agent.py` - it's a great way to learn how Plano handles dynamic data! 🚀 ### Weather Agent - **Port**: 10510 @@ -143,17 +133,13 @@ The orchestrator can select multiple agents simultaneously for queries containin - **Capabilities**: Real-time flight status, schedules, delays, gates, terminals, live tracking ### Currency Agent -- **Port**: 10530 -- **API**: Frankfurter (free, no API key) -- **Capabilities**: Exchange rates, currency conversions, historical rates +- **Port**: 10530day weather, temperature (Celsius/Fahrenheit), conditions, sunrise/sunset +- **Learning Opportunity**: Multi-day forecasts available as TODO exercise -## Architecture - -``` -User Request → Plano Gateway (port 8001) - ↓ - Agent Router (LLM-based) - ↓ +### Flight Agent +- **Port**: 10520 +- **API**: FlightAware AeroAPI +- **Capabilities**: Real-time flight status, schedules, delays, gates, terminals, live tracking ┌───────────┼───────────┐ ↓ ↓ ↓ Weather Flight Currency @@ -162,53 +148,64 @@ Agent Agent Agent ``` Each agent: -1. Extracts intent using GPT-4o-mini +1. E ┌──────┴──────┐ + ↓ ↓ + Weather Flight + Agent Agent + (10510) (10520) + [Docker] [Docker] +``` + +Each agent: +1. Extracts intent using GPT-4o-mini (with OpenTelemetry tracing) 2. Fetches real-time data from APIs 3. Generates response using GPT-4o 4. Streams response back to user -## Configuration +Both agents run as Docker containers and communicate with Plano via `host.docker.internal`. +arch_config.yaml -### plano_config.yaml +Defines the two agents, their descriptions, and routing configuration. The agent router uses these descriptions to intelligently route requests. -Defines the three agents, their descriptions, and routing configuration. The agent router uses these descriptions to intelligently route requests. +### docker-compose.yaml + +Orchestrates the deployment of: +- Weather Agent (builds from Dockerfile) +- Flight Agent (builds from Dockerfile) +- Open WebUI (for testing) +- SignOz (for observability) ### Environment Variables - -- `OPENAI_API_KEY` - Required for LLM operations -- `AEROAPI_KEY` - Optional, FlightAware API key (demo key included) -- `LLM_GATEWAY_ENDPOINT` - Plano LLM gateway URL (default: http://localhost:12000/v1) - -## Project Structure - -``` -travel_booking/ +agents/ ├── arch_config.yaml # Plano configuration -├── start_agents.sh # Start all agents script +├── docker-compose.yaml # Docker services orchestration +├── Dockerfile # Multi-agent container image +├── start_agents.sh # Quick start script ├── pyproject.toml # Python dependencies └── src/ └── travel_agents/ ├── __init__.py # CLI entry point - ├── api.py # Shared API models - ├── weather_agent.py # Weather forecast agent + ├── weather_agent.py # Weather forecast agent (single-day) + └── flight_agent.py # Flight informationgent ├── flight_agent.py # Flight information agent └── currency_agent.py # Currency exchange agent ``` ## Troubleshooting - -**Agents won't start** -- Ensure Python 3.10+ is installed -- Check that UV is installed: `pip install uv` -- Verify ports 10510, 10520, 10530 are available +Docker and Docker Compose are installed +- Check that ports 10510, 10520, 8080 are available +- Review container logs: `docker compose logs weather-agent` or `docker compose logs flight-agent` **Plano won't start** - Verify Plano is installed: `plano --version` -- Check that `OPENAI_API_KEY` is set -- Ensure you're in the travel_booking directory +- Ensure you're in the travel_agents directory +- Check arch_config.yaml is valid **No response from agents** -- Verify all agents are running (check start_agents.sh output) +- Verify all containers are running: `docker compose ps` +- Check that Plano is running on port 8001 +- Review agent logs: `docker compose logs -f` +- Verify `host.docker.internal` resolves correctly (should point to host machine)g (check start_agents.sh output) - Check that Plano is running on port 8001 - Review agent logs for errors diff --git a/demos/use_cases/travel_agents/arch_config.yaml b/demos/use_cases/travel_agents/arch_config.yaml deleted file mode 100644 index ba00048a..00000000 --- a/demos/use_cases/travel_agents/arch_config.yaml +++ /dev/null @@ -1,34 +0,0 @@ -version: v0.3.0 - -agents: - - id: weather_agent - url: http://host.docker.internal:10510 - - id: flight_agent - url: http://host.docker.internal:10520 - - id: currency_agent - url: http://host.docker.internal:10530 - -model_providers: - - model: openai/gpt-4o - access_key: $OPENAI_API_KEY - - model: openai/gpt-4o-mini - access_key: $OPENAI_API_KEY - -system_prompt: | - You are a professional travel planner assistant. Your role is to provide accurate, clear, and helpful information about weather and flights based on the structured data provided to you.\n\nCRITICAL INSTRUCTIONS:\n\n1. DATA STRUCTURE:\n \n WEATHER DATA:\n - You will receive weather data as JSON in a system message\n - The data contains a \"location\" field (string) and a \"forecast\" array\n - Each forecast entry has: date, day_name, temperature_c, temperature_f, temperature_max_c, temperature_min_c, condition, sunrise, sunset\n - Some fields may be null/None - handle these gracefully\n \n FLIGHT DATA:\n - You will receive flight information in a system message\n - Flight data includes: airline, flight number, departure time, arrival time, origin airport, destination airport, aircraft type, status, gate, terminal\n - Information may include both scheduled and estimated times\n - Some fields may be unavailable - handle these gracefully\n\n2. WEATHER HANDLING:\n - For single-day queries: Use temperature_c/temperature_f (current/primary temperature)\n - For multi-day forecasts: Use temperature_max_c and temperature_min_c when available\n - Always provide temperatures in both Celsius and Fahrenheit when available\n - If temperature is null, say \"temperature data unavailable\" rather than making up numbers\n - Use exact condition descriptions provided (e.g., \"Clear sky\", \"Rainy\", \"Partly Cloudy\")\n - Add helpful context when appropriate (e.g., \"perfect for outdoor activities\" for clear skies)\n\n3. FLIGHT HANDLING:\n - Present flight information clearly with airline name and flight number\n - Include departure and arrival times with time zones when provided\n - Mention origin and destination airports with their codes\n - Include gate and terminal information when available\n - Note aircraft type if relevant to the query\n - Highlight any status updates (delays, early arrivals, etc.)\n - For multiple flights, list them in chronological order by departure time\n - If specific details are missing, acknowledge this rather than inventing information\n\n4. MULTI-PART QUERIES:\n - Users may ask about both weather and flights in one message\n - Answer ALL parts of the query that you have data for\n - Organize your response logically - typically weather first, then flights, or vice versa based on the query\n - Provide complete information for each topic without mentioning other agents\n - If you receive data for only one topic but the user asked about multiple, answer what you can with the provided data\n\n5. ERROR HANDLING:\n - If weather forecast contains an \"error\" field, acknowledge the issue politely\n - If temperature or condition is null/None, mention that specific data is unavailable\n - If flight details are incomplete, state which information is unavailable\n - Never invent or guess weather or flight data - only use what's provided\n - If location couldn't be determined, acknowledge this but still provide available data\n\n6. RESPONSE FORMAT:\n \n For Weather:\n - Single-day queries: Provide current conditions, temperature, and condition\n - Multi-day forecasts: List each day with date, day name, high/low temps, and condition\n - Include sunrise/sunset times when available and relevant\n \n For Flights:\n - List flights with clear numbering or bullet points\n - Include key details: airline, flight number, departure/arrival times, airports\n - Add gate, terminal, and status information when available\n - For multiple flights, organize chronologically\n \n General:\n - Use natural, conversational language\n - Be concise but complete\n - Format dates and times clearly\n - Use bullet points or numbered lists for clarity\n\n7. LOCATION HANDLING:\n - Always mention location names from the data\n - For flights, clearly state origin and destination cities/airports\n - If locations differ from what the user asked, acknowledge this politely\n\n8. RESPONSE STYLE:\n - Be friendly and professional\n - Use natural language, not technical jargon\n - Provide information in a logical, easy-to-read format\n - When answering multi-part queries, create a cohesive response that addresses all aspects\n\nRemember: Only use the data provided. Never fabricate weather or flight information. If data is missing, clearly state what's unavailable. Answer all parts of the user's query that you have data for. - -listeners: - - type: agent - name: travel_booking_service - port: 8001 - router: plano_orchestrator_v1 - agents: - - id: weather_agent - description: Get real-time weather conditions and multi-day forecasts for any city worldwide using Open-Meteo API (free, no API key needed). Provides current temperature, multi-day forecasts, weather conditions, sunrise/sunset times, and detailed weather information. Understands conversation context to resolve location references from previous messages. Handles weather-related questions including "What's the weather in [city]?", "What's the forecast for [city]?", "How's the weather in [city]?". When queries include both weather and other travel questions (e.g., flights, currency), this agent answers ONLY the weather part. - - id: flight_agent - description: Get live flight information between airports using FlightAware AeroAPI. Shows real-time flight status, scheduled/estimated/actual departure and arrival times, gate and terminal information, delays, aircraft type, and flight status. Automatically resolves city names to airport codes (IATA/ICAO). Understands conversation context to infer origin/destination from follow-up questions. Handles flight-related questions including "What flights go from [city] to [city]?", "Do flights go to [city]?", "Are there direct flights from [city]?". When queries include both flight and other travel questions (e.g., weather, currency), this agent answers ONLY the flight part. - - id: currency_agent - description: Get real-time currency exchange rates and perform currency conversions using Frankfurter API (free, no API key needed). Provides latest exchange rates, currency conversions with amount calculations, and supports any currency pair. Automatically extracts currency codes from country names and conversation context. Understands pronouns like "their currency" when referring to previously mentioned countries. Uses standard 3-letter ISO currency codes (e.g., USD, EUR, GBP, JPY, PKR). - -tracing: - random_sampling: 100 diff --git a/demos/use_cases/travel_agents/config.yaml b/demos/use_cases/travel_agents/config.yaml new file mode 100644 index 00000000..0b6aaba2 --- /dev/null +++ b/demos/use_cases/travel_agents/config.yaml @@ -0,0 +1,57 @@ +version: v0.3.0 + +agents: + - id: weather_agent + url: http://host.docker.internal:10510 + - id: flight_agent + url: http://host.docker.internal:10520 + +model_providers: + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY + default: true + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY # smaller, faster, cheaper model for extracting entities like location + +listeners: + - type: agent + name: travel_booking_service + port: 8001 + router: plano_orchestrator_v1 + agents: + - id: weather_agent + description: | + + WeatherAgent is a specialized AI assistant for real-time weather information and forecasts. It provides accurate weather data for any city worldwide using the Open-Meteo API, helping travelers plan their trips with up-to-date weather conditions. + + Capabilities: + * Get real-time weather conditions and multi-day forecasts for any city worldwide using Open-Meteo API (free, no API key needed) + * Provides current temperature + * Provides multi-day forecasts + * Provides weather conditions + * Provides sunrise/sunset times + * Provides detailed weather information + * Understands conversation context to resolve location references from previous messages + * Handles weather-related questions including "What's the weather in [city]?", "What's the forecast for [city]?", "How's the weather in [city]?" + * When queries include both weather and other travel questions (e.g., flights, currency), this agent answers ONLY the weather part + + - id: flight_agent + description: | + + FlightAgent is an AI-powered tool specialized in providing live flight information between airports. It leverages the FlightAware AeroAPI to deliver real-time flight status, gate information, and delay updates. + + Capabilities: + * Get live flight information between airports using FlightAware AeroAPI + * Shows real-time flight status + * Shows scheduled/estimated/actual departure and arrival times + * Shows gate and terminal information + * Shows delays + * Shows aircraft type + * Shows flight status + * Automatically resolves city names to airport codes (IATA/ICAO) + * Understands conversation context to infer origin/destination from follow-up questions + * Handles flight-related questions including "What flights go from [city] to [city]?", "Do flights go to [city]?", "Are there direct flights from [city]?" + * When queries include both flight and other travel questions (e.g., weather, currency), this agent answers ONLY the flight part + +tracing: + random_sampling: 100 diff --git a/demos/use_cases/travel_agents/docker-compose.yaml b/demos/use_cases/travel_agents/docker-compose.yaml index a5d45ed9..36b666d4 100644 --- a/demos/use_cases/travel_agents/docker-compose.yaml +++ b/demos/use_cases/travel_agents/docker-compose.yaml @@ -1,11 +1,44 @@ + services: jaeger: build: context: ../../shared/jaeger + container_name: jaeger + restart: always ports: - - "16686:16686" - - "4317:4317" - - "4318:4318" + - "16686:16686" # Jaeger UI + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + + weather-agent: + build: + context: . + dockerfile: Dockerfile + container_name: weather-agent + restart: always + ports: + - "10510:10510" + environment: + - LLM_GATEWAY_ENDPOINT=http://host.docker.internal:12000/v1 + command: ["uv", "run", "python", "src/travel_agents/weather_agent.py"] + extra_hosts: + - "host.docker.internal:host-gateway" + + flight-agent: + build: + context: . + dockerfile: Dockerfile + container_name: flight-agent + restart: always + ports: + - "10520:10520" + environment: + - LLM_GATEWAY_ENDPOINT=http://host.docker.internal:12000/v1 + - AEROAPI_KEY=${AEROAPI_KEY:-ESVFX7TJLxB7OTuayUv0zTQBryA3tOPr} + command: ["uv", "run", "python", "src/travel_agents/flight_agent.py"] + extra_hosts: + - "host.docker.internal:host-gateway" + open-web-ui: image: dyrnq/open-webui:main restart: always @@ -15,3 +48,6 @@ services: - DEFAULT_MODEL=gpt-4o-mini - ENABLE_OPENAI_API=true - OPENAI_API_BASE_URL=http://host.docker.internal:8001/v1 + depends_on: + - weather-agent + - flight-agent diff --git a/demos/use_cases/travel_agents/pyproject.toml b/demos/use_cases/travel_agents/pyproject.toml index 286d2432..7f37b26b 100644 --- a/demos/use_cases/travel_agents/pyproject.toml +++ b/demos/use_cases/travel_agents/pyproject.toml @@ -7,10 +7,11 @@ requires-python = ">=3.10" dependencies = [ "click>=8.2.1", "pydantic>=2.11.7", - "fastapi>=0.104.1", - "uvicorn>=0.24.0", - "openai>=2.13.0", + "fastapi>=0.115.0", + "uvicorn>=0.30.0", + "openai>=1.0.0", "httpx>=0.24.0", + "opentelemetry-api>=1.20.0", ] [project.scripts] @@ -19,3 +20,6 @@ travel_agents = "travel_agents:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/travel_agents"] diff --git a/demos/use_cases/travel_agents/src/travel_agents/__init__.py b/demos/use_cases/travel_agents/src/travel_agents/__init__.py deleted file mode 100644 index 2729d1ed..00000000 --- a/demos/use_cases/travel_agents/src/travel_agents/__init__.py +++ /dev/null @@ -1,48 +0,0 @@ -import click - - -@click.command() -@click.option("--host", "host", default="localhost", help="Host to bind server to") -@click.option("--port", "port", type=int, default=8000, help="Port for server") -@click.option( - "--agent", - "agent", - required=True, - help="Agent name: weather, flight, or currency", -) -def main(host, port, agent): - """Start a travel agent REST server.""" - agent_map = { - "weather": ("travel_agents.weather_agent", 10510), - "flight": ("travel_agents.flight_agent", 10520), - "currency": ("travel_agents.currency_agent", 10530), - } - - if agent not in agent_map: - print(f"Error: Unknown agent '{agent}'") - print(f"Available agents: {', '.join(agent_map.keys())}") - return - - module_name, default_port = agent_map[agent] - - if port == 8000: - port = default_port - - print(f"Starting {agent} agent REST server on {host}:{port}") - - if agent == "weather": - from travel_agents.weather_agent import start_server - - start_server(host=host, port=port) - elif agent == "flight": - from travel_agents.flight_agent import start_server - - start_server(host=host, port=port) - elif agent == "currency": - from travel_agents.currency_agent import start_server - - start_server(host=host, port=port) - - -if __name__ == "__main__": - main() diff --git a/demos/use_cases/travel_agents/src/travel_agents/__main__.py b/demos/use_cases/travel_agents/src/travel_agents/__main__.py deleted file mode 100644 index 868d99ef..00000000 --- a/demos/use_cases/travel_agents/src/travel_agents/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import main - -if __name__ == "__main__": - main() diff --git a/demos/use_cases/travel_agents/src/travel_agents/api.py b/demos/use_cases/travel_agents/src/travel_agents/api.py deleted file mode 100644 index eb63ea99..00000000 --- a/demos/use_cases/travel_agents/src/travel_agents/api.py +++ /dev/null @@ -1,36 +0,0 @@ -from pydantic import BaseModel -from typing import List, Optional, Dict, Any - - -class ChatMessage(BaseModel): - role: str - content: str - - -class ChatCompletionRequest(BaseModel): - model: str - messages: List[ChatMessage] - temperature: Optional[float] = 1.0 - max_tokens: Optional[int] = None - top_p: Optional[float] = 1.0 - frequency_penalty: Optional[float] = 0.0 - presence_penalty: Optional[float] = 0.0 - stream: Optional[bool] = False - stop: Optional[List[str]] = None - - -class ChatCompletionResponse(BaseModel): - id: str - object: str = "chat.completion" - created: int - model: str - choices: List[Dict[str, Any]] - usage: Dict[str, int] - - -class ChatCompletionStreamResponse(BaseModel): - id: str - object: str = "chat.completion.chunk" - created: int - model: str - choices: List[Dict[str, Any]] diff --git a/demos/use_cases/travel_agents/src/travel_agents/currency_agent.py b/demos/use_cases/travel_agents/src/travel_agents/currency_agent.py deleted file mode 100644 index 7ad9c914..00000000 --- a/demos/use_cases/travel_agents/src/travel_agents/currency_agent.py +++ /dev/null @@ -1,584 +0,0 @@ -import json -from fastapi import FastAPI, Request -from fastapi.responses import StreamingResponse -from openai import AsyncOpenAI -import os -import logging -import time -import uuid -import uvicorn -import httpx -from typing import Optional -from urllib.parse import quote -from .api import ( - ChatCompletionRequest, - ChatCompletionStreamResponse, -) - -# Set up logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - [CURRENCY_AGENT] - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - -# Configuration for archgw LLM gateway -LLM_GATEWAY_ENDPOINT = os.getenv("LLM_GATEWAY_ENDPOINT", "http://localhost:12000/v1") -CURRENCY_MODEL = "openai/gpt-4o" -CURRENCY_EXTRACTION_MODEL = "openai/gpt-4o-mini" - -# HTTP client for API calls -http_client = httpx.AsyncClient(timeout=10.0) - -# Initialize OpenAI client for archgw -archgw_client = AsyncOpenAI( - base_url=LLM_GATEWAY_ENDPOINT, - api_key="EMPTY", -) - -# System prompt for currency agent -SYSTEM_PROMPT = """You are a professional travel planner assistant. Your role is to provide accurate, clear, and helpful information about weather, flights, and currency exchange based on the structured data provided to you. - -CRITICAL INSTRUCTIONS: - -1. DATA STRUCTURE: - - WEATHER DATA: - - You will receive weather data as JSON in a system message - - The data contains a "location" field (string) and a "forecast" array - - Each forecast entry has: date, day_name, temperature_c, temperature_f, temperature_max_c, temperature_min_c, condition, sunrise, sunset - - Some fields may be null/None - handle these gracefully - - FLIGHT DATA: - - You will receive flight information in a system message - - Flight data includes: airline, flight number, departure time, arrival time, origin airport, destination airport, aircraft type, status, gate, terminal - - Information may include both scheduled and estimated times - - Some fields may be unavailable - handle these gracefully - - CURRENCY DATA: - - You will receive currency exchange data as JSON in a system message - - The data contains: from_currency, to_currency, rate, date, and optionally original_amount and converted_amount - - Some fields may be null/None - handle these gracefully - -2. WEATHER HANDLING: - - For single-day queries: Use temperature_c/temperature_f (current/primary temperature) - - For multi-day forecasts: Use temperature_max_c and temperature_min_c when available - - Always provide temperatures in both Celsius and Fahrenheit when available - - If temperature is null, say "temperature data unavailable" rather than making up numbers - - Use exact condition descriptions provided (e.g., "Clear sky", "Rainy", "Partly Cloudy") - - Add helpful context when appropriate (e.g., "perfect for outdoor activities" for clear skies) - -3. FLIGHT HANDLING: - - Present flight information clearly with airline name and flight number - - Include departure and arrival times with time zones when provided - - Mention origin and destination airports with their codes - - Include gate and terminal information when available - - Note aircraft type if relevant to the query - - Highlight any status updates (delays, early arrivals, etc.) - - For multiple flights, list them in chronological order by departure time - - If specific details are missing, acknowledge this rather than inventing information - -4. CURRENCY HANDLING: - - Present exchange rates clearly with both currency codes and names when helpful - - Include the date of the exchange rate - - If an amount was provided, show both the original and converted amounts - - Use clear formatting (e.g., "100 USD = 92.50 EUR" or "1 USD = 0.925 EUR") - - If rate data is unavailable, acknowledge this politely - -5. MULTI-PART QUERIES: - - Users may ask about weather, flights, and currency in one message - - Answer ALL parts of the query that you have data for - - Organize your response logically - typically weather first, then flights, then currency, or based on the query order - - Provide complete information for each topic without mentioning other agents - - If you receive data for only one topic but the user asked about multiple, answer what you can with the provided data - -6. ERROR HANDLING: - - If weather forecast contains an "error" field, acknowledge the issue politely - - If temperature or condition is null/None, mention that specific data is unavailable - - If flight details are incomplete, state which information is unavailable - - If currency rate is unavailable, mention that specific data is unavailable - - Never invent or guess weather, flight, or currency data - only use what's provided - - If location couldn't be determined, acknowledge this but still provide available data - -7. RESPONSE FORMAT: - - For Weather: - - Single-day queries: Provide current conditions, temperature, and condition - - Multi-day forecasts: List each day with date, day name, high/low temps, and condition - - Include sunrise/sunset times when available and relevant - - For Flights: - - List flights with clear numbering or bullet points - - Include key details: airline, flight number, departure/arrival times, airports - - Add gate, terminal, and status information when available - - For multiple flights, organize chronologically - - For Currency: - - Show exchange rate clearly: "1 [FROM] = [RATE] [TO]" - - If amount provided: "[AMOUNT] [FROM] = [CONVERTED] [TO]" - - Include the date of the exchange rate - - General: - - Use natural, conversational language - - Be concise but complete - - Format dates and times clearly - - Use bullet points or numbered lists for clarity - -8. LOCATION HANDLING: - - Always mention location names from the data - - For flights, clearly state origin and destination cities/airports - - For currency, use country/city context to resolve currency references - - If locations differ from what the user asked, acknowledge this politely - -9. RESPONSE STYLE: - - Be friendly and professional - - Use natural language, not technical jargon - - Provide information in a logical, easy-to-read format - - When answering multi-part queries, create a cohesive response that addresses all aspects - -Remember: Only use the data provided. Never fabricate weather, flight, or currency information. If data is missing, clearly state what's unavailable. Answer all parts of the user's query that you have data for.""" - - -CURRENCY_EXTRACTION_PROMPT = """You are a currency information extraction assistant. Your ONLY job is to extract currency-related information from user messages and convert it to standard 3-letter ISO currency codes. - -CRITICAL RULES: -1. Extract currency codes (3-letter ISO codes like USD, EUR, GBP, JPY, PKR, etc.) from the message AND conversation context -2. Extract any mentioned amounts or numbers that might be currency amounts -3. PAY ATTENTION TO CONVERSATION CONTEXT: - - If previous messages mention a country/city, use that context to resolve pronouns like "their", "that country", "there", etc. - - Example: If previous message was "What's the weather in Lahore, Pakistan?" and current message is "What is their currency exchange rate with USD?", then "their" = Pakistan = PKR - - Look for country names in the conversation history to infer currencies -4. If country names or regions are mentioned (in current message OR conversation context), convert them to their standard currency codes: - - United States/USA/US → USD - - Europe/Eurozone/France/Germany/Italy/Spain/etc. → EUR - - United Kingdom/UK/Britain → GBP - - Japan → JPY - - China → CNY - - India → INR - - Pakistan → PKR - - Australia → AUD - - Canada → CAD - - Switzerland → CHF - - South Korea → KRW - - Singapore → SGD - - Hong Kong → HKD - - Brazil → BRL - - Mexico → MXN - - And any other countries you know the currency for -5. Determine the FROM currency (source) and TO currency (target) based on context: - - "from X to Y" → from_currency=X, to_currency=Y - - "X to Y" → from_currency=X, to_currency=Y - - "convert X to Y" → from_currency=X, to_currency=Y - - "X in Y" → from_currency=X, to_currency=Y - - "rate for X" or "X rate" → to_currency=X (assume USD as base) - - "their currency with USD" or "their currency to USD" → from_currency=country_from_context, to_currency=USD - - "X dollars/euros/pounds/etc." → from_currency=X -6. If only one currency is mentioned, determine if it's the source or target based on context -7. ALWAYS return currency codes, never country names in the currency fields -8. Return your response as a JSON object with the following structure: - { - "from_currency": "USD" or null, - "to_currency": "EUR" or null, - "amount": 100.0 or null - } - -9. If you cannot determine a currency, use null for that field -10. Use standard 3-letter ISO currency codes ONLY -11. Ignore error messages, HTML tags, and assistant responses -12. Extract from the most recent user message BUT use conversation context to resolve references -13. Default behavior: If only one currency is mentioned without context, assume it's the target currency and use USD as the source - -Examples with context: -- Conversation: "What's the weather in Lahore, Pakistan?" → Current: "What is their currency exchange rate with USD?" → {"from_currency": "PKR", "to_currency": "USD", "amount": null} -- Conversation: "Tell me about Tokyo" → Current: "What's their currency rate?" → {"from_currency": "JPY", "to_currency": "USD", "amount": null} -- "What's the exchange rate from USD to EUR?" → {"from_currency": "USD", "to_currency": "EUR", "amount": null} -- "Convert 100 dollars to euros" → {"from_currency": "USD", "to_currency": "EUR", "amount": 100.0} -- "How much is 50 GBP in Japanese yen?" → {"from_currency": "GBP", "to_currency": "JPY", "amount": 50.0} -- "What's the rate for euros?" → {"from_currency": "USD", "to_currency": "EUR", "amount": null} -- "Convert money from United States to France" → {"from_currency": "USD", "to_currency": "EUR", "amount": null} -- "100 pounds to dollars" → {"from_currency": "GBP", "to_currency": "USD", "amount": 100.0} - -Now extract the currency information from this message, considering the conversation context:""" - - -async def extract_currency_info_from_messages(messages): - """Extract currency information from user messages using LLM, considering conversation context.""" - # Get all messages for context (both user and assistant) - conversation_context = [] - for msg in messages: - # Skip error messages and HTML tags - content = msg.content.strip() - content_lower = content.lower() - if any( - pattern in content_lower - for pattern in ["<", ">", "error:", "i apologize", "i'm having trouble"] - ): - continue - conversation_context.append({"role": msg.role, "content": content}) - - # Get the most recent user message - user_messages = [msg for msg in messages if msg.role == "user"] - - if not user_messages: - logger.warning("No user messages found") - return {"from_currency": "USD", "to_currency": "EUR", "amount": None} - - # Get the most recent user message (skip error messages and HTML tags) - user_content = None - for msg in reversed(user_messages): - content = msg.content.strip() - # Skip messages with error patterns or HTML tags - content_lower = content.lower() - if any( - pattern in content_lower - for pattern in [ - "<", - ">", - "assistant:", - "error:", - "i apologize", - "i'm having trouble", - ] - ): - continue - user_content = content - break - - if not user_content: - logger.warning("No valid user message found") - return {"from_currency": "USD", "to_currency": "EUR", "amount": None} - - try: - logger.info(f"Extracting currency info from user message: {user_content[:200]}") - logger.info( - f"Using conversation context with {len(conversation_context)} messages" - ) - - llm_messages = [{"role": "system", "content": CURRENCY_EXTRACTION_PROMPT}] - - context_messages = ( - conversation_context[-10:] - if len(conversation_context) > 10 - else conversation_context - ) - for msg in context_messages: - llm_messages.append({"role": msg["role"], "content": msg["content"]}) - - response = await archgw_client.chat.completions.create( - model=CURRENCY_EXTRACTION_MODEL, - messages=llm_messages, - temperature=0.1, - max_tokens=200, - ) - - extracted_text = response.choices[0].message.content.strip() - - try: - if "```json" in extracted_text: - extracted_text = ( - extracted_text.split("```json")[1].split("```")[0].strip() - ) - elif "```" in extracted_text: - extracted_text = extracted_text.split("```")[1].split("```")[0].strip() - - currency_info = json.loads(extracted_text) - - from_currency = currency_info.get("from_currency") - to_currency = currency_info.get("to_currency") - amount = currency_info.get("amount") - - if not from_currency: - from_currency = "USD" - if not to_currency: - to_currency = "EUR" - - result = { - "from_currency": from_currency, - "to_currency": to_currency, - "amount": amount, - } - - logger.info(f"LLM extracted currency info: {result}") - return result - - except json.JSONDecodeError as e: - logger.warning( - f"Failed to parse JSON from LLM response: {extracted_text}, error: {e}" - ) - return {"from_currency": "USD", "to_currency": "EUR", "amount": None} - - except Exception as e: - logger.error(f"Error extracting currency info with LLM: {e}, using defaults") - return {"from_currency": "USD", "to_currency": "EUR", "amount": None} - - -async def get_currency_exchange_rate( - from_currency: str, to_currency: str -) -> Optional[dict]: - """Get currency exchange rate between two currencies using Frankfurter API. - - Uses the Frankfurter API (api.frankfurter.dev) which provides free, open-source - currency data tracking reference exchange rates published by institutional sources. - No API keys required. - - Args: - from_currency: Base currency code (e.g., "USD", "EUR") - to_currency: Target currency code (e.g., "EUR", "GBP") - - Returns: - Dictionary with exchange rate data or None if error occurs - """ - try: - url = f"https://api.frankfurter.dev/v1/latest?base={from_currency}&symbols={to_currency}" - response = await http_client.get(url) - - if response.status_code != 200: - logger.warning( - f"Currency API returned status {response.status_code} for {from_currency} to {to_currency}" - ) - return None - - data = response.json() - - if "rates" not in data: - logger.warning(f"Invalid API response structure: missing 'rates' field") - return None - - if to_currency not in data["rates"]: - logger.warning( - f"Currency {to_currency} not found in API response for base {from_currency}" - ) - return None - - return { - "from_currency": from_currency, - "to_currency": to_currency, - "rate": data["rates"][to_currency], - "date": data.get("date"), - "base": data.get("base"), - } - except httpx.HTTPError as e: - logger.error( - f"HTTP error fetching currency rate from {from_currency} to {to_currency}: {e}" - ) - return None - except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSON response from currency API: {e}") - return None - except Exception as e: - logger.error(f"Unexpected error fetching currency rate: {e}") - return None - - -# FastAPI app for REST server -app = FastAPI(title="Currency Exchange Agent", version="1.0.0") - - -async def prepare_currency_messages(request_body: ChatCompletionRequest): - """Prepare messages with currency exchange data.""" - # Extract currency information from conversation using LLM - currency_info = await extract_currency_info_from_messages(request_body.messages) - - from_currency = currency_info["from_currency"] - to_currency = currency_info["to_currency"] - amount = currency_info.get("amount") - - # Get currency exchange rate - rate_data = await get_currency_exchange_rate(from_currency, to_currency) - - if rate_data: - currency_data = { - "from_currency": rate_data["from_currency"], - "to_currency": rate_data["to_currency"], - "rate": rate_data["rate"], - "date": rate_data.get("date"), - } - - # If an amount was mentioned, calculate the conversion - if amount is not None: - converted_amount = amount * rate_data["rate"] - currency_data["original_amount"] = amount - currency_data["converted_amount"] = round(converted_amount, 2) - else: - logger.warning( - f"Could not fetch currency rate for {from_currency} to {to_currency}" - ) - currency_data = { - "from_currency": from_currency, - "to_currency": to_currency, - "rate": None, - "error": "Could not retrieve exchange rate", - } - - # Create system message with currency data - currency_context = f""" -Current currency exchange data: - -{json.dumps(currency_data, indent=2)} - -Use this data to answer the user's currency exchange query. -""" - - response_messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "assistant", "content": currency_context}, - ] - - # Add conversation history - for msg in request_body.messages: - response_messages.append({"role": msg.role, "content": msg.content}) - - return response_messages - - -@app.post("/v1/chat/completions") -async def chat_completion_http(request: Request, request_body: ChatCompletionRequest): - """HTTP endpoint for chat completions with streaming support.""" - logger.info(f"Received currency request with {len(request_body.messages)} messages") - - traceparent_header = request.headers.get("traceparent") - - if traceparent_header: - logger.info(f"Received traceparent header: {traceparent_header}") - - return StreamingResponse( - stream_chat_completions(request_body, traceparent_header), - media_type="text/plain", - headers={ - "content-type": "text/event-stream", - }, - ) - - -async def stream_chat_completions( - request_body: ChatCompletionRequest, traceparent_header: str = None -): - """Generate streaming chat completions.""" - # Prepare messages with currency exchange data - response_messages = await prepare_currency_messages(request_body) - - try: - logger.info( - f"Calling archgw at {LLM_GATEWAY_ENDPOINT} to generate currency response" - ) - - # Prepare extra headers - extra_headers = {"x-envoy-max-retries": "3"} - if traceparent_header: - extra_headers["traceparent"] = traceparent_header - - response_stream = await archgw_client.chat.completions.create( - model=CURRENCY_MODEL, - messages=response_messages, - temperature=request_body.temperature or 0.7, - max_tokens=request_body.max_tokens or 1000, - stream=True, - extra_headers=extra_headers, - ) - - completion_id = f"chatcmpl-{uuid.uuid4().hex[:8]}" - created_time = int(time.time()) - collected_content = [] - - async for chunk in response_stream: - if chunk.choices and chunk.choices[0].delta.content: - content = chunk.choices[0].delta.content - collected_content.append(content) - - stream_chunk = ChatCompletionStreamResponse( - id=completion_id, - created=created_time, - model=request_body.model, - choices=[ - { - "index": 0, - "delta": {"content": content}, - "finish_reason": None, - } - ], - ) - - yield f"data: {stream_chunk.model_dump_json()}\n\n" - - full_response = "".join(collected_content) - updated_history = [{"role": "assistant", "content": full_response}] - - final_chunk = ChatCompletionStreamResponse( - id=completion_id, - created=created_time, - model=request_body.model, - choices=[ - { - "index": 0, - "delta": {}, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": json.dumps(updated_history), - }, - } - ], - ) - - yield f"data: {final_chunk.model_dump_json()}\n\n" - yield "data: [DONE]\n\n" - - except Exception as e: - logger.error(f"Error generating currency response: {e}") - - error_chunk = ChatCompletionStreamResponse( - id=f"chatcmpl-{uuid.uuid4().hex[:8]}", - created=int(time.time()), - model=request_body.model, - choices=[ - { - "index": 0, - "delta": { - "content": "I apologize, but I'm having trouble generating a currency exchange response right now. Please try again." - }, - "finish_reason": "stop", - } - ], - ) - - yield f"data: {error_chunk.model_dump_json()}\n\n" - yield "data: [DONE]\n\n" - - -@app.get("/health") -async def health_check(): - """Health check endpoint.""" - return {"status": "healthy", "agent": "currency_exchange"} - - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=10530) - - -def start_server(host: str = "localhost", port: int = 10530): - """Start the currency agent server.""" - uvicorn.run( - app, - host=host, - port=port, - log_config={ - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "format": "%(asctime)s - [CURRENCY_AGENT] - %(levelname)s - %(message)s", - }, - }, - "handlers": { - "default": { - "formatter": "default", - "class": "logging.StreamHandler", - "stream": "ext://sys.stdout", - }, - }, - "root": { - "level": "INFO", - "handlers": ["default"], - }, - }, - ) diff --git a/demos/use_cases/travel_agents/src/travel_agents/flight_agent.py b/demos/use_cases/travel_agents/src/travel_agents/flight_agent.py index 5e0c8068..d1ad8575 100644 --- a/demos/use_cases/travel_agents/src/travel_agents/flight_agent.py +++ b/demos/use_cases/travel_agents/src/travel_agents/flight_agent.py @@ -1,5 +1,4 @@ import json -import re from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse from openai import AsyncOpenAI @@ -11,12 +10,7 @@ import uvicorn from datetime import datetime, timedelta import httpx from typing import Optional -from urllib.parse import quote - -from .api import ( - ChatCompletionRequest, - ChatCompletionStreamResponse, -) +from opentelemetry.propagate import extract, inject # Set up logging logging.basicConfig( @@ -25,10 +19,12 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -# Configuration for archgw LLM gateway -LLM_GATEWAY_ENDPOINT = os.getenv("LLM_GATEWAY_ENDPOINT", "http://localhost:12000/v1") +# Configuration +LLM_GATEWAY_ENDPOINT = os.getenv( + "LLM_GATEWAY_ENDPOINT", "http://host.docker.internal:12000/v1" +) FLIGHT_MODEL = "openai/gpt-4o" -FLIGHT_EXTRACTION_MODEL = "openai/gpt-4o-mini" +EXTRACTION_MODEL = "openai/gpt-4o-mini" # FlightAware AeroAPI configuration AEROAPI_BASE_URL = "https://aeroapi.flightaware.com/aeroapi" @@ -37,829 +33,396 @@ AEROAPI_KEY = os.getenv("AEROAPI_KEY", "ESVFX7TJLxB7OTuayUv0zTQBryA3tOPr") # HTTP client for API calls http_client = httpx.AsyncClient(timeout=30.0) -# Initialize OpenAI client for archgw -archgw_client = AsyncOpenAI( +# Initialize OpenAI client +openai_client_via_plano = AsyncOpenAI( base_url=LLM_GATEWAY_ENDPOINT, api_key="EMPTY", ) # System prompt for flight agent -SYSTEM_PROMPT = """You are a professional travel planner assistant. Your role is to provide accurate, clear, and helpful information about weather and flights based on the structured data provided to you. +SYSTEM_PROMPT = """You are a travel planning assistant specializing in flight information in a multi-agent system. You will receive flight data in JSON format with these fields: -CRITICAL INSTRUCTIONS: +- "airline": Full airline name (e.g., "Delta Air Lines") +- "flight_number": Flight identifier (e.g., "DL123") +- "departure_time": ISO 8601 timestamp for scheduled departure (e.g., "2025-12-24T23:00:00Z") +- "arrival_time": ISO 8601 timestamp for scheduled arrival (e.g., "2025-12-25T04:40:00Z") +- "origin": Origin airport IATA code (e.g., "ATL") +- "destination": Destination airport IATA code (e.g., "SEA") +- "aircraft_type": Aircraft model code (e.g., "A21N", "B739") +- "status": Flight status (e.g., "Scheduled", "Delayed") +- "terminal_origin": Departure terminal (may be null) +- "gate_origin": Departure gate (may be null) -1. DATA STRUCTURE: +Your task: +1. Read the JSON flight data carefully +2. Present each flight clearly with: airline, flight number, departure/arrival times (convert from ISO format to readable time), airports, and aircraft type +3. Organize flights chronologically by departure time +4. Convert ISO timestamps to readable format (e.g., "11:00 PM" or "23:00") +5. Include terminal/gate info when available +6. Use natural, conversational language - WEATHER DATA: - - You will receive weather data as JSON in a system message - - The data contains a "location" field (string) and a "forecast" array - - Each forecast entry has: date, day_name, temperature_c, temperature_f, temperature_max_c, temperature_min_c, condition, sunrise, sunset - - Some fields may be null/None - handle these gracefully +Important: If the conversation includes information from other agents (like weather details), acknowledge and build upon that context naturally. Your primary focus is flights, but maintain awareness of the full conversation. - FLIGHT DATA: - - You will receive flight information in a system message - - Flight data includes: airline, flight number, departure time, arrival time, origin airport, destination airport, aircraft type, status, gate, terminal - - Information may include both scheduled and estimated times - - Some fields may be unavailable - handle these gracefully - -2. WEATHER HANDLING: - - For single-day queries: Use temperature_c/temperature_f (current/primary temperature) - - For multi-day forecasts: Use temperature_max_c and temperature_min_c when available - - Always provide temperatures in both Celsius and Fahrenheit when available - - If temperature is null, say "temperature data unavailable" rather than making up numbers - - Use exact condition descriptions provided (e.g., "Clear sky", "Rainy", "Partly Cloudy") - - Add helpful context when appropriate (e.g., "perfect for outdoor activities" for clear skies) - -3. FLIGHT HANDLING: - - Present flight information clearly with airline name and flight number - - Include departure and arrival times with time zones when provided - - Mention origin and destination airports with their codes - - Include gate and terminal information when available - - Note aircraft type if relevant to the query - - Highlight any status updates (delays, early arrivals, etc.) - - For multiple flights, list them in chronological order by departure time - - If specific details are missing, acknowledge this rather than inventing information - -4. MULTI-PART QUERIES: - - Users may ask about both weather and flights in one message - - Answer ALL parts of the query that you have data for - - Organize your response logically - typically weather first, then flights, or vice versa based on the query - - Provide complete information for each topic without mentioning other agents - - If you receive data for only one topic but the user asked about multiple, answer what you can with the provided data - -5. ERROR HANDLING: - - If weather forecast contains an "error" field, acknowledge the issue politely - - If temperature or condition is null/None, mention that specific data is unavailable - - If flight details are incomplete, state which information is unavailable - - Never invent or guess weather or flight data - only use what's provided - - If location couldn't be determined, acknowledge this but still provide available data - -6. RESPONSE FORMAT: - - For Weather: - - Single-day queries: Provide current conditions, temperature, and condition - - Multi-day forecasts: List each day with date, day name, high/low temps, and condition - - Include sunrise/sunset times when available and relevant - - For Flights: - - List flights with clear numbering or bullet points - - Include key details: airline, flight number, departure/arrival times, airports - - Add gate, terminal, and status information when available - - For multiple flights, organize chronologically - - General: - - Use natural, conversational language - - Be concise but complete - - Format dates and times clearly - - Use bullet points or numbered lists for clarity - -7. LOCATION HANDLING: - - Always mention location names from the data - - For flights, clearly state origin and destination cities/airports - - If locations differ from what the user asked, acknowledge this politely - -8. RESPONSE STYLE: - - Be friendly and professional - - Use natural language, not technical jargon - - Provide information in a logical, easy-to-read format - - When answering multi-part queries, create a cohesive response that addresses all aspects - -Remember: Only use the data provided. Never fabricate weather or flight information. If data is missing, clearly state what's unavailable. Answer all parts of the user's query that you have data for.""" +Remember: All the data you need is in the JSON. Use it directly.""" -FLIGHT_EXTRACTION_PROMPT = """You are a flight information extraction assistant. Your ONLY job is to extract flight-related information from user messages and convert it to structured data. +async def extract_flight_route(messages: list, request: Request) -> dict: + """Extract origin, destination, and date from conversation using LLM.""" -CRITICAL RULES: -1. Extract origin city/airport and destination city/airport from the message AND conversation context -2. Extract any mentioned dates or time references -3. **CROSS-AGENT REFERENCE HANDLING - CRITICAL**: When extracting flight info, use cities mentioned in weather queries as context - - If a weather query mentions a city (e.g., "weather in Seattle"), use that city to fill missing flight origin/destination - - Example: "What is the weather in Seattle and what flight goes to New York direct?" - → Weather mentions "Seattle" → Use Seattle as flight origin - → Extract origin=Seattle, destination=New York - - Example: "What is the weather in Atlanta and what flight goes from Detroit to Atlanta?" - → Extract origin=Detroit, destination=Atlanta (both explicitly mentioned in flight part) - - **ALWAYS check conversation history for cities mentioned in weather queries** - use them to infer missing flight origin/destination -4. **MULTI-PART QUERY HANDLING**: When the user asks about both weather/flights/currency in one query, extract ONLY the flight-related parts - - Look for patterns like "flight from X to Y", "flights from X", "flights to Y", "flight goes from X to Y" - - Example: "What is the weather in Atlanta and what flight goes from Detroit to Atlanta?" → Extract origin=Detroit, destination=Atlanta (ignore Atlanta weather part) - - Example: "What's the weather in Seattle, and what is one flight that goes direct to Atlanta?" → Extract origin=Seattle (from weather context), destination=Atlanta - - Focus on the flight route, but use weather context to fill missing parts -5. PAY ATTENTION TO CONVERSATION CONTEXT - THIS IS CRITICAL: - - If previous messages mention cities/countries, use that context to resolve pronouns and incomplete queries - - Example 1: Previous: "What's the weather in Istanbul?" → Current: "Do they fly out from Seattle?" - → "they" refers to Istanbul → origin=Istanbul, destination=Seattle - - Example 2: Previous: "What's the weather in London?" → Current: "What flights go from there to Seattle?" - → "there" = London → origin=London, destination=Seattle - - Example 3: Previous: "What's the exchange rate for Turkey?" → Current: "Do they have flights to Seattle?" - → "they" refers to Turkey/Istanbul → origin=Istanbul, destination=Seattle - - Example 4: Previous: "What is the weather in Seattle?" → Current: "What flight goes to New York direct?" - → Seattle mentioned in weather query → Use Seattle as origin → origin=Seattle, destination=New York -6. For follow-up questions like "Do they fly out from X?" or "Do they have flights to Y?": - - Look for previously mentioned cities/countries in the conversation - - If a city was mentioned earlier, use it as the missing origin or destination - - If the question mentions a city explicitly, use that city - - Try to infer the complete route from context -7. Extract dates and time references: - - "tomorrow", "today", "next week", specific dates - - Convert relative dates to ISO format (YYYY-MM-DD) when possible -8. Determine the origin and destination based on context: - - "from X to Y" → origin=X, destination=Y - - "X to Y" → origin=X, destination=Y - - "flight goes from X to Y" → origin=X, destination=Y - - "flights from X" → origin=X, destination=null (UNLESS conversation context provides a previously mentioned city - use that as destination) - - "flights to Y" → origin=null (UNLESS conversation context provides a previously mentioned city - use that as origin), destination=Y - - "What flights go direct from X?" → origin=X, destination=from conversation context (if a city was mentioned earlier) - - "Do they fly out from X?" → origin=X (or from context), destination=from context (check ALL previous messages for mentioned cities) - - "Do they have flights to Y?" → origin=from context (check ALL previous messages), destination=Y - - CRITICAL: When only one part (origin OR destination) is provided, ALWAYS check conversation history for the missing part -8. Return your response as a JSON object with the following structure: - { - "origin": "London" or null, - "destination": "Seattle" or null, - "date": "2025-12-20" or null, - "origin_airport_code": "LHR" or null, - "destination_airport_code": "SEA" or null - } + extraction_prompt = """Extract flight origin, destination cities, and travel date from the conversation. -9. If you cannot determine a value, use null for that field -10. Use city names (not airport codes) in origin/destination fields - airport codes will be resolved separately -11. Ignore error messages, HTML tags, and assistant responses -12. Extract from the most recent user message BUT use conversation context to resolve references -13. For dates: Use ISO format (YYYY-MM-DD). If relative date like "tomorrow", calculate the actual date -14. IMPORTANT: When a follow-up question mentions one city but context has another city, try to infer the complete route + Rules: + 1. Look for patterns: "flight from X to Y", "flights to Y", "fly from X" + 2. Extract dates like "tomorrow", "next week", "December 25", "12/25", "on Monday" + 3. Use conversation context to fill in missing details + 4. Return JSON: {"origin": "City" or null, "destination": "City" or null, "date": "YYYY-MM-DD" or null} -Examples with context: -- "What is the weather in Atlanta and what flight goes from Detroit to Atlanta?" → {"origin": "Detroit", "destination": "Atlanta", "date": null, "origin_airport_code": null, "destination_airport_code": null} -- "What is the weather in Seattle and what flight goes to New York direct?" → {"origin": "Seattle", "destination": "New York", "date": null, "origin_airport_code": null, "destination_airport_code": null} (Seattle from weather context) -- Conversation: "What's the weather in Istanbul?" → Current: "Do they fly out from Seattle?" → {"origin": "Istanbul", "destination": "Seattle", "date": null, "origin_airport_code": null, "destination_airport_code": null} -- Conversation: "What's the weather in Istanbul?" → Current: "What flights go direct from Seattle?" → {"origin": "Seattle", "destination": "Istanbul", "date": null, "origin_airport_code": null, "destination_airport_code": null} (Istanbul from previous context) -- Conversation: "What's the weather in London?" → Current: "What flights go from there to Seattle?" → {"origin": "London", "destination": "Seattle", "date": null, "origin_airport_code": null, "destination_airport_code": null} -- Conversation: "Tell me about Istanbul" → Current: "Do they have flights to Seattle?" → {"origin": "Istanbul", "destination": "Seattle", "date": null, "origin_airport_code": null, "destination_airport_code": null} -- "What flights go from London to Seattle?" → {"origin": "London", "destination": "Seattle", "date": null, "origin_airport_code": null, "destination_airport_code": null} -- "Show me flights to New York tomorrow" → {"origin": null, "destination": "New York", "date": "2025-12-21", "origin_airport_code": null, "destination_airport_code": null} -- "Flights from LAX to JFK" → {"origin": "Los Angeles", "destination": "New York", "date": null, "origin_airport_code": "LAX", "destination_airport_code": "JFK"} + Examples: + - "Flight from Seattle to Atlanta tomorrow" → {"origin": "Seattle", "destination": "Atlanta", "date": "2025-12-24"} + - "What flights go to New York?" → {"origin": null, "destination": "New York", "date": null} + - "Flights to Miami on Christmas" → {"origin": null, "destination": "Miami", "date": "2025-12-25"} + - "Show me flights from LA to NYC next Monday" → {"origin": "LA", "destination": "NYC", "date": "2025-12-30"} -Now extract the flight information from this message, considering the conversation context:""" - - -async def extract_flight_info_from_messages(messages): - """Extract flight information from user messages using LLM, considering conversation context.""" - conversation_context = [] - for msg in messages: - content = msg.content.strip() - content_lower = content.lower() - if any( - pattern in content_lower - for pattern in ["<", ">", "error:", "i apologize", "i'm having trouble"] - ): - continue - conversation_context.append({"role": msg.role, "content": content}) - - user_messages = [msg for msg in messages if msg.role == "user"] - - if not user_messages: - logger.warning("No user messages found") - return { - "origin": None, - "destination": None, - "date": None, - "origin_airport_code": None, - "destination_airport_code": None, - } - - # CRITICAL: Always preserve the FIRST user message (original query) for multi-agent scenarios - # When Plano processes multiple agents, it may add assistant responses that get filtered out, - # but we need to always use the original user query - original_user_message = user_messages[0].content.strip() if user_messages else None - - # Try to find a valid recent user message first (for follow-up queries) - user_content = None - for msg in reversed(user_messages): - content = msg.content.strip() - content_lower = content.lower() - - # Skip messages that are clearly JSON-encoded assistant responses or errors - # But be less aggressive - only skip if it's clearly not a user query - if content.startswith("[{") or content.startswith("[{"): - # Likely JSON-encoded assistant response - continue - if any( - pattern in content_lower - for pattern in [ - '"role": "assistant"', - '"role":"assistant"', - "error:", - ] - ): - continue - # Don't skip messages that just happen to contain these words naturally - user_content = content - break - - # Fallback to original user message if no valid recent message found - if not user_content and original_user_message: - # Check if original message is valid (not JSON-encoded) - if not ( - original_user_message.startswith("[{") - or original_user_message.startswith("[{") - ): - user_content = original_user_message - logger.info(f"Using original user message: {user_content[:200]}") - - if not user_content: - logger.warning("No valid user message found") - return { - "origin": None, - "destination": None, - "date": None, - "origin_airport_code": None, - "destination_airport_code": None, - } + Today is December 23, 2025. Extract flight route and date:""" try: - logger.info(f"Extracting flight info from user message: {user_content[:200]}") - logger.info( - f"Using conversation context with {len(conversation_context)} messages" - ) + ctx = extract(request.headers) + extra_headers = {} + inject(extra_headers, context=ctx) - llm_messages = [{"role": "system", "content": FLIGHT_EXTRACTION_PROMPT}] - - context_messages = ( - conversation_context[-10:] - if len(conversation_context) > 10 - else conversation_context - ) - for msg in context_messages: - llm_messages.append({"role": msg["role"], "content": msg["content"]}) - - response = await archgw_client.chat.completions.create( - model=FLIGHT_EXTRACTION_MODEL, - messages=llm_messages, + response = await openai_client_via_plano.chat.completions.create( + model=EXTRACTION_MODEL, + messages=[ + {"role": "system", "content": extraction_prompt}, + *[ + {"role": msg.get("role"), "content": msg.get("content")} + for msg in messages[-5:] + ], + ], temperature=0.1, - max_tokens=300, + max_tokens=100, + extra_headers=extra_headers if extra_headers else None, ) - extracted_text = response.choices[0].message.content.strip() + result = response.choices[0].message.content.strip() + if "```json" in result: + result = result.split("```json")[1].split("```")[0].strip() + elif "```" in result: + result = result.split("```")[1].split("```")[0].strip() - try: - if "```json" in extracted_text: - extracted_text = ( - extracted_text.split("```json")[1].split("```")[0].strip() - ) - elif "```" in extracted_text: - extracted_text = extracted_text.split("```")[1].split("```")[0].strip() - - flight_info = json.loads(extracted_text) - - date = flight_info.get("date") - if date: - today = datetime.now().date() - if date.lower() == "tomorrow": - date = (today + timedelta(days=1)).strftime("%Y-%m-%d") - elif date.lower() == "today": - date = today.strftime("%Y-%m-%d") - elif "next week" in date.lower(): - date = (today + timedelta(days=7)).strftime("%Y-%m-%d") - - result = { - "origin": flight_info.get("origin"), - "destination": flight_info.get("destination"), - "date": date, - "origin_airport_code": flight_info.get("origin_airport_code"), - "destination_airport_code": flight_info.get("destination_airport_code"), - } - - # Fallback: If origin is missing but we have destination, infer from weather context - if not result["origin"] and result["destination"]: - # Look for cities mentioned in weather queries in conversation context - for msg in reversed(conversation_context): - if msg["role"] == "user": - content = msg["content"] - # Look for weather queries mentioning cities - if ( - "weather" in content.lower() - or "forecast" in content.lower() - ): - # Common patterns: "weather in [city]", "forecast for [city]", "weather [city]" - patterns = [ - r"(?:weather|forecast).*?(?:in|for)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - r"weather\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - ] - for pattern in patterns: - city_match = re.search(pattern, content, re.IGNORECASE) - if city_match: - potential_city = city_match.group(1).strip() - # Don't use the same city as destination - if ( - potential_city.lower() - != result["destination"].lower() - ): - logger.info( - f"Inferring origin from weather context in extraction: {potential_city}" - ) - result["origin"] = potential_city - break - if result["origin"]: - break - - # Fallback: If destination is missing but we have origin, try to infer from conversation context - if result["origin"] and not result["destination"]: - # Look for cities mentioned in previous messages - for msg in reversed(conversation_context): - if msg["role"] == "user": - content = msg["content"] - # Look for weather queries mentioning cities - if ( - "weather" in content.lower() - or "forecast" in content.lower() - ): - # Common patterns: "weather in [city]", "forecast for [city]", "weather [city]" - patterns = [ - r"(?:weather|forecast).*?(?:in|for)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - r"weather\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - ] - for pattern in patterns: - city_match = re.search(pattern, content, re.IGNORECASE) - if city_match: - potential_city = city_match.group(1).strip() - # Don't use the same city as origin - if ( - potential_city.lower() - != result["origin"].lower() - ): - logger.info( - f"Inferring destination from conversation context: {potential_city}" - ) - result["destination"] = potential_city - break - if result["destination"]: - break - - logger.info(f"LLM extracted flight info: {result}") - return result - - except json.JSONDecodeError as e: - logger.warning( - f"Failed to parse JSON from LLM response: {extracted_text}, error: {e}" - ) - return { - "origin": None, - "destination": None, - "date": None, - "origin_airport_code": None, - "destination_airport_code": None, - } - - except Exception as e: - logger.error(f"Error extracting flight info with LLM: {e}, using defaults") + route = json.loads(result) return { - "origin": None, - "destination": None, - "date": None, - "origin_airport_code": None, - "destination_airport_code": None, + "origin": route.get("origin"), + "destination": route.get("destination"), + "date": route.get("date"), } + except Exception as e: + logger.error(f"Error extracting flight route: {e}") + return {"origin": None, "destination": None, "date": None} -AIRPORT_CODE_RESOLUTION_PROMPT = """You are an airport code resolution assistant. Your ONLY job is to convert city names or locations to their primary airport IATA/ICAO codes. - -CRITICAL RULES: -1. Convert city names, locations, or airport names to their primary airport code (prefer IATA 3-letter codes like JFK, LHR, LAX) -2. For cities with multiple airports, choose the PRIMARY/MOST COMMONLY USED airport: - - London → LHR (Heathrow, not Gatwick or Stansted) - - New York → JFK (not LGA or EWR) - - Paris → CDG (not ORY) - - Tokyo → NRT (Narita, not HND) - - Beijing → PEK (not PKX) - - Shanghai → PVG (not SHA) -3. If the input is already an airport code (3-letter IATA or 4-letter ICAO), return it as-is -4. Return ONLY the airport code, nothing else -5. Use standard IATA codes when available, ICAO codes as fallback -6. If you cannot determine the airport code, return "NOT_FOUND" - -Examples: -- "London" → "LHR" -- "New York" → "JFK" -- "Los Angeles" → "LAX" -- "Seattle" → "SEA" -- "Paris" → "CDG" -- "Tokyo" → "NRT" -- "JFK" → "JFK" -- "LAX" → "LAX" -- "LHR" → "LHR" -- "Unknown City" → "NOT_FOUND" - -Now convert this location to an airport code:""" - - -async def resolve_airport_code(city_name: str) -> Optional[str]: - """Resolve city name to airport code using LLM and FlightAware API. - - Uses LLM to convert city names to airport codes, then validates via API. - """ +async def resolve_airport_code(city_name: str, request: Request) -> Optional[str]: + """Convert city name to airport code using LLM.""" if not city_name: return None try: - logger.info(f"Resolving airport code for: {city_name}") + ctx = extract(request.headers) + extra_headers = {} + inject(extra_headers, context=ctx) - response = await archgw_client.chat.completions.create( - model=FLIGHT_EXTRACTION_MODEL, + response = await openai_client_via_plano.chat.completions.create( + model=EXTRACTION_MODEL, messages=[ - {"role": "system", "content": AIRPORT_CODE_RESOLUTION_PROMPT}, + { + "role": "system", + "content": "Convert city names to primary airport IATA codes. Return only the 3-letter code. Examples: Seattle→SEA, Atlanta→ATL, New York→JFK, London→LHR", + }, {"role": "user", "content": city_name}, ], temperature=0.1, - max_tokens=50, + max_tokens=10, + extra_headers=extra_headers if extra_headers else None, ) - airport_code = response.choices[0].message.content.strip().upper() - airport_code = airport_code.strip("\"'`.,!? \n\t") - - if airport_code == "NOT_FOUND" or not airport_code: - logger.warning(f"LLM could not resolve airport code for {city_name}") - return None - - logger.info(f"LLM resolved {city_name} to airport code: {airport_code}") - - try: - url = f"{AEROAPI_BASE_URL}/airports/{airport_code}" - headers = {"x-apikey": AEROAPI_KEY} - - validation_response = await http_client.get(url, headers=headers) - - if validation_response.status_code == 200: - data = validation_response.json() - validated_code = data.get("code_icao") or data.get("code_iata") - if validated_code: - logger.info( - f"Validated airport code {airport_code} → {validated_code}" - ) - return validated_code - else: - return airport_code - else: - logger.warning( - f"API validation failed for {airport_code}, but using LLM result" - ) - return airport_code - - except Exception as e: - logger.warning( - f"API validation error for {airport_code}: {e}, using LLM result" - ) - return airport_code - + code = response.choices[0].message.content.strip().upper() + code = code.strip("\"'`.,!? \n\t") + return code if len(code) == 3 else None except Exception as e: - logger.error(f"Error resolving airport code for {city_name} with LLM: {e}") + logger.error(f"Error resolving airport code for {city_name}: {e}") return None -async def get_flights_between_airports( - origin_code: str, dest_code: str, start_date: str = None, end_date: str = None +async def get_flights( + origin_code: str, dest_code: str, travel_date: Optional[str] = None ) -> Optional[dict]: - """Get flights between two airports using FlightAware AeroAPI.""" + """Get flights between two airports using FlightAware API. + + Args: + origin_code: Origin airport IATA code + dest_code: Destination airport IATA code + travel_date: Travel date in YYYY-MM-DD format, defaults to today + + Note: FlightAware API limits searches to 2 days in the future. + """ try: + # Use provided date or default to today + if travel_date: + search_date = travel_date + else: + search_date = datetime.now().strftime("%Y-%m-%d") + + # Validate date is not too far in the future (FlightAware limit: 2 days) + search_date_obj = datetime.strptime(search_date, "%Y-%m-%d") + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + days_ahead = (search_date_obj - today).days + + if days_ahead > 2: + logger.warning( + f"Requested date {search_date} is {days_ahead} days ahead, exceeds FlightAware 2-day limit" + ) + return { + "origin_code": origin_code, + "destination_code": dest_code, + "flights": [], + "count": 0, + "error": f"FlightAware API only provides flight data up to 2 days in the future. The requested date ({search_date}) is {days_ahead} days ahead. Please search for today, tomorrow, or the day after.", + } + url = f"{AEROAPI_BASE_URL}/airports/{origin_code}/flights/to/{dest_code}" headers = {"x-apikey": AEROAPI_KEY} - - params = {} - if start_date: - params["start"] = start_date - if end_date: - params["end"] = end_date - params["connection"] = "nonstop" - params["max_pages"] = 1 + params = { + "start": f"{search_date}T00:00:00Z", + "end": f"{search_date}T23:59:59Z", + "connection": "nonstop", + "max_pages": 1, + } response = await http_client.get(url, headers=headers, params=params) if response.status_code != 200: - logger.warning( - f"FlightAware API returned status {response.status_code} for {origin_code} to {dest_code}" + logger.error( + f"FlightAware API error {response.status_code}: {response.text}" ) return None data = response.json() - flights = [] - for flight_group in data.get("flights", []): + + # Log raw API response for debugging + logger.info(f"FlightAware API returned {len(data.get('flights', []))} flights") + + for idx, flight_group in enumerate( + data.get("flights", [])[:5] + ): # Limit to 5 flights + # FlightAware API nests data in segments array segments = flight_group.get("segments", []) - if segments: - segment = segments[0] - flight_info = { - "ident": segment.get("ident"), - "ident_icao": segment.get("ident_icao"), - "ident_iata": segment.get("ident_iata"), - "operator": segment.get("operator"), - "operator_iata": segment.get("operator_iata"), - "flight_number": segment.get("flight_number"), - "origin": { - "code": segment.get("origin", {}).get("code"), - "code_iata": segment.get("origin", {}).get("code_iata"), - "name": segment.get("origin", {}).get("name"), - "city": segment.get("origin", {}).get("city"), - }, - "destination": { - "code": segment.get("destination", {}).get("code"), - "code_iata": segment.get("destination", {}).get("code_iata"), - "name": segment.get("destination", {}).get("name"), - "city": segment.get("destination", {}).get("city"), - }, - "scheduled_out": segment.get("scheduled_out"), - "estimated_out": segment.get("estimated_out"), - "actual_out": segment.get("actual_out"), - "scheduled_off": segment.get("scheduled_off"), - "estimated_off": segment.get("estimated_off"), - "actual_off": segment.get("actual_off"), - "scheduled_on": segment.get("scheduled_on"), - "estimated_on": segment.get("estimated_on"), - "actual_on": segment.get("actual_on"), - "scheduled_in": segment.get("scheduled_in"), - "estimated_in": segment.get("estimated_in"), - "actual_in": segment.get("actual_in"), - "status": segment.get("status"), - "aircraft_type": segment.get("aircraft_type"), - "departure_delay": segment.get("departure_delay"), - "arrival_delay": segment.get("arrival_delay"), - "gate_origin": segment.get("gate_origin"), - "gate_destination": segment.get("gate_destination"), - "terminal_origin": segment.get("terminal_origin"), - "terminal_destination": segment.get("terminal_destination"), - "cancelled": segment.get("cancelled"), - "diverted": segment.get("diverted"), + if not segments: + continue + + flight = segments[0] # Get first segment (direct flights only have one) + + # Extract airport codes from nested objects + flight_origin = None + flight_dest = None + + if isinstance(flight.get("origin"), dict): + flight_origin = flight["origin"].get("code_iata") + + if isinstance(flight.get("destination"), dict): + flight_dest = flight["destination"].get("code_iata") + + # Build flight object + flights.append( + { + "airline": flight.get("operator"), + "flight_number": flight.get("ident_iata") or flight.get("ident"), + "departure_time": flight.get("scheduled_out"), + "arrival_time": flight.get("scheduled_in"), + "origin": flight_origin, + "destination": flight_dest, + "aircraft_type": flight.get("aircraft_type"), + "status": flight.get("status"), + "terminal_origin": flight.get("terminal_origin"), + "gate_origin": flight.get("gate_origin"), } - flights.append(flight_info) + ) return { "origin_code": origin_code, "destination_code": dest_code, "flights": flights, - "num_flights": len(flights), + "count": len(flights), } - - except httpx.HTTPError as e: - logger.error( - f"HTTP error fetching flights from {origin_code} to {dest_code}: {e}" - ) - return None - except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSON response from FlightAware API: {e}") - return None except Exception as e: - logger.error(f"Unexpected error fetching flights: {e}") + logger.error(f"Error fetching flights: {e}") return None app = FastAPI(title="Flight Information Agent", version="1.0.0") -async def prepare_flight_messages(request_body: ChatCompletionRequest): - """Prepare messages with flight data.""" - flight_info = await extract_flight_info_from_messages(request_body.messages) - - origin = flight_info.get("origin") - destination = flight_info.get("destination") - date = flight_info.get("date") - origin_code = flight_info.get("origin_airport_code") - dest_code = flight_info.get("destination_airport_code") - - # Enhanced context extraction: Use weather queries to infer missing origin or destination - # CRITICAL: When user asks "weather in X and flight to Y", use X as origin - if not origin and destination: - # Look through conversation history for cities mentioned in weather queries - for msg in request_body.messages: - if msg.role == "user": - content = msg.content - # Extract cities from weather queries: "weather in [city]", "forecast for [city]" - weather_patterns = [ - r"(?:weather|forecast).*?(?:in|for)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - r"weather\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - ] - for pattern in weather_patterns: - matches = re.findall(pattern, content, re.IGNORECASE) - for match in matches: - city = match.strip() - # Use weather city as origin if it's different from destination - if ( - city.lower() != destination.lower() - and len(city.split()) <= 3 - ): - origin = city - logger.info( - f"Inferred origin from weather context: {origin} (destination: {destination})" - ) - flight_info["origin"] = origin - break - if origin: - break - if origin: - break - - # If destination is missing but origin is present, try to infer from conversation - if origin and not destination: - # Look through conversation history for mentioned cities - mentioned_cities = set() - for msg in request_body.messages: - if msg.role == "user": - content = msg.content - # Extract cities from weather queries: "weather in [city]", "forecast for [city]" - weather_patterns = [ - r"(?:weather|forecast).*?(?:in|for)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - r"weather\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - ] - for pattern in weather_patterns: - matches = re.findall(pattern, content, re.IGNORECASE) - for match in matches: - city = match.strip() - # Don't use same city as origin, and validate it's a real city name - if city.lower() != origin.lower() and len(city.split()) <= 3: - mentioned_cities.add(city) - - # If we found cities in context, use the first one as destination - if mentioned_cities: - destination = list(mentioned_cities)[0] - logger.info( - f"Inferred destination from conversation context: {destination}" - ) - flight_info["destination"] = destination - - if origin and not origin_code: - origin_code = await resolve_airport_code(origin) - if destination and not dest_code: - dest_code = await resolve_airport_code(destination) - - if not date: - date = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d") - - start_date = f"{date}T00:00:00Z" - end_date = f"{date}T23:59:59Z" - - flight_data = None - if origin_code and dest_code: - flight_data = await get_flights_between_airports( - origin_code, dest_code, start_date, end_date - ) - else: - logger.warning( - f"Cannot fetch flights: origin_code={origin_code}, dest_code={dest_code}" - ) - - # Build context message based on what we have - if flight_data and flight_data.get("flights"): - flight_context = f""" -Flight search results for {origin or origin_code} to {destination or dest_code} on {date}: - -{json.dumps(flight_data, indent=2)} - -Use this data to answer the user's flight query. Present the flights clearly with all relevant details. -""" - elif origin_code and not dest_code: - # We have origin but no destination - this is a follow-up question - flight_context = f""" -The user is asking about flights from {origin or origin_code}, but no destination was specified. - -From the conversation context, it appears the user may be asking about flights from {origin or origin_code} to a previously mentioned location, or they may need to specify a destination. - -Check the conversation history to see if a destination was mentioned earlier. If so, you can mention that you'd be happy to search for flights from {origin or origin_code} to that destination. If not, politely ask the user to specify both origin and destination cities. - -Example response: "I can help you find flights from {origin or origin_code}! Could you please tell me which city you'd like to fly to? For example, 'flights from {origin or origin_code} to Seattle' or 'flights from {origin or origin_code} to Istanbul'." -""" - elif dest_code and not origin_code: - # We have destination but no origin - flight_context = f""" -The user is asking about flights to {destination or dest_code}, but no origin was specified. - -From the conversation context, it appears the user may be asking about flights to {destination or dest_code} from a previously mentioned location, or they may need to specify an origin. - -Check the conversation history to see if an origin was mentioned earlier. If so, you can mention that you'd be happy to search for flights from that origin to {destination or dest_code}. If not, politely ask the user to specify both origin and destination cities. -""" - else: - # Neither origin nor destination - flight_context = f""" -Flight search attempted but both origin and destination are missing. - -The user's query was incomplete. Check the conversation history to see if origin or destination cities were mentioned earlier. If so, use that context to help the user. If not, politely ask the user to specify both origin and destination cities for their flight search. - -Example: "I'd be happy to help you find flights! Could you please tell me both the departure city and destination city? For example, 'flights from Seattle to Istanbul' or 'flights from Istanbul to Seattle'." -""" - - response_messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "assistant", "content": flight_context}, - ] - - # Add conversation history - for msg in request_body.messages: - response_messages.append({"role": msg.role, "content": msg.content}) - - return response_messages - - @app.post("/v1/chat/completions") -async def chat_completion_http(request: Request, request_body: ChatCompletionRequest): +async def handle_request(request: Request): """HTTP endpoint for chat completions with streaming support.""" - logger.info(f"Received flight request with {len(request_body.messages)} messages") - - traceparent_header = request.headers.get("traceparent") - - if traceparent_header: - logger.info(f"Received traceparent header: {traceparent_header}") + request_body = await request.json() + messages = request_body.get("messages", []) return StreamingResponse( - stream_chat_completions(request_body, traceparent_header), + invoke_flight_agent(request, request_body), media_type="text/plain", - headers={ - "content-type": "text/event-stream", - }, + headers={"content-type": "text/event-stream"}, ) -async def stream_chat_completions( - request_body: ChatCompletionRequest, traceparent_header: str = None -): +async def invoke_flight_agent(request: Request, request_body: dict): """Generate streaming chat completions.""" + messages = request_body.get("messages", []) - logger.info("Preparing flight messages for LLM") - # Prepare messages with flight data - response_messages = await prepare_flight_messages(request_body) + # Step 1: Extract origin, destination, and date + route = await extract_flight_route(messages, request) + origin = route.get("origin") + destination = route.get("destination") + travel_date = route.get("date") + # Step 2: Short circuit if missing origin or destination + if not origin or not destination: + missing = [] + if not origin: + missing.append("origin city") + if not destination: + missing.append("destination city") + + error_message = f"I need both origin and destination cities to search for flights. Please provide the {' and '.join(missing)}. For example: 'Flights from Seattle to Atlanta'" + + error_chunk = { + "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": request_body.get("model", FLIGHT_MODEL), + "choices": [ + { + "index": 0, + "delta": {"content": error_message}, + "finish_reason": "stop", + } + ], + } + yield f"data: {json.dumps(error_chunk)}\n\n" + yield "data: [DONE]\n\n" + return + + # Step 3: Resolve airport codes + origin_code = await resolve_airport_code(origin, request) + dest_code = await resolve_airport_code(destination, request) + + if not origin_code or not dest_code: + error_chunk = { + "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": request_body.get("model", FLIGHT_MODEL), + "choices": [ + { + "index": 0, + "delta": { + "content": f"I couldn't find airport codes for {origin if not origin_code else destination}. Please check the city name." + }, + "finish_reason": "stop", + } + ], + } + yield f"data: {json.dumps(error_chunk)}\n\n" + yield "data: [DONE]\n\n" + return + + # Step 4: Get live flight data + flight_data = await get_flights(origin_code, dest_code, travel_date) + + # Determine date display for messages + date_display = travel_date if travel_date else "today" + + if not flight_data or not flight_data.get("flights"): + # Check if there's a specific error message (e.g., date too far in future) + error_detail = flight_data.get("error") if flight_data else None + if error_detail: + no_flights_message = error_detail + else: + no_flights_message = f"No direct flights found from {origin} ({origin_code}) to {destination} ({dest_code}) for {date_display}." + + error_chunk = { + "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": request_body.get("model", FLIGHT_MODEL), + "choices": [ + { + "index": 0, + "delta": {"content": no_flights_message}, + "finish_reason": "stop", + } + ], + } + yield f"data: {json.dumps(error_chunk)}\n\n" + yield "data: [DONE]\n\n" + return + + # Step 5: Prepare context for LLM - append flight data to last user message + flight_context = f""" + +Flight search results from {origin} ({origin_code}) to {destination} ({dest_code}): + +Flight data in JSON format: +{json.dumps(flight_data, indent=2)} + +Present these {len(flight_data.get('flights', []))} flight(s) to the user in a clear, readable format.""" + + # Build message history with flight data appended to the last user message + response_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + + for i, msg in enumerate(messages): + # Append flight data to the last user message + if i == len(messages) - 1 and msg.get("role") == "user": + response_messages.append( + {"role": "user", "content": msg.get("content") + flight_context} + ) + else: + response_messages.append( + {"role": msg.get("role"), "content": msg.get("content")} + ) + + # Log what we're sending to the LLM for debugging + logger.info(f"Sending messages to LLM: {json.dumps(response_messages, indent=2)}") + + # Step 6: Stream response try: - logger.info( - f"Calling archgw at {LLM_GATEWAY_ENDPOINT} to generate flight response" - ) - - # Prepare extra headers + ctx = extract(request.headers) extra_headers = {"x-envoy-max-retries": "3"} - if traceparent_header: - extra_headers["traceparent"] = traceparent_header + inject(extra_headers, context=ctx) - response_stream = await archgw_client.chat.completions.create( + stream = await openai_client_via_plano.chat.completions.create( model=FLIGHT_MODEL, messages=response_messages, - temperature=request_body.temperature or 0.7, - max_tokens=request_body.max_tokens or 1000, + temperature=request_body.get("temperature", 0.7), + max_tokens=request_body.get("max_tokens", 1000), stream=True, extra_headers=extra_headers, ) - completion_id = f"chatcmpl-{uuid.uuid4().hex[:8]}" - created_time = int(time.time()) - collected_content = [] + async for chunk in stream: + if chunk.choices: + yield f"data: {chunk.model_dump_json()}\n\n" - async for chunk in response_stream: - if chunk.choices and chunk.choices[0].delta.content: - content = chunk.choices[0].delta.content - collected_content.append(content) - - stream_chunk = ChatCompletionStreamResponse( - id=completion_id, - created=created_time, - model=request_body.model, - choices=[ - { - "index": 0, - "delta": {"content": content}, - "finish_reason": None, - } - ], - ) - - yield f"data: {stream_chunk.model_dump_json()}\n\n" - - full_response = "".join(collected_content) - updated_history = [{"role": "assistant", "content": full_response}] - - logger.info(f"Full flight agent response: {full_response}") - - final_chunk = ChatCompletionStreamResponse( - id=completion_id, - created=created_time, - model=request_body.model, - choices=[ - { - "index": 0, - "delta": {}, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": json.dumps(updated_history), - }, - } - ], - ) - - yield f"data: {final_chunk.model_dump_json()}\n\n" yield "data: [DONE]\n\n" except Exception as e: logger.error(f"Error generating flight response: {e}") - - error_chunk = ChatCompletionStreamResponse( - id=f"chatcmpl-{uuid.uuid4().hex[:8]}", - created=int(time.time()), - model=request_body.model, - choices=[ + error_chunk = { + "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": request_body.get("model", FLIGHT_MODEL), + "choices": [ { "index": 0, "delta": { @@ -868,9 +431,8 @@ async def stream_chat_completions( "finish_reason": "stop", } ], - ) - - yield f"data: {error_chunk.model_dump_json()}\n\n" + } + yield f"data: {json.dumps(error_chunk)}\n\n" yield "data: [DONE]\n\n" @@ -880,10 +442,6 @@ async def health_check(): return {"status": "healthy", "agent": "flight_information"} -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=10520) - - def start_server(host: str = "localhost", port: int = 10520): """Start the REST server.""" uvicorn.run( @@ -911,3 +469,7 @@ def start_server(host: str = "localhost", port: int = 10520): }, }, ) + + +if __name__ == "__main__": + start_server(host="0.0.0.0", port=10520) diff --git a/demos/use_cases/travel_agents/src/travel_agents/weather_agent.py b/demos/use_cases/travel_agents/src/travel_agents/weather_agent.py index c0eb1f7d..58c732c4 100644 --- a/demos/use_cases/travel_agents/src/travel_agents/weather_agent.py +++ b/demos/use_cases/travel_agents/src/travel_agents/weather_agent.py @@ -12,12 +12,7 @@ from datetime import datetime, timedelta import httpx from typing import Optional from urllib.parse import quote - -from .api import ( - ChatCompletionRequest, - ChatCompletionResponse, - ChatCompletionStreamResponse, -) +from opentelemetry.propagate import extract, inject # Set up logging logging.basicConfig( @@ -26,458 +21,16 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -# Configuration for archgw LLM gateway -LLM_GATEWAY_ENDPOINT = os.getenv("LLM_GATEWAY_ENDPOINT", "http://localhost:12000/v1") + +# Configuration for plano LLM gateway +LLM_GATEWAY_ENDPOINT = os.getenv( + "LLM_GATEWAY_ENDPOINT", "http://host.docker.internal:12001/v1" +) WEATHER_MODEL = "openai/gpt-4o" LOCATION_MODEL = "openai/gpt-4o-mini" -# HTTP client for API calls -http_client = httpx.AsyncClient(timeout=10.0) - -# System prompt for weather agent -SYSTEM_PROMPT = """You are a professional travel planner assistant. Your role is to provide accurate, clear, and helpful information about weather and flights based on the structured data provided to you. - -CRITICAL INSTRUCTIONS: - -1. DATA STRUCTURE: - - WEATHER DATA: - - You will receive weather data as JSON in a system message - - The data contains a "location" field (string) and a "forecast" array - - Each forecast entry has: date, day_name, temperature_c, temperature_f, temperature_max_c, temperature_min_c, condition, sunrise, sunset - - Some fields may be null/None - handle these gracefully - - FLIGHT DATA: - - You will receive flight information in a system message - - Flight data includes: airline, flight number, departure time, arrival time, origin airport, destination airport, aircraft type, status, gate, terminal - - Information may include both scheduled and estimated times - - Some fields may be unavailable - handle these gracefully - -2. WEATHER HANDLING: - - For single-day queries: Use temperature_c/temperature_f (current/primary temperature) - - For multi-day forecasts: Use temperature_max_c and temperature_min_c when available - - Always provide temperatures in both Celsius and Fahrenheit when available - - If temperature is null, say "temperature data unavailable" rather than making up numbers - - Use exact condition descriptions provided (e.g., "Clear sky", "Rainy", "Partly Cloudy") - - Add helpful context when appropriate (e.g., "perfect for outdoor activities" for clear skies) - -3. FLIGHT HANDLING: - - Present flight information clearly with airline name and flight number - - Include departure and arrival times with time zones when provided - - Mention origin and destination airports with their codes - - Include gate and terminal information when available - - Note aircraft type if relevant to the query - - Highlight any status updates (delays, early arrivals, etc.) - - For multiple flights, list them in chronological order by departure time - - If specific details are missing, acknowledge this rather than inventing information - -4. MULTI-PART QUERIES: - - Users may ask about both weather and flights in one message - - Answer ALL parts of the query that you have data for - - Organize your response logically - typically weather first, then flights, or vice versa based on the query - - Provide complete information for each topic without mentioning other agents - - If you receive data for only one topic but the user asked about multiple, answer what you can with the provided data - -5. ERROR HANDLING: - - If weather forecast contains an "error" field, acknowledge the issue politely - - If temperature or condition is null/None, mention that specific data is unavailable - - If flight details are incomplete, state which information is unavailable - - Never invent or guess weather or flight data - only use what's provided - - If location couldn't be determined, acknowledge this but still provide available data - -6. RESPONSE FORMAT: - - For Weather: - - Single-day queries: Provide current conditions, temperature, and condition - - Multi-day forecasts: List each day with date, day name, high/low temps, and condition - - Include sunrise/sunset times when available and relevant - - For Flights: - - List flights with clear numbering or bullet points - - Include key details: airline, flight number, departure/arrival times, airports - - Add gate, terminal, and status information when available - - For multiple flights, organize chronologically - - General: - - Use natural, conversational language - - Be concise but complete - - Format dates and times clearly - - Use bullet points or numbered lists for clarity - -7. LOCATION HANDLING: - - Always mention location names from the data - - For flights, clearly state origin and destination cities/airports - - If locations differ from what the user asked, acknowledge this politely - -8. RESPONSE STYLE: - - Be friendly and professional - - Use natural language, not technical jargon - - Provide information in a logical, easy-to-read format - - When answering multi-part queries, create a cohesive response that addresses all aspects - -Remember: Only use the data provided. Never fabricate weather or flight information. If data is missing, clearly state what's unavailable. Answer all parts of the user's query that you have data for.""" - - -async def geocode_city(city: str) -> Optional[dict]: - """Geocode a city name to latitude and longitude using Open-Meteo API.""" - try: - url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(city)}&count=1&language=en&format=json" - response = await http_client.get(url) - - if response.status_code != 200: - logger.warning( - f"Geocoding API returned status {response.status_code} for city: {city}" - ) - return None - - data = response.json() - - if not data.get("results") or len(data["results"]) == 0: - logger.warning(f"No geocoding results found for city: {city}") - return None - - result = data["results"][0] - return { - "latitude": result["latitude"], - "longitude": result["longitude"], - "name": result.get("name", city), - } - except Exception as e: - logger.error(f"Error geocoding city {city}: {e}") - return None - - -async def get_live_weather( - latitude: float, longitude: float, days: int = 1 -) -> Optional[dict]: - """Get live weather data from Open-Meteo API.""" - try: - forecast_days = min(days, 16) - - url = ( - f"https://api.open-meteo.com/v1/forecast?" - f"latitude={latitude}&" - f"longitude={longitude}&" - f"current=temperature_2m&" - f"hourly=temperature_2m&" - f"daily=sunrise,sunset,temperature_2m_max,temperature_2m_min,weather_code&" - f"forecast_days={forecast_days}&" - f"timezone=auto" - ) - - response = await http_client.get(url) - - if response.status_code != 200: - logger.warning(f"Weather API returned status {response.status_code}") - return None - - return response.json() - except Exception as e: - logger.error(f"Error fetching weather data: {e}") - return None - - -def weather_code_to_condition(weather_code: int) -> str: - """Convert WMO weather code to human-readable condition.""" - # WMO Weather interpretation codes (WW) - if weather_code == 0: - return "Clear sky" - elif weather_code in [1, 2, 3]: - return "Partly Cloudy" - elif weather_code in [45, 48]: - return "Foggy" - elif weather_code in [51, 53, 55, 56, 57]: - return "Drizzle" - elif weather_code in [61, 63, 65, 66, 67]: - return "Rainy" - elif weather_code in [71, 73, 75, 77]: - return "Snowy" - elif weather_code in [80, 81, 82]: - return "Rainy" - elif weather_code in [85, 86]: - return "Snowy" - elif weather_code in [95, 96, 99]: - return "Stormy" - else: - return "Cloudy" - - -async def get_weather_data(location: str, days: int = 1): - """Get live weather data for a location using Open-Meteo API.""" - geocode_result = await geocode_city(location) - - if not geocode_result: - logger.warning(f"Could not geocode location: {location}, using fallback") - geocode_result = await geocode_city("New York") - if not geocode_result: - return { - "location": location, - "forecast": [ - { - "date": datetime.now().strftime("%Y-%m-%d"), - "day_name": datetime.now().strftime("%A"), - "temperature_c": None, - "temperature_f": None, - "condition": "Unknown", - "error": "Could not retrieve weather data", - } - ], - } - - location_name = geocode_result["name"] - latitude = geocode_result["latitude"] - longitude = geocode_result["longitude"] - - weather_data = await get_live_weather(latitude, longitude, days) - - if not weather_data: - logger.warning("Could not fetch weather data for requested location") - return { - "location": location_name, - "forecast": [ - { - "date": datetime.now().strftime("%Y-%m-%d"), - "day_name": datetime.now().strftime("%A"), - "temperature_c": None, - "temperature_f": None, - "condition": "Unknown", - "error": "Could not retrieve weather data", - } - ], - } - - current_temp = weather_data.get("current", {}).get("temperature_2m") - daily_data = weather_data.get("daily", {}) - - forecast = [] - for i in range(min(days, len(daily_data.get("time", [])))): - date_str = daily_data["time"][i] - date_obj = datetime.fromisoformat(date_str.replace("Z", "+00:00")) - - temp_max = ( - daily_data.get("temperature_2m_max", [None])[i] - if i < len(daily_data.get("temperature_2m_max", [])) - else None - ) - temp_min = ( - daily_data.get("temperature_2m_min", [None])[i] - if i < len(daily_data.get("temperature_2m_min", [])) - else None - ) - weather_code = ( - daily_data.get("weather_code", [0])[i] - if i < len(daily_data.get("weather_code", [])) - else 0 - ) - sunrise = ( - daily_data.get("sunrise", [None])[i] - if i < len(daily_data.get("sunrise", [])) - else None - ) - sunset = ( - daily_data.get("sunset", [None])[i] - if i < len(daily_data.get("sunset", [])) - else None - ) - - temp_c = ( - temp_max if temp_max is not None else (current_temp if i == 0 else temp_min) - ) - - day_info = { - "date": date_str.split("T")[0], - "day_name": date_obj.strftime("%A"), - "temperature_c": round(temp_c, 1) if temp_c is not None else None, - "temperature_f": ( - round(temp_c * 9 / 5 + 32, 1) if temp_c is not None else None - ), - "temperature_max_c": round(temp_max, 1) if temp_max is not None else None, - "temperature_min_c": round(temp_min, 1) if temp_min is not None else None, - "condition": weather_code_to_condition(weather_code), - "sunrise": sunrise.split("T")[1] if sunrise else None, - "sunset": sunset.split("T")[1] if sunset else None, - } - forecast.append(day_info) - - return {"location": location_name, "forecast": forecast} - - -LOCATION_EXTRACTION_PROMPT = """You are a location extraction assistant for WEATHER queries. Your ONLY job is to extract the geographic location (city, state, country, etc.) that the user is asking about for WEATHER information. - -CRITICAL RULES: -1. Extract ONLY the location name associated with WEATHER questions - nothing else -2. Return just the location name in plain text (e.g., "London", "New York", "Paris, France") -3. **MULTI-PART QUERY HANDLING**: If the user mentions multiple locations in a multi-part query, extract ONLY the location mentioned in the WEATHER part - - Look for patterns like "weather in [location]", "forecast for [location]", "weather [location]" - - The location that appears WITH "weather" keywords is the weather location - - Example: "What's the weather in Seattle, and what is one flight that goes direct to Atlanta?" → Extract "Seattle" (appears with "weather in") - - Example: "What is the weather in Atlanta and what flight goes from Detroit to Atlanta?" → Extract "Atlanta" (appears with "weather in", even though Atlanta also appears in flight part) - - Example: "Weather in London and flights to Paris" → Extract "London" (weather location) - - Example: "What flight goes from Detroit to Atlanta and what's the weather in Atlanta?" → Extract "Atlanta" (appears with "weather in") -4. Look for patterns like "weather in [location]", "forecast for [location]", "weather [location]", "temperature in [location]" -5. Ignore error messages, HTML tags, and assistant responses -6. If no clear weather-related location is found, return exactly: "NOT_FOUND" -7. Clean the location name - remove words like "about", "for", "in", "the weather in", etc. -8. Return the location in a format suitable for geocoding (city name, or "City, State", or "City, Country") - -Examples: -- "What's the weather in London?" → "London" -- "Tell me about the weather for New York" → "New York" -- "Weather forecast for Paris, France" → "Paris, France" -- "What's the weather in Seattle, and what is one flight that goes direct to Atlanta?" → "Seattle" (appears with "weather in") -- "What is the weather in Atlanta and what flight goes from Detroit to Atlanta?" → "Atlanta" (appears with "weather in") -- "Weather in Istanbul and flights to Seattle" → "Istanbul" (weather location) -- "What flight goes from Detroit to Atlanta and what's the weather in Atlanta?" → "Atlanta" (appears with "weather in") -- "I'm going to Seattle" → "Seattle" (if context suggests weather query) -- "What's happening?" → "NOT_FOUND" - -Now extract the WEATHER location from this message:""" - - -async def extract_location_from_messages(messages): - """Extract location from user messages using LLM, focusing on weather-related locations.""" - user_messages = [msg for msg in messages if msg.role == "user"] - - if not user_messages: - logger.warning("No user messages found, using default: New York") - return "New York" - - # CRITICAL: Always preserve the FIRST user message (original query) for multi-agent scenarios - # When Plano processes multiple agents, it may add assistant responses that get filtered out, - # but we need to always use the original user query - original_user_message = user_messages[0].content.strip() if user_messages else None - - # Try to find a valid recent user message first (for follow-up queries) - user_content = None - for msg in reversed(user_messages): - content = msg.content.strip() - content_lower = content.lower() - - # Skip messages that are clearly JSON-encoded assistant responses or errors - # But be less aggressive - only skip if it's clearly not a user query - if content.startswith("[{") or content.startswith("[{"): - # Likely JSON-encoded assistant response - continue - if any( - pattern in content_lower - for pattern in [ - '"role": "assistant"', - '"role":"assistant"', - "error:", - ] - ): - continue - # Don't skip messages that just happen to contain these words naturally - user_content = content - break - - # Fallback to original user message if no valid recent message found - if not user_content and original_user_message: - # Check if original message is valid (not JSON-encoded) - if not ( - original_user_message.startswith("[{") - or original_user_message.startswith("[{") - ): - user_content = original_user_message - logger.info(f"Using original user message: {user_content[:200]}") - - if not user_content: - logger.warning("No valid user message found, using default: New York") - return "New York" - - try: - logger.info( - f"Extracting weather location from user message: {user_content[:200]}" - ) - - # Build context from conversation history - conversation_context = [] - for msg in messages: - content = msg.content.strip() - content_lower = content.lower() - if any( - pattern in content_lower - for pattern in ["<", ">", "error:", "i apologize", "i'm having trouble"] - ): - continue - conversation_context.append({"role": msg.role, "content": content}) - - # Use last 5 messages for context - context_messages = ( - conversation_context[-5:] - if len(conversation_context) > 5 - else conversation_context - ) - - llm_messages = [{"role": "system", "content": LOCATION_EXTRACTION_PROMPT}] - for msg in context_messages: - llm_messages.append({"role": msg["role"], "content": msg["content"]}) - - response = await archgw_client.chat.completions.create( - model=LOCATION_MODEL, - messages=llm_messages, - temperature=0.1, - max_tokens=50, - ) - - location = response.choices[0].message.content.strip() - location = location.strip("\"'`.,!?") - - if not location or location.upper() == "NOT_FOUND": - # Fallback: Try regex extraction for weather patterns - weather_patterns = [ - r"weather\s+(?:in|for)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - r"forecast\s+(?:in|for)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - r"weather\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - ] - for msg in reversed(context_messages): - if msg["role"] == "user": - content = msg["content"] - for pattern in weather_patterns: - match = re.search(pattern, content, re.IGNORECASE) - if match: - potential_location = match.group(1).strip() - logger.info( - f"Fallback regex extracted weather location: {potential_location}" - ) - return potential_location - - logger.warning( - f"LLM could not extract location from message, using default: New York" - ) - return "New York" - - logger.info(f"LLM extracted weather location: {location}") - return location - - except Exception as e: - logger.error(f"Error extracting location with LLM: {e}, trying fallback regex") - # Fallback regex extraction - try: - for msg in reversed(messages): - if msg.role == "user": - content = msg.content - weather_patterns = [ - r"weather\s+(?:in|for)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - r"forecast\s+(?:in|for)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)", - ] - for pattern in weather_patterns: - match = re.search(pattern, content, re.IGNORECASE) - if match: - potential_location = match.group(1).strip() - logger.info( - f"Fallback regex extracted weather location: {potential_location}" - ) - return potential_location - except: - pass - - logger.error("All extraction methods failed, using default: New York") - return "New York" - - -# Initialize OpenAI client for archgw -archgw_client = AsyncOpenAI( +# Initialize OpenAI client for plano +openai_client_via_plano = AsyncOpenAI( base_url=LLM_GATEWAY_ENDPOINT, api_key="EMPTY", ) @@ -485,60 +38,242 @@ archgw_client = AsyncOpenAI( # FastAPI app for REST server app = FastAPI(title="Weather Forecast Agent", version="1.0.0") +# HTTP client for API calls +http_client = httpx.AsyncClient(timeout=10.0) -async def prepare_weather_messages(request_body: ChatCompletionRequest): - """Prepare messages with weather data.""" - # Extract location from conversation using LLM - location = await extract_location_from_messages(request_body.messages) - # Determine if they want forecast (multi-day) - last_user_msg = "" - for msg in reversed(request_body.messages): - if msg.role == "user": - last_user_msg = msg.content.lower() - break +# Utility functions +def celsius_to_fahrenheit(temp_c: Optional[float]) -> Optional[float]: + """Convert Celsius to Fahrenheit.""" + return round(temp_c * 9 / 5 + 32, 1) if temp_c is not None else None - days = 5 if "forecast" in last_user_msg or "week" in last_user_msg else 1 - # Get live weather data - weather_data = await get_weather_data(location, days) +def get_user_messages(messages: list) -> list: + """Extract user messages from message list.""" + return [msg for msg in messages if msg.get("role") == "user"] - # Create system message with weather data - weather_context = f""" -Current weather data for {weather_data['location']}: -{json.dumps(weather_data, indent=2)} +def get_last_user_content(messages: list) -> str: + """Get the content of the most recent user message.""" + for msg in reversed(messages): + if msg.get("role") == "user": + return msg.get("content", "").lower() + return "" -Use this data to answer the user's weather query. -""" - response_messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "assistant", "content": weather_context}, - ] +async def get_weather_data(request: Request, messages: list, days: int = 1): + """Extract location from user's conversation and fetch weather data from Open-Meteo API. - # Add conversation history - for msg in request_body.messages: - response_messages.append({"role": msg.role, "content": msg.content}) + This function does two things: + 1. Uses an LLM to extract the location from the user's message + 2. Fetches weather data for that location from Open-Meteo - return response_messages + Currently returns only current day weather. Want to add multi-day forecasts? + Check out the TODO comments below - it's a great learning exercise! 🚀 + """ + # Step 1: Extract location from conversation using LLM + instructions = """Extract the location for WEATHER queries. Return just the city name. + + Rules: + 1. For multi-part queries, extract ONLY the location mentioned with weather keywords ("weather in [location]") + 2. If user says "there" or "that city", it typically refers to the DESTINATION city in travel contexts (not the origin) + 3. For flight queries with weather, "there" means the destination city where they're traveling TO + 4. Return plain text (e.g., "London", "New York", "Paris, France") + 5. If no weather location found, return "NOT_FOUND" + + Examples: + - "What's the weather in London?" → "London" + - "Flights from Seattle to Atlanta, and show me the weather there" → "Atlanta" + - "Can you get me flights from Seattle to Atlanta tomorrow, and also please show me the weather there" → "Atlanta" + - "What's the weather in Seattle, and what is one flight that goes direct to Atlanta?" → "Seattle" + - User asked about flights to Atlanta, then "what's the weather like there?" → "Atlanta" + - "I'm going to Seattle" → "Seattle" + - "What's happening?" → "NOT_FOUND" + + Extract location:""" + + try: + user_messages = [ + msg.get("content") for msg in messages if msg.get("role") == "user" + ] + + if not user_messages: + location = "New York" + else: + ctx = extract(request.headers) + extra_headers = {} + inject(extra_headers, context=ctx) + + # For location extraction, pass full conversation for context (e.g., "there" = previous destination) + response = await openai_client_via_plano.chat.completions.create( + model=LOCATION_MODEL, + messages=[ + {"role": "system", "content": instructions}, + *[ + {"role": msg.get("role"), "content": msg.get("content")} + for msg in messages + ], + ], + temperature=0.1, + max_tokens=50, + extra_headers=extra_headers if extra_headers else None, + ) + + location = response.choices[0].message.content.strip().strip("\"'`.,!?") + logger.info(f"Location extraction result: '{location}'") + + if not location or location.upper() == "NOT_FOUND": + location = "New York" + logger.info(f"Location not found, defaulting to: {location}") + + except Exception as e: + logger.error(f"Error extracting location: {e}") + location = "New York" + + logger.info(f"Fetching weather for location: '{location}' (days: {days})") + + # Step 2: Fetch weather data for the extracted location + try: + # Geocode city to get coordinates + geocode_url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(location)}&count=1&language=en&format=json" + geocode_response = await http_client.get(geocode_url) + + if geocode_response.status_code != 200 or not geocode_response.json().get( + "results" + ): + logger.warning(f"Could not geocode {location}, using New York") + location = "New York" + geocode_url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(location)}&count=1&language=en&format=json" + geocode_response = await http_client.get(geocode_url) + + geocode_data = geocode_response.json() + if not geocode_data.get("results"): + return { + "location": location, + "weather": { + "date": datetime.now().strftime("%Y-%m-%d"), + "day_name": datetime.now().strftime("%A"), + "temperature_c": None, + "temperature_f": None, + "weather_code": None, + "error": "Could not retrieve weather data", + }, + } + + result = geocode_data["results"][0] + location_name = result.get("name", location) + latitude = result["latitude"] + longitude = result["longitude"] + + logger.info( + f"Geocoded '{location}' to {location_name} ({latitude}, {longitude})" + ) + + # Get weather forecast + weather_url = ( + f"https://api.open-meteo.com/v1/forecast?" + f"latitude={latitude}&longitude={longitude}&" + f"current=temperature_2m&" + f"daily=sunrise,sunset,temperature_2m_max,temperature_2m_min,weather_code&" + f"forecast_days={days}&timezone=auto" + ) + + weather_response = await http_client.get(weather_url) + if weather_response.status_code != 200: + return { + "location": location_name, + "weather": { + "date": datetime.now().strftime("%Y-%m-%d"), + "day_name": datetime.now().strftime("%A"), + "temperature_c": None, + "temperature_f": None, + "weather_code": None, + "error": "Could not retrieve weather data", + }, + } + + weather_data = weather_response.json() + current_temp = weather_data.get("current", {}).get("temperature_2m") + daily = weather_data.get("daily", {}) + + # Build forecast for requested number of days + forecast = [] + for i in range(days): + date_str = daily["time"][i] + date_obj = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + + temp_max = ( + daily.get("temperature_2m_max", [])[i] + if daily.get("temperature_2m_max") + else None + ) + temp_min = ( + daily.get("temperature_2m_min", [])[i] + if daily.get("temperature_2m_min") + else None + ) + weather_code = ( + daily.get("weather_code", [0])[i] if daily.get("weather_code") else 0 + ) + sunrise = daily.get("sunrise", [])[i] if daily.get("sunrise") else None + sunset = daily.get("sunset", [])[i] if daily.get("sunset") else None + + # Use current temp for today, otherwise use max temp + temp_c = ( + temp_max + if temp_max is not None + else (current_temp if i == 0 and current_temp else temp_min) + ) + + forecast.append( + { + "date": date_str.split("T")[0], + "day_name": date_obj.strftime("%A"), + "temperature_c": round(temp_c, 1) if temp_c is not None else None, + "temperature_f": celsius_to_fahrenheit(temp_c), + "temperature_max_c": round(temp_max, 1) + if temp_max is not None + else None, + "temperature_min_c": round(temp_min, 1) + if temp_min is not None + else None, + "weather_code": weather_code, + "sunrise": sunrise.split("T")[1] if sunrise else None, + "sunset": sunset.split("T")[1] if sunset else None, + } + ) + + return {"location": location_name, "forecast": forecast} + + except Exception as e: + logger.error(f"Error getting weather data: {e}") + return { + "location": location, + "weather": { + "date": datetime.now().strftime("%Y-%m-%d"), + "day_name": datetime.now().strftime("%A"), + "temperature_c": None, + "temperature_f": None, + "weather_code": None, + "error": "Could not retrieve weather data", + }, + } @app.post("/v1/chat/completions") -async def chat_completion_http(request: Request, request_body: ChatCompletionRequest): +async def handle_request(request: Request): """HTTP endpoint for chat completions with streaming support.""" - logger.info(f"Received weather request with {len(request_body.messages)} messages") + + request_body = await request.json() + messages = request_body.get("messages", []) logger.info( - f"messages detail json dumps: {json.dumps([msg.model_dump() for msg in request_body.messages], indent=2)}" + "messages detail json dumps: %s", + json.dumps(messages, indent=2), ) traceparent_header = request.headers.get("traceparent") - - if traceparent_header: - logger.info(f"Received traceparent header: {traceparent_header}") - return StreamingResponse( - stream_chat_completions(request_body, traceparent_header), + invoke_weather_agent(request, request_body, traceparent_header), media_type="text/plain", headers={ "content-type": "text/event-stream", @@ -546,85 +281,100 @@ async def chat_completion_http(request: Request, request_body: ChatCompletionReq ) -async def stream_chat_completions( - request_body: ChatCompletionRequest, traceparent_header: str = None +async def invoke_weather_agent( + request: Request, request_body: dict, traceparent_header: str = None ): """Generate streaming chat completions.""" - response_messages = await prepare_weather_messages(request_body) + messages = request_body.get("messages", []) + + # Detect if user wants multi-day forecast + last_user_msg = get_last_user_content(messages) + days = 1 + + if "forecast" in last_user_msg or "week" in last_user_msg: + days = 7 + elif "tomorrow" in last_user_msg: + days = 2 + + # Extract specific number of days if mentioned (e.g., "5 day forecast") + import re + + day_match = re.search(r"(\d+)\s*day", last_user_msg) + if day_match: + requested_days = int(day_match.group(1)) + days = min(requested_days, 16) # API supports max 16 days + + # Get live weather data (location extraction happens inside this function) + weather_data = await get_weather_data(request, messages, days) + + # Create weather context to append to user message + forecast_type = "forecast" if days > 1 else "current weather" + weather_context = f""" + +Weather data for {weather_data['location']} ({forecast_type}): +{json.dumps(weather_data, indent=2)}""" + + # System prompt for weather agent + instructions = """You are a weather assistant in a multi-agent system. You will receive weather data in JSON format with these fields: + + - "location": City name + - "forecast": Array of weather objects, each with date, day_name, temperature_c, temperature_f, temperature_max_c, temperature_min_c, weather_code, sunrise, sunset + - weather_code: WMO code (0=clear, 1-3=partly cloudy, 45-48=fog, 51-67=rain, 71-86=snow, 95-99=thunderstorm) + + Your task: + 1. Present the weather/forecast clearly for the location + 2. For single day: show current conditions + 3. For multi-day: show each day with date and conditions + 4. Include temperature in both Celsius and Fahrenheit + 5. Describe conditions naturally based on weather_code + 6. Use conversational language + + Important: If the conversation includes information from other agents (like flight details), acknowledge and build upon that context naturally. Your primary focus is weather, but maintain awareness of the full conversation. + + Remember: Only use the provided data. If fields are null, mention data is unavailable.""" + + # Build message history with weather data appended to the last user message + response_messages = [{"role": "system", "content": instructions}] + + for i, msg in enumerate(messages): + # Append weather data to the last user message + if i == len(messages) - 1 and msg.get("role") == "user": + response_messages.append( + {"role": "user", "content": msg.get("content") + weather_context} + ) + else: + response_messages.append( + {"role": msg.get("role"), "content": msg.get("content")} + ) try: - logger.info( - f"Calling archgw at {LLM_GATEWAY_ENDPOINT} to generate weather response" - ) - + ctx = extract(request.headers) extra_headers = {"x-envoy-max-retries": "3"} - if traceparent_header: - extra_headers["traceparent"] = traceparent_header + inject(extra_headers, context=ctx) - response_stream = await archgw_client.chat.completions.create( + stream = await openai_client_via_plano.chat.completions.create( model=WEATHER_MODEL, messages=response_messages, - temperature=request_body.temperature or 0.7, - max_tokens=request_body.max_tokens or 1000, + temperature=request_body.get("temperature", 0.7), + max_tokens=request_body.get("max_tokens", 1000), stream=True, extra_headers=extra_headers, ) - completion_id = f"chatcmpl-{uuid.uuid4().hex[:8]}" - created_time = int(time.time()) - collected_content = [] + async for chunk in stream: + if chunk.choices: + yield f"data: {chunk.model_dump_json()}\n\n" - async for chunk in response_stream: - if chunk.choices and chunk.choices[0].delta.content: - content = chunk.choices[0].delta.content - collected_content.append(content) - - stream_chunk = ChatCompletionStreamResponse( - id=completion_id, - created=created_time, - model=request_body.model, - choices=[ - { - "index": 0, - "delta": {"content": content}, - "finish_reason": None, - } - ], - ) - - yield f"data: {stream_chunk.model_dump_json()}\n\n" - - full_response = "".join(collected_content) - updated_history = [{"role": "assistant", "content": full_response}] - - final_chunk = ChatCompletionStreamResponse( - id=completion_id, - created=created_time, - model=request_body.model, - choices=[ - { - "index": 0, - "delta": {}, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": json.dumps(updated_history), - }, - } - ], - ) - - yield f"data: {final_chunk.model_dump_json()}\n\n" yield "data: [DONE]\n\n" except Exception as e: logger.error(f"Error generating weather response: {e}") - - error_chunk = ChatCompletionStreamResponse( - id=f"chatcmpl-{uuid.uuid4().hex[:8]}", - created=int(time.time()), - model=request_body.model, - choices=[ + error_chunk = { + "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": request_body.get("model", WEATHER_MODEL), + "choices": [ { "index": 0, "delta": { @@ -633,9 +383,8 @@ async def stream_chat_completions( "finish_reason": "stop", } ], - ) - - yield f"data: {error_chunk.model_dump_json()}\n\n" + } + yield f"data: {json.dumps(error_chunk)}\n\n" yield "data: [DONE]\n\n" @@ -672,3 +421,7 @@ def start_server(host: str = "localhost", port: int = 10510): }, }, ) + + +if __name__ == "__main__": + start_server(host="0.0.0.0", port=10510) diff --git a/demos/use_cases/travel_agents/start_agents.sh b/demos/use_cases/travel_agents/start_agents.sh deleted file mode 100755 index dde61c94..00000000 --- a/demos/use_cases/travel_agents/start_agents.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -set -e - -WAIT_FOR_PIDS=() - -log() { - timestamp=$(python3 -c 'from datetime import datetime; print(datetime.now().strftime("%Y-%m-%d %H:%M:%S,%f")[:23])') - message="$*" - echo "$timestamp - $message" -} - -cleanup() { - log "Caught signal, terminating all agent processes ..." - for PID in "${WAIT_FOR_PIDS[@]}"; do - if kill $PID 2> /dev/null; then - log "killed process: $PID" - fi - done - exit 1 -} - -trap cleanup EXIT - -log "Starting weather agent on port 10510..." -uv run python -m travel_agents --host 0.0.0.0 --port 10510 --agent weather & -WAIT_FOR_PIDS+=($!) - -log "Starting flight agent on port 10520..." -uv run python -m travel_agents --host 0.0.0.0 --port 10520 --agent flight & -WAIT_FOR_PIDS+=($!) - -log "Starting currency agent on port 10530..." -uv run python -m travel_agents --host 0.0.0.0 --port 10530 --agent currency & -WAIT_FOR_PIDS+=($!) - -log "All agents started successfully!" -log " - Weather Agent: http://localhost:10510" -log " - Flight Agent: http://localhost:10520" -log " - Currency Agent: http://localhost:10530" -log "" -log "Waiting for agents to run..." - -for PID in "${WAIT_FOR_PIDS[@]}"; do - wait "$PID" -done diff --git a/demos/use_cases/travel_agents/test.rest b/demos/use_cases/travel_agents/test.rest index a8863b36..f3ecaf66 100644 --- a/demos/use_cases/travel_agents/test.rest +++ b/demos/use_cases/travel_agents/test.rest @@ -7,18 +7,6 @@ Content-Type: application/json { "model": "gpt-4o", "messages": [ - { - "role": "system", - "content": "You are a professional travel planner assistant. Your role is to provide accurate, clear, and helpful information about weather and flights based on the structured data provided to you.\n\nCRITICAL INSTRUCTIONS:\n\n1. DATA STRUCTURE:\n \n WEATHER DATA:\n - You will receive weather data as JSON in a system message\n - The data contains a \"location\" field (string) and a \"forecast\" array\n - Each forecast entry has: date, day_name, temperature_c, temperature_f, temperature_max_c, temperature_min_c, condition, sunrise, sunset\n - Some fields may be null/None - handle these gracefully\n \n FLIGHT DATA:\n - You will receive flight information in a system message\n - Flight data includes: airline, flight number, departure time, arrival time, origin airport, destination airport, aircraft type, status, gate, terminal\n - Information may include both scheduled and estimated times\n - Some fields may be unavailable - handle these gracefully\n\n2. WEATHER HANDLING:\n - For single-day queries: Use temperature_c/temperature_f (current/primary temperature)\n - For multi-day forecasts: Use temperature_max_c and temperature_min_c when available\n - Always provide temperatures in both Celsius and Fahrenheit when available\n - If temperature is null, say \"temperature data unavailable\" rather than making up numbers\n - Use exact condition descriptions provided (e.g., \"Clear sky\", \"Rainy\", \"Partly Cloudy\")\n - Add helpful context when appropriate (e.g., \"perfect for outdoor activities\" for clear skies)\n\n3. FLIGHT HANDLING:\n - Present flight information clearly with airline name and flight number\n - Include departure and arrival times with time zones when provided\n - Mention origin and destination airports with their codes\n - Include gate and terminal information when available\n - Note aircraft type if relevant to the query\n - Highlight any status updates (delays, early arrivals, etc.)\n - For multiple flights, list them in chronological order by departure time\n - If specific details are missing, acknowledge this rather than inventing information\n\n4. MULTI-PART QUERIES:\n - Users may ask about both weather and flights in one message\n - Answer ALL parts of the query that you have data for\n - Organize your response logically - typically weather first, then flights, or vice versa based on the query\n - Provide complete information for each topic without mentioning other agents\n - If you receive data for only one topic but the user asked about multiple, answer what you can with the provided data\n\n5. ERROR HANDLING:\n - If weather forecast contains an \"error\" field, acknowledge the issue politely\n - If temperature or condition is null/None, mention that specific data is unavailable\n - If flight details are incomplete, state which information is unavailable\n - Never invent or guess weather or flight data - only use what's provided\n - If location couldn't be determined, acknowledge this but still provide available data\n\n6. RESPONSE FORMAT:\n \n For Weather:\n - Single-day queries: Provide current conditions, temperature, and condition\n - Multi-day forecasts: List each day with date, day name, high/low temps, and condition\n - Include sunrise/sunset times when available and relevant\n \n For Flights:\n - List flights with clear numbering or bullet points\n - Include key details: airline, flight number, departure/arrival times, airports\n - Add gate, terminal, and status information when available\n - For multiple flights, organize chronologically\n \n General:\n - Use natural, conversational language\n - Be concise but complete\n - Format dates and times clearly\n - Use bullet points or numbered lists for clarity\n\n7. LOCATION HANDLING:\n - Always mention location names from the data\n - For flights, clearly state origin and destination cities/airports\n - If locations differ from what the user asked, acknowledge this politely\n\n8. RESPONSE STYLE:\n - Be friendly and professional\n - Use natural language, not technical jargon\n - Provide information in a logical, easy-to-read format\n - When answering multi-part queries, create a cohesive response that addresses all aspects\n\nRemember: Only use the data provided. Never fabricate weather or flight information. If data is missing, clearly state what's unavailable. Answer all parts of the user's query that you have data for." - }, - { - "role": "system", - "content": "Current weather data for Seattle:\n\n{\n \"location\": \"Seattle\",\n \"forecast\": [\n {\n \"date\": \"2025-12-22\",\n \"day_name\": \"Monday\",\n \"temperature_c\": 8.3,\n \"temperature_f\": 46.9,\n \"temperature_max_c\": 8.3,\n \"temperature_min_c\": 2.8,\n \"condition\": \"Rainy\",\n \"sunrise\": \"07:55\",\n \"sunset\": \"16:20\"\n }\n ]\n}\n\nUse this data to answer the user's weather query." - }, - { - "role": "system", - "content": "Here are some direct flights from Seattle to Atlanta on December 23, 2025:\n\n1. **Delta Airlines Flight DL552**\n - **Departure:** Scheduled at 3:47 PM (Seattle Time), from Seattle-Tacoma Intl (SEA)\n - **Arrival:** Scheduled at 8:31 PM (Atlanta Time), at Hartsfield-Jackson Intl (ATL)\n - **Aircraft:** Boeing 737-900 (B739)\n - **Status:** Scheduled\n - **Terminal at Atlanta:** S\n - **Estimated arrival slightly early**: 8:26 PM\n\n2. **Delta Airlines Flight DL542**\n - **Departure:** Scheduled at 12:00 PM (Seattle Time), Gate A4, from Seattle-Tacoma Intl (SEA)\n - **Arrival:** Scheduled at 4:49 PM (Atlanta Time), at Hartsfield-Jackson Intl (ATL)\n - **Aircraft:** Boeing 737-900 (B739)\n - **Status:** Scheduled\n - **Gate at Atlanta:** E10, Terminal: S\n - **Estimated early arrival**: 4:44 PM\n\n3. **Delta Airlines Flight DL554**\n - **Departure:** Scheduled at 10:15 AM (Seattle Time), Gate A10, from Seattle-Tacoma Intl (SEA)\n - **Arrival:** Scheduled at 4:05 PM (Atlanta Time), at Hartsfield-Jackson Intl (ATL)\n - **Aircraft:** Boeing 737-900 (B739)\n - **Status:** Scheduled\n - **Gate at Atlanta:** B19, Terminal: S\n - **Estimated late arrival**: 4:06 PM\n\n4. **Alaska Airlines Flight AS334**\n - **Departure:** Scheduled at 9:16 AM (Seattle Time), Gate C20, from Seattle-Tacoma Intl (SEA)\n - **Arrival:** Scheduled at 5:08 PM (Atlanta Time), at Hartsfield-Jackson Intl (ATL)\n - **Aircraft:** Boeing 737-900 (B739)\n - **Status:** Scheduled\n - **Gate at Atlanta:** C5, Terminal: N\n\nThese are just a few of the direct flights available. Please let me know if you need more details on any other specific flight." - }, { "role": "user", "content": "What's the weather in Seattle?"