destinyebuka commited on
Commit
6c296a0
·
1 Parent(s): 17f1f90
Files changed (2) hide show
  1. app/routes/search.py +164 -106
  2. main.py +1 -1
app/routes/search.py CHANGED
@@ -1,21 +1,21 @@
1
  # ============================================================
2
- # app/routes/search.py - AIDA Dedicated Search Endpoint
3
  # ============================================================
4
  """
5
- Dedicated search endpoint for AIDA AI-powered property search.
6
- This endpoint is designed for search bars where users type natural language queries.
7
  No intent detection - directly processes search queries and returns listings.
8
  """
9
 
 
10
  import logging
11
- from fastapi import APIRouter, Depends, HTTPException, status
12
- from pydantic import BaseModel, Field
13
- from typing import Optional, List
14
- from bson import ObjectId
15
 
16
  from app.database import get_db
 
17
  from app.models.listing import Listing
18
- from app.guards.jwt_guard import get_current_user
19
 
20
  # Import existing search logic
21
  from app.ai.agent.nodes.search_query import (
@@ -32,34 +32,6 @@ router = APIRouter(tags=["AIDA Search"])
32
  logger = logging.getLogger(__name__)
33
 
34
 
35
- # ============================================================
36
- # REQUEST/RESPONSE SCHEMAS
37
- # ============================================================
38
-
39
- class SearchRequestDto(BaseModel):
40
- """Search request DTO"""
41
- query: str = Field(
42
- ...,
43
- min_length=1,
44
- description="Natural language search query (e.g., 'I want a house of 52k in Cotonou')"
45
- )
46
- limit: Optional[int] = Field(
47
- default=10,
48
- ge=1,
49
- le=50,
50
- description="Maximum number of results to return"
51
- )
52
-
53
-
54
- class SearchResponseDto(BaseModel):
55
- """Search response DTO"""
56
- success: bool
57
- message: str # AIDA's friendly message
58
- data: List[dict] # Standard listing format
59
- total: int
60
- search_params: Optional[dict] = None # Extracted search parameters for debugging
61
-
62
-
63
  # ============================================================
64
  # AIDA MESSAGE GENERATOR
65
  # ============================================================
@@ -69,10 +41,7 @@ def generate_aida_message(
69
  search_params: dict,
70
  currency: str = "XOF"
71
  ) -> str:
72
- """
73
- Generate a short, friendly message from AIDA based on search results.
74
- This is a lightweight version - no LLM call for speed.
75
- """
76
  count = len(results)
77
  location = search_params.get("location", "")
78
  max_price = search_params.get("max_price")
@@ -80,7 +49,6 @@ def generate_aida_message(
80
  listing_type = search_params.get("listing_type")
81
  bedrooms = search_params.get("bedrooms")
82
 
83
- # Build context parts
84
  parts = []
85
 
86
  if location:
@@ -109,56 +77,35 @@ def generate_aida_message(
109
 
110
  if count == 0:
111
  if context:
112
- return f"I couldn't find any properties {context}. Try adjusting your search criteria!"
113
- return "I couldn't find any properties matching your search. Try a different query!"
114
  elif count == 1:
115
  if context:
116
- return f"I found 1 property {context}! 🏠"
117
- return "I found 1 property that matches your search! 🏠"
118
  else:
119
  if context:
120
  return f"Found {count} properties {context}! 🏠"
121
- return f"Found {count} properties matching your search! 🏠"
122
 
123
 
124
  # ============================================================
125
- # SEARCH ENDPOINT
126
  # ============================================================
127
 
128
- @router.post("/", response_model=SearchResponseDto)
129
- async def aida_search(
130
- dto: SearchRequestDto,
131
- current_user: Optional[dict] = Depends(get_current_user),
132
- ):
133
  """
134
- AIDA AI-powered property search.
135
-
136
- Send a natural language query like:
137
- - "I want a house of 52k in Cotonou"
138
- - "2 bedroom apartment near the beach"
139
- - "Cheap room for students in Porto-Novo"
140
-
141
- AIDA will:
142
- 1. Extract search parameters (location, price, bedrooms, etc.)
143
- 2. Search the database using hybrid search (semantic + filters)
144
- 3. Return listings in standard API format with a friendly message
145
-
146
- Requires: Bearer token in Authorization header
147
  """
148
 
149
- logger.info(f"AIDA Search: {dto.query[:50]}...")
150
-
151
  try:
152
- # STEP 1: Extract search parameters from natural language
153
- search_params = await extract_search_params(dto.query)
154
 
155
  if not search_params:
156
- logger.warning("Could not extract search parameters")
157
  search_params = {}
158
 
159
- logger.info(f"Extracted params: {search_params}")
160
-
161
- # STEP 2: Infer currency from location
162
  currency = "XOF"
163
  if search_params.get("location"):
164
  inferred = await infer_currency_from_location(search_params["location"])
@@ -167,82 +114,193 @@ async def aida_search(
167
  else:
168
  currency = inferred or "XOF"
169
 
170
- # STEP 3: Perform search
171
- # Priority: MongoDB for location searches (exact match), Qdrant for semantic
172
  results = []
173
 
174
  if search_params.get("location"):
175
- # Use MongoDB for exact location match
176
  results = await search_listings(search_params)
177
 
178
- # If no results, try semantic search
179
  if not results:
180
- logger.info("No exact matches, trying semantic search...")
181
  results, _ = await search_listings_hybrid(
182
- user_query=dto.query,
183
  search_params=search_params,
184
- limit=dto.limit,
185
  mode="relaxed"
186
  )
187
  else:
188
- # Use semantic search for general queries
189
  results, _ = await search_listings_hybrid(
190
- user_query=dto.query,
191
  search_params=search_params,
192
- limit=dto.limit,
193
  mode="strict"
194
  )
195
 
196
  if not results:
197
  results, _ = await search_listings_hybrid(
198
- user_query=dto.query,
199
  search_params=search_params,
200
- limit=dto.limit,
201
  mode="relaxed"
202
  )
203
 
204
- # STEP 4: Format listings to standard API format
205
  formatted_listings = []
206
- for doc in results[:dto.limit]:
207
- # Ensure _id is string
208
  if "_id" in doc and not isinstance(doc["_id"], str):
209
  doc["_id"] = str(doc["_id"])
210
 
211
- # Remove internal fields (vector search metadata)
212
  doc.pop("_relevance_score", None)
213
  doc.pop("_is_suggestion", None)
214
  doc.pop("location_lower", None)
215
  doc.pop("listing_type_lower", None)
216
 
217
  try:
218
- # Use Listing model to validate and format
219
  listing = Listing(**doc)
220
  formatted_listings.append(listing.model_dump(by_alias=True))
221
  except Exception as e:
222
- # If validation fails, just use raw doc
223
  logger.warning(f"Listing validation failed: {e}")
224
  formatted_listings.append(doc)
225
 
226
- # STEP 5: Generate AIDA message
227
  message = generate_aida_message(
228
  results=formatted_listings,
229
  search_params=search_params,
230
  currency=currency
231
  )
232
 
233
- logger.info(f"Search complete: {len(formatted_listings)} results")
234
-
235
- return SearchResponseDto(
236
- success=True,
237
- message=message,
238
- data=formatted_listings,
239
- total=len(formatted_listings),
240
- search_params=search_params
241
- )
242
 
243
  except Exception as e:
244
- logger.error(f"AIDA Search error: {e}")
245
- raise HTTPException(
246
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
247
- detail=f"Search failed: {str(e)}"
248
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # ============================================================
2
+ # app/routes/search.py - AIDA Live Search via WebSocket
3
  # ============================================================
4
  """
5
+ Real-time search endpoint for AIDA AI-powered property search.
6
+ WebSocket-based for live search as user types in search bar.
7
  No intent detection - directly processes search queries and returns listings.
8
  """
9
 
10
+ import json
11
  import logging
12
+ import asyncio
13
+ from datetime import datetime
14
+ from fastapi import APIRouter, WebSocket, Query, WebSocketDisconnect
 
15
 
16
  from app.database import get_db
17
+ from app.core.security import verify_token
18
  from app.models.listing import Listing
 
19
 
20
  # Import existing search logic
21
  from app.ai.agent.nodes.search_query import (
 
32
  logger = logging.getLogger(__name__)
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # ============================================================
36
  # AIDA MESSAGE GENERATOR
37
  # ============================================================
 
41
  search_params: dict,
42
  currency: str = "XOF"
43
  ) -> str:
44
+ """Generate a short, friendly message from AIDA based on search results."""
 
 
 
45
  count = len(results)
46
  location = search_params.get("location", "")
47
  max_price = search_params.get("max_price")
 
49
  listing_type = search_params.get("listing_type")
50
  bedrooms = search_params.get("bedrooms")
51
 
 
52
  parts = []
53
 
54
  if location:
 
77
 
78
  if count == 0:
79
  if context:
80
+ return f"I couldn't find any properties {context}. Try adjusting your search!"
81
+ return "I couldn't find any properties matching your search."
82
  elif count == 1:
83
  if context:
84
+ return f"Found 1 property {context}! 🏠"
85
+ return "Found 1 property! 🏠"
86
  else:
87
  if context:
88
  return f"Found {count} properties {context}! 🏠"
89
+ return f"Found {count} properties! 🏠"
90
 
91
 
92
  # ============================================================
93
+ # SEARCH PROCESSING
94
  # ============================================================
95
 
96
+ async def process_search(query: str, limit: int = 10) -> dict:
 
 
 
 
97
  """
98
+ Process a search query and return formatted results.
 
 
 
 
 
 
 
 
 
 
 
 
99
  """
100
 
 
 
101
  try:
102
+ # Extract search parameters from natural language
103
+ search_params = await extract_search_params(query)
104
 
105
  if not search_params:
 
106
  search_params = {}
107
 
108
+ # Infer currency from location
 
 
109
  currency = "XOF"
110
  if search_params.get("location"):
111
  inferred = await infer_currency_from_location(search_params["location"])
 
114
  else:
115
  currency = inferred or "XOF"
116
 
117
+ # Perform search
 
118
  results = []
119
 
120
  if search_params.get("location"):
 
121
  results = await search_listings(search_params)
122
 
 
123
  if not results:
 
124
  results, _ = await search_listings_hybrid(
125
+ user_query=query,
126
  search_params=search_params,
127
+ limit=limit,
128
  mode="relaxed"
129
  )
130
  else:
 
131
  results, _ = await search_listings_hybrid(
132
+ user_query=query,
133
  search_params=search_params,
134
+ limit=limit,
135
  mode="strict"
136
  )
137
 
138
  if not results:
139
  results, _ = await search_listings_hybrid(
140
+ user_query=query,
141
  search_params=search_params,
142
+ limit=limit,
143
  mode="relaxed"
144
  )
145
 
146
+ # Format listings to standard API format
147
  formatted_listings = []
148
+ for doc in results[:limit]:
 
149
  if "_id" in doc and not isinstance(doc["_id"], str):
150
  doc["_id"] = str(doc["_id"])
151
 
 
152
  doc.pop("_relevance_score", None)
153
  doc.pop("_is_suggestion", None)
154
  doc.pop("location_lower", None)
155
  doc.pop("listing_type_lower", None)
156
 
157
  try:
 
158
  listing = Listing(**doc)
159
  formatted_listings.append(listing.model_dump(by_alias=True))
160
  except Exception as e:
 
161
  logger.warning(f"Listing validation failed: {e}")
162
  formatted_listings.append(doc)
163
 
164
+ # Generate AIDA message
165
  message = generate_aida_message(
166
  results=formatted_listings,
167
  search_params=search_params,
168
  currency=currency
169
  )
170
 
171
+ return {
172
+ "success": True,
173
+ "message": message,
174
+ "data": formatted_listings,
175
+ "total": len(formatted_listings),
176
+ "search_params": search_params,
177
+ "query": query,
178
+ }
 
179
 
180
  except Exception as e:
181
+ logger.error(f"Search processing error: {e}")
182
+ return {
183
+ "success": False,
184
+ "message": f"Search failed: {str(e)}",
185
+ "data": [],
186
+ "total": 0,
187
+ "query": query,
188
+ }
189
+
190
+
191
+ # ============================================================
192
+ # WEBSOCKET SEARCH ENDPOINT
193
+ # ============================================================
194
+
195
+ @router.websocket("/ws/search")
196
+ async def websocket_search_endpoint(
197
+ websocket: WebSocket,
198
+ token: str = Query(...),
199
+ ):
200
+ """
201
+ WebSocket endpoint for real-time AIDA search.
202
+ Search runs as user types - no need to click send.
203
+
204
+ Connect with: ws://localhost:8000/ws/search?token=YOUR_JWT_TOKEN
205
+
206
+ Send messages:
207
+ {"type": "search", "query": "I want a house of 52k in Cotonou", "limit": 10}
208
+ {"type": "ping"}
209
+
210
+ Receive messages:
211
+ {"type": "search_results", "success": true, "message": "...", "data": [...], "total": 5}
212
+ {"type": "searching", "query": "..."} # Sent when search starts
213
+ {"type": "pong"}
214
+ {"type": "error", "message": "..."}
215
+ """
216
+
217
+ # Verify JWT token
218
+ try:
219
+ user = verify_token(token)
220
+ if not user:
221
+ await websocket.close(code=4001, reason="Unauthorized")
222
+ return
223
+ logger.info(f"[WS Search] User authenticated: {user.get('email', 'unknown')}")
224
+ except Exception as e:
225
+ logger.error(f"[WS Search] Token verification failed: {e}")
226
+ await websocket.close(code=4001, reason="Unauthorized")
227
+ return
228
+
229
+ await websocket.accept()
230
+ logger.info("[WS Search] Client connected")
231
+
232
+ # Track current search task for cancellation
233
+ current_search_task = None
234
+
235
+ try:
236
+ while True:
237
+ # Receive message from client
238
+ data = await websocket.receive_text()
239
+ message = json.loads(data)
240
+
241
+ msg_type = message.get("type")
242
+
243
+ if msg_type == "search":
244
+ query = message.get("query", "").strip()
245
+ limit = message.get("limit", 10)
246
+
247
+ if not query:
248
+ await websocket.send_json({
249
+ "type": "search_results",
250
+ "success": True,
251
+ "message": "Start typing to search...",
252
+ "data": [],
253
+ "total": 0,
254
+ "query": "",
255
+ })
256
+ continue
257
+
258
+ # Cancel previous search if still running
259
+ if current_search_task and not current_search_task.done():
260
+ current_search_task.cancel()
261
+ try:
262
+ await current_search_task
263
+ except asyncio.CancelledError:
264
+ pass
265
+
266
+ # Notify client that search is starting
267
+ await websocket.send_json({
268
+ "type": "searching",
269
+ "query": query,
270
+ "timestamp": datetime.utcnow().isoformat(),
271
+ })
272
+
273
+ # Start search in background task
274
+ async def do_search():
275
+ try:
276
+ result = await process_search(query, limit)
277
+ result["type"] = "search_results"
278
+ result["timestamp"] = datetime.utcnow().isoformat()
279
+ await websocket.send_json(result)
280
+ except asyncio.CancelledError:
281
+ pass
282
+ except Exception as e:
283
+ logger.error(f"[WS Search] Search error: {e}")
284
+ await websocket.send_json({
285
+ "type": "error",
286
+ "message": str(e),
287
+ })
288
+
289
+ current_search_task = asyncio.create_task(do_search())
290
+
291
+ elif msg_type == "ping":
292
+ await websocket.send_json({"type": "pong"})
293
+
294
+ else:
295
+ await websocket.send_json({
296
+ "type": "error",
297
+ "message": f"Unknown message type: {msg_type}",
298
+ })
299
+
300
+ except WebSocketDisconnect:
301
+ logger.info("[WS Search] Client disconnected")
302
+ except Exception as e:
303
+ logger.error(f"[WS Search] Error: {e}")
304
+ finally:
305
+ if current_search_task and not current_search_task.done():
306
+ current_search_task.cancel()
main.py CHANGED
@@ -246,7 +246,7 @@ app.include_router(listing_router, prefix="/api/listings", tags=["Listings"])
246
  app.include_router(user_public_router, prefix="/api/users", tags=["Users"])
247
  app.include_router(ws_router, tags=["WebSocket"])
248
  app.include_router(reviews_router, prefix="/api/reviews", tags=["Reviews"])
249
- app.include_router(search_router, prefix="/api/search", tags=["AIDA Search"])
250
 
251
  logger.info("=" * 70)
252
  logger.info("✅ All routers registered successfully")
 
246
  app.include_router(user_public_router, prefix="/api/users", tags=["Users"])
247
  app.include_router(ws_router, tags=["WebSocket"])
248
  app.include_router(reviews_router, prefix="/api/reviews", tags=["Reviews"])
249
+ app.include_router(search_router, tags=["AIDA Search"])
250
 
251
  logger.info("=" * 70)
252
  logger.info("✅ All routers registered successfully")