zenaight commited on
Commit
b7a94e7
·
1 Parent(s): e9ce725

peronsas new

Browse files
Files changed (7) hide show
  1. ai_chat.py +97 -322
  2. api_routes.py +1 -34
  3. database.py +32 -67
  4. main.py +6 -2
  5. persona_manager.py +0 -504
  6. supabase_setup.sql +13 -13
  7. 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, search_properties, get_available_cities
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
- async def search_properties_for_ai(city=None, min_size=None, features=None, price_type=None, limit=5):
20
- """Search properties for AI responses"""
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
- # Build context for the AI
56
- context = f"User: {user_info.get('name', 'there')}\n"
57
- if persona:
58
- context += f"Persona: {persona}\n"
59
 
60
- # Add system message with context
61
- system_message = f"""You are a friendly, professional industrial property agent assistant with access to a real property database. Be conversational and human-like in your responses.
62
-
63
- Context:
64
- {context}
65
-
66
- Key guidelines:
67
- - You are a property agent with access to real property listings in a database
68
- - You can search the database for properties using the search_properties_for_ai() function
69
- - When users ask about properties, search the database and provide real property information
70
- - NEVER say you can't help or suggest other websites - you have real property data
71
- - Be helpful and informative about industrial properties
72
- - Keep responses conversational and not robotic
73
- - Always respond naturally and conversationally
74
- - Use the conversation context to understand what the user needs
75
-
76
- Available functions:
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
- messages = [{"role": "system", "content": system_message}]
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.strip()
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 handle_property_search(user_message, persona):
174
- """Detects property search intent and returns AI-summarized property suggestions."""
175
- # Extract city from persona or message
176
- city = None
177
- if persona.get("location_preference"):
178
- city = persona["location_preference"]
179
- else:
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
- is_broad_search = any(indicator in user_message_lower for indicator in broad_search_indicators)
212
- is_specific_search = any(indicator in user_message_lower for indicator in specific_search_indicators)
213
-
214
- # Use ONLY location filter - make search very broad
215
- min_size = None # Don't filter by size for now
216
- features = None # Don't filter by features
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
- if available_cities:
238
- msg += f"\n\nWe do have properties in: {', '.join(available_cities)}."
239
- if city and city.lower() in [c.lower() for c in available_cities]:
240
- msg += f"\n\nTry asking for properties in {city.title()} without specific requirements, or let me know what you're looking for!"
241
- return [msg]
242
-
243
- # Create AI-summarized property suggestions
244
- messages = []
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
- Property: {prop['title']}
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
- User's preferences: {persona.get('size_preference_sqm', 'No size preference')} sqm, {persona.get('must_have', 'No specific features')}
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
- summary_response = llm.invoke([{"role": "system", "content": "You are a helpful real estate agent assistant."}, {"role": "user", "content": summary_prompt}])
273
- summary = summary_response.content.strip()
274
- except:
275
- # Fallback if AI fails
276
- summary = f"🏢 **Property {i}**: {prop['title']}\n📍 {prop['location']}, {prop['city']} • {prop['size_sqm']} sqm • R{prop['price']:,.0f}/month"
 
 
 
 
 
 
 
 
 
277
 
278
- messages.append(summary)
279
-
280
- # Final message: Interactive follow-up
281
- follow_up = "Which of these properties catches your eye? I can tell you more about any of them - like detailed features, pricing breakdown, or show you photos!"
282
- messages.append(follow_up)
283
-
284
- return messages
 
285
 
286
- async def handle_property_interest(user_message, persona):
287
- """Handle when user shows interest in a specific property"""
288
- user_message_lower = user_message.lower()
289
-
290
- # Try to identify which property they're interested in
291
- # Look for property numbers, titles, or descriptions
292
- property_indicators = ["property 1", "property 2", "property 3", "first", "second", "third", "1st", "2nd", "3rd"]
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 and persona collection"""
351
  if user_info is None:
352
  user_info = {}
353
 
354
- # Always handle greetings first, before any property logic
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=30)
372
 
373
- # Prepare state for LangGraph - let the AI handle everything
374
- state = {
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": updated_persona,
382
- "current_property": None,
383
- "property_details": {}
384
- }
385
 
386
- # Run LangGraph - the AI will handle property detection and responses naturally
387
- result = await chat_graph.ainvoke(state)
 
 
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
- async def get_property_by_id(property_id: str) -> dict:
230
- """Get a property by its ID."""
 
 
 
 
231
  if not supabase:
232
- return None
 
233
  try:
234
- resp = supabase.table("properties").select("*").eq("id", property_id).single().execute()
235
- return resp.data if resp.data else None
 
 
 
 
 
236
  except Exception as e:
237
- print(f"Error getting property by id: {e}")
238
- return None
239
 
240
- async def get_available_cities() -> list:
241
- """Get a list of all cities with active properties."""
 
 
 
242
  if not supabase:
243
- return []
 
244
  try:
245
- print("Getting available cities...")
246
- resp = supabase.table("properties").select("city").eq("is_active", True).execute()
247
- print(f"Available cities raw response: {resp.data}")
248
- cities = set()
249
- if resp.data:
250
- for row in resp.data:
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 getting available cities: {e}")
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 user_personas table
30
- CREATE TABLE IF NOT EXISTS user_personas (
31
  wa_id TEXT PRIMARY KEY REFERENCES users(wa_id) ON DELETE CASCADE,
32
- language TEXT DEFAULT 'English',
33
- tone TEXT DEFAULT 'neutral',
34
  intent TEXT,
35
- budget NUMERIC,
36
- size_preference_sqm INTEGER,
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 idx_user_personas_intent ON user_personas(intent);
54
- CREATE INDEX IF NOT EXISTS idx_user_personas_location ON user_personas(location_preference);
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 user_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,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 user_personas" ON user_personas
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 user_personas
105
- CREATE TRIGGER update_user_personas_updated_at
106
- BEFORE UPDATE ON user_personas
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
- async with httpx.AsyncClient() as client:
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