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('**')}") # Multiple passes to catch all patterns for i in range(3): # Up to 3 passes to catch nested patterns original_text = text # Fix double asterisks to single asterisks for bold (multiple patterns) 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) # Brute force: Replace any remaining ** with * text = text.replace('**', '*') if text == original_text: break # No more changes # Fix double underscores to single underscores for italic text = re.sub(r'__([^_]+)__', r'_\1_', text) text = text.replace('__', '_') # Fix markdown links [text](url) to WhatsApp format text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1: \2', text) # Remove any remaining square brackets text = re.sub(r'\[([^\]]+)\]', r'\1', text) # Final safety check remaining_double = text.count('**') print(f"DEBUG - After processing ** count: {remaining_double}") if '**' in text: print(f"DEBUG - Markdown still found after processing: {text}") # Nuclear option: replace ALL ** with * 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") # 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", "") classification = state.get("classification", "") # Check if this is an image request and handle it directly if classification.startswith("request_images"): print("DEBUG - Image request detected in chat function") # This will be handled by the async wrapper that calls handle_image_request # For now, just return a placeholder response 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 } # 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." "\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" # 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 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" ) # 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" # Show only key features in listings (first 3-4) features = p.get("features", []) if features: key_features = features[:4] # Show first 4 features only system_message += f" Key Features: {', '.join(key_features)}\n" if len(features) > 4: system_message += f" (+ {len(features) - 4} more features available)\n" # Don't show tags in listings, but make them available for questions 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" # 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 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" # Include FULL features and tags for AI knowledge (not shown to user unless asked) 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!" ) # Build messages array with history messages = [{"role": "system", "content": system_message}] # Add conversation history (last 30 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 # Debug: Check original response if '**' in ai_response: print(f"DEBUG - BEFORE processing: Found ** in response: {ai_response[:200]}...") # Post-process to fix any markdown formatting that slipped through ai_response = fix_whatsapp_formatting(ai_response) # Debug: Check after processing if '**' in ai_response: print(f"DEBUG - AFTER processing: Still found ** in response: {ai_response[:200]}...") # 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", {}) # Check if user is explicitly asking about or setting preferences is_preference_related = any(word in user_message.lower() for word in [ "language", "tone", "prefer", "speak", "formal", "casual", "friendly" ]) # Only extract persona if user is actually setting preferences or this is preference-related if is_preference_related: # 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_light.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 # Don't ask for preferences unless user explicitly asks about them # Let conversation flow naturally 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: # 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 } # If user is not actively searching for properties, don't ask preference questions # Let conversation flow naturally - only ask preferences when they're actually looking 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"] # Get available properties for context props = state.get("properties", []) prop_titles = [p.get("title", "").lower() for p in props] # Get recent conversation context session_messages = state.get("session_messages", []) recent_context = "" if session_messages: recent_messages = session_messages[-5:] # Last 5 messages 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") # 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"] # Add transaction type if set (rent vs buy) if intent.get("transaction_type") is not None: filters["transaction_type"] = intent["transaction_type"] # 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 using AI. """ user_message = state["user_message"] session_id = state["session_id"] # Use AI to detect if user wants to end the conversation 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}") # Fallback to continue conversation if AI fails return {"response": None} # --- Build LangGraph --- 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") # 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", "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 = {} # 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 [] }) # Check if this is an image request and handle it classification = result.get("classification", "") if classification.startswith("request_images"): print("DEBUG - Processing image request in process_message") # Create state for image request handling image_state = { "user_message": user_message, "properties": result.get("properties", []), "classification": classification, "session_messages": session_messages } # Handle image request image_messages = await handle_image_request(image_state) if image_messages: # Save the first text message to database if session_id and wa_id and wamid: await save_message(session_id, wa_id, wamid, "user", user_message) # Save the first text response 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 # Additional field for image handling } # 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 specific property type mentions that user explicitly asked about # Only if they specifically mentioned the property type in their image request 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 # Use the property the user specifically selected/discussed if selected_property_context: selected_property = selected_property_context # Enhanced context analysis for "this", "that", pronouns, etc. if not selected_property: # Check if user is using pronouns or demonstratives 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") # Look for the most recently discussed property in the conversation recent_messages = session_messages[-10:] # Last 10 messages 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() # Check for property mentions with higher weight for recent messages score = 0 title_words = title.split() # Check if property title words are mentioned for word in title_words[:3]: # First 3 words of title if word in content: score += 2 # Check if location is mentioned if location in content: score += 1 # Check if city is mentioned if city in content: score += 1 # Give higher weight to more recent messages message_index = recent_messages.index(msg) recency_weight = 10 - message_index # More recent = higher weight 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}") # Use most mentioned property from recent 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 pronoun context: {selected_property.get('title')}") # If still no property selected and user didn't specify, ask for clarification 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): # 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