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:
2026-02-19 13:05:48 -06:00
parent 6850abf7d2
commit d4adbde3df
10 changed files with 474 additions and 69 deletions

View File

@@ -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()