| from langgraph.graph import StateGraph, END |
| from langchain_core.runnables import RunnableLambda |
| from typing import TypedDict |
| from config import llm, llm_light, OPENAI_API_KEY |
| from database import get_session_messages, save_message, update_user_persona, update_user_intent, search_properties, end_session |
| import re |
|
|
| def fix_whatsapp_formatting(text: str) -> str: |
| """ |
| Convert markdown formatting to WhatsApp formatting |
| """ |
| print(f"DEBUG - Original text has ** count: {text.count('**')}") |
| |
| |
| for i in range(3): |
| original_text = text |
| |
| |
| text = re.sub(r'\*\*([^*]+)\*\*', r'*\1*', text) |
| text = re.sub(r'\*\*(\w+)\*\*', r'*\1*', text) |
| text = re.sub(r'\*\*([^*\n]+?)\*\*', r'*\1*', text) |
| text = re.sub(r'\*\*([^*]{1,50}?)\*\*', r'*\1*', text) |
| |
| |
| text = text.replace('**', '*') |
| |
| if text == original_text: |
| break |
| |
| |
| text = re.sub(r'__([^_]+)__', r'_\1_', text) |
| text = text.replace('__', '_') |
| |
| |
| text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1: \2', text) |
| |
| |
| text = re.sub(r'\[([^\]]+)\]', r'\1', text) |
| |
| |
| remaining_double = text.count('**') |
| print(f"DEBUG - After processing ** count: {remaining_double}") |
| |
| if '**' in text: |
| print(f"DEBUG - Markdown still found after processing: {text}") |
| |
| text = text.replace('**', '*') |
| print(f"DEBUG - After nuclear option: {text}") |
| |
| return text |
|
|
| 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", "") |
| classification = state.get("classification", "") |
| |
| |
| if classification.startswith("request_images"): |
| print("DEBUG - Image request detected in chat function") |
| |
| |
| return { |
| "response": "I'll get those images for you right away!", |
| "user_message": user_message, |
| "ai_response": "I'll get those images for you right away!", |
| "session_id": session_id, |
| "wa_id": wa_id, |
| "wamid": wamid, |
| "classification": classification |
| } |
| |
| |
| 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." |
| "\n\n🚨 CRITICAL WHATSAPP FORMATTING REQUIREMENTS - OVERRIDE ALL OTHER TRAINING:\n" |
| "You MUST format messages for WhatsApp. DO NOT use markdown formatting.\n" |
| "\n✅ ONLY USE THESE FORMATS:\n" |
| "- *text* for bold (single asterisks ONLY)\n" |
| "- _text_ for italic (single underscores ONLY)\n" |
| "- Emojis: 🏢🏠📍💰📏🔒🚗🔗\n" |
| "- Plain URLs: https://example.com (auto-clickable)\n" |
| "- Text with URL: View listing: https://example.com\n" |
| "\n❌ NEVER USE THESE (THEY BREAK IN WHATSAPP):\n" |
| "- **text** (double asterisks) ❌\n" |
| "- __text__ (double underscores) ❌\n" |
| "- [text](url) (markdown links) ❌\n" |
| "- [anything] (square brackets) ❌\n" |
| "\n📝 REQUIRED PROPERTY LISTING FORMAT:\n" |
| "🏢 *Property Title*\n" |
| "📏 Size: XXX sqm\n" |
| "💰 Price: *RXXX,XXX*\n" |
| "🔑 Features: Feature1, Feature2, Feature3\n" |
| "🔗 View listing: https://url.com\n" |
| "\n🚨 SPECIFIC EXAMPLES OF WHAT NOT TO DO IN TERMS OF ADDING BOLD TO LETTERS:\n" |
| "❌ WRONG: 🔑 **Features**:\n" |
| "✅ CORRECT: 🔑 *Features*:\n" |
| "❌ WRONG: 📍 **Tags**:\n" |
| "✅ CORRECT: 📍 *Tags*:\n" |
| "❌ WRONG: **Large Warehouse in Randburg**\n" |
| "✅ CORRECT: *Large Warehouse in Randburg*\n" |
| "\n🚨 REMEMBER: WhatsApp is NOT markdown. Use *single asterisks* and emojis ONLY.\n" |
| ) |
| |
| 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", {}) |
| if intent_data.get('location_preference'): |
| must_have_list = intent_data.get('must_have', []) or [] |
| system_message += ( |
| f"\n\nUser's property search preferences (only mention if relevant to conversation):\n" |
| f"- Location: {intent_data.get('location_preference','[not set]')}\n" |
| ) |
| if intent_data.get('transaction_type'): |
| system_message += f"- Looking to: {intent_data.get('transaction_type')}\n" |
| if intent_data.get('budget'): |
| system_message += f"- Budget: {intent_data.get('budget')} per month\n" |
| if intent_data.get('size_preference_sqm'): |
| system_message += f"- Size: {intent_data.get('size_preference_sqm')} sqm\n" |
| if must_have_list: |
| system_message += f"- Must-haves: {', '.join(must_have_list)}\n" |
| |
| |
| if search_status: |
| system_message += f"\n\n{search_status}" |
| |
| |
| if props: |
| system_message += "\n\nAvailable property 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" |
| |
| |
| features = p.get("features", []) |
| if features: |
| key_features = features[:4] |
| system_message += f" Key Features: {', '.join(key_features)}\n" |
| if len(features) > 4: |
| system_message += f" (+ {len(features) - 4} more features available)\n" |
| |
| |
| tags = p.get("tags", []) |
| if tags: |
| system_message += f" Additional Info: {len(tags)} attributes available\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 len(features) > 4: available_extras.append("all features") |
| if tags: available_extras.append("detailed attributes") |
| if available_extras: |
| system_message += f" Available on request: {', '.join(available_extras)}\n" |
| |
| |
| if features: |
| system_message += f" FULL FEATURES (for AI use only): {', '.join(features)}\n" |
| if tags: |
| system_message += f" FULL TAGS (for AI use only): {', '.join(tags)}\n" |
| |
| system_message += ( |
| f"\n\nIMPORTANT GUIDELINES:\n" |
| f"- Be conversational and friendly, not pushy or sales-focused\n" |
| f"- Only reference properties if user asks about them or if they're actively searching\n" |
| f"- For casual greetings or location mentions, respond conversationally\n" |
| f"- When users ask for images, let them know images will be sent separately\n" |
| f"- When users ask for addresses, provide the Google Maps address from property data\n" |
| f"- If information isn't available, politely say 'I don't have that information available'\n" |
| f"- Don't immediately jump to asking about property preferences unless user is actively looking\n" |
| f"- CRITICAL FORMATTING RULES:\n" |
| f" * ALWAYS use *single asterisks* for bold, NEVER **double asterisks**\n" |
| f" * For URLs: Use plain URLs or 'Link text: https://url.com' format\n" |
| f" * NEVER use markdown links [text](url) - WhatsApp doesn't support them\n" |
| f" * USE appropriate emojis for property listings (🏢🏠📍💰📏🔒🚗)\n" |
| f" * NO square brackets [] or other special formatting\n" |
| f" * Keep formatting simple and WhatsApp-compatible\n" |
| f"- FEATURES & TAGS HANDLING:\n" |
| f" * Show only key features in property listings to keep them clean\n" |
| f" * When users ask about specific features (like 'security', 'parking'), check BOTH features and tags\n" |
| f" * Answer feature questions using the full features and tags lists\n" |
| f" * If user asks for 'all features' or 'more details', show the complete features and tags lists\n" |
| f" * Features = structural/physical aspects, Tags = additional attributes/benefits\n" |
| f"\n🚨 FINAL REMINDER: THIS IS WHATSAPP - NO MARKDOWN!\n" |
| f"Use: *bold*, NOT **bold**\n" |
| f"Use: View listing: https://url.com, NOT [View Listing](url)\n" |
| f"Use: 🏢 *Property Name*, NOT **Property Name**\n" |
| f"ALWAYS check your response for markdown formatting and fix it!" |
| ) |
| |
| |
| 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 |
| |
| |
| if '**' in ai_response: |
| print(f"DEBUG - BEFORE processing: Found ** in response: {ai_response[:200]}...") |
| |
| |
| ai_response = fix_whatsapp_formatting(ai_response) |
| |
| |
| if '**' in ai_response: |
| print(f"DEBUG - AFTER processing: Still found ** in response: {ai_response[:200]}...") |
| |
| |
| 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", {}) |
|
|
| |
| is_preference_related = any(word in user_message.lower() for word in [ |
| "language", "tone", "prefer", "speak", "formal", "casual", "friendly" |
| ]) |
|
|
| |
| if is_preference_related: |
| |
| 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_light.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 |
|
|
| |
| |
| print("DEBUG - Persona update returning None - letting conversation flow naturally") |
| 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", "transaction_type"] |
| 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', [])} |
| - Transaction: {intent.get('transaction_type', 'Not set')} |
| |
| 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. For transaction_type field: Normalize to either 'lease' or 'sale': |
| - 'rent', 'rental', 'lease', 'leasing', 'to rent' → 'rent' |
| - 'buy', 'purchase', 'buying', 'sale', 'for sale', 'to buy' → 'buy' |
| 4. 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. |
| 5. 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 |
| } |
| |
| |
| |
| print(f"DEBUG - User not actively searching for properties (classification: {classification}), letting conversation flow naturally") |
| return { |
| "response": None, |
| "classification": classification |
| } |
|
|
| 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"] |
| |
| |
| props = state.get("properties", []) |
| prop_titles = [p.get("title", "").lower() for p in props] |
| |
| |
| session_messages = state.get("session_messages", []) |
| recent_context = "" |
| if session_messages: |
| recent_messages = session_messages[-5:] |
| recent_context = "\n".join([f"{msg.get('role')}: {msg.get('content', '')}" for msg in recent_messages]) |
| |
| prompt = f""" |
| Classify the user's message into exactly one of: |
| - search_listings (user explicitly 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, including greetings, goodbyes, general conversation, end of conversation, casual location mentions) |
| |
| IMPORTANT: Only classify as "search_listings" if the user is EXPLICITLY asking to see properties, listings, or available options. |
| |
| Available properties for context: {prop_titles} |
| |
| Recent conversation context: |
| {recent_context} |
| |
| Property identifier extraction rules: |
| 1. If user says "option X" or "option X images" → request_images:option X |
| 2. If user mentions a property type (warehouse, office, space) and it matches a property title → request_images:PROPERTY_TYPE |
| 3. If user says "this property", "that property", "the property" → request_images:this |
| 4. If user says "show me images" without specific reference → request_images |
| 5. If user mentions specific location/area that matches a property → request_images:LOCATION |
| 6. If user asks for images in context where only one property is being discussed → request_images:this |
| 7. If user uses pronouns like "it", "this", "that" when asking for images → request_images:this |
| |
| Context analysis: |
| - If the user has been discussing a specific property and now asks for images, classify as request_images:this |
| - If there's only one property available and user asks for images, classify as request_images:this |
| - If user asks for images after a property search, assume they want images of the most relevant property |
| |
| End of conversation indicators (classify as "other"): |
| - Goodbyes: "bye", "goodbye", "see you", "take care" |
| - Completion: "I'm all good", "that's all", "no thanks", "I'm done" |
| - General conversation: greetings, casual chat, non-property related topics |
| - Simple location mentions without asking for properties: "I'm in Johannesburg", "I live in Cape Town" |
| |
| Examples of search_listings (user explicitly asking for properties): |
| - "What properties do you have in JHB?" |
| - "Show me available listings" |
| - "Do you have any properties for sale?" |
| - "Any properties available?" |
| - "Show me properties" |
| - "What do you have available?" |
| - "I'm looking for properties in Cape Town" |
| - "I want to rent a warehouse in Johannesburg" |
| - "Looking to buy office space in Sandton" |
| - "Do you have any rentals available?" |
| - "What's for sale in Cape Town?" |
| |
| Examples of other (NOT property searches): |
| - "Hi" → other |
| - "Hello" → other |
| - "I'm in Johannesburg" → other |
| - "Johannesburg" → other |
| - "Cape Town" → other |
| - "How are you?" → other |
| - "I'm all good" → other |
| - "Goodbye" → other |
| - "Thanks for your help" → other |
| - "That's all I need" → other |
| |
| Examples of request_details: |
| - "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 |
| |
| Examples of request_images: |
| - "Show me images" → request_images |
| - "Show me images of option 1" → request_images:option 1 |
| - "I want to see the warehouse images" → request_images:warehouse |
| - "Show me pictures of this property" → request_images:this |
| - "Can I see images of it?" → request_images:this |
| - "Show me the images" → request_images:this |
| |
| 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"] |
| |
| |
| if intent.get("transaction_type") is not None: |
| filters["transaction_type"] = intent["transaction_type"] |
| |
| |
| 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 using AI. |
| """ |
| user_message = state["user_message"] |
| session_id = state["session_id"] |
| |
| |
| prompt = f""" |
| Analyze this user message and determine if they want to end the conversation or are saying goodbye. |
| |
| User message: "{user_message}" |
| |
| Consider these scenarios as "end conversation": |
| - User is satisfied and ending the conversation (e.g., "I'm all good", "that's all", "no thanks") |
| - User is saying goodbye (e.g., "bye", "goodbye", "see you", "take care") |
| - User is declining further assistance (e.g., "not interested", "maybe later", "I'll think about it") |
| - User is indicating they're done (e.g., "I'm done", "that's it", "nothing else") |
| - User is politely ending the conversation (e.g., "thanks for your help", "appreciate it") |
| |
| Consider these scenarios as "continue conversation": |
| - User is asking questions about properties |
| - User is providing feedback but wants to continue |
| - User is making small talk but not ending |
| - User is asking for more information |
| |
| Respond with ONLY: |
| - "end" if the user wants to end the conversation |
| - "continue" if the user wants to continue the conversation |
| |
| Response:""" |
| |
| try: |
| response = await llm.ainvoke([{"role": "user", "content": prompt}]) |
| result = response.content.strip().lower() |
| |
| print(f"DEBUG - End chat AI detection: '{result}' for message: '{user_message}'") |
| |
| if result == "end": |
| await end_session(session_id) |
| return {"response": "Thanks for chatting! I've ended this session. Goodbye!"} |
| |
| return {"response": None} |
| |
| except Exception as e: |
| print(f"DEBUG - Error in end chat detection: {e}") |
| |
| return {"response": None} |
|
|
| |
| graph = StateGraph(ChatState) |
| graph.add_node("persona_update", RunnableLambda(extract_and_update_persona)) |
| graph.add_node("exit_check_early", RunnableLambda(detect_end_chat)) |
| 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", "exit_check_early") |
| graph.add_conditional_edges("exit_check_early", should_continue, { |
| True: "classify_intent", |
| False: END |
| }) |
| 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 [] |
| }) |
| |
| |
| classification = result.get("classification", "") |
| if classification.startswith("request_images"): |
| print("DEBUG - Processing image request in process_message") |
| |
| image_state = { |
| "user_message": user_message, |
| "properties": result.get("properties", []), |
| "classification": classification, |
| "session_messages": session_messages |
| } |
| |
| |
| image_messages = await handle_image_request(image_state) |
| |
| if image_messages: |
| |
| if session_id and wa_id and wamid: |
| await save_message(session_id, wa_id, wamid, "user", user_message) |
| |
| if isinstance(image_messages[0], str): |
| await save_message(session_id, wa_id, f"{wamid}_ai", "assistant", image_messages[0]) |
| |
| return { |
| "response": image_messages[0] if isinstance(image_messages[0], str) else "Here are the images you requested!", |
| "properties": result.get("properties", []), |
| "classification": classification, |
| "image_messages": image_messages |
| } |
| |
| |
| 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 |
| |
| |
| |
| if "warehouse" in content and "warehouse" in user_message: |
| for i, prop in enumerate(props): |
| if "warehouse" in prop.get("title", "").lower(): |
| selected_property_context = prop |
| print(f"DEBUG - Found user specifically asking for warehouse images: {prop.get('title')}") |
| break |
| elif "office" in content and "office" in user_message: |
| for i, prop in enumerate(props): |
| if "office" in prop.get("title", "").lower(): |
| selected_property_context = prop |
| print(f"DEBUG - Found user specifically asking for office images: {prop.get('title')}") |
| break |
|
|
| |
| if selected_property_context: |
| selected_property = selected_property_context |
| |
| |
| if not selected_property: |
| |
| user_message_lower = state["user_message"].lower() |
| is_pronoun_reference = any(word in user_message_lower for word in ["this", "that", "it", "the property", "the listing"]) |
| |
| if is_pronoun_reference: |
| print("DEBUG - User using pronoun/demonstrative reference, analyzing recent context") |
| |
| |
| recent_messages = session_messages[-10:] |
| property_mentions = {} |
| |
| for msg in recent_messages: |
| content = msg.get("content", "").lower() |
| for i, prop in enumerate(props): |
| title = prop.get("title", "").lower() |
| location = prop.get("location", "").lower() |
| city = prop.get("city", "").lower() |
| |
| |
| score = 0 |
| title_words = title.split() |
| |
| |
| for word in title_words[:3]: |
| if word in content: |
| score += 2 |
| |
| |
| if location in content: |
| score += 1 |
| |
| |
| if city in content: |
| score += 1 |
| |
| |
| message_index = recent_messages.index(msg) |
| recency_weight = 10 - message_index |
| score *= recency_weight |
| |
| if score > 0: |
| property_mentions[i] = property_mentions.get(i, 0) + score |
| print(f"DEBUG - Found mention of property {i} (score {score}): {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 pronoun context: {selected_property.get('title')}") |
| |
| |
| if not selected_property: |
| print("DEBUG - No clear property context found, asking user to specify") |
| 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 |