zenaight commited on
Commit ·
42bd84b
1
Parent(s): 217b7cf
Update AI chat functionality to improve property recommendation responses
Browse files- Modified the system message in `chat_with_session_memory` to allow sharing of listing URLs and additional property details, enhancing user interaction.
- Added a `search_status_message` to provide context on search results and fallback responses when specific property details are unavailable.
- Removed debug print statements from various functions to streamline the code and improve readability, ensuring a cleaner implementation.
- Enhanced the property search logic to return relevant status messages, improving communication with users when exact matches are not found.
- ai_chat.py +34 -62
- database.py +1 -27
ai_chat.py
CHANGED
|
@@ -47,7 +47,7 @@ def chat_with_session_memory(state):
|
|
| 47 |
system_message = (
|
| 48 |
f"Hello {user_info.get('name','there')}! You are a helpful and concise property agent. "
|
| 49 |
"Always base property recommendations solely on listings in our database. "
|
| 50 |
-
"
|
| 51 |
)
|
| 52 |
if user_info.get("name") and user_info["name"] != "Unknown":
|
| 53 |
system_message += f" The user's name is {user_info['name']}."
|
|
@@ -67,9 +67,12 @@ def chat_with_session_memory(state):
|
|
| 67 |
)
|
| 68 |
|
| 69 |
props = state.get("properties", [])
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
if props:
|
| 72 |
-
print(f"DEBUG: First property: {props[0]}")
|
| 73 |
system_message += "\nHere are some listings I found:\n"
|
| 74 |
for p in props[:5]:
|
| 75 |
title = p.get("title", "[no title]")
|
|
@@ -84,12 +87,8 @@ def chat_with_session_memory(state):
|
|
| 84 |
f"{price} ({ptype}); features: {features}\n"
|
| 85 |
)
|
| 86 |
url = p.get("listing_url")
|
| 87 |
-
print(f"DEBUG: Property URL: {url}")
|
| 88 |
if url:
|
| 89 |
system_message += f" Link: {url}\n"
|
| 90 |
-
print(f"DEBUG: Added URL to system message")
|
| 91 |
-
else:
|
| 92 |
-
print(f"DEBUG: No URL found for property")
|
| 93 |
|
| 94 |
# After the listings loop, before building messages[]
|
| 95 |
extras = set()
|
|
@@ -108,6 +107,8 @@ def chat_with_session_memory(state):
|
|
| 108 |
f"\nI can also share {opts} for any listing—just let me know what you'd like."
|
| 109 |
)
|
| 110 |
|
|
|
|
|
|
|
| 111 |
# Build messages array with history
|
| 112 |
messages = [{"role": "system", "content": system_message}]
|
| 113 |
|
|
@@ -149,6 +150,7 @@ class ChatState(TypedDict):
|
|
| 149 |
persona: dict
|
| 150 |
intent: dict
|
| 151 |
properties: list
|
|
|
|
| 152 |
|
| 153 |
async def extract_and_update_persona(state):
|
| 154 |
# a. Define which persona fields to track
|
|
@@ -205,11 +207,6 @@ async def extract_and_update_persona(state):
|
|
| 205 |
return {"response": None}
|
| 206 |
|
| 207 |
async def extract_and_update_intent(state):
|
| 208 |
-
print(f"=== Starting extract_and_update_intent ===")
|
| 209 |
-
print(f"Session ID: {state.get('session_id')}")
|
| 210 |
-
print(f"User message: {state.get('user_message')}")
|
| 211 |
-
print(f"Current intent: {state.get('intent')}")
|
| 212 |
-
|
| 213 |
intent_fields = ["location_preference", "budget", "size_preference_sqm", "must_have"]
|
| 214 |
user_message = state["user_message"]
|
| 215 |
session_id = state["session_id"]
|
|
@@ -236,48 +233,32 @@ async def extract_and_update_intent(state):
|
|
| 236 |
"""
|
| 237 |
|
| 238 |
response = await llm.ainvoke([{"role":"user","content":extraction_prompt}])
|
| 239 |
-
print(f"LLM response for intent extraction: {response.content}")
|
| 240 |
|
| 241 |
import json
|
| 242 |
try:
|
| 243 |
# Clean up the response content (remove markdown formatting if present)
|
| 244 |
content = response.content.strip()
|
| 245 |
-
print(f"Original content: {repr(content)}")
|
| 246 |
|
| 247 |
if content.startswith('```json'):
|
| 248 |
content = content[7:] # Remove ```json
|
| 249 |
-
print(f"After removing ```json: {repr(content)}")
|
| 250 |
if content.endswith('```'):
|
| 251 |
content = content[:-3] # Remove ```
|
| 252 |
-
print(f"After removing ```: {repr(content)}")
|
| 253 |
content = content.strip()
|
| 254 |
-
print(f"Final cleaned content: {repr(content)}")
|
| 255 |
|
| 256 |
# Check if cleaned content is JSON
|
| 257 |
if not content.startswith("{"):
|
| 258 |
-
print("Cleaned content is not JSON, treating as explanatory answer")
|
| 259 |
state["response"] = response.content
|
| 260 |
return state
|
| 261 |
|
| 262 |
extracted = json.loads(content)
|
| 263 |
-
print(f"Successfully parsed intent JSON: {extracted}")
|
| 264 |
except Exception as e:
|
| 265 |
-
print("Failed to parse intent JSON:", response.content)
|
| 266 |
-
print("Error:", e)
|
| 267 |
-
print(f"Content that failed to parse: {repr(content)}")
|
| 268 |
extracted = {}
|
| 269 |
|
| 270 |
-
print(f"Starting field update loop. Extracted: {extracted}")
|
| 271 |
-
print(f"Current intent state: {intent}")
|
| 272 |
-
|
| 273 |
for field in intent_fields:
|
| 274 |
new_val = extracted.get(field)
|
| 275 |
old_val = intent.get(field)
|
| 276 |
-
print(f"Processing field {field}: new_val={new_val}, old_val={old_val}")
|
| 277 |
|
| 278 |
if new_val is not None and new_val != old_val:
|
| 279 |
-
print(f"Updating intent field {field}: {old_val} -> {new_val}")
|
| 280 |
-
|
| 281 |
# Handle must_have field as array (LLM decides the logic)
|
| 282 |
if field == "must_have" and new_val:
|
| 283 |
# Convert to array format for database
|
|
@@ -289,16 +270,11 @@ async def extract_and_update_intent(state):
|
|
| 289 |
else:
|
| 290 |
must_have_array = new_val if isinstance(new_val, list) else [str(new_val)]
|
| 291 |
|
| 292 |
-
print(f"LLM decided must_have: {must_have_array}")
|
| 293 |
await update_user_intent(session_id, {field: must_have_array})
|
| 294 |
intent[field] = must_have_array
|
| 295 |
else:
|
| 296 |
await update_user_intent(session_id, {field: new_val})
|
| 297 |
intent[field] = new_val
|
| 298 |
-
elif new_val is not None:
|
| 299 |
-
print(f"Intent field {field} unchanged: {new_val}")
|
| 300 |
-
else:
|
| 301 |
-
print(f"Intent field {field} not extracted")
|
| 302 |
|
| 303 |
state["intent"] = intent
|
| 304 |
|
|
@@ -319,18 +295,13 @@ async def extract_and_search_properties(state):
|
|
| 319 |
"""
|
| 320 |
Search for properties based on user intent and store results in state.
|
| 321 |
"""
|
| 322 |
-
print(f"=== Starting extract_and_search_properties ===")
|
| 323 |
intent = state.get("intent", {})
|
| 324 |
-
print(f"Intent data: {intent}")
|
| 325 |
|
| 326 |
# Check if we have the minimum required field for property search
|
| 327 |
location = intent.get("location_preference")
|
| 328 |
|
| 329 |
-
print(f"Location: {location}")
|
| 330 |
-
|
| 331 |
if not location:
|
| 332 |
# Missing location, skip property search
|
| 333 |
-
print(f"Missing location - skipping property search")
|
| 334 |
return {"response": None}
|
| 335 |
|
| 336 |
# Prepare filters for property search
|
|
@@ -349,8 +320,6 @@ async def extract_and_search_properties(state):
|
|
| 349 |
if must_have and len(must_have) > 0:
|
| 350 |
filters["must_have"] = must_have
|
| 351 |
|
| 352 |
-
print(f"Searching with filters: {filters}")
|
| 353 |
-
|
| 354 |
# Search for properties
|
| 355 |
properties = await search_properties(filters)
|
| 356 |
state["properties"] = properties
|
|
@@ -364,36 +333,39 @@ async def extract_and_search_properties(state):
|
|
| 364 |
filters2 = {k: v for k, v in filters.items() if k != "must_have"}
|
| 365 |
props2 = await search_properties(filters2)
|
| 366 |
if props2:
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
"
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
|
|
|
| 373 |
|
| 374 |
# Pass 3: drop size
|
| 375 |
-
if "size_preference_sqm" in
|
| 376 |
-
filters3 = {k: v for k, v in
|
| 377 |
props3 = await search_properties(filters3)
|
| 378 |
if props3:
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
"
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
| 385 |
|
| 386 |
# Pass 4: drop budget
|
| 387 |
-
if "budget" in
|
| 388 |
-
filters4 = {k: v for k, v in
|
| 389 |
props4 = await search_properties(filters4)
|
| 390 |
if props4:
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
|
|
|
| 397 |
|
| 398 |
# Final fallback: no listings at all
|
| 399 |
state["response"] = (
|
|
|
|
| 47 |
system_message = (
|
| 48 |
f"Hello {user_info.get('name','there')}! You are a helpful and concise property agent. "
|
| 49 |
"Always base property recommendations solely on listings in our database. "
|
| 50 |
+
"You can share listing URLs and other details that are available in the property data."
|
| 51 |
)
|
| 52 |
if user_info.get("name") and user_info["name"] != "Unknown":
|
| 53 |
system_message += f" The user's name is {user_info['name']}."
|
|
|
|
| 67 |
)
|
| 68 |
|
| 69 |
props = state.get("properties", [])
|
| 70 |
+
search_status = state.get("search_status_message", "")
|
| 71 |
+
|
| 72 |
+
if search_status:
|
| 73 |
+
system_message += f"\n{search_status}\n"
|
| 74 |
+
|
| 75 |
if props:
|
|
|
|
| 76 |
system_message += "\nHere are some listings I found:\n"
|
| 77 |
for p in props[:5]:
|
| 78 |
title = p.get("title", "[no title]")
|
|
|
|
| 87 |
f"{price} ({ptype}); features: {features}\n"
|
| 88 |
)
|
| 89 |
url = p.get("listing_url")
|
|
|
|
| 90 |
if url:
|
| 91 |
system_message += f" Link: {url}\n"
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
# After the listings loop, before building messages[]
|
| 94 |
extras = set()
|
|
|
|
| 107 |
f"\nI can also share {opts} for any listing—just let me know what you'd like."
|
| 108 |
)
|
| 109 |
|
| 110 |
+
system_message += "\n\nIMPORTANT: When users ask about specific details for a listing, check if that information is available in the property data. If not available, respond with 'For this listing, I don't have [specific detail] available right now' instead of saying you can't provide URLs or external information."
|
| 111 |
+
|
| 112 |
# Build messages array with history
|
| 113 |
messages = [{"role": "system", "content": system_message}]
|
| 114 |
|
|
|
|
| 150 |
persona: dict
|
| 151 |
intent: dict
|
| 152 |
properties: list
|
| 153 |
+
search_status_message: str
|
| 154 |
|
| 155 |
async def extract_and_update_persona(state):
|
| 156 |
# a. Define which persona fields to track
|
|
|
|
| 207 |
return {"response": None}
|
| 208 |
|
| 209 |
async def extract_and_update_intent(state):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
intent_fields = ["location_preference", "budget", "size_preference_sqm", "must_have"]
|
| 211 |
user_message = state["user_message"]
|
| 212 |
session_id = state["session_id"]
|
|
|
|
| 233 |
"""
|
| 234 |
|
| 235 |
response = await llm.ainvoke([{"role":"user","content":extraction_prompt}])
|
|
|
|
| 236 |
|
| 237 |
import json
|
| 238 |
try:
|
| 239 |
# Clean up the response content (remove markdown formatting if present)
|
| 240 |
content = response.content.strip()
|
|
|
|
| 241 |
|
| 242 |
if content.startswith('```json'):
|
| 243 |
content = content[7:] # Remove ```json
|
|
|
|
| 244 |
if content.endswith('```'):
|
| 245 |
content = content[:-3] # Remove ```
|
|
|
|
| 246 |
content = content.strip()
|
|
|
|
| 247 |
|
| 248 |
# Check if cleaned content is JSON
|
| 249 |
if not content.startswith("{"):
|
|
|
|
| 250 |
state["response"] = response.content
|
| 251 |
return state
|
| 252 |
|
| 253 |
extracted = json.loads(content)
|
|
|
|
| 254 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
| 255 |
extracted = {}
|
| 256 |
|
|
|
|
|
|
|
|
|
|
| 257 |
for field in intent_fields:
|
| 258 |
new_val = extracted.get(field)
|
| 259 |
old_val = intent.get(field)
|
|
|
|
| 260 |
|
| 261 |
if new_val is not None and new_val != old_val:
|
|
|
|
|
|
|
| 262 |
# Handle must_have field as array (LLM decides the logic)
|
| 263 |
if field == "must_have" and new_val:
|
| 264 |
# Convert to array format for database
|
|
|
|
| 270 |
else:
|
| 271 |
must_have_array = new_val if isinstance(new_val, list) else [str(new_val)]
|
| 272 |
|
|
|
|
| 273 |
await update_user_intent(session_id, {field: must_have_array})
|
| 274 |
intent[field] = must_have_array
|
| 275 |
else:
|
| 276 |
await update_user_intent(session_id, {field: new_val})
|
| 277 |
intent[field] = new_val
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
state["intent"] = intent
|
| 280 |
|
|
|
|
| 295 |
"""
|
| 296 |
Search for properties based on user intent and store results in state.
|
| 297 |
"""
|
|
|
|
| 298 |
intent = state.get("intent", {})
|
|
|
|
| 299 |
|
| 300 |
# Check if we have the minimum required field for property search
|
| 301 |
location = intent.get("location_preference")
|
| 302 |
|
|
|
|
|
|
|
| 303 |
if not location:
|
| 304 |
# Missing location, skip property search
|
|
|
|
| 305 |
return {"response": None}
|
| 306 |
|
| 307 |
# Prepare filters for property search
|
|
|
|
| 320 |
if must_have and len(must_have) > 0:
|
| 321 |
filters["must_have"] = must_have
|
| 322 |
|
|
|
|
|
|
|
| 323 |
# Search for properties
|
| 324 |
properties = await search_properties(filters)
|
| 325 |
state["properties"] = properties
|
|
|
|
| 333 |
filters2 = {k: v for k, v in filters.items() if k != "must_have"}
|
| 334 |
props2 = await search_properties(filters2)
|
| 335 |
if props2:
|
| 336 |
+
return {
|
| 337 |
+
"properties": props2,
|
| 338 |
+
"search_status_message": (
|
| 339 |
+
"I don't have an exact match with those features, "
|
| 340 |
+
"but here are listings matching your location, budget and size."
|
| 341 |
+
)
|
| 342 |
+
}
|
| 343 |
|
| 344 |
# Pass 3: drop size
|
| 345 |
+
if "size_preference_sqm" in filters:
|
| 346 |
+
filters3 = {k: v for k, v in filters.items() if k not in ["must_have", "size_preference_sqm"]}
|
| 347 |
props3 = await search_properties(filters3)
|
| 348 |
if props3:
|
| 349 |
+
return {
|
| 350 |
+
"properties": props3,
|
| 351 |
+
"search_status_message": (
|
| 352 |
+
"I don't have that exact size, "
|
| 353 |
+
"but here are listings matching your location and budget."
|
| 354 |
+
)
|
| 355 |
+
}
|
| 356 |
|
| 357 |
# Pass 4: drop budget
|
| 358 |
+
if "budget" in filters:
|
| 359 |
+
filters4 = {k: v for k, v in filters.items() if k not in ["must_have", "size_preference_sqm", "budget"]}
|
| 360 |
props4 = await search_properties(filters4)
|
| 361 |
if props4:
|
| 362 |
+
return {
|
| 363 |
+
"properties": props4,
|
| 364 |
+
"search_status_message": (
|
| 365 |
+
f"I don't have listings within your budget in {filters4['location_preference']}, "
|
| 366 |
+
"but here are some options in that area."
|
| 367 |
+
)
|
| 368 |
+
}
|
| 369 |
|
| 370 |
# Final fallback: no listings at all
|
| 371 |
state["response"] = (
|
database.py
CHANGED
|
@@ -273,87 +273,61 @@ async def update_user_intent(session_id: str, updates: dict):
|
|
| 273 |
Patch the intent record with any changed fields.
|
| 274 |
"""
|
| 275 |
if not supabase or session_id == "local_session":
|
| 276 |
-
print(f"Skipping intent update for session {session_id} (no supabase or local session)")
|
| 277 |
return
|
| 278 |
|
| 279 |
try:
|
| 280 |
updates_with_ts = { **updates, "updated_at": datetime.utcnow().isoformat() }
|
| 281 |
-
print(f"Updating user_intents table for session {session_id} with: {updates_with_ts}")
|
| 282 |
supabase \
|
| 283 |
.table("user_intents") \
|
| 284 |
.update(updates_with_ts) \
|
| 285 |
.eq("session_id", session_id) \
|
| 286 |
.execute()
|
| 287 |
-
print(f"Successfully updated intent for session {session_id}")
|
| 288 |
except Exception as e:
|
| 289 |
-
|
| 290 |
|
| 291 |
# --- Property Search ---
|
| 292 |
async def search_properties(filters: dict) -> list:
|
| 293 |
"""
|
| 294 |
Returns up to 5 active properties matching the user's filters.
|
| 295 |
"""
|
| 296 |
-
print(f"=== Starting search_properties ===")
|
| 297 |
-
print(f"Filters: {filters}")
|
| 298 |
-
|
| 299 |
if not supabase:
|
| 300 |
-
print("No supabase client - returning empty list")
|
| 301 |
return []
|
| 302 |
|
| 303 |
try:
|
| 304 |
-
# First, let's see if there are any properties at all
|
| 305 |
-
test_query = supabase.table("properties").select("*").limit(5)
|
| 306 |
-
test_resp = test_query.execute()
|
| 307 |
-
print(f"Total properties in database: {len(test_resp.data) if test_resp.data else 0}")
|
| 308 |
-
if test_resp.data:
|
| 309 |
-
print(f"Sample properties: {test_resp.data}")
|
| 310 |
-
|
| 311 |
query = supabase.table("properties").select("*").eq("is_active", True)
|
| 312 |
-
print("Base query created")
|
| 313 |
|
| 314 |
# a. Location: try city first, then location field
|
| 315 |
loc = filters.get("location_preference")
|
| 316 |
if loc:
|
| 317 |
-
print(f"Searching for location: {loc}")
|
| 318 |
query = query.ilike("city", f"%{loc}%")
|
| 319 |
-
print(f"After city filter: {query}")
|
| 320 |
resp = query.execute()
|
| 321 |
-
print(f"City search result: {resp.data}")
|
| 322 |
if not resp.data:
|
| 323 |
# fallback to suburb/area match
|
| 324 |
-
print(f"No city matches, trying location field...")
|
| 325 |
query = supabase.table("properties").select("*")\
|
| 326 |
.eq("is_active", True)\
|
| 327 |
.ilike("location", f"%{loc}%")
|
| 328 |
-
print(f"After location filter: {query}")
|
| 329 |
|
| 330 |
# b. Budget cap
|
| 331 |
budget = filters.get("budget")
|
| 332 |
if budget is not None:
|
| 333 |
-
print(f"Adding budget filter: <= {budget}")
|
| 334 |
query = query.lte("price", budget)
|
| 335 |
|
| 336 |
# c. Minimum size
|
| 337 |
size = filters.get("size_preference_sqm")
|
| 338 |
if size is not None:
|
| 339 |
-
print(f"Adding size filter: >= {size}")
|
| 340 |
query = query.gte("size_sqm", size)
|
| 341 |
|
| 342 |
# d. Must-have features
|
| 343 |
features = filters.get("must_have") or []
|
| 344 |
for feat in features:
|
| 345 |
-
print(f"Adding feature filter: {feat}")
|
| 346 |
query = query.contains("features", [feat])
|
| 347 |
|
| 348 |
# e. Order & limit
|
| 349 |
query = query.order("is_featured", desc=True).order("price").limit(5)
|
| 350 |
|
| 351 |
# f. Execute and return
|
| 352 |
-
print(f"Executing final query...")
|
| 353 |
resp = query.execute()
|
| 354 |
-
print(f"Query executed. Response: {resp.data}")
|
| 355 |
return resp.data or []
|
| 356 |
|
| 357 |
except Exception as e:
|
| 358 |
-
print(f"Error searching properties: {e}")
|
| 359 |
return []
|
|
|
|
| 273 |
Patch the intent record with any changed fields.
|
| 274 |
"""
|
| 275 |
if not supabase or session_id == "local_session":
|
|
|
|
| 276 |
return
|
| 277 |
|
| 278 |
try:
|
| 279 |
updates_with_ts = { **updates, "updated_at": datetime.utcnow().isoformat() }
|
|
|
|
| 280 |
supabase \
|
| 281 |
.table("user_intents") \
|
| 282 |
.update(updates_with_ts) \
|
| 283 |
.eq("session_id", session_id) \
|
| 284 |
.execute()
|
|
|
|
| 285 |
except Exception as e:
|
| 286 |
+
pass
|
| 287 |
|
| 288 |
# --- Property Search ---
|
| 289 |
async def search_properties(filters: dict) -> list:
|
| 290 |
"""
|
| 291 |
Returns up to 5 active properties matching the user's filters.
|
| 292 |
"""
|
|
|
|
|
|
|
|
|
|
| 293 |
if not supabase:
|
|
|
|
| 294 |
return []
|
| 295 |
|
| 296 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
query = supabase.table("properties").select("*").eq("is_active", True)
|
|
|
|
| 298 |
|
| 299 |
# a. Location: try city first, then location field
|
| 300 |
loc = filters.get("location_preference")
|
| 301 |
if loc:
|
|
|
|
| 302 |
query = query.ilike("city", f"%{loc}%")
|
|
|
|
| 303 |
resp = query.execute()
|
|
|
|
| 304 |
if not resp.data:
|
| 305 |
# fallback to suburb/area match
|
|
|
|
| 306 |
query = supabase.table("properties").select("*")\
|
| 307 |
.eq("is_active", True)\
|
| 308 |
.ilike("location", f"%{loc}%")
|
|
|
|
| 309 |
|
| 310 |
# b. Budget cap
|
| 311 |
budget = filters.get("budget")
|
| 312 |
if budget is not None:
|
|
|
|
| 313 |
query = query.lte("price", budget)
|
| 314 |
|
| 315 |
# c. Minimum size
|
| 316 |
size = filters.get("size_preference_sqm")
|
| 317 |
if size is not None:
|
|
|
|
| 318 |
query = query.gte("size_sqm", size)
|
| 319 |
|
| 320 |
# d. Must-have features
|
| 321 |
features = filters.get("must_have") or []
|
| 322 |
for feat in features:
|
|
|
|
| 323 |
query = query.contains("features", [feat])
|
| 324 |
|
| 325 |
# e. Order & limit
|
| 326 |
query = query.order("is_featured", desc=True).order("price").limit(5)
|
| 327 |
|
| 328 |
# f. Execute and return
|
|
|
|
| 329 |
resp = query.execute()
|
|
|
|
| 330 |
return resp.data or []
|
| 331 |
|
| 332 |
except Exception as e:
|
|
|
|
| 333 |
return []
|