diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 04d08ba..da2c7c4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,22 +4,43 @@ on: push: branches: [ deploy ] +permissions: + contents: read + packages: write env: PYTHON_VERSION: '3.12' NODE_VERSION: '18' + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + + - name: Set lowercase image name + run: echo "IMAGE_NAME=$(echo ghcr.io/${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build Docker image (Nginx + FastAPI) run: | - docker build -t weather-center-chat:ci . + docker build -t weather-center-chat:ci -t ${{ env.IMAGE_NAME }}:latest . + + - name: Push to GHCR + run: | + docker push ${{ env.IMAGE_NAME }}:latest + - name: Save Docker image as artifact run: | docker save weather-center-chat:ci -o image.tar + - name: Upload image artifact uses: actions/upload-artifact@v4 with: @@ -58,24 +79,12 @@ jobs: done echo "service not ready"; docker logs app || true; exit 1 - - name: Set up Python (for client tests) - uses: actions/setup-python@v4 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv (official installer) - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Sync backend deps for requests - run: | - cd backend - uv sync + - name: Install test dependencies + run: pip install requests - - name: Run test_local.py against Nginx + - name: Run integration tests run: | - BACKEND_BASE_URL=http://localhost:8080 uv run --directory backend python ../test_local.py + BACKEND_BASE_URL=http://localhost:8080 python test_local.py env: VISUAL_CROSSING_API_KEY: ${{ secrets.VISUAL_CROSSING_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} @@ -90,29 +99,30 @@ jobs: render_deploy: needs: test runs-on: ubuntu-latest - if: github.ref == 'refs/heads/deploy' - + if: github.ref == 'refs/heads/deploy' steps: - - uses: actions/checkout@v4 - - - name: Deploy to Render (Manual) - run: | - echo "Deployment to Render should be configured in Render dashboard" - echo "Environment variables are available in GitHub Secrets:" - echo "" - echo "Backend variables:" - echo "- VISUAL_CROSSING_API_KEY: ${{ secrets.VISUAL_CROSSING_API_KEY != '' }}" - echo "- GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY != '' }}" - - name: Notify deployment status - run: | - echo "✅ Tests passed and deployment is ready" - echo "📋 Next steps:" - echo "1. Go to Render.com dashboard" - echo "2. Create new Web Service from this repository" - echo "3. Use Docker deployment with render.yaml" - echo "4. Set environment variables:" - echo " Backend:" - echo " - VISUAL_CROSSING_API_KEY" - echo " - GOOGLE_API_KEY" - echo "5. Deploy!" \ No newline at end of file + run: echo "✅ Tests passed — Render deploys automatically via dashboard webhook" + + azure_deploy: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/deploy' + steps: + - name: Set lowercase image name + run: echo "IMAGE_NAME=$(echo ghcr.io/${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Log in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Deploy to Azure Container Apps + uses: azure/container-apps-deploy-action@v1 + with: + containerAppName: weather-chat-env + resourceGroup: weather-chat-env + imageToDeploy: ${{ env.IMAGE_NAME }}:latest + registryUrl: ghcr.io + registryUsername: po33ski + registryPassword: ${{ secrets.GHCR_PAT }} \ No newline at end of file diff --git a/README.md b/README.md index 0b1d938..3de5db6 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,205 @@ # Weather Center Chat -Weather Center Chat focuses on a single conversational experience: users ask for **weather insights** and **weather-aware travel advice**, and the app orchestrates Google ADK agents to produce concise human responses plus a structured `weather-json` payload that powers the chat UI. +Weather Center Chat is a conversational assistant that combines **weather data**, **weather-aware travel advice**, and **hotel search**. Users chat naturally, and the app orchestrates a Google ADK multi-agent system to produce human-friendly responses paired with structured JSON payloads that drive the chat UI panels. + +## Features + +- **Current weather** — real-time conditions (temperature, wind, humidity, pressure, sunrise/sunset) +- **Weather forecast** — up to 15-day forecast for any city +- **Historical weather** — data for any past date range +- **Travel advice** — three weather-adapted activity suggestions for a city (outdoor vs indoor based on conditions) +- **Hotel search** — finds up to 3 hotels in any city via live web search (Tavily), returning price, rating, reviews and booking highlights ## AI Chat Flow -- A React (Vite SPA) chat surface (`frontend/src/app/views/ChatPage.tsx`) renders the human-friendly message and consumes the structured `weather-json` block returned by the agent. -- The FastAPI backend exposes `/api/chat`, keeps lightweight per-browser sessions alive, and pipes user prompts into a Google ADK runner. -- The response travels back as one string that combines chat text and the fenced JSON block so the frontend can deterministically split it. +``` +User message + │ + ▼ +FastAPI /api/chat + │ + ▼ +Google ADK Runner ──► root_agent (weather_assistant) + │ + ┌───────┼───────────────┐ + ▼ ▼ ▼ + get_weather travel_advice search_hotels + _agent _agent _agent + │ │ + weather tools Tavily search + (Visual Crossing API) (booking.com, + hotels.com, etc.) +``` + +- The **root agent** maintains a per-session context template (city, date range, language) and routes to the correct child agent. +- Every response returns a human-readable text block **plus** one fenced JSON block (`weather-json` or `hotel-json`) that the frontend parses to render the side panel. +- The FastAPI layer validates the structured payload with Pydantic before sending it to the client. ## Google ADK Agent Graph ```text backend/agent_system/src/multi_tool_agent/ -├── agent.py # root agent stitched into FastAPI -├── prompt.py # global instructions/shared context template +├── agent.py # root agent wired into FastAPI +├── prompt.py # routing logic + shared context template ├── sub_agents/ │ ├── get_weather/ -│ │ ├── agent.py # specialist enforcing weather-json output contract +│ │ ├── agent.py # enforces weather-json output contract +│ │ └── prompt.py +│ ├── travel_advice/ +│ │ ├── agent.py # suggests activities based on current weather │ │ └── prompt.py -│ └── travel_advice/ -│ ├── agent.py # specialist suggesting places/activities based on weather +│ └── search_hotels/ +│ ├── agent.py # calls Tavily, extracts hotel data, returns hotel-json │ └── prompt.py -└── tools/ - ├── get_current_weather.py # wraps backend weather service: current conditions - ├── get_forecast.py # wraps backend weather service: forecast - ├── get_history_weather.py # wraps backend weather service: historical data - └── send_email.py # SMTP helper (currently unused in the agent graph) +├── tools/ +│ ├── get_current_weather.py # Visual Crossing API — current conditions +│ ├── get_forecast.py # Visual Crossing API — 15-day forecast +│ ├── get_history_weather.py # Visual Crossing API — historical date range +│ └── search_hotels.py # Tavily web search — hotels in a city +└── templates/ + ├── json_format.py # all JSON output schemas (current/forecast/history/hotels) + └── context_template.py # shared session context passed to all agents +``` + +## JSON Output Schemas + +All responses include one fenced JSON block. The frontend uses `meta.kind` to select the correct rendering panel. + +### `weather-json` — weather and forecast data + +**`kind: "current"`** +```json +{ + "meta": { "city": "Krakow", "kind": "current", "date": "2025-08-01", "date_range": null, "language": "en" }, + "current": { + "temp": 22, "tempmax": 25, "tempmin": 16, + "windspeed": 14, "winddir": 200, "pressure": 1012, + "humidity": 60, "sunrise": "05:10", "sunset": "20:30", + "conditions": "Partly cloudy" + } +} +``` + +**`kind: "forecast"` / `kind: "history"`** +```json +{ + "meta": { "city": "Warsaw", "kind": "forecast", "date": null, "date_range": "2025-08-01..2025-08-15", "language": "pl" }, + "days": [ + { "datetime": "2025-08-01", "temp": 24, "tempmax": 27, "tempmin": 18, "windspeed": 10, "winddir": 180, + "conditions": "Sunny", "sunrise": "05:20", "sunset": "20:10", "pressure": 1015, "humidity": 55 } + ] +} ``` -- The root agent loads into a `google.adk.runners.Runner`, maintains the chat context (city, date range, unit system, language), and decides when to delegate to child agents. -- The weather specialist ensures every weather reply follows the `weather-json` contract and only calls tools when the context is complete. -- The travel advice specialist reads the same context (city + current weather) and returns three short, human‑friendly suggestions (places/activities) tailored to the conditions (sun, rain, heat, cold, wind) without any JSON. -- Tool wrappers read normalized weather data from the backend service, keeping the agent grounded on deterministic values instead of free-form hallucinations. +### `hotel-json` — hotel search results + +**`kind: "hotels"`** +```json +{ + "meta": { "city": "Paris", "kind": "hotels", "date": null, "date_range": "2025-08-10..2025-08-17", "language": "en" }, + "hotels": [ + { + "name": "Hotel Le Marais", + "price_per_night": "145", + "currency": "EUR", + "availability": "available", + "rating": 8.7, + "reviews_count": 2340, + "highlights": ["Central location", "Great breakfast", "Friendly staff"], + "url": "https://www.booking.com/..." + } + ] +} +``` -### Example conversations +## Frontend Components -- **Pure weather:** - - "What is the current weather in Krakow?" - - "What will the weather be in Paris tomorrow?" -- **Weather‑aware travel advice (two‑step flow):** - - Step 1: "What is the current weather in Lisbon?" - - Step 2: "Given this weather, what are three things worth visiting in Lisbon?" - - Under the hood, the root agent first calls `get_weather_agent` and then `travel_advice_agent`, which uses the current weather data for the city. +```text +frontend/src/app/ +├── views/ +│ └── ChatPage.tsx # two-column layout: Chat (left) + panel (right) +├── components/ +│ ├── Chat/Chat.tsx # message input, session management, API calls +│ ├── AiWeatherPanel/ # right panel — switches view based on meta.kind +│ ├── WeatherView/ # renders current weather data +│ ├── DayList/ # renders forecast / history day list +│ └── HotelView/HotelView.tsx # renders hotel cards (price, rating, highlights, booking link) +├── utils/ +│ └── parseAiMessage.ts # splits human text from weather-json / hotel-json fence +└── types/ + ├── aiChat.ts # AiMeta, AiChatData (current | days | hotels) + └── hotelTypes.ts # Hotel, HotelMeta, HotelPayload +``` -## Local Development (quick start) +The `AiWeatherPanel` uses `meta.kind` to decide which component to render: -Requirements: Python 3.12 with `uv`, Node.js 18+, and API keys for Google Generative AI + Visual Crossing. +| `meta.kind` | Rendered component | +|----------------------|--------------------| +| `current` | `WeatherView` | +| `forecast`/`history` | `DayList` | +| `hotels` | `HotelView` | +| `travel_advice` | *(text only)* | + +## Backend API + +| Method | Path | Description | +|--------|--------------|--------------------------------------| +| `POST` | `/api/chat` | Send a message; returns `ChatResponse` | +| `GET` | `/api/health`| Health check + env/service status | + +`ChatResponse` shape: +```json +{ "success": true, "data": { "message": "", "sender": "ai" }, "session_id": "..." } +``` + +## Example Conversations + +**Weather:** +- "What is the current weather in Krakow?" +- "Show me the forecast for Paris for the next 7 days." +- "What was the weather in London last week?" *(asks for date range)* + +**Travel advice** *(two-step)*: +1. "What is the current weather in Lisbon?" +2. "Given this weather, what are three things worth visiting?" → `travel_advice_agent` uses the cached weather context + +**Hotel search:** +- "Find hotels in Rome" +- "Search for hotels in Warsaw for August 10–17" +- "Znajdź hotele w Krakowie na przyszły tydzień" + +## Environment Variables + +| Variable | Required | Description | +|---------------------------|----------|----------------------------------------------| +| `GOOGLE_API_KEY` | ✅ | Google Generative AI key for ADK agents | +| `VISUAL_CROSSING_API_KEY` | ✅ | Visual Crossing Weather API key | +| `TAVILY_API_KEY` | ✅ | Tavily search API key (hotel search) | +| `MODEL` | optional | Gemini model ID (default: `gemini-2.5-flash`)| +| `PUBLIC_WEB_ORIGIN` | optional | Public domain added to CORS allowed origins | +| `ENVIRONMENT` | optional | Set to `production` to enforce required vars | + +Get your free Tavily key at [tavily.com](https://tavily.com) — the free tier provides 1000 requests/month. + +## Local Development + +Requirements: Python 3.12 with `uv`, Node.js 18+. ```bash # 1. Backend cd backend -uv sync -source ../env-scratchpad.sh # exports GOOGLE_API_KEY, VISUAL_CROSSING_API_KEY, etc. +uv sync # installs all deps including tavily-python +source ../env-scratchpad.sh # exports GOOGLE_API_KEY, VISUAL_CROSSING_API_KEY, TAVILY_API_KEY uv run uvicorn api.main:app --reload --port 8000 # 2. Frontend (new terminal) cd frontend npm install -# Optional: point the frontend straight at FastAPI without Nginx export VITE_API_URL=http://localhost:8000 npm run dev -# App will be available at: -# http://localhost:5173 +# App: http://localhost:5173 +# API: http://localhost:8000/api/health ``` ## Docker (single container) @@ -72,6 +209,8 @@ source env-scratchpad.sh ./deploy-production.sh ``` -The script builds the multi-stage image (backend builder → frontend (Vite) builder → runtime), launches Nginx on port 80, and proxies `/api` requests to FastAPI. Use `curl http://localhost/api/health` to confirm the stack is ready. +Builds a multi-stage image (backend + Vite frontend), starts Nginx on port 80 and proxies `/api/*` to FastAPI. -Visit `http://localhost`, sign in, and start chatting—the assistant will stream Google ADK-backed weather answers into the single-page experience. +```bash +curl http://localhost/api/health +``` diff --git a/backend/agent_system/__init__.py b/backend/agent_system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/__init__.py b/backend/agent_system/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/multi_tool_agent/__init__.py b/backend/agent_system/src/multi_tool_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/multi_tool_agent/agent.py b/backend/agent_system/src/multi_tool_agent/agent.py index b43c2be..ddffa9c 100644 --- a/backend/agent_system/src/multi_tool_agent/agent.py +++ b/backend/agent_system/src/multi_tool_agent/agent.py @@ -7,18 +7,17 @@ from .sub_agents.get_weather.agent import get_weather_agent from .sub_agents.travel_advice.agent import travel_advice_agent +from .sub_agents.search_hotels.agent import search_hotels_agent MODEL = load_model() GOOGLE_API_KEY = load_google_api_key() - root_agent = Agent( name=prompt.ROOT_NAME, model=MODEL, - description= prompt.ROOT_DESCRIPTION, + description=prompt.ROOT_DESCRIPTION, global_instruction=prompt.ROOT_GLOBAL_INSTR, instruction=prompt.ROOT_INSTR, - sub_agents=[get_weather_agent, travel_advice_agent], + sub_agents=[get_weather_agent, travel_advice_agent, search_hotels_agent], ) - diff --git a/backend/agent_system/src/multi_tool_agent/prompt.py b/backend/agent_system/src/multi_tool_agent/prompt.py index 4b78b55..d5d16e6 100644 --- a/backend/agent_system/src/multi_tool_agent/prompt.py +++ b/backend/agent_system/src/multi_tool_agent/prompt.py @@ -3,12 +3,12 @@ ROOT_NAME = "weather_assistant" -ROOT_DESCRIPTION = "You are a weather and travel assistant. Your primary job is to provide weather information via your child agent get_weather_agent, and when requested, to provide travel advice via your child agent travel_advice_agent. Always answer in the language detected from the user's most recent message. If you are unsure about the language, respond in English. Keep responses short and direct." +ROOT_DESCRIPTION = "You are a weather, travel, and hotel search assistant. Your primary job is to provide weather information via your child agent get_weather_agent, travel advice via travel_advice_agent, and hotel search via search_hotels_agent. Always answer in the language detected from the user's most recent message. If you are unsure about the language, respond in English. Keep responses short and direct." ROOT_GLOBAL_INSTR = "Detect the language from the latest user message only. If detection is uncertain, default to English. Keep messages concise." ROOT_INSTR = f""" - **INSTRUCTIONS** + **INSTRUCTIONS** - Your job: when the user asks about weather, maintain a CONTEXT TEMPLATE (city, kind, dates, weather information type, specific weather information) updated from the user's messages. - Call get_weather_agent with the city/kind/date info you infer from the CONTEXT TEMPLATE. - If city is missing, ask a single short question to get it. No JSON in that case. @@ -19,7 +19,6 @@ - If user is using different date or date range then you should change the date or date range in your CONTEXT TEMPLATE to the date or date range which user is currently using. - If user is using different weather information type then you should change the weather information type in your CONTEXT TEMPLATE to the weather information type which user is currently using. - If user is using different specific weather information then you should change the specific weather information in your CONTEXT TEMPLATE to the specific weather information which user is currently using. - - If user is using different weather information type then you should change the weather information type in your CONTEXT TEMPLATE to the weather information type which user is currently using. **TRAVEL ADVICE LOGIC** - If the user asks for travel advice, trip ideas, what to do/visit in a city given the weather (e.g. "Co warto zobaczyć w Krakowie przy takiej pogodzie?"): @@ -32,6 +31,19 @@ - Reply to the user by returning exactly what travel_advice_agent returns (only human text, no weather-json). - When answering travel advice questions, do NOT include any weather-json fences in your final reply; only plain human text with recommendations. + **HOTEL SEARCH LOGIC** + - If the user asks to find, search, or suggest hotels in a city (e.g. "znajdź hotele w Paryżu", "find hotels in Rome", "hotels in Warsaw for next week"): + - Extract the city from the user's message. If missing, ask a single short question. + - Extract check-in and check-out dates if provided (format: YYYY-MM-DD). If not provided, pass empty strings. + - Delegate the request to search_hotels_agent by transferring to it. + - Return exactly what search_hotels_agent returns verbatim: + - Human text summary. + - A blank line. + - The fenced hotel-json block. + - Do NOT modify or reformat the search_hotels_agent output. + - Do NOT include any weather-json blocks in hotel search replies. + - Hotel search is INDEPENDENT of weather: you do NOT need weather data first. + **MINIMUM INFO** - You need at least the city. If only a city is given, default to current weather. @@ -44,16 +56,17 @@ **OUTPUT** - Weather replies: exactly what the get_weather_agent child returns (human text + fenced weather-json). Nothing else. - Travel advice replies: exactly what the travel_advice_agent child returns (only human text, no JSON or weather-json fences). + - Hotel search replies: exactly what the search_hotels_agent child returns (human text + fenced hotel-json). Nothing else. - If get_weather_agent returns an error (fenced weather-json with {{"error": "..."}}), return it exactly as received. + - If search_hotels_agent returns an error (fenced hotel-json with {{"error": "..."}}), return it exactly as received. - Other replies (clarifying/missing info): only human text, no JSON! - + **TOOL ERROR HANDLING** - - Tools may raise exceptions (errors) when something goes wrong. - - When a tool raises an exception, Google ADK will inform you about the error. - - get_weather_agent will catch tool exceptions and format them as error responses with fenced weather-json containing {{"error": "error message"}}. - - Pass through error responses from get_weather_agent exactly as received. + - When get_weather_agent encounters a tool error, it returns a response containing a fenced weather-json with {{"error": "message"}}. + - When search_hotels_agent encounters a tool error, it returns a response containing a fenced hotel-json with {{"error": "message"}}. + - Pass through such error responses exactly as received. Do not modify, suppress, or replace them with invented data. **JSON FORMAT (REFERENCE FOR CHILD)** {json_format_instructions} {json_format} -""" \ No newline at end of file +""" diff --git a/backend/agent_system/src/multi_tool_agent/sub_agents/__init__.py b/backend/agent_system/src/multi_tool_agent/sub_agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/__init__.py b/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/agent.py b/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/agent.py index 971f6a6..f3b89f8 100644 --- a/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/agent.py +++ b/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/agent.py @@ -1,3 +1,5 @@ +from typing import Any, Optional + from google.adk.agents import Agent from ...tools.get_current_weather import get_current_weather @@ -7,10 +9,31 @@ from ....utils.load_env_data import load_model + +def _after_tool_callback( + tool: Any, + args: dict[str, Any], + tool_context: Any, + tool_response: dict[str, Any], +) -> Optional[dict[str, Any]]: + """ + Safety net: normalize tool error responses to {"error": "..."} format. + + Tools return {"error": "..."} on failure. This callback ensures the format + is always consistent before the response reaches the LLM, regardless of + what the tool returned. + """ + if isinstance(tool_response, dict) and "error" in tool_response: + error_msg = str(tool_response.get("error") or "Tool returned an unknown error.") + return {"error": error_msg} + return None # Successful responses are passed through unchanged + + get_weather_agent = Agent( model=load_model(), name=prompt.GET_WEATHER_AGENT_NAME, instruction=prompt.GET_WEATHER_AGENT_INSTRUCTION, tools=[get_current_weather, get_forecast, get_history_weather], - output_key='get_weather_agent_prompt' -) \ No newline at end of file + output_key='get_weather_agent_prompt', + after_tool_callback=_after_tool_callback, +) diff --git a/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/prompt.py b/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/prompt.py index 0e896da..c4c0838 100644 --- a/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/prompt.py +++ b/backend/agent_system/src/multi_tool_agent/sub_agents/get_weather/prompt.py @@ -19,14 +19,13 @@ 3. get_history_weather(city, start_date, end_date) - Get historical weather data for a city and date range (returns a dictionary with weather data in metric units) **TOOL ERROR HANDLING** - - Tools may raise exceptions (errors) when something goes wrong (e.g., missing city, API failure, configuration error). - - When a tool raises an exception, Google ADK will inform you about the error. - - You MUST catch and handle these errors by returning an error response in the following format: + - Tools return a dict with an "error" key when something goes wrong (e.g., {{"error": "City not found."}}). + - When a tool returns a dict that contains an "error" key, you MUST return an error response. Do NOT invent or hallucinate any weather data. + - Error response format: - Human text: A brief explanation of the error (1-2 sentences) in the user's language. - A blank line. - - A fenced JSON block labeled weather-json containing ONLY: {{"error": "error message from the exception"}} - - Extract the error message from the exception and use it in the error response. - - If a tool executes successfully, it returns a dictionary with weather data that you should process and format according to the OUTPUT FORMAT section. + - A fenced JSON block labeled weather-json containing ONLY: {{"error": "error message from the tool"}} + - If a tool returns a dict WITHOUT an "error" key, the call succeeded. Process and format the data according to the OUTPUT FORMAT section. TOOL SELECTION RULES (NO DATE TOOLS): - Detect the requested kind from your CONTEXT TEMPLATE and the user's message: @@ -56,19 +55,19 @@ {context_template} **OUTPUT FORMAT (STRICT, THREE TEMPLATES)** - - If a tool raises an exception (error), you MUST return an error response in the following format: + - If a tool returns {{"error": "..."}}, you MUST return an error response in the following format: - Human text: A brief explanation of the error (1-2 sentences) in the user's language. - A blank line. - - A fenced JSON block labeled weather-json containing ONLY: {{"error": "error message from the exception"}} + - A fenced JSON block labeled weather-json containing ONLY: {{"error": "error message from the tool"}} Example: ``` I encountered an error while fetching the weather data. - + ```weather-json - {{"error": "No city provided."}} + {{"error": "City not found or invalid."}} ``` ``` - - If a tool executes successfully and returns valid weather data (dict), format it according to the JSON FORMAT section below. + - If a tool returns data WITHOUT an "error" key, the call succeeded. Format it according to the JSON FORMAT section below. INSTRUCTIONS FOR OUTPUT FORMAT (VERY IMPORTANT): {json_format_instructions} diff --git a/backend/agent_system/src/multi_tool_agent/sub_agents/search_hotels/__init__.py b/backend/agent_system/src/multi_tool_agent/sub_agents/search_hotels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/multi_tool_agent/sub_agents/search_hotels/agent.py b/backend/agent_system/src/multi_tool_agent/sub_agents/search_hotels/agent.py new file mode 100644 index 0000000..d1b23e5 --- /dev/null +++ b/backend/agent_system/src/multi_tool_agent/sub_agents/search_hotels/agent.py @@ -0,0 +1,30 @@ +from typing import Any, Optional + +from google.adk.agents import Agent + +from ...tools.search_hotels import search_hotels +from . import prompt +from ....utils.load_env_data import load_model + + +def _after_tool_callback( + tool: Any, + args: dict[str, Any], + tool_context: Any, + tool_response: dict[str, Any], +) -> Optional[dict[str, Any]]: + """Normalize tool error responses to {"error": "..."} format.""" + if isinstance(tool_response, dict) and "error" in tool_response: + error_msg = str(tool_response.get("error") or "Hotel search returned an unknown error.") + return {"error": error_msg} + return None + + +search_hotels_agent = Agent( + model=load_model(), + name=prompt.SEARCH_HOTELS_AGENT_NAME, + instruction=prompt.SEARCH_HOTELS_AGENT_INSTRUCTION, + tools=[search_hotels], + output_key="search_hotels_agent_output", + after_tool_callback=_after_tool_callback, +) diff --git a/backend/agent_system/src/multi_tool_agent/sub_agents/search_hotels/prompt.py b/backend/agent_system/src/multi_tool_agent/sub_agents/search_hotels/prompt.py new file mode 100644 index 0000000..7fc74a5 --- /dev/null +++ b/backend/agent_system/src/multi_tool_agent/sub_agents/search_hotels/prompt.py @@ -0,0 +1,81 @@ +from ...templates.context_template import context_template, context_template_instructions + +SEARCH_HOTELS_AGENT_NAME = "search_hotels_agent" + +SEARCH_HOTELS_AGENT_INSTRUCTION = f""" + **MAIN INSTRUCTIONS** + - You are a specialized hotel search agent. + - Your job is to call the search_hotels tool and then extract structured hotel data from the raw results. + - You receive the city (and optionally check-in/check-out dates) from your parent agent via the CONTEXT TEMPLATE. + - You do NOT make up hotel data. All data must come from the tool results. + + **AVAILABLE TOOLS** + - search_hotels(city, check_in="", check_out="") — searches for hotels via Tavily web search. + Returns: {{"city": "...", "check_in": "...", "check_out": "...", "results": [...]}}. + Each result has: url, title, content, score. + On failure returns: {{"error": "..."}}. + + **TOOL ERROR HANDLING** + - If the tool returns {{"error": "..."}} you MUST return an error response: + - Human text: brief explanation in the user's language (1-2 sentences). + - A blank line. + - A fenced hotel-json block containing ONLY: {{"error": "the error message"}} + - Do NOT invent hotel data if the tool fails. + + **EXTRACTION RULES** + - From the raw "content" and "title" fields of each tool result, extract: + - name: hotel name (string) + - price_per_night: numeric value only, no currency symbol (string, e.g. "120") + - currency: currency code if detectable (string, e.g. "EUR", "USD", "PLN"), otherwise "" + - availability: "available" if dates mentioned and bookable, "unknown" if not determinable + - rating: numeric rating out of 10 (float, e.g. 8.5) or out of 5 (convert to 10 scale), null if not found + - reviews_count: integer number of reviews, null if not found + - highlights: list of 2-3 short strings about the hotel (location, amenities, guest praise) + - url: the source URL + - Extract exactly 3 hotels. If fewer than 3 are clearly identifiable, extract as many as possible (minimum 1). + - Do NOT duplicate hotels. Each entry must be a distinct property. + - If price is not determinable from the content, set price_per_night to "". + + **CONTEXT TEMPLATE** + {context_template} + + **CONTEXT TEMPLATE INSTRUCTIONS** + {context_template_instructions} + + **OUTPUT FORMAT (STRICT)** + Return exactly ONE message: + 1) Human text (2-4 sentences) summarizing the hotels found, in the language from the CONTEXT TEMPLATE. + 2) A single blank line. + 3) ONE fenced block labeled hotel-json containing ONLY the JSON below. + + hotel-json schema: + {{ + "meta": {{ + "city": "", + "kind": "hotels", + "date": null, + "date_range": ".." or null if no dates provided, + "language": "" + }}, + "hotels": [ + {{ + "name": "", + "price_per_night": "", + "currency": "", + "availability": "available" | "unknown", + "rating": , + "reviews_count": , + "highlights": ["", ""], + "url": "" + }} + ] + }} + + **RULES** + - Do NOT include any extra text after the closing ``` of the hotel-json block. + - Do NOT use weather-json; always use hotel-json. + - Set meta.language to the language you used in the human text. + - The "hotels" array must contain between 1 and 3 objects. + - Do NOT include any other fenced blocks. + - Do not introduce yourself; answer directly with the summary and the JSON. +""" diff --git a/backend/agent_system/src/multi_tool_agent/sub_agents/travel_advice/__init__.py b/backend/agent_system/src/multi_tool_agent/sub_agents/travel_advice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/multi_tool_agent/templates/__init__.py b/backend/agent_system/src/multi_tool_agent/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/multi_tool_agent/templates/json_format.py b/backend/agent_system/src/multi_tool_agent/templates/json_format.py index 680522c..bfc62a1 100644 --- a/backend/agent_system/src/multi_tool_agent/templates/json_format.py +++ b/backend/agent_system/src/multi_tool_agent/templates/json_format.py @@ -3,7 +3,7 @@ - Return ONE message composed of: 1) Human text (1–3 sentences if user ask only for weather information or longer text if user ask for travel advice, trip ideas, what to do/visit in a city given the weather). 2) A single blank line. - 3) ONE fenced JSON block labeled weather-json that contains ONLY JSON. + 3) ONE fenced JSON block labeled weather-json (for weather/travel data) or hotel-json (for hotel search results) that contains ONLY JSON. - No extra code blocks, no extra text below the fence. - The UI parses the short text (above) and the JSON (inside the fenced block). - The JSON must follow one of the schemas below. @@ -85,4 +85,48 @@ } ] } -""" \ No newline at end of file + +TRAVEL_ADVICE (if user asks for travel advice) +{ + "meta": { + "city": "", + "kind": "travel_advice", + "date": null, + "date_range": null, + "language": "", + }, + "travel_advice": [ + { + "text": "" + } + ] +} + +HOTELS (when user asks to search or find hotels in a city) — uses hotel-json fence, NOT weather-json +```hotel-json +{ + "meta": { + "city": "", + "kind": "hotels", + "date": null, + "date_range": ".. or null if no dates given", + "language": "" + }, + "hotels": [ + { + "name": "", + "price_per_night": "", + "currency": "", + "availability": "available | unknown", + "rating": , + "reviews_count": , + "highlights": ["", "", ""], + "url": "" + } + ] +} +``` +- The hotels array must contain between 1 and 3 hotel objects. +- Use the hotel-json fence label (not weather-json) for hotel responses. +- The frontend detects the hotel-json fence and renders a dedicated hotel card view. +""" diff --git a/backend/agent_system/src/multi_tool_agent/tools/__init__.py b/backend/agent_system/src/multi_tool_agent/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/multi_tool_agent/tools/get_current_weather.py b/backend/agent_system/src/multi_tool_agent/tools/get_current_weather.py index b42aa28..69f5bde 100644 --- a/backend/agent_system/src/multi_tool_agent/tools/get_current_weather.py +++ b/backend/agent_system/src/multi_tool_agent/tools/get_current_weather.py @@ -1,42 +1,44 @@ import requests -import os import json +import os from typing import Dict, Any -from .exceptions import ToolValidationError, ToolConfigurationError, ToolAPIError +from .utils import normalize_sunrise_sunset -# Load Visual Crossing API key from environment variables -API_KEY = os.getenv("VISUAL_CROSSING_API_KEY") API_HTTP = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/" + def get_current_weather(city: str) -> Dict[str, Any]: """ Fetch current weather data for a given city using the Visual Crossing API. - Returns a dictionary with weather data. - + Returns a dictionary with weather data, or {"error": "message"} on failure. + Args: city: The city name - + Returns: - Dict containing weather data from API - - Raises: - ToolValidationError: If city is not provided - ToolConfigurationError: If API key is not configured - ToolAPIError: If API request fails or response cannot be parsed + Dict containing weather data from API, or {"error": "..."} if the call failed. """ if not city: - raise ToolValidationError("No city provided.") - if not API_KEY: - raise ToolConfigurationError("API key not found.") - - url = f"{API_HTTP}{city}?unitGroup=metric&key={API_KEY}&contentType=json" + return {"error": "No city provided."} + + api_key = os.getenv("VISUAL_CROSSING_API_KEY") + if not api_key: + return {"error": "Weather service API key is not configured."} + + url = f"{API_HTTP}{city}?unitGroup=metric&key={api_key}&contentType=json" try: response = requests.get(url, timeout=10) response.raise_for_status() - weather_data = response.json() # Parse JSON to dict - return weather_data - except requests.exceptions.RequestException as e: - raise ToolAPIError(f"API request failed: {str(e)}") from e - except json.JSONDecodeError as e: - raise ToolAPIError(f"Failed to parse API response: {str(e)}") from e \ No newline at end of file + weather_data = response.json() + return normalize_sunrise_sunset(weather_data) + except requests.exceptions.HTTPError as e: + if e.response.status_code == 400: + return {"error": f"City '{city}' not found or invalid."} + return {"error": f"Weather service error ({e.response.status_code})."} + except requests.exceptions.Timeout: + return {"error": "Weather service request timed out."} + except requests.exceptions.RequestException: + return {"error": "Weather service is temporarily unavailable."} + except json.JSONDecodeError: + return {"error": "Weather service returned invalid data."} diff --git a/backend/agent_system/src/multi_tool_agent/tools/get_forecast.py b/backend/agent_system/src/multi_tool_agent/tools/get_forecast.py index 9e4cd89..15d2bb4 100644 --- a/backend/agent_system/src/multi_tool_agent/tools/get_forecast.py +++ b/backend/agent_system/src/multi_tool_agent/tools/get_forecast.py @@ -1,42 +1,44 @@ import requests -import os import json +import os from typing import Dict, Any -from .exceptions import ToolValidationError, ToolConfigurationError, ToolAPIError +from .utils import normalize_sunrise_sunset -# Load Visual Crossing API key from environment variables -API_KEY = os.getenv("VISUAL_CROSSING_API_KEY") API_HTTP = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/" + def get_forecast(city: str) -> Dict[str, Any]: """ Fetch weather forecast data for a given city using the Visual Crossing API. - Returns a dictionary with weather data. - + Returns a dictionary with weather data, or {"error": "message"} on failure. + Args: city: The city name - + Returns: - Dict containing weather data from API - - Raises: - ToolValidationError: If city is not provided - ToolConfigurationError: If API key is not configured - ToolAPIError: If API request fails or response cannot be parsed + Dict containing weather data from API, or {"error": "..."} if the call failed. """ if not city: - raise ToolValidationError("No city provided.") - if not API_KEY: - raise ToolConfigurationError("API key not found.") - - url = f"{API_HTTP}{city}?unitGroup=metric&key={API_KEY}&contentType=json" + return {"error": "No city provided."} + + api_key = os.getenv("VISUAL_CROSSING_API_KEY") + if not api_key: + return {"error": "Weather service API key is not configured."} + + url = f"{API_HTTP}{city}?unitGroup=metric&key={api_key}&contentType=json" try: response = requests.get(url, timeout=10) response.raise_for_status() - weather_data = response.json() # Parse JSON to dict - return weather_data - except requests.exceptions.RequestException as e: - raise ToolAPIError(f"API request failed: {str(e)}") from e - except json.JSONDecodeError as e: - raise ToolAPIError(f"Failed to parse API response: {str(e)}") from e \ No newline at end of file + weather_data = response.json() + return normalize_sunrise_sunset(weather_data) + except requests.exceptions.HTTPError as e: + if e.response.status_code == 400: + return {"error": f"City '{city}' not found or invalid."} + return {"error": f"Weather service error ({e.response.status_code})."} + except requests.exceptions.Timeout: + return {"error": "Weather service request timed out."} + except requests.exceptions.RequestException: + return {"error": "Weather service is temporarily unavailable."} + except json.JSONDecodeError: + return {"error": "Weather service returned invalid data."} diff --git a/backend/agent_system/src/multi_tool_agent/tools/get_history_weather.py b/backend/agent_system/src/multi_tool_agent/tools/get_history_weather.py index 13152ef..452f7ad 100644 --- a/backend/agent_system/src/multi_tool_agent/tools/get_history_weather.py +++ b/backend/agent_system/src/multi_tool_agent/tools/get_history_weather.py @@ -1,61 +1,48 @@ import requests -import os import json -from datetime import datetime +import os from typing import Dict, Any -from .exceptions import ToolValidationError, ToolConfigurationError, ToolAPIError +from .utils import normalize_sunrise_sunset -# Load Visual Crossing API key from environment variables -API_KEY = os.getenv("VISUAL_CROSSING_API_KEY") API_HTTP = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/" -def normal_date_formatted(d: datetime) -> str: - """ - Format date to YYYY-MM-DD format, similar to the frontend function. - """ - if d: - return ( - str(d.year) + - "-" + - ("0" + str(d.month + 1))[-2:] + - "-" + - ("0" + str(d.day))[-2:] - ) - return "" def get_history_weather(city: str, start_date: str, end_date: str) -> Dict[str, Any]: """ Fetch historical weather data for a given city and date range using the Visual Crossing API. - Returns a dictionary with weather data. - + Returns a dictionary with weather data, or {"error": "message"} on failure. + Args: city: The city name start_date: Start date in YYYY-MM-DD format end_date: End date in YYYY-MM-DD format - + Returns: - Dict containing weather data from API - - Raises: - ToolValidationError: If city, start_date, or end_date is not provided - ToolConfigurationError: If API key is not configured - ToolAPIError: If API request fails or response cannot be parsed + Dict containing weather data from API, or {"error": "..."} if the call failed. """ if not city: - raise ToolValidationError("No city provided.") + return {"error": "No city provided."} if not start_date or not end_date: - raise ToolValidationError("Both start_date and end_date are required.") - if not API_KEY: - raise ToolConfigurationError("API key not found.") - - url = f"{API_HTTP}{city}/{start_date}/{end_date}?unitGroup=metric&key={API_KEY}&contentType=json" + return {"error": "Both start_date and end_date are required."} + + api_key = os.getenv("VISUAL_CROSSING_API_KEY") + if not api_key: + return {"error": "Weather service API key is not configured."} + + url = f"{API_HTTP}{city}/{start_date}/{end_date}?unitGroup=metric&key={api_key}&contentType=json" try: response = requests.get(url, timeout=10) response.raise_for_status() - weather_data = response.json() # Parse JSON to dict - return weather_data - except requests.exceptions.RequestException as e: - raise ToolAPIError(f"API request failed: {str(e)}") from e - except json.JSONDecodeError as e: - raise ToolAPIError(f"Failed to parse API response: {str(e)}") from e \ No newline at end of file + weather_data = response.json() + return normalize_sunrise_sunset(weather_data) + except requests.exceptions.HTTPError as e: + if e.response.status_code == 400: + return {"error": f"City '{city}' not found or invalid date range."} + return {"error": f"Weather service error ({e.response.status_code})."} + except requests.exceptions.Timeout: + return {"error": "Weather service request timed out."} + except requests.exceptions.RequestException: + return {"error": "Weather service is temporarily unavailable."} + except json.JSONDecodeError: + return {"error": "Weather service returned invalid data."} diff --git a/backend/agent_system/src/multi_tool_agent/tools/search_hotels.py b/backend/agent_system/src/multi_tool_agent/tools/search_hotels.py new file mode 100644 index 0000000..785f93c --- /dev/null +++ b/backend/agent_system/src/multi_tool_agent/tools/search_hotels.py @@ -0,0 +1,68 @@ +import os +from typing import Any, Dict + +from .exceptions import ToolConfigurationError + + +def search_hotels(city: str, check_in: str = "", check_out: str = "") -> Dict[str, Any]: + """ + Search for hotels in a given city using Tavily web search. + Returns raw search results from booking sites so the agent can extract + structured hotel data (name, price, rating, reviews, highlights). + + Args: + city: The city name to search hotels in (required). + check_in: Check-in date in YYYY-MM-DD format (optional). + check_out: Check-out date in YYYY-MM-DD format (optional). + + Returns: + Dict with "results" list (each entry has url, title, content, score) + or {"error": "..."} on failure. + """ + if not city: + return {"error": "No city provided."} + + api_key = os.getenv("TAVILY_API_KEY") + if not api_key: + return {"error": "Hotel search API key (TAVILY_API_KEY) is not configured."} + + try: + from tavily import TavilyClient + except ImportError: + return {"error": "Hotel search library is not installed. Run: pip install tavily-python"} + + date_hint = "" + if check_in: + date_hint += f" check-in {check_in}" + if check_out: + date_hint += f" check-out {check_out}" + + query = f"hotels in {city}{date_hint} price per night rating reviews booking" + + try: + client = TavilyClient(api_key=api_key) + response = client.search( + query=query, + search_depth="advanced", + max_results=5, + include_domains=["booking.com", "hotels.com", "tripadvisor.com"], + ) + + results = response.get("results", []) + if not results: + return {"error": f"No hotel results found for '{city}'."} + + simplified = [ + { + "url": r.get("url", ""), + "title": r.get("title", ""), + "content": r.get("content", ""), + "score": r.get("score", 0.0), + } + for r in results + ] + + return {"city": city, "check_in": check_in, "check_out": check_out, "results": simplified} + + except Exception as exc: + return {"error": f"Hotel search failed: {str(exc)}"} diff --git a/backend/agent_system/src/multi_tool_agent/tools/send_email.py b/backend/agent_system/src/multi_tool_agent/tools/send_email.py index 154e5bb..a42b2bb 100644 --- a/backend/agent_system/src/multi_tool_agent/tools/send_email.py +++ b/backend/agent_system/src/multi_tool_agent/tools/send_email.py @@ -3,15 +3,6 @@ from email.message import EmailMessage from typing import Dict, Any -from .exceptions import ToolValidationError, ToolConfigurationError, ToolAPIError - -# SMTP configuration loaded from environment variables -SMTP_HOST = os.getenv("SMTP_HOST") -SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) -SMTP_USER = os.getenv("SMTP_USER") -SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") -SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USER) - def send_email(email: str, title: str, text: str) -> Dict[str, Any]: """ @@ -23,50 +14,54 @@ def send_email(email: str, title: str, text: str) -> Dict[str, Any]: text: Email body (plain text). Returns: - Dict with success message: {"success": True, "message": "Email sent successfully."} - - Raises: - ToolValidationError: If email, title, or text is not provided - ToolConfigurationError: If SMTP configuration is missing - ToolAPIError: If email sending fails + {"success": True, "message": "Email sent successfully."} on success, + or {"error": "message"} on failure. """ - # Basic input validation if not email: - raise ToolValidationError("No recipient email provided.") + return {"error": "No recipient email provided."} if not title: - raise ToolValidationError("No email title provided.") + return {"error": "No email title provided."} if not text: - raise ToolValidationError("No email text provided.") + return {"error": "No email text provided."} + + smtp_host = os.getenv("SMTP_HOST") + smtp_port_str = os.getenv("SMTP_PORT", "587") + smtp_user = os.getenv("SMTP_USER") + smtp_password = os.getenv("SMTP_PASSWORD") + smtp_from_email = os.getenv("SMTP_FROM_EMAIL") or smtp_user - # Validate SMTP configuration - if not SMTP_HOST: - raise ToolConfigurationError("SMTP_HOST not configured.") - if not SMTP_FROM_EMAIL: - raise ToolConfigurationError("SMTP_FROM_EMAIL or SMTP_USER not configured.") + if not smtp_host: + return {"error": "Email service is not configured (SMTP_HOST missing)."} + if not smtp_from_email: + return {"error": "Email sender address is not configured (SMTP_FROM_EMAIL or SMTP_USER missing)."} + + try: + smtp_port = int(smtp_port_str) + except ValueError: + return {"error": f"Email service has invalid port configuration: '{smtp_port_str}'."} msg = EmailMessage() - msg["From"] = SMTP_FROM_EMAIL + msg["From"] = smtp_from_email msg["To"] = email msg["Subject"] = title msg.set_content(text) try: - with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as server: - # Use STARTTLS by default (common for port 587) + with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as server: try: server.starttls() - except Exception: - # If STARTTLS is not supported, continue without it - pass + except smtplib.SMTPException: + pass # Server does not support STARTTLS; continue without encryption - if SMTP_USER and SMTP_PASSWORD: - server.login(SMTP_USER, SMTP_PASSWORD) + if smtp_user and smtp_password: + server.login(smtp_user, smtp_password) server.send_message(msg) return {"success": True, "message": "Email sent successfully."} + except smtplib.SMTPAuthenticationError: + return {"error": "Email authentication failed. Check SMTP credentials."} except smtplib.SMTPException as e: - raise ToolAPIError(f"Failed to send email: {str(e)}") from e + return {"error": f"Failed to send email: {str(e)}"} except Exception as e: - raise ToolAPIError(f"Unexpected error while sending email: {str(e)}") from e - + return {"error": f"Unexpected error while sending email: {str(e)}"} diff --git a/backend/agent_system/src/multi_tool_agent/tools/utils.py b/backend/agent_system/src/multi_tool_agent/tools/utils.py new file mode 100644 index 0000000..eb8fb5e --- /dev/null +++ b/backend/agent_system/src/multi_tool_agent/tools/utils.py @@ -0,0 +1,31 @@ +"""Shared utilities for weather tools.""" + + +def _time_to_hhmm(value: str | None) -> str | None: + """Truncate HH:MM:SS to HH:MM.""" + if not value or not isinstance(value, str): + return value + parts = value.strip().split(":") + if len(parts) >= 2: + return f"{parts[0]}:{parts[1]}" + return value + + +def normalize_sunrise_sunset(data: dict) -> dict: + """Convert sunrise/sunset from HH:MM:SS to HH:MM in API response.""" + result = dict(data) + if "currentConditions" in result and isinstance(result["currentConditions"], dict): + current = dict(result["currentConditions"]) + current["sunrise"] = _time_to_hhmm(current.get("sunrise")) + current["sunset"] = _time_to_hhmm(current.get("sunset")) + result["currentConditions"] = current + if "days" in result: + result["days"] = [ + { + **day, + "sunrise": _time_to_hhmm(day.get("sunrise")), + "sunset": _time_to_hhmm(day.get("sunset")), + } + for day in result["days"] + ] + return result diff --git a/backend/agent_system/src/utils/__init__.py b/backend/agent_system/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agent_system/src/utils/load_env_data.py b/backend/agent_system/src/utils/load_env_data.py index 26b8f1b..4b73560 100644 --- a/backend/agent_system/src/utils/load_env_data.py +++ b/backend/agent_system/src/utils/load_env_data.py @@ -90,6 +90,15 @@ def load_visual_crossing_api_key(): return os.getenv("VISUAL_CROSSING_API_KEY") +def load_tavily_api_key(): + """ + Retrieves the TAVILY_API_KEY from environment variables. + Returns: + str: The value of the TAVILY_API_KEY variable, or None if not set. + """ + return os.getenv("TAVILY_API_KEY") + + def load_disable_web_driver() -> int: """ Retrieves the DISABLE_WEB_DRIVER variable from environment variables. diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/chat_service.py b/backend/api/chat_service.py index 8a35b3a..02b491e 100644 --- a/backend/api/chat_service.py +++ b/backend/api/chat_service.py @@ -1,6 +1,8 @@ +import asyncio import os import re import json +import logging from typing import Optional, Tuple import agent_system.src.multi_tool_agent.agent as agent_module @@ -9,68 +11,99 @@ from .models import ChatRequest, ChatResponse from .session_manager import session_manager +from .weather_payload import validate_weather_payload +from .hotel_payload import validate_hotel_payload -def _missing_env_response() -> ChatResponse: - return ChatResponse( - success=False, - error="AI chat is not available. Please set the GOOGLE_API_KEY environment variable.", - ) +logger = logging.getLogger(__name__) + +# Timeout for the ADK runner. Covers the full round-trip: LLM call(s) + tool +# calls + final response generation. Increase if tools become slower. +_ADK_TIMEOUT_SECONDS = 60 + +# Matches weather-json, hotel-json, and plain json fenced blocks. +FENCE_PATTERN = re.compile( + r"```\s*(weather-json|hotel-json|json)\s*\n([\s\S]*?)\n```", + re.IGNORECASE, +) + + +def _extract_fenced_json(raw_text: str) -> Optional[Tuple[str, dict]]: + """Return (fence_type, parsed_dict) for the first fenced JSON block, or None.""" + match = FENCE_PATTERN.search(raw_text) + if not match: + return None + fence_type = match.group(1).lower() + json_body = match.group(2).strip() + return fence_type, json.loads(json_body) def _normalize_agent_response(raw_text: str) -> str: if not raw_text: return "[Agent error] No response content" - fence_pattern = re.compile(r"```\s*(weather-json|json)\s*\n([\s\S]*?)\n```", re.IGNORECASE) - match = fence_pattern.search(raw_text) + match = FENCE_PATTERN.search(raw_text) if match: human_text = raw_text[:match.start()].strip() + fence_type = match.group(1).lower() json_body = match.group(2).strip() - return (human_text + "\n\n" if human_text else "") + f"```weather-json\n{json_body}\n```" + return (human_text + "\n\n" if human_text else "") + f"```{fence_type}\n{json_body}\n```" return raw_text -def _detect_error_in_response(raw_text: str) -> Tuple[bool, Optional[str]]: +def _detect_error_in_response(raw_text: str) -> Tuple[bool, Optional[str], Optional[str], Optional[dict]]: """ Detect if the agent response contains an error. Agent returns errors in fenced blocks as {"error": "message"}. - + Returns: - Tuple[bool, Optional[str]]: (is_error, error_message) - - is_error: True if error detected, False otherwise - - error_message: Extracted error message if error found, None otherwise + (is_error, error_message, fence_type, json_payload) """ if not raw_text: - return True, "[Agent error] No response content" - - # Check fenced blocks (weather-json or json) for error - fence_pattern = re.compile(r"```\s*(weather-json|json)\s*\n([\s\S]*?)\n```", re.IGNORECASE) - match = fence_pattern.search(raw_text) - - if match: - json_body = match.group(2).strip() - try: - parsed_json = json.loads(json_body) - # If JSON contains an "error" key, it's an error response - if isinstance(parsed_json, dict) and "error" in parsed_json: - error_msg = parsed_json["error"] - if error_msg: - return True, str(error_msg) - except (json.JSONDecodeError, TypeError): - # If JSON parsing fails, it's not a valid error format - pass - - # No error detected - return False, None + return True, "No response content from agent.", None, None + + try: + result = _extract_fenced_json(raw_text) + except (json.JSONDecodeError, TypeError) as exc: + return True, f"Agent returned malformed JSON: {exc}", None, None + + if result is not None: + fence_type, parsed_json = result + if isinstance(parsed_json, dict) and "error" in parsed_json: + error_msg = parsed_json["error"] + if error_msg: + return True, str(error_msg), fence_type, parsed_json + return True, "Agent encountered an error.", fence_type, parsed_json + return False, None, fence_type, parsed_json + + return False, None, None, None + + +def _validate_payload(fence_type: str, payload: dict) -> None: + """Route payload validation to the correct validator based on fence type and kind.""" + kind = payload.get("meta", {}).get("kind") if isinstance(payload, dict) else None + + if fence_type == "hotel-json" or kind == "hotels": + validate_hotel_payload(payload) + else: + validate_weather_payload(payload) async def process_chat_request(request: ChatRequest) -> ChatResponse: + """ + Process a chat request through the ADK agent and return a ChatResponse. + + Always returns ChatResponse (success=True or success=False) — never raises. + HTTP status is always 200; callers check response.success for error state. + """ session_data: Optional[dict] = None try: if not os.getenv("GOOGLE_API_KEY"): - return _missing_env_response() + return ChatResponse( + success=False, + error="AI chat is not available. GOOGLE_API_KEY is not configured.", + ) session_manager.cleanup_expired_sessions() session_data = await session_manager.ensure_session(request.session_id) @@ -82,52 +115,68 @@ async def process_chat_request(request: ChatRequest) -> ChatResponse: ) content = types.Content(role="user", parts=[types.Part(text=request.message)]) - events = runner.run_async( - user_id=session_data["user_id"], - session_id=session_data["adk_session_id"], - new_message=content, - ) - async for event in events: - if event.is_final_response(): - raw_text = "" - if getattr(event, "content", None) and getattr(event.content, "parts", None): - parts_text = [] - for part in event.content.parts: - text = getattr(part, "text", None) - if text: - parts_text.append(text) - raw_text = "\n".join(parts_text).strip() - - # Detect if response contains an error - is_error, error_message = _detect_error_in_response(raw_text) - - if is_error: - # Return error response - return ChatResponse( - success=False, - error=error_message or "[Agent error] Error detected in response", - session_id=session_data["session_id"], - ) - - # Normal response - normalize and return - normalized = _normalize_agent_response(raw_text) - - return ChatResponse( - success=True, - data={"message": normalized, "sender": "ai"}, - session_id=session_data["session_id"], + try: + async with asyncio.timeout(_ADK_TIMEOUT_SECONDS): + events = runner.run_async( + user_id=session_data["user_id"], + session_id=session_data["adk_session_id"], + new_message=content, ) - + async for event in events: + if event.is_final_response(): + raw_text = "" + if getattr(event, "content", None) and getattr(event.content, "parts", None): + parts_text = [ + text + for part in event.content.parts + if (text := getattr(part, "text", None)) + ] + raw_text = "\n".join(parts_text).strip() + + is_error, error_message, fence_type, json_payload = _detect_error_in_response(raw_text) + if is_error: + return ChatResponse( + success=False, + error=error_message, + session_id=session_data["session_id"], + ) + + if json_payload is not None: + try: + _validate_payload(fence_type or "", json_payload) + except ValueError as exc: + return ChatResponse( + success=False, + error=f"Invalid response data: {exc}", + session_id=session_data["session_id"], + ) + + normalized = _normalize_agent_response(raw_text) + return ChatResponse( + success=True, + data={"message": normalized, "sender": "ai"}, + session_id=session_data["session_id"], + ) + + except TimeoutError: + logger.warning("ADK runner timed out after %s seconds", _ADK_TIMEOUT_SECONDS) + return ChatResponse( + success=False, + error="The request timed out. Please try again.", + session_id=session_data["session_id"] if session_data else None, + ) + + logger.warning("ADK runner finished without a final response event") return ChatResponse( success=False, - error="[Agent error] No response from agent.", - session_id=session_data["session_id"], + error="No response from agent. Please try again.", + session_id=session_data["session_id"] if session_data else None, ) - except Exception as exc: # noqa: BLE001 - print(f"Chat endpoint error: {exc}") + + except Exception: + logger.exception("Unexpected error in chat endpoint") return ChatResponse( success=False, - error=f"Error: {exc}", - session_id=session_data["session_id"] if session_data else request.session_id, + error="An unexpected error occurred. Please try again.", + session_id=session_data["session_id"] if session_data else None, ) - diff --git a/backend/api/hotel_payload.py b/backend/api/hotel_payload.py new file mode 100644 index 0000000..1956074 --- /dev/null +++ b/backend/api/hotel_payload.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + + +class HotelMeta(BaseModel): + city: Annotated[str, Field(min_length=1)] + kind: Literal["hotels"] + date: None = None + date_range: str | None = None + language: Annotated[str, Field(min_length=1)] + + model_config = ConfigDict(extra="allow") + + +class Hotel(BaseModel): + name: Annotated[str, Field(min_length=1)] + price_per_night: str = "" + currency: str = "" + availability: str = "unknown" + rating: float | None = None + reviews_count: int | None = None + highlights: list[str] = Field(default_factory=list) + url: str = "" + + model_config = ConfigDict(extra="allow") + + +class HotelPayload(BaseModel): + meta: HotelMeta + hotels: Annotated[list[Hotel], Field(min_length=1)] + + model_config = ConfigDict(extra="allow") + + +def _format_validation_error(exc: ValidationError) -> str: + errors = exc.errors(include_url=False) + if not errors: + return "Invalid hotel-json payload" + first = errors[0] + loc = ".".join(str(p) for p in first.get("loc", ())) + msg = first.get("msg", "Invalid value") + return f"{loc}: {msg}" if loc else str(msg) + + +def validate_hotel_payload(payload: Any) -> None: + """ + Validate agent hotel payload against the hotel-json schema. + + Raises: + ValueError: if payload is invalid. + """ + if not isinstance(payload, dict): + raise ValueError("hotel-json must be an object") + + meta = payload.get("meta") + if not isinstance(meta, dict): + raise ValueError("meta must be an object") + + try: + HotelPayload.model_validate(payload) + except ValidationError as exc: + raise ValueError(_format_validation_error(exc)) from exc diff --git a/backend/api/main.py b/backend/api/main.py index 8a2bc08..cc739d3 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -1,14 +1,22 @@ +import logging +import os +from datetime import datetime + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware + from agent_system.src.utils.load_env_data import load_env_data, get_environment_info -import os -from datetime import datetime from .models import ChatRequest, ChatResponse - from .chat_service import process_chat_request -# Load environment variables (if needed) -load_env_data() +logger = logging.getLogger(__name__) + +# Load environment variables (warn about missing keys, never crash on startup) +try: + load_env_data() +except ValueError as e: + logger.warning("Environment startup warning: %s", e) + logger.warning("Some features may be unavailable until environment variables are configured.") app = FastAPI( title="Weather Center Chat API", @@ -63,7 +71,12 @@ def health(): return { "status": "unhealthy", "timestamp": datetime.now().isoformat(), - "error": str(e) + "error": str(e), + "services": { + "api": "running", + "weather_service": "unknown", + "ai_chat": "unknown", + }, } # Mirror health under /api for frontend behind nginx diff --git a/backend/api/models.py b/backend/api/models.py index f6572fe..6a10046 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,6 +1,8 @@ -from pydantic import BaseModel -from typing import List, Optional, Dict, Any, Union -from datetime import datetime, date +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field # Pydantic Models for API @@ -8,11 +10,12 @@ class ChatRequest(BaseModel): message: str - conversation_history: List[Dict[str, Any]] # Each entry must include text and sender - session_id: Optional[str] = None + conversation_history: list[dict[str, Any]] = Field(default_factory=list) + session_id: str | None = None class ChatResponse(BaseModel): success: bool - data: Optional[dict] = None - error: Optional[str] = None - session_id: Optional[str] = None + data: dict[str, Any] | None = None + error: str | None = None + session_id: str | None = None + diff --git a/backend/api/weather_payload.py b/backend/api/weather_payload.py new file mode 100644 index 0000000..99f9f58 --- /dev/null +++ b/backend/api/weather_payload.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Any, Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$" +DATE_RANGE_PATTERN = r"^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$" +TIME_PATTERN = r"^\d{2}:\d{2}$" + +DateStr = Annotated[str, Field(pattern=DATE_PATTERN)] +DateRangeStr = Annotated[str, Field(pattern=DATE_RANGE_PATTERN)] +TimeStr = Annotated[str, Field(pattern=TIME_PATTERN)] + + +class WeatherMetaBase(BaseModel): + city: Annotated[str, Field(min_length=1)] + kind: Literal["current", "forecast", "history"] + language: Annotated[str, Field(min_length=1)] + + model_config = ConfigDict(extra="allow") + + +class WeatherMetaCurrent(WeatherMetaBase): + kind: Literal["current"] + date: DateStr + date_range: None = None + + +class WeatherMetaRange(WeatherMetaBase): + kind: Literal["forecast", "history"] + date: None = None + date_range: DateRangeStr + + +class CurrentWeather(BaseModel): + temp: float + tempmax: float + tempmin: float + windspeed: float + winddir: float + pressure: float + humidity: float + sunrise: TimeStr + sunset: TimeStr + conditions: str + + model_config = ConfigDict(extra="allow") + + +class DayWeather(BaseModel): + datetime: DateStr + temp: float + tempmax: float + tempmin: float + windspeed: float + winddir: float + pressure: float + humidity: float + sunrise: TimeStr + sunset: TimeStr + conditions: str + + model_config = ConfigDict(extra="allow") + + +class WeatherCurrentPayload(BaseModel): + meta: WeatherMetaCurrent + current: CurrentWeather + + model_config = ConfigDict(extra="allow") + + +class WeatherDaysPayload(BaseModel): + meta: WeatherMetaRange + days: Annotated[list[DayWeather], Field(min_length=1)] + + model_config = ConfigDict(extra="allow") + + +def _format_validation_error(exc: ValidationError) -> str: + errors = exc.errors(include_url=False) + if not errors: + return "Invalid weather-json payload" + first = errors[0] + loc = ".".join(str(p) for p in first.get("loc", ())) + msg = first.get("msg", "Invalid value") + return f"{loc}: {msg}" if loc else str(msg) + + +def validate_weather_payload(payload: Any) -> None: + """ + Validate agent weather payload against the schema documented in + `agent_system/src/multi_tool_agent/templates/json_format.py`. + + Raises: + ValueError: if payload is invalid. + """ + if not isinstance(payload, dict): + raise ValueError("weather-json must be an object") + + meta = payload.get("meta") + if not isinstance(meta, dict): + raise ValueError("meta must be an object") + + kind = meta.get("kind") + try: + if kind == "current": + WeatherCurrentPayload.model_validate(payload) + return + if kind in ("forecast", "history"): + WeatherDaysPayload.model_validate(payload) + return + except ValidationError as exc: + raise ValueError(_format_validation_error(exc)) from exc + + raise ValueError('meta.kind must be one of: "current", "forecast", "history"') + diff --git a/backend/api/weather_service.py b/backend/api/weather_service.py deleted file mode 100644 index b1b8759..0000000 --- a/backend/api/weather_service.py +++ /dev/null @@ -1,166 +0,0 @@ -import requests -from datetime import datetime, date, timedelta -from typing import Dict, List, Any -from .models import WeatherData -from agent_system.src.utils.load_env_data import load_env_data, load_visual_crossing_api_key - -# Load environment variables -load_env_data() - -class WeatherService: - def __init__(self): - self.api_key = load_visual_crossing_api_key() - self.base_url = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline" - - if self.api_key: - print(f"Weather service initialized with API key: {self.api_key[:8]}...") - else: - print("⚠️ Weather service initialized without API key - weather features will not work") - print(" Set VISUAL_CROSSING_API_KEY environment variable for weather functionality") - - def _make_api_request(self, location: str, start_date: str, end_date: str, include: str = "current,days,hours") -> Dict[str, Any]: - if not self.api_key: - raise ValueError("VISUAL_CROSSING_API_KEY not set. Please set this environment variable to use weather features.") - - url = f"{self.base_url}/{location}/{start_date}/{end_date}" - params = { - 'unitGroup': 'metric', - 'include': include, - 'key': self.api_key, - 'contentType': 'json' - } - try: - response = requests.get(url, params=params) - response.raise_for_status() - return response.json() - except requests.exceptions.RequestException as e: - raise Exception(f"API request failed: {str(e)}") - - def _parse_current_weather(self, data: Dict[str, Any], location: str) -> WeatherData: - current_conditions = data.get('currentConditions', {}) - - # Handle wind_direction conversion - wind_dir = current_conditions.get('winddir') - if wind_dir is not None: - if isinstance(wind_dir, (int, float)): - wind_dir = str(int(wind_dir)) # Convert to string - elif isinstance(wind_dir, str): - wind_dir = wind_dir - else: - wind_dir = None - - # Get sunrise/sunset from the first day (current day) - days = data.get('days', []) - sunrise = None - sunset = None - if days: - first_day = days[0] - sunrise = first_day.get('sunrise') - sunset = first_day.get('sunset') - - return WeatherData( - location=location, - temperature=current_conditions.get('temp', 0), - humidity=float(current_conditions.get('humidity', 0)) if current_conditions.get('humidity') is not None else None, - wind_speed=current_conditions.get('windspeed'), - wind_direction=wind_dir, - pressure=current_conditions.get('pressure'), - visibility=current_conditions.get('visibility'), - uv_index=current_conditions.get('uvindex'), - conditions=current_conditions.get('conditions'), - icon=current_conditions.get('icon'), - sunrise=sunrise, - sunset=sunset, - timestamp=datetime.now(), - weather_type='current' - ) - - def _parse_forecast_weather(self, data: Dict[str, Any], location: str) -> List[WeatherData]: - days = data.get('days', []) - weather_data = [] - - for day in days: - # Handle wind_direction conversion - wind_dir = day.get('winddir') - if wind_dir is not None: - if isinstance(wind_dir, (int, float)): - wind_dir = str(int(wind_dir)) # Convert to string - elif isinstance(wind_dir, str): - wind_dir = wind_dir - else: - wind_dir = None - - weather_data.append(WeatherData( - location=location, - temperature=day.get('temp', 0), - humidity=float(day.get('humidity', 0)) if day.get('humidity') is not None else None, - wind_speed=day.get('windspeed'), - wind_direction=wind_dir, - pressure=day.get('pressure'), - visibility=day.get('visibility'), - uv_index=day.get('uvindex'), - conditions=day.get('conditions'), - icon=day.get('icon'), - sunrise=day.get('sunrise'), - sunset=day.get('sunset'), - timestamp=datetime.strptime(day.get('datetime', ''), '%Y-%m-%d'), - weather_type='forecast' - )) - - return weather_data - - def _parse_history_weather(self, data: Dict[str, Any], location: str) -> List[WeatherData]: - days = data.get('days', []) - weather_data = [] - - for day in days: - # Handle wind_direction conversion - wind_dir = day.get('winddir') - if wind_dir is not None: - if isinstance(wind_dir, (int, float)): - wind_dir = str(int(wind_dir)) # Convert to string - elif isinstance(wind_dir, str): - wind_dir = wind_dir - else: - wind_dir = None - - weather_data.append(WeatherData( - location=location, - temperature=day.get('temp', 0), - humidity=float(day.get('humidity', 0)) if day.get('humidity') is not None else None, - wind_speed=day.get('windspeed'), - wind_direction=wind_dir, - pressure=day.get('pressure'), - visibility=day.get('visibility'), - uv_index=day.get('uvindex'), - conditions=day.get('conditions'), - icon=day.get('icon'), - sunrise=day.get('sunrise'), - sunset=day.get('sunset'), - timestamp=datetime.strptime(day.get('datetime', ''), '%Y-%m-%d'), - weather_type='history' - )) - - return weather_data - - def get_current_weather(self, location: str) -> WeatherData: - today = date.today().strftime('%Y-%m-%d') - data = self._make_api_request(location, today, today, "current") - return self._parse_current_weather(data, location) - - def get_forecast_weather(self, location: str, days: int = 7) -> List[WeatherData]: - start_date = date.today().strftime('%Y-%m-%d') - end_date = (date.today() + timedelta(days=days-1)).strftime('%Y-%m-%d') - data = self._make_api_request(location, start_date, end_date, "days") - return self._parse_forecast_weather(data, location) - - def get_history_weather(self, location: str, start_date: date, end_date: date) -> List[WeatherData]: - start_str = start_date.strftime('%Y-%m-%d') - end_str = end_date.strftime('%Y-%m-%d') - data = self._make_api_request(location, start_str, end_str, "days") - return self._parse_history_weather(data, location) - - -# --- Weather API Endpoints --- -# Create global instance -weather_service = WeatherService() \ No newline at end of file diff --git a/backend/database.db b/backend/database.db deleted file mode 100644 index a3f808f..0000000 Binary files a/backend/database.db and /dev/null differ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f1c51af..8c04caf 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "google-adk>=1.5.0", "google-genai>=0.3.0", "tzdata>=2025.1", + "jsonschema>=4.24.0", + "tavily-python>=0.5.0", ] [project.optional-dependencies] diff --git a/backend/uv.lock b/backend/uv.lock index c48ba9c..ec17a0d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1317,6 +1317,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, ] +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, + { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, + { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, + { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, + { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, + { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, + { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, + { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, +] + [[package]] name = "requests" version = "2.32.4" @@ -1499,6 +1587,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] +[[package]] +name = "tavily-python" +version = "0.7.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "requests" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/d3/a6a9c24bfafed30b4ce3c3d685ab00806ad631c9742441f2597ec91f0002/tavily_python-0.7.24.tar.gz", hash = "sha256:6c8954193c6472231e813fe50cbd07806bd86c7228957675eb45875a44d58296", size = 27311, upload-time = "2026-04-27T17:26:50.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/ce/37e3aba0f359f540bfc57eb178f73d521161761f21e0aa28749f42750b11/tavily_python-0.7.24-py3-none-any.whl", hash = "sha256:1a750108de42c4b0b46e4c1b7b64aeaf7fad7d7bac9167927edce0081fe166c9", size = 20022, upload-time = "2026-04-27T17:26:48.885Z" }, +] + [[package]] name = "tenacity" version = "8.5.0" @@ -1508,6 +1610,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, ] +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.0" @@ -1687,8 +1836,10 @@ dependencies = [ { name = "fastapi" }, { name = "google-adk" }, { name = "google-genai" }, + { name = "jsonschema" }, { name = "pydantic" }, { name = "requests" }, + { name = "tavily-python" }, { name = "tzdata" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1710,10 +1861,12 @@ requires-dist = [ { name = "google-adk", specifier = ">=1.5.0" }, { name = "google-genai", specifier = ">=0.3.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, + { name = "jsonschema", specifier = ">=4.24.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "requests", specifier = ">=2.31.0" }, + { name = "tavily-python", specifier = ">=0.5.0" }, { name = "tzdata", specifier = ">=2025.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" }, ] diff --git a/frontend/src/app/components/AiWeatherPanel/AiWeatherPanel.tsx b/frontend/src/app/components/AiWeatherPanel/AiWeatherPanel.tsx index f08b779..c0e6467 100644 --- a/frontend/src/app/components/AiWeatherPanel/AiWeatherPanel.tsx +++ b/frontend/src/app/components/AiWeatherPanel/AiWeatherPanel.tsx @@ -3,95 +3,72 @@ import { LanguageContext } from "@/app/contexts/LanguageContext"; import type { AiMeta, AiChatData, AiKind } from "@/app/types/aiChat"; import { WeatherView } from "@/app/components/WeatherView/WeatherView"; import { List } from "@/app/components/List/List"; +import { HotelView } from "@/app/components/HotelView/HotelView"; export function AiWeatherPanel({ meta, data }: { meta: AiMeta | null; data: AiChatData | null }) { const lang = useContext(LanguageContext); const [resolvedKind, setResolvedKind] = useState(meta?.kind ?? null); useEffect(() => { - // Resolve kind from meta first; if missing, infer from data shape - const k: AiKind = (meta?.kind as AiKind) ?? (data?.current ? 'current' : (Array.isArray(data?.days) ? 'forecast' : null)); + const k: AiKind = + (meta?.kind as AiKind) ?? + (data?.hotels ? 'hotels' : + data?.current ? 'current' : + Array.isArray(data?.days) ? 'forecast' : null); setResolvedKind(k); }, [meta, data]); if (!meta && !data) { - return
{lang?.t('chat.subtitle')}
; + return ( +
+ 🌤️ +

{lang?.t('chat.subtitle')}

+
+ ); } + const isHotel = resolvedKind === 'hotels'; + return ( -
- {/* Meta only: city/date/date_range */} - {(meta?.city || meta?.date || meta?.date_range) && ( -
-
+
+ {/* City / date header — only for non-hotel (hotels have their own header) */} + {!isHotel && (meta?.city || meta?.date || meta?.date_range) && ( +
+
{meta?.city && ( -

- {meta.city} -

- )} - {meta?.date && ( -
- {meta.date} -
+

{meta.city}

)} - {meta?.date_range && ( -
- {meta.date_range} -
+ {(meta?.date || meta?.date_range) && ( +

+ {meta.date || meta.date_range} +

)}
+ 📍
)} - {/* Preferred render using existing components */} - {resolvedKind === 'current' && data?.current && ( -
- -
+ {/* Hotels */} + {isHotel && Array.isArray(data?.hotels) && data.hotels.length > 0 && ( + )} - {(resolvedKind === 'forecast' || resolvedKind === 'history' || (!resolvedKind && Array.isArray(data?.days))) && Array.isArray(data?.days) && ( - + {/* Current weather */} + {resolvedKind === 'current' && data?.current && ( + )} - {/** - * Raw data dump (previous minimal output) kept for reference during development: - * - * {resolvedKind === 'current' && data?.current && ( - *
- *
temp: {String(data.current.temp ?? '')}
- *
tempmax: {String(data.current.tempmax ?? '')}
- *
tempmin: {String(data.current.tempmin ?? '')}
- *
windspeed: {String(data.current.windspeed ?? '')}
- *
winddir: {String(data.current.winddir ?? '')}
- *
pressure: {String(data.current.pressure ?? '')}
- *
humidity: {String(data.current.humidity ?? '')}
- *
sunrise: {String(data.current.sunrise ?? '')}
- *
sunset: {String(data.current.sunset ?? '')}
- *
conditions: {String(data.current.conditions ?? '')}
- *
- * )} - * - * {(resolvedKind === 'forecast' || resolvedKind === 'history' || (!resolvedKind && Array.isArray(data?.days))) && Array.isArray(data?.days) && ( - *
- * {data?.days?.map((d, i) => ( - *
- *
datetime: {String(d.datetime ?? '')}
- *
temp: {String(d.temp ?? '')}
- *
tempmax: {String(d.tempmax ?? '')}
- *
tempmin: {String(d.tempmin ?? '')}
- *
windspeed: {String(d.windspeed ?? '')}
- *
winddir: {String(d.winddir ?? '')}
- *
pressure: {String(d.pressure ?? '')}
- *
humidity: {String(d.humidity ?? '')}
- *
sunrise: {String(d.sunrise ?? '')}
- *
sunset: {String(d.sunset ?? '')}
- *
conditions: {String(d.conditions ?? '')}
- *
- * ))} - *
- * )} - */} + {/* Forecast / History */} + {(resolvedKind === 'forecast' || + resolvedKind === 'history' || + (!resolvedKind && Array.isArray(data?.days))) && + Array.isArray(data?.days) && ( + + )}
); } diff --git a/frontend/src/app/components/Brick/Brick.tsx b/frontend/src/app/components/Brick/Brick.tsx index 674885d..9b5226f 100644 --- a/frontend/src/app/components/Brick/Brick.tsx +++ b/frontend/src/app/components/Brick/Brick.tsx @@ -2,9 +2,8 @@ import { useContext } from "react"; import { Icon } from "../Icon/Icon"; import { UnitSystemContext } from "@/app/contexts/UnitSystemContext"; import { BrickModalContext } from "@/app/contexts/BrickModalContext"; -import { checkSign, findDirection, translateConditions } from "@/app/functions/functions"; +import { checkSign, findDirection, translateConditions, systemsConvert } from "@/app/functions/functions"; import { LanguageContext } from "@/app/contexts/LanguageContext"; -import { systemsConvert } from "@/app/functions/functions"; import { UnitSystemContextType, WhereFromType } from "@/app/types/types"; import { UNIT_SYSTEMS } from "@/app/constants/unitSystems"; @@ -21,9 +20,7 @@ export function Brick({ desc: string | null; whereFrom: WhereFromType; }) { - const unitSystemContext = useContext( - UnitSystemContext - ); + const unitSystemContext = useContext(UnitSystemContext); const brickModalContext = useContext(BrickModalContext); const lang = useContext(LanguageContext); @@ -31,80 +28,66 @@ export function Brick({ unitSystemContext?.unitSystem.data === "US" || unitSystemContext?.unitSystem.data === "METRIC" || unitSystemContext?.unitSystem.data === "UK" - ? unitSystemContext?.unitSystem.data + ? unitSystemContext.unitSystem.data : "METRIC"; + function handleOnClick() { - whereFrom === "current weather" && brickModalContext?.setIsModalShownInCurrentWeatherPage?.(true); - whereFrom === "chat" && brickModalContext?.setIsModalShownInChatPage?.(true); - brickModalContext?.setModalData({ - data: data, - kindOfData: kindOfData, - title: title, - desc: desc, - }); + if (whereFrom === "current weather") brickModalContext?.setIsModalShownInCurrentWeatherPage?.(true); + if (whereFrom === "chat") brickModalContext?.setIsModalShownInChatPage?.(true); + brickModalContext?.setModalData({ data, kindOfData, title, desc }); } - const titleData: string | number | null = - typeof kindOfData === "string" ? kindOfData : 0; - return ( - <> - - + {/* Value + unit */} +
+

+ {displayValue !== null && displayValue !== undefined ? String(displayValue) : '—'} +

+ {unit &&

{unit}

} +
+ ); } diff --git a/frontend/src/app/components/Chat/Chat.tsx b/frontend/src/app/components/Chat/Chat.tsx index 04cee7c..d3372f9 100644 --- a/frontend/src/app/components/Chat/Chat.tsx +++ b/frontend/src/app/components/Chat/Chat.tsx @@ -7,9 +7,10 @@ import type { AiMeta, AiChatData } from '@/app/types/aiChat'; import { BACKEND_API_URL } from '@/app/constants/apiConstants'; import { ErrorMessage } from '../ErrorMessage/ErrorMessage'; - - -export const Chat: React.FC<{ onMetaChange?: (m: AiMeta | null) => void; onDataChange?: (d: AiChatData | null) => void }> = ({ onMetaChange, onDataChange }) => { +export const Chat: React.FC<{ + onMetaChange?: (m: AiMeta | null) => void; + onDataChange?: (d: AiChatData | null) => void; +}> = ({ onMetaChange, onDataChange }) => { const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -20,34 +21,26 @@ export const Chat: React.FC<{ onMetaChange?: (m: AiMeta | null) => void; onDataC const lang = useContext(LanguageContext); useEffect(() => { - scrollToBottom(); + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { checkBackendConnection(); }, []); - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - const checkBackendConnection = async () => { try { const response = await fetch(`${BACKEND_API_URL}/api/health`); - if (response.ok) { - setIsConnected(true); - } - } catch (error) { + if (response.ok) setIsConnected(true); + } catch { setIsConnected(false); } }; - // Send message to the backend const handleSendMessage = async () => { if (!inputText.trim()) return; const activeSessionId = sessionId || undefined; - const userMessage: Message = { id: (Date.now() + 1).toString(), text: inputText.trim(), @@ -70,51 +63,51 @@ export const Chat: React.FC<{ onMetaChange?: (m: AiMeta | null) => void; onDataC conversationHistory, activeSessionId, ); + if (response.session_id && response.session_id !== sessionId) { setSessionId(response.session_id); } if (response.success && response.data) { - console.log(response.data); - // Clear any previous error messages setErrorMessage(null); const parsed = parseAiMessage(response.data.message); onMetaChange && onMetaChange(parsed.metaData); onDataChange && onDataChange(parsed.aiChatData); - const humanText = parsed.humanText; const aiMessage: Message = { id: (Date.now() + 1).toString(), - text: humanText || response.data.message, + text: parsed.humanText || response.data.message, sender: 'ai', timestamp: new Date(), }; setMessages((prev) => [...prev, aiMessage]); } else { - // Extract error message from response const errorText = response.error || 'Failed to fetch chat response'; setErrorMessage(errorText); - // Also show error in chat message - const errorChatMessage: Message = { + setMessages((prev) => [ + ...prev, + { + id: (Date.now() + 1).toString(), + text: `Error: ${errorText}`, + sender: 'ai', + timestamp: new Date(), + }, + ]); + } + } catch (error) { + const errorText = + error instanceof Error + ? error.message + : 'An unexpected error occurred. Please try again later.'; + setErrorMessage(errorText); + setMessages((prev) => [ + ...prev, + { id: (Date.now() + 1).toString(), text: `Error: ${errorText}`, sender: 'ai', timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorChatMessage]); - console.error('Chat API error:', errorText); - } - } catch (error) { - // Handle network errors or other exceptions - const errorText = error instanceof Error ? error.message : 'An unexpected error occurred. Please try again later.'; - setErrorMessage(errorText); - const errorChatMessage: Message = { - id: (Date.now() + 1).toString(), - text: `Error: ${errorText}`, - sender: 'ai', - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorChatMessage]); - console.error('Chat request failed:', error); + }, + ]); } finally { setIsLoading(false); } @@ -128,9 +121,7 @@ export const Chat: React.FC<{ onMetaChange?: (m: AiMeta | null) => void; onDataC }; const formatTime = (date: Date) => { - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - return `${hours}:${minutes}`; + return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; }; return ( @@ -140,92 +131,110 @@ export const Chat: React.FC<{ onMetaChange?: (m: AiMeta | null) => void; onDataC {errorMessage} )} -
+ +
{/* Header */} -
-
-
-
-

{lang?.t('chat.title')}

-

{lang?.t('chat.subtitle')}

-
+
+
+

{lang?.t('chat.title')}

+

{lang?.t('chat.subtitle')}

-
-
-
- - {isConnected ? lang?.t('chat.connected') : lang?.t('chat.disconnected')} - -
+
+
+ + {isConnected ? lang?.t('chat.connected') : lang?.t('chat.disconnected')} +
-
- {/* Messages */} -
- {messages.length === 0 && ( -
-

{lang?.t('chat.subtitle')}

-
- )} - {messages.map((message) => ( -
+ {/* Messages */} +
+ {messages.length === 0 && ( +
+ 🌤️ +

+ {lang?.t('chat.subtitle')} +

+
+ )} + + {messages.map((message) => (
-

{message.text}

-

- {formatTime(message.timestamp)} -

+
+

+ {message.text} +

+

+ {formatTime(message.timestamp)} +

+
-
- ))} - {isLoading && ( -
-
-
-
- {lang?.t('chat.sending')} + ))} + + {isLoading && ( +
+
+
+
+ + + +
+ {lang?.t('chat.sending')} +
-
- )} -
-
+ )} - {/* Input */} -
-
- setInputText(e.target.value)} - onKeyPress={handleKeyPress} - placeholder={lang?.t('chat.placeholder')} - className="flex-1 px-3 sm:px-4 py-2 border border-blue-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm" - disabled={isLoading} - /> - +
+
+ + {/* Input */} +
+
+ setInputText(e.target.value)} + onKeyPress={handleKeyPress} + placeholder={lang?.t('chat.placeholder')} + className="flex-1 bg-white/10 border border-white/15 rounded-2xl px-4 py-2.5 text-white text-sm placeholder-sky-400/40 focus:outline-none focus:ring-1 focus:ring-sky-500/50 focus:border-sky-500/50 transition-all" + disabled={isLoading} + /> + +
-
); }; diff --git a/frontend/src/app/components/Footer/Footer.tsx b/frontend/src/app/components/Footer/Footer.tsx index 7d52567..5ab3eba 100644 --- a/frontend/src/app/components/Footer/Footer.tsx +++ b/frontend/src/app/components/Footer/Footer.tsx @@ -7,31 +7,24 @@ import { InfoModalContextType } from "@/app/types/types"; export function Footer() { const [infoModal, setInfoModal] = useState(null); - const infoModalContext = useContext( - InfoModalContext - ); + const infoModalContext = useContext(InfoModalContext); + useEffect(() => { const createInfoModal = createPortal(, document.body); setInfoModal(createInfoModal); }, []); - return ( -