Bas95 commited on
Commit
649703e
·
verified ·
1 Parent(s): 285bf18

Deploy Tripplanner backend

Browse files
.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: yellow
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ])]