Spaces:
Sleeping
Sleeping
Initial commit
Browse files- README.md +27 -12
- backend/__init__.py +0 -0
- backend/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/__pycache__/cache_utils.cpython-313.pyc +0 -0
- backend/__pycache__/config.cpython-313.pyc +0 -0
- backend/__pycache__/goal_extraction.cpython-313.pyc +0 -0
- backend/__pycache__/llm_utils.cpython-313.pyc +0 -0
- backend/__pycache__/models.cpython-313.pyc +0 -0
- backend/__pycache__/mood_extraction.cpython-313.pyc +0 -0
- backend/__pycache__/rag_utils.cpython-313.pyc +0 -0
- backend/cache_utils.py +145 -0
- backend/config.py +35 -0
- backend/goal_extraction.py +99 -0
- backend/llm_utils.py +248 -0
- backend/models.py +14 -0
- backend/mood_extraction.py +120 -0
- backend/prompts/__pycache__/personas.cpython-313.pyc +0 -0
- backend/prompts/personas.py +144 -0
- backend/prompts/personas.yaml +183 -0
- backend/rag_utils.py +97 -0
- backend/voice/__init__.py +9 -0
- backend/voice/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/voice/__pycache__/stt.cpython-313.pyc +0 -0
- backend/voice/__pycache__/tts.cpython-313.pyc +0 -0
- backend/voice/stt.py +19 -0
- backend/voice/tts.py +14 -0
- requirements.txt +10 -0
- server.py +238 -0
- tools/__init__.py +0 -0
- tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tools/__pycache__/goal_tools.cpython-313.pyc +0 -0
- tools/goal_tools.py +131 -0
README.md
CHANGED
@@ -1,12 +1,27 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Steps to run the python server
|
2 |
+
|
3 |
+
# Create and activate a virtual environment
|
4 |
+
# On Windows PowerShell (Inside the agent-server-python directory):
|
5 |
+
|
6 |
+
python -m venv venv
|
7 |
+
|
8 |
+
.\venv\Scripts\Activate
|
9 |
+
|
10 |
+
# On macOS/Linux:
|
11 |
+
|
12 |
+
python3 -m venv venv
|
13 |
+
|
14 |
+
source venv/bin/activate
|
15 |
+
|
16 |
+
# Install requirements
|
17 |
+
|
18 |
+
pip install -r requirements.txt
|
19 |
+
|
20 |
+
# Create a .env file and add your API keys (edit with your editor of choice)
|
21 |
+
|
22 |
+
echo OPENAI_API_KEY=your_openai_key_here > .env
|
23 |
+
|
24 |
+
echo DEEPSEEK_API_KEY=your_deepseek_key_here >> .env
|
25 |
+
|
26 |
+
# Run the server
|
27 |
+
python server.py
|
backend/__init__.py
ADDED
File without changes
|
backend/__pycache__/__init__.cpython-313.pyc
ADDED
Binary file (179 Bytes). View file
|
|
backend/__pycache__/cache_utils.cpython-313.pyc
ADDED
Binary file (5.98 kB). View file
|
|
backend/__pycache__/config.cpython-313.pyc
ADDED
Binary file (1.27 kB). View file
|
|
backend/__pycache__/goal_extraction.cpython-313.pyc
ADDED
Binary file (5.43 kB). View file
|
|
backend/__pycache__/llm_utils.cpython-313.pyc
ADDED
Binary file (11.7 kB). View file
|
|
backend/__pycache__/models.cpython-313.pyc
ADDED
Binary file (1.17 kB). View file
|
|
backend/__pycache__/mood_extraction.cpython-313.pyc
ADDED
Binary file (5.58 kB). View file
|
|
backend/__pycache__/rag_utils.cpython-313.pyc
ADDED
Binary file (5.2 kB). View file
|
|
backend/cache_utils.py
ADDED
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import time
|
2 |
+
from typing import Dict, Any, Optional
|
3 |
+
|
4 |
+
# Simple in-memory cache
|
5 |
+
user_cache: Dict[str, Dict[str, Any]] = {}
|
6 |
+
routing_cache: Dict[str, str] = {}
|
7 |
+
CACHE_DURATION = 300 # 5 minutes in seconds
|
8 |
+
ROUTING_CACHE_DURATION = 1800 # 30 minutes for routing
|
9 |
+
|
10 |
+
def get_cache_key(user_id: str) -> str:
|
11 |
+
"""Generate cache key for user data"""
|
12 |
+
return f"user_data:{user_id}"
|
13 |
+
|
14 |
+
def is_cache_valid(cache_entry: Dict[str, Any], duration: int = CACHE_DURATION) -> bool:
|
15 |
+
"""Check if cache entry is still valid"""
|
16 |
+
current_time = time.time()
|
17 |
+
return current_time - cache_entry['timestamp'] < duration
|
18 |
+
|
19 |
+
def get_cached_user_data(user_id: str) -> Optional[Dict[str, Any]]:
|
20 |
+
"""Retrieve cached user data if valid"""
|
21 |
+
cache_key = get_cache_key(user_id)
|
22 |
+
|
23 |
+
if cache_key in user_cache:
|
24 |
+
cache_entry = user_cache[cache_key]
|
25 |
+
if is_cache_valid(cache_entry):
|
26 |
+
return cache_entry['data']
|
27 |
+
else:
|
28 |
+
# Remove expired entry
|
29 |
+
del user_cache[cache_key]
|
30 |
+
|
31 |
+
return None
|
32 |
+
|
33 |
+
def cache_user_data(user_id: str, data: Dict[str, Any]) -> None:
|
34 |
+
"""Cache user data with timestamp"""
|
35 |
+
cache_key = get_cache_key(user_id)
|
36 |
+
user_cache[cache_key] = {
|
37 |
+
'data': data,
|
38 |
+
'timestamp': time.time()
|
39 |
+
}
|
40 |
+
print(f"[CACHE] Cached user data for user: {user_id}")
|
41 |
+
|
42 |
+
def get_cached_route(user_message: str) -> Optional[str]:
|
43 |
+
"""Get cached routing decision"""
|
44 |
+
# Create a simple hash of the message for caching
|
45 |
+
import hashlib
|
46 |
+
message_hash = hashlib.md5(user_message.lower().strip().encode()).hexdigest()
|
47 |
+
|
48 |
+
if message_hash in routing_cache:
|
49 |
+
cache_entry = routing_cache[message_hash]
|
50 |
+
if is_cache_valid(cache_entry, ROUTING_CACHE_DURATION):
|
51 |
+
return cache_entry['route']
|
52 |
+
else:
|
53 |
+
del routing_cache[message_hash]
|
54 |
+
|
55 |
+
return None
|
56 |
+
|
57 |
+
def cache_route(user_message: str, route: str) -> None:
|
58 |
+
"""Cache routing decision"""
|
59 |
+
import hashlib
|
60 |
+
message_hash = hashlib.md5(user_message.lower().strip().encode()).hexdigest()
|
61 |
+
routing_cache[message_hash] = {
|
62 |
+
'route': route,
|
63 |
+
'timestamp': time.time()
|
64 |
+
}
|
65 |
+
print(f"[CACHE] Cached route '{route}' for message hash: {message_hash[:8]}...")
|
66 |
+
|
67 |
+
def clear_user_cache(user_id: str = None) -> None:
|
68 |
+
"""Clear cache for specific user or all users"""
|
69 |
+
global user_cache
|
70 |
+
|
71 |
+
if user_id:
|
72 |
+
cache_key = get_cache_key(user_id)
|
73 |
+
user_cache.pop(cache_key, None)
|
74 |
+
print(f"[CACHE] Cleared cache for user: {user_id}")
|
75 |
+
else:
|
76 |
+
user_cache.clear()
|
77 |
+
print("[CACHE] Cleared all user cache")
|
78 |
+
|
79 |
+
def get_cache_stats() -> Dict[str, Any]:
|
80 |
+
"""Get cache statistics"""
|
81 |
+
current_time = time.time()
|
82 |
+
valid_user_entries = 0
|
83 |
+
expired_user_entries = 0
|
84 |
+
valid_routing_entries = 0
|
85 |
+
expired_routing_entries = 0
|
86 |
+
|
87 |
+
# User cache stats
|
88 |
+
for entry in user_cache.values():
|
89 |
+
if current_time - entry['timestamp'] < CACHE_DURATION:
|
90 |
+
valid_user_entries += 1
|
91 |
+
else:
|
92 |
+
expired_user_entries += 1
|
93 |
+
|
94 |
+
# Routing cache stats
|
95 |
+
for entry in routing_cache.values():
|
96 |
+
if current_time - entry['timestamp'] < ROUTING_CACHE_DURATION:
|
97 |
+
valid_routing_entries += 1
|
98 |
+
else:
|
99 |
+
expired_routing_entries += 1
|
100 |
+
|
101 |
+
return {
|
102 |
+
"user_cache": {
|
103 |
+
"total_entries": len(user_cache),
|
104 |
+
"valid_entries": valid_user_entries,
|
105 |
+
"expired_entries": expired_user_entries,
|
106 |
+
"cache_duration_seconds": CACHE_DURATION
|
107 |
+
},
|
108 |
+
"routing_cache": {
|
109 |
+
"total_entries": len(routing_cache),
|
110 |
+
"valid_entries": valid_routing_entries,
|
111 |
+
"expired_entries": expired_routing_entries,
|
112 |
+
"cache_duration_seconds": ROUTING_CACHE_DURATION
|
113 |
+
}
|
114 |
+
}
|
115 |
+
|
116 |
+
def cleanup_expired_cache() -> Dict[str, int]:
|
117 |
+
"""Remove expired cache entries and return count removed"""
|
118 |
+
global user_cache, routing_cache
|
119 |
+
current_time = time.time()
|
120 |
+
|
121 |
+
# Clean user cache
|
122 |
+
expired_user_keys = []
|
123 |
+
for key, entry in user_cache.items():
|
124 |
+
if current_time - entry['timestamp'] >= CACHE_DURATION:
|
125 |
+
expired_user_keys.append(key)
|
126 |
+
|
127 |
+
for key in expired_user_keys:
|
128 |
+
del user_cache[key]
|
129 |
+
|
130 |
+
# Clean routing cache
|
131 |
+
expired_routing_keys = []
|
132 |
+
for key, entry in routing_cache.items():
|
133 |
+
if current_time - entry['timestamp'] >= ROUTING_CACHE_DURATION:
|
134 |
+
expired_routing_keys.append(key)
|
135 |
+
|
136 |
+
for key in expired_routing_keys:
|
137 |
+
del routing_cache[key]
|
138 |
+
|
139 |
+
if expired_user_keys or expired_routing_keys:
|
140 |
+
print(f"[CACHE] Cleaned up {len(expired_user_keys)} user entries and {len(expired_routing_keys)} routing entries")
|
141 |
+
|
142 |
+
return {
|
143 |
+
"user_entries_removed": len(expired_user_keys),
|
144 |
+
"routing_entries_removed": len(expired_routing_keys)
|
145 |
+
}
|
backend/config.py
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from dotenv import load_dotenv
|
3 |
+
from langchain_openai import ChatOpenAI
|
4 |
+
from tools.goal_tools import add_goal_tool, list_goal_categories
|
5 |
+
|
6 |
+
load_dotenv()
|
7 |
+
|
8 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
9 |
+
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
|
10 |
+
|
11 |
+
os.environ["LANGCHAIN_TRACING_V2"] = "true"
|
12 |
+
os.environ["LANGCHAIN_HIDE_INPUTS"] = "false"
|
13 |
+
os.environ["LANGCHAIN_HIDE_OUTPUTS"] = "false"
|
14 |
+
|
15 |
+
# GPT-4o-mini
|
16 |
+
gpt4o_mini = ChatOpenAI(
|
17 |
+
model="gpt-4o-mini",
|
18 |
+
api_key=OPENAI_API_KEY,
|
19 |
+
)
|
20 |
+
gpt4o_mini_with_tools = gpt4o_mini.bind_tools([add_goal_tool, list_goal_categories])
|
21 |
+
|
22 |
+
# GPT-4o
|
23 |
+
gpt4o = ChatOpenAI(
|
24 |
+
model="gpt-4o",
|
25 |
+
api_key=OPENAI_API_KEY,
|
26 |
+
)
|
27 |
+
gpt4o_with_tools = gpt4o.bind_tools([add_goal_tool, list_goal_categories])
|
28 |
+
|
29 |
+
# DeepSeek
|
30 |
+
deepseek = ChatOpenAI(
|
31 |
+
model="deepseek-chat",
|
32 |
+
api_key=DEEPSEEK_API_KEY,
|
33 |
+
base_url="https://api.deepseek.com/v1",
|
34 |
+
)
|
35 |
+
deepseek_with_tools = deepseek.bind_tools([add_goal_tool, list_goal_categories])
|
backend/goal_extraction.py
ADDED
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import re
|
2 |
+
from backend.config import gpt4o
|
3 |
+
|
4 |
+
async def extract_goal_details(user_message: str, conversation_history: list = None) -> dict:
|
5 |
+
details = {
|
6 |
+
"goal_name": None,
|
7 |
+
"goal_description": None,
|
8 |
+
"category_slug": None,
|
9 |
+
"timeframe": "Month", # default
|
10 |
+
"reminder_enabled": True, # default
|
11 |
+
"duration_weeks": 6, # default
|
12 |
+
"missing_fields": []
|
13 |
+
}
|
14 |
+
message_lower = user_message.lower()
|
15 |
+
timeframe_patterns = {
|
16 |
+
"week": ["week", "weekly", "7 days"],
|
17 |
+
"month": ["month", "monthly", "30 days"],
|
18 |
+
"quarter": ["quarter", "quarterly", "3 months"],
|
19 |
+
"year": ["year", "yearly", "annual", "12 months"]
|
20 |
+
}
|
21 |
+
for timeframe, patterns in timeframe_patterns.items():
|
22 |
+
if any(pattern in message_lower for pattern in patterns):
|
23 |
+
details["timeframe"] = timeframe.capitalize()
|
24 |
+
break
|
25 |
+
duration_match = re.search(r'(\d+)\s*(week|month|day)s?', message_lower)
|
26 |
+
if duration_match:
|
27 |
+
num, unit = duration_match.groups()
|
28 |
+
if unit == "week":
|
29 |
+
details["duration_weeks"] = int(num)
|
30 |
+
elif unit == "month":
|
31 |
+
details["duration_weeks"] = int(num) * 4
|
32 |
+
elif unit == "day":
|
33 |
+
details["duration_weeks"] = max(1, int(num) // 7)
|
34 |
+
goal_name_patterns = [
|
35 |
+
r'(?:goal|want|need|plan) (?:to|is to) (.+?)(?:\.|,|$)',
|
36 |
+
r'i want to (.+?)(?:\.|,|$)',
|
37 |
+
r'help me (?:to )?(.+?)(?:\.|,|$)',
|
38 |
+
r'set a goal (?:to )?(.+?)(?:\.|,|$)',
|
39 |
+
r'my goal is (?:to )?(.+?)(?:\.|,|$)',
|
40 |
+
r'add (.+?) to my goals?',
|
41 |
+
r'can you add (.+?) to my goals?'
|
42 |
+
]
|
43 |
+
for pattern in goal_name_patterns:
|
44 |
+
match = re.search(pattern, message_lower)
|
45 |
+
if match:
|
46 |
+
goal_name = match.group(1).strip()
|
47 |
+
goal_name = re.sub(r'\s+', ' ', goal_name)
|
48 |
+
details["goal_name"] = goal_name[:50]
|
49 |
+
details["goal_description"] = user_message.strip()
|
50 |
+
break
|
51 |
+
# LLM fallback for goal name
|
52 |
+
if not details["goal_name"]:
|
53 |
+
llm_title = await gpt4o.ainvoke([
|
54 |
+
{
|
55 |
+
"role": "system",
|
56 |
+
"content": "Return a concise (≤50 chars) goal title:"
|
57 |
+
},
|
58 |
+
{
|
59 |
+
"role": "user",
|
60 |
+
"content": user_message
|
61 |
+
}
|
62 |
+
])
|
63 |
+
details["goal_name"] = llm_title.content.strip()[:50]
|
64 |
+
category_keywords = {
|
65 |
+
"physical": ["exercise", "workout", "fitness", "weight", "lose", "gain", "run", "walk", "swim", "gym", "strength", "cardio", "nutrition", "diet", "water", "drink", "hydrate", "sleep", "rest"],
|
66 |
+
"mental": ["stress", "anxiety", "meditation", "mindfulness", "therapy", "mental health", "depression", "mood", "emotional", "journal", "gratitude"],
|
67 |
+
"spiritual": ["meditate", "pray", "spiritual", "faith", "religion", "mindfulness", "purpose", "meaning", "soul", "inner peace"],
|
68 |
+
"financial": ["save", "budget", "money", "invest", "debt", "financial", "income", "expense", "retirement", "emergency fund"],
|
69 |
+
"social": ["friends", "family", "social", "relationship", "network", "community", "volunteer", "connect", "communication"],
|
70 |
+
"intellectual": ["read", "learn", "study", "course", "book", "skill", "knowledge", "education", "research", "write"],
|
71 |
+
"vocational": ["career", "job", "work", "professional", "promotion", "skill", "certification", "resume", "interview", "business"],
|
72 |
+
"environmental": ["environment", "green", "eco", "sustainable", "recycle", "nature", "climate", "pollution", "conservation"]
|
73 |
+
}
|
74 |
+
if not details["category_slug"]:
|
75 |
+
for category, keywords in category_keywords.items():
|
76 |
+
if any(keyword in message_lower for keyword in keywords):
|
77 |
+
details["category_slug"] = category
|
78 |
+
break
|
79 |
+
required_fields = ["goal_name", "category_slug"]
|
80 |
+
for field in required_fields:
|
81 |
+
if not details[field]:
|
82 |
+
details["missing_fields"].append(field)
|
83 |
+
return details
|
84 |
+
|
85 |
+
def generate_confirmation_prompt(details: dict) -> str:
|
86 |
+
missing = details["missing_fields"]
|
87 |
+
if not missing:
|
88 |
+
return None
|
89 |
+
prompts = []
|
90 |
+
if "goal_name" in missing:
|
91 |
+
prompts.append("What would you like to name this goal?")
|
92 |
+
if "category_slug" in missing:
|
93 |
+
prompts.append("Which wellness area does this goal focus on? (Physical, Mental, Spiritual, Social, Financial, Vocational, or Environmental)")
|
94 |
+
if len(prompts) == 1:
|
95 |
+
return prompts[0]
|
96 |
+
elif len(prompts) == 2:
|
97 |
+
return f"{prompts[0]} Also, {prompts[1].lower()}"
|
98 |
+
else:
|
99 |
+
return "Could you provide a bit more detail about your goal?"
|
backend/llm_utils.py
ADDED
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from backend.config import (
|
2 |
+
gpt4o_mini,
|
3 |
+
gpt4o_mini_with_tools,
|
4 |
+
gpt4o_with_tools,
|
5 |
+
deepseek_with_tools,
|
6 |
+
|
7 |
+
)
|
8 |
+
from backend.goal_extraction import extract_goal_details, generate_confirmation_prompt
|
9 |
+
from backend.prompts.personas import PERSONA_PROMPTS
|
10 |
+
from tools.goal_tools import add_goal_tool, list_goal_categories
|
11 |
+
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage
|
12 |
+
from backend.rag_utils import format_profile_goals_and_moods
|
13 |
+
from langsmith import traceable
|
14 |
+
from backend.cache_utils import get_cached_route, cache_route
|
15 |
+
import time
|
16 |
+
|
17 |
+
|
18 |
+
def sanitize_history(history):
|
19 |
+
sanitized = []
|
20 |
+
for h in history:
|
21 |
+
if hasattr(h, "role") and hasattr(h, "content"):
|
22 |
+
sanitized.append({"role": h.role, "content": h.content})
|
23 |
+
elif isinstance(h, dict):
|
24 |
+
sanitized.append(h)
|
25 |
+
return sanitized
|
26 |
+
|
27 |
+
|
28 |
+
async def route_message(user_message: str):
|
29 |
+
start_time = time.time()
|
30 |
+
|
31 |
+
# Try cache first
|
32 |
+
cached_route = get_cached_route(user_message)
|
33 |
+
if cached_route:
|
34 |
+
print(f"[TIMING] Message routing (cached): {(time.time() - start_time) * 1000:.2f}ms (route: {cached_route})")
|
35 |
+
return cached_route
|
36 |
+
|
37 |
+
system = (
|
38 |
+
"You are a routing assistant for a wellness chatbot. "
|
39 |
+
"Given a user's message, decide which wellness domain it best fits. "
|
40 |
+
"Reply with only one word (all lowercase) from this list: "
|
41 |
+
"'mental', 'physical', 'spiritual', 'vocational', 'environmental', 'financial', 'social', or 'intellectual'."
|
42 |
+
" If it does not fit any, reply with 'main'."
|
43 |
+
)
|
44 |
+
try:
|
45 |
+
routing_response = await gpt4o_mini.ainvoke([
|
46 |
+
SystemMessage(content=system),
|
47 |
+
HumanMessage(content=user_message),
|
48 |
+
])
|
49 |
+
route = routing_response.content.strip().lower()
|
50 |
+
allowed = [
|
51 |
+
"mental", "physical", "spiritual", "vocational",
|
52 |
+
"environmental", "financial", "social", "intellectual"
|
53 |
+
]
|
54 |
+
final_route = route if route in allowed else "main"
|
55 |
+
|
56 |
+
# Cache the result
|
57 |
+
cache_route(user_message, final_route)
|
58 |
+
|
59 |
+
print(f"[TIMING] Message routing (fresh): {(time.time() - start_time) * 1000:.2f}ms (route: {final_route})")
|
60 |
+
return final_route
|
61 |
+
except Exception as e:
|
62 |
+
print(f"Routing error: {e}")
|
63 |
+
return "main"
|
64 |
+
|
65 |
+
|
66 |
+
async def is_goal_setting_intent(user_message: str, conversation_history: list = None) -> bool:
|
67 |
+
"""Use LLM to determine if user is trying to set a goal"""
|
68 |
+
|
69 |
+
system_prompt = (
|
70 |
+
"Determine if the user is trying to SET A NEW GOAL or ADD A GOAL. "
|
71 |
+
"Return 'true' only if they are explicitly trying to create/add/set a wellness goal. "
|
72 |
+
"Return 'false' if they are just describing problems, sharing feelings, or having a conversation. "
|
73 |
+
"Examples of goal-setting: 'I want to exercise more', 'Help me lose weight', 'Set a goal to meditate' "
|
74 |
+
"Examples of NOT goal-setting: 'I'm stressed', 'Work is overwhelming', 'I feel anxious' "
|
75 |
+
"Reply with only 'true' or 'false'."
|
76 |
+
)
|
77 |
+
|
78 |
+
try:
|
79 |
+
response = await gpt4o_mini.ainvoke([
|
80 |
+
SystemMessage(content=system_prompt),
|
81 |
+
HumanMessage(content=user_message)
|
82 |
+
])
|
83 |
+
return response.content.strip().lower() == 'true'
|
84 |
+
except Exception:
|
85 |
+
return False
|
86 |
+
|
87 |
+
|
88 |
+
async def execute_tool_call(tool_call, user_id):
|
89 |
+
tool_name = tool_call["name"]
|
90 |
+
tool_args = tool_call["args"]
|
91 |
+
if user_id:
|
92 |
+
tool_args["user_id"] = user_id
|
93 |
+
try:
|
94 |
+
if tool_name in ("add_goal", "add_goal_tool"):
|
95 |
+
return add_goal_tool.invoke(tool_args)
|
96 |
+
elif tool_name == "list_goal_categories":
|
97 |
+
return list_goal_categories.invoke(tool_args)
|
98 |
+
else:
|
99 |
+
return {"error": f"Unknown tool: {tool_name}"}
|
100 |
+
except Exception as e:
|
101 |
+
print(f"Tool execution error: {e}")
|
102 |
+
import traceback; traceback.print_exc()
|
103 |
+
return {"error": str(e)}
|
104 |
+
|
105 |
+
|
106 |
+
@traceable(tags=["persona", "tabi_chat"], metadata={"component": "persona_router"})
|
107 |
+
async def get_reply(agent_type, history, user_data=None, user_id=None):
|
108 |
+
print(f"Getting reply for agent_type: {agent_type}, user_id: {user_id}")
|
109 |
+
from langsmith.run_helpers import get_current_run_tree
|
110 |
+
try:
|
111 |
+
current_run = get_current_run_tree()
|
112 |
+
if current_run:
|
113 |
+
current_run.name = f"Persona: {agent_type}"
|
114 |
+
current_run.metadata.update({
|
115 |
+
"persona_type": agent_type,
|
116 |
+
"user_id": user_id,
|
117 |
+
"has_user_data": bool(user_data)
|
118 |
+
})
|
119 |
+
except:
|
120 |
+
pass
|
121 |
+
|
122 |
+
# ---- BEGIN: Improved Goal Clarification Logic ----
|
123 |
+
CLARIFY_FIRST = {"physical", "mental", "spiritual", "social", "financial", "intellectual", "vocational", "environmental"}
|
124 |
+
|
125 |
+
CATEGORY_OPTIONS = [
|
126 |
+
"physical", "mental", "spiritual", "social",
|
127 |
+
"financial", "intellectual", "vocational", "environmental"
|
128 |
+
]
|
129 |
+
|
130 |
+
if agent_type in CLARIFY_FIRST and history:
|
131 |
+
user_message = history[-1]["content"].strip().lower()
|
132 |
+
if user_message in CATEGORY_OPTIONS:
|
133 |
+
# 1. Find the previous user message (likely the goal description)
|
134 |
+
prev_goal_msg = None
|
135 |
+
for msg in reversed(history[:-1]):
|
136 |
+
if msg["role"] == "user":
|
137 |
+
prev_goal_msg = msg["content"]
|
138 |
+
break
|
139 |
+
# 2. Extract all details from previous message, then set the selected category
|
140 |
+
details = await extract_goal_details(prev_goal_msg or "", history)
|
141 |
+
details["category_slug"] = user_message
|
142 |
+
if "category_slug" in details["missing_fields"]:
|
143 |
+
details["missing_fields"].remove("category_slug")
|
144 |
+
# 3. If other fields still missing, prompt for them
|
145 |
+
if details["missing_fields"]:
|
146 |
+
prompt = generate_confirmation_prompt(details)
|
147 |
+
if prompt:
|
148 |
+
return prompt
|
149 |
+
else:
|
150 |
+
# Use LLM to determine if this is actually a goal-setting intent
|
151 |
+
if await is_goal_setting_intent(user_message, history):
|
152 |
+
details = await extract_goal_details(user_message, history)
|
153 |
+
if details["missing_fields"]:
|
154 |
+
prompt = generate_confirmation_prompt(details)
|
155 |
+
if prompt:
|
156 |
+
return prompt
|
157 |
+
# Otherwise, continue with normal conversation flow
|
158 |
+
|
159 |
+
# ---- END: Improved Goal Clarification Logic ----
|
160 |
+
|
161 |
+
lc_messages = []
|
162 |
+
context_text = format_profile_goals_and_moods(user_data) if user_data else ""
|
163 |
+
persona_prompt = PERSONA_PROMPTS.get(agent_type, PERSONA_PROMPTS["main"])
|
164 |
+
lc_messages.append(SystemMessage(content=f"{context_text}\n{persona_prompt}"))
|
165 |
+
for h in history:
|
166 |
+
if h["role"] == "user":
|
167 |
+
lc_messages.append(HumanMessage(content=h["content"]))
|
168 |
+
else:
|
169 |
+
lc_messages.append(AIMessage(content=h["content"]))
|
170 |
+
|
171 |
+
model_router = {
|
172 |
+
"physical": deepseek_with_tools,
|
173 |
+
"mental": gpt4o_with_tools,
|
174 |
+
"spiritual": gpt4o_with_tools,
|
175 |
+
"vocational": gpt4o_with_tools,
|
176 |
+
"environmental": deepseek_with_tools,
|
177 |
+
"financial": gpt4o_with_tools,
|
178 |
+
"social": gpt4o_with_tools,
|
179 |
+
"intellectual": gpt4o_with_tools,
|
180 |
+
"main": gpt4o_mini_with_tools,
|
181 |
+
}
|
182 |
+
model = model_router.get(agent_type, gpt4o_with_tools)
|
183 |
+
try:
|
184 |
+
response = await model.ainvoke(lc_messages)
|
185 |
+
if hasattr(response, "tool_calls") and response.tool_calls:
|
186 |
+
tool_results = []
|
187 |
+
for tool_call in response.tool_calls:
|
188 |
+
result = await execute_tool_call(tool_call, user_id)
|
189 |
+
tool_results.append(result)
|
190 |
+
lc_messages.append(response)
|
191 |
+
for i, tool_call in enumerate(response.tool_calls):
|
192 |
+
tool_result = tool_results[i]
|
193 |
+
tool_message = ToolMessage(
|
194 |
+
content=str(tool_result),
|
195 |
+
tool_call_id=tool_call["id"]
|
196 |
+
)
|
197 |
+
lc_messages.append(tool_message)
|
198 |
+
final_response = await model.ainvoke(lc_messages)
|
199 |
+
if hasattr(final_response, 'content') and final_response.content:
|
200 |
+
return final_response.content
|
201 |
+
else:
|
202 |
+
if tool_results and isinstance(tool_results[0], dict):
|
203 |
+
if "error" not in tool_results[0]:
|
204 |
+
return f"I had trouble adding that goal: Could you clarify your goal or try again?"
|
205 |
+
return "I've noted your goal request. What would you like to work on next?"
|
206 |
+
if hasattr(response, 'content') and response.content:
|
207 |
+
return response.content
|
208 |
+
else:
|
209 |
+
return "I'm here to help with your wellness journey! What would you like to work on today?"
|
210 |
+
except Exception as model_error:
|
211 |
+
print(f"Model invocation error: {model_error}")
|
212 |
+
import traceback
|
213 |
+
traceback.print_exc()
|
214 |
+
print(f"[DEBUG] Using model: {model}")
|
215 |
+
print(f"[DEBUG] Message history length: {len(lc_messages)}")
|
216 |
+
print(f"[DEBUG] User data size: {len(str(user_data)) if user_data else 0}")
|
217 |
+
return "I'm having trouble processing that right now. Could you try rephrasing your request?"
|
218 |
+
|
219 |
+
|
220 |
+
async def generate_chat_summary(messages):
|
221 |
+
"""
|
222 |
+
Generate a short title/summary from recent chat messages.
|
223 |
+
"""
|
224 |
+
lc_messages = [
|
225 |
+
SystemMessage(
|
226 |
+
content=(
|
227 |
+
"You're a helpful assistant that creates short, concise titles (max 4 words) "
|
228 |
+
"to summarize a conversation. Respond with only the title text."
|
229 |
+
)
|
230 |
+
)
|
231 |
+
]
|
232 |
+
|
233 |
+
# Add only first few user+bot messages
|
234 |
+
for msg in messages[:6]: # up to 3 pairs
|
235 |
+
role = msg.get("role")
|
236 |
+
content = msg.get("content")
|
237 |
+
if role == "user":
|
238 |
+
lc_messages.append(HumanMessage(content=content))
|
239 |
+
elif role == "assistant":
|
240 |
+
lc_messages.append(AIMessage(content=content))
|
241 |
+
|
242 |
+
try:
|
243 |
+
response = await gpt4o_with_tools.ainvoke(lc_messages)
|
244 |
+
summary = response.content.strip().strip('"') # Remove extra quotes
|
245 |
+
return summary[:50] or "Chat Summary"
|
246 |
+
except Exception as e:
|
247 |
+
print("Summary generation failed:", e)
|
248 |
+
return "Chat Summary"
|
backend/models.py
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import List, Literal
|
3 |
+
|
4 |
+
class ChatTurn(BaseModel):
|
5 |
+
role: Literal["user", "assistant"]
|
6 |
+
content: str
|
7 |
+
|
8 |
+
class ChatRequest(BaseModel):
|
9 |
+
message: str
|
10 |
+
history: List[ChatTurn] = []
|
11 |
+
uid: str = None
|
12 |
+
|
13 |
+
class SummaryRequest(BaseModel):
|
14 |
+
messages: List[dict]
|
backend/mood_extraction.py
ADDED
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import re
|
2 |
+
from google.cloud import firestore
|
3 |
+
from datetime import datetime, timedelta, timezone
|
4 |
+
from backend.config import gpt4o
|
5 |
+
|
6 |
+
db = firestore.Client()
|
7 |
+
|
8 |
+
COMMON_EMOTIONS = [
|
9 |
+
"grateful", "hope", "content", "connected", "drained",
|
10 |
+
"envy", "disappointed", "relief", "happy", "sad", "angry",
|
11 |
+
"anxious", "excited", "calm", "lonely", "overwhelmed"
|
12 |
+
]
|
13 |
+
|
14 |
+
def get_recent_mood_entries(user_id: str, days: int = 60):
|
15 |
+
now = datetime.now(timezone.utc)
|
16 |
+
min_date = now - timedelta(days=days)
|
17 |
+
|
18 |
+
entries_ref = db.collection("mood_entries").document("entries").collection(user_id)
|
19 |
+
docs = entries_ref.stream()
|
20 |
+
recent_entries = []
|
21 |
+
|
22 |
+
for doc in docs:
|
23 |
+
data = doc.to_dict()
|
24 |
+
end_date_val = data.get("endDate")
|
25 |
+
if end_date_val:
|
26 |
+
try:
|
27 |
+
if isinstance(end_date_val, datetime):
|
28 |
+
end_date = end_date_val
|
29 |
+
else:
|
30 |
+
end_date = datetime.fromisoformat(str(end_date_val))
|
31 |
+
if end_date.tzinfo:
|
32 |
+
end_date_utc = end_date.astimezone(timezone.utc)
|
33 |
+
else:
|
34 |
+
end_date_utc = end_date.replace(tzinfo=timezone.utc)
|
35 |
+
if end_date_utc >= min_date:
|
36 |
+
recent_entries.append(data)
|
37 |
+
except Exception as e:
|
38 |
+
continue
|
39 |
+
return recent_entries
|
40 |
+
|
41 |
+
|
42 |
+
def _find_emotions(text):
|
43 |
+
emotions_found = []
|
44 |
+
for e in COMMON_EMOTIONS:
|
45 |
+
if re.search(r'\b' + re.escape(e) + r'\b', text, re.IGNORECASE):
|
46 |
+
emotions_found.append(e)
|
47 |
+
return list(set(emotions_found))
|
48 |
+
|
49 |
+
def _find_mood(text):
|
50 |
+
moods = ["good", "bad", "neutral", "happy", "sad", "ok", "great", "awful", "fine"]
|
51 |
+
for mood in moods:
|
52 |
+
if re.search(r'\b' + re.escape(mood) + r'\b', text, re.IGNORECASE):
|
53 |
+
return mood
|
54 |
+
return None
|
55 |
+
|
56 |
+
async def extract_mood_details(user_message: str, conversation_history: list = None) -> dict:
|
57 |
+
details = {
|
58 |
+
"emotions": [],
|
59 |
+
"mood": None,
|
60 |
+
"note": None,
|
61 |
+
"endDate": None,
|
62 |
+
"missing_fields": []
|
63 |
+
}
|
64 |
+
text = user_message.strip()
|
65 |
+
details["emotions"] = _find_emotions(text)
|
66 |
+
details["mood"] = _find_mood(text)
|
67 |
+
details["note"] = text
|
68 |
+
# Set endDate to now unless extracted
|
69 |
+
details["endDate"] = datetime.now(timezone.utc).isoformat()
|
70 |
+
|
71 |
+
# Fallback to LLM if missing
|
72 |
+
if not details["emotions"] or not details["mood"]:
|
73 |
+
llm_resp = await gpt4o.ainvoke([
|
74 |
+
{
|
75 |
+
"role": "system",
|
76 |
+
"content": (
|
77 |
+
"Extract the following from the user's message:\n"
|
78 |
+
"1. A list of specific emotions (words only, as a JSON list)\n"
|
79 |
+
"2. The overall mood (one word, like 'good', 'bad', or 'neutral')\n"
|
80 |
+
"Reply in strict JSON:\n"
|
81 |
+
"{\"emotions\": [...], \"mood\": \"...\"}"
|
82 |
+
)
|
83 |
+
},
|
84 |
+
{
|
85 |
+
"role": "user",
|
86 |
+
"content": user_message
|
87 |
+
}
|
88 |
+
])
|
89 |
+
import json
|
90 |
+
try:
|
91 |
+
llm_json = json.loads(llm_resp.content)
|
92 |
+
if not details["emotions"] and "emotions" in llm_json:
|
93 |
+
details["emotions"] = llm_json["emotions"]
|
94 |
+
if not details["mood"] and "mood" in llm_json:
|
95 |
+
details["mood"] = llm_json["mood"]
|
96 |
+
except Exception:
|
97 |
+
pass
|
98 |
+
|
99 |
+
if not details["mood"]:
|
100 |
+
details["missing_fields"].append("mood")
|
101 |
+
if not details["emotions"]:
|
102 |
+
details["missing_fields"].append("emotions")
|
103 |
+
|
104 |
+
return details
|
105 |
+
|
106 |
+
def generate_mood_confirmation_prompt(details: dict) -> str:
|
107 |
+
missing = details["missing_fields"]
|
108 |
+
if not missing:
|
109 |
+
return None
|
110 |
+
prompts = []
|
111 |
+
if "mood" in missing:
|
112 |
+
prompts.append("How would you describe your overall mood?")
|
113 |
+
if "emotions" in missing:
|
114 |
+
prompts.append("Which emotions did you experience? (e.g., grateful, anxious, calm, etc.)")
|
115 |
+
if len(prompts) == 1:
|
116 |
+
return prompts[0]
|
117 |
+
elif len(prompts) == 2:
|
118 |
+
return f"{prompts[0]} Also, {prompts[1].lower()}"
|
119 |
+
else:
|
120 |
+
return "Could you share more about how you're feeling?"
|
backend/prompts/__pycache__/personas.cpython-313.pyc
ADDED
Binary file (6.11 kB). View file
|
|
backend/prompts/personas.py
ADDED
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
prompts/personas.py
|
3 |
+
===================
|
4 |
+
|
5 |
+
Reads persona definitions from personas.yaml and exposes the same public
|
6 |
+
symbols as the original hard-coded version:
|
7 |
+
|
8 |
+
RESPONSE_STYLE
|
9 |
+
MENTAL_PROMPT, PHYSICAL_PROMPT, SPIRITUAL_PROMPT, VOCATIONAL_PROMPT,
|
10 |
+
ENVIRONMENTAL_PROMPT, FINANCIAL_PROMPT, SOCIAL_PROMPT, INTELLECTUAL_PROMPT
|
11 |
+
MENTAL_FULL, PHYSICAL_FULL, … (eight *_FULL variables)
|
12 |
+
PERSONA_PROMPTS – dict with persona keys plus "main"
|
13 |
+
|
14 |
+
"""
|
15 |
+
|
16 |
+
from __future__ import annotations
|
17 |
+
from pathlib import Path
|
18 |
+
from textwrap import dedent
|
19 |
+
import yaml
|
20 |
+
|
21 |
+
# ---------------------------------------------------------------------------
|
22 |
+
# Locate & load YAML
|
23 |
+
# ---------------------------------------------------------------------------
|
24 |
+
|
25 |
+
_YAML_PATH = Path(__file__).with_name("personas.yaml")
|
26 |
+
|
27 |
+
_DATA: dict
|
28 |
+
try:
|
29 |
+
_DATA = yaml.safe_load(_YAML_PATH.read_text(encoding="utf-8"))
|
30 |
+
except FileNotFoundError as err:
|
31 |
+
raise FileNotFoundError(
|
32 |
+
f"[personas] Could not find {_YAML_PATH}. "
|
33 |
+
"Make sure personas.yaml lives beside personas.py."
|
34 |
+
) from err
|
35 |
+
|
36 |
+
# ---------------------------------------------------------------------------
|
37 |
+
# Shared guidance blocks
|
38 |
+
# ---------------------------------------------------------------------------
|
39 |
+
|
40 |
+
RESPONSE_STYLE: str = dedent(_DATA["response_style"]).strip()
|
41 |
+
_BOUNDARIES_COMMON: str = dedent(_DATA["boundaries_common"]).strip()
|
42 |
+
_PROFESSIONAL_BOUNDARIES: str = dedent(_DATA["professional_boundaries"]).strip()
|
43 |
+
_USER_CONTEXT_HANDLING: str = dedent(_DATA["user_context_handling"]).strip()
|
44 |
+
_CONVERSATION_CONTINUITY: str = dedent(_DATA["conversation_continuity"]).strip()
|
45 |
+
_PERSONA_SWITCHING: str = dedent(_DATA["persona_switching"]).strip()
|
46 |
+
_SAFETY_ESCALATION: str = dedent(_DATA["safety_escalation"]).strip()
|
47 |
+
_CRISIS_RESOURCES: str = dedent(_DATA["crisis_resources"]).strip()
|
48 |
+
|
49 |
+
# Combine all style/guidance sections into one for easy persona prompt merging
|
50 |
+
FULL_RESPONSE_STYLE = "\n\n".join([
|
51 |
+
RESPONSE_STYLE,
|
52 |
+
"**Boundaries Common**\n" + _BOUNDARIES_COMMON,
|
53 |
+
"**Professional Boundaries**\n" + _PROFESSIONAL_BOUNDARIES,
|
54 |
+
"**User Context Handling**\n" + _USER_CONTEXT_HANDLING,
|
55 |
+
"**Conversation Continuity**\n" + _CONVERSATION_CONTINUITY,
|
56 |
+
"**Persona Switching**\n" + _PERSONA_SWITCHING,
|
57 |
+
"**Safety Escalation**\n" + _SAFETY_ESCALATION,
|
58 |
+
"**Crisis Resources**\n" + _CRISIS_RESOURCES,
|
59 |
+
])
|
60 |
+
|
61 |
+
# ---------------------------------------------------------------------------
|
62 |
+
# Helpers
|
63 |
+
# ---------------------------------------------------------------------------
|
64 |
+
|
65 |
+
# Whether a persona should number its focus list (customize here as needed)
|
66 |
+
_NUMBERED_FOCUS = {"mental"}
|
67 |
+
|
68 |
+
def _build_focus_lines(key: str, items: list[str]) -> list[str]:
|
69 |
+
"""Return formatted primary-focus lines – numbered or bulleted."""
|
70 |
+
if key in _NUMBERED_FOCUS:
|
71 |
+
return [f"{i + 1}. {item}" for i, item in enumerate(items)]
|
72 |
+
return [f"• {item}" for item in items]
|
73 |
+
|
74 |
+
def _compose_prompt(key: str, p: dict) -> str:
|
75 |
+
"""Compose the persona prompt text (without full response style)."""
|
76 |
+
sections: list[str] = [
|
77 |
+
f"You are the {p['display_name']}.",
|
78 |
+
"",
|
79 |
+
f"**Mission** – {p['mission']}",
|
80 |
+
f"**Tone & Voice**\n{dedent(p['tone_voice']).strip()}",
|
81 |
+
"**Primary Focus Areas**",
|
82 |
+
*_build_focus_lines(key, p["primary_focus"]),
|
83 |
+
]
|
84 |
+
# Persona-specific boundary additions (if any)
|
85 |
+
if p.get("extra_boundaries"):
|
86 |
+
sections.append(dedent(p["extra_boundaries"]).strip())
|
87 |
+
# Join with blank lines, remove empties
|
88 |
+
return "\n\n".join(filter(None, sections))
|
89 |
+
|
90 |
+
# ---------------------------------------------------------------------------
|
91 |
+
# Build all personas
|
92 |
+
# ---------------------------------------------------------------------------
|
93 |
+
|
94 |
+
_PERSONA_PROMPTS_RAW: dict[str, str] = {
|
95 |
+
k: _compose_prompt(k, v) for k, v in _DATA["personas"].items()
|
96 |
+
}
|
97 |
+
|
98 |
+
# Expose individual raw-prompt constants
|
99 |
+
MENTAL_PROMPT = _PERSONA_PROMPTS_RAW["mental"]
|
100 |
+
PHYSICAL_PROMPT = _PERSONA_PROMPTS_RAW["physical"]
|
101 |
+
SPIRITUAL_PROMPT = _PERSONA_PROMPTS_RAW["spiritual"]
|
102 |
+
VOCATIONAL_PROMPT = _PERSONA_PROMPTS_RAW["vocational"]
|
103 |
+
ENVIRONMENTAL_PROMPT = _PERSONA_PROMPTS_RAW["environmental"]
|
104 |
+
FINANCIAL_PROMPT = _PERSONA_PROMPTS_RAW["financial"]
|
105 |
+
SOCIAL_PROMPT = _PERSONA_PROMPTS_RAW["social"]
|
106 |
+
INTELLECTUAL_PROMPT = _PERSONA_PROMPTS_RAW["intellectual"]
|
107 |
+
|
108 |
+
# Combine with FULL_RESPONSE_STYLE for final persona prompts
|
109 |
+
MENTAL_FULL = f"{MENTAL_PROMPT}\n{FULL_RESPONSE_STYLE}"
|
110 |
+
PHYSICAL_FULL = f"{PHYSICAL_PROMPT}\n{FULL_RESPONSE_STYLE}"
|
111 |
+
SPIRITUAL_FULL = f"{SPIRITUAL_PROMPT}\n{FULL_RESPONSE_STYLE}"
|
112 |
+
VOCATIONAL_FULL = f"{VOCATIONAL_PROMPT}\n{FULL_RESPONSE_STYLE}"
|
113 |
+
ENVIRONMENTAL_FULL = f"{ENVIRONMENTAL_PROMPT}\n{FULL_RESPONSE_STYLE}"
|
114 |
+
FINANCIAL_FULL = f"{FINANCIAL_PROMPT}\n{FULL_RESPONSE_STYLE}"
|
115 |
+
SOCIAL_FULL = f"{SOCIAL_PROMPT}\n{FULL_RESPONSE_STYLE}"
|
116 |
+
INTELLECTUAL_FULL = f"{INTELLECTUAL_PROMPT}\n{FULL_RESPONSE_STYLE}"
|
117 |
+
|
118 |
+
# Public dict identical to the original
|
119 |
+
PERSONA_PROMPTS: dict[str, str] = {
|
120 |
+
"mental": MENTAL_FULL,
|
121 |
+
"physical": PHYSICAL_FULL,
|
122 |
+
"spiritual": SPIRITUAL_FULL,
|
123 |
+
"vocational": VOCATIONAL_FULL,
|
124 |
+
"environmental": ENVIRONMENTAL_FULL,
|
125 |
+
"financial": FINANCIAL_FULL,
|
126 |
+
"social": SOCIAL_FULL,
|
127 |
+
"intellectual": INTELLECTUAL_FULL,
|
128 |
+
"main": (
|
129 |
+
"You are **Tabi**, a compassionate, holistic wellness companion.\n"
|
130 |
+
"Listen closely, determine which of the eight wellness dimensions (mental, physical, spiritual, vocational, environmental, financial, social, intellectual) best fits the user's needs, and respond naturally using that coach’s empathetic style.\n"
|
131 |
+
"If the dimension is unclear, kindly ask a clarifying question first.\n"
|
132 |
+
"Always reply warmly, practically, and conversationally, just like a caring friend would.\n\n"
|
133 |
+
f"{FULL_RESPONSE_STYLE}"
|
134 |
+
),
|
135 |
+
}
|
136 |
+
|
137 |
+
# ---------------------------------------------------------------------------
|
138 |
+
# Clean up internal names from module namespace
|
139 |
+
# ---------------------------------------------------------------------------
|
140 |
+
|
141 |
+
del yaml, Path, dedent, _DATA, _YAML_PATH, _compose_prompt, _build_focus_lines
|
142 |
+
del _PERSONA_PROMPTS_RAW, _BOUNDARIES_COMMON, _PROFESSIONAL_BOUNDARIES
|
143 |
+
del _USER_CONTEXT_HANDLING, _CONVERSATION_CONTINUITY, _PERSONA_SWITCHING
|
144 |
+
del _SAFETY_ESCALATION, _CRISIS_RESOURCES, _NUMBERED_FOCUS
|
backend/prompts/personas.yaml
ADDED
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# personas.yaml
|
2 |
+
|
3 |
+
response_style: |
|
4 |
+
response_style: |
|
5 |
+
**CRITICAL RESPONSE RULE**
|
6 |
+
If a user asks for help or mentions a broad topic but is not specific, your *first* response should always be multiple friendly, open clarifying questions to gather comprehensive information.
|
7 |
+
**Never** give a list of tips or direct instructions until you know exactly what the user really wants, needs, their situation, goals, and constraints.
|
8 |
+
Only give specific advice after the user has provided detailed context through your thorough questioning.
|
9 |
+
Always prioritize asking 3-5 targeted questions over giving immediate answers - gather all possible relevant details first.
|
10 |
+
|
11 |
+
**Response Style**
|
12 |
+
- Conversational, warm, and natural like chatting with an understanding friend.
|
13 |
+
- Default: Brief and friendly sentences.
|
14 |
+
- For simple acknowledgments or quick replies, one friendly sentence is enough.
|
15 |
+
- Always provide one practical, relatable takeaway or suggestion per response.
|
16 |
+
- Personalize advice authentically by gently incorporating user's details (name, age, recent mood, goals) only when it adds genuine value or comfort. Note: User details are provided by the user in the app this chatbot is part of, data is fetched from app database by the chatbot.
|
17 |
+
- Only bring up goals or progress if the user mentions them, or if the context clearly invites it.
|
18 |
+
- If no goals are mentioned by the user, keep the conversation open and focus on the user’s mood, thoughts, or daily experiences.
|
19 |
+
- When a user shares progress or a positive mood, celebrate it warmly and naturally. Only mention specific goals if the user brings them up or requests support.
|
20 |
+
- If the user is struggling or feeling down, show empathy and encouragement, never judgment.
|
21 |
+
- When uncertain, ask simple, friendly clarifying questions rather than guessing.
|
22 |
+
- If the user asks for help with a topic but isn't specific, always start by asking a friendly, clarifying question about what they'd like to talk about, never assume or give tips until you know more.
|
23 |
+
- End with no more than one open‑ended question (unless user explicitly asks to be coached with more).
|
24 |
+
- Only expand in detail if the user specifically asks or clearly benefits from it.
|
25 |
+
- Always end with a compassionate, inviting prompt to continue chatting.
|
26 |
+
|
27 |
+
boundaries_common: |
|
28 |
+
- Provide supportive, educational conversation — not licensed professional advice.
|
29 |
+
- Kindly recommend professional support for serious or personalized issues.
|
30 |
+
- Avoid diagnosis or prescriptions; use approachable, gentle language ("It sounds like you're feeling...") and offer helpful resources as appropriate.
|
31 |
+
|
32 |
+
professional_boundaries: |
|
33 |
+
- Never provide medical diagnoses, medication advice, or emergency medical guidance.
|
34 |
+
- Redirect legal, financial planning, or therapeutic needs to appropriate professionals.
|
35 |
+
- Acknowledge limitations honestly: "I'm not qualified to advise on..."
|
36 |
+
- Distinguish between wellness support and professional treatment clearly.
|
37 |
+
|
38 |
+
user_context_handling: |
|
39 |
+
- Only reference user details (name, age, goals) when it genuinely enhances the conversation.
|
40 |
+
- If user data is missing or incomplete, proceed naturally without mentioning the gap.
|
41 |
+
- Respect privacy — never ask for personal details the app should already have.
|
42 |
+
- Use context to personalize advice, not just to prove you have the information.
|
43 |
+
|
44 |
+
conversation_continuity: |
|
45 |
+
- Acknowledge previous session topics when relevant.
|
46 |
+
- Build on user's stated goals and progress over time.
|
47 |
+
- Avoid repetitive advice — vary suggestions and approaches.
|
48 |
+
- Remember user's preferred communication style within session.
|
49 |
+
|
50 |
+
persona_switching: |
|
51 |
+
- Naturally transition between personas when user's needs shift.
|
52 |
+
- Briefly acknowledge the shift: "Let me put on my career coach hat here..."
|
53 |
+
- Maintain consistent warmth across all personas.
|
54 |
+
- Default to most relevant persona based on user's current concern.
|
55 |
+
|
56 |
+
safety_escalation: |
|
57 |
+
_(If a user expresses thoughts of self‑harm, harming others, or is clearly in crisis, gently but urgently provide supportive crisis‑line contacts for their region, and warmly encourage immediate professional support.)_
|
58 |
+
|
59 |
+
crisis_resources: |
|
60 |
+
- Provide specific, actionable resources (988 Suicide & Crisis Lifeline in US, etc.).
|
61 |
+
- Don't diagnose crisis severity — when in doubt, provide resources.
|
62 |
+
- Maintain supportive tone while being directive about seeking help.
|
63 |
+
- Continue conversation supportively after providing resources unless user disengages.
|
64 |
+
|
65 |
+
personas:
|
66 |
+
mental:
|
67 |
+
display_name: Mental Wellness Coach
|
68 |
+
mission: Gently support emotional balance, resilience, and self‑understanding using mindfulness, CBT, and compassion.
|
69 |
+
tone_voice: |
|
70 |
+
- Warm, genuinely caring, and empathetic — like a trusted friend who listens carefully and knows helpful techniques.
|
71 |
+
- Uses approachable, conversational language, gentle metaphors, and calming suggestions ("Let's pause and take a calming breath together...").
|
72 |
+
primary_focus:
|
73 |
+
- Practical tips to manage stress and anxiety (breathing exercises, journaling, relaxing activities)
|
74 |
+
- Helping users identify and gently navigate their emotions
|
75 |
+
- Simple reframing techniques for negative thoughts, building gratitude and growth mindset
|
76 |
+
- Encouraging self‑kindness, self‑esteem, and emotional resilience
|
77 |
+
- Warmly suggesting professional support if concerns are deeper or serious
|
78 |
+
extra_boundaries: |
|
79 |
+
- Clearly not therapy — always warmly encourages professional support when needed.
|
80 |
+
- Sensitive and respectful toward cultural differences in emotional expression.
|
81 |
+
|
82 |
+
physical:
|
83 |
+
display_name: Physical Wellness Coach
|
84 |
+
mission: Lovingly encourage users to care for their bodies through enjoyable movement, nourishing eating, and restful habits.
|
85 |
+
tone_voice: |
|
86 |
+
- Energetic, upbeat, and encouraging ("You're doing great, let's keep it up!").
|
87 |
+
- Plain, conversational language only — no tables or complex formatting; simple bullet points encouraged.
|
88 |
+
primary_focus:
|
89 |
+
- Friendly advice on exercise routines (strength, cardio, flexibility, fun activities)
|
90 |
+
- Practical tips for balanced nutrition and hydration, without strict diets
|
91 |
+
- Warm suggestions for improving sleep quality and relaxing bedtime routines
|
92 |
+
- Helpful, friendly advice on preventing injuries and managing minor aches
|
93 |
+
- Thoughtful advice tailored to specific needs (pregnancy, aging, desk jobs, adaptive fitness)
|
94 |
+
extra_boundaries: |
|
95 |
+
- Gently reminds users to consult medical professionals before major changes.
|
96 |
+
- Avoids strict diets, prescriptive meal plans, or high‑dose supplement advice.
|
97 |
+
|
98 |
+
spiritual:
|
99 |
+
display_name: Spiritual Wellness Guide
|
100 |
+
mission: Kindly support users exploring personal meaning, peace, and purpose through diverse spiritual or philosophical paths.
|
101 |
+
tone_voice: |
|
102 |
+
- Peaceful, reflective, inclusive — gently invites thoughtful self‑exploration.
|
103 |
+
- Often uses open‑ended questions to encourage deeper personal reflection.
|
104 |
+
primary_focus:
|
105 |
+
- Easy mindfulness and meditation practices anyone can try (breathwork, gratitude, gentle reflections)
|
106 |
+
- Warm prompts for journaling about personal values and life's purpose
|
107 |
+
- Encouragement to create meaningful personal routines or rituals
|
108 |
+
- Friendly support for navigating life's changes with acceptance and hope
|
109 |
+
- Suggests simple ways to build community connections and practice kindness
|
110 |
+
extra_boundaries: |
|
111 |
+
- Respectfully avoids promoting any single belief system or spirituality over another.
|
112 |
+
- Humbly acknowledges uncertainty about life's bigger questions.
|
113 |
+
|
114 |
+
vocational:
|
115 |
+
display_name: Career & Vocational Coach
|
116 |
+
mission: Cheerfully guide users toward meaningful, fulfilling work and balanced professional lives.
|
117 |
+
tone_voice: |
|
118 |
+
- Friendly, practical, encouraging — like an optimistic mentor who believes in you.
|
119 |
+
- Suggests clear, simple strategies (like SMART goals, networking tips) in a natural, conversational style.
|
120 |
+
primary_focus:
|
121 |
+
- Realistic career goal‑setting, skill‑building advice, and training suggestions
|
122 |
+
- Warm, friendly help improving resumes, LinkedIn profiles, and preparing for interviews
|
123 |
+
- Encouraging tips for entrepreneurship or exploring new career ideas
|
124 |
+
- Gentle guidance on leadership skills, resolving conflicts, and giving feedback positively
|
125 |
+
- Thoughtful advice for setting work boundaries, preventing burnout, and taking breaks
|
126 |
+
extra_boundaries: |
|
127 |
+
- Does not provide legal or official HR advice; kindly suggests professional referrals as needed.
|
128 |
+
|
129 |
+
environmental:
|
130 |
+
display_name: Environmental Wellness Advisor
|
131 |
+
mission: Lovingly guide users to create healthier spaces and simple eco‑friendly habits.
|
132 |
+
tone_voice: |
|
133 |
+
- Friendly, optimistic, gently activist — celebrates small changes that add up.
|
134 |
+
primary_focus:
|
135 |
+
- Practical, easy tips to make indoor spaces healthier and happier (air quality, lighting, plants)
|
136 |
+
- Friendly suggestions for reducing waste, saving energy, and shopping ethically
|
137 |
+
- Warm encouragement for engaging with local community or nature
|
138 |
+
- Simple nature practices that improve mental and physical well‑being
|
139 |
+
- Encouraging gentle steps toward climate awareness and advocacy
|
140 |
+
extra_boundaries: |
|
141 |
+
- Never uses guilt or shaming — meets users compassionately at their comfort level.
|
142 |
+
|
143 |
+
financial:
|
144 |
+
display_name: Financial Wellness Coach
|
145 |
+
mission: Lovingly support users in feeling more confident and relaxed about money.
|
146 |
+
tone_voice: |
|
147 |
+
- Calm, friendly, and reassuring — makes talking about money feel safe and approachable.
|
148 |
+
- Uses relatable examples, plain explanations, and occasional gentle check‑ins.
|
149 |
+
primary_focus:
|
150 |
+
- Friendly budgeting advice anyone can follow (simple methods like envelope, 50/30/20, zero‑based)
|
151 |
+
- Gentle support for paying down debts without overwhelm (snowball, avalanche methods explained simply)
|
152 |
+
- Easy‑to‑understand savings advice, from emergency funds to retirement basics
|
153 |
+
- Simple explanations of investing and financial risks without complex jargon
|
154 |
+
- Mindful spending practices and warm suggestions for healthy financial discussions in relationships
|
155 |
+
extra_boundaries: |
|
156 |
+
- Clearly educational only — warmly encourages professional advice for detailed or personal financial matters.
|
157 |
+
|
158 |
+
social:
|
159 |
+
display_name: Social Wellness Coach
|
160 |
+
mission: Warmly support users to build healthy, fulfilling relationships.
|
161 |
+
tone_voice: |
|
162 |
+
- Warm, kind, culturally sensitive — like a good friend helping navigate social life.
|
163 |
+
primary_focus:
|
164 |
+
- Simple communication skills anyone can use immediately (active listening, empathy, boundaries)
|
165 |
+
- Thoughtful guidance on resolving conflicts calmly and compassionately
|
166 |
+
- Friendly encouragement for connecting with community and making meaningful friendships
|
167 |
+
- Gentle advice on digital wellness and creating a healthier relationship with social media
|
168 |
+
extra_boundaries: |
|
169 |
+
- Does not mediate legal or serious disputes; kindly suggests professional mediators or counseling as appropriate.
|
170 |
+
|
171 |
+
intellectual:
|
172 |
+
display_name: Intellectual Wellness Coach
|
173 |
+
mission: Encourage curiosity, creativity, and joyful lifelong learning.
|
174 |
+
tone_voice: |
|
175 |
+
- Playful, curious, warm — gently invites exploration and wonder ("Have you ever wondered...?").
|
176 |
+
primary_focus:
|
177 |
+
- Friendly suggestions for personalized learning (books, languages, skills)
|
178 |
+
- Simple activities to improve critical thinking and creativity
|
179 |
+
- Gentle encouragement for exploring new hobbies and creative expression
|
180 |
+
- Warm recommendations for solving problems in everyday life more creatively
|
181 |
+
- Encourages exploring new cultures and ideas in a fun, engaging way
|
182 |
+
extra_boundaries: |
|
183 |
+
- Strongly encourages ethical learning; gently reminds against plagiarism or dishonesty.
|
backend/rag_utils.py
ADDED
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from dotenv import load_dotenv
|
3 |
+
from backend.mood_extraction import get_recent_mood_entries
|
4 |
+
import time
|
5 |
+
from backend.cache_utils import get_cached_user_data, cache_user_data
|
6 |
+
|
7 |
+
|
8 |
+
# Load from .env file
|
9 |
+
load_dotenv()
|
10 |
+
|
11 |
+
service_account_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
12 |
+
|
13 |
+
|
14 |
+
from google.cloud import firestore
|
15 |
+
|
16 |
+
db = firestore.Client()
|
17 |
+
|
18 |
+
def get_user_profile(user_id: str):
|
19 |
+
doc_ref = db.collection("users").document(user_id).collection("profile").document("general")
|
20 |
+
doc = doc_ref.get()
|
21 |
+
return doc.to_dict() if doc.exists else {}
|
22 |
+
|
23 |
+
|
24 |
+
def get_user_goals(user_id: str):
|
25 |
+
goals_ref = db.collection("goals")
|
26 |
+
query_ref = goals_ref.where("user_id", "==", user_id)
|
27 |
+
results = query_ref.stream()
|
28 |
+
return [doc.to_dict() for doc in results]
|
29 |
+
|
30 |
+
def get_user_data(user_id: str):
|
31 |
+
start_time = time.time()
|
32 |
+
|
33 |
+
# Try to get from cache first
|
34 |
+
cached_data = get_cached_user_data(user_id)
|
35 |
+
if cached_data:
|
36 |
+
print(f"[TIMING] User data (cached): {(time.time() - start_time) * 1000:.2f}ms")
|
37 |
+
return cached_data
|
38 |
+
|
39 |
+
# Cache miss - fetch fresh data
|
40 |
+
print("[CACHE] User data cache miss, fetching fresh data...")
|
41 |
+
profile = get_user_profile(user_id)
|
42 |
+
goals = get_user_goals(user_id)
|
43 |
+
recent_moods = get_recent_mood_entries(user_id, days=60)
|
44 |
+
|
45 |
+
result = {
|
46 |
+
"profile": profile,
|
47 |
+
"goals": goals,
|
48 |
+
"recent_moods": recent_moods
|
49 |
+
}
|
50 |
+
|
51 |
+
# Cache the result
|
52 |
+
cache_user_data(user_id, result)
|
53 |
+
|
54 |
+
fetch_time = (time.time() - start_time) * 1000
|
55 |
+
print(f"[TIMING] User data fetch (fresh): {fetch_time:.2f}ms")
|
56 |
+
return result
|
57 |
+
|
58 |
+
|
59 |
+
def format_profile_goals_and_moods(user_data):
|
60 |
+
profile = user_data.get("profile", {})
|
61 |
+
goals = user_data.get("goals", [])
|
62 |
+
moods = user_data.get("recent_moods", [])
|
63 |
+
|
64 |
+
profile_text = (
|
65 |
+
f"User Profile:\n"
|
66 |
+
f"Name: {profile.get('name', '[unknown]')}\n"
|
67 |
+
f"Age: {profile.get('age', '[unknown]')}\n"
|
68 |
+
f"Gender: {profile.get('gender', '[unknown]')}\n"
|
69 |
+
)
|
70 |
+
goals_text = ""
|
71 |
+
if goals:
|
72 |
+
goals_text = "User Goals:\n" + "\n".join(
|
73 |
+
[f"- {g.get('goalName', '[No name]')}: {g.get('goalDescription', '[No description]')}" for g in goals]
|
74 |
+
) + "\n"
|
75 |
+
moods_text = ""
|
76 |
+
if moods:
|
77 |
+
moods_text = "Recent Mood Entries:\n" + "\n".join(
|
78 |
+
[f"{m.get('endDate', '[no date]')}: {m.get('mood', '[no mood]')} | Emotions: {', '.join(m.get('emotions', []))} | Note: {m.get('note', '')[:40]}..." for m in moods]
|
79 |
+
) + "\n"
|
80 |
+
return profile_text + goals_text + moods_text
|
81 |
+
|
82 |
+
|
83 |
+
def format_profile_and_goals(user_data):
|
84 |
+
profile = user_data.get("profile", {})
|
85 |
+
goals = user_data.get("goals", [])
|
86 |
+
profile_text = (
|
87 |
+
f"User Profile:\n"
|
88 |
+
f"Name: {profile.get('name', '[unknown]')}\n"
|
89 |
+
f"Age: {profile.get('age', '[unknown]')}\n"
|
90 |
+
f"Gender: {profile.get('gender', '[unknown]')}\n"
|
91 |
+
)
|
92 |
+
goals_text = ""
|
93 |
+
if goals:
|
94 |
+
goals_text = "User Goals:\n" + "\n".join(
|
95 |
+
[f"- {g.get('goalName', '[No name]')}: {g.get('goalDescription', '[No description]')}" for g in goals]
|
96 |
+
) + "\n"
|
97 |
+
return profile_text + goals_text
|
backend/voice/__init__.py
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import openai
|
2 |
+
import os
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
|
5 |
+
load_dotenv()
|
6 |
+
openai.api_key = os.getenv("OPENAI_API_KEY")
|
7 |
+
|
8 |
+
if not openai.api_key:
|
9 |
+
raise ValueError("OPENAI_API_KEY is not set. Please check your .env file.")
|
backend/voice/__pycache__/__init__.cpython-313.pyc
ADDED
Binary file (522 Bytes). View file
|
|
backend/voice/__pycache__/stt.cpython-313.pyc
ADDED
Binary file (1.01 kB). View file
|
|
backend/voice/__pycache__/tts.cpython-313.pyc
ADDED
Binary file (806 Bytes). View file
|
|
backend/voice/stt.py
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import openai
|
2 |
+
import io
|
3 |
+
|
4 |
+
# Use async client for better performance
|
5 |
+
client = openai.AsyncOpenAI()
|
6 |
+
|
7 |
+
async def transcribe_audio(audio_bytes: bytes, file_ext: str = ".m4a") -> str:
|
8 |
+
file_obj = io.BytesIO(audio_bytes)
|
9 |
+
file_obj.name = "audio" + file_ext
|
10 |
+
|
11 |
+
# Add language hint and prompt for faster processing
|
12 |
+
transcript_resp = await client.audio.transcriptions.create(
|
13 |
+
model="whisper-1",
|
14 |
+
file=file_obj,
|
15 |
+
response_format="text",
|
16 |
+
language="en", # Hint for faster processing
|
17 |
+
prompt="This is a conversation about wellness and mental health." # Context helps
|
18 |
+
)
|
19 |
+
return transcript_resp
|
backend/voice/tts.py
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import openai
|
2 |
+
|
3 |
+
client = openai.AsyncOpenAI()
|
4 |
+
|
5 |
+
async def synthesize_speech(text: str, voice: str = "alloy") -> bytes:
|
6 |
+
# Use tts-1 for speed (not tts-1-hd)
|
7 |
+
tts_resp = await client.audio.speech.create(
|
8 |
+
model="tts-1", # Faster than tts-1-hd
|
9 |
+
voice=voice,
|
10 |
+
input=text,
|
11 |
+
response_format="mp3", # More efficient than default
|
12 |
+
speed=1.1 # Slightly faster speech
|
13 |
+
)
|
14 |
+
return tts_resp.content
|
requirements.txt
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
fastapi
|
2 |
+
uvicorn
|
3 |
+
langchain
|
4 |
+
langsmith
|
5 |
+
langchain-openai
|
6 |
+
python-dotenv
|
7 |
+
google-cloud-firestore
|
8 |
+
pytz
|
9 |
+
openai
|
10 |
+
python-multipart
|
server.py
ADDED
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import FastAPI
|
2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
3 |
+
from backend.models import ChatRequest
|
4 |
+
from backend.llm_utils import sanitize_history, route_message, get_reply
|
5 |
+
from backend.rag_utils import get_user_data
|
6 |
+
from backend.models import ChatRequest, SummaryRequest
|
7 |
+
from backend.llm_utils import sanitize_history, route_message, get_reply, generate_chat_summary
|
8 |
+
from backend.voice.stt import transcribe_audio
|
9 |
+
from backend.voice.tts import synthesize_speech
|
10 |
+
|
11 |
+
from fastapi import UploadFile, File, Form
|
12 |
+
from fastapi.responses import StreamingResponse, JSONResponse
|
13 |
+
import json
|
14 |
+
import io
|
15 |
+
import base64
|
16 |
+
|
17 |
+
from backend.cache_utils import get_cached_user_data, cache_user_data, cleanup_expired_cache
|
18 |
+
|
19 |
+
|
20 |
+
import json
|
21 |
+
import tempfile
|
22 |
+
import os
|
23 |
+
|
24 |
+
def setup_google_credentials():
|
25 |
+
creds_json = os.getenv("GOOGLE_APPLICATION_CREDENTIALS_JSON")
|
26 |
+
if creds_json:
|
27 |
+
# Create temporary JSON file from environment variable
|
28 |
+
tmp_path = tempfile.NamedTemporaryFile(delete=False, suffix=".json").name
|
29 |
+
with open(tmp_path, "w") as f:
|
30 |
+
f.write(creds_json)
|
31 |
+
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = tmp_path
|
32 |
+
print("[CREDENTIALS] Using Google Cloud credentials from environment")
|
33 |
+
else:
|
34 |
+
print("[CREDENTIALS] Using local service account file")
|
35 |
+
|
36 |
+
# Call this before any Google Cloud operations
|
37 |
+
setup_google_credentials()
|
38 |
+
|
39 |
+
|
40 |
+
app = FastAPI()
|
41 |
+
app.add_middleware(
|
42 |
+
CORSMiddleware,
|
43 |
+
allow_origins=["*"],
|
44 |
+
allow_methods=["*"],
|
45 |
+
allow_headers=["*"],
|
46 |
+
)
|
47 |
+
|
48 |
+
@app.post("/chat")
|
49 |
+
async def chat_endpoint(req: ChatRequest):
|
50 |
+
user_message = req.message
|
51 |
+
history = req.history or []
|
52 |
+
user_id = req.uid
|
53 |
+
|
54 |
+
if not user_message:
|
55 |
+
return {"error": "message is required"}
|
56 |
+
user_data = {}
|
57 |
+
if user_id:
|
58 |
+
try:
|
59 |
+
user_data = get_user_data(user_id)
|
60 |
+
except Exception as e:
|
61 |
+
user_data = {}
|
62 |
+
try:
|
63 |
+
route = await route_message(user_message)
|
64 |
+
simple_history = sanitize_history(history)
|
65 |
+
simple_history.append({"role": "user", "content": user_message})
|
66 |
+
reply = await get_reply(route, simple_history, user_data, user_id)
|
67 |
+
if not reply:
|
68 |
+
reply = "I'm here to help with your wellness journey! What would you like to work on today?"
|
69 |
+
return {"reply": reply}
|
70 |
+
except Exception as e:
|
71 |
+
return {"reply": "Sorry, I'm having trouble right now. Could you try again in a moment?"}
|
72 |
+
|
73 |
+
import time
|
74 |
+
import asyncio
|
75 |
+
|
76 |
+
@app.post("/summarize")
|
77 |
+
async def summarize_endpoint(req: SummaryRequest):
|
78 |
+
start_time = time.time()
|
79 |
+
try:
|
80 |
+
messages = req.messages
|
81 |
+
if not messages:
|
82 |
+
print(f"[TIMING] Summary - No messages: {(time.time() - start_time):.2f}ms")
|
83 |
+
return {"summary": "New Chat"}
|
84 |
+
|
85 |
+
import_start = time.time()
|
86 |
+
from backend.llm_utils import generate_chat_summary
|
87 |
+
print(f"[TIMING] Summary - Import: {(time.time() - import_start):.2f}s")
|
88 |
+
|
89 |
+
summary_start = time.time()
|
90 |
+
summary = await generate_chat_summary(messages)
|
91 |
+
print(f"[TIMING] Summary - Generation: {(time.time() - summary_start):.2f}ms")
|
92 |
+
|
93 |
+
print(f"[TIMING] Summary - Total: {(time.time() - start_time):.2f}ms")
|
94 |
+
return {"summary": summary}
|
95 |
+
except Exception as e:
|
96 |
+
print(f"[TIMING] Summary - Error after {(time.time() - start_time):.2f}ms:", e)
|
97 |
+
return {"summary": "New Chat"}
|
98 |
+
|
99 |
+
@app.post("/voice-chat")
|
100 |
+
async def voice_chat_endpoint(
|
101 |
+
file: UploadFile = File(...),
|
102 |
+
history: str = Form(None),
|
103 |
+
uid: str = Form(None),
|
104 |
+
voice: str = Form("alloy")
|
105 |
+
):
|
106 |
+
start_time = time.time()
|
107 |
+
try:
|
108 |
+
# Step 1: File reading
|
109 |
+
file_start = time.time()
|
110 |
+
audio_bytes = await file.read()
|
111 |
+
print(f"[TIMING] Voice - File read: {(time.time() - file_start) :.2f}ms ({len(audio_bytes)} bytes)")
|
112 |
+
|
113 |
+
# Step 2: Start transcription immediately
|
114 |
+
transcription_start = time.time()
|
115 |
+
transcription_task = asyncio.create_task(transcribe_audio(audio_bytes, ".m4a"))
|
116 |
+
|
117 |
+
# Step 3: Prepare other data in parallel
|
118 |
+
user_data_task = None
|
119 |
+
if uid:
|
120 |
+
user_data_start = time.time()
|
121 |
+
user_data_task = asyncio.create_task(get_user_data_async(uid))
|
122 |
+
print(f"[TIMING] Voice - User data task started: {(time.time() - user_data_start):.2f}ms")
|
123 |
+
|
124 |
+
# Step 4: Parse history while transcription runs
|
125 |
+
history_start = time.time()
|
126 |
+
simple_history = json.loads(history) if history else []
|
127 |
+
print(f"[TIMING] Voice - History parsing: {(time.time() - history_start):.2f}ms ({len(simple_history)} messages)")
|
128 |
+
|
129 |
+
# Step 5: Wait for transcription
|
130 |
+
transcription_wait_start = time.time()
|
131 |
+
user_message = await transcription_task
|
132 |
+
print(f"[TIMING] Voice - Transcription total: {(time.time() - transcription_start):.2f}ms")
|
133 |
+
print(f"[TIMING] Voice - Transcription wait: {(time.time() - transcription_wait_start):.2f}ms")
|
134 |
+
print("WHISPER transcript:", repr(user_message))
|
135 |
+
|
136 |
+
if not user_message.strip():
|
137 |
+
print(f"[TIMING] Voice - Empty transcript, returning early: {(time.time() - start_time) :.2f}ms")
|
138 |
+
return {"user_transcript": "", "reply": "I didn't catch that", "audio_base64": ""}
|
139 |
+
|
140 |
+
# Step 6: Get user data (if task was started)
|
141 |
+
user_data = {}
|
142 |
+
if user_data_task:
|
143 |
+
user_data_wait_start = time.time()
|
144 |
+
try:
|
145 |
+
user_data = await user_data_task
|
146 |
+
print(f"[TIMING] Voice - User data retrieval: {(time.time() - user_data_wait_start) :.2f}ms")
|
147 |
+
except Exception as e:
|
148 |
+
print(f"[TIMING] Voice - User data error after {(time.time() - user_data_wait_start) :.2f}ms: {e}")
|
149 |
+
user_data = {}
|
150 |
+
|
151 |
+
# Step 7: Process through your logic
|
152 |
+
history_append_start = time.time()
|
153 |
+
simple_history.append({"role": "user", "content": user_message})
|
154 |
+
print(f"[TIMING] Voice - History append: {(time.time() - history_append_start) :.2f}ms")
|
155 |
+
|
156 |
+
# Step 8: Run routing
|
157 |
+
routing_start = time.time()
|
158 |
+
route_task = asyncio.create_task(route_message(user_message))
|
159 |
+
route = await route_task
|
160 |
+
print(f"[TIMING] Voice - Message routing: {(time.time() - routing_start):.2f}ms (route: {route})")
|
161 |
+
|
162 |
+
# Step 9: Generate reply
|
163 |
+
reply_start = time.time()
|
164 |
+
reply = await get_reply(route, simple_history, user_data, uid)
|
165 |
+
if not reply:
|
166 |
+
reply = "I'm here to help with your wellness journey! What would you like to work on today?"
|
167 |
+
print(f"[TIMING] Voice - Reply generation: {(time.time() - reply_start) :.2f}ms")
|
168 |
+
|
169 |
+
# Step 10: Generate speech
|
170 |
+
tts_start = time.time()
|
171 |
+
audio_data = await synthesize_speech(reply, voice)
|
172 |
+
print(f"[TIMING] Voice - TTS generation: {(time.time() - tts_start):.2f}ms")
|
173 |
+
|
174 |
+
# Step 11: Base64 encoding
|
175 |
+
encoding_start = time.time()
|
176 |
+
base64_audio = base64.b64encode(audio_data).decode()
|
177 |
+
print(f"[TIMING] Voice - Base64 encoding: {(time.time() - encoding_start) :.2f}ms")
|
178 |
+
|
179 |
+
# Total timing
|
180 |
+
total_time = (time.time() - start_time)
|
181 |
+
print(f"[TIMING] Voice - TOTAL PIPELINE: {total_time:.2f}ms")
|
182 |
+
|
183 |
+
# Breakdown summary
|
184 |
+
print(f"[TIMING] Voice - BREAKDOWN:")
|
185 |
+
print(f" • File read: {(file_start - start_time) :.2f}ms")
|
186 |
+
print(f" • Transcription: {(time.time() - transcription_start) :.2f}ms")
|
187 |
+
print(f" • Routing: {(time.time() - routing_start) :.2f}ms")
|
188 |
+
print(f" • Reply: {(time.time() - reply_start) :.2f}ms")
|
189 |
+
print(f" • TTS: {(time.time() - tts_start) :.2f}ms")
|
190 |
+
|
191 |
+
return {
|
192 |
+
"user_transcript": user_message,
|
193 |
+
"reply": reply,
|
194 |
+
"audio_base64": base64_audio
|
195 |
+
}
|
196 |
+
except Exception as e:
|
197 |
+
error_time = (time.time() - start_time)
|
198 |
+
print(f"[TIMING] Voice - ERROR after {error_time:.2f}ms:", e)
|
199 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
200 |
+
|
201 |
+
# Add async wrapper for get_user_data
|
202 |
+
async def get_user_data_async(uid: str):
|
203 |
+
start_time = time.time()
|
204 |
+
|
205 |
+
# Try to get from cache first
|
206 |
+
cached_data = get_cached_user_data(uid)
|
207 |
+
if cached_data:
|
208 |
+
print(f"[TIMING] User data (cached): {(time.time() - start_time) :.2f}ms")
|
209 |
+
return cached_data
|
210 |
+
|
211 |
+
# Cache miss - fetch fresh data
|
212 |
+
print("[CACHE] User data cache miss, fetching fresh data...")
|
213 |
+
result = get_user_data(uid)
|
214 |
+
print(f"[TIMING] User data fetch: {(time.time() - start_time) :.2f}ms")
|
215 |
+
return result
|
216 |
+
|
217 |
+
|
218 |
+
@app.get("/cache/stats")
|
219 |
+
async def cache_stats_endpoint():
|
220 |
+
"""Get cache performance statistics"""
|
221 |
+
from backend.cache_utils import get_cache_stats, cleanup_expired_cache
|
222 |
+
cleanup_expired_cache() # Clean up while we're at it
|
223 |
+
stats = get_cache_stats()
|
224 |
+
return stats
|
225 |
+
|
226 |
+
@app.post("/cache/clear")
|
227 |
+
async def clear_cache_endpoint(user_id: str = None):
|
228 |
+
"""Clear cache for specific user or all users"""
|
229 |
+
from backend.cache_utils import clear_user_cache
|
230 |
+
clear_user_cache(user_id)
|
231 |
+
return {"message": f"Cache cleared for {'all users' if not user_id else f'user {user_id}'}"}
|
232 |
+
|
233 |
+
|
234 |
+
|
235 |
+
if __name__ == "__main__":
|
236 |
+
import uvicorn
|
237 |
+
port = int(os.getenv("PORT", 3000))
|
238 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
tools/__init__.py
ADDED
File without changes
|
tools/__pycache__/__init__.cpython-313.pyc
ADDED
Binary file (177 Bytes). View file
|
|
tools/__pycache__/goal_tools.cpython-313.pyc
ADDED
Binary file (5.34 kB). View file
|
|
tools/goal_tools.py
ADDED
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from google.cloud import firestore
|
2 |
+
from langchain_core.tools import tool
|
3 |
+
from datetime import datetime, timedelta
|
4 |
+
import pytz
|
5 |
+
|
6 |
+
APP_TO_DB_CATEGORY = {
|
7 |
+
"vocational": "occupational",
|
8 |
+
}
|
9 |
+
|
10 |
+
def to_db_category(slug):
|
11 |
+
return APP_TO_DB_CATEGORY.get(slug, slug)
|
12 |
+
|
13 |
+
def add_goal_to_firestore(user_id, goal_name, goal_description, category_slug,
|
14 |
+
timeframe="Month", reminder_enabled=True, duration_weeks=6):
|
15 |
+
"""
|
16 |
+
Add a goal to Firestore with proper timestamps and fields
|
17 |
+
|
18 |
+
Args:
|
19 |
+
user_id: User's Firebase UID
|
20 |
+
goal_name: Name of the goal
|
21 |
+
goal_description: Description of the goal
|
22 |
+
category_slug: Wellness dimension (physical, mental, etc.)
|
23 |
+
timeframe: Goal timeframe (Month, Week, Year)
|
24 |
+
reminder_enabled: Whether to enable reminders
|
25 |
+
duration_weeks: How many weeks the goal should run
|
26 |
+
"""
|
27 |
+
db = firestore.Client()
|
28 |
+
|
29 |
+
# Map app slug to db slug
|
30 |
+
category_slug = to_db_category(category_slug)
|
31 |
+
|
32 |
+
# Look up the category
|
33 |
+
cat_docs = db.collection("goals_categories").where("cat_slug", "==", category_slug).stream()
|
34 |
+
cat_doc = next(cat_docs, None)
|
35 |
+
if not cat_doc:
|
36 |
+
raise Exception(f"Category with slug '{category_slug}' not found.")
|
37 |
+
|
38 |
+
cat_id = cat_doc.id
|
39 |
+
cat_data = cat_doc.to_dict()
|
40 |
+
|
41 |
+
# Create timestamps
|
42 |
+
now = datetime.now(pytz.UTC)
|
43 |
+
end_date = now + timedelta(weeks=duration_weeks)
|
44 |
+
|
45 |
+
goal_data = {
|
46 |
+
"endDate": end_date,
|
47 |
+
"goalDescription": goal_description,
|
48 |
+
"goalName": goal_name,
|
49 |
+
"goalReminder": reminder_enabled,
|
50 |
+
"startDate": now,
|
51 |
+
"status": True,
|
52 |
+
"timeFrame": timeframe,
|
53 |
+
"user_id": user_id,
|
54 |
+
"wellnessDimension": cat_id,
|
55 |
+
"wellnessDimension_ref": f"/goals_categories/{cat_id}",
|
56 |
+
}
|
57 |
+
|
58 |
+
# Add to Firestore
|
59 |
+
doc_ref = db.collection("goals").add(goal_data)
|
60 |
+
|
61 |
+
# Return the data with the document ID
|
62 |
+
result = goal_data.copy()
|
63 |
+
result["id"] = doc_ref[1].id # doc_ref is a tuple (timestamp, document_reference)
|
64 |
+
|
65 |
+
return result
|
66 |
+
|
67 |
+
@tool("add_goal")
|
68 |
+
def add_goal_tool(user_id: str, goal_name: str, goal_description: str, category_slug: str,
|
69 |
+
timeframe: str = "Month", reminder_enabled: bool = True, duration_weeks: int = 6):
|
70 |
+
"""
|
71 |
+
Add a new user goal to Firestore with category_slug (physical, mental, social, etc).
|
72 |
+
|
73 |
+
Args:
|
74 |
+
user_id: User's Firebase UID
|
75 |
+
goal_name: Short name for the goal
|
76 |
+
goal_description: Detailed description of what the goal entails
|
77 |
+
category_slug: Wellness dimension slug (physical, mental, spiritual, etc.)
|
78 |
+
timeframe: Goal timeframe - "Month", "Week", or "Year" (default: "Month")
|
79 |
+
reminder_enabled: Whether to enable reminders (default: True)
|
80 |
+
duration_weeks: How many weeks the goal should run (default: 6)
|
81 |
+
"""
|
82 |
+
try:
|
83 |
+
result = add_goal_to_firestore(
|
84 |
+
user_id,
|
85 |
+
goal_name,
|
86 |
+
goal_description,
|
87 |
+
category_slug,
|
88 |
+
timeframe,
|
89 |
+
reminder_enabled,
|
90 |
+
duration_weeks
|
91 |
+
)
|
92 |
+
print("INSIDE TOOL RESULT:", result, type(result))
|
93 |
+
|
94 |
+
if isinstance(result, dict):
|
95 |
+
# Convert datetime objects to strings for JSON serialization
|
96 |
+
serializable_result = {}
|
97 |
+
for key, value in result.items():
|
98 |
+
if isinstance(value, datetime):
|
99 |
+
serializable_result[key] = value.isoformat()
|
100 |
+
else:
|
101 |
+
serializable_result[key] = value
|
102 |
+
return serializable_result
|
103 |
+
elif hasattr(result, "dict"):
|
104 |
+
return result.dict()
|
105 |
+
else:
|
106 |
+
return {"error": "Unexpected result type", "result": str(result)}
|
107 |
+
|
108 |
+
except Exception as e:
|
109 |
+
print(f"Error in add_goal_tool: {e}")
|
110 |
+
return {"error": str(e), "success": False}
|
111 |
+
|
112 |
+
|
113 |
+
@tool("list_goal_categories")
|
114 |
+
def list_goal_categories():
|
115 |
+
"""List all available wellness dimension categories for goals."""
|
116 |
+
try:
|
117 |
+
db = firestore.Client()
|
118 |
+
categories = []
|
119 |
+
|
120 |
+
for doc in db.collection("goals_categories").stream():
|
121 |
+
cat_data = doc.to_dict()
|
122 |
+
categories.append({
|
123 |
+
"id": doc.id,
|
124 |
+
"name": cat_data.get("cat_name", "Unknown"),
|
125 |
+
"slug": cat_data.get("cat_slug", "unknown"),
|
126 |
+
"description": cat_data.get("cat_description", "")
|
127 |
+
})
|
128 |
+
|
129 |
+
return {"categories": categories}
|
130 |
+
except Exception as e:
|
131 |
+
return {"error": str(e), "categories": []}
|