feat: langchain demo

This commit is contained in:
Musa 2026-01-13 10:58:57 -08:00
parent cde1d0f87f
commit 9f87aab202
10 changed files with 2704 additions and 0 deletions

View file

@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# Install bash and uv
RUN apt-get update && apt-get install -y bash && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir 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"]

View file

@ -0,0 +1,212 @@
# Travel Booking Agent Demo (LangChain-first)
A lightweight LangChain-powered multi-agent travel booking system that runs two small agents behind Planos router: a weather agent and a flight agent. Each agent is implemented with LangChain tool-calling out of the box and kept minimal so you can read, tweak, and extend quickly.
## Overview
This demo consists of two LangChain agents that work together seamlessly:
- **Weather Agent** - Real-time weather conditions and multi-day forecasts for any city worldwide
- **Flight Agent** - Live flight information between airports with real-time tracking
Both agents are plain LangChain tool-callers. Plano routes traffic based on intent and forwards to the right LangChain agent. Everything runs in Docker for quick start.
## Features
- **Lightweight code**: Minimal prompts + tools you can read in one pass
- **Intelligent routing**: Plano auto-routes to weather vs flight
- **Real-time data**: Weather (Open-Meteo) + flights (FlightAware)
- **Multi-day forecasts**: Up to 16 days for weather
## Prerequisites
- Docker and Docker Compose
- [Plano CLI](https://docs.planoai.dev) installed
- OpenAI API key
## Quick Start
### 1. Set Environment Variables
Create a `.env` file or export environment variables:
```bash
export AEROAPI_KEY="your-flightaware-api-key" # Optional, demo key included
```
### 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
- Open WebUI on port 8080
### 3. Start Plano Orchestrator
In a new terminal:
```bash
cd /path/to/travel_agents
planoai up config.yaml
# Or if installed with uv: uvx planoai up config.yaml
```
The gateway will start on port 8001 and route requests to the appropriate agents.
### 4. Test the System
**Option 1**: Use Open WebUI at http://localhost:8080
**Option 2**: Send requests directly to Plano Orchestrator:
```bash
curl http://localhost:8001/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "What is the weather like in Paris?"}
]
}'
```
## Example Conversations
### Weather Query
```
User: What's the weather in Istanbul?
Assistant: [Weather Agent provides current conditions and forecast]
```
### Flight Search
```
User: What flights go from London to Seattle?
Assistant: [Flight Agent shows available flights with schedules and status]
```
### Multi-Agent Conversation
```
User: What's the weather in Istanbul?
Assistant: [Weather information]
User: Do they fly out from Seattle?
Assistant: [Flight information from Istanbul to Seattle]
```
The system understands context and pronouns, automatically routing to the right agent.
### Multi-Intent Queries
```
User: What's the weather in Seattle, and do any flights go direct to New York?
Assistant: [Both weather_agent and flight_agent respond simultaneously]
- Weather Agent: [Weather information for Seattle]
- Flight Agent: [Flight information from Seattle to New York]
```
The orchestrator can select multiple agents simultaneously for queries containing multiple intents.
## Agent Details (LangChain)
### Weather Agent
- **Port**: 10510
- **API**: Open-Meteo (free, no API key)
- **LangChain**: Tool to fetch weather; LLM summarizes with provided data
- **Capabilities**: Current weather, multi-day forecasts, temperature, conditions, sunrise/sunset
### Flight Agent
- **Port**: 10520
- **API**: FlightAware AeroAPI
- **LangChain**: Tool resolves cities → IATA and fetches flights
- **Capabilities**: Real-time flight status, schedules, delays, gates, terminals, live tracking
## Architecture
```
User Request
Plano (8001)
[Orchestrator]
|
┌────┴────┐
↓ ↓
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
Both agents run as Docker containers and communicate with Plano via `host.docker.internal`.
## Project Structure
```
travel_agents/
├── config.yaml # Plano configuration
├── 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
├── weather_agent.py # Weather forecast agent (multi-day support)
└── flight_agent.py # Flight information agent
```
## Configuration Files
### config.yaml
Defines the two 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)
- Jaeger (for distributed tracing)
## Troubleshooting
**Docker containers won't start**
- Verify 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`
- Ensure you're in the travel_agents directory
- Check config.yaml is valid
**No response from agents**
- 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)
## API Endpoints
All agents expose OpenAI-compatible chat completion endpoints:
- `POST /v1/chat/completions` - Chat completion endpoint
- `GET /health` - Health check endpoint

View file

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

View file

@ -0,0 +1,63 @@
services:
plano:
build:
context: ../../../
dockerfile: Dockerfile
ports:
- "12000:12000"
- "8001:8001"
environment:
- ARCH_CONFIG_PATH=/config/config.yaml
- OPENAI_API_KEY=${OPENAI_API_KEY:?OPENAI_API_KEY environment variable is required but not set}
volumes:
- ./config.yaml:/app/arch_config.yaml
- /etc/ssl/cert.pem:/etc/ssl/cert.pem
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:? AEROAPI_KEY environment variable is required but not set}
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
ports:
- "8080:8080"
environment:
- 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
jaeger:
build:
context: ../../shared/jaeger
container_name: jaeger
restart: always
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC receiver
- "4318:4318" # OTLP HTTP receiver

View file

@ -0,0 +1,27 @@
[project]
name = "travel-agents"
version = "0.1.0"
description = "Travel Booking Agents - Weather, Flight, and Currency"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"click>=8.2.1",
"pydantic>=2.11.7",
"fastapi>=0.115.0",
"uvicorn>=0.30.0",
"openai>=1.0.0",
"httpx>=0.24.0",
"opentelemetry-api>=1.20.0",
"langchain>=0.3.13",
"langchain-openai>=0.2.14",
]
[project.scripts]
travel_agents = "travel_agents:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/travel_agents"]

View file

@ -0,0 +1,407 @@
import json
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from openai import AsyncOpenAI
import os
import logging
import uvicorn
from datetime import datetime
import httpx
from typing import Optional
import uuid
import time
from opentelemetry.propagate import extract, inject
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain.prompts import ChatPromptTemplate
from langchain.agents import create_tool_calling_agent, AgentExecutor
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - [FLIGHT_AGENT] - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
LLM_GATEWAY_ENDPOINT = os.getenv(
"LLM_GATEWAY_ENDPOINT", "http://host.docker.internal:12000/v1"
)
FLIGHT_MODEL = "openai/gpt-4o"
EXTRACTION_MODEL = "openai/gpt-4o-mini"
AEROAPI_BASE_URL = "https://aeroapi.flightaware.com/aeroapi"
AEROAPI_KEY = os.getenv("AEROAPI_KEY")
http_client = httpx.AsyncClient(timeout=30.0)
openai_client = AsyncOpenAI(base_url=LLM_GATEWAY_ENDPOINT, api_key="EMPTY")
class FlightSearchInput(BaseModel):
origin_city: str = Field(..., description="Origin city for the flight search")
destination_city: str = Field(
..., description="Destination city for the flight search"
)
travel_date: Optional[str] = Field(
None,
description="Optional travel date in YYYY-MM-DD format. If not provided, defaults to today.",
)
SYSTEM_PROMPT = """You are a travel planning assistant specializing in flight information. You support both direct flights AND multi-leg connecting flights.
Flight data fields:
- airline: Full airline name (e.g., "Delta Air Lines")
- flight_number: Flight identifier (e.g., "DL123")
- departure_time/arrival_time: ISO 8601 timestamps
- origin/destination: Airport IATA codes
- aircraft_type: Aircraft model code (e.g., "B739")
- status: Flight status (e.g., "Scheduled", "Delayed")
- terminal_origin/gate_origin: Departure terminal and gate (may be null)
Your task:
1. Present flights clearly with airline, flight number, readable times, airports, and aircraft
2. Organize chronologically by departure time
3. Convert ISO timestamps to readable format (e.g., "11:00 AM")
4. Include terminal/gate info when available
5. For multi-leg flights: present each leg separately with connection timing
Multi-agent context: If the conversation includes information from other sources, incorporate it naturally into your response."""
ROUTE_EXTRACTION_PROMPT = """Extract flight route and travel date. Support direct AND multi-leg flights.
Rules:
1. Patterns: "flight from X to Y", "X to Y to Z", "fly from X through Y to Z"
2. For multi-leg (e.g., "Seattle to Dubai to Lahore"), extract ALL cities in order
3. Extract dates: "tomorrow", "next week", "December 25", "12/25", "on Monday"
4. Use conversation context for missing details
Output format: {"cities": ["City1", "City2", ...], "date": "YYYY-MM-DD" or null}
Examples:
- "Flight from Seattle to Atlanta tomorrow" {"cities": ["Seattle", "Atlanta"], "date": "2026-01-07"}
- "Seattle to Dubai to Lahore" {"cities": ["Seattle", "Dubai", "Lahore"], "date": null}
- "Flights from LA through Chicago to NYC" {"cities": ["LA", "Chicago", "NYC"], "date": null}
Today is January 6, 2026. Extract flight route:"""
async def extract_flight_route(messages: list, request: Request) -> dict:
try:
ctx = extract(request.headers)
extra_headers = {}
inject(extra_headers, context=ctx)
response = await openai_client.chat.completions.create(
model=EXTRACTION_MODEL,
messages=[
{"role": "system", "content": ROUTE_EXTRACTION_PROMPT},
*[
{"role": m.get("role"), "content": m.get("content")}
for m in messages[-5:]
],
],
temperature=0.1,
max_tokens=100,
extra_headers=extra_headers or None,
)
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()
route = json.loads(result)
cities = route.get("cities", [])
if not cities and (route.get("origin") or route.get("destination")):
cities = [c for c in [route.get("origin"), route.get("destination")] if c]
return {"cities": cities, "date": route.get("date")}
except Exception as e:
logger.error(f"Error extracting flight route: {e}")
return {"cities": [], "date": None}
async def resolve_airport_code(city_name: str, request: Request) -> Optional[str]:
if not city_name:
return None
try:
ctx = extract(request.headers)
extra_headers = {}
inject(extra_headers, context=ctx)
response = await openai_client.chat.completions.create(
model=EXTRACTION_MODEL,
messages=[
{
"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, Dubai→DXB, Lahore→LHE",
},
{"role": "user", "content": city_name},
],
temperature=0.1,
max_tokens=10,
extra_headers=extra_headers or None,
)
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}: {e}")
return None
async def fetch_flights(
origin_code: str, dest_code: str, travel_date: Optional[str] = None
) -> dict:
"""Fetch flights between two airports. Note: FlightAware limits to 2 days ahead."""
search_date = travel_date or datetime.now().strftime("%Y-%m-%d")
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"Date {search_date} is {days_ahead} days ahead, exceeds FlightAware limit"
)
return {
"origin_code": origin_code,
"destination_code": dest_code,
"flights": [],
"count": 0,
"error": f"FlightAware API only provides data up to 2 days ahead. Requested date ({search_date}) is {days_ahead} days away.",
}
try:
url = f"{AEROAPI_BASE_URL}/airports/{origin_code}/flights/to/{dest_code}"
headers = {"x-apikey": AEROAPI_KEY}
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.error(
f"FlightAware API error {response.status_code}: {response.text}"
)
return {
"origin_code": origin_code,
"destination_code": dest_code,
"flights": [],
"count": 0,
}
data = response.json()
flights = []
for flight_group in data.get("flights", [])[:5]:
segments = flight_group.get("segments", [])
if not segments:
continue
flight = segments[0]
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"].get("code_iata")
if isinstance(flight.get("origin"), dict)
else None,
"destination": flight["destination"].get("code_iata")
if isinstance(flight.get("destination"), dict)
else None,
"aircraft_type": flight.get("aircraft_type"),
"status": flight.get("status"),
"terminal_origin": flight.get("terminal_origin"),
"gate_origin": flight.get("gate_origin"),
}
)
logger.info(f"Found {len(flights)} flights from {origin_code} to {dest_code}")
return {
"origin_code": origin_code,
"destination_code": dest_code,
"flights": flights,
"count": len(flights),
}
except Exception as e:
logger.error(f"Error fetching flights: {e}")
return {
"origin_code": origin_code,
"destination_code": dest_code,
"flights": [],
"count": 0,
}
def build_flight_context(cities: list, airport_codes: list, legs_data: list) -> str:
if len(cities) == 2:
leg = legs_data[0]
flight_data = {
"flights": leg["flights"],
"count": len(leg["flights"]),
"origin_code": leg["origin_code"],
"destination_code": leg["dest_code"],
}
if leg["flights"]:
return f"""
Flight search results from {leg['origin']} ({leg['origin_code']}) to {leg['destination']} ({leg['dest_code']}):
Flight data in JSON format:
{json.dumps(flight_data, indent=2)}
Present these {len(leg['flights'])} flight(s) to the user clearly."""
else:
error = leg.get("error") or "No direct flights found"
return f"""
Flight search from {leg['origin']} ({leg['origin_code']}) to {leg['destination']} ({leg['dest_code']}):
Result: {error}
Let the user know and suggest alternatives if appropriate."""
route_str = "".join(
[f"{city} ({code})" for city, code in zip(cities, airport_codes)]
)
context = f"\nMulti-leg flight search: {route_str}\n\n"
for leg in legs_data:
context += f"**Leg {leg['leg']}: {leg['origin']} ({leg['origin_code']}) → {leg['destination']} ({leg['dest_code']})**\n"
if leg["flights"]:
leg_data = {"flights": leg["flights"], "count": len(leg["flights"])}
context += f"Flight data:\n{json.dumps(leg_data, indent=2)}\n\n"
elif leg.get("error"):
context += f"Error: {leg['error']}\n\n"
else:
context += "No direct flights found for this leg.\n\n"
context += "Present this itinerary clearly. For each leg, show available flights by departure time. Note connection timing between legs."
return context
app = FastAPI(title="Flight Information Agent", version="1.0.0")
@app.post("/v1/chat/completions")
async def handle_request(request: Request):
request_body = await request.json()
content = await invoke_flight_agent(request, request_body)
return JSONResponse({"content": content})
async def invoke_flight_agent(request: Request, request_body: dict):
messages = request_body.get("messages", [])
conversation = "\n".join(
[f"{m.get('role')}: {m.get('content')}" for m in messages if m.get("content")]
)
ctx = extract(request.headers)
extra_headers = {"x-envoy-max-retries": "3"}
inject(extra_headers, context=ctx)
@tool("search_flights", args_schema=FlightSearchInput)
async def search_flights(
origin_city: str, destination_city: str, travel_date: Optional[str] = None
):
"""Search for flights between two cities. Supports optional travel date."""
origin_code = await resolve_airport_code(origin_city, request)
dest_code = await resolve_airport_code(destination_city, request)
if not origin_code or not dest_code:
return {
"error": "Could not resolve airport codes for provided cities.",
"origin_city": origin_city,
"destination_city": destination_city,
}
flight_data = await fetch_flights(origin_code, dest_code, travel_date)
return {
"origin_city": origin_city,
"destination_city": destination_city,
"origin_code": origin_code,
"destination_code": dest_code,
"travel_date": travel_date or datetime.now().strftime("%Y-%m-%d"),
"flights": flight_data.get("flights", []),
"count": flight_data.get("count", 0),
"error": flight_data.get("error"),
}
tools = [search_flights]
prompt = ChatPromptTemplate.from_messages(
[
("system", SYSTEM_PROMPT),
(
"user",
"Conversation:\n{conversation}\n\nUse tools to fetch real flight options. Be concise and clear.",
),
]
)
llm = ChatOpenAI(
model=FLIGHT_MODEL,
api_key="EMPTY",
base_url=LLM_GATEWAY_ENDPOINT,
temperature=request_body.get("temperature", 0.7),
max_tokens=request_body.get("max_tokens", 1000),
streaming=False,
default_headers=extra_headers,
)
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=False)
try:
result = await executor.ainvoke({"conversation": conversation})
return result.get("output", "")
except Exception as e:
logger.error(f"Error generating response: {e}")
return "Im having trouble retrieving flight information right now. Please try again."
@app.get("/health")
async def health_check():
return {"status": "healthy", "agent": "flight_information"}
def start_server(host: str = "0.0.0.0", port: int = 10520):
uvicorn.run(
app,
host=host,
port=port,
log_config={
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s - [FLIGHT_AGENT] - %(levelname)s - %(message)s"
}
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
}
},
"root": {"level": "INFO", "handlers": ["default"]},
},
)
if __name__ == "__main__":
start_server()

View file

@ -0,0 +1,424 @@
import json
import re
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
import os
import logging
import time
import uuid
import uvicorn
from datetime import datetime, timedelta
import httpx
from typing import Optional
from urllib.parse import quote
from opentelemetry.propagate import extract, inject
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain.prompts import ChatPromptTemplate
from langchain.agents import create_tool_calling_agent, AgentExecutor
# Set up logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - [WEATHER_AGENT] - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# 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"
# Initialize OpenAI client for plano
openai_client_via_plano = AsyncOpenAI(
base_url=LLM_GATEWAY_ENDPOINT,
api_key="EMPTY",
)
# 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)
# 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
def get_user_messages(messages: list) -> list:
"""Extract user messages from message list."""
return [msg for msg in messages if msg.get("role") == "user"]
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 ""
async def get_weather_data(
request: Request,
messages: list,
days: int = 1,
traceparent_header: str = None,
request_id: str = None,
):
"""Extract location from user's conversation and fetch weather data from Open-Meteo API.
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
Currently returns only current day weather. Want to add multi-day forecasts?
"""
instructions = """You are a city name extractor. Look at the FINAL user message ONLY and extract the city name.
The FINAL user message will be the LAST message with role "user" in the conversation.
IMPORTANT: Ignore all previous messages. Focus ONLY on the FINAL user message.
Examples of what to extract from the FINAL user message:
- "What's the weather in Seattle?" Seattle
- "What's the weather in San Francisco?" San Francisco
- "What about Dubai?" Dubai
- "How's the weather in Tokyo today?" Tokyo
- "Tell me about Lahore" Lahore
- "What about there?" Look at conversation for the last mentioned city
Output ONLY the city name. Nothing else. One word or city name only.
If no city can be found, output: NOT_FOUND"""
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 = {}
if request_id:
extra_headers["x-request-id"] = request_id
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=10,
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",
},
}
class WeatherToolInput(BaseModel):
city: str = Field(..., description="City name to look up weather for")
days: int = Field(
1,
ge=1,
le=16,
description="Number of forecast days (1-16). Defaults to 1 (current).",
)
@app.post("/v1/chat/completions")
async def handle_request(request: Request):
"""HTTP endpoint for chat completions with LangChain agent execution."""
request_body = await request.json()
messages = request_body.get("messages", [])
is_streaming = request_body.get("stream", True)
logger.info("messages detail json dumps: %s", json.dumps(messages, indent=2))
traceparent_header = request.headers.get("traceparent")
request_id = request.headers.get("x-request-id")
return StreamingResponse(
invoke_weather_agent(request, request_body, traceparent_header, request_id),
media_type="text/plain",
headers={
"content-type": "text/event-stream",
},
)
async def invoke_weather_agent(
request: Request,
request_body: dict,
traceparent_header: str = None,
request_id: str = None,
):
"""Generate chat completions using a LangChain tool-calling agent."""
messages = request_body.get("messages", [])
# Build conversation string for agent context
conversation = "\n".join(
[f"{m.get('role')}: {m.get('content')}" for m in messages if m.get("content")]
)
# Trace propagation for downstream requests
ctx = extract(request.headers)
extra_headers = {"x-envoy-max-retries": "3"}
if request_id:
extra_headers["x-request-id"] = request_id
inject(extra_headers, context=ctx)
# Detect if user wants multi-day forecast from the last user message
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
day_match = re.search(r"(\d{1,2})\s+day", last_user_msg)
if day_match:
requested_days = int(day_match.group(1))
days = min(requested_days, 16)
@tool("get_weather_forecast", args_schema=WeatherToolInput)
async def get_weather_forecast(city: str, days: int = 1):
"""Lookup weather for a city and return structured forecast."""
return await get_weather_data(
request,
messages + [{"role": "user", "content": city}],
days,
traceparent_header,
request_id,
)
tools = [get_weather_forecast]
system_prompt = """You are a weather assistant in a multi-agent system.
Use tools to fetch live weather and provide concise, friendly answers.
If no city is provided, pick a sensible default (e.g., New York) and say you defaulted."""
prompt = ChatPromptTemplate.from_messages(
[
("system", system_prompt),
(
"user",
"Conversation:\n{conversation}\n\nIf needed, call tools to get the latest weather.",
),
]
)
llm = ChatOpenAI(
model=WEATHER_MODEL,
api_key="EMPTY",
base_url=LLM_GATEWAY_ENDPOINT,
temperature=request_body.get("temperature", 0.7),
max_tokens=request_body.get("max_tokens", 1000),
streaming=False,
default_headers=extra_headers,
)
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=False)
try:
result = await executor.ainvoke({"conversation": conversation, "days": days})
content = result.get("output", "")
# Stream back plain SSE data like a LangChain agent out-of-the-box
yield f"data: {content}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
logger.error(f"Error generating weather response: {e}")
yield "data: Im having trouble retrieving weather information right now. Please try again.\n\n"
yield "data: [DONE]\n\n"
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "agent": "weather_forecast"}
def start_server(host: str = "localhost", port: int = 10510):
"""Start the REST server."""
uvicorn.run(
app,
host=host,
port=port,
log_config={
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s - [WEATHER_AGENT] - %(levelname)s - %(message)s",
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
"root": {
"level": "INFO",
"handlers": ["default"],
},
},
)
if __name__ == "__main__":
start_server(host="0.0.0.0", port=10510)

View file

@ -0,0 +1,43 @@
@llm_endpoint = http://localhost:12000
### Travel Agent Chat Completion Request
POST {{llm_endpoint}}/v1/chat/completions HTTP/1.1
Content-Type: application/json
{
"model": "gpt-4o",
"messages": [
{
"role": "user",
"content": "What's the weather in Seattle?"
},
{
"role": "assistant",
"content": "The weather in Seattle is sunny with a temperature of 60 degrees Fahrenheit."
},
{
"role": "user",
"content": "What is one Alaska flight that goes direct to Atlanta from Seattle?"
}
],
"max_tokens": 1000,
"stream": false,
"temperature": 1.0
}
### test 8001
### test upstream llm
POST http://localhost:8001/v1/chat/completions HTTP/1.1
Content-Type: application/json
{
"messages": [
{
"role": "system",
"content": "\nCurrent 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."
}
],
"model": "gpt-4o",
}

View file

@ -0,0 +1,30 @@
@llm_endpoint = http://localhost:12000
### Travel Agent Chat Completion - Full Conversation
POST {{llm_endpoint}}/v1/chat/completions HTTP/1.1
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": "assistant",
"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": "assistant",
"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 is the weather in Atlanta and what flight goes from Seattle to Atlanta?"
}
],
"max_tokens": 1000,
"stream": false,
"temperature": 1.0
}

1419
demos/use_cases/langchain/uv.lock generated Normal file

File diff suppressed because it is too large Load diff