""" 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