diff --git a/python/samples/05-end-to-end/signalr_streaming/README.md b/python/samples/05-end-to-end/signalr_streaming/README.md new file mode 100644 index 0000000000..53a1053480 --- /dev/null +++ b/python/samples/05-end-to-end/signalr_streaming/README.md @@ -0,0 +1,150 @@ +# Agent Streaming with Azure SignalR Service + +This sample demonstrates how to stream durable agent responses to web clients in real time using Azure SignalR Service. The agent processes requests via Azure Functions with durable orchestration, and each streaming chunk is pushed to the browser through SignalR groups for user isolation. + +![Demo](./az_func_signalr_demo.gif) + +## Key Concepts Demonstrated + +- `AgentResponseCallbackProtocol` to capture streaming agent responses +- Real-time delivery of streaming chunks via Azure SignalR Service REST API +- **Multi-user isolation** using SignalR user-targeted messaging (each client only receives its own messages) +- Custom SignalR negotiation endpoint with user identity embedded via `nameid` JWT claim +- Automatic reconnection support using the SignalR JavaScript client +- Durable agent execution with streaming callbacks +- Multi-turn conversation continuity + +## Prerequisites + +1. **Azure SignalR Service** — Create a SignalR Service instance in Azure (Serverless mode). There is no local emulator. +2. **Azure Functions Core Tools** — [Install Core Tools 4.x](https://learn.microsoft.com/azure/azure-functions/functions-run-local) +3. **Azurite** — [Install the storage emulator](https://learn.microsoft.com/azure/storage/common/storage-install-azurite) +4. **Azure OpenAI** — An Azure OpenAI resource with a chat model deployment +5. **Azure CLI** — Logged in via `az login` for `AzureCliCredential` + +## Setup + +### 1. Create and activate a virtual environment + +```bash +python -m venv .venv +source .venv/bin/activate # Linux/macOS +# .venv\Scripts\Activate.ps1 # Windows PowerShell +``` + +### 2. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 3. Configure settings + +Copy `local.settings.json.template` to `local.settings.json` and fill in: + +- `AZURE_OPENAI_ENDPOINT` — Your Azure OpenAI endpoint +- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` — Your chat model deployment name +- `AzureSignalRConnectionString` — Your Azure SignalR Service connection string +- `SIGNALR_HUB_NAME` — Hub name (defaults to `travel`) + +The sample uses `AzureCliCredential` by default. Alternatively, set `AZURE_OPENAI_API_KEY` for API key authentication. + +### 4. Start Azurite + +```bash +azurite --silent +``` + +## Running the Sample + +### 1. Start the Azure Functions host + +```bash +func start +``` + +The function app starts on `http://localhost:7071`. + +### 2. Open the web interface + +Navigate to `http://localhost:7071/api/index` in your browser. The page automatically: + +- Connects to Azure SignalR Service via the `/api/agent/negotiate` endpoint +- Displays the connection status (Connected / Disconnected) +- Enables the chat interface + +### 3. Send a message + +Type a travel planning request, for example: + +```text +Plan a 3-day trip to Singapore +``` + +Click **Send** (or press Enter). The agent: + +- Executes in the background via durable orchestration +- Streams responses in real time as they are generated + +### 4. Continue the conversation + +The client maintains the `thread_id` across messages for multi-turn conversation: + +```text +Include neighbouring countries as well +``` + +## API Endpoints + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/agents/TravelPlanner/run?thread_id=` | Start or continue an agent conversation | +| GET/POST | `/api/agent/negotiate` | SignalR negotiation (pass `x-user-id` header) | +| POST | `/api/agent/create-thread` | Create a `thread_id` and register the user mapping (pass `x-user-id` header) | +| GET | `/api/index` | Serve the web interface | + +## How It Works + +### Flow + +``` +Client Functions SignalR Service Agent + │ │ │ │ + │ negotiate │ │ │ + │ (x-user-id) │ │ │ + │───────────────>│ │ │ + │<─ url+token ───│ (nameid claim │ │ + │ │ = user_id) │ │ + │ │ │ │ + │ connect(token) │ │ │ + │────────────────────────────────────>│ │ + │ │ │ │ + │ create-thread │ │ │ + │ (x-user-id) │ │ │ + │───────────────>│ stores mapping │ │ + │<─ thread_id ───│ thread→user │ │ + │ │ │ │ + │ run (thread_id)│ │ │ + │───────────────>│──────────────────────────────────>│ + │<─ 202 accepted │ │ │ + │ │ │ streaming │ + │ │ │<───────────────│ + │ agentMessage │<── to user ───────│ │ + │<───────────────│ │ │ + │ agentDone │<── to user ───────│ │ + │<───────────────│ │ │ +``` + +### User Isolation + +1. **Client generates a user ID** — stored in `sessionStorage` for the browser tab lifetime. +2. **Negotiate** — The client sends `x-user-id` header; the server embeds it as a `nameid` claim in the JWT so SignalR binds the connection to that user. +3. **Thread creation** — The client sends `x-user-id` when creating a thread; the server stores a `thread_id → user_id` mapping. +4. **Streaming** — The `SignalRCallback` looks up the `user_id` for the `thread_id` and sends messages via the `/users/{userId}` REST API path. + +This ensures: + +- Each user only sees their own conversation +- No groups or group-join race conditions +- Multiple users can use the app simultaneously without interference +- Works correctly across page refreshes within the same tab diff --git a/python/samples/05-end-to-end/signalr_streaming/az_func_signalr_demo.gif b/python/samples/05-end-to-end/signalr_streaming/az_func_signalr_demo.gif new file mode 100644 index 0000000000..c4c95dcdb4 Binary files /dev/null and b/python/samples/05-end-to-end/signalr_streaming/az_func_signalr_demo.gif differ diff --git a/python/samples/05-end-to-end/signalr_streaming/content/index.html b/python/samples/05-end-to-end/signalr_streaming/content/index.html new file mode 100644 index 0000000000..1cf1c7c575 --- /dev/null +++ b/python/samples/05-end-to-end/signalr_streaming/content/index.html @@ -0,0 +1,674 @@ + + + + + + + Travel Planner Agent - SignalR Chat + + + + + +
+ +
+
+

🌍 Travel Planner Agent

+
+
+ Connecting... +
+
+
+
+
Welcome! Ask me to plan a trip to any destination.
+
+
+
+
+ + +
+
+
+ + +
+
Session Info
+
+
+
User ID
+
+
+
+
Thread ID
+
Not started
+
+
+
SignalR Connection ID
+
+
+ +
+ +
+
Conversation Stats
+
+ Messages sent + 0 +
+
+ Agent responses + 0 +
+
+ Chunks received + 0 +
+
+ +
+ +
+
Latest Correlation ID
+
+
+ +
+ + +
+
+
+ + + + + diff --git a/python/samples/05-end-to-end/signalr_streaming/function_app.py b/python/samples/05-end-to-end/signalr_streaming/function_app.py new file mode 100644 index 0000000000..9401803889 --- /dev/null +++ b/python/samples/05-end-to-end/signalr_streaming/function_app.py @@ -0,0 +1,363 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Stream agent responses to clients in real time via Azure SignalR Service. + +This sample demonstrates how to: +- Host an agent built with Agent Framework in an Azure Functions app +- Use Azure OpenAI (via AzureOpenAIChatClient) to power a travel planning agent +- Stream incremental agent responses to clients over Azure SignalR Service +- Implement user isolation using SignalR user-targeted messaging so each client only receives its own messages + +Components used in this sample: +- AgentFunctionApp to expose HTTP endpoints via the Durable Functions extension. +- AzureOpenAIChatClient to call the Azure OpenAI chat deployment. +- AgentResponseCallbackProtocol to forward streaming updates to SignalR. +- A lightweight REST client (SignalRServiceClient) to call the SignalR Service REST API. +- Mock tool functions (get_weather_forecast, get_local_events) for the travel agent. + +Prerequisites: +- An Azure SignalR Service instance (Serverless mode). There is no local emulator. +- An Azure OpenAI resource with a chat model deployment. +- Azure Functions Core Tools and Azurite for local development. +- Logged in via ``az login`` for AzureCliCredential. +- Environment variables set in local.settings.json (see local.settings.json.template). +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import os +import time +import uuid +from typing import Any + +import aiohttp +import azure.functions as func +from agent_framework import AgentResponseUpdate +from agent_framework.azure import ( + AgentCallbackContext, + AgentFunctionApp, + AgentResponseCallbackProtocol, + AzureOpenAIChatClient, +) +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from tools import get_local_events, get_weather_forecast + +# Load environment variables from .env file +load_dotenv() + +# Configuration +# AzureSignalRConnectionString is the standard Azure Functions setting name for SignalR Service. +SIGNALR_CONNECTION_STRING = os.environ.get("AzureSignalRConnectionString", "") # noqa: SIM112 +SIGNALR_HUB_NAME = os.environ.get("SIGNALR_HUB_NAME", "travel") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) + + +# --------------------------------------------------------------------------- +# 1. SignalR Service REST client +# --------------------------------------------------------------------------- + + +class SignalRServiceClient: + """Lightweight client for Azure SignalR Service REST API.""" + + def __init__(self, connection_string: str, hub_name: str) -> None: + parts = { + key: value + for key, value in (segment.split("=", 1) for segment in connection_string.split(";") if segment) + } + + self._endpoint = parts.get("Endpoint", "").rstrip("/") + "/" + self._access_key = parts.get("AccessKey") + self._hub_name = hub_name + + if not self._endpoint or not self._access_key: + raise ValueError("AzureSignalRConnectionString must include Endpoint and AccessKey.") + + @staticmethod + def _encode_segment(data: dict) -> bytes: + return base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode("utf-8")).rstrip(b"=") + + def _generate_token( + self, audience: str, expires_in_seconds: int = 3600, *, user_id: str | None = None + ) -> str: + header = {"alg": "HS256", "typ": "JWT"} + payload: dict[str, Any] = { + "aud": audience, + "exp": int(time.time()) + expires_in_seconds, + } + if user_id: + payload["nameid"] = user_id + + signing_input = b".".join([self._encode_segment(header), self._encode_segment(payload)]) + signature = hmac.new( + self._access_key.encode("utf-8"), # type: ignore[union-attr] + signing_input, + hashlib.sha256, + ).digest() + token = b".".join([signing_input, base64.urlsafe_b64encode(signature).rstrip(b"=")]) + return token.decode("utf-8") + + async def send( + self, + *, + target: str, + arguments: list, + group: str | None = None, + user_id: str | None = None, + ) -> None: + """Send a message to SignalR clients via the REST API.""" + url_path = f"/api/v1/hubs/{self._hub_name}" + if group: + url_path += f"/groups/{group}" + elif user_id: + url_path += f"/users/{user_id}" + + base_endpoint = self._endpoint.rstrip("/") + url = f"{base_endpoint}{url_path}" + token = self._generate_token(url) + + async with aiohttp.ClientSession() as session: + async with session.post( + url, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={"target": target, "arguments": arguments}, + ) as response: + if response.status >= 300: + details = await response.text() + raise RuntimeError(f"SignalR send failed ({response.status}): {details}") + + + + +# --------------------------------------------------------------------------- +# 2. Callback that pushes streaming updates to SignalR +# --------------------------------------------------------------------------- + + +# Thread-to-user mapping for routing SignalR messages to the correct user. +# In production, use a shared store (e.g., Redis) for multi-instance deployments. +_thread_user_map: dict[str, str] = {} + + +class SignalRCallback(AgentResponseCallbackProtocol): + """Callback that pushes streaming updates to the correct SignalR user.""" + + def __init__( + self, + client: SignalRServiceClient, + thread_user_map: dict[str, str], + *, + message_target: str = "agentMessage", + done_target: str = "agentDone", + ) -> None: + self._client = client + self._thread_user_map = thread_user_map + self._message_target = message_target + self._done_target = done_target + self._logger = logging.getLogger("durableagent.samples.signalr_streaming") + + def _resolve_user(self, thread_id: str | None) -> str | None: + """Look up the user_id for a given thread_id.""" + if not thread_id: + return None + return self._thread_user_map.get(thread_id) + + async def on_streaming_response_update( + self, + update: AgentResponseUpdate, + context: AgentCallbackContext, + ) -> None: + text = update.text + if not text: + return + + target_user = self._resolve_user(context.thread_id) + if not target_user: + self._logger.warning("No user_id mapped for thread %s", context.thread_id) + return + + payload = { + "conversationId": context.thread_id, + "correlationId": context.correlation_id, + "text": text, + } + + try: + await self._client.send( + target=self._message_target, + arguments=[payload], + user_id=target_user, + ) + except Exception as ex: + if "404" not in str(ex): + self._logger.error("SignalR send failed: %s", ex) + + async def on_agent_response(self, response: Any, context: AgentCallbackContext) -> None: + target_user = self._resolve_user(context.thread_id) + if not target_user: + return + + payload = { + "conversationId": context.thread_id, + "correlationId": context.correlation_id, + "status": "completed", + } + + try: + await self._client.send( + target=self._done_target, + arguments=[payload], + user_id=target_user, + ) + except Exception as ex: + if "404" not in str(ex): + self._logger.error("SignalR send failed: %s", ex) + + +# --------------------------------------------------------------------------- +# 3. Create SignalR client and callback instances +# --------------------------------------------------------------------------- + +signalr_client = SignalRServiceClient( + connection_string=SIGNALR_CONNECTION_STRING, + hub_name=SIGNALR_HUB_NAME, +) +signalr_callback = SignalRCallback(client=signalr_client, thread_user_map=_thread_user_map) + + +# --------------------------------------------------------------------------- +# 4. Create the travel planner agent +# --------------------------------------------------------------------------- + + +def _create_travel_agent() -> Any: + """Create the TravelPlanner agent with tools.""" + return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + name="TravelPlanner", + instructions=( + "You are an expert travel planner who creates detailed, personalized travel itineraries.\n" + "When asked to plan a trip, you should:\n" + "1. Create a comprehensive day-by-day itinerary\n" + "2. Include specific recommendations for activities, restaurants, and attractions\n" + "3. Provide practical tips for each destination\n" + "4. Consider weather and local events when making recommendations\n" + "5. Include estimated times and logistics between activities\n\n" + "Always use the available tools to get current weather forecasts and local events\n" + "for the destination to make your recommendations more relevant and timely.\n\n" + "Format your response with clear headings for each day and include emoji icons\n" + "to make the itinerary easy to scan and visually appealing." + ), + tools=[get_weather_forecast, get_local_events], + ) + + +# --------------------------------------------------------------------------- +# 5. Register with AgentFunctionApp +# --------------------------------------------------------------------------- + +app = AgentFunctionApp( + agents=[_create_travel_agent()], + enable_health_check=True, + default_callback=signalr_callback, + max_poll_retries=100, +) + + +# --------------------------------------------------------------------------- +# 6. Custom HTTP endpoints +# --------------------------------------------------------------------------- + + +@app.function_name("negotiate") +@app.route(route="agent/negotiate", methods=["POST", "GET"]) +def negotiate(req: func.HttpRequest) -> func.HttpResponse: + """Provide SignalR connection info with the user identity embedded in the token. + + The client must pass an ``x-user-id`` header so that SignalR can route + messages to the correct user via the ``nameid`` JWT claim. + """ + try: + user_id = req.headers.get("x-user-id", "") + base_url = signalr_client._endpoint.rstrip("/") + client_url = f"{base_url}/client/?hub={SIGNALR_HUB_NAME}" + access_token = signalr_client._generate_token(client_url, user_id=user_id or None) + + body = json.dumps({"url": client_url, "accessToken": access_token}) + return func.HttpResponse(body=body, mimetype="application/json") + except Exception as ex: + logging.error("Failed to negotiate SignalR connection: %s", ex) + return func.HttpResponse( + json.dumps({"error": str(ex)}), + status_code=500, + mimetype="application/json", + ) + + +@app.function_name("createThread") +@app.route(route="agent/create-thread", methods=["POST"]) +def create_thread(req: func.HttpRequest) -> func.HttpResponse: + """Create a new thread_id and register the user mapping. + + The client must pass an ``x-user-id`` header so that the callback knows + which SignalR user to target when streaming responses for this thread. + """ + user_id = req.headers.get("x-user-id", "") + thread_id = uuid.uuid4().hex + if user_id: + _thread_user_map[thread_id] = user_id + return func.HttpResponse( + json.dumps({"thread_id": thread_id}), + mimetype="application/json", + ) + + +@app.route(route="index", methods=["GET"]) +def index(req: func.HttpRequest) -> func.HttpResponse: + """Serve the web interface.""" + html_path = os.path.join(os.path.dirname(__file__), "content", "index.html") + try: + with open(html_path) as f: + return func.HttpResponse(f.read(), mimetype="text/html") + except FileNotFoundError: + logging.error("index.html not found at path: %s", html_path) + return func.HttpResponse( + json.dumps({"error": "index.html not found"}), + status_code=404, + mimetype="application/json", + ) + except OSError as ex: + logging.error("Failed to read index.html at path %s: %s", html_path, ex) + return func.HttpResponse( + json.dumps({"error": "Failed to load index page"}), + status_code=500, + mimetype="application/json", + ) + + +""" +Sample output: + +1. Start the Functions host: func start +2. Open http://localhost:7071/api/index in a browser +3. The page connects to Azure SignalR Service and displays a chat interface +4. Type "Plan a 3-day trip to Singapore" and press Send +5. The agent streams its response in real time through SignalR + +User:> Plan a 3-day trip to Singapore +Agent:> 🌴 **3-Day Singapore Itinerary** ... + (response streamed chunk-by-chunk via SignalR) +""" diff --git a/python/samples/05-end-to-end/signalr_streaming/host.json b/python/samples/05-end-to-end/signalr_streaming/host.json new file mode 100644 index 0000000000..9e7fd873dd --- /dev/null +++ b/python/samples/05-end-to-end/signalr_streaming/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "hubName": "%TASKHUB_NAME%" + } + } +} diff --git a/python/samples/05-end-to-end/signalr_streaming/local.settings.json.template b/python/samples/05-end-to-end/signalr_streaming/local.settings.json.template new file mode 100644 index 0000000000..a5f8a008a0 --- /dev/null +++ b/python/samples/05-end-to-end/signalr_streaming/local.settings.json.template @@ -0,0 +1,13 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "TASKHUB_NAME": "default", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", + "AZURE_OPENAI_API_KEY": "", + "AzureSignalRConnectionString": "Endpoint=https://.service.signalr.net;AccessKey=;Version=1.0;ServiceMode=Serverless;", + "SIGNALR_HUB_NAME": "travel" + } +} diff --git a/python/samples/05-end-to-end/signalr_streaming/requirements.txt b/python/samples/05-end-to-end/signalr_streaming/requirements.txt new file mode 100644 index 0000000000..b671bae0fd --- /dev/null +++ b/python/samples/05-end-to-end/signalr_streaming/requirements.txt @@ -0,0 +1,16 @@ +# Agent Framework packages +# To use the deployed version, uncomment the line below and comment out the local installation lines +# agent-framework-azurefunctions + +# Local installation (for development and testing) +# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. +# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. +-e ../../../packages/core # Core framework - base dependency for all packages +-e ../../../packages/durabletask # Durable Task support - dependency of azurefunctions +-e ../../../packages/azurefunctions # Azure Functions integration - the main package for this sample + +# Azure authentication +azure-identity + +# HTTP client for SignalR REST API +aiohttp diff --git a/python/samples/05-end-to-end/signalr_streaming/tools.py b/python/samples/05-end-to-end/signalr_streaming/tools.py new file mode 100644 index 0000000000..2448987a27 --- /dev/null +++ b/python/samples/05-end-to-end/signalr_streaming/tools.py @@ -0,0 +1,172 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Mock travel tools for demonstration purposes. + +In a real application, these would call actual weather and events APIs. +""" + +from __future__ import annotations + +from typing import Annotated + + +def get_weather_forecast( + destination: Annotated[str, "The destination city or location"], + date: Annotated[str, 'The date for the forecast (e.g., "2025-01-15" or "next Monday")'], +) -> str: + """Get the weather forecast for a destination on a specific date. + + Use this to provide weather-aware recommendations in the itinerary. + + Args: + destination: The destination city or location. + date: The date for the forecast. + + Returns: + A weather forecast summary. + """ + # Mock weather data based on destination for realistic responses + weather_by_region = { + "Tokyo": ("Partly cloudy with a chance of light rain", 58, 45), + "Paris": ("Overcast with occasional drizzle", 52, 41), + "New York": ("Clear and cold", 42, 28), + "London": ("Foggy morning, clearing in afternoon", 48, 38), + "Sydney": ("Sunny and warm", 82, 68), + "Rome": ("Sunny with light breeze", 62, 48), + "Barcelona": ("Partly sunny", 59, 47), + "Amsterdam": ("Cloudy with light rain", 46, 38), + "Dubai": ("Sunny and hot", 85, 72), + "Singapore": ("Tropical thunderstorms in afternoon", 88, 77), + "Bangkok": ("Hot and humid, afternoon showers", 91, 78), + "Los Angeles": ("Sunny and pleasant", 72, 55), + "San Francisco": ("Morning fog, afternoon sun", 62, 52), + "Seattle": ("Rainy with breaks", 48, 40), + "Miami": ("Warm and sunny", 78, 65), + "Honolulu": ("Tropical paradise weather", 82, 72), + } + + # Find a matching destination or use a default + forecast = ("Partly cloudy", 65, 50) + for city, weather in weather_by_region.items(): + if city.lower() in destination.lower(): + forecast = weather + break + + condition, high_f, low_f = forecast + high_c = (high_f - 32) * 5 // 9 + low_c = (low_f - 32) * 5 // 9 + + recommendation = _get_weather_recommendation(condition) + + return f"""Weather forecast for {destination} on {date}: +Conditions: {condition} +High: {high_f}°F ({high_c}°C) +Low: {low_f}°F ({low_c}°C) + +Recommendation: {recommendation}""" + + +def get_local_events( + destination: Annotated[str, "The destination city or location"], + date: Annotated[str, 'The date to search for events (e.g., "2025-01-15" or "next week")'], +) -> str: + """Get local events and activities happening at a destination around a specific date. + + Use this to suggest timely activities and experiences. + + Args: + destination: The destination city or location. + date: The date to search for events. + + Returns: + A list of local events and activities. + """ + # Mock events data based on destination + events_by_city = { + "Tokyo": [ + "🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama", + "🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays", + "🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan", + "🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology", + ], + "Paris": [ + "🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours", + "🍷 Wine Tasting Tour in Le Marais - Local sommelier guided", + "🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club", + "🥐 French Pastry Workshop - Learn from master pâtissiers", + ], + "New York": [ + "🎭 Broadway Show: Hamilton - Limited engagement performances", + "🏀 Knicks vs Lakers at Madison Square Garden", + "🎨 Modern Art Exhibit at MoMA - New installations", + "🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias", + ], + "London": [ + "👑 Royal Collection Exhibition at Buckingham Palace", + "🎭 West End Musical: The Phantom of the Opera", + "🍺 Craft Beer Festival at Brick Lane", + "🎪 Winter Wonderland at Hyde Park - Rides and markets", + ], + "Sydney": [ + "🏄 Pro Surfing Competition at Bondi Beach", + "🎵 Opera at Sydney Opera House - La Bohème", + "🦘 Wildlife Night Safari at Taronga Zoo", + "🍽️ Harbor Dinner Cruise with fireworks", + ], + "Rome": [ + "🏛️ After-Hours Vatican Tour - Skip the crowds", + "🍝 Pasta Making Class in Trastevere", + "🎵 Classical Concert at Borghese Gallery", + "🍷 Wine Tasting in Roman Cellars", + ], + "Singapore": [ + "🌆 Marina Bay Light Show - Spectacular nightly display", + "🍜 Hawker Center Food Tour - Authentic local cuisine", + "🌿 Night Safari at Singapore Zoo - World's first nocturnal zoo", + "🏛️ Peranakan Culture Experience at Katong", + ], + } + + # Find events for the destination or use generic events + events = [ + "🎭 Local theater performance", + "🍽️ Food and wine festival", + "🎨 Art gallery opening", + "🎵 Live music at local venues", + ] + + for city, city_events in events_by_city.items(): + if city.lower() in destination.lower(): + events = city_events + break + + event_list = "\n• ".join(events) + return f"""Local events in {destination} around {date}: + +• {event_list} + +💡 Tip: Book popular events in advance as they may sell out quickly!""" + + +def _get_weather_recommendation(condition: str) -> str: + """Get a recommendation based on weather conditions. + + Args: + condition: The weather condition description. + + Returns: + A recommendation string. + """ + condition_lower = condition.lower() + + if "rain" in condition_lower or "drizzle" in condition_lower: + return "Bring an umbrella and waterproof jacket. Consider indoor activities for backup." + if "fog" in condition_lower: + return "Morning visibility may be limited. Plan outdoor sightseeing for afternoon." + if "cold" in condition_lower: + return "Layer up with warm clothing. Hot drinks and cozy cafés recommended." + if "hot" in condition_lower or "warm" in condition_lower: + return "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours." + if "thunder" in condition_lower or "storm" in condition_lower: + return "Keep an eye on weather updates. Have indoor alternatives ready." + return "Pleasant conditions expected. Great day for outdoor exploration!"