zenaight commited on
Commit ·
b7a94e7
1
Parent(s): e9ce725
peronsas new
Browse files- ai_chat.py +97 -322
- api_routes.py +1 -34
- database.py +32 -67
- main.py +6 -2
- persona_manager.py +0 -504
- supabase_setup.sql +13 -13
- whatsapp.py +2 -47
ai_chat.py
CHANGED
|
@@ -2,49 +2,15 @@ from langgraph.graph import StateGraph, END
|
|
| 2 |
from langchain_core.runnables import RunnableLambda
|
| 3 |
from typing import TypedDict
|
| 4 |
from config import llm, OPENAI_API_KEY
|
| 5 |
-
from database import get_session_messages, save_message,
|
| 6 |
-
from persona_manager import (
|
| 7 |
-
get_or_create_persona,
|
| 8 |
-
should_ask_persona_question,
|
| 9 |
-
parse_user_response,
|
| 10 |
-
update_persona_field,
|
| 11 |
-
PERSONA_FIELDS,
|
| 12 |
-
get_persona_summary,
|
| 13 |
-
extract_persona_from_message
|
| 14 |
-
)
|
| 15 |
-
from config import supabase
|
| 16 |
-
from datetime import datetime
|
| 17 |
-
import re
|
| 18 |
|
| 19 |
-
|
| 20 |
-
"""
|
| 21 |
-
try:
|
| 22 |
-
properties = await search_properties(city=city, min_size=min_size, features=features, price_type=price_type, limit=limit)
|
| 23 |
-
return properties
|
| 24 |
-
except Exception as e:
|
| 25 |
-
print(f"Error searching properties: {e}")
|
| 26 |
-
return []
|
| 27 |
-
|
| 28 |
-
async def get_property_details_for_ai(property_id):
|
| 29 |
-
"""Get detailed property information for AI responses"""
|
| 30 |
-
try:
|
| 31 |
-
properties = await search_properties(limit=100) # Get all properties
|
| 32 |
-
for prop in properties:
|
| 33 |
-
if prop['id'] == property_id:
|
| 34 |
-
return prop
|
| 35 |
-
return None
|
| 36 |
-
except Exception as e:
|
| 37 |
-
print(f"Error getting property details: {e}")
|
| 38 |
-
return None
|
| 39 |
-
|
| 40 |
-
async def chat_with_session_memory(state):
|
| 41 |
-
"""Chat function with session-based memory and persona collection"""
|
| 42 |
user_message = state["user_message"]
|
| 43 |
user_info = state.get("user_info", {})
|
| 44 |
session_id = state.get("session_id")
|
| 45 |
wa_id = state.get("wa_id")
|
| 46 |
wamid = state.get("wamid")
|
| 47 |
-
persona = state.get("persona", {})
|
| 48 |
|
| 49 |
# Get conversation history from database
|
| 50 |
session_messages = []
|
|
@@ -52,104 +18,48 @@ async def chat_with_session_memory(state):
|
|
| 52 |
# This will be populated by the async wrapper
|
| 53 |
session_messages = state.get("session_messages", [])
|
| 54 |
|
| 55 |
-
#
|
| 56 |
-
|
| 57 |
-
if
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
-
system_message =
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
{
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
- search_properties_for_ai(city=None, min_size=None, features=None, price_type=None, limit=5) - Search for properties in the database
|
| 78 |
-
|
| 79 |
-
When users ask about properties, search the database and provide the actual property listings with details like title, location, size, price, and features.
|
| 80 |
-
|
| 81 |
-
Respond naturally to the user's message: {user_message}"""
|
| 82 |
|
| 83 |
-
# Use LLM to generate response
|
| 84 |
try:
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
# Add recent conversation history for context
|
| 88 |
-
for msg in session_messages[-5:]: # Last 5 messages for context
|
| 89 |
-
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 90 |
-
|
| 91 |
-
messages.append({"role": "user", "content": user_message})
|
| 92 |
|
| 93 |
response = llm.invoke(messages)
|
| 94 |
-
ai_response = response.content
|
| 95 |
-
|
| 96 |
-
# If the AI response suggests it needs property information, search the database
|
| 97 |
-
if any(keyword in user_message.lower() for keyword in ["property", "properties", "warehouse", "office", "space", "johannesburg", "cape town", "pretoria", "durban"]):
|
| 98 |
-
# Extract city from persona or message
|
| 99 |
-
city = None
|
| 100 |
-
if persona.get("location_preference"):
|
| 101 |
-
city = persona["location_preference"]
|
| 102 |
-
|
| 103 |
-
# Search for properties
|
| 104 |
-
properties = await search_properties_for_ai(city=city, limit=5)
|
| 105 |
-
|
| 106 |
-
if properties:
|
| 107 |
-
# Create a detailed property response
|
| 108 |
-
property_response = "🏢 **Available Properties:**\n\n"
|
| 109 |
-
for i, prop in enumerate(properties[:3], 1):
|
| 110 |
-
property_response += f"**{i}. {prop['title']}**\n"
|
| 111 |
-
property_response += f"📍 {prop['location']}, {prop['city']}\n"
|
| 112 |
-
property_response += f"📏 {prop['size_sqm']} sqm • 💰 R{prop['price']:,.0f}/month\n"
|
| 113 |
-
if prop.get('listing_url'):
|
| 114 |
-
property_response += f"🔗 {prop['listing_url']}\n"
|
| 115 |
-
if prop.get('features'):
|
| 116 |
-
property_response += f"✨ {', '.join(prop.get('features', [])[:3])}\n"
|
| 117 |
-
property_response += "\n"
|
| 118 |
-
|
| 119 |
-
property_response += "Which property interests you? I can show you photos, provide more details, or help you schedule a viewing!"
|
| 120 |
-
|
| 121 |
-
return {
|
| 122 |
-
"response": property_response,
|
| 123 |
-
"user_message": user_message,
|
| 124 |
-
"ai_response": property_response,
|
| 125 |
-
"session_id": session_id,
|
| 126 |
-
"wa_id": wa_id,
|
| 127 |
-
"wamid": wamid,
|
| 128 |
-
"current_property": None,
|
| 129 |
-
"property_details": {}
|
| 130 |
-
}
|
| 131 |
|
|
|
|
| 132 |
return {
|
| 133 |
"response": ai_response,
|
| 134 |
"user_message": user_message,
|
| 135 |
"ai_response": ai_response,
|
| 136 |
"session_id": session_id,
|
| 137 |
"wa_id": wa_id,
|
| 138 |
-
"wamid": wamid,
|
| 139 |
-
"current_property": None,
|
| 140 |
-
"property_details": {}
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
except Exception as e:
|
| 144 |
-
print(f"Error in LangGraph: {e}")
|
| 145 |
-
return {
|
| 146 |
-
"response": "I'm having trouble processing your request right now. Could you please try again?",
|
| 147 |
-
"user_message": user_message,
|
| 148 |
-
"ai_response": "I'm having trouble processing your request right now. Could you please try again?",
|
| 149 |
-
"session_id": session_id,
|
| 150 |
-
"wa_id": wa_id,
|
| 151 |
"wamid": wamid
|
| 152 |
}
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
class ChatState(TypedDict):
|
| 155 |
user_message: str
|
|
@@ -160,230 +70,95 @@ class ChatState(TypedDict):
|
|
| 160 |
wamid: str
|
| 161 |
session_messages: list
|
| 162 |
persona: dict
|
| 163 |
-
current_property: str
|
| 164 |
-
property_details: dict
|
| 165 |
-
|
| 166 |
-
# --- Build LangGraph ---
|
| 167 |
-
graph = StateGraph(ChatState)
|
| 168 |
-
graph.add_node("chat", chat_with_session_memory)
|
| 169 |
-
graph.set_entry_point("chat")
|
| 170 |
-
graph.add_edge("chat", END)
|
| 171 |
-
chat_graph = graph.compile()
|
| 172 |
|
| 173 |
-
async def
|
| 174 |
-
|
| 175 |
-
#
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
# Try to extract city from message (improved pattern matching)
|
| 181 |
-
# Pattern 1: "in [city]"
|
| 182 |
-
match = re.search(r"in ([a-zA-Z ]+)", user_message, re.IGNORECASE)
|
| 183 |
-
if match:
|
| 184 |
-
city = match.group(1).strip()
|
| 185 |
-
else:
|
| 186 |
-
# Pattern 2: "show me [city]" or "show [city]"
|
| 187 |
-
match = re.search(r"show me ([a-zA-Z ]+)", user_message, re.IGNORECASE)
|
| 188 |
-
if match:
|
| 189 |
-
city = match.group(1).strip()
|
| 190 |
-
else:
|
| 191 |
-
# Pattern 3: "show [city]"
|
| 192 |
-
match = re.search(r"show ([a-zA-Z ]+)", user_message, re.IGNORECASE)
|
| 193 |
-
if match:
|
| 194 |
-
city = match.group(1).strip()
|
| 195 |
-
|
| 196 |
-
if not city:
|
| 197 |
-
return ["I'd be happy to help you find properties! Which city are you looking in?"]
|
| 198 |
-
|
| 199 |
-
# Determine search specificity based on user message
|
| 200 |
-
user_message_lower = user_message.lower()
|
| 201 |
-
|
| 202 |
-
# Check if this is a broad search (just city) or specific search
|
| 203 |
-
broad_search_indicators = [
|
| 204 |
-
"show me", "show", "listings", "properties", "warehouses", "eiendomme", "toon my"
|
| 205 |
-
]
|
| 206 |
-
|
| 207 |
-
specific_search_indicators = [
|
| 208 |
-
"matching", "that match", "with", "for my", "suitable for", "perfect for", "ideal for"
|
| 209 |
]
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
price_type = None # Don't filter by price type
|
| 218 |
-
|
| 219 |
-
print(f"Search type: {'Broad' if is_broad_search else 'Specific'}")
|
| 220 |
-
print(f"Searching properties with filters: city={city}, min_size={min_size}, features={features}, price_type={price_type}")
|
| 221 |
-
properties = await search_properties(city=city, min_size=min_size, features=features, price_type=price_type, limit=5)
|
| 222 |
-
print(f"Found {len(properties)} properties")
|
| 223 |
-
|
| 224 |
-
if not properties:
|
| 225 |
-
# Check if the city is actually in available cities
|
| 226 |
-
available_cities = await get_available_cities()
|
| 227 |
-
print(f"Available cities: {available_cities}")
|
| 228 |
-
|
| 229 |
-
if city.lower() in [c.lower() for c in available_cities]:
|
| 230 |
-
if is_broad_search:
|
| 231 |
-
msg = f"Sorry, we currently have no properties available in {city.title()}."
|
| 232 |
-
else:
|
| 233 |
-
msg = f"Sorry, we currently have no properties available in {city.title()} that match your specific requirements."
|
| 234 |
-
else:
|
| 235 |
-
msg = f"Sorry, we don't have any properties in {city.title()} at the moment."
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
# First message: Introduction
|
| 247 |
-
intro_msg = f"Great! I found {len(properties)} properties in {city.title()} that might interest you. "
|
| 248 |
-
if persona.get("size_preference_sqm"):
|
| 249 |
-
intro_msg += f"Based on your preference for {persona['size_preference_sqm']} sqm, "
|
| 250 |
-
intro_msg += "Here are my top recommendations:"
|
| 251 |
-
messages.append(intro_msg)
|
| 252 |
-
|
| 253 |
-
# Individual property messages with AI summary
|
| 254 |
-
for i, prop in enumerate(properties[:3], 1):
|
| 255 |
-
# Create AI summary for each property
|
| 256 |
-
summary_prompt = f"""
|
| 257 |
-
Summarize this property in a conversational, engaging way for a potential tenant:
|
| 258 |
|
| 259 |
-
|
| 260 |
-
Location: {prop['location']}, {prop['city']}
|
| 261 |
-
Size: {prop['size_sqm']} sqm
|
| 262 |
-
Price: R{prop['price']:,.0f} per month
|
| 263 |
-
Features: {', '.join(prop.get('features', []))}
|
| 264 |
-
Description: {prop.get('description', 'No description available')}
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
Make it sound like a real estate agent talking to a client. Be enthusiastic but professional. Keep it under 100 words.
|
| 269 |
"""
|
| 270 |
|
| 271 |
try:
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
property_interest = None
|
| 295 |
-
for indicator in property_indicators:
|
| 296 |
-
if indicator in user_message_lower:
|
| 297 |
-
property_interest = indicator
|
| 298 |
-
break
|
| 299 |
-
|
| 300 |
-
# If we can't identify a specific property, ask for clarification
|
| 301 |
-
if not property_interest:
|
| 302 |
-
return "I'd love to tell you more! Which property are you interested in? You can say 'Property 1', 'the first one', or describe it briefly."
|
| 303 |
-
|
| 304 |
-
# Ask what specific information they want
|
| 305 |
-
return f"Great choice! What would you like to know about {property_interest}? I can tell you about:\n\n📸 **Photos** - See the property\n💰 **Pricing** - Detailed cost breakdown\n🏗️ **Features** - All amenities and facilities\n📍 **Location** - Area details and accessibility\n📋 **Terms** - Lease conditions and requirements\n\nWhat interests you most?"
|
| 306 |
-
|
| 307 |
-
async def handle_property_info_request(user_message, session_messages, persona):
|
| 308 |
-
"""Handle when user requests specific property information"""
|
| 309 |
-
user_message_lower = user_message.lower()
|
| 310 |
-
|
| 311 |
-
# Determine what type of information they want
|
| 312 |
-
info_types = {
|
| 313 |
-
"photos": ["photos", "images", "pictures", "see", "look", "photo", "image"],
|
| 314 |
-
"pricing": ["pricing", "price", "cost", "rent", "lease", "monthly", "payment", "money"],
|
| 315 |
-
"features": ["features", "amenities", "facilities", "what's included", "whats included", "equipment", "utilities"],
|
| 316 |
-
"location": ["location", "area", "neighborhood", "address", "where", "access", "transport"],
|
| 317 |
-
"terms": ["terms", "conditions", "lease", "contract", "requirements", "deposit", "duration"]
|
| 318 |
-
}
|
| 319 |
-
|
| 320 |
-
requested_info = []
|
| 321 |
-
for info_type, keywords in info_types.items():
|
| 322 |
-
if any(keyword in user_message_lower for keyword in keywords):
|
| 323 |
-
requested_info.append(info_type)
|
| 324 |
-
|
| 325 |
-
if not requested_info:
|
| 326 |
-
# If we can't determine what they want, ask for clarification
|
| 327 |
-
return "I'm not sure what specific information you'd like. Could you tell me if you want to see photos, pricing details, features, location info, or lease terms?"
|
| 328 |
-
|
| 329 |
-
# Provide the requested information
|
| 330 |
-
response_parts = []
|
| 331 |
-
|
| 332 |
-
for info_type in requested_info:
|
| 333 |
-
if info_type == "photos":
|
| 334 |
-
response_parts.append("📸 **Photos**: I'll send you the property photos right away! (Note: In a real implementation, this would send actual images)")
|
| 335 |
-
elif info_type == "pricing":
|
| 336 |
-
response_parts.append("💰 **Pricing**: The property is R25,000 per month, including basic utilities. There's a 2-month deposit required and the lease is for 12 months minimum.")
|
| 337 |
-
elif info_type == "features":
|
| 338 |
-
response_parts.append("🏗️ **Features**: This property includes 24/7 security, loading docks, office space, parking for 10 vehicles, and fiber internet ready.")
|
| 339 |
-
elif info_type == "location":
|
| 340 |
-
response_parts.append("��� **Location**: Located in the industrial district with easy access to major highways. Close to shipping ports and has excellent transport links.")
|
| 341 |
-
elif info_type == "terms":
|
| 342 |
-
response_parts.append("📋 **Terms**: 12-month minimum lease, 2-month security deposit, utilities included, available immediately.")
|
| 343 |
-
|
| 344 |
-
response = "\n\n".join(response_parts)
|
| 345 |
-
response += "\n\nWould you like to know anything else about this property, or shall I show you other options?"
|
| 346 |
-
|
| 347 |
-
return response
|
| 348 |
|
| 349 |
-
async def process_message(user_message: str, user_info: dict = None, session_id: str = None, wa_id: str = None, wamid: str = None):
|
| 350 |
-
"""Process a message through the AI chat system with session memory
|
| 351 |
if user_info is None:
|
| 352 |
user_info = {}
|
| 353 |
|
| 354 |
-
#
|
| 355 |
-
if user_message.lower() in ["hi", "hello", "hey", "good morning", "good afternoon", "good evening", "morning", "afternoon", "evening"]:
|
| 356 |
-
return f"Hi {user_info.get('name', 'there')}! 👋\n\nI'm your property agent assistant. I can help you find industrial properties, warehouses, offices, and commercial spaces. What are you looking for today?"
|
| 357 |
-
|
| 358 |
-
# Get user persona
|
| 359 |
-
persona = await get_or_create_persona(wa_id) if wa_id else {}
|
| 360 |
-
|
| 361 |
-
# Extract persona information from message
|
| 362 |
-
extracted_persona = await extract_persona_from_message(user_message, persona)
|
| 363 |
-
updated_persona = persona.copy()
|
| 364 |
-
for field, value in extracted_persona.items():
|
| 365 |
-
if value is not None:
|
| 366 |
-
updated_persona[field] = value
|
| 367 |
-
|
| 368 |
-
# Get session messages
|
| 369 |
session_messages = []
|
| 370 |
if session_id:
|
| 371 |
-
session_messages = await get_session_messages(session_id, limit=
|
| 372 |
|
| 373 |
-
#
|
| 374 |
-
|
| 375 |
"user_message": user_message,
|
| 376 |
"user_info": user_info,
|
| 377 |
"session_id": session_id,
|
| 378 |
"wa_id": wa_id,
|
| 379 |
"wamid": wamid,
|
| 380 |
"session_messages": session_messages,
|
| 381 |
-
"persona":
|
| 382 |
-
|
| 383 |
-
"property_details": {}
|
| 384 |
-
}
|
| 385 |
|
| 386 |
-
#
|
| 387 |
-
|
|
|
|
|
|
|
| 388 |
|
| 389 |
return result["response"]
|
|
|
|
| 2 |
from langchain_core.runnables import RunnableLambda
|
| 3 |
from typing import TypedDict
|
| 4 |
from config import llm, OPENAI_API_KEY
|
| 5 |
+
from database import get_session_messages, save_message, update_user_persona
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
def chat_with_session_memory(state):
|
| 8 |
+
"""Chat function with session-based memory"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
user_message = state["user_message"]
|
| 10 |
user_info = state.get("user_info", {})
|
| 11 |
session_id = state.get("session_id")
|
| 12 |
wa_id = state.get("wa_id")
|
| 13 |
wamid = state.get("wamid")
|
|
|
|
| 14 |
|
| 15 |
# Get conversation history from database
|
| 16 |
session_messages = []
|
|
|
|
| 18 |
# This will be populated by the async wrapper
|
| 19 |
session_messages = state.get("session_messages", [])
|
| 20 |
|
| 21 |
+
# Add system message with user context
|
| 22 |
+
system_message = "You are a helpful and concise property agent."
|
| 23 |
+
if user_info.get("name") and user_info["name"] != "Unknown":
|
| 24 |
+
system_message += f" The user's name is {user_info['name']}."
|
| 25 |
|
| 26 |
+
p = state.get("persona", {})
|
| 27 |
+
system_message += (
|
| 28 |
+
f" The user prefers {p.get('language','[unspecified]')} and wants a {p.get('tone','neutral')} tone. "
|
| 29 |
+
f"They are {p.get('intent','[unspecified]')}ing with a budget up to {p.get('budget','any')} per month, "
|
| 30 |
+
f"prefer around {p.get('size_preference_sqm','any')} sqm in {p.get('location_preference','[anywhere]')}, "
|
| 31 |
+
f"and must-haves are {', '.join(p.get('must_have',[])) or '[none]'}. "
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Build messages array with history
|
| 35 |
+
messages = [{"role": "system", "content": system_message}]
|
| 36 |
+
|
| 37 |
+
# Add conversation history (last 10 messages)
|
| 38 |
+
for msg in session_messages[-30:]:
|
| 39 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 40 |
+
|
| 41 |
+
# Add current user message
|
| 42 |
+
messages.append({"role": "user", "content": user_message})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
|
|
|
| 44 |
try:
|
| 45 |
+
if not OPENAI_API_KEY:
|
| 46 |
+
return {"response": "Sorry, AI chat is not available. Please check your OpenAI API key configuration."}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
response = llm.invoke(messages)
|
| 49 |
+
ai_response = response.content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
# Save messages to database (this will be handled by the async wrapper)
|
| 52 |
return {
|
| 53 |
"response": ai_response,
|
| 54 |
"user_message": user_message,
|
| 55 |
"ai_response": ai_response,
|
| 56 |
"session_id": session_id,
|
| 57 |
"wa_id": wa_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
"wamid": wamid
|
| 59 |
}
|
| 60 |
+
except Exception as e:
|
| 61 |
+
print(f"Error in chat_with_session_memory: {e}")
|
| 62 |
+
return {"response": "Sorry, something went wrong: " + str(e)}
|
| 63 |
|
| 64 |
class ChatState(TypedDict):
|
| 65 |
user_message: str
|
|
|
|
| 70 |
wamid: str
|
| 71 |
session_messages: list
|
| 72 |
persona: dict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
+
async def extract_and_update_persona(state):
|
| 75 |
+
persona = state.get("persona", {})
|
| 76 |
+
# 1. Build a list of missing fields:
|
| 77 |
+
missing = [f for f in
|
| 78 |
+
("language","tone","intent","budget",
|
| 79 |
+
"size_preference_sqm","location_preference","must_have")
|
| 80 |
+
if not persona.get(f)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
]
|
| 82 |
+
# 2. If any fields are missing:
|
| 83 |
+
if missing:
|
| 84 |
+
# • Prompt your LLM to extract and normalize all missing fields from
|
| 85 |
+
# state["user_message"] in one shot.
|
| 86 |
+
# • Receive a dict of field→value pairs.
|
| 87 |
+
extraction_prompt = f"""
|
| 88 |
+
Extract the following information from this user message: {state["user_message"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
Required fields to extract:
|
| 91 |
+
- language: The language the user prefers (e.g., "English", "Afrikaans")
|
| 92 |
+
- tone: The communication style preference (e.g., "formal", "casual", "friendly")
|
| 93 |
+
- intent: What the user wants to do (e.g., "buy", "rent", "sell", "invest")
|
| 94 |
+
- budget: Monthly budget in currency (e.g., "2000 ZAR", "R3000")
|
| 95 |
+
- size_preference_sqm: Property size preference in square meters (e.g., "80", "120")
|
| 96 |
+
- location_preference: Preferred location or area (e.g., "Pretoria", "Johannesburg", "Cape Town")
|
| 97 |
+
- must_have: Essential features or requirements (e.g., "parking", "balcony", "2 bedrooms")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
Missing fields: {', '.join(missing)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
+
Return only a JSON object with the extracted values for the missing fields. If a field cannot be determined, use null.
|
|
|
|
|
|
|
| 102 |
"""
|
| 103 |
|
| 104 |
try:
|
| 105 |
+
response = await llm.ainvoke([{"role": "user", "content": extraction_prompt}])
|
| 106 |
+
extracted_data = response.content
|
| 107 |
+
|
| 108 |
+
import json
|
| 109 |
+
try:
|
| 110 |
+
extracted = json.loads(extracted_data)
|
| 111 |
+
# • Update the database and in‐memory state:
|
| 112 |
+
await update_user_persona(state["wa_id"], extracted)
|
| 113 |
+
persona.update(extracted)
|
| 114 |
+
state["persona"] = persona
|
| 115 |
+
except json.JSONDecodeError:
|
| 116 |
+
print(f"Failed to parse LLM response as JSON: {extracted_data}")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
print(f"Error extracting persona data: {e}")
|
| 119 |
|
| 120 |
+
# • If there are still missing fields after that:
|
| 121 |
+
still_missing = [f for f in missing if f not in persona]
|
| 122 |
+
if still_missing:
|
| 123 |
+
# Ask just one follow-up for the first missing field
|
| 124 |
+
state["response"] = f"What is your {still_missing[0].replace('_',' ')}?"
|
| 125 |
+
return state
|
| 126 |
+
# 3. No fields missing or all filled:
|
| 127 |
+
return {"response": None}
|
| 128 |
|
| 129 |
+
# --- Build LangGraph ---
|
| 130 |
+
graph = StateGraph(ChatState)
|
| 131 |
+
graph.add_node("persona_update", RunnableLambda(extract_and_update_persona))
|
| 132 |
+
graph.add_node("chat", RunnableLambda(chat_with_session_memory))
|
| 133 |
+
graph.set_entry_point("persona_update")
|
| 134 |
+
graph.add_edge("persona_update", "chat")
|
| 135 |
+
graph.add_edge("chat", END)
|
| 136 |
+
chat_graph = graph.compile()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
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):
|
| 139 |
+
"""Process a message through the AI chat system with session memory"""
|
| 140 |
if user_info is None:
|
| 141 |
user_info = {}
|
| 142 |
|
| 143 |
+
# Get session messages for context
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
session_messages = []
|
| 145 |
if session_id:
|
| 146 |
+
session_messages = await get_session_messages(session_id, limit=10)
|
| 147 |
|
| 148 |
+
# Process with AI
|
| 149 |
+
result = await chat_graph.ainvoke({
|
| 150 |
"user_message": user_message,
|
| 151 |
"user_info": user_info,
|
| 152 |
"session_id": session_id,
|
| 153 |
"wa_id": wa_id,
|
| 154 |
"wamid": wamid,
|
| 155 |
"session_messages": session_messages,
|
| 156 |
+
"persona": persona or {}
|
| 157 |
+
})
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
# Save messages to database
|
| 160 |
+
if session_id and wa_id and wamid:
|
| 161 |
+
await save_message(session_id, wa_id, wamid, "user", user_message)
|
| 162 |
+
await save_message(session_id, wa_id, f"{wamid}_ai", "assistant", result["response"])
|
| 163 |
|
| 164 |
return result["response"]
|
api_routes.py
CHANGED
|
@@ -7,7 +7,6 @@ from datetime import datetime
|
|
| 7 |
from config import VERIFY_TOKEN, supabase, OPENAI_API_KEY, WHATSAPP_API_TOKEN
|
| 8 |
from database import get_user, list_users, update_user_name
|
| 9 |
from ai_chat import process_message
|
| 10 |
-
from persona_manager import get_user_persona, get_persona_summary, update_persona_field
|
| 11 |
|
| 12 |
router = APIRouter()
|
| 13 |
|
|
@@ -85,36 +84,4 @@ async def update_user_endpoint(wa_id: str, name: str):
|
|
| 85 |
if user:
|
| 86 |
return user
|
| 87 |
else:
|
| 88 |
-
raise HTTPException(status_code=404, detail="User not found")
|
| 89 |
-
|
| 90 |
-
# --- Persona Management Endpoints ---
|
| 91 |
-
@router.get("/personas/{wa_id}")
|
| 92 |
-
async def get_persona_endpoint(wa_id: str):
|
| 93 |
-
"""Get user persona by WhatsApp ID"""
|
| 94 |
-
if not supabase:
|
| 95 |
-
raise HTTPException(status_code=503, detail="Database not configured")
|
| 96 |
-
|
| 97 |
-
persona = await get_user_persona(wa_id)
|
| 98 |
-
if persona:
|
| 99 |
-
return {
|
| 100 |
-
**persona,
|
| 101 |
-
"summary": get_persona_summary(persona)
|
| 102 |
-
}
|
| 103 |
-
else:
|
| 104 |
-
raise HTTPException(status_code=404, detail="Persona not found")
|
| 105 |
-
|
| 106 |
-
@router.put("/personas/{wa_id}")
|
| 107 |
-
async def update_persona_endpoint(wa_id: str, field: str, value: str):
|
| 108 |
-
"""Update a specific persona field"""
|
| 109 |
-
if not supabase:
|
| 110 |
-
raise HTTPException(status_code=503, detail="Database not configured")
|
| 111 |
-
|
| 112 |
-
success = await update_persona_field(wa_id, field, value)
|
| 113 |
-
if success:
|
| 114 |
-
persona = await get_user_persona(wa_id)
|
| 115 |
-
return {
|
| 116 |
-
**persona,
|
| 117 |
-
"summary": get_persona_summary(persona)
|
| 118 |
-
}
|
| 119 |
-
else:
|
| 120 |
-
raise HTTPException(status_code=404, detail="Persona not found")
|
|
|
|
| 7 |
from config import VERIFY_TOKEN, supabase, OPENAI_API_KEY, WHATSAPP_API_TOKEN
|
| 8 |
from database import get_user, list_users, update_user_name
|
| 9 |
from ai_chat import process_message
|
|
|
|
| 10 |
|
| 11 |
router = APIRouter()
|
| 12 |
|
|
|
|
| 84 |
if user:
|
| 85 |
return user
|
| 86 |
else:
|
| 87 |
+
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
database.py
CHANGED
|
@@ -181,78 +181,43 @@ async def update_user_name(wa_id: str, name: str):
|
|
| 181 |
return response.data[0] if response.data else None
|
| 182 |
except Exception as e:
|
| 183 |
print(f"Error updating user: {e}")
|
| 184 |
-
return None
|
| 185 |
-
|
| 186 |
-
# --- Property Listings ---
|
| 187 |
-
async def search_properties(
|
| 188 |
-
city: str = None,
|
| 189 |
-
min_size: int = None,
|
| 190 |
-
max_size: int = None,
|
| 191 |
-
min_price: float = None,
|
| 192 |
-
max_price: float = None,
|
| 193 |
-
features: list = None,
|
| 194 |
-
price_type: str = None,
|
| 195 |
-
limit: int = 10,
|
| 196 |
-
offset: int = 0
|
| 197 |
-
) -> list:
|
| 198 |
-
"""Search properties with flexible filters. Only returns active listings."""
|
| 199 |
-
if not supabase:
|
| 200 |
-
return []
|
| 201 |
-
try:
|
| 202 |
-
print(f"Database search - Starting query with city={city}")
|
| 203 |
-
query = supabase.table("properties").select("*").eq("is_active", True)
|
| 204 |
-
if city:
|
| 205 |
-
print(f"Adding city filter: {city}")
|
| 206 |
-
query = query.ilike("city", f"%{city}%")
|
| 207 |
-
if min_size:
|
| 208 |
-
query = query.gte("size_sqm", min_size)
|
| 209 |
-
if max_size:
|
| 210 |
-
query = query.lte("size_sqm", max_size)
|
| 211 |
-
if min_price:
|
| 212 |
-
query = query.gte("price", min_price)
|
| 213 |
-
if max_price:
|
| 214 |
-
query = query.lte("price", max_price)
|
| 215 |
-
if price_type:
|
| 216 |
-
query = query.eq("price_type", price_type)
|
| 217 |
-
if features:
|
| 218 |
-
for feature in features:
|
| 219 |
-
query = query.contains("features", [feature])
|
| 220 |
-
query = query.range(offset, offset + limit - 1)
|
| 221 |
-
query = query.order("is_featured", desc=True).order("updated_at", desc=True)
|
| 222 |
-
resp = query.execute()
|
| 223 |
-
print(f"Database search - Raw response: {resp.data}")
|
| 224 |
-
return resp.data if resp.data else []
|
| 225 |
-
except Exception as e:
|
| 226 |
-
print(f"Error searching properties: {e}")
|
| 227 |
-
return []
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
if not supabase:
|
| 232 |
-
return
|
|
|
|
| 233 |
try:
|
| 234 |
-
resp = supabase
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
except Exception as e:
|
| 237 |
-
print(f"Error getting
|
| 238 |
-
return
|
| 239 |
|
| 240 |
-
async def
|
| 241 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 242 |
if not supabase:
|
| 243 |
-
return
|
|
|
|
| 244 |
try:
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
if row.get("city"):
|
| 252 |
-
cities.add(row["city"].strip())
|
| 253 |
-
result = sorted(list(cities))
|
| 254 |
-
print(f"Available cities result: {result}")
|
| 255 |
-
return result
|
| 256 |
except Exception as e:
|
| 257 |
-
print(f"Error
|
| 258 |
-
return []
|
|
|
|
| 181 |
return response.data[0] if response.data else None
|
| 182 |
except Exception as e:
|
| 183 |
print(f"Error updating user: {e}")
|
| 184 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
+
# --- Persona Management ---
|
| 187 |
+
async def get_user_persona(wa_id: str) -> dict:
|
| 188 |
+
"""
|
| 189 |
+
Fetch the persona row for this user.
|
| 190 |
+
Returns an empty dict if no data exists.
|
| 191 |
+
"""
|
| 192 |
if not supabase:
|
| 193 |
+
return {}
|
| 194 |
+
|
| 195 |
try:
|
| 196 |
+
resp = supabase\
|
| 197 |
+
.table("personas")\
|
| 198 |
+
.select("*")\
|
| 199 |
+
.eq("wa_id", wa_id)\
|
| 200 |
+
.single()\
|
| 201 |
+
.execute()
|
| 202 |
+
return resp.data or {}
|
| 203 |
except Exception as e:
|
| 204 |
+
print(f"Error getting user persona: {e}")
|
| 205 |
+
return {}
|
| 206 |
|
| 207 |
+
async def update_user_persona(wa_id: str, updates: dict):
|
| 208 |
+
"""
|
| 209 |
+
Apply partial updates to the persona row.
|
| 210 |
+
Automatically sets `updated_at` to now().
|
| 211 |
+
"""
|
| 212 |
if not supabase:
|
| 213 |
+
return
|
| 214 |
+
|
| 215 |
try:
|
| 216 |
+
updates_with_ts = {**updates, "updated_at": datetime.utcnow().isoformat()}
|
| 217 |
+
supabase\
|
| 218 |
+
.table("personas")\
|
| 219 |
+
.update(updates_with_ts)\
|
| 220 |
+
.eq("wa_id", wa_id)\
|
| 221 |
+
.execute()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
except Exception as e:
|
| 223 |
+
print(f"Error updating user persona: {e}")
|
|
|
main.py
CHANGED
|
@@ -3,7 +3,7 @@ from fastapi.responses import JSONResponse
|
|
| 3 |
|
| 4 |
# Import our modular components
|
| 5 |
from config import supabase
|
| 6 |
-
from database import get_or_create_user, update_user_activity, get_or_create_active_session
|
| 7 |
from whatsapp import send_whatsapp_message
|
| 8 |
from ai_chat import process_message
|
| 9 |
from api_routes import router
|
|
@@ -46,13 +46,17 @@ async def receive_message(req: Request):
|
|
| 46 |
# Get or create active session
|
| 47 |
session = await get_or_create_active_session(wa_id)
|
| 48 |
|
|
|
|
|
|
|
|
|
|
| 49 |
# Process with AI including session memory
|
| 50 |
ai_response = await process_message(
|
| 51 |
user_message=user_message,
|
| 52 |
user_info=user_info,
|
| 53 |
session_id=session["id"],
|
| 54 |
wa_id=wa_id,
|
| 55 |
-
wamid=wamid
|
|
|
|
| 56 |
)
|
| 57 |
|
| 58 |
# Send response back to WhatsApp
|
|
|
|
| 3 |
|
| 4 |
# Import our modular components
|
| 5 |
from config import supabase
|
| 6 |
+
from database import get_or_create_user, update_user_activity, get_or_create_active_session, get_user_persona
|
| 7 |
from whatsapp import send_whatsapp_message
|
| 8 |
from ai_chat import process_message
|
| 9 |
from api_routes import router
|
|
|
|
| 46 |
# Get or create active session
|
| 47 |
session = await get_or_create_active_session(wa_id)
|
| 48 |
|
| 49 |
+
# Get user persona
|
| 50 |
+
persona = await get_user_persona(wa_id)
|
| 51 |
+
|
| 52 |
# Process with AI including session memory
|
| 53 |
ai_response = await process_message(
|
| 54 |
user_message=user_message,
|
| 55 |
user_info=user_info,
|
| 56 |
session_id=session["id"],
|
| 57 |
wa_id=wa_id,
|
| 58 |
+
wamid=wamid,
|
| 59 |
+
persona=persona
|
| 60 |
)
|
| 61 |
|
| 62 |
# Send response back to WhatsApp
|
persona_manager.py
DELETED
|
@@ -1,504 +0,0 @@
|
|
| 1 |
-
from datetime import datetime
|
| 2 |
-
from typing import Dict, List, Optional, Tuple
|
| 3 |
-
from config import supabase, llm, OPENAI_API_KEY
|
| 4 |
-
|
| 5 |
-
# Persona field definitions and their collection prompts
|
| 6 |
-
PERSONA_FIELDS = {
|
| 7 |
-
"intent": {
|
| 8 |
-
"prompt": "Great! Are you looking to buy a place, or maybe lease for now?",
|
| 9 |
-
"clarification": "Great question! Buying gives you long-term control and equity, but requires a larger upfront investment. Leasing gives you flexibility and lower initial costs, but you don't build equity. Both have their benefits - what feels right for your situation?",
|
| 10 |
-
"skip_response": "No problem at all. We can always revisit that later."
|
| 11 |
-
},
|
| 12 |
-
"location_preference": {
|
| 13 |
-
"prompt": "Any specific areas you'd prefer to be based in?",
|
| 14 |
-
"clarification": "I can help you find properties in any area! Some popular industrial areas include downtown, suburbs, industrial districts, and warehouse zones. Each has different benefits - downtown for accessibility, suburbs for space, industrial areas for zoning. What kind of location works best for your business?",
|
| 15 |
-
"skip_response": "No problem at all. We can always revisit that later."
|
| 16 |
-
},
|
| 17 |
-
"size_preference_sqm": {
|
| 18 |
-
"prompt": "Do you have a rough size in mind? For example, 1500 or 3000 sqm?",
|
| 19 |
-
"clarification": "Property sizes can vary a lot! Small workshops might be 500-1000 sqm, medium warehouses 1500-3000 sqm, and large facilities 5000+ sqm. Think about your current space needs and future growth. What kind of operations will you be running?",
|
| 20 |
-
"skip_response": "No problem at all. We can always revisit that later."
|
| 21 |
-
},
|
| 22 |
-
"budget": {
|
| 23 |
-
"prompt": "Is there a budget you'd like me to work within?",
|
| 24 |
-
"clarification": "Budget helps me find the right properties for you! Industrial properties can range from $200k for small units to millions for large facilities. We can discuss options at any price point - what's comfortable for your business?",
|
| 25 |
-
"skip_response": "No problem at all. We can always revisit that later."
|
| 26 |
-
},
|
| 27 |
-
"must_have": {
|
| 28 |
-
"prompt": "Anything important that's a must-have? Like truck access or yard space?",
|
| 29 |
-
"clarification": "Must-haves are features you really need for your business! Common ones include loading docks, high ceilings, truck access, yard space, office areas, parking, or specific zoning. What features are essential for your operations?",
|
| 30 |
-
"skip_response": "No problem at all. We can always revisit that later."
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
async def get_user_persona(wa_id: str) -> Optional[Dict]:
|
| 35 |
-
"""Get user persona from database"""
|
| 36 |
-
if not supabase:
|
| 37 |
-
return None
|
| 38 |
-
|
| 39 |
-
try:
|
| 40 |
-
response = supabase.table("user_personas").select("*").eq("wa_id", wa_id).execute()
|
| 41 |
-
return response.data[0] if response.data else None
|
| 42 |
-
except Exception as e:
|
| 43 |
-
print(f"Error getting user persona: {e}")
|
| 44 |
-
return None
|
| 45 |
-
|
| 46 |
-
async def create_user_persona(wa_id: str) -> Dict:
|
| 47 |
-
"""Create a new user persona"""
|
| 48 |
-
if not supabase:
|
| 49 |
-
return {"wa_id": wa_id, "language": "English", "tone": "neutral"}
|
| 50 |
-
|
| 51 |
-
try:
|
| 52 |
-
new_persona = {
|
| 53 |
-
"wa_id": wa_id,
|
| 54 |
-
"language": "English",
|
| 55 |
-
"tone": "neutral",
|
| 56 |
-
"created_at": datetime.utcnow().isoformat(),
|
| 57 |
-
"updated_at": datetime.utcnow().isoformat()
|
| 58 |
-
}
|
| 59 |
-
response = supabase.table("user_personas").insert(new_persona).execute()
|
| 60 |
-
return response.data[0] if response.data else new_persona
|
| 61 |
-
except Exception as e:
|
| 62 |
-
print(f"Error creating user persona: {e}")
|
| 63 |
-
return {"wa_id": wa_id, "language": "English", "tone": "neutral"}
|
| 64 |
-
|
| 65 |
-
async def update_persona_field(wa_id: str, field: str, value) -> bool:
|
| 66 |
-
"""Update a specific field in user persona"""
|
| 67 |
-
if not supabase:
|
| 68 |
-
print(f"Supabase not configured - skipping update for {field}: {value}")
|
| 69 |
-
return False
|
| 70 |
-
|
| 71 |
-
try:
|
| 72 |
-
update_data = {
|
| 73 |
-
field: value,
|
| 74 |
-
"updated_at": datetime.utcnow().isoformat()
|
| 75 |
-
}
|
| 76 |
-
print(f"Updating persona field {field} with value {value} for user {wa_id}")
|
| 77 |
-
print(f"Update data: {update_data}")
|
| 78 |
-
|
| 79 |
-
# First check if the persona exists
|
| 80 |
-
check_response = supabase.table("user_personas").select("*").eq("wa_id", wa_id).execute()
|
| 81 |
-
print(f"Current persona data: {check_response.data}")
|
| 82 |
-
|
| 83 |
-
response = supabase.table("user_personas").update(update_data).eq("wa_id", wa_id).execute()
|
| 84 |
-
print(f"Persona update response: {response.data if response.data else 'No data returned'}")
|
| 85 |
-
print(f"Response status: {response.status_code if hasattr(response, 'status_code') else 'No status'}")
|
| 86 |
-
|
| 87 |
-
# Verify the update
|
| 88 |
-
verify_response = supabase.table("user_personas").select("*").eq("wa_id", wa_id).execute()
|
| 89 |
-
print(f"Updated persona data: {verify_response.data}")
|
| 90 |
-
|
| 91 |
-
return True
|
| 92 |
-
except Exception as e:
|
| 93 |
-
print(f"Error updating persona field {field}: {e}")
|
| 94 |
-
return False
|
| 95 |
-
|
| 96 |
-
async def get_or_create_persona(wa_id: str) -> Dict:
|
| 97 |
-
"""Get existing persona or create new one"""
|
| 98 |
-
persona = await get_user_persona(wa_id)
|
| 99 |
-
if persona:
|
| 100 |
-
return persona
|
| 101 |
-
else:
|
| 102 |
-
return await create_user_persona(wa_id)
|
| 103 |
-
|
| 104 |
-
def get_missing_fields(persona: Dict) -> List[str]:
|
| 105 |
-
"""Get list of missing persona fields"""
|
| 106 |
-
missing = []
|
| 107 |
-
for field in PERSONA_FIELDS.keys():
|
| 108 |
-
if not persona.get(field):
|
| 109 |
-
missing.append(field)
|
| 110 |
-
return missing
|
| 111 |
-
|
| 112 |
-
def get_next_field_to_ask(persona: Dict) -> Optional[str]:
|
| 113 |
-
"""Get the next field to ask about"""
|
| 114 |
-
missing = get_missing_fields(persona)
|
| 115 |
-
return missing[0] if missing else None
|
| 116 |
-
|
| 117 |
-
async def parse_user_response(user_message: str, field: str) -> Tuple[bool, any, str]:
|
| 118 |
-
"""
|
| 119 |
-
Parse user response for a specific field
|
| 120 |
-
Returns: (is_valid, parsed_value, response_message)
|
| 121 |
-
"""
|
| 122 |
-
if not OPENAI_API_KEY:
|
| 123 |
-
return False, None, "Sorry, I can't process that right now."
|
| 124 |
-
|
| 125 |
-
try:
|
| 126 |
-
# Create a structured prompt for parsing
|
| 127 |
-
system_prompt = f"""You are a property agent assistant. Parse the user's response for the field '{field}'.
|
| 128 |
-
|
| 129 |
-
Field: {field}
|
| 130 |
-
Field description: {PERSONA_FIELDS[field]['prompt']}
|
| 131 |
-
|
| 132 |
-
Parse the user's response and return ONLY a JSON object with these fields:
|
| 133 |
-
- "is_valid": boolean (true if user provided a valid answer)
|
| 134 |
-
- "value": the parsed value (null if invalid/not provided)
|
| 135 |
-
- "response": string message to send back to user
|
| 136 |
-
- "is_clarification": boolean (true if user asked for clarification)
|
| 137 |
-
- "is_skip": boolean (true if user wants to skip this question)
|
| 138 |
-
|
| 139 |
-
Rules:
|
| 140 |
-
- For intent: accept "buy", "lease", "rent", "purchase", "own"
|
| 141 |
-
- For budget: extract numeric values (e.g., "around 500k" -> 500000, "$500,000" -> 500000)
|
| 142 |
-
- For size: extract square meters (e.g., "1500 sqm" -> 1500, "10000 sq ft" -> 929)
|
| 143 |
-
- For location: extract location names
|
| 144 |
-
- For must_have: extract features as array (e.g., ["truck access", "yard space"])
|
| 145 |
-
- If user says "yes", "yeah", "sure" to a suggestion, extract the suggested value
|
| 146 |
-
- If user asks for clarification, set is_clarification=true
|
| 147 |
-
- If user says "not sure", "skip", "later", set is_skip=true
|
| 148 |
-
- Be generous in parsing - if you can reasonably extract a value, do so
|
| 149 |
-
"""
|
| 150 |
-
|
| 151 |
-
messages = [
|
| 152 |
-
{"role": "system", "content": system_prompt},
|
| 153 |
-
{"role": "user", "content": user_message}
|
| 154 |
-
]
|
| 155 |
-
|
| 156 |
-
response = llm.invoke(messages)
|
| 157 |
-
|
| 158 |
-
# Try to parse the JSON response
|
| 159 |
-
import json
|
| 160 |
-
try:
|
| 161 |
-
result = json.loads(response.content)
|
| 162 |
-
return (
|
| 163 |
-
result.get("is_valid", False),
|
| 164 |
-
result.get("value"),
|
| 165 |
-
result.get("response", "I understand. Let me know if you need anything else.")
|
| 166 |
-
)
|
| 167 |
-
except json.JSONDecodeError:
|
| 168 |
-
# Fallback parsing
|
| 169 |
-
return await fallback_parse_response(user_message, field)
|
| 170 |
-
|
| 171 |
-
except Exception as e:
|
| 172 |
-
print(f"Error parsing user response: {e}")
|
| 173 |
-
return False, None, "I didn't quite catch that. Could you rephrase?"
|
| 174 |
-
|
| 175 |
-
async def fallback_parse_response(user_message: str, field: str) -> Tuple[bool, any, str]:
|
| 176 |
-
"""Fallback parsing when LLM parsing fails"""
|
| 177 |
-
user_message_lower = user_message.lower()
|
| 178 |
-
|
| 179 |
-
# Check for clarification requests - more comprehensive
|
| 180 |
-
clarification_words = [
|
| 181 |
-
"what", "how", "explain", "difference", "mean", "clarify", "tell me",
|
| 182 |
-
"what do you mean", "what does", "how does", "can you explain",
|
| 183 |
-
"i don't understand", "not sure what", "what's the difference"
|
| 184 |
-
]
|
| 185 |
-
|
| 186 |
-
# Check for questions about the field specifically
|
| 187 |
-
field_specific_clarifications = {
|
| 188 |
-
"intent": ["buy", "lease", "purchase", "rent", "owning", "owning vs leasing"],
|
| 189 |
-
"size_preference_sqm": ["size", "sqm", "square meters", "how big", "dimensions"],
|
| 190 |
-
"budget": ["budget", "cost", "price", "how much", "expensive"],
|
| 191 |
-
"location_preference": ["location", "area", "where", "place"],
|
| 192 |
-
"must_have": ["must have", "features", "requirements", "need", "essential"]
|
| 193 |
-
}
|
| 194 |
-
|
| 195 |
-
# Check for general clarification requests
|
| 196 |
-
if any(word in user_message_lower for word in clarification_words):
|
| 197 |
-
return False, None, PERSONA_FIELDS[field]["clarification"]
|
| 198 |
-
|
| 199 |
-
# Check for field-specific clarification requests
|
| 200 |
-
if field in field_specific_clarifications:
|
| 201 |
-
field_words = field_specific_clarifications[field]
|
| 202 |
-
if any(word in user_message_lower for word in field_words):
|
| 203 |
-
return False, None, PERSONA_FIELDS[field]["clarification"]
|
| 204 |
-
|
| 205 |
-
# Check for affirmative responses (yes, sure, ok, etc.)
|
| 206 |
-
affirmative_words = ["yes", "yeah", "yep", "sure", "ok", "okay", "correct", "right", "that's right", "exactly"]
|
| 207 |
-
if any(word in user_message_lower for word in affirmative_words):
|
| 208 |
-
# For affirmative responses, we need to get the value from context
|
| 209 |
-
# This will be handled by the LLM parsing, but we can provide a fallback
|
| 210 |
-
return True, "confirmed", "Got it! Thanks for confirming.", False, False
|
| 211 |
-
|
| 212 |
-
# Check for skip requests
|
| 213 |
-
skip_words = ["not sure", "skip", "later", "don't know", "maybe later", "pass", "no idea"]
|
| 214 |
-
if any(word in user_message_lower for word in skip_words):
|
| 215 |
-
return True, None, PERSONA_FIELDS[field]["skip_response"], False, True
|
| 216 |
-
|
| 217 |
-
# Basic field-specific parsing
|
| 218 |
-
if field == "intent":
|
| 219 |
-
if any(word in user_message_lower for word in ["buy", "purchase", "own"]):
|
| 220 |
-
return True, "buy", "Got it, you're looking to buy. That's great!", False, False
|
| 221 |
-
elif any(word in user_message_lower for word in ["lease", "rent"]):
|
| 222 |
-
return True, "lease", "Perfect, leasing gives you flexibility.", False, False
|
| 223 |
-
|
| 224 |
-
elif field == "budget":
|
| 225 |
-
import re
|
| 226 |
-
# Look for currency patterns
|
| 227 |
-
budget_patterns = [
|
| 228 |
-
r'\$(\d+(?:,\d{3})*)',
|
| 229 |
-
r'(\d+(?:,\d{3})*)\s*dollars',
|
| 230 |
-
r'(\d+)\s*k',
|
| 231 |
-
r'(\d+)\s*thousand',
|
| 232 |
-
r'(\d+)\s*m',
|
| 233 |
-
r'(\d+)\s*million'
|
| 234 |
-
]
|
| 235 |
-
|
| 236 |
-
for pattern in budget_patterns:
|
| 237 |
-
match = re.search(pattern, user_message_lower)
|
| 238 |
-
if match:
|
| 239 |
-
budget_str = match.group(1).replace(',', '')
|
| 240 |
-
budget = int(budget_str)
|
| 241 |
-
|
| 242 |
-
# Apply multipliers
|
| 243 |
-
if "k" in pattern or "thousand" in pattern:
|
| 244 |
-
budget *= 1000
|
| 245 |
-
elif "m" in pattern or "million" in pattern:
|
| 246 |
-
budget *= 1000000
|
| 247 |
-
|
| 248 |
-
return True, budget, f"Thanks! I'll look for properties around ${budget:,}.", False, False
|
| 249 |
-
|
| 250 |
-
# Fallback: just look for numbers
|
| 251 |
-
numbers = re.findall(r'\d+', user_message)
|
| 252 |
-
if numbers:
|
| 253 |
-
budget = max(int(n) for n in numbers)
|
| 254 |
-
if "k" in user_message_lower or "thousand" in user_message_lower:
|
| 255 |
-
budget *= 1000
|
| 256 |
-
elif "m" in user_message_lower or "million" in user_message_lower:
|
| 257 |
-
budget *= 1000000
|
| 258 |
-
return True, budget, f"Thanks! I'll look for properties around ${budget:,}.", False, False
|
| 259 |
-
|
| 260 |
-
elif field == "size_preference_sqm":
|
| 261 |
-
import re
|
| 262 |
-
# Look for numbers followed by sqm, sq ft, square meters, etc.
|
| 263 |
-
size_patterns = [
|
| 264 |
-
r'(\d+)\s*sqm',
|
| 265 |
-
r'(\d+)\s*square\s*meters',
|
| 266 |
-
r'(\d+)\s*sq\s*ft',
|
| 267 |
-
r'(\d+)\s*square\s*feet',
|
| 268 |
-
r'(\d+)\s*ft',
|
| 269 |
-
r'(\d+)\s*feet'
|
| 270 |
-
]
|
| 271 |
-
|
| 272 |
-
for pattern in size_patterns:
|
| 273 |
-
match = re.search(pattern, user_message_lower)
|
| 274 |
-
if match:
|
| 275 |
-
size = int(match.group(1))
|
| 276 |
-
# Convert sq ft to sqm if needed
|
| 277 |
-
if 'ft' in pattern and 'sq' in pattern:
|
| 278 |
-
size = int(size * 0.0929) # Convert sq ft to sqm
|
| 279 |
-
return True, size, f"Perfect! {size} sqm should give you good options.", False, False
|
| 280 |
-
|
| 281 |
-
# Check for context-based size suggestions
|
| 282 |
-
if any(word in user_message_lower for word in ["manufacturing", "factory", "plant", "production"]):
|
| 283 |
-
if "large" in user_message_lower or "big" in user_message_lower:
|
| 284 |
-
return True, 5000, "Perfect! For a large manufacturing plant, I'd recommend around 5000 sqm. This gives you plenty of space for production lines, storage, and office areas.", False, False
|
| 285 |
-
else:
|
| 286 |
-
return True, 3000, "Great! For a manufacturing plant, I'd suggest around 3000 sqm. This should accommodate your production needs well.", False, False
|
| 287 |
-
|
| 288 |
-
# Fallback: just look for numbers
|
| 289 |
-
numbers = re.findall(r'\d+', user_message)
|
| 290 |
-
if numbers:
|
| 291 |
-
size = max(int(n) for n in numbers)
|
| 292 |
-
return True, size, f"Perfect! {size} sqm should give you good options.", False, False
|
| 293 |
-
|
| 294 |
-
elif field == "location_preference":
|
| 295 |
-
# Extract location names (basic approach)
|
| 296 |
-
locations = ["downtown", "suburb", "industrial", "warehouse district"]
|
| 297 |
-
found_location = None
|
| 298 |
-
for loc in locations:
|
| 299 |
-
if loc in user_message_lower:
|
| 300 |
-
found_location = loc
|
| 301 |
-
break
|
| 302 |
-
|
| 303 |
-
if found_location:
|
| 304 |
-
return True, found_location, f"Great! {found_location.title()} has good options.", False, False
|
| 305 |
-
else:
|
| 306 |
-
# Assume the whole message is a location
|
| 307 |
-
return True, user_message.strip(), f"Got it! I'll look in {user_message.strip()}.", False, False
|
| 308 |
-
|
| 309 |
-
elif field == "must_have":
|
| 310 |
-
features = []
|
| 311 |
-
feature_keywords = {
|
| 312 |
-
"truck": ["truck", "loading", "dock"],
|
| 313 |
-
"yard": ["yard", "space", "outdoor"],
|
| 314 |
-
"office": ["office", "admin"],
|
| 315 |
-
"parking": ["parking", "car"],
|
| 316 |
-
"high_ceiling": ["ceiling", "height", "tall"]
|
| 317 |
-
}
|
| 318 |
-
|
| 319 |
-
for feature, keywords in feature_keywords.items():
|
| 320 |
-
if any(keyword in user_message_lower for keyword in keywords):
|
| 321 |
-
features.append(feature)
|
| 322 |
-
|
| 323 |
-
if features:
|
| 324 |
-
return True, features, f"Perfect! I'll make sure to find places with {', '.join(features)}.", False, False
|
| 325 |
-
|
| 326 |
-
return False, None, "I didn't quite understand. Could you try again?", False, False
|
| 327 |
-
|
| 328 |
-
async def should_ask_persona_question(persona: Dict, conversation_context: str = "") -> Tuple[bool, Optional[str]]:
|
| 329 |
-
"""
|
| 330 |
-
Determine if we should ask a persona question using AI
|
| 331 |
-
Returns: (should_ask, field_to_ask)
|
| 332 |
-
"""
|
| 333 |
-
# Check if persona is complete
|
| 334 |
-
missing_fields = get_missing_fields(persona)
|
| 335 |
-
|
| 336 |
-
if not missing_fields:
|
| 337 |
-
return False, None
|
| 338 |
-
|
| 339 |
-
if not OPENAI_API_KEY:
|
| 340 |
-
# Fallback to basic keyword detection
|
| 341 |
-
conversation_lower = conversation_context.lower()
|
| 342 |
-
|
| 343 |
-
# Check for greetings
|
| 344 |
-
casual_indicators = ["hi", "hello", "hey", "thanks", "thank you", "bye", "goodbye"]
|
| 345 |
-
is_casual = any(indicator in conversation_lower for indicator in casual_indicators)
|
| 346 |
-
|
| 347 |
-
if is_casual:
|
| 348 |
-
return False, None
|
| 349 |
-
|
| 350 |
-
# Check for property-related keywords
|
| 351 |
-
search_indicators = ["property", "warehouse", "industrial", "space", "building", "looking for", "need", "find"]
|
| 352 |
-
is_searching = any(indicator in conversation_lower for indicator in search_indicators)
|
| 353 |
-
|
| 354 |
-
if is_searching:
|
| 355 |
-
return True, missing_fields[0]
|
| 356 |
-
|
| 357 |
-
return False, None
|
| 358 |
-
|
| 359 |
-
try:
|
| 360 |
-
# Use AI to determine if we should ask persona questions
|
| 361 |
-
system_prompt = f"""You are a property agent assistant. Determine if the user is showing interest in finding a property and needs persona questions asked.
|
| 362 |
-
|
| 363 |
-
Current conversation context: "{conversation_context}"
|
| 364 |
-
|
| 365 |
-
Available persona fields to ask about: {missing_fields}
|
| 366 |
-
|
| 367 |
-
Rules:
|
| 368 |
-
- If the user is just greeting (hi, hello, etc.) → return "no"
|
| 369 |
-
- If the user is asking about properties, locations, business needs, or showing interest in finding space → return "yes"
|
| 370 |
-
- If the user mentions locations (including abbreviations like cpt, pta, jhb) → return "yes"
|
| 371 |
-
- If the user mentions business types (clothing, manufacturing, etc.) → return "yes"
|
| 372 |
-
- If the user has already provided context about their needs (like "big enough for manufacturing plant") → return "no" (they've given enough context)
|
| 373 |
-
- If the user is frustrated or asking you to stop asking questions → return "no"
|
| 374 |
-
|
| 375 |
-
Return ONLY "yes" or "no"."""
|
| 376 |
-
|
| 377 |
-
messages = [
|
| 378 |
-
{"role": "system", "content": system_prompt},
|
| 379 |
-
{"role": "user", "content": conversation_context}
|
| 380 |
-
]
|
| 381 |
-
|
| 382 |
-
response = llm.invoke(messages)
|
| 383 |
-
should_ask = response.content.strip().lower() == "yes"
|
| 384 |
-
|
| 385 |
-
if should_ask:
|
| 386 |
-
return True, missing_fields[0]
|
| 387 |
-
else:
|
| 388 |
-
return False, None
|
| 389 |
-
|
| 390 |
-
except Exception as e:
|
| 391 |
-
print(f"Error in AI persona question detection: {e}")
|
| 392 |
-
return False, None
|
| 393 |
-
|
| 394 |
-
def get_persona_summary(persona: Dict) -> str:
|
| 395 |
-
"""Get a human-readable summary of the user's persona"""
|
| 396 |
-
summary_parts = []
|
| 397 |
-
|
| 398 |
-
if persona.get("intent"):
|
| 399 |
-
summary_parts.append(f"Looking to {persona['intent']}")
|
| 400 |
-
|
| 401 |
-
if persona.get("location_preference"):
|
| 402 |
-
summary_parts.append(f"in {persona['location_preference']}")
|
| 403 |
-
|
| 404 |
-
if persona.get("size_preference_sqm"):
|
| 405 |
-
summary_parts.append(f"around {persona['size_preference_sqm']} sqm")
|
| 406 |
-
|
| 407 |
-
if persona.get("budget"):
|
| 408 |
-
summary_parts.append(f"budget ~${persona['budget']:,}")
|
| 409 |
-
|
| 410 |
-
if persona.get("must_have"):
|
| 411 |
-
summary_parts.append(f"must have: {', '.join(persona['must_have'])}")
|
| 412 |
-
|
| 413 |
-
if summary_parts:
|
| 414 |
-
return " | ".join(summary_parts)
|
| 415 |
-
else:
|
| 416 |
-
return "New user - profile incomplete"
|
| 417 |
-
|
| 418 |
-
async def extract_persona_from_message(user_message: str, current_persona: Dict) -> Dict:
|
| 419 |
-
"""
|
| 420 |
-
Proactively extract persona fields from any user message using AI
|
| 421 |
-
Returns: dict of field -> value for any fields that can be extracted
|
| 422 |
-
"""
|
| 423 |
-
if not OPENAI_API_KEY:
|
| 424 |
-
return {}
|
| 425 |
-
|
| 426 |
-
try:
|
| 427 |
-
print(f"Starting AI extraction for message: '{user_message}'")
|
| 428 |
-
# Create a comprehensive prompt for AI extraction
|
| 429 |
-
system_prompt = """You are a property agent assistant. Extract any persona information from the user's message.
|
| 430 |
-
|
| 431 |
-
Available persona fields:
|
| 432 |
-
- intent: "buy" or "lease" (extract from words like buy, purchase, own, lease, rent)
|
| 433 |
-
- location_preference: Extract location names, ALWAYS use full names (e.g., "cpt" = "cape town", "jhb" = "johannesburg", "pta" = "pretoria")
|
| 434 |
-
- budget: Extract numeric values with currency/budget indicators (e.g., "500k" = 500000, "$1m" = 1000000)
|
| 435 |
-
- size_preference_sqm: Extract size in square meters (convert from sq ft if needed)
|
| 436 |
-
- must_have: Extract features as array (e.g., ["truck access", "office space"])
|
| 437 |
-
- language: Extract language preference (e.g., "afrikaans", "english", "afrikaans praat", "speak afrikaans")
|
| 438 |
-
|
| 439 |
-
IMPORTANT: You MUST return a valid JSON object. If no information is found, return {} (empty object).
|
| 440 |
-
For location_preference, be very generous in extraction and ALWAYS use full city names, not abbreviations.
|
| 441 |
-
For language, extract if user mentions language preference (e.g., "praat afrikaans", "speak english", etc.)
|
| 442 |
-
|
| 443 |
-
Examples:
|
| 444 |
-
- "I want a property in cpt" → {"location_preference": "cape town"}
|
| 445 |
-
- "Looking for warehouse in pta around 500k" → {"location_preference": "pretoria", "budget": 500000}
|
| 446 |
-
- "Looking for a property in pretoria for my clothing business" → {"location_preference": "pretoria"}
|
| 447 |
-
- "Need space for clothing business with office" → {"must_have": ["office"]}
|
| 448 |
-
- "I want one big enough for a phara manufacturing plant" → {"size_preference_sqm": 5000, "must_have": ["manufacturing"]}
|
| 449 |
-
- "Praat van nou af net afrikaans met my" → {"language": "afrikaans"}
|
| 450 |
-
- "Speak English from now on" → {"language": "english"}
|
| 451 |
-
|
| 452 |
-
Return ONLY the JSON object, no other text."""
|
| 453 |
-
|
| 454 |
-
messages = [
|
| 455 |
-
{"role": "system", "content": system_prompt},
|
| 456 |
-
{"role": "user", "content": user_message}
|
| 457 |
-
]
|
| 458 |
-
|
| 459 |
-
print(f"Calling LLM with messages: {messages}")
|
| 460 |
-
response = llm.invoke(messages)
|
| 461 |
-
print(f"LLM response received: {type(response)}")
|
| 462 |
-
|
| 463 |
-
print(f"AI extraction response: {response.content}") # Debug
|
| 464 |
-
print(f"User message being extracted: {user_message}") # Debug
|
| 465 |
-
|
| 466 |
-
# Parse the JSON response
|
| 467 |
-
import json
|
| 468 |
-
try:
|
| 469 |
-
extracted = json.loads(response.content)
|
| 470 |
-
print(f"Parsed extraction: {extracted}") # Debug
|
| 471 |
-
|
| 472 |
-
# Validate and clean the extracted data
|
| 473 |
-
cleaned_extracted = {}
|
| 474 |
-
|
| 475 |
-
# Include fields that can be extracted, even if they're already set (allow updates)
|
| 476 |
-
for field, value in extracted.items():
|
| 477 |
-
if field in PERSONA_FIELDS and value is not None:
|
| 478 |
-
cleaned_extracted[field] = value
|
| 479 |
-
|
| 480 |
-
print(f"Cleaned extraction: {cleaned_extracted}") # Debug
|
| 481 |
-
return cleaned_extracted
|
| 482 |
-
|
| 483 |
-
except json.JSONDecodeError:
|
| 484 |
-
print(f"Failed to parse AI extraction response: {response.content}")
|
| 485 |
-
print(f"Response type: {type(response.content)}")
|
| 486 |
-
print(f"Response length: {len(response.content)}")
|
| 487 |
-
# Try to clean the response
|
| 488 |
-
cleaned_response = response.content.strip()
|
| 489 |
-
if cleaned_response.startswith('```json'):
|
| 490 |
-
cleaned_response = cleaned_response[7:]
|
| 491 |
-
if cleaned_response.endswith('```'):
|
| 492 |
-
cleaned_response = cleaned_response[:-3]
|
| 493 |
-
cleaned_response = cleaned_response.strip()
|
| 494 |
-
try:
|
| 495 |
-
extracted = json.loads(cleaned_response)
|
| 496 |
-
print(f"Successfully parsed after cleaning: {extracted}")
|
| 497 |
-
return extracted
|
| 498 |
-
except:
|
| 499 |
-
print(f"Still failed after cleaning: {cleaned_response}")
|
| 500 |
-
return {}
|
| 501 |
-
|
| 502 |
-
except Exception as e:
|
| 503 |
-
print(f"Error in AI persona extraction: {e}")
|
| 504 |
-
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
supabase_setup.sql
CHANGED
|
@@ -26,14 +26,14 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
| 26 |
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 27 |
);
|
| 28 |
|
| 29 |
-
-- Create
|
| 30 |
-
CREATE TABLE IF NOT EXISTS
|
| 31 |
wa_id TEXT PRIMARY KEY REFERENCES users(wa_id) ON DELETE CASCADE,
|
| 32 |
-
language TEXT
|
| 33 |
-
tone TEXT
|
| 34 |
intent TEXT,
|
| 35 |
-
budget
|
| 36 |
-
size_preference_sqm
|
| 37 |
location_preference TEXT,
|
| 38 |
must_have TEXT[],
|
| 39 |
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
@@ -50,14 +50,14 @@ CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
|
| 50 |
CREATE INDEX IF NOT EXISTS idx_messages_wa_id ON messages(wa_id);
|
| 51 |
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
| 52 |
CREATE INDEX IF NOT EXISTS idx_messages_wamid ON messages(wamid);
|
| 53 |
-
CREATE INDEX IF NOT EXISTS
|
| 54 |
-
CREATE INDEX IF NOT EXISTS
|
| 55 |
|
| 56 |
-- Enable Row Level Security (RLS) - optional but recommended
|
| 57 |
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
| 58 |
ALTER TABLE chat_sessions ENABLE ROW LEVEL SECURITY;
|
| 59 |
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
| 60 |
-
ALTER TABLE
|
| 61 |
|
| 62 |
-- Create policies to allow all operations (you can restrict this based on your needs)
|
| 63 |
CREATE POLICY "Allow all operations on users" ON users
|
|
@@ -69,7 +69,7 @@ CREATE POLICY "Allow all operations on chat_sessions" ON chat_sessions
|
|
| 69 |
CREATE POLICY "Allow all operations on messages" ON messages
|
| 70 |
FOR ALL USING (true);
|
| 71 |
|
| 72 |
-
CREATE POLICY "Allow all operations on
|
| 73 |
FOR ALL USING (true);
|
| 74 |
|
| 75 |
-- Optional: Create a function to automatically update the updated_at timestamp
|
|
@@ -101,8 +101,8 @@ CREATE TRIGGER update_chat_sessions_last_activity
|
|
| 101 |
FOR EACH ROW
|
| 102 |
EXECUTE FUNCTION update_last_activity_column();
|
| 103 |
|
| 104 |
-
-- Create trigger to automatically update updated_at for
|
| 105 |
-
CREATE TRIGGER
|
| 106 |
-
BEFORE UPDATE ON
|
| 107 |
FOR EACH ROW
|
| 108 |
EXECUTE FUNCTION update_updated_at_column();
|
|
|
|
| 26 |
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 27 |
);
|
| 28 |
|
| 29 |
+
-- Create personas table for user preferences and context
|
| 30 |
+
CREATE TABLE IF NOT EXISTS personas (
|
| 31 |
wa_id TEXT PRIMARY KEY REFERENCES users(wa_id) ON DELETE CASCADE,
|
| 32 |
+
language TEXT,
|
| 33 |
+
tone TEXT,
|
| 34 |
intent TEXT,
|
| 35 |
+
budget TEXT,
|
| 36 |
+
size_preference_sqm TEXT,
|
| 37 |
location_preference TEXT,
|
| 38 |
must_have TEXT[],
|
| 39 |
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
|
|
| 50 |
CREATE INDEX IF NOT EXISTS idx_messages_wa_id ON messages(wa_id);
|
| 51 |
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
| 52 |
CREATE INDEX IF NOT EXISTS idx_messages_wamid ON messages(wamid);
|
| 53 |
+
CREATE INDEX IF NOT EXISTS idx_personas_wa_id ON personas(wa_id);
|
| 54 |
+
CREATE INDEX IF NOT EXISTS idx_personas_updated_at ON personas(updated_at DESC);
|
| 55 |
|
| 56 |
-- Enable Row Level Security (RLS) - optional but recommended
|
| 57 |
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
| 58 |
ALTER TABLE chat_sessions ENABLE ROW LEVEL SECURITY;
|
| 59 |
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
| 60 |
+
ALTER TABLE personas ENABLE ROW LEVEL SECURITY;
|
| 61 |
|
| 62 |
-- Create policies to allow all operations (you can restrict this based on your needs)
|
| 63 |
CREATE POLICY "Allow all operations on users" ON users
|
|
|
|
| 69 |
CREATE POLICY "Allow all operations on messages" ON messages
|
| 70 |
FOR ALL USING (true);
|
| 71 |
|
| 72 |
+
CREATE POLICY "Allow all operations on personas" ON personas
|
| 73 |
FOR ALL USING (true);
|
| 74 |
|
| 75 |
-- Optional: Create a function to automatically update the updated_at timestamp
|
|
|
|
| 101 |
FOR EACH ROW
|
| 102 |
EXECUTE FUNCTION update_last_activity_column();
|
| 103 |
|
| 104 |
+
-- Create trigger to automatically update updated_at for personas
|
| 105 |
+
CREATE TRIGGER update_personas_updated_at
|
| 106 |
+
BEFORE UPDATE ON personas
|
| 107 |
FOR EACH ROW
|
| 108 |
EXECUTE FUNCTION update_updated_at_column();
|
whatsapp.py
CHANGED
|
@@ -3,23 +3,6 @@ from config import PHONE_NUMBER_ID, WHATSAPP_API_TOKEN
|
|
| 3 |
|
| 4 |
async def send_whatsapp_message(wa_id: str, message: str):
|
| 5 |
"""Send a message via WhatsApp Business API"""
|
| 6 |
-
# Split message if it's too long (WhatsApp limit is ~4096 characters)
|
| 7 |
-
max_length = 3000 # Leave some buffer
|
| 8 |
-
|
| 9 |
-
if len(message) <= max_length:
|
| 10 |
-
# Single message
|
| 11 |
-
return await _send_single_message(wa_id, message)
|
| 12 |
-
else:
|
| 13 |
-
# Split into multiple messages
|
| 14 |
-
messages = _split_message(message, max_length)
|
| 15 |
-
success = True
|
| 16 |
-
for msg in messages:
|
| 17 |
-
if not await _send_single_message(wa_id, msg):
|
| 18 |
-
success = False
|
| 19 |
-
return success
|
| 20 |
-
|
| 21 |
-
async def _send_single_message(wa_id: str, message: str):
|
| 22 |
-
"""Send a single message via WhatsApp Business API"""
|
| 23 |
url = f"https://graph.facebook.com/v18.0/{PHONE_NUMBER_ID}/messages"
|
| 24 |
headers = {
|
| 25 |
"Authorization": f"Bearer {WHATSAPP_API_TOKEN}",
|
|
@@ -32,37 +15,9 @@ async def _send_single_message(wa_id: str, message: str):
|
|
| 32 |
"text": {"body": message}
|
| 33 |
}
|
| 34 |
try:
|
| 35 |
-
|
| 36 |
-
resp = await client.post(url, headers=headers, json=payload)
|
| 37 |
print(f"Sent message → {resp.status_code}: {resp.text}")
|
| 38 |
return resp.status_code == 200
|
| 39 |
except Exception as e:
|
| 40 |
print(f"Failed to send message: {e}")
|
| 41 |
-
return False
|
| 42 |
-
|
| 43 |
-
def _split_message(message: str, max_length: int):
|
| 44 |
-
"""Split a long message into smaller chunks"""
|
| 45 |
-
if len(message) <= max_length:
|
| 46 |
-
return [message]
|
| 47 |
-
|
| 48 |
-
# Split by double newlines first (to preserve property separations)
|
| 49 |
-
parts = message.split('\n\n')
|
| 50 |
-
messages = []
|
| 51 |
-
current_message = ""
|
| 52 |
-
|
| 53 |
-
for part in parts:
|
| 54 |
-
# If adding this part would exceed limit, start a new message
|
| 55 |
-
if len(current_message) + len(part) + 2 > max_length and current_message:
|
| 56 |
-
messages.append(current_message.strip())
|
| 57 |
-
current_message = part
|
| 58 |
-
else:
|
| 59 |
-
if current_message:
|
| 60 |
-
current_message += '\n\n' + part
|
| 61 |
-
else:
|
| 62 |
-
current_message = part
|
| 63 |
-
|
| 64 |
-
# Add the last message
|
| 65 |
-
if current_message:
|
| 66 |
-
messages.append(current_message.strip())
|
| 67 |
-
|
| 68 |
-
return messages
|
|
|
|
| 3 |
|
| 4 |
async def send_whatsapp_message(wa_id: str, message: str):
|
| 5 |
"""Send a message via WhatsApp Business API"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
url = f"https://graph.facebook.com/v18.0/{PHONE_NUMBER_ID}/messages"
|
| 7 |
headers = {
|
| 8 |
"Authorization": f"Bearer {WHATSAPP_API_TOKEN}",
|
|
|
|
| 15 |
"text": {"body": message}
|
| 16 |
}
|
| 17 |
try:
|
| 18 |
+
resp = await httpx.post(url, headers=headers, json=payload)
|
|
|
|
| 19 |
print(f"Sent message → {resp.status_code}: {resp.text}")
|
| 20 |
return resp.status_code == 200
|
| 21 |
except Exception as e:
|
| 22 |
print(f"Failed to send message: {e}")
|
| 23 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|