bharatverse11 commited on
Commit
e737a76
·
1 Parent(s): bf6a17a

Add SafarX AI Travel Companion backend with Docker support

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