tripplanner-backend / backend /orchestrator.py
Bas95's picture
Fix: agent call signatures
77c968b verified
"""
orchestrator.py — Tripplanner search orchestrator.
Coordinates Delta, IHG, and Resy subagents sequentially over a single shared
BrowserSession, then merges their results into ranked TripResult objects.
"""
from __future__ import annotations
import os
import asyncio
from typing import Callable
from .models import (
TripSearchRequest,
FlightOption,
HotelOption,
RestaurantOption,
TripResult,
SearchProgress,
)
from .tools.browser import BrowserSession
from .agents.delta_agent import search_companion_cert
from .agents.ihg_agent import search_points_availability
from .agents.resy_agent import find_resy_restaurants
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def calculate_cash_out_of_pocket(
flight: FlightOption,
hotel: HotelOption | None,
) -> float:
"""
Return the total cash the traveler pays.
The companion's ticket is covered by the certificate; they only owe the
companion_taxes. Hotel is redeemed with points so costs $0 cash.
Restaurant dining credit is separate from this calculation.
"""
# Primary ticket + companion taxes (companion cert covers the fare itself)
return flight.base_price + flight.companion_taxes
def collect_benefits(
flight: FlightOption,
hotel: HotelOption | None,
restaurant: RestaurantOption | None,
) -> list[str]:
"""Return human-readable benefit strings for a trip combination."""
benefits: list[str] = []
if flight.eligible_for_companion_cert:
benefits.append("Delta companion certificate used")
if hotel is not None:
if hotel.nights_free > 0:
benefits.append(
f"IHG 4th night free ({hotel.nights_free} night{'s' if hotel.nights_free != 1 else ''} free)"
)
else:
benefits.append(f"IHG points redemption at {hotel.property_name}")
if restaurant is not None:
if restaurant.resy_credit_eligible:
benefits.append("Resy $20 dining credit eligible")
if restaurant.global_dining_access:
benefits.append(f"Global Dining Access reservation at {restaurant.name}")
return benefits
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _score_trip(
flight: FlightOption,
hotel: HotelOption | None,
restaurant: RestaurantOption | None,
) -> float:
"""
Score a trip combination on a 0–100 scale.
Breakdown:
+40 companion cert eligible
+30 hotel found with points availability
+15 restaurant found
+15 value score (lower cash out of pocket relative to flight base price)
"""
score = 0.0
if flight.eligible_for_companion_cert:
score += 40.0
if hotel is not None:
score += 30.0
if restaurant is not None:
score += 15.0
# Value component: full +15 when cash_out == companion_taxes only (i.e.
# traveler saved the most), scaling down as cash_out approaches base_price*2.
cash_out = calculate_cash_out_of_pocket(flight, hotel)
max_possible_cash = flight.base_price * 2 # both tickets at full price
if max_possible_cash > 0:
savings_ratio = 1.0 - (cash_out / max_possible_cash)
# Clamp to [0, 1] so edge cases don't break the scale.
savings_ratio = max(0.0, min(1.0, savings_ratio))
score += savings_ratio * 15.0
return round(score, 2)
def _extract_date_pairs(
flights: list[FlightOption],
) -> list[tuple[str, str]]:
"""Return (check_in_iso, check_out_iso) pairs from flight results."""
return [
(flight.outbound_date.isoformat(), flight.return_date.isoformat())
for flight in flights
]
def _extract_destinations(flights: list[FlightOption]) -> list[str]:
return list(dict.fromkeys(flight.destination for flight in flights))
def _extract_cities_and_arrival_dates(
flights: list[FlightOption],
) -> tuple[list[str], list[str]]:
cities = list(dict.fromkeys(flight.destination for flight in flights))
arrival_dates = list(
dict.fromkeys(flight.outbound_date.isoformat() for flight in flights)
)
return cities, arrival_dates
def _find_matching_hotel(
flight: FlightOption,
hotels: list[HotelOption],
) -> HotelOption | None:
"""Return the first HotelOption whose destination and dates overlap the flight."""
for hotel in hotels:
if hotel.destination != flight.destination:
continue
# Dates overlap when check_in <= return_date AND check_out >= outbound_date
if hotel.check_in <= flight.return_date and hotel.check_out >= flight.outbound_date:
return hotel
return None
def _find_matching_restaurant(
flight: FlightOption,
restaurants: list[RestaurantOption],
) -> RestaurantOption | None:
"""Return the first RestaurantOption matching the flight city and arrival date."""
for restaurant in restaurants:
if (
restaurant.city == flight.destination
and restaurant.reservation_date == flight.outbound_date
):
return restaurant
return None
def _emit(
callback: Callable[[SearchProgress], None],
step: str,
message: str,
progress: int,
) -> None:
callback(SearchProgress(step=step, message=message, progress=progress))
# ---------------------------------------------------------------------------
# Main orchestrator
# ---------------------------------------------------------------------------
async def run_trip_search(
request: TripSearchRequest,
progress_callback: Callable[[SearchProgress], None],
) -> list[TripResult]:
"""
Coordinate Delta, IHG, and Resy subagents and return ranked TripResult list.
All three agents share a single BrowserSession so that login state is
preserved across site transitions.
"""
user_data_dir = os.environ.get("BROWSER_USER_DATA_DIR", "./browser_data")
async with BrowserSession(user_data_dir=user_data_dir) as browser:
# --- Step 1: Delta companion certificate search ---
_emit(
progress_callback,
step="delta",
message="Checking companion certificate availability...",
progress=10,
)
def delta_progress(msg: str) -> None:
_emit(progress_callback, step="delta", message=msg, progress=20)
flights: list[FlightOption] = await search_companion_cert(
request, browser, delta_progress
)
# --- Step 2: IHG points availability ---
_emit(
progress_callback,
step="ihg",
message="Checking IHG points availability...",
progress=40,
)
destinations = _extract_destinations(flights)
date_pairs = _extract_date_pairs(flights)
def ihg_progress(msg: str) -> None:
_emit(progress_callback, step="ihg", message=msg, progress=55)
hotels: list[HotelOption] = await search_points_availability(
destinations, date_pairs, browser,
request.ihg_brands, request.trip_duration_nights, ihg_progress
)
# --- Step 3: Resy dining options ---
_emit(
progress_callback,
step="resy",
message="Finding Resy dining options...",
progress=70,
)
cities, arrival_dates = _extract_cities_and_arrival_dates(flights)
def resy_progress(msg: str) -> None:
_emit(progress_callback, step="resy", message=msg, progress=80)
restaurants: list[RestaurantOption] = await find_resy_restaurants(
cities, arrival_dates, request.party_size, browser, resy_progress
)
# BrowserSession closed — merge results in memory.
# --- Step 4: Merge and rank ---
_emit(
progress_callback,
step="merging",
message="Ranking trip combinations...",
progress=90,
)
results: list[TripResult] = []
for flight in flights:
hotel = _find_matching_hotel(flight, hotels)
restaurant = _find_matching_restaurant(flight, restaurants)
cash_out = calculate_cash_out_of_pocket(flight, hotel)
points_required = hotel.total_points if hotel is not None else 0
benefits = collect_benefits(flight, hotel, restaurant)
score = _score_trip(flight, hotel, restaurant)
results.append(
TripResult(
destination=flight.destination,
flight=flight,
hotel=hotel,
restaurant=restaurant,
total_cash_out_of_pocket=cash_out,
total_points_required=points_required,
benefits_captured=benefits,
score=score,
)
)
results.sort(key=lambda r: r.score, reverse=True)
# --- Step 5: Done ---
_emit(
progress_callback,
step="done",
message="Search complete!",
progress=100,
)
return results