PropAgent / ai_chat.py
zenaight
Refactor property selection logic and enhance user context analysis
f7aa5b7
Raw
History Blame Contribute Delete
29.5 kB
from langgraph.graph import StateGraph, END
from langchain_core.runnables import RunnableLambda
from typing import TypedDict
from config import llm, OPENAI_API_KEY
from database import get_session_messages, save_message, update_user_persona, update_user_intent, search_properties, end_session
def chat_with_session_memory(state):
"""Chat function with session-based memory"""
user_message = state["user_message"]
user_info = state.get("user_info", {})
session_id = state.get("session_id")
wa_id = state.get("wa_id")
wamid = state.get("wamid")
# Get conversation history from database
session_messages = []
if session_id:
# This will be populated by the async wrapper
session_messages = state.get("session_messages", [])
# Get properties from state
props = state.get("properties", [])
search_status = state.get("search_status_message", "")
# Add system message with user context
system_message = (
f"Hello {user_info.get('name','there')}! You are a helpful and concise property agent. "
"You may only reference listings passed in state['properties']. "
"If the user requests more detail, respond with whatever is in that listing dict (URL, images, features, etc.). "
"Always base property recommendations solely on listings in our database."
)
if user_info.get("name") and user_info["name"] != "Unknown":
system_message += f" The user's name is {user_info['name']}."
p = state.get("persona", {})
system_message += (
f" The user prefers {p.get('language','[unspecified]')} and wants a {p.get('tone','neutral')} tone."
)
intent_data = state.get("intent", {})
must_have_list = intent_data.get('must_have', []) or []
system_message += (
f" They're looking for a property in {intent_data.get('location_preference','[any area]')}, "
f"with a budget up to {intent_data.get('budget','[any amount]')} per month, "
f"around {intent_data.get('size_preference_sqm','[size]')} sqm, "
f"and must-haves: {', '.join(must_have_list) if must_have_list else '[none]'}. "
)
# Include search status if present
if search_status:
system_message += f"\n\n{search_status}"
# Include property data if available (without showing images or addresses in listings)
if props:
system_message += "\n\nAvailable listings:\n"
for p in props[:5]:
system_message += (
f"- {p.get('title')} in {p.get('location')}, {p.get('city')}: "
f"{p.get('size_sqm')} sqm, {p.get('price')} ({p.get('price_type')})\n"
)
# Include available data but NOT image URLs or addresses in public listings
if p.get("listing_url"):
system_message += f" URL: {p.get('listing_url')}\n"
if p.get("features"):
system_message += f" Features: {', '.join(p.get('features', [])[:5])}\n"
if p.get("floorplan_pdf"):
system_message += f" Floorplan: {p.get('floorplan_pdf')}\n"
if p.get("video_url"):
system_message += f" Video: {p.get('video_url')}\n"
# Include address data for AI use (but don't show in listings unless requested)
if p.get("address"):
system_message += f" Google Maps Address (for requests only): {p.get('address')}\n"
# Let AI know what additional info is available on request
available_extras = []
if p.get("images"): available_extras.append("images")
if p.get("address"): available_extras.append("address")
if available_extras:
system_message += f" Available on request: {', '.join(available_extras)}\n"
system_message += "\n\nIMPORTANT: You may only reference listings passed in state['properties']. When users ask for images, photos, or pictures, let them know that images are available and will be sent separately. When users ask for the address, location, or where a property is located, provide the Google Maps address from the property data. The addresses are Google Maps compatible for navigation. If information is not available, respond with 'For this listing, I don't have [specific detail] available right now'."
# Build messages array with history
messages = [{"role": "system", "content": system_message}]
# Add conversation history (last 10 messages)
for msg in session_messages[-30:]:
messages.append({"role": msg["role"], "content": msg["content"]})
# Add current user message
messages.append({"role": "user", "content": user_message})
try:
if not OPENAI_API_KEY:
return {"response": "Sorry, AI chat is not available. Please check your OpenAI API key configuration."}
response = llm.invoke(messages)
ai_response = response.content
# Save messages to database (this will be handled by the async wrapper)
return {
"response": ai_response,
"user_message": user_message,
"ai_response": ai_response,
"session_id": session_id,
"wa_id": wa_id,
"wamid": wamid
}
except Exception as e:
print(f"Error in chat_with_session_memory: {e}")
return {"response": "Sorry, something went wrong: " + str(e)}
class ChatState(TypedDict):
user_message: str
response: str
user_info: dict
session_id: str
wa_id: str
wamid: str
session_messages: list
persona: dict
intent: dict
properties: list
search_status_message: str
classification: str
async def extract_and_update_persona(state):
print("DEBUG - Starting extract_and_update_persona")
# a. Define which persona fields to track
persona_fields = ["language", "tone"]
user_message = state["user_message"]
wa_id = state["wa_id"]
persona = state.get("persona", {})
# b. Build a one-shot extraction prompt
extraction_prompt = f"""
Extract and normalize the user's language and tone preferences from this message:
{user_message}
Normalize any shorthand or typos before deciding language and tone.
Return only a JSON object with keys "language" and "tone", and use null for unknown.
"""
# c. Call the LLM
response = await llm.ainvoke([{"role":"user","content":extraction_prompt}])
import json
import re
extracted = {}
try:
# Clean up the response content (remove markdown formatting if present)
content = response.content.strip()
if content.startswith('```json'):
content = content[7:] # Remove ```json
if content.endswith('```'):
content = content[:-3] # Remove ```
content = content.strip()
extracted = json.loads(content)
except Exception as e:
print("Failed to parse persona JSON:", response.content)
print("Error:", e)
# d. Update DB and in-memory state for any changed values
for field in persona_fields:
new_val = extracted.get(field)
old_val = persona.get(field)
if new_val is not None and new_val != old_val:
await update_user_persona(wa_id, {field: new_val})
persona[field] = new_val
state["persona"] = persona
# e. If any field still unset, ask a gentle follow-up
missing = [f for f in persona_fields if state["persona"].get(f) is None]
if missing:
state["response"] = f"Hi there! What is your {missing[0]} preference?"
print(f"DEBUG - Persona update returning response: {state['response']}")
return state
# f. All persona fields present—proceed to chat
print("DEBUG - Persona update returning None")
return {"response": None}
async def extract_and_update_intent(state):
print("DEBUG - Starting extract_and_update_intent")
intent_fields = ["location_preference", "budget", "size_preference_sqm", "must_have"]
user_message = state["user_message"]
session_id = state["session_id"]
intent = state.get("intent", {})
extraction_prompt = f"""
Extract and normalize the user's current property search intent from this message:
{user_message}
Current intent state:
- Location: {intent.get('location_preference', 'Not set')}
- Budget: {intent.get('budget', 'Not set')}
- Size: {intent.get('size_preference_sqm', 'Not set')} sqm
- Must-haves: {intent.get('must_have', [])}
Instructions:
1. Normalize abbreviations and common terms:
- 'JHB' or 'Jhb' → 'Johannesburg'
- 'CT' or 'Cape Town' → 'Cape Town'
- 'DBN' or 'Durban' → 'Durban'
- 'sqm' → 'square metres'
2. For must_have field: Determine if the user is ADDING new requirements, CHANGING their mind, or CLARIFYING existing ones.
- If adding: Include both existing and new items in the array
- If changing: Replace with new requirements
- If clarifying: Update with more specific versions
3. If the user is asking a definition or clarification (e.g. 'What does square metre mean?'), answer that question fully and do not update the intent.
4. Return only a JSON object with keys {intent_fields}, using null for unknown.
"""
response = await llm.ainvoke([{"role":"user","content":extraction_prompt}])
import json
try:
# Clean up the response content (remove markdown formatting if present)
content = response.content.strip()
if content.startswith('```json'):
content = content[7:] # Remove ```json
if content.endswith('```'):
content = content[:-3] # Remove ```
content = content.strip()
# Check if cleaned content is JSON
if not content.startswith("{"):
state["response"] = response.content
return state
extracted = json.loads(content)
print(f"DEBUG - Intent extraction result: {extracted}")
except Exception as e:
extracted = {}
print(f"DEBUG - Intent extraction error: {e}")
# Check if this is a general area search (like "what do you have in [area]")
is_general_area_search = (
"what do you have" in user_message.lower() and
any(word in user_message.lower() for word in ["in ", "area", "jhb", "johannesburg", "cape town", "durban"])
)
for field in intent_fields:
new_val = extracted.get(field)
old_val = intent.get(field)
# For general area searches, clear restrictive filters
if is_general_area_search and field in ["budget", "size_preference_sqm", "must_have"]:
if old_val is not None:
print(f"DEBUG - Clearing restrictive field {field} for general area search")
await update_user_intent(session_id, {field: None})
intent[field] = None
continue
if new_val is not None and new_val != old_val:
# Handle must_have field as array (LLM decides the logic)
if field == "must_have" and new_val:
# Convert to array format for database
if isinstance(new_val, str):
if "," in new_val:
must_have_array = [item.strip() for item in new_val.split(",")]
else:
must_have_array = [new_val.strip()]
else:
must_have_array = new_val if isinstance(new_val, list) else [str(new_val)]
await update_user_intent(session_id, {field: must_have_array})
intent[field] = must_have_array
else:
await update_user_intent(session_id, {field: new_val})
intent[field] = new_val
state["intent"] = intent
print(f"DEBUG - Final intent state: {state['intent']}")
missing = [f for f in intent_fields if state["intent"].get(f) is None]
print(f"DEBUG - Missing intent fields: {missing}")
if missing:
# Check if user is asking for properties, images, or address (using AI classification)
classification = state.get("classification")
print(f"DEBUG - Intent update classification check: '{classification}'")
# Check if this is a request that should skip preference questions
skip_preferences = (
classification == "search_listings" or
classification.startswith("request_images") or
classification == "request_address" or
classification == "request_details"
)
if skip_preferences:
# User is asking for properties, images, or address - don't interrupt with preference questions
print(f"DEBUG - User asking for {classification}, skipping preference questions")
# Special case: if user is asking for properties but has no location, ask for location first
if classification == "search_listings" and not state["intent"].get("location_preference"):
print("DEBUG - User asking for properties but no location, asking for location")
state["response"] = "I'd be happy to help you find properties! Which area or city are you interested in?"
return state
return {
"response": None,
"classification": classification
}
# User is setting preferences, ask for missing fields
questions = {
"location_preference": "Hi there! Which area or suburb are you interested in?",
"budget": "Hi there! What is your monthly budget?",
"size_preference_sqm": "Hi there! How many square metres do you need?",
"must_have": "Hi there! What features are must-haves for you?"
}
state["response"] = questions.get(missing[0], f"Hi there! Could you tell me your {missing[0]}?")
print(f"DEBUG - Intent update returning response: {state['response']}")
return state
print("DEBUG - Intent update returning None")
return {
"response": None,
"classification": state.get("classification")
}
async def classify_user_intent(state):
"""
Classify the user's message to determine if they want to search for properties.
"""
print("DEBUG - Starting classify_user_intent")
user_message = state["user_message"]
prompt = f"""
Classify the user's message into exactly one of:
- search_listings (user wants to see property listings)
- request_images (user wants to see images/photos/pictures of a listing)
- request_address (user wants the address/location of a listing)
- request_details (user wants specific property info like price, features, floorplan, video, size, etc.)
- other (anything else)
If the user is asking for images and mentions a specific property (like "option 1", "the office", "warehouse", etc.),
extract the property identifier and return: request_images:IDENTIFIER
Examples:
- "How much is this warehouse?" → request_details
- "What is the price?" → request_details
- "What are the features?" → request_details
- "How big is it?" → request_details
- "Show me images" → request_images
- "What do you have in JHB?" → search_listings
- "Do you have any properties for sale?" → search_listings
- "Any properties available?" → search_listings
- "Show me properties" → search_listings
Return only the tag (and identifier if applicable).
Message: {user_message}
"""
resp = await llm.ainvoke([{"role":"user","content":prompt}])
classification = resp.content.strip()
state["classification"] = classification
print(f"DEBUG - Classification result: '{classification}' for message: '{user_message}'")
return {"classification": classification, "response": None}
async def extract_and_search_properties(state):
"""
Search for properties based on user intent and store results in state.
"""
print("DEBUG - Starting extract_and_search_properties")
# Only search when the LLM tagged this as a listings request
classification = state.get("classification")
print(f"DEBUG - Property search classification check: '{classification}'")
# Check if classification matches our search categories
is_search_request = (
classification == "search_listings" or
classification.startswith("request_images") or
classification == "request_address" or
classification == "request_details"
)
if not is_search_request:
print(f"DEBUG - Skipping property search, classification is '{classification}'")
return {"response": None}
intent = state.get("intent", {})
user_message = state.get("user_message", "").lower()
print(f"DEBUG - extract_and_search_properties intent: {intent}")
print(f"DEBUG - User message: {user_message}")
# Check if we have the minimum required field for property search
location = intent.get("location_preference")
if not location:
# Missing location, but user is asking for properties - ask for location
print("DEBUG - No location found, asking for location")
state["response"] = "I'd be happy to help you find properties! Which area or city are you interested in?"
return state
# Prepare filters for property search
filters = {"location_preference": location}
# Add budget if set
if intent.get("budget") is not None:
filters["budget"] = intent["budget"]
# Add size if set
if intent.get("size_preference_sqm") is not None:
filters["size_preference_sqm"] = intent["size_preference_sqm"]
# Search for properties with flexible ranges
print(f"DEBUG - Searching with filters: {filters}")
properties = await search_properties(filters)
state["properties"] = properties
print(f"DEBUG - Found {len(properties)} properties with flexible ranges")
if properties:
print("DEBUG - Properties found, returning properties to continue to chat")
return {
"properties": properties,
"classification": state.get("classification")
}
# No properties found with any filters
state["response"] = (
f"I don't have any listings right now in {location}. "
"I'll notify you as soon as something becomes available. "
"Feel free to reach out any time!"
)
await end_session(state["session_id"])
return state
async def detect_end_chat(state):
"""
Detect if the user wants to end the chat session.
"""
user_message = state["user_message"].lower()
session_id = state["session_id"]
if any(phrase in user_message for phrase in ["thank you", "thanks", "bye", "goodbye", "end chat"]):
await end_session(session_id)
return {"response": "Thanks for chatting! I've ended this session. Goodbye!"}
return {"response": None}
# --- Build LangGraph ---
graph = StateGraph(ChatState)
graph.add_node("persona_update", RunnableLambda(extract_and_update_persona))
graph.add_node("classify_intent", RunnableLambda(classify_user_intent))
graph.add_node("intent_update", RunnableLambda(extract_and_update_intent))
graph.add_node("property_search", RunnableLambda(extract_and_search_properties))
graph.add_node("exit_check", RunnableLambda(detect_end_chat))
graph.add_node("chat", RunnableLambda(chat_with_session_memory))
graph.set_entry_point("persona_update")
# Add conditional edges - if a node returns a response, go to END
def should_continue(state):
"""Check if we should continue to the next node or end"""
has_response = state.get("response") is not None
print(f"DEBUG - should_continue: response={state.get('response')}, has_response={has_response}, should_continue={not has_response}")
return not has_response
graph.add_edge("persona_update", "classify_intent")
graph.add_edge("classify_intent", "intent_update")
graph.add_conditional_edges("intent_update", should_continue, {
True: "property_search",
False: END
})
graph.add_conditional_edges("property_search", should_continue, {
True: "exit_check",
False: END
})
graph.add_conditional_edges("exit_check", should_continue, {
True: "chat",
False: END
})
graph.add_edge("chat", END)
chat_graph = graph.compile()
async def process_message(user_message: str, user_info: dict = None, session_id: str = None, wa_id: str = None, wamid: str = None, persona: dict = None, intent: dict = None, properties: list = None):
"""Process a message through the AI chat system with session memory"""
if user_info is None:
user_info = {}
# Get session messages for context
session_messages = []
if session_id:
session_messages = await get_session_messages(session_id, limit=10)
# Process with AI
result = await chat_graph.ainvoke({
"user_message": user_message,
"user_info": user_info,
"session_id": session_id,
"wa_id": wa_id,
"wamid": wamid,
"session_messages": session_messages,
"persona": persona or {},
"intent": intent or {},
"properties": properties or []
})
# Save messages to database
if session_id and wa_id and wamid:
await save_message(session_id, wa_id, wamid, "user", user_message)
await save_message(session_id, wa_id, f"{wamid}_ai", "assistant", result["response"])
return {
"response": result["response"],
"properties": result.get("properties", []),
"classification": result.get("classification", "")
}
async def handle_image_request(state):
"""
Handle requests for property images and return image messages to send.
"""
user_message = state["user_message"].lower()
props = state.get("properties", [])
classification = state.get("classification", "")
print(f"DEBUG - handle_image_request: classification='{classification}', props count={len(props)}")
# Check if this is an image request
if not classification.startswith("request_images") or not props:
print(f"DEBUG - Image request check failed: classification starts with request_images? {classification.startswith('request_images')}, has props? {len(props) > 0}")
return None
# Extract property identifier from classification if present
property_identifier = None
if ":" in classification:
property_identifier = classification.split(":", 1)[1].lower()
print(f"DEBUG - Property identifier: '{property_identifier}'")
print(f"DEBUG - Available properties: {[p.get('title') for p in props]}")
# Smart property selection based on AI classification
selected_property = None
if property_identifier:
# Method 1: Handle option numbers
if "option" in property_identifier:
# Extract numeric option
import re
numbers = re.findall(r'\d+', property_identifier)
if numbers:
option_num = int(numbers[0])
if 1 <= option_num <= len(props):
selected_property = props[option_num - 1]
# Method 2: Handle text-based identifiers
if not selected_property:
best_match_score = 0
for prop in props:
title = prop.get("title", "").lower()
location = prop.get("location", "").lower()
city = prop.get("city", "").lower()
# Check if identifier matches property keywords
score = 0
identifier_words = property_identifier.split()
for word in identifier_words:
if word in title:
score += 3 # Title matches are most important
if word in location:
score += 2
if word in city:
score += 1
# Check for property type keywords
if word in ["office", "warehouse", "space"] and word in title:
score += 2
if score > best_match_score:
best_match_score = score
selected_property = prop
# Look for specific property selections in conversation
session_messages = state.get("session_messages", [])
recent_messages = session_messages[-20:] # Look at more messages
# Look for patterns like "option 3", "the warehouse", "this property"
selected_property_context = None
for msg in recent_messages:
if msg.get("role") == "user":
content = msg.get("content", "").lower()
# Look for option selections
if "option" in content:
import re
option_match = re.search(r'option\s+(\d+)', content)
if option_match:
option_num = int(option_match.group(1))
if 1 <= option_num <= len(props):
selected_property_context = props[option_num - 1]
print(f"DEBUG - Found user selected option {option_num}: {selected_property_context.get('title')}")
break
# Look for property type mentions that user specifically asked about
for i, prop in enumerate(props):
title_words = prop.get("title", "").lower().split()
for word in ["warehouse", "office", "space", "unit"]:
if word in content and word in title_words:
selected_property_context = prop
print(f"DEBUG - Found user interest in {word}: {prop.get('title')}")
break
# Use the property the user specifically selected/discussed
if selected_property_context:
selected_property = selected_property_context
# Fallback: Use conversation context to find which property user was discussing
if not selected_property and len(props) > 1:
# Check conversation history for property context
session_messages = state.get("session_messages", [])
print(f"DEBUG - Checking {len(session_messages)} session messages for property context")
# Look for property mentions in recent conversation
recent_messages = session_messages[-10:] # Last 10 messages
property_mentions = {}
for msg in recent_messages:
if msg.get("role") == "assistant":
content = msg.get("content", "").lower()
for i, prop in enumerate(props):
title = prop.get("title", "").lower()
location = prop.get("location", "").lower()
# Check if this property was mentioned in AI response
title_words = title.split()
if len(title_words) >= 2: # Use first 2 words for matching
key_phrase = " ".join(title_words[:2])
if key_phrase in content or location in content:
property_mentions[i] = property_mentions.get(i, 0) + 1
print(f"DEBUG - Found mention of property {i}: {title}")
# Use most mentioned property from conversation
if property_mentions:
most_mentioned = max(property_mentions.items(), key=lambda x: x[1])
selected_property = props[most_mentioned[0]]
print(f"DEBUG - Selected property from conversation context: {selected_property.get('title')}")
else:
# Multiple properties available - ask user to specify
prop_options = []
for i, prop in enumerate(props[:3], 1): # Show first 3 options
prop_options.append(f"Option {i}: {prop.get('title')}")
options_text = "\n".join(prop_options)
return [f"I have multiple properties available. Which one would you like to see images of?\n\n{options_text}\n\nPlease let me know which option you'd like images for."]
# Final fallback: use first property if only one or no context found
if not selected_property:
selected_property = props[0]
print(f"DEBUG - Selected property: '{selected_property.get('title')}'")
# Get images from selected property
images = selected_property.get("images", [])
print(f"DEBUG - Images found: {len(images) if images else 0}")
print(f"DEBUG - Image URLs: {images}")
if not images:
property_title = selected_property.get("title", "this listing")
return [f"Sorry, I don't have any images available for {property_title}."]
# Prepare image messages
image_messages = []
property_title = selected_property.get("title", "This property")
# Add a text message first to introduce the images
image_messages.append(f"Here are the images for {property_title}:")
for i, image_url in enumerate(images[:5]): # Limit to 5 images
caption = f"{property_title} - Image {i+1}" if i > 0 else f"{property_title}"
image_messages.append({
"type": "image",
"url": image_url,
"caption": caption
})
return image_messages