destinyebuka commited on
Commit
5a6c225
·
1 Parent(s): 8c9362b
Files changed (48) hide show
  1. .gitignore +0 -0
  2. app/ai/agent/__pycache__/graph.cpython-313.pyc +0 -0
  3. app/ai/agent/__pycache__/schemas.cpython-313.pyc +0 -0
  4. app/ai/agent/__pycache__/state.cpython-313.pyc +0 -0
  5. app/ai/agent/graph.py +7 -0
  6. app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc +0 -0
  7. app/ai/agent/nodes/__pycache__/edit_listing.cpython-313.pyc +0 -0
  8. app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc +0 -0
  9. app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc +0 -0
  10. app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc +0 -0
  11. app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc +0 -0
  12. app/ai/agent/nodes/__pycache__/validate_output.cpython-313.pyc +0 -0
  13. app/ai/agent/nodes/classify_intent.py +21 -3
  14. app/ai/agent/nodes/edit_listing.py +2 -0
  15. app/ai/agent/nodes/listing_collect.py +252 -24
  16. app/ai/agent/nodes/listing_publish.py +40 -4
  17. app/ai/agent/nodes/listing_validate.py +48 -6
  18. app/ai/agent/nodes/notification.py +60 -0
  19. app/ai/agent/nodes/search_query.py +90 -161
  20. app/ai/agent/nodes/validate_output.py +30 -0
  21. app/ai/agent/schemas.py +4 -1
  22. app/ai/agent/state.py +7 -0
  23. app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc +0 -0
  24. app/ai/prompts/system_prompt.py +4 -3
  25. app/ai/routes/__pycache__/chat.cpython-313.pyc +0 -0
  26. app/ai/routes/chat.py +1 -1
  27. app/ai/services/__pycache__/search_service.cpython-313.pyc +0 -0
  28. app/ai/services/notification_service.py +78 -0
  29. app/ai/services/search_service.py +73 -100
  30. app/ai/services/vector_service.py +127 -0
  31. app/ai/tools/__pycache__/listing_conversation_manager.cpython-313.pyc +0 -0
  32. app/ai/tools/__pycache__/listing_tool.cpython-313.pyc +0 -0
  33. app/ai/tools/listing_conversation_manager.py +23 -1
  34. app/ai/tools/listing_tool.py +438 -45
  35. app/ml/models/ml_listing_extractor.py +12 -4
  36. app/models/__pycache__/listing.cpython-313.pyc +0 -0
  37. app/models/listing.py +3 -0
  38. app/routes/__pycache__/listing.cpython-313.pyc +0 -0
  39. app/routes/listing.py +5 -1
  40. app/routes/websocket_listings.py +2 -2
  41. backfill_geocoding.py +93 -0
  42. check_db_listings.py +45 -0
  43. check_qdrant_info.py +23 -0
  44. fix_failed_listing.py +34 -0
  45. migrate_to_4096.py +8 -1
  46. sync_all_listings_to_qdrant.py +193 -0
  47. test_chat_ui.html +343 -236
  48. test_listing_fields.py +40 -0
.gitignore CHANGED
Binary files a/.gitignore and b/.gitignore differ
 
app/ai/agent/__pycache__/graph.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/__pycache__/graph.cpython-313.pyc and b/app/ai/agent/__pycache__/graph.cpython-313.pyc differ
 
app/ai/agent/__pycache__/schemas.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/__pycache__/schemas.cpython-313.pyc and b/app/ai/agent/__pycache__/schemas.cpython-313.pyc differ
 
app/ai/agent/__pycache__/state.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/__pycache__/state.cpython-313.pyc and b/app/ai/agent/__pycache__/state.cpython-313.pyc differ
 
app/ai/agent/graph.py CHANGED
@@ -20,6 +20,7 @@ from app.ai.agent.nodes.my_listings import my_listings_handler
20
  from app.ai.agent.nodes.edit_listing import edit_listing_handler
21
  from app.ai.agent.nodes.casual_chat import casual_chat_handler
22
  from app.ai.agent.nodes.validate_output import validate_output_node
 
23
  from app.ai.agent.nodes.respond import respond_to_user
24
 
25
  logger = get_logger(__name__)
@@ -41,6 +42,7 @@ def route_by_intent(state: AgentState) -> str:
41
  "listing": "listing_collect",
42
  "publish": "listing_publish",
43
  "search": "search_query",
 
44
  "my_listings": "my_listings",
45
  "edit_listing": "edit_listing",
46
  "casual_chat": "casual_chat",
@@ -180,6 +182,7 @@ def build_aida_graph():
180
  graph.add_node("search_query", search_query_handler)
181
  graph.add_node("my_listings", my_listings_handler)
182
  graph.add_node("edit_listing", edit_listing_handler)
 
183
  graph.add_node("casual_chat", casual_chat_handler)
184
  graph.add_node("validate_output", validate_output_node)
185
  graph.add_node("respond", respond_to_user)
@@ -205,6 +208,7 @@ def build_aida_graph():
205
  "listing_collect": "listing_collect",
206
  "listing_publish": "listing_publish",
207
  "search_query": "search_query",
 
208
  "my_listings": "my_listings",
209
  "edit_listing": "edit_listing",
210
  "casual_chat": "casual_chat",
@@ -241,6 +245,9 @@ def build_aida_graph():
241
  # Search → validate_output
242
  graph.add_edge("search_query", "validate_output")
243
 
 
 
 
244
  # My Listings → validate_output
245
  graph.add_edge("my_listings", "validate_output")
246
 
 
20
  from app.ai.agent.nodes.edit_listing import edit_listing_handler
21
  from app.ai.agent.nodes.casual_chat import casual_chat_handler
22
  from app.ai.agent.nodes.validate_output import validate_output_node
23
+ from app.ai.agent.nodes.notification import notification_handler
24
  from app.ai.agent.nodes.respond import respond_to_user
25
 
26
  logger = get_logger(__name__)
 
42
  "listing": "listing_collect",
43
  "publish": "listing_publish",
44
  "search": "search_query",
45
+ "set_notification": "notification",
46
  "my_listings": "my_listings",
47
  "edit_listing": "edit_listing",
48
  "casual_chat": "casual_chat",
 
182
  graph.add_node("search_query", search_query_handler)
183
  graph.add_node("my_listings", my_listings_handler)
184
  graph.add_node("edit_listing", edit_listing_handler)
185
+ graph.add_node("notification", notification_handler)
186
  graph.add_node("casual_chat", casual_chat_handler)
187
  graph.add_node("validate_output", validate_output_node)
188
  graph.add_node("respond", respond_to_user)
 
208
  "listing_collect": "listing_collect",
209
  "listing_publish": "listing_publish",
210
  "search_query": "search_query",
211
+ "notification": "notification",
212
  "my_listings": "my_listings",
213
  "edit_listing": "edit_listing",
214
  "casual_chat": "casual_chat",
 
245
  # Search → validate_output
246
  graph.add_edge("search_query", "validate_output")
247
 
248
+ # Notification → validate_output
249
+ graph.add_edge("notification", "validate_output")
250
+
251
  # My Listings → validate_output
252
  graph.add_edge("my_listings", "validate_output")
253
 
app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc differ
 
app/ai/agent/nodes/__pycache__/edit_listing.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/edit_listing.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/edit_listing.cpython-313.pyc differ
 
app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc differ
 
app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc differ
 
app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc differ
 
app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc differ
 
app/ai/agent/nodes/__pycache__/validate_output.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/validate_output.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/validate_output.cpython-313.pyc differ
 
app/ai/agent/nodes/classify_intent.py CHANGED
@@ -38,12 +38,13 @@ Classify into ONE of these intents:
38
  4. "my_listings" - User wants to view THEIR OWN listings (show my listings, view my properties, my homes)
39
  5. "edit_listing" - User wants to EDIT an existing listing (edit listing [id], update my listing, modify listing)
40
  6. "publish" - User wants to publish the current listing
41
- 7. "casual_chat" - Other conversation
42
- 8. "unknown" - You don't understand
 
43
 
44
  - "Return ONLY valid JSON (no markdown, no extra text):
45
  {{
46
- "type": "greeting|listing|search|my_listings|edit_listing|publish|casual_chat|unknown",
47
  "confidence": 0.0-1.0,
48
  "reasoning": "Why you chose this intent",
49
  "requires_auth": true/false,
@@ -261,6 +262,23 @@ async def classify_intent(state: AgentState) -> AgentState:
261
  logger.info("Staying in listing_collect for save/publish - letting save logic handle transition")
262
  return state
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  # Transition with validation
265
  success, error = state.transition_to(next_flow, reason=f"Intent: {state.intent_type}")
266
  if not success:
 
38
  4. "my_listings" - User wants to view THEIR OWN listings (show my listings, view my properties, my homes)
39
  5. "edit_listing" - User wants to EDIT an existing listing (edit listing [id], update my listing, modify listing)
40
  6. "publish" - User wants to publish the current listing
41
+ 7. "set_notification" - User wants to be notified/alerted about properties (notify me, set alert, keep me posted, waitlist)
42
+ 8. "casual_chat" - Other conversation
43
+ 9. "unknown" - You don't understand
44
 
45
  - "Return ONLY valid JSON (no markdown, no extra text):
46
  {{
47
+ "type": "greeting|listing|search|my_listings|edit_listing|publish|set_notification|casual_chat|unknown",
48
  "confidence": 0.0-1.0,
49
  "reasoning": "Why you chose this intent",
50
  "requires_auth": true/false,
 
262
  logger.info("Staying in listing_collect for save/publish - letting save logic handle transition")
263
  return state
264
 
265
+ # ✅ SPECIAL: When in listing_collect and message contains image URL, stay in flow
266
+ # This prevents breaking the listing flow when user sends an image
267
+ if state.current_flow == FlowState.LISTING_COLLECT:
268
+ message_lower = state.last_user_message.lower() if state.last_user_message else ""
269
+ has_image_url = any(domain in message_lower for domain in [
270
+ "imagedelivery.net", "cloudflare", "imgur", "cloudinary",
271
+ ".jpg", ".jpeg", ".png", ".webp", ".gif",
272
+ "http://", "https://"
273
+ ])
274
+
275
+ # If message has image URL OR intent is unknown/casual_chat, treat as listing update
276
+ if has_image_url or state.intent_type in ["unknown", "casual_chat"]:
277
+ logger.info("Staying in listing_collect - image URL or continuing listing flow",
278
+ has_image_url=has_image_url, original_intent=state.intent_type)
279
+ state.intent_type = "listing" # Override to stay in listing flow
280
+ return state
281
+
282
  # Transition with validation
283
  success, error = state.transition_to(next_flow, reason=f"Intent: {state.intent_type}")
284
  if not success:
app/ai/agent/nodes/edit_listing.py CHANGED
@@ -104,6 +104,8 @@ async def edit_listing_handler(state: AgentState) -> AgentState:
104
 
105
  # Convert to draft format for editing
106
  draft = {
 
 
107
  "title": listing.get("title", ""),
108
  "description": listing.get("description", ""),
109
  "location": listing.get("location", ""),
 
104
 
105
  # Convert to draft format for editing
106
  draft = {
107
+ "user_id": state.user_id, # Required for validation
108
+ "user_role": state.user_role, # Required for validation
109
  "title": listing.get("title", ""),
110
  "description": listing.get("description", ""),
111
  "location": listing.get("location", ""),
app/ai/agent/nodes/listing_collect.py CHANGED
@@ -170,12 +170,24 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
170
  from app.ai.tools.listing_conversation_manager import generate_smart_listing_response
171
  from app.ai.tools.listing_tool import extract_listing_fields_smart
172
 
173
- # Generate dynamic example
174
- listing_example = await generate_listing_example(
175
- user_role=state.user_role,
176
- user_name=state.user_name,
177
- user_location=state.user_location
 
 
178
  )
 
 
 
 
 
 
 
 
 
 
179
 
180
  # ✅ STEP 1: Generate intelligent response using LLM
181
  # This analyzes the message, detects intent, and creates contextual reply
@@ -186,7 +198,9 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
186
  provided_fields=state.provided_fields,
187
  missing_required_fields=state.missing_required_fields or [],
188
  last_action=state.temp_data.get("action"),
189
- listing_example=listing_example, # Pass the generated example
 
 
190
  )
191
 
192
  logger.info("Smart response received",
@@ -204,6 +218,27 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
204
  state.temp_data["new_intent"] = detected_intent
205
  return state
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  # ✅ STEP 3: Extract fields (LLM does this smartly)
208
  extracted = await extract_listing_fields_smart(
209
  state.last_user_message,
@@ -214,21 +249,42 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
214
  if extracted:
215
  # Get operation modes for list fields (default to "add" for backward compat)
216
  images_operation = extracted.pop("images_operation", "add")
 
217
  amenities_operation = extracted.pop("amenities_operation", "add")
218
 
219
  for field, value in extracted.items():
220
  # Fix: Handle 0 as a valid value for price/bedrooms
221
  if value is not None and value != "" and (value != [] or field in ["images", "amenities"]):
222
 
223
- # Special handling for images: add or replace
224
- if field == "images" and isinstance(value, list) and value:
 
 
225
  if images_operation == "replace":
226
  # Replace all images
227
- state.update_listing_progress(field, value)
228
- logger.info("Images REPLACED", count=len(value))
229
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  # Add to existing images (default)
231
- current_imgs = state.provided_fields.get("images", [])
232
  new_imgs = list(set(current_imgs + value)) # Avoid duplicates
233
  state.update_listing_progress(field, new_imgs)
234
  logger.info("Images ADDED", added=len(value), total=len(new_imgs))
@@ -270,6 +326,84 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
270
  state.listing_draft["currency"] = currency
271
  state.update_listing_progress("currency", currency)
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  # ✅ SYNC: Update listing_draft with all provided_fields when in edit mode
274
  is_editing_flag = state.temp_data.get("is_editing")
275
  editing_id = state.temp_data.get("editing_listing_id")
@@ -299,14 +433,49 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
299
  state.provided_fields["listing_type"] = "sale"
300
  logger.info("Auto-inferred listing_type to sale from one-time price_type")
301
 
302
- # ✅ REGENERATE title and description with updated fields
303
- from app.ai.tools.listing_tool import generate_title_and_description
304
- title, description = await generate_title_and_description(state.listing_draft, state.user_role)
305
- state.listing_draft["title"] = title
306
- state.listing_draft["description"] = description
307
- state.provided_fields["title"] = title
308
- state.provided_fields["description"] = description
309
- logger.info("Title/description regenerated", title=title)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
  # ✅ Regenerate draft_ui so frontend gets updated card
312
  from app.ai.agent.nodes.listing_validate import build_draft_ui_from_dict
@@ -314,11 +483,67 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
314
  draft_ui["status"] = "editing"
315
  state.temp_data["draft_ui"] = draft_ui
316
  logger.info("Draft UI regenerated for edit mode", ui_location=draft_ui.get("details", {}).get("location"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
  logger.info("Current provided fields after update", fields=state.provided_fields)
319
 
320
  # ✅ STEP 4: Check completion status
321
- required_fields = ["location", "bedrooms", "bathrooms", "price", "price_type", "images"]
322
 
323
  # Fix: Check for None OR empty values (empty list/string)
324
  missing_required = []
@@ -407,13 +632,15 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
407
 
408
  Changes made: {changes_text}
409
  New listing title: "{new_title}"
 
410
 
411
  The message should:
412
  - Confirm the updates naturally (1-2 sentences)
 
413
  - Mention the new title
414
- - End by asking if they want to change anything else OR say 'save' when done
415
 
416
- Example tone: "Done! Updated your location and price. Your listing is now '...'. What else, or say 'save' when ready!"
417
 
418
  Just return the message, no quotes."""
419
 
@@ -422,7 +649,8 @@ Just return the message, no quotes."""
422
  acknowledgment = response.content.strip().strip('"')
423
  except Exception:
424
  # Fallback
425
- acknowledgment = f"Done! Updated {changes_text}.\n\nYour listing: **\"{new_title}\"**\n\nWhat else? Or say **'save'** when ready!"
 
426
 
427
  state.temp_data["response_text"] = acknowledgment
428
  state.temp_data["action"] = "edit_field_updated"
 
170
  from app.ai.tools.listing_conversation_manager import generate_smart_listing_response
171
  from app.ai.tools.listing_tool import extract_listing_fields_smart
172
 
173
+ # SMART TUTORIAL SKIP: Only show example if user provided NO details
174
+ # If user said "I want to list my 3-bed in Lagos for 500k/month", skip the tutorial
175
+ # First, do a quick extraction to see if they provided details
176
+ quick_extraction = await extract_listing_fields_smart(
177
+ state.last_user_message,
178
+ state.user_role,
179
+ state.provided_fields
180
  )
181
+ has_details_in_message = len(quick_extraction) > 0
182
+
183
+ # Only generate example if no details provided AND no existing fields
184
+ listing_example = None
185
+ if not has_details_in_message and not state.provided_fields:
186
+ listing_example = await generate_listing_example(
187
+ user_role=state.user_role,
188
+ user_name=state.user_name,
189
+ user_location=state.user_location
190
+ )
191
 
192
  # ✅ STEP 1: Generate intelligent response using LLM
193
  # This analyzes the message, detects intent, and creates contextual reply
 
198
  provided_fields=state.provided_fields,
199
  missing_required_fields=state.missing_required_fields or [],
200
  last_action=state.temp_data.get("action"),
201
+ listing_example=listing_example, # Only passed if truly fresh start
202
+ needs_address_refinement=state.temp_data.get("needs_address_refinement", False),
203
+ vague_location=state.temp_data.get("vague_location"),
204
  )
205
 
206
  logger.info("Smart response received",
 
218
  state.temp_data["new_intent"] = detected_intent
219
  return state
220
 
221
+ # ✅ STEP 2.5: Extract image URLs directly from message (don't rely on LLM)
222
+ # This ensures image URLs are captured even if LLM misses them
223
+ # Supports: multiple images, text alongside images (text will be processed separately by LLM)
224
+ import re
225
+ image_url_pattern = r'https?://[^\s<>"{}|\\^`\[\]]+\.(?:jpg|jpeg|png|gif|webp)|https?://imagedelivery\.net/[^\s<>"{}|\\^`\[\]]+'
226
+ found_image_urls = re.findall(image_url_pattern, state.last_user_message or "", re.IGNORECASE)
227
+
228
+ if found_image_urls:
229
+ logger.info("📸 Image URLs extracted directly from message",
230
+ count=len(found_image_urls),
231
+ urls=found_image_urls[:3]) # Log first 3 to avoid spam
232
+ # Add to existing images (handles multiple images in one message)
233
+ current_images = state.provided_fields.get("images", [])
234
+ new_images = list(set(current_images + found_image_urls)) # Avoid duplicates
235
+ state.update_listing_progress("images", new_images)
236
+ logger.info("✅ Images updated", new_count=len(found_image_urls), total=len(new_images))
237
+
238
+ # NOTE: Any text alongside the images will still be processed below by the LLM
239
+ # Example: "Here are pics https://img1.jpg https://img2.jpg, it has wifi and pool"
240
+ # → Images extracted above, "it has wifi and pool" processed below for amenities
241
+
242
  # ✅ STEP 3: Extract fields (LLM does this smartly)
243
  extracted = await extract_listing_fields_smart(
244
  state.last_user_message,
 
249
  if extracted:
250
  # Get operation modes for list fields (default to "add" for backward compat)
251
  images_operation = extracted.pop("images_operation", "add")
252
+ image_index = extracted.pop("image_index", None) # NEW: Get target image index
253
  amenities_operation = extracted.pop("amenities_operation", "add")
254
 
255
  for field, value in extracted.items():
256
  # Fix: Handle 0 as a valid value for price/bedrooms
257
  if value is not None and value != "" and (value != [] or field in ["images", "amenities"]):
258
 
259
+ # Special handling for images: add, replace, replace_at, remove_at
260
+ if field == "images":
261
+ current_imgs = state.provided_fields.get("images", [])
262
+
263
  if images_operation == "replace":
264
  # Replace all images
265
+ state.update_listing_progress(field, value if isinstance(value, list) else [])
266
+ logger.info("Images REPLACED", count=len(value) if isinstance(value, list) else 0)
267
+
268
+ elif images_operation == "replace_at" and image_index is not None:
269
+ # Replace image at specific index (1-indexed)
270
+ if isinstance(value, list) and value and 1 <= image_index <= len(current_imgs):
271
+ current_imgs[image_index - 1] = value[0] # Replace with first new URL
272
+ state.update_listing_progress(field, current_imgs)
273
+ logger.info(f"Image REPLACED at index {image_index}")
274
+ else:
275
+ logger.warning(f"Invalid replace_at: index={image_index}, images_count={len(current_imgs)}")
276
+
277
+ elif images_operation == "remove_at" and image_index is not None:
278
+ # Remove image at specific index (1-indexed)
279
+ if 1 <= image_index <= len(current_imgs):
280
+ removed = current_imgs.pop(image_index - 1)
281
+ state.update_listing_progress(field, current_imgs)
282
+ logger.info(f"Image REMOVED at index {image_index}: {removed}")
283
+ else:
284
+ logger.warning(f"Invalid remove_at: index={image_index}, images_count={len(current_imgs)}")
285
+
286
+ elif isinstance(value, list) and value:
287
  # Add to existing images (default)
 
288
  new_imgs = list(set(current_imgs + value)) # Avoid duplicates
289
  state.update_listing_progress(field, new_imgs)
290
  logger.info("Images ADDED", added=len(value), total=len(new_imgs))
 
326
  state.listing_draft["currency"] = currency
327
  state.update_listing_progress("currency", currency)
328
 
329
+ # ✅ ADDRESS REFINEMENT & GEOCODING
330
+ # Handle address extraction and geocoding for precise map display
331
+ from app.ai.tools.listing_tool import is_vague_location, geocode_address
332
+
333
+ # Check if we extracted an address in this message
334
+ extracted_address = extracted.get("address") if extracted else None
335
+ current_location = state.provided_fields.get("location")
336
+ current_address = state.provided_fields.get("address")
337
+
338
+ # Scenario 1: User provided address → geocode and extract city
339
+ if extracted_address and extracted_address != current_address:
340
+ # Build full address for geocoding
341
+ search_address = extracted_address
342
+ if current_location:
343
+ search_address = f"{extracted_address}, {current_location}"
344
+
345
+ geo_result = await geocode_address(search_address, current_location)
346
+
347
+ if geo_result.get("success"):
348
+ # Store the full address with city appended
349
+ full_address = f"{extracted_address}, {geo_result.get('city', current_location)}"
350
+ state.update_listing_progress("address", full_address)
351
+ state.update_listing_progress("latitude", geo_result.get("latitude"))
352
+ state.update_listing_progress("longitude", geo_result.get("longitude"))
353
+
354
+ # If we didn't have a location, extract city from geocoding result
355
+ if not current_location and geo_result.get("city"):
356
+ state.update_listing_progress("location", geo_result.get("city"))
357
+ # Also update currency for the new location
358
+ try:
359
+ from app.ai.tools.listing_tool import get_currency_for_location
360
+ currency = await get_currency_for_location(geo_result.get("city"))
361
+ state.update_listing_progress("currency", currency)
362
+ except Exception:
363
+ pass
364
+
365
+ logger.info("Address geocoded successfully",
366
+ address=full_address,
367
+ lat=geo_result.get("latitude"),
368
+ lon=geo_result.get("longitude"))
369
+ else:
370
+ # Geocoding failed, still store the address
371
+ if current_location:
372
+ full_address = f"{extracted_address}, {current_location}"
373
+ else:
374
+ full_address = extracted_address
375
+ state.update_listing_progress("address", full_address)
376
+ logger.warning("Geocoding failed, storing address without coordinates", address=full_address)
377
+
378
+ # Clear address refinement flags since we now have an address
379
+ state.temp_data.pop("needs_address_refinement", None)
380
+ state.temp_data.pop("vague_location", None)
381
+
382
+ # Scenario 2: No address but we have a location - geocode the location for city-level coordinates
383
+ # This gives us temporary coords, but we still ask for the neighborhood for more accuracy
384
+ if current_location and not current_address and not state.provided_fields.get("latitude"):
385
+ # Geocode the location to get at least city-level coordinates (temporary)
386
+ geo_result = await geocode_address(current_location, None)
387
+
388
+ if geo_result.get("success"):
389
+ # Store city-level coords temporarily (will be overwritten when we get neighborhood)
390
+ state.update_listing_progress("latitude", geo_result.get("latitude"))
391
+ state.update_listing_progress("longitude", geo_result.get("longitude"))
392
+ # DON'T store city as address - we want the actual neighborhood
393
+ logger.info("City-level geocoding done (temporary coords)",
394
+ location=current_location,
395
+ lat=geo_result.get("latitude"),
396
+ lon=geo_result.get("longitude"))
397
+ else:
398
+ logger.warning("Location geocoding failed", location=current_location)
399
+
400
+ # Check if location is vague (just city name) and we need to ask for neighborhood
401
+ if is_vague_location(current_location):
402
+ state.temp_data["needs_address_refinement"] = True
403
+ state.temp_data["vague_location"] = current_location
404
+ logger.info("City only detected, will ask for neighborhood", location=current_location)
405
+
406
+
407
  # ✅ SYNC: Update listing_draft with all provided_fields when in edit mode
408
  is_editing_flag = state.temp_data.get("is_editing")
409
  editing_id = state.temp_data.get("editing_listing_id")
 
433
  state.provided_fields["listing_type"] = "sale"
434
  logger.info("Auto-inferred listing_type to sale from one-time price_type")
435
 
436
+ # ✅ AUTO-DETECT CURRENCY: Ensure currency matches location (important for auto-save)
437
+ location = state.listing_draft.get("location")
438
+ if location:
439
+ try:
440
+ from app.ai.tools.listing_tool import get_currency_for_location
441
+ detected_currency = await get_currency_for_location(location)
442
+ if detected_currency and detected_currency != state.listing_draft.get("currency"):
443
+ state.listing_draft["currency"] = detected_currency
444
+ state.provided_fields["currency"] = detected_currency
445
+ logger.info("💰 Currency auto-detected for edit mode", location=location, currency=detected_currency)
446
+ except Exception as e:
447
+ logger.warning(f"Currency detection failed for {location}: {e}")
448
+
449
+ # ✅ CHECK FOR DESCRIPTION REGENERATION REQUEST
450
+ from app.ai.tools.listing_tool import is_description_regeneration_request, regenerate_description_with_prompt
451
+
452
+ if state.last_user_message and is_description_regeneration_request(state.last_user_message):
453
+ # User wants to regenerate description with their specific prompt
454
+ logger.info("✍️ Description regeneration requested", user_prompt=state.last_user_message[:50])
455
+ new_description = await regenerate_description_with_prompt(
456
+ state.listing_draft,
457
+ state.last_user_message,
458
+ state.user_role
459
+ )
460
+ state.listing_draft["description"] = new_description
461
+ state.provided_fields["description"] = new_description
462
+ logger.info("✅ Description regenerated per user request", desc_len=len(new_description))
463
+
464
+ # Still regenerate title (keep it SEO-friendly)
465
+ from app.ai.tools.listing_tool import generate_title_and_description
466
+ title, _ = await generate_title_and_description(state.listing_draft, state.user_role)
467
+ state.listing_draft["title"] = title
468
+ state.provided_fields["title"] = title
469
+ else:
470
+ # Standard regeneration
471
+ from app.ai.tools.listing_tool import generate_title_and_description
472
+ title, description = await generate_title_and_description(state.listing_draft, state.user_role)
473
+ state.listing_draft["title"] = title
474
+ state.listing_draft["description"] = description
475
+ state.provided_fields["title"] = title
476
+ state.provided_fields["description"] = description
477
+
478
+ logger.info("Title/description updated", title=state.listing_draft.get("title"))
479
 
480
  # ✅ Regenerate draft_ui so frontend gets updated card
481
  from app.ai.agent.nodes.listing_validate import build_draft_ui_from_dict
 
483
  draft_ui["status"] = "editing"
484
  state.temp_data["draft_ui"] = draft_ui
485
  logger.info("Draft UI regenerated for edit mode", ui_location=draft_ui.get("details", {}).get("location"))
486
+
487
+ # ✅ AUTO-SAVE: Save changes to database immediately
488
+ # Always attempt auto-save in edit mode (draft is already synced above)
489
+ logger.info("🔄 Attempting auto-save", editing_id=editing_id, has_extracted=bool(extracted))
490
+ try:
491
+ from app.database import get_db
492
+ from bson import ObjectId
493
+ from datetime import datetime
494
+
495
+ # Validate the listing before saving
496
+ from app.ai.agent.validators import ListingValidator
497
+ validation = ListingValidator.validate_draft(state.listing_draft)
498
+
499
+ if validation.is_valid:
500
+ # Prepare update document
501
+ update_doc = {
502
+ "title": state.listing_draft.get("title"),
503
+ "description": state.listing_draft.get("description"),
504
+ "location": state.listing_draft.get("location"),
505
+ "address": state.listing_draft.get("address"), # Precise address
506
+ "latitude": state.listing_draft.get("latitude"), # Geocoded lat
507
+ "longitude": state.listing_draft.get("longitude"), # Geocoded lon
508
+ "price": state.listing_draft.get("price"),
509
+ "currency": state.listing_draft.get("currency"),
510
+ "price_type": state.listing_draft.get("price_type"),
511
+ "bedrooms": state.listing_draft.get("bedrooms"),
512
+ "bathrooms": state.listing_draft.get("bathrooms"),
513
+ "amenities": state.listing_draft.get("amenities", []),
514
+ "images": state.listing_draft.get("images", []),
515
+ "listing_type": state.listing_draft.get("listing_type"),
516
+ "updated_at": datetime.utcnow(),
517
+ }
518
+
519
+ logger.info("💾 Saving to database", listing_id=editing_id, location=update_doc.get("location"))
520
+
521
+ # Save to database
522
+ db = await get_db()
523
+ result = await db.listings.update_one(
524
+ {"_id": ObjectId(editing_id)},
525
+ {"$set": update_doc}
526
+ )
527
+
528
+ if result.matched_count > 0:
529
+ logger.info("✅ AUTO-SAVED listing to database", listing_id=editing_id, modified_count=result.modified_count)
530
+ # Add subtle save indicator to temp_data
531
+ state.temp_data["auto_saved"] = True
532
+ else:
533
+ logger.warning("⚠️ Auto-save: Listing not found in database", listing_id=editing_id)
534
+ else:
535
+ logger.warning("⚠️ Skipping auto-save: validation failed", errors=validation.errors)
536
+
537
+ except Exception as e:
538
+ logger.error("❌ Auto-save failed", exc_info=e, listing_id=editing_id)
539
+ # Don't fail the entire flow - just log the error
540
+ # User can still manually save later
541
+
542
 
543
  logger.info("Current provided fields after update", fields=state.provided_fields)
544
 
545
  # ✅ STEP 4: Check completion status
546
+ required_fields = ["location", "address", "bedrooms", "bathrooms", "price", "price_type", "images"]
547
 
548
  # Fix: Check for None OR empty values (empty list/string)
549
  missing_required = []
 
632
 
633
  Changes made: {changes_text}
634
  New listing title: "{new_title}"
635
+ Auto-saved: {state.temp_data.get("auto_saved", False)}
636
 
637
  The message should:
638
  - Confirm the updates naturally (1-2 sentences)
639
+ - If auto-saved is True, add a subtle "✓ Saved" indicator at the start
640
  - Mention the new title
641
+ - End by asking if they want to change anything else
642
 
643
+ Example tone: "Done! ✓ Saved. Updated your location and price. Your listing is now '...'. What else?"
644
 
645
  Just return the message, no quotes."""
646
 
 
649
  acknowledgment = response.content.strip().strip('"')
650
  except Exception:
651
  # Fallback
652
+ save_indicator = " Saved. " if state.temp_data.get("auto_saved") else ""
653
+ acknowledgment = f"Done! {save_indicator}Updated {changes_text}.\n\nYour listing: **\"{new_title}\"**\n\nWhat else would you like to change?"
654
 
655
  state.temp_data["response_text"] = acknowledgment
656
  state.temp_data["action"] = "edit_field_updated"
app/ai/agent/nodes/listing_publish.py CHANGED
@@ -10,6 +10,7 @@ from datetime import datetime
10
  from app.ai.agent.state import AgentState, FlowState
11
  from app.ai.agent.schemas import ListingDraft
12
  from app.database import get_db
 
13
 
14
  logger = get_logger(__name__)
15
 
@@ -78,6 +79,9 @@ async def listing_publish_handler(state: AgentState) -> AgentState:
78
  "title": draft.title,
79
  "description": draft.description,
80
  "location": draft.location,
 
 
 
81
  "bedrooms": draft.bedrooms,
82
  "bathrooms": draft.bathrooms,
83
  "price": draft.price,
@@ -152,6 +156,17 @@ async def listing_publish_handler(state: AgentState) -> AgentState:
152
  except Exception as user_update_err:
153
  logger.warning("Failed to increment totalListings", error=str(user_update_err))
154
 
 
 
 
 
 
 
 
 
 
 
 
155
  except Exception as e:
156
  logger.error("MongoDB save failed", exc_info=e)
157
  error_msg = f"Failed to save listing: {str(e)}"
@@ -193,16 +208,37 @@ async def listing_publish_handler(state: AgentState) -> AgentState:
193
  user_name = state.user_name or "there"
194
  is_update = bool(state.temp_data.get("editing_listing_id"))
195
 
 
 
 
 
 
 
196
  if is_update:
197
  prompt = f"""Generate a SHORT, excited message for {user_name} that their listing "{draft.title}" has been successfully UPDATED!
198
- Write in this exact style:
199
- "Great news {user_name}! ✨ Your '{draft.title}' listing has been UPDATED successfully! The changes are now live. What else would you like to do?"
 
 
 
 
 
 
 
200
  Be super excited and clear. 2 sentences max."""
201
  fallback_msg = f"Great news {user_name}! ✨ Your '{draft.title}' listing has been UPDATED successfully! What else would you like to do?"
202
  else:
203
  prompt = f"""Generate a SHORT, excited message for {user_name} that their listing "{draft.title}" in {draft.location} is NOW LIVE!
204
- Write in this exact style:
205
- "Wow {user_name}! 🎉🏠 Your '{draft.title}' listing is now LIVE on Lojiz! Anyone searching can now find it. What else can I help you with?"
 
 
 
 
 
 
 
 
206
  Be super excited and celebratory. Use emojis. 2 sentences max."""
207
  fallback_msg = f"Wow {user_name}! 🎉🏠 Your '{draft.title}' listing is now LIVE on Lojiz! What else can I help you with?"
208
 
 
10
  from app.ai.agent.state import AgentState, FlowState
11
  from app.ai.agent.schemas import ListingDraft
12
  from app.database import get_db
13
+ from app.ai.services.vector_service import upsert_listing_to_vector_db
14
 
15
  logger = get_logger(__name__)
16
 
 
79
  "title": draft.title,
80
  "description": draft.description,
81
  "location": draft.location,
82
+ "address": draft.address, # Precise address for display
83
+ "latitude": draft.latitude, # Geocoded latitude for map
84
+ "longitude": draft.longitude, # Geocoded longitude for map
85
  "bedrooms": draft.bedrooms,
86
  "bathrooms": draft.bathrooms,
87
  "price": draft.price,
 
156
  except Exception as user_update_err:
157
  logger.warning("Failed to increment totalListings", error=str(user_update_err))
158
 
159
+ # ✅ STEP 3.1: Sync to Qdrant for real-time smart search
160
+ listing_document["_id"] = listing_id
161
+ await upsert_listing_to_vector_db(listing_document)
162
+
163
+ # ✅ STEP 3.2: Check for waitlist matches and notify users
164
+ try:
165
+ from app.ai.services.notification_service import check_matches_for_new_listing
166
+ await check_matches_for_new_listing(listing_document)
167
+ except Exception as notify_err:
168
+ logger.warning("Proactive notification check failed", error=str(notify_err))
169
+
170
  except Exception as e:
171
  logger.error("MongoDB save failed", exc_info=e)
172
  error_msg = f"Failed to save listing: {str(e)}"
 
208
  user_name = state.user_name or "there"
209
  is_update = bool(state.temp_data.get("editing_listing_id"))
210
 
211
+ # Detect language from conversation history
212
+ user_messages = ""
213
+ if state.conversation_history:
214
+ user_msgs = [msg.get("content", "") for msg in state.conversation_history if msg.get("role") == "user"]
215
+ user_messages = " ".join(user_msgs[:2]) # First 2 messages
216
+
217
  if is_update:
218
  prompt = f"""Generate a SHORT, excited message for {user_name} that their listing "{draft.title}" has been successfully UPDATED!
219
+
220
+ CRITICAL LANGUAGE RULE:
221
+ User's messages: "{user_messages[:200]}"
222
+ Detect the language and write your ENTIRE message in that SAME language (French, English, Spanish, etc.).
223
+
224
+ Examples:
225
+ English: "Great news {user_name}! ✨ Your '{draft.title}' listing has been UPDATED successfully! The changes are now live. What else would you like to do?"
226
+ French: "Super nouvelle {user_name}! ✨ Ton annonce '{draft.title}' a été MISE À JOUR avec succès! Les changements sont maintenant en ligne. Que veux-tu faire d'autre?"
227
+
228
  Be super excited and clear. 2 sentences max."""
229
  fallback_msg = f"Great news {user_name}! ✨ Your '{draft.title}' listing has been UPDATED successfully! What else would you like to do?"
230
  else:
231
  prompt = f"""Generate a SHORT, excited message for {user_name} that their listing "{draft.title}" in {draft.location} is NOW LIVE!
232
+
233
+ CRITICAL LANGUAGE RULE:
234
+ User's messages: "{user_messages[:200]}"
235
+ Detect the language and write your ENTIRE message in that SAME language (French, English, Spanish, etc.).
236
+
237
+ Examples:
238
+ English: "Wow {user_name}! 🎉🏠 Your '{draft.title}' listing is now LIVE on Lojiz! Anyone searching can now find it. What else can I help you with?"
239
+ French: "Wow {user_name}! 🎉🏠 Ton annonce '{draft.title}' est maintenant EN LIGNE sur Lojiz! Tout le monde peut la trouver maintenant. Que puis-je faire d'autre pour toi?"
240
+ Spanish: "¡Wow {user_name}! 🎉🏠 ¡Tu anuncio '{draft.title}' está ahora EN VIVO en Lojiz! Cualquiera puede encontrarlo ahora. ¿En qué más puedo ayudarte?"
241
+
242
  Be super excited and celebratory. Use emojis. 2 sentences max."""
243
  fallback_msg = f"Wow {user_name}! 🎉🏠 Your '{draft.title}' listing is now LIVE on Lojiz! What else can I help you with?"
244
 
app/ai/agent/nodes/listing_validate.py CHANGED
@@ -59,6 +59,7 @@ def build_draft_ui(draft: ListingDraft) -> dict:
59
  "description": draft.description,
60
  "details": {
61
  "location": draft.location,
 
62
  "bedrooms": draft.bedrooms,
63
  "bathrooms": draft.bathrooms,
64
  "price": f"{draft.price} {draft.currency}",
@@ -69,6 +70,10 @@ def build_draft_ui(draft: ListingDraft) -> dict:
69
  "requirements": draft.requirements or "No special requirements",
70
  "images_count": len(draft.images),
71
  "images": draft.images[:5], # Show first 5
 
 
 
 
72
  "status": "ready_for_review",
73
  "actions": ["publish", "edit", "discard"],
74
  }
@@ -76,6 +81,7 @@ def build_draft_ui(draft: ListingDraft) -> dict:
76
  return ui_component
77
 
78
 
 
79
  def build_draft_ui_from_dict(draft_dict: dict) -> dict:
80
  """
81
  Build UI preview component from a draft dictionary.
@@ -120,6 +126,7 @@ def build_draft_ui_from_dict(draft_dict: dict) -> dict:
120
  "description": draft_dict.get("description", ""),
121
  "details": {
122
  "location": draft_dict.get("location", "Unknown"),
 
123
  "bedrooms": draft_dict.get("bedrooms", 0),
124
  "bathrooms": draft_dict.get("bathrooms", 0),
125
  "price": f"{draft_dict.get('price', 0)} {draft_dict.get('currency', 'NGN')}",
@@ -130,6 +137,10 @@ def build_draft_ui_from_dict(draft_dict: dict) -> dict:
130
  "requirements": draft_dict.get("requirements") or "No special requirements",
131
  "images_count": len(images),
132
  "images": images[:5], # Show first 5
 
 
 
 
133
  "status": "ready_for_review",
134
  "actions": ["publish", "edit", "discard"],
135
  }
@@ -220,7 +231,13 @@ async def listing_validate_handler(state: AgentState) -> AgentState:
220
  "amenities": amenities,
221
  }
222
 
223
- title, description = await generate_title_and_description(draft_data, state.user_role)
 
 
 
 
 
 
224
 
225
  logger.info("Title and description generated", title=title)
226
 
@@ -228,12 +245,20 @@ async def listing_validate_handler(state: AgentState) -> AgentState:
228
  # STEP 5: Build ListingDraft
229
  # ============================================================
230
 
 
 
 
 
 
231
  draft_dict = {
232
  "user_id": state.user_id,
233
  "user_role": state.user_role,
234
  "title": title,
235
  "description": description,
236
  "location": location,
 
 
 
237
  "bedrooms": int(bedrooms),
238
  "bathrooms": int(bathrooms),
239
  "price": float(price),
@@ -290,21 +315,38 @@ async def listing_validate_handler(state: AgentState) -> AgentState:
290
 
291
  user_name = state.user_name or "there"
292
 
 
 
 
 
 
 
293
  if is_update:
294
  state.temp_data["replace_last_message"] = True
295
  prompt = f"""Write a casual 1-2 sentence message for {user_name} confirming their listing "{draft.title}" was updated.
 
 
 
 
296
  Flow naturally into mentioning they can say "publish" or keep editing.
297
- Example: "All done, {user_name}! ✨ Your listing's looking great. Just say 'publish' when you're ready, or keep making changes!"
 
298
  Be creative and vary your wording each time."""
299
  else:
300
  prompt = f"""Write a casual, friendly message for {user_name} presenting their listing "{draft.title}" in {draft.location}.
 
 
 
 
 
301
  Write like you're texting a friend - flow naturally from one sentence to the next.
302
  Include ALL THREE actions in NATURAL sentences (not bullet points):
303
- - publishing ("say 'publish' and I'll handle it")
304
- - editing ("tell me what to change")
305
- - discarding ("say 'discard' if you've changed your mind")
306
 
307
- Example: "Alright {user_name}! 🏠 Here's your listing preview! To publish it, just say 'publish' and I'll handle the rest. Want to change something? Just tell me what to edit. Or say 'discard' if you've changed your mind."
 
308
 
309
  Be creative and vary your wording. Use emojis. 2-3 natural flowing sentences."""
310
 
 
59
  "description": draft.description,
60
  "details": {
61
  "location": draft.location,
62
+ "address": draft.address, # Precise address for display
63
  "bedrooms": draft.bedrooms,
64
  "bathrooms": draft.bathrooms,
65
  "price": f"{draft.price} {draft.currency}",
 
70
  "requirements": draft.requirements or "No special requirements",
71
  "images_count": len(draft.images),
72
  "images": draft.images[:5], # Show first 5
73
+ "coordinates": {
74
+ "latitude": draft.latitude,
75
+ "longitude": draft.longitude,
76
+ } if draft.latitude else None,
77
  "status": "ready_for_review",
78
  "actions": ["publish", "edit", "discard"],
79
  }
 
81
  return ui_component
82
 
83
 
84
+
85
  def build_draft_ui_from_dict(draft_dict: dict) -> dict:
86
  """
87
  Build UI preview component from a draft dictionary.
 
126
  "description": draft_dict.get("description", ""),
127
  "details": {
128
  "location": draft_dict.get("location", "Unknown"),
129
+ "address": draft_dict.get("address"), # Precise address for display
130
  "bedrooms": draft_dict.get("bedrooms", 0),
131
  "bathrooms": draft_dict.get("bathrooms", 0),
132
  "price": f"{draft_dict.get('price', 0)} {draft_dict.get('currency', 'NGN')}",
 
137
  "requirements": draft_dict.get("requirements") or "No special requirements",
138
  "images_count": len(images),
139
  "images": images[:5], # Show first 5
140
+ "coordinates": {
141
+ "latitude": draft_dict.get("latitude"),
142
+ "longitude": draft_dict.get("longitude"),
143
+ } if draft_dict.get("latitude") else None,
144
  "status": "ready_for_review",
145
  "actions": ["publish", "edit", "discard"],
146
  }
 
231
  "amenities": amenities,
232
  }
233
 
234
+ # Pass conversation history so LLM can detect language from user's actual words
235
+ title, description = await generate_title_and_description(
236
+ draft_data,
237
+ state.user_role,
238
+ user_language=None,
239
+ conversation_history=state.conversation_history
240
+ )
241
 
242
  logger.info("Title and description generated", title=title)
243
 
 
245
  # STEP 5: Build ListingDraft
246
  # ============================================================
247
 
248
+ # Get address and coordinates from provided fields
249
+ address = state.provided_fields.get("address")
250
+ latitude = state.provided_fields.get("latitude")
251
+ longitude = state.provided_fields.get("longitude")
252
+
253
  draft_dict = {
254
  "user_id": state.user_id,
255
  "user_role": state.user_role,
256
  "title": title,
257
  "description": description,
258
  "location": location,
259
+ "address": address,
260
+ "latitude": latitude,
261
+ "longitude": longitude,
262
  "bedrooms": int(bedrooms),
263
  "bathrooms": int(bathrooms),
264
  "price": float(price),
 
315
 
316
  user_name = state.user_name or "there"
317
 
318
+ # Detect language from conversation history
319
+ user_messages = ""
320
+ if state.conversation_history:
321
+ user_msgs = [msg.get("content", "") for msg in state.conversation_history if msg.get("role") == "user"]
322
+ user_messages = " ".join(user_msgs[:2]) # First 2 messages
323
+
324
  if is_update:
325
  state.temp_data["replace_last_message"] = True
326
  prompt = f"""Write a casual 1-2 sentence message for {user_name} confirming their listing "{draft.title}" was updated.
327
+
328
+ CRITICAL: The user speaks in this language: "{user_messages[:200]}"
329
+ Detect the language and write your message in THE SAME LANGUAGE (French, English, Spanish, etc.).
330
+
331
  Flow naturally into mentioning they can say "publish" or keep editing.
332
+ Example (English): "All done, {user_name}! ✨ Your listing's looking great. Just say 'publish' when you're ready, or keep making changes!"
333
+ Example (French): "C'est fait, {user_name}! ✨ Ton annonce est superbe. Dis 'publier' quand tu es prêt, ou continue à modifier!"
334
  Be creative and vary your wording each time."""
335
  else:
336
  prompt = f"""Write a casual, friendly message for {user_name} presenting their listing "{draft.title}" in {draft.location}.
337
+
338
+ CRITICAL LANGUAGE RULE:
339
+ User's messages: "{user_messages[:200]}"
340
+ Detect the language from above and write your ENTIRE message in that SAME language (French, English, Spanish, etc.).
341
+
342
  Write like you're texting a friend - flow naturally from one sentence to the next.
343
  Include ALL THREE actions in NATURAL sentences (not bullet points):
344
+ - publishing ("say 'publish' and I'll handle it" / "dis 'publier' et je m'en occupe")
345
+ - editing ("tell me what to change" / "dis-moi ce que tu veux changer")
346
+ - discarding ("say 'discard' if you've changed your mind" / "dis 'annuler' si tu as changé d'avis")
347
 
348
+ Example (English): "Alright {user_name}! 🏠 Here's your listing preview! To publish it, just say 'publish' and I'll handle the rest. Want to change something? Just tell me what to edit. Or say 'discard' if you've changed your mind."
349
+ Example (French): "Ça y est {user_name}! 🏠 Voici l'aperçu de ton annonce! Pour la publier, dis simplement 'publier' et je m'en occupe. Tu veux modifier quelque chose? Dis-moi ce que tu veux changer. Ou dis 'annuler' si tu as changé d'avis."
350
 
351
  Be creative and vary your wording. Use emojis. 2-3 natural flowing sentences."""
352
 
app/ai/agent/nodes/notification.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/notification.py
2
+ """
3
+ Node: Handle notification/waitlist registration.
4
+ """
5
+
6
+ from structlog import get_logger
7
+ from app.ai.agent.state import AgentState, FlowState
8
+ from app.ai.services.notification_service import create_waitlist_request
9
+ from app.ai.agent.nodes.search_query import extract_search_params
10
+
11
+ logger = get_logger(__name__)
12
+
13
+ async def notification_handler(state: AgentState) -> AgentState:
14
+ """
15
+ Register a user for proactive notifications based on their last search.
16
+ """
17
+ logger.info("Handling notification request", user_id=state.user_id)
18
+
19
+ try:
20
+ # 1. Identify what they want to be notified about
21
+ # We use the conservation history to find their last search intent
22
+ # Or re-extract from the current message if it contains details
23
+
24
+ search_params = await extract_search_params(state.last_user_message)
25
+
26
+ # Fallback: if they just said "notify me", look at state.temp_data for previous params
27
+ if not search_params or all(v is None for v in search_params.values()):
28
+ # Logic to find last search in history could be more complex,
29
+ # for now we check if they just finished a search in this session
30
+ search_params = state.search_results[0] if state.search_results else {}
31
+ # This is a simplification; in a real app, we'd store search_params in state explicitly
32
+
33
+ if not search_params:
34
+ state.temp_data["response_text"] = "What exactly should I notify you about? (e.g., 'Notify me when you find a 2-bed in Calavi')"
35
+ state.transition_to(FlowState.IDLE)
36
+ return state
37
+
38
+ # 2. Store the request
39
+ waitlist_id = await create_waitlist_request(
40
+ user_id=state.user_id,
41
+ search_params=search_params,
42
+ original_query=state.last_user_message
43
+ )
44
+
45
+ if waitlist_id:
46
+ state.temp_data["response_text"] = (
47
+ "Done! ✅ I've added you to my waitlist. "
48
+ "The moment I find a property matching your criteria, I'll reach out to you immediately! 🔔"
49
+ )
50
+ state.temp_data["action"] = "notification_set"
51
+ else:
52
+ state.temp_data["response_text"] = "I had a little trouble setting that up. Could you try again in a moment?"
53
+
54
+ state.transition_to(FlowState.IDLE)
55
+ return state
56
+
57
+ except Exception as e:
58
+ logger.error("Notification handling error", exc_info=e)
59
+ state.set_error(str(e))
60
+ return state
app/ai/agent/nodes/search_query.py CHANGED
@@ -180,32 +180,39 @@ SEARCH_RESULTS_PROMPT = """You are presenting property search results to a user.
180
 
181
  CRITICAL LANGUAGE RULE:
182
  The user's query is: "{user_query}"
183
- - If the query is in ENGLISH (like "show me houses"), respond in ENGLISH
184
- - If the query is in FRENCH (like "montre moi des maisons"), respond in FRENCH
185
- - IGNORE the location name when determining language (Cotonou is just a place, not a language indicator)
186
- - The query "{user_query}" is in ENGLISH if it contains words like "show", "me", "house", "find", "looking"
187
 
188
  USER INFO:
189
  - Name: {user_name}
190
  - Query: "{user_query}"
 
191
 
192
  SEARCH RESULTS ({count} properties found):
193
  {listings_summary}
194
 
195
  CURRENCY: {currency}
196
 
197
- YOUR TASK:
198
- Write a friendly, personalized response presenting these search results. Rules:
199
- 1. RESPOND IN THE SAME LANGUAGE AS THE QUERY TEXT (not the location!)
200
- 2. Start with a warm greeting using the user's name if provided
201
- 3. Give a brief 1-2 sentence summary about EACH property (title, location, price, key features)
202
- 4. End by mentioning they can view the cards below for details and ask for more info
203
- 5. Keep it concise but friendly and helpful
204
- 6. Use emojis appropriately (🏠 💰 etc.)
205
 
206
- If no properties found, give helpful suggestions.
 
 
 
207
 
208
- Write ONLY the response text, no JSON or formatting instructions."""
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
 
211
  async def generate_search_results_text(
@@ -213,24 +220,14 @@ async def generate_search_results_text(
213
  search_params: dict,
214
  user_query: str,
215
  user_name: str = None,
216
- inferred_currency: str = None
 
217
  ) -> str:
218
  """
219
  Use LLM to generate personalized, multilingual search results text.
220
-
221
- Args:
222
- listings: List of matching listings
223
- search_params: Original search parameters
224
- user_query: Original user query (determines language)
225
- user_name: User's name for personalization
226
- inferred_currency: Currency for the location
227
-
228
- Returns:
229
- LLM-generated response text in user's language
230
  """
231
 
232
  count = len(listings)
233
- location = search_params.get("location", "")
234
 
235
  # Build listings summary for LLM
236
  if listings:
@@ -242,21 +239,20 @@ async def generate_search_results_text(
242
  currency = listing.get("currency", inferred_currency or "XOF")
243
  price_type = listing.get("price_type", "monthly")
244
  bedrooms = listing.get("bedrooms", "?")
245
- bathrooms = listing.get("bathrooms", "?")
246
- amenities = listing.get("amenities", [])
247
  description = str(listing.get("description", ""))[:100]
 
248
 
249
  listings_summary += f"""
250
  Property {i}:
251
  - Title: {title}
252
  - Location: {loc}
253
  - Price: {currency} {price:,.0f} {price_type}
254
- - Bedrooms: {bedrooms}, Bathrooms: {bathrooms}
255
- - Amenities: {', '.join(amenities[:4]) if amenities else 'Not specified'}
256
  - Description: {description}...
 
257
  """
258
  else:
259
- listings_summary = f"No properties found matching criteria in {location or 'the specified area'}."
260
 
261
  # Format prompt
262
  prompt = SEARCH_RESULTS_PROMPT.format(
@@ -264,175 +260,108 @@ Property {i}:
264
  user_query=user_query,
265
  count=count,
266
  listings_summary=listings_summary,
267
- currency=inferred_currency or "local currency"
 
268
  )
269
 
270
  try:
271
  messages = [
272
- SystemMessage(content="You are AIDA, a friendly and helpful real estate AI assistant."),
273
  HumanMessage(content=prompt)
274
  ]
275
 
276
  response = await llm.ainvoke(messages)
277
- result_text = response.content.strip()
278
-
279
- logger.info("LLM generated search results text", text_len=len(result_text))
280
- return result_text
281
 
282
  except Exception as e:
283
- logger.error("LLM search text generation failed, using fallback", error=str(e))
284
- # Fallback to simple format
285
- return _fallback_format_results(listings, search_params, inferred_currency)
286
-
287
-
288
- def _fallback_format_results(listings: list, search_params: dict, inferred_currency: str = None) -> str:
289
- """Simple fallback if LLM fails."""
290
- if not listings:
291
- return f"😕 No listings found matching your criteria. Try adjusting your search."
292
-
293
- location = search_params.get("location", "")
294
- count = len(listings)
295
-
296
- text = f"🏠 Found {count} properties"
297
- if location:
298
- text += f" in {location}"
299
- text += "!\n\n"
300
-
301
- for listing in listings:
302
- title = listing.get("title", "Property")
303
- price = listing.get("price", 0)
304
- currency = listing.get("currency", inferred_currency or "")
305
- text += f"• **{title}** - {currency} {price:,.0f}\n"
306
-
307
- text += "\nCheck the cards below for details!"
308
- return text
309
 
310
 
311
  async def search_query_handler(state: AgentState) -> AgentState:
312
  """
313
- Handle search flow with HYBRID SEARCH.
314
-
315
- Flow:
316
- 1. Extract search criteria from message (LLM)
317
- 2. Infer currency from location
318
- 3. Perform hybrid search (Qdrant vector + filters)
319
- 4. Format and display results
320
- 5. Transition to IDLE
321
-
322
- Args:
323
- state: Agent state
324
-
325
- Returns:
326
- Updated state
327
  """
328
 
329
- logger.info(
330
- "Handling search query (HYBRID MODE)",
331
- user_id=state.user_id,
332
- message=state.last_user_message[:50]
333
- )
334
 
335
  try:
336
- # ============================================================
337
- # STEP 1: Extract search parameters with enhanced LLM
338
- # ============================================================
339
-
340
  search_params = await extract_search_params(state.last_user_message)
341
 
342
  if not search_params:
343
- logger.warning("No search parameters extracted")
344
- state.temp_data["response_text"] = (
345
- "I couldn't understand your search. Try asking:\n"
346
- "- \"I want a house in Calavi for 50k per month with wifi\"\n"
347
- "- \"2-bedroom apartments in Lagos under 500k\"\n"
348
- "- \"Short-stay rentals with balcony and parking\""
349
- )
350
  state.temp_data["action"] = "search_invalid"
351
  return state
352
 
353
- logger.info("Search parameters extracted", params=search_params)
354
-
355
- # ============================================================
356
- # STEP 2: Hybrid Search (Qdrant Vector + Filters)
357
- # ============================================================
358
 
359
- results, inferred_currency = await search_listings_hybrid(
360
- user_query=state.last_user_message,
361
- search_params=search_params,
362
- limit=10
363
- )
364
-
365
- logger.info(
366
- "Hybrid search completed",
367
- results_count=len(results),
368
- currency=inferred_currency
369
- )
370
-
371
- # ============================================================
372
- # STEP 3: Fallback to MongoDB if Qdrant returns no results
373
- # ============================================================
374
-
375
- if not results:
376
- logger.info("Qdrant returned no results, trying MongoDB fallback")
377
  results = await search_listings(search_params)
378
- logger.info("MongoDB fallback results", count=len(results))
379
-
380
- # ============================================================
381
- # STEP 4: Generate LLM-based personalized response text
382
- # ============================================================
383
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  formatted_results = await generate_search_results_text(
385
  listings=results,
386
  search_params=search_params,
387
  user_query=state.last_user_message,
388
  user_name=state.user_name,
389
- inferred_currency=inferred_currency
 
390
  )
391
 
392
- logger.info("LLM results formatted", length=len(formatted_results))
393
-
394
- # ============================================================
395
- # STEP 5: Store in state
396
- # ============================================================
397
-
398
  state.search_results = results
399
  state.temp_data["response_text"] = formatted_results
400
  state.temp_data["action"] = "search_results"
401
  state.temp_data["inferred_currency"] = inferred_currency
402
 
403
- # ============================================================
404
- # STEP 6: Transition to SEARCH_RESULTS then IDLE
405
- # ============================================================
406
-
407
- # First transition: search_query → search_results
408
- success, error = state.transition_to(FlowState.SEARCH_RESULTS, reason="Hybrid search completed")
409
-
410
- if not success:
411
- logger.error("Transition to SEARCH_RESULTS failed", error=error)
412
- state.set_error(error, should_retry=False)
413
- else:
414
- # Second transition: search_results → idle
415
- success2, error2 = state.transition_to(FlowState.IDLE, reason="Search results shown")
416
- if not success2:
417
- logger.warning("Transition to IDLE failed", error=error2)
418
-
419
- logger.info(
420
- "Hybrid search flow completed",
421
- user_id=state.user_id,
422
- results_count=len(results),
423
- currency=inferred_currency
424
- )
425
 
426
  return state
427
 
428
  except Exception as e:
429
- logger.error("Search query error", exc_info=e)
430
- error_msg = f"Search error: {str(e)}"
431
-
432
- state.set_error(error_msg, should_retry=True)
433
- state.temp_data["response_text"] = (
434
- "I had trouble searching for listings. Please try again with a simpler query."
435
- )
436
- state.temp_data["action"] = "search_error"
437
-
438
- return state
 
180
 
181
  CRITICAL LANGUAGE RULE:
182
  The user's query is: "{user_query}"
183
+ - If the query is in ENGLISH, respond in ENGLISH
184
+ - If the query is in FRENCH, respond in FRENCH
 
 
185
 
186
  USER INFO:
187
  - Name: {user_name}
188
  - Query: "{user_query}"
189
+ - Mode: {search_mode}
190
 
191
  SEARCH RESULTS ({count} properties found):
192
  {listings_summary}
193
 
194
  CURRENCY: {currency}
195
 
196
+ YOUR TASK - KEEP IT SHORT:
 
 
 
 
 
 
 
197
 
198
+ 1. If search_mode is "strict" or "broad":
199
+ - These are EXACT MATCHES for what the user asked.
200
+ - Start with: "Here are {count} properties in [location]! 🏠" (or similar short intro)
201
+ - DO NOT say "suggestions" or "alternatives" - these ARE what they asked for.
202
 
203
+ 2. If search_mode is "relaxed":
204
+ - You couldn't find exact matches, so these are alternatives.
205
+ - Say: "I couldn't find exactly what you're looking for, but you might like these:"
206
+
207
+ 3. FORMAT EACH PROPERTY (in the USER'S LANGUAGE):
208
+ - Show: "1. [Title] - [Price] 💰"
209
+ - Add ONE short sentence describing it IN THE USER'S LANGUAGE (even if title is different language)
210
+ - Example English: "1. Villa de Prestige - 350,000 XOF/month 💰 → A luxurious 4-bedroom villa with pool"
211
+ - Example French: "1. 3-Bed Rent in Cotonou - 200,000 XOF/mois 💰 → Appartement 3 chambres bien situé"
212
+
213
+ 4. Keep responses concise - users see full details on cards below.
214
+
215
+ Write ONLY the response text."""
216
 
217
 
218
  async def generate_search_results_text(
 
220
  search_params: dict,
221
  user_query: str,
222
  user_name: str = None,
223
+ inferred_currency: str = None,
224
+ search_mode: str = "strict"
225
  ) -> str:
226
  """
227
  Use LLM to generate personalized, multilingual search results text.
 
 
 
 
 
 
 
 
 
 
228
  """
229
 
230
  count = len(listings)
 
231
 
232
  # Build listings summary for LLM
233
  if listings:
 
239
  currency = listing.get("currency", inferred_currency or "XOF")
240
  price_type = listing.get("price_type", "monthly")
241
  bedrooms = listing.get("bedrooms", "?")
 
 
242
  description = str(listing.get("description", ""))[:100]
243
+ relevance = listing.get("_relevance_score", 0)
244
 
245
  listings_summary += f"""
246
  Property {i}:
247
  - Title: {title}
248
  - Location: {loc}
249
  - Price: {currency} {price:,.0f} {price_type}
250
+ - Bedrooms: {bedrooms}
 
251
  - Description: {description}...
252
+ - Match Score: {relevance:.2f}
253
  """
254
  else:
255
+ listings_summary = "No properties found."
256
 
257
  # Format prompt
258
  prompt = SEARCH_RESULTS_PROMPT.format(
 
260
  user_query=user_query,
261
  count=count,
262
  listings_summary=listings_summary,
263
+ currency=inferred_currency or "local currency",
264
+ search_mode=search_mode
265
  )
266
 
267
  try:
268
  messages = [
269
+ SystemMessage(content="You are AIDA, a friendly and helpful real estate AI assistant. You help users find 'closest matches' when exact ones aren't available."),
270
  HumanMessage(content=prompt)
271
  ]
272
 
273
  response = await llm.ainvoke(messages)
274
+ return response.content.strip()
 
 
 
275
 
276
  except Exception as e:
277
+ logger.error("LLM search text generation failed", error=str(e))
278
+ return f"I found {count} properties that might interest you! Take a look below."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
 
281
  async def search_query_handler(state: AgentState) -> AgentState:
282
  """
283
+ Handle search flow with Two-Step Hybrid Search (Strict -> Relaxed).
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  """
285
 
286
+ logger.info("Handling search query", user_id=state.user_id)
 
 
 
 
287
 
288
  try:
289
+ # STEP 1: Extract search parameters
 
 
 
290
  search_params = await extract_search_params(state.last_user_message)
291
 
292
  if not search_params:
293
+ state.temp_data["response_text"] = "I couldn't quite understand your search. Could you try rephrasing it?"
 
 
 
 
 
 
294
  state.temp_data["action"] = "search_invalid"
295
  return state
296
 
297
+ # Helper: Check if query has location
298
+ def has_location_filter(params: dict) -> bool:
299
+ return bool(params.get("location"))
 
 
300
 
301
+ # STEP 2: PRIMARY SEARCH - Always use MongoDB for location searches (exact match)
302
+ # This ensures users get exact location results, not semantic "similar" results
303
+ if has_location_filter(search_params):
304
+ logger.info("Location search: Using MongoDB for exact match", location=search_params.get("location"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  results = await search_listings(search_params)
306
+ inferred_currency = await infer_currency_from_location(search_params.get("location"))
307
+ search_mode = "strict"
308
+
309
+ # STEP 3: If no exact matches, offer suggestions via Qdrant semantic search
310
+ if not results:
311
+ logger.info("No exact matches found, trying Qdrant for suggestions...")
312
+ results, _ = await search_listings_hybrid(
313
+ user_query=state.last_user_message,
314
+ search_params=search_params,
315
+ mode="relaxed"
316
+ )
317
+ if results:
318
+ search_mode = "relaxed"
319
+ logger.info("Found semantic suggestions", count=len(results))
320
+ else:
321
+ # No location specified - use semantic search for general queries
322
+ logger.info("General query: Using Qdrant semantic search")
323
+ results, inferred_currency = await search_listings_hybrid(
324
+ user_query=state.last_user_message,
325
+ search_params=search_params,
326
+ mode="strict"
327
+ )
328
+ search_mode = "strict"
329
+
330
+ if not results:
331
+ results, _ = await search_listings_hybrid(
332
+ user_query=state.last_user_message,
333
+ search_params=search_params,
334
+ mode="relaxed"
335
+ )
336
+ if results:
337
+ search_mode = "relaxed"
338
+
339
+ # STEP 5: Generate LLM Response
340
  formatted_results = await generate_search_results_text(
341
  listings=results,
342
  search_params=search_params,
343
  user_query=state.last_user_message,
344
  user_name=state.user_name,
345
+ inferred_currency=inferred_currency,
346
+ search_mode=search_mode
347
  )
348
 
349
+ # STEP 6: Finalize state
 
 
 
 
 
350
  state.search_results = results
351
  state.temp_data["response_text"] = formatted_results
352
  state.temp_data["action"] = "search_results"
353
  state.temp_data["inferred_currency"] = inferred_currency
354
 
355
+ # Offer notification if no exact matches were found (even if suggestions were shown)
356
+ if search_mode == "relaxed" or not results:
357
+ state.temp_data["response_text"] += "\n\nWould you like me to notify you if an exact match for your request becomes available? Just say \"notify me\"!"
358
+
359
+ state.transition_to(FlowState.SEARCH_RESULTS)
360
+ state.transition_to(FlowState.IDLE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
  return state
363
 
364
  except Exception as e:
365
+ logger.error("Search flow failed", exc_info=e)
366
+ state.set_error(str(e))
367
+ return state
 
 
 
 
 
 
 
app/ai/agent/nodes/validate_output.py CHANGED
@@ -16,6 +16,30 @@ from app.ai.tools.listing_tool import get_currency_for_location
16
  logger = get_logger(__name__)
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  class OutputValidator:
20
  """Comprehensive output validation"""
21
 
@@ -125,6 +149,12 @@ class OutputValidator:
125
  elif len(description) > 1500:
126
  warnings.append("Description is very long")
127
 
 
 
 
 
 
 
128
  # Check price is positive
129
  price = draft.get("price")
130
  if isinstance(price, (int, float)) and price <= 0:
 
16
  logger = get_logger(__name__)
17
 
18
 
19
+ def count_sentences(text: str) -> int:
20
+ """
21
+ Count the number of sentences in a text.
22
+ A sentence ends with ., !, or ?
23
+
24
+ Args:
25
+ text: Text to count sentences in
26
+
27
+ Returns:
28
+ Number of sentences
29
+ """
30
+ if not text:
31
+ return 0
32
+
33
+ # Count sentence endings: . ! ?
34
+ import re
35
+ # Split by sentence endings and filter out empty strings
36
+ sentences = re.split(r'[.!?]+', text.strip())
37
+ # Filter out empty or whitespace-only sentences
38
+ sentences = [s.strip() for s in sentences if s.strip()]
39
+ return len(sentences)
40
+
41
+
42
+
43
  class OutputValidator:
44
  """Comprehensive output validation"""
45
 
 
149
  elif len(description) > 1500:
150
  warnings.append("Description is very long")
151
 
152
+ # Check for minimum 3 sentences
153
+ sentence_count = count_sentences(description)
154
+ if sentence_count < 3:
155
+ errors.append(f"Description must have at least 3 sentences (currently has {sentence_count})")
156
+
157
+
158
  # Check price is positive
159
  price = draft.get("price")
160
  if isinstance(price, (int, float)) and price <= 0:
app/ai/agent/schemas.py CHANGED
@@ -32,7 +32,7 @@ class UserMessage(BaseModel):
32
 
33
  class Intent(BaseModel):
34
  """LLM classification output"""
35
- type: Literal["greeting", "listing", "search", "my_listings", "edit_listing", "publish", "casual_chat", "unknown"]
36
  confidence: float = Field(..., ge=0.0, le=1.0)
37
  reasoning: str = Field(..., min_length=1, max_length=500)
38
  requires_auth: bool = False
@@ -73,6 +73,9 @@ class ListingDraft(BaseModel):
73
  listing_type: Literal["rent", "short-stay", "sale", "roommate"]
74
  amenities: List[str] = Field(default_factory=list)
75
  requirements: Optional[str] = None
 
 
 
76
  images: List[str] = Field(default_factory=list)
77
 
78
  @validator("images")
 
32
 
33
  class Intent(BaseModel):
34
  """LLM classification output"""
35
+ type: Literal["greeting", "listing", "search", "my_listings", "edit_listing", "publish", "set_notification", "casual_chat", "unknown"]
36
  confidence: float = Field(..., ge=0.0, le=1.0)
37
  reasoning: str = Field(..., min_length=1, max_length=500)
38
  requires_auth: bool = False
 
73
  listing_type: Literal["rent", "short-stay", "sale", "roommate"]
74
  amenities: List[str] = Field(default_factory=list)
75
  requirements: Optional[str] = None
76
+ address: Optional[str] = None # Full precise address (e.g., "Akpakpa, Cotonou, Benin")
77
+ latitude: Optional[float] = None # Geocoded latitude for map display
78
+ longitude: Optional[float] = None # Geocoded longitude for map display
79
  images: List[str] = Field(default_factory=list)
80
 
81
  @validator("images")
app/ai/agent/state.py CHANGED
@@ -38,6 +38,7 @@ class FlowState(str, Enum):
38
 
39
  # Other flows
40
  GREETING = "greeting"
 
41
  CASUAL_CHAT = "casual_chat"
42
 
43
  # Terminal states
@@ -121,6 +122,7 @@ class AgentState(BaseModel):
121
  FlowState.SEARCH_QUERY,
122
  FlowState.MY_LISTINGS, # Added for my listings
123
  FlowState.EDIT_LISTING, # Added for edit listing
 
124
  FlowState.CASUAL_CHAT,
125
  FlowState.ERROR,
126
  ],
@@ -169,6 +171,11 @@ class AgentState(BaseModel):
169
  FlowState.IDLE,
170
  FlowState.ERROR,
171
  ],
 
 
 
 
 
172
  FlowState.CASUAL_CHAT: [
173
  FlowState.IDLE,
174
  FlowState.CLASSIFY_INTENT,
 
38
 
39
  # Other flows
40
  GREETING = "greeting"
41
+ SET_NOTIFICATION = "set_notification"
42
  CASUAL_CHAT = "casual_chat"
43
 
44
  # Terminal states
 
122
  FlowState.SEARCH_QUERY,
123
  FlowState.MY_LISTINGS, # Added for my listings
124
  FlowState.EDIT_LISTING, # Added for edit listing
125
+ FlowState.SET_NOTIFICATION, # Added for notifications
126
  FlowState.CASUAL_CHAT,
127
  FlowState.ERROR,
128
  ],
 
171
  FlowState.IDLE,
172
  FlowState.ERROR,
173
  ],
174
+ FlowState.SET_NOTIFICATION: [
175
+ FlowState.IDLE,
176
+ FlowState.CLASSIFY_INTENT,
177
+ FlowState.ERROR,
178
+ ],
179
  FlowState.CASUAL_CHAT: [
180
  FlowState.IDLE,
181
  FlowState.CLASSIFY_INTENT,
app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc CHANGED
Binary files a/app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc and b/app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc differ
 
app/ai/prompts/system_prompt.py CHANGED
@@ -194,10 +194,11 @@ Title Generation:
194
  - MUST include location name
195
 
196
  Description Generation:
197
- - Professional, clean, 2-3 sentences
198
  - Include: bedrooms, bathrooms, location, amenities (if any), price, requirements (if any)
199
  - Make it appealing and detailed
200
  - Example: "Spacious 3-bedroom, 2-bathroom rental in Lagos with wifi, parking, and balcony. Priced at 50,000 NGN per month. Tenants must provide 3-month security deposit."
 
201
 
202
  STEP 5: WHEN ALL FIELDS COLLECTED
203
  Once all required fields complete:
@@ -292,7 +293,7 @@ Title
292
  - Example: "3-Bed Rent in Lagos"
293
 
294
  Description
295
- - Auto-generated: Professional, 2-3 sentences
296
  - Includes: bedrooms, bathrooms, location, amenities, price, requirements
297
 
298
  ========== IMPORTANT RULES ==========
@@ -404,7 +405,7 @@ Title Format:
404
  - "{{Bedrooms}}-Bed {{ListingType}} in {{Location}}"
405
 
406
  Description:
407
- - Professional, 2-3 sentences
408
  - Includes all key details
409
 
410
  FIELDS COLLECTED:
 
194
  - MUST include location name
195
 
196
  Description Generation:
197
+ - Professional, clean, at least 3 sentences (MINIMUM REQUIREMENT)
198
  - Include: bedrooms, bathrooms, location, amenities (if any), price, requirements (if any)
199
  - Make it appealing and detailed
200
  - Example: "Spacious 3-bedroom, 2-bathroom rental in Lagos with wifi, parking, and balcony. Priced at 50,000 NGN per month. Tenants must provide 3-month security deposit."
201
+ - CRITICAL: Always ensure at least 3 complete sentences
202
 
203
  STEP 5: WHEN ALL FIELDS COLLECTED
204
  Once all required fields complete:
 
293
  - Example: "3-Bed Rent in Lagos"
294
 
295
  Description
296
+ - Auto-generated: Professional, at least 3 sentences (MINIMUM)
297
  - Includes: bedrooms, bathrooms, location, amenities, price, requirements
298
 
299
  ========== IMPORTANT RULES ==========
 
405
  - "{{Bedrooms}}-Bed {{ListingType}} in {{Location}}"
406
 
407
  Description:
408
+ - Professional, at least 3 sentences (MINIMUM REQUIREMENT)
409
  - Includes all key details
410
 
411
  FIELDS COLLECTED:
app/ai/routes/__pycache__/chat.cpython-313.pyc CHANGED
Binary files a/app/ai/routes/__pycache__/chat.cpython-313.pyc and b/app/ai/routes/__pycache__/chat.cpython-313.pyc differ
 
app/ai/routes/chat.py CHANGED
@@ -447,7 +447,7 @@ async def handle_image_upload_result(body: ImageUploadResult) -> AgentResponse:
447
  user_id=user_id,
448
  user_role="landlord"
449
  )
450
- return await ask(ask_body)
451
 
452
  else:
453
  # Image rejected - generate friendly error via AIDA
 
447
  user_id=user_id,
448
  user_role="landlord"
449
  )
450
+ return await ask_ai(ask_body)
451
 
452
  else:
453
  # Image rejected - generate friendly error via AIDA
app/ai/services/__pycache__/search_service.cpython-313.pyc CHANGED
Binary files a/app/ai/services/__pycache__/search_service.cpython-313.pyc and b/app/ai/services/__pycache__/search_service.cpython-313.pyc differ
 
app/ai/services/notification_service.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/services/notification_service.py
2
+ """
3
+ Notification Service - Handles user waitlists and proactive alerts.
4
+ """
5
+
6
+ from typing import Dict, Any, List
7
+ from datetime import datetime
8
+ from structlog import get_logger
9
+ from bson import ObjectId
10
+
11
+ from app.database import get_db
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ async def create_waitlist_request(user_id: str, search_params: Dict[str, Any], original_query: str) -> str:
16
+ """
17
+ Store a user's search criteria for future notifications.
18
+ """
19
+ try:
20
+ db = await get_db()
21
+
22
+ waitlist_entry = {
23
+ "user_id": user_id,
24
+ "search_params": search_params,
25
+ "original_query": original_query,
26
+ "status": "active",
27
+ "created_at": datetime.utcnow(),
28
+ "last_checked_at": datetime.utcnow()
29
+ }
30
+
31
+ result = await db.waitlist.insert_one(waitlist_entry)
32
+ logger.info("Waitlist entry created", user_id=user_id, entry_id=str(result.inserted_id))
33
+ return str(result.inserted_id)
34
+
35
+ except Exception as e:
36
+ logger.error("Failed to create waitlist entry", error=str(e), user_id=user_id)
37
+ return ""
38
+
39
+ async def check_matches_for_new_listing(listing_doc: Dict[str, Any]):
40
+ """
41
+ Scan the waitlist for users who might be interested in this new listing.
42
+ In a real production app, this would trigger Push Notifications or emails.
43
+ For now, we log the matches.
44
+ """
45
+ try:
46
+ db = await get_db()
47
+
48
+ # Simple matching logic: Location + Price (Max) + Bedrooms
49
+ location = listing_doc.get("location", "").lower()
50
+ price = float(listing_doc.get("price", 0))
51
+ bedrooms = int(listing_doc.get("bedrooms", 0))
52
+
53
+ # Find active waitlist entries matching this criteria
54
+ # This is a simplified example of proactive matching
55
+ query = {
56
+ "status": "active",
57
+ "search_params.location": {"$regex": f"^{location}$", "$options": "i"},
58
+ "search_params.max_price": {"$gte": price} if price > 0 else {"$exists": True},
59
+ "search_params.bedrooms": {"$lte": bedrooms} if bedrooms > 0 else {"$exists": True}
60
+ }
61
+
62
+ matches = await db.waitlist.find(query).to_list(100)
63
+
64
+ if matches:
65
+ logger.info("Found waitlist matches for new listing!", count=len(matches), listing_id=str(listing_doc.get("_id")))
66
+ for match in matches:
67
+ # Logic to notify user would go here (e.g., Firebase Cloud Messaging)
68
+ # For this task, we assume the system registers the match.
69
+ logger.info("PROACTIVE MATCH FOUND", user_id=match["user_id"], listing_title=listing_doc.get("title"))
70
+
71
+ # Mark as notified or update last checked
72
+ await db.waitlist.update_one(
73
+ {"_id": match["_id"]},
74
+ {"$set": {"last_match_at": datetime.utcnow()}}
75
+ )
76
+
77
+ except Exception as e:
78
+ logger.error("Proactive match check failed", error=str(e))
app/ai/services/search_service.py CHANGED
@@ -222,115 +222,101 @@ def normalize_amenities(amenities: List[str]) -> List[str]:
222
  async def hybrid_search(
223
  query_text: str,
224
  search_params: Dict[str, Any],
225
- limit: int = 10
 
226
  ) -> List[Dict]:
227
  """
228
  Perform hybrid search: vector similarity + payload filters.
229
 
230
  Args:
231
  query_text: Original user query for semantic search
232
- search_params: Extracted search parameters (location, price, amenities, etc.)
233
- limit: Maximum results to return
 
234
 
235
  Returns:
236
  List of matching listings sorted by relevance
237
  """
238
 
239
- logger.info("Starting hybrid search", query=query_text[:50], params_keys=list(search_params.keys()))
240
 
241
  if not qdrant_client:
242
  logger.error("Qdrant client not available")
243
  return []
244
 
245
  # ============================================================
246
- # STEP 1: Build filter conditions
247
  # ============================================================
248
 
249
  filter_conditions = []
250
 
251
- # Location filter (exact match on lowercase - uses KEYWORD index)
252
- if search_params.get("location"):
253
- location_lower = search_params["location"].lower()
254
- filter_conditions.append(
255
- FieldCondition(
256
- key="location_lower",
257
- match=MatchValue(value=location_lower)
258
- )
259
- )
260
- logger.info("Added location filter", location=location_lower)
261
-
262
- # Max price filter
263
- if search_params.get("max_price"):
264
- filter_conditions.append(
265
- FieldCondition(
266
- key="price",
267
- range=Range(lte=float(search_params["max_price"]))
268
- )
269
- )
270
- logger.info("Added max_price filter", max_price=search_params["max_price"])
271
-
272
- # Min price filter
273
- if search_params.get("min_price"):
274
- filter_conditions.append(
275
- FieldCondition(
276
- key="price",
277
- range=Range(gte=float(search_params["min_price"]))
278
  )
279
- )
280
- logger.info("Added min_price filter", min_price=search_params["min_price"])
281
-
282
- # Bedrooms filter
283
- if search_params.get("bedrooms"):
284
- filter_conditions.append(
285
- FieldCondition(
286
- key="bedrooms",
287
- range=Range(gte=int(search_params["bedrooms"]))
288
  )
289
- )
290
- logger.info("Added bedrooms filter", bedrooms=search_params["bedrooms"])
291
-
292
- # Bathrooms filter
293
- if search_params.get("bathrooms"):
294
- filter_conditions.append(
295
- FieldCondition(
296
- key="bathrooms",
297
- range=Range(gte=int(search_params["bathrooms"]))
298
  )
299
- )
300
- logger.info("Added bathrooms filter", bathrooms=search_params["bathrooms"])
301
-
302
- # Listing type filter
303
- if search_params.get("listing_type"):
304
- filter_conditions.append(
305
- FieldCondition(
306
- key="listing_type_lower",
307
- match=MatchValue(value=search_params["listing_type"].lower())
308
  )
309
- )
310
- logger.info("Added listing_type filter", listing_type=search_params["listing_type"])
311
-
312
- # Price type filter (monthly, weekly, etc.)
313
- if search_params.get("price_type"):
314
- filter_conditions.append(
315
- FieldCondition(
316
- key="price_type_lower",
317
- match=MatchValue(value=search_params["price_type"].lower())
318
  )
319
- )
320
- logger.info("Added price_type filter", price_type=search_params["price_type"])
321
-
322
- # Amenities filter - ALL must match
323
- if search_params.get("amenities"):
324
- normalized = normalize_amenities(search_params["amenities"])
325
- for amenity in normalized:
326
  filter_conditions.append(
327
  FieldCondition(
328
- key="amenities",
329
- match=MatchValue(value=amenity)
330
  )
331
  )
332
- logger.info("Added amenities filter", amenities=normalized)
333
-
 
 
 
 
 
 
 
 
 
 
334
  # ============================================================
335
  # STEP 2: Build query filter
336
  # ============================================================
@@ -338,7 +324,6 @@ async def hybrid_search(
338
  query_filter = None
339
  if filter_conditions:
340
  query_filter = Filter(must=filter_conditions)
341
- logger.info("Filter built", conditions_count=len(filter_conditions))
342
 
343
  # ============================================================
344
  # STEP 3: Embed the query for semantic search
@@ -348,7 +333,6 @@ async def hybrid_search(
348
 
349
  if not query_vector:
350
  logger.warning("Query embedding failed, falling back to filter-only search")
351
- # Fallback: scroll with filters only
352
  try:
353
  results, _ = await qdrant_client.scroll(
354
  collection_name=COLLECTION_NAME,
@@ -366,7 +350,6 @@ async def hybrid_search(
366
  # ============================================================
367
 
368
  try:
369
- # Use query method (not search) for async client
370
  results = await qdrant_client.query_points(
371
  collection_name=COLLECTION_NAME,
372
  query=query_vector,
@@ -375,13 +358,11 @@ async def hybrid_search(
375
  with_payload=True
376
  )
377
 
378
- logger.info("Hybrid search completed", results_count=len(results.points))
379
-
380
- # Extract payloads with scores
381
  listings = []
382
  for point in results.points:
383
  listing = dict(point.payload)
384
  listing["_relevance_score"] = point.score
 
385
  listings.append(listing)
386
 
387
  return listings
@@ -390,7 +371,6 @@ async def hybrid_search(
390
  logger.error("Hybrid search failed", error=str(e))
391
  return []
392
 
393
-
394
  # ============================================================
395
  # MAIN SEARCH FUNCTION (Public API)
396
  # ============================================================
@@ -398,31 +378,24 @@ async def hybrid_search(
398
  async def search_listings_hybrid(
399
  user_query: str,
400
  search_params: Dict[str, Any],
401
- limit: int = 10
 
402
  ) -> Tuple[List[Dict], str]:
403
  """
404
  Main entry point for hybrid property search.
405
-
406
- Args:
407
- user_query: Original natural language query
408
- search_params: Extracted search parameters
409
- limit: Max results
410
-
411
- Returns:
412
- Tuple of (listings, inferred_currency)
413
  """
414
 
415
  # Infer currency from location
416
- currency = "XOF" # Default
417
  if search_params.get("location"):
418
- currency, confidence = await infer_currency_from_location(search_params["location"])
419
- logger.info("Currency for search", currency=currency, confidence=confidence)
420
 
421
- # Perform hybrid search
422
  results = await hybrid_search(
423
  query_text=user_query,
424
  search_params=search_params,
425
- limit=limit
 
426
  )
427
 
428
  return results, currency
 
222
  async def hybrid_search(
223
  query_text: str,
224
  search_params: Dict[str, Any],
225
+ limit: int = 10,
226
+ mode: str = "strict"
227
  ) -> List[Dict]:
228
  """
229
  Perform hybrid search: vector similarity + payload filters.
230
 
231
  Args:
232
  query_text: Original user query for semantic search
233
+ search_params: Extracted search parameters
234
+ limit: Max results
235
+ mode: "strict" (apply all filters) or "relaxed" (skip filters, use vector similarity)
236
 
237
  Returns:
238
  List of matching listings sorted by relevance
239
  """
240
 
241
+ logger.info("Starting hybrid search", query=query_text[:50], mode=mode)
242
 
243
  if not qdrant_client:
244
  logger.error("Qdrant client not available")
245
  return []
246
 
247
  # ============================================================
248
+ # STEP 1: Build filter conditions (ONLY for strict mode)
249
  # ============================================================
250
 
251
  filter_conditions = []
252
 
253
+ if mode == "strict":
254
+ # Location filter
255
+ if search_params.get("location"):
256
+ location_lower = search_params["location"].lower()
257
+ filter_conditions.append(
258
+ FieldCondition(
259
+ key="location_lower",
260
+ match=MatchValue(value=location_lower)
261
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  )
263
+
264
+ # Max price filter
265
+ if search_params.get("max_price"):
266
+ filter_conditions.append(
267
+ FieldCondition(
268
+ key="price",
269
+ range=Range(lte=float(search_params["max_price"]))
270
+ )
 
271
  )
272
+
273
+ # Min price filter
274
+ if search_params.get("min_price"):
275
+ filter_conditions.append(
276
+ FieldCondition(
277
+ key="price",
278
+ range=Range(gte=float(search_params["min_price"]))
279
+ )
 
280
  )
281
+
282
+ # Bedrooms filter
283
+ if search_params.get("bedrooms"):
284
+ filter_conditions.append(
285
+ FieldCondition(
286
+ key="bedrooms",
287
+ range=Range(gte=int(search_params["bedrooms"]))
288
+ )
 
289
  )
290
+
291
+ # Bathrooms filter
292
+ if search_params.get("bathrooms"):
293
+ filter_conditions.append(
294
+ FieldCondition(
295
+ key="bathrooms",
296
+ range=Range(gte=int(search_params["bathrooms"]))
297
+ )
 
298
  )
299
+
300
+ # Listing type filter
301
+ if search_params.get("listing_type"):
 
 
 
 
302
  filter_conditions.append(
303
  FieldCondition(
304
+ key="listing_type_lower",
305
+ match=MatchValue(value=search_params["listing_type"].lower())
306
  )
307
  )
308
+
309
+ # Amenities filter
310
+ if search_params.get("amenities"):
311
+ normalized = normalize_amenities(search_params["amenities"])
312
+ for amenity in normalized:
313
+ filter_conditions.append(
314
+ FieldCondition(
315
+ key="amenities",
316
+ match=MatchValue(value=amenity)
317
+ )
318
+ )
319
+
320
  # ============================================================
321
  # STEP 2: Build query filter
322
  # ============================================================
 
324
  query_filter = None
325
  if filter_conditions:
326
  query_filter = Filter(must=filter_conditions)
 
327
 
328
  # ============================================================
329
  # STEP 3: Embed the query for semantic search
 
333
 
334
  if not query_vector:
335
  logger.warning("Query embedding failed, falling back to filter-only search")
 
336
  try:
337
  results, _ = await qdrant_client.scroll(
338
  collection_name=COLLECTION_NAME,
 
350
  # ============================================================
351
 
352
  try:
 
353
  results = await qdrant_client.query_points(
354
  collection_name=COLLECTION_NAME,
355
  query=query_vector,
 
358
  with_payload=True
359
  )
360
 
 
 
 
361
  listings = []
362
  for point in results.points:
363
  listing = dict(point.payload)
364
  listing["_relevance_score"] = point.score
365
+ listing["_is_suggestion"] = (mode == "relaxed")
366
  listings.append(listing)
367
 
368
  return listings
 
371
  logger.error("Hybrid search failed", error=str(e))
372
  return []
373
 
 
374
  # ============================================================
375
  # MAIN SEARCH FUNCTION (Public API)
376
  # ============================================================
 
378
  async def search_listings_hybrid(
379
  user_query: str,
380
  search_params: Dict[str, Any],
381
+ limit: int = 10,
382
+ mode: str = "strict"
383
  ) -> Tuple[List[Dict], str]:
384
  """
385
  Main entry point for hybrid property search.
 
 
 
 
 
 
 
 
386
  """
387
 
388
  # Infer currency from location
389
+ currency = "XOF"
390
  if search_params.get("location"):
391
+ currency, _ = await infer_currency_from_location(search_params["location"])
 
392
 
393
+ # Perform search
394
  results = await hybrid_search(
395
  query_text=user_query,
396
  search_params=search_params,
397
+ limit=limit,
398
+ mode=mode
399
  )
400
 
401
  return results, currency
app/ai/services/vector_service.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/services/vector_service.py
2
+ """
3
+ Vector Service - Handles automated Qdrant indexing for listings.
4
+ Ensures real-time synchronization between MongoDB and Qdrant.
5
+ """
6
+
7
+ import uuid
8
+ from typing import Dict, Any, Optional
9
+ from structlog import get_logger
10
+ from qdrant_client.models import PointStruct
11
+
12
+ from app.ai.config import qdrant_client
13
+ from app.ai.services.search_service import embed_query, COLLECTION_NAME
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ def get_deterministic_uuid(mongo_id: str) -> str:
18
+ """
19
+ Generate a deterministic UUID from a MongoDB ObjectId string.
20
+ Ensures that multiple upserts for the same MongoDB listing result
21
+ in the same Qdrant point ID, correctly handling updates.
22
+ """
23
+ if not mongo_id:
24
+ return str(uuid.uuid4())
25
+
26
+ # MongoDB ID is 24 hex chars. UUID needs 32 hex chars.
27
+ # We pad the mongo_id with zeros to make it a valid UUID hex.
28
+ # Pattern: 00000000-0000-0000-0000-xxxxxxxxxxxx
29
+ padded_hex = mongo_id.zfill(32)
30
+ return str(uuid.UUID(hex=padded_hex))
31
+
32
+ async def upsert_listing_to_vector_db(listing_doc: Dict[str, Any]) -> bool:
33
+ """
34
+ Index or update a listing in the Qdrant vector database.
35
+
36
+ Args:
37
+ listing_doc: Dictionary containing listing details from MongoDB
38
+
39
+ Returns:
40
+ True if successful, False otherwise
41
+ """
42
+ if not qdrant_client:
43
+ logger.error("Qdrant client not configured, skipping vector indexing")
44
+ return False
45
+
46
+ try:
47
+ mongo_id = str(listing_doc.get("_id"))
48
+ point_id = get_deterministic_uuid(mongo_id)
49
+
50
+ # Build text for embedding
51
+ bedrooms = listing_doc.get("bedrooms") or 0
52
+ location = listing_doc.get("location") or ""
53
+ address = listing_doc.get("address") or ""
54
+ title = listing_doc.get("title") or ""
55
+ description = listing_doc.get("description") or ""
56
+
57
+ # We include address and title for better semantic matching
58
+ text = f"{title}. {bedrooms}-bed in {location} ({address}). {description}".strip()
59
+
60
+ vector = await embed_query(text)
61
+ if not vector:
62
+ logger.error("Failed to generate embedding for listing", mongo_id=mongo_id)
63
+ return False
64
+
65
+ # Prepare payload - keep it in sync with search_service.py filters
66
+ price_type = listing_doc.get("price_type") or ""
67
+ listing_type = listing_doc.get("listing_type") or listing_doc.get("type") or ""
68
+
69
+ payload = {
70
+ "mongo_id": mongo_id,
71
+ "title": title,
72
+ "description": description,
73
+ "location": location,
74
+ "location_lower": location.lower() if location else "",
75
+ "address": address,
76
+ "price": float(listing_doc.get("price") or 0),
77
+ "price_type": price_type,
78
+ "price_type_lower": price_type.lower() if price_type else "",
79
+ "listing_type": listing_type,
80
+ "listing_type_lower": listing_type.lower() if listing_type else "",
81
+ "bedrooms": int(bedrooms),
82
+ "bathrooms": int(listing_doc.get("bathrooms") or 0),
83
+ "amenities": [a.lower() for a in (listing_doc.get("amenities") or [])],
84
+ "currency": listing_doc.get("currency", "XOF"),
85
+ "status": listing_doc.get("status", "active"),
86
+ "latitude": listing_doc.get("latitude"),
87
+ "longitude": listing_doc.get("longitude"),
88
+ "images": listing_doc.get("images", []), # ✅ FIX: Include images array
89
+ }
90
+
91
+ # Upsert point
92
+ await qdrant_client.upsert(
93
+ collection_name=COLLECTION_NAME,
94
+ points=[
95
+ PointStruct(
96
+ id=point_id,
97
+ vector=vector,
98
+ payload=payload
99
+ )
100
+ ]
101
+ )
102
+
103
+ logger.info("Listing synced to Qdrant", mongo_id=mongo_id, point_id=point_id)
104
+ return True
105
+
106
+ except Exception as e:
107
+ logger.error("Failed to sync listing to Qdrant", error=str(e), mongo_id=str(listing_doc.get("_id")))
108
+ return False
109
+
110
+ async def delete_listing_from_vector_db(mongo_id: str) -> bool:
111
+ """
112
+ Remove a listing from the Qdrant vector database.
113
+ """
114
+ if not qdrant_client:
115
+ return False
116
+
117
+ try:
118
+ point_id = get_deterministic_uuid(mongo_id)
119
+ await qdrant_client.delete(
120
+ collection_name=COLLECTION_NAME,
121
+ points_selector=[point_id]
122
+ )
123
+ logger.info("Listing removed from Qdrant", mongo_id=mongo_id, point_id=point_id)
124
+ return True
125
+ except Exception as e:
126
+ logger.error("Failed to remove listing from Qdrant", error=str(e), mongo_id=mongo_id)
127
+ return False
app/ai/tools/__pycache__/listing_conversation_manager.cpython-313.pyc CHANGED
Binary files a/app/ai/tools/__pycache__/listing_conversation_manager.cpython-313.pyc and b/app/ai/tools/__pycache__/listing_conversation_manager.cpython-313.pyc differ
 
app/ai/tools/__pycache__/listing_tool.cpython-313.pyc CHANGED
Binary files a/app/ai/tools/__pycache__/listing_tool.cpython-313.pyc and b/app/ai/tools/__pycache__/listing_tool.cpython-313.pyc differ
 
app/ai/tools/listing_conversation_manager.py CHANGED
@@ -32,6 +32,8 @@ async def generate_smart_listing_response(
32
  missing_required_fields: list,
33
  last_action: str = None,
34
  listing_example: str = None,
 
 
35
  ) -> Dict:
36
  """
37
  Generate FULLY INTELLIGENT response for listing flow
@@ -68,6 +70,25 @@ IMPORTANT: This is the user's first interaction in the listing flow.
68
  You MUST show them this example to get them started:
69
  "{listing_example}"
70
  State your intention is "show_example".
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  """
72
 
73
  prompt = f"""{system_prompt}
@@ -107,11 +128,12 @@ Last action I took:
107
  - If only images are missing, say something like: "Almost done! Just need a photo of your property. Can you share an image?"
108
 
109
  {example_instruction}
 
110
 
111
  OUTPUT JSON format:
112
  {{
113
  "intent_still_listing": boolean,
114
- "action": "ask_fields" | "answer_question" | "show_draft" | "show_example",
115
  "response_text": "Your natural, friendly response here",
116
  "reasoning": "Why you chose this action"
117
  }}
 
32
  missing_required_fields: list,
33
  last_action: str = None,
34
  listing_example: str = None,
35
+ needs_address_refinement: bool = False,
36
+ vague_location: str = None,
37
  ) -> Dict:
38
  """
39
  Generate FULLY INTELLIGENT response for listing flow
 
70
  You MUST show them this example to get them started:
71
  "{listing_example}"
72
  State your intention is "show_example".
73
+ """
74
+
75
+ # If location is vague, guide LLM to ask for precise neighborhood/area
76
+ address_refinement_instruction = ""
77
+ if needs_address_refinement and vague_location:
78
+ address_refinement_instruction = f"""
79
+ 🏠 NEIGHBORHOOD/AREA REQUIRED:
80
+ The user only provided the city: "{vague_location}"
81
+ You MUST ask them for the specific NEIGHBORHOOD or AREA (NOT a full street address).
82
+ We need this to pin the property on a map more accurately.
83
+
84
+ Examples of what we want:
85
+ - In Cotonou: "Akpakpa", "Kpondehou", "Cadjehoun", "Haie Vive", "Fidjrossè"
86
+ - In Lagos: "Victoria Island", "Lekki", "Ikeja", "Yaba"
87
+
88
+ Example question: "Where exactly in {vague_location} is your property? (e.g., Akpakpa, Kpondehou, Fidjrossè...)"
89
+
90
+ This takes PRIORITY over "all_collected" - do NOT transition to draft preview until you have the neighborhood!
91
+ Set action to "ask_address".
92
  """
93
 
94
  prompt = f"""{system_prompt}
 
128
  - If only images are missing, say something like: "Almost done! Just need a photo of your property. Can you share an image?"
129
 
130
  {example_instruction}
131
+ {address_refinement_instruction}
132
 
133
  OUTPUT JSON format:
134
  {{
135
  "intent_still_listing": boolean,
136
+ "action": "ask_fields" | "ask_address" | "answer_question" | "show_draft" | "show_example",
137
  "response_text": "Your natural, friendly response here",
138
  "reasoning": "Why you chose this action"
139
  }}
app/ai/tools/listing_tool.py CHANGED
@@ -22,6 +22,123 @@ llm = ChatOpenAI(
22
  temperature=0.3,
23
  )
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  # ============================================================
26
  # FIELD EXTRACTION - FIXED TO RETURN VALUES
27
  # ============================================================
@@ -47,45 +164,79 @@ User role: {user_role}
47
  User message: "{user_message}"{context}
48
 
49
  Extract these fields (IMPORTANT: return the ACTUAL VALUES, not just field names!):
50
- - location: City/area name (e.g., "Lagos", "Cotonou") or null
 
 
 
 
51
  - bedrooms: Number as integer (e.g., 2, 3, 4) or null
52
  - bathrooms: Number as integer (e.g., 1, 2) or null
53
  - price: Amount as number (e.g., 50000, 1200) or null - understand "50k" as 50000
54
- - price_type: "monthly", "yearly", "weekly", "daily", "nightly" or null
55
- - amenities: List of amenities ["wifi", "parking", "furnished", "ac"] or []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  - amenities_operation: "add" (append to existing) or "replace" (replace all) - default is "add"
57
  - requirements: Text of requirements or null
58
  - images: List of valid URLs (start with http/https) found in message or []
59
- - images_operation: "add" (append to existing) or "replace" (replace all) - default is "add"
 
60
  - title: If user explicitly wants to change title, the new title or null
61
  - description: If user explicitly wants to change description, the new description or null
62
 
63
- OPERATION DETECTION:
64
- - "add image" / "here is another photo" / "one more" → images_operation: "add"
65
- - "replace images" / "change images to" / "use this instead" / "new images" → images_operation: "replace"
 
 
66
  - "add wifi" / "also has parking" → amenities_operation: "add"
67
  - "replace amenities" / "only wifi" / "change amenities to" → amenities_operation: "replace"
68
  - If user just says "swimming pool, wifi" while editing → amenities_operation: "replace" (replacing)
69
  - If user says "add swimming pool" → amenities_operation: "add" (appending)
70
 
71
  CRITICAL: Extract ACTUAL VALUES!
72
- - "50k per month" → price: 50000, price_type: "monthly"
 
 
 
73
  - "2-bed, 1-bath" → bedrooms: 2, bathrooms: 1
74
- - "Lagos" → location: "Lagos"
 
 
75
  - "Here is the photo: https://example.com/img.jpg" → images: ["https://example.com/img.jpg"]
 
 
76
 
77
  Return ONLY valid JSON:
78
  {{
79
  "location": "Lagos" or null,
 
80
  "bedrooms": 2 or null,
81
  "bathrooms": 1 or null,
82
  "price": 50000 or null,
83
  "price_type": "monthly" or null,
84
- "amenities": ["wifi", "parking"] or [],
 
85
  "amenities_operation": "add" or "replace",
86
  "requirements": "3-month deposit" or null,
87
  "images": ["https://..."] or [],
88
- "images_operation": "add" or "replace",
 
89
  "title": "New Title" or null,
90
  "description": "New description" or null
91
  }}"""
@@ -157,39 +308,69 @@ Return ONLY the example, no extra text."""
157
  # AUTO-DETECT LISTING TYPE (Role-Based Restrictions)
158
  # ============================================================
159
 
160
- async def auto_detect_listing_type(price_type: str, user_role: str, user_message: str = "") -> str:
161
  """
162
- Auto-detect listing type from context with role-based restrictions.
163
 
164
- Rules:
165
- - Renter: Can ONLY list "roommate" (anything else is blocked)
166
- - Landlord: Can list "rent", "short-stay", "sale" (but NOT "roommate")
167
-
168
- Detection logic:
169
- - "for sale" keywords → "sale"
170
- - weekly/daily/nightly price_type → "short-stay"
171
- - monthly/yearly price_type → "rent"
172
  """
173
 
174
- # RENTER: Can ONLY list roommate
175
- if user_role == "renter":
176
  return "roommate"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
- # LANDLORD: Detect type from context (cannot list roommate)
179
- message_lower = (user_message or "").lower()
180
-
181
- # Check for sale keywords
182
- if any(word in message_lower for word in ["sell", "sale", "selling", "for sale", "buy"]):
183
- return "sale"
184
 
185
- # Check price_type for short-stay vs rent
186
- price_type_lower = (price_type or "").lower()
 
 
 
 
 
 
 
187
 
188
- # Short-stay: weekly, daily, nightly
189
- if price_type_lower in ["weekly", "week", "daily", "day", "nightly", "night"]:
190
- return "short-stay"
191
 
192
- # Rent: monthly, yearly (default for landlord)
 
 
 
 
193
  return "rent"
194
 
195
 
@@ -260,8 +441,156 @@ async def get_currency_for_location(location: str) -> str:
260
  # GENERATE TITLE AND DESCRIPTION
261
  # ============================================================
262
 
263
- async def generate_title_and_description(draft_data: Dict, user_role: str) -> Tuple[str, str]:
264
- """Auto-generate title and description for listing"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  bedrooms = draft_data.get("bedrooms", "?")
266
  bathrooms = draft_data.get("bathrooms", "?")
267
  location = draft_data.get("location", "Unknown")
@@ -269,16 +598,79 @@ async def generate_title_and_description(draft_data: Dict, user_role: str) -> Tu
269
  price = draft_data.get("price", "?")
270
  price_type = draft_data.get("price_type", "monthly")
271
  currency = draft_data.get("currency", "")
272
- amenities = ", ".join(draft_data.get("amenities", [])) if draft_data.get("amenities") else "No amenities"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
- title = f"{bedrooms}-Bed {listing_type.capitalize()} in {location}"
 
275
 
276
- description = f"Beautiful {bedrooms}-bedroom, {bathrooms}-bathroom {listing_type} in {location}. "
277
- description += f"Price: {price} {currency}/{price_type}. "
278
- description += f"Amenities: {amenities}."
279
 
280
- logger.info("Title and description generated", title=title)
281
- return title, description
282
 
283
 
284
  # ============================================================
@@ -418,7 +810,8 @@ async def process_listing(
418
  listing_type = await auto_detect_listing_type(
419
  price_type=provided_fields.get("price_type", ""),
420
  user_role=user_role,
421
- user_message=user_message
 
422
  )
423
  provided_fields["listing_type"] = listing_type
424
 
 
22
  temperature=0.3,
23
  )
24
 
25
+ # ============================================================
26
+ # ADDRESS REFINEMENT & GEOCODING
27
+ # ============================================================
28
+
29
+ def is_vague_location(location: str) -> bool:
30
+ """
31
+ Detect if a location is too vague (city-only, needs precise address).
32
+
33
+ Returns:
34
+ True if location is vague (e.g., "Cotonou", "Lagos")
35
+ False if location is precise (e.g., "Akpakpa, Cotonou", "Victoria Island Lagos")
36
+ """
37
+ if not location:
38
+ return True
39
+
40
+ location = location.strip()
41
+
42
+ # If location contains comma or multiple words with neighborhood indicators, it's likely precise
43
+ if "," in location:
44
+ return False
45
+
46
+ # Check for common neighborhood/area indicators
47
+ words = location.split()
48
+ if len(words) >= 2:
49
+ # Multiple words like "Akpakpa Midombo" or "Victoria Island" are likely precise
50
+ return False
51
+
52
+ # Single word like "Cotonou", "Lagos" is vague
53
+ return True
54
+
55
+
56
+ async def geocode_address(address: str, city: str = None) -> Dict:
57
+ """
58
+ Geocode an address using Nominatim (OpenStreetMap).
59
+
60
+ Args:
61
+ address: The precise address/neighborhood (e.g., "Akpakpa")
62
+ city: Optional city to append for better accuracy (e.g., "Cotonou")
63
+
64
+ Returns:
65
+ {
66
+ "success": True/False,
67
+ "full_address": "Akpakpa, Cotonou, Benin",
68
+ "city": "Cotonou",
69
+ "latitude": 6.3654,
70
+ "longitude": 2.4183
71
+ }
72
+ """
73
+ import aiohttp
74
+
75
+ if not address:
76
+ return {"success": False, "error": "No address provided"}
77
+
78
+ # Build search query
79
+ search_query = address.strip()
80
+ if city and city.lower() not in search_query.lower():
81
+ search_query = f"{search_query}, {city}"
82
+
83
+ try:
84
+ async with aiohttp.ClientSession() as session:
85
+ # URL-encode the search query to handle special characters, spaces, and accents
86
+ from urllib.parse import quote
87
+ encoded_query = quote(search_query, safe='')
88
+ url = f"https://nominatim.openstreetmap.org/search?q={encoded_query}&format=json&limit=1&addressdetails=1"
89
+ headers = {"User-Agent": "AIDA-RealEstate-App/1.0"}
90
+
91
+ async with session.get(url, headers=headers, timeout=10) as resp:
92
+ if resp.status == 200:
93
+ data = await resp.json()
94
+
95
+ if data and len(data) > 0:
96
+ result = data[0]
97
+ address_details = result.get("address", {})
98
+
99
+ # Extract city from Nominatim response
100
+ extracted_city = (
101
+ address_details.get("city") or
102
+ address_details.get("town") or
103
+ address_details.get("municipality") or
104
+ address_details.get("county") or
105
+ address_details.get("village") or
106
+ city # Fallback to provided city
107
+ )
108
+
109
+ # Build full address
110
+ full_address = result.get("display_name", search_query)
111
+
112
+ logger.info(
113
+ "Geocoding successful",
114
+ query=search_query,
115
+ city=extracted_city,
116
+ lat=result.get("lat"),
117
+ lon=result.get("lon")
118
+ )
119
+
120
+ return {
121
+ "success": True,
122
+ "full_address": full_address,
123
+ "city": extracted_city,
124
+ "latitude": float(result.get("lat")),
125
+ "longitude": float(result.get("lon")),
126
+ }
127
+ else:
128
+ logger.warning("Geocoding returned no results", query=search_query)
129
+ return {
130
+ "success": False,
131
+ "error": "No results found",
132
+ "city": city,
133
+ }
134
+ else:
135
+ logger.error("Geocoding API error", status=resp.status)
136
+ return {"success": False, "error": f"API error: {resp.status}"}
137
+
138
+ except Exception as e:
139
+ logger.error("Geocoding failed", error=str(e), query=search_query)
140
+ return {"success": False, "error": str(e), "city": city}
141
+
142
  # ============================================================
143
  # FIELD EXTRACTION - FIXED TO RETURN VALUES
144
  # ============================================================
 
164
  User message: "{user_message}"{context}
165
 
166
  Extract these fields (IMPORTANT: return the ACTUAL VALUES, not just field names!):
167
+ - location: City name ONLY (e.g., "Lagos", "Cotonou") or null
168
+ - address: Precise neighborhood/area/street (e.g., "Akpakpa", "Victoria Island", "Akpakpa Midombo") or null
169
+ - If user provides "Akpakpa, Cotonou" → location: "Cotonou", address: "Akpakpa"
170
+ - If user provides just "Cotonou" → location: "Cotonou", address: null
171
+ - If user provides "my apartment in Akpakpa" → location: null, address: "Akpakpa"
172
  - bedrooms: Number as integer (e.g., 2, 3, 4) or null
173
  - bathrooms: Number as integer (e.g., 1, 2) or null
174
  - price: Amount as number (e.g., 50000, 1200) or null - understand "50k" as 50000
175
+ - price_type: "monthly", "yearly", "weekly", "daily", "nightly", "total", "one-time" or null
176
+ - listing_type: "rent", "sale", "short-stay", "roommate" or null (infer from context: "selling"->"sale", "renting"->"rent", "airbnb"->"short-stay")
177
+ - amenities: List of property features/amenities in ANY LANGUAGE - extract the EXACT words the user used
178
+
179
+ 🌍 AMENITY DETECTION (UNIVERSAL - ANY LANGUAGE):
180
+ Detect ANY property features, facilities, or amenities mentioned by the user.
181
+ Return them in their ORIGINAL language as the user wrote them.
182
+
183
+ Examples of amenities in different languages:
184
+ • English: "wifi", "parking", "pool", "gym", "garden", "balcony", "furnished", "air conditioning", "modern kitchen"
185
+ • French: "wifi", "parking", "piscine", "salle de sport", "jardin", "balcon", "meublé", "climatisation", "cuisine moderne"
186
+ • Spanish: "wifi", "estacionamiento", "piscina", "gimnasio", "jardín", "balcón", "amueblado", "aire acondicionado"
187
+ • Portuguese: "wifi", "estacionamento", "piscina", "academia", "jardim", "varanda", "mobilado", "ar condicionado"
188
+
189
+ CRITICAL: Extract amenities in the EXACT language the user used. Do NOT translate.
190
+ - User says "grand jardin, parking, wifi" → amenities: ["grand jardin", "parking", "wifi"]
191
+ - User says "large garden, parking, wifi" → amenities: ["large garden", "parking", "wifi"]
192
+ - User says "piscina, gimnasio, wifi" → amenities: ["piscina", "gimnasio", "wifi"]
193
+
194
  - amenities_operation: "add" (append to existing) or "replace" (replace all) - default is "add"
195
  - requirements: Text of requirements or null
196
  - images: List of valid URLs (start with http/https) found in message or []
197
+ - images_operation: "add", "replace", "replace_at", or "remove_at" - default is "add"
198
+ - image_index: Integer (1-indexed) if user specifies which image to modify (e.g., "image 2", "first photo") or null
199
  - title: If user explicitly wants to change title, the new title or null
200
  - description: If user explicitly wants to change description, the new description or null
201
 
202
+ IMAGE OPERATION DETECTION:
203
+ - "add image" / "here is another photo" / "one more" → images_operation: "add", image_index: null
204
+ - "replace all images" / "change images to" / "new images" → images_operation: "replace", image_index: null
205
+ - "replace image 2" / "change the second photo" / "swap image 3" → images_operation: "replace_at", image_index: 2 (or 3)
206
+ - "remove image 1" / "delete the first photo" / "remove image 4" → images_operation: "remove_at", image_index: 1 (or 4)
207
  - "add wifi" / "also has parking" → amenities_operation: "add"
208
  - "replace amenities" / "only wifi" / "change amenities to" → amenities_operation: "replace"
209
  - If user just says "swimming pool, wifi" while editing → amenities_operation: "replace" (replacing)
210
  - If user says "add swimming pool" → amenities_operation: "add" (appending)
211
 
212
  CRITICAL: Extract ACTUAL VALUES!
213
+ - "50k per month" → price: 50000, price_type: "monthly", listing_type: "rent"
214
+ - "Selling for 50M" → price: 50000000, price_type: "total", listing_type: "sale"
215
+ - "20k per night" → price: 20000, price_type: "nightly", listing_type: "short-stay"
216
+ - "grand jardin, cuisine moderne, parking" → amenities: ["grand jardin", "cuisine moderne", "parking"]
217
  - "2-bed, 1-bath" → bedrooms: 2, bathrooms: 1
218
+ - "Lagos" → location: "Lagos", address: null
219
+ - "Akpakpa, Cotonou" → location: "Cotonou", address: "Akpakpa"
220
+ - "Victoria Island Lagos" → location: "Lagos", address: "Victoria Island"
221
  - "Here is the photo: https://example.com/img.jpg" → images: ["https://example.com/img.jpg"]
222
+ - "Replace image 2 with this" + URL → images_operation: "replace_at", image_index: 2, images: ["https://..."]
223
+ - "Delete the first photo" → images_operation: "remove_at", image_index: 1, images: []
224
 
225
  Return ONLY valid JSON:
226
  {{
227
  "location": "Lagos" or null,
228
+ "address": "Victoria Island" or null,
229
  "bedrooms": 2 or null,
230
  "bathrooms": 1 or null,
231
  "price": 50000 or null,
232
  "price_type": "monthly" or null,
233
+ "listing_type": "rent" or null,
234
+ "amenities": ["grand jardin", "cuisine moderne", "parking"] or [],
235
  "amenities_operation": "add" or "replace",
236
  "requirements": "3-month deposit" or null,
237
  "images": ["https://..."] or [],
238
+ "images_operation": "add" or "replace" or "replace_at" or "remove_at",
239
+ "image_index": 2 or null,
240
  "title": "New Title" or null,
241
  "description": "New description" or null
242
  }}"""
 
308
  # AUTO-DETECT LISTING TYPE (Role-Based Restrictions)
309
  # ============================================================
310
 
311
+ async def auto_detect_listing_type(price_type: str, user_role: str, user_message: str = "", extracted_type: str = None) -> str:
312
  """
313
+ Auto-detect listing type using LLM extraction + price rules.
314
 
315
+ Priority:
316
+ 1. Role restrictions (Renter -> roommate)
317
+ 2. LLM extracted type (if valid)
318
+ 3. Price type inference
319
+ 4. Default (rent)
 
 
 
320
  """
321
 
322
+ # RENTER/ROOMMATE role: Always roommate listings
323
+ if user_role in ["renter", "roommate"]:
324
  return "roommate"
325
+
326
+ # If LLM explicitly extracted a valid type, trust it (but validate)
327
+ if extracted_type:
328
+ valid_types = ["rent", "sale", "short-stay", "roommate"]
329
+ if extracted_type in valid_types:
330
+ # Landlords can't list roommates usually, but if explicitly said...
331
+ if extracted_type == "roommate" and user_role == "landlord":
332
+ pass # Allow or block? Let's strictly block for now
333
+ else:
334
+ return extracted_type
335
+
336
+ # LANDLORD: Detect from price_type as backup
337
+ price_type_lower = (price_type or "").lower().strip()
338
+
339
+ # ============================================================
340
+ # SHORT-STAY: daily, nightly, weekly (any language)
341
+ # ============================================================
342
+ SHORT_STAY_TYPES = [
343
+ # English
344
+ "daily", "day", "per day",
345
+ "nightly", "night", "per night",
346
+ "weekly", "week", "per week",
347
+ # French
348
+ "jour", "journalier", "par jour",
349
+ "nuit", "nuitée", "par nuit",
350
+ "semaine", "hebdomadaire", "par semaine",
351
+ ]
352
 
353
+ if any(t in price_type_lower for t in SHORT_STAY_TYPES):
354
+ return "short-stay"
 
 
 
 
355
 
356
+ # ============================================================
357
+ # SALE: one-time/total price
358
+ # ============================================================
359
+ SALE_TYPES = [
360
+ # English
361
+ "total", "one-time", "once", "fixed", "purchase",
362
+ # French
363
+ "unique", "forfait", "total", "achat",
364
+ ]
365
 
366
+ if any(t in price_type_lower for t in SALE_TYPES):
367
+ return "sale"
 
368
 
369
+ # ============================================================
370
+ # RENT: monthly, yearly (default for landlord)
371
+ # ============================================================
372
+ # This includes: "monthly", "yearly", "annual", "par mois", "par an", "mensuel", "annuel"
373
+ # But also anything else that isn't short-stay or sale
374
  return "rent"
375
 
376
 
 
441
  # GENERATE TITLE AND DESCRIPTION
442
  # ============================================================
443
 
444
+ async def generate_title_and_description(
445
+ draft_data: Dict,
446
+ user_role: str,
447
+ user_language: str = None,
448
+ conversation_history: list = None
449
+ ) -> Tuple[str, str]:
450
+ """Auto-generate attractive title and description for listing using LLM.
451
+
452
+ Args:
453
+ draft_data: Listing data
454
+ user_role: Role of the user
455
+ user_language: Detected language (e.g., 'fr', 'en'). If None, LLM will detect from context.
456
+ conversation_history: User's messages to detect language from
457
+ """
458
+ bedrooms = draft_data.get("bedrooms", "?")
459
+ bathrooms = draft_data.get("bathrooms", "?")
460
+ location = draft_data.get("location", "Unknown")
461
+ listing_type = draft_data.get("listing_type", "property")
462
+ price = draft_data.get("price", "?")
463
+ price_type = draft_data.get("price_type", "monthly")
464
+ currency = draft_data.get("currency", "")
465
+ amenities = draft_data.get("amenities", [])
466
+ requirements = draft_data.get("requirements", "")
467
+
468
+ # Use LLM to generate BOTH title and description in the correct language
469
+ try:
470
+ from langchain_openai import ChatOpenAI
471
+ from langchain_core.messages import HumanMessage
472
+ from app.config import settings
473
+
474
+ llm = ChatOpenAI(
475
+ api_key=settings.DEEPSEEK_API_KEY,
476
+ base_url=settings.DEEPSEEK_BASE_URL,
477
+ model="deepseek-chat",
478
+ temperature=0.7,
479
+ )
480
+
481
+ amenities_str = ", ".join(amenities) if amenities else "standard features"
482
+
483
+ # Extract user's actual words from conversation history
484
+ user_messages = ""
485
+ if conversation_history:
486
+ user_msgs = [msg.get("content", "") for msg in conversation_history if msg.get("role") == "user"]
487
+ user_messages = " ".join(user_msgs[:3]) # First 3 messages
488
+
489
+ # Language instruction - universal language detection
490
+ lang_instruction = f"""
491
+
492
+ 🌍 CRITICAL LANGUAGE RULE:
493
+ The user described their property in their own language. You MUST generate the title and description in the EXACT SAME LANGUAGE.
494
+
495
+ User's original messages: "{user_messages[:300] if user_messages else 'Not available'}"
496
+
497
+ INSTRUCTIONS:
498
+ 1. Detect the language from the user's messages above (French, English, Spanish, Portuguese, Arabic, Chinese, etc.)
499
+ 2. Generate BOTH title and description in that EXACT language
500
+ 3. Do NOT translate - use the same language the user used
501
+ 4. This applies to ANY language, not just English or French
502
+
503
+ Examples:
504
+ - User speaks French → Title and description in French
505
+ - User speaks Spanish → Title and description in Spanish
506
+ - User speaks English → Title and description in English
507
+ """
508
+
509
+ prompt = f"""Generate an attractive TITLE and DESCRIPTION for a real estate listing.
510
+ {lang_instruction}
511
+
512
+ Property Details:
513
+ - Type: {listing_type}
514
+ - Bedrooms: {bedrooms}
515
+ - Bathrooms: {bathrooms}
516
+ - Location: {location}
517
+ - Price: {price} {currency} per {price_type}
518
+ - Amenities: {amenities_str}
519
+ {f'- Requirements: {requirements}' if requirements else ''}
520
+
521
+ Guidelines:
522
+ 1. TITLE: Short, SEO-friendly (e.g., "Villa 4 chambres à Cotonou" in French, "4-Bed Villa in Lagos" in English)
523
+ 2. DESCRIPTION: 3-4 engaging sentences that would attract renters/buyers
524
+ 3. Start with a compelling hook about the property
525
+ 4. Highlight the location and key features naturally
526
+ 5. End with a call-to-action
527
+ 6. Do NOT use bullet points
528
+
529
+ Return ONLY valid JSON:
530
+ {{
531
+ "title": "Your generated title here",
532
+ "description": "Your generated description here"
533
+ }}"""
534
+
535
+ response = await llm.ainvoke([HumanMessage(content=prompt)])
536
+ response_text = response.content.strip()
537
+
538
+ # Extract JSON
539
+ import re
540
+ json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
541
+ if json_match:
542
+ result = json.loads(json_match.group())
543
+ title = result.get("title", "").strip()
544
+ description = result.get("description", "").strip()
545
+
546
+ if title and description and len(description) > 50:
547
+ logger.info("Title and description generated via LLM", title=title, desc_len=len(description))
548
+ return title, description
549
+
550
+ raise ValueError("Failed to parse LLM response")
551
+
552
+ except Exception as e:
553
+ logger.warning(f"LLM description failed, using fallback: {e}")
554
+
555
+ # Fallback to English template
556
+ title = f"{bedrooms}-Bed {listing_type.capitalize()} in {location}"
557
+
558
+ amenities_text = ""
559
+ if amenities:
560
+ if len(amenities) == 1:
561
+ amenities_text = f"featuring {amenities[0]}"
562
+ elif len(amenities) == 2:
563
+ amenities_text = f"featuring {amenities[0]} and {amenities[1]}"
564
+ else:
565
+ amenities_text = f"featuring {', '.join(amenities[:-1])}, and {amenities[-1]}"
566
+
567
+ description = f"This charming {bedrooms}-bedroom, {bathrooms}-bathroom {listing_type} is located in {location}. "
568
+
569
+ if amenities_text:
570
+ description += f"The property comes {amenities_text}, offering comfort and convenience. "
571
+ else:
572
+ description += f"The property offers a perfect blend of comfort and modern living. "
573
+
574
+ description += f"Available at {price} {currency} per {price_type}. "
575
+ description += "Don't miss this opportunity - schedule a viewing today!"
576
+
577
+ if requirements:
578
+ description += f" Requirements: {requirements}."
579
+
580
+ logger.info("Title and description generated (fallback)", title=title, sentence_count=description.count('.'))
581
+ return title, description
582
+
583
+
584
+ async def regenerate_description_with_prompt(draft_data: Dict, user_prompt: str, user_role: str) -> str:
585
+ """
586
+ Regenerate description based on user's specific prompt/guidance.
587
+
588
+ Examples:
589
+ - "make it more appealing"
590
+ - "add more details about the location"
591
+ - "make it sound luxurious"
592
+ - "give my property a better description"
593
+ """
594
  bedrooms = draft_data.get("bedrooms", "?")
595
  bathrooms = draft_data.get("bathrooms", "?")
596
  location = draft_data.get("location", "Unknown")
 
598
  price = draft_data.get("price", "?")
599
  price_type = draft_data.get("price_type", "monthly")
600
  currency = draft_data.get("currency", "")
601
+ amenities = draft_data.get("amenities", [])
602
+ current_description = draft_data.get("description", "")
603
+
604
+ try:
605
+ from langchain_openai import ChatOpenAI
606
+ from langchain_core.messages import HumanMessage
607
+ from app.config import settings
608
+
609
+ llm = ChatOpenAI(
610
+ api_key=settings.DEEPSEEK_API_KEY,
611
+ base_url=settings.DEEPSEEK_BASE_URL,
612
+ model="deepseek-chat",
613
+ temperature=0.8, # Slightly higher for more creativity
614
+ )
615
+
616
+ amenities_str = ", ".join(amenities) if amenities else "standard features"
617
+
618
+ prompt = f"""You are a professional real estate copywriter. Rewrite this property description based on the user's request.
619
+
620
+ Current Description:
621
+ "{current_description}"
622
+
623
+ Property Details:
624
+ - Type: {listing_type}
625
+ - Bedrooms: {bedrooms}
626
+ - Bathrooms: {bathrooms}
627
+ - Location: {location}
628
+ - Price: {price} {currency} per {price_type}
629
+ - Amenities: {amenities_str}
630
+
631
+ User's Request: "{user_prompt}"
632
+
633
+ Guidelines:
634
+ 1. Follow the user's request carefully
635
+ 2. Write 3-5 engaging sentences
636
+ 3. Make it feel welcoming, desirable, and professional
637
+ 4. Include a compelling hook and call-to-action
638
+ 5. Highlight key features naturally (don't just list them)
639
+ 6. Do NOT use bullet points or lists
640
+ 7. Be creative and paint a picture
641
+
642
+ Write ONLY the new description, no quotes or extra text:"""
643
+
644
+ response = await llm.ainvoke([HumanMessage(content=prompt)])
645
+ description = response.content.strip().strip('"').strip("'")
646
+
647
+ # Ensure minimum length
648
+ if len(description) < 50:
649
+ raise ValueError("Description too short")
650
+
651
+ logger.info("Description regenerated via user prompt", desc_len=len(description), user_prompt=user_prompt[:50])
652
+ return description
653
+
654
+ except Exception as e:
655
+ logger.warning(f"LLM description regeneration failed: {e}")
656
+ # Return original if failed
657
+ return current_description
658
+
659
+
660
+ def is_description_regeneration_request(message: str) -> bool:
661
+ """
662
+ Detect if user is asking to regenerate/improve the description.
663
+ """
664
+ message_lower = message.lower()
665
 
666
+ description_keywords = ["description", "describe", "describing"]
667
+ action_keywords = ["better", "improve", "regenerate", "rewrite", "change", "update", "new", "make", "give", "write", "create", "more", "different", "attractive", "appealing", "professional", "nice", "cool"]
668
 
669
+ has_description = any(kw in message_lower for kw in description_keywords)
670
+ has_action = any(kw in message_lower for kw in action_keywords)
 
671
 
672
+ return has_description and has_action
673
+
674
 
675
 
676
  # ============================================================
 
810
  listing_type = await auto_detect_listing_type(
811
  price_type=provided_fields.get("price_type", ""),
812
  user_role=user_role,
813
+ user_message=user_message,
814
+ extracted_type=provided_fields.get("listing_type")
815
  )
816
  provided_fields["listing_type"] = listing_type
817
 
app/ml/models/ml_listing_extractor.py CHANGED
@@ -45,9 +45,13 @@ class CurrencyManager:
45
  try:
46
  # Use Nominatim (free, no API key needed)
47
  async with aiohttp.ClientSession() as session:
48
- url = f"https://nominatim.openstreetmap.org/search?q={location}&format=json&limit=1&addressdetails=1"
 
 
 
 
49
 
50
- async with session.get(url, timeout=5) as resp:
51
  if resp.status == 200:
52
  data = await resp.json()
53
 
@@ -417,9 +421,13 @@ class MLListingExtractor:
417
 
418
  try:
419
  async with aiohttp.ClientSession() as session:
420
- url = f"https://nominatim.openstreetmap.org/search?q={address}&format=json&limit=1&addressdetails=1"
 
 
 
 
421
 
422
- async with session.get(url, timeout=5) as resp:
423
  if resp.status == 200:
424
  data = await resp.json()
425
 
 
45
  try:
46
  # Use Nominatim (free, no API key needed)
47
  async with aiohttp.ClientSession() as session:
48
+ # URL-encode the location to handle special characters
49
+ from urllib.parse import quote
50
+ encoded_location = quote(location, safe='')
51
+ url = f"https://nominatim.openstreetmap.org/search?q={encoded_location}&format=json&limit=1&addressdetails=1"
52
+ headers = {"User-Agent": "AIDA-RealEstate-App/1.0"}
53
 
54
+ async with session.get(url, headers=headers, timeout=10) as resp:
55
  if resp.status == 200:
56
  data = await resp.json()
57
 
 
421
 
422
  try:
423
  async with aiohttp.ClientSession() as session:
424
+ # URL-encode the address to handle special characters
425
+ from urllib.parse import quote
426
+ encoded_address = quote(address, safe='')
427
+ url = f"https://nominatim.openstreetmap.org/search?q={encoded_address}&format=json&limit=1&addressdetails=1"
428
+ headers = {"User-Agent": "AIDA-RealEstate-App/1.0"}
429
 
430
+ async with session.get(url, headers=headers, timeout=10) as resp:
431
  if resp.status == 200:
432
  data = await resp.json()
433
 
app/models/__pycache__/listing.cpython-313.pyc CHANGED
Binary files a/app/models/__pycache__/listing.cpython-313.pyc and b/app/models/__pycache__/listing.cpython-313.pyc differ
 
app/models/listing.py CHANGED
@@ -19,6 +19,9 @@ class Listing(BaseModel):
19
  bedrooms: Optional[int] = None
20
  bathrooms: Optional[int] = None
21
  location: str
 
 
 
22
  amenities: Optional[List[str]] = None
23
  currency: str = "XOF"
24
  status: str = "draft" # draft | active | archived
 
19
  bedrooms: Optional[int] = None
20
  bathrooms: Optional[int] = None
21
  location: str
22
+ address: Optional[str] = None
23
+ latitude: Optional[float] = None
24
+ longitude: Optional[float] = None
25
  amenities: Optional[List[str]] = None
26
  currency: str = "XOF"
27
  status: str = "draft" # draft | active | archived
app/routes/__pycache__/listing.cpython-313.pyc CHANGED
Binary files a/app/routes/__pycache__/listing.cpython-313.pyc and b/app/routes/__pycache__/listing.cpython-313.pyc differ
 
app/routes/listing.py CHANGED
@@ -34,7 +34,7 @@ async def get_active_listings(
34
  try:
35
  # Pydantic will handle datetime conversion
36
  listing = Listing(**doc)
37
- listings.append(listing.dict(by_alias=True))
38
  except Exception as e:
39
  print(f"[WARNING] Error parsing listing {doc.get('_id', 'unknown')}: {e}")
40
  continue
@@ -81,6 +81,10 @@ async def delete_listing(
81
  if result.deleted_count == 0:
82
  raise HTTPException(status_code=500, detail="Failed to delete listing")
83
 
 
 
 
 
84
  # Decrement user's totalListings counter
85
  try:
86
  await db.users.update_one(
 
34
  try:
35
  # Pydantic will handle datetime conversion
36
  listing = Listing(**doc)
37
+ listings.append(listing.model_dump(by_alias=True))
38
  except Exception as e:
39
  print(f"[WARNING] Error parsing listing {doc.get('_id', 'unknown')}: {e}")
40
  continue
 
81
  if result.deleted_count == 0:
82
  raise HTTPException(status_code=500, detail="Failed to delete listing")
83
 
84
+ # ✅ Sync to Qdrant - remove the vector
85
+ from app.ai.services.vector_service import delete_listing_from_vector_db
86
+ await delete_listing_from_vector_db(listing_id)
87
+
88
  # Decrement user's totalListings counter
89
  try:
90
  await db.users.update_one(
app/routes/websocket_listings.py CHANGED
@@ -96,7 +96,7 @@ async def websocket_listings_endpoint(websocket: WebSocket, token: str = Query(.
96
  doc["_id"] = str(doc["_id"])
97
  try:
98
  listing = Listing(**doc)
99
- listings.append(listing.dict(by_alias=True))
100
  except Exception as e:
101
  logger.warning(f"[WS] Error parsing listing: {e}")
102
  continue
@@ -136,7 +136,7 @@ async def websocket_listings_endpoint(websocket: WebSocket, token: str = Query(.
136
  doc["_id"] = str(doc["_id"])
137
  try:
138
  listing = Listing(**doc)
139
- listings.append(listing.dict(by_alias=True))
140
  except Exception as e:
141
  logger.warning(f"[WS] Error parsing listing: {e}")
142
  continue
 
96
  doc["_id"] = str(doc["_id"])
97
  try:
98
  listing = Listing(**doc)
99
+ listings.append(listing.model_dump(by_alias=True))
100
  except Exception as e:
101
  logger.warning(f"[WS] Error parsing listing: {e}")
102
  continue
 
136
  doc["_id"] = str(doc["_id"])
137
  try:
138
  listing = Listing(**doc)
139
+ listings.append(listing.model_dump(by_alias=True))
140
  except Exception as e:
141
  logger.warning(f"[WS] Error parsing listing: {e}")
142
  continue
backfill_geocoding.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Backfill geocoding for existing listings
3
+ This script geocodes addresses for listings that have an address but no coordinates
4
+ """
5
+ import asyncio
6
+ from app.database import connect_db, get_db
7
+ from app.ai.tools.listing_tool import geocode_address
8
+ from bson import ObjectId
9
+
10
+ async def backfill_geocoding():
11
+ # Initialize database connection
12
+ await connect_db()
13
+ db = await get_db()
14
+
15
+ # Find listings that have a location but no latitude/longitude
16
+ query = {
17
+ "$or": [
18
+ # Has address but no coordinates
19
+ {"address": {"$ne": None}, "latitude": None},
20
+ {"address": {"$ne": None}, "latitude": {"$exists": False}},
21
+ # Has location but no coordinates (for city-level geocoding)
22
+ {"location": {"$ne": None}, "latitude": None},
23
+ {"location": {"$ne": None}, "latitude": {"$exists": False}},
24
+ ]
25
+ }
26
+
27
+ cursor = db.listings.find(query)
28
+
29
+ print("=== Backfilling Geocoding for Existing Listings ===\n")
30
+
31
+ updated_count = 0
32
+ failed_count = 0
33
+
34
+ async for doc in cursor:
35
+ listing_id = str(doc.get("_id"))
36
+ title = doc.get("title", "No title")[:40]
37
+ location = doc.get("location")
38
+ address = doc.get("address")
39
+
40
+ # Build search query - prefer address if available
41
+ if address and location:
42
+ search_query = f"{address}, {location}"
43
+ elif address:
44
+ search_query = address
45
+ elif location:
46
+ search_query = location
47
+ else:
48
+ print(f"⏭️ Skipping {title} - no location or address")
49
+ continue
50
+
51
+ print(f"🔍 Geocoding: {title}")
52
+ print(f" Query: {search_query}")
53
+
54
+ # Call geocode function
55
+ geo_result = await geocode_address(search_query, location)
56
+
57
+ if geo_result.get("success"):
58
+ lat = geo_result.get("latitude")
59
+ lon = geo_result.get("longitude")
60
+
61
+ # Update the listing in the database
62
+ update_data = {
63
+ "latitude": lat,
64
+ "longitude": lon,
65
+ }
66
+
67
+ # If we didn't have an address, store the location as address
68
+ if not address and location:
69
+ update_data["address"] = location
70
+
71
+ result = await db.listings.update_one(
72
+ {"_id": ObjectId(listing_id)},
73
+ {"$set": update_data}
74
+ )
75
+
76
+ if result.modified_count > 0:
77
+ print(f" ✅ Updated: lat={lat}, lon={lon}")
78
+ updated_count += 1
79
+ else:
80
+ print(f" ⚠️ No change made")
81
+ else:
82
+ print(f" ❌ Geocoding failed: {geo_result.get('error', 'Unknown error')}")
83
+ failed_count += 1
84
+
85
+ # Small delay to respect Nominatim rate limits
86
+ await asyncio.sleep(1.1)
87
+
88
+ print(f"\n=== Summary ===")
89
+ print(f"✅ Updated: {updated_count} listings")
90
+ print(f"❌ Failed: {failed_count} listings")
91
+
92
+ if __name__ == "__main__":
93
+ asyncio.run(backfill_geocoding())
check_db_listings.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Check what's in the database for listings"""
2
+ import asyncio
3
+ from app.database import connect_db, get_db
4
+ from app.models.listing import Listing
5
+
6
+ async def check_listings():
7
+ # Initialize database connection
8
+ await connect_db()
9
+ db = await get_db()
10
+
11
+ # Get the 3 most recent active listings
12
+ cursor = db.listings.find({"status": "active"}).sort("created_at", -1).limit(3)
13
+
14
+ print("=== Checking Recent Listings in MongoDB ===\n")
15
+
16
+ async for doc in cursor:
17
+ listing_id = str(doc.get("_id"))
18
+ title = doc.get("title", "No title")[:50]
19
+
20
+ print(f"Listing: {title}")
21
+ print(f" _id: {listing_id}")
22
+ print(f" location: {doc.get('location')}")
23
+ print(f" address (in DB): {doc.get('address')}")
24
+ print(f" latitude (in DB): {doc.get('latitude')}")
25
+ print(f" longitude (in DB): {doc.get('longitude')}")
26
+
27
+ # Now convert to Listing model and back
28
+ if "_id" in doc:
29
+ doc["_id"] = str(doc["_id"])
30
+
31
+ try:
32
+ listing = Listing(**doc)
33
+ result = listing.model_dump(by_alias=True)
34
+
35
+ print(f" --- After Pydantic ---")
36
+ print(f" address (serialized): {result.get('address')}")
37
+ print(f" latitude (serialized): {result.get('latitude')}")
38
+ print(f" longitude (serialized): {result.get('longitude')}")
39
+ except Exception as e:
40
+ print(f" ERROR: {e}")
41
+
42
+ print()
43
+
44
+ if __name__ == "__main__":
45
+ asyncio.run(check_listings())
check_qdrant_info.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import asyncio
3
+ import os
4
+ from qdrant_client import AsyncQdrantClient
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ QDRANT_URL = os.getenv("QDRANT_URL")
10
+ QDRANT_API_KEY = os.getenv("QDRANT_API_KEY")
11
+
12
+ async def check():
13
+ client = AsyncQdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
14
+ try:
15
+ info = await client.get_collection("listings")
16
+ print(f"Collection: listings")
17
+ print(f"Vector size: {info.config.params.vectors.size}")
18
+ print(f"Points count: {info.points_count}")
19
+ except Exception as e:
20
+ print(f"Error: {e}")
21
+
22
+ if __name__ == "__main__":
23
+ asyncio.run(check())
fix_failed_listing.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fix the one failed listing by geocoding just the city"""
2
+ import asyncio
3
+ from app.database import connect_db, get_db
4
+ from app.ai.tools.listing_tool import geocode_address
5
+ from bson import ObjectId
6
+
7
+ async def fix_failed_listing():
8
+ await connect_db()
9
+ db = await get_db()
10
+
11
+ # Find the specific listing
12
+ listing_id = "694402f3947fa344b4462fce"
13
+
14
+ print("🔍 Geocoding Cotonou (city only)...")
15
+ geo_result = await geocode_address("Cotonou", None)
16
+
17
+ if geo_result.get("success"):
18
+ lat = geo_result.get("latitude")
19
+ lon = geo_result.get("longitude")
20
+
21
+ result = await db.listings.update_one(
22
+ {"_id": ObjectId(listing_id)},
23
+ {"$set": {"latitude": lat, "longitude": lon}}
24
+ )
25
+
26
+ if result.modified_count > 0:
27
+ print(f"✅ Updated: lat={lat}, lon={lon}")
28
+ else:
29
+ print("⚠️ No change made")
30
+ else:
31
+ print(f"❌ Failed: {geo_result.get('error')}")
32
+
33
+ if __name__ == "__main__":
34
+ asyncio.run(fix_failed_listing())
migrate_to_4096.py CHANGED
@@ -56,6 +56,12 @@ async def embed(text: str) -> List[float]:
56
  # ------------------------------------------------------------------
57
  # Main migration function
58
  # ------------------------------------------------------------------
 
 
 
 
 
 
59
  async def rebuild():
60
  """Delete old collection, create new 4096-D collection with indexed fields, and migrate all documents."""
61
  try:
@@ -158,7 +164,8 @@ async def rebuild():
158
  "currency": doc.get("currency", "XOF"),
159
  }
160
 
161
- batch.append(models.PointStruct(id=str(uuid4()), vector=vector, payload=payload))
 
162
 
163
  # Upload batch when it reaches BATCH_SIZE
164
  if len(batch) >= BATCH_SIZE:
 
56
  # ------------------------------------------------------------------
57
  # Main migration function
58
  # ------------------------------------------------------------------
59
+ def get_deterministic_uuid(mongo_id: str) -> str:
60
+ """Generate a deterministic UUID from a MongoDB ObjectId string."""
61
+ from uuid import UUID
62
+ padded_hex = mongo_id.zfill(32)
63
+ return str(UUID(hex=padded_hex))
64
+
65
  async def rebuild():
66
  """Delete old collection, create new 4096-D collection with indexed fields, and migrate all documents."""
67
  try:
 
164
  "currency": doc.get("currency", "XOF"),
165
  }
166
 
167
+ point_id = get_deterministic_uuid(str(doc["_id"]))
168
+ batch.append(models.PointStruct(id=point_id, vector=vector, payload=payload))
169
 
170
  # Upload batch when it reaches BATCH_SIZE
171
  if len(batch) >= BATCH_SIZE:
sync_all_listings_to_qdrant.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Full Sync Script: MongoDB → Qdrant
3
+ ===================================
4
+ Syncs ALL active listings from MongoDB to Qdrant with complete data including images.
5
+ This creates new points in Qdrant for listings that don't exist yet.
6
+
7
+ Usage:
8
+ python sync_all_listings_to_qdrant.py
9
+ """
10
+
11
+ import asyncio
12
+ from motor.motor_asyncio import AsyncIOMotorClient
13
+ from qdrant_client import QdrantClient
14
+ from qdrant_client.models import PointStruct
15
+ from structlog import get_logger
16
+ import os
17
+ from dotenv import load_dotenv
18
+ from uuid import UUID
19
+ import httpx
20
+
21
+ # Load environment variables
22
+ load_dotenv()
23
+
24
+ logger = get_logger(__name__)
25
+
26
+ # Configuration
27
+ MONGO_URI = os.getenv("MONGODB_URL")
28
+ if not MONGO_URI:
29
+ raise ValueError("MONGODB_URL environment variable not set in .env file")
30
+
31
+ MONGO_DB = os.getenv("MONGODB_DATABASE", "lojiz")
32
+ QDRANT_URL = os.getenv("QDRANT_URL")
33
+ QDRANT_API_KEY = os.getenv("QDRANT_API_KEY")
34
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
35
+
36
+ if not QDRANT_URL or not QDRANT_API_KEY:
37
+ raise ValueError("QDRANT_URL and QDRANT_API_KEY must be set in .env")
38
+
39
+ if not OPENROUTER_API_KEY:
40
+ raise ValueError("OPENROUTER_API_KEY must be set in .env for embeddings")
41
+
42
+ COLLECTION_NAME = "listings"
43
+ EMBED_MODEL = "qwen/qwen3-embedding-8b"
44
+
45
+
46
+ def get_deterministic_uuid(mongo_id: str) -> str:
47
+ """Convert MongoDB ObjectId to deterministic UUID for Qdrant."""
48
+ import hashlib
49
+ hash_bytes = hashlib.md5(mongo_id.encode()).digest()
50
+ return str(UUID(bytes=hash_bytes))
51
+
52
+
53
+ async def embed_query(text: str) -> list:
54
+ """Create embedding using OpenRouter."""
55
+ async with httpx.AsyncClient(timeout=30) as client:
56
+ payload = {
57
+ "model": EMBED_MODEL,
58
+ "input": text,
59
+ "encoding_format": "float"
60
+ }
61
+ headers = {
62
+ "Authorization": f"Bearer {OPENROUTER_API_KEY}",
63
+ "Content-Type": "application/json",
64
+ }
65
+
66
+ response = await client.post(
67
+ "https://openrouter.ai/api/v1/embeddings",
68
+ json=payload,
69
+ headers=headers
70
+ )
71
+ response.raise_for_status()
72
+
73
+ data = response.json()
74
+ return data["data"][0]["embedding"]
75
+
76
+
77
+ async def sync_all_listings():
78
+ """Sync all active listings from MongoDB to Qdrant."""
79
+
80
+ # Connect to MongoDB
81
+ mongo_client = AsyncIOMotorClient(MONGO_URI)
82
+ db = mongo_client[MONGO_DB]
83
+
84
+ # Connect to Qdrant
85
+ qdrant_client = QdrantClient(
86
+ url=QDRANT_URL,
87
+ api_key=QDRANT_API_KEY,
88
+ timeout=60
89
+ )
90
+
91
+ try:
92
+ # Fetch all active listings from MongoDB
93
+ cursor = db.listings.find({"status": "active"})
94
+ listings = await cursor.to_list(length=None)
95
+
96
+ total = len(listings)
97
+ logger.info(f"Found {total} active listings to sync")
98
+
99
+ synced = 0
100
+ skipped = 0
101
+ errors = 0
102
+
103
+ for idx, listing in enumerate(listings, 1):
104
+ mongo_id = str(listing["_id"])
105
+ point_id = get_deterministic_uuid(mongo_id)
106
+
107
+ try:
108
+ # Build text for embedding
109
+ bedrooms = listing.get("bedrooms") or 0
110
+ location = listing.get("location") or ""
111
+ address = listing.get("address") or ""
112
+ title = listing.get("title") or ""
113
+ description = listing.get("description") or ""
114
+
115
+ text = f"{title}. {bedrooms}-bed in {location} ({address}). {description}".strip()
116
+
117
+ # Generate embedding
118
+ print(f"[{idx}/{total}] Generating embedding for: {title[:50]}...")
119
+ vector = await embed_query(text)
120
+
121
+ # Prepare payload
122
+ price_type = listing.get("price_type") or ""
123
+ listing_type = listing.get("listing_type") or listing.get("type") or ""
124
+
125
+ payload = {
126
+ "mongo_id": mongo_id,
127
+ "title": title,
128
+ "description": description,
129
+ "location": location,
130
+ "location_lower": location.lower() if location else "",
131
+ "address": address,
132
+ "price": float(listing.get("price") or 0),
133
+ "price_type": price_type,
134
+ "price_type_lower": price_type.lower() if price_type else "",
135
+ "listing_type": listing_type,
136
+ "listing_type_lower": listing_type.lower() if listing_type else "",
137
+ "bedrooms": int(bedrooms),
138
+ "bathrooms": int(listing.get("bathrooms") or 0),
139
+ "amenities": [a.lower() for a in (listing.get("amenities") or [])],
140
+ "currency": listing.get("currency", "XOF"),
141
+ "status": listing.get("status", "active"),
142
+ "latitude": listing.get("latitude"),
143
+ "longitude": listing.get("longitude"),
144
+ "images": listing.get("images", []), # ✅ Include images
145
+ }
146
+
147
+ # Upsert point to Qdrant
148
+ qdrant_client.upsert(
149
+ collection_name=COLLECTION_NAME,
150
+ points=[
151
+ PointStruct(
152
+ id=point_id,
153
+ vector=vector,
154
+ payload=payload
155
+ )
156
+ ]
157
+ )
158
+
159
+ synced += 1
160
+ logger.info(
161
+ f"[{idx}/{total}] ✅ Synced",
162
+ mongo_id=mongo_id,
163
+ title=title[:30],
164
+ images_count=len(listing.get("images", []))
165
+ )
166
+
167
+ except Exception as e:
168
+ errors += 1
169
+ logger.error(
170
+ f"[{idx}/{total}] ❌ Failed",
171
+ mongo_id=mongo_id,
172
+ error=str(e)
173
+ )
174
+
175
+ # Summary
176
+ print("\n" + "="*60)
177
+ print("SYNC COMPLETE")
178
+ print("="*60)
179
+ print(f"Total listings: {total}")
180
+ print(f"✅ Synced: {synced}")
181
+ print(f"⚠️ Skipped: {skipped}")
182
+ print(f"❌ Errors: {errors}")
183
+ print("="*60)
184
+
185
+ finally:
186
+ mongo_client.close()
187
+ qdrant_client.close()
188
+
189
+
190
+ if __name__ == "__main__":
191
+ print("Starting full sync from MongoDB to Qdrant...")
192
+ print("This will create embeddings for all listings (may take a few minutes)...\n")
193
+ asyncio.run(sync_all_listings())
test_chat_ui.html CHANGED
@@ -274,7 +274,17 @@
274
  width: 100%;
275
  height: 200px;
276
  object-fit: cover;
277
- display: block;
 
 
 
 
 
 
 
 
 
 
278
  }
279
 
280
  .listing-draft .content {
@@ -382,11 +392,15 @@
382
  width: 100%;
383
  height: 100%;
384
  object-fit: cover;
385
- transition: transform 0.3s ease;
 
 
 
 
386
  }
387
 
388
  .search-result-card:hover .search-card-hero {
389
- transform: scale(1.05);
390
  }
391
 
392
  .search-card-type {
@@ -528,6 +542,11 @@
528
  width: 100%;
529
  height: 100%;
530
  object-fit: cover;
 
 
 
 
 
531
  }
532
 
533
  .my-listing-card-content {
@@ -596,6 +615,45 @@
596
  opacity: 0.5;
597
  cursor: not-allowed;
598
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  </style>
600
  </head>
601
 
@@ -681,12 +739,29 @@
681
  </div>
682
  </div>
683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  <div class="input-area">
 
 
685
  <input type="text" id="message-input" placeholder="Type your message..."
686
  onkeypress="if(event.key === 'Enter') sendMessage()">
687
  <button onclick="sendMessage()" style="width: auto;">Send</button>
688
- <button onclick="uploadImage()" class="secondary" style="width: auto; background-color: #f59e0b;">📷
689
- Upload Image</button>
690
  </div>
691
 
692
  <!-- Typing Indicator (Hidden by default) -->
@@ -705,6 +780,11 @@
705
  let currentSessionId = '';
706
  // Use 127.0.0.1 to avoid localhost DNS/Av issues
707
  const API_BASE = 'http://127.0.0.1:8000';
 
 
 
 
 
708
 
709
  // Init
710
  window.onload = function () {
@@ -725,6 +805,34 @@
725
  });
726
  }
727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
  // Image URL Regex
729
  const IMG_URL_REGEX = /(https?:\/\/.*\.(?:png|jpg|jpeg|gif|webp|svg))/i;
730
 
@@ -766,30 +874,34 @@
766
  el.textContent = JSON.stringify(data, null, 2);
767
  }
768
 
769
- function addMessage(role, text, debugInfo = null) {
770
  const history = document.getElementById('chat-history');
771
  const div = document.createElement('div');
772
  div.className = `message ${role}`;
773
 
774
  let html = `<div class="meta">${role === 'user' ? 'You' : 'AIDA'}</div>`;
775
 
776
- // Check for image URL to render it
777
- const imgMatch = text.match(IMG_URL_REGEX);
778
- if (imgMatch) {
779
- // If text contains an image URL, render the image + the text (if it's not JUST the url)
780
- const url = imgMatch[0];
781
- const textWithoutUrl = text.replace(url, '').trim();
782
-
783
- html += `<div style="margin-bottom:8px;">
784
- <img src="${url}" style="max-width:100%; border-radius:8px; display:block;" alt="Uploaded Image" onerror="this.style.display='none'">
785
- </div>`;
786
-
787
- if (textWithoutUrl) {
788
- html += `<div>${formatMarkdown(textWithoutUrl)}</div>`;
789
- }
790
  } else {
791
- // Always show the actual text from the response (personalized by LLM)
792
- html += `<div>${formatMarkdown(text)}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
  }
794
 
795
  // Handle draft_ui: Message + Card appear as ONE UNIT
@@ -842,18 +954,36 @@
842
 
843
  // NEW: Render card WITH message as ONE unit
844
  function renderDraftWithMessage(draft, message) {
845
- const heroImage = (draft.images && draft.images.length > 0) ? draft.images[0] : 'https://via.placeholder.com/400x200?text=No+Image';
 
846
 
847
  let amenitiesHtml = '';
848
  if (draft.amenities && draft.amenities.length > 0) {
849
  amenitiesHtml = '<div class="amenities">' + draft.amenities.map(a => `<span class="pill">${a}</span>`).join('') + '</div>';
850
  }
851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  return `
853
  <div class="listing-card-unit">
854
  <div class="card-message">${formatMarkdown(message)}</div>
855
- <div class="listing-draft" id="listing-draft-preview">
856
- <img src="${heroImage}" class="hero" alt="Listing Image">
 
 
 
857
  <div class="content">
858
  <h3>${draft.title}</h3>
859
  <p>${draft.description}</p>
@@ -943,7 +1073,7 @@
943
  function renderSearchResults(results) {
944
  if (!results || results.length === 0) return '';
945
 
946
- const cards = results.map(listing => {
947
  const title = listing.title || 'Untitled Property';
948
  const description = listing.description || 'A beautiful property waiting to be explored.';
949
  const location = listing.location || 'Unknown';
@@ -956,11 +1086,12 @@
956
  const relevance = listing._relevance_score;
957
  const amenities = listing.amenities || [];
958
 
959
- // Get hero image (first image or placeholder)
960
- const images = listing.images || [];
961
- const heroImage = images.length > 0
962
- ? images[0]
963
- : 'https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=400&h=200&fit=crop';
 
964
 
965
  // Truncate description to 2 lines
966
  const shortDesc = description.length > 100
@@ -984,12 +1115,27 @@
984
  relevanceBadge = `<span class="search-relevance-badge">⭐ ${percent}% match</span>`;
985
  }
986
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
987
  return `
988
- <div class="search-result-card">
989
  <div class="search-card-image-container">
990
- <img src="${heroImage}" class="search-card-hero" alt="${title}" onerror="this.src='https://via.placeholder.com/400x180?text=Property'">
991
  <span class="search-card-type">${listingType}</span>
992
  ${relevanceBadge}
 
993
  </div>
994
  <div class="search-card-content">
995
  <h3 class="search-card-title">${title}</h3>
@@ -1025,20 +1171,38 @@
1025
  return '';
1026
  }
1027
 
1028
- const cards = listings.map(listing => {
1029
  const id = listing._id || listing.id || '';
1030
  const title = listing.title || 'Untitled';
1031
  const location = listing.location || 'Unknown Location';
1032
  const price = (listing.price || 0).toLocaleString();
1033
  const currency = listing.currency || 'XOF';
1034
  const priceType = listing.price_type || 'monthly';
1035
- const images = listing.images || [];
1036
- const heroImage = images.length > 0 ? images[0] : 'https://via.placeholder.com/400x140?text=Property';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1037
 
1038
  return `
1039
- <div class="my-listing-card" data-listing-id="${id}">
1040
  <div class="my-listing-card-image">
1041
- <img src="${heroImage}" alt="${title}" onerror="this.src='https://via.placeholder.com/400x140?text=Property'">
 
1042
  </div>
1043
  <div class="my-listing-card-content">
1044
  <h4 class="my-listing-card-title">${title}</h4>
@@ -1118,6 +1282,8 @@
1118
  // Get user details from DOM inputs (set during login)
1119
  const currentUserId = document.getElementById('user-id').value;
1120
  const currentUserRole = document.getElementById('user-role').value;
 
 
1121
 
1122
  // Show user message
1123
  addMessage('user', `✏️ Edit listing`);
@@ -1131,7 +1297,7 @@
1131
  },
1132
  body: JSON.stringify({
1133
  message: editMessage,
1134
- session_id: currentSessionId,
1135
  user_id: currentUserId,
1136
  user_role: currentUserRole,
1137
  user_name: userName,
@@ -1141,6 +1307,13 @@
1141
 
1142
  const data = await res.json();
1143
 
 
 
 
 
 
 
 
1144
  if (data.text) {
1145
  addMessage('assistant', data.text, data);
1146
  } else {
@@ -1248,22 +1421,96 @@
1248
  async function sendMessage() {
1249
  const input = document.getElementById('message-input');
1250
  const text = input.value.trim();
1251
- if (!text) return;
 
 
1252
 
1253
  // Use manual token if provided
1254
  const manualToken = document.getElementById('manual-token').value.trim();
1255
  const effectiveToken = manualToken || authToken;
1256
 
1257
  input.value = '';
1258
- addMessage('user', text);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1259
 
1260
  const payload = {
1261
  message: text,
1262
- session_id: document.getElementById('session-id').value,
1263
- user_id: document.getElementById('user-id').value || undefined,
1264
- user_role: document.getElementById('user-role').value,
1265
- user_name: userName || undefined,
1266
- user_location: userLocation || undefined
1267
  };
1268
 
1269
  const headers = {
@@ -1275,9 +1522,6 @@
1275
  }
1276
 
1277
  updateDebug({ status: 'Sending...', payload });
1278
-
1279
- // Show typing indicator
1280
- const typingEl = document.getElementById('typing-indicator');
1281
  const chatHistory = document.getElementById('chat-history');
1282
 
1283
  // Move typing indicator to inside chat history temporarily or just toggle it
@@ -1317,40 +1561,22 @@
1317
  if (data.metadata && data.metadata.replace_last_message) {
1318
  console.log('[UI] Replace last message signal received');
1319
 
1320
- // For edit flow: Remove the old card-message unit entirely
1321
- // Then use addMessage to create a new one at the bottom
1322
- // This ensures both message AND card update correctly
1323
-
1324
  if (data.draft_ui) {
1325
- // Find and remove the entire old card-message unit
1326
  const existingCardUnit = document.querySelector('.listing-card-unit');
1327
  if (existingCardUnit) {
1328
  const parentMessageDiv = existingCardUnit.closest('.message');
1329
  if (parentMessageDiv) {
1330
  parentMessageDiv.remove();
1331
- console.log('[UI] Old card-message unit removed');
1332
  } else {
1333
  existingCardUnit.remove();
1334
  }
1335
  }
1336
  }
1337
-
1338
- // Now use addMessage to create the proper response (with card if present)
1339
- const text = data.text || data.message || '';
1340
- addMessage('ai', text, data);
1341
- console.log('[UI] New message (with card if present) added at bottom');
1342
-
1343
- } else {
1344
- // Standard append
1345
- if (data.text) {
1346
- addMessage('ai', data.text, data);
1347
- } else if (data.message) {
1348
- addMessage('ai', data.message, data);
1349
- } else {
1350
- addMessage('ai', JSON.stringify(data), data);
1351
- }
1352
  }
1353
 
 
 
 
1354
  } catch (e) {
1355
  // Remove typing indicator on error
1356
  const typingNode = document.getElementById(tempTypingId);
@@ -1361,184 +1587,37 @@
1361
  }
1362
  }
1363
 
1364
- async function uploadImage() {
1365
- // Trigger hidden file input
1366
- let fileInput = document.getElementById('hidden-file-input');
1367
- if (!fileInput) {
1368
- fileInput = document.createElement('input');
1369
- fileInput.type = 'file';
1370
- fileInput.id = 'hidden-file-input';
1371
- fileInput.accept = 'image/*';
1372
- fileInput.style.display = 'none';
1373
- document.body.appendChild(fileInput);
1374
 
1375
- fileInput.addEventListener('change', handleFileSelect);
 
 
 
1376
  }
1377
- fileInput.click();
1378
- }
1379
 
1380
- async function handleFileSelect(event) {
1381
- const file = event.target.files[0];
1382
- if (!file) return;
1383
-
1384
- const history = document.getElementById('chat-history');
1385
-
1386
- // Create local preview URL immediately
1387
- const localPreviewUrl = URL.createObjectURL(file);
1388
-
1389
- // Create a unique ID for this upload message
1390
- const uploadMsgId = 'upload-' + Date.now();
1391
-
1392
- // Show user message with image preview + loading overlay
1393
- const previewHtml = `
1394
- <div id="${uploadMsgId}" class="message user">
1395
- <div class="meta">You</div>
1396
- <div style="position: relative; display: inline-block; max-width: 300px;">
1397
- <img id="${uploadMsgId}-img" src="${localPreviewUrl}"
1398
- style="max-width: 100%; border-radius: 8px; display: block; opacity: 0.7;"
1399
- alt="Uploading...">
1400
- <div id="${uploadMsgId}-overlay" style="
1401
- position: absolute;
1402
- top: 0; left: 0; right: 0; bottom: 0;
1403
- background: rgba(0,0,0,0.4);
1404
- display: flex; align-items: center; justify-content: center;
1405
- border-radius: 8px;
1406
- color: white; font-weight: 500;
1407
- ">
1408
- <span>⏳ Uploading...</span>
1409
- </div>
1410
- </div>
1411
- <div style="font-size: 0.85rem; color: #64748b; margin-top: 4px;">
1412
- 📷 Property image
1413
- </div>
1414
- </div>`;
1415
-
1416
- history.insertAdjacentHTML('beforeend', previewHtml);
1417
- history.scrollTop = history.scrollHeight;
1418
-
1419
- const formData = new FormData();
1420
- formData.append('file', file);
1421
-
1422
- try {
1423
- const response = await fetch('https://lojiz-worker.lojiz-uploadapi.workers.dev/', {
1424
- method: 'POST',
1425
- body: formData
1426
- });
1427
-
1428
- if (!response.ok) {
1429
- throw new Error(`Upload failed: ${response.status}`);
1430
- }
1431
-
1432
- const result = await response.json();
1433
- console.log("Upload result:", result);
1434
-
1435
- let imageUrl = result.url || result.secure_url || result.data?.url || result.imageUrl;
1436
-
1437
- if (!imageUrl && typeof result === 'string' && result.startsWith('http')) {
1438
- imageUrl = result;
1439
- }
1440
-
1441
- if (imageUrl) {
1442
- // Update the preview: remove overlay, set final image, full opacity
1443
- const imgEl = document.getElementById(`${uploadMsgId}-img`);
1444
- const overlayEl = document.getElementById(`${uploadMsgId}-overlay`);
1445
-
1446
- if (imgEl) {
1447
- imgEl.src = imageUrl;
1448
- imgEl.style.opacity = '1';
1449
- }
1450
- if (overlayEl) {
1451
- overlayEl.innerHTML = '✅';
1452
- setTimeout(() => overlayEl.remove(), 1000);
1453
- }
1454
-
1455
- // Revoke the local URL to free memory
1456
- URL.revokeObjectURL(localPreviewUrl);
1457
-
1458
- // Send the image URL to AIDA (hidden from user display since image is already shown)
1459
- const message = `Here is the image of the property: ${imageUrl}`;
1460
-
1461
- // Don't show another user message, just send to backend
1462
- await sendImageToAi(message);
1463
- } else {
1464
- throw new Error("Could not extract image URL from response");
1465
- }
1466
-
1467
- } catch (e) {
1468
- console.error("Upload failed:", e);
1469
-
1470
- // Update overlay to show error
1471
- const overlayEl = document.getElementById(`${uploadMsgId}-overlay`);
1472
- if (overlayEl) {
1473
- overlayEl.innerHTML = '❌ Failed';
1474
- overlayEl.style.background = 'rgba(220, 38, 38, 0.7)';
1475
- }
1476
-
1477
- URL.revokeObjectURL(localPreviewUrl);
1478
- }
1479
-
1480
- // Clear file input
1481
- event.target.value = '';
1482
- }
1483
-
1484
- // Send image URL to AI without showing duplicate user message
1485
- async function sendImageToAi(message) {
1486
- const manualToken = document.getElementById('manual-token').value.trim();
1487
- const effectiveToken = manualToken || authToken;
1488
-
1489
- const payload = {
1490
- message: message,
1491
- session_id: document.getElementById('session-id').value,
1492
- user_id: document.getElementById('user-id').value || undefined,
1493
- user_role: document.getElementById('user-role').value
1494
- };
1495
-
1496
- const headers = { 'Content-Type': 'application/json' };
1497
- if (effectiveToken) {
1498
- headers['Authorization'] = `Bearer ${effectiveToken}`;
1499
- }
1500
-
1501
- // Show typing indicator
1502
- const history = document.getElementById('chat-history');
1503
- const tempTypingId = 'temp-typing-' + Date.now();
1504
- const typingHtml = `
1505
- <div id="${tempTypingId}" class="message ai" style="display:flex; align-items:center; gap:8px;">
1506
- <div class="meta">AIDA</div>
1507
- <div style="display:flex; gap:4px; align-items:center;">
1508
- <span>Processing image</span>
1509
- <div class="typing-dot"></div>
1510
- <div class="typing-dot"></div>
1511
- <div class="typing-dot"></div>
1512
- </div>
1513
- </div>`;
1514
- history.insertAdjacentHTML('beforeend', typingHtml);
1515
- history.scrollTop = history.scrollHeight;
1516
-
1517
- try {
1518
- const res = await fetch(`${API_BASE}/ai/ask`, {
1519
- method: 'POST',
1520
- headers: headers,
1521
- body: JSON.stringify(payload)
1522
- });
1523
-
1524
- const data = await res.json();
1525
-
1526
- // Remove typing indicator
1527
- const typingNode = document.getElementById(tempTypingId);
1528
- if (typingNode) typingNode.remove();
1529
 
 
 
1530
  if (data.text) {
1531
  addMessage('ai', data.text, data);
1532
  } else if (data.message) {
1533
  addMessage('ai', data.message, data);
 
 
1534
  }
1535
- } catch (e) {
1536
- const typingNode = document.getElementById(tempTypingId);
1537
- if (typingNode) typingNode.remove();
1538
- addMessage('ai', 'Error processing image: ' + e.message);
1539
  }
1540
  }
1541
 
 
1542
  function newConversation() {
1543
  currentSessionId = generateUUID();
1544
  document.getElementById('session-id').value = currentSessionId;
@@ -1553,6 +1632,34 @@
1553
 
1554
  updateDebug({ status: 'New conversation started', session_id: currentSessionId });
1555
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1556
  </script>
1557
 
1558
  </body>
 
274
  width: 100%;
275
  height: 200px;
276
  object-fit: cover;
277
+ transition: opacity 0.3s ease;
278
+ }
279
+
280
+ .listing-draft img.hero.hidden {
281
+ display: none;
282
+ }
283
+
284
+ .listing-draft .draft-image-container {
285
+ position: relative;
286
+ height: 200px;
287
+ overflow: hidden;
288
  }
289
 
290
  .listing-draft .content {
 
392
  width: 100%;
393
  height: 100%;
394
  object-fit: cover;
395
+ transition: opacity 0.3s ease;
396
+ }
397
+
398
+ .search-card-hero.hidden {
399
+ display: none;
400
  }
401
 
402
  .search-result-card:hover .search-card-hero {
403
+ opacity: 0.95;
404
  }
405
 
406
  .search-card-type {
 
542
  width: 100%;
543
  height: 100%;
544
  object-fit: cover;
545
+ transition: opacity 0.3s ease;
546
+ }
547
+
548
+ .my-listing-card-image img.hidden {
549
+ display: none;
550
  }
551
 
552
  .my-listing-card-content {
 
615
  opacity: 0.5;
616
  cursor: not-allowed;
617
  }
618
+
619
+ /* ============================================================
620
+ IMAGE CAROUSEL - DOT INDICATORS
621
+ ============================================================ */
622
+ .carousel-dots {
623
+ position: absolute;
624
+ bottom: 10px;
625
+ left: 50%;
626
+ transform: translateX(-50%);
627
+ display: flex;
628
+ gap: 6px;
629
+ z-index: 10;
630
+ background: rgba(0, 0, 0, 0.3);
631
+ padding: 6px 10px;
632
+ border-radius: 20px;
633
+ backdrop-filter: blur(4px);
634
+ }
635
+
636
+ .carousel-dot {
637
+ width: 8px;
638
+ height: 8px;
639
+ border-radius: 50%;
640
+ background: rgba(255, 255, 255, 0.5);
641
+ cursor: pointer;
642
+ transition: all 0.3s ease;
643
+ border: 1px solid rgba(255, 255, 255, 0.3);
644
+ }
645
+
646
+ .carousel-dot:hover {
647
+ background: rgba(255, 255, 255, 0.8);
648
+ transform: scale(1.2);
649
+ }
650
+
651
+ .carousel-dot.active {
652
+ background: white;
653
+ width: 24px;
654
+ border-radius: 10px;
655
+ border: 1px solid rgba(255, 255, 255, 0.8);
656
+ }
657
  </style>
658
  </head>
659
 
 
739
  </div>
740
  </div>
741
 
742
+ <!-- Image Preview Area -->
743
+ <div id="image-preview-container"
744
+ style="display:none; padding: 10px 20px; background: #f0f9ff; border-top: 1px solid #e0f2fe;">
745
+ <div style="position: relative; display: inline-block;">
746
+ <img id="image-preview" src=""
747
+ style="height: 80px; width: auto; border-radius: 8px; border: 2px solid #3b82f6; object-fit: cover;">
748
+ <button onclick="clearImage()"
749
+ style="position: absolute; top: -10px; right: -10px; background: #ef4444; color: white; border: 2px solid white; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">✕</button>
750
+ </div>
751
+ <div
752
+ style="display: inline-block; vertical-align: top; margin-left: 10px; margin-top: 25px; color: #3b82f6; font-size: 0.9rem; font-weight: 500;">
753
+ Image ready to send
754
+ </div>
755
+ </div>
756
+
757
  <div class="input-area">
758
+ <input type="file" id="file-input" accept="image/*" style="display: none;"
759
+ onchange="handleFileSelect(this)">
760
  <input type="text" id="message-input" placeholder="Type your message..."
761
  onkeypress="if(event.key === 'Enter') sendMessage()">
762
  <button onclick="sendMessage()" style="width: auto;">Send</button>
763
+ <button onclick="triggerFileSelect()" class="secondary" style="width: auto; background-color: #f59e0b;"
764
+ title="Upload Image">📷</button>
765
  </div>
766
 
767
  <!-- Typing Indicator (Hidden by default) -->
 
780
  let currentSessionId = '';
781
  // Use 127.0.0.1 to avoid localhost DNS/Av issues
782
  const API_BASE = 'http://127.0.0.1:8000';
783
+ // CLOUDFLARE WORKER URL (Replace with your actual deployed Worker URL)
784
+ const CF_WORKER_URL = "https://lojiz-worker.lojiz-uploadapi.workers.dev"; // UPDATE THIS!
785
+
786
+ // Image Upload State
787
+ let pendingUploadFile = null;
788
 
789
  // Init
790
  window.onload = function () {
 
805
  });
806
  }
807
 
808
+ // Image Handling Functions
809
+ function triggerFileSelect() {
810
+ document.getElementById('file-input').click();
811
+ }
812
+
813
+ function handleFileSelect(input) {
814
+ if (input.files && input.files[0]) {
815
+ const file = input.files[0];
816
+ pendingUploadFile = file;
817
+
818
+ // Show preview
819
+ const reader = new FileReader();
820
+ reader.onload = function (e) {
821
+ document.getElementById('image-preview').src = e.target.result;
822
+ document.getElementById('image-preview-container').style.display = 'block';
823
+ // Focus input so user can type message immediately
824
+ document.getElementById('message-input').focus();
825
+ }
826
+ reader.readAsDataURL(file);
827
+ }
828
+ }
829
+
830
+ function clearImage() {
831
+ pendingUploadFile = null;
832
+ document.getElementById('file-input').value = '';
833
+ document.getElementById('image-preview-container').style.display = 'none';
834
+ }
835
+
836
  // Image URL Regex
837
  const IMG_URL_REGEX = /(https?:\/\/.*\.(?:png|jpg|jpeg|gif|webp|svg))/i;
838
 
 
874
  el.textContent = JSON.stringify(data, null, 2);
875
  }
876
 
877
+ function addMessage(role, text, debugInfo = null, isHtml = false) {
878
  const history = document.getElementById('chat-history');
879
  const div = document.createElement('div');
880
  div.className = `message ${role}`;
881
 
882
  let html = `<div class="meta">${role === 'user' ? 'You' : 'AIDA'}</div>`;
883
 
884
+ if (isHtml) {
885
+ html += `<div>${text}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
886
  } else {
887
+ // Check for image URL to render it
888
+ const imgMatch = text.match(IMG_URL_REGEX);
889
+ if (imgMatch) {
890
+ // If text contains an image URL, render the image + the text (if it's not JUST the url)
891
+ const url = imgMatch[0];
892
+ const textWithoutUrl = text.replace(url, '').trim();
893
+
894
+ html += `<div style="margin-bottom:8px;">
895
+ <img src="${url}" style="max-width:100%; border-radius:8px; display:block;" alt="Uploaded Image" onerror="this.style.display='none'">
896
+ </div>`;
897
+
898
+ if (textWithoutUrl) {
899
+ html += `<div>${formatMarkdown(textWithoutUrl)}</div>`;
900
+ }
901
+ } else {
902
+ // Always show the actual text from the response (personalized by LLM)
903
+ html += `<div>${formatMarkdown(text)}</div>`;
904
+ }
905
  }
906
 
907
  // Handle draft_ui: Message + Card appear as ONE UNIT
 
954
 
955
  // NEW: Render card WITH message as ONE unit
956
  function renderDraftWithMessage(draft, message) {
957
+ const images = (draft.images && draft.images.length > 0) ? draft.images : ['https://via.placeholder.com/400x200?text=No+Image'];
958
+ const cardId = 'draft-' + Date.now();
959
 
960
  let amenitiesHtml = '';
961
  if (draft.amenities && draft.amenities.length > 0) {
962
  amenitiesHtml = '<div class="amenities">' + draft.amenities.map(a => `<span class="pill">${a}</span>`).join('') + '</div>';
963
  }
964
 
965
+ // Build image carousel HTML
966
+ const imagesHtml = images.map((img, idx) =>
967
+ `<img src="${img}" class="hero ${idx === 0 ? '' : 'hidden'}" alt="Listing Image ${idx + 1}" data-index="${idx}">`
968
+ ).join('');
969
+
970
+ // Build dots HTML (only if more than 1 image)
971
+ let dotsHtml = '';
972
+ if (images.length > 1) {
973
+ const dots = images.map((_, idx) =>
974
+ `<span class="carousel-dot ${idx === 0 ? 'active' : ''}" onclick="switchImage('${cardId}', ${idx})" data-index="${idx}"></span>`
975
+ ).join('');
976
+ dotsHtml = `<div class="carousel-dots">${dots}</div>`;
977
+ }
978
+
979
  return `
980
  <div class="listing-card-unit">
981
  <div class="card-message">${formatMarkdown(message)}</div>
982
+ <div class="listing-draft" id="${cardId}">
983
+ <div class="draft-image-container">
984
+ ${imagesHtml}
985
+ ${dotsHtml}
986
+ </div>
987
  <div class="content">
988
  <h3>${draft.title}</h3>
989
  <p>${draft.description}</p>
 
1073
  function renderSearchResults(results) {
1074
  if (!results || results.length === 0) return '';
1075
 
1076
+ const cards = results.map((listing, cardIdx) => {
1077
  const title = listing.title || 'Untitled Property';
1078
  const description = listing.description || 'A beautiful property waiting to be explored.';
1079
  const location = listing.location || 'Unknown';
 
1086
  const relevance = listing._relevance_score;
1087
  const amenities = listing.amenities || [];
1088
 
1089
+ // Get all images or use placeholder
1090
+ const images = (listing.images && listing.images.length > 0)
1091
+ ? listing.images
1092
+ : ['https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=400&h=200&fit=crop'];
1093
+
1094
+ const cardId = 'search-card-' + Date.now() + '-' + cardIdx;
1095
 
1096
  // Truncate description to 2 lines
1097
  const shortDesc = description.length > 100
 
1115
  relevanceBadge = `<span class="search-relevance-badge">⭐ ${percent}% match</span>`;
1116
  }
1117
 
1118
+ // Build image carousel HTML
1119
+ const imagesHtml = images.map((img, idx) =>
1120
+ `<img src="${img}" class="search-card-hero ${idx === 0 ? '' : 'hidden'}" alt="${title}" data-index="${idx}" onerror="this.src='https://via.placeholder.com/400x180?text=Property'">`
1121
+ ).join('');
1122
+
1123
+ // Build dots HTML (only if more than 1 image)
1124
+ let dotsHtml = '';
1125
+ if (images.length > 1) {
1126
+ const dots = images.map((_, idx) =>
1127
+ `<span class="carousel-dot ${idx === 0 ? 'active' : ''}" onclick="switchImage('${cardId}', ${idx})" data-index="${idx}"></span>`
1128
+ ).join('');
1129
+ dotsHtml = `<div class="carousel-dots">${dots}</div>`;
1130
+ }
1131
+
1132
  return `
1133
+ <div class="search-result-card" id="${cardId}">
1134
  <div class="search-card-image-container">
1135
+ ${imagesHtml}
1136
  <span class="search-card-type">${listingType}</span>
1137
  ${relevanceBadge}
1138
+ ${dotsHtml}
1139
  </div>
1140
  <div class="search-card-content">
1141
  <h3 class="search-card-title">${title}</h3>
 
1171
  return '';
1172
  }
1173
 
1174
+ const cards = listings.map((listing, cardIdx) => {
1175
  const id = listing._id || listing.id || '';
1176
  const title = listing.title || 'Untitled';
1177
  const location = listing.location || 'Unknown Location';
1178
  const price = (listing.price || 0).toLocaleString();
1179
  const currency = listing.currency || 'XOF';
1180
  const priceType = listing.price_type || 'monthly';
1181
+ const images = (listing.images && listing.images.length > 0)
1182
+ ? listing.images
1183
+ : ['https://via.placeholder.com/400x140?text=Property'];
1184
+
1185
+ const cardId = 'my-listing-' + Date.now() + '-' + cardIdx;
1186
+
1187
+ // Build image carousel HTML
1188
+ const imagesHtml = images.map((img, idx) =>
1189
+ `<img src="${img}" alt="${title}" class="${idx === 0 ? '' : 'hidden'}" data-index="${idx}" onerror="this.src='https://via.placeholder.com/400x140?text=Property'">`
1190
+ ).join('');
1191
+
1192
+ // Build dots HTML (only if more than 1 image)
1193
+ let dotsHtml = '';
1194
+ if (images.length > 1) {
1195
+ const dots = images.map((_, idx) =>
1196
+ `<span class="carousel-dot ${idx === 0 ? 'active' : ''}" onclick="switchImage('${cardId}', ${idx})" data-index="${idx}"></span>`
1197
+ ).join('');
1198
+ dotsHtml = `<div class="carousel-dots">${dots}</div>`;
1199
+ }
1200
 
1201
  return `
1202
+ <div class="my-listing-card" id="${cardId}" data-listing-id="${id}">
1203
  <div class="my-listing-card-image">
1204
+ ${imagesHtml}
1205
+ ${dotsHtml}
1206
  </div>
1207
  <div class="my-listing-card-content">
1208
  <h4 class="my-listing-card-title">${title}</h4>
 
1282
  // Get user details from DOM inputs (set during login)
1283
  const currentUserId = document.getElementById('user-id').value;
1284
  const currentUserRole = document.getElementById('user-role').value;
1285
+ // Use the same session-id source as sendMessage for consistency
1286
+ const sessionId = document.getElementById('session-id').value || currentSessionId;
1287
 
1288
  // Show user message
1289
  addMessage('user', `✏️ Edit listing`);
 
1297
  },
1298
  body: JSON.stringify({
1299
  message: editMessage,
1300
+ session_id: sessionId,
1301
  user_id: currentUserId,
1302
  user_role: currentUserRole,
1303
  user_name: userName,
 
1307
 
1308
  const data = await res.json();
1309
 
1310
+ // Sync session_id from response to keep edit context
1311
+ if (data.session_id) {
1312
+ currentSessionId = data.session_id;
1313
+ document.getElementById('session-id').value = data.session_id;
1314
+ console.log('Session ID synced from response:', data.session_id);
1315
+ }
1316
+
1317
  if (data.text) {
1318
  addMessage('assistant', data.text, data);
1319
  } else {
 
1421
  async function sendMessage() {
1422
  const input = document.getElementById('message-input');
1423
  const text = input.value.trim();
1424
+ const hasImage = pendingUploadFile !== null;
1425
+
1426
+ if (!text && !hasImage) return;
1427
 
1428
  // Use manual token if provided
1429
  const manualToken = document.getElementById('manual-token').value.trim();
1430
  const effectiveToken = manualToken || authToken;
1431
 
1432
  input.value = '';
1433
+
1434
+ // Show user message immediately
1435
+ if (hasImage) {
1436
+ // Show text + "Uploading image..." status or similar
1437
+ let displayMsg = text;
1438
+ const previewSrc = document.getElementById('image-preview').src;
1439
+ displayMsg += `<br><img src="${previewSrc}" style="max-height:150px; border-radius:8px; margin-top:5px; opacity: 0.7;">`;
1440
+ displayMsg += `<div style="font-size:0.8em; color:#666;">Uploading image to Cloudflare...</div>`;
1441
+ addMessage('user', displayMsg, null, true);
1442
+ } else {
1443
+ addMessage('user', text);
1444
+ }
1445
+
1446
+ // Get session info
1447
+ const sessionId = document.getElementById('session-id').value;
1448
+ const userId = document.getElementById('user-id').value || undefined;
1449
+ const userRole = document.getElementById('user-role').value;
1450
+ const userNameVal = userName || undefined;
1451
+ const userLocVal = userLocation || undefined;
1452
+
1453
+ // Show typing indicator
1454
+ const typingEl = document.getElementById('typing-indicator');
1455
+ // ... (rest of typing indicator logic handled in UI usually, but we'll adapt)
1456
+
1457
+ // ===================================
1458
+ // PATH A: Image Upload Flow
1459
+ // ===================================
1460
+ if (hasImage) {
1461
+ try {
1462
+ // Prepare FormData for Cloudflare Worker
1463
+ const formData = new FormData();
1464
+ formData.append('file', pendingUploadFile);
1465
+ formData.append('message', text); // Attach user command
1466
+ formData.append('user_id', userId || 'anon');
1467
+ formData.append('session_id', sessionId);
1468
+ formData.append('operation', 'add'); // Default to add, can be improved later
1469
+
1470
+ // Clear pending image
1471
+ const fileToUpload = pendingUploadFile; // Keep ref
1472
+ clearImage();
1473
+
1474
+ // Upload to Cloudflare Worker
1475
+ const cfRes = await fetch(CF_WORKER_URL, {
1476
+ method: 'POST',
1477
+ body: formData // No headers needed for FormData
1478
+ });
1479
+
1480
+ const cfData = await cfRes.json();
1481
+
1482
+ // Whether success or error, send result to AIDA to handle
1483
+ // The Worker returns {success: true/false, ...} which matches AIDA's expectation
1484
+ const aidaRes = await fetch(`${API_BASE}/ai/image-upload-result`, {
1485
+ method: 'POST',
1486
+ headers: {
1487
+ 'Content-Type': 'application/json',
1488
+ ...(effectiveToken ? { 'Authorization': `Bearer ${effectiveToken}` } : {})
1489
+ },
1490
+ body: JSON.stringify(cfData)
1491
+ });
1492
+
1493
+ const aidaData = await aidaRes.json();
1494
+ handleAidaResponse(aidaData);
1495
+
1496
+ } catch (e) {
1497
+ console.error("Upload Error:", e);
1498
+ addMessage('assistant', `❌ Upload Error: ${e.message}. Is the Cloudflare Worker URL correct?`);
1499
+ }
1500
+ return;
1501
+ }
1502
+
1503
+ // ===================================
1504
+ // PATH B: Standard Text Message Flow
1505
+ // ===================================
1506
 
1507
  const payload = {
1508
  message: text,
1509
+ session_id: sessionId,
1510
+ user_id: userId,
1511
+ user_role: userRole,
1512
+ user_name: userNameVal,
1513
+ user_location: userLocVal
1514
  };
1515
 
1516
  const headers = {
 
1522
  }
1523
 
1524
  updateDebug({ status: 'Sending...', payload });
 
 
 
1525
  const chatHistory = document.getElementById('chat-history');
1526
 
1527
  // Move typing indicator to inside chat history temporarily or just toggle it
 
1561
  if (data.metadata && data.metadata.replace_last_message) {
1562
  console.log('[UI] Replace last message signal received');
1563
 
 
 
 
 
1564
  if (data.draft_ui) {
 
1565
  const existingCardUnit = document.querySelector('.listing-card-unit');
1566
  if (existingCardUnit) {
1567
  const parentMessageDiv = existingCardUnit.closest('.message');
1568
  if (parentMessageDiv) {
1569
  parentMessageDiv.remove();
 
1570
  } else {
1571
  existingCardUnit.remove();
1572
  }
1573
  }
1574
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1575
  }
1576
 
1577
+ // Use the shared handler
1578
+ handleAidaResponse(data);
1579
+
1580
  } catch (e) {
1581
  // Remove typing indicator on error
1582
  const typingNode = document.getElementById(tempTypingId);
 
1587
  }
1588
  }
1589
 
1590
+ // Shared Response Handler
1591
+ function handleAidaResponse(data) {
1592
+ // Remove any temp typing indicators
1593
+ const indicators = document.querySelectorAll('[id^="temp-typing-"]');
1594
+ indicators.forEach(el => el.remove());
 
 
 
 
 
1595
 
1596
+ // Sync session_id from response to keep context (especially for edit mode)
1597
+ if (data.session_id) {
1598
+ currentSessionId = data.session_id;
1599
+ document.getElementById('session-id').value = data.session_id;
1600
  }
 
 
1601
 
1602
+ if (data.metadata && data.metadata.replace_last_message) {
1603
+ // Logic already handled in sendMessage mostly for removing, but let's ensure addMessage works
1604
+ // Just use addMessage
1605
+ const text = data.text || data.message || '';
1606
+ addMessage('ai', text, data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1607
 
1608
+ } else {
1609
+ // Standard append
1610
  if (data.text) {
1611
  addMessage('ai', data.text, data);
1612
  } else if (data.message) {
1613
  addMessage('ai', data.message, data);
1614
+ } else {
1615
+ addMessage('ai', JSON.stringify(data), data);
1616
  }
 
 
 
 
1617
  }
1618
  }
1619
 
1620
+
1621
  function newConversation() {
1622
  currentSessionId = generateUUID();
1623
  document.getElementById('session-id').value = currentSessionId;
 
1632
 
1633
  updateDebug({ status: 'New conversation started', session_id: currentSessionId });
1634
  }
1635
+
1636
+ // Image Carousel Navigation
1637
+ function switchImage(cardId, targetIndex) {
1638
+ const card = document.getElementById(cardId);
1639
+ if (!card) return;
1640
+
1641
+ // Find all images in this card
1642
+ const images = card.querySelectorAll('img[data-index]');
1643
+ const dots = card.querySelectorAll('.carousel-dot');
1644
+
1645
+ // Hide all images and show the target one
1646
+ images.forEach((img, idx) => {
1647
+ if (idx === targetIndex) {
1648
+ img.classList.remove('hidden');
1649
+ } else {
1650
+ img.classList.add('hidden');
1651
+ }
1652
+ });
1653
+
1654
+ // Update dot states
1655
+ dots.forEach((dot, idx) => {
1656
+ if (idx === targetIndex) {
1657
+ dot.classList.add('active');
1658
+ } else {
1659
+ dot.classList.remove('active');
1660
+ }
1661
+ });
1662
+ }
1663
  </script>
1664
 
1665
  </body>
test_listing_fields.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quick test to verify listing serialization"""
2
+ import asyncio
3
+ from app.models.listing import Listing
4
+
5
+ def test_serialization():
6
+ # Test with address and coordinates
7
+ doc = {
8
+ 'user_id': '123',
9
+ 'listing_type': 'rent',
10
+ 'title': 'Test Property',
11
+ 'description': 'Test description for property',
12
+ 'price': 100000,
13
+ 'price_type': 'monthly',
14
+ 'location': 'Lagos',
15
+ 'address': 'Victoria Island, Lagos',
16
+ 'latitude': 6.4281,
17
+ 'longitude': 3.4219,
18
+ 'status': 'active'
19
+ }
20
+
21
+ listing = Listing(**doc)
22
+
23
+ print("=== Listing Object Fields ===")
24
+ print(f"address: {listing.address}")
25
+ print(f"latitude: {listing.latitude}")
26
+ print(f"longitude: {listing.longitude}")
27
+
28
+ print("\n=== listing.dict(by_alias=True) ===")
29
+ result = listing.dict(by_alias=True)
30
+ print(f"address in result: {'address' in result}")
31
+ print(f"address value: {result.get('address')}")
32
+ print(f"latitude value: {result.get('latitude')}")
33
+ print(f"longitude value: {result.get('longitude')}")
34
+
35
+ print("\n=== Full Result ===")
36
+ for key in ['address', 'latitude', 'longitude', 'location']:
37
+ print(f" {key}: {result.get(key)}")
38
+
39
+ if __name__ == "__main__":
40
+ test_serialization()