destinyebuka commited on
Commit
4b85c5e
·
1 Parent(s): 80b923f
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. HOW_TO_RUN.md +58 -0
  2. RUN_TEST_UI.md +59 -0
  3. __pycache__/main.cpython-313.pyc +0 -0
  4. agent_workflow.png +0 -0
  5. app/ai/__pycache__/config.cpython-313.pyc +0 -0
  6. app/ai/agent/__pycache__/graph.cpython-313.pyc +0 -0
  7. app/ai/agent/__pycache__/schemas.cpython-313.pyc +0 -0
  8. app/ai/agent/__pycache__/state.cpython-313.pyc +0 -0
  9. app/ai/agent/graph.py +73 -15
  10. app/ai/agent/nodes/__pycache__/authenticate.cpython-313.pyc +0 -0
  11. app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc +0 -0
  12. app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc +0 -0
  13. app/ai/agent/nodes/__pycache__/edit_listing.cpython-313.pyc +0 -0
  14. app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc +0 -0
  15. app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc +0 -0
  16. app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc +0 -0
  17. app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc +0 -0
  18. app/ai/agent/nodes/__pycache__/my_listings.cpython-313.pyc +0 -0
  19. app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc +0 -0
  20. app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc +0 -0
  21. app/ai/agent/nodes/__pycache__/validate_output.cpython-313.pyc +0 -0
  22. app/ai/agent/nodes/authenticate.py +19 -3
  23. app/ai/agent/nodes/casual_chat.py +9 -5
  24. app/ai/agent/nodes/classify_intent.py +47 -5
  25. app/ai/agent/nodes/edit_listing.py +194 -0
  26. app/ai/agent/nodes/greeting.py +10 -7
  27. app/ai/agent/nodes/listing_collect.py +296 -76
  28. app/ai/agent/nodes/listing_publish.py +113 -33
  29. app/ai/agent/nodes/listing_validate.py +127 -25
  30. app/ai/agent/nodes/my_listings.py +94 -0
  31. app/ai/agent/nodes/respond.py +19 -1
  32. app/ai/agent/nodes/search_query.py +203 -74
  33. app/ai/agent/nodes/validate_output.py +39 -0
  34. app/ai/agent/schemas.py +4 -2
  35. app/ai/agent/state.py +28 -0
  36. app/ai/memory/__pycache__/redis_context_memory.cpython-313.pyc +0 -0
  37. app/ai/memory/__pycache__/redis_memory.cpython-313.pyc +0 -0
  38. app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc +0 -0
  39. app/ai/prompts/system_prompt.py +74 -9
  40. app/ai/routes/__pycache__/chat.cpython-313.pyc +0 -0
  41. app/ai/routes/__pycache__/chat_refactored.cpython-313.pyc +0 -0
  42. app/ai/routes/chat.py +423 -428
  43. app/ai/routes/chat_refactored.py +0 -346
  44. app/ai/services/__pycache__/search_service.cpython-313.pyc +0 -0
  45. app/ai/services/search_service.py +428 -0
  46. app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc +0 -0
  47. app/ai/tools/__pycache__/greeting_tool.cpython-313.pyc +0 -0
  48. app/ai/tools/__pycache__/intent_detector_tool.cpython-313.pyc +0 -0
  49. app/ai/tools/__pycache__/listing_conversation_manager.cpython-313.pyc +0 -0
  50. app/ai/tools/__pycache__/listing_tool.cpython-313.pyc +0 -0
HOW_TO_RUN.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # How to Run AIDA Agent for Testing
2
+
3
+ ## 1. Start the Backend Server
4
+
5
+ In the AIDA directory, run:
6
+
7
+ ```bash
8
+ python -m uvicorn main:app --reload
9
+ ```
10
+
11
+ **Note:** Use `main:app` not `app.main:app` (main.py is in the root, not in the app folder)
12
+
13
+ The server will start on `http://127.0.0.1:8000`
14
+
15
+ You should see:
16
+ ```
17
+ INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
18
+ INFO: Started reloader process [xxxx] using WatchFiles
19
+ ```
20
+
21
+ ## 2. Open the Test UI
22
+
23
+ Simply open `test_chat_ui.html` in your browser:
24
+ - Double-click the file, OR
25
+ - Right-click → Open with → Chrome/Edge/Firefox
26
+
27
+ The UI will automatically connect to `http://localhost:8000`
28
+
29
+ ## 3. Test the Agent
30
+
31
+ 1. **Login** (in the sidebar):
32
+ - Enter your test credentials
33
+ - Or paste a JWT token in the "Manual Token Override" field
34
+
35
+ 2. **Chat**:
36
+ - Type messages in the input box at the bottom
37
+ - Click Send or press Enter
38
+ - View responses in the chat area
39
+
40
+ 3. **Debug**:
41
+ - Check the "Debug Output" section in the sidebar for raw JSON responses
42
+ - Monitor the server terminal for backend logs
43
+
44
+ ## Troubleshooting
45
+
46
+ ### Server won't start
47
+ - Check if port 8000 is already in use
48
+ - Verify MongoDB, Redis, Qdrant connection strings in `.env`
49
+ - Check the terminal for specific error messages
50
+
51
+ ### UI shows "Offline"
52
+ - Make sure the server is running on port 8000
53
+ - Check browser console (F12) for CORS or network errors
54
+
55
+ ### Authentication fails
56
+ - Verify your test user exists in the database
57
+ - Check the credentials match
58
+ - Use the manual token input as a fallback
RUN_TEST_UI.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Run Test UI - QUICK START
2
+
3
+ ## The Problem
4
+ Opening `test_chat_ui.html` directly (file://) causes CORS errors because browsers send `origin: null`.
5
+
6
+ ## The Solution - Serve it via HTTP
7
+
8
+ ### Option 1: Using Python (Recommended)
9
+
10
+ **In a NEW terminal** (keep the backend server running in the first one):
11
+
12
+ ```bash
13
+ cd C:\Users\Destiny Ebuka\Desktop\python-Backend\lojiz-backend\AIDA
14
+ python -m http.server 8080
15
+ ```
16
+
17
+ Then open in browser: **http://localhost:8080/test_chat_ui.html**
18
+
19
+ ### Option 2: Using VS Code Live Server
20
+
21
+ 1. Install "Live Server" extension in VS Code
22
+ 2. Right-click `test_chat_ui.html`
23
+ 3. Select "Open with Live Server"
24
+
25
+ ## Full Testing Steps
26
+
27
+ ### Terminal 1 - Backend Server
28
+ ```bash
29
+ cd C:\Users\Destiny Ebuka\Desktop\python-Backend\lojiz-backend\AIDA
30
+ python -m uvicorn main:app --reload
31
+ ```
32
+
33
+ ### Terminal 2 - Test UI Server
34
+ ```bash
35
+ cd C:\Users\Destiny Ebuka\Desktop\python-Backend\lojiz-backend\AIDA
36
+ python -m http.server 8080
37
+ ```
38
+
39
+ ### Browser
40
+ Open: **http://localhost:8080/test_chat_ui.html**
41
+
42
+ ✅ Now CORS will work because:
43
+ - UI runs on `http://localhost:8080`
44
+ - API runs on `http://localhost:8000`
45
+ - Both are proper HTTP origins (not `null`)
46
+ - CORS middleware allows localhost
47
+
48
+ ## Test the Login
49
+
50
+ 1. Enter credentials
51
+ 2. Click "Login"
52
+ 3. Check Debug Output - should see successful response with JWT
53
+ 4. Check server logs - should see:
54
+ ```
55
+ INFO: OPTIONS /api/auth/login HTTP/1.1" 200 OK
56
+ INFO: POST /api/auth/login HTTP/1.1" 200 OK
57
+ ```
58
+
59
+ That's it! 🚀
__pycache__/main.cpython-313.pyc ADDED
Binary file (13.9 kB). View file
 
agent_workflow.png ADDED
app/ai/__pycache__/config.cpython-313.pyc CHANGED
Binary files a/app/ai/__pycache__/config.cpython-313.pyc and b/app/ai/__pycache__/config.cpython-313.pyc 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
@@ -16,6 +16,8 @@ from app.ai.agent.nodes.listing_collect import listing_collect_handler
16
  from app.ai.agent.nodes.listing_validate import listing_validate_handler
17
  from app.ai.agent.nodes.listing_publish import listing_publish_handler
18
  from app.ai.agent.nodes.search_query import search_query_handler
 
 
19
  from app.ai.agent.nodes.casual_chat import casual_chat_handler
20
  from app.ai.agent.nodes.validate_output import validate_output_node
21
  from app.ai.agent.nodes.respond import respond_to_user
@@ -37,7 +39,10 @@ def route_by_intent(state: AgentState) -> str:
37
  intent_to_node = {
38
  "greeting": "greeting",
39
  "listing": "listing_collect",
 
40
  "search": "search_query",
 
 
41
  "casual_chat": "casual_chat",
42
  "unknown": "casual_chat",
43
  }
@@ -75,31 +80,61 @@ def route_after_listing_collect(state: AgentState) -> str:
75
  logger.info("All fields collected signal detected, moving to validate")
76
  return "listing_validate"
77
 
78
- # ✅ Check if showing example (stay in collect)
79
  if state.temp_data.get("action") == "show_example":
80
- logger.info("Showing example, staying in listing_collect")
81
- return "listing_collect"
82
 
83
- # ✅ Check if asking for fields (stay in collect)
84
  if state.temp_data.get("action") in ["asking_field", "asking_first_field", "asking_optional"]:
85
- logger.info("Asking for fields, staying in listing_collect")
86
- return "listing_collect"
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  # ✅ Check required fields completion
89
- required = ["location", "bedrooms", "bathrooms", "price", "price_type"]
90
  has_all = all(
91
- state.provided_fields.get(f) is not None
 
92
  for f in required
93
  )
94
 
95
  if not has_all:
96
- logger.info("Still missing required fields, staying in listing_collect")
97
- return "listing_collect"
98
  else:
99
  logger.info("All fields present, moving to listing_validate")
100
  return "listing_validate"
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  def route_after_listing_validate(state: AgentState) -> str:
104
  """
105
  After validation, determine next step.
@@ -128,8 +163,9 @@ def build_aida_graph():
128
 
129
  logger.info("Building AIDA Graph with LangGraph")
130
 
131
- # Create graph
132
- graph = StateGraph(AgentState)
 
133
 
134
  # ============================================================
135
  # ADD NODES (Each is a handler function)
@@ -142,11 +178,13 @@ def build_aida_graph():
142
  graph.add_node("listing_validate", listing_validate_handler)
143
  graph.add_node("listing_publish", listing_publish_handler)
144
  graph.add_node("search_query", search_query_handler)
 
 
145
  graph.add_node("casual_chat", casual_chat_handler)
146
  graph.add_node("validate_output", validate_output_node)
147
  graph.add_node("respond", respond_to_user)
148
 
149
- logger.info("✅ 10 nodes added")
150
 
151
  # ============================================================
152
  # ADD EDGES (Define flow transitions)
@@ -165,7 +203,10 @@ def build_aida_graph():
165
  {
166
  "greeting": "greeting",
167
  "listing_collect": "listing_collect",
 
168
  "search_query": "search_query",
 
 
169
  "casual_chat": "casual_chat",
170
  }
171
  )
@@ -200,6 +241,19 @@ def build_aida_graph():
200
  # Search → validate_output
201
  graph.add_edge("search_query", "validate_output")
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  # Casual chat → validate_output
204
  graph.add_edge("casual_chat", "validate_output")
205
 
@@ -215,9 +269,13 @@ def build_aida_graph():
215
  # COMPILE (Create executable graph)
216
  # ============================================================
217
 
218
- compiled_graph = graph.compile()
 
 
 
 
219
 
220
- logger.info("✅ LangGraph compiled and ready")
221
 
222
  return compiled_graph
223
 
 
16
  from app.ai.agent.nodes.listing_validate import listing_validate_handler
17
  from app.ai.agent.nodes.listing_publish import listing_publish_handler
18
  from app.ai.agent.nodes.search_query import search_query_handler
19
+ 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
 
39
  intent_to_node = {
40
  "greeting": "greeting",
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",
47
  "unknown": "casual_chat",
48
  }
 
80
  logger.info("All fields collected signal detected, moving to validate")
81
  return "listing_validate"
82
 
83
+ # ✅ Check if showing example (send response to user)
84
  if state.temp_data.get("action") == "show_example":
85
+ logger.info("Showing example, sending response to user")
86
+ return "validate_output"
87
 
88
+ # ✅ Check if asking for fields (send response to user)
89
  if state.temp_data.get("action") in ["asking_field", "asking_first_field", "asking_optional"]:
90
+ logger.info("Asking for fields, sending response to user")
91
+ return "validate_output"
92
+
93
+ # ✅ EDIT MODE CHECK: When editing, wait for explicit save unless user said "save"
94
+ is_editing = (
95
+ state.temp_data.get("is_editing", False) or
96
+ state.temp_data.get("editing_listing_id") is not None
97
+ )
98
+ edit_waiting_actions = ["edit_listing_ready", "edit_waiting_input", "edit_field_updated", "edit_continue"]
99
+
100
+ if is_editing and state.temp_data.get("action") in edit_waiting_actions:
101
+ logger.info("Edit mode: Waiting for user input, NOT auto-validating", action=state.temp_data.get("action"))
102
+ return "validate_output"
103
 
104
  # ✅ Check required fields completion
105
+ required = ["location", "bedrooms", "bathrooms", "price", "price_type", "images"]
106
  has_all = all(
107
+ state.provided_fields.get(f) is not None and
108
+ (f != "images" or (isinstance(state.provided_fields.get(f), list) and len(state.provided_fields.get(f)) > 0))
109
  for f in required
110
  )
111
 
112
  if not has_all:
113
+ logger.info("Still missing required fields, sending response to user")
114
+ return "validate_output"
115
  else:
116
  logger.info("All fields present, moving to listing_validate")
117
  return "listing_validate"
118
 
119
 
120
+ def route_after_edit_listing(state: AgentState) -> str:
121
+ """
122
+ After edit_listing, determine next step:
123
+ - If listing loaded successfully → go to listing_collect for edits
124
+ - If error (not found, unauthorized) → go to validate_output to show error message
125
+ """
126
+ action = state.temp_data.get("action", "")
127
+
128
+ # If the action indicates success, go to listing_collect
129
+ if action == "edit_listing_ready":
130
+ logger.info("Edit listing successful, moving to listing_collect")
131
+ return "listing_collect"
132
+ else:
133
+ # Error cases: edit_listing_prompt, edit_listing_not_found, edit_listing_unauthorized, edit_listing_error
134
+ logger.info("Edit listing failed or needs input, moving to validate_output", action=action)
135
+ return "validate_output"
136
+
137
+
138
  def route_after_listing_validate(state: AgentState) -> str:
139
  """
140
  After validation, determine next step.
 
163
 
164
  logger.info("Building AIDA Graph with LangGraph")
165
 
166
+ # Create graph with name
167
+ graph = StateGraph(AgentState, config_schema=None)
168
+ graph.name = "AIDA - AI Real Estate Assistant"
169
 
170
  # ============================================================
171
  # ADD NODES (Each is a handler function)
 
178
  graph.add_node("listing_validate", listing_validate_handler)
179
  graph.add_node("listing_publish", listing_publish_handler)
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)
186
 
187
+ logger.info("✅ 12 nodes added")
188
 
189
  # ============================================================
190
  # ADD EDGES (Define flow transitions)
 
203
  {
204
  "greeting": "greeting",
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",
211
  }
212
  )
 
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
+
247
+ # Edit Listing → conditional routing (success→listing_collect, error→validate_output)
248
+ graph.add_conditional_edges(
249
+ "edit_listing",
250
+ route_after_edit_listing,
251
+ {
252
+ "listing_collect": "listing_collect",
253
+ "validate_output": "validate_output",
254
+ }
255
+ )
256
+
257
  # Casual chat → validate_output
258
  graph.add_edge("casual_chat", "validate_output")
259
 
 
269
  # COMPILE (Create executable graph)
270
  # ============================================================
271
 
272
+ # Add checkpointer for state persistence
273
+ from langgraph.checkpoint.memory import MemorySaver
274
+ checkpointer = MemorySaver()
275
+
276
+ compiled_graph = graph.compile(checkpointer=checkpointer)
277
 
278
+ logger.info("✅ LangGraph compiled and ready (with MemorySaver persistence)")
279
 
280
  return compiled_graph
281
 
app/ai/agent/nodes/__pycache__/authenticate.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/authenticate.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/authenticate.cpython-313.pyc differ
 
app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc differ
 
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 ADDED
Binary file (8.43 kB). View file
 
app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/greeting.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__/my_listings.cpython-313.pyc ADDED
Binary file (3.7 kB). View file
 
app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc CHANGED
Binary files a/app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/respond.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/authenticate.py CHANGED
@@ -36,8 +36,9 @@ async def authenticate(
36
  )
37
 
38
  try:
39
- # ✅ Just accept everyone
40
- state.user_role = "renter" # Default role for anonymous users
 
41
 
42
  logger.info(
43
  "✅ User session accepted (no auth required)",
@@ -45,7 +46,22 @@ async def authenticate(
45
  user_role=state.user_role
46
  )
47
 
48
- # Transition to next state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  success, error = state.transition_to(
50
  FlowState.CLASSIFY_INTENT,
51
  reason="Public access - no authentication required"
 
36
  )
37
 
38
  try:
39
+ # ✅ Preserve user_role from request, only default if not set
40
+ if not state.user_role:
41
+ state.user_role = "renter" # Default role for anonymous users
42
 
43
  logger.info(
44
  "✅ User session accepted (no auth required)",
 
46
  user_role=state.user_role
47
  )
48
 
49
+ # Check if we're already in an active flow that should continue
50
+ active_flows = [
51
+ FlowState.LISTING_COLLECT,
52
+ FlowState.LISTING_VALIDATE,
53
+ FlowState.SEARCH_QUERY
54
+ ]
55
+
56
+ if state.current_flow in active_flows:
57
+ # Stay in current flow - don't re-classify
58
+ logger.info(
59
+ "↩️ Staying in active flow",
60
+ current_flow=state.current_flow.value
61
+ )
62
+ return state
63
+
64
+ # Transition to classify intent for new interactions
65
  success, error = state.transition_to(
66
  FlowState.CLASSIFY_INTENT,
67
  reason="Public access - no authentication required"
app/ai/agent/nodes/casual_chat.py CHANGED
@@ -87,7 +87,11 @@ async def casual_chat_handler(state: AgentState) -> AgentState:
87
  # STEP 2: Get system prompt
88
  # ============================================================
89
 
90
- system_prompt = get_system_prompt(user_role=state.user_role)
 
 
 
 
91
 
92
  logger.info("System prompt loaded", user_role=state.user_role)
93
 
@@ -143,13 +147,13 @@ Respond naturally and helpfully. Keep your response conversational and friendly
143
  logger.info("Response stored in state", user_id=state.user_id)
144
 
145
  # ============================================================
146
- # STEP 7: Transition to COMPLETE
147
  # ============================================================
148
 
149
- success, error = state.transition_to(FlowState.COMPLETE, reason="Casual chat completed")
150
 
151
  if not success:
152
- logger.error("Transition to COMPLETE failed", error=error)
153
  state.set_error(error, should_retry=False)
154
  return state
155
 
@@ -171,7 +175,7 @@ Respond naturally and helpfully. Keep your response conversational and friendly
171
 
172
  # Try to recover
173
  if state.set_error(error_msg, should_retry=True):
174
- state.transition_to(FlowState.COMPLETE, reason="Chat with error recovery")
175
  else:
176
  state.transition_to(FlowState.ERROR, reason="Casual chat error")
177
 
 
87
  # STEP 2: Get system prompt
88
  # ============================================================
89
 
90
+ system_prompt = get_system_prompt(
91
+ user_role=state.user_role,
92
+ user_name=state.user_name,
93
+ user_location=state.user_location
94
+ )
95
 
96
  logger.info("System prompt loaded", user_role=state.user_role)
97
 
 
147
  logger.info("Response stored in state", user_id=state.user_id)
148
 
149
  # ============================================================
150
+ # STEP 7: Transition to IDLE (ready for next interaction)
151
  # ============================================================
152
 
153
+ success, error = state.transition_to(FlowState.IDLE, reason="Casual chat completed")
154
 
155
  if not success:
156
+ logger.error("Transition to IDLE failed", error=error)
157
  state.set_error(error, should_retry=False)
158
  return state
159
 
 
175
 
176
  # Try to recover
177
  if state.set_error(error_msg, should_retry=True):
178
+ state.transition_to(FlowState.IDLE, reason="Chat with error recovery")
179
  else:
180
  state.transition_to(FlowState.ERROR, reason="Casual chat error")
181
 
app/ai/agent/nodes/classify_intent.py CHANGED
@@ -33,24 +33,42 @@ User message: "{user_message}"
33
 
34
  Classify into ONE of these intents:
35
  1. "greeting" - Pure greeting (Hello, Hi, Good morning, etc.)
36
- 2. "listing" - User wants to create/list a property
37
  3. "search" - User wants to search/find properties
38
- 4. "casual_chat" - Other conversation
39
- 5. "unknown" - You don't understand
 
 
 
40
 
41
- Return ONLY valid JSON (no markdown, no extra text):
42
  {{
43
- "type": "greeting|listing|search|casual_chat|unknown",
44
  "confidence": 0.0-1.0,
45
  "reasoning": "Why you chose this intent",
46
  "requires_auth": true/false,
47
  "next_action": "What Aida should do next"
48
  }}
49
 
 
 
 
 
 
 
 
50
  Examples:
51
  - "Hello!" → {{"type": "greeting", "confidence": 0.95, "reasoning": "Pure greeting", "requires_auth": false, "next_action": "respond_warmly"}}
52
  - "List my apartment" → {{"type": "listing", "confidence": 0.90, "reasoning": "User wants to create listing", "requires_auth": true, "next_action": "start_listing_flow"}}
 
 
53
  - "Find me a 2-bed in Lagos" → {{"type": "search", "confidence": 0.90, "reasoning": "User searching for properties", "requires_auth": false, "next_action": "execute_search"}}
 
 
 
 
 
 
54
  - "What's 2+2?" → {{"type": "casual_chat", "confidence": 0.85, "reasoning": "General question", "requires_auth": false, "next_action": "respond_naturally"}}"""
55
 
56
  def _has_saved_listing_progress(state: AgentState) -> bool:
@@ -200,6 +218,16 @@ async def classify_intent(state: AgentState) -> AgentState:
200
  intent_data = validation.data
201
  state.intent_type = intent_data.type
202
  state.intent_confidence = intent_data.confidence
 
 
 
 
 
 
 
 
 
 
203
 
204
  logger.info(
205
  "Intent classified",
@@ -212,13 +240,27 @@ async def classify_intent(state: AgentState) -> AgentState:
212
  intent_to_flow = {
213
  "greeting": FlowState.GREETING,
214
  "listing": FlowState.LISTING_COLLECT,
 
215
  "search": FlowState.SEARCH_QUERY,
 
 
216
  "casual_chat": FlowState.CASUAL_CHAT,
217
  "unknown": FlowState.CASUAL_CHAT, # Default to casual chat
218
  }
219
 
220
  next_flow = intent_to_flow.get(state.intent_type, FlowState.CASUAL_CHAT)
221
 
 
 
 
 
 
 
 
 
 
 
 
222
  # Transition with validation
223
  success, error = state.transition_to(next_flow, reason=f"Intent: {state.intent_type}")
224
  if not success:
 
33
 
34
  Classify into ONE of these intents:
35
  1. "greeting" - Pure greeting (Hello, Hi, Good morning, etc.)
36
+ 2. "listing" - User wants to create/list a NEW property
37
  3. "search" - User wants to search/find properties
38
+ 4. "my_listings" - User wants to view THEIR OWN listings (show my listings, view my properties, my homes)
39
+ 5. "edit_listing" - User wants to EDIT an existing listing (edit listing [id], update my listing, modify listing)
40
+ 6. "publish" - User wants to publish the current listing
41
+ 7. "casual_chat" - Other conversation
42
+ 8. "unknown" - You don't understand
43
 
44
+ - "Return ONLY valid JSON (no markdown, no extra text):
45
  {{
46
+ "type": "greeting|listing|search|my_listings|edit_listing|publish|casual_chat|unknown",
47
  "confidence": 0.0-1.0,
48
  "reasoning": "Why you chose this intent",
49
  "requires_auth": true/false,
50
  "next_action": "What Aida should do next"
51
  }}
52
 
53
+ IMPORTANT:
54
+ - "edit_listing" is ONLY for STARTING the edit process (e.g. "edit my listing", "edit listing [ID]").
55
+ - If user is ALREADY editing and says "update price", "change location", this is "listing" (updating fields).
56
+ - "Change price to 85k" → "listing"
57
+ - "Update location to Lagos" → "listing"
58
+ - "Correct the description" → "listing"
59
+
60
  Examples:
61
  - "Hello!" → {{"type": "greeting", "confidence": 0.95, "reasoning": "Pure greeting", "requires_auth": false, "next_action": "respond_warmly"}}
62
  - "List my apartment" → {{"type": "listing", "confidence": 0.90, "reasoning": "User wants to create listing", "requires_auth": true, "next_action": "start_listing_flow"}}
63
+ - "Change price to 85k" → {{"type": "listing", "confidence": 0.95, "reasoning": "User wants to modify fields in current flow", "requires_auth": true, "next_action": "update_listing"}}
64
+ - "Update location to Parakou" → {{"type": "listing", "confidence": 0.95, "reasoning": "User updating location field", "requires_auth": true, "next_action": "update_listing"}}
65
  - "Find me a 2-bed in Lagos" → {{"type": "search", "confidence": 0.90, "reasoning": "User searching for properties", "requires_auth": false, "next_action": "execute_search"}}
66
+ - "Show my listings" → {{"type": "my_listings", "confidence": 0.95, "reasoning": "User wants to view their own listings", "requires_auth": true, "next_action": "show_my_listings"}}
67
+ - "View my properties" → {{"type": "my_listings", "confidence": 0.95, "reasoning": "User wants to see their published properties", "requires_auth": true, "next_action": "show_my_listings"}}
68
+ - "edit listing 507f1f77bcf86cd799439011" → {{"type": "edit_listing", "confidence": 0.95, "reasoning": "User wants to start editing a specific listing", "requires_auth": true, "next_action": "edit_listing"}}
69
+ - "edit my first listing" → {{"type": "edit_listing", "confidence": 0.90, "reasoning": "User wants to pick a listing to edit", "requires_auth": true, "next_action": "edit_listing"}}
70
+ - "Publish it" → {{"type": "publish", "confidence": 0.95, "reasoning": "User wants to finalize and publish listing", "requires_auth": true, "next_action": "publish_listing"}}
71
+ - "Yes, go ahead" → {{"type": "publish", "confidence": 0.85, "reasoning": "Confirmation to publish", "requires_auth": true, "next_action": "publish_listing"}}
72
  - "What's 2+2?" → {{"type": "casual_chat", "confidence": 0.85, "reasoning": "General question", "requires_auth": false, "next_action": "respond_naturally"}}"""
73
 
74
  def _has_saved_listing_progress(state: AgentState) -> bool:
 
218
  intent_data = validation.data
219
  state.intent_type = intent_data.type
220
  state.intent_confidence = intent_data.confidence
221
+
222
+ # ✅ SAFETY CHECK: If editing active draft, don't restart edit_listing unless ID provided
223
+ if state.intent_type == "edit_listing" and state.listing_draft:
224
+ import re
225
+ # Check if message contains a listing ID (24 char hex)
226
+ has_id = bool(re.search(r'[0-9a-fA-F]{24}', state.last_user_message))
227
+
228
+ if not has_id:
229
+ logger.info("Override intent: edit_listing -> listing (active edit session)")
230
+ state.intent_type = "listing"
231
 
232
  logger.info(
233
  "Intent classified",
 
240
  intent_to_flow = {
241
  "greeting": FlowState.GREETING,
242
  "listing": FlowState.LISTING_COLLECT,
243
+ "publish": FlowState.LISTING_PUBLISH, # Direct route to publish
244
  "search": FlowState.SEARCH_QUERY,
245
+ "my_listings": FlowState.MY_LISTINGS, # Route to my listings
246
+ "edit_listing": FlowState.EDIT_LISTING, # Route to edit listing
247
  "casual_chat": FlowState.CASUAL_CHAT,
248
  "unknown": FlowState.CASUAL_CHAT, # Default to casual chat
249
  }
250
 
251
  next_flow = intent_to_flow.get(state.intent_type, FlowState.CASUAL_CHAT)
252
 
253
+ # ✅ FIXED: If already in the target flow, skip transition (no error)
254
+ if state.current_flow == next_flow:
255
+ logger.info("Already in target flow, skipping transition", flow=next_flow.value)
256
+ return state
257
+
258
+ # ✅ SPECIAL: When in listing_collect and user says "publish/save",
259
+ # stay in listing_collect - let its save logic handle the transition
260
+ if state.current_flow == FlowState.LISTING_COLLECT and state.intent_type in ["publish", "listing"]:
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:
app/ai/agent/nodes/edit_listing.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/edit_listing.py
2
+ """
3
+ Handler for editing existing listings.
4
+ Fetches listing by ID and prepares it for editing through the listing_collect flow.
5
+ """
6
+
7
+ import re
8
+ from typing import Optional
9
+ from structlog import get_logger
10
+ from bson import ObjectId
11
+
12
+ from app.ai.agent.state import AgentState, FlowState
13
+ from app.database import get_db_sync
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ def extract_listing_id(message: str) -> Optional[str]:
19
+ """Extract a MongoDB ObjectId from the message."""
20
+ # Look for 24-character hex string (MongoDB ObjectId format)
21
+ pattern = r'[0-9a-fA-F]{24}'
22
+ match = re.search(pattern, message)
23
+ if match:
24
+ return match.group(0)
25
+ return None
26
+
27
+
28
+ async def edit_listing_handler(state: AgentState) -> AgentState:
29
+ """
30
+ Fetch an existing listing and prepare it for editing.
31
+
32
+ Flow:
33
+ 1. Extract listing ID from message
34
+ 2. Fetch listing from MongoDB
35
+ 3. Verify ownership
36
+ 4. Convert to draft format
37
+ 5. Ask user what to edit
38
+ """
39
+
40
+ logger.info(
41
+ "Starting edit listing flow",
42
+ user_id=state.user_id,
43
+ message=state.last_user_message
44
+ )
45
+
46
+ try:
47
+ # Extract listing ID from message
48
+ listing_id = extract_listing_id(state.last_user_message or "")
49
+
50
+ # Also check temp_data for listing_id (set by frontend)
51
+ if not listing_id:
52
+ listing_id = state.temp_data.get("edit_listing_id")
53
+
54
+ if not listing_id:
55
+ # No ID provided - ask user
56
+ state.temp_data["response_text"] = (
57
+ "Which listing would you like to edit? 📝\n\n"
58
+ "Please say 'show my listings' first to see your properties, "
59
+ "then click the Edit button on the listing you want to modify."
60
+ )
61
+ state.temp_data["action"] = "edit_listing_prompt"
62
+ state.transition_to(FlowState.IDLE, reason="No listing ID provided")
63
+ return state
64
+
65
+ # Get database
66
+ db = get_db_sync()
67
+
68
+ # Fetch the listing
69
+ try:
70
+ listing = await db.listings.find_one({"_id": ObjectId(listing_id)})
71
+ except Exception:
72
+ listing = None
73
+
74
+ if not listing:
75
+ state.temp_data["response_text"] = (
76
+ f"I couldn't find that listing. It may have been deleted. 🔍\n\n"
77
+ "Say 'show my listings' to see your current properties."
78
+ )
79
+ state.temp_data["action"] = "edit_listing_not_found"
80
+ state.transition_to(FlowState.IDLE, reason="Listing not found")
81
+ return state
82
+
83
+ # Verify ownership
84
+ if str(listing.get("user_id")) != state.user_id:
85
+ state.temp_data["response_text"] = (
86
+ "You can only edit your own listings. 🔒\n\n"
87
+ "Say 'show my listings' to see properties you can edit."
88
+ )
89
+ state.temp_data["action"] = "edit_listing_unauthorized"
90
+ state.transition_to(FlowState.IDLE, reason="Not owner")
91
+ return state
92
+
93
+ # Helper to infer currency from location (simple map)
94
+ def infer_currency(loc: str) -> str:
95
+ loc = loc.lower()
96
+ if any(c in loc for c in ["nigeria", "lagos", "abuja", "parakou"]):
97
+ return "XOF"
98
+ if any(c in loc for c in ["usa", "new york", "san francisco", "los angeles"]):
99
+ return "USD"
100
+ if any(c in loc for c in ["uk", "london", "england"]):
101
+ return "GBP"
102
+ # Default fallback
103
+ return "USD"
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", ""),
110
+ "price": listing.get("price", 0),
111
+ "currency": listing.get("currency", "XOF"),
112
+ "price_type": listing.get("price_type", "monthly"),
113
+ "bedrooms": listing.get("bedrooms"),
114
+ "bathrooms": listing.get("bathrooms"),
115
+ "amenities": listing.get("amenities", []),
116
+ "images": listing.get("images", []),
117
+ "listing_type": listing.get("listing_type", "rent"),
118
+ }
119
+ # If location is present, ensure currency matches location
120
+ if draft["location"]:
121
+ draft["currency"] = infer_currency(draft["location"])
122
+
123
+
124
+ # Store in state for editing
125
+ state.listing_draft = draft
126
+ state.temp_data["editing_listing_id"] = listing_id
127
+ state.temp_data["is_editing"] = True
128
+
129
+ # Copy fields to provided_fields so listing_collect knows what exists
130
+ state.provided_fields = {k: v for k, v in draft.items() if v}
131
+
132
+ # Build draft UI using the consistent function
133
+ from app.ai.agent.nodes.listing_validate import build_draft_ui_from_dict
134
+ draft_ui = build_draft_ui_from_dict(draft)
135
+ draft_ui["status"] = "editing" # Mark as editing
136
+ state.temp_data["draft_ui"] = draft_ui
137
+
138
+ # Generate LLM response for initial edit message
139
+ user_name = state.user_name or "there"
140
+ listing_title = draft.get("title", "your listing")
141
+
142
+ # Use LLM to generate a friendly, natural initial edit message
143
+ from langchain_openai import ChatOpenAI
144
+ from langchain_core.messages import HumanMessage
145
+ from app.config import settings
146
+
147
+ llm = ChatOpenAI(
148
+ api_key=settings.DEEPSEEK_API_KEY,
149
+ base_url=settings.DEEPSEEK_BASE_URL,
150
+ model="deepseek-chat",
151
+ temperature=0.8,
152
+ )
153
+
154
+ prompt = f"""Generate a SHORT, friendly message for user "{user_name}" who wants to edit their listing titled "{listing_title}".
155
+ The message should:
156
+ - Be casual and welcoming (1-2 sentences max)
157
+ - Invite them to tell you what they want to change
158
+ - NOT list specific fields or examples
159
+ - NOT mention "save" yet (that comes later)
160
+
161
+ Example tone: "Here's your listing! What would you like to change?"
162
+
163
+ Just return the message, no quotes."""
164
+
165
+ try:
166
+ response = await llm.ainvoke([HumanMessage(content=prompt)])
167
+ edit_message = response.content.strip().strip('"')
168
+ except Exception:
169
+ # Fallback if LLM fails
170
+ edit_message = f"Here's your listing **\"{listing_title}\"** ✏️ What would you like to change?"
171
+
172
+ state.temp_data["response_text"] = edit_message
173
+ state.temp_data["action"] = "edit_listing_ready"
174
+
175
+ # Transition to listing_collect for edits
176
+ state.transition_to(FlowState.LISTING_COLLECT, reason="Listing loaded for editing")
177
+
178
+ logger.info(
179
+ "Edit listing ready",
180
+ user_id=state.user_id,
181
+ listing_id=listing_id,
182
+ title=draft["title"]
183
+ )
184
+
185
+ return state
186
+
187
+ except Exception as e:
188
+ logger.error("Edit listing error", exc_info=e)
189
+ state.temp_data["response_text"] = (
190
+ "Sorry, something went wrong while loading the listing. Please try again."
191
+ )
192
+ state.temp_data["action"] = "edit_listing_error"
193
+ state.transition_to(FlowState.IDLE, reason="Edit listing error")
194
+ return state
app/ai/agent/nodes/greeting.py CHANGED
@@ -26,6 +26,7 @@ llm = ChatOpenAI(
26
  GREETING_PROMPT = """You are AIDA, a warm and friendly real estate AI assistant for Lojiz platform.
27
 
28
  User greeted you with: "{user_message}"
 
29
 
30
  Generate a WARM, NATURAL, UNIQUE response that:
31
  1. Responds to their greeting in the SAME language they used
@@ -43,12 +44,6 @@ Language rules:
43
 
44
  Vary your greeting! Use different emojis, different greetings, different ways to introduce yourself.
45
 
46
- Examples of varied responses:
47
- - "Hey there! 👋 I'm Aida, your real estate buddy. Looking to find or list a property today?"
48
- - "Hello! 😊 I'm Aida from Lojiz. How can I help you with real estate?"
49
- - "Hi! 🏠 I'm Aida, your property assistant. What's on your mind - buying, renting, or listing?"
50
- - "Bonjour! 👋 Je suis Aida, votre assistant immobilier. Comment puis-je vous aider?"
51
-
52
  Now generate YOUR unique, warm response (2-3 sentences only, be creative!):"""
53
 
54
 
@@ -77,7 +72,15 @@ async def greeting_handler(state: AgentState) -> AgentState:
77
  # STEP 1: Generate warm greeting response with LLM
78
  # ============================================================
79
 
80
- prompt = GREETING_PROMPT.format(user_message=state.last_user_message)
 
 
 
 
 
 
 
 
81
 
82
  logger.info("Generating greeting response with LLM", user_message=state.last_user_message[:30])
83
 
 
26
  GREETING_PROMPT = """You are AIDA, a warm and friendly real estate AI assistant for Lojiz platform.
27
 
28
  User greeted you with: "{user_message}"
29
+ {name_instruction}
30
 
31
  Generate a WARM, NATURAL, UNIQUE response that:
32
  1. Responds to their greeting in the SAME language they used
 
44
 
45
  Vary your greeting! Use different emojis, different greetings, different ways to introduce yourself.
46
 
 
 
 
 
 
 
47
  Now generate YOUR unique, warm response (2-3 sentences only, be creative!):"""
48
 
49
 
 
72
  # STEP 1: Generate warm greeting response with LLM
73
  # ============================================================
74
 
75
+ # Build personalized prompt
76
+ name_instruction = ""
77
+ if state.user_name:
78
+ name_instruction = f"\nThe user's name is: {state.user_name}. Use their name warmly in your greeting (e.g., 'Hi {state.user_name}!')."
79
+
80
+ prompt = GREETING_PROMPT.format(
81
+ user_message=state.last_user_message,
82
+ name_instruction=name_instruction
83
+ )
84
 
85
  logger.info("Generating greeting response with LLM", user_message=state.last_user_message[:30])
86
 
app/ai/agent/nodes/listing_collect.py CHANGED
@@ -142,113 +142,333 @@ Return ONLY valid JSON:
142
 
143
  async def listing_collect_handler(state: AgentState) -> AgentState:
144
  """
145
- Dynamic listing collection - FINAL FIX
146
- Key: Don't return early, always reach the end to set proper action
 
 
 
 
 
 
147
  """
148
 
149
- logger.info("Dynamic listing collection",
150
  user_id=state.user_id,
151
- current_action=state.temp_data.get("action"))
152
 
153
  try:
154
- # ✅ STEP 1: Check if user changed intent
155
- intent_check = await is_still_listing_intent(state)
156
-
157
- if not intent_check["is_listing_related"]:
158
- logger.info("Intent switched", new_intent=intent_check["detected_intent"])
 
 
159
  return state
160
 
161
- # STEP 2: Check if this is initial listing request (first message)
162
- initial_listing_triggers = [
163
- "i want to list", "i want to list a property", "list my property",
164
- "list property", "create listing", "post listing", "add property"
165
- ]
 
 
 
 
 
166
 
167
- user_message_lower = state.last_user_message.lower().strip()
168
- is_initial_request = (
169
- not state.provided_fields and
170
- any(trigger in user_message_lower for trigger in initial_listing_triggers)
 
 
 
 
 
 
171
  )
172
 
173
- # CRITICAL: If initial request AND we haven't shown example yet
174
- if is_initial_request and state.temp_data.get("action") != "show_example":
175
- logger.info("Initial listing request - showing example first")
176
- example = await generate_listing_example("en", state.user_role)
177
- state.temp_data["response_text"] = f"Great! 🎯 Here's an example:\n\n\"{example}\"\n\nNow tell me about your property."
178
- state.temp_data["action"] = "show_example"
179
- logger.info("Example shown, set action to show_example")
180
- return state # ✅ Return here ONLY on first call
 
 
 
 
 
 
181
 
182
- # ✅ STEP 3: Extract fields from user message
183
- logger.info("Extracting fields from user message")
184
  extracted = await extract_listing_fields_smart(
185
  state.last_user_message,
186
  state.user_role,
187
  state.provided_fields
188
  )
189
 
190
- logger.info("Field extraction result", extracted=extracted)
191
-
192
- # Update state with extracted fields
193
  if extracted:
 
 
 
 
194
  for field, value in extracted.items():
195
- if value is not None and value != [] and value != "":
196
- state.update_listing_progress(field, value)
197
- logger.info("Field updated", field=field, value=str(value)[:30])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
- # ✅ STEP 4: Check if we have ANY real data
200
- required_fields = ["location", "bedrooms", "bathrooms", "price", "price_type"]
201
- has_any_real_data = any(
202
- state.provided_fields.get(f) is not None
203
- for f in required_fields
204
- )
205
 
206
- if not has_any_real_data:
207
- logger.info("No real data extracted, asking for first field")
208
- question = await generate_contextual_question(state, "location")
209
- state.temp_data["response_text"] = question
210
- state.temp_data["action"] = "asking_first_field"
211
- state.current_asking_for = "location"
212
- return state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
- # STEP 5: Check if ALL required fields are present
215
- missing_required = [f for f in required_fields if state.provided_fields.get(f) is None]
216
 
217
- if missing_required:
218
- logger.info("Missing required fields", missing=missing_required)
219
- next_field = missing_required[0]
220
- question = await generate_contextual_question(state, next_field)
221
- state.temp_data["response_text"] = question
222
- state.temp_data["action"] = "asking_field"
223
- state.current_asking_for = next_field
224
- return state
225
 
226
- # ✅ STEP 6: All required fields present - ask about optional fields
227
- if not state.provided_fields.get("amenities") and not state.provided_fields.get("requirements"):
228
- question = "Great! Any amenities like wifi, parking, furnished, AC? And any special requirements?"
229
- state.temp_data["response_text"] = question
230
- state.temp_data["action"] = "asking_optional"
231
- logger.info("Asking about optional fields")
232
- return state
233
 
234
- # STEP 7: All fields complete - transition to VALIDATE
235
- logger.info("All fields collected, transitioning to LISTING_VALIDATE")
236
- state.temp_data["response_text"] = "Perfect! Creating your listing preview..."
237
- state.temp_data["action"] = "all_fields_collected"
238
 
239
- success, error = state.transition_to(
240
- FlowState.LISTING_VALIDATE,
241
- reason="All required fields collected"
 
 
 
 
 
242
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
- if not success:
245
- logger.error("Failed to transition to LISTING_VALIDATE", error=error)
246
- state.set_error(error, should_retry=False)
247
 
248
  return state
249
 
250
  except Exception as e:
251
- logger.error("Dynamic listing collection error", exc_info=e)
252
  error_msg = f"Error processing listing: {str(e)}"
253
 
254
  if state.set_error(error_msg, should_retry=True):
@@ -257,4 +477,4 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
257
  else:
258
  state.transition_to(FlowState.ERROR, reason="Listing collection error")
259
 
260
- return state
 
142
 
143
  async def listing_collect_handler(state: AgentState) -> AgentState:
144
  """
145
+ FULLY LLM-DRIVEN listing collection
146
+ Zero hard-coded responses - all intelligent and contextual
147
+
148
+ Flow:
149
+ 1. Every message → LLM reasons → detects intent → generates response
150
+ 2. If intent changed → switch flows
151
+ 3. If still listing → LLM decides what to say based on context
152
+ 4. Extract fields → LLM asks for missing ones naturally
153
  """
154
 
155
+ logger.info("Smart listing collection",
156
  user_id=state.user_id,
157
+ provided_fields=list(state.provided_fields.keys()))
158
 
159
  try:
160
+ # ✅ FIRST ENTRY CHECK: If we just loaded from edit_listing, skip processing
161
+ # The edit_listing handler already set up the response and draft
162
+ if state.temp_data.get("is_editing") and state.temp_data.get("action") == "edit_listing_ready":
163
+ logger.info("Edit mode: First entry after load, using initial edit message")
164
+ # Clear the flag so subsequent entries get processed normally
165
+ state.temp_data["action"] = "edit_waiting_input"
166
+ # Response text is already set by edit_listing_handler
167
  return state
168
 
169
+ # Import the smart conversation manager
170
+ from app.ai.tools.listing_conversation_manager import generate_smart_listing_response
171
+ from app.ai.tools.listing_tool import extract_listing_fields_smart
172
+
173
+ # Generate dynamic example
174
+ listing_example = await generate_listing_example(
175
+ user_role=state.user_role,
176
+ user_name=state.user_name,
177
+ user_location=state.user_location
178
+ )
179
 
180
+ # STEP 1: Generate intelligent response using LLM
181
+ # This analyzes the message, detects intent, and creates contextual reply
182
+ smart_response = await generate_smart_listing_response(
183
+ user_message=state.last_user_message,
184
+ user_role=state.user_role,
185
+ conversation_history=state.conversation_history,
186
+ provided_fields=state.provided_fields,
187
+ missing_required_fields=state.missing_required_fields or [],
188
+ last_action=state.temp_data.get("action"),
189
+ listing_example=listing_example, # Pass the generated example
190
  )
191
 
192
+ logger.info("Smart response received",
193
+ action=smart_response.get("action"),
194
+ intent_still_listing=smart_response.get("intent_still_listing"))
195
+
196
+ # STEP 2: Check if user changed intent
197
+ if not smart_response.get("intent_still_listing"):
198
+ detected_intent = smart_response.get("detected_new_intent")
199
+ logger.info("Intent changed", new_intent=detected_intent)
200
+
201
+ # Set response and let router handle intent change
202
+ state.temp_data["response_text"] = smart_response.get("response_text")
203
+ state.temp_data["action"] = "intent_switched"
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,
210
  state.user_role,
211
  state.provided_fields
212
  )
213
 
 
 
 
214
  if extracted:
215
+ # Get operation modes for list fields (default to "add" for backward compat)
216
+ images_operation = extracted.pop("images_operation", "add")
217
+ amenities_operation = extracted.pop("amenities_operation", "add")
218
+
219
  for field, value in extracted.items():
220
+ # Fix: Handle 0 as a valid value for price/bedrooms
221
+ if value is not None and value != "" and (value != [] or field in ["images", "amenities"]):
222
+
223
+ # Special handling for images: add or replace
224
+ if field == "images" and isinstance(value, list) and value:
225
+ if images_operation == "replace":
226
+ # Replace all images
227
+ state.update_listing_progress(field, value)
228
+ logger.info("Images REPLACED", count=len(value))
229
+ else:
230
+ # Add to existing images (default)
231
+ current_imgs = state.provided_fields.get("images", [])
232
+ new_imgs = list(set(current_imgs + value)) # Avoid duplicates
233
+ state.update_listing_progress(field, new_imgs)
234
+ logger.info("Images ADDED", added=len(value), total=len(new_imgs))
235
+
236
+ # Special handling for amenities: add or replace
237
+ elif field == "amenities" and isinstance(value, list) and value:
238
+ if amenities_operation == "replace":
239
+ # Replace all amenities
240
+ state.update_listing_progress(field, value)
241
+ logger.info("Amenities REPLACED", amenities=value)
242
+ else:
243
+ # Add to existing amenities (default)
244
+ current_amenities = state.provided_fields.get("amenities", [])
245
+ new_amenities = list(set(current_amenities + value)) # Avoid duplicates
246
+ state.update_listing_progress(field, new_amenities)
247
+ logger.info("Amenities ADDED", added=value, total=new_amenities)
248
+
249
+ else:
250
+ # Regular fields: just update/replace
251
+ state.update_listing_progress(field, value)
252
+
253
+ logger.info("Field extracted and updated", field=field, value=value)
254
+
255
+ # If location was updated, get accurate currency using Nominatim API
256
+ if field == "location" and isinstance(value, str):
257
+ try:
258
+ from app.ai.tools.listing_tool import get_currency_for_location
259
+ currency = await get_currency_for_location(value)
260
+ # Update both draft and provided fields
261
+ if hasattr(state, "listing_draft") and isinstance(state.listing_draft, dict):
262
+ state.listing_draft["currency"] = currency
263
+ state.update_listing_progress("currency", currency)
264
+ logger.info("Currency updated via Nominatim API", location=value, currency=currency)
265
+ except Exception as e:
266
+ logger.warning(f"Failed to get currency for {value}, defaulting to XOF: {e}")
267
+ # Fallback to XOF (common for Africa)
268
+ currency = "XOF"
269
+ if hasattr(state, "listing_draft") and isinstance(state.listing_draft, dict):
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")
276
+ logger.info("Sync check", is_editing=is_editing_flag, editing_id=editing_id, has_draft=bool(state.listing_draft))
 
 
277
 
278
+ if (is_editing_flag or editing_id) and state.listing_draft:
279
+ logger.info("Before sync", draft_location=state.listing_draft.get("location"), draft_price=state.listing_draft.get("price"))
280
+ for field, value in state.provided_fields.items():
281
+ if value is not None and field in state.listing_draft:
282
+ state.listing_draft[field] = value
283
+ logger.info("After sync", draft_location=state.listing_draft.get("location"), draft_price=state.listing_draft.get("price"))
284
+ logger.info("Listing draft synced with provided_fields")
285
+
286
+ # ✅ SMART INFERENCE: Auto-detect related fields (same logic as listing_validate)
287
+ price_type = state.listing_draft.get("price_type", "monthly")
288
+ current_listing_type = state.listing_draft.get("listing_type", "rent")
289
+
290
+ # If price_type is "nightly" → listing_type should be "short-stay"
291
+ if price_type == "nightly" and current_listing_type != "short-stay":
292
+ state.listing_draft["listing_type"] = "short-stay"
293
+ state.provided_fields["listing_type"] = "short-stay"
294
+ logger.info("Auto-inferred listing_type to short-stay from nightly price_type")
295
+
296
+ # If price_type is "one-time" → listing_type should be "sale"
297
+ if price_type == "one-time" and current_listing_type != "sale":
298
+ state.listing_draft["listing_type"] = "sale"
299
+ state.provided_fields["listing_type"] = "sale"
300
+ logger.info("Auto-inferred listing_type to sale from one-time price_type")
301
+
302
+ # ✅ REGENERATE title and description with updated fields
303
+ from app.ai.tools.listing_tool import generate_title_and_description
304
+ title, description = await generate_title_and_description(state.listing_draft, state.user_role)
305
+ state.listing_draft["title"] = title
306
+ state.listing_draft["description"] = description
307
+ state.provided_fields["title"] = title
308
+ state.provided_fields["description"] = description
309
+ logger.info("Title/description regenerated", title=title)
310
+
311
+ # ✅ Regenerate draft_ui so frontend gets updated card
312
+ from app.ai.agent.nodes.listing_validate import build_draft_ui_from_dict
313
+ draft_ui = build_draft_ui_from_dict(state.listing_draft)
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 = []
325
+ for f in required_fields:
326
+ val = state.provided_fields.get(f)
327
+ # If val is None, or empty list [], or empty string "" -> it's missing
328
+ if val is None or val == "" or (isinstance(val, list) and len(val) == 0):
329
+ missing_required.append(f)
330
 
331
+ state.missing_required_fields = missing_required
 
 
 
332
 
333
+ state.missing_required_fields = missing_required
334
+
335
+ # STEP 5: Check if user wants to save/publish
336
+ # When editing an existing listing, wait for explicit "save" command
337
+ # Check multiple indicators for edit mode (more robust)
338
+ is_editing = (
339
+ state.temp_data.get("is_editing", False) or
340
+ state.temp_data.get("editing_listing_id") is not None
341
  )
342
+ user_message = (state.last_user_message or "").lower()
343
+
344
+ # Keywords that indicate user wants to save/finalize
345
+ save_keywords = ["save", "publish", "done", "finish", "update listing", "save changes", "that's all", "thats all"]
346
+ wants_to_save = any(kw in user_message for kw in save_keywords)
347
+
348
+ # ✅ If editing: only advance to validation when user explicitly says save
349
+ if is_editing:
350
+ if wants_to_save:
351
+ logger.info("Edit mode: User wants to save, moving to listing_validate")
352
+ state.temp_data["response_text"] = "Let me validate your changes..."
353
+ state.temp_data["action"] = "saving_edits"
354
+
355
+ success, error = state.transition_to(
356
+ FlowState.LISTING_VALIDATE,
357
+ reason="User requested to save edits"
358
+ )
359
+
360
+ if not success:
361
+ logger.error("Failed to transition to LISTING_VALIDATE", error=error)
362
+ state.set_error(error, should_retry=False)
363
+
364
+ return state
365
+ else:
366
+ # Stay in edit mode - acknowledge any changes made and ask for more
367
+ action = smart_response.get("action", "edit_continue")
368
+ response_text = smart_response.get("response_text", "")
369
+
370
+ # If fields were extracted, generate LLM acknowledgment
371
+ if extracted:
372
+ changed_fields = list(extracted.keys())
373
+ if changed_fields:
374
+ user_name = state.user_name or "there"
375
+ new_title = state.listing_draft.get("title", "your listing")
376
+
377
+ # Build change summary for LLM prompt
378
+ changes_summary = []
379
+ for field in changed_fields:
380
+ val = state.listing_draft.get(field)
381
+ if field == "location":
382
+ changes_summary.append(f"location to {val}")
383
+ elif field == "price":
384
+ currency = state.listing_draft.get("currency", "")
385
+ price_type = state.listing_draft.get("price_type", "monthly")
386
+ changes_summary.append(f"price to {val} {currency} per {price_type}")
387
+ elif field == "price_type":
388
+ changes_summary.append(f"pricing to {val}")
389
+ else:
390
+ changes_summary.append(f"{field} to {val}")
391
+
392
+ changes_text = ", ".join(changes_summary)
393
+
394
+ # Use LLM to generate natural acknowledgment
395
+ from langchain_openai import ChatOpenAI
396
+ from langchain_core.messages import HumanMessage
397
+ from app.config import settings
398
+
399
+ edit_llm = ChatOpenAI(
400
+ api_key=settings.DEEPSEEK_API_KEY,
401
+ base_url=settings.DEEPSEEK_BASE_URL,
402
+ model="deepseek-chat",
403
+ temperature=0.8,
404
+ )
405
+
406
+ prompt = f"""Generate a SHORT, friendly acknowledgment for user "{user_name}" after updating their listing.
407
+
408
+ Changes made: {changes_text}
409
+ New listing title: "{new_title}"
410
+
411
+ The message should:
412
+ - Confirm the updates naturally (1-2 sentences)
413
+ - Mention the new title
414
+ - End by asking if they want to change anything else OR say 'save' when done
415
+
416
+ Example tone: "Done! Updated your location and price. Your listing is now '...'. What else, or say 'save' when ready!"
417
+
418
+ Just return the message, no quotes."""
419
+
420
+ try:
421
+ response = await edit_llm.ainvoke([HumanMessage(content=prompt)])
422
+ acknowledgment = response.content.strip().strip('"')
423
+ except Exception:
424
+ # Fallback
425
+ acknowledgment = f"Done! ✅ Updated {changes_text}.\n\nYour listing: **\"{new_title}\"**\n\nWhat else? Or say **'save'** when ready!"
426
+
427
+ state.temp_data["response_text"] = acknowledgment
428
+ state.temp_data["action"] = "edit_field_updated"
429
+ else:
430
+ state.temp_data["response_text"] = response_text or "What would you like to change?"
431
+ state.temp_data["action"] = action
432
+ else:
433
+ state.temp_data["response_text"] = response_text or "What would you like to change?"
434
+ state.temp_data["action"] = action
435
+
436
+ # ✅ ALWAYS set replace_last_message in edit mode so card+message updates
437
+ state.temp_data["replace_last_message"] = True
438
+
439
+ logger.info("Edit mode: Waiting for more changes or save command")
440
+ return state
441
+
442
+ # ✅ STEP 5b: Normal flow (creating new listing) - auto-advance when complete
443
+ if not missing_required:
444
+ logger.info("All fields present, moving to listing_validate")
445
+ state.temp_data["response_text"] = smart_response.get("response_text")
446
+ state.temp_data["action"] = "all_fields_collected"
447
+
448
+ success, error = state.transition_to(
449
+ FlowState.LISTING_VALIDATE,
450
+ reason="All required fields collected"
451
+ )
452
+
453
+ if not success:
454
+ logger.error("Failed to transition to LISTING_VALIDATE", error=error)
455
+ state.set_error(error, should_retry=False)
456
+
457
+ return state
458
+
459
+ # ✅ STEP 6: Still collecting → use LLM's response
460
+ action = smart_response.get("action")
461
+ state.temp_data["response_text"] = smart_response.get("response_text")
462
+ state.temp_data["action"] = action
463
 
464
+ logger.info("Continuing collection",
465
+ action=action,
466
+ missing_count=len(missing_required))
467
 
468
  return state
469
 
470
  except Exception as e:
471
+ logger.error("Smart listing collection error", exc_info=e)
472
  error_msg = f"Error processing listing: {str(e)}"
473
 
474
  if state.set_error(error_msg, should_retry=True):
 
477
  else:
478
  state.transition_to(FlowState.ERROR, reason="Listing collection error")
479
 
480
+ return state
app/ai/agent/nodes/listing_publish.py CHANGED
@@ -8,6 +8,7 @@ from structlog import get_logger
8
  from datetime import datetime
9
 
10
  from app.ai.agent.state import AgentState, FlowState
 
11
  from app.database import get_db
12
 
13
  logger = get_logger(__name__)
@@ -53,7 +54,18 @@ async def listing_publish_handler(state: AgentState) -> AgentState:
53
  state.temp_data["action"] = "error"
54
  return state
55
 
56
- draft = state.listing_draft
 
 
 
 
 
 
 
 
 
 
 
57
  logger.info("Draft found, preparing to publish", title=draft.title)
58
 
59
  # ============================================================
@@ -88,21 +100,60 @@ async def listing_publish_handler(state: AgentState) -> AgentState:
88
  )
89
 
90
  # ============================================================
91
- # STEP 3: Insert into MongoDB
92
  # ============================================================
93
 
94
  try:
95
  db = await get_db()
96
- result = await db.listings.insert_one(listing_document)
97
 
98
- if not result.inserted_id:
99
- raise ValueError("Insert returned no ID")
100
 
101
- listing_id = str(result.inserted_id)
102
- logger.info("Listing inserted successfully", listing_id=listing_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
  except Exception as e:
105
- logger.error("MongoDB insert failed", exc_info=e)
106
  error_msg = f"Failed to save listing: {str(e)}"
107
 
108
  if state.set_error(error_msg, should_retry=True):
@@ -112,45 +163,69 @@ async def listing_publish_handler(state: AgentState) -> AgentState:
112
  return state
113
  else:
114
  # Max retries exceeded
115
- state.transition_to(FlowState.ERROR, reason="MongoDB insert failed after retries")
116
  state.temp_data["response_text"] = f"Sorry, couldn't save your listing: {error_msg}"
117
  state.temp_data["action"] = "error"
118
  return state
119
 
120
  # ============================================================
121
- # STEP 4: Generate success message
122
  # ============================================================
123
 
124
- success_message = f"""🎉 **Listing Published Successfully!**
125
-
126
- Your listing **"{draft.title}"** is now live on Lojiz!
127
-
128
- 📊 **Listing Details:**
129
- - 📍 Location: {draft.location}
130
- - 🛏️ Bedrooms: {draft.bedrooms}
131
- - 🚿 Bathrooms: {draft.bathrooms}
132
- - 💰 Price: {draft.price} {draft.currency}/{draft.price_type}
133
-
134
- 🔗 **Listing ID:** `{listing_id}`
135
-
136
- ✨ Your property is now visible to potential renters/buyers. They can:
137
- - View your listing details
138
- - See all {len(draft.images)} images
139
- - Contact you about the property
140
-
141
- 📱 You can:
142
- - Edit the listing anytime
143
- - View interested users
144
- - Manage inquiries
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
- Good luck with your listing! 🚀"""
 
 
 
 
 
 
 
 
147
 
148
  state.temp_data["response_text"] = success_message
149
  state.temp_data["action"] = "published"
150
  state.temp_data["listing_id"] = listing_id
151
 
 
 
 
 
152
  logger.info(
153
- "Success message generated",
154
  listing_id=listing_id,
155
  title=draft.title
156
  )
@@ -165,6 +240,11 @@ Good luck with your listing! 🚀"""
165
  state.missing_required_fields.clear()
166
  state.current_asking_for = None
167
 
 
 
 
 
 
168
  # Transition to complete
169
  success, error = state.transition_to(
170
  FlowState.COMPLETE,
 
8
  from datetime import datetime
9
 
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__)
 
54
  state.temp_data["action"] = "error"
55
  return state
56
 
57
+ # Convert dict to ListingDraft object if needed
58
+ draft_data = state.listing_draft
59
+ if isinstance(draft_data, dict):
60
+ # Ensure user_id and user_role are present (may be missing if edited from existing listing)
61
+ if "user_id" not in draft_data:
62
+ draft_data["user_id"] = state.user_id
63
+ if "user_role" not in draft_data:
64
+ draft_data["user_role"] = state.user_role
65
+ draft = ListingDraft(**draft_data)
66
+ else:
67
+ draft = draft_data
68
+
69
  logger.info("Draft found, preparing to publish", title=draft.title)
70
 
71
  # ============================================================
 
100
  )
101
 
102
  # ============================================================
103
+ # STEP 3: Insert or Update MongoDB
104
  # ============================================================
105
 
106
  try:
107
  db = await get_db()
108
+ editing_id = state.temp_data.get("editing_listing_id")
109
 
110
+ from bson import ObjectId
 
111
 
112
+ if editing_id:
113
+ # UPDATE existing listing
114
+ logger.info("Updating existing listing", listing_id=editing_id)
115
+
116
+ # Check consistency
117
+ if "_id" in listing_document:
118
+ del listing_document["_id"] # Don't update _id
119
+
120
+ listing_document["updated_at"] = datetime.utcnow()
121
+ # Maintain created_at if possible, but it's already in the doc from draft conversion
122
+ # Ideally fetch original created_at but keeping draft's is fine if it was preserved
123
+
124
+ result = await db.listings.update_one(
125
+ {"_id": ObjectId(editing_id)},
126
+ {"$set": listing_document}
127
+ )
128
+
129
+ if result.matched_count == 0:
130
+ raise ValueError(f"Listing {editing_id} not found for update")
131
+
132
+ listing_id = editing_id
133
+ logger.info("Listing updated successfully", listing_id=listing_id)
134
+
135
+ else:
136
+ # INSERT new listing
137
+ result = await db.listings.insert_one(listing_document)
138
+
139
+ if not result.inserted_id:
140
+ raise ValueError("Insert returned no ID")
141
+
142
+ listing_id = str(result.inserted_id)
143
+ logger.info("Listing inserted successfully", listing_id=listing_id)
144
+
145
+ # Increment user's totalListings counter only for new listings
146
+ try:
147
+ await db.users.update_one(
148
+ {"_id": ObjectId(draft.user_id)},
149
+ {"$inc": {"totalListings": 1}}
150
+ )
151
+ logger.info("User totalListings incremented", user_id=draft.user_id)
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)}"
158
 
159
  if state.set_error(error_msg, should_retry=True):
 
163
  return state
164
  else:
165
  # Max retries exceeded
166
+ state.transition_to(FlowState.ERROR, reason="MongoDB save failed after retries")
167
  state.temp_data["response_text"] = f"Sorry, couldn't save your listing: {error_msg}"
168
  state.temp_data["action"] = "error"
169
  return state
170
 
171
  # ============================================================
172
+ # STEP 4: Generate success message & UI Update
173
  # ============================================================
174
 
175
+ # Re-build UI for the published state
176
+ from app.ai.agent.nodes.listing_validate import build_draft_ui
177
+ draft_ui = build_draft_ui(draft)
178
+ draft_ui["status"] = "published"
179
+ draft_ui["title"] = f"✅ {draft.title}" # Add checkmark to title
180
+
181
+ # Generate personalized success message using LLM
182
+ from langchain_openai import ChatOpenAI
183
+ from langchain_core.messages import SystemMessage, HumanMessage
184
+ from app.config import settings
185
+
186
+ llm = ChatOpenAI(
187
+ api_key=settings.DEEPSEEK_API_KEY,
188
+ base_url=settings.DEEPSEEK_BASE_URL,
189
+ model="deepseek-chat",
190
+ temperature=0.8,
191
+ )
192
+
193
+ user_name = state.user_name or "there"
194
+ is_update = bool(state.temp_data.get("editing_listing_id"))
195
+
196
+ if is_update:
197
+ prompt = f"""Generate a SHORT, excited message for {user_name} that their listing "{draft.title}" has been successfully UPDATED!
198
+ Write in this exact style:
199
+ "Great news {user_name}! ✨ Your '{draft.title}' listing has been UPDATED successfully! The changes are now live. What else would you like to do?"
200
+ Be super excited and clear. 2 sentences max."""
201
+ fallback_msg = f"Great news {user_name}! ✨ Your '{draft.title}' listing has been UPDATED successfully! What else would you like to do?"
202
+ else:
203
+ prompt = f"""Generate a SHORT, excited message for {user_name} that their listing "{draft.title}" in {draft.location} is NOW LIVE!
204
+ Write in this exact style:
205
+ "Wow {user_name}! 🎉🏠 Your '{draft.title}' listing is now LIVE on Lojiz! Anyone searching can now find it. What else can I help you with?"
206
+ Be super excited and celebratory. Use emojis. 2 sentences max."""
207
+ fallback_msg = f"Wow {user_name}! 🎉🏠 Your '{draft.title}' listing is now LIVE on Lojiz! What else can I help you with?"
208
 
209
+ try:
210
+ response = await llm.ainvoke([
211
+ SystemMessage(content="You are AIDA, a super friendly real estate assistant. Write like you're celebrating with a friend - excited, warm, enthusiastic!"),
212
+ HumanMessage(content=prompt)
213
+ ])
214
+ success_message = response.content.strip()
215
+ except Exception as e:
216
+ logger.warning("LLM message generation failed, using fallback", error=str(e))
217
+ success_message = fallback_msg
218
 
219
  state.temp_data["response_text"] = success_message
220
  state.temp_data["action"] = "published"
221
  state.temp_data["listing_id"] = listing_id
222
 
223
+ # Signal UI updates
224
+ state.temp_data["draft_ui"] = draft_ui
225
+ state.temp_data["replace_last_message"] = True
226
+
227
  logger.info(
228
+ "Success message & UI generated",
229
  listing_id=listing_id,
230
  title=draft.title
231
  )
 
240
  state.missing_required_fields.clear()
241
  state.current_asking_for = None
242
 
243
+ # ✅ Clear edit mode flags so messages go below card (not replace)
244
+ state.temp_data.pop("is_editing", None)
245
+ state.temp_data.pop("editing_listing_id", None)
246
+ state.temp_data.pop("replace_last_message", None)
247
+
248
  # Transition to complete
249
  success, error = state.transition_to(
250
  FlowState.COMPLETE,
app/ai/agent/nodes/listing_validate.py CHANGED
@@ -76,6 +76,67 @@ def build_draft_ui(draft: ListingDraft) -> dict:
76
  return ui_component
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  async def listing_validate_handler(state: AgentState) -> AgentState:
80
  """
81
  Validate listing and show preview.
@@ -176,7 +237,7 @@ async def listing_validate_handler(state: AgentState) -> AgentState:
176
  "bedrooms": int(bedrooms),
177
  "bathrooms": int(bathrooms),
178
  "price": float(price),
179
- "price_type": price_type,
180
  "currency": currency,
181
  "listing_type": listing_type,
182
  "amenities": amenities,
@@ -205,50 +266,91 @@ async def listing_validate_handler(state: AgentState) -> AgentState:
205
  # STEP 7: Store in state and show preview
206
  # ============================================================
207
 
208
- state.listing_draft = draft
209
- state.temp_data["draft"] = draft
 
 
 
210
  state.temp_data["draft_ui"] = draft_ui
211
  state.temp_data["action"] = "show_draft"
212
 
213
- # Build preview response
214
- preview_text = f"""📋 **Your Listing Preview**
215
-
216
- **{draft.title}**
217
-
218
- {draft.description}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
- 📍 **Location:** {draft.location}
221
- 🛏️ **Bedrooms:** {draft.bedrooms}
222
- 🚿 **Bathrooms:** {draft.bathrooms}
223
- 💰 **Price:** {draft.price} {draft.currency} per {draft.price_type}
224
- 🏷️ **Type:** {draft.listing_type.capitalize()}
225
 
226
- **Amenities:** {', '.join(draft.amenities) if draft.amenities else 'None listed'}
227
- 📌 **Requirements:** {draft.requirements if draft.requirements else 'None'}
228
- 📷 **Images:** {len(draft.images)} uploaded
229
 
230
- ---
231
- Ready to publish? Say **"publish"**, or **"edit [field]"** to change something, or **"discard"** to cancel."""
 
 
 
 
 
 
 
 
 
 
232
 
233
  state.temp_data["response_text"] = preview_text
234
 
235
  logger.info(
236
  "Listing preview ready",
237
  user_id=state.user_id,
238
- title=draft.title
 
239
  )
240
 
241
  return state
242
 
243
  except ValueError as e:
244
- # Schema validation error
245
- logger.error("ListingDraft validation failed", error=str(e))
246
- error_msg = f"Invalid listing data: {str(e)}"
 
 
247
 
248
- state.set_error(error_msg, should_retry=True)
249
- state.temp_data["response_text"] = f"There's an issue with your listing: {error_msg}\n\nLet's fix it. What would you like to change?"
 
 
 
 
 
250
  state.temp_data["action"] = "validation_error"
251
 
 
 
 
252
  return state
253
 
254
  except Exception as e:
 
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.
82
+
83
+ Used when draft is stored as dict (after model_dump) and needs to regenerate UI.
84
+
85
+ Args:
86
+ draft_dict: Dictionary containing draft fields
87
+
88
+ Returns:
89
+ Dict with UI component structure
90
+ """
91
+
92
+ # Amenity icons
93
+ amenity_icons = {
94
+ "wifi": "📶",
95
+ "parking": "🅿️",
96
+ "furnished": "🛋️",
97
+ "washing machine": "🧼",
98
+ "dryer": "🌪️",
99
+ "ac": "❄️",
100
+ "air conditioning": "❄️",
101
+ "balcony": "🏠",
102
+ "pool": "🏊",
103
+ "gym": "💪",
104
+ "garden": "🌳",
105
+ "kitchen": "🍳",
106
+ }
107
+
108
+ amenities = draft_dict.get("amenities") or []
109
+ amenities_display = []
110
+
111
+ for amenity in amenities:
112
+ icon = amenity_icons.get(amenity.lower(), "✓")
113
+ amenities_display.append(f"{icon} {amenity.capitalize()}")
114
+
115
+ images = draft_dict.get("images") or []
116
+
117
+ ui_component = {
118
+ "component_type": "listing_draft_preview",
119
+ "title": draft_dict.get("title", "Untitled"),
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')}",
126
+ "price_type": draft_dict.get("price_type", "monthly"),
127
+ "listing_type": (draft_dict.get("listing_type") or "rent").capitalize(),
128
+ },
129
+ "amenities": amenities_display if amenities_display else ["No amenities listed"],
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
+ }
136
+
137
+ return ui_component
138
+
139
+
140
  async def listing_validate_handler(state: AgentState) -> AgentState:
141
  """
142
  Validate listing and show preview.
 
237
  "bedrooms": int(bedrooms),
238
  "bathrooms": int(bathrooms),
239
  "price": float(price),
240
+ "price_type": "one-time" if listing_type == "sale" else price_type,
241
  "currency": currency,
242
  "listing_type": listing_type,
243
  "amenities": amenities,
 
266
  # STEP 7: Store in state and show preview
267
  # ============================================================
268
 
269
+ # Check if this is an update to an existing draft
270
+ is_update = state.listing_draft is not None
271
+
272
+ state.listing_draft = draft.model_dump() # Convert to dict for AgentState
273
+ state.temp_data["draft"] = draft.model_dump() # Also store as dict
274
  state.temp_data["draft_ui"] = draft_ui
275
  state.temp_data["action"] = "show_draft"
276
 
277
+ # ============================================================
278
+ # STEP 8: Generate personalized message using LLM
279
+ # ============================================================
280
+ from langchain_openai import ChatOpenAI
281
+ from langchain_core.messages import SystemMessage, HumanMessage
282
+ from app.config import settings
283
+
284
+ llm = ChatOpenAI(
285
+ api_key=settings.DEEPSEEK_API_KEY,
286
+ base_url=settings.DEEPSEEK_BASE_URL,
287
+ model="deepseek-chat",
288
+ temperature=0.7,
289
+ )
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
 
311
+ try:
312
+ response = await llm.ainvoke([
313
+ SystemMessage(content="You are AIDA, a super friendly assistant. Write like you're texting a friend. Never use bullet points or numbered lists. Always write in natural, flowing sentences. Be creative and vary your wording each time."),
314
+ HumanMessage(content=prompt)
315
+ ])
316
+ preview_text = response.content.strip()
317
+ except Exception as e:
318
+ logger.warning("LLM message generation failed, using fallback", error=str(e))
319
+ if is_update:
320
+ preview_text = f"All done, {user_name}! ✨ Your listing's updated. Just say 'publish' when you're ready, or tell me what else to change!"
321
+ else:
322
+ preview_text = f"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."
323
 
324
  state.temp_data["response_text"] = preview_text
325
 
326
  logger.info(
327
  "Listing preview ready",
328
  user_id=state.user_id,
329
+ title=draft.title,
330
+ is_update=is_update
331
  )
332
 
333
  return state
334
 
335
  except ValueError as e:
336
+ # Validation failed - NOT a system error, just user needs to fix input
337
+ # Do NOT call state.set_error() here as it increments retry counters and could force System Error state
338
+
339
+ logger.warning("ListingDraft validation failed (user input error)", error=str(e))
340
+ error_msg = str(e)
341
 
342
+ # Clean up error message for user
343
+ if "images" in error_msg and "required" in error_msg:
344
+ user_msg = "I just need at least one photo of your property to continue."
345
+ else:
346
+ user_msg = f"There's a small issue: {error_msg}. Could you fix that?"
347
+
348
+ state.temp_data["response_text"] = f"Almost there! {user_msg}\n\nPlease upload or provide it so I can finish your listing."
349
  state.temp_data["action"] = "validation_error"
350
 
351
+ # Transition back to collect to get missing fields
352
+ state.transition_to(FlowState.LISTING_COLLECT, reason="Validation failed, collecting missing info")
353
+
354
  return state
355
 
356
  except Exception as e:
app/ai/agent/nodes/my_listings.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/my_listings.py
2
+ """
3
+ Handler for viewing user's own listings with Edit/Delete options.
4
+ """
5
+
6
+ from typing import Dict, Any, List
7
+ from structlog import get_logger
8
+ from bson import ObjectId
9
+
10
+ from app.ai.agent.state import AgentState, FlowState
11
+ from app.database import get_db_sync
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ def serialize_listing(listing: Dict) -> Dict:
17
+ """Convert MongoDB document to JSON-serializable format."""
18
+ result = {}
19
+ for key, value in listing.items():
20
+ if isinstance(value, ObjectId):
21
+ result[key] = str(value)
22
+ elif hasattr(value, 'isoformat'):
23
+ result[key] = value.isoformat()
24
+ else:
25
+ result[key] = value
26
+ return result
27
+
28
+
29
+ async def my_listings_handler(state: AgentState) -> AgentState:
30
+ """
31
+ Fetch and display user's own listings with Edit/Delete options.
32
+ """
33
+
34
+ logger.info(
35
+ "Fetching user's listings",
36
+ user_id=state.user_id
37
+ )
38
+
39
+ try:
40
+ # Get database
41
+ db = get_db_sync()
42
+
43
+ # Fetch user's active listings
44
+ cursor = db.listings.find({
45
+ "user_id": state.user_id,
46
+ "status": "active"
47
+ }).sort("createdAt", -1) # Most recent first
48
+
49
+ listings = []
50
+ async for doc in cursor:
51
+ listings.append(serialize_listing(doc))
52
+
53
+ logger.info(
54
+ "User listings fetched",
55
+ user_id=state.user_id,
56
+ count=len(listings)
57
+ )
58
+
59
+ # Store in state
60
+ state.my_listings = listings
61
+
62
+ # Generate simple message
63
+ user_name = state.user_name or "there"
64
+
65
+ if listings:
66
+ message = f"Here are your listings, {user_name}! 🏠\n\n"
67
+ message += f"You have **{len(listings)}** published listing(s). "
68
+ message += "Click **Delete** to remove or **Edit** to update any listing."
69
+ else:
70
+ message = f"Hey {user_name}! 📋\n\n"
71
+ message += "You don't have any published listings yet. "
72
+ message += "Would you like to create one? Just say 'list my property'!"
73
+
74
+ # Store response
75
+ state.temp_data["response_text"] = message
76
+ state.temp_data["action"] = "my_listings"
77
+
78
+ # Transition to idle
79
+ state.transition_to(FlowState.IDLE, reason="My listings shown")
80
+
81
+ logger.info(
82
+ "My listings flow completed",
83
+ user_id=state.user_id,
84
+ listings_count=len(listings)
85
+ )
86
+
87
+ return state
88
+
89
+ except Exception as e:
90
+ logger.error("My listings error", exc_info=e)
91
+ state.temp_data["response_text"] = "Sorry, I couldn't fetch your listings right now. Please try again."
92
+ state.temp_data["action"] = "my_listings_error"
93
+ state.transition_to(FlowState.IDLE, reason="My listings error")
94
+ return state
app/ai/agent/nodes/respond.py CHANGED
@@ -48,6 +48,18 @@ async def respond_to_user(state: AgentState) -> AgentState:
48
  listing_id = state.temp_data.get("listing_id")
49
  tool_result = state.temp_data.get("tool_result")
50
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  logger.info(
52
  "📦 Response components extracted",
53
  has_text=bool(response_text),
@@ -72,6 +84,9 @@ async def respond_to_user(state: AgentState) -> AgentState:
72
  # Build AgentResponse
73
  # ============================================================
74
 
 
 
 
75
  response = AgentResponse(
76
  success=state.last_error is None,
77
  text=response_text,
@@ -82,8 +97,10 @@ async def respond_to_user(state: AgentState) -> AgentState:
82
  "errors": state.error_count,
83
  "last_error": state.last_error,
84
  },
85
- draft=draft,
86
  draft_ui=draft_ui,
 
 
87
  tool_result=tool_result,
88
  error=state.last_error,
89
  metadata={
@@ -93,6 +110,7 @@ async def respond_to_user(state: AgentState) -> AgentState:
93
  "messages_in_session": len(state.conversation_history),
94
  "listing_id": listing_id,
95
  "timestamp": datetime.utcnow().isoformat(),
 
96
  }
97
  )
98
 
 
48
  listing_id = state.temp_data.get("listing_id")
49
  tool_result = state.temp_data.get("tool_result")
50
 
51
+ # ============================================================
52
+ # SYNC: Regenerate draft_ui from listing_draft if available
53
+ # This ensures draft_ui stays in sync after edits
54
+ # ============================================================
55
+
56
+ if state.listing_draft and isinstance(state.listing_draft, dict):
57
+ # Always regenerate draft_ui from current listing_draft
58
+ from app.ai.agent.nodes.listing_validate import build_draft_ui_from_dict
59
+ draft_ui = build_draft_ui_from_dict(state.listing_draft)
60
+ draft = state.listing_draft
61
+ logger.info("🔄 Draft UI regenerated from listing_draft")
62
+
63
  logger.info(
64
  "📦 Response components extracted",
65
  has_text=bool(response_text),
 
84
  # Build AgentResponse
85
  # ============================================================
86
 
87
+ # Check if we need to signal card update
88
+ replace_last_message = state.temp_data.get("replace_last_message", False)
89
+
90
  response = AgentResponse(
91
  success=state.last_error is None,
92
  text=response_text,
 
97
  "errors": state.error_count,
98
  "last_error": state.last_error,
99
  },
100
+ draft=None, # Don't expose raw draft - only use draft_ui for display
101
  draft_ui=draft_ui,
102
+ search_results=state.search_results if state.search_results else None, # Include search results
103
+ my_listings=state.my_listings if state.my_listings else None, # Include user's listings
104
  tool_result=tool_result,
105
  error=state.last_error,
106
  metadata={
 
110
  "messages_in_session": len(state.conversation_history),
111
  "listing_id": listing_id,
112
  "timestamp": datetime.utcnow().isoformat(),
113
+ "replace_last_message": replace_last_message, # Signal frontend to update card
114
  }
115
  )
116
 
app/ai/agent/nodes/search_query.py CHANGED
@@ -1,7 +1,7 @@
1
  # app/ai/agent/nodes/search_query.py
2
  """
3
  Node: Process search queries and return matching listings.
4
- Extracts search criteria, queries MongoDB, formats results.
5
  """
6
 
7
  import json
@@ -14,6 +14,7 @@ from app.ai.agent.state import AgentState, FlowState
14
  from app.ai.agent.validators import JSONValidator
15
  from app.database import get_db
16
  from app.config import settings
 
17
 
18
  logger = get_logger(__name__)
19
 
@@ -25,23 +26,34 @@ llm = ChatOpenAI(
25
  temperature=0.3,
26
  )
27
 
28
- SEARCH_EXTRACTION_PROMPT = """Extract search criteria from user's query.
29
 
30
  User message: "{user_message}"
31
 
32
- Extract search parameters (set to null if not mentioned):
33
- - location: City/area name (e.g., "Lagos", "Cotonou") or null
34
- - min_price: Minimum price or null
35
- - max_price: Maximum price or null
36
- - bedrooms: Number of bedrooms or null
37
- - bathrooms: Number of bathrooms or null
38
- - listing_type: Type of listing (rent, short-stay, sale, roommate) or null
39
- - amenities: List of desired amenities or []
 
40
 
41
- Be smart about:
42
- - Understanding "under 50k" as max_price: 50000
43
- - "3+ bedrooms" as bedrooms: 3
44
- - "furnished apartment" as listing_type: rent
 
 
 
 
 
 
 
 
 
 
45
 
46
  Return ONLY valid JSON:
47
  {{
@@ -51,6 +63,7 @@ Return ONLY valid JSON:
51
  "bedrooms": integer or null,
52
  "bathrooms": integer or null,
53
  "listing_type": string or null,
 
54
  "amenities": []
55
  }}"""
56
 
@@ -149,6 +162,11 @@ async def search_listings(search_params: dict) -> list:
149
  # Execute query with limit
150
  results = await db.listings.find(query).limit(10).to_list(10)
151
 
 
 
 
 
 
152
  logger.info("Search completed", results_count=len(results))
153
 
154
  return results
@@ -158,68 +176,148 @@ async def search_listings(search_params: dict) -> list:
158
  return []
159
 
160
 
161
- def format_search_results(listings: list, search_params: dict) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  """
163
- Format search results for display.
164
 
165
  Args:
166
  listings: List of matching listings
167
- search_params: Original search parameters (for context)
 
 
 
168
 
169
  Returns:
170
- Formatted string with results
171
  """
172
 
173
- if not listings:
174
- location = search_params.get("location", "that location")
175
- return (
176
- f"😕 No listings found matching your criteria in {location}.\n\n"
177
- "Try:\n"
178
- "- Searching in a different area\n"
179
- "- Adjusting your price range\n"
180
- "- Reducing bedroom/bathroom requirements\n"
181
- "- Searching for a different listing type (rent, sale, short-stay, roommate)"
182
- )
183
-
184
- results_text = f"🏠 Found **{len(listings)}** matching listing{'s' if len(listings) != 1 else ''}:\n\n"
185
 
186
- for i, listing in enumerate(listings, 1):
187
- title = listing.get("title", "Untitled")
188
- location = listing.get("location", "Unknown")
189
- price = listing.get("price", "N/A")
190
- currency = listing.get("currency", "")
191
- price_type = listing.get("price_type", "")
192
- bedrooms = listing.get("bedrooms", "?")
193
- bathrooms = listing.get("bathrooms", "?")
194
- listing_type = listing.get("listing_type", "").capitalize()
195
- images_count = len(listing.get("images", []))
196
-
197
- results_text += f"""**{i}. {title}**
198
- 📍 {location} | 🛏️ {bedrooms}bd {bathrooms}ba
199
- 💰 {price} {currency}/{price_type} | 🏷️ {listing_type}
200
- 📷 {images_count} image{'s' if images_count != 1 else ''}
201
-
 
 
 
 
 
 
202
  """
 
 
203
 
204
- results_text += (
205
- "\n💬 Would you like more details about any of these listings? "
206
- "Just ask!\n\n"
207
- "📝 Or if you'd like to list your own property, I can help with that too!"
 
 
 
208
  )
209
 
210
- return results_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
 
213
  async def search_query_handler(state: AgentState) -> AgentState:
214
  """
215
- Handle search flow.
216
 
217
  Flow:
218
- 1. Extract search criteria from message
219
- 2. Query MongoDB
220
- 3. Format results
221
- 4. Show to user
222
- 5. Transition to COMPLETE
223
 
224
  Args:
225
  state: Agent state
@@ -229,14 +327,14 @@ async def search_query_handler(state: AgentState) -> AgentState:
229
  """
230
 
231
  logger.info(
232
- "Handling search query",
233
  user_id=state.user_id,
234
  message=state.last_user_message[:50]
235
  )
236
 
237
  try:
238
  # ============================================================
239
- # STEP 1: Extract search parameters
240
  # ============================================================
241
 
242
  search_params = await extract_search_params(state.last_user_message)
@@ -245,9 +343,9 @@ async def search_query_handler(state: AgentState) -> AgentState:
245
  logger.warning("No search parameters extracted")
246
  state.temp_data["response_text"] = (
247
  "I couldn't understand your search. Try asking:\n"
248
- "- \"2-bedroom apartments in Lagos\"\n"
249
- "- \"Properties under 50k per month\"\n"
250
- "- \"Short-stay rentals with wifi\""
251
  )
252
  state.temp_data["action"] = "search_invalid"
253
  return state
@@ -255,43 +353,74 @@ async def search_query_handler(state: AgentState) -> AgentState:
255
  logger.info("Search parameters extracted", params=search_params)
256
 
257
  # ============================================================
258
- # STEP 2: Search MongoDB
259
  # ============================================================
260
 
261
- results = await search_listings(search_params)
 
 
 
 
262
 
263
- logger.info("Search results retrieved", count=len(results))
 
 
 
 
264
 
265
  # ============================================================
266
- # STEP 3: Format results
267
  # ============================================================
268
 
269
- formatted_results = format_search_results(results, search_params)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
- logger.info("Results formatted", length=len(formatted_results))
272
 
273
  # ============================================================
274
- # STEP 4: Store in state
275
  # ============================================================
276
 
277
  state.search_results = results
278
  state.temp_data["response_text"] = formatted_results
279
  state.temp_data["action"] = "search_results"
 
280
 
281
  # ============================================================
282
- # STEP 5: Transition to COMPLETE
283
  # ============================================================
284
 
285
- success, error = state.transition_to(FlowState.COMPLETE, reason="Search completed")
 
286
 
287
  if not success:
288
- logger.error("Transition to COMPLETE failed", error=error)
289
  state.set_error(error, should_retry=False)
 
 
 
 
 
290
 
291
  logger.info(
292
- "Search flow completed",
293
  user_id=state.user_id,
294
- results_count=len(results)
 
295
  )
296
 
297
  return state
 
1
  # app/ai/agent/nodes/search_query.py
2
  """
3
  Node: Process search queries and return matching listings.
4
+ HYBRID SEARCH: Uses Qdrant vector search + payload filters for intelligent NLP-based search.
5
  """
6
 
7
  import json
 
14
  from app.ai.agent.validators import JSONValidator
15
  from app.database import get_db
16
  from app.config import settings
17
+ from app.ai.services.search_service import search_listings_hybrid, infer_currency_from_location
18
 
19
  logger = get_logger(__name__)
20
 
 
26
  temperature=0.3,
27
  )
28
 
29
+ SEARCH_EXTRACTION_PROMPT = """You are extracting search criteria from a natural language property search query.
30
 
31
  User message: "{user_message}"
32
 
33
+ Extract ONLY what is EXPLICITLY mentioned (set to null if not clearly stated):
34
+ - location: City/area/neighborhood name (e.g., "Calavi", "Lagos", "Cotonou", "Victoria Island") or null
35
+ - min_price: Minimum price as number or null
36
+ - max_price: Maximum price as number or null (interpret "20k" as 20000, "of 20k" as max_price: 20000)
37
+ - bedrooms: Minimum number of bedrooms or null
38
+ - bathrooms: Minimum number of bathrooms or null
39
+ - listing_type: ONLY if explicitly stated. Options: "rent", "short-stay", "sale", "roommate". Set to null otherwise.
40
+ - price_type: Payment frequency or null. Options: "monthly", "weekly", "nightly", "yearly"
41
+ - amenities: List of desired features (e.g., ["wifi", "balcony", "parking"]) or []
42
 
43
+ IMPORTANT RULES:
44
+ - Do NOT infer listing_type from words like "house", "apartment", "room" - these are property types, not listing types
45
+ - ONLY set listing_type if user explicitly says "for rent", "to buy", "for sale", "short stay", "roommate"
46
+ - "I want a house of 20k in Cotonou" listing_type: null (not mentioned)
47
+ - "I want to rent a house" → listing_type: "rent" (explicitly mentioned)
48
+ - "House for sale in Lagos" → listing_type: "sale" (explicitly mentioned)
49
+
50
+ Price understanding:
51
+ - "50k" or "50K" = 50000
52
+ - "of 20k" or "for 20k" = max_price: 20000
53
+ - "under 50k" or "less than 50k" = max_price: 50000
54
+ - "around 80k" = min_price: 70000, max_price: 90000
55
+ - "per month" = price_type: "monthly"
56
+ - "per night" = price_type: "nightly"
57
 
58
  Return ONLY valid JSON:
59
  {{
 
63
  "bedrooms": integer or null,
64
  "bathrooms": integer or null,
65
  "listing_type": string or null,
66
+ "price_type": string or null,
67
  "amenities": []
68
  }}"""
69
 
 
162
  # Execute query with limit
163
  results = await db.listings.find(query).limit(10).to_list(10)
164
 
165
+ # Convert ObjectId to string to prevent serialization errors
166
+ for item in results:
167
+ if "_id" in item:
168
+ item["_id"] = str(item["_id"])
169
+
170
  logger.info("Search completed", results_count=len(results))
171
 
172
  return results
 
176
  return []
177
 
178
 
179
+ SEARCH_RESULTS_PROMPT = """You are presenting property search results to a user.
180
+
181
+ CRITICAL LANGUAGE RULE:
182
+ The user's query is: "{user_query}"
183
+ - If the query is in ENGLISH (like "show me houses"), respond in ENGLISH
184
+ - If the query is in FRENCH (like "montre moi des maisons"), respond in FRENCH
185
+ - IGNORE the location name when determining language (Cotonou is just a place, not a language indicator)
186
+ - The query "{user_query}" is in ENGLISH if it contains words like "show", "me", "house", "find", "looking"
187
+
188
+ USER INFO:
189
+ - Name: {user_name}
190
+ - Query: "{user_query}"
191
+
192
+ SEARCH RESULTS ({count} properties found):
193
+ {listings_summary}
194
+
195
+ CURRENCY: {currency}
196
+
197
+ YOUR TASK:
198
+ Write a friendly, personalized response presenting these search results. Rules:
199
+ 1. RESPOND IN THE SAME LANGUAGE AS THE QUERY TEXT (not the location!)
200
+ 2. Start with a warm greeting using the user's name if provided
201
+ 3. Give a brief 1-2 sentence summary about EACH property (title, location, price, key features)
202
+ 4. End by mentioning they can view the cards below for details and ask for more info
203
+ 5. Keep it concise but friendly and helpful
204
+ 6. Use emojis appropriately (🏠 💰 etc.)
205
+
206
+ If no properties found, give helpful suggestions.
207
+
208
+ Write ONLY the response text, no JSON or formatting instructions."""
209
+
210
+
211
+ async def generate_search_results_text(
212
+ listings: list,
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:
237
+ listings_summary = ""
238
+ for i, listing in enumerate(listings, 1):
239
+ title = listing.get("title", "Untitled")
240
+ loc = listing.get("location", "Unknown")
241
+ price = float(listing.get("price", 0) or 0)
242
+ currency = listing.get("currency", inferred_currency or "XOF")
243
+ price_type = listing.get("price_type", "monthly")
244
+ bedrooms = listing.get("bedrooms", "?")
245
+ bathrooms = listing.get("bathrooms", "?")
246
+ amenities = listing.get("amenities", [])
247
+ description = str(listing.get("description", ""))[:100]
248
+
249
+ listings_summary += f"""
250
+ Property {i}:
251
+ - Title: {title}
252
+ - Location: {loc}
253
+ - Price: {currency} {price:,.0f} {price_type}
254
+ - Bedrooms: {bedrooms}, Bathrooms: {bathrooms}
255
+ - Amenities: {', '.join(amenities[:4]) if amenities else 'Not specified'}
256
+ - Description: {description}...
257
  """
258
+ else:
259
+ listings_summary = f"No properties found matching criteria in {location or 'the specified area'}."
260
 
261
+ # Format prompt
262
+ prompt = SEARCH_RESULTS_PROMPT.format(
263
+ user_name=user_name or "there",
264
+ user_query=user_query,
265
+ count=count,
266
+ listings_summary=listings_summary,
267
+ currency=inferred_currency or "local currency"
268
  )
269
 
270
+ try:
271
+ messages = [
272
+ SystemMessage(content="You are AIDA, a friendly and helpful real estate AI assistant."),
273
+ HumanMessage(content=prompt)
274
+ ]
275
+
276
+ response = await llm.ainvoke(messages)
277
+ result_text = response.content.strip()
278
+
279
+ logger.info("LLM generated search results text", text_len=len(result_text))
280
+ return result_text
281
+
282
+ except Exception as e:
283
+ logger.error("LLM search text generation failed, using fallback", error=str(e))
284
+ # Fallback to simple format
285
+ return _fallback_format_results(listings, search_params, inferred_currency)
286
+
287
+
288
+ def _fallback_format_results(listings: list, search_params: dict, inferred_currency: str = None) -> str:
289
+ """Simple fallback if LLM fails."""
290
+ if not listings:
291
+ return f"😕 No listings found matching your criteria. Try adjusting your search."
292
+
293
+ location = search_params.get("location", "")
294
+ count = len(listings)
295
+
296
+ text = f"🏠 Found {count} properties"
297
+ if location:
298
+ text += f" in {location}"
299
+ text += "!\n\n"
300
+
301
+ for listing in listings:
302
+ title = listing.get("title", "Property")
303
+ price = listing.get("price", 0)
304
+ currency = listing.get("currency", inferred_currency or "")
305
+ text += f"• **{title}** - {currency} {price:,.0f}\n"
306
+
307
+ text += "\nCheck the cards below for details!"
308
+ return text
309
 
310
 
311
  async def search_query_handler(state: AgentState) -> AgentState:
312
  """
313
+ Handle search flow with HYBRID SEARCH.
314
 
315
  Flow:
316
+ 1. Extract search criteria from message (LLM)
317
+ 2. Infer currency from location
318
+ 3. Perform hybrid search (Qdrant vector + filters)
319
+ 4. Format and display results
320
+ 5. Transition to IDLE
321
 
322
  Args:
323
  state: Agent state
 
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)
 
343
  logger.warning("No search parameters extracted")
344
  state.temp_data["response_text"] = (
345
  "I couldn't understand your search. Try asking:\n"
346
+ "- \"I want a house in Calavi for 50k per month with wifi\"\n"
347
+ "- \"2-bedroom apartments in Lagos under 500k\"\n"
348
+ "- \"Short-stay rentals with balcony and parking\""
349
  )
350
  state.temp_data["action"] = "search_invalid"
351
  return state
 
353
  logger.info("Search parameters extracted", params=search_params)
354
 
355
  # ============================================================
356
+ # STEP 2: Hybrid Search (Qdrant Vector + Filters)
357
  # ============================================================
358
 
359
+ results, inferred_currency = await search_listings_hybrid(
360
+ user_query=state.last_user_message,
361
+ search_params=search_params,
362
+ limit=10
363
+ )
364
 
365
+ logger.info(
366
+ "Hybrid search completed",
367
+ results_count=len(results),
368
+ currency=inferred_currency
369
+ )
370
 
371
  # ============================================================
372
+ # STEP 3: Fallback to MongoDB if Qdrant returns no results
373
  # ============================================================
374
 
375
+ if not results:
376
+ logger.info("Qdrant returned no results, trying MongoDB fallback")
377
+ results = await search_listings(search_params)
378
+ logger.info("MongoDB fallback results", count=len(results))
379
+
380
+ # ============================================================
381
+ # STEP 4: Generate LLM-based personalized response text
382
+ # ============================================================
383
+
384
+ formatted_results = await generate_search_results_text(
385
+ listings=results,
386
+ search_params=search_params,
387
+ user_query=state.last_user_message,
388
+ user_name=state.user_name,
389
+ inferred_currency=inferred_currency
390
+ )
391
 
392
+ logger.info("LLM results formatted", length=len(formatted_results))
393
 
394
  # ============================================================
395
+ # STEP 5: Store in state
396
  # ============================================================
397
 
398
  state.search_results = results
399
  state.temp_data["response_text"] = formatted_results
400
  state.temp_data["action"] = "search_results"
401
+ state.temp_data["inferred_currency"] = inferred_currency
402
 
403
  # ============================================================
404
+ # STEP 6: Transition to SEARCH_RESULTS then IDLE
405
  # ============================================================
406
 
407
+ # First transition: search_query search_results
408
+ success, error = state.transition_to(FlowState.SEARCH_RESULTS, reason="Hybrid search completed")
409
 
410
  if not success:
411
+ logger.error("Transition to SEARCH_RESULTS failed", error=error)
412
  state.set_error(error, should_retry=False)
413
+ else:
414
+ # Second transition: search_results → idle
415
+ success2, error2 = state.transition_to(FlowState.IDLE, reason="Search results shown")
416
+ if not success2:
417
+ logger.warning("Transition to IDLE failed", error=error2)
418
 
419
  logger.info(
420
+ "Hybrid search flow completed",
421
  user_id=state.user_id,
422
+ results_count=len(results),
423
+ currency=inferred_currency
424
  )
425
 
426
  return state
app/ai/agent/nodes/validate_output.py CHANGED
@@ -11,6 +11,7 @@ from typing import Dict, Any, Optional, List
11
  from app.ai.agent.state import AgentState
12
  from app.ai.agent.validators import ResponseValidator, ListingValidator
13
  from app.ai.agent.schemas import ListingDraft, ValidationResult
 
14
 
15
  logger = get_logger(__name__)
16
 
@@ -202,6 +203,44 @@ async def validate_output_node(state: AgentState) -> AgentState:
202
  state.set_error("No response text generated", should_retry=True)
203
  return state
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  # ✅ VALIDATE
206
  validation_result = await OutputValidator.validate_response(
207
  text=response_text,
 
11
  from app.ai.agent.state import AgentState
12
  from app.ai.agent.validators import ResponseValidator, ListingValidator
13
  from app.ai.agent.schemas import ListingDraft, ValidationResult
14
+ from app.ai.tools.listing_tool import get_currency_for_location
15
 
16
  logger = get_logger(__name__)
17
 
 
203
  state.set_error("No response text generated", should_retry=True)
204
  return state
205
 
206
+ # ✅ SYNC: Update listing_draft from provided_fields if they differ
207
+ # This handles the case where user edited a field (e.g., "edit location to Owerri")
208
+ if state.listing_draft and state.provided_fields:
209
+ updated_draft = state.listing_draft.copy()
210
+ needs_update = False
211
+
212
+ # Sync each field from provided_fields to listing_draft
213
+ sync_fields = ["location", "bedrooms", "bathrooms", "price", "price_type",
214
+ "amenities", "requirements", "images"]
215
+ for field in sync_fields:
216
+ if field in state.provided_fields:
217
+ provided_val = state.provided_fields[field]
218
+ draft_val = updated_draft.get(field)
219
+ if provided_val != draft_val:
220
+ updated_draft[field] = provided_val
221
+ needs_update = True
222
+ logger.info(f"🔄 Syncing {field}: {draft_val} → {provided_val}")
223
+
224
+ # ⚡ If location changed, recalculate currency
225
+ if field == "location":
226
+ new_currency = await get_currency_for_location(provided_val)
227
+ updated_draft["currency"] = new_currency
228
+ logger.info(f"💱 Currency updated: → {new_currency}")
229
+
230
+ # Regenerate title and description if location changed
231
+ if needs_update:
232
+ # Update title to reflect new location
233
+ location = updated_draft.get("location", "Unknown")
234
+ bedrooms = updated_draft.get("bedrooms", "?")
235
+ listing_type = updated_draft.get("listing_type", "property")
236
+ updated_draft["title"] = f"{bedrooms}-Bed {listing_type.capitalize()} in {location}"
237
+ updated_draft["description"] = f"Beautiful {bedrooms}-bedroom, {updated_draft.get('bathrooms', '?')}-bathroom {listing_type} in {location}. Price: {updated_draft.get('price', '?')} {updated_draft.get('currency', 'NGN')}/{updated_draft.get('price_type', 'monthly')}. Amenities: {', '.join(updated_draft.get('amenities', []) or ['None'])}."
238
+
239
+ state.listing_draft = updated_draft
240
+ state.temp_data["draft"] = updated_draft
241
+ draft = updated_draft
242
+ logger.info("✅ listing_draft synced with provided_fields")
243
+
244
  # ✅ VALIDATE
245
  validation_result = await OutputValidator.validate_response(
246
  text=response_text,
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", "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
@@ -68,7 +68,7 @@ class ListingDraft(BaseModel):
68
  bedrooms: int = Field(..., ge=0, le=20)
69
  bathrooms: int = Field(..., ge=0, le=20)
70
  price: float = Field(..., gt=0)
71
- price_type: Literal["monthly", "yearly", "weekly", "daily", "nightly"]
72
  currency: str = Field(..., min_length=3, max_length=3)
73
  listing_type: Literal["rent", "short-stay", "sale", "roommate"]
74
  amenities: List[str] = Field(default_factory=list)
@@ -164,6 +164,8 @@ class AgentResponse(BaseModel):
164
  state: Dict[str, Any] = Field(default_factory=dict)
165
  draft: Optional[ListingDraft] = None
166
  draft_ui: Optional[Dict[str, Any]] = None
 
 
167
  tool_result: Optional[ToolResult] = None
168
  error: Optional[str] = None
169
  metadata: Dict[str, Any] = Field(default_factory=dict)
 
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
 
68
  bedrooms: int = Field(..., ge=0, le=20)
69
  bathrooms: int = Field(..., ge=0, le=20)
70
  price: float = Field(..., gt=0)
71
+ price_type: Literal["monthly", "yearly", "weekly", "daily", "nightly", "one-time"]
72
  currency: str = Field(..., min_length=3, max_length=3)
73
  listing_type: Literal["rent", "short-stay", "sale", "roommate"]
74
  amenities: List[str] = Field(default_factory=list)
 
164
  state: Dict[str, Any] = Field(default_factory=dict)
165
  draft: Optional[ListingDraft] = None
166
  draft_ui: Optional[Dict[str, Any]] = None
167
+ search_results: Optional[List[Dict[str, Any]]] = None # For search results cards
168
+ my_listings: Optional[List[Dict[str, Any]]] = None # For user's own listings
169
  tool_result: Optional[ToolResult] = None
170
  error: Optional[str] = None
171
  metadata: Dict[str, Any] = Field(default_factory=dict)
app/ai/agent/state.py CHANGED
@@ -30,6 +30,12 @@ class FlowState(str, Enum):
30
  SEARCH_QUERY = "search_query"
31
  SEARCH_RESULTS = "search_results"
32
 
 
 
 
 
 
 
33
  # Other flows
34
  GREETING = "greeting"
35
  CASUAL_CHAT = "casual_chat"
@@ -51,6 +57,10 @@ class AgentState(BaseModel):
51
  session_id: str
52
  user_role: str
53
 
 
 
 
 
54
  # Current flow tracking
55
  current_flow: FlowState = FlowState.IDLE
56
  previous_flow: Optional[FlowState] = None
@@ -67,6 +77,9 @@ class AgentState(BaseModel):
67
  search_query: Optional[str] = None
68
  search_results: List[Dict[str, Any]] = Field(default_factory=list)
69
 
 
 
 
70
  # Conversation context
71
  conversation_history: List[Dict[str, str]] = Field(default_factory=list)
72
  language_detected: str = "en"
@@ -104,7 +117,10 @@ class AgentState(BaseModel):
104
  FlowState.CLASSIFY_INTENT: [
105
  FlowState.GREETING,
106
  FlowState.LISTING_COLLECT,
 
107
  FlowState.SEARCH_QUERY,
 
 
108
  FlowState.CASUAL_CHAT,
109
  FlowState.ERROR,
110
  ],
@@ -141,6 +157,18 @@ class AgentState(BaseModel):
141
  FlowState.CLASSIFY_INTENT,
142
  FlowState.ERROR,
143
  ],
 
 
 
 
 
 
 
 
 
 
 
 
144
  FlowState.CASUAL_CHAT: [
145
  FlowState.IDLE,
146
  FlowState.CLASSIFY_INTENT,
 
30
  SEARCH_QUERY = "search_query"
31
  SEARCH_RESULTS = "search_results"
32
 
33
+ # My Listings flow
34
+ MY_LISTINGS = "my_listings"
35
+
36
+ # Edit Listing flow
37
+ EDIT_LISTING = "edit_listing"
38
+
39
  # Other flows
40
  GREETING = "greeting"
41
  CASUAL_CHAT = "casual_chat"
 
57
  session_id: str
58
  user_role: str
59
 
60
+ # Personalization (optional - from login)
61
+ user_name: Optional[str] = None
62
+ user_location: Optional[str] = None
63
+
64
  # Current flow tracking
65
  current_flow: FlowState = FlowState.IDLE
66
  previous_flow: Optional[FlowState] = None
 
77
  search_query: Optional[str] = None
78
  search_results: List[Dict[str, Any]] = Field(default_factory=list)
79
 
80
+ # My listings data
81
+ my_listings: List[Dict[str, Any]] = Field(default_factory=list)
82
+
83
  # Conversation context
84
  conversation_history: List[Dict[str, str]] = Field(default_factory=list)
85
  language_detected: str = "en"
 
117
  FlowState.CLASSIFY_INTENT: [
118
  FlowState.GREETING,
119
  FlowState.LISTING_COLLECT,
120
+ FlowState.LISTING_PUBLISH,
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
  ],
 
157
  FlowState.CLASSIFY_INTENT,
158
  FlowState.ERROR,
159
  ],
160
+ # My Listings flow
161
+ FlowState.MY_LISTINGS: [
162
+ FlowState.IDLE,
163
+ FlowState.CLASSIFY_INTENT,
164
+ FlowState.ERROR,
165
+ ],
166
+ # Edit Listing flow
167
+ FlowState.EDIT_LISTING: [
168
+ FlowState.LISTING_COLLECT, # Goes to listing collect for edits
169
+ FlowState.IDLE,
170
+ FlowState.ERROR,
171
+ ],
172
  FlowState.CASUAL_CHAT: [
173
  FlowState.IDLE,
174
  FlowState.CLASSIFY_INTENT,
app/ai/memory/__pycache__/redis_context_memory.cpython-313.pyc ADDED
Binary file (15.7 kB). View file
 
app/ai/memory/__pycache__/redis_memory.cpython-313.pyc CHANGED
Binary files a/app/ai/memory/__pycache__/redis_memory.cpython-313.pyc and b/app/ai/memory/__pycache__/redis_memory.cpython-313.pyc differ
 
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
@@ -1,7 +1,7 @@
1
  # app/ai/prompts/system_prompt.py
2
  # FINAL: Simplified listing flow - show example, collect fields, auto-detect everything
3
 
4
- def get_system_prompt(user_role: str = "landlord") -> str:
5
  """
6
  Get Aida's system prompt - UPDATED for simplified listing flow.
7
 
@@ -12,9 +12,12 @@ def get_system_prompt(user_role: str = "landlord") -> str:
12
  - Auto-generate: title (short, contains location), description (clean, detailed)
13
  - NO asking for property type - it's auto-detected
14
  - Handle image URLs from Cloudflare (client-side upload)
 
15
 
16
  Args:
17
  user_role: "landlord" or "renter"
 
 
18
 
19
  Returns:
20
  System prompt string for LLM
@@ -22,14 +25,65 @@ def get_system_prompt(user_role: str = "landlord") -> str:
22
 
23
  role_upper = user_role.upper()
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  return f"""You are AIDA, a friendly and professional real estate AI assistant for the Lojiz platform.
 
26
 
27
  ========== WHO YOU ARE ==========
28
  Name: AIDA (Lojiz AI)
29
- Created by: Lojiz team
30
- Specialty: Real estate assistance
31
  Important: NEVER claim to be another AI (DeepSeek, GPT, Claude, etc.)
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  ========== YOUR PERSONALITY ==========
34
  - Warm, friendly, and professional
35
  - Speak naturally (short sentences, conversational)
@@ -78,10 +132,17 @@ REQUIRED FIELDS TO COLLECT:
78
  - Bedrooms (number like 2, 3, 4)
79
  - Bathrooms (number like 1, 2, 3)
80
  - Price (amount like 50000, 1200, 500)
81
- - Price Type (user MUST provide: "monthly", "yearly", "weekly", "daily", "nightly")
 
 
82
  - Amenities (optional but ask: wifi, parking, furnished, washing machine, ac, balcony, etc.)
83
  - Requirements (optional but ask: "3-month deposit", "no pets", "stable income", etc.)
84
 
 
 
 
 
 
85
  HOW TO COLLECT:
86
  - User can provide multiple fields: "2-bed, 1-bath in Lagos for 50k per month"
87
  - Extract ALL provided fields from that message
@@ -138,12 +199,16 @@ Description Generation:
138
  - Make it appealing and detailed
139
  - 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."
140
 
141
- STEP 5: SHOW DRAFT PREVIEW
142
  Once all required fields complete:
143
- - Generate draft with auto-detected fields
144
- - Format preview nicely with emoji icons
145
- - Show: title, description, location, bedrooms, bathrooms, price, amenities, requirements, images count
146
- - Ask: "Ready to publish? Say 'publish', or 'edit [field]' to change, or 'discard' to cancel."
 
 
 
 
147
 
148
  STEP 6: USER ACTIONS
149
 
 
1
  # app/ai/prompts/system_prompt.py
2
  # FINAL: Simplified listing flow - show example, collect fields, auto-detect everything
3
 
4
+ def get_system_prompt(user_role: str = "landlord", user_name: str = None, user_location: str = None) -> str:
5
  """
6
  Get Aida's system prompt - UPDATED for simplified listing flow.
7
 
 
12
  - Auto-generate: title (short, contains location), description (clean, detailed)
13
  - NO asking for property type - it's auto-detected
14
  - Handle image URLs from Cloudflare (client-side upload)
15
+ - PERSONALIZE greetings and examples using user's name and location (if available)
16
 
17
  Args:
18
  user_role: "landlord" or "renter"
19
+ user_name: User's first name (optional, for personalized greetings)
20
+ user_location: User's city/location (optional, for relevant examples)
21
 
22
  Returns:
23
  System prompt string for LLM
 
25
 
26
  role_upper = user_role.upper()
27
 
28
+ # Build personalization section
29
+ personalization_section = ""
30
+ if user_name or user_location:
31
+ personalization_section = """
32
+ ========== PERSONALIZATION ==========
33
+ """
34
+ if user_name:
35
+ personalization_section += f"""USER'S NAME: {user_name}
36
+ - Use their name occasionally in greetings and responses (e.g., "Hi {user_name}!", "Great choice, {user_name}!")
37
+ - Don't overuse it - once or twice per conversation is natural
38
+ - If name seems like a full name, use just the first part
39
+ """
40
+ if user_location:
41
+ personalization_section += f"""USER'S LOCATION: {user_location}
42
+ - When generating listing examples, use cities/areas near {user_location} (same country/region)
43
+ - Use the local currency for that region in examples
44
+ - Make examples feel relevant and realistic for their area
45
+ - Example: If user is in Cotonou, use Cotonou, Calavi, Porto-Novo, etc.
46
+ - Example: If user is in Lagos, use Lekki, Victoria Island, Ikeja, Surulere, etc.
47
+ """
48
+ else:
49
+ personalization_section = """
50
+ ========== PERSONALIZATION ==========
51
+ No personalization data available. Use generic greetings and varied global examples.
52
+ """
53
+
54
  return f"""You are AIDA, a friendly and professional real estate AI assistant for the Lojiz platform.
55
+ {personalization_section}
56
 
57
  ========== WHO YOU ARE ==========
58
  Name: AIDA (Lojiz AI)
59
+ Created by: The Lojiz Team
60
+ Specialty: Real estate assistance for property listing, search, and house hunting
61
  Important: NEVER claim to be another AI (DeepSeek, GPT, Claude, etc.)
62
 
63
+ ========== ABOUT LOJIZ ==========
64
+ Lojiz is an innovative startup on a mission to revolutionize house hunting worldwide. We're bridging the gap in property search and listing by leveraging the power of AI to make finding, listing, and renting properties easier than ever before.
65
+
66
+ When someone asks "What is Lojiz?", respond naturally with something like:
67
+ - "Lojiz is a real estate platform that uses AI to make house hunting and property listing simple and seamless. Whether you're looking for a rental, listing a property, or searching for a roommate, we've got you covered!"
68
+ - "We're a startup focused on making the entire real estate experience smoother - from listing to searching to renting. AI-powered, global, and user-friendly."
69
+
70
+ You can phrase it differently each time, but always convey:
71
+ 1. Lojiz uses AI to simplify real estate
72
+ 2. We help with both listing AND searching for properties
73
+ 3. We aim to serve users worldwide
74
+ 4. We make house hunting easier and more accessible
75
+
76
+ ========== ABOUT THE TEAM ==========
77
+ When someone asks "Who made Lojiz?", "Who are the developers?", "Who works at Lojiz?", or similar questions:
78
+
79
+ Answer: "Lojiz was built by the **Lojiz Team** - a talented group of developers, product designers, and innovators passionate about transforming real estate with technology."
80
+
81
+ You can also say:
82
+ - "The Lojiz Team is behind everything you see here - developers, designers, and people who care about making your property journey seamless."
83
+ - "A dedicated team of engineers and designers at Lojiz created me and this platform!"
84
+
85
+ IMPORTANT: Always credit "The Lojiz Team" - never mention individual names unless explicitly asked.
86
+
87
  ========== YOUR PERSONALITY ==========
88
  - Warm, friendly, and professional
89
  - Speak naturally (short sentences, conversational)
 
132
  - Bedrooms (number like 2, 3, 4)
133
  - Bathrooms (number like 1, 2, 3)
134
  - Price (amount like 50000, 1200, 500)
135
+ - Price Type:
136
+ * For RENTALS: Ask user - "monthly", "yearly", "weekly", "daily", "nightly"
137
+ * For SALES: Auto-set to "one-time" - NEVER ASK! Sale = one-time purchase.
138
  - Amenities (optional but ask: wifi, parking, furnished, washing machine, ac, balcony, etc.)
139
  - Requirements (optional but ask: "3-month deposit", "no pets", "stable income", etc.)
140
 
141
+ IMPORTANT SALE HANDLING:
142
+ - If user says "for sale", "sell", "selling" → listing_type = "sale", price_type = "one-time"
143
+ - DO NOT ask "is it monthly or yearly?" for sales - sales are ALWAYS one-time purchases!
144
+ - Only ask price_type for rentals/short-stays
145
+
146
  HOW TO COLLECT:
147
  - User can provide multiple fields: "2-bed, 1-bath in Lagos for 50k per month"
148
  - Extract ALL provided fields from that message
 
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:
204
+ - Say a SHORT confirmation message like "Perfect! Here's your listing preview:"
205
+ - DO NOT write out the draft in text - the UI will display a visual card automatically
206
+ - Just ask: "Ready to publish? Say 'publish', 'edit [field]' to change, or 'discard' to cancel."
207
+
208
+ DO NOT generate a text-based preview like:
209
+ ❌ "DRAFT PREVIEW: 🏠 4-Bed in Lagos 📍 Lagos | 🛏️ 4 beds..."
210
+
211
+ ✅ Instead, just say: "Perfect! Here's your listing preview. Ready to publish?"
212
 
213
  STEP 6: USER ACTIONS
214
 
app/ai/routes/__pycache__/chat.cpython-313.pyc ADDED
Binary file (17.3 kB). View file
 
app/ai/routes/__pycache__/chat_refactored.cpython-313.pyc ADDED
Binary file (12 kB). View file
 
app/ai/routes/chat.py CHANGED
@@ -1,483 +1,478 @@
1
- # app/ai/routes/chat.py - LLM-POWERED Intent Detection & Routing (FIXED ASYNC)
 
 
 
 
2
 
3
- from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
4
- from fastapi.security import HTTPBearer
5
  from pydantic import BaseModel
6
- from typing import Optional, Dict, Any, List
7
  from structlog import get_logger
8
- from datetime import datetime, timedelta
9
- import os
 
10
 
11
- # LangSmith tracing
12
- os.environ["LANGCHAIN_TRACING_V2"] = "true"
13
- os.environ["LANGCHAIN_API_KEY"] = os.getenv("LANGCHAIN_API_KEY", "")
14
-
15
- from app.guards.jwt_guard import decode_access_token
16
- from app.ai.memory.redis_context_memory import get_current_memory
17
- from app.ai.tools.intent_detector_tool import process_user_message
18
- from app.ai.tools.listing_tool import process_listing
19
- from app.ai.tools.greeting_tool import process_greeting, is_greeting
20
- from app.ai.memory.redis_memory import is_rate_limited
21
 
22
  logger = get_logger(__name__)
23
 
24
  router = APIRouter()
25
- security = HTTPBearer()
26
 
27
- # ========== REQUEST/RESPONSE MODELS ==========
 
 
 
28
 
29
  class AskBody(BaseModel):
 
30
  message: str
31
  session_id: Optional[str] = None
32
- thread_id: Optional[str] = None
 
33
  start_new_session: Optional[bool] = False
 
 
34
 
35
 
36
- class ChatResponse(BaseModel):
37
- success: bool
38
- text: str
39
- action: str
40
- state: Optional[Dict[str, Any]] = None
41
- draft: Optional[Dict[str, Any]] = None
42
- draft_ui: Optional[Dict[str, Any]] = None
43
- mongo_id: Optional[str] = None
44
- error: Optional[str] = None
45
-
46
-
47
- # ========== INTENT DETECTION (LLM-POWERED) ==========
48
-
49
- def user_wants_fresh_start(message: str) -> bool:
50
- message_lower = message.lower().strip()
51
- keywords = ["start fresh", "new listing", "clear", "reset", "start over", "new conversation"]
52
- return any(keyword in message_lower for keyword in keywords)
53
-
54
-
55
- def is_listing_intent(message: str) -> bool:
56
- message_lower = message.lower().strip()
57
- intents = ["list", "post", "create", "add property", "sell", "rent out", "want to list", "list my"]
58
- return any(intent in message_lower for intent in intents)
59
-
60
-
61
- def is_publish_intent(message: str) -> bool:
62
- message_lower = message.lower().strip()
63
- publish_variants = ["publish", "publsih", "post", "confirm", "list it", "go live", "submit"]
64
- return any(variant in message_lower for variant in publish_variants)
65
-
66
-
67
- def is_edit_intent(message: str) -> bool:
68
- return message.lower().strip().startswith("edit ")
69
-
70
 
71
- def is_discard_intent(message: str) -> bool:
72
- message_lower = message.lower().strip()
73
- discards = ["discard", "cancel", "delete", "remove", "clear", "start over", "trash"]
74
- return message_lower in discards or any(discard in message_lower for discard in discards)
75
-
76
-
77
- # ========== CONTEXT MANAGEMENT ==========
78
-
79
- async def is_context_idle(context: Dict, idle_threshold_minutes: int = 30) -> bool:
80
- if not context or not context.get("last_activity"):
81
- return False
82
- try:
83
- last_activity = datetime.fromisoformat(context["last_activity"])
84
- idle_time = datetime.utcnow() - last_activity
85
- if idle_time > timedelta(minutes=idle_threshold_minutes):
86
- logger.info("Context idle, resetting", idle_minutes=idle_time.total_seconds() / 60)
87
- return True
88
- return False
89
- except Exception as e:
90
- logger.warning(f"Could not check idle time: {e}")
91
- return False
92
-
93
-
94
- def reset_context() -> Dict:
95
- return {
96
- "status": "idle",
97
- "language": "en",
98
- "user_role": None,
99
- "draft": None,
100
- "state": {},
101
- "last_activity": datetime.utcnow().isoformat(),
102
- }
103
-
104
-
105
- # ========== MAIN CHAT ENDPOINT ==========
106
-
107
- @router.post("/ask", response_model=ChatResponse)
108
- async def ask_ai(
109
- body: AskBody,
110
- token: str = Depends(security),
111
- background_tasks: BackgroundTasks = BackgroundTasks(),
112
- ) -> ChatResponse:
113
  """
114
- LLM-POWERED chat endpoint with intelligent intent routing:
115
- Priority Order:
116
- 1. If draft exists → Handle publish/edit/discard (LLM-powered)
117
- 2. If in listing mode Process as listing (LLM-powered)
118
- 3. AI-powered greeting detection (LLM-based)
119
- 4. Listing intent (new) Start listing (LLM-powered)
120
- 5. Default LangChain agent (LLM-powered)
 
 
 
 
 
 
 
121
  """
 
 
 
122
  try:
123
- # AUTHENTICATE
124
- payload = decode_access_token(token.credentials)
125
- if not payload:
126
- logger.warning("Invalid token")
127
- raise HTTPException(status_code=401, detail="Invalid token")
128
- user_id = payload["user_id"]
129
- user_role = payload.get("role", "renter")
130
-
131
- # RATE LIMIT
132
- if await is_rate_limited(user_id):
133
- logger.warning("Rate limit exceeded", user_id=user_id)
134
- raise HTTPException(status_code=429, detail="Rate limit exceeded")
135
-
136
- # GET MEMORY
137
- session_id = body.session_id or "default"
138
- memory = await get_current_memory(user_id, session_id)
139
- context = await memory.get_context()
140
- logger.info("Chat message received", user_id=user_id, session_id=session_id, status=context.get("status"))
141
-
142
- # CHECK RESET
143
- should_reset = (
144
- body.start_new_session or
145
- user_wants_fresh_start(body.message) or
146
- await is_context_idle(context, idle_threshold_minutes=30)
147
- )
148
- if should_reset:
149
- logger.info("Resetting context", user_id=user_id)
150
- context = reset_context()
151
- context["user_role"] = user_role
152
- await memory.update_context(context)
153
- await memory.clear()
154
-
155
- # INIT CONTEXT IF NEW
156
- if not context:
157
- context = reset_context()
158
- context["user_role"] = user_role
159
- await memory.update_context(context)
160
-
161
- # VALIDATE MESSAGE
162
- if not body.message or body.message.strip() == "":
163
- return ChatResponse(success=False, text="Please provide a message.", action="error", error="Empty message")
164
-
165
- # GET HISTORY
166
- messages = await memory.get_messages()
167
-
168
- # ========== LLM-POWERED INTENT ROUTING ==========
169
-
170
- # ✅ PRIORITY 1: Handle draft actions (LLM-powered detection)
171
- if is_publish_intent(body.message) and context.get("draft"):
172
- logger.info("Publish intent detected (LLM-powered)", user_id=user_id)
173
- draft = context.get("draft")
174
- try:
175
- # ✅ SAVE TO MONGODB (FIXED ASYNC - AWAIT ADDED!)
176
- from motor.motor_asyncio import AsyncIOMotorDatabase
177
- from app.database import get_db
178
- db = await get_db() # ✅ FIXED: Added await
179
- listing_data = {
180
- "user_id": draft["user_id"],
181
- "user_role": draft["user_role"],
182
- "title": draft["title"],
183
- "description": draft["description"],
184
- "location": draft["location"],
185
- "bedrooms": int(draft["bedrooms"]),
186
- "bathrooms": int(draft["bathrooms"]),
187
- "price": float(draft["price"]),
188
- "price_type": draft["price_type"],
189
- "currency": draft["currency"],
190
- "listing_type": draft["listing_type"],
191
- "amenities": draft.get("amenities", []),
192
- "requirements": draft.get("requirements"),
193
- "images": draft.get("images", []),
194
- "status": "active",
195
- "created_at": datetime.utcnow(),
196
- "updated_at": datetime.utcnow(),
197
- }
198
- result = await db.listings.insert_one(listing_data) # ✅ FIXED: Added await
199
- listing_id = str(result.inserted_id)
200
- logger.info("✅ Listing published successfully", user_id=user_id, listing_id=listing_id)
201
- context["status"] = "idle"
202
- context["listing_state"] = {}
203
- context["draft"] = None
204
- context["editing_field"] = None
205
- context["last_activity"] = datetime.utcnow().isoformat()
206
- await memory.update_context(context)
207
- await memory.add_message("user", body.message)
208
- reply = f"🎉 Your listing '{draft['title']}' is now live! View it in your listings."
209
- await memory.add_message("assistant", reply)
210
- return ChatResponse(success=True, text=reply, action="published", state=context, mongo_id=listing_id)
211
- except Exception as e:
212
- logger.error("Failed to publish listing", exc_info=e)
213
- return ChatResponse(success=False, text="Sorry, I couldn't publish your listing. Please try again.", action="error", state=context, error=str(e))
214
-
215
- # EDIT (LLM-powered detection)
216
- if is_edit_intent(body.message) and context.get("draft"):
217
- logger.info("Edit intent detected (LLM-powered)", user_id=user_id)
218
- field_to_edit = body.message[5:].strip()
219
- context["editing_field"] = field_to_edit
220
- context["last_activity"] = datetime.utcnow().isoformat()
221
- await memory.update_context(context)
222
- reply = f"What would you like to change the {field_to_edit} to?"
223
- await memory.add_message("user", body.message)
224
- await memory.add_message("assistant", reply)
225
- return ChatResponse(success=True, text=reply, action="editing", state=context)
226
-
227
- # APPLY EDIT (LLM-powered field update)
228
- if context.get("editing_field") and context.get("draft"):
229
- logger.info("Applying edit (LLM-powered)", user_id=user_id)
230
- editing_field = context.get("editing_field")
231
- draft = context["draft"]
232
- new_value = body.message.strip()
233
- if editing_field in ["price", "bedrooms", "bathrooms"]:
234
- try:
235
- draft[editing_field] = int(new_value) if editing_field != "price" else float(new_value)
236
- except ValueError:
237
- draft[editing_field] = new_value
238
- else:
239
- draft[editing_field] = new_value
240
- context["editing_field"] = None
241
- context["draft"] = draft
242
- context["last_activity"] = datetime.utcnow().isoformat()
243
- await memory.update_context(context)
244
- reply = "✅ Updated! Here's your revised draft:"
245
- await memory.add_message("user", body.message)
246
- await memory.add_message("assistant", reply)
247
- return ChatResponse(success=True, text=reply, action="show_draft", state=context, draft=draft)
248
-
249
- # DISCARD (LLM-powered detection)
250
- if is_discard_intent(body.message) and context.get("draft"):
251
- logger.info("Discard intent detected (LLM-powered)", user_id=user_id)
252
- context["status"] = "idle"
253
- context["listing_state"] = {}
254
- context["draft"] = None
255
- context["editing_field"] = None
256
- context["last_activity"] = datetime.utcnow().isoformat()
257
- await memory.update_context(context)
258
- reply = "Draft cleared. What would you like to do next?"
259
- await memory.add_message("user", body.message)
260
- await memory.add_message("assistant", reply)
261
- return ChatResponse(success=True, text=reply, action="draft_discarded", state=context)
262
-
263
- # ✅ PRIORITY 2: Continue listing (if in progress)
264
- if context.get("status") == "listing":
265
- logger.info("Continuing listing flow (LLM-powered)", user_id=user_id)
266
- listing_state = context.get("listing_state", {
267
- "step": "initial",
268
- "provided_fields": {},
269
- "images": [],
270
- })
271
- result = await process_listing(
272
- user_message=body.message,
273
- user_id=user_id,
274
- user_role=user_role,
275
- current_state=listing_state,
276
  )
277
- context["status"] = "listing"
278
- context["listing_state"] = result.get("state", {})
279
- context["last_activity"] = datetime.utcnow().isoformat()
280
- if result.get("draft"):
281
- context["draft"] = result["draft"]
282
- await memory.update_context(context)
283
- await memory.add_message("user", body.message)
284
- await memory.add_message("assistant", result["reply"])
285
- return ChatResponse(
286
- success=result.get("success", True),
287
- text=result["reply"],
288
- action=result["action"],
289
- state=context,
290
- draft=result.get("draft"),
291
- draft_ui=result.get("draft_ui"),
292
- error=result.get("error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  )
294
-
295
- # ✅ PRIORITY 3: AI-powered greeting detection
296
- greeting_detected = await is_greeting(body.message)
297
- if greeting_detected:
298
- logger.info("Greeting detected (AI-powered)", user_id=user_id)
299
- greeting_result = await process_greeting(
300
- user_message=body.message,
301
- user_id=user_id,
302
- user_role=user_role
 
 
 
 
 
 
 
 
 
303
  )
304
- await memory.add_message("user", body.message)
305
- await memory.add_message("assistant", greeting_result["reply"])
306
- context["last_activity"] = datetime.utcnow().isoformat()
307
- context["status"] = greeting_result["state"].get("status", "idle")
308
- await memory.update_context(context)
309
- return ChatResponse(
310
- success=greeting_result["success"],
311
- text=greeting_result["reply"],
312
- action="greeting",
313
- state=context,
314
  )
315
-
316
- # PRIORITY 4: New listing intent (LLM-powered)
317
- if is_listing_intent(body.message):
318
- logger.info("Listing intent detected (LLM-powered)", user_id=user_id)
319
- listing_state = {
320
- "step": "initial",
321
- "provided_fields": {},
322
- "images": [],
323
- }
324
- result = await process_listing(
325
- user_message=body.message,
326
- user_id=user_id,
327
- user_role=user_role,
328
- current_state=listing_state,
 
 
 
 
 
329
  )
330
- context["status"] = "listing"
331
- context["listing_state"] = result.get("state", {})
332
- context["last_activity"] = datetime.utcnow().isoformat()
333
- if result.get("draft"):
334
- context["draft"] = result["draft"]
335
- await memory.update_context(context)
336
- await memory.add_message("user", body.message)
337
- await memory.add_message("assistant", result["reply"])
338
- return ChatResponse(
339
- success=result.get("success", True),
340
- text=result["reply"],
341
- action=result["action"],
342
- state=context,
343
- draft=result.get("draft"),
344
- draft_ui=result.get("draft_ui"),
345
- error=result.get("error")
346
  )
347
-
348
- # ✅ PRIORITY 5: Default → LangChain Agent (LLM-powered)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  else:
350
- logger.info("Processing with LangChain Agent (LLM-powered)", user_id=user_id)
351
- try:
352
- reply, tool_result = await process_user_message(
353
- user_message=body.message,
354
- user_id=user_id,
355
- user_role=user_role,
356
- conversation_history=messages,
357
- conversation_context=context
358
- )
359
- await memory.add_message("user", body.message)
360
- await memory.add_message("assistant", reply)
361
- context["last_activity"] = datetime.utcnow().isoformat()
362
- if "tool" in tool_result:
363
- context["last_tool"] = tool_result["tool"]
364
- await memory.update_context(context)
365
- return ChatResponse(
366
- success=tool_result.get("success", True),
367
- text=reply,
368
- action=tool_result.get("tool", "response"),
369
- state=context,
370
- error=tool_result.get("error")
371
- )
372
- except Exception as e:
373
- logger.error("LangChain processing error", exc_info=e)
374
- fallback_reply = "Sorry, I had an error processing your request. Please try again."
375
- await memory.add_message("user", body.message)
376
- await memory.add_message("assistant", fallback_reply)
377
- context["last_activity"] = datetime.utcnow().isoformat()
378
- await memory.update_context(context)
379
- return ChatResponse(success=False, text=fallback_reply, action="error", state=context, error=str(e))
380
-
381
  except HTTPException:
382
  raise
383
  except Exception as e:
384
- logger.error("Chat endpoint error", exc_info=e)
385
- raise HTTPException(status_code=500, detail=f"Error processing message: {str(e)}")
 
 
 
 
 
386
 
387
 
388
- # ========== HEALTH CHECK ==========
 
 
389
 
390
  @router.get("/health")
391
- async def health_check():
392
- """Health check for chat service"""
393
- return {
394
- "status": "healthy",
395
- "service": "Aida Chat with LLM-Powered Intent Detection",
396
- "langsmith": "enabled" if os.getenv("LANGCHAIN_API_KEY") else "disabled",
397
- "intent_detection": "llm-powered",
398
- "draft_handling": "intelligent",
399
- }
 
 
 
 
 
 
 
 
 
 
 
 
400
 
401
 
402
- # ========== CHAT HISTORY ==========
 
 
403
 
404
  @router.get("/history/{session_id}")
405
- async def get_chat_history(
406
- session_id: str,
407
- token: str = Depends(security),
408
- ):
409
- """Get chat history for a session"""
410
  try:
411
- payload = decode_access_token(token.credentials)
412
- if not payload:
413
- raise HTTPException(status_code=401, detail="Invalid token")
414
- user_id = payload["user_id"]
415
- memory = await get_current_memory(user_id, session_id)
416
- messages = await memory.get_messages()
417
- summary = await memory.get_summary()
418
- logger.info("Retrieved chat history", user_id=user_id, message_count=len(messages))
419
  return {
420
  "success": True,
421
- "summary": summary,
422
- "messages": messages,
 
423
  }
424
- except HTTPException:
425
- raise
426
  except Exception as e:
427
- logger.error("Failed to get history", exc_info=e)
428
- raise HTTPException(status_code=500, detail="Failed to retrieve history")
 
 
 
429
 
430
 
431
- # ========== SESSION MANAGEMENT ==========
 
 
432
 
433
- @router.post("/close-session/{session_id}")
434
- async def close_session(
435
- session_id: str,
436
- token: str = Depends(security),
437
- ):
438
- """Close/clear a chat session"""
439
  try:
440
- payload = decode_access_token(token.credentials)
441
- if not payload:
442
- raise HTTPException(status_code=401, detail="Invalid token")
443
- user_id = payload["user_id"]
444
- from app.ai.memory.redis_context_memory import get_memory_manager
445
- manager = get_memory_manager()
446
- await manager.close_session(user_id, session_id)
447
- logger.info("Session closed", user_id=user_id, session_id=session_id)
448
- return {"success": True, "message": "Session closed"}
449
- except HTTPException:
450
- raise
451
  except Exception as e:
452
- logger.error("Failed to close session", exc_info=e)
453
- raise HTTPException(status_code=500, detail="Failed to close session")
 
 
 
454
 
455
 
456
- @router.post("/reset-session/{session_id}")
457
- async def reset_session_endpoint(
458
- session_id: str,
459
- token: str = Depends(security),
460
- ):
461
- """Explicitly reset a session to fresh state"""
462
  try:
463
- payload = decode_access_token(token.credentials)
464
- if not payload:
465
- raise HTTPException(status_code=401, detail="Invalid token")
466
- user_id = payload["user_id"]
467
- user_role = payload.get("role", "renter")
468
- memory = await get_current_memory(user_id, session_id)
469
- await memory.clear()
470
- fresh_context = reset_context()
471
- fresh_context["user_role"] = user_role
472
- await memory.update_context(fresh_context)
473
- logger.info("Session reset to fresh state", user_id=user_id, session_id=session_id)
474
  return {
475
  "success": True,
476
- "message": "Session reset to fresh state",
477
- "context": fresh_context
 
478
  }
479
- except HTTPException:
480
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  except Exception as e:
482
- logger.error("Failed to reset session", exc_info=e)
483
- raise HTTPException(status_code=500, detail="Failed to reset session")
 
 
 
 
 
 
1
+ # app/ai/routes/chat.py
2
+ """
3
+ AIDA Chat Endpoint - LangGraph Powered (PRIMARY)
4
+ FIXED: Properly handles recursion limit and dict output from graph.ainvoke()
5
+ """
6
 
7
+ from fastapi import APIRouter, HTTPException
 
8
  from pydantic import BaseModel
9
+ from typing import Optional, Dict, Any
10
  from structlog import get_logger
11
+ from uuid import uuid4
12
+ from datetime import datetime
13
+ from langgraph.types import Command
14
 
15
+ from app.ai.agent.graph import get_aida_graph
16
+ from app.ai.agent.schemas import AgentResponse
 
 
 
 
 
 
 
 
17
 
18
  logger = get_logger(__name__)
19
 
20
  router = APIRouter()
 
21
 
22
+
23
+ # ============================================================
24
+ # REQUEST/RESPONSE MODELS
25
+ # ============================================================
26
 
27
  class AskBody(BaseModel):
28
+ """Request body for /ask endpoint"""
29
  message: str
30
  session_id: Optional[str] = None
31
+ user_id: Optional[str] = None
32
+ user_role: Optional[str] = "renter"
33
  start_new_session: Optional[bool] = False
34
+ user_name: Optional[str] = None
35
+ user_location: Optional[str] = None
36
 
37
 
38
+ # ============================================================
39
+ # MAIN CHAT ENDPOINT - LANGGRAPH POWERED (FIXED)
40
+ # ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
+ @router.post("/ask", response_model=AgentResponse)
43
+ async def ask_ai(body: AskBody) -> AgentResponse:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  """
45
+ Main chat endpoint using LangGraph state machine.
46
+
47
+ CRITICAL FIXES:
48
+ - Set recursion_limit to 50 (prevents infinite loops)
49
+ - graph.ainvoke() returns a DICT, not AgentState object
50
+ - Access dict keys with ['key'], not .attribute
51
+ - Extract response from dict['temp_data']['final_response']
52
+
53
+ Flow:
54
+ 1. Validate input
55
+ 2. Build input dict
56
+ 3. Invoke graph with dict input and high recursion_limit
57
+ 4. Extract final_response from returned dict
58
+ 5. Return to client
59
  """
60
+
61
+ logger.info("🚀 Chat request received", message_len=len(body.message))
62
+
63
  try:
64
+ # ============================================================
65
+ # STEP 1: Validate input
66
+ # ============================================================
67
+
68
+ if not body.message or not body.message.strip():
69
+ logger.warning("❌ Empty message received")
70
+ return AgentResponse(
71
+ success=False,
72
+ text="Please provide a message.",
73
+ action="error",
74
+ error="Empty message",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  )
76
+
77
+ message = body.message.strip()
78
+ session_id = body.session_id or str(uuid4())
79
+ user_id = body.user_id or f"anonymous_{uuid4()}"
80
+ user_role = body.user_role or "renter"
81
+
82
+ logger.info(
83
+ "📋 User session info",
84
+ user_id=user_id,
85
+ session_id=session_id,
86
+ user_role=user_role,
87
+ message_len=len(message),
88
+ )
89
+
90
+ # ============================================================
91
+ # STEP 2: Build input dict for graph
92
+ # ============================================================
93
+ # ✅ CRITICAL: Pass dict, not AgentState
94
+
95
+ input_dict = {
96
+ "user_id": user_id,
97
+ "session_id": session_id,
98
+ "user_role": user_role,
99
+ "user_name": body.user_name,
100
+ "user_location": body.user_location,
101
+ "last_user_message": message,
102
+ # "conversation_history": [], <-- REMOVED: Do not overwrite history!
103
+ "language_detected": "en",
104
+ "start_new_session": body.start_new_session or False,
105
+ }
106
+
107
+ # Only initialize history if starting new session
108
+ if body.start_new_session:
109
+ input_dict["conversation_history"] = []
110
+ input_dict["provided_fields"] = {}
111
+ input_dict["missing_required_fields"] = []
112
+ logger.info("🆕 Starting NEW session (clearing state)")
113
+
114
+ logger.info("📦 Input dict prepared", keys=list(input_dict.keys()))
115
+
116
+ # ============================================================
117
+ # STEP 3: Get graph and validate
118
+ # ============================================================
119
+
120
+ try:
121
+ graph = get_aida_graph()
122
+
123
+ if graph is None:
124
+ logger.error("❌ Graph is None!")
125
+ return AgentResponse(
126
+ success=False,
127
+ text="System error: Graph not initialized",
128
+ action="error",
129
+ error="Graph initialization failed",
130
+ )
131
+
132
+ logger.info("✅ Graph retrieved successfully")
133
+
134
+ except Exception as e:
135
+ logger.error("❌ Graph retrieval failed", exc_info=e)
136
+ return AgentResponse(
137
+ success=False,
138
+ text=f"System error: {str(e)}",
139
+ action="error",
140
+ error=str(e),
141
  )
142
+
143
+ # ============================================================
144
+ # STEP 4: Invoke graph with dict input
145
+ # ============================================================
146
+
147
+ logger.info("🔄 Invoking LangGraph...", user_id=user_id)
148
+
149
+ try:
150
+ # ✅ CRITICAL FIX: Pass recursion_limit config to prevent infinite loops
151
+ # ✅ CRITICAL FIX: Pass thread_id for persistence
152
+ config = {
153
+ "recursion_limit": 50,
154
+ "configurable": {"thread_id": session_id}
155
+ }
156
+
157
+ final_state_dict = await graph.ainvoke(
158
+ input_dict,
159
+ config=config
160
  )
161
+
162
+ # ✅ CRITICAL: final_state_dict is a DICT, not AgentState!
163
+ # Access with dict keys: ['key'], not .attribute
164
+
165
+ logger.info(
166
+ "✅ LangGraph execution completed",
167
+ flow=final_state_dict.get("current_flow", {}).get("value") if isinstance(final_state_dict.get("current_flow"), dict) else str(final_state_dict.get("current_flow")),
168
+ steps=final_state_dict.get("steps_taken"),
169
+ has_error=final_state_dict.get("last_error") is not None,
 
170
  )
171
+
172
+ except Exception as e:
173
+ logger.error("❌ Graph execution failed", exc_info=e)
174
+
175
+ # Check if it's a recursion error
176
+ if "recursion" in str(e).lower():
177
+ logger.error("⚠️ Graph hit recursion limit - check for infinite loops in listing_collect")
178
+ return AgentResponse(
179
+ success=False,
180
+ text="System is processing your request too long. This usually means you're in the listing flow. Please try again or provide more details.",
181
+ action="error",
182
+ error="Recursion limit exceeded - infinite loop detected",
183
+ )
184
+
185
+ return AgentResponse(
186
+ success=False,
187
+ text="Error processing your request. Please try again.",
188
+ action="error",
189
+ error=str(e),
190
  )
191
+
192
+ # ============================================================
193
+ # STEP 5: Extract response from final state dict
194
+ # ============================================================
195
+
196
+ # ✅ Access dict['key'] not dict.key
197
+ temp_data = final_state_dict.get("temp_data", {})
198
+ final_response = temp_data.get("final_response")
199
+
200
+ if final_response:
201
+ logger.info(
202
+ "✅ Response extracted from state",
203
+ action=final_response.action if hasattr(final_response, 'action') else "unknown",
204
+ success=final_response.success if hasattr(final_response, 'success') else False,
 
 
205
  )
206
+ return final_response
207
+
208
+ # ============================================================
209
+ # FALLBACK: Build response manually if not in temp_data
210
+ # ============================================================
211
+
212
+ logger.warning("⚠️ No final_response in temp_data, building manually")
213
+
214
+ response_text = temp_data.get("response_text", "")
215
+ if not response_text:
216
+ response_text = "I'm here to help! What would you like to do?"
217
+
218
+ # Get flow state - handle both FlowState enum and string
219
+ current_flow = final_state_dict.get("current_flow")
220
+ if hasattr(current_flow, 'value'):
221
+ flow_str = current_flow.value
222
  else:
223
+ flow_str = str(current_flow)
224
+
225
+ response = AgentResponse(
226
+ success=final_state_dict.get("last_error") is None,
227
+ text=response_text,
228
+ action=temp_data.get("action", flow_str),
229
+ state={
230
+ "flow": flow_str,
231
+ "steps": final_state_dict.get("steps_taken", 0),
232
+ "errors": final_state_dict.get("error_count", 0),
233
+ },
234
+ draft=temp_data.get("draft"),
235
+ draft_ui=temp_data.get("draft_ui"),
236
+ error=final_state_dict.get("last_error"),
237
+ metadata={
238
+ "intent": final_state_dict.get("intent_type"),
239
+ "intent_confidence": final_state_dict.get("intent_confidence", 0),
240
+ "language": final_state_dict.get("language_detected", "en"),
241
+ "messages_in_session": len(final_state_dict.get("conversation_history", [])),
242
+ "user_id": user_id,
243
+ "session_id": session_id,
244
+ "replace_last_message": temp_data.get("replace_last_message", False),
245
+ }
246
+ )
247
+
248
+ logger.info("✅ Fallback response built", action=response.action)
249
+
250
+ return response
251
+
 
 
252
  except HTTPException:
253
  raise
254
  except Exception as e:
255
+ logger.error("Chat endpoint critical error", exc_info=e)
256
+ return AgentResponse(
257
+ success=False,
258
+ text="An unexpected error occurred. Please try again.",
259
+ action="error",
260
+ error=str(e),
261
+ )
262
 
263
 
264
+ # ============================================================
265
+ # HEALTH CHECK
266
+ # ============================================================
267
 
268
  @router.get("/health")
269
+ async def health_check() -> Dict[str, Any]:
270
+ """Health check for AIDA chat service"""
271
+
272
+ try:
273
+ graph = get_aida_graph()
274
+
275
+ return {
276
+ "status": "healthy",
277
+ "service": "AIDA Chat (LangGraph)",
278
+ "version": "2.0.0",
279
+ "graph_available": graph is not None,
280
+ "recursion_limit_config": "50",
281
+ "timestamp": datetime.utcnow().isoformat(),
282
+ }
283
+ except Exception as e:
284
+ logger.error("❌ Health check failed", exc_info=e)
285
+ return {
286
+ "status": "unhealthy",
287
+ "error": str(e),
288
+ "timestamp": datetime.utcnow().isoformat(),
289
+ }
290
 
291
 
292
+ # ============================================================
293
+ # HISTORY ENDPOINT
294
+ # ============================================================
295
 
296
  @router.get("/history/{session_id}")
297
+ async def get_history(session_id: str) -> Dict[str, Any]:
298
+ """Get conversation history for a session"""
299
+
 
 
300
  try:
301
+ logger.info("📖 History requested", session_id=session_id)
302
+
 
 
 
 
 
 
303
  return {
304
  "success": True,
305
+ "session_id": session_id,
306
+ "messages": [],
307
+ "note": "History persistence not yet implemented"
308
  }
309
+
 
310
  except Exception as e:
311
+ logger.error(" History retrieval error", exc_info=e)
312
+ return {
313
+ "success": False,
314
+ "error": str(e),
315
+ }
316
 
317
 
318
+ # ============================================================
319
+ # SESSION MANAGEMENT
320
+ # ============================================================
321
 
322
+ @router.post("/reset-session/{session_id}")
323
+ async def reset_session(session_id: str) -> Dict[str, Any]:
324
+ """Reset a session to fresh state"""
325
+
 
 
326
  try:
327
+ logger.info("🔄 Session reset requested", session_id=session_id)
328
+
329
+ return {
330
+ "success": True,
331
+ "message": "Session reset to fresh state",
332
+ "session_id": session_id,
333
+ "timestamp": datetime.utcnow().isoformat(),
334
+ }
335
+
 
 
336
  except Exception as e:
337
+ logger.error(" Session reset error", exc_info=e)
338
+ return {
339
+ "success": False,
340
+ "error": str(e),
341
+ }
342
 
343
 
344
+ @router.post("/close-session/{session_id}")
345
+ async def close_session(session_id: str) -> Dict[str, Any]:
346
+ """Close a session"""
347
+
 
 
348
  try:
349
+ logger.info("❌ Session closed", session_id=session_id)
350
+
 
 
 
 
 
 
 
 
 
351
  return {
352
  "success": True,
353
+ "message": "Session closed",
354
+ "session_id": session_id,
355
+ "timestamp": datetime.utcnow().isoformat(),
356
  }
357
+
358
+ except Exception as e:
359
+ logger.error("❌ Session close error", exc_info=e)
360
+ return {
361
+ "success": False,
362
+ "error": str(e),
363
+ }
364
+
365
+
366
+ # ============================================================
367
+ # IMAGE UPLOAD ENDPOINTS (For Cloudflare Worker Integration)
368
+ # ============================================================
369
+
370
+ class ImageNameRequest(BaseModel):
371
+ """Request for getting image name from current listing context"""
372
+ user_id: str
373
+ session_id: str
374
+
375
+
376
+ class ImageUploadResult(BaseModel):
377
+ """Result from Cloudflare Worker image upload"""
378
+ success: bool
379
+ url: Optional[str] = None
380
+ id: Optional[str] = None
381
+ filename: Optional[str] = None
382
+ error: Optional[str] = None
383
+ reason: Optional[str] = None
384
+ message: Optional[str] = None # User's original message
385
+ operation: Optional[str] = "add" # "add" or "replace"
386
+ replace_index: Optional[int] = None
387
+ user_id: Optional[str] = None
388
+ session_id: Optional[str] = None
389
+
390
+
391
+ @router.post("/get-image-name")
392
+ async def get_image_name(body: ImageNameRequest) -> Dict[str, Any]:
393
+ """
394
+ Get the current listing title for image naming.
395
+ Called by Cloudflare Worker when uploading new images.
396
+ """
397
+ try:
398
+ graph = get_aida_graph()
399
+ config = {"configurable": {"thread_id": body.session_id}}
400
+
401
+ # Get current state
402
+ state = graph.get_state(config)
403
+
404
+ if state and state.values:
405
+ listing_draft = state.values.get("listing_draft", {})
406
+ title = listing_draft.get("title") if listing_draft else None
407
+
408
+ if title:
409
+ # Clean title for filename
410
+ clean_name = title.lower().replace(" ", "-").replace("'", "")
411
+ return {"success": True, "name": clean_name}
412
+
413
+ # Fallback to timestamp-based name
414
+ return {"success": True, "name": f"property-{int(datetime.utcnow().timestamp())}"}
415
+
416
+ except Exception as e:
417
+ logger.error("Failed to get image name", exc_info=e)
418
+ return {"success": False, "name": f"property-{int(datetime.utcnow().timestamp())}"}
419
+
420
+
421
+ @router.post("/image-upload-result")
422
+ async def handle_image_upload_result(body: ImageUploadResult) -> AgentResponse:
423
+ """
424
+ Handle the result from Cloudflare Worker image upload.
425
+ If success: Process the image with user's command
426
+ If error: Generate friendly error message via AIDA
427
+ """
428
+ try:
429
+ session_id = body.session_id or str(uuid4())
430
+ user_id = body.user_id or f"anonymous_{uuid4()}"
431
+
432
+ if body.success and body.url:
433
+ # Image validated and uploaded - combine with user message
434
+ user_message = body.message or ""
435
+ if body.url not in user_message:
436
+ user_message = f"{user_message} {body.url}".strip()
437
+
438
+ # Add operation context if replacing
439
+ if body.operation == "replace" and body.replace_index:
440
+ if "replace" not in user_message.lower():
441
+ user_message = f"Replace image {body.replace_index} with {body.url}"
442
+
443
+ # Send to AIDA for processing
444
+ ask_body = AskBody(
445
+ message=user_message,
446
+ session_id=session_id,
447
+ user_id=user_id,
448
+ user_role="landlord"
449
+ )
450
+ return await ask(ask_body)
451
+
452
+ else:
453
+ # Image rejected - generate friendly error via AIDA
454
+ error_type = body.error or "unknown"
455
+ reason = body.reason or ""
456
+
457
+ if error_type == "not_property_image":
458
+ error_message = f"[IMAGE_REJECTED] User tried to upload an image that doesn't appear to be a property photo. Reason: {reason}. Generate a friendly message asking them to upload a proper property image."
459
+ else:
460
+ error_message = f"[IMAGE_ERROR] Failed to upload image: {error_type}. Generate a friendly error message."
461
+
462
+ # Send error context to AIDA
463
+ ask_body = AskBody(
464
+ message=error_message,
465
+ session_id=session_id,
466
+ user_id=user_id,
467
+ user_role="landlord"
468
+ )
469
+ return await ask(ask_body)
470
+
471
  except Exception as e:
472
+ logger.error("Image upload result handling error", exc_info=e)
473
+ return AgentResponse(
474
+ success=False,
475
+ text="Sorry, there was an issue processing your image. Please try again.",
476
+ action="error",
477
+ error=str(e)
478
+ )
app/ai/routes/chat_refactored.py DELETED
@@ -1,346 +0,0 @@
1
- # app/ai/routes/chat_refactored.py
2
- """
3
- AIDA Chat Endpoint - LangGraph Powered (PRIMARY - v1)
4
- FIXED: Properly handles recursion limit and dict output from graph.ainvoke()
5
- """
6
-
7
- from fastapi import APIRouter, HTTPException
8
- from pydantic import BaseModel
9
- from typing import Optional, Dict, Any
10
- from structlog import get_logger
11
- from uuid import uuid4
12
- from datetime import datetime
13
- from langgraph.types import Command
14
-
15
- from app.ai.agent.graph import get_aida_graph
16
- from app.ai.agent.schemas import AgentResponse
17
-
18
- logger = get_logger(__name__)
19
-
20
- router = APIRouter()
21
-
22
-
23
- # ============================================================
24
- # REQUEST/RESPONSE MODELS
25
- # ============================================================
26
-
27
- class AskBody(BaseModel):
28
- """Request body for /ask endpoint"""
29
- message: str
30
- session_id: Optional[str] = None
31
- user_id: Optional[str] = None
32
- user_role: Optional[str] = "renter"
33
- start_new_session: Optional[bool] = False
34
-
35
-
36
- # ============================================================
37
- # MAIN CHAT ENDPOINT - LANGGRAPH POWERED (FIXED)
38
- # ============================================================
39
-
40
- @router.post("/ask", response_model=AgentResponse)
41
- async def ask_ai(body: AskBody) -> AgentResponse:
42
- """
43
- Main chat endpoint using LangGraph state machine.
44
-
45
- CRITICAL FIXES:
46
- - Set recursion_limit to 50 (prevents infinite loops)
47
- - graph.ainvoke() returns a DICT, not AgentState object
48
- - Access dict keys with ['key'], not .attribute
49
- - Extract response from dict['temp_data']['final_response']
50
-
51
- Flow:
52
- 1. Validate input
53
- 2. Build input dict
54
- 3. Invoke graph with dict input and high recursion_limit
55
- 4. Extract final_response from returned dict
56
- 5. Return to client
57
- """
58
-
59
- logger.info("🚀 Chat request received", message_len=len(body.message))
60
-
61
- try:
62
- # ============================================================
63
- # STEP 1: Validate input
64
- # ============================================================
65
-
66
- if not body.message or not body.message.strip():
67
- logger.warning("❌ Empty message received")
68
- return AgentResponse(
69
- success=False,
70
- text="Please provide a message.",
71
- action="error",
72
- error="Empty message",
73
- )
74
-
75
- message = body.message.strip()
76
- session_id = body.session_id or str(uuid4())
77
- user_id = body.user_id or f"anonymous_{uuid4()}"
78
- user_role = body.user_role or "renter"
79
-
80
- logger.info(
81
- "📋 User session info",
82
- user_id=user_id,
83
- session_id=session_id,
84
- user_role=user_role,
85
- message_len=len(message),
86
- )
87
-
88
- # ============================================================
89
- # STEP 2: Build input dict for graph
90
- # ============================================================
91
- # ✅ CRITICAL: Pass dict, not AgentState
92
-
93
- input_dict = {
94
- "user_id": user_id,
95
- "session_id": session_id,
96
- "user_role": user_role,
97
- "last_user_message": message,
98
- "conversation_history": [],
99
- "language_detected": "en",
100
- "start_new_session": body.start_new_session or False,
101
- }
102
-
103
- logger.info("📦 Input dict prepared", keys=list(input_dict.keys()))
104
-
105
- # ============================================================
106
- # STEP 3: Get graph and validate
107
- # ============================================================
108
-
109
- try:
110
- graph = get_aida_graph()
111
-
112
- if graph is None:
113
- logger.error("❌ Graph is None!")
114
- return AgentResponse(
115
- success=False,
116
- text="System error: Graph not initialized",
117
- action="error",
118
- error="Graph initialization failed",
119
- )
120
-
121
- logger.info("✅ Graph retrieved successfully")
122
-
123
- except Exception as e:
124
- logger.error("❌ Graph retrieval failed", exc_info=e)
125
- return AgentResponse(
126
- success=False,
127
- text=f"System error: {str(e)}",
128
- action="error",
129
- error=str(e),
130
- )
131
-
132
- # ============================================================
133
- # STEP 4: Invoke graph with dict input
134
- # ============================================================
135
-
136
- logger.info("🔄 Invoking LangGraph...", user_id=user_id)
137
-
138
- try:
139
- # ✅ CRITICAL FIX: Pass recursion_limit config to prevent infinite loops
140
- # Default is 25, we set it to 50 to allow for longer flows
141
- final_state_dict = await graph.ainvoke(
142
- input_dict,
143
- config={"recursion_limit": 50} # ✅ FIX: High recursion limit
144
- )
145
-
146
- # ✅ CRITICAL: final_state_dict is a DICT, not AgentState!
147
- # Access with dict keys: ['key'], not .attribute
148
-
149
- logger.info(
150
- "✅ LangGraph execution completed",
151
- flow=final_state_dict.get("current_flow", {}).get("value") if isinstance(final_state_dict.get("current_flow"), dict) else str(final_state_dict.get("current_flow")),
152
- steps=final_state_dict.get("steps_taken"),
153
- has_error=final_state_dict.get("last_error") is not None,
154
- )
155
-
156
- except Exception as e:
157
- logger.error("❌ Graph execution failed", exc_info=e)
158
-
159
- # Check if it's a recursion error
160
- if "recursion" in str(e).lower():
161
- logger.error("⚠️ Graph hit recursion limit - check for infinite loops in listing_collect")
162
- return AgentResponse(
163
- success=False,
164
- text="System is processing your request too long. This usually means you're in the listing flow. Please try again or provide more details.",
165
- action="error",
166
- error="Recursion limit exceeded - infinite loop detected",
167
- )
168
-
169
- return AgentResponse(
170
- success=False,
171
- text="Error processing your request. Please try again.",
172
- action="error",
173
- error=str(e),
174
- )
175
-
176
- # ============================================================
177
- # STEP 5: Extract response from final state dict
178
- # ============================================================
179
-
180
- # ✅ Access dict['key'] not dict.key
181
- temp_data = final_state_dict.get("temp_data", {})
182
- final_response = temp_data.get("final_response")
183
-
184
- if final_response:
185
- logger.info(
186
- "✅ Response extracted from state",
187
- action=final_response.action if hasattr(final_response, 'action') else "unknown",
188
- success=final_response.success if hasattr(final_response, 'success') else False,
189
- )
190
- return final_response
191
-
192
- # ============================================================
193
- # FALLBACK: Build response manually if not in temp_data
194
- # ============================================================
195
-
196
- logger.warning("⚠️ No final_response in temp_data, building manually")
197
-
198
- response_text = temp_data.get("response_text", "")
199
- if not response_text:
200
- response_text = "I'm here to help! What would you like to do?"
201
-
202
- # Get flow state - handle both FlowState enum and string
203
- current_flow = final_state_dict.get("current_flow")
204
- if hasattr(current_flow, 'value'):
205
- flow_str = current_flow.value
206
- else:
207
- flow_str = str(current_flow)
208
-
209
- response = AgentResponse(
210
- success=final_state_dict.get("last_error") is None,
211
- text=response_text,
212
- action=temp_data.get("action", flow_str),
213
- state={
214
- "flow": flow_str,
215
- "steps": final_state_dict.get("steps_taken", 0),
216
- "errors": final_state_dict.get("error_count", 0),
217
- },
218
- draft=temp_data.get("draft"),
219
- draft_ui=temp_data.get("draft_ui"),
220
- error=final_state_dict.get("last_error"),
221
- metadata={
222
- "intent": final_state_dict.get("intent_type"),
223
- "intent_confidence": final_state_dict.get("intent_confidence", 0),
224
- "language": final_state_dict.get("language_detected", "en"),
225
- "messages_in_session": len(final_state_dict.get("conversation_history", [])),
226
- "user_id": user_id,
227
- "session_id": session_id,
228
- }
229
- )
230
-
231
- logger.info("✅ Fallback response built", action=response.action)
232
-
233
- return response
234
-
235
- except HTTPException:
236
- raise
237
- except Exception as e:
238
- logger.error("❌ Chat endpoint critical error", exc_info=e)
239
- return AgentResponse(
240
- success=False,
241
- text="An unexpected error occurred. Please try again.",
242
- action="error",
243
- error=str(e),
244
- )
245
-
246
-
247
- # ============================================================
248
- # HEALTH CHECK
249
- # ============================================================
250
-
251
- @router.get("/health")
252
- async def health_check() -> Dict[str, Any]:
253
- """Health check for AIDA chat service"""
254
-
255
- try:
256
- graph = get_aida_graph()
257
-
258
- return {
259
- "status": "healthy",
260
- "service": "AIDA Chat (LangGraph)",
261
- "version": "2.0.0",
262
- "graph_available": graph is not None,
263
- "recursion_limit_config": "50",
264
- "timestamp": datetime.utcnow().isoformat(),
265
- }
266
- except Exception as e:
267
- logger.error("❌ Health check failed", exc_info=e)
268
- return {
269
- "status": "unhealthy",
270
- "error": str(e),
271
- "timestamp": datetime.utcnow().isoformat(),
272
- }
273
-
274
-
275
- # ============================================================
276
- # HISTORY ENDPOINT
277
- # ============================================================
278
-
279
- @router.get("/history/{session_id}")
280
- async def get_history(session_id: str) -> Dict[str, Any]:
281
- """Get conversation history for a session"""
282
-
283
- try:
284
- logger.info("📖 History requested", session_id=session_id)
285
-
286
- return {
287
- "success": True,
288
- "session_id": session_id,
289
- "messages": [],
290
- "note": "History persistence not yet implemented"
291
- }
292
-
293
- except Exception as e:
294
- logger.error("❌ History retrieval error", exc_info=e)
295
- return {
296
- "success": False,
297
- "error": str(e),
298
- }
299
-
300
-
301
- # ============================================================
302
- # SESSION MANAGEMENT
303
- # ============================================================
304
-
305
- @router.post("/reset-session/{session_id}")
306
- async def reset_session(session_id: str) -> Dict[str, Any]:
307
- """Reset a session to fresh state"""
308
-
309
- try:
310
- logger.info("🔄 Session reset requested", session_id=session_id)
311
-
312
- return {
313
- "success": True,
314
- "message": "Session reset to fresh state",
315
- "session_id": session_id,
316
- "timestamp": datetime.utcnow().isoformat(),
317
- }
318
-
319
- except Exception as e:
320
- logger.error("❌ Session reset error", exc_info=e)
321
- return {
322
- "success": False,
323
- "error": str(e),
324
- }
325
-
326
-
327
- @router.post("/close-session/{session_id}")
328
- async def close_session(session_id: str) -> Dict[str, Any]:
329
- """Close a session"""
330
-
331
- try:
332
- logger.info("❌ Session closed", session_id=session_id)
333
-
334
- return {
335
- "success": True,
336
- "message": "Session closed",
337
- "session_id": session_id,
338
- "timestamp": datetime.utcnow().isoformat(),
339
- }
340
-
341
- except Exception as e:
342
- logger.error("❌ Session close error", exc_info=e)
343
- return {
344
- "success": False,
345
- "error": str(e),
346
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/ai/services/__pycache__/search_service.cpython-313.pyc ADDED
Binary file (13.5 kB). View file
 
app/ai/services/search_service.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/services/search_service.py
2
+ """
3
+ Hybrid Search Service - Combines Qdrant vector search with payload filters
4
+ for intelligent natural language property search.
5
+
6
+ Features:
7
+ - LLM-based query parameter extraction
8
+ - Location-to-currency inference
9
+ - Amenity normalization with aliases
10
+ - Qdrant hybrid search (vector + filters)
11
+ """
12
+
13
+ import os
14
+ import httpx
15
+ from typing import Dict, List, Optional, Tuple, Any
16
+ from structlog import get_logger
17
+ from qdrant_client.models import Filter, FieldCondition, MatchValue, MatchAny, Range
18
+
19
+ from app.ai.config import qdrant_client
20
+ from app.config import settings
21
+
22
+ logger = get_logger(__name__)
23
+
24
+ # ============================================================
25
+ # CONFIGURATION
26
+ # ============================================================
27
+
28
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
29
+ EMBED_MODEL = "qwen/qwen3-embedding-8b"
30
+ VECTOR_SIZE = 4096
31
+ COLLECTION_NAME = "listings"
32
+
33
+ # ============================================================
34
+ # AMENITY ALIASES - Map common variations to canonical names
35
+ # ============================================================
36
+
37
+ AMENITY_ALIASES = {
38
+ "wifi": ["wifi", "wi-fi", "internet", "wireless", "connexion"],
39
+ "balcony": ["balcony", "terrace", "patio", "balcon"],
40
+ "parking": ["parking", "garage", "car park", "stationnement"],
41
+ "pool": ["pool", "swimming", "swimming pool", "piscine"],
42
+ "gym": ["gym", "fitness", "workout", "salle de sport"],
43
+ "security": ["security", "guard", "sécurité", "gardien"],
44
+ "furnished": ["furnished", "meublé", "meuble"],
45
+ "air conditioning": ["air conditioning", "ac", "climatisation", "clim"],
46
+ "kitchen": ["kitchen", "cuisine"],
47
+ "laundry": ["laundry", "washing", "laverie", "lave-linge"],
48
+ "garden": ["garden", "jardin", "yard"],
49
+ "elevator": ["elevator", "lift", "ascenseur"],
50
+ "hot water": ["hot water", "eau chaude"],
51
+ "tv cable": ["tv cable", "cable tv", "tv", "television"],
52
+ "heating": ["heating", "chauffage"],
53
+ }
54
+
55
+
56
+ # ============================================================
57
+ # EMBEDDING FUNCTION
58
+ # ============================================================
59
+
60
+ async def embed_query(text: str) -> Optional[List[float]]:
61
+ """
62
+ Create embedding for a search query using OpenRouter.
63
+
64
+ Args:
65
+ text: Query text to embed
66
+
67
+ Returns:
68
+ 4096-dim embedding vector or None if error
69
+ """
70
+
71
+ if not OPENROUTER_API_KEY:
72
+ logger.warning("OpenRouter API key not set, cannot embed query")
73
+ return None
74
+
75
+ try:
76
+ async with httpx.AsyncClient(timeout=30) as client:
77
+ payload = {
78
+ "model": EMBED_MODEL,
79
+ "input": text,
80
+ "encoding_format": "float"
81
+ }
82
+ headers = {
83
+ "Authorization": f"Bearer {OPENROUTER_API_KEY}",
84
+ "Content-Type": "application/json",
85
+ }
86
+
87
+ response = await client.post(
88
+ "https://openrouter.ai/api/v1/embeddings",
89
+ json=payload,
90
+ headers=headers
91
+ )
92
+ response.raise_for_status()
93
+
94
+ data = response.json()
95
+ embedding = data["data"][0]["embedding"]
96
+
97
+ logger.info("Query embedded successfully", vector_dim=len(embedding))
98
+ return embedding
99
+
100
+ except Exception as e:
101
+ logger.error("Embedding failed", error=str(e))
102
+ return None
103
+
104
+
105
+ # ============================================================
106
+ # CURRENCY INFERENCE
107
+ # ============================================================
108
+
109
+ async def infer_currency_from_location(location: str) -> Tuple[str, float]:
110
+ """
111
+ Infer currency code from location using CurrencyManager.
112
+
113
+ Args:
114
+ location: Location string (e.g., "Calavi", "Lagos")
115
+
116
+ Returns:
117
+ Tuple of (currency_code, confidence)
118
+ """
119
+
120
+ if not location:
121
+ return "XOF", 0.0 # Default for Benin
122
+
123
+ try:
124
+ from app.ml.models.ml_listing_extractor import get_ml_extractor
125
+
126
+ ml = get_ml_extractor()
127
+ currency, country, city, confidence = await ml.currency_mgr.get_currency_for_location(location)
128
+
129
+ if currency:
130
+ logger.info(
131
+ "Currency inferred from location",
132
+ location=location,
133
+ currency=currency,
134
+ country=country,
135
+ confidence=confidence
136
+ )
137
+ return currency, confidence
138
+
139
+ except Exception as e:
140
+ logger.warning(f"Currency inference failed: {e}")
141
+
142
+ # Fallback to known cities
143
+ location_lower = location.lower()
144
+
145
+ CITY_CURRENCY_MAP = {
146
+ # Benin
147
+ "cotonou": "XOF", "calavi": "XOF", "porto-novo": "XOF",
148
+ "abomey": "XOF", "parakou": "XOF", "bohicon": "XOF",
149
+ # Nigeria
150
+ "lagos": "NGN", "abuja": "NGN", "ibadan": "NGN",
151
+ "kano": "NGN", "port harcourt": "NGN",
152
+ # Ghana
153
+ "accra": "GHS", "kumasi": "GHS",
154
+ # Senegal
155
+ "dakar": "XOF",
156
+ # Ivory Coast
157
+ "abidjan": "XOF",
158
+ # Other
159
+ "nairobi": "KES", "kampala": "UGX",
160
+ "johannesburg": "ZAR", "cape town": "ZAR",
161
+ "london": "GBP", "paris": "EUR",
162
+ "new york": "USD", "dubai": "AED",
163
+ }
164
+
165
+ for city, currency in CITY_CURRENCY_MAP.items():
166
+ if city in location_lower:
167
+ logger.info("Currency from fallback map", location=location, currency=currency)
168
+ return currency, 0.8
169
+
170
+ logger.warning("Currency not detected, using default XOF", location=location)
171
+ return "XOF", 0.5
172
+
173
+
174
+ # ============================================================
175
+ # AMENITY NORMALIZATION
176
+ # ============================================================
177
+
178
+ def normalize_amenities(amenities: List[str]) -> List[str]:
179
+ """
180
+ Normalize amenity names to canonical forms for database matching.
181
+
182
+ Args:
183
+ amenities: List of user-mentioned amenities
184
+
185
+ Returns:
186
+ List of normalized amenity names
187
+ """
188
+
189
+ normalized = []
190
+
191
+ for amenity in amenities:
192
+ amenity_lower = amenity.lower().strip()
193
+ found = False
194
+
195
+ # Check if this matches any known alias
196
+ for canonical, aliases in AMENITY_ALIASES.items():
197
+ if amenity_lower in aliases:
198
+ normalized.append(canonical)
199
+ found = True
200
+ break
201
+
202
+ # If not found in aliases, use as-is
203
+ if not found:
204
+ normalized.append(amenity_lower)
205
+
206
+ # Remove duplicates while preserving order
207
+ seen = set()
208
+ unique = []
209
+ for item in normalized:
210
+ if item not in seen:
211
+ seen.add(item)
212
+ unique.append(item)
213
+
214
+ logger.info("Amenities normalized", original=amenities, normalized=unique)
215
+ return unique
216
+
217
+
218
+ # ============================================================
219
+ # HYBRID SEARCH
220
+ # ============================================================
221
+
222
+ async def hybrid_search(
223
+ query_text: str,
224
+ search_params: Dict[str, Any],
225
+ limit: int = 10
226
+ ) -> List[Dict]:
227
+ """
228
+ Perform hybrid search: vector similarity + payload filters.
229
+
230
+ Args:
231
+ query_text: Original user query for semantic search
232
+ search_params: Extracted search parameters (location, price, amenities, etc.)
233
+ limit: Maximum results to return
234
+
235
+ Returns:
236
+ List of matching listings sorted by relevance
237
+ """
238
+
239
+ logger.info("Starting hybrid search", query=query_text[:50], params_keys=list(search_params.keys()))
240
+
241
+ if not qdrant_client:
242
+ logger.error("Qdrant client not available")
243
+ return []
244
+
245
+ # ============================================================
246
+ # STEP 1: Build filter conditions
247
+ # ============================================================
248
+
249
+ filter_conditions = []
250
+
251
+ # Location filter (exact match on lowercase - uses KEYWORD index)
252
+ if search_params.get("location"):
253
+ location_lower = search_params["location"].lower()
254
+ filter_conditions.append(
255
+ FieldCondition(
256
+ key="location_lower",
257
+ match=MatchValue(value=location_lower)
258
+ )
259
+ )
260
+ logger.info("Added location filter", location=location_lower)
261
+
262
+ # Max price filter
263
+ if search_params.get("max_price"):
264
+ filter_conditions.append(
265
+ FieldCondition(
266
+ key="price",
267
+ range=Range(lte=float(search_params["max_price"]))
268
+ )
269
+ )
270
+ logger.info("Added max_price filter", max_price=search_params["max_price"])
271
+
272
+ # Min price filter
273
+ if search_params.get("min_price"):
274
+ filter_conditions.append(
275
+ FieldCondition(
276
+ key="price",
277
+ range=Range(gte=float(search_params["min_price"]))
278
+ )
279
+ )
280
+ logger.info("Added min_price filter", min_price=search_params["min_price"])
281
+
282
+ # Bedrooms filter
283
+ if search_params.get("bedrooms"):
284
+ filter_conditions.append(
285
+ FieldCondition(
286
+ key="bedrooms",
287
+ range=Range(gte=int(search_params["bedrooms"]))
288
+ )
289
+ )
290
+ logger.info("Added bedrooms filter", bedrooms=search_params["bedrooms"])
291
+
292
+ # Bathrooms filter
293
+ if search_params.get("bathrooms"):
294
+ filter_conditions.append(
295
+ FieldCondition(
296
+ key="bathrooms",
297
+ range=Range(gte=int(search_params["bathrooms"]))
298
+ )
299
+ )
300
+ logger.info("Added bathrooms filter", bathrooms=search_params["bathrooms"])
301
+
302
+ # Listing type filter
303
+ if search_params.get("listing_type"):
304
+ filter_conditions.append(
305
+ FieldCondition(
306
+ key="listing_type_lower",
307
+ match=MatchValue(value=search_params["listing_type"].lower())
308
+ )
309
+ )
310
+ logger.info("Added listing_type filter", listing_type=search_params["listing_type"])
311
+
312
+ # Price type filter (monthly, weekly, etc.)
313
+ if search_params.get("price_type"):
314
+ filter_conditions.append(
315
+ FieldCondition(
316
+ key="price_type_lower",
317
+ match=MatchValue(value=search_params["price_type"].lower())
318
+ )
319
+ )
320
+ logger.info("Added price_type filter", price_type=search_params["price_type"])
321
+
322
+ # Amenities filter - ALL must match
323
+ if search_params.get("amenities"):
324
+ normalized = normalize_amenities(search_params["amenities"])
325
+ for amenity in normalized:
326
+ filter_conditions.append(
327
+ FieldCondition(
328
+ key="amenities",
329
+ match=MatchValue(value=amenity)
330
+ )
331
+ )
332
+ logger.info("Added amenities filter", amenities=normalized)
333
+
334
+ # ============================================================
335
+ # STEP 2: Build query filter
336
+ # ============================================================
337
+
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
345
+ # ============================================================
346
+
347
+ query_vector = await embed_query(query_text)
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,
355
+ scroll_filter=query_filter,
356
+ limit=limit,
357
+ with_payload=True
358
+ )
359
+ return [point.payload for point in results]
360
+ except Exception as e:
361
+ logger.error("Filter-only search failed", error=str(e))
362
+ return []
363
+
364
+ # ============================================================
365
+ # STEP 4: Execute 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,
373
+ query_filter=query_filter,
374
+ limit=limit,
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
388
+
389
+ except Exception as e:
390
+ logger.error("Hybrid search failed", error=str(e))
391
+ return []
392
+
393
+
394
+ # ============================================================
395
+ # MAIN SEARCH FUNCTION (Public API)
396
+ # ============================================================
397
+
398
+ async def search_listings_hybrid(
399
+ user_query: str,
400
+ search_params: Dict[str, Any],
401
+ limit: int = 10
402
+ ) -> Tuple[List[Dict], str]:
403
+ """
404
+ Main entry point for hybrid property search.
405
+
406
+ Args:
407
+ user_query: Original natural language query
408
+ search_params: Extracted search parameters
409
+ limit: Max results
410
+
411
+ Returns:
412
+ Tuple of (listings, inferred_currency)
413
+ """
414
+
415
+ # Infer currency from location
416
+ currency = "XOF" # Default
417
+ if search_params.get("location"):
418
+ currency, confidence = await infer_currency_from_location(search_params["location"])
419
+ logger.info("Currency for search", currency=currency, confidence=confidence)
420
+
421
+ # Perform hybrid search
422
+ results = await hybrid_search(
423
+ query_text=user_query,
424
+ search_params=search_params,
425
+ limit=limit
426
+ )
427
+
428
+ return results, currency
app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc CHANGED
Binary files a/app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc and b/app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc differ
 
app/ai/tools/__pycache__/greeting_tool.cpython-313.pyc ADDED
Binary file (9.11 kB). View file
 
app/ai/tools/__pycache__/intent_detector_tool.cpython-313.pyc ADDED
Binary file (9.28 kB). View file
 
app/ai/tools/__pycache__/listing_conversation_manager.cpython-313.pyc ADDED
Binary file (5.82 kB). View file
 
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