Spaces:
Sleeping
Sleeping
Commit ·
e737a76
1
Parent(s): bf6a17a
Add SafarX AI Travel Companion backend with Docker support
Browse files- .dockerignore +14 -0
- .env.example +33 -0
- Dockerfile +18 -0
- README.md +22 -3
- agent.py +224 -0
- config.py +46 -0
- main.py +209 -0
- models/flight_models.py +98 -0
- models/hotel_models.py +37 -0
- prompts.py +75 -0
- requirements.txt +10 -0
- routers/flights.py +236 -0
- routers/hotel_routes.py +136 -0
- services/hotel_service.py +376 -0
- services/tbo_service.py +745 -0
- tavily_client.py +96 -0
- tools.py +158 -0
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
.Python
|
| 6 |
+
env/
|
| 7 |
+
venv/
|
| 8 |
+
.env
|
| 9 |
+
.venv
|
| 10 |
+
env.bak
|
| 11 |
+
venv.bak
|
| 12 |
+
.git/
|
| 13 |
+
.gitignore
|
| 14 |
+
.vscode/
|
.env.example
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy this file to .env and fill in your own values
|
| 2 |
+
# NEVER commit the actual .env file to version control
|
| 3 |
+
|
| 4 |
+
# ── Google Gemini ─────────────────────────────────────────────
|
| 5 |
+
GEMINI_API_KEY=your_gemini_api_key_here
|
| 6 |
+
|
| 7 |
+
# ── Tavily Search ─────────────────────────────────────────────
|
| 8 |
+
TAVILY_API_KEY=your_tavily_api_key_here
|
| 9 |
+
|
| 10 |
+
# ── TBO (TekTravels) API ──────────────────────────────────────
|
| 11 |
+
TBO_CLIENT_ID=ApiIntegrationNew
|
| 12 |
+
TBO_USERNAME=your_tbo_username
|
| 13 |
+
TBO_PASSWORD=your_tbo_password
|
| 14 |
+
TBO_END_USER_IP=
|
| 15 |
+
|
| 16 |
+
# TBO Endpoint URLs (leave as-is unless using a different environment)
|
| 17 |
+
TBO_AUTH_URL=http://Sharedapi.tektravels.com/SharedData.svc/rest/Authenticate
|
| 18 |
+
TBO_LOGOUT_URL=http://Sharedapi.tektravels.com/SharedData.svc/rest/Logout
|
| 19 |
+
TBO_SEARCH_URL=http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Search
|
| 20 |
+
TBO_BOOK_URL=http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Book
|
| 21 |
+
TBO_TICKET_URL=http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Ticket
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ── RapidAPI / TripAdvisor Hotel Credentials ─────────────
|
| 25 |
+
RAPIDAPI_KEY=your_rapidapi_key_here
|
| 26 |
+
RAPIDAPI_HOST=tripadvisor16.p.rapidapi.com
|
| 27 |
+
|
| 28 |
+
# ── Server ────────────────────────────────────────────────────
|
| 29 |
+
HOST=0.0.0.0
|
| 30 |
+
PORT=8000
|
| 31 |
+
|
| 32 |
+
# ── Frontend URL (for CORS) ───────────────────────────────────
|
| 33 |
+
FRONTEND_URL=http://localhost:5173
|
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use the official Python string image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install dependencies
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
# Copy the application code
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
# Expose port (Hugging Face Spaces uses 7860 by default for Docker spaces)
|
| 15 |
+
EXPOSE 7860
|
| 16 |
+
|
| 17 |
+
# Command to run the FastApi application
|
| 18 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,29 @@
|
|
| 1 |
---
|
| 2 |
title: SafarX
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: SafarX
|
| 3 |
+
emoji: ✈️
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# SafarX - AI Travel Companion Backend
|
| 13 |
+
|
| 14 |
+
FastAPI backend for the SafarX AI Travel Companion, powered by Google Gemini.
|
| 15 |
+
|
| 16 |
+
## Features
|
| 17 |
+
- AI-powered travel chat agent
|
| 18 |
+
- Web search via Tavily
|
| 19 |
+
- Flight booking (TBO/TekTravels API)
|
| 20 |
+
- Hotel search (TripAdvisor via RapidAPI)
|
| 21 |
+
- Itinerary generation
|
| 22 |
+
- Destination recommendations
|
| 23 |
+
|
| 24 |
+
## Environment Variables
|
| 25 |
+
Set the following secrets in your Hugging Face Space settings:
|
| 26 |
+
- `GEMINI_API_KEY`
|
| 27 |
+
- `TAVILY_API_KEY`
|
| 28 |
+
- `TBO_CLIENT_ID`, `TBO_USERNAME`, `TBO_PASSWORD`
|
| 29 |
+
- `RAPIDAPI_KEY`
|
agent.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini-powered Travel AI Agent with tool calling capabilities.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
from typing import Optional
|
| 8 |
+
import google.generativeai as genai
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
from prompts import SYSTEM_PROMPT, ITINERARY_PROMPT, RECOMMENDATION_PROMPT
|
| 12 |
+
from tools import (
|
| 13 |
+
search_web,
|
| 14 |
+
generate_itinerary,
|
| 15 |
+
recommend_destinations,
|
| 16 |
+
TOOL_DEFINITIONS
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
load_dotenv()
|
| 21 |
+
|
| 22 |
+
# Configure Gemini
|
| 23 |
+
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TravelAgent:
|
| 27 |
+
"""
|
| 28 |
+
Immersive Traveler Companion AI Agent powered by Gemini.
|
| 29 |
+
Handles travel queries with tool calling for VR, search, and recommendations.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self):
|
| 33 |
+
self.model = genai.GenerativeModel(
|
| 34 |
+
model_name="gemini-3-flash-preview",
|
| 35 |
+
system_instruction=SYSTEM_PROMPT,
|
| 36 |
+
tools=self._get_tools()
|
| 37 |
+
)
|
| 38 |
+
self.chat_sessions = {} # Store chat history per session
|
| 39 |
+
|
| 40 |
+
def _get_tools(self):
|
| 41 |
+
"""Define tools for Gemini function calling."""
|
| 42 |
+
return [
|
| 43 |
+
genai.protos.Tool(
|
| 44 |
+
function_declarations=[
|
| 45 |
+
genai.protos.FunctionDeclaration(
|
| 46 |
+
name="search_web",
|
| 47 |
+
description="Search the web for travel information, booking websites, flight prices, hotel deals. Use when users ask about booking or need real-time info.",
|
| 48 |
+
parameters=genai.protos.Schema(
|
| 49 |
+
type=genai.protos.Type.OBJECT,
|
| 50 |
+
properties={
|
| 51 |
+
"query": genai.protos.Schema(
|
| 52 |
+
type=genai.protos.Type.STRING,
|
| 53 |
+
description="Search query for travel information"
|
| 54 |
+
)
|
| 55 |
+
},
|
| 56 |
+
required=["query"]
|
| 57 |
+
)
|
| 58 |
+
),
|
| 59 |
+
|
| 60 |
+
genai.protos.FunctionDeclaration(
|
| 61 |
+
name="generate_itinerary",
|
| 62 |
+
description="Generate a detailed day-by-day travel itinerary. Use when users want to plan a trip.",
|
| 63 |
+
parameters=genai.protos.Schema(
|
| 64 |
+
type=genai.protos.Type.OBJECT,
|
| 65 |
+
properties={
|
| 66 |
+
"destination": genai.protos.Schema(
|
| 67 |
+
type=genai.protos.Type.STRING,
|
| 68 |
+
description="Travel destination"
|
| 69 |
+
),
|
| 70 |
+
"days": genai.protos.Schema(
|
| 71 |
+
type=genai.protos.Type.INTEGER,
|
| 72 |
+
description="Number of days"
|
| 73 |
+
),
|
| 74 |
+
"preferences": genai.protos.Schema(
|
| 75 |
+
type=genai.protos.Type.STRING,
|
| 76 |
+
description="Travel preferences"
|
| 77 |
+
)
|
| 78 |
+
},
|
| 79 |
+
required=["destination", "days"]
|
| 80 |
+
)
|
| 81 |
+
),
|
| 82 |
+
genai.protos.FunctionDeclaration(
|
| 83 |
+
name="recommend_destinations",
|
| 84 |
+
description="Recommend destinations based on preferences. Use when users describe their ideal trip.",
|
| 85 |
+
parameters=genai.protos.Schema(
|
| 86 |
+
type=genai.protos.Type.OBJECT,
|
| 87 |
+
properties={
|
| 88 |
+
"budget": genai.protos.Schema(
|
| 89 |
+
type=genai.protos.Type.STRING,
|
| 90 |
+
description="Budget: 'budget', 'moderate', 'luxury'"
|
| 91 |
+
),
|
| 92 |
+
"style": genai.protos.Schema(
|
| 93 |
+
type=genai.protos.Type.STRING,
|
| 94 |
+
description="Style: 'adventure', 'relaxation', 'cultural'"
|
| 95 |
+
),
|
| 96 |
+
"interests": genai.protos.Schema(
|
| 97 |
+
type=genai.protos.Type.STRING,
|
| 98 |
+
description="Interests like beaches, mountains, food"
|
| 99 |
+
)
|
| 100 |
+
},
|
| 101 |
+
required=[]
|
| 102 |
+
)
|
| 103 |
+
)
|
| 104 |
+
]
|
| 105 |
+
)
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
def get_or_create_session(self, session_id: str):
|
| 109 |
+
"""Get existing chat session or create new one."""
|
| 110 |
+
if session_id not in self.chat_sessions:
|
| 111 |
+
self.chat_sessions[session_id] = self.model.start_chat(history=[])
|
| 112 |
+
return self.chat_sessions[session_id]
|
| 113 |
+
|
| 114 |
+
async def _execute_tool(self, tool_name: str, tool_args: dict) -> dict:
|
| 115 |
+
"""Execute a tool and return results."""
|
| 116 |
+
if tool_name == "search_web":
|
| 117 |
+
return await search_web(tool_args.get("query", ""))
|
| 118 |
+
|
| 119 |
+
elif tool_name == "generate_itinerary":
|
| 120 |
+
return generate_itinerary(
|
| 121 |
+
tool_args.get("destination", ""),
|
| 122 |
+
tool_args.get("days", 3),
|
| 123 |
+
tool_args.get("preferences")
|
| 124 |
+
)
|
| 125 |
+
elif tool_name == "recommend_destinations":
|
| 126 |
+
return recommend_destinations(
|
| 127 |
+
budget=tool_args.get("budget"),
|
| 128 |
+
style=tool_args.get("style"),
|
| 129 |
+
interests=tool_args.get("interests", "").split(",") if tool_args.get("interests") else None
|
| 130 |
+
)
|
| 131 |
+
return {"error": f"Unknown tool: {tool_name}"}
|
| 132 |
+
|
| 133 |
+
async def chat(self, message: str, session_id: str = "default") -> dict:
|
| 134 |
+
"""
|
| 135 |
+
Process a chat message and return agent response.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
message: User message
|
| 139 |
+
session_id: Session ID for chat history
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Dictionary with response text, tool calls, and metadata
|
| 143 |
+
"""
|
| 144 |
+
chat = self.get_or_create_session(session_id)
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
# Send message to Gemini
|
| 148 |
+
response = chat.send_message(message)
|
| 149 |
+
|
| 150 |
+
result = {
|
| 151 |
+
"response": "",
|
| 152 |
+
"tool_calls": [],
|
| 153 |
+
"search_results": None,
|
| 154 |
+
"itinerary": None,
|
| 155 |
+
"recommendations": None
|
| 156 |
+
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
# Process response parts
|
| 160 |
+
for part in response.parts:
|
| 161 |
+
if hasattr(part, 'text') and part.text:
|
| 162 |
+
result["response"] += part.text
|
| 163 |
+
|
| 164 |
+
if hasattr(part, 'function_call') and part.function_call:
|
| 165 |
+
fc = part.function_call
|
| 166 |
+
tool_name = fc.name
|
| 167 |
+
tool_args = dict(fc.args)
|
| 168 |
+
|
| 169 |
+
# Execute tool
|
| 170 |
+
tool_result = await self._execute_tool(tool_name, tool_args)
|
| 171 |
+
|
| 172 |
+
result["tool_calls"].append({
|
| 173 |
+
"name": tool_name,
|
| 174 |
+
"args": tool_args,
|
| 175 |
+
"result": tool_result
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
# Categorize results
|
| 179 |
+
if tool_name == "search_web":
|
| 180 |
+
result["search_results"] = tool_result
|
| 181 |
+
elif tool_name == "generate_itinerary":
|
| 182 |
+
result["itinerary"] = tool_result
|
| 183 |
+
elif tool_name == "recommend_destinations":
|
| 184 |
+
result["recommendations"] = tool_result
|
| 185 |
+
|
| 186 |
+
# Send tool result back to model for final response
|
| 187 |
+
|
| 188 |
+
function_response = genai.protos.Part(
|
| 189 |
+
function_response=genai.protos.FunctionResponse(
|
| 190 |
+
name=tool_name,
|
| 191 |
+
response={"result": json.dumps(tool_result)}
|
| 192 |
+
)
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
follow_up = chat.send_message(function_response)
|
| 196 |
+
for fp in follow_up.parts:
|
| 197 |
+
if hasattr(fp, 'text') and fp.text:
|
| 198 |
+
result["response"] += "\n\n" + fp.text
|
| 199 |
+
|
| 200 |
+
return result
|
| 201 |
+
|
| 202 |
+
except Exception as e:
|
| 203 |
+
return {
|
| 204 |
+
"response": f"I apologize, but I encountered an error: {str(e)}. Please try again.",
|
| 205 |
+
"tool_calls": [],
|
| 206 |
+
"error": str(e)
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
def clear_session(self, session_id: str):
|
| 210 |
+
"""Clear chat history for a session."""
|
| 211 |
+
if session_id in self.chat_sessions:
|
| 212 |
+
del self.chat_sessions[session_id]
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# Global agent instance
|
| 216 |
+
_agent = None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def get_agent() -> TravelAgent:
|
| 220 |
+
"""Get or create agent singleton."""
|
| 221 |
+
global _agent
|
| 222 |
+
if _agent is None:
|
| 223 |
+
_agent = TravelAgent()
|
| 224 |
+
return _agent
|
config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/config.py
|
| 2 |
+
|
| 3 |
+
from pydantic_settings import BaseSettings
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
# ── TBO Credentials ──────────────────────────────────────────
|
| 9 |
+
tbo_client_id: str = "ApiIntegrationNew"
|
| 10 |
+
tbo_username: str = ""
|
| 11 |
+
tbo_password: str = ""
|
| 12 |
+
tbo_end_user_ip: str = ""
|
| 13 |
+
|
| 14 |
+
# ── TBO URLs ─────────────────────────────────────────────────
|
| 15 |
+
tbo_auth_url: str = "http://Sharedapi.tektravels.com/SharedData.svc/rest/Authenticate"
|
| 16 |
+
tbo_logout_url: str = "http://Sharedapi.tektravels.com/SharedData.svc/rest/Logout"
|
| 17 |
+
tbo_search_url: str = "http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Search"
|
| 18 |
+
tbo_book_url: str = "http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Book"
|
| 19 |
+
tbo_ticket_url: str = "http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Ticket"
|
| 20 |
+
|
| 21 |
+
# ── RapidAPI / TripAdvisor Hotel Credentials ─────────────────
|
| 22 |
+
rapidapi_key: str = ""
|
| 23 |
+
rapidapi_host: str = "tripadvisor16.p.rapidapi.com"
|
| 24 |
+
|
| 25 |
+
# ── Gemini AI ─────────────────────────────────────────────────
|
| 26 |
+
gemini_api_key: str = ""
|
| 27 |
+
|
| 28 |
+
# ── Tavily Search ─────────────────────────────────────────────
|
| 29 |
+
tavily_api_key: str = ""
|
| 30 |
+
|
| 31 |
+
# ── Server ────────────────────────────────────────────────────
|
| 32 |
+
host: str = "0.0.0.0"
|
| 33 |
+
port: int = 8000
|
| 34 |
+
|
| 35 |
+
# ── App ───────────────────────────────────────────────────────
|
| 36 |
+
frontend_url: str = "http://localhost:5173"
|
| 37 |
+
|
| 38 |
+
model_config = {
|
| 39 |
+
"env_file": ".env",
|
| 40 |
+
"extra": "ignore", # ← silently ignore any unknown .env keys
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@lru_cache()
|
| 45 |
+
def get_settings() -> Settings:
|
| 46 |
+
return Settings()
|
main.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI Backend for Immersive Traveler Companion AI
|
| 3 |
+
Main application with all API endpoints.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from typing import Optional, List
|
| 8 |
+
from fastapi import FastAPI, HTTPException
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
|
| 13 |
+
from agent import get_agent
|
| 14 |
+
from tavily_client import get_tavily_client
|
| 15 |
+
from tools import recommend_destinations, generate_itinerary
|
| 16 |
+
|
| 17 |
+
# Add this to your existing main FastAPI file
|
| 18 |
+
from routers.hotel_routes import router as hotel_router
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ── NEW: Import flight router ─────────────────────────────────
|
| 22 |
+
from routers.flights import router as flights_router
|
| 23 |
+
# ─────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
load_dotenv()
|
| 26 |
+
|
| 27 |
+
# Initialize FastAPI app
|
| 28 |
+
app = FastAPI(
|
| 29 |
+
title="Immersive Traveler Companion AI",
|
| 30 |
+
description="AI-powered travel assistant with VR previews, web search, and personalized recommendations",
|
| 31 |
+
version="1.0.0"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# CORS middleware for frontend
|
| 35 |
+
app.add_middleware(
|
| 36 |
+
CORSMiddleware,
|
| 37 |
+
allow_origins=["*"], # In production, specify your frontend URL
|
| 38 |
+
allow_credentials=True,
|
| 39 |
+
allow_methods=["*"],
|
| 40 |
+
allow_headers=["*"],
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# ── NEW: Mount flight booking routes ──────────────────────────
|
| 44 |
+
# All flight routes will be prefixed with /flights
|
| 45 |
+
# e.g. POST /flights/search, POST /flights/book
|
| 46 |
+
app.include_router(flights_router)
|
| 47 |
+
# ─────────────────────────────────────────────────────────────
|
| 48 |
+
app.include_router(hotel_router)
|
| 49 |
+
|
| 50 |
+
# ============== Pydantic Models ==============
|
| 51 |
+
# (all your existing models stay completely unchanged)
|
| 52 |
+
|
| 53 |
+
class ChatRequest(BaseModel):
|
| 54 |
+
message: str
|
| 55 |
+
session_id: Optional[str] = "default"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ChatResponse(BaseModel):
|
| 59 |
+
response: str
|
| 60 |
+
tool_calls: list = []
|
| 61 |
+
search_results: Optional[dict] = None
|
| 62 |
+
itinerary: Optional[dict] = None
|
| 63 |
+
recommendations: Optional[dict] = None
|
| 64 |
+
error: Optional[str] = None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class SearchRequest(BaseModel):
|
| 68 |
+
query: str
|
| 69 |
+
max_results: Optional[int] = 5
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class SearchResponse(BaseModel):
|
| 73 |
+
query: str
|
| 74 |
+
results: list
|
| 75 |
+
answer: Optional[str] = None
|
| 76 |
+
error: Optional[str] = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ItineraryRequest(BaseModel):
|
| 80 |
+
destination: str
|
| 81 |
+
days: int
|
| 82 |
+
preferences: Optional[str] = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ItineraryResponse(BaseModel):
|
| 86 |
+
destination: str
|
| 87 |
+
days: int
|
| 88 |
+
itinerary: str
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class RecommendRequest(BaseModel):
|
| 92 |
+
budget: Optional[str] = None
|
| 93 |
+
style: Optional[str] = None
|
| 94 |
+
interests: Optional[List[str]] = None
|
| 95 |
+
duration: Optional[str] = None
|
| 96 |
+
season: Optional[str] = None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class RecommendResponse(BaseModel):
|
| 100 |
+
recommendations: str
|
| 101 |
+
preferences: dict
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ============== API Endpoints ==============
|
| 105 |
+
# (all your existing endpoints stay completely unchanged)
|
| 106 |
+
|
| 107 |
+
@app.get("/")
|
| 108 |
+
async def root():
|
| 109 |
+
"""Health check endpoint."""
|
| 110 |
+
return {
|
| 111 |
+
"status": "online",
|
| 112 |
+
"service": "Immersive Traveler Companion AI",
|
| 113 |
+
"version": "1.0.0"
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.post("/agent", response_model=ChatResponse)
|
| 118 |
+
async def agent_chat(request: ChatRequest):
|
| 119 |
+
"""Main chat endpoint for the AI travel agent."""
|
| 120 |
+
try:
|
| 121 |
+
agent = get_agent()
|
| 122 |
+
result = await agent.chat(request.message, request.session_id)
|
| 123 |
+
return ChatResponse(**result)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.post("/search", response_model=SearchResponse)
|
| 129 |
+
async def web_search(request: SearchRequest):
|
| 130 |
+
"""Direct web search endpoint using Tavily."""
|
| 131 |
+
try:
|
| 132 |
+
client = get_tavily_client()
|
| 133 |
+
result = await client.search(request.query, request.max_results)
|
| 134 |
+
return SearchResponse(**result)
|
| 135 |
+
except Exception as e:
|
| 136 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@app.post("/itinerary", response_model=ItineraryResponse)
|
| 140 |
+
async def create_itinerary(request: ItineraryRequest):
|
| 141 |
+
"""Generate a detailed travel itinerary."""
|
| 142 |
+
try:
|
| 143 |
+
agent = get_agent()
|
| 144 |
+
prompt = f"Generate a detailed {request.days}-day itinerary for {request.destination}"
|
| 145 |
+
if request.preferences:
|
| 146 |
+
prompt += f" with focus on: {request.preferences}"
|
| 147 |
+
result = await agent.chat(prompt, session_id=f"itinerary_{request.destination}")
|
| 148 |
+
return ItineraryResponse(
|
| 149 |
+
destination=request.destination,
|
| 150 |
+
days=request.days,
|
| 151 |
+
itinerary=result["response"]
|
| 152 |
+
)
|
| 153 |
+
except Exception as e:
|
| 154 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.post("/recommend", response_model=RecommendResponse)
|
| 158 |
+
async def get_recommendations(request: RecommendRequest):
|
| 159 |
+
"""Get personalized destination recommendations."""
|
| 160 |
+
try:
|
| 161 |
+
agent = get_agent()
|
| 162 |
+
preferences = []
|
| 163 |
+
if request.budget:
|
| 164 |
+
preferences.append(f"budget: {request.budget}")
|
| 165 |
+
if request.style:
|
| 166 |
+
preferences.append(f"style: {request.style}")
|
| 167 |
+
if request.interests:
|
| 168 |
+
preferences.append(f"interests: {', '.join(request.interests)}")
|
| 169 |
+
if request.duration:
|
| 170 |
+
preferences.append(f"duration: {request.duration}")
|
| 171 |
+
if request.season:
|
| 172 |
+
preferences.append(f"season: {request.season}")
|
| 173 |
+
|
| 174 |
+
pref_str = ", ".join(preferences) if preferences else "no specific preferences"
|
| 175 |
+
prompt = f"Recommend travel destinations based on: {pref_str}"
|
| 176 |
+
result = await agent.chat(prompt, session_id="recommendations")
|
| 177 |
+
|
| 178 |
+
return RecommendResponse(
|
| 179 |
+
recommendations=result["response"],
|
| 180 |
+
preferences={
|
| 181 |
+
"budget": request.budget,
|
| 182 |
+
"style": request.style,
|
| 183 |
+
"interests": request.interests,
|
| 184 |
+
"duration": request.duration,
|
| 185 |
+
"season": request.season
|
| 186 |
+
}
|
| 187 |
+
)
|
| 188 |
+
except Exception as e:
|
| 189 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@app.delete("/session/{session_id}")
|
| 193 |
+
async def clear_session(session_id: str):
|
| 194 |
+
"""Clear chat history for a session."""
|
| 195 |
+
try:
|
| 196 |
+
agent = get_agent()
|
| 197 |
+
agent.clear_session(session_id)
|
| 198 |
+
return {"status": "cleared", "session_id": session_id}
|
| 199 |
+
except Exception as e:
|
| 200 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# ============== Run Server ==============
|
| 204 |
+
|
| 205 |
+
if __name__ == "__main__":
|
| 206 |
+
import uvicorn
|
| 207 |
+
host = os.getenv("HOST", "0.0.0.0")
|
| 208 |
+
port = int(os.getenv("PORT", 8000))
|
| 209 |
+
uvicorn.run("main:app", host=host, port=port, reload=True)
|
models/flight_models.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# ─── Auth Models ─────────────────────────────────────────────
|
| 7 |
+
|
| 8 |
+
class TokenCache(BaseModel):
|
| 9 |
+
"""
|
| 10 |
+
Stores the token in memory so we don't
|
| 11 |
+
call authenticate on every search request.
|
| 12 |
+
Token is valid from 00:00 AM to 11:59 PM same day.
|
| 13 |
+
"""
|
| 14 |
+
token_id: str
|
| 15 |
+
member_id: int
|
| 16 |
+
agency_id: int
|
| 17 |
+
generated_date: str # store as YYYY-MM-DD
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ─── Search Request Models ────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
class FlightSegment(BaseModel):
|
| 23 |
+
"""One segment of the journey (origin → destination)"""
|
| 24 |
+
origin: str # e.g. "DEL"
|
| 25 |
+
destination: str # e.g. "BOM"
|
| 26 |
+
departure_date: str # e.g. "2024-08-10"
|
| 27 |
+
cabin_class: int = 2 # 1=All 2=Economy 3=PremiumEconomy 4=Business 5=PremiumBusiness 6=First
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class FlightSearchRequest(BaseModel):
|
| 31 |
+
"""
|
| 32 |
+
What the frontend sends to our backend
|
| 33 |
+
(simplified, human-friendly format)
|
| 34 |
+
"""
|
| 35 |
+
origin: str # e.g. "DEL"
|
| 36 |
+
destination: str # e.g. "DXB"
|
| 37 |
+
departure_date: str # e.g. "2024-08-10"
|
| 38 |
+
return_date: Optional[str] = None # only for round trips
|
| 39 |
+
adult_count: int = 1
|
| 40 |
+
child_count: int = 0
|
| 41 |
+
infant_count: int = 0
|
| 42 |
+
cabin_class: int = 2 # Economy default
|
| 43 |
+
trip_type: str = "one-way" # "one-way" | "round"
|
| 44 |
+
direct_flight: bool = False
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ─── Booking Request Models ───────────────────────────────────
|
| 48 |
+
|
| 49 |
+
class PassengerFare(BaseModel):
|
| 50 |
+
base_fare: float
|
| 51 |
+
tax: float
|
| 52 |
+
transaction_fee: float = 0
|
| 53 |
+
yq_tax: float = 0
|
| 54 |
+
additional_txn_fee_ofrd: float = 0
|
| 55 |
+
additional_txn_fee_pub: float = 0
|
| 56 |
+
air_trans_fee: float = 0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class PassengerDetail(BaseModel):
|
| 60 |
+
title: str # "Mr", "Mrs", "Miss", "Mstr"
|
| 61 |
+
first_name: str
|
| 62 |
+
last_name: str
|
| 63 |
+
pax_type: int # 1=Adult, 2=Child, 3=Infant
|
| 64 |
+
gender: int # 1=Male, 2=Female
|
| 65 |
+
date_of_birth: Optional[str] = None
|
| 66 |
+
passport_no: Optional[str] = None
|
| 67 |
+
passport_expiry: Optional[str] = None
|
| 68 |
+
address_line1: str
|
| 69 |
+
city: str
|
| 70 |
+
country_code: str
|
| 71 |
+
country_name: str
|
| 72 |
+
contact_no: str
|
| 73 |
+
email: str
|
| 74 |
+
is_lead_pax: bool = False
|
| 75 |
+
nationality: str = "IN"
|
| 76 |
+
fare: PassengerFare
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class FlightBookRequest(BaseModel):
|
| 80 |
+
"""What the frontend sends to book a flight"""
|
| 81 |
+
result_index: str # from search results
|
| 82 |
+
trace_id: str # from search response (valid 15 mins)
|
| 83 |
+
passengers: List[PassengerDetail]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class FlightTicketRequest(BaseModel):
|
| 87 |
+
"""What the frontend sends to ticket (for LCC)"""
|
| 88 |
+
result_index: str
|
| 89 |
+
trace_id: str
|
| 90 |
+
passengers: List[PassengerDetail]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class NonLCCTicketRequest(BaseModel):
|
| 94 |
+
"""For Non-LCC (already booked via Book endpoint)"""
|
| 95 |
+
trace_id: str
|
| 96 |
+
pnr: str
|
| 97 |
+
booking_id: int
|
| 98 |
+
is_price_change_accepted: bool = False
|
models/hotel_models.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
|
| 4 |
+
class SearchLocationRequest(BaseModel):
|
| 5 |
+
query: str
|
| 6 |
+
|
| 7 |
+
class SearchHotelsRequest(BaseModel):
|
| 8 |
+
geoId: str
|
| 9 |
+
checkIn: str
|
| 10 |
+
checkOut: str
|
| 11 |
+
pageNumber: Optional[int] = 1
|
| 12 |
+
sort: Optional[str] = None
|
| 13 |
+
adults: Optional[int] = 0
|
| 14 |
+
rooms: Optional[int] = 0
|
| 15 |
+
currencyCode: Optional[str] = "USD"
|
| 16 |
+
rating: Optional[int] = 0
|
| 17 |
+
priceMin: Optional[int] = 0
|
| 18 |
+
priceMax: Optional[int] = 0
|
| 19 |
+
|
| 20 |
+
class SearchHotelsByLocationRequest(BaseModel):
|
| 21 |
+
latitude: str
|
| 22 |
+
longitude: str
|
| 23 |
+
checkIn: str
|
| 24 |
+
checkOut: str
|
| 25 |
+
pageNumber: Optional[int] = 1
|
| 26 |
+
sort: Optional[str] = None
|
| 27 |
+
adults: Optional[int] = 0
|
| 28 |
+
rooms: Optional[int] = 0
|
| 29 |
+
currencyCode: Optional[str] = "USD"
|
| 30 |
+
|
| 31 |
+
class HotelDetailsRequest(BaseModel):
|
| 32 |
+
id: str
|
| 33 |
+
checkIn: str
|
| 34 |
+
checkOut: str
|
| 35 |
+
adults: Optional[int] = 0
|
| 36 |
+
rooms: Optional[int] = 0
|
| 37 |
+
currency: Optional[str] = "USD"
|
prompts.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System prompts for the Immersive Traveler Companion AI Agent.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
SYSTEM_PROMPT = """You are an Immersive Traveler Companion AI — a sophisticated, friendly, and knowledgeable travel assistant.
|
| 6 |
+
|
| 7 |
+
## Your Capabilities:
|
| 8 |
+
1. **Destination Discovery**: Help users explore and discover travel destinations based on their preferences, budget, travel style, and interests.
|
| 9 |
+
2. **Itinerary Generation**: Create detailed, day-by-day travel itineraries with activities, timings, and local tips.
|
| 10 |
+
3. **Web Search**: Use Tavily to search for real-time information about booking websites, flights, hotels, and travel deals.
|
| 11 |
+
4. **Personalized Recommendations**: Offer tailored suggestions based on user preferences.
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
## Your Personality:
|
| 15 |
+
- Friendly and enthusiastic about travel
|
| 16 |
+
- Concise but informative
|
| 17 |
+
- Confident in your recommendations
|
| 18 |
+
- Culturally aware and respectful
|
| 19 |
+
- Proactive in offering helpful suggestions
|
| 20 |
+
|
| 21 |
+
## Response Guidelines:
|
| 22 |
+
1. Keep responses concise and scannable
|
| 23 |
+
2. Use bullet points and structured formatting
|
| 24 |
+
3. Always offer actionable next steps
|
| 25 |
+
4. When mentioning destinations, offer helpful details
|
| 26 |
+
|
| 27 |
+
5. When users ask about bookings, use web search to find current options
|
| 28 |
+
6. Be proactive about suggesting itineraries for mentioned destinations
|
| 29 |
+
|
| 30 |
+
## Tool Usage:
|
| 31 |
+
- Use `search_web` when users ask about booking, prices, current deals, or need real-time information
|
| 32 |
+
|
| 33 |
+
- Use `generate_itinerary` when users want a trip plan
|
| 34 |
+
- Use `recommend_destinations` when users describe their travel preferences
|
| 35 |
+
|
| 36 |
+
## Example Interactions:
|
| 37 |
+
User: "Plan a 3-day Goa trip"
|
| 38 |
+
→ Generate itinerary + Ask about booking needs
|
| 39 |
+
|
| 40 |
+
User: "Where should I book tickets?"
|
| 41 |
+
→ Use Tavily to search for best booking platforms
|
| 42 |
+
|
| 43 |
+
User: "I want a peaceful beach destination under $1000"
|
| 44 |
+
→ Recommend destinations
|
| 45 |
+
|
| 46 |
+
Remember: You're here to make travel planning exciting, effortless, and immersive!
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
ITINERARY_PROMPT = """Generate a detailed {days}-day travel itinerary for {destination}.
|
| 50 |
+
|
| 51 |
+
Include for each day:
|
| 52 |
+
- Morning, afternoon, and evening activities
|
| 53 |
+
- Recommended restaurants and local cuisine
|
| 54 |
+
- Travel tips and estimated costs
|
| 55 |
+
- Must-see attractions
|
| 56 |
+
- Hidden gems and local experiences
|
| 57 |
+
|
| 58 |
+
Format the response as a structured itinerary with clear day-by-day breakdown.
|
| 59 |
+
Make it practical, exciting, and culturally immersive.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
RECOMMENDATION_PROMPT = """Based on the following travel preferences, recommend the top 3-5 destinations:
|
| 63 |
+
|
| 64 |
+
Preferences: {preferences}
|
| 65 |
+
|
| 66 |
+
For each destination, provide:
|
| 67 |
+
- Destination name and country
|
| 68 |
+
- Why it matches their preferences
|
| 69 |
+
- Best time to visit
|
| 70 |
+
- Estimated budget range
|
| 71 |
+
- 2-3 highlight experiences
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
Be creative and consider both popular and off-the-beaten-path options.
|
| 75 |
+
"""
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.109.0
|
| 2 |
+
uvicorn[standard]==0.27.0
|
| 3 |
+
python-dotenv==1.0.0
|
| 4 |
+
google-generativeai==0.8.3
|
| 5 |
+
httpx==0.27.0
|
| 6 |
+
pydantic==2.5.3
|
| 7 |
+
tavily-python==0.5.0
|
| 8 |
+
|
| 9 |
+
pydantic-settings==2.1.0
|
| 10 |
+
python-multipart==0.0.6
|
routers/flights.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/routers/flights.py
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
Flight booking API routes.
|
| 5 |
+
These get mounted into the existing FastAPI app in main.py.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, HTTPException, status
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
from typing import Optional, List
|
| 11 |
+
from services import tbo_service
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
router = APIRouter(
|
| 17 |
+
prefix="/flights",
|
| 18 |
+
tags=["Flight Booking"],
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ─── Request / Response Models ────────────────────────────────
|
| 23 |
+
|
| 24 |
+
class FlightSearchRequest(BaseModel):
|
| 25 |
+
origin: str = Field(..., min_length=2, max_length=3, example="DEL")
|
| 26 |
+
destination: str = Field(..., min_length=2, max_length=3, example="DXB")
|
| 27 |
+
departure_date: str = Field(..., example="2024-08-10")
|
| 28 |
+
return_date: Optional[str] = Field(None, example="2024-08-17")
|
| 29 |
+
adult_count: int = Field(1, ge=1, le=9)
|
| 30 |
+
child_count: int = Field(0, ge=0, le=9)
|
| 31 |
+
infant_count: int = Field(0, ge=0, le=9)
|
| 32 |
+
cabin_class: int = Field(2, ge=1, le=6)
|
| 33 |
+
trip_type: str = Field("one-way", example="one-way")
|
| 34 |
+
direct_flight: bool = False
|
| 35 |
+
|
| 36 |
+
class Config:
|
| 37 |
+
json_schema_extra = {
|
| 38 |
+
"example": {
|
| 39 |
+
"origin": "DEL",
|
| 40 |
+
"destination": "BOM",
|
| 41 |
+
"departure_date": "2024-08-10",
|
| 42 |
+
"adult_count": 1,
|
| 43 |
+
"child_count": 0,
|
| 44 |
+
"infant_count": 0,
|
| 45 |
+
"cabin_class": 2,
|
| 46 |
+
"trip_type": "one-way",
|
| 47 |
+
"direct_flight": False
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class PassengerFareModel(BaseModel):
|
| 53 |
+
base_fare: float = 0
|
| 54 |
+
tax: float = 0
|
| 55 |
+
transaction_fee: float = 0
|
| 56 |
+
yq_tax: float = 0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class PassengerModel(BaseModel):
|
| 60 |
+
title: str = "Mr"
|
| 61 |
+
first_name: str
|
| 62 |
+
last_name: str
|
| 63 |
+
pax_type: int = 1 # 1=Adult, 2=Child, 3=Infant
|
| 64 |
+
gender: int = 1 # 1=Male, 2=Female
|
| 65 |
+
date_of_birth: Optional[str] = None
|
| 66 |
+
passport_no: Optional[str] = None
|
| 67 |
+
passport_expiry: Optional[str] = None
|
| 68 |
+
address_line1: str = ""
|
| 69 |
+
city: str = ""
|
| 70 |
+
country_code: str = "IN"
|
| 71 |
+
country_name: str = "India"
|
| 72 |
+
contact_no: str = ""
|
| 73 |
+
email: str = ""
|
| 74 |
+
is_lead_pax: bool = False
|
| 75 |
+
nationality: str = "IN"
|
| 76 |
+
fare: PassengerFareModel = PassengerFareModel()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class FlightBookRequest(BaseModel):
|
| 80 |
+
result_index: str
|
| 81 |
+
trace_id: str
|
| 82 |
+
passengers: List[PassengerModel]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class FlightTicketLCCRequest(BaseModel):
|
| 86 |
+
result_index: str
|
| 87 |
+
trace_id: str
|
| 88 |
+
passengers: List[PassengerModel]
|
| 89 |
+
is_price_change_accepted: bool = False
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class FlightTicketNonLCCRequest(BaseModel):
|
| 93 |
+
trace_id: str
|
| 94 |
+
pnr: str
|
| 95 |
+
booking_id: int
|
| 96 |
+
is_price_change_accepted: bool = False
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ─── Routes ──────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
@router.post("/search")
|
| 102 |
+
async def search_flights(request: FlightSearchRequest):
|
| 103 |
+
"""
|
| 104 |
+
Search available flights via TBO API.
|
| 105 |
+
|
| 106 |
+
- Returns list of flights sorted cheapest first
|
| 107 |
+
- Save the `trace_id` from response for booking
|
| 108 |
+
- trace_id expires in 15 minutes!
|
| 109 |
+
- Cabin class: 2=Economy, 4=Business, 6=First
|
| 110 |
+
"""
|
| 111 |
+
try:
|
| 112 |
+
results = await tbo_service.search_flights(
|
| 113 |
+
origin=request.origin,
|
| 114 |
+
destination=request.destination,
|
| 115 |
+
departure_date=request.departure_date,
|
| 116 |
+
adult_count=request.adult_count,
|
| 117 |
+
child_count=request.child_count,
|
| 118 |
+
infant_count=request.infant_count,
|
| 119 |
+
cabin_class=request.cabin_class,
|
| 120 |
+
trip_type=request.trip_type,
|
| 121 |
+
return_date=request.return_date,
|
| 122 |
+
direct_flight=request.direct_flight,
|
| 123 |
+
)
|
| 124 |
+
return {"success": True, "data": results}
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.error(f"Flight search failed: {str(e)}")
|
| 128 |
+
raise HTTPException(
|
| 129 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 130 |
+
detail=str(e)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@router.post("/book")
|
| 135 |
+
async def book_flight(request: FlightBookRequest):
|
| 136 |
+
"""
|
| 137 |
+
Book a Non-LCC flight (creates PNR / hold).
|
| 138 |
+
|
| 139 |
+
- Only for Non-LCC flights (check IsLCC field in search results)
|
| 140 |
+
- For LCC flights use /flights/ticket/lcc instead
|
| 141 |
+
- If IsPriceChanged=true → show user new price → re-call with updated fare
|
| 142 |
+
- trace_id must be used within 15 minutes of search!
|
| 143 |
+
"""
|
| 144 |
+
try:
|
| 145 |
+
# Convert Pydantic models to dicts for tbo_service
|
| 146 |
+
passengers_data = [p.model_dump() for p in request.passengers]
|
| 147 |
+
|
| 148 |
+
result = await tbo_service.book_flight(
|
| 149 |
+
result_index=request.result_index,
|
| 150 |
+
trace_id=request.trace_id,
|
| 151 |
+
passengers=passengers_data,
|
| 152 |
+
)
|
| 153 |
+
return {"success": True, "data": result}
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"Flight booking failed: {str(e)}")
|
| 157 |
+
raise HTTPException(
|
| 158 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 159 |
+
detail=str(e)
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.post("/ticket/lcc")
|
| 164 |
+
async def ticket_lcc(request: FlightTicketLCCRequest):
|
| 165 |
+
"""
|
| 166 |
+
Directly issue ticket for LCC flights (no prior Book needed).
|
| 167 |
+
|
| 168 |
+
LCC airlines: SpiceJet, IndiGo, GoAir, AirAsia, FlyDubai, etc.
|
| 169 |
+
Check IsLCC=true in search results to identify LCC flights.
|
| 170 |
+
"""
|
| 171 |
+
try:
|
| 172 |
+
passengers_data = [p.model_dump() for p in request.passengers]
|
| 173 |
+
|
| 174 |
+
result = await tbo_service.ticket_flight_lcc(
|
| 175 |
+
result_index=request.result_index,
|
| 176 |
+
trace_id=request.trace_id,
|
| 177 |
+
passengers=passengers_data,
|
| 178 |
+
is_price_change_accepted=request.is_price_change_accepted,
|
| 179 |
+
)
|
| 180 |
+
return {"success": True, "data": result}
|
| 181 |
+
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(f"LCC ticket failed: {str(e)}")
|
| 184 |
+
raise HTTPException(
|
| 185 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 186 |
+
detail=str(e)
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@router.post("/ticket/non-lcc")
|
| 191 |
+
async def ticket_non_lcc(request: FlightTicketNonLCCRequest):
|
| 192 |
+
"""
|
| 193 |
+
Issue ticket for Non-LCC flight (after /book call).
|
| 194 |
+
|
| 195 |
+
Requires PNR and BookingId from the /book response.
|
| 196 |
+
"""
|
| 197 |
+
try:
|
| 198 |
+
result = await tbo_service.ticket_flight_non_lcc(
|
| 199 |
+
trace_id=request.trace_id,
|
| 200 |
+
pnr=request.pnr,
|
| 201 |
+
booking_id=request.booking_id,
|
| 202 |
+
is_price_change_accepted=request.is_price_change_accepted,
|
| 203 |
+
)
|
| 204 |
+
return {"success": True, "data": result}
|
| 205 |
+
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error(f"Non-LCC ticket failed: {str(e)}")
|
| 208 |
+
raise HTTPException(
|
| 209 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 210 |
+
detail=str(e)
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@router.get("/auth-status")
|
| 215 |
+
async def check_auth_status():
|
| 216 |
+
"""
|
| 217 |
+
Check if TBO authentication token is valid.
|
| 218 |
+
Useful for debugging — confirms credentials are working.
|
| 219 |
+
"""
|
| 220 |
+
try:
|
| 221 |
+
auth = await tbo_service.authenticate()
|
| 222 |
+
ip = await tbo_service.get_end_user_ip()
|
| 223 |
+
return {
|
| 224 |
+
"success": True,
|
| 225 |
+
"authenticated": True,
|
| 226 |
+
"member_id": auth["member_id"],
|
| 227 |
+
"agency_id": auth["agency_id"],
|
| 228 |
+
"server_ip": ip,
|
| 229 |
+
"token_preview": auth["token_id"][:8] + "..."
|
| 230 |
+
}
|
| 231 |
+
except Exception as e:
|
| 232 |
+
return {
|
| 233 |
+
"success": False,
|
| 234 |
+
"authenticated": False,
|
| 235 |
+
"error": str(e)
|
| 236 |
+
}
|
routers/hotel_routes.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from services.hotel_service import (
|
| 4 |
+
search_location,
|
| 5 |
+
get_hotels_filter,
|
| 6 |
+
search_hotels,
|
| 7 |
+
search_hotels_by_location,
|
| 8 |
+
get_hotel_details
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/hotels", tags=["hotels"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/search-location")
|
| 15 |
+
async def api_search_location(query: str = Query(..., description="Location name")):
|
| 16 |
+
if not query or len(query.strip()) < 3:
|
| 17 |
+
raise HTTPException(status_code=400, detail="Query must be at least 3 characters")
|
| 18 |
+
|
| 19 |
+
result = search_location(query.strip())
|
| 20 |
+
|
| 21 |
+
if not result["success"]:
|
| 22 |
+
error_msg = result.get("error", "Search failed")
|
| 23 |
+
|
| 24 |
+
# propagate rate limit correctly
|
| 25 |
+
if "429" in error_msg or "Too Many Requests" in error_msg:
|
| 26 |
+
raise HTTPException(status_code=429, detail="Rate limit reached. Please type slower.")
|
| 27 |
+
|
| 28 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
| 29 |
+
|
| 30 |
+
return result
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get("/filters")
|
| 34 |
+
async def api_get_filters(
|
| 35 |
+
geoId: str = Query(...),
|
| 36 |
+
checkIn: str = Query(...),
|
| 37 |
+
checkOut: str = Query(...)
|
| 38 |
+
):
|
| 39 |
+
"""Get hotel filters for a location."""
|
| 40 |
+
result = get_hotels_filter(geoId, checkIn, checkOut)
|
| 41 |
+
|
| 42 |
+
if not result["success"]:
|
| 43 |
+
raise HTTPException(status_code=500, detail=result.get("error", "Failed to get filters"))
|
| 44 |
+
|
| 45 |
+
return result
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.get("/search")
|
| 49 |
+
async def api_search_hotels(
|
| 50 |
+
geoId: str = Query(...),
|
| 51 |
+
checkIn: str = Query(...),
|
| 52 |
+
checkOut: str = Query(...),
|
| 53 |
+
pageNumber: int = Query(default=1),
|
| 54 |
+
sort: Optional[str] = Query(default=None),
|
| 55 |
+
adults: int = Query(default=0),
|
| 56 |
+
rooms: int = Query(default=0),
|
| 57 |
+
currencyCode: str = Query(default="USD"),
|
| 58 |
+
rating: int = Query(default=0),
|
| 59 |
+
priceMin: int = Query(default=0),
|
| 60 |
+
priceMax: int = Query(default=0)
|
| 61 |
+
):
|
| 62 |
+
"""Search hotels by location ID."""
|
| 63 |
+
result = search_hotels(
|
| 64 |
+
geoId=geoId,
|
| 65 |
+
checkIn=checkIn,
|
| 66 |
+
checkOut=checkOut,
|
| 67 |
+
pageNumber=pageNumber,
|
| 68 |
+
sort=sort,
|
| 69 |
+
adults=adults,
|
| 70 |
+
rooms=rooms,
|
| 71 |
+
currencyCode=currencyCode,
|
| 72 |
+
rating=rating,
|
| 73 |
+
priceMin=priceMin,
|
| 74 |
+
priceMax=priceMax
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if not result["success"]:
|
| 78 |
+
raise HTTPException(status_code=500, detail=result.get("error", "Search failed"))
|
| 79 |
+
|
| 80 |
+
return result
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@router.get("/search-by-location")
|
| 84 |
+
async def api_search_by_location(
|
| 85 |
+
latitude: str = Query(...),
|
| 86 |
+
longitude: str = Query(...),
|
| 87 |
+
checkIn: str = Query(...),
|
| 88 |
+
checkOut: str = Query(...),
|
| 89 |
+
pageNumber: int = Query(default=1),
|
| 90 |
+
sort: Optional[str] = Query(default=None),
|
| 91 |
+
adults: int = Query(default=0),
|
| 92 |
+
rooms: int = Query(default=0),
|
| 93 |
+
currencyCode: str = Query(default="USD")
|
| 94 |
+
):
|
| 95 |
+
"""Search hotels by coordinates."""
|
| 96 |
+
result = search_hotels_by_location(
|
| 97 |
+
latitude=latitude,
|
| 98 |
+
longitude=longitude,
|
| 99 |
+
checkIn=checkIn,
|
| 100 |
+
checkOut=checkOut,
|
| 101 |
+
pageNumber=pageNumber,
|
| 102 |
+
sort=sort,
|
| 103 |
+
adults=adults,
|
| 104 |
+
rooms=rooms,
|
| 105 |
+
currencyCode=currencyCode
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
if not result["success"]:
|
| 109 |
+
raise HTTPException(status_code=500, detail=result.get("error", "Search failed"))
|
| 110 |
+
|
| 111 |
+
return result
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.get("/details")
|
| 115 |
+
async def api_get_hotel_details(
|
| 116 |
+
id: str = Query(...),
|
| 117 |
+
checkIn: str = Query(...),
|
| 118 |
+
checkOut: str = Query(...),
|
| 119 |
+
adults: int = Query(default=0),
|
| 120 |
+
rooms: int = Query(default=0),
|
| 121 |
+
currency: str = Query(default="USD")
|
| 122 |
+
):
|
| 123 |
+
"""Get details for a specific hotel."""
|
| 124 |
+
result = get_hotel_details(
|
| 125 |
+
hotel_id=id,
|
| 126 |
+
checkIn=checkIn,
|
| 127 |
+
checkOut=checkOut,
|
| 128 |
+
adults=adults,
|
| 129 |
+
rooms=rooms,
|
| 130 |
+
currency=currency
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
if not result["success"]:
|
| 134 |
+
raise HTTPException(status_code=500, detail=result.get("error", "Failed to get details"))
|
| 135 |
+
|
| 136 |
+
return result
|
services/hotel_service.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/services/hotel_service.py
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
from typing import Optional, Dict, Any
|
| 7 |
+
from functools import lru_cache
|
| 8 |
+
from config import get_settings
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _get_hotel_headers() -> dict:
|
| 14 |
+
"""Build RapidAPI headers fresh from settings each call."""
|
| 15 |
+
settings = get_settings()
|
| 16 |
+
return {
|
| 17 |
+
"x-rapidapi-key": settings.rapidapi_key,
|
| 18 |
+
"x-rapidapi-host": settings.rapidapi_host,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get_base_url() -> str:
|
| 23 |
+
settings = get_settings()
|
| 24 |
+
return f"https://{settings.rapidapi_host}/api/v1/hotels"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _extract_geo_id(raw_geo_id: str) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Extract the clean numeric geoId from whatever format TripAdvisor returns.
|
| 30 |
+
|
| 31 |
+
Examples:
|
| 32 |
+
"loc;317100;g317100" → "317100"
|
| 33 |
+
"g317100" → "317100"
|
| 34 |
+
"317100" → "317100"
|
| 35 |
+
"295424" → "295424"
|
| 36 |
+
"""
|
| 37 |
+
if not raw_geo_id:
|
| 38 |
+
return raw_geo_id
|
| 39 |
+
|
| 40 |
+
# If it contains semicolons e.g. "loc;317100;g317100"
|
| 41 |
+
# grab the first pure numeric segment
|
| 42 |
+
if ";" in raw_geo_id:
|
| 43 |
+
parts = raw_geo_id.split(";")
|
| 44 |
+
for part in parts:
|
| 45 |
+
# strip leading letters like "g" then check if numeric
|
| 46 |
+
cleaned = re.sub(r'^[a-zA-Z]+', '', part)
|
| 47 |
+
if cleaned.isdigit():
|
| 48 |
+
return cleaned
|
| 49 |
+
|
| 50 |
+
# If it starts with letters followed by digits e.g. "g317100"
|
| 51 |
+
match = re.match(r'^[a-zA-Z]+(\d+)$', raw_geo_id)
|
| 52 |
+
if match:
|
| 53 |
+
return match.group(1)
|
| 54 |
+
|
| 55 |
+
# Already a plain number
|
| 56 |
+
if raw_geo_id.isdigit():
|
| 57 |
+
return raw_geo_id
|
| 58 |
+
|
| 59 |
+
# Fallback — return as-is
|
| 60 |
+
return raw_geo_id
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ── Public service functions ──────────────────────────────────────────────────
|
| 64 |
+
def _normalize_query(q: str) -> str:
|
| 65 |
+
return q.strip().lower()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@lru_cache(maxsize=200)
|
| 69 |
+
def _search_location_cached(norm_query: str) -> Dict[str, Any]:
|
| 70 |
+
"""Cached internal location search."""
|
| 71 |
+
try:
|
| 72 |
+
url = f"{_get_base_url()}/searchLocation"
|
| 73 |
+
params = {"query": norm_query}
|
| 74 |
+
|
| 75 |
+
response = requests.get(
|
| 76 |
+
url,
|
| 77 |
+
headers=_get_hotel_headers(),
|
| 78 |
+
params=params,
|
| 79 |
+
timeout=15,
|
| 80 |
+
)
|
| 81 |
+
response.raise_for_status()
|
| 82 |
+
|
| 83 |
+
data = response.json()
|
| 84 |
+
|
| 85 |
+
if data.get("status"):
|
| 86 |
+
items = data.get("data", [])
|
| 87 |
+
for item in items:
|
| 88 |
+
if "title" in item:
|
| 89 |
+
item["title"] = (
|
| 90 |
+
item["title"]
|
| 91 |
+
.replace("<b>", "")
|
| 92 |
+
.replace("</b>", "")
|
| 93 |
+
)
|
| 94 |
+
if "documentId" in item:
|
| 95 |
+
item["geoId"] = _extract_geo_id(item["documentId"])
|
| 96 |
+
|
| 97 |
+
return {"success": True, "data": items}
|
| 98 |
+
|
| 99 |
+
return {"success": False, "error": data.get("message", "Unknown error"), "data": []}
|
| 100 |
+
|
| 101 |
+
except requests.exceptions.Timeout:
|
| 102 |
+
logger.error("Timeout searching location: %s", norm_query)
|
| 103 |
+
return {"success": False, "error": "Request timed out", "data": []}
|
| 104 |
+
except requests.exceptions.RequestException as e:
|
| 105 |
+
logger.error("Error searching location: %s", str(e))
|
| 106 |
+
return {"success": False, "error": str(e), "data": []}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def search_location(query: str) -> Dict[str, Any]:
|
| 110 |
+
norm = _normalize_query(query)
|
| 111 |
+
|
| 112 |
+
if len(norm) < 3:
|
| 113 |
+
return {"success": True, "data": []}
|
| 114 |
+
|
| 115 |
+
return _search_location_cached(norm)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def get_hotels_filter(
|
| 119 |
+
geoId: str,
|
| 120 |
+
checkIn: str,
|
| 121 |
+
checkOut: str,
|
| 122 |
+
) -> Dict[str, Any]:
|
| 123 |
+
"""Get available hotel filters for a location."""
|
| 124 |
+
try:
|
| 125 |
+
url = f"{_get_base_url()}/getHotelsFilter"
|
| 126 |
+
params = {
|
| 127 |
+
"geoId": _extract_geo_id(geoId),
|
| 128 |
+
"checkIn": checkIn,
|
| 129 |
+
"checkOut": checkOut,
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
response = requests.get(
|
| 133 |
+
url,
|
| 134 |
+
headers=_get_hotel_headers(),
|
| 135 |
+
params=params,
|
| 136 |
+
timeout=15,
|
| 137 |
+
)
|
| 138 |
+
response.raise_for_status()
|
| 139 |
+
|
| 140 |
+
data = response.json()
|
| 141 |
+
|
| 142 |
+
if data.get("status"):
|
| 143 |
+
return {"success": True, "data": data.get("data", {})}
|
| 144 |
+
|
| 145 |
+
return {
|
| 146 |
+
"success": False,
|
| 147 |
+
"error": data.get("message", "Unknown error"),
|
| 148 |
+
"data": {},
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
except requests.exceptions.RequestException as e:
|
| 152 |
+
logger.error("Error getting hotel filters: %s", str(e))
|
| 153 |
+
return {"success": False, "error": str(e), "data": {}}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def search_hotels(
|
| 157 |
+
geoId: str,
|
| 158 |
+
checkIn: str,
|
| 159 |
+
checkOut: str,
|
| 160 |
+
pageNumber: int = 1,
|
| 161 |
+
sort: Optional[str] = None,
|
| 162 |
+
adults: int = 0,
|
| 163 |
+
rooms: int = 0,
|
| 164 |
+
currencyCode: str = "USD",
|
| 165 |
+
rating: int = 0,
|
| 166 |
+
priceMin: int = 0,
|
| 167 |
+
priceMax: int = 0,
|
| 168 |
+
) -> Dict[str, Any]:
|
| 169 |
+
"""Search hotels by geoId."""
|
| 170 |
+
try:
|
| 171 |
+
# Always extract clean numeric geoId before calling RapidAPI
|
| 172 |
+
clean_geo_id = _extract_geo_id(geoId)
|
| 173 |
+
logger.info("Searching hotels — raw geoId: %s → clean: %s", geoId, clean_geo_id)
|
| 174 |
+
|
| 175 |
+
url = f"{_get_base_url()}/searchHotels"
|
| 176 |
+
params = {
|
| 177 |
+
"geoId": clean_geo_id,
|
| 178 |
+
"checkIn": checkIn,
|
| 179 |
+
"checkOut": checkOut,
|
| 180 |
+
"pageNumber": str(pageNumber),
|
| 181 |
+
"currencyCode": currencyCode,
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
if sort: params["sort"] = sort
|
| 185 |
+
if adults > 0: params["adults"] = str(adults)
|
| 186 |
+
if rooms > 0: params["rooms"] = str(rooms)
|
| 187 |
+
if rating > 0: params["rating"] = str(rating)
|
| 188 |
+
if priceMin > 0: params["priceMin"] = str(priceMin)
|
| 189 |
+
if priceMax > 0: params["priceMax"] = str(priceMax)
|
| 190 |
+
|
| 191 |
+
response = requests.get(
|
| 192 |
+
url,
|
| 193 |
+
headers=_get_hotel_headers(),
|
| 194 |
+
params=params,
|
| 195 |
+
timeout=20,
|
| 196 |
+
)
|
| 197 |
+
response.raise_for_status()
|
| 198 |
+
|
| 199 |
+
data = response.json()
|
| 200 |
+
|
| 201 |
+
if data.get("status"):
|
| 202 |
+
hotels = data.get("data", {}).get("data", [])
|
| 203 |
+
processed = _process_hotel_list(hotels)
|
| 204 |
+
return {
|
| 205 |
+
"success": True,
|
| 206 |
+
"data": processed,
|
| 207 |
+
"sortDisclaimer": data.get("data", {}).get("sortDisclaimer", ""),
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
# RapidAPI returned status: false
|
| 211 |
+
logger.error(
|
| 212 |
+
"RapidAPI hotel search failed — geoId: %s message: %s",
|
| 213 |
+
clean_geo_id,
|
| 214 |
+
data.get("message"),
|
| 215 |
+
)
|
| 216 |
+
return {
|
| 217 |
+
"success": False,
|
| 218 |
+
"error": data.get("message", "No hotels found for this location"),
|
| 219 |
+
"data": [],
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
except requests.exceptions.Timeout:
|
| 223 |
+
logger.error("Timeout searching hotels for geoId: %s", geoId)
|
| 224 |
+
return {"success": False, "error": "Request timed out", "data": []}
|
| 225 |
+
except requests.exceptions.RequestException as e:
|
| 226 |
+
logger.error("Error searching hotels: %s", str(e))
|
| 227 |
+
return {"success": False, "error": str(e), "data": []}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def search_hotels_by_location(
|
| 231 |
+
latitude: str,
|
| 232 |
+
longitude: str,
|
| 233 |
+
checkIn: str,
|
| 234 |
+
checkOut: str,
|
| 235 |
+
pageNumber: int = 1,
|
| 236 |
+
sort: Optional[str] = None,
|
| 237 |
+
adults: int = 0,
|
| 238 |
+
rooms: int = 0,
|
| 239 |
+
currencyCode: str = "USD",
|
| 240 |
+
) -> Dict[str, Any]:
|
| 241 |
+
"""Search hotels by geographic coordinates."""
|
| 242 |
+
try:
|
| 243 |
+
url = f"{_get_base_url()}/searchHotelsByLocation"
|
| 244 |
+
params = {
|
| 245 |
+
"latitude": latitude,
|
| 246 |
+
"longitude": longitude,
|
| 247 |
+
"checkIn": checkIn,
|
| 248 |
+
"checkOut": checkOut,
|
| 249 |
+
"pageNumber": str(pageNumber),
|
| 250 |
+
"currencyCode": currencyCode,
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
if sort: params["sort"] = sort
|
| 254 |
+
if adults > 0: params["adults"] = str(adults)
|
| 255 |
+
if rooms > 0: params["rooms"] = str(rooms)
|
| 256 |
+
|
| 257 |
+
response = requests.get(
|
| 258 |
+
url,
|
| 259 |
+
headers=_get_hotel_headers(),
|
| 260 |
+
params=params,
|
| 261 |
+
timeout=20,
|
| 262 |
+
)
|
| 263 |
+
response.raise_for_status()
|
| 264 |
+
|
| 265 |
+
data = response.json()
|
| 266 |
+
|
| 267 |
+
if data.get("status"):
|
| 268 |
+
hotels = data.get("data", {}).get("data", [])
|
| 269 |
+
processed = _process_hotel_list(hotels)
|
| 270 |
+
return {
|
| 271 |
+
"success": True,
|
| 272 |
+
"data": processed,
|
| 273 |
+
"sortDisclaimer": data.get("data", {}).get("sortDisclaimer", ""),
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
return {
|
| 277 |
+
"success": False,
|
| 278 |
+
"error": data.get("message", "Unknown error"),
|
| 279 |
+
"data": [],
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
except requests.exceptions.RequestException as e:
|
| 283 |
+
logger.error("Error searching hotels by location: %s", str(e))
|
| 284 |
+
return {"success": False, "error": str(e), "data": []}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def get_hotel_details(
|
| 288 |
+
hotel_id: str,
|
| 289 |
+
checkIn: str,
|
| 290 |
+
checkOut: str,
|
| 291 |
+
adults: int = 0,
|
| 292 |
+
rooms: int = 0,
|
| 293 |
+
currency: str = "USD",
|
| 294 |
+
) -> Dict[str, Any]:
|
| 295 |
+
"""Get detailed information for a specific hotel."""
|
| 296 |
+
try:
|
| 297 |
+
url = f"{_get_base_url()}/getHotelDetails"
|
| 298 |
+
params = {
|
| 299 |
+
"id": hotel_id,
|
| 300 |
+
"checkIn": checkIn,
|
| 301 |
+
"checkOut": checkOut,
|
| 302 |
+
"currency": currency,
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
if adults > 0: params["adults"] = str(adults)
|
| 306 |
+
if rooms > 0: params["rooms"] = str(rooms)
|
| 307 |
+
|
| 308 |
+
response = requests.get(
|
| 309 |
+
url,
|
| 310 |
+
headers=_get_hotel_headers(),
|
| 311 |
+
params=params,
|
| 312 |
+
timeout=20,
|
| 313 |
+
)
|
| 314 |
+
response.raise_for_status()
|
| 315 |
+
|
| 316 |
+
data = response.json()
|
| 317 |
+
|
| 318 |
+
if data.get("status"):
|
| 319 |
+
return {"success": True, "data": data.get("data", {})}
|
| 320 |
+
|
| 321 |
+
return {
|
| 322 |
+
"success": False,
|
| 323 |
+
"error": data.get("message", "Unknown error"),
|
| 324 |
+
"data": {},
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
except requests.exceptions.Timeout:
|
| 328 |
+
logger.error("Timeout getting hotel details for id: %s", hotel_id)
|
| 329 |
+
return {"success": False, "error": "Request timed out", "data": {}}
|
| 330 |
+
except requests.exceptions.RequestException as e:
|
| 331 |
+
logger.error("Error getting hotel details: %s", str(e))
|
| 332 |
+
return {"success": False, "error": str(e), "data": {}}
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# ── Private helper ────────────────────────────────────────────────────────────
|
| 336 |
+
|
| 337 |
+
def _process_hotel_list(hotels: list) -> list:
|
| 338 |
+
"""Clean and normalise hotel list data."""
|
| 339 |
+
processed = []
|
| 340 |
+
|
| 341 |
+
for hotel in hotels:
|
| 342 |
+
thumbnail = None
|
| 343 |
+
photos = hotel.get("cardPhotos", [])
|
| 344 |
+
|
| 345 |
+
if photos:
|
| 346 |
+
template = photos[0].get("sizes", {}).get("urlTemplate", "")
|
| 347 |
+
if template:
|
| 348 |
+
thumbnail = (
|
| 349 |
+
template
|
| 350 |
+
.replace("{width}", "400")
|
| 351 |
+
.replace("{height}", "300")
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
processed.append({
|
| 355 |
+
"id": hotel.get("id"),
|
| 356 |
+
"title": hotel.get("title", "").lstrip("0123456789. "),
|
| 357 |
+
"primaryInfo": hotel.get("primaryInfo"),
|
| 358 |
+
"secondaryInfo": hotel.get("secondaryInfo"),
|
| 359 |
+
"rating": hotel.get("bubbleRating", {}).get("rating", 0),
|
| 360 |
+
"reviewCount": hotel.get("bubbleRating", {}).get("count", "0"),
|
| 361 |
+
"provider": hotel.get("provider"),
|
| 362 |
+
"priceForDisplay": hotel.get("priceForDisplay"),
|
| 363 |
+
"isSponsored": hotel.get("isSponsored", False),
|
| 364 |
+
"badge": hotel.get("badge", {}),
|
| 365 |
+
"thumbnail": thumbnail,
|
| 366 |
+
"photos": [
|
| 367 |
+
p.get("sizes", {})
|
| 368 |
+
.get("urlTemplate", "")
|
| 369 |
+
.replace("{width}", "800")
|
| 370 |
+
.replace("{height}", "600")
|
| 371 |
+
for p in photos[:4]
|
| 372 |
+
if p.get("sizes", {}).get("urlTemplate")
|
| 373 |
+
],
|
| 374 |
+
})
|
| 375 |
+
|
| 376 |
+
return processed
|
services/tbo_service.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/services/tbo_service.py
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
TBO Flight API Service
|
| 5 |
+
Handles all communication with TBO's flight booking API.
|
| 6 |
+
|
| 7 |
+
Key Points from TBO Docs:
|
| 8 |
+
- Generate token ONCE per day (valid 00:00 AM to 11:59 PM)
|
| 9 |
+
- NEVER generate a new token on every search request
|
| 10 |
+
- TraceId from search is valid for only 15 minutes
|
| 11 |
+
- For LCC flights: go directly to Ticket endpoint
|
| 12 |
+
- For Non-LCC flights: Book first, then Ticket
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
import logging
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# ─── Load environment variables directly ─────────────────────
|
| 23 |
+
import os
|
| 24 |
+
from dotenv import load_dotenv
|
| 25 |
+
load_dotenv()
|
| 26 |
+
|
| 27 |
+
TBO_CLIENT_ID = os.getenv("TBO_CLIENT_ID", "ApiIntegrationNew")
|
| 28 |
+
TBO_USERNAME = os.getenv("TBO_USERNAME", "")
|
| 29 |
+
TBO_PASSWORD = os.getenv("TBO_PASSWORD", "")
|
| 30 |
+
TBO_END_USER_IP_ENV = os.getenv("TBO_END_USER_IP", "")
|
| 31 |
+
|
| 32 |
+
TBO_AUTH_URL = os.getenv(
|
| 33 |
+
"TBO_AUTH_URL",
|
| 34 |
+
"http://Sharedapi.tektravels.com/SharedData.svc/rest/Authenticate"
|
| 35 |
+
)
|
| 36 |
+
TBO_LOGOUT_URL = os.getenv(
|
| 37 |
+
"TBO_LOGOUT_URL",
|
| 38 |
+
"http://Sharedapi.tektravels.com/SharedData.svc/rest/Logout"
|
| 39 |
+
)
|
| 40 |
+
TBO_SEARCH_URL = os.getenv(
|
| 41 |
+
"TBO_SEARCH_URL",
|
| 42 |
+
"http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Search"
|
| 43 |
+
)
|
| 44 |
+
TBO_BOOK_URL = os.getenv(
|
| 45 |
+
"TBO_BOOK_URL",
|
| 46 |
+
"http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Book"
|
| 47 |
+
)
|
| 48 |
+
TBO_TICKET_URL = os.getenv(
|
| 49 |
+
"TBO_TICKET_URL",
|
| 50 |
+
"http://api.tektravels.com/BookingEngineService_Air/AirService.svc/rest/Ticket"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ─── In-Memory Token Cache ────────────────────────────────────
|
| 55 |
+
# IMPORTANT: Per TBO docs — generate only ONE token per day
|
| 56 |
+
# Token is valid from 00:00 AM to 11:59 PM of the same day
|
| 57 |
+
_token_cache = {
|
| 58 |
+
"token_id": None,
|
| 59 |
+
"member_id": None,
|
| 60 |
+
"agency_id": None,
|
| 61 |
+
"date": None, # date this token was generated (YYYY-MM-DD)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# Cache for public IP so we don't fetch it on every request
|
| 65 |
+
_public_ip_cache = {
|
| 66 |
+
"ip": None
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ─── Helper: Get Public IP ────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
async def get_end_user_ip() -> str:
|
| 73 |
+
"""
|
| 74 |
+
Get the server's public IP address.
|
| 75 |
+
|
| 76 |
+
TBO requires EndUserIp in every request.
|
| 77 |
+
We auto-detect if not set in .env
|
| 78 |
+
|
| 79 |
+
Priority:
|
| 80 |
+
1. Value from .env file (TBO_END_USER_IP)
|
| 81 |
+
2. Auto-detected from ipify.org (cached after first call)
|
| 82 |
+
3. Fallback to localhost (not ideal but won't crash)
|
| 83 |
+
"""
|
| 84 |
+
# Priority 1: Use manually set IP from .env
|
| 85 |
+
if TBO_END_USER_IP_ENV and TBO_END_USER_IP_ENV.strip():
|
| 86 |
+
return TBO_END_USER_IP_ENV.strip()
|
| 87 |
+
|
| 88 |
+
# Priority 2: Use cached IP
|
| 89 |
+
if _public_ip_cache["ip"]:
|
| 90 |
+
return _public_ip_cache["ip"]
|
| 91 |
+
|
| 92 |
+
# Priority 3: Auto-detect public IP
|
| 93 |
+
try:
|
| 94 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 95 |
+
response = await client.get("https://api.ipify.org")
|
| 96 |
+
ip = response.text.strip()
|
| 97 |
+
_public_ip_cache["ip"] = ip
|
| 98 |
+
logger.info(f"Auto-detected public IP: {ip}")
|
| 99 |
+
return ip
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.warning(f"Could not detect public IP: {e}. Using fallback.")
|
| 102 |
+
return "127.0.0.1" # fallback
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ─── Helper: Check Token Validity ────────────────────────────
|
| 106 |
+
|
| 107 |
+
def _is_token_valid() -> bool:
|
| 108 |
+
"""
|
| 109 |
+
Check if the cached TBO token is still valid for today.
|
| 110 |
+
TBO tokens expire at midnight (00:00) each day.
|
| 111 |
+
"""
|
| 112 |
+
if not _token_cache["token_id"]:
|
| 113 |
+
return False
|
| 114 |
+
|
| 115 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 116 |
+
|
| 117 |
+
if _token_cache["date"] != today:
|
| 118 |
+
logger.info("TBO token expired (new calendar day). Will re-authenticate.")
|
| 119 |
+
return False
|
| 120 |
+
|
| 121 |
+
return True
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ─── Helper: Map cabin class ─────────────────────────────────
|
| 125 |
+
|
| 126 |
+
def _map_cabin_class(class_name: str) -> int:
|
| 127 |
+
"""
|
| 128 |
+
Map human-readable class name to TBO FlightCabinClass enum.
|
| 129 |
+
1=All, 2=Economy, 3=PremiumEconomy, 4=Business,
|
| 130 |
+
5=PremiumBusiness, 6=First
|
| 131 |
+
"""
|
| 132 |
+
class_map = {
|
| 133 |
+
"all": 1,
|
| 134 |
+
"economy": 2,
|
| 135 |
+
"premiumeconomy": 3,
|
| 136 |
+
"business": 4,
|
| 137 |
+
"premiumbusiness": 5,
|
| 138 |
+
"first": 6,
|
| 139 |
+
}
|
| 140 |
+
return class_map.get(class_name.lower().replace(" ", ""), 2)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ─── Helper: Map trip type ───────────────────────────────────
|
| 144 |
+
|
| 145 |
+
def _map_trip_type(trip_type: str) -> int:
|
| 146 |
+
"""
|
| 147 |
+
Map trip type string to TBO JourneyType enum.
|
| 148 |
+
1=OneWay, 2=Return, 3=MultiStop,
|
| 149 |
+
4=AdvanceSearch, 5=SpecialReturn
|
| 150 |
+
"""
|
| 151 |
+
trip_map = {
|
| 152 |
+
"one-way": 1,
|
| 153 |
+
"oneway": 1,
|
| 154 |
+
"round": 2,
|
| 155 |
+
"return": 2,
|
| 156 |
+
"multistop": 3,
|
| 157 |
+
}
|
| 158 |
+
return trip_map.get(trip_type.lower(), 1)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ─── Helper: Format date for TBO ─────────────────────────────
|
| 162 |
+
|
| 163 |
+
def _format_date_for_tbo(date_str: str) -> str:
|
| 164 |
+
"""
|
| 165 |
+
Convert 'YYYY-MM-DD' to TBO format 'YYYY-MM-DDT00:00:00'
|
| 166 |
+
"""
|
| 167 |
+
return f"{date_str}T00:00:00"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ─── Core: Authenticate ──────────────────────────────────────
|
| 171 |
+
|
| 172 |
+
async def authenticate() -> dict:
|
| 173 |
+
"""
|
| 174 |
+
Authenticate with TBO API and get/return token.
|
| 175 |
+
|
| 176 |
+
Uses in-memory cache — only makes actual API call
|
| 177 |
+
when token is missing or expired (past midnight).
|
| 178 |
+
|
| 179 |
+
Returns:
|
| 180 |
+
dict with token_id, member_id, agency_id
|
| 181 |
+
|
| 182 |
+
Raises:
|
| 183 |
+
Exception if authentication fails
|
| 184 |
+
"""
|
| 185 |
+
# Return cached token if still valid for today
|
| 186 |
+
if _is_token_valid():
|
| 187 |
+
return {
|
| 188 |
+
"token_id": _token_cache["token_id"],
|
| 189 |
+
"member_id": _token_cache["member_id"],
|
| 190 |
+
"agency_id": _token_cache["agency_id"],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
logger.info("Calling TBO Authenticate API...")
|
| 194 |
+
|
| 195 |
+
end_user_ip = await get_end_user_ip()
|
| 196 |
+
|
| 197 |
+
payload = {
|
| 198 |
+
"ClientId": TBO_CLIENT_ID,
|
| 199 |
+
"UserName": TBO_USERNAME,
|
| 200 |
+
"Password": TBO_PASSWORD,
|
| 201 |
+
"EndUserIp": end_user_ip,
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 205 |
+
response = await client.post(
|
| 206 |
+
TBO_AUTH_URL,
|
| 207 |
+
json=payload,
|
| 208 |
+
headers={"Content-Type": "application/json"}
|
| 209 |
+
)
|
| 210 |
+
response.raise_for_status()
|
| 211 |
+
|
| 212 |
+
data = response.json()
|
| 213 |
+
|
| 214 |
+
# TBO Status codes:
|
| 215 |
+
# 0=NotSet, 1=Successful, 2=Failed,
|
| 216 |
+
# 3=IncorrectUsername, 4=IncorrectPassword, 5=PasswordExpired
|
| 217 |
+
status = data.get("Status")
|
| 218 |
+
|
| 219 |
+
status_messages = {
|
| 220 |
+
0: "Authentication status not set",
|
| 221 |
+
2: "Authentication failed",
|
| 222 |
+
3: "Incorrect TBO username",
|
| 223 |
+
4: "Incorrect TBO password",
|
| 224 |
+
5: "TBO password expired — contact TBO support",
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
if status != 1:
|
| 228 |
+
error_msg = data.get("Error", {}).get("ErrorMessage", "Unknown error")
|
| 229 |
+
friendly_msg = status_messages.get(status, error_msg)
|
| 230 |
+
logger.error(f"TBO Auth failed with status {status}: {friendly_msg}")
|
| 231 |
+
raise Exception(f"TBO Authentication Error: {friendly_msg}")
|
| 232 |
+
|
| 233 |
+
# Store in cache for the rest of the day
|
| 234 |
+
_token_cache["token_id"] = data["TokenId"]
|
| 235 |
+
_token_cache["member_id"] = data["Member"]["MemberId"]
|
| 236 |
+
_token_cache["agency_id"] = data["Member"]["AgencyId"]
|
| 237 |
+
_token_cache["date"] = datetime.now().strftime("%Y-%m-%d")
|
| 238 |
+
|
| 239 |
+
logger.info(
|
| 240 |
+
f"TBO Auth successful | "
|
| 241 |
+
f"MemberId: {_token_cache['member_id']} | "
|
| 242 |
+
f"AgencyId: {_token_cache['agency_id']} | "
|
| 243 |
+
f"Token: {data['TokenId'][:8]}..."
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"token_id": _token_cache["token_id"],
|
| 248 |
+
"member_id": _token_cache["member_id"],
|
| 249 |
+
"agency_id": _token_cache["agency_id"],
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ─── Core: Search Flights ────────────────────────────────────
|
| 254 |
+
|
| 255 |
+
async def search_flights(
|
| 256 |
+
origin: str,
|
| 257 |
+
destination: str,
|
| 258 |
+
departure_date: str,
|
| 259 |
+
adult_count: int = 1,
|
| 260 |
+
child_count: int = 0,
|
| 261 |
+
infant_count: int = 0,
|
| 262 |
+
cabin_class: int = 2,
|
| 263 |
+
trip_type: str = "one-way",
|
| 264 |
+
return_date: Optional[str] = None,
|
| 265 |
+
direct_flight: bool = False,
|
| 266 |
+
) -> dict:
|
| 267 |
+
"""
|
| 268 |
+
Search for available flights via TBO Search API.
|
| 269 |
+
|
| 270 |
+
Flow:
|
| 271 |
+
1. Get token (cached or fresh)
|
| 272 |
+
2. Get public IP
|
| 273 |
+
3. Build TBO request payload
|
| 274 |
+
4. Call TBO Search endpoint
|
| 275 |
+
5. Clean and return response
|
| 276 |
+
|
| 277 |
+
IMPORTANT: Save the trace_id from response!
|
| 278 |
+
It's needed for Book/Ticket calls and
|
| 279 |
+
expires in only 15 minutes.
|
| 280 |
+
|
| 281 |
+
Args:
|
| 282 |
+
origin: IATA code e.g. "DEL"
|
| 283 |
+
destination: IATA code e.g. "BOM"
|
| 284 |
+
departure_date: "YYYY-MM-DD"
|
| 285 |
+
adult_count: number of adults (max 9 total)
|
| 286 |
+
child_count: number of children
|
| 287 |
+
infant_count: number of infants
|
| 288 |
+
cabin_class: 2=Economy, 4=Business, 6=First
|
| 289 |
+
trip_type: "one-way" or "round"
|
| 290 |
+
return_date: "YYYY-MM-DD" (required for round trips)
|
| 291 |
+
direct_flight: filter for direct flights only
|
| 292 |
+
|
| 293 |
+
Returns:
|
| 294 |
+
dict with trace_id and list of flight results
|
| 295 |
+
"""
|
| 296 |
+
auth = await authenticate()
|
| 297 |
+
end_user_ip = await get_end_user_ip()
|
| 298 |
+
|
| 299 |
+
# Build segments (journey legs)
|
| 300 |
+
segments = [
|
| 301 |
+
{
|
| 302 |
+
"Origin": origin.upper().strip(),
|
| 303 |
+
"Destination": destination.upper().strip(),
|
| 304 |
+
"FlightCabinClass": cabin_class,
|
| 305 |
+
"PreferredDepartureTime": _format_date_for_tbo(departure_date),
|
| 306 |
+
"PreferredArrivalTime": _format_date_for_tbo(departure_date),
|
| 307 |
+
}
|
| 308 |
+
]
|
| 309 |
+
|
| 310 |
+
# Add return segment for round trips
|
| 311 |
+
if trip_type in ("round", "return") and return_date:
|
| 312 |
+
segments.append({
|
| 313 |
+
"Origin": destination.upper().strip(),
|
| 314 |
+
"Destination": origin.upper().strip(),
|
| 315 |
+
"FlightCabinClass": cabin_class,
|
| 316 |
+
"PreferredDepartureTime": _format_date_for_tbo(return_date),
|
| 317 |
+
"PreferredArrivalTime": _format_date_for_tbo(return_date),
|
| 318 |
+
})
|
| 319 |
+
|
| 320 |
+
journey_type = _map_trip_type(trip_type)
|
| 321 |
+
|
| 322 |
+
payload = {
|
| 323 |
+
"EndUserIp": end_user_ip,
|
| 324 |
+
"TokenId": auth["token_id"],
|
| 325 |
+
"AdultCount": str(adult_count),
|
| 326 |
+
"ChildCount": str(child_count),
|
| 327 |
+
"InfantCount": str(infant_count),
|
| 328 |
+
"DirectFlight": "true" if direct_flight else "false",
|
| 329 |
+
"OneStopFlight": "false",
|
| 330 |
+
"JourneyType": str(journey_type),
|
| 331 |
+
"PreferredAirlines": None,
|
| 332 |
+
"Segments": segments,
|
| 333 |
+
"Sources": None,
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
logger.info(
|
| 337 |
+
f"TBO Search | {origin} → {destination} | "
|
| 338 |
+
f"{departure_date} | Adults: {adult_count} | "
|
| 339 |
+
f"Class: {cabin_class} | Type: {trip_type}"
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 343 |
+
response = await client.post(
|
| 344 |
+
TBO_SEARCH_URL,
|
| 345 |
+
json=payload,
|
| 346 |
+
headers={"Content-Type": "application/json"}
|
| 347 |
+
)
|
| 348 |
+
response.raise_for_status()
|
| 349 |
+
|
| 350 |
+
data = response.json()
|
| 351 |
+
|
| 352 |
+
# TBO wraps response in "Response" key
|
| 353 |
+
tbo_response = data.get("Response", data)
|
| 354 |
+
|
| 355 |
+
# Check for API-level errors
|
| 356 |
+
error = tbo_response.get("Error", {})
|
| 357 |
+
if error.get("ErrorCode", 0) != 0:
|
| 358 |
+
error_msg = error.get("ErrorMessage", "Unknown search error")
|
| 359 |
+
logger.error(f"TBO Search Error: {error_msg}")
|
| 360 |
+
raise Exception(f"Flight search failed: {error_msg}")
|
| 361 |
+
|
| 362 |
+
return _format_search_response(tbo_response)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _format_search_response(tbo_response: dict) -> dict:
|
| 366 |
+
"""
|
| 367 |
+
Transform TBO's raw search response into a
|
| 368 |
+
clean format that our frontend can easily consume.
|
| 369 |
+
"""
|
| 370 |
+
trace_id = tbo_response.get("TraceId", "")
|
| 371 |
+
raw_results = tbo_response.get("Results", [])
|
| 372 |
+
|
| 373 |
+
if not raw_results:
|
| 374 |
+
return {
|
| 375 |
+
"trace_id": trace_id,
|
| 376 |
+
"origin": tbo_response.get("Origin", ""),
|
| 377 |
+
"destination": tbo_response.get("Destination", ""),
|
| 378 |
+
"total_results": 0,
|
| 379 |
+
"flights": [],
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
# TBO sometimes returns Results as array of arrays
|
| 383 |
+
# e.g. [[flight1, flight2], [flight3]] for round trips
|
| 384 |
+
# We flatten for one-way, keep structure for round trip
|
| 385 |
+
if raw_results and isinstance(raw_results[0], list):
|
| 386 |
+
flat_results = []
|
| 387 |
+
for group in raw_results:
|
| 388 |
+
flat_results.extend(group)
|
| 389 |
+
raw_results = flat_results
|
| 390 |
+
|
| 391 |
+
formatted_flights = []
|
| 392 |
+
for flight in raw_results:
|
| 393 |
+
try:
|
| 394 |
+
formatted = _format_single_flight(flight)
|
| 395 |
+
formatted_flights.append(formatted)
|
| 396 |
+
except Exception as e:
|
| 397 |
+
logger.warning(f"Skipping malformed flight result: {e}")
|
| 398 |
+
continue
|
| 399 |
+
|
| 400 |
+
# Sort cheapest first
|
| 401 |
+
formatted_flights.sort(
|
| 402 |
+
key=lambda x: x.get("fare", {}).get("offered_fare", float("inf"))
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
return {
|
| 406 |
+
"trace_id": trace_id,
|
| 407 |
+
"origin": tbo_response.get("Origin", ""),
|
| 408 |
+
"destination": tbo_response.get("Destination", ""),
|
| 409 |
+
"total_results": len(formatted_flights),
|
| 410 |
+
"flights": formatted_flights,
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _format_single_flight(flight: dict) -> dict:
|
| 415 |
+
"""
|
| 416 |
+
Extract and clean a single flight result from TBO response.
|
| 417 |
+
Only picks fields the frontend actually needs.
|
| 418 |
+
"""
|
| 419 |
+
fare = flight.get("Fare", {})
|
| 420 |
+
segments = flight.get("Segments", [[]])
|
| 421 |
+
|
| 422 |
+
# Segments come as [[seg1, seg2], [seg3]] nested structure
|
| 423 |
+
segment_list = (
|
| 424 |
+
segments[0]
|
| 425 |
+
if segments and isinstance(segments[0], list)
|
| 426 |
+
else segments
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
first_seg = segment_list[0] if segment_list else {}
|
| 430 |
+
last_seg = segment_list[-1] if segment_list else {}
|
| 431 |
+
|
| 432 |
+
airline = first_seg.get("Airline", {})
|
| 433 |
+
origin_airport = first_seg.get("Origin", {}).get("Airport", {})
|
| 434 |
+
dest_airport = last_seg.get("Destination", {}).get("Airport", {})
|
| 435 |
+
|
| 436 |
+
return {
|
| 437 |
+
"result_index": flight.get("ResultIndex", ""),
|
| 438 |
+
"source": flight.get("Source", ""),
|
| 439 |
+
"is_lcc": flight.get("IsLCC", False),
|
| 440 |
+
"is_refundable": flight.get("IsRefundable", False),
|
| 441 |
+
"airline_remarks": flight.get("AirlineRemarks", ""),
|
| 442 |
+
"airline": {
|
| 443 |
+
"code": airline.get("AirlineCode", ""),
|
| 444 |
+
"name": airline.get("AirlineName", ""),
|
| 445 |
+
"flight_number": airline.get("FlightNumber", ""),
|
| 446 |
+
"fare_class": airline.get("FareClass", ""),
|
| 447 |
+
"operating_carrier": airline.get("OperatingCarrier", ""),
|
| 448 |
+
},
|
| 449 |
+
"origin": {
|
| 450 |
+
"airport_code": origin_airport.get("AirportCode", ""),
|
| 451 |
+
"airport_name": origin_airport.get("AirportName", ""),
|
| 452 |
+
"city_name": origin_airport.get("CityName", ""),
|
| 453 |
+
"city_code": origin_airport.get("CityCode", ""),
|
| 454 |
+
"country_name": origin_airport.get("CountryName", ""),
|
| 455 |
+
"terminal": origin_airport.get("Terminal", ""),
|
| 456 |
+
"departure_time": first_seg.get("DepTime", ""),
|
| 457 |
+
},
|
| 458 |
+
"destination": {
|
| 459 |
+
"airport_code": dest_airport.get("AirportCode", ""),
|
| 460 |
+
"airport_name": dest_airport.get("AirportName", ""),
|
| 461 |
+
"city_name": dest_airport.get("CityName", ""),
|
| 462 |
+
"city_code": dest_airport.get("CityCode", ""),
|
| 463 |
+
"country_name": dest_airport.get("CountryName", ""),
|
| 464 |
+
"terminal": dest_airport.get("Terminal", ""),
|
| 465 |
+
"arrival_time": last_seg.get("ArrTime", ""),
|
| 466 |
+
},
|
| 467 |
+
"duration": first_seg.get("Duration", 0),
|
| 468 |
+
"accumulated_duration": first_seg.get("AccumulatedDuration", 0),
|
| 469 |
+
"stop_count": max(0, len(segment_list) - 1),
|
| 470 |
+
"fare": {
|
| 471 |
+
"currency": fare.get("Currency", "INR"),
|
| 472 |
+
"base_fare": fare.get("BaseFare", 0),
|
| 473 |
+
"tax": fare.get("Tax", 0),
|
| 474 |
+
"yq_tax": fare.get("YQTax", 0),
|
| 475 |
+
"offered_fare": fare.get("OfferedFare", 0),
|
| 476 |
+
"published_fare": fare.get("PublishedFare", 0),
|
| 477 |
+
"other_charges": fare.get("OtherCharges", 0),
|
| 478 |
+
"commission_earned": fare.get("CommissionEarned", 0),
|
| 479 |
+
},
|
| 480 |
+
"fare_breakdown": flight.get("FareBreakdown", []),
|
| 481 |
+
"segments": segment_list,
|
| 482 |
+
"last_ticket_date": flight.get("LastTicketDate", ""),
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
# ─── Core: Book Flight (Non-LCC only) ────────────────────────
|
| 487 |
+
|
| 488 |
+
async def book_flight(
|
| 489 |
+
result_index: str,
|
| 490 |
+
trace_id: str,
|
| 491 |
+
passengers: list,
|
| 492 |
+
) -> dict:
|
| 493 |
+
"""
|
| 494 |
+
Book a Non-LCC flight to get a PNR (hold booking).
|
| 495 |
+
|
| 496 |
+
IMPORTANT:
|
| 497 |
+
- Only for Non-LCC flights (IsLCC = False in search results)
|
| 498 |
+
- For LCC flights, call ticket_flight_lcc() directly
|
| 499 |
+
- If IsPriceChanged=True in response, re-send with updated fare
|
| 500 |
+
- trace_id must be used within 15 minutes of search
|
| 501 |
+
|
| 502 |
+
Args:
|
| 503 |
+
result_index: from search response
|
| 504 |
+
trace_id: from search response (15 min expiry!)
|
| 505 |
+
passengers: list of passenger dicts
|
| 506 |
+
|
| 507 |
+
Returns:
|
| 508 |
+
dict with booking_id, pnr, and fare details
|
| 509 |
+
"""
|
| 510 |
+
auth = await authenticate()
|
| 511 |
+
end_user_ip = await get_end_user_ip()
|
| 512 |
+
|
| 513 |
+
# Format passengers for TBO
|
| 514 |
+
tbo_passengers = _format_passengers_for_tbo(passengers)
|
| 515 |
+
|
| 516 |
+
payload = {
|
| 517 |
+
"EndUserIp": end_user_ip,
|
| 518 |
+
"TokenId": auth["token_id"],
|
| 519 |
+
"TraceId": trace_id,
|
| 520 |
+
"ResultIndex": result_index,
|
| 521 |
+
"Passengers": tbo_passengers,
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
logger.info(
|
| 525 |
+
f"TBO Book | ResultIndex: {result_index} | "
|
| 526 |
+
f"Passengers: {len(tbo_passengers)}"
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 530 |
+
response = await client.post(
|
| 531 |
+
TBO_BOOK_URL,
|
| 532 |
+
json=payload,
|
| 533 |
+
headers={"Content-Type": "application/json"}
|
| 534 |
+
)
|
| 535 |
+
response.raise_for_status()
|
| 536 |
+
|
| 537 |
+
data = response.json()
|
| 538 |
+
tbo_response = data.get("Response", data)
|
| 539 |
+
|
| 540 |
+
is_price_changed = tbo_response.get("IsPriceChanged", False)
|
| 541 |
+
is_time_changed = tbo_response.get("IsTimeChanged", False)
|
| 542 |
+
|
| 543 |
+
if is_price_changed:
|
| 544 |
+
logger.warning("TBO Book: Price has changed! Frontend must confirm new price.")
|
| 545 |
+
if is_time_changed:
|
| 546 |
+
logger.warning("TBO Book: Flight time has changed! Frontend must inform user.")
|
| 547 |
+
|
| 548 |
+
itinerary = tbo_response.get("FlightItinerary", {})
|
| 549 |
+
|
| 550 |
+
return {
|
| 551 |
+
"success": True,
|
| 552 |
+
"is_price_changed": is_price_changed,
|
| 553 |
+
"is_time_changed": is_time_changed,
|
| 554 |
+
"booking_id": itinerary.get("BookingId"),
|
| 555 |
+
"pnr": itinerary.get("PNR"),
|
| 556 |
+
"is_lcc": itinerary.get("IsLCC", False),
|
| 557 |
+
"status": tbo_response.get("Status"),
|
| 558 |
+
"fare": itinerary.get("Fare", {}),
|
| 559 |
+
"passengers": itinerary.get("Passenger", []),
|
| 560 |
+
"segments": itinerary.get("Segments", []),
|
| 561 |
+
"ssr_denied": tbo_response.get("SSRDenied", ""),
|
| 562 |
+
"ssr_message": tbo_response.get("SSRMessage", ""),
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
# ─── Core: Ticket LCC Flights ────────────────────────────────
|
| 567 |
+
|
| 568 |
+
async def ticket_flight_lcc(
|
| 569 |
+
result_index: str,
|
| 570 |
+
trace_id: str,
|
| 571 |
+
passengers: list,
|
| 572 |
+
is_price_change_accepted: bool = False,
|
| 573 |
+
) -> dict:
|
| 574 |
+
"""
|
| 575 |
+
Issue ticket for LCC (Low Cost Carrier) flights directly.
|
| 576 |
+
No prior Book call needed for LCC.
|
| 577 |
+
|
| 578 |
+
LCC examples: SpiceJet, IndiGo, GoAir, AirAsia, etc.
|
| 579 |
+
Non-LCC examples: Air India, Jet Airways (via GDS)
|
| 580 |
+
|
| 581 |
+
Args:
|
| 582 |
+
result_index: from search response
|
| 583 |
+
trace_id: from search response (15 min expiry!)
|
| 584 |
+
passengers: list of passenger dicts with fare
|
| 585 |
+
is_price_change_accepted: set True if user confirmed price change
|
| 586 |
+
|
| 587 |
+
Returns:
|
| 588 |
+
dict with pnr, booking_id, ticket_status
|
| 589 |
+
"""
|
| 590 |
+
auth = await authenticate()
|
| 591 |
+
end_user_ip = await get_end_user_ip()
|
| 592 |
+
|
| 593 |
+
tbo_passengers = _format_passengers_for_tbo(passengers)
|
| 594 |
+
|
| 595 |
+
payload = {
|
| 596 |
+
"EndUserIp": end_user_ip,
|
| 597 |
+
"TokenId": auth["token_id"],
|
| 598 |
+
"TraceId": trace_id,
|
| 599 |
+
"ResultIndex": result_index,
|
| 600 |
+
"Passengers": tbo_passengers,
|
| 601 |
+
"IsPriceChangeAccepted": is_price_change_accepted,
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
logger.info(f"TBO Ticket LCC | ResultIndex: {result_index}")
|
| 605 |
+
|
| 606 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 607 |
+
response = await client.post(
|
| 608 |
+
TBO_TICKET_URL,
|
| 609 |
+
json=payload,
|
| 610 |
+
headers={"Content-Type": "application/json"}
|
| 611 |
+
)
|
| 612 |
+
response.raise_for_status()
|
| 613 |
+
|
| 614 |
+
data = response.json()
|
| 615 |
+
tbo_response = data.get("Response", data)
|
| 616 |
+
|
| 617 |
+
# TicketStatus: 0=Failed, 1=Successful, 2=NotSaved,
|
| 618 |
+
# 3=NotCreated, 5=InProgress, 8=PriceChanged, 9=OtherError
|
| 619 |
+
ticket_status = tbo_response.get("TicketStatus")
|
| 620 |
+
|
| 621 |
+
if ticket_status == 8:
|
| 622 |
+
logger.warning("LCC Ticket: Price changed — user must reconfirm")
|
| 623 |
+
elif ticket_status not in (1, 5):
|
| 624 |
+
logger.error(f"LCC Ticket failed with status: {ticket_status}")
|
| 625 |
+
|
| 626 |
+
return {
|
| 627 |
+
"success": ticket_status in (1, 5),
|
| 628 |
+
"pnr": tbo_response.get("PNR"),
|
| 629 |
+
"booking_id": tbo_response.get("BookingId"),
|
| 630 |
+
"is_price_changed": tbo_response.get("IsPriceChanged", False),
|
| 631 |
+
"is_time_changed": tbo_response.get("IsTimeChanged", False),
|
| 632 |
+
"ticket_status": ticket_status,
|
| 633 |
+
"message": tbo_response.get("Message", ""),
|
| 634 |
+
"flight_itinerary": tbo_response.get("FlightItinerary", {}),
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
# ─── Core: Ticket Non-LCC Flights ────────────────────────────
|
| 639 |
+
|
| 640 |
+
async def ticket_flight_non_lcc(
|
| 641 |
+
trace_id: str,
|
| 642 |
+
pnr: str,
|
| 643 |
+
booking_id: int,
|
| 644 |
+
is_price_change_accepted: bool = False,
|
| 645 |
+
) -> dict:
|
| 646 |
+
"""
|
| 647 |
+
Issue ticket for Non-LCC flights (after Book call).
|
| 648 |
+
|
| 649 |
+
Must be called AFTER book_flight() for Non-LCC airlines.
|
| 650 |
+
Uses PNR and BookingId from book_flight() response.
|
| 651 |
+
|
| 652 |
+
Args:
|
| 653 |
+
trace_id: from original search response
|
| 654 |
+
pnr: from book_flight() response
|
| 655 |
+
booking_id: from book_flight() response
|
| 656 |
+
is_price_change_accepted: True if user confirmed price change
|
| 657 |
+
|
| 658 |
+
Returns:
|
| 659 |
+
dict with ticket details
|
| 660 |
+
"""
|
| 661 |
+
auth = await authenticate()
|
| 662 |
+
end_user_ip = await get_end_user_ip()
|
| 663 |
+
|
| 664 |
+
payload = {
|
| 665 |
+
"EndUserIp": end_user_ip,
|
| 666 |
+
"TokenId": auth["token_id"],
|
| 667 |
+
"TraceId": trace_id,
|
| 668 |
+
"PNR": pnr,
|
| 669 |
+
"BookingId": booking_id,
|
| 670 |
+
"IsPriceChangeAccepted": is_price_change_accepted,
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
logger.info(f"TBO Ticket Non-LCC | PNR: {pnr} | BookingId: {booking_id}")
|
| 674 |
+
|
| 675 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 676 |
+
response = await client.post(
|
| 677 |
+
TBO_TICKET_URL,
|
| 678 |
+
json=payload,
|
| 679 |
+
headers={"Content-Type": "application/json"}
|
| 680 |
+
)
|
| 681 |
+
response.raise_for_status()
|
| 682 |
+
|
| 683 |
+
data = response.json()
|
| 684 |
+
tbo_response = data.get("Response", data)
|
| 685 |
+
|
| 686 |
+
ticket_status = tbo_response.get("TicketStatus")
|
| 687 |
+
|
| 688 |
+
return {
|
| 689 |
+
"success": ticket_status in (1, 5),
|
| 690 |
+
"pnr": tbo_response.get("PNR"),
|
| 691 |
+
"booking_id": tbo_response.get("BookingId"),
|
| 692 |
+
"is_price_changed": tbo_response.get("IsPriceChanged", False),
|
| 693 |
+
"ticket_status": ticket_status,
|
| 694 |
+
"message": tbo_response.get("Message", ""),
|
| 695 |
+
"flight_itinerary": tbo_response.get("FlightItinerary", {}),
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
# ─── Helper: Format Passengers ───────────────────────────────
|
| 700 |
+
|
| 701 |
+
def _format_passengers_for_tbo(passengers: list) -> list:
|
| 702 |
+
"""
|
| 703 |
+
Convert passenger data from our API format to TBO format.
|
| 704 |
+
Handles all required and optional fields.
|
| 705 |
+
"""
|
| 706 |
+
tbo_passengers = []
|
| 707 |
+
|
| 708 |
+
for pax in passengers:
|
| 709 |
+
tbo_pax = {
|
| 710 |
+
"Title": pax.get("title", "Mr"),
|
| 711 |
+
"FirstName": pax.get("first_name", ""),
|
| 712 |
+
"LastName": pax.get("last_name", ""),
|
| 713 |
+
"PaxType": str(pax.get("pax_type", 1)), # 1=Adult, 2=Child, 3=Infant
|
| 714 |
+
"DateOfBirth": pax.get("date_of_birth", ""),
|
| 715 |
+
"Gender": str(pax.get("gender", 1)), # 1=Male, 2=Female
|
| 716 |
+
"PassportNo": pax.get("passport_no", ""),
|
| 717 |
+
"PassportExpiry": pax.get("passport_expiry", ""),
|
| 718 |
+
"AddressLine1": pax.get("address_line1", ""),
|
| 719 |
+
"AddressLine2": pax.get("address_line2", ""),
|
| 720 |
+
"City": pax.get("city", ""),
|
| 721 |
+
"CountryCode": pax.get("country_code", "IN"),
|
| 722 |
+
"CountryName": pax.get("country_name", "India"),
|
| 723 |
+
"ContactNo": pax.get("contact_no", ""),
|
| 724 |
+
"Email": pax.get("email", ""),
|
| 725 |
+
"IsLeadPax": pax.get("is_lead_pax", False),
|
| 726 |
+
"Nationality": pax.get("nationality", "IN"),
|
| 727 |
+
# GST fields (mandatory in API but blank for B2C customers)
|
| 728 |
+
"GSTCompanyAddress": pax.get("gst_company_address", ""),
|
| 729 |
+
"GSTCompanyContactNumber": pax.get("gst_company_contact", ""),
|
| 730 |
+
"GSTCompanyName": pax.get("gst_company_name", ""),
|
| 731 |
+
"GSTNumber": pax.get("gst_number", ""),
|
| 732 |
+
"GSTCompanyEmail": pax.get("gst_company_email", ""),
|
| 733 |
+
"Fare": {
|
| 734 |
+
"BaseFare": pax.get("fare", {}).get("base_fare", 0),
|
| 735 |
+
"Tax": pax.get("fare", {}).get("tax", 0),
|
| 736 |
+
"TransactionFee": pax.get("fare", {}).get("transaction_fee", 0),
|
| 737 |
+
"YQTax": pax.get("fare", {}).get("yq_tax", 0),
|
| 738 |
+
"AdditionalTxnFeeOfrd": 0,
|
| 739 |
+
"AdditionalTxnFeePub": 0,
|
| 740 |
+
"AirTransFee": 0,
|
| 741 |
+
},
|
| 742 |
+
}
|
| 743 |
+
tbo_passengers.append(tbo_pax)
|
| 744 |
+
|
| 745 |
+
return tbo_passengers
|
tavily_client.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tavily API Client for web search functionality.
|
| 3 |
+
Used for finding booking websites, travel deals, and real-time travel information.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from tavily import TavilyClient
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TavilySearchClient:
|
| 14 |
+
"""Client for Tavily web search API."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
api_key = os.getenv("TAVILY_API_KEY")
|
| 18 |
+
if not api_key:
|
| 19 |
+
raise ValueError("TAVILY_API_KEY environment variable is not set")
|
| 20 |
+
self.client = TavilyClient(api_key=api_key)
|
| 21 |
+
|
| 22 |
+
async def search(self, query: str, max_results: int = 5) -> dict:
|
| 23 |
+
"""
|
| 24 |
+
Perform a web search using Tavily API.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
query: Search query string
|
| 28 |
+
max_results: Maximum number of results to return
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Dictionary containing search results with URLs and summaries
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
# Add travel context to improve results
|
| 35 |
+
enhanced_query = f"travel booking {query}"
|
| 36 |
+
|
| 37 |
+
response = self.client.search(
|
| 38 |
+
query=enhanced_query,
|
| 39 |
+
search_depth="advanced",
|
| 40 |
+
max_results=max_results,
|
| 41 |
+
include_domains=[
|
| 42 |
+
"booking.com", "expedia.com", "skyscanner.com",
|
| 43 |
+
"kayak.com", "makemytrip.com", "goibibo.com",
|
| 44 |
+
"tripadvisor.com", "airbnb.com", "hotels.com",
|
| 45 |
+
"agoda.com", "cleartrip.com", "yatra.com"
|
| 46 |
+
]
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
results = []
|
| 50 |
+
for result in response.get("results", []):
|
| 51 |
+
results.append({
|
| 52 |
+
"title": result.get("title", ""),
|
| 53 |
+
"url": result.get("url", ""),
|
| 54 |
+
"content": result.get("content", "")[:300],
|
| 55 |
+
"score": result.get("score", 0)
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
return {
|
| 59 |
+
"query": query,
|
| 60 |
+
"results": results,
|
| 61 |
+
"answer": response.get("answer", "")
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
return {
|
| 66 |
+
"query": query,
|
| 67 |
+
"results": [],
|
| 68 |
+
"error": str(e)
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async def search_flights(self, origin: str, destination: str) -> dict:
|
| 72 |
+
"""Search for flight booking options."""
|
| 73 |
+
query = f"best flight tickets from {origin} to {destination} cheap deals"
|
| 74 |
+
return await self.search(query)
|
| 75 |
+
|
| 76 |
+
async def search_hotels(self, destination: str, budget: str = "") -> dict:
|
| 77 |
+
"""Search for hotel booking options."""
|
| 78 |
+
query = f"best hotels in {destination} {budget} booking"
|
| 79 |
+
return await self.search(query)
|
| 80 |
+
|
| 81 |
+
async def search_activities(self, destination: str) -> dict:
|
| 82 |
+
"""Search for activities and experiences."""
|
| 83 |
+
query = f"best things to do activities experiences in {destination}"
|
| 84 |
+
return await self.search(query)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# Singleton instance
|
| 88 |
+
_client = None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def get_tavily_client() -> TavilySearchClient:
|
| 92 |
+
"""Get or create Tavily client singleton."""
|
| 93 |
+
global _client
|
| 94 |
+
if _client is None:
|
| 95 |
+
_client = TavilySearchClient()
|
| 96 |
+
return _client
|
tools.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tool implementations for the Travel AI Agent.
|
| 3 |
+
These tools are called by the Gemini agent to perform specific actions.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import urllib.parse
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from tavily_client import get_tavily_client
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def search_web(query: str) -> dict:
|
| 18 |
+
"""
|
| 19 |
+
Search the web using Tavily for travel-related information.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
query: Search query
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
Search results with URLs and summaries
|
| 26 |
+
"""
|
| 27 |
+
client = get_tavily_client()
|
| 28 |
+
results = await client.search(query)
|
| 29 |
+
return results
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def generate_itinerary(destination: str, days: int, preferences: Optional[str] = None) -> dict:
|
| 33 |
+
"""
|
| 34 |
+
Generate a base itinerary structure (actual content generated by Gemini).
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
destination: Travel destination
|
| 38 |
+
days: Number of days
|
| 39 |
+
preferences: Optional travel preferences
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Itinerary metadata and prompt for generation
|
| 43 |
+
"""
|
| 44 |
+
return {
|
| 45 |
+
"destination": destination,
|
| 46 |
+
"days": days,
|
| 47 |
+
"preferences": preferences or "balanced mix of culture, food, and relaxation",
|
| 48 |
+
"generate_prompt": True,
|
| 49 |
+
"message": f"📅 Generating a {days}-day itinerary for {destination}..."
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def recommend_destinations(
|
| 54 |
+
budget: Optional[str] = None,
|
| 55 |
+
style: Optional[str] = None,
|
| 56 |
+
interests: Optional[list] = None,
|
| 57 |
+
duration: Optional[str] = None,
|
| 58 |
+
season: Optional[str] = None
|
| 59 |
+
) -> dict:
|
| 60 |
+
"""
|
| 61 |
+
Structure preferences for destination recommendations.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
budget: Budget range (e.g., "low", "medium", "luxury")
|
| 65 |
+
style: Travel style (e.g., "adventure", "relaxation", "cultural")
|
| 66 |
+
interests: List of interests
|
| 67 |
+
duration: Trip duration
|
| 68 |
+
season: Preferred travel season
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
Structured preferences for recommendation generation
|
| 72 |
+
"""
|
| 73 |
+
preferences = {
|
| 74 |
+
"budget": budget or "flexible",
|
| 75 |
+
"style": style or "balanced",
|
| 76 |
+
"interests": interests or ["sightseeing", "local cuisine", "culture"],
|
| 77 |
+
"duration": duration or "flexible",
|
| 78 |
+
"season": season or "any"
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return {
|
| 82 |
+
"preferences": preferences,
|
| 83 |
+
"generate_prompt": True,
|
| 84 |
+
"message": "🗺️ Finding perfect destinations based on your preferences..."
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Tool definitions for Gemini function calling
|
| 89 |
+
TOOL_DEFINITIONS = [
|
| 90 |
+
{
|
| 91 |
+
"name": "search_web",
|
| 92 |
+
"description": "Search the web for travel information, booking websites, flight prices, hotel deals, and current travel news. Use this when users ask about booking, prices, or need real-time information.",
|
| 93 |
+
"parameters": {
|
| 94 |
+
"type": "object",
|
| 95 |
+
"properties": {
|
| 96 |
+
"query": {
|
| 97 |
+
"type": "string",
|
| 98 |
+
"description": "The search query for travel-related information"
|
| 99 |
+
}
|
| 100 |
+
},
|
| 101 |
+
"required": ["query"]
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
|
| 105 |
+
{
|
| 106 |
+
"name": "generate_itinerary",
|
| 107 |
+
"description": "Generate a detailed day-by-day travel itinerary for a destination. Use this when users want to plan a trip or ask for itinerary suggestions.",
|
| 108 |
+
"parameters": {
|
| 109 |
+
"type": "object",
|
| 110 |
+
"properties": {
|
| 111 |
+
"destination": {
|
| 112 |
+
"type": "string",
|
| 113 |
+
"description": "The travel destination"
|
| 114 |
+
},
|
| 115 |
+
"days": {
|
| 116 |
+
"type": "integer",
|
| 117 |
+
"description": "Number of days for the trip"
|
| 118 |
+
},
|
| 119 |
+
"preferences": {
|
| 120 |
+
"type": "string",
|
| 121 |
+
"description": "Optional travel preferences or interests"
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
"required": ["destination", "days"]
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"name": "recommend_destinations",
|
| 129 |
+
"description": "Recommend travel destinations based on user preferences. Use this when users describe what kind of trip they want.",
|
| 130 |
+
"parameters": {
|
| 131 |
+
"type": "object",
|
| 132 |
+
"properties": {
|
| 133 |
+
"budget": {
|
| 134 |
+
"type": "string",
|
| 135 |
+
"description": "Budget range: 'budget', 'moderate', 'luxury'"
|
| 136 |
+
},
|
| 137 |
+
"style": {
|
| 138 |
+
"type": "string",
|
| 139 |
+
"description": "Travel style: 'adventure', 'relaxation', 'cultural', 'romantic', 'family'"
|
| 140 |
+
},
|
| 141 |
+
"interests": {
|
| 142 |
+
"type": "array",
|
| 143 |
+
"items": {"type": "string"},
|
| 144 |
+
"description": "List of interests like 'beaches', 'mountains', 'history', 'food'"
|
| 145 |
+
},
|
| 146 |
+
"duration": {
|
| 147 |
+
"type": "string",
|
| 148 |
+
"description": "Trip duration like '3 days', '1 week'"
|
| 149 |
+
},
|
| 150 |
+
"season": {
|
| 151 |
+
"type": "string",
|
| 152 |
+
"description": "Preferred travel season"
|
| 153 |
+
}
|
| 154 |
+
},
|
| 155 |
+
"required": []
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
]
|