Spaces:
Running
Running
Commit ·
4b85c5e
1
Parent(s): 80b923f
fyp
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- HOW_TO_RUN.md +58 -0
- RUN_TEST_UI.md +59 -0
- __pycache__/main.cpython-313.pyc +0 -0
- agent_workflow.png +0 -0
- app/ai/__pycache__/config.cpython-313.pyc +0 -0
- app/ai/agent/__pycache__/graph.cpython-313.pyc +0 -0
- app/ai/agent/__pycache__/schemas.cpython-313.pyc +0 -0
- app/ai/agent/__pycache__/state.cpython-313.pyc +0 -0
- app/ai/agent/graph.py +73 -15
- app/ai/agent/nodes/__pycache__/authenticate.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/edit_listing.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/my_listings.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/validate_output.cpython-313.pyc +0 -0
- app/ai/agent/nodes/authenticate.py +19 -3
- app/ai/agent/nodes/casual_chat.py +9 -5
- app/ai/agent/nodes/classify_intent.py +47 -5
- app/ai/agent/nodes/edit_listing.py +194 -0
- app/ai/agent/nodes/greeting.py +10 -7
- app/ai/agent/nodes/listing_collect.py +296 -76
- app/ai/agent/nodes/listing_publish.py +113 -33
- app/ai/agent/nodes/listing_validate.py +127 -25
- app/ai/agent/nodes/my_listings.py +94 -0
- app/ai/agent/nodes/respond.py +19 -1
- app/ai/agent/nodes/search_query.py +203 -74
- app/ai/agent/nodes/validate_output.py +39 -0
- app/ai/agent/schemas.py +4 -2
- app/ai/agent/state.py +28 -0
- app/ai/memory/__pycache__/redis_context_memory.cpython-313.pyc +0 -0
- app/ai/memory/__pycache__/redis_memory.cpython-313.pyc +0 -0
- app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc +0 -0
- app/ai/prompts/system_prompt.py +74 -9
- app/ai/routes/__pycache__/chat.cpython-313.pyc +0 -0
- app/ai/routes/__pycache__/chat_refactored.cpython-313.pyc +0 -0
- app/ai/routes/chat.py +423 -428
- app/ai/routes/chat_refactored.py +0 -346
- app/ai/services/__pycache__/search_service.cpython-313.pyc +0 -0
- app/ai/services/search_service.py +428 -0
- app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc +0 -0
- app/ai/tools/__pycache__/greeting_tool.cpython-313.pyc +0 -0
- app/ai/tools/__pycache__/intent_detector_tool.cpython-313.pyc +0 -0
- app/ai/tools/__pycache__/listing_conversation_manager.cpython-313.pyc +0 -0
- 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 (
|
| 79 |
if state.temp_data.get("action") == "show_example":
|
| 80 |
-
logger.info("Showing example,
|
| 81 |
-
return "
|
| 82 |
|
| 83 |
-
# ✅ Check if asking for fields (
|
| 84 |
if state.temp_data.get("action") in ["asking_field", "asking_first_field", "asking_optional"]:
|
| 85 |
-
logger.info("Asking for fields,
|
| 86 |
-
return "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 97 |
-
return "
|
| 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("✅
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# ✅
|
| 40 |
-
state.user_role
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 147 |
# ============================================================
|
| 148 |
|
| 149 |
-
success, error = state.transition_to(FlowState.
|
| 150 |
|
| 151 |
if not success:
|
| 152 |
-
logger.error("Transition to
|
| 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.
|
| 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. "
|
| 39 |
-
5. "
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
"""
|
| 148 |
|
| 149 |
-
logger.info("
|
| 150 |
user_id=state.user_id,
|
| 151 |
-
|
| 152 |
|
| 153 |
try:
|
| 154 |
-
# ✅
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
return state
|
| 160 |
|
| 161 |
-
#
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
)
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
# ✅ STEP 3: Extract fields
|
| 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 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
-
# ✅
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
for f in required_fields
|
| 204 |
-
)
|
| 205 |
|
| 206 |
-
if
|
| 207 |
-
logger.info("
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
-
|
| 215 |
-
missing_required = [f for f in required_fields if state.provided_fields.get(f) is None]
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 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 |
-
#
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
|
| 234 |
-
|
| 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 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
|
| 248 |
return state
|
| 249 |
|
| 250 |
except Exception as e:
|
| 251 |
-
logger.error("
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 92 |
# ============================================================
|
| 93 |
|
| 94 |
try:
|
| 95 |
db = await get_db()
|
| 96 |
-
|
| 97 |
|
| 98 |
-
|
| 99 |
-
raise ValueError("Insert returned no ID")
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
except Exception as e:
|
| 105 |
-
logger.error("MongoDB
|
| 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
|
| 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 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 209 |
-
state.
|
|
|
|
|
|
|
|
|
|
| 210 |
state.temp_data["draft_ui"] = draft_ui
|
| 211 |
state.temp_data["action"] = "show_draft"
|
| 212 |
|
| 213 |
-
#
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
-
|
| 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 |
-
|
| 227 |
-
📌 **Requirements:** {draft.requirements if draft.requirements else 'None'}
|
| 228 |
-
📷 **Images:** {len(draft.images)} uploaded
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
| 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 = """
|
| 29 |
|
| 30 |
User message: "{user_message}"
|
| 31 |
|
| 32 |
-
Extract
|
| 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:
|
| 37 |
-
- bathrooms:
|
| 38 |
-
- listing_type:
|
| 39 |
-
-
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
-
|
| 43 |
-
- "
|
| 44 |
-
- "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
"""
|
| 163 |
-
|
| 164 |
|
| 165 |
Args:
|
| 166 |
listings: List of matching listings
|
| 167 |
-
search_params: Original search parameters
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
Returns:
|
| 170 |
-
|
| 171 |
"""
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 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 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
"""
|
|
|
|
|
|
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
| 208 |
)
|
| 209 |
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 220 |
-
3.
|
| 221 |
-
4.
|
| 222 |
-
5. Transition to
|
| 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 |
-
"- \"
|
| 249 |
-
"- \"
|
| 250 |
-
"- \"Short-stay rentals with
|
| 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
|
| 259 |
# ============================================================
|
| 260 |
|
| 261 |
-
results = await
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
# ============================================================
|
| 266 |
-
# STEP 3:
|
| 267 |
# ============================================================
|
| 268 |
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
-
logger.info("
|
| 272 |
|
| 273 |
# ============================================================
|
| 274 |
-
# STEP
|
| 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
|
| 283 |
# ============================================================
|
| 284 |
|
| 285 |
-
|
|
|
|
| 286 |
|
| 287 |
if not success:
|
| 288 |
-
logger.error("Transition to
|
| 289 |
state.set_error(error, should_retry=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
logger.info(
|
| 292 |
-
"
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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:
|
| 142 |
Once all required fields complete:
|
| 143 |
-
-
|
| 144 |
-
-
|
| 145 |
-
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
from fastapi import APIRouter,
|
| 4 |
-
from fastapi.security import HTTPBearer
|
| 5 |
from pydantic import BaseModel
|
| 6 |
-
from typing import Optional, Dict, Any
|
| 7 |
from structlog import get_logger
|
| 8 |
-
from
|
| 9 |
-
import
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
class AskBody(BaseModel):
|
|
|
|
| 30 |
message: str
|
| 31 |
session_id: Optional[str] = None
|
| 32 |
-
|
|
|
|
| 33 |
start_new_session: Optional[bool] = False
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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 |
-
|
| 72 |
-
|
| 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 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
"""
|
|
|
|
|
|
|
|
|
|
| 122 |
try:
|
| 123 |
-
#
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
)
|
| 294 |
-
|
| 295 |
-
#
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
)
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
state=context,
|
| 314 |
)
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
"
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
)
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
draft_ui=result.get("draft_ui"),
|
| 345 |
-
error=result.get("error")
|
| 346 |
)
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
else:
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
)
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
)
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
|
| 387 |
|
| 388 |
-
# ==========
|
|
|
|
|
|
|
| 389 |
|
| 390 |
@router.get("/health")
|
| 391 |
-
async def health_check():
|
| 392 |
-
"""Health check for chat service"""
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
|
| 402 |
-
# ==========
|
|
|
|
|
|
|
| 403 |
|
| 404 |
@router.get("/history/{session_id}")
|
| 405 |
-
async def
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
):
|
| 409 |
-
"""Get chat history for a session"""
|
| 410 |
try:
|
| 411 |
-
|
| 412 |
-
|
| 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 |
-
"
|
| 422 |
-
"messages":
|
|
|
|
| 423 |
}
|
| 424 |
-
|
| 425 |
-
raise
|
| 426 |
except Exception as e:
|
| 427 |
-
logger.error("
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
|
| 431 |
-
# ==========
|
|
|
|
|
|
|
| 432 |
|
| 433 |
-
@router.post("/
|
| 434 |
-
async def
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
):
|
| 438 |
-
"""Close/clear a chat session"""
|
| 439 |
try:
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
except HTTPException:
|
| 450 |
-
raise
|
| 451 |
except Exception as e:
|
| 452 |
-
logger.error("
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
|
| 456 |
-
@router.post("/
|
| 457 |
-
async def
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
):
|
| 461 |
-
"""Explicitly reset a session to fresh state"""
|
| 462 |
try:
|
| 463 |
-
|
| 464 |
-
|
| 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
|
| 477 |
-
"
|
|
|
|
| 478 |
}
|
| 479 |
-
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
except Exception as e:
|
| 482 |
-
logger.error("
|
| 483 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|