Spaces:
Sleeping
Sleeping
Deploy Tripplanner backend
Browse files- .gitignore +27 -0
- Dockerfile +20 -0
- README.md +133 -3
- backend/.env.example +4 -0
- backend/__init__.py +0 -0
- backend/agents/__init__.py +0 -0
- backend/agents/delta_agent.py +128 -0
- backend/agents/ihg_agent.py +140 -0
- backend/agents/resy_agent.py +123 -0
- backend/main.py +176 -0
- backend/models.py +76 -0
- backend/orchestrator.py +275 -0
- backend/pyproject.toml +17 -0
- backend/requirements.txt +7 -0
- backend/tools/__init__.py +0 -0
- backend/tools/browser.py +434 -0
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
!.env.example
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
*.pyo
|
| 10 |
+
*.pyd
|
| 11 |
+
*.egg-info/
|
| 12 |
+
.venv/
|
| 13 |
+
venv/
|
| 14 |
+
|
| 15 |
+
# Node
|
| 16 |
+
node_modules/
|
| 17 |
+
dist/
|
| 18 |
+
|
| 19 |
+
# Playwright
|
| 20 |
+
browser_data/
|
| 21 |
+
.playwright/
|
| 22 |
+
|
| 23 |
+
# Logs
|
| 24 |
+
*.log
|
| 25 |
+
|
| 26 |
+
# macOS
|
| 27 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
# Playwright system dependencies
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
libglib2.0-0 libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \
|
| 6 |
+
libcups2 libdrm2 libdbus-1-3 libxkbcommon0 libx11-6 libxcomposite1 \
|
| 7 |
+
libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2 \
|
| 8 |
+
libpango-1.0-0 libcairo2 libatspi2.0-0 libx11-xcb1 \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
COPY backend/requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
RUN playwright install chromium --with-deps
|
| 16 |
+
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,140 @@
|
|
| 1 |
---
|
| 2 |
title: Tripplanner Backend
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Tripplanner Backend
|
| 3 |
+
emoji: ✈️
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Tripplanner
|
| 12 |
+
|
| 13 |
+
A travel-benefits stacking app that finds dates where your credit card perks align — so you can book trips that squeeze maximum value out of every benefit you already have.
|
| 14 |
+
|
| 15 |
+
## What It Does
|
| 16 |
+
|
| 17 |
+
Tripplanner searches for windows where three high-value perks overlap:
|
| 18 |
+
|
| 19 |
+
- **Delta companion certificate** (Delta Reserve Amex) — a second ticket at taxes only
|
| 20 |
+
- **IHG 4th-night-free** (IHG Premier Mastercard) — book 4 nights on points, pay for 3
|
| 21 |
+
- **Resy dining credits** (Delta Reserve Amex) — up to $20/month at Resy restaurants
|
| 22 |
+
|
| 23 |
+
When all three line up on the same trip — same destination, same travel dates — you get flights, hotel, and dinner at a fraction of the normal cost. Tripplanner does the calendar math and site-checking so you don't have to do it manually.
|
| 24 |
+
|
| 25 |
+
## Architecture
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
Browser (React + Vite)
|
| 29 |
+
|
|
| 30 |
+
v
|
| 31 |
+
FastAPI backend ←→ Orchestrator agent (claude-sonnet-4-6)
|
| 32 |
+
|
|
| 33 |
+
┌───────────────┼───────────────┐
|
| 34 |
+
v v v
|
| 35 |
+
Delta subagent IHG subagent Resy subagent
|
| 36 |
+
(claude-haiku-4-5) (claude-haiku-4-5) (claude-haiku-4-5)
|
| 37 |
+
+ Playwright + Playwright + Playwright
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
The orchestrator fans out to three browser subagents that run in parallel. Each subagent gets a fresh AI context and its own Playwright browser instance. They navigate their assigned site, extract availability and pricing data, then return structured results to the orchestrator, which finds overlapping date windows and ranks them.
|
| 41 |
+
|
| 42 |
+
## Cards Supported
|
| 43 |
+
|
| 44 |
+
| Card | Benefit Used |
|
| 45 |
+
|------|-------------|
|
| 46 |
+
| Delta Reserve Amex | Companion certificate (domestic main cabin or first) |
|
| 47 |
+
| Delta Reserve Amex | Resy dining credit ($20/month) |
|
| 48 |
+
| Delta Reserve Amex | Delta Stays credit |
|
| 49 |
+
| IHG Premier Mastercard | 4th night free on points redemptions |
|
| 50 |
+
|
| 51 |
+
## Prerequisites
|
| 52 |
+
|
| 53 |
+
- Python 3.11+
|
| 54 |
+
- Node 18+
|
| 55 |
+
- An [Anthropic API key](https://console.anthropic.com/)
|
| 56 |
+
|
| 57 |
+
## Setup
|
| 58 |
+
|
| 59 |
+
### 1. Clone the repo
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
git clone <repo-url>
|
| 63 |
+
cd "Trip finder with claude"
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
### 2. Install Python dependencies
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
cd backend
|
| 70 |
+
pip install -r requirements.txt
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### 3. Install Playwright browser
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
playwright install chromium
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### 4. Configure environment variables
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
cp .env.example .env
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Open `.env` and fill in your Anthropic API key:
|
| 86 |
+
|
| 87 |
+
```
|
| 88 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
### 5. Start the backend
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
python -m uvicorn backend.main:app --reload
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### 6. Start the frontend
|
| 98 |
+
|
| 99 |
+
In a second terminal:
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
cd frontend
|
| 103 |
+
npm install
|
| 104 |
+
npm run dev
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### 7. Open the app
|
| 108 |
+
|
| 109 |
+
Navigate to [http://localhost:5173](http://localhost:5173).
|
| 110 |
+
|
| 111 |
+
### 8. First run: set up browser sessions
|
| 112 |
+
|
| 113 |
+
**You only need to do this once.**
|
| 114 |
+
|
| 115 |
+
On first launch, click **"Setup Browser Sessions"**. A Chromium window will open. Log in to each of the three sites in that window:
|
| 116 |
+
|
| 117 |
+
1. [delta.com](https://www.delta.com) — sign in to your SkyMiles account
|
| 118 |
+
2. [ihg.com](https://www.ihg.com) — sign in to your IHG One Rewards account
|
| 119 |
+
3. [resy.com](https://resy.com) — sign in to your Resy account
|
| 120 |
+
|
| 121 |
+
Once you're logged in to all three, close the setup window and return to the app. Sessions are saved to disk in `browser_data/` and reused on every subsequent run. You won't be prompted again unless the sessions expire.
|
| 122 |
+
|
| 123 |
+
## How It Works
|
| 124 |
+
|
| 125 |
+
1. You enter a destination, travel window, and trip length in the UI.
|
| 126 |
+
2. The FastAPI backend spins up an orchestrator agent.
|
| 127 |
+
3. The orchestrator launches three subagents in parallel — one per site.
|
| 128 |
+
4. Each subagent opens a Playwright browser using your saved session, navigates to the relevant search page, and extracts available dates, award availability, and pricing.
|
| 129 |
+
5. The orchestrator collects results, finds date ranges where Delta companion availability, IHG 4th-night-free award space, and Resy restaurant options all overlap in the destination city.
|
| 130 |
+
6. Ranked results appear in the UI, showing estimated cash value of the stacked benefits for each date window.
|
| 131 |
+
|
| 132 |
+
## Privacy
|
| 133 |
+
|
| 134 |
+
No credentials are stored by the app. Playwright authenticates using your browser's own saved session cookies (written to `browser_data/`, which is git-ignored). Your username and password never pass through the app.
|
| 135 |
+
|
| 136 |
+
## Troubleshooting
|
| 137 |
+
|
| 138 |
+
- **Session expired**: if a subagent reports it can't access your account, click "Setup Browser Sessions" again and re-log in to the affected site.
|
| 139 |
+
- **Port conflict**: the backend defaults to `8000` and the frontend to `5173`. Set `PORT` in `.env` to override the backend port.
|
| 140 |
+
- **Slow first search**: the three Playwright browsers launch cold on the first query. Subsequent searches reuse warm sessions and are faster.
|
backend/.env.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GEMINI_API_KEY=AIza...
|
| 2 |
+
BROWSER_USER_DATA_DIR=./browser_data
|
| 3 |
+
# Port for the FastAPI server
|
| 4 |
+
PORT=8000
|
backend/__init__.py
ADDED
|
File without changes
|
backend/agents/__init__.py
ADDED
|
File without changes
|
backend/agents/delta_agent.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Delta.com browser agent — finds companion certificate availability.
|
| 3 |
+
Uses google-genai (Gemini Flash free tier) with Playwright browser tools.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
from typing import Callable
|
| 10 |
+
|
| 11 |
+
from google import genai
|
| 12 |
+
from google.genai import types
|
| 13 |
+
|
| 14 |
+
from ..models import Cabin, FlightOption, TripSearchRequest
|
| 15 |
+
from ..tools.browser import BrowserSession
|
| 16 |
+
|
| 17 |
+
_MODEL = "gemini-1.5-flash"
|
| 18 |
+
_MAX_ITER = 30
|
| 19 |
+
_ECONOMY_CLASSES = ["L", "U", "T", "X", "V"]
|
| 20 |
+
_FIRST_CLASSES = ["Z", "D", "I"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _system_prompt(request: TripSearchRequest) -> str:
|
| 24 |
+
cabin = "economy" if request.preferred_cabin == Cabin.economy else "first class"
|
| 25 |
+
eligible = _ECONOMY_CLASSES if request.preferred_cabin == Cabin.economy else _FIRST_CLASSES
|
| 26 |
+
dests = ", ".join(request.destinations)
|
| 27 |
+
return f"""You are a travel research assistant navigating delta.com to find companion certificate availability.
|
| 28 |
+
|
| 29 |
+
## Goal
|
| 30 |
+
Search delta.com for flights from {request.origin} to: {dests}.
|
| 31 |
+
Date window: {request.earliest_departure} to {request.latest_return}.
|
| 32 |
+
Trip length: ~{request.trip_duration_nights} nights. Cabin: {cabin}.
|
| 33 |
+
|
| 34 |
+
## Companion Certificate Rules
|
| 35 |
+
- Companion flies for taxes only (~$80).
|
| 36 |
+
- Economy eligible fare classes: L, U, T, X, V
|
| 37 |
+
- First class eligible fare classes: Z, D, I
|
| 38 |
+
- Hawaii (HNL) is domestic — eligible.
|
| 39 |
+
- Use the Price Calendar to find eligible dates.
|
| 40 |
+
|
| 41 |
+
## Steps
|
| 42 |
+
1. User is already logged in — do NOT log in.
|
| 43 |
+
2. Go to My Profile → Certificates, eCredits & Vouchers. Confirm cert is present.
|
| 44 |
+
3. For each destination: search round-trip with flexible dates, use Price Calendar,
|
| 45 |
+
find dates with eligible fare classes ({", ".join(eligible)}), note outbound date,
|
| 46 |
+
return date, fare class, and base price.
|
| 47 |
+
|
| 48 |
+
## Output
|
| 49 |
+
After searching ALL destinations, output ONLY a JSON array, no prose, no fences:
|
| 50 |
+
[{{"destination":"<IATA>","outbound_date":"<YYYY-MM-DD>","return_date":"<YYYY-MM-DD>",
|
| 51 |
+
"fare_class":"<letter>","base_price":<float>,"companion_taxes":80.0,
|
| 52 |
+
"eligible_for_companion_cert":true,"cabin":"{cabin}"}}]
|
| 53 |
+
Omit destinations with no eligible flights. Output JSON only."""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _user_prompt(request: TripSearchRequest) -> str:
|
| 57 |
+
return (
|
| 58 |
+
f"Search delta.com companion cert availability: {request.origin} → "
|
| 59 |
+
f"{', '.join(request.destinations)}. "
|
| 60 |
+
f"Dates {request.earliest_departure} – {request.latest_return}, "
|
| 61 |
+
f"~{request.trip_duration_nights} nights. Return JSON array."
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _parse(text: str) -> list[FlightOption]:
|
| 66 |
+
text = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`").strip()
|
| 67 |
+
s, e = text.find("["), text.rfind("]")
|
| 68 |
+
if s == -1 or e <= s:
|
| 69 |
+
return []
|
| 70 |
+
try:
|
| 71 |
+
items = json.loads(text[s : e + 1])
|
| 72 |
+
except json.JSONDecodeError:
|
| 73 |
+
return []
|
| 74 |
+
out = []
|
| 75 |
+
for item in items:
|
| 76 |
+
try:
|
| 77 |
+
out.append(FlightOption(**item))
|
| 78 |
+
except Exception:
|
| 79 |
+
pass
|
| 80 |
+
return out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def search_companion_cert(
|
| 84 |
+
request: TripSearchRequest,
|
| 85 |
+
browser: BrowserSession,
|
| 86 |
+
progress: Callable[[str], None],
|
| 87 |
+
) -> list[FlightOption]:
|
| 88 |
+
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
|
| 89 |
+
config = types.GenerateContentConfig(
|
| 90 |
+
system_instruction=_system_prompt(request),
|
| 91 |
+
tools=browser.gemini_tools,
|
| 92 |
+
)
|
| 93 |
+
contents = [types.Content(role="user", parts=[types.Part(text=_user_prompt(request))])]
|
| 94 |
+
|
| 95 |
+
progress(f"Delta: searching {request.origin} → {', '.join(request.destinations)}")
|
| 96 |
+
|
| 97 |
+
last_text = ""
|
| 98 |
+
for i in range(_MAX_ITER):
|
| 99 |
+
response = await client.aio.models.generate_content(
|
| 100 |
+
model=_MODEL, contents=contents, config=config
|
| 101 |
+
)
|
| 102 |
+
contents.append(response.candidates[0].content)
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
if response.text:
|
| 106 |
+
last_text = response.text
|
| 107 |
+
except ValueError:
|
| 108 |
+
pass
|
| 109 |
+
|
| 110 |
+
fn_calls = response.function_calls
|
| 111 |
+
if not fn_calls:
|
| 112 |
+
progress("Delta: navigation complete.")
|
| 113 |
+
break
|
| 114 |
+
|
| 115 |
+
fn_parts = []
|
| 116 |
+
for fc in fn_calls:
|
| 117 |
+
progress(f"Delta [{i+1}/{_MAX_ITER}]: {fc.name}")
|
| 118 |
+
result = await browser.execute_tool(fc.name, dict(fc.args))
|
| 119 |
+
fn_parts.append(types.Part(
|
| 120 |
+
function_response=types.FunctionResponse(name=fc.name, response={"result": result})
|
| 121 |
+
))
|
| 122 |
+
contents.append(types.Content(role="user", parts=fn_parts))
|
| 123 |
+
else:
|
| 124 |
+
progress(f"Delta: reached max iterations, parsing partial results.")
|
| 125 |
+
|
| 126 |
+
options = _parse(last_text)
|
| 127 |
+
progress(f"Delta: found {len(options)} eligible flight(s).")
|
| 128 |
+
return options
|
backend/agents/ihg_agent.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
IHG.com browser agent — finds reward-night (points) availability.
|
| 3 |
+
Uses google-genai (Gemini Flash free tier) with Playwright browser tools.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
from datetime import date
|
| 12 |
+
from typing import Callable
|
| 13 |
+
|
| 14 |
+
from google import genai
|
| 15 |
+
from google.genai import types
|
| 16 |
+
|
| 17 |
+
from ..models import HotelOption
|
| 18 |
+
from ..tools.browser import BrowserSession
|
| 19 |
+
|
| 20 |
+
_MODEL = "gemini-1.5-flash"
|
| 21 |
+
_MAX_ITER = 30
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _system_prompt(
|
| 25 |
+
destination: str, check_in: date, check_out: date,
|
| 26 |
+
preferred_brands: list[str], nights: int, nights_paid: int, nights_free: int,
|
| 27 |
+
) -> str:
|
| 28 |
+
brands = ", ".join(preferred_brands)
|
| 29 |
+
return f"""You are an IHG hotel search assistant. Search ihg.com for reward-night availability.
|
| 30 |
+
|
| 31 |
+
## Search
|
| 32 |
+
Destination: {destination} | Check-in: {check_in} | Check-out: {check_out}
|
| 33 |
+
Nights: {nights} total ({nights_paid} paid, {nights_free} free via 4th-night-free benefit)
|
| 34 |
+
Preferred brands (in order): {brands}
|
| 35 |
+
|
| 36 |
+
## Steps
|
| 37 |
+
1. Go to https://www.ihg.com/hotels/us/en/find-hotels/hotel/list
|
| 38 |
+
2. Enter "{destination}", set dates {check_in} → {check_out}.
|
| 39 |
+
3. CRITICAL: Toggle "Use Points" ON before searching. Cash rates don't qualify.
|
| 40 |
+
4. Only include brands: {brands}
|
| 41 |
+
5. Only include hotels with points availability on ALL nights.
|
| 42 |
+
6. total_points = points_per_night × {nights_paid}
|
| 43 |
+
|
| 44 |
+
## Output — JSON array only, no prose, no fences:
|
| 45 |
+
[{{"destination":"{destination}","property_name":"<name>","brand":"<brand>",
|
| 46 |
+
"check_in":"{check_in}","check_out":"{check_out}",
|
| 47 |
+
"points_per_night":<int>,"total_points":<int>,
|
| 48 |
+
"nights_paid":{nights_paid},"nights_free":{nights_free},"cash_rate":<float|null>}}]
|
| 49 |
+
Return [] if nothing found."""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _parse(text: str, destination: str, check_in: date, check_out: date) -> list[HotelOption]:
|
| 53 |
+
m = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
| 54 |
+
text = m.group(1).strip() if m else text
|
| 55 |
+
s, e = text.find("["), text.rfind("]")
|
| 56 |
+
if s == -1 or e == -1:
|
| 57 |
+
return []
|
| 58 |
+
try:
|
| 59 |
+
items = json.loads(text[s : e + 1])
|
| 60 |
+
except json.JSONDecodeError:
|
| 61 |
+
return []
|
| 62 |
+
out = []
|
| 63 |
+
for item in items:
|
| 64 |
+
try:
|
| 65 |
+
for k in ("check_in", "check_out"):
|
| 66 |
+
if isinstance(item.get(k), str):
|
| 67 |
+
item[k] = date.fromisoformat(item[k])
|
| 68 |
+
out.append(HotelOption(**item))
|
| 69 |
+
except Exception:
|
| 70 |
+
pass
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def _search_one(
|
| 75 |
+
destination: str, check_in: date, check_out: date,
|
| 76 |
+
browser: BrowserSession, preferred_brands: list[str],
|
| 77 |
+
nights: int, progress: Callable[[str], None],
|
| 78 |
+
) -> list[HotelOption]:
|
| 79 |
+
nights_free = nights // 4
|
| 80 |
+
nights_paid = nights - nights_free
|
| 81 |
+
|
| 82 |
+
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
|
| 83 |
+
config = types.GenerateContentConfig(
|
| 84 |
+
system_instruction=_system_prompt(
|
| 85 |
+
destination, check_in, check_out, preferred_brands, nights, nights_paid, nights_free
|
| 86 |
+
),
|
| 87 |
+
tools=browser.gemini_tools,
|
| 88 |
+
)
|
| 89 |
+
contents = [types.Content(role="user", parts=[types.Part(text=(
|
| 90 |
+
f"Search ihg.com for reward-night availability in {destination} "
|
| 91 |
+
f"from {check_in} to {check_out}. Toggle Use Points. Return JSON array."
|
| 92 |
+
))])]
|
| 93 |
+
|
| 94 |
+
last_text = ""
|
| 95 |
+
for i in range(_MAX_ITER):
|
| 96 |
+
response = await client.aio.models.generate_content(
|
| 97 |
+
model=_MODEL, contents=contents, config=config
|
| 98 |
+
)
|
| 99 |
+
contents.append(response.candidates[0].content)
|
| 100 |
+
try:
|
| 101 |
+
if response.text:
|
| 102 |
+
last_text = response.text
|
| 103 |
+
except ValueError:
|
| 104 |
+
pass
|
| 105 |
+
|
| 106 |
+
fn_calls = response.function_calls
|
| 107 |
+
if not fn_calls:
|
| 108 |
+
break
|
| 109 |
+
|
| 110 |
+
fn_parts = []
|
| 111 |
+
for fc in fn_calls:
|
| 112 |
+
progress(f"IHG [{destination}]: {fc.name} (step {i+1})")
|
| 113 |
+
result = await browser.execute_tool(fc.name, dict(fc.args))
|
| 114 |
+
fn_parts.append(types.Part(
|
| 115 |
+
function_response=types.FunctionResponse(name=fc.name, response={"result": result})
|
| 116 |
+
))
|
| 117 |
+
contents.append(types.Content(role="user", parts=fn_parts))
|
| 118 |
+
|
| 119 |
+
results = _parse(last_text, destination, check_in, check_out)
|
| 120 |
+
progress(f"IHG [{destination}]: {len(results)} property(ies) found.")
|
| 121 |
+
return results
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def search_points_availability(
|
| 125 |
+
destinations: list[str],
|
| 126 |
+
date_pairs: list[tuple[date, date]],
|
| 127 |
+
browser: BrowserSession,
|
| 128 |
+
preferred_brands: list[str],
|
| 129 |
+
nights: int,
|
| 130 |
+
progress: Callable[[str], None],
|
| 131 |
+
) -> list[HotelOption]:
|
| 132 |
+
all_results: list[HotelOption] = []
|
| 133 |
+
for destination in destinations:
|
| 134 |
+
for check_in, check_out in date_pairs:
|
| 135 |
+
progress(f"IHG: {destination} {check_in} → {check_out}")
|
| 136 |
+
all_results.extend(await _search_one(
|
| 137 |
+
destination, check_in, check_out, browser, preferred_brands, nights, progress
|
| 138 |
+
))
|
| 139 |
+
progress(f"IHG: complete — {len(all_results)} total option(s).")
|
| 140 |
+
return all_results
|
backend/agents/resy_agent.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Resy.com browser agent — finds restaurants eligible for the $20 monthly dining credit.
|
| 3 |
+
Uses google-genai (Gemini Flash free tier) with Playwright browser tools.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
from collections.abc import Callable
|
| 10 |
+
from datetime import date
|
| 11 |
+
|
| 12 |
+
from google import genai
|
| 13 |
+
from google.genai import types
|
| 14 |
+
|
| 15 |
+
from ..models import RestaurantOption
|
| 16 |
+
from ..tools.browser import BrowserSession
|
| 17 |
+
|
| 18 |
+
_MODEL = "gemini-1.5-flash"
|
| 19 |
+
_MAX_ITER = 20
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _system_prompt(cities: list[str], dinner_dates: list[date], party_size: int) -> str:
|
| 23 |
+
pairs = "\n".join(f" - {c}: {d}" for c, d in zip(cities, dinner_dates))
|
| 24 |
+
return f"""You are a restaurant-discovery assistant on Resy.com.
|
| 25 |
+
|
| 26 |
+
## Mission
|
| 27 |
+
Find 2–3 dinner options per city below. Return ONE JSON array when done.
|
| 28 |
+
|
| 29 |
+
## Resy credit facts
|
| 30 |
+
- Delta Reserve Amex $20/month credit applies to ANY Resy booking. Set resy_credit_eligible=true always.
|
| 31 |
+
- Global Dining Access (GDA) badge appears on select listings. Set global_dining_access=true only if you see it.
|
| 32 |
+
|
| 33 |
+
## Strategy
|
| 34 |
+
1. Navigate to resy.com for each city (e.g. https://resy.com/cities/hnl for Honolulu).
|
| 35 |
+
2. Search dinner availability on the dinner date, party size {party_size}, 7pm–9pm slots.
|
| 36 |
+
3. Find 2–3 restaurants per city.
|
| 37 |
+
|
| 38 |
+
## Cities and dinner dates:
|
| 39 |
+
{pairs}
|
| 40 |
+
Party size: {party_size}
|
| 41 |
+
|
| 42 |
+
## Output — JSON array only, no prose, no fences:
|
| 43 |
+
[{{"city":"<city>","name":"<restaurant>","cuisine":"<type>","reservation_date":"<YYYY-MM-DD>",
|
| 44 |
+
"party_size":{party_size},"resy_credit_eligible":true,"global_dining_access":<bool>}}]"""
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _parse(text: str, party_size: int) -> list[RestaurantOption]:
|
| 48 |
+
text = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`").strip()
|
| 49 |
+
s, e = text.find("["), text.rfind("]")
|
| 50 |
+
if s == -1 or e == -1:
|
| 51 |
+
return []
|
| 52 |
+
try:
|
| 53 |
+
items = json.loads(text[s : e + 1])
|
| 54 |
+
except json.JSONDecodeError:
|
| 55 |
+
return []
|
| 56 |
+
out = []
|
| 57 |
+
for item in items:
|
| 58 |
+
try:
|
| 59 |
+
rd = item.get("reservation_date")
|
| 60 |
+
if isinstance(rd, str):
|
| 61 |
+
rd = date.fromisoformat(rd)
|
| 62 |
+
out.append(RestaurantOption(
|
| 63 |
+
city=item["city"], name=item["name"],
|
| 64 |
+
cuisine=item.get("cuisine", ""),
|
| 65 |
+
reservation_date=rd,
|
| 66 |
+
party_size=item.get("party_size", party_size),
|
| 67 |
+
resy_credit_eligible=item.get("resy_credit_eligible", True),
|
| 68 |
+
global_dining_access=item.get("global_dining_access", False),
|
| 69 |
+
))
|
| 70 |
+
except (KeyError, ValueError):
|
| 71 |
+
pass
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def find_resy_restaurants(
|
| 76 |
+
cities: list[str],
|
| 77 |
+
dinner_dates: list[date],
|
| 78 |
+
party_size: int,
|
| 79 |
+
browser: BrowserSession,
|
| 80 |
+
progress: Callable[[str], None],
|
| 81 |
+
) -> list[RestaurantOption]:
|
| 82 |
+
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
|
| 83 |
+
config = types.GenerateContentConfig(
|
| 84 |
+
system_instruction=_system_prompt(cities, dinner_dates, party_size),
|
| 85 |
+
tools=browser.gemini_tools,
|
| 86 |
+
)
|
| 87 |
+
contents = [types.Content(role="user", parts=[types.Part(text=(
|
| 88 |
+
"Search Resy for restaurants in each city in the system prompt. Return the JSON array when done."
|
| 89 |
+
))])]
|
| 90 |
+
|
| 91 |
+
progress(f"Resy: searching {len(cities)} city/cities…")
|
| 92 |
+
|
| 93 |
+
last_text = ""
|
| 94 |
+
for i in range(_MAX_ITER):
|
| 95 |
+
response = await client.aio.models.generate_content(
|
| 96 |
+
model=_MODEL, contents=contents, config=config
|
| 97 |
+
)
|
| 98 |
+
contents.append(response.candidates[0].content)
|
| 99 |
+
try:
|
| 100 |
+
if response.text:
|
| 101 |
+
last_text = response.text
|
| 102 |
+
except ValueError:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
fn_calls = response.function_calls
|
| 106 |
+
if not fn_calls:
|
| 107 |
+
break
|
| 108 |
+
|
| 109 |
+
fn_parts = []
|
| 110 |
+
for fc in fn_calls:
|
| 111 |
+
progress(f"Resy: {fc.name}…")
|
| 112 |
+
try:
|
| 113 |
+
result = await browser.execute_tool(fc.name, dict(fc.args))
|
| 114 |
+
except Exception as exc:
|
| 115 |
+
result = f"Error: {exc}"
|
| 116 |
+
fn_parts.append(types.Part(
|
| 117 |
+
function_response=types.FunctionResponse(name=fc.name, response={"result": result})
|
| 118 |
+
))
|
| 119 |
+
contents.append(types.Content(role="user", parts=fn_parts))
|
| 120 |
+
|
| 121 |
+
results = _parse(last_text, party_size)
|
| 122 |
+
progress(f"Resy: found {len(results)} restaurant(s).")
|
| 123 |
+
return results
|
backend/main.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
from typing import AsyncGenerator
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from fastapi.responses import StreamingResponse
|
| 13 |
+
|
| 14 |
+
from .models import SearchProgress, SearchResponse, TripResult, TripSearchRequest
|
| 15 |
+
from .orchestrator import run_trip_search
|
| 16 |
+
|
| 17 |
+
load_dotenv()
|
| 18 |
+
|
| 19 |
+
logging.basicConfig(level=logging.INFO)
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# In-memory store: search_id -> {"status": str, "results": list | None, "queue": asyncio.Queue}
|
| 23 |
+
searches: dict[str, dict] = {}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@asynccontextmanager
|
| 27 |
+
async def lifespan(app: FastAPI):
|
| 28 |
+
api_key = os.getenv("GEMINI_API_KEY")
|
| 29 |
+
if not api_key:
|
| 30 |
+
logger.warning(
|
| 31 |
+
"GEMINI_API_KEY is not set. Trip search will fail until it is configured."
|
| 32 |
+
)
|
| 33 |
+
else:
|
| 34 |
+
logger.info("GEMINI_API_KEY found — ready to serve requests.")
|
| 35 |
+
yield
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
app = FastAPI(title="Tripplanner API", version="0.1.0", lifespan=lifespan)
|
| 39 |
+
|
| 40 |
+
app.add_middleware(
|
| 41 |
+
CORSMiddleware,
|
| 42 |
+
allow_origins=["http://localhost:5173", "http://localhost:5174",
|
| 43 |
+
"http://localhost:5175", "http://localhost:3000",
|
| 44 |
+
"*"], # Vercel preview URLs
|
| 45 |
+
allow_credentials=True,
|
| 46 |
+
allow_methods=["*"],
|
| 47 |
+
allow_headers=["*"],
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Background task wrapper
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
async def _run_search(search_id: str, request: TripSearchRequest) -> None:
|
| 56 |
+
"""Run the trip search and feed progress into the per-search queue."""
|
| 57 |
+
queue: asyncio.Queue = searches[search_id]["queue"]
|
| 58 |
+
try:
|
| 59 |
+
async for progress in run_trip_search(request):
|
| 60 |
+
await queue.put(progress)
|
| 61 |
+
if isinstance(progress, SearchProgress) and progress.step == "done":
|
| 62 |
+
break
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
logger.exception("Error in background trip search %s", search_id)
|
| 65 |
+
# Push an error sentinel so the SSE stream can close cleanly
|
| 66 |
+
error_progress = SearchProgress(
|
| 67 |
+
step="error", message=str(exc), progress=0
|
| 68 |
+
)
|
| 69 |
+
await queue.put(error_progress)
|
| 70 |
+
searches[search_id]["status"] = "error"
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
searches[search_id]["status"] = "done"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
# Endpoints
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
@app.get("/api/health")
|
| 81 |
+
async def health() -> dict:
|
| 82 |
+
return {"status": "ok"}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@app.post("/api/session/upload")
|
| 86 |
+
async def upload_session(request: Request) -> dict:
|
| 87 |
+
"""Accept a Playwright storage-state JSON from the local session_setup.py script."""
|
| 88 |
+
secret = os.getenv("SESSION_UPLOAD_SECRET", "")
|
| 89 |
+
if secret and request.headers.get("X-Session-Secret") != secret:
|
| 90 |
+
raise HTTPException(status_code=403, detail="Invalid session secret")
|
| 91 |
+
body = await request.body()
|
| 92 |
+
session_path = os.path.join(
|
| 93 |
+
os.getenv("BROWSER_USER_DATA_DIR", "./browser_data"), "sessions.json"
|
| 94 |
+
)
|
| 95 |
+
os.makedirs(os.path.dirname(session_path), exist_ok=True)
|
| 96 |
+
with open(session_path, "wb") as f:
|
| 97 |
+
f.write(body)
|
| 98 |
+
logger.info("Session state uploaded (%d bytes)", len(body))
|
| 99 |
+
return {"status": "ok", "bytes": len(body)}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.post("/api/search", response_model=dict)
|
| 103 |
+
async def start_search(
|
| 104 |
+
request: TripSearchRequest, background_tasks: BackgroundTasks
|
| 105 |
+
) -> dict:
|
| 106 |
+
search_id = str(uuid.uuid4())
|
| 107 |
+
searches[search_id] = {
|
| 108 |
+
"status": "running",
|
| 109 |
+
"results": None,
|
| 110 |
+
"queue": asyncio.Queue(),
|
| 111 |
+
}
|
| 112 |
+
background_tasks.add_task(_run_search, search_id, request)
|
| 113 |
+
return {"search_id": search_id}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@app.get("/api/search/{search_id}")
|
| 117 |
+
async def get_search(search_id: str) -> dict:
|
| 118 |
+
entry = searches.get(search_id)
|
| 119 |
+
if entry is None:
|
| 120 |
+
raise HTTPException(status_code=404, detail="Search not found")
|
| 121 |
+
return {"status": entry["status"], "results": entry["results"]}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.get("/api/search/{search_id}/stream")
|
| 125 |
+
async def stream_search(search_id: str) -> StreamingResponse:
|
| 126 |
+
entry = searches.get(search_id)
|
| 127 |
+
if entry is None:
|
| 128 |
+
raise HTTPException(status_code=404, detail="Search not found")
|
| 129 |
+
|
| 130 |
+
async def event_generator() -> AsyncGenerator[str, None]:
|
| 131 |
+
queue: asyncio.Queue = entry["queue"]
|
| 132 |
+
while True:
|
| 133 |
+
try:
|
| 134 |
+
item = await asyncio.wait_for(queue.get(), timeout=30.0)
|
| 135 |
+
except asyncio.TimeoutError:
|
| 136 |
+
# Send a keep-alive comment so the connection doesn't drop
|
| 137 |
+
yield ": keep-alive\n\n"
|
| 138 |
+
continue
|
| 139 |
+
|
| 140 |
+
if isinstance(item, SearchProgress):
|
| 141 |
+
payload = item.model_dump()
|
| 142 |
+
yield f"data: {json.dumps(payload)}\n\n"
|
| 143 |
+
|
| 144 |
+
if item.step == "done":
|
| 145 |
+
# Emit the final results as a separate "results" event
|
| 146 |
+
results = entry.get("results")
|
| 147 |
+
if results is not None:
|
| 148 |
+
results_payload = [
|
| 149 |
+
r.model_dump() if isinstance(r, TripResult) else r
|
| 150 |
+
for r in results
|
| 151 |
+
]
|
| 152 |
+
yield f"event: results\ndata: {json.dumps(results_payload)}\n\n"
|
| 153 |
+
break
|
| 154 |
+
|
| 155 |
+
if item.step == "error":
|
| 156 |
+
break
|
| 157 |
+
|
| 158 |
+
elif isinstance(item, list):
|
| 159 |
+
# The orchestrator may push the final TripResult list directly
|
| 160 |
+
entry["results"] = item
|
| 161 |
+
results_payload = [
|
| 162 |
+
r.model_dump() if isinstance(r, TripResult) else r
|
| 163 |
+
for r in item
|
| 164 |
+
]
|
| 165 |
+
yield f"event: results\ndata: {json.dumps(results_payload)}\n\n"
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
return StreamingResponse(
|
| 169 |
+
event_generator(),
|
| 170 |
+
media_type="text/event-stream",
|
| 171 |
+
headers={
|
| 172 |
+
"Cache-Control": "no-cache",
|
| 173 |
+
"X-Accel-Buffering": "no",
|
| 174 |
+
"Connection": "keep-alive",
|
| 175 |
+
},
|
| 176 |
+
)
|
backend/models.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from datetime import date
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Cabin(str, Enum):
|
| 8 |
+
economy = "economy"
|
| 9 |
+
first = "first"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TripSearchRequest(BaseModel):
|
| 13 |
+
origin: str # e.g. "JAX" or "GNV"
|
| 14 |
+
destinations: list[str] # e.g. ["HNL", "LAX", "JFK"]
|
| 15 |
+
earliest_departure: date
|
| 16 |
+
latest_return: date
|
| 17 |
+
party_size: int = 2
|
| 18 |
+
trip_duration_nights: int = 4
|
| 19 |
+
preferred_cabin: Cabin = Cabin.economy
|
| 20 |
+
ihg_brands: list[str] = ["Kimpton", "InterContinental", "Hotel Indigo"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FlightOption(BaseModel):
|
| 24 |
+
destination: str
|
| 25 |
+
outbound_date: date
|
| 26 |
+
return_date: date
|
| 27 |
+
fare_class: str # e.g. "L", "U", "Z"
|
| 28 |
+
base_price: float # price for one ticket
|
| 29 |
+
companion_taxes: float # taxes the companion pays (~$80)
|
| 30 |
+
eligible_for_companion_cert: bool
|
| 31 |
+
cabin: Cabin
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class HotelOption(BaseModel):
|
| 35 |
+
destination: str
|
| 36 |
+
property_name: str
|
| 37 |
+
brand: str
|
| 38 |
+
check_in: date
|
| 39 |
+
check_out: date
|
| 40 |
+
points_per_night: int
|
| 41 |
+
total_points: int # for nights_paid only
|
| 42 |
+
nights_paid: int
|
| 43 |
+
nights_free: int # from 4th-night-free benefit
|
| 44 |
+
cash_rate: Optional[float] = None # nightly cash rate if available
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class RestaurantOption(BaseModel):
|
| 48 |
+
city: str
|
| 49 |
+
name: str
|
| 50 |
+
cuisine: str
|
| 51 |
+
reservation_date: date
|
| 52 |
+
party_size: int
|
| 53 |
+
resy_credit_eligible: bool
|
| 54 |
+
global_dining_access: bool
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class TripResult(BaseModel):
|
| 58 |
+
destination: str
|
| 59 |
+
flight: FlightOption
|
| 60 |
+
hotel: Optional[HotelOption] = None
|
| 61 |
+
restaurant: Optional[RestaurantOption] = None
|
| 62 |
+
total_cash_out_of_pocket: float
|
| 63 |
+
total_points_required: int
|
| 64 |
+
benefits_captured: list[str]
|
| 65 |
+
score: float # 0–100, higher = better value
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class SearchProgress(BaseModel):
|
| 69 |
+
step: str # "delta" | "ihg" | "resy" | "done"
|
| 70 |
+
message: str
|
| 71 |
+
progress: int # 0–100
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class SearchResponse(BaseModel):
|
| 75 |
+
search_id: str
|
| 76 |
+
results: list[TripResult]
|
backend/orchestrator.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
orchestrator.py — Tripplanner search orchestrator.
|
| 3 |
+
|
| 4 |
+
Coordinates Delta, IHG, and Resy subagents sequentially over a single shared
|
| 5 |
+
BrowserSession, then merges their results into ranked TripResult objects.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import asyncio
|
| 12 |
+
from typing import Callable
|
| 13 |
+
|
| 14 |
+
from .models import (
|
| 15 |
+
TripSearchRequest,
|
| 16 |
+
FlightOption,
|
| 17 |
+
HotelOption,
|
| 18 |
+
RestaurantOption,
|
| 19 |
+
TripResult,
|
| 20 |
+
SearchProgress,
|
| 21 |
+
)
|
| 22 |
+
from .tools.browser import BrowserSession
|
| 23 |
+
from .agents.delta_agent import search_companion_cert
|
| 24 |
+
from .agents.ihg_agent import search_points_availability
|
| 25 |
+
from .agents.resy_agent import find_resy_restaurants
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Public helpers
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
def calculate_cash_out_of_pocket(
|
| 33 |
+
flight: FlightOption,
|
| 34 |
+
hotel: HotelOption | None,
|
| 35 |
+
) -> float:
|
| 36 |
+
"""
|
| 37 |
+
Return the total cash the traveler pays.
|
| 38 |
+
|
| 39 |
+
The companion's ticket is covered by the certificate; they only owe the
|
| 40 |
+
companion_taxes. Hotel is redeemed with points so costs $0 cash.
|
| 41 |
+
Restaurant dining credit is separate from this calculation.
|
| 42 |
+
"""
|
| 43 |
+
# Primary ticket + companion taxes (companion cert covers the fare itself)
|
| 44 |
+
return flight.base_price + flight.companion_taxes
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def collect_benefits(
|
| 48 |
+
flight: FlightOption,
|
| 49 |
+
hotel: HotelOption | None,
|
| 50 |
+
restaurant: RestaurantOption | None,
|
| 51 |
+
) -> list[str]:
|
| 52 |
+
"""Return human-readable benefit strings for a trip combination."""
|
| 53 |
+
benefits: list[str] = []
|
| 54 |
+
|
| 55 |
+
if flight.eligible_for_companion_cert:
|
| 56 |
+
benefits.append("Delta companion certificate used")
|
| 57 |
+
|
| 58 |
+
if hotel is not None:
|
| 59 |
+
if hotel.nights_free > 0:
|
| 60 |
+
benefits.append(
|
| 61 |
+
f"IHG 4th night free ({hotel.nights_free} night{'s' if hotel.nights_free != 1 else ''} free)"
|
| 62 |
+
)
|
| 63 |
+
else:
|
| 64 |
+
benefits.append(f"IHG points redemption at {hotel.property_name}")
|
| 65 |
+
|
| 66 |
+
if restaurant is not None:
|
| 67 |
+
if restaurant.resy_credit_eligible:
|
| 68 |
+
benefits.append("Resy $20 dining credit eligible")
|
| 69 |
+
if restaurant.global_dining_access:
|
| 70 |
+
benefits.append(f"Global Dining Access reservation at {restaurant.name}")
|
| 71 |
+
|
| 72 |
+
return benefits
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# Internal helpers
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
def _score_trip(
|
| 80 |
+
flight: FlightOption,
|
| 81 |
+
hotel: HotelOption | None,
|
| 82 |
+
restaurant: RestaurantOption | None,
|
| 83 |
+
) -> float:
|
| 84 |
+
"""
|
| 85 |
+
Score a trip combination on a 0–100 scale.
|
| 86 |
+
|
| 87 |
+
Breakdown:
|
| 88 |
+
+40 companion cert eligible
|
| 89 |
+
+30 hotel found with points availability
|
| 90 |
+
+15 restaurant found
|
| 91 |
+
+15 value score (lower cash out of pocket relative to flight base price)
|
| 92 |
+
"""
|
| 93 |
+
score = 0.0
|
| 94 |
+
|
| 95 |
+
if flight.eligible_for_companion_cert:
|
| 96 |
+
score += 40.0
|
| 97 |
+
|
| 98 |
+
if hotel is not None:
|
| 99 |
+
score += 30.0
|
| 100 |
+
|
| 101 |
+
if restaurant is not None:
|
| 102 |
+
score += 15.0
|
| 103 |
+
|
| 104 |
+
# Value component: full +15 when cash_out == companion_taxes only (i.e.
|
| 105 |
+
# traveler saved the most), scaling down as cash_out approaches base_price*2.
|
| 106 |
+
cash_out = calculate_cash_out_of_pocket(flight, hotel)
|
| 107 |
+
max_possible_cash = flight.base_price * 2 # both tickets at full price
|
| 108 |
+
if max_possible_cash > 0:
|
| 109 |
+
savings_ratio = 1.0 - (cash_out / max_possible_cash)
|
| 110 |
+
# Clamp to [0, 1] so edge cases don't break the scale.
|
| 111 |
+
savings_ratio = max(0.0, min(1.0, savings_ratio))
|
| 112 |
+
score += savings_ratio * 15.0
|
| 113 |
+
|
| 114 |
+
return round(score, 2)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _extract_date_pairs(
|
| 118 |
+
flights: list[FlightOption],
|
| 119 |
+
) -> list[tuple[str, str]]:
|
| 120 |
+
"""Return (check_in_iso, check_out_iso) pairs from flight results."""
|
| 121 |
+
return [
|
| 122 |
+
(flight.outbound_date.isoformat(), flight.return_date.isoformat())
|
| 123 |
+
for flight in flights
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _extract_destinations(flights: list[FlightOption]) -> list[str]:
|
| 128 |
+
return list(dict.fromkeys(flight.destination for flight in flights))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _extract_cities_and_arrival_dates(
|
| 132 |
+
flights: list[FlightOption],
|
| 133 |
+
) -> tuple[list[str], list[str]]:
|
| 134 |
+
cities = list(dict.fromkeys(flight.destination for flight in flights))
|
| 135 |
+
arrival_dates = list(
|
| 136 |
+
dict.fromkeys(flight.outbound_date.isoformat() for flight in flights)
|
| 137 |
+
)
|
| 138 |
+
return cities, arrival_dates
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _find_matching_hotel(
|
| 142 |
+
flight: FlightOption,
|
| 143 |
+
hotels: list[HotelOption],
|
| 144 |
+
) -> HotelOption | None:
|
| 145 |
+
"""Return the first HotelOption whose destination and dates overlap the flight."""
|
| 146 |
+
for hotel in hotels:
|
| 147 |
+
if hotel.destination != flight.destination:
|
| 148 |
+
continue
|
| 149 |
+
# Dates overlap when check_in <= return_date AND check_out >= outbound_date
|
| 150 |
+
if hotel.check_in <= flight.return_date and hotel.check_out >= flight.outbound_date:
|
| 151 |
+
return hotel
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _find_matching_restaurant(
|
| 156 |
+
flight: FlightOption,
|
| 157 |
+
restaurants: list[RestaurantOption],
|
| 158 |
+
) -> RestaurantOption | None:
|
| 159 |
+
"""Return the first RestaurantOption matching the flight city and arrival date."""
|
| 160 |
+
for restaurant in restaurants:
|
| 161 |
+
if (
|
| 162 |
+
restaurant.city == flight.destination
|
| 163 |
+
and restaurant.reservation_date == flight.outbound_date
|
| 164 |
+
):
|
| 165 |
+
return restaurant
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _emit(
|
| 170 |
+
callback: Callable[[SearchProgress], None],
|
| 171 |
+
step: str,
|
| 172 |
+
message: str,
|
| 173 |
+
progress: int,
|
| 174 |
+
) -> None:
|
| 175 |
+
callback(SearchProgress(step=step, message=message, progress=progress))
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
# Main orchestrator
|
| 180 |
+
# ---------------------------------------------------------------------------
|
| 181 |
+
|
| 182 |
+
async def run_trip_search(
|
| 183 |
+
request: TripSearchRequest,
|
| 184 |
+
progress_callback: Callable[[SearchProgress], None],
|
| 185 |
+
) -> list[TripResult]:
|
| 186 |
+
"""
|
| 187 |
+
Coordinate Delta, IHG, and Resy subagents and return ranked TripResult list.
|
| 188 |
+
|
| 189 |
+
All three agents share a single BrowserSession so that login state is
|
| 190 |
+
preserved across site transitions.
|
| 191 |
+
"""
|
| 192 |
+
user_data_dir = os.environ.get("BROWSER_USER_DATA_DIR", "./browser_data")
|
| 193 |
+
|
| 194 |
+
async with BrowserSession(user_data_dir=user_data_dir) as browser:
|
| 195 |
+
|
| 196 |
+
# --- Step 1: Delta companion certificate search ---
|
| 197 |
+
_emit(
|
| 198 |
+
progress_callback,
|
| 199 |
+
step="delta",
|
| 200 |
+
message="Checking companion certificate availability...",
|
| 201 |
+
progress=10,
|
| 202 |
+
)
|
| 203 |
+
flights: list[FlightOption] = await search_companion_cert(
|
| 204 |
+
request, browser
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# --- Step 2: IHG points availability ---
|
| 208 |
+
_emit(
|
| 209 |
+
progress_callback,
|
| 210 |
+
step="ihg",
|
| 211 |
+
message="Checking IHG points availability...",
|
| 212 |
+
progress=40,
|
| 213 |
+
)
|
| 214 |
+
destinations = _extract_destinations(flights)
|
| 215 |
+
date_pairs = _extract_date_pairs(flights)
|
| 216 |
+
hotels: list[HotelOption] = await search_points_availability(
|
| 217 |
+
destinations, date_pairs, browser
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# --- Step 3: Resy dining options ---
|
| 221 |
+
_emit(
|
| 222 |
+
progress_callback,
|
| 223 |
+
step="resy",
|
| 224 |
+
message="Finding Resy dining options...",
|
| 225 |
+
progress=70,
|
| 226 |
+
)
|
| 227 |
+
cities, arrival_dates = _extract_cities_and_arrival_dates(flights)
|
| 228 |
+
restaurants: list[RestaurantOption] = await find_resy_restaurants(
|
| 229 |
+
cities, arrival_dates, request.party_size, browser
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# BrowserSession closed — merge results in memory.
|
| 233 |
+
|
| 234 |
+
# --- Step 4: Merge and rank ---
|
| 235 |
+
_emit(
|
| 236 |
+
progress_callback,
|
| 237 |
+
step="merging",
|
| 238 |
+
message="Ranking trip combinations...",
|
| 239 |
+
progress=90,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
results: list[TripResult] = []
|
| 243 |
+
for flight in flights:
|
| 244 |
+
hotel = _find_matching_hotel(flight, hotels)
|
| 245 |
+
restaurant = _find_matching_restaurant(flight, restaurants)
|
| 246 |
+
|
| 247 |
+
cash_out = calculate_cash_out_of_pocket(flight, hotel)
|
| 248 |
+
points_required = hotel.total_points if hotel is not None else 0
|
| 249 |
+
benefits = collect_benefits(flight, hotel, restaurant)
|
| 250 |
+
score = _score_trip(flight, hotel, restaurant)
|
| 251 |
+
|
| 252 |
+
results.append(
|
| 253 |
+
TripResult(
|
| 254 |
+
destination=flight.destination,
|
| 255 |
+
flight=flight,
|
| 256 |
+
hotel=hotel,
|
| 257 |
+
restaurant=restaurant,
|
| 258 |
+
total_cash_out_of_pocket=cash_out,
|
| 259 |
+
total_points_required=points_required,
|
| 260 |
+
benefits_captured=benefits,
|
| 261 |
+
score=score,
|
| 262 |
+
)
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
results.sort(key=lambda r: r.score, reverse=True)
|
| 266 |
+
|
| 267 |
+
# --- Step 5: Done ---
|
| 268 |
+
_emit(
|
| 269 |
+
progress_callback,
|
| 270 |
+
step="done",
|
| 271 |
+
message="Search complete!",
|
| 272 |
+
progress=100,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
return results
|
backend/pyproject.toml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "tripplanner-backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
requires-python = ">=3.11"
|
| 5 |
+
dependencies = [
|
| 6 |
+
"fastapi>=0.115.0",
|
| 7 |
+
"uvicorn[standard]>=0.30.0",
|
| 8 |
+
"playwright>=1.45.0",
|
| 9 |
+
"google-genai>=1.0.0",
|
| 10 |
+
"python-dotenv>=1.0.0",
|
| 11 |
+
"pydantic>=2.0.0",
|
| 12 |
+
"httpx>=0.27.0",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
[build-system]
|
| 16 |
+
requires = ["setuptools"]
|
| 17 |
+
build-backend = "setuptools.backends.legacy:build"
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.30.0
|
| 3 |
+
playwright>=1.45.0
|
| 4 |
+
google-genai>=1.0.0
|
| 5 |
+
python-dotenv>=1.0.0
|
| 6 |
+
pydantic>=2.0.0
|
| 7 |
+
httpx>=0.27.0
|
backend/tools/__init__.py
ADDED
|
File without changes
|
backend/tools/browser.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BrowserSession — async Playwright wrapper for the Tripplanner browser-automation layer.
|
| 3 |
+
|
| 4 |
+
AI subagents (claude-haiku with tool_use) call the exposed methods to navigate
|
| 5 |
+
delta.com, ihg.com, and resy.com. A persistent Chromium profile keeps users
|
| 6 |
+
logged in between runs.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
async with BrowserSession(user_data_dir="/tmp/tripplanner-profile") as browser:
|
| 10 |
+
await browser.navigate("https://www.delta.com")
|
| 11 |
+
text = await browser.get_page_text()
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import base64
|
| 17 |
+
import os
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
from playwright.async_api import async_playwright, BrowserContext, Page, TimeoutError as PWTimeoutError
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class BrowserSession:
|
| 25 |
+
"""Async context manager wrapping a Chromium browser.
|
| 26 |
+
|
| 27 |
+
On Render (or any headless environment), set BROWSER_USER_DATA_DIR to a
|
| 28 |
+
persistent disk path and upload sessions.json via /api/session/upload.
|
| 29 |
+
Locally, run scripts/session_setup.py once to log in interactively.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(
|
| 33 |
+
self,
|
| 34 |
+
user_data_dir: str,
|
| 35 |
+
headless: bool | None = None,
|
| 36 |
+
viewport: dict[str, int] | None = None,
|
| 37 |
+
slow_mo: float = 0,
|
| 38 |
+
) -> None:
|
| 39 |
+
self._user_data_dir = user_data_dir
|
| 40 |
+
# Auto-detect headless: if no DISPLAY env var, force headless (i.e. on Render)
|
| 41 |
+
if headless is None:
|
| 42 |
+
self._headless = not bool(os.getenv("DISPLAY", "")) or os.getenv("RENDER") == "true"
|
| 43 |
+
else:
|
| 44 |
+
self._headless = headless
|
| 45 |
+
self._viewport = viewport or {"width": 1280, "height": 900}
|
| 46 |
+
self._slow_mo = slow_mo
|
| 47 |
+
|
| 48 |
+
self._pw = None
|
| 49 |
+
self._context: BrowserContext | None = None
|
| 50 |
+
self._page: Page | None = None
|
| 51 |
+
|
| 52 |
+
def _session_file(self) -> Path | None:
|
| 53 |
+
"""Return path to sessions.json if available.
|
| 54 |
+
|
| 55 |
+
Priority:
|
| 56 |
+
1. BROWSER_SESSION_JSON env var (base64-encoded) — for HF Spaces / ephemeral envs
|
| 57 |
+
2. sessions.json on disk (uploaded via /api/session/upload)
|
| 58 |
+
"""
|
| 59 |
+
# 1. Decode from env var (HF Spaces stores secrets as env vars)
|
| 60 |
+
b64 = os.getenv("BROWSER_SESSION_JSON", "")
|
| 61 |
+
if b64:
|
| 62 |
+
out = Path(self._user_data_dir) / "sessions.json"
|
| 63 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 64 |
+
out.write_bytes(base64.b64decode(b64))
|
| 65 |
+
return out
|
| 66 |
+
|
| 67 |
+
# 2. File on disk (Render persistent disk or local upload)
|
| 68 |
+
p = Path(self._user_data_dir) / "sessions.json"
|
| 69 |
+
return p if p.exists() else None
|
| 70 |
+
|
| 71 |
+
# ------------------------------------------------------------------
|
| 72 |
+
# Async context manager
|
| 73 |
+
# ------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
async def __aenter__(self) -> "BrowserSession":
|
| 76 |
+
self._pw = await async_playwright().start()
|
| 77 |
+
session_file = self._session_file()
|
| 78 |
+
|
| 79 |
+
if session_file:
|
| 80 |
+
# Render / deployed: use a regular (non-persistent) context with saved storage state.
|
| 81 |
+
browser = await self._pw.chromium.launch(
|
| 82 |
+
headless=self._headless,
|
| 83 |
+
slow_mo=self._slow_mo,
|
| 84 |
+
)
|
| 85 |
+
self._context = await browser.new_context(
|
| 86 |
+
storage_state=str(session_file),
|
| 87 |
+
viewport=self._viewport,
|
| 88 |
+
)
|
| 89 |
+
else:
|
| 90 |
+
# Local: use a persistent profile so the user stays logged in.
|
| 91 |
+
self._context = await self._pw.chromium.launch_persistent_context(
|
| 92 |
+
self._user_data_dir,
|
| 93 |
+
headless=self._headless,
|
| 94 |
+
viewport=self._viewport,
|
| 95 |
+
slow_mo=self._slow_mo,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
if self._context.pages:
|
| 99 |
+
self._page = self._context.pages[0]
|
| 100 |
+
else:
|
| 101 |
+
self._page = await self._context.new_page()
|
| 102 |
+
return self
|
| 103 |
+
|
| 104 |
+
async def __aexit__(self, *_: Any) -> None:
|
| 105 |
+
if self._context:
|
| 106 |
+
await self._context.close()
|
| 107 |
+
if self._pw:
|
| 108 |
+
await self._pw.stop()
|
| 109 |
+
|
| 110 |
+
# ------------------------------------------------------------------
|
| 111 |
+
# Browser action methods
|
| 112 |
+
# ------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
async def navigate(self, url: str) -> str:
|
| 115 |
+
"""Navigate to a URL and return the page title."""
|
| 116 |
+
try:
|
| 117 |
+
await self._page.goto(url, wait_until="domcontentloaded")
|
| 118 |
+
title = await self._page.title()
|
| 119 |
+
return title or "(no title)"
|
| 120 |
+
except Exception as exc:
|
| 121 |
+
return f"Error: {exc}"
|
| 122 |
+
|
| 123 |
+
async def click(self, selector: str | None = None, text: str | None = None) -> str:
|
| 124 |
+
"""Click an element by CSS selector OR by visible text (not both).
|
| 125 |
+
|
| 126 |
+
Returns a confirmation string or an error message.
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
if selector and text:
|
| 130 |
+
return "Error: provide selector OR text, not both."
|
| 131 |
+
if selector:
|
| 132 |
+
await self._page.click(selector)
|
| 133 |
+
return f"Clicked selector: {selector}"
|
| 134 |
+
if text:
|
| 135 |
+
await self._page.get_by_text(text, exact=False).first.click()
|
| 136 |
+
return f"Clicked element with text: {text!r}"
|
| 137 |
+
return "Error: provide selector or text."
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
return f"Error: {exc}"
|
| 140 |
+
|
| 141 |
+
async def fill(self, selector: str, value: str) -> str:
|
| 142 |
+
"""Clear and fill an input field, then return confirmation."""
|
| 143 |
+
try:
|
| 144 |
+
await self._page.fill(selector, value)
|
| 145 |
+
return f"Filled {selector!r} with value."
|
| 146 |
+
except Exception as exc:
|
| 147 |
+
return f"Error: {exc}"
|
| 148 |
+
|
| 149 |
+
async def get_text(self, selector: str, limit: int = 20) -> list[str]:
|
| 150 |
+
"""Return the inner text of all elements matching *selector*, up to *limit* items."""
|
| 151 |
+
try:
|
| 152 |
+
texts = await self._page.locator(selector).all_inner_texts()
|
| 153 |
+
return texts[:limit]
|
| 154 |
+
except Exception as exc:
|
| 155 |
+
return [f"Error: {exc}"]
|
| 156 |
+
|
| 157 |
+
async def get_page_text(self) -> str:
|
| 158 |
+
"""Return visible page text (body), truncated to 8000 characters."""
|
| 159 |
+
try:
|
| 160 |
+
text = await self._page.inner_text("body")
|
| 161 |
+
return text[:8000]
|
| 162 |
+
except Exception as exc:
|
| 163 |
+
return f"Error: {exc}"
|
| 164 |
+
|
| 165 |
+
async def wait_for(self, selector: str, timeout: int = 10000) -> bool:
|
| 166 |
+
"""Wait until *selector* appears in the DOM. Returns True on success, False on timeout."""
|
| 167 |
+
try:
|
| 168 |
+
await self._page.wait_for_selector(selector, timeout=timeout)
|
| 169 |
+
return True
|
| 170 |
+
except PWTimeoutError:
|
| 171 |
+
return False
|
| 172 |
+
except Exception:
|
| 173 |
+
return False
|
| 174 |
+
|
| 175 |
+
async def select_option(self, selector: str, value: str) -> str:
|
| 176 |
+
"""Select an <option> by value in a <select> element."""
|
| 177 |
+
try:
|
| 178 |
+
await self._page.select_option(selector, value=value)
|
| 179 |
+
return f"Selected option {value!r} in {selector!r}."
|
| 180 |
+
except Exception as exc:
|
| 181 |
+
return f"Error: {exc}"
|
| 182 |
+
|
| 183 |
+
async def press_key(self, key: str) -> str:
|
| 184 |
+
"""Send a keyboard key to the focused element (e.g. 'Enter', 'Tab')."""
|
| 185 |
+
try:
|
| 186 |
+
await self._page.keyboard.press(key)
|
| 187 |
+
return f"Pressed key: {key}"
|
| 188 |
+
except Exception as exc:
|
| 189 |
+
return f"Error: {exc}"
|
| 190 |
+
|
| 191 |
+
async def scroll_down(self) -> str:
|
| 192 |
+
"""Scroll the page down by ~800 px."""
|
| 193 |
+
try:
|
| 194 |
+
await self._page.mouse.wheel(0, 800)
|
| 195 |
+
return "Scrolled down."
|
| 196 |
+
except Exception as exc:
|
| 197 |
+
return f"Error: {exc}"
|
| 198 |
+
|
| 199 |
+
async def screenshot_base64(self) -> str:
|
| 200 |
+
"""Take a full-page screenshot and return it as a base64-encoded PNG string."""
|
| 201 |
+
try:
|
| 202 |
+
png_bytes = await self._page.screenshot(full_page=True)
|
| 203 |
+
return base64.b64encode(png_bytes).decode()
|
| 204 |
+
except Exception as exc:
|
| 205 |
+
return f"Error: {exc}"
|
| 206 |
+
|
| 207 |
+
# ------------------------------------------------------------------
|
| 208 |
+
# Anthropic tool definitions
|
| 209 |
+
# ------------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
@classmethod
|
| 212 |
+
def tool_definitions(cls) -> list[dict]:
|
| 213 |
+
"""Return Anthropic-compatible tool definitions for all browser methods.
|
| 214 |
+
|
| 215 |
+
Pass the result directly to ``client.messages.create(tools=...)``.
|
| 216 |
+
"""
|
| 217 |
+
return [
|
| 218 |
+
{
|
| 219 |
+
"name": "navigate",
|
| 220 |
+
"description": "Navigate the browser to a URL and return the page title.",
|
| 221 |
+
"input_schema": {
|
| 222 |
+
"type": "object",
|
| 223 |
+
"properties": {
|
| 224 |
+
"url": {
|
| 225 |
+
"type": "string",
|
| 226 |
+
"description": "The full URL to navigate to (e.g. 'https://www.delta.com').",
|
| 227 |
+
}
|
| 228 |
+
},
|
| 229 |
+
"required": ["url"],
|
| 230 |
+
},
|
| 231 |
+
},
|
| 232 |
+
{
|
| 233 |
+
"name": "click",
|
| 234 |
+
"description": (
|
| 235 |
+
"Click an element on the page. Provide EITHER 'selector' (CSS) "
|
| 236 |
+
"OR 'text' (visible text match), not both."
|
| 237 |
+
),
|
| 238 |
+
"input_schema": {
|
| 239 |
+
"type": "object",
|
| 240 |
+
"properties": {
|
| 241 |
+
"selector": {
|
| 242 |
+
"type": "string",
|
| 243 |
+
"description": "CSS selector of the element to click.",
|
| 244 |
+
},
|
| 245 |
+
"text": {
|
| 246 |
+
"type": "string",
|
| 247 |
+
"description": "Visible text of the element to click.",
|
| 248 |
+
},
|
| 249 |
+
},
|
| 250 |
+
"required": [],
|
| 251 |
+
},
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
"name": "fill",
|
| 255 |
+
"description": "Clear and type a value into an input or textarea element.",
|
| 256 |
+
"input_schema": {
|
| 257 |
+
"type": "object",
|
| 258 |
+
"properties": {
|
| 259 |
+
"selector": {
|
| 260 |
+
"type": "string",
|
| 261 |
+
"description": "CSS selector of the input element.",
|
| 262 |
+
},
|
| 263 |
+
"value": {
|
| 264 |
+
"type": "string",
|
| 265 |
+
"description": "The text to enter into the field.",
|
| 266 |
+
},
|
| 267 |
+
},
|
| 268 |
+
"required": ["selector", "value"],
|
| 269 |
+
},
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"name": "get_text",
|
| 273 |
+
"description": (
|
| 274 |
+
"Return the inner text of all elements matching a CSS selector, "
|
| 275 |
+
"up to 'limit' results."
|
| 276 |
+
),
|
| 277 |
+
"input_schema": {
|
| 278 |
+
"type": "object",
|
| 279 |
+
"properties": {
|
| 280 |
+
"selector": {
|
| 281 |
+
"type": "string",
|
| 282 |
+
"description": "CSS selector to match elements.",
|
| 283 |
+
},
|
| 284 |
+
"limit": {
|
| 285 |
+
"type": "integer",
|
| 286 |
+
"description": "Maximum number of text strings to return (default 20).",
|
| 287 |
+
},
|
| 288 |
+
},
|
| 289 |
+
"required": ["selector"],
|
| 290 |
+
},
|
| 291 |
+
},
|
| 292 |
+
{
|
| 293 |
+
"name": "get_page_text",
|
| 294 |
+
"description": "Return the full visible text of the current page, truncated to 8000 characters.",
|
| 295 |
+
"input_schema": {
|
| 296 |
+
"type": "object",
|
| 297 |
+
"properties": {},
|
| 298 |
+
"required": [],
|
| 299 |
+
},
|
| 300 |
+
},
|
| 301 |
+
{
|
| 302 |
+
"name": "wait_for",
|
| 303 |
+
"description": (
|
| 304 |
+
"Wait for a CSS selector to appear in the DOM. "
|
| 305 |
+
"Returns true on success, false on timeout."
|
| 306 |
+
),
|
| 307 |
+
"input_schema": {
|
| 308 |
+
"type": "object",
|
| 309 |
+
"properties": {
|
| 310 |
+
"selector": {
|
| 311 |
+
"type": "string",
|
| 312 |
+
"description": "CSS selector to wait for.",
|
| 313 |
+
},
|
| 314 |
+
"timeout": {
|
| 315 |
+
"type": "integer",
|
| 316 |
+
"description": "Maximum wait time in milliseconds (default 10000).",
|
| 317 |
+
},
|
| 318 |
+
},
|
| 319 |
+
"required": ["selector"],
|
| 320 |
+
},
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"name": "select_option",
|
| 324 |
+
"description": "Select an option by value in a <select> element.",
|
| 325 |
+
"input_schema": {
|
| 326 |
+
"type": "object",
|
| 327 |
+
"properties": {
|
| 328 |
+
"selector": {
|
| 329 |
+
"type": "string",
|
| 330 |
+
"description": "CSS selector of the <select> element.",
|
| 331 |
+
},
|
| 332 |
+
"value": {
|
| 333 |
+
"type": "string",
|
| 334 |
+
"description": "The option value to select.",
|
| 335 |
+
},
|
| 336 |
+
},
|
| 337 |
+
"required": ["selector", "value"],
|
| 338 |
+
},
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"name": "press_key",
|
| 342 |
+
"description": "Send a keyboard key press to the focused element (e.g. 'Enter', 'Tab', 'Escape').",
|
| 343 |
+
"input_schema": {
|
| 344 |
+
"type": "object",
|
| 345 |
+
"properties": {
|
| 346 |
+
"key": {
|
| 347 |
+
"type": "string",
|
| 348 |
+
"description": "The key to press (Playwright key name, e.g. 'Enter', 'Tab').",
|
| 349 |
+
}
|
| 350 |
+
},
|
| 351 |
+
"required": ["key"],
|
| 352 |
+
},
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
"name": "scroll_down",
|
| 356 |
+
"description": "Scroll the current page down by approximately 800 pixels.",
|
| 357 |
+
"input_schema": {
|
| 358 |
+
"type": "object",
|
| 359 |
+
"properties": {},
|
| 360 |
+
"required": [],
|
| 361 |
+
},
|
| 362 |
+
},
|
| 363 |
+
{
|
| 364 |
+
"name": "screenshot_base64",
|
| 365 |
+
"description": "Take a full-page screenshot and return it as a base64-encoded PNG string (for debugging).",
|
| 366 |
+
"input_schema": {
|
| 367 |
+
"type": "object",
|
| 368 |
+
"properties": {},
|
| 369 |
+
"required": [],
|
| 370 |
+
},
|
| 371 |
+
},
|
| 372 |
+
]
|
| 373 |
+
|
| 374 |
+
# ------------------------------------------------------------------
|
| 375 |
+
# Tool dispatcher
|
| 376 |
+
# ------------------------------------------------------------------
|
| 377 |
+
|
| 378 |
+
async def dispatch_tool(self, name: str, input: dict) -> str:
|
| 379 |
+
"""Dispatch a tool call by name to the matching method.
|
| 380 |
+
|
| 381 |
+
Returns the result as a string (serialised if necessary).
|
| 382 |
+
Unknown names and bad inputs are caught and returned as error strings.
|
| 383 |
+
"""
|
| 384 |
+
_dispatch: dict[str, Any] = {
|
| 385 |
+
"navigate": self.navigate,
|
| 386 |
+
"click": self.click,
|
| 387 |
+
"fill": self.fill,
|
| 388 |
+
"get_text": self.get_text,
|
| 389 |
+
"get_page_text": self.get_page_text,
|
| 390 |
+
"wait_for": self.wait_for,
|
| 391 |
+
"select_option": self.select_option,
|
| 392 |
+
"press_key": self.press_key,
|
| 393 |
+
"scroll_down": self.scroll_down,
|
| 394 |
+
"screenshot_base64": self.screenshot_base64,
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
method = _dispatch.get(name)
|
| 398 |
+
if method is None:
|
| 399 |
+
return f"Error: unknown tool '{name}'."
|
| 400 |
+
|
| 401 |
+
try:
|
| 402 |
+
result = await method(**input)
|
| 403 |
+
# Coerce non-string results (bool, list) to strings for uniform AI consumption.
|
| 404 |
+
if isinstance(result, list):
|
| 405 |
+
return "\n".join(str(item) for item in result)
|
| 406 |
+
return str(result)
|
| 407 |
+
except TypeError as exc:
|
| 408 |
+
return f"Error: bad arguments for '{name}': {exc}"
|
| 409 |
+
except Exception as exc:
|
| 410 |
+
return f"Error: {exc}"
|
| 411 |
+
|
| 412 |
+
# Aliases used by subagents
|
| 413 |
+
async def execute_tool(self, name: str, input: dict) -> str:
|
| 414 |
+
return await self.dispatch_tool(name, input)
|
| 415 |
+
|
| 416 |
+
@property
|
| 417 |
+
def tools(self) -> list[dict]:
|
| 418 |
+
return BrowserSession.tool_definitions()
|
| 419 |
+
|
| 420 |
+
def get_tool_definitions(self) -> list[dict]:
|
| 421 |
+
return BrowserSession.tool_definitions()
|
| 422 |
+
|
| 423 |
+
@property
|
| 424 |
+
def gemini_tools(self):
|
| 425 |
+
"""Return google-genai Tool objects for all browser methods."""
|
| 426 |
+
from google.genai import types as gtypes
|
| 427 |
+
return [gtypes.Tool(function_declarations=[
|
| 428 |
+
gtypes.FunctionDeclaration(
|
| 429 |
+
name=t["name"],
|
| 430 |
+
description=t["description"],
|
| 431 |
+
parameters=t["input_schema"],
|
| 432 |
+
)
|
| 433 |
+
for t in BrowserSession.tool_definitions()
|
| 434 |
+
])]
|