Spaces:
Running
Running
Commit
·
5a6c225
1
Parent(s):
8c9362b
fyp
Browse files- .gitignore +0 -0
- app/ai/agent/__pycache__/graph.cpython-313.pyc +0 -0
- app/ai/agent/__pycache__/schemas.cpython-313.pyc +0 -0
- app/ai/agent/__pycache__/state.cpython-313.pyc +0 -0
- app/ai/agent/graph.py +7 -0
- app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/edit_listing.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/validate_output.cpython-313.pyc +0 -0
- app/ai/agent/nodes/classify_intent.py +21 -3
- app/ai/agent/nodes/edit_listing.py +2 -0
- app/ai/agent/nodes/listing_collect.py +252 -24
- app/ai/agent/nodes/listing_publish.py +40 -4
- app/ai/agent/nodes/listing_validate.py +48 -6
- app/ai/agent/nodes/notification.py +60 -0
- app/ai/agent/nodes/search_query.py +90 -161
- app/ai/agent/nodes/validate_output.py +30 -0
- app/ai/agent/schemas.py +4 -1
- app/ai/agent/state.py +7 -0
- app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc +0 -0
- app/ai/prompts/system_prompt.py +4 -3
- app/ai/routes/__pycache__/chat.cpython-313.pyc +0 -0
- app/ai/routes/chat.py +1 -1
- app/ai/services/__pycache__/search_service.cpython-313.pyc +0 -0
- app/ai/services/notification_service.py +78 -0
- app/ai/services/search_service.py +73 -100
- app/ai/services/vector_service.py +127 -0
- app/ai/tools/__pycache__/listing_conversation_manager.cpython-313.pyc +0 -0
- app/ai/tools/__pycache__/listing_tool.cpython-313.pyc +0 -0
- app/ai/tools/listing_conversation_manager.py +23 -1
- app/ai/tools/listing_tool.py +438 -45
- app/ml/models/ml_listing_extractor.py +12 -4
- app/models/__pycache__/listing.cpython-313.pyc +0 -0
- app/models/listing.py +3 -0
- app/routes/__pycache__/listing.cpython-313.pyc +0 -0
- app/routes/listing.py +5 -1
- app/routes/websocket_listings.py +2 -2
- backfill_geocoding.py +93 -0
- check_db_listings.py +45 -0
- check_qdrant_info.py +23 -0
- fix_failed_listing.py +34 -0
- migrate_to_4096.py +8 -1
- sync_all_listings_to_qdrant.py +193 -0
- test_chat_ui.html +343 -236
- 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. "
|
| 42 |
-
8. "
|
|
|
|
| 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 |
-
#
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
| 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, #
|
|
|
|
|
|
|
| 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
|
| 224 |
-
if field == "images"
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# ✅
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 415 |
|
| 416 |
-
Example tone: "Done! Updated your location and price. Your listing is now '...'. What else
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 184 |
-
- If the query is 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
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 255 |
-
- Amenities: {', '.join(amenities[:4]) if amenities else 'Not specified'}
|
| 256 |
- Description: {description}...
|
|
|
|
| 257 |
"""
|
| 258 |
else:
|
| 259 |
-
listings_summary =
|
| 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 |
-
|
| 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
|
| 284 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
# STEP 2: Hybrid Search (Qdrant Vector + Filters)
|
| 357 |
-
# ============================================================
|
| 358 |
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 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 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 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 |
-
|
| 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 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 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
|
| 430 |
-
|
| 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,
|
| 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,
|
| 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,
|
| 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
|
| 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
|
| 233 |
-
limit:
|
|
|
|
| 234 |
|
| 235 |
Returns:
|
| 236 |
List of matching listings sorted by relevance
|
| 237 |
"""
|
| 238 |
|
| 239 |
-
logger.info("Starting hybrid search", query=query_text[:50],
|
| 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 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 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 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
range=Range(gte=int(search_params["bedrooms"]))
|
| 288 |
)
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
range=Range(gte=int(search_params["bathrooms"]))
|
| 298 |
)
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
match=MatchValue(value=search_params["listing_type"].lower())
|
| 308 |
)
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
match=MatchValue(value=search_params["price_type"].lower())
|
| 318 |
)
|
| 319 |
-
|
| 320 |
-
|
| 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="
|
| 329 |
-
match=MatchValue(value=
|
| 330 |
)
|
| 331 |
)
|
| 332 |
-
|
| 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"
|
| 417 |
if search_params.get("location"):
|
| 418 |
-
currency,
|
| 419 |
-
logger.info("Currency for search", currency=currency, confidence=confidence)
|
| 420 |
|
| 421 |
-
# Perform
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
| 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" / "
|
|
|
|
|
|
|
| 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 |
-
"
|
|
|
|
| 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
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
- "for sale" keywords → "sale"
|
| 170 |
-
- weekly/daily/nightly price_type → "short-stay"
|
| 171 |
-
- monthly/yearly price_type → "rent"
|
| 172 |
"""
|
| 173 |
|
| 174 |
-
# RENTER:
|
| 175 |
-
if user_role
|
| 176 |
return "roommate"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 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 |
-
#
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
return "short-stay"
|
| 191 |
|
| 192 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
-
|
|
|
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
description += f"Amenities: {amenities}."
|
| 279 |
|
| 280 |
-
|
| 281 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
async with session.get(url, timeout=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
|
| 422 |
-
async with session.get(url, timeout=
|
| 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.
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
}
|
| 279 |
|
| 280 |
.listing-draft .content {
|
|
@@ -382,11 +392,15 @@
|
|
| 382 |
width: 100%;
|
| 383 |
height: 100%;
|
| 384 |
object-fit: cover;
|
| 385 |
-
transition:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}
|
| 387 |
|
| 388 |
.search-result-card:hover .search-card-hero {
|
| 389 |
-
|
| 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="
|
| 689 |
-
Upload Image
|
| 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 |
-
|
| 777 |
-
|
| 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 |
-
//
|
| 792 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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="
|
| 856 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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
|
| 960 |
-
const images = listing.images
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
|
| 1038 |
return `
|
| 1039 |
-
<div class="my-listing-card" data-listing-id="${id}">
|
| 1040 |
<div class="my-listing-card-image">
|
| 1041 |
-
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1259 |
|
| 1260 |
const payload = {
|
| 1261 |
message: text,
|
| 1262 |
-
session_id:
|
| 1263 |
-
user_id:
|
| 1264 |
-
user_role:
|
| 1265 |
-
user_name:
|
| 1266 |
-
user_location:
|
| 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 |
-
|
| 1365 |
-
|
| 1366 |
-
|
| 1367 |
-
|
| 1368 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 1376 |
}
|
| 1377 |
-
fileInput.click();
|
| 1378 |
-
}
|
| 1379 |
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 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()
|