Fix issues #6, #7, #11, #12, #13: med reminders, push notifications, auth persistence, scheduling conflicts
- Fix TIME object vs string comparison in scheduler preventing adaptive med reminders from ever firing (#12, #6) - Add frequency filtering to midnight schedule creation for every_n_days meds - Require start_date and interval_days for every_n_days medications - Add refresh token support (30-day) to API and bot for persistent sessions (#13) - Add "trusted device" checkbox to frontend login for long-lived sessions (#7) - Auto-refresh expired tokens in both bot (apiRequest) and frontend (api.ts) - Restore bot sessions from cache on restart using refresh tokens - Duration-aware routine scheduling conflict detection (#11) - Add conflict check when starting routine sessions against medication times - Add diagnostic logging to notification delivery channels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
200
bot/bot.py
200
bot/bot.py
@@ -116,21 +116,26 @@ class JurySystem:
|
||||
print(f"Error loading DBT knowledge base: {e}")
|
||||
raise
|
||||
|
||||
async def query(self, query_text):
|
||||
"""Query the DBT knowledge base"""
|
||||
try:
|
||||
# Get embedding
|
||||
response = self.client.embeddings.create(
|
||||
model="qwen/qwen3-embedding-8b", input=query_text
|
||||
)
|
||||
query_emb = response.data[0].embedding
|
||||
def _retrieve_sync(self, query_text, top_k=5):
|
||||
"""Embed query and search vector store. Returns list of chunk dicts."""
|
||||
response = self.client.embeddings.create(
|
||||
model="qwen/qwen3-embedding-8b", input=query_text
|
||||
)
|
||||
query_emb = response.data[0].embedding
|
||||
return self.vector_store.search(query_emb, top_k=top_k)
|
||||
|
||||
# Search
|
||||
context_chunks = self.vector_store.search(query_emb, top_k=5)
|
||||
async def retrieve(self, query_text, top_k=5):
|
||||
"""Async retrieval — returns list of {metadata, score} dicts."""
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._retrieve_sync, query_text, top_k)
|
||||
|
||||
async def query(self, query_text):
|
||||
"""Query the DBT knowledge base (legacy path, kept for compatibility)."""
|
||||
try:
|
||||
context_chunks = await self.retrieve(query_text)
|
||||
if not context_chunks:
|
||||
return "I couldn't find relevant DBT information for that query."
|
||||
|
||||
# Generate answer
|
||||
context_text = "\n\n---\n\n".join(
|
||||
[chunk["metadata"]["text"] for chunk in context_chunks]
|
||||
)
|
||||
@@ -140,20 +145,8 @@ Use the provided context from the DBT Skills Training Handouts to answer the use
|
||||
If the answer is not in the context, say you don't know based on the provided text.
|
||||
Be concise, compassionate, and practical."""
|
||||
|
||||
user_prompt = f"Context:\n{context_text}\n\nQuestion: {query_text}"
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.config.get("models", {}).get(
|
||||
"generator", "openai/gpt-4o-mini"
|
||||
),
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
return response.choices[0].message.content
|
||||
from ai.jury_council import generate_rag_answer
|
||||
return await generate_rag_answer(query_text, context_text, system_prompt)
|
||||
except Exception as e:
|
||||
return f"Error querying DBT knowledge base: {e}"
|
||||
|
||||
@@ -176,13 +169,18 @@ def decodeJwtPayload(token):
|
||||
return json.loads(base64.urlsafe_b64decode(payload))
|
||||
|
||||
|
||||
def apiRequest(method, endpoint, token=None, data=None):
|
||||
def apiRequest(method, endpoint, token=None, data=None, _retried=False):
|
||||
url = f"{API_URL}{endpoint}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
try:
|
||||
resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10)
|
||||
# Auto-refresh on 401 using refresh token
|
||||
if resp.status_code == 401 and not _retried:
|
||||
new_token = _try_refresh_token_for_session(token)
|
||||
if new_token:
|
||||
return apiRequest(method, endpoint, token=new_token, data=data, _retried=True)
|
||||
try:
|
||||
return resp.json(), resp.status_code
|
||||
except ValueError:
|
||||
@@ -191,6 +189,31 @@ def apiRequest(method, endpoint, token=None, data=None):
|
||||
return {"error": "API unavailable"}, 503
|
||||
|
||||
|
||||
def _try_refresh_token_for_session(expired_token):
|
||||
"""Find the discord user with this token and refresh it using their refresh token."""
|
||||
for discord_id, session in user_sessions.items():
|
||||
if session.get("token") == expired_token:
|
||||
refresh_token = session.get("refresh_token")
|
||||
if not refresh_token:
|
||||
# Check cache for refresh token
|
||||
cached = getCachedUser(discord_id)
|
||||
if cached:
|
||||
refresh_token = cached.get("refresh_token")
|
||||
if refresh_token:
|
||||
result, status = apiRequest("post", "/api/refresh",
|
||||
data={"refresh_token": refresh_token},
|
||||
_retried=True)
|
||||
if status == 200 and "token" in result:
|
||||
new_token = result["token"]
|
||||
session["token"] = new_token
|
||||
# Update cache
|
||||
cached = getCachedUser(discord_id) or {}
|
||||
cached["refresh_token"] = refresh_token
|
||||
setCachedUser(discord_id, cached)
|
||||
return new_token
|
||||
return None
|
||||
|
||||
|
||||
def loadCache():
|
||||
try:
|
||||
if os.path.exists(CACHE_FILE):
|
||||
@@ -229,14 +252,32 @@ def setCachedUser(discord_id, user_data):
|
||||
|
||||
def negotiateToken(discord_id, username, password):
|
||||
cached = getCachedUser(discord_id)
|
||||
|
||||
# Try refresh token first (avoids sending password)
|
||||
if cached and cached.get("refresh_token"):
|
||||
result, status = apiRequest(
|
||||
"post", "/api/refresh",
|
||||
data={"refresh_token": cached["refresh_token"]},
|
||||
_retried=True,
|
||||
)
|
||||
if status == 200 and "token" in result:
|
||||
token = result["token"]
|
||||
payload = decodeJwtPayload(token)
|
||||
user_uuid = payload["sub"]
|
||||
cached["user_uuid"] = user_uuid
|
||||
setCachedUser(discord_id, cached)
|
||||
return token, user_uuid
|
||||
|
||||
# Fall back to password login, always request refresh token (trust_device)
|
||||
login_data = {"username": username, "password": password, "trust_device": True}
|
||||
|
||||
if (
|
||||
cached
|
||||
and cached.get("username") == username
|
||||
and cached.get("hashed_password")
|
||||
and verifyPassword(password, cached.get("hashed_password"))
|
||||
):
|
||||
result, status = apiRequest(
|
||||
"post", "/api/login", data={"username": username, "password": password}
|
||||
)
|
||||
result, status = apiRequest("post", "/api/login", data=login_data, _retried=True)
|
||||
if status == 200 and "token" in result:
|
||||
token = result["token"]
|
||||
payload = decodeJwtPayload(token)
|
||||
@@ -247,14 +288,13 @@ def negotiateToken(discord_id, username, password):
|
||||
"hashed_password": cached["hashed_password"],
|
||||
"user_uuid": user_uuid,
|
||||
"username": username,
|
||||
"refresh_token": result.get("refresh_token"),
|
||||
},
|
||||
)
|
||||
return token, user_uuid
|
||||
return None, None
|
||||
|
||||
result, status = apiRequest(
|
||||
"post", "/api/login", data={"username": username, "password": password}
|
||||
)
|
||||
result, status = apiRequest("post", "/api/login", data=login_data, _retried=True)
|
||||
if status == 200 and "token" in result:
|
||||
token = result["token"]
|
||||
payload = decodeJwtPayload(token)
|
||||
@@ -265,6 +305,7 @@ def negotiateToken(discord_id, username, password):
|
||||
"hashed_password": hashPassword(password),
|
||||
"user_uuid": user_uuid,
|
||||
"username": username,
|
||||
"refresh_token": result.get("refresh_token"),
|
||||
},
|
||||
)
|
||||
return token, user_uuid
|
||||
@@ -428,7 +469,7 @@ async def handleActiveSessionShortcuts(message, session, active_session):
|
||||
|
||||
|
||||
async def handleDBTQuery(message):
|
||||
"""Handle DBT-related queries using JurySystem"""
|
||||
"""Handle DBT-related queries using JurySystem + jury council pipeline."""
|
||||
if not jury_system:
|
||||
return False
|
||||
|
||||
@@ -456,13 +497,66 @@ async def handleDBTQuery(message):
|
||||
user_input_lower = message.content.lower()
|
||||
is_dbt_query = any(keyword in user_input_lower for keyword in dbt_keywords)
|
||||
|
||||
if is_dbt_query:
|
||||
async with message.channel.typing():
|
||||
response = await jury_system.query(message.content)
|
||||
await message.channel.send(f"🧠 **DBT Support:**\n{response}")
|
||||
return True
|
||||
if not is_dbt_query:
|
||||
return False
|
||||
|
||||
return False
|
||||
from ai.jury_council import (
|
||||
generate_search_questions,
|
||||
run_jury_filter,
|
||||
generate_rag_answer,
|
||||
split_for_discord,
|
||||
)
|
||||
|
||||
async with message.channel.typing():
|
||||
# Step 1: Generate candidate questions via Qwen Nitro (fallback: qwen3-235b)
|
||||
candidates, gen_error = await generate_search_questions(message.content)
|
||||
if gen_error:
|
||||
await message.channel.send(f"⚠️ **Question generator failed:** {gen_error}")
|
||||
return True
|
||||
|
||||
# Step 2: Jury council filters candidates → safe question JSON list
|
||||
jury_result = await run_jury_filter(candidates, message.content)
|
||||
breakdown = jury_result.format_breakdown()
|
||||
|
||||
# Always show the jury deliberation (verbose, as requested)
|
||||
for chunk in split_for_discord(breakdown):
|
||||
await message.channel.send(chunk)
|
||||
|
||||
if jury_result.has_error:
|
||||
return True
|
||||
|
||||
if not jury_result.safe_questions:
|
||||
return True
|
||||
|
||||
await message.channel.send("🔍 Searching knowledge base with approved questions...")
|
||||
|
||||
# Step 3: Multi-query retrieval — deduplicated by chunk ID
|
||||
seen_ids = set()
|
||||
context_chunks = []
|
||||
for q in jury_result.safe_questions:
|
||||
results = await jury_system.retrieve(q)
|
||||
for r in results:
|
||||
chunk_id = r["metadata"].get("id")
|
||||
if chunk_id not in seen_ids:
|
||||
seen_ids.add(chunk_id)
|
||||
context_chunks.append(r["metadata"]["text"])
|
||||
|
||||
if not context_chunks:
|
||||
await message.channel.send("⚠️ No relevant content found in the knowledge base.")
|
||||
return True
|
||||
|
||||
context = "\n\n---\n\n".join(context_chunks)
|
||||
|
||||
# Step 4: Generate answer with qwen3-235b
|
||||
system_prompt = """You are a helpful mental health support assistant with expertise in DBT (Dialectical Behavior Therapy).
|
||||
Use the provided context to answer the user's question accurately and compassionately.
|
||||
If the answer is not in the context, say so — do not invent information.
|
||||
Be concise, practical, and supportive."""
|
||||
|
||||
answer = await generate_rag_answer(message.content, context, system_prompt)
|
||||
|
||||
await message.channel.send(f"🧠 **Response:**\n{answer}")
|
||||
return True
|
||||
|
||||
|
||||
async def routeCommand(message):
|
||||
@@ -540,10 +634,38 @@ async def routeCommand(message):
|
||||
)
|
||||
|
||||
|
||||
def _restore_sessions_from_cache():
|
||||
"""Try to restore user sessions from cached refresh tokens on startup."""
|
||||
restored = 0
|
||||
for discord_id, cached in user_cache.items():
|
||||
refresh_token = cached.get("refresh_token")
|
||||
if not refresh_token:
|
||||
continue
|
||||
result, status = apiRequest(
|
||||
"post", "/api/refresh",
|
||||
data={"refresh_token": refresh_token},
|
||||
_retried=True,
|
||||
)
|
||||
if status == 200 and "token" in result:
|
||||
token = result["token"]
|
||||
payload = decodeJwtPayload(token)
|
||||
user_uuid = payload["sub"]
|
||||
user_sessions[discord_id] = {
|
||||
"token": token,
|
||||
"user_uuid": user_uuid,
|
||||
"username": cached.get("username", ""),
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
restored += 1
|
||||
if restored:
|
||||
print(f"Restored {restored} user session(s) from cache")
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_ready():
|
||||
print(f"Bot logged in as {client.user}")
|
||||
loadCache()
|
||||
_restore_sessions_from_cache()
|
||||
backgroundLoop.start()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user