From 8cfc71cadd14b138c70a91fcf0c45b08279ed20e Mon Sep 17 00:00:00 2001 From: hassan1731996 Date: Sat, 14 Mar 2026 22:06:27 +0500 Subject: [PATCH 1/6] Add flight-booking community ability --- community/flight-booking/README.md | 54 +++ community/flight-booking/__init__.py | 0 community/flight-booking/main.py | 635 +++++++++++++++++++++++++++ 3 files changed, 689 insertions(+) create mode 100644 community/flight-booking/README.md create mode 100644 community/flight-booking/__init__.py create mode 100644 community/flight-booking/main.py diff --git a/community/flight-booking/README.md b/community/flight-booking/README.md new file mode 100644 index 00000000..da8ce89d --- /dev/null +++ b/community/flight-booking/README.md @@ -0,0 +1,54 @@ +# Flight Booking + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-@hassan1731996-lightgrey?style=flat-square) + +Search and book flights entirely by voice using the Duffel API. Supports one-way and round-trip searches, reads back the top 3 options, collects passenger details, and creates a hold booking — no payment required at voice time. + +## Features + +- **Natural language search** — "Book me a flight from London to Dubai next Friday" +- **One-way & round-trip** — detects trip type automatically from your request +- **Top 3 results** — sorted by price, read back in a voice-friendly format +- **Full booking flow** — collects passenger name, date of birth, email, and phone +- **Hold booking** — creates a confirmed reservation without charging you. Pay later via email link +- **Booking history** — saves all booking references to `flight_booking_history.json` +- **City name resolution** — say "London" or "New York", not just airport codes + +## Setup + +1. Sign up for a free account at [duffel.com](https://duffel.com) +2. Create a test API token in the Developer settings +3. Open `main.py` and replace `duffel_test_YOUR_KEY_HERE` with your token +4. Upload to the Live Editor + +## Trigger Words + +`book a flight`, `search for flights`, `find me a flight`, `flight to`, `book flights`, `i need a flight`, `fly to`, `check flights` + +## Example Conversation + +> **User:** "Book me a flight from London to Dubai next Friday" +> **AI:** "Let me search for flights, one moment." +> **AI:** "Option 1: Emirates. Departs 2026-03-20 at 08:00, arrives at 18:00. Non-stop. USD 420. +> Option 2: British Airways via Heathrow. Departs at 10:00, arrives at 22:00. 1 stop. USD 380. +> Option 3: Flydubai. Departs at 14:00, arrives at 23:30. Non-stop. USD 310. +> Which option — 1, 2, or 3? Or say none to search again." +> **User:** "Option 3" +> **AI:** "What's the passenger's first name?" +> **User:** "John" +> **AI:** "And the last name?" +> ... +> **AI:** "Booking Flydubai from LHR to DXB on 2026-03-20 for John Smith. Total USD 310. This creates a hold — you'll pay at checkout. Confirm?" +> **User:** "Yes" +> **AI:** "Done! Your booking reference is ORD-123456. Check your email for payment details." + +## Storage + +- `flight_booking_history.json` — persistent log of all booking references and details + +## Notes + +- Uses Duffel's **hold** booking type — no payment is processed by this ability +- Flights via Duffel's test environment use "Duffel Airways" (IATA: ZZ) for sandbox testing +- Switch to a live API token for real bookings (handle with care — hold bookings are real reservations) diff --git a/community/flight-booking/__init__.py b/community/flight-booking/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/flight-booking/main.py b/community/flight-booking/main.py new file mode 100644 index 00000000..11be5238 --- /dev/null +++ b/community/flight-booking/main.py @@ -0,0 +1,635 @@ +import json +import re +import requests +from datetime import datetime +from typing import ClassVar + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# ============================================================================= +# FLIGHT BOOKING +# Voice-controlled flight search and booking via Duffel API. +# Supports one-way and round-trip, reads back top 3 offers, collects passenger +# details, and creates a hold booking (pay-later). No payment by voice. +# ============================================================================= + + +class FlightBookingCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + api_key: str = None + + # Do not change following tag of register capability + #{{register capability}} + + DUFFEL_BASE: ClassVar[str] = "https://api.duffel.com" + DUFFEL_API_KEY: ClassVar[str] = "duffel_test_YOUR_KEY_HERE" + HISTORY_FILE: ClassVar[str] = "flight_booking_history.json" + + IATA_MAP: ClassVar[dict] = { + "new york": "JFK", "london": "LHR", "dubai": "DXB", + "paris": "CDG", "los angeles": "LAX", "chicago": "ORD", + "toronto": "YYZ", "sydney": "SYD", "tokyo": "NRT", + "karachi": "KHI", "lahore": "LHE", "islamabad": "ISB", + "istanbul": "IST", "amsterdam": "AMS", "frankfurt": "FRA", + "singapore": "SIN", "hong kong": "HKG", "bangkok": "BKK", + "madrid": "MAD", "rome": "FCO", "milan": "MXP", + "san francisco": "SFO", "miami": "MIA", "dallas": "DFW", + "boston": "BOS", "seattle": "SEA", "washington": "IAD", + "manchester": "MAN", "birmingham": "BHX", "edinburgh": "EDI", + "mumbai": "BOM", "delhi": "DEL", "bangalore": "BLR", + "cairo": "CAI", "nairobi": "NBO", "johannesburg": "JNB", + "barcelona": "BCN", "lisbon": "LIS", "zurich": "ZRH", + "vienna": "VIE", "brussels": "BRU", "copenhagen": "CPH", + "oslo": "OSL", "stockholm": "ARN", "helsinki": "HEL", + "athens": "ATH", "budapest": "BUD", "warsaw": "WAW", + "prague": "PRG", "bucharest": "OTP", "kyiv": "KBP", + "moscow": "SVO", "beijing": "PEK", "shanghai": "PVG", + "seoul": "ICN", "taipei": "TPE", "kuala lumpur": "KUL", + "jakarta": "CGK", "manila": "MNL", "ho chi minh": "SGN", + "hanoi": "HAN", "dhaka": "DAC", "colombo": "CMB", + "riyadh": "RUH", "jeddah": "JED", "doha": "DOH", + "abu dhabi": "AUH", "kuwait": "KWI", "beirut": "BEY", + "tel aviv": "TLV", "amman": "AMM", "casablanca": "CMN", + "lagos": "LOS", "accra": "ACC", "addis ababa": "ADD", + "mexico city": "MEX", "sao paulo": "GRU", "buenos aires": "EZE", + "bogota": "BOG", "lima": "LIM", "santiago": "SCL", + "montreal": "YUL", "vancouver": "YVR", "calgary": "YYC", + } + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def _normalize_city_name(self, raw: str, iata: str) -> str: + """Return a clean display name for a city given its resolved IATA code.""" + # Reverse lookup in IATA_MAP — covers all mapped cities cleanly + for city, code in self.IATA_MAP.items(): + if code == iata: + return city.title() + # Short input (≤3 words) is likely already clean — just strip punctuation + words = raw.strip().split() + if len(words) <= 3: + return raw.strip().rstrip(".,!?").title() + # LLM fallback for noisy long-form inputs outside the map + prompt = ( + f"What city does the IATA airport code '{iata}' primarily serve? " + "Return ONLY the common city name, nothing else." + ) + return self.capability_worker.text_to_text_response(prompt).strip().title() + + def _parse_phone(self, user_input: str) -> str: + """Convert spoken phone number (words or digits) to E.164 format via LLM.""" + prompt = ( + f"The user said: '{user_input}'. " + "This is a phone number including a country code. " + "Convert it to E.164 format (e.g. +923013018173). " + "Return ONLY the + sign followed by digits. No spaces, no hyphens, no other text." + ) + result = self.capability_worker.text_to_text_response(prompt).strip() + clean = re.sub(r"[^\d+]", "", result) + if clean and not clean.startswith("+"): + clean = "+" + clean + self.worker.editor_logging_handler.info(f"[FlightBooking] Phone parsed: '{user_input}' → '{clean}'") + return clean + + def _format_date_natural(self, date_str: str) -> str: + """Convert '2026-04-25' → 'April 25th' for voice readback.""" + try: + dt = datetime.strptime(date_str, "%Y-%m-%d") + d = dt.day + sfx = "th" if 11 <= d <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(d % 10, "th") + return dt.strftime(f"%B {d}{sfx}") + except Exception: + return date_str + + def _duffel_headers(self) -> dict: + return { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + "Content-Type": "application/json", + "Duffel-Version": "v2", + } + + def _resolve_airport(self, city_or_code: str) -> str: + """Map city name to IATA code. Falls back to LLM.""" + lower = city_or_code.lower().strip() + if lower in self.IATA_MAP: + return self.IATA_MAP[lower] + upper = city_or_code.strip().upper() + if len(upper) == 3 and upper.isalpha(): + return upper + prompt = ( + f"Return ONLY the 3-letter IATA airport code for the main international " + f"airport serving: {city_or_code}. No explanation, no punctuation, just 3 letters." + ) + code = self.capability_worker.text_to_text_response(prompt).strip().upper() + self.worker.editor_logging_handler.info(f"[FlightBooking] IATA resolve '{city_or_code}' → '{code}'") + return code if len(code) == 3 and code.isalpha() else "" + + def _parse_date(self, user_input: str) -> str: + """LLM parses natural language date to YYYY-MM-DD.""" + today = datetime.now().strftime("%Y-%m-%d") + prompt = ( + f"Today is {today}. The user said: '{user_input}'. " + "Return ONLY the date in YYYY-MM-DD format. No other text." + ) + result = self.capability_worker.text_to_text_response(prompt).strip() + match = re.search(r"\d{4}-\d{2}-\d{2}", result) + return match.group() if match else result + + def _extract_flight_details_from_utterance(self, utterance: str) -> dict: + """Try to extract origin, destination, and date from the trigger sentence.""" + today = datetime.now().strftime("%Y-%m-%d") + prompt = ( + f"Today is {today}. The user said: '{utterance}'.\n" + "Extract flight details. Return ONLY valid JSON with these fields (use null if not mentioned):\n" + '{"origin": "city or airport", "destination": "city or airport", ' + '"date": "YYYY-MM-DD or null", "return_date": "YYYY-MM-DD or null", ' + '"trip_type": "one-way or round-trip or null", "cabin": "economy or business or first or null"}' + ) + raw = self.capability_worker.text_to_text_response(prompt).strip() + if raw.startswith("```"): + lines = raw.splitlines() + raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() + try: + return json.loads(raw) + except Exception: + return {} + + def _search_flights(self, origin: str, dest: str, date: str, + return_date: str, cabin: str) -> list: + """Create Duffel offer request, return top 3 offers sorted by price.""" + slices = [{"origin": origin, "destination": dest, "departure_date": date}] + if return_date: + slices.append({"origin": dest, "destination": origin, "departure_date": return_date}) + + payload = {"data": { + "slices": slices, + "passengers": [{"type": "adult"}], + "cabin_class": cabin, + "return_offers": True, + }} + self.worker.editor_logging_handler.info(f"[FlightBooking] Search: {origin}→{dest} on {date}") + resp = requests.post( + f"{self.DUFFEL_BASE}/air/offer_requests", + headers=self._duffel_headers(), + json=payload, + timeout=30, + ) + resp.raise_for_status() + data = resp.json().get("data", {}) + offers = data.get("offers", []) + self.worker.editor_logging_handler.info(f"[FlightBooking] {len(offers)} offer(s) returned (pre-filter)") + + # Keep only offers that support hold booking (requires_instant_payment must be false) + holdable = [ + o for o in offers + if not o.get("payment_requirements", {}).get("requires_instant_payment", True) + ] + self.worker.editor_logging_handler.info( + f"[FlightBooking] {len(holdable)} holdable offer(s) after filter" + ) + # Use holdable offers; fall back to all if somehow none qualify + result = holdable if holdable else offers + result.sort(key=lambda o: float(o.get("total_amount", "9999") or "9999")) + return result[:3] + + def _format_offer(self, offer: dict, index: int) -> str: + """Build a concise, voice-friendly offer summary.""" + slice0 = offer.get("slices", [{}])[0] + segments = slice0.get("segments", []) + stops = max(len(segments) - 1, 0) + carrier = "Unknown airline" + if segments: + carrier = segments[0].get("operating_carrier", {}).get("name", "Unknown airline") + first_seg = segments[0] if segments else {} + last_seg = segments[-1] if segments else {} + dep_raw = first_seg.get("departing_at", "") + arr_raw = last_seg.get("arriving_at", "") + dep = dep_raw[11:16] if len(dep_raw) >= 16 else dep_raw + arr = arr_raw[11:16] if len(arr_raw) >= 16 else arr_raw + price = offer.get("total_amount", "?") + currency = offer.get("total_currency", "USD") + stop_str = "Non-stop" if stops == 0 else f"{stops} stop{'s' if stops > 1 else ''}" + return ( + f"Option {index}: {carrier}. " + f"Departs at {dep}, arrives at {arr}. " + f"{stop_str}. {currency} {price}." + ) + + def _book_flight(self, offer_id: str, passengers: list) -> dict: + """Create Duffel order with type hold.""" + payload = {"data": { + "type": "hold", + "selected_offers": [offer_id], + "passengers": passengers, + }} + self.worker.editor_logging_handler.info(f"[FlightBooking] Booking offer {offer_id}") + resp = requests.post( + f"{self.DUFFEL_BASE}/air/orders", + headers=self._duffel_headers(), + json=payload, + timeout=30, + ) + if not resp.ok: + self.worker.editor_logging_handler.error( + f"[FlightBooking] Duffel {resp.status_code} body: {resp.text}" + ) + resp.raise_for_status() + return resp.json().get("data", {}) + + async def _save_booking(self, order: dict, origin: str, dest: str, date: str): + """Append booking to persistent flight_booking_history.json.""" + entry = { + "booking_ref": order.get("booking_reference", ""), + "order_id": order.get("id", ""), + "origin": origin, + "destination": dest, + "date": date, + "created_at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + } + history = [] + exists = await self.capability_worker.check_if_file_exists(self.HISTORY_FILE, False) + if exists: + try: + raw = await self.capability_worker.read_file(self.HISTORY_FILE, False) + history = json.loads(raw) + except Exception: + history = [] + await self.capability_worker.delete_file(self.HISTORY_FILE, False) + history.append(entry) + await self.capability_worker.write_file(self.HISTORY_FILE, json.dumps(history, indent=2), False) + self.worker.editor_logging_handler.info(f"[FlightBooking] Saved booking {entry['booking_ref']}") + + # ------------------------------------------------------------------------- + # Passenger Details Collection + # ------------------------------------------------------------------------- + + async def _collect_passenger_details(self, passenger_id: str) -> dict: + """Ask for passenger name, title, DOB, email, phone.""" + given = await self.capability_worker.run_io_loop("What's the passenger's first name?") + family = await self.capability_worker.run_io_loop("And the last name?") + + # Title → also derives gender (required by Duffel API) + title_raw = await self.capability_worker.run_io_loop( + "What's your title? Mr, Mrs, Miss, Ms, or Dr?" + ) + t = title_raw.lower().strip() + if "mrs" in t: + title, gender = "mrs", "f" + elif "miss" in t: + title, gender = "miss", "f" + elif "ms" in t: + title, gender = "ms", "f" + elif "dr" in t: + title, gender = "dr", "m" + else: + title, gender = "mr", "m" + + dob_raw = await self.capability_worker.run_io_loop( + "Date of birth? For example, March 5th 1990." + ) + dob = self._parse_date(dob_raw) + email = await self.capability_worker.run_io_loop("Email address?") + # Re-ask once if email contains non-ASCII or is missing @ / domain + email_clean_check = re.sub(r"[^\x00-\x7F]", "", email).strip().lower() + if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email_clean_check): + email = await self.capability_worker.run_io_loop( + "I didn't catch that clearly. Please spell out your email address." + ) + email_clean_check = re.sub(r"[^\x00-\x7F]", "", email).strip().lower() + email = email_clean_check + phone = await self.capability_worker.run_io_loop( + "Phone number with country code? For example, plus 44 7700 900 123." + ) + phone_clean = self._parse_phone(phone) + + given_clean = re.sub(r"[^a-zA-Z\s'\-]", "", given).strip().title() + family_clean = re.sub(r"[^a-zA-Z\s'\-]", "", family).strip().title() + + details = { + "id": passenger_id, + "title": title, + "gender": gender, + "given_name": given_clean, + "family_name": family_clean, + "born_on": dob, + "email": email, + "phone_number": phone_clean, + } + self.worker.editor_logging_handler.info(f"[FlightBooking] Passenger: {details['given_name']} {details['family_name']} ({title})") + return details + + # ------------------------------------------------------------------------- + # Main Flow + # ------------------------------------------------------------------------- + + async def run_booking_flow(self): + """Orchestrate the full flight search → select → book flow.""" + try: + self.worker.editor_logging_handler.info("[FlightBooking] ✓ run_booking_flow started") + + # Step 1: Capture the full trigger utterance + full_utterance = await self.capability_worker.wait_for_complete_transcription() + self.worker.editor_logging_handler.info(f"[FlightBooking] Utterance: '{full_utterance}'") + + # Step 2: Try to extract details from the utterance + extracted = {} + if full_utterance and full_utterance.strip(): + extracted = self._extract_flight_details_from_utterance(full_utterance) + self.worker.editor_logging_handler.info(f"[FlightBooking] Extracted: {extracted}") + + # Step 3: Fill in any missing details by asking + origin_city = extracted.get("origin") or "" + dest_city = extracted.get("destination") or "" + date_str = extracted.get("date") or "" + return_date_str = extracted.get("return_date") or "" + trip_type = extracted.get("trip_type") or "" + cabin = extracted.get("cabin") or "economy" + + if not origin_city: + origin_city = await self.capability_worker.run_io_loop( + "Where are you flying from?" + ) + if not dest_city: + dest_city = await self.capability_worker.run_io_loop( + "Where are you flying to?" + ) + if not date_str: + date_raw = await self.capability_worker.run_io_loop( + "What date are you travelling? For example, March 20th." + ) + date_str = self._parse_date(date_raw) + + # Validate date is not in the past + try: + if date_str < datetime.now().strftime("%Y-%m-%d"): + date_raw = await self.capability_worker.run_io_loop( + f"{date_str} is in the past. What date did you mean?" + ) + date_str = self._parse_date(date_raw) + except Exception: + pass + + if not trip_type: + trip_raw = await self.capability_worker.run_io_loop( + "Is that one-way or round trip?" + ) + trip_type = "round-trip" if any( + w in trip_raw.lower() for w in ["round", "return", "both"] + ) else "one-way" + + if trip_type == "round-trip" and not return_date_str: + return_raw = await self.capability_worker.run_io_loop( + "When are you returning?" + ) + return_date_str = self._parse_date(return_raw) + + if cabin not in ("economy", "business", "first", "premium_economy"): + cabin_raw = await self.capability_worker.run_io_loop( + "Economy, business, or first class?" + ) + cabin_lower = cabin_raw.lower() + if "business" in cabin_lower: + cabin = "business" + elif "first" in cabin_lower: + cabin = "first" + elif "premium" in cabin_lower: + cabin = "premium_economy" + else: + cabin = "economy" + + # Step 4: Resolve airport codes (with one retry each) + origin_iata = self._resolve_airport(origin_city) + if not origin_iata: + retry_city = await self.capability_worker.run_io_loop( + "I didn't catch the departure city. Could you say it again?" + ) + origin_iata = self._resolve_airport(retry_city) + if origin_iata: + origin_city = retry_city + + dest_iata = self._resolve_airport(dest_city) + if not dest_iata: + retry_city = await self.capability_worker.run_io_loop( + "And the destination — could you say that city again?" + ) + dest_iata = self._resolve_airport(retry_city) + if dest_iata: + dest_city = retry_city + + if not origin_iata or not dest_iata: + await self.capability_worker.speak( + "I'm still having trouble with those city names. Please try again." + ) + self.capability_worker.resume_normal_flow() + return + + # Normalise city names for clean voice readback (removes STT noise) + origin_city = self._normalize_city_name(origin_city, origin_iata) + dest_city = self._normalize_city_name(dest_city, dest_iata) + + self.worker.editor_logging_handler.info( + f"[FlightBooking] Route: {origin_iata}→{dest_iata}, date={date_str}, " + f"return={return_date_str}, cabin={cabin}" + ) + + # Steps 5–6: Search + present options (retryable loop) + selected_offer = None + while True: + await self.capability_worker.speak("Let me search for flights, one moment.") + try: + offers = self._search_flights( + origin_iata, dest_iata, date_str, + return_date_str if trip_type == "round-trip" else "", + cabin, + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[FlightBooking] Search error: {e}") + await self.capability_worker.speak( + "Sorry, I couldn't reach the flight search service. Please try again." + ) + self.capability_worker.resume_normal_flow() + return + + if not offers: + retry = await self.capability_worker.run_io_loop( + "No flights found for that route and date. " + "Would you like to change the date or destination?" + ) + if any(w in retry.lower() for w in ["yes", "sure", "ok", "try", "change", "different"]): + what = await self.capability_worker.run_io_loop( + "What would you like to change — the date or the destination?" + ) + wl = what.lower() + if "dest" in wl or "where" in wl or "to" in wl: + dest_city = await self.capability_worker.run_io_loop("Where would you like to fly?") + dest_iata = self._resolve_airport(dest_city) + else: + date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") + date_str = self._parse_date(date_raw) + continue + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + + # ── Single offer: skip selection, ask yes/no directly ────────── + if len(offers) == 1: + offer_summary = self._format_offer(offers[0], 1).replace("Option 1: ", "") + confirm_search = await self.capability_worker.run_io_loop( + f"I found one flight: {offer_summary} " + "Would you like to book it? Or say no to change something." + ) + if any(w in confirm_search.lower() for w in + ["yes", "yeah", "sure", "ok", "book", "go ahead", "sounds good"]): + selected_offer = offers[0] + break + # User said no — ask what to change + what = await self.capability_worker.run_io_loop( + "Sure. What would you like to change — the date, destination, or cabin class?" + ) + wl = what.lower() + if "date" in wl: + date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") + date_str = self._parse_date(date_raw) + elif any(w in wl for w in ["dest", "where", "to", "fly"]): + dest_city = await self.capability_worker.run_io_loop("Where would you like to fly?") + dest_iata = self._resolve_airport(dest_city) + elif any(w in wl for w in ["cabin", "class", "business", "first", "economy"]): + cabin_raw = await self.capability_worker.run_io_loop( + "Economy, business, or first class?" + ) + cabin = ("business" if "business" in cabin_raw.lower() else + "first" if "first" in cabin_raw.lower() else "economy") + continue + + # ── Multiple offers: present numbered list ────────────────────── + options_text = " ".join(self._format_offer(o, i + 1) for i, o in enumerate(offers)) + option_range = "1 or 2" if len(offers) == 2 else "1, 2, or 3" + choice_raw = await self.capability_worker.run_io_loop( + f"{options_text} Which option — {option_range}? Or say none to change something." + ) + + if any(w in choice_raw.lower() for w in ["none", "no", "neither", "change", "different"]): + what = await self.capability_worker.run_io_loop( + "Sure. What would you like to change — the date, destination, or cabin class?" + ) + wl = what.lower() + if "date" in wl: + date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") + date_str = self._parse_date(date_raw) + elif any(w in wl for w in ["dest", "where", "to", "fly"]): + dest_city = await self.capability_worker.run_io_loop("Where would you like to fly?") + dest_iata = self._resolve_airport(dest_city) + elif any(w in wl for w in ["cabin", "class", "business", "first", "economy"]): + cabin_raw = await self.capability_worker.run_io_loop( + "Economy, business, or first class?" + ) + cabin = ("business" if "business" in cabin_raw.lower() else + "first" if "first" in cabin_raw.lower() else "economy") + continue # re-search with updated params + + # Extract choice number via LLM + num_prompt = ( + f"The user was given {len(offers)} flight option(s) and said: '{choice_raw}'. " + "Return ONLY the number 1, 2, or 3. Nothing else." + ) + num_str = self.capability_worker.text_to_text_response(num_prompt).strip() + self.worker.editor_logging_handler.info(f"[FlightBooking] User chose: '{choice_raw}' → '{num_str}'") + + try: + idx = int(num_str) - 1 + if not 0 <= idx < len(offers): + raise ValueError("out of range") + except Exception: + await self.capability_worker.speak("I didn't catch that. Please say 1, 2, or 3.") + self.capability_worker.resume_normal_flow() + return + + selected_offer = offers[idx] + break # valid choice made + offer_id = selected_offer.get("id", "") + # Get passenger ID from offer + offer_passengers = selected_offer.get("passengers", []) + passenger_id = offer_passengers[0].get("id", "") if offer_passengers else "" + + # Step 7: Collect passenger details + await self.capability_worker.speak( + "Great choice. I just need a few details for the booking." + ) + passenger_details = await self._collect_passenger_details(passenger_id) + + # Step 8: Confirmation (irreversible — use run_confirmation_loop) + slice0 = selected_offer.get("slices", [{}])[0] + segments = slice0.get("segments", []) + carrier = segments[0].get("operating_carrier", {}).get("name", "the airline") if segments else "the airline" + price = selected_offer.get("total_amount", "?") + currency = selected_offer.get("total_currency", "USD") + pax_name = f"{passenger_details['given_name']} {passenger_details['family_name']}" + + confirmed = await self.capability_worker.run_confirmation_loop( + f"Booking {carrier} from {origin_city} to {dest_city} " + f"on {self._format_date_natural(date_str)} for {pax_name}. " + f"Total {currency} {price}. " + f"This holds the seat — payment must be completed before the airline's deadline. Confirm?" + ) + + if not confirmed: + await self.capability_worker.speak("Booking cancelled. Let me know if you want to search again.") + self.capability_worker.resume_normal_flow() + return + + # Step 9: Book + await self.capability_worker.speak("Placing your hold booking now.") + try: + order = self._book_flight(offer_id, [passenger_details]) + except Exception as e: + self.worker.editor_logging_handler.error(f"[FlightBooking] Booking error: {e}") + await self.capability_worker.speak( + "Sorry, the booking didn't go through. The flight may no longer be available. " + "Please try again." + ) + self.capability_worker.resume_normal_flow() + return + + # Step 10: Save and confirm + booking_ref = order.get("booking_reference", "") + await self._save_booking(order, origin_iata, dest_iata, date_str) + self.worker.editor_logging_handler.info(f"[FlightBooking] ✓ Booking complete ref={booking_ref}") + + # Extract payment deadline from order if present + pay_by_raw = ( + order.get("payment_required_by") or + order.get("payment_status", {}).get("payment_required_by", "") + ) + if pay_by_raw: + pay_by_date = self._format_date_natural(pay_by_raw[:10]) + deadline_str = f" You must complete payment by {pay_by_date}." + else: + deadline_str = "" + + ref_spoken = ", ".join(list(booking_ref)) if booking_ref else "unavailable" + self.worker.editor_logging_handler.info( + f"[FlightBooking] pay_by_raw={pay_by_raw!r} deadline_str={deadline_str!r}" + ) + await self.capability_worker.speak( + f"Done! Your booking reference is {ref_spoken}.{deadline_str} " + f"A payment link will be sent to {passenger_details['email']}. " + f"Please complete payment before the deadline to confirm your seat." + ) + self.capability_worker.resume_normal_flow() + + except Exception as e: + self.worker.editor_logging_handler.error(f"[FlightBooking] Unhandled error: {e}") + await self.capability_worker.speak("Sorry, something went wrong. Please try again.") + self.capability_worker.resume_normal_flow() + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.api_key = self.DUFFEL_API_KEY + self.worker.editor_logging_handler.info("[FlightBooking] ✓ call() — starting run_booking_flow") + self.worker.session_tasks.create(self.run_booking_flow()) From e2fffdaece63c5b64452eb64552d4e2f0bbc1495 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 17:07:02 +0000 Subject: [PATCH 2/6] style: auto-format Python files with autoflake + autopep8 --- community/flight-booking/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/community/flight-booking/main.py b/community/flight-booking/main.py index 11be5238..19813bad 100644 --- a/community/flight-booking/main.py +++ b/community/flight-booking/main.py @@ -22,7 +22,7 @@ class FlightBookingCapability(MatchingCapability): api_key: str = None # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} DUFFEL_BASE: ClassVar[str] = "https://api.duffel.com" DUFFEL_API_KEY: ClassVar[str] = "duffel_test_YOUR_KEY_HERE" @@ -602,8 +602,8 @@ async def run_booking_flow(self): # Extract payment deadline from order if present pay_by_raw = ( - order.get("payment_required_by") or - order.get("payment_status", {}).get("payment_required_by", "") + order.get("payment_required_by") + or order.get("payment_status", {}).get("payment_required_by", "") ) if pay_by_raw: pay_by_date = self._format_date_natural(pay_by_raw[:10]) From 88204b83a8097f61647931c757ee2ec7520b4c3f Mon Sep 17 00:00:00 2001 From: hassan1731996 Date: Tue, 17 Mar 2026 14:38:39 +0500 Subject: [PATCH 3/6] Address PR review: LLM classifiers, 12h times, progressive offers, combined pax prompt --- community/flight-booking/main.py | 375 ++++++++++++++++++++----------- 1 file changed, 248 insertions(+), 127 deletions(-) diff --git a/community/flight-booking/main.py b/community/flight-booking/main.py index 19813bad..04a5ee87 100644 --- a/community/flight-booking/main.py +++ b/community/flight-booking/main.py @@ -63,17 +63,18 @@ class FlightBookingCapability(MatchingCapability): # Helpers # ------------------------------------------------------------------------- + def _llm_classify(self, prompt: str) -> str: + """Call LLM classifier and return stripped lowercase result.""" + return self.capability_worker.text_to_text_response(prompt).strip().lower() + def _normalize_city_name(self, raw: str, iata: str) -> str: """Return a clean display name for a city given its resolved IATA code.""" - # Reverse lookup in IATA_MAP — covers all mapped cities cleanly for city, code in self.IATA_MAP.items(): if code == iata: return city.title() - # Short input (≤3 words) is likely already clean — just strip punctuation words = raw.strip().split() if len(words) <= 3: return raw.strip().rstrip(".,!?").title() - # LLM fallback for noisy long-form inputs outside the map prompt = ( f"What city does the IATA airport code '{iata}' primarily serve? " "Return ONLY the common city name, nothing else." @@ -84,8 +85,8 @@ def _parse_phone(self, user_input: str) -> str: """Convert spoken phone number (words or digits) to E.164 format via LLM.""" prompt = ( f"The user said: '{user_input}'. " - "This is a phone number including a country code. " - "Convert it to E.164 format (e.g. +923013018173). " + "This is a phone number. Convert it to E.164 format (e.g. +14155552671). " + "If no country code was spoken, assume +1 (United States). " "Return ONLY the + sign followed by digits. No spaces, no hyphens, no other text." ) result = self.capability_worker.text_to_text_response(prompt).strip() @@ -98,13 +99,22 @@ def _parse_phone(self, user_input: str) -> str: def _format_date_natural(self, date_str: str) -> str: """Convert '2026-04-25' → 'April 25th' for voice readback.""" try: - dt = datetime.strptime(date_str, "%Y-%m-%d") + dt = datetime.strptime(date_str[:10], "%Y-%m-%d") d = dt.day sfx = "th" if 11 <= d <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(d % 10, "th") return dt.strftime(f"%B {d}{sfx}") except Exception: return date_str + def _format_time_ampm(self, raw_dt: str) -> str: + """Convert ISO datetime string to 12-hour AM/PM time for voice readback.""" + try: + t = raw_dt[11:16] + dt = datetime.strptime(t, "%H:%M") + return dt.strftime("%I:%M %p").lstrip("0") + except Exception: + return raw_dt[11:16] if len(raw_dt) >= 16 else raw_dt + def _duffel_headers(self) -> dict: return { "Authorization": f"Bearer {self.api_key}", @@ -123,7 +133,10 @@ def _resolve_airport(self, city_or_code: str) -> str: return upper prompt = ( f"Return ONLY the 3-letter IATA airport code for the main international " - f"airport serving: {city_or_code}. No explanation, no punctuation, just 3 letters." + f"airport serving: {city_or_code}. " + "The input may be noisy speech transcription (e.g. 'I want to fly to new york' " + "or 'new york city'). Extract the city and return ONLY the 3-letter IATA code. " + "No explanation, no punctuation." ) code = self.capability_worker.text_to_text_response(prompt).strip().upper() self.worker.editor_logging_handler.info(f"[FlightBooking] IATA resolve '{city_or_code}' → '{code}'") @@ -148,7 +161,8 @@ def _extract_flight_details_from_utterance(self, utterance: str) -> dict: "Extract flight details. Return ONLY valid JSON with these fields (use null if not mentioned):\n" '{"origin": "city or airport", "destination": "city or airport", ' '"date": "YYYY-MM-DD or null", "return_date": "YYYY-MM-DD or null", ' - '"trip_type": "one-way or round-trip or null", "cabin": "economy or business or first or null"}' + '"trip_type": "one-way or round-trip or null", ' + '"cabin": "economy or business or first or premium_economy or null"}' ) raw = self.capability_worker.text_to_text_response(prompt).strip() if raw.startswith("```"): @@ -161,7 +175,7 @@ def _extract_flight_details_from_utterance(self, utterance: str) -> dict: def _search_flights(self, origin: str, dest: str, date: str, return_date: str, cabin: str) -> list: - """Create Duffel offer request, return top 3 offers sorted by price.""" + """Create Duffel offer request, return top 3 holdable offers sorted by price.""" slices = [{"origin": origin, "destination": dest, "departure_date": date}] if return_date: slices.append({"origin": dest, "destination": origin, "departure_date": return_date}) @@ -184,7 +198,6 @@ def _search_flights(self, origin: str, dest: str, date: str, offers = data.get("offers", []) self.worker.editor_logging_handler.info(f"[FlightBooking] {len(offers)} offer(s) returned (pre-filter)") - # Keep only offers that support hold booking (requires_instant_payment must be false) holdable = [ o for o in offers if not o.get("payment_requirements", {}).get("requires_instant_payment", True) @@ -192,13 +205,12 @@ def _search_flights(self, origin: str, dest: str, date: str, self.worker.editor_logging_handler.info( f"[FlightBooking] {len(holdable)} holdable offer(s) after filter" ) - # Use holdable offers; fall back to all if somehow none qualify result = holdable if holdable else offers result.sort(key=lambda o: float(o.get("total_amount", "9999") or "9999")) return result[:3] def _format_offer(self, offer: dict, index: int) -> str: - """Build a concise, voice-friendly offer summary.""" + """Build a concise, voice-friendly offer summary with 12-hour times.""" slice0 = offer.get("slices", [{}])[0] segments = slice0.get("segments", []) stops = max(len(segments) - 1, 0) @@ -207,10 +219,8 @@ def _format_offer(self, offer: dict, index: int) -> str: carrier = segments[0].get("operating_carrier", {}).get("name", "Unknown airline") first_seg = segments[0] if segments else {} last_seg = segments[-1] if segments else {} - dep_raw = first_seg.get("departing_at", "") - arr_raw = last_seg.get("arriving_at", "") - dep = dep_raw[11:16] if len(dep_raw) >= 16 else dep_raw - arr = arr_raw[11:16] if len(arr_raw) >= 16 else arr_raw + dep = self._format_time_ampm(first_seg.get("departing_at", "")) + arr = self._format_time_ampm(last_seg.get("arriving_at", "")) price = offer.get("total_amount", "?") currency = offer.get("total_currency", "USD") stop_str = "Non-stop" if stops == 0 else f"{stops} stop{'s' if stops > 1 else ''}" @@ -264,51 +274,126 @@ async def _save_booking(self, order: dict, origin: str, dest: str, date: str): await self.capability_worker.write_file(self.HISTORY_FILE, json.dumps(history, indent=2), False) self.worker.editor_logging_handler.info(f"[FlightBooking] Saved booking {entry['booking_ref']}") + # ------------------------------------------------------------------------- + # Change-routing helper (used in search-retry loop) + # ------------------------------------------------------------------------- + + async def _ask_and_apply_change(self, date_str: str, dest_city: str, + dest_iata: str, cabin: str): + """Ask what the user wants to change and update the relevant parameter.""" + what = await self.capability_worker.run_io_loop( + "What would you like to change — the date, destination, or cabin class?" + ) + answer = self._llm_classify( + f"The user said: '{what}'. Are they changing the DATE, DESTINATION, or CABIN CLASS? " + "Reply ONLY with one word: DATE, DESTINATION, or CABIN." + ) + if "date" in answer: + date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") + date_str = self._parse_date(date_raw) + elif "destination" in answer: + dest_city = await self.capability_worker.run_io_loop("Where would you like to fly?") + dest_iata = self._resolve_airport(dest_city) + dest_city = self._normalize_city_name(dest_city, dest_iata) + else: + cabin_raw = await self.capability_worker.run_io_loop( + "Economy, business, or first class?" + ) + cabin_lower = cabin_raw.lower() + cabin = ( + "business" if "business" in cabin_lower else + "first" if "first" in cabin_lower else + "premium_economy" if "premium" in cabin_lower else + "economy" + ) + return date_str, dest_city, dest_iata, cabin + # ------------------------------------------------------------------------- # Passenger Details Collection # ------------------------------------------------------------------------- async def _collect_passenger_details(self, passenger_id: str) -> dict: - """Ask for passenger name, title, DOB, email, phone.""" - given = await self.capability_worker.run_io_loop("What's the passenger's first name?") - family = await self.capability_worker.run_io_loop("And the last name?") + """Ask for passenger details with a combined prompt; re-ask only for null fields.""" + # One combined prompt — user can answer everything at once + combined = await self.capability_worker.run_io_loop( + "I need a few details. Please say your full name, date of birth, " + "email address, and phone number with country code." + ) + today = datetime.now().strftime("%Y-%m-%d") + extract_prompt = ( + f"Today is {today}. The user said: '{combined}'.\n" + "Extract passenger details. Return ONLY valid JSON with these exact keys " + "(use null for any field not mentioned):\n" + '{"given_name": null, "family_name": null, ' + '"born_on": "YYYY-MM-DD or null", "email": null, "phone": null}' + ) + raw = self.capability_worker.text_to_text_response(extract_prompt).strip() + if raw.startswith("```"): + lines = raw.splitlines() + raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() + extracted = {} + try: + extracted = json.loads(raw) + except Exception: + pass + + given = extracted.get("given_name") or None + family = extracted.get("family_name") or None + dob = extracted.get("born_on") or None + email_raw = extracted.get("email") or None + phone_raw = extracted.get("phone") or None + + # Re-ask individually for any field still missing + if not given: + given = await self.capability_worker.run_io_loop("What's the passenger's first name?") + if not family: + family = await self.capability_worker.run_io_loop("And the last name?") + if not dob: + dob_raw = await self.capability_worker.run_io_loop( + "Date of birth? For example, March 5th 1990." + ) + dob = self._parse_date(dob_raw) + else: + # dob came from LLM extraction — validate it looks like a date + if not re.match(r"\d{4}-\d{2}-\d{2}", str(dob)): + dob = self._parse_date(str(dob)) + + if not email_raw: + email_raw = await self.capability_worker.run_io_loop("Email address?") + email_clean = re.sub(r"[^\x00-\x7F]", "", email_raw).strip().lower() + if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email_clean): + email_raw = await self.capability_worker.run_io_loop( + "I didn't catch that clearly. Please spell out your email address." + ) + email_clean = re.sub(r"[^\x00-\x7F]", "", email_raw).strip().lower() - # Title → also derives gender (required by Duffel API) + if not phone_raw: + phone_raw = await self.capability_worker.run_io_loop( + "Phone number with country code? For example, plus 1 and then your ten-digit number." + ) + phone_clean = self._parse_phone(str(phone_raw)) + + # Title — separate question with LLM classifier (TTS-friendly phrasing) title_raw = await self.capability_worker.run_io_loop( - "What's your title? Mr, Mrs, Miss, Ms, or Dr?" + "Are you Mister, Missus, Miss, or Doctor?" ) - t = title_raw.lower().strip() - if "mrs" in t: + title_answer = self._llm_classify( + f"The user said: '{title_raw}'. Which title are they choosing? " + "Reply ONLY with one of: mr, mrs, miss, ms, dr." + ) + if "mrs" in title_answer: title, gender = "mrs", "f" - elif "miss" in t: + elif "miss" in title_answer: title, gender = "miss", "f" - elif "ms" in t: + elif "ms" in title_answer: title, gender = "ms", "f" - elif "dr" in t: + elif "dr" in title_answer: title, gender = "dr", "m" else: title, gender = "mr", "m" - dob_raw = await self.capability_worker.run_io_loop( - "Date of birth? For example, March 5th 1990." - ) - dob = self._parse_date(dob_raw) - email = await self.capability_worker.run_io_loop("Email address?") - # Re-ask once if email contains non-ASCII or is missing @ / domain - email_clean_check = re.sub(r"[^\x00-\x7F]", "", email).strip().lower() - if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email_clean_check): - email = await self.capability_worker.run_io_loop( - "I didn't catch that clearly. Please spell out your email address." - ) - email_clean_check = re.sub(r"[^\x00-\x7F]", "", email).strip().lower() - email = email_clean_check - phone = await self.capability_worker.run_io_loop( - "Phone number with country code? For example, plus 44 7700 900 123." - ) - phone_clean = self._parse_phone(phone) - - given_clean = re.sub(r"[^a-zA-Z\s'\-]", "", given).strip().title() - family_clean = re.sub(r"[^a-zA-Z\s'\-]", "", family).strip().title() + given_clean = re.sub(r"[^a-zA-Z\s'\-]", "", str(given)).strip().title() + family_clean = re.sub(r"[^a-zA-Z\s'\-]", "", str(family)).strip().title() details = { "id": passenger_id, @@ -317,10 +402,12 @@ async def _collect_passenger_details(self, passenger_id: str) -> dict: "given_name": given_clean, "family_name": family_clean, "born_on": dob, - "email": email, + "email": email_clean, "phone_number": phone_clean, } - self.worker.editor_logging_handler.info(f"[FlightBooking] Passenger: {details['given_name']} {details['family_name']} ({title})") + self.worker.editor_logging_handler.info( + f"[FlightBooking] Passenger: {details['given_name']} {details['family_name']} ({title})" + ) return details # ------------------------------------------------------------------------- @@ -342,7 +429,7 @@ async def run_booking_flow(self): extracted = self._extract_flight_details_from_utterance(full_utterance) self.worker.editor_logging_handler.info(f"[FlightBooking] Extracted: {extracted}") - # Step 3: Fill in any missing details by asking + # Step 3: Fill in missing details origin_city = extracted.get("origin") or "" dest_city = extracted.get("destination") or "" date_str = extracted.get("date") or "" @@ -350,6 +437,20 @@ async def run_booking_flow(self): trip_type = extracted.get("trip_type") or "" cabin = extracted.get("cabin") or "economy" + # Catch-all: if 2+ core fields are missing, ask one combined question first + null_count = sum(1 for v in [origin_city, dest_city, date_str] if not v) + if null_count >= 2: + catch_all = await self.capability_worker.run_io_loop( + "Where are you flying, and when?" + ) + extra = self._extract_flight_details_from_utterance(catch_all) + origin_city = origin_city or extra.get("origin") or "" + dest_city = dest_city or extra.get("destination") or "" + date_str = date_str or extra.get("date") or "" + trip_type = trip_type or extra.get("trip_type") or "" + return_date_str = return_date_str or extra.get("return_date") or "" + + # Individual fallback prompts for any still-missing fields if not origin_city: origin_city = await self.capability_worker.run_io_loop( "Where are you flying from?" @@ -368,7 +469,7 @@ async def run_booking_flow(self): try: if date_str < datetime.now().strftime("%Y-%m-%d"): date_raw = await self.capability_worker.run_io_loop( - f"{date_str} is in the past. What date did you mean?" + f"{self._format_date_natural(date_str)} is in the past. What date did you mean?" ) date_str = self._parse_date(date_raw) except Exception: @@ -378,9 +479,11 @@ async def run_booking_flow(self): trip_raw = await self.capability_worker.run_io_loop( "Is that one-way or round trip?" ) - trip_type = "round-trip" if any( - w in trip_raw.lower() for w in ["round", "return", "both"] - ) else "one-way" + answer = self._llm_classify( + f"The user said: '{trip_raw}'. Are they booking a one-way or round-trip flight? " + "Reply ONLY with ONE-WAY or ROUND-TRIP." + ) + trip_type = "round-trip" if "round" in answer else "one-way" if trip_type == "round-trip" and not return_date_str: return_raw = await self.capability_worker.run_io_loop( @@ -428,7 +531,7 @@ async def run_booking_flow(self): self.capability_worker.resume_normal_flow() return - # Normalise city names for clean voice readback (removes STT noise) + # Normalise city names for clean voice readback origin_city = self._normalize_city_name(origin_city, origin_iata) dest_city = self._normalize_city_name(dest_city, dest_iata) @@ -460,19 +563,18 @@ async def run_booking_flow(self): "No flights found for that route and date. " "Would you like to change the date or destination?" ) - if any(w in retry.lower() for w in ["yes", "sure", "ok", "try", "change", "different"]): - what = await self.capability_worker.run_io_loop( - "What would you like to change — the date or the destination?" + answer = self._llm_classify( + f"The user said: '{retry}'. Did they agree to change their search? " + "Reply ONLY with YES or NO." + ) + if answer.startswith("yes"): + date_str, dest_city, dest_iata, cabin = await self._ask_and_apply_change( + date_str, dest_city, dest_iata, cabin ) - wl = what.lower() - if "dest" in wl or "where" in wl or "to" in wl: - dest_city = await self.capability_worker.run_io_loop("Where would you like to fly?") - dest_iata = self._resolve_airport(dest_city) - else: - date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") - date_str = self._parse_date(date_raw) continue - await self.capability_worker.speak("No problem. Let me know if you need anything else.") + await self.capability_worker.speak( + "No problem. Let me know if you need anything else." + ) self.capability_worker.resume_normal_flow() return @@ -483,54 +585,50 @@ async def run_booking_flow(self): f"I found one flight: {offer_summary} " "Would you like to book it? Or say no to change something." ) - if any(w in confirm_search.lower() for w in - ["yes", "yeah", "sure", "ok", "book", "go ahead", "sounds good"]): + answer = self._llm_classify( + f"The user said: '{confirm_search}'. " + "Do they want to book this flight? Reply ONLY with YES or NO." + ) + if answer.startswith("yes"): selected_offer = offers[0] break - # User said no — ask what to change - what = await self.capability_worker.run_io_loop( - "Sure. What would you like to change — the date, destination, or cabin class?" + date_str, dest_city, dest_iata, cabin = await self._ask_and_apply_change( + date_str, dest_city, dest_iata, cabin ) - wl = what.lower() - if "date" in wl: - date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") - date_str = self._parse_date(date_raw) - elif any(w in wl for w in ["dest", "where", "to", "fly"]): - dest_city = await self.capability_worker.run_io_loop("Where would you like to fly?") - dest_iata = self._resolve_airport(dest_city) - elif any(w in wl for w in ["cabin", "class", "business", "first", "economy"]): - cabin_raw = await self.capability_worker.run_io_loop( - "Economy, business, or first class?" - ) - cabin = ("business" if "business" in cabin_raw.lower() else - "first" if "first" in cabin_raw.lower() else "economy") continue - # ── Multiple offers: present numbered list ────────────────────── - options_text = " ".join(self._format_offer(o, i + 1) for i, o in enumerate(offers)) + # ── Multiple offers: progressive reveal ───────────────────────── + await self.capability_worker.speak(self._format_offer(offers[0], 1)) + await self.capability_worker.speak(self._format_offer(offers[1], 2)) + + if len(offers) == 3: + want_more = await self.capability_worker.run_io_loop( + "Want to hear the third option, or go with one of those?" + ) + want_ans = self._llm_classify( + f"The user said: '{want_more}'. " + "Do they want to hear the third option? Reply ONLY with YES or NO." + ) + if want_ans.startswith("yes"): + await self.capability_worker.speak(self._format_offer(offers[2], 3)) + else: + offers = offers[:2] + option_range = "1 or 2" if len(offers) == 2 else "1, 2, or 3" choice_raw = await self.capability_worker.run_io_loop( - f"{options_text} Which option — {option_range}? Or say none to change something." + f"Which option — {option_range}? Or say none to change something." ) - if any(w in choice_raw.lower() for w in ["none", "no", "neither", "change", "different"]): - what = await self.capability_worker.run_io_loop( - "Sure. What would you like to change — the date, destination, or cabin class?" + change_answer = self._llm_classify( + f"The user said: '{choice_raw}'. " + "Do they want to change something instead of choosing a flight option? " + "Reply ONLY with YES or NO." + ) + if change_answer.startswith("yes"): + date_str, dest_city, dest_iata, cabin = await self._ask_and_apply_change( + date_str, dest_city, dest_iata, cabin ) - wl = what.lower() - if "date" in wl: - date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") - date_str = self._parse_date(date_raw) - elif any(w in wl for w in ["dest", "where", "to", "fly"]): - dest_city = await self.capability_worker.run_io_loop("Where would you like to fly?") - dest_iata = self._resolve_airport(dest_city) - elif any(w in wl for w in ["cabin", "class", "business", "first", "economy"]): - cabin_raw = await self.capability_worker.run_io_loop( - "Economy, business, or first class?" - ) - cabin = ("business" if "business" in cabin_raw.lower() else - "first" if "first" in cabin_raw.lower() else "economy") - continue # re-search with updated params + continue # Extract choice number via LLM num_prompt = ( @@ -538,7 +636,9 @@ async def run_booking_flow(self): "Return ONLY the number 1, 2, or 3. Nothing else." ) num_str = self.capability_worker.text_to_text_response(num_prompt).strip() - self.worker.editor_logging_handler.info(f"[FlightBooking] User chose: '{choice_raw}' → '{num_str}'") + self.worker.editor_logging_handler.info( + f"[FlightBooking] User chose: '{choice_raw}' → '{num_str}'" + ) try: idx = int(num_str) - 1 @@ -550,9 +650,9 @@ async def run_booking_flow(self): return selected_offer = offers[idx] - break # valid choice made + break + offer_id = selected_offer.get("id", "") - # Get passenger ID from offer offer_passengers = selected_offer.get("passengers", []) passenger_id = offer_passengers[0].get("id", "") if offer_passengers else "" @@ -562,45 +662,66 @@ async def run_booking_flow(self): ) passenger_details = await self._collect_passenger_details(passenger_id) - # Step 8: Confirmation (irreversible — use run_confirmation_loop) + # Step 8: Confirmation (~28 words, TTS-friendly) slice0 = selected_offer.get("slices", [{}])[0] segments = slice0.get("segments", []) - carrier = segments[0].get("operating_carrier", {}).get("name", "the airline") if segments else "the airline" + carrier = ( + segments[0].get("operating_carrier", {}).get("name", "the airline") + if segments else "the airline" + ) price = selected_offer.get("total_amount", "?") currency = selected_offer.get("total_currency", "USD") pax_name = f"{passenger_details['given_name']} {passenger_details['family_name']}" confirmed = await self.capability_worker.run_confirmation_loop( - f"Booking {carrier} from {origin_city} to {dest_city} " - f"on {self._format_date_natural(date_str)} for {pax_name}. " - f"Total {currency} {price}. " - f"This holds the seat — payment must be completed before the airline's deadline. Confirm?" + f"Confirm: {carrier}, {origin_city} to {dest_city}, " + f"{self._format_date_natural(date_str)}, {currency} {price} for {pax_name}. " + f"This is a hold — you'll pay later. Shall I book it?" ) if not confirmed: - await self.capability_worker.speak("Booking cancelled. Let me know if you want to search again.") - self.capability_worker.resume_normal_flow() - return - - # Step 9: Book - await self.capability_worker.speak("Placing your hold booking now.") - try: - order = self._book_flight(offer_id, [passenger_details]) - except Exception as e: - self.worker.editor_logging_handler.error(f"[FlightBooking] Booking error: {e}") await self.capability_worker.speak( - "Sorry, the booking didn't go through. The flight may no longer be available. " - "Please try again." + "Booking cancelled. Let me know if you want to search again." ) self.capability_worker.resume_normal_flow() return + # Step 9: Book (with one retry on failure) + await self.capability_worker.speak("Placing your hold booking now.") + order = None + for attempt in range(2): + try: + order = self._book_flight(offer_id, [passenger_details]) + break + except Exception as e: + self.worker.editor_logging_handler.error( + f"[FlightBooking] Booking error (attempt {attempt + 1}): {e}" + ) + if attempt == 0: + retry_ans = await self.capability_worker.run_io_loop( + "Sorry, I couldn't complete that booking. Want me to try again?" + ) + retry_classify = self._llm_classify( + f"The user said: '{retry_ans}'. Do they want to retry? " + "Reply ONLY with YES or NO." + ) + if not retry_classify.startswith("yes"): + self.capability_worker.resume_normal_flow() + return + else: + await self.capability_worker.speak( + "Sorry, the booking still didn't go through. Please try again later." + ) + self.capability_worker.resume_normal_flow() + return + # Step 10: Save and confirm booking_ref = order.get("booking_reference", "") await self._save_booking(order, origin_iata, dest_iata, date_str) - self.worker.editor_logging_handler.info(f"[FlightBooking] ✓ Booking complete ref={booking_ref}") + self.worker.editor_logging_handler.info( + f"[FlightBooking] ✓ Booking complete ref={booking_ref}" + ) - # Extract payment deadline from order if present pay_by_raw = ( order.get("payment_required_by") or order.get("payment_status", {}).get("payment_required_by", "") @@ -617,7 +738,7 @@ async def run_booking_flow(self): ) await self.capability_worker.speak( f"Done! Your booking reference is {ref_spoken}.{deadline_str} " - f"A payment link will be sent to {passenger_details['email']}. " + f"A payment link will be sent to your email address. " f"Please complete payment before the deadline to confirm your seat." ) self.capability_worker.resume_normal_flow() From d6db36d2ca15e0c42d2fc876656b7768f416f635 Mon Sep 17 00:00:00 2001 From: hassan1731996 Date: Wed, 18 Mar 2026 14:17:43 +0500 Subject: [PATCH 4/6] Address round 2 review: grouped passenger prompts, exit intent, confirmation change routing, 12h times --- community/flight-booking/main.py | 623 +++++++++++++++++-------------- 1 file changed, 349 insertions(+), 274 deletions(-) diff --git a/community/flight-booking/main.py b/community/flight-booking/main.py index 04a5ee87..5f812391 100644 --- a/community/flight-booking/main.py +++ b/community/flight-booking/main.py @@ -22,7 +22,7 @@ class FlightBookingCapability(MatchingCapability): api_key: str = None # Do not change following tag of register capability - # {{register capability}} + #{{register capability}} DUFFEL_BASE: ClassVar[str] = "https://api.duffel.com" DUFFEL_API_KEY: ClassVar[str] = "duffel_test_YOUR_KEY_HERE" @@ -63,9 +63,14 @@ class FlightBookingCapability(MatchingCapability): # Helpers # ------------------------------------------------------------------------- - def _llm_classify(self, prompt: str) -> str: - """Call LLM classifier and return stripped lowercase result.""" - return self.capability_worker.text_to_text_response(prompt).strip().lower() + def _is_exit_intent(self, response: str) -> bool: + """Return True if the user wants to cancel or exit the current task.""" + prompt = ( + f"The user said: '{response}'. " + "Are they trying to cancel, stop, or exit the current task? " + "Reply ONLY with YES or NO." + ) + return self.capability_worker.text_to_text_response(prompt).strip().upper().startswith("Y") def _normalize_city_name(self, raw: str, iata: str) -> str: """Return a clean display name for a city given its resolved IATA code.""" @@ -85,8 +90,8 @@ def _parse_phone(self, user_input: str) -> str: """Convert spoken phone number (words or digits) to E.164 format via LLM.""" prompt = ( f"The user said: '{user_input}'. " - "This is a phone number. Convert it to E.164 format (e.g. +14155552671). " - "If no country code was spoken, assume +1 (United States). " + "This is a phone number including a country code spoken with a US/international accent. " + "Convert it to E.164 format (e.g. +923013018173). " "Return ONLY the + sign followed by digits. No spaces, no hyphens, no other text." ) result = self.capability_worker.text_to_text_response(prompt).strip() @@ -99,21 +104,23 @@ def _parse_phone(self, user_input: str) -> str: def _format_date_natural(self, date_str: str) -> str: """Convert '2026-04-25' → 'April 25th' for voice readback.""" try: - dt = datetime.strptime(date_str[:10], "%Y-%m-%d") + dt = datetime.strptime(date_str, "%Y-%m-%d") d = dt.day sfx = "th" if 11 <= d <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(d % 10, "th") return dt.strftime(f"%B {d}{sfx}") except Exception: return date_str - def _format_time_ampm(self, raw_dt: str) -> str: - """Convert ISO datetime string to 12-hour AM/PM time for voice readback.""" + def _fmt_time_12h(self, t: str) -> str: + """Convert HH:MM (24-hour) to h:MM AM/PM for natural voice readback.""" try: - t = raw_dt[11:16] dt = datetime.strptime(t, "%H:%M") - return dt.strftime("%I:%M %p").lstrip("0") + hour = dt.hour % 12 or 12 + minute = dt.strftime("%M") + period = "AM" if dt.hour < 12 else "PM" + return f"{hour}:{minute} {period}" except Exception: - return raw_dt[11:16] if len(raw_dt) >= 16 else raw_dt + return t def _duffel_headers(self) -> dict: return { @@ -133,10 +140,7 @@ def _resolve_airport(self, city_or_code: str) -> str: return upper prompt = ( f"Return ONLY the 3-letter IATA airport code for the main international " - f"airport serving: {city_or_code}. " - "The input may be noisy speech transcription (e.g. 'I want to fly to new york' " - "or 'new york city'). Extract the city and return ONLY the 3-letter IATA code. " - "No explanation, no punctuation." + f"airport serving: {city_or_code}. No explanation, no punctuation, just 3 letters." ) code = self.capability_worker.text_to_text_response(prompt).strip().upper() self.worker.editor_logging_handler.info(f"[FlightBooking] IATA resolve '{city_or_code}' → '{code}'") @@ -161,8 +165,7 @@ def _extract_flight_details_from_utterance(self, utterance: str) -> dict: "Extract flight details. Return ONLY valid JSON with these fields (use null if not mentioned):\n" '{"origin": "city or airport", "destination": "city or airport", ' '"date": "YYYY-MM-DD or null", "return_date": "YYYY-MM-DD or null", ' - '"trip_type": "one-way or round-trip or null", ' - '"cabin": "economy or business or first or premium_economy or null"}' + '"trip_type": "one-way or round-trip or null", "cabin": "economy or business or first or null"}' ) raw = self.capability_worker.text_to_text_response(prompt).strip() if raw.startswith("```"): @@ -173,6 +176,26 @@ def _extract_flight_details_from_utterance(self, utterance: str) -> dict: except Exception: return {} + def _extract_passenger_fields(self, utterance: str, fields: list) -> dict: + """Extract named passenger fields from a spoken utterance via LLM.""" + today = datetime.now().strftime("%Y-%m-%d") + prompt = ( + f"Today is {today}. The user said: '{utterance}'.\n" + f"Extract these fields: {fields}.\n" + "Return ONLY valid JSON. Use null for any field not clearly present.\n" + "Rules: born_on → YYYY-MM-DD, email → lowercase ASCII, " + "phone_number → E.164 (+countrycode digits, e.g. +12025551234).\n" + 'Example: {"given_name": "John", "family_name": "Smith", "born_on": "1990-03-05"}' + ) + raw = self.capability_worker.text_to_text_response(prompt).strip() + if raw.startswith("```"): + lines = raw.splitlines() + raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() + try: + return json.loads(raw) + except Exception: + return {f: None for f in fields} + def _search_flights(self, origin: str, dest: str, date: str, return_date: str, cabin: str) -> list: """Create Duffel offer request, return top 3 holdable offers sorted by price.""" @@ -194,8 +217,7 @@ def _search_flights(self, origin: str, dest: str, date: str, timeout=30, ) resp.raise_for_status() - data = resp.json().get("data", {}) - offers = data.get("offers", []) + offers = resp.json().get("data", {}).get("offers", []) self.worker.editor_logging_handler.info(f"[FlightBooking] {len(offers)} offer(s) returned (pre-filter)") holdable = [ @@ -219,8 +241,10 @@ def _format_offer(self, offer: dict, index: int) -> str: carrier = segments[0].get("operating_carrier", {}).get("name", "Unknown airline") first_seg = segments[0] if segments else {} last_seg = segments[-1] if segments else {} - dep = self._format_time_ampm(first_seg.get("departing_at", "")) - arr = self._format_time_ampm(last_seg.get("arriving_at", "")) + dep_raw = first_seg.get("departing_at", "") + arr_raw = last_seg.get("arriving_at", "") + dep = self._fmt_time_12h(dep_raw[11:16]) if len(dep_raw) >= 16 else "TBD" + arr = self._fmt_time_12h(arr_raw[11:16]) if len(arr_raw) >= 16 else "TBD" price = offer.get("total_amount", "?") currency = offer.get("total_currency", "USD") stop_str = "Non-stop" if stops == 0 else f"{stops} stop{'s' if stops > 1 else ''}" @@ -275,125 +299,139 @@ async def _save_booking(self, order: dict, origin: str, dest: str, date: str): self.worker.editor_logging_handler.info(f"[FlightBooking] Saved booking {entry['booking_ref']}") # ------------------------------------------------------------------------- - # Change-routing helper (used in search-retry loop) + # Change routing helper (deduplicates the 3 copy-pasted change blocks) # ------------------------------------------------------------------------- - async def _ask_and_apply_change(self, date_str: str, dest_city: str, - dest_iata: str, cabin: str): - """Ask what the user wants to change and update the relevant parameter.""" + async def _ask_and_apply_change(self, date_str, dest_city, dest_iata, cabin): + """Ask what the user wants to change and return updated (date_str, dest_city, dest_iata, cabin). + Returns None if the user wants to exit.""" what = await self.capability_worker.run_io_loop( "What would you like to change — the date, destination, or cabin class?" ) - answer = self._llm_classify( - f"The user said: '{what}'. Are they changing the DATE, DESTINATION, or CABIN CLASS? " - "Reply ONLY with one word: DATE, DESTINATION, or CABIN." + if self._is_exit_intent(what): + return None + + change_prompt = ( + f"The user said: '{what}'. They want to change something about their flight search. " + "Reply with exactly one of: DATE, DESTINATION, CABIN, OTHER" ) - if "date" in answer: + change_intent = self.capability_worker.text_to_text_response(change_prompt).strip().upper() + self.worker.editor_logging_handler.info(f"[FlightBooking] Change intent: '{what}' → {change_intent}") + + if change_intent == "DATE": date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") + if self._is_exit_intent(date_raw): + return None date_str = self._parse_date(date_raw) - elif "destination" in answer: - dest_city = await self.capability_worker.run_io_loop("Where would you like to fly?") - dest_iata = self._resolve_airport(dest_city) - dest_city = self._normalize_city_name(dest_city, dest_iata) - else: - cabin_raw = await self.capability_worker.run_io_loop( - "Economy, business, or first class?" - ) + elif change_intent == "DESTINATION": + new_dest = await self.capability_worker.run_io_loop("Where would you like to fly?") + if self._is_exit_intent(new_dest): + return None + dest_iata = self._resolve_airport(new_dest) + dest_city = self._normalize_city_name(new_dest, dest_iata) + elif change_intent == "CABIN": + cabin_raw = await self.capability_worker.run_io_loop("Economy, business, or first class?") + if self._is_exit_intent(cabin_raw): + return None cabin_lower = cabin_raw.lower() - cabin = ( - "business" if "business" in cabin_lower else - "first" if "first" in cabin_lower else - "premium_economy" if "premium" in cabin_lower else - "economy" - ) + cabin = ("business" if "business" in cabin_lower else + "first" if "first" in cabin_lower else + "premium_economy" if "premium" in cabin_lower else "economy") + return date_str, dest_city, dest_iata, cabin # ------------------------------------------------------------------------- # Passenger Details Collection # ------------------------------------------------------------------------- - async def _collect_passenger_details(self, passenger_id: str) -> dict: - """Ask for passenger details with a combined prompt; re-ask only for null fields.""" - # One combined prompt — user can answer everything at once - combined = await self.capability_worker.run_io_loop( - "I need a few details. Please say your full name, date of birth, " - "email address, and phone number with country code." - ) - today = datetime.now().strftime("%Y-%m-%d") - extract_prompt = ( - f"Today is {today}. The user said: '{combined}'.\n" - "Extract passenger details. Return ONLY valid JSON with these exact keys " - "(use null for any field not mentioned):\n" - '{"given_name": null, "family_name": null, ' - '"born_on": "YYYY-MM-DD or null", "email": null, "phone": null}' + async def _collect_passenger_details(self, passenger_id: str): + """Collect passenger details using grouped prompts with LLM extraction. + Returns a dict on success, or None if the user exits.""" + + # Group 1: full name + DOB in one prompt + group1_raw = await self.capability_worker.run_io_loop( + "I need a few details for the booking. " + "What's the passenger's full name and date of birth? " + "For example: John Smith, born March 5th 1990." ) - raw = self.capability_worker.text_to_text_response(extract_prompt).strip() - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() - extracted = {} - try: - extracted = json.loads(raw) - except Exception: - pass + if self._is_exit_intent(group1_raw): + return None - given = extracted.get("given_name") or None - family = extracted.get("family_name") or None - dob = extracted.get("born_on") or None - email_raw = extracted.get("email") or None - phone_raw = extracted.get("phone") or None + g1 = self._extract_passenger_fields( + group1_raw, ["given_name", "family_name", "born_on"] + ) + given = g1.get("given_name") + family = g1.get("family_name") + dob = g1.get("born_on") - # Re-ask individually for any field still missing + # Fall back to individual asks only for fields that weren't extracted if not given: given = await self.capability_worker.run_io_loop("What's the passenger's first name?") + if self._is_exit_intent(given): + return None if not family: family = await self.capability_worker.run_io_loop("And the last name?") + if self._is_exit_intent(family): + return None if not dob: dob_raw = await self.capability_worker.run_io_loop( "Date of birth? For example, March 5th 1990." ) + if self._is_exit_intent(dob_raw): + return None dob = self._parse_date(dob_raw) - else: - # dob came from LLM extraction — validate it looks like a date - if not re.match(r"\d{4}-\d{2}-\d{2}", str(dob)): - dob = self._parse_date(str(dob)) - - if not email_raw: - email_raw = await self.capability_worker.run_io_loop("Email address?") - email_clean = re.sub(r"[^\x00-\x7F]", "", email_raw).strip().lower() - if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email_clean): - email_raw = await self.capability_worker.run_io_loop( - "I didn't catch that clearly. Please spell out your email address." - ) - email_clean = re.sub(r"[^\x00-\x7F]", "", email_raw).strip().lower() - if not phone_raw: - phone_raw = await self.capability_worker.run_io_loop( - "Phone number with country code? For example, plus 1 and then your ten-digit number." - ) - phone_clean = self._parse_phone(str(phone_raw)) - - # Title — separate question with LLM classifier (TTS-friendly phrasing) + # Title (kept separate — categorical choice, not reliably extracted from free-form) title_raw = await self.capability_worker.run_io_loop( - "Are you Mister, Missus, Miss, or Doctor?" + "What's your title? Mister, Missus, Miss, Ms, or Doctor?" ) - title_answer = self._llm_classify( - f"The user said: '{title_raw}'. Which title are they choosing? " - "Reply ONLY with one of: mr, mrs, miss, ms, dr." - ) - if "mrs" in title_answer: + if self._is_exit_intent(title_raw): + return None + t = title_raw.lower().strip() + if "mrs" in t or "missus" in t: title, gender = "mrs", "f" - elif "miss" in title_answer: + elif "miss" in t: title, gender = "miss", "f" - elif "ms" in title_answer: + elif "ms" in t: title, gender = "ms", "f" - elif "dr" in title_answer: + elif "dr" in t or "doctor" in t: title, gender = "dr", "m" else: title, gender = "mr", "m" - given_clean = re.sub(r"[^a-zA-Z\s'\-]", "", str(given)).strip().title() - family_clean = re.sub(r"[^a-zA-Z\s'\-]", "", str(family)).strip().title() + # Group 2: email + phone in one prompt + group2_raw = await self.capability_worker.run_io_loop( + "And what's the email address and phone number, including country code?" + ) + if self._is_exit_intent(group2_raw): + return None + + g2 = self._extract_passenger_fields(group2_raw, ["email", "phone_number"]) + email = g2.get("email") or "" + phone_clean = g2.get("phone_number") or "" + + # Validate email — re-ask once if invalid or missing + email_clean = re.sub(r"[^\x00-\x7F]", "", email).strip().lower() + if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email_clean): + email_raw = await self.capability_worker.run_io_loop( + "I didn't catch the email clearly. Please say it again." + ) + if self._is_exit_intent(email_raw): + return None + email_clean = re.sub(r"[^\x00-\x7F]", "", email_raw).strip().lower() + email = email_clean + + # Validate phone — re-ask once if blank after LLM extraction + if not phone_clean: + phone_raw = await self.capability_worker.run_io_loop( + "And the phone number with country code? For example, plus 44 7700 900 123." + ) + if self._is_exit_intent(phone_raw): + return None + phone_clean = self._parse_phone(phone_raw) + + given_clean = re.sub(r"[^a-zA-Z\s'\-]", "", given).strip().title() + family_clean = re.sub(r"[^a-zA-Z\s'\-]", "", family).strip().title() details = { "id": passenger_id, @@ -402,7 +440,7 @@ async def _collect_passenger_details(self, passenger_id: str) -> dict: "given_name": given_clean, "family_name": family_clean, "born_on": dob, - "email": email_clean, + "email": email, "phone_number": phone_clean, } self.worker.editor_logging_handler.info( @@ -429,7 +467,7 @@ async def run_booking_flow(self): extracted = self._extract_flight_details_from_utterance(full_utterance) self.worker.editor_logging_handler.info(f"[FlightBooking] Extracted: {extracted}") - # Step 3: Fill in missing details + # Step 3: Fill in any missing details by asking origin_city = extracted.get("origin") or "" dest_city = extracted.get("destination") or "" date_str = extracted.get("date") or "" @@ -437,32 +475,28 @@ async def run_booking_flow(self): trip_type = extracted.get("trip_type") or "" cabin = extracted.get("cabin") or "economy" - # Catch-all: if 2+ core fields are missing, ask one combined question first - null_count = sum(1 for v in [origin_city, dest_city, date_str] if not v) - if null_count >= 2: - catch_all = await self.capability_worker.run_io_loop( - "Where are you flying, and when?" - ) - extra = self._extract_flight_details_from_utterance(catch_all) - origin_city = origin_city or extra.get("origin") or "" - dest_city = dest_city or extra.get("destination") or "" - date_str = date_str or extra.get("date") or "" - trip_type = trip_type or extra.get("trip_type") or "" - return_date_str = return_date_str or extra.get("return_date") or "" - - # Individual fallback prompts for any still-missing fields if not origin_city: - origin_city = await self.capability_worker.run_io_loop( - "Where are you flying from?" - ) + origin_city = await self.capability_worker.run_io_loop("Where are you flying from?") + if self._is_exit_intent(origin_city): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + if not dest_city: - dest_city = await self.capability_worker.run_io_loop( - "Where are you flying to?" - ) + dest_city = await self.capability_worker.run_io_loop("Where are you flying to?") + if self._is_exit_intent(dest_city): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + if not date_str: date_raw = await self.capability_worker.run_io_loop( "What date are you travelling? For example, March 20th." ) + if self._is_exit_intent(date_raw): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return date_str = self._parse_date(date_raw) # Validate date is not in the past @@ -471,39 +505,42 @@ async def run_booking_flow(self): date_raw = await self.capability_worker.run_io_loop( f"{self._format_date_natural(date_str)} is in the past. What date did you mean?" ) + if self._is_exit_intent(date_raw): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return date_str = self._parse_date(date_raw) except Exception: pass if not trip_type: - trip_raw = await self.capability_worker.run_io_loop( - "Is that one-way or round trip?" - ) - answer = self._llm_classify( - f"The user said: '{trip_raw}'. Are they booking a one-way or round-trip flight? " - "Reply ONLY with ONE-WAY or ROUND-TRIP." - ) - trip_type = "round-trip" if "round" in answer else "one-way" + trip_raw = await self.capability_worker.run_io_loop("Is that one-way or round trip?") + if self._is_exit_intent(trip_raw): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + trip_type = "round-trip" if any( + w in trip_raw.lower() for w in ["round", "return", "both"] + ) else "one-way" if trip_type == "round-trip" and not return_date_str: - return_raw = await self.capability_worker.run_io_loop( - "When are you returning?" - ) + return_raw = await self.capability_worker.run_io_loop("When are you returning?") + if self._is_exit_intent(return_raw): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return return_date_str = self._parse_date(return_raw) if cabin not in ("economy", "business", "first", "premium_economy"): - cabin_raw = await self.capability_worker.run_io_loop( - "Economy, business, or first class?" - ) + cabin_raw = await self.capability_worker.run_io_loop("Economy, business, or first class?") + if self._is_exit_intent(cabin_raw): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return cabin_lower = cabin_raw.lower() - if "business" in cabin_lower: - cabin = "business" - elif "first" in cabin_lower: - cabin = "first" - elif "premium" in cabin_lower: - cabin = "premium_economy" - else: - cabin = "economy" + cabin = ("business" if "business" in cabin_lower else + "first" if "first" in cabin_lower else + "premium_economy" if "premium" in cabin_lower else "economy") # Step 4: Resolve airport codes (with one retry each) origin_iata = self._resolve_airport(origin_city) @@ -540,8 +577,10 @@ async def run_booking_flow(self): f"return={return_date_str}, cabin={cabin}" ) - # Steps 5–6: Search + present options (retryable loop) + # Steps 5–8: Search + select + confirm (retryable loop) selected_offer = None + passenger_details = None + while True: await self.capability_worker.speak("Let me search for flights, one moment.") try: @@ -563,20 +602,19 @@ async def run_booking_flow(self): "No flights found for that route and date. " "Would you like to change the date or destination?" ) - answer = self._llm_classify( - f"The user said: '{retry}'. Did they agree to change their search? " - "Reply ONLY with YES or NO." - ) - if answer.startswith("yes"): - date_str, dest_city, dest_iata, cabin = await self._ask_and_apply_change( - date_str, dest_city, dest_iata, cabin - ) - continue - await self.capability_worker.speak( - "No problem. Let me know if you need anything else." - ) - self.capability_worker.resume_normal_flow() - return + if self._is_exit_intent(retry) or not any( + w in retry.lower() for w in ["yes", "sure", "ok", "try", "change", "different"] + ): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + result = await self._ask_and_apply_change(date_str, dest_city, dest_iata, cabin) + if result is None: + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + date_str, dest_city, dest_iata, cabin = result + continue # ── Single offer: skip selection, ask yes/no directly ────────── if len(offers) == 1: @@ -585,146 +623,184 @@ async def run_booking_flow(self): f"I found one flight: {offer_summary} " "Would you like to book it? Or say no to change something." ) - answer = self._llm_classify( + if self._is_exit_intent(confirm_search): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + yes_prompt = ( f"The user said: '{confirm_search}'. " - "Do they want to book this flight? Reply ONLY with YES or NO." + "Are they agreeing to book this flight? Reply ONLY with YES or NO." ) - if answer.startswith("yes"): + if self.capability_worker.text_to_text_response(yes_prompt).strip().upper().startswith("Y"): selected_offer = offers[0] - break - date_str, dest_city, dest_iata, cabin = await self._ask_and_apply_change( - date_str, dest_city, dest_iata, cabin + else: + result = await self._ask_and_apply_change(date_str, dest_city, dest_iata, cabin) + if result is None: + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + date_str, dest_city, dest_iata, cabin = result + continue + + else: + # ── Multiple offers: present numbered list ────────────────── + options_text = " ".join(self._format_offer(o, i + 1) for i, o in enumerate(offers)) + option_range = "1 or 2" if len(offers) == 2 else "1, 2, or 3" + choice_raw = await self.capability_worker.run_io_loop( + f"{options_text} Which option — {option_range}? Or say none to change something." ) - continue + if self._is_exit_intent(choice_raw): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return - # ── Multiple offers: progressive reveal ───────────────────────── - await self.capability_worker.speak(self._format_offer(offers[0], 1)) - await self.capability_worker.speak(self._format_offer(offers[1], 2)) + none_prompt = ( + f"The user said: '{choice_raw}'. " + "Are they saying none / neither / they don't want any of these options? " + "Reply ONLY with YES or NO." + ) + if self.capability_worker.text_to_text_response(none_prompt).strip().upper().startswith("Y"): + result = await self._ask_and_apply_change(date_str, dest_city, dest_iata, cabin) + if result is None: + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + date_str, dest_city, dest_iata, cabin = result + continue - if len(offers) == 3: - want_more = await self.capability_worker.run_io_loop( - "Want to hear the third option, or go with one of those?" + num_prompt = ( + f"The user was given {len(offers)} flight option(s) and said: '{choice_raw}'. " + "Return ONLY the number 1, 2, or 3. Nothing else." ) - want_ans = self._llm_classify( - f"The user said: '{want_more}'. " - "Do they want to hear the third option? Reply ONLY with YES or NO." + num_str = self.capability_worker.text_to_text_response(num_prompt).strip() + self.worker.editor_logging_handler.info( + f"[FlightBooking] User chose: '{choice_raw}' → '{num_str}'" ) - if want_ans.startswith("yes"): - await self.capability_worker.speak(self._format_offer(offers[2], 3)) - else: - offers = offers[:2] + try: + idx = int(num_str) - 1 + if not 0 <= idx < len(offers): + raise ValueError("out of range") + except Exception: + await self.capability_worker.speak("I didn't catch that. Please say 1, 2, or 3.") + self.capability_worker.resume_normal_flow() + return + selected_offer = offers[idx] - option_range = "1 or 2" if len(offers) == 2 else "1, 2, or 3" - choice_raw = await self.capability_worker.run_io_loop( - f"Which option — {option_range}? Or say none to change something." - ) + # ── Step 7: Collect passenger details ────────────────────────── + await self.capability_worker.speak("Great choice. I'll need a few details for the booking.") + offer_passengers = selected_offer.get("passengers", []) + passenger_id = offer_passengers[0].get("id", "") if offer_passengers else "" + passenger_details = await self._collect_passenger_details(passenger_id) - change_answer = self._llm_classify( - f"The user said: '{choice_raw}'. " - "Do they want to change something instead of choosing a flight option? " - "Reply ONLY with YES or NO." - ) - if change_answer.startswith("yes"): - date_str, dest_city, dest_iata, cabin = await self._ask_and_apply_change( - date_str, dest_city, dest_iata, cabin - ) - continue + if passenger_details is None: + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return - # Extract choice number via LLM - num_prompt = ( - f"The user was given {len(offers)} flight option(s) and said: '{choice_raw}'. " - "Return ONLY the number 1, 2, or 3. Nothing else." + # ── Step 8: Confirmation with change routing ──────────────────── + slice0 = selected_offer.get("slices", [{}])[0] + segments = slice0.get("segments", []) + carrier = (segments[0].get("operating_carrier", {}).get("name", "the airline") + if segments else "the airline") + price = selected_offer.get("total_amount", "?") + currency = selected_offer.get("total_currency", "USD") + pax_name = f"{passenger_details['given_name']} {passenger_details['family_name']}" + offer_id = selected_offer.get("id", "") + + confirm_raw = await self.capability_worker.run_io_loop( + f"Booking {carrier} from {origin_city} to {dest_city} " + f"on {self._format_date_natural(date_str)} for {pax_name}. " + f"Total {currency} {price}. " + "This holds the seat — payment must be completed before the airline's deadline. " + "Say confirm to book, cancel to stop, or tell me what you'd like to change." ) - num_str = self.capability_worker.text_to_text_response(num_prompt).strip() - self.worker.editor_logging_handler.info( - f"[FlightBooking] User chose: '{choice_raw}' → '{num_str}'" + + routing_prompt = ( + f"The user was asked to confirm a flight booking. They said: '{confirm_raw}'. " + "Reply with exactly one of these words:\n" + "CONFIRM - they want to go ahead\n" + "CANCEL - they want to stop\n" + "CHANGE_DATE - they want to change the travel date\n" + "CHANGE_DESTINATION - they want to change origin or destination\n" + "CHANGE_PASSENGER - they want to correct passenger details\n" + "CHANGE_CABIN - they want a different cabin class\n" + "Reply ONLY with one of these exact words." ) + intent = self.capability_worker.text_to_text_response(routing_prompt).strip().upper() + self.worker.editor_logging_handler.info(f"[FlightBooking] Confirmation intent: {intent}") - try: - idx = int(num_str) - 1 - if not 0 <= idx < len(offers): - raise ValueError("out of range") - except Exception: - await self.capability_worker.speak("I didn't catch that. Please say 1, 2, or 3.") + if intent == "CONFIRM": + break # proceed to booking + + if intent == "CANCEL": + await self.capability_worker.speak( + "Booking cancelled. Let me know if you'd like to search again." + ) self.capability_worker.resume_normal_flow() return - selected_offer = offers[idx] - break + if intent == "CHANGE_PASSENGER": + # Re-collect passenger details without re-searching + passenger_details = await self._collect_passenger_details(passenger_id) + if passenger_details is None: + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + continue # loop back to confirmation with updated details - offer_id = selected_offer.get("id", "") - offer_passengers = selected_offer.get("passengers", []) - passenger_id = offer_passengers[0].get("id", "") if offer_passengers else "" + # CHANGE_DATE / CHANGE_DESTINATION / CHANGE_CABIN → re-search + if intent == "CHANGE_DATE": + date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") + if self._is_exit_intent(date_raw): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + date_str = self._parse_date(date_raw) + elif intent == "CHANGE_DESTINATION": + dest_city = await self.capability_worker.run_io_loop( + "Where would you like to fly instead?" + ) + if self._is_exit_intent(dest_city): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + dest_iata = self._resolve_airport(dest_city) + dest_city = self._normalize_city_name(dest_city, dest_iata) + elif intent == "CHANGE_CABIN": + cabin_raw = await self.capability_worker.run_io_loop("Economy, business, or first class?") + if self._is_exit_intent(cabin_raw): + await self.capability_worker.speak("No problem. Let me know if you need anything else.") + self.capability_worker.resume_normal_flow() + return + cabin_lower = cabin_raw.lower() + cabin = ("business" if "business" in cabin_lower else + "first" if "first" in cabin_lower else "economy") - # Step 7: Collect passenger details - await self.capability_worker.speak( - "Great choice. I just need a few details for the booking." - ) - passenger_details = await self._collect_passenger_details(passenger_id) - - # Step 8: Confirmation (~28 words, TTS-friendly) - slice0 = selected_offer.get("slices", [{}])[0] - segments = slice0.get("segments", []) - carrier = ( - segments[0].get("operating_carrier", {}).get("name", "the airline") - if segments else "the airline" - ) - price = selected_offer.get("total_amount", "?") - currency = selected_offer.get("total_currency", "USD") - pax_name = f"{passenger_details['given_name']} {passenger_details['family_name']}" - - confirmed = await self.capability_worker.run_confirmation_loop( - f"Confirm: {carrier}, {origin_city} to {dest_city}, " - f"{self._format_date_natural(date_str)}, {currency} {price} for {pax_name}. " - f"This is a hold — you'll pay later. Shall I book it?" - ) + selected_offer = None + passenger_details = None + continue # re-search with updated params - if not confirmed: + # Step 9: Book + await self.capability_worker.speak("Placing your hold booking now.") + try: + order = self._book_flight(offer_id, [passenger_details]) + except Exception as e: + self.worker.editor_logging_handler.error(f"[FlightBooking] Booking error: {e}") await self.capability_worker.speak( - "Booking cancelled. Let me know if you want to search again." + "Sorry, the booking didn't go through. The flight may no longer be available. " + "Please try again." ) self.capability_worker.resume_normal_flow() return - # Step 9: Book (with one retry on failure) - await self.capability_worker.speak("Placing your hold booking now.") - order = None - for attempt in range(2): - try: - order = self._book_flight(offer_id, [passenger_details]) - break - except Exception as e: - self.worker.editor_logging_handler.error( - f"[FlightBooking] Booking error (attempt {attempt + 1}): {e}" - ) - if attempt == 0: - retry_ans = await self.capability_worker.run_io_loop( - "Sorry, I couldn't complete that booking. Want me to try again?" - ) - retry_classify = self._llm_classify( - f"The user said: '{retry_ans}'. Do they want to retry? " - "Reply ONLY with YES or NO." - ) - if not retry_classify.startswith("yes"): - self.capability_worker.resume_normal_flow() - return - else: - await self.capability_worker.speak( - "Sorry, the booking still didn't go through. Please try again later." - ) - self.capability_worker.resume_normal_flow() - return - # Step 10: Save and confirm booking_ref = order.get("booking_reference", "") await self._save_booking(order, origin_iata, dest_iata, date_str) - self.worker.editor_logging_handler.info( - f"[FlightBooking] ✓ Booking complete ref={booking_ref}" - ) + self.worker.editor_logging_handler.info(f"[FlightBooking] ✓ Booking complete ref={booking_ref}") pay_by_raw = ( - order.get("payment_required_by") - or order.get("payment_status", {}).get("payment_required_by", "") + order.get("payment_required_by") or + order.get("payment_status", {}).get("payment_required_by", "") ) if pay_by_raw: pay_by_date = self._format_date_natural(pay_by_raw[:10]) @@ -738,7 +814,6 @@ async def run_booking_flow(self): ) await self.capability_worker.speak( f"Done! Your booking reference is {ref_spoken}.{deadline_str} " - f"A payment link will be sent to your email address. " f"Please complete payment before the deadline to confirm your seat." ) self.capability_worker.resume_normal_flow() From 710d18910dc74037329db14779b161be0dd6b5b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 18 Mar 2026 09:18:28 +0000 Subject: [PATCH 5/6] style: auto-format Python files with autoflake + autopep8 --- community/flight-booking/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/community/flight-booking/main.py b/community/flight-booking/main.py index 5f812391..f506ed37 100644 --- a/community/flight-booking/main.py +++ b/community/flight-booking/main.py @@ -22,7 +22,7 @@ class FlightBookingCapability(MatchingCapability): api_key: str = None # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} DUFFEL_BASE: ClassVar[str] = "https://api.duffel.com" DUFFEL_API_KEY: ClassVar[str] = "duffel_test_YOUR_KEY_HERE" @@ -799,8 +799,8 @@ async def run_booking_flow(self): self.worker.editor_logging_handler.info(f"[FlightBooking] ✓ Booking complete ref={booking_ref}") pay_by_raw = ( - order.get("payment_required_by") or - order.get("payment_status", {}).get("payment_required_by", "") + order.get("payment_required_by") + or order.get("payment_status", {}).get("payment_required_by", "") ) if pay_by_raw: pay_by_date = self._format_date_natural(pay_by_raw[:10]) From e8c3a82d154858c3480953434f41c43f4efb1097 Mon Sep 17 00:00:00 2001 From: hassan1731996 Date: Wed, 18 Mar 2026 16:06:41 +0500 Subject: [PATCH 6/6] Polish: phone validation, origin change, round-trip dates, exit keywords, retry, README --- community/flight-booking/README.md | 49 ++++-- community/flight-booking/main.py | 264 +++++++++++++++++++---------- 2 files changed, 206 insertions(+), 107 deletions(-) diff --git a/community/flight-booking/README.md b/community/flight-booking/README.md index da8ce89d..dd611e23 100644 --- a/community/flight-booking/README.md +++ b/community/flight-booking/README.md @@ -9,9 +9,11 @@ Search and book flights entirely by voice using the Duffel API. Supports one-way - **Natural language search** — "Book me a flight from London to Dubai next Friday" - **One-way & round-trip** — detects trip type automatically from your request -- **Top 3 results** — sorted by price, read back in a voice-friendly format -- **Full booking flow** — collects passenger name, date of birth, email, and phone -- **Hold booking** — creates a confirmed reservation without charging you. Pay later via email link +- **Top 3 results** — sorted by price, read back with 12-hour times +- **Full booking flow** — name + DOB in one prompt, email + phone in one prompt (grouped for speed) +- **Hold booking** — creates a confirmed reservation without charging you. Pay later via the airline +- **Cancel at any point** — say "never mind" or "cancel" at any step to exit cleanly +- **Change at confirmation** — say "change the date" or "wrong name" after the summary to correct before booking - **Booking history** — saves all booking references to `flight_booking_history.json` - **City name resolution** — say "London" or "New York", not just airport codes @@ -22,6 +24,8 @@ Search and book flights entirely by voice using the Duffel API. Supports one-way 3. Open `main.py` and replace `duffel_test_YOUR_KEY_HERE` with your token 4. Upload to the Live Editor +> **Important:** Never commit your real API key to a public repo. The placeholder is intentional. + ## Trigger Words `book a flight`, `search for flights`, `find me a flight`, `flight to`, `book flights`, `i need a flight`, `fly to`, `check flights` @@ -29,26 +33,35 @@ Search and book flights entirely by voice using the Duffel API. Supports one-way ## Example Conversation > **User:** "Book me a flight from London to Dubai next Friday" -> **AI:** "Let me search for flights, one moment." -> **AI:** "Option 1: Emirates. Departs 2026-03-20 at 08:00, arrives at 18:00. Non-stop. USD 420. -> Option 2: British Airways via Heathrow. Departs at 10:00, arrives at 22:00. 1 stop. USD 380. -> Option 3: Flydubai. Departs at 14:00, arrives at 23:30. Non-stop. USD 310. -> Which option — 1, 2, or 3? Or say none to search again." +> **AI:** "Searching one-way economy flights from London to Dubai on March 20th. One moment." +> **AI:** "I found three options. Option 1: Emirates. Departs at 8:00 AM, arrives at 6:00 PM. Non-stop. USD 420. Option 2: British Airways. Departs at 10:00 AM, arrives at 10:00 PM. 1 stop. USD 380. Option 3: Flydubai. Departs at 2:00 PM, arrives at 11:30 PM. Non-stop. USD 310. Which option — 1, 2, or 3? Or say none to change something." > **User:** "Option 3" -> **AI:** "What's the passenger's first name?" -> **User:** "John" -> **AI:** "And the last name?" -> ... -> **AI:** "Booking Flydubai from LHR to DXB on 2026-03-20 for John Smith. Total USD 310. This creates a hold — you'll pay at checkout. Confirm?" -> **User:** "Yes" -> **AI:** "Done! Your booking reference is ORD-123456. Check your email for payment details." +> **AI:** "Great choice. I'll need a few details for the booking. What's the passenger's full name and date of birth?" +> **User:** "John Smith, born March 5th 1990" +> **AI:** "What's your title? Mister, Missus, Miss, Ms, or Doctor?" +> **User:** "Mister" +> **AI:** "And what's the email address and phone number, including country code?" +> **User:** "john@example.com, plus 44 7700 900 123" +> **AI:** "Booking Flydubai from London to Dubai on March 20th for John Smith. Total USD 310. This holds the seat — payment must be completed before the airline's deadline. Say confirm to book, cancel to stop, or tell me what you'd like to change." +> **User:** "Confirm" +> **AI:** "Done! Your booking reference is O, R, D, 1, 2, 3. You must complete payment by April 1st to confirm your seat." ## Storage -- `flight_booking_history.json` — persistent log of all booking references and details +- `flight_booking_history.json` — persistent log of all booking references and route details + +## Known Limitations + +| Limitation | Detail | +|---|---| +| Sandbox flights | Test API keys only return fictional "Duffel Airways" flights. Switch to a live key for real airlines. | +| Hold bookings only | This ability creates a hold (pay-later) reservation. Immediate payment is not supported by design. | +| Hold availability | Not all airlines support hold bookings. The ability filters to holdable offers only; if none are available on a route, all offers are shown (but booking may require payment outside this ability). | +| 1 adult passenger | Multi-passenger booking is a planned v2 feature. | +| Regional coverage | Best coverage for major international routes. Some domestic or low-cost carriers in certain regions may not appear. See [duffel.com/airlines](https://duffel.com/airlines) for the full list. | ## Notes - Uses Duffel's **hold** booking type — no payment is processed by this ability -- Flights via Duffel's test environment use "Duffel Airways" (IATA: ZZ) for sandbox testing -- Switch to a live API token for real bookings (handle with care — hold bookings are real reservations) +- Switch to a live API token for real bookings (hold bookings are real reservations with the airline) +- The ability retries automatically on Duffel 429/5xx errors before surfacing a failure to the user diff --git a/community/flight-booking/main.py b/community/flight-booking/main.py index f506ed37..b204e4a7 100644 --- a/community/flight-booking/main.py +++ b/community/flight-booking/main.py @@ -1,5 +1,6 @@ import json import re +import time import requests from datetime import datetime from typing import ClassVar @@ -28,6 +29,13 @@ class FlightBookingCapability(MatchingCapability): DUFFEL_API_KEY: ClassVar[str] = "duffel_test_YOUR_KEY_HERE" HISTORY_FILE: ClassVar[str] = "flight_booking_history.json" + # Quick-exit keywords — checked before calling LLM to save ~1s per turn + EXIT_KEYWORDS: ClassVar[set] = { + "stop", "cancel", "quit", "exit", "nevermind", "never mind", + "forget it", "forget that", "no thanks", "nope", "nah", "abort", + "end", "leave", "bye", "that's all", "thats all", + } + IATA_MAP: ClassVar[dict] = { "new york": "JFK", "london": "LHR", "dubai": "DXB", "paris": "CDG", "los angeles": "LAX", "chicago": "ORD", @@ -64,7 +72,15 @@ class FlightBookingCapability(MatchingCapability): # ------------------------------------------------------------------------- def _is_exit_intent(self, response: str) -> bool: - """Return True if the user wants to cancel or exit the current task.""" + """Return True if the user wants to cancel or exit. + Checks obvious keywords first to avoid an unnecessary LLM call.""" + lower = response.lower().strip() + # Fast path — exact or contained keyword match + if lower in self.EXIT_KEYWORDS: + return True + if any(kw in lower for kw in self.EXIT_KEYWORDS): + return True + # LLM for ambiguous phrasing ("I think I'll leave it", "not now", etc.) prompt = ( f"The user said: '{response}'. " "Are they trying to cancel, stop, or exit the current task? " @@ -87,7 +103,7 @@ def _normalize_city_name(self, raw: str, iata: str) -> str: return self.capability_worker.text_to_text_response(prompt).strip().title() def _parse_phone(self, user_input: str) -> str: - """Convert spoken phone number (words or digits) to E.164 format via LLM.""" + """Convert spoken or typed phone number to E.164 format via LLM.""" prompt = ( f"The user said: '{user_input}'. " "This is a phone number including a country code spoken with a US/international accent. " @@ -101,6 +117,10 @@ def _parse_phone(self, user_input: str) -> str: self.worker.editor_logging_handler.info(f"[FlightBooking] Phone parsed: '{user_input}' → '{clean}'") return clean + def _validate_phone(self, phone: str) -> bool: + """Return True if phone looks like a valid E.164 number (+digits, 7–15 digits).""" + return bool(re.match(r'^\+\d{7,15}$', phone)) + def _format_date_natural(self, date_str: str) -> str: """Convert '2026-04-25' → 'April 25th' for voice readback.""" try: @@ -158,14 +178,15 @@ def _parse_date(self, user_input: str) -> str: return match.group() if match else result def _extract_flight_details_from_utterance(self, utterance: str) -> dict: - """Try to extract origin, destination, and date from the trigger sentence.""" + """Try to extract origin, destination, dates, trip type, cabin from the trigger sentence.""" today = datetime.now().strftime("%Y-%m-%d") prompt = ( f"Today is {today}. The user said: '{utterance}'.\n" "Extract flight details. Return ONLY valid JSON with these fields (use null if not mentioned):\n" '{"origin": "city or airport", "destination": "city or airport", ' '"date": "YYYY-MM-DD or null", "return_date": "YYYY-MM-DD or null", ' - '"trip_type": "one-way or round-trip or null", "cabin": "economy or business or first or null"}' + '"trip_type": "one-way or round-trip or null", ' + '"cabin": "economy or business or first or premium_economy or null"}' ) raw = self.capability_worker.text_to_text_response(prompt).strip() if raw.startswith("```"): @@ -196,6 +217,17 @@ def _extract_passenger_fields(self, utterance: str, fields: list) -> dict: except Exception: return {f: None for f in fields} + def _duffel_request_with_retry(self, method: str, url: str, **kwargs) -> requests.Response: + """Make a Duffel API request with one retry on 429/5xx errors.""" + resp = requests.request(method, url, headers=self._duffel_headers(), **kwargs) + if resp.status_code in (429, 500, 502, 503, 504): + self.worker.editor_logging_handler.info( + f"[FlightBooking] Duffel {resp.status_code} — retrying in 2s" + ) + time.sleep(2) + resp = requests.request(method, url, headers=self._duffel_headers(), **kwargs) + return resp + def _search_flights(self, origin: str, dest: str, date: str, return_date: str, cabin: str) -> list: """Create Duffel offer request, return top 3 holdable offers sorted by price.""" @@ -210,11 +242,9 @@ def _search_flights(self, origin: str, dest: str, date: str, "return_offers": True, }} self.worker.editor_logging_handler.info(f"[FlightBooking] Search: {origin}→{dest} on {date}") - resp = requests.post( - f"{self.DUFFEL_BASE}/air/offer_requests", - headers=self._duffel_headers(), - json=payload, - timeout=30, + resp = self._duffel_request_with_retry( + "POST", f"{self.DUFFEL_BASE}/air/offer_requests", + json=payload, timeout=30, ) resp.raise_for_status() offers = resp.json().get("data", {}).get("offers", []) @@ -262,11 +292,9 @@ def _book_flight(self, offer_id: str, passengers: list) -> dict: "passengers": passengers, }} self.worker.editor_logging_handler.info(f"[FlightBooking] Booking offer {offer_id}") - resp = requests.post( - f"{self.DUFFEL_BASE}/air/orders", - headers=self._duffel_headers(), - json=payload, - timeout=30, + resp = self._duffel_request_with_retry( + "POST", f"{self.DUFFEL_BASE}/air/orders", + json=payload, timeout=30, ) if not resp.ok: self.worker.editor_logging_handler.error( @@ -299,36 +327,54 @@ async def _save_booking(self, order: dict, origin: str, dest: str, date: str): self.worker.editor_logging_handler.info(f"[FlightBooking] Saved booking {entry['booking_ref']}") # ------------------------------------------------------------------------- - # Change routing helper (deduplicates the 3 copy-pasted change blocks) + # Change routing helper # ------------------------------------------------------------------------- - async def _ask_and_apply_change(self, date_str, dest_city, dest_iata, cabin): - """Ask what the user wants to change and return updated (date_str, dest_city, dest_iata, cabin). + async def _ask_and_apply_change(self, date_str, origin_city, origin_iata, + dest_city, dest_iata, cabin, trip_type, return_date_str): + """Ask what the user wants to change and return an updated state dict. Returns None if the user wants to exit.""" what = await self.capability_worker.run_io_loop( - "What would you like to change — the date, destination, or cabin class?" + "What would you like to change — the date, origin, destination, or cabin class?" ) if self._is_exit_intent(what): return None change_prompt = ( f"The user said: '{what}'. They want to change something about their flight search. " - "Reply with exactly one of: DATE, DESTINATION, CABIN, OTHER" + "Reply with exactly one of: DATE, ORIGIN, DESTINATION, CABIN, OTHER" ) change_intent = self.capability_worker.text_to_text_response(change_prompt).strip().upper() self.worker.editor_logging_handler.info(f"[FlightBooking] Change intent: '{what}' → {change_intent}") if change_intent == "DATE": - date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") + date_raw = await self.capability_worker.run_io_loop("What departure date would you prefer?") if self._is_exit_intent(date_raw): return None date_str = self._parse_date(date_raw) + # For round-trips, also update the return date + if trip_type == "round-trip": + ret_raw = await self.capability_worker.run_io_loop( + "And the return date?" + ) + if self._is_exit_intent(ret_raw): + return None + return_date_str = self._parse_date(ret_raw) + + elif change_intent == "ORIGIN": + new_origin = await self.capability_worker.run_io_loop("Where would you like to fly from?") + if self._is_exit_intent(new_origin): + return None + origin_iata = self._resolve_airport(new_origin) + origin_city = self._normalize_city_name(new_origin, origin_iata) + elif change_intent == "DESTINATION": - new_dest = await self.capability_worker.run_io_loop("Where would you like to fly?") + new_dest = await self.capability_worker.run_io_loop("Where would you like to fly to?") if self._is_exit_intent(new_dest): return None dest_iata = self._resolve_airport(new_dest) dest_city = self._normalize_city_name(new_dest, dest_iata) + elif change_intent == "CABIN": cabin_raw = await self.capability_worker.run_io_loop("Economy, business, or first class?") if self._is_exit_intent(cabin_raw): @@ -338,7 +384,15 @@ async def _ask_and_apply_change(self, date_str, dest_city, dest_iata, cabin): "first" if "first" in cabin_lower else "premium_economy" if "premium" in cabin_lower else "economy") - return date_str, dest_city, dest_iata, cabin + return { + "date_str": date_str, + "origin_city": origin_city, + "origin_iata": origin_iata, + "dest_city": dest_city, + "dest_iata": dest_iata, + "cabin": cabin, + "return_date_str": return_date_str, + } # ------------------------------------------------------------------------- # Passenger Details Collection @@ -408,7 +462,9 @@ async def _collect_passenger_details(self, passenger_id: str): g2 = self._extract_passenger_fields(group2_raw, ["email", "phone_number"]) email = g2.get("email") or "" - phone_clean = g2.get("phone_number") or "" + # Always run extracted phone through _parse_phone to guarantee E.164 + raw_phone = g2.get("phone_number") or "" + phone_clean = self._parse_phone(raw_phone) if raw_phone else "" # Validate email — re-ask once if invalid or missing email_clean = re.sub(r"[^\x00-\x7F]", "", email).strip().lower() @@ -421,8 +477,8 @@ async def _collect_passenger_details(self, passenger_id: str): email_clean = re.sub(r"[^\x00-\x7F]", "", email_raw).strip().lower() email = email_clean - # Validate phone — re-ask once if blank after LLM extraction - if not phone_clean: + # Validate phone — re-ask once if missing or malformed + if not self._validate_phone(phone_clean): phone_raw = await self.capability_worker.run_io_loop( "And the phone number with country code? For example, plus 44 7700 900 123." ) @@ -448,6 +504,15 @@ async def _collect_passenger_details(self, passenger_id: str): ) return details + # ------------------------------------------------------------------------- + # Shared exit helper + # ------------------------------------------------------------------------- + + async def _exit(self, msg: str = "No problem. Let me know if you need anything else."): + """Speak exit message and resume normal flow.""" + await self.capability_worker.speak(msg) + self.capability_worker.resume_normal_flow() + # ------------------------------------------------------------------------- # Main Flow # ------------------------------------------------------------------------- @@ -478,15 +543,13 @@ async def run_booking_flow(self): if not origin_city: origin_city = await self.capability_worker.run_io_loop("Where are you flying from?") if self._is_exit_intent(origin_city): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return if not dest_city: dest_city = await self.capability_worker.run_io_loop("Where are you flying to?") if self._is_exit_intent(dest_city): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return if not date_str: @@ -494,8 +557,7 @@ async def run_booking_flow(self): "What date are you travelling? For example, March 20th." ) if self._is_exit_intent(date_raw): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return date_str = self._parse_date(date_raw) @@ -506,8 +568,7 @@ async def run_booking_flow(self): f"{self._format_date_natural(date_str)} is in the past. What date did you mean?" ) if self._is_exit_intent(date_raw): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return date_str = self._parse_date(date_raw) except Exception: @@ -516,8 +577,7 @@ async def run_booking_flow(self): if not trip_type: trip_raw = await self.capability_worker.run_io_loop("Is that one-way or round trip?") if self._is_exit_intent(trip_raw): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return trip_type = "round-trip" if any( w in trip_raw.lower() for w in ["round", "return", "both"] @@ -526,16 +586,14 @@ async def run_booking_flow(self): if trip_type == "round-trip" and not return_date_str: return_raw = await self.capability_worker.run_io_loop("When are you returning?") if self._is_exit_intent(return_raw): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return return_date_str = self._parse_date(return_raw) if cabin not in ("economy", "business", "first", "premium_economy"): cabin_raw = await self.capability_worker.run_io_loop("Economy, business, or first class?") if self._is_exit_intent(cabin_raw): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return cabin_lower = cabin_raw.lower() cabin = ("business" if "business" in cabin_lower else @@ -574,7 +632,7 @@ async def run_booking_flow(self): self.worker.editor_logging_handler.info( f"[FlightBooking] Route: {origin_iata}→{dest_iata}, date={date_str}, " - f"return={return_date_str}, cabin={cabin}" + f"return={return_date_str}, cabin={cabin}, trip={trip_type}" ) # Steps 5–8: Search + select + confirm (retryable loop) @@ -582,7 +640,12 @@ async def run_booking_flow(self): passenger_details = None while True: - await self.capability_worker.speak("Let me search for flights, one moment.") + # Confirm trip details aloud before searching + trip_label = "round-trip" if trip_type == "round-trip" else "one-way" + await self.capability_worker.speak( + f"Searching {trip_label} {cabin} flights from {origin_city} to {dest_city} " + f"on {self._format_date_natural(date_str)}. One moment." + ) try: offers = self._search_flights( origin_iata, dest_iata, date_str, @@ -605,15 +668,20 @@ async def run_booking_flow(self): if self._is_exit_intent(retry) or not any( w in retry.lower() for w in ["yes", "sure", "ok", "try", "change", "different"] ): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return - result = await self._ask_and_apply_change(date_str, dest_city, dest_iata, cabin) - if result is None: - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + state = await self._ask_and_apply_change( + date_str, origin_city, origin_iata, + dest_city, dest_iata, cabin, trip_type, return_date_str + ) + if state is None: + await self._exit() return - date_str, dest_city, dest_iata, cabin = result + date_str = state["date_str"] + origin_city, origin_iata = state["origin_city"], state["origin_iata"] + dest_city, dest_iata = state["dest_city"], state["dest_iata"] + cabin = state["cabin"] + return_date_str = state["return_date_str"] continue # ── Single offer: skip selection, ask yes/no directly ────────── @@ -624,8 +692,7 @@ async def run_booking_flow(self): "Would you like to book it? Or say no to change something." ) if self._is_exit_intent(confirm_search): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return yes_prompt = ( f"The user said: '{confirm_search}'. " @@ -634,24 +701,31 @@ async def run_booking_flow(self): if self.capability_worker.text_to_text_response(yes_prompt).strip().upper().startswith("Y"): selected_offer = offers[0] else: - result = await self._ask_and_apply_change(date_str, dest_city, dest_iata, cabin) - if result is None: - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + state = await self._ask_and_apply_change( + date_str, origin_city, origin_iata, + dest_city, dest_iata, cabin, trip_type, return_date_str + ) + if state is None: + await self._exit() return - date_str, dest_city, dest_iata, cabin = result + date_str = state["date_str"] + origin_city, origin_iata = state["origin_city"], state["origin_iata"] + dest_city, dest_iata = state["dest_city"], state["dest_iata"] + cabin = state["cabin"] + return_date_str = state["return_date_str"] continue else: - # ── Multiple offers: present numbered list ────────────────── + # ── Multiple offers: present with count intro ─────────────── + count_word = {2: "two", 3: "three"}.get(len(offers), str(len(offers))) + await self.capability_worker.speak(f"I found {count_word} options.") options_text = " ".join(self._format_offer(o, i + 1) for i, o in enumerate(offers)) option_range = "1 or 2" if len(offers) == 2 else "1, 2, or 3" choice_raw = await self.capability_worker.run_io_loop( f"{options_text} Which option — {option_range}? Or say none to change something." ) if self._is_exit_intent(choice_raw): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return none_prompt = ( @@ -660,12 +734,18 @@ async def run_booking_flow(self): "Reply ONLY with YES or NO." ) if self.capability_worker.text_to_text_response(none_prompt).strip().upper().startswith("Y"): - result = await self._ask_and_apply_change(date_str, dest_city, dest_iata, cabin) - if result is None: - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + state = await self._ask_and_apply_change( + date_str, origin_city, origin_iata, + dest_city, dest_iata, cabin, trip_type, return_date_str + ) + if state is None: + await self._exit() return - date_str, dest_city, dest_iata, cabin = result + date_str = state["date_str"] + origin_city, origin_iata = state["origin_city"], state["origin_iata"] + dest_city, dest_iata = state["dest_city"], state["dest_iata"] + cabin = state["cabin"] + return_date_str = state["return_date_str"] continue num_prompt = ( @@ -693,8 +773,7 @@ async def run_booking_flow(self): passenger_details = await self._collect_passenger_details(passenger_id) if passenger_details is None: - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return # ── Step 8: Confirmation with change routing ──────────────────── @@ -707,9 +786,14 @@ async def run_booking_flow(self): pax_name = f"{passenger_details['given_name']} {passenger_details['family_name']}" offer_id = selected_offer.get("id", "") + # Build confirmation — include return date for round-trips + route_str = ( + f"from {origin_city} to {dest_city} on {self._format_date_natural(date_str)}" + + (f", returning {self._format_date_natural(return_date_str)}" + if trip_type == "round-trip" and return_date_str else "") + ) confirm_raw = await self.capability_worker.run_io_loop( - f"Booking {carrier} from {origin_city} to {dest_city} " - f"on {self._format_date_natural(date_str)} for {pax_name}. " + f"Booking {carrier} {route_str} for {pax_name}. " f"Total {currency} {price}. " "This holds the seat — payment must be completed before the airline's deadline. " "Say confirm to book, cancel to stop, or tell me what you'd like to change." @@ -733,44 +817,42 @@ async def run_booking_flow(self): break # proceed to booking if intent == "CANCEL": - await self.capability_worker.speak( - "Booking cancelled. Let me know if you'd like to search again." - ) - self.capability_worker.resume_normal_flow() + await self._exit("Booking cancelled. Let me know if you'd like to search again.") return if intent == "CHANGE_PASSENGER": - # Re-collect passenger details without re-searching passenger_details = await self._collect_passenger_details(passenger_id) if passenger_details is None: - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return continue # loop back to confirmation with updated details # CHANGE_DATE / CHANGE_DESTINATION / CHANGE_CABIN → re-search if intent == "CHANGE_DATE": - date_raw = await self.capability_worker.run_io_loop("What date would you prefer?") + date_raw = await self.capability_worker.run_io_loop("What departure date would you prefer?") if self._is_exit_intent(date_raw): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return date_str = self._parse_date(date_raw) + if trip_type == "round-trip": + ret_raw = await self.capability_worker.run_io_loop("And the return date?") + if self._is_exit_intent(ret_raw): + await self._exit() + return + return_date_str = self._parse_date(ret_raw) elif intent == "CHANGE_DESTINATION": dest_city = await self.capability_worker.run_io_loop( "Where would you like to fly instead?" ) if self._is_exit_intent(dest_city): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return dest_iata = self._resolve_airport(dest_city) dest_city = self._normalize_city_name(dest_city, dest_iata) elif intent == "CHANGE_CABIN": cabin_raw = await self.capability_worker.run_io_loop("Economy, business, or first class?") if self._is_exit_intent(cabin_raw): - await self.capability_worker.speak("No problem. Let me know if you need anything else.") - self.capability_worker.resume_normal_flow() + await self._exit() return cabin_lower = cabin_raw.lower() cabin = ("business" if "business" in cabin_lower else @@ -802,20 +884,24 @@ async def run_booking_flow(self): order.get("payment_required_by") or order.get("payment_status", {}).get("payment_required_by", "") ) + ref_spoken = ", ".join(list(booking_ref)) if booking_ref else "unavailable" + if pay_by_raw: pay_by_date = self._format_date_natural(pay_by_raw[:10]) - deadline_str = f" You must complete payment by {pay_by_date}." + success_msg = ( + f"Done! Your booking reference is {ref_spoken}. " + f"You must complete payment by {pay_by_date} to confirm your seat." + ) else: - deadline_str = "" + success_msg = ( + f"Done! Your booking reference is {ref_spoken}. " + "Please complete payment with the airline to confirm your seat." + ) - ref_spoken = ", ".join(list(booking_ref)) if booking_ref else "unavailable" self.worker.editor_logging_handler.info( - f"[FlightBooking] pay_by_raw={pay_by_raw!r} deadline_str={deadline_str!r}" - ) - await self.capability_worker.speak( - f"Done! Your booking reference is {ref_spoken}.{deadline_str} " - f"Please complete payment before the deadline to confirm your seat." + f"[FlightBooking] pay_by_raw={pay_by_raw!r}" ) + await self.capability_worker.speak(success_msg) self.capability_worker.resume_normal_flow() except Exception as e: