zenaight commited on
Commit ·
bcd0eb8
1
Parent(s): a758b0c
user personas
Browse files- ai_chat.py +112 -7
- api_routes.py +34 -1
- persona_manager.py +284 -0
- supabase_setup.sql +27 -1
ai_chat.py
CHANGED
|
@@ -3,14 +3,25 @@ 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 |
|
| 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,15 +29,68 @@ def chat_with_session_memory(state):
|
|
| 18 |
# This will be populated by the async wrapper
|
| 19 |
session_messages = state.get("session_messages", [])
|
| 20 |
|
| 21 |
-
#
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
# Build messages array with history
|
| 27 |
messages = [{"role": "system", "content": system_message}]
|
| 28 |
|
| 29 |
-
# Add conversation history (last
|
| 30 |
for msg in session_messages[-30:]:
|
| 31 |
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 32 |
|
|
@@ -61,6 +125,15 @@ class ChatState(TypedDict):
|
|
| 61 |
wa_id: str
|
| 62 |
wamid: str
|
| 63 |
session_messages: list
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
# --- Build LangGraph ---
|
| 66 |
graph = StateGraph(ChatState)
|
|
@@ -70,14 +143,37 @@ graph.add_edge("chat", END)
|
|
| 70 |
chat_graph = graph.compile()
|
| 71 |
|
| 72 |
async def process_message(user_message: str, user_info: dict = None, session_id: str = None, wa_id: str = None, wamid: str = None):
|
| 73 |
-
"""Process a message through the AI chat system with session memory"""
|
| 74 |
if user_info is None:
|
| 75 |
user_info = {}
|
| 76 |
|
|
|
|
|
|
|
|
|
|
| 77 |
# Get session messages for context
|
| 78 |
session_messages = []
|
| 79 |
if session_id:
|
| 80 |
-
session_messages = await get_session_messages(session_id, limit=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
# Process with AI
|
| 83 |
result = await chat_graph.ainvoke({
|
|
@@ -86,9 +182,18 @@ async def process_message(user_message: str, user_info: dict = None, session_id:
|
|
| 86 |
"session_id": session_id,
|
| 87 |
"wa_id": wa_id,
|
| 88 |
"wamid": wamid,
|
| 89 |
-
"session_messages": session_messages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
})
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
# Save messages to database
|
| 93 |
if session_id and wa_id and wamid:
|
| 94 |
await save_message(session_id, wa_id, wamid, "user", user_message)
|
|
|
|
| 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 |
+
)
|
| 14 |
|
| 15 |
def chat_with_session_memory(state):
|
| 16 |
+
"""Chat function with session-based memory and persona collection"""
|
| 17 |
user_message = state["user_message"]
|
| 18 |
user_info = state.get("user_info", {})
|
| 19 |
session_id = state.get("session_id")
|
| 20 |
wa_id = state.get("wa_id")
|
| 21 |
wamid = state.get("wamid")
|
| 22 |
+
persona = state.get("persona", {})
|
| 23 |
+
is_persona_question = state.get("is_persona_question", False)
|
| 24 |
+
current_field = state.get("current_field")
|
| 25 |
|
| 26 |
# Get conversation history from database
|
| 27 |
session_messages = []
|
|
|
|
| 29 |
# This will be populated by the async wrapper
|
| 30 |
session_messages = state.get("session_messages", [])
|
| 31 |
|
| 32 |
+
# Check if we should handle persona collection
|
| 33 |
+
if is_persona_question and current_field:
|
| 34 |
+
# Handle persona question response
|
| 35 |
+
is_valid, parsed_value, response_message = state.get("parsed_response", (False, None, ""))
|
| 36 |
+
|
| 37 |
+
if is_valid:
|
| 38 |
+
# Update persona in database (handled by async wrapper)
|
| 39 |
+
return {
|
| 40 |
+
"response": response_message,
|
| 41 |
+
"user_message": user_message,
|
| 42 |
+
"ai_response": response_message,
|
| 43 |
+
"session_id": session_id,
|
| 44 |
+
"wa_id": wa_id,
|
| 45 |
+
"wamid": wamid,
|
| 46 |
+
"update_persona": True,
|
| 47 |
+
"persona_field": current_field,
|
| 48 |
+
"persona_value": parsed_value
|
| 49 |
+
}
|
| 50 |
+
else:
|
| 51 |
+
# Invalid response, ask for clarification or re-ask
|
| 52 |
+
return {
|
| 53 |
+
"response": response_message,
|
| 54 |
+
"user_message": user_message,
|
| 55 |
+
"ai_response": response_message,
|
| 56 |
+
"session_id": session_id,
|
| 57 |
+
"wa_id": wa_id,
|
| 58 |
+
"wamid": wamid,
|
| 59 |
+
"ask_persona_question": True,
|
| 60 |
+
"current_field": current_field
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# Regular conversation - check if we should ask persona questions
|
| 64 |
+
should_ask, field_to_ask = state.get("persona_check", (False, None))
|
| 65 |
+
|
| 66 |
+
if should_ask and field_to_ask:
|
| 67 |
+
# Ask persona question
|
| 68 |
+
question = PERSONA_FIELDS[field_to_ask]["prompt"]
|
| 69 |
+
return {
|
| 70 |
+
"response": question,
|
| 71 |
+
"user_message": user_message,
|
| 72 |
+
"ai_response": question,
|
| 73 |
+
"session_id": session_id,
|
| 74 |
+
"wa_id": wa_id,
|
| 75 |
+
"wamid": wamid,
|
| 76 |
+
"ask_persona_question": True,
|
| 77 |
+
"current_field": field_to_ask
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
# Add system message with user context and persona
|
| 81 |
+
system_message = "You are a helpful industrial property agent assistant."
|
| 82 |
if user_info.get("name") and user_info["name"] != "Unknown":
|
| 83 |
system_message += f" The user's name is {user_info['name']}."
|
| 84 |
|
| 85 |
+
# Add persona context if available
|
| 86 |
+
persona_summary = get_persona_summary(persona)
|
| 87 |
+
if persona_summary != "New user - profile incomplete":
|
| 88 |
+
system_message += f" User profile: {persona_summary}. Use this information to provide relevant property advice."
|
| 89 |
+
|
| 90 |
# Build messages array with history
|
| 91 |
messages = [{"role": "system", "content": system_message}]
|
| 92 |
|
| 93 |
+
# Add conversation history (last 30 messages)
|
| 94 |
for msg in session_messages[-30:]:
|
| 95 |
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 96 |
|
|
|
|
| 125 |
wa_id: str
|
| 126 |
wamid: str
|
| 127 |
session_messages: list
|
| 128 |
+
persona: dict
|
| 129 |
+
is_persona_question: bool
|
| 130 |
+
current_field: str
|
| 131 |
+
parsed_response: tuple
|
| 132 |
+
persona_check: tuple
|
| 133 |
+
update_persona: bool
|
| 134 |
+
persona_field: str
|
| 135 |
+
persona_value: any
|
| 136 |
+
ask_persona_question: bool
|
| 137 |
|
| 138 |
# --- Build LangGraph ---
|
| 139 |
graph = StateGraph(ChatState)
|
|
|
|
| 143 |
chat_graph = graph.compile()
|
| 144 |
|
| 145 |
async def process_message(user_message: str, user_info: dict = None, session_id: str = None, wa_id: str = None, wamid: str = None):
|
| 146 |
+
"""Process a message through the AI chat system with session memory and persona collection"""
|
| 147 |
if user_info is None:
|
| 148 |
user_info = {}
|
| 149 |
|
| 150 |
+
# Get user persona
|
| 151 |
+
persona = await get_or_create_persona(wa_id) if wa_id else {}
|
| 152 |
+
|
| 153 |
# Get session messages for context
|
| 154 |
session_messages = []
|
| 155 |
if session_id:
|
| 156 |
+
session_messages = await get_session_messages(session_id, limit=30)
|
| 157 |
+
|
| 158 |
+
# Check if we should ask persona questions
|
| 159 |
+
conversation_context = " ".join([msg["content"] for msg in session_messages[-5:]]) + " " + user_message
|
| 160 |
+
should_ask, field_to_ask = await should_ask_persona_question(persona, conversation_context)
|
| 161 |
+
|
| 162 |
+
# Check if this is a response to a persona question
|
| 163 |
+
is_persona_question = False
|
| 164 |
+
current_field = None
|
| 165 |
+
parsed_response = (False, None, "")
|
| 166 |
+
|
| 167 |
+
# Check if the last AI message was a persona question
|
| 168 |
+
if session_messages and session_messages[-1]["role"] == "assistant":
|
| 169 |
+
last_ai_message = session_messages[-1]["content"]
|
| 170 |
+
for field, field_info in PERSONA_FIELDS.items():
|
| 171 |
+
if field_info["prompt"] in last_ai_message:
|
| 172 |
+
is_persona_question = True
|
| 173 |
+
current_field = field
|
| 174 |
+
# Parse user response
|
| 175 |
+
parsed_response = await parse_user_response(user_message, field)
|
| 176 |
+
break
|
| 177 |
|
| 178 |
# Process with AI
|
| 179 |
result = await chat_graph.ainvoke({
|
|
|
|
| 182 |
"session_id": session_id,
|
| 183 |
"wa_id": wa_id,
|
| 184 |
"wamid": wamid,
|
| 185 |
+
"session_messages": session_messages,
|
| 186 |
+
"persona": persona,
|
| 187 |
+
"is_persona_question": is_persona_question,
|
| 188 |
+
"current_field": current_field,
|
| 189 |
+
"parsed_response": parsed_response,
|
| 190 |
+
"persona_check": (should_ask, field_to_ask)
|
| 191 |
})
|
| 192 |
|
| 193 |
+
# Handle persona updates
|
| 194 |
+
if result.get("update_persona") and wa_id:
|
| 195 |
+
await update_persona_field(wa_id, result["persona_field"], result["persona_value"])
|
| 196 |
+
|
| 197 |
# Save messages to database
|
| 198 |
if session_id and wa_id and wamid:
|
| 199 |
await save_message(session_id, wa_id, wamid, "user", user_message)
|
api_routes.py
CHANGED
|
@@ -7,6 +7,7 @@ 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 |
|
| 11 |
router = APIRouter()
|
| 12 |
|
|
@@ -84,4 +85,36 @@ async def update_user_endpoint(wa_id: str, name: str):
|
|
| 84 |
if user:
|
| 85 |
return user
|
| 86 |
else:
|
| 87 |
-
raise HTTPException(status_code=404, detail="User 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 |
+
from persona_manager import get_user_persona, get_persona_summary, update_persona_field
|
| 11 |
|
| 12 |
router = APIRouter()
|
| 13 |
|
|
|
|
| 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")
|
persona_manager.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "Are you looking to buy a place, or maybe lease for now?",
|
| 9 |
+
"clarification": "Great question. Buying gives you long-term control, but leasing gives flexibility. Happy to guide you either way.",
|
| 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. Just let me know what locations work best for you.",
|
| 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": "Size can vary a lot - from small workshops to large warehouses. What kind of space do you need?",
|
| 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. We can discuss options at any price point.",
|
| 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, like loading docks, high ceilings, or specific zoning.",
|
| 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 |
+
return False
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
update_data = {
|
| 72 |
+
field: value,
|
| 73 |
+
"updated_at": datetime.utcnow().isoformat()
|
| 74 |
+
}
|
| 75 |
+
supabase.table("user_personas").update(update_data).eq("wa_id", wa_id).execute()
|
| 76 |
+
return True
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"Error updating persona field {field}: {e}")
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
async def get_or_create_persona(wa_id: str) -> Dict:
|
| 82 |
+
"""Get existing persona or create new one"""
|
| 83 |
+
persona = await get_user_persona(wa_id)
|
| 84 |
+
if persona:
|
| 85 |
+
return persona
|
| 86 |
+
else:
|
| 87 |
+
return await create_user_persona(wa_id)
|
| 88 |
+
|
| 89 |
+
def get_missing_fields(persona: Dict) -> List[str]:
|
| 90 |
+
"""Get list of missing persona fields"""
|
| 91 |
+
missing = []
|
| 92 |
+
for field in PERSONA_FIELDS.keys():
|
| 93 |
+
if not persona.get(field):
|
| 94 |
+
missing.append(field)
|
| 95 |
+
return missing
|
| 96 |
+
|
| 97 |
+
def get_next_field_to_ask(persona: Dict) -> Optional[str]:
|
| 98 |
+
"""Get the next field to ask about"""
|
| 99 |
+
missing = get_missing_fields(persona)
|
| 100 |
+
return missing[0] if missing else None
|
| 101 |
+
|
| 102 |
+
async def parse_user_response(user_message: str, field: str) -> Tuple[bool, any, str]:
|
| 103 |
+
"""
|
| 104 |
+
Parse user response for a specific field
|
| 105 |
+
Returns: (is_valid, parsed_value, response_message)
|
| 106 |
+
"""
|
| 107 |
+
if not OPENAI_API_KEY:
|
| 108 |
+
return False, None, "Sorry, I can't process that right now."
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
# Create a structured prompt for parsing
|
| 112 |
+
system_prompt = f"""You are a property agent assistant. Parse the user's response for the field '{field}'.
|
| 113 |
+
|
| 114 |
+
Field: {field}
|
| 115 |
+
Field description: {PERSONA_FIELDS[field]['prompt']}
|
| 116 |
+
|
| 117 |
+
Parse the user's response and return ONLY a JSON object with these fields:
|
| 118 |
+
- "is_valid": boolean (true if user provided a valid answer)
|
| 119 |
+
- "value": the parsed value (null if invalid/not provided)
|
| 120 |
+
- "response": string message to send back to user
|
| 121 |
+
- "is_clarification": boolean (true if user asked for clarification)
|
| 122 |
+
- "is_skip": boolean (true if user wants to skip this question)
|
| 123 |
+
|
| 124 |
+
Rules:
|
| 125 |
+
- For intent: accept "buy", "lease", "rent", "purchase", "own"
|
| 126 |
+
- For budget: extract numeric values (e.g., "around 500k" -> 500000)
|
| 127 |
+
- For size: extract square meters (e.g., "1500 sqm" -> 1500)
|
| 128 |
+
- For location: extract location names
|
| 129 |
+
- For must_have: extract features as array (e.g., ["truck access", "yard space"])
|
| 130 |
+
- If user asks for clarification, set is_clarification=true
|
| 131 |
+
- If user says "not sure", "skip", "later", set is_skip=true
|
| 132 |
+
"""
|
| 133 |
+
|
| 134 |
+
messages = [
|
| 135 |
+
{"role": "system", "content": system_prompt},
|
| 136 |
+
{"role": "user", "content": user_message}
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
response = llm.invoke(messages)
|
| 140 |
+
|
| 141 |
+
# Try to parse the JSON response
|
| 142 |
+
import json
|
| 143 |
+
try:
|
| 144 |
+
result = json.loads(response.content)
|
| 145 |
+
return (
|
| 146 |
+
result.get("is_valid", False),
|
| 147 |
+
result.get("value"),
|
| 148 |
+
result.get("response", "I understand. Let me know if you need anything else.")
|
| 149 |
+
)
|
| 150 |
+
except json.JSONDecodeError:
|
| 151 |
+
# Fallback parsing
|
| 152 |
+
return await fallback_parse_response(user_message, field)
|
| 153 |
+
|
| 154 |
+
except Exception as e:
|
| 155 |
+
print(f"Error parsing user response: {e}")
|
| 156 |
+
return False, None, "I didn't quite catch that. Could you rephrase?"
|
| 157 |
+
|
| 158 |
+
async def fallback_parse_response(user_message: str, field: str) -> Tuple[bool, any, str]:
|
| 159 |
+
"""Fallback parsing when LLM parsing fails"""
|
| 160 |
+
user_message_lower = user_message.lower()
|
| 161 |
+
|
| 162 |
+
# Check for clarification requests
|
| 163 |
+
clarification_words = ["what", "how", "explain", "difference", "mean", "clarify"]
|
| 164 |
+
if any(word in user_message_lower for word in clarification_words):
|
| 165 |
+
return False, None, PERSONA_FIELDS[field]["clarification"]
|
| 166 |
+
|
| 167 |
+
# Check for skip requests
|
| 168 |
+
skip_words = ["not sure", "skip", "later", "don't know", "maybe later"]
|
| 169 |
+
if any(word in user_message_lower for word in skip_words):
|
| 170 |
+
return True, None, PERSONA_FIELDS[field]["skip_response"]
|
| 171 |
+
|
| 172 |
+
# Basic field-specific parsing
|
| 173 |
+
if field == "intent":
|
| 174 |
+
if any(word in user_message_lower for word in ["buy", "purchase", "own"]):
|
| 175 |
+
return True, "buy", "Got it, you're looking to buy. That's great!"
|
| 176 |
+
elif any(word in user_message_lower for word in ["lease", "rent"]):
|
| 177 |
+
return True, "lease", "Perfect, leasing gives you flexibility."
|
| 178 |
+
|
| 179 |
+
elif field == "budget":
|
| 180 |
+
import re
|
| 181 |
+
numbers = re.findall(r'\d+', user_message)
|
| 182 |
+
if numbers:
|
| 183 |
+
# Assume the largest number is the budget
|
| 184 |
+
budget = max(int(n) for n in numbers)
|
| 185 |
+
if "k" in user_message_lower or "thousand" in user_message_lower:
|
| 186 |
+
budget *= 1000
|
| 187 |
+
elif "m" in user_message_lower or "million" in user_message_lower:
|
| 188 |
+
budget *= 1000000
|
| 189 |
+
return True, budget, f"Thanks! I'll look for properties around ${budget:,}."
|
| 190 |
+
|
| 191 |
+
elif field == "size_preference_sqm":
|
| 192 |
+
import re
|
| 193 |
+
numbers = re.findall(r'\d+', user_message)
|
| 194 |
+
if numbers:
|
| 195 |
+
size = max(int(n) for n in numbers)
|
| 196 |
+
return True, size, f"Perfect! {size} sqm should give you good options."
|
| 197 |
+
|
| 198 |
+
elif field == "location_preference":
|
| 199 |
+
# Extract location names (basic approach)
|
| 200 |
+
locations = ["downtown", "suburb", "industrial", "warehouse district"]
|
| 201 |
+
found_location = None
|
| 202 |
+
for loc in locations:
|
| 203 |
+
if loc in user_message_lower:
|
| 204 |
+
found_location = loc
|
| 205 |
+
break
|
| 206 |
+
|
| 207 |
+
if found_location:
|
| 208 |
+
return True, found_location, f"Great! {found_location.title()} has good options."
|
| 209 |
+
else:
|
| 210 |
+
# Assume the whole message is a location
|
| 211 |
+
return True, user_message.strip(), f"Got it! I'll look in {user_message.strip()}."
|
| 212 |
+
|
| 213 |
+
elif field == "must_have":
|
| 214 |
+
features = []
|
| 215 |
+
feature_keywords = {
|
| 216 |
+
"truck": ["truck", "loading", "dock"],
|
| 217 |
+
"yard": ["yard", "space", "outdoor"],
|
| 218 |
+
"office": ["office", "admin"],
|
| 219 |
+
"parking": ["parking", "car"],
|
| 220 |
+
"high_ceiling": ["ceiling", "height", "tall"]
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
for feature, keywords in feature_keywords.items():
|
| 224 |
+
if any(keyword in user_message_lower for keyword in keywords):
|
| 225 |
+
features.append(feature)
|
| 226 |
+
|
| 227 |
+
if features:
|
| 228 |
+
return True, features, f"Perfect! I'll make sure to find places with {', '.join(features)}."
|
| 229 |
+
|
| 230 |
+
return False, None, "I didn't quite understand. Could you try again?"
|
| 231 |
+
|
| 232 |
+
async def should_ask_persona_question(persona: Dict, conversation_context: str = "") -> Tuple[bool, Optional[str]]:
|
| 233 |
+
"""
|
| 234 |
+
Determine if we should ask a persona question
|
| 235 |
+
Returns: (should_ask, field_to_ask)
|
| 236 |
+
"""
|
| 237 |
+
# Check if persona is complete
|
| 238 |
+
missing_fields = get_missing_fields(persona)
|
| 239 |
+
|
| 240 |
+
if not missing_fields:
|
| 241 |
+
return False, None
|
| 242 |
+
|
| 243 |
+
# Check if user is asking about properties (indicates they want to search)
|
| 244 |
+
search_indicators = [
|
| 245 |
+
"property", "warehouse", "industrial", "space", "building",
|
| 246 |
+
"available", "looking for", "need", "find", "search"
|
| 247 |
+
]
|
| 248 |
+
|
| 249 |
+
conversation_lower = conversation_context.lower()
|
| 250 |
+
is_searching = any(indicator in conversation_lower for indicator in search_indicators)
|
| 251 |
+
|
| 252 |
+
# If user is actively searching, ask persona questions
|
| 253 |
+
if is_searching:
|
| 254 |
+
return True, missing_fields[0]
|
| 255 |
+
|
| 256 |
+
# If this is a new user (no fields filled), ask the first question
|
| 257 |
+
if len(missing_fields) == len(PERSONA_FIELDS):
|
| 258 |
+
return True, missing_fields[0]
|
| 259 |
+
|
| 260 |
+
return False, None
|
| 261 |
+
|
| 262 |
+
def get_persona_summary(persona: Dict) -> str:
|
| 263 |
+
"""Get a human-readable summary of the user's persona"""
|
| 264 |
+
summary_parts = []
|
| 265 |
+
|
| 266 |
+
if persona.get("intent"):
|
| 267 |
+
summary_parts.append(f"Looking to {persona['intent']}")
|
| 268 |
+
|
| 269 |
+
if persona.get("location_preference"):
|
| 270 |
+
summary_parts.append(f"in {persona['location_preference']}")
|
| 271 |
+
|
| 272 |
+
if persona.get("size_preference_sqm"):
|
| 273 |
+
summary_parts.append(f"around {persona['size_preference_sqm']} sqm")
|
| 274 |
+
|
| 275 |
+
if persona.get("budget"):
|
| 276 |
+
summary_parts.append(f"budget ~${persona['budget']:,}")
|
| 277 |
+
|
| 278 |
+
if persona.get("must_have"):
|
| 279 |
+
summary_parts.append(f"must have: {', '.join(persona['must_have'])}")
|
| 280 |
+
|
| 281 |
+
if summary_parts:
|
| 282 |
+
return " | ".join(summary_parts)
|
| 283 |
+
else:
|
| 284 |
+
return "New user - profile incomplete"
|
supabase_setup.sql
CHANGED
|
@@ -26,6 +26,20 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
| 26 |
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 27 |
);
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
-- Create indexes for better query performance
|
| 30 |
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at DESC);
|
| 31 |
CREATE INDEX IF NOT EXISTS idx_users_updated_at ON users(updated_at DESC);
|
|
@@ -36,11 +50,14 @@ CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
|
| 36 |
CREATE INDEX IF NOT EXISTS idx_messages_wa_id ON messages(wa_id);
|
| 37 |
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
| 38 |
CREATE INDEX IF NOT EXISTS idx_messages_wamid ON messages(wamid);
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-- Enable Row Level Security (RLS) - optional but recommended
|
| 41 |
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
| 42 |
ALTER TABLE chat_sessions ENABLE ROW LEVEL SECURITY;
|
| 43 |
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
|
|
|
| 44 |
|
| 45 |
-- Create policies to allow all operations (you can restrict this based on your needs)
|
| 46 |
CREATE POLICY "Allow all operations on users" ON users
|
|
@@ -52,6 +69,9 @@ CREATE POLICY "Allow all operations on chat_sessions" ON chat_sessions
|
|
| 52 |
CREATE POLICY "Allow all operations on messages" ON messages
|
| 53 |
FOR ALL USING (true);
|
| 54 |
|
|
|
|
|
|
|
|
|
|
| 55 |
-- Optional: Create a function to automatically update the updated_at timestamp
|
| 56 |
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
| 57 |
RETURNS TRIGGER AS $$
|
|
@@ -79,4 +99,10 @@ $$ language 'plpgsql';
|
|
| 79 |
CREATE TRIGGER update_chat_sessions_last_activity
|
| 80 |
BEFORE UPDATE ON chat_sessions
|
| 81 |
FOR EACH ROW
|
| 82 |
-
EXECUTE FUNCTION update_last_activity_column();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(),
|
| 40 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
-- Create indexes for better query performance
|
| 44 |
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at DESC);
|
| 45 |
CREATE INDEX IF NOT EXISTS idx_users_updated_at ON users(updated_at DESC);
|
|
|
|
| 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 |
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
|
| 76 |
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
| 77 |
RETURNS TRIGGER AS $$
|
|
|
|
| 99 |
CREATE TRIGGER update_chat_sessions_last_activity
|
| 100 |
BEFORE UPDATE ON chat_sessions
|
| 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();
|