| 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") |
| |
| |
| session_messages = [] |
| if session_id: |
| |
| session_messages = state.get("session_messages", []) |
| |
| |
| props = state.get("properties", []) |
| search_status = state.get("search_status_message", "") |
| |
|
|
| |
| |
| 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]'}. " |
| ) |
| |
| |
| if search_status: |
| system_message += f"\n\n{search_status}" |
| |
| |
| 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" |
| ) |
| |
| 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" |
| |
| |
| if p.get("address"): |
| system_message += f" Google Maps Address (for requests only): {p.get('address')}\n" |
| |
| |
| 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'." |
| |
| |
| messages = [{"role": "system", "content": system_message}] |
| |
| |
| for msg in session_messages[-30:]: |
| messages.append({"role": msg["role"], "content": msg["content"]}) |
| |
| |
| 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 |
| |
| |
| 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") |
| |
| persona_fields = ["language", "tone"] |
| user_message = state["user_message"] |
| wa_id = state["wa_id"] |
| persona = state.get("persona", {}) |
|
|
| |
| 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. |
| """ |
|
|
| |
| response = await llm.ainvoke([{"role":"user","content":extraction_prompt}]) |
| import json |
| import re |
| extracted = {} |
| try: |
| |
| content = response.content.strip() |
| if content.startswith('```json'): |
| content = content[7:] |
| if content.endswith('```'): |
| content = content[:-3] |
| content = content.strip() |
| |
| extracted = json.loads(content) |
| except Exception as e: |
| print("Failed to parse persona JSON:", response.content) |
| print("Error:", e) |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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: |
| |
| content = response.content.strip() |
| |
| if content.startswith('```json'): |
| content = content[7:] |
| if content.endswith('```'): |
| content = content[:-3] |
| content = content.strip() |
| |
| |
| 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}") |
|
|
| |
| 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) |
| |
| |
| 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: |
| |
| if field == "must_have" and new_val: |
| |
| 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: |
| |
| classification = state.get("classification") |
| print(f"DEBUG - Intent update classification check: '{classification}'") |
| |
| |
| skip_preferences = ( |
| classification == "search_listings" or |
| classification.startswith("request_images") or |
| classification == "request_address" or |
| classification == "request_details" |
| ) |
| |
| if skip_preferences: |
| |
| print(f"DEBUG - User asking for {classification}, skipping preference questions") |
| |
| |
| 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 |
| } |
| |
| |
| 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") |
| |
| classification = state.get("classification") |
| print(f"DEBUG - Property search classification check: '{classification}'") |
| |
| |
| 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}") |
| |
| |
| location = intent.get("location_preference") |
| |
| if not 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 |
| |
| |
| filters = {"location_preference": location} |
| |
| |
| if intent.get("budget") is not None: |
| filters["budget"] = intent["budget"] |
| |
| |
| if intent.get("size_preference_sqm") is not None: |
| filters["size_preference_sqm"] = intent["size_preference_sqm"] |
| |
| |
| 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") |
| } |
|
|
| |
| 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} |
|
|
| |
| 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") |
|
|
| |
| 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 = {} |
| |
| |
| session_messages = [] |
| if session_id: |
| session_messages = await get_session_messages(session_id, limit=10) |
| |
| |
| 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 [] |
| }) |
| |
| |
| 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)}") |
| |
| |
| 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 |
| |
| |
| 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]}") |
| |
| |
| selected_property = None |
| |
| if property_identifier: |
| |
| if "option" in property_identifier: |
| |
| 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] |
| |
| |
| 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() |
| |
| |
| score = 0 |
| identifier_words = property_identifier.split() |
| |
| for word in identifier_words: |
| if word in title: |
| score += 3 |
| if word in location: |
| score += 2 |
| if word in city: |
| score += 1 |
| |
| if word in ["office", "warehouse", "space"] and word in title: |
| score += 2 |
| |
| if score > best_match_score: |
| best_match_score = score |
| selected_property = prop |
| |
| |
| session_messages = state.get("session_messages", []) |
| recent_messages = session_messages[-20:] |
|
|
| |
| selected_property_context = None |
|
|
| for msg in recent_messages: |
| if msg.get("role") == "user": |
| content = msg.get("content", "").lower() |
| |
| 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 |
| |
| |
| 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 |
|
|
| |
| if selected_property_context: |
| selected_property = selected_property_context |
| |
| |
| if not selected_property and len(props) > 1: |
| |
| session_messages = state.get("session_messages", []) |
| print(f"DEBUG - Checking {len(session_messages)} session messages for property context") |
| |
| |
| recent_messages = session_messages[-10:] |
| 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() |
| |
| title_words = title.split() |
| if len(title_words) >= 2: |
| 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}") |
| |
| |
| 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: |
| |
| prop_options = [] |
| for i, prop in enumerate(props[:3], 1): |
| 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."] |
| |
| |
| if not selected_property: |
| selected_property = props[0] |
| |
| print(f"DEBUG - Selected property: '{selected_property.get('title')}'") |
| |
| |
| 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}."] |
| |
| |
| image_messages = [] |
| property_title = selected_property.get("title", "This property") |
| |
| |
| image_messages.append(f"Here are the images for {property_title}:") |
| |
| for i, image_url in enumerate(images[:5]): |
| 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 |