Compare commits

..

9 Commits

Author SHA1 Message Date
Chelsea
215c3d7f95 fix yesterdays schedule blocking todays 2026-02-20 20:04:35 +00:00
fe07b3ebe7 Fix cross-midnight adaptive dose creating false missed reminders
When adaptive timing shifts a late-night dose past midnight (e.g. 23:00
→ 00:42), the scheduler would create a new pending schedule on the next
day even if the dose was already taken. The proximity window was too
narrow to match the take log against the shifted time.

- Skip creating schedules for doses already taken/skipped (checks
  today + yesterday logs against base_time)
- Fix midnight wraparound in proximity check for should_send_nag
- Display base_time (actual dose time) in reminders instead of the
  internal adjusted_time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:58:45 -06:00
019561e7cd Fix bot routine scheduling field mismatch and add debug logging
Bot was sending days_of_week/times but API expects days/time, so
bot-scheduled routines never got reminders. Also handle NULL frequency
from pre-migration rows and add detailed logging to routine reminder
checks for diagnosing further issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:21:22 -06:00
e89656a87c Fix adaptive medication timing and update README
- Fix double notifications: remove redundant check_medication_reminders()
  call, use adaptive path as primary with basic as fallback
- Fix nag firing immediately: require nag_interval minutes after scheduled
  dose time before first nag
- Fix missing schedules: create on-demand if midnight window was missed
- Fix wrong timezone: use user_now_for() instead of request-context
  user_now() in calculate_adjusted_times()
- Fix immutable schedules: recalculate pending schedules on wake event
  detection so adaptive timing actually adapts
- Fix take/skip not updating schedule: API endpoints now call
  mark_med_taken/skipped so nags stop after logging a dose
- Fix skipped doses still triggering reminders: check both taken and
  skipped in adaptive reminder and log queries
- Update README with tasks, AI step generation, auth refresh tokens,
  knowledge base improvements, and current architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 23:34:38 -06:00
03da0b0156 Fix timezone mismatch in already-taken check 2026-02-19 20:51:44 -06:00
cf29d17183 Also suppress nags for skipped medications 2026-02-19 20:40:31 -06:00
cc1aace73d Fix presence tracking and med reminder bugs 2026-02-19 20:31:43 -06:00
a19e30db68 Fix medication reminders for already-taken meds
- Convert created_at from UTC to user's local timezone before comparing dates

- Add scheduled_time check in adaptive reminders (was only checking if any dose was taken today)

- Prevents duplicate reminders when user is in a different timezone than UTC
2026-02-19 20:12:22 -06:00
e9a2f96f91 Fix offset-naive/aware datetime error in nag checker
Make last_nag_at timezone-aware before subtracting from the tz-aware
current_time, and store future nag timestamps with timezone.utc to
prevent the mismatch recurring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 19:43:32 -06:00
12 changed files with 5396 additions and 167 deletions

300
README.md
View File

@@ -1,14 +1,14 @@
# Synculous # Synculous
A routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD. A comprehensive routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame. The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame. It combines structured routines, intelligent medication tracking, AI-powered safety systems, and peer accountability features into one unified platform.
## Architecture ## Architecture
``` ```
synculous/ synculous/
├── synculous-client/ # Next.js 16 frontend (React, Tailwind) ├── synculous-client/ # Next.js frontend (React, Tailwind)
├── api/ # Flask REST API ├── api/ # Flask REST API
│ ├── main.py # App entry point, auth routes │ ├── main.py # App entry point, auth routes
│ └── routes/ # Domain route modules │ └── routes/ # Domain route modules
@@ -19,28 +19,49 @@ synculous/
│ ├── routine_steps_extended.py # Step instructions, types, media │ ├── routine_steps_extended.py # Step instructions, types, media
│ ├── routine_tags.py # Tagging system │ ├── routine_tags.py # Tagging system
│ ├── medications.py # Medication scheduling + adherence │ ├── medications.py # Medication scheduling + adherence
│ ├── adaptive_meds.py # Adaptive medication timing (learning)
│ ├── tasks.py # One-off tasks/appointments CRUD
│ ├── ai.py # AI-powered step generation
│ ├── preferences.py # User settings + timezone │ ├── preferences.py # User settings + timezone
│ ├── notifications.py # Web push subscriptions │ ├── notifications.py # Web push subscriptions
│ ├── rewards.py # Variable reward system │ ├── rewards.py # Variable reward system
── victories.py # Achievement detection ── victories.py # Achievement detection
│ └── snitch.py # Peer accountability contacts + notifications
├── core/ # Shared business logic ├── core/ # Shared business logic
│ ├── postgres.py # Generic PostgreSQL CRUD │ ├── postgres.py # Generic PostgreSQL CRUD
│ ├── auth.py # JWT + bcrypt authentication │ ├── auth.py # JWT + bcrypt authentication
│ ├── users.py # User management │ ├── users.py # User management
│ ├── routines.py # Routine/session/streak logic │ ├── routines.py # Routine/session/streak logic
│ ├── tz.py # Timezone-aware date/time helpers │ ├── stats.py # Statistics calculations (completion rates, streaks)
│ ├── snitch.py # Snitch trigger logic + notification delivery
│ ├── adaptive_meds.py # Adaptive medication timing logic
│ ├── tz.py # Timezone-aware date/time helpers (IANA + offset)
│ └── notifications.py # Multi-channel notifications │ └── notifications.py # Multi-channel notifications
├── scheduler/ ├── scheduler/
│ └── daemon.py # Background polling for reminders │ └── daemon.py # Background polling for reminders
├── bot/ # Discord bot (optional) ├── bot/ # Discord bot with knowledge RAG
├── ai/ # LLM parser for natural language commands │ ├── bot.py # Bot entry point + session management
│ ├── command_registry.py # Module-based command routing
│ ├── commands/ # Command modules
│ │ ├── routines.py # /routine commands
│ │ ├── medications.py # /med, /take, /skip commands
│ │ ├── tasks.py # /task commands (one-off tasks/appointments)
│ │ └── knowledge.py # /ask command (jury-filtered RAG)
│ └── hooks.py # Event listeners
├── ai/ # LLM-powered features
│ ├── parser.py # OpenRouter API client
│ ├── jury_council.py # 5-juror safety filtration system
│ ├── ai_config.json # Model + prompt configuration
│ └── (optional) RAG embeddings
├── config/ ├── config/
│ ├── schema.sql # Database schema │ ├── schema.sql # Database schema
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates │ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
│ ├── seed_rewards.sql # Variable reward pool │ ├── seed_rewards.sql # Variable reward pool
│ └── .env.example # Environment template │ └── .env.example # Environment template
├── diagrams/ # Architecture diagrams (Mermaid)
├── docker-compose.yml ├── docker-compose.yml
── Dockerfile ── Dockerfile
└── tests/
``` ```
## Quick Start ## Quick Start
@@ -49,10 +70,10 @@ synculous/
# Copy environment config # Copy environment config
cp config/.env.example config/.env cp config/.env.example config/.env
# Edit with your values # Edit with your values (at minimum: DB_PASS, JWT_SECRET, optionally DISCORD_BOT_TOKEN, OPENROUTER_API_KEY)
nano config/.env nano config/.env
# Start everything # Start everything (db, api, scheduler, optional bot, frontend client)
docker-compose up docker-compose up
``` ```
@@ -61,20 +82,23 @@ This starts five services:
| Service | Port | Description | | Service | Port | Description |
|---------|------|-------------| |---------|------|-------------|
| `db` | 5432 | PostgreSQL 16 with schema + seed data | | `db` | 5432 | PostgreSQL 16 with schema + seed data |
| `app` | 8080 | Flask API | | `app` | 8010 | Flask API (internal: 5000) |
| `scheduler` | — | Background daemon for medication/routine reminders | | `scheduler` | — | Background daemon for medication/routine reminders |
| `bot` | — | Discord bot (optional, needs `DISCORD_BOT_TOKEN`) | | `bot` | — | Discord bot with commands and knowledge RAG (optional, needs `DISCORD_BOT_TOKEN`) |
| `client` | 3000 | Next.js frontend | | `client` | 3001 | Next.js frontend (internal: 3000) |
## Features ## Features
### Routines ### Routines & Sessions
- Create routines with ordered steps (4-7 steps recommended) - Create routines with ordered steps (4-7 steps recommended)
- Run sessions with a guided, one-step-at-a-time focus interface - Run sessions with a guided, one-step-at-a-time focus interface
- Complete, skip, pause, resume, or cancel sessions - Complete, skip, pause, resume, or cancel sessions
- Swipe gestures for step completion on mobile - Swipe gestures for step completion on mobile
- Per-step timing with visual countdown - Per-step timing with visual countdown
- Animated celebration screen on completion with streak stats and variable rewards - Animated celebration screen on completion with streak stats and variable rewards
- Every-N-day frequency option for routines
- Tagging system for organizing routines by category
- Session notes for logging context or blockers
### Premade Templates ### Premade Templates
12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`: 12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`:
@@ -96,13 +120,44 @@ This starts five services:
All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max. All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max.
### One-Off Tasks & Appointments
- Create standalone tasks and appointments outside of routines
- Scheduled date/time with optional reminders
- Quick-complete action for fast check-off
- Tasks appear on the routines timeline for a unified daily view
- AI-powered task composition via bot and web client
- Natural language date parsing in bot commands
### AI-Powered Step Generation
- Generate ADHD-friendly routine steps from a plain-language goal description
- Uses OpenRouter LLM to produce 2-minute-rule-compliant steps
- Each step includes name and estimated duration
- Available via API endpoint and integrated into the web client's routine creation flow
### Medications ### Medications
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN) - Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN); batch-schedule multiple meds at once
- "Today's meds" view with cross-midnight lookahead (late night + early morning) - "Today's meds" view with cross-midnight lookahead (late night + early morning)
- Take, skip, snooze actions with logging - Take, skip, snooze actions with logging
- Adherence tracking and statistics - Adherence tracking and statistics
- Refill tracking with low-quantity alerts - Refill tracking with low-quantity alerts
- Background reminders via the scheduler daemon - Background reminders via the scheduler daemon
- Medication editing (update name, dose, schedule, refill count)
- Web push and Discord notifications for doses
#### Adaptive Medication Timing
- Machine-learning-based timing predictions based on user adherence patterns
- System learns when you're most likely to take meds
- Automatic reminder optimization (fewer false-positive reminders)
- Override and manual timing adjustments always available
- Useful for people with irregular schedules
### Peer Accountability: "Snitch" Feature
- Designate trusted contacts to receive medication adherence notifications
- Contacts don't need an account or login
- Granular privacy controls: users choose *what* to share (meds, streaks, notes)
- Contacts receive weekly summaries or real-time alerts for missed doses
- Based on research showing peer accountability improves adherence
- Optional consent-based consent flow for contact approvals
### Streaks and Stats ### Streaks and Stats
- Per-routine streak tracking (current + longest) - Per-routine streak tracking (current + longest)
@@ -116,36 +171,97 @@ All templates follow the design framework: two-minute-rule entry points, concret
- Random reward on routine completion (post-completion only, never mid-routine) - Random reward on routine completion (post-completion only, never mid-routine)
- Reward history tracking per user - Reward history tracking per user
- Common and rare rarity tiers - Common and rare rarity tiers
- Designed to leverage variable-ratio reinforcement schedules
### Notifications ### Notifications
- Web push notifications via VAPID - Web push notifications via VAPID (in-browser)
- Discord webhooks - Discord webhooks (for bot mentions)
- ntfy support - Discord DMs (for sensitive notifications like "snitch" alerts)
- Scheduled reminders for medications and routines - ntfy support (for self-hosted push)
- Timezone-aware scheduled reminders for medications and routines
### Timezone Support ### Timezone Support (Dual-Path)
All date/time operations respect the user's local timezone: All date/time operations respect the user's local timezone via two mechanisms:
- The frontend sends `X-Timezone-Offset` with every API request
- The timezone offset is also persisted to `user_preferences` for background jobs **In request context:**
- Streaks, "today's meds," weekly stats, and reminders all use the user's local date - The frontend sends `X-Timezone-Name` (IANA standard, e.g., "America/New_York") or `X-Timezone-Offset` header
- The scheduler daemon looks up each user's stored offset for reminder timing - Handlers use these headers for real-time API operations
**In background jobs (scheduler daemon):**
- Timezone is stored in `user_preferences.timezone_name` (IANA format)
- Scheduler retrieves stored timezone for each user
- Falls back to numeric offset, then UTC if name is unavailable
- Enables accurate reminder delivery even if user's browser context is offline
**Result:** Streaks, "today's meds," weekly stats, and reminders all use the user's local date.
### Authentication & Session Persistence
- JWT access tokens (1-hour expiry) + optional long-lived refresh tokens (30 days)
- "Trust this device" option on login issues a refresh token for seamless re-auth
- Bot session caching with persistent pickle storage across restarts
### User Preferences ### User Preferences
- Sound effects (default off — habituation risk) - Sound effects (default off — habituation risk)
- Haptic feedback (default on) - Haptic feedback (default on)
- Launch screen toggle - Launch screen toggle
- Celebration style - Celebration style
- Timezone offset (auto-synced from browser) - Timezone (IANA name + numeric offset, auto-synced from browser)
- Discord presence indicator toggle (shows online/offline in Discord)
### Discord Bot Integration
Full-featured Discord bot for managing routines and medications without opening the app:
#### Bot Commands
- `/routine list` — List all routines
- `/routine start <name>` — Start a routine session (guided steps in Discord thread)
- `/routine stats <name>` — View streak and completion stats
- `/med today` — Show today's medications with status
- `/med take <med_name>` — Log a dose as taken
- `/med skip <med_name>` — Log a dose as skipped
- `/task add <description>` — Create a one-off task (supports natural language dates)
- `/task list` — Show upcoming tasks
- `/task done <name>` — Mark a task as complete
- `/ask <question>` — Query the knowledge base with jury-filtered safety checks
- Discord presence automatically shows your routine/meditation status
#### Jury Council Safety System
The `/ask` command uses a sophisticated 5-juror safety filtration pipeline:
**Two-stage process:**
1. **Question Generator** (Qwen3 Nitro, fallback to Qwen3-235B): Expands user query into 2-3 precise search questions
2. **Jury Council** (5 parallel jurors, 100% consensus required): Each juror evaluates questions from a distinct safety lens
**Juror Roles:**
- **Safety:** Would answering cause harm? Evaluates crisis risk (C-SSRS framework), self-harm methods, lethal means
- **Empathy:** Is this emotionally appropriate for someone in distress? Checks for deceptive empathy, harmful validation, stigmatizing language
- **Intent:** Is this benign? Detects jailbreaks, social engineering, prompt injection, method-seeking disguised as education
- **Clarity:** Is the question retrievable? Checks if the question is specific enough to get meaningful results from the knowledge base
- **Ethics:** Within bounds for an informational AI? Blocks diagnosis, treatment planning, medication advice, scope violations, deceptive role-play
**Safety model:** Questions only approved if ALL 5 jurors vote yes. Any juror error = fail closed. Crisis indicators trigger immediate resource redirection (988, Crisis Text Line, etc.) instead of RAG answers.
This system makes it possible to serve help-seeking users asking about self-harm coping strategies, suicidal ideation management, and DBT skills while firmly rejecting harmful intent (method-seeking, glorification, extraction attempts).
### Knowledge Base & RAG
- Embeddings-based retrieval of ADHD, DBT, and mental health educational content
- Multi-book support with user-selectable knowledge bases
- Jury-filtered questions for safety
- LLM-powered intent classification routes general questions to the knowledge base automatically
- Multi-query retrieval with deduplication for better coverage
- DBT advice evaluation mode (checks advice against DBT principles)
- Discord bot `/ask` command uses RAG with jury council checks
- Extensible knowledge source (can add more documents)
## API Overview ## API Overview
All endpoints require `Authorization: Bearer <token>` except `/api/register` and `/api/login`. All endpoints require `Authorization: Bearer <token>` except `/api/register`, `/api/login`, and `/api/refresh`.
### Auth ### Auth
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| POST | `/api/register` | Create account | | POST | `/api/register` | Create account |
| POST | `/api/login` | Get JWT token | | POST | `/api/login` | Get JWT token (optionally with refresh token via `trust_device`) |
| POST | `/api/refresh` | Exchange refresh token for new access token |
### Routines ### Routines
| Method | Endpoint | Description | | Method | Endpoint | Description |
@@ -170,6 +286,7 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| POST | `/api/sessions/:id/resume` | Resume session | | POST | `/api/sessions/:id/resume` | Resume session |
| POST | `/api/sessions/:id/cancel` | Cancel session | | POST | `/api/sessions/:id/cancel` | Cancel session |
| POST | `/api/sessions/:id/abort` | Abort with reason | | POST | `/api/sessions/:id/abort` | Abort with reason |
| POST | `/api/sessions/:id/note` | Add session note |
### Medications ### Medications
| Method | Endpoint | Description | | Method | Endpoint | Description |
@@ -182,6 +299,41 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| GET | `/api/medications/adherence` | Adherence stats | | GET | `/api/medications/adherence` | Adherence stats |
| GET | `/api/medications/refills-due` | Refills due soon | | GET | `/api/medications/refills-due` | Refills due soon |
### Adaptive Medications
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/adaptive-meds/:id` | Get adaptive timing data for a medication |
| PUT | `/api/adaptive-meds/:id` | Update adaptive timing preferences |
| POST | `/api/adaptive-meds/:id/reset` | Reset adaptive learning and return to default schedule |
| GET | `/api/adaptive-meds/stats` | View adaptive timing effectiveness stats |
### Snitch (Peer Accountability)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/snitch/contacts` | List designated accountability contacts |
| POST | `/api/snitch/contacts` | Add a new contact (generates invite link) |
| PUT | `/api/snitch/contacts/:id` | Update contact (name, shared info, frequency) |
| DELETE | `/api/snitch/contacts/:id` | Remove a contact |
| POST | `/api/snitch/contacts/:id/resend-invite` | Resend contact invite link |
| GET | `/api/snitch/contacts/:id/consent` | Get contact's consent status |
| POST | `/api/snitch/contacts/:id/consent` | Contact accepts or declines sharing |
| GET | `/api/snitch/history` | View recent alerts sent to contacts |
| POST | `/api/snitch/test-send` | Send test alert to a contact |
### Tasks
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/tasks` | List user's tasks |
| POST | `/api/tasks` | Create a task |
| PUT | `/api/tasks/:id` | Update a task |
| DELETE | `/api/tasks/:id` | Delete a task |
| POST | `/api/tasks/:id/complete` | Mark task as complete |
### AI
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/ai/generate-steps` | Generate ADHD-friendly routine steps from a goal description |
### Stats ### Stats
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
@@ -191,37 +343,99 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| GET | `/api/routines/weekly-summary` | Weekly progress | | GET | `/api/routines/weekly-summary` | Weekly progress |
| GET | `/api/victories` | Achievement detection | | GET | `/api/victories` | Achievement detection |
### Templates, Tags, Rewards, Preferences ### Templates, Tags, Rewards, Preferences, Notifications
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| GET | `/api/templates` | List available templates | | GET | `/api/templates` | List available templates |
| POST | `/api/templates/:id/clone` | Clone template to user's routines | | POST | `/api/templates/:id/clone` | Clone template to user's routines |
| GET/PUT | `/api/preferences` | User settings | | GET/PUT | `/api/preferences` | User settings and timezone |
| GET | `/api/rewards/random` | Random completion reward | | GET | `/api/rewards/random` | Random completion reward |
| POST | `/api/notifications/subscribe` | Subscribe to web push notifications (VAPID) |
| POST | `/api/notifications/unsubscribe` | Unsubscribe from push notifications |
## Environment Variables ## Environment Variables
| Variable | Description | | Variable | Required | Description |
|----------|-------------| |----------|----------|-------------|
| `DB_HOST` | PostgreSQL host | | `DB_HOST` | Yes | PostgreSQL host (default: `db` in Docker) |
| `DB_PORT` | PostgreSQL port | | `DB_PORT` | Yes | PostgreSQL port (default: `5432`) |
| `DB_NAME` | Database name | | `DB_NAME` | Yes | Database name (default: `app`) |
| `DB_USER` | Database user | | `DB_USER` | Yes | Database user (default: `app`) |
| `DB_PASS` | Database password | | `DB_PASS` | Yes | Database password |
| `JWT_SECRET` | JWT signing secret | | `JWT_SECRET` | Yes | JWT signing secret (generate a random string, min 32 chars) |
| `DISCORD_BOT_TOKEN` | Discord bot token (optional) | | `DISCORD_BOT_TOKEN` | No | Discord bot token (if running Discord bot) |
| `API_URL` | API URL for bot (default: `http://app:5000`) | | `OPENROUTER_API_KEY` | No | OpenRouter API key (if using jury council RAG features) |
| `OPENROUTER_API_KEY` | OpenRouter API key (for AI parser) | | `API_URL` | No | API URL for bot (default: `http://app:5000` in Docker) |
| `POLL_INTERVAL` | Scheduler poll interval in seconds (default: 60) | | `POLL_INTERVAL` | No | Scheduler poll interval in seconds (default: `60`) |
| `VAPID_PUBLIC_KEY` | No | VAPID public key for web push notifications |
| `VAPID_PRIVATE_KEY` | No | VAPID private key for web push notifications |
## Design Framework ## Design Framework
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The three core principles: Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The core principles inform every feature:
### Core Principles
1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration. 1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration.
2. **One Thing at a Time** Current step visually dominant. No decisions during execution. 4-7 steps max per routine. 2. **One Thing at a Time** Current step visually dominant. No decisions during execution. 4-7 steps max per routine.
3. **Zero Shame** No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere. 3. **Zero Shame** No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere.
### Behavioral Foundations
- **Two-Minute Rule Entry Points** Every routine starts with something achievable in under 2 minutes (lower activation energy)
- **Variable Reward Scheduling** Random rewards on completion leverage variable-ratio reinforcement (proven for habit building)
- **Streak-Based Identity** Streaks build intrinsic motivation by making completion a visible, accumulating identity signal
- **Peer Accountability** "Snitch" contacts provide external accountability without shame (research shows this improves adherence)
- **Adaptive Timing** System learns your natural rhythm and optimizes reminders based on your actual behavior (reduces cognitive load)
### Safety & Ethics
- **Jury Council** 5-juror consensus model for AI safety ensures content is appropriate for emotionally vulnerable users
- **Crisis Awareness** System detects crisis indicators and redirects to professional resources (988, Crisis Text Line) rather than generic psychoeducation
- **Transparent Limitations** All system messages clarify "this is educational, not treatment" and encourage professional care
- **User Agency** All adaptive and automated features can be overridden; manual controls are always available
## Development & Testing
### Running Tests
```bash
# Run pytest on all tests
pytest
# Run specific test file
pytest tests/test_routines.py
# Run with coverage
pytest --cov=core --cov=api tests/
```
### Database Migrations
For schema changes, create migration scripts in `config/` and reference them in `docker-compose.yml` or run manually:
```bash
psql -h localhost -U app -d app -f config/migration_name.sql
```
### Timezone Testing
The system uses dual-path timezone support. Test both:
1. **Request headers**: X-Timezone-Name (IANA) or X-Timezone-Offset
2. **Stored preferences**: Verify `user_preferences.timezone_name` is persisted and read by scheduler
### Discord Bot Development
Bot commands are modular in `bot/commands/`. To add a command:
1. Create a new command file in `bot/commands/`
2. Import and register in `bot/bot.py`
3. Bot automatically syncs commands to Discord on startup
## Documentation
Comprehensive documentation is available in `DOCUMENTATION.md`:
- Detailed feature explanations
- Database schema reference
- Jury Council safety model (full spec)
- Deployment & configuration
- Contributing guidelines
## License ## License
MIT MIT
---
**Built with evidence-based design for ADHD. Not a replacement for therapy or medication — a tool to support them.**

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -13,6 +13,7 @@ from psycopg2.extras import Json
import core.auth as auth import core.auth as auth
import core.postgres as postgres import core.postgres as postgres
import core.tz as tz import core.tz as tz
import core.adaptive_meds as adaptive_meds
def _get_user_uuid(token): def _get_user_uuid(token):
@@ -264,6 +265,11 @@ def register(app):
"notes": data.get("notes"), "notes": data.get("notes"),
} }
log = postgres.insert("med_logs", log_entry) log = postgres.insert("med_logs", log_entry)
# Update adaptive schedule status so nags stop
try:
adaptive_meds.mark_med_taken(user_uuid, med_id, data.get("scheduled_time"))
except Exception:
pass # Don't fail the take action if schedule update fails
# Advance next_dose_date for interval meds # Advance next_dose_date for interval meds
if med.get("frequency") == "every_n_days" and med.get("interval_days"): if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med) next_date = _compute_next_dose_date(med)
@@ -290,6 +296,11 @@ def register(app):
"notes": data.get("reason"), "notes": data.get("reason"),
} }
log = postgres.insert("med_logs", log_entry) log = postgres.insert("med_logs", log_entry)
# Update adaptive schedule status so nags stop
try:
adaptive_meds.mark_med_skipped(user_uuid, med_id, data.get("scheduled_time"))
except Exception:
pass # Don't fail the skip action if schedule update fails
# Advance next_dose_date for interval meds # Advance next_dose_date for interval meds
if med.get("frequency") == "every_n_days" and med.get("interval_days"): if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med) next_date = _compute_next_dose_date(med)

View File

@@ -128,6 +128,7 @@ class JurySystem:
async def retrieve(self, query_text, top_k=5): async def retrieve(self, query_text, top_k=5):
"""Async retrieval — returns list of {metadata, score} dicts.""" """Async retrieval — returns list of {metadata, score} dicts."""
import asyncio import asyncio
return await asyncio.to_thread(self._retrieve_sync, query_text, top_k) return await asyncio.to_thread(self._retrieve_sync, query_text, top_k)
async def query(self, query_text): async def query(self, query_text):
@@ -147,6 +148,7 @@ If the answer is not in the context, say you don't know based on the provided te
Be concise, compassionate, and practical.""" Be concise, compassionate, and practical."""
from ai.jury_council import generate_rag_answer from ai.jury_council import generate_rag_answer
return await generate_rag_answer(query_text, context_text, system_prompt) return await generate_rag_answer(query_text, context_text, system_prompt)
except Exception as e: except Exception as e:
return f"Error querying DBT knowledge base: {e}" return f"Error querying DBT knowledge base: {e}"
@@ -181,7 +183,9 @@ def apiRequest(method, endpoint, token=None, data=None, _retried=False):
if resp.status_code == 401 and not _retried: if resp.status_code == 401 and not _retried:
new_token = _try_refresh_token_for_session(token) new_token = _try_refresh_token_for_session(token)
if new_token: if new_token:
return apiRequest(method, endpoint, token=new_token, data=data, _retried=True) return apiRequest(
method, endpoint, token=new_token, data=data, _retried=True
)
try: try:
return resp.json(), resp.status_code return resp.json(), resp.status_code
except ValueError: except ValueError:
@@ -201,9 +205,12 @@ def _try_refresh_token_for_session(expired_token):
if cached: if cached:
refresh_token = cached.get("refresh_token") refresh_token = cached.get("refresh_token")
if refresh_token: if refresh_token:
result, status = apiRequest("post", "/api/refresh", result, status = apiRequest(
"post",
"/api/refresh",
data={"refresh_token": refresh_token}, data={"refresh_token": refresh_token},
_retried=True) _retried=True,
)
if status == 200 and "token" in result: if status == 200 and "token" in result:
new_token = result["token"] new_token = result["token"]
session["token"] = new_token session["token"] = new_token
@@ -258,7 +265,8 @@ def negotiateToken(discord_id, username, password):
# Try refresh token first (avoids sending password) # Try refresh token first (avoids sending password)
if cached and cached.get("refresh_token"): if cached and cached.get("refresh_token"):
result, status = apiRequest( result, status = apiRequest(
"post", "/api/refresh", "post",
"/api/refresh",
data={"refresh_token": cached["refresh_token"]}, data={"refresh_token": cached["refresh_token"]},
_retried=True, _retried=True,
) )
@@ -279,7 +287,9 @@ def negotiateToken(discord_id, username, password):
and cached.get("hashed_password") and cached.get("hashed_password")
and verifyPassword(password, cached.get("hashed_password")) and verifyPassword(password, cached.get("hashed_password"))
): ):
result, status = apiRequest("post", "/api/login", data=login_data, _retried=True) result, status = apiRequest(
"post", "/api/login", data=login_data, _retried=True
)
if status == 200 and "token" in result: if status == 200 and "token" in result:
token = result["token"] token = result["token"]
payload = decodeJwtPayload(token) payload = decodeJwtPayload(token)
@@ -530,7 +540,9 @@ async def handleDBTQuery(message):
if not jury_result.safe_questions: if not jury_result.safe_questions:
return True return True
await message.channel.send("🔍 Searching knowledge base with approved questions...") await message.channel.send(
"🔍 Searching knowledge base with approved questions..."
)
# Step 3: Multi-query retrieval — deduplicated by chunk ID # Step 3: Multi-query retrieval — deduplicated by chunk ID
seen_ids = set() seen_ids = set()
@@ -544,7 +556,9 @@ async def handleDBTQuery(message):
context_chunks.append(r["metadata"]["text"]) context_chunks.append(r["metadata"]["text"])
if not context_chunks: if not context_chunks:
await message.channel.send("⚠️ No relevant content found in the knowledge base.") await message.channel.send(
"⚠️ No relevant content found in the knowledge base."
)
return True return True
context = "\n\n---\n\n".join(context_chunks) context = "\n\n---\n\n".join(context_chunks)
@@ -644,7 +658,8 @@ def _restore_sessions_from_cache():
if not refresh_token: if not refresh_token:
continue continue
result, status = apiRequest( result, status = apiRequest(
"post", "/api/refresh", "post",
"/api/refresh",
data={"refresh_token": refresh_token}, data={"refresh_token": refresh_token},
_retried=True, _retried=True,
) )
@@ -705,9 +720,14 @@ async def update_presence_tracking():
import core.adaptive_meds as adaptive_meds import core.adaptive_meds as adaptive_meds
import core.postgres as postgres import core.postgres as postgres
print(f"[DEBUG] Running presence tracking. Guilds: {len(client.guilds)}", flush=True) print(
f"[DEBUG] Running presence tracking. Guilds: {len(client.guilds)}",
flush=True,
)
for guild in client.guilds: for guild in client.guilds:
print(f"[DEBUG] Guild: {guild.name} ({guild.id}) - Members: {guild.member_count}") print(
f"[DEBUG] Guild: {guild.name} ({guild.id}) - Members: {guild.member_count}"
)
# Get all users with presence tracking enabled # Get all users with presence tracking enabled
settings = postgres.select( settings = postgres.select(
@@ -733,27 +753,46 @@ async def update_presence_tracking():
# Get the member from a shared guild (needed for presence data) # Get the member from a shared guild (needed for presence data)
try: try:
member = None member = None
try:
target_id = int(discord_user_id) target_id = int(discord_user_id)
except (ValueError, TypeError):
print(
f"[DEBUG] Invalid Discord ID for user {user_uuid}: {discord_user_id}",
flush=True,
)
continue
# Search through all guilds the bot is in # Search through all guilds the bot is in
for guild in client.guilds: for guild in client.guilds:
member = guild.get_member(target_id) member = guild.get_member(target_id)
print(f"[DEBUG] Checked guild {guild.name}, member: {member}", flush=True) print(
f"[DEBUG] Checked guild {guild.name}, member: {member}",
flush=True,
)
if member: if member:
break break
if not member: if not member:
print(f"[DEBUG] User {discord_user_id} not found in any shared guild", flush=True) print(
f"[DEBUG] User {discord_user_id} not found in any shared guild",
flush=True,
)
continue continue
# Check if user is online # Check if user is online
is_online = member.status != discord.Status.offline is_online = member.status != discord.Status.offline
print(f"[DEBUG] User status: {member.status}, is_online: {is_online}", flush=True) print(
f"[DEBUG] User status: {member.status}, is_online: {is_online}",
flush=True,
)
# Get current presence from DB # Get current presence from DB
presence = adaptive_meds.get_user_presence(user_uuid) presence = adaptive_meds.get_user_presence(user_uuid)
was_online = presence.get("is_currently_online") if presence else False was_online = presence.get("is_currently_online") if presence else False
print(f"[DEBUG] Previous state: {was_online}, Current: {is_online}", flush=True) print(
f"[DEBUG] Previous state: {was_online}, Current: {is_online}",
flush=True,
)
# Update presence if changed # Update presence if changed
if is_online != was_online: if is_online != was_online:
@@ -788,6 +827,7 @@ async def presenceTrackingLoop():
except Exception as e: except Exception as e:
print(f"[ERROR] presenceTrackingLoop failed: {e}", flush=True) print(f"[ERROR] presenceTrackingLoop failed: {e}", flush=True)
import traceback import traceback
traceback.print_exc() traceback.print_exc()

View File

@@ -217,12 +217,13 @@ async def handle_routine(message, session, parsed):
await message.channel.send("When should this routine be scheduled? (e.g., 'Monday Wednesday Friday at 7am')") await message.channel.send("When should this routine be scheduled? (e.g., 'Monday Wednesday Friday at 7am')")
return return
# Build schedule data # Build schedule data (API expects "days" and "time")
schedule_data = {} schedule_data = {}
if days_of_week: if days_of_week:
schedule_data["days_of_week"] = days_of_week schedule_data["days"] = days_of_week
if times: if times:
schedule_data["times"] = times schedule_data["time"] = times[0]
schedule_data["remind"] = True
resp, status = api_request("put", f"/api/routines/{routine_id}/schedule", token, schedule_data) resp, status = api_request("put", f"/api/routines/{routine_id}/schedule", token, schedule_data)
if status == 200: if status == 200:

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,10 @@ This module handles:
import json import json
import uuid import uuid
from datetime import datetime, timedelta, time from datetime import datetime, timedelta, time, timezone
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
import core.postgres as postgres import core.postgres as postgres
from core.tz import user_now, user_today_for from core.tz import user_now, user_now_for, user_today_for, tz_for_user
def _normalize_time(val): def _normalize_time(val):
@@ -42,12 +42,24 @@ def get_user_presence(user_uuid: str) -> Optional[Dict]:
def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool): def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
"""Update user's presence status.""" """Update user's presence status. If a wake event is detected (came online
after 30+ minutes offline), recalculates today's adaptive medication schedules."""
now = datetime.utcnow() now = datetime.utcnow()
presence = get_user_presence(user_uuid) presence = get_user_presence(user_uuid)
is_wake_event = False
if presence: if presence:
# Detect wake event before updating
if is_online and not presence.get("is_currently_online"):
last_offline = presence.get("last_offline_at")
if last_offline:
if isinstance(last_offline, datetime) and last_offline.tzinfo is None:
last_offline = last_offline.replace(tzinfo=timezone.utc)
offline_duration = (now.replace(tzinfo=timezone.utc) - last_offline).total_seconds()
if offline_duration > 1800: # 30 minutes
is_wake_event = True
# Update existing record # Update existing record
updates = {"is_currently_online": is_online, "updated_at": now} updates = {"is_currently_online": is_online, "updated_at": now}
@@ -71,6 +83,26 @@ def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
} }
postgres.insert("user_presence", data) postgres.insert("user_presence", data)
# On wake event, recalculate today's adaptive schedules
if is_wake_event:
_recalculate_schedules_on_wake(user_uuid, now)
def _recalculate_schedules_on_wake(user_uuid: str, wake_time: datetime):
"""Recalculate today's pending adaptive schedules using the actual wake time."""
settings = get_adaptive_settings(user_uuid)
if not settings or not settings.get("adaptive_timing_enabled"):
return
try:
meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in meds:
times = med.get("times", [])
if times:
create_daily_schedule(user_uuid, med["id"], times, recalculate=True)
except Exception:
pass # Best-effort — don't break presence tracking if this fails
def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime): def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime):
"""Record a presence event in the history.""" """Record a presence event in the history."""
@@ -182,12 +214,8 @@ def calculate_adjusted_times(
# Return base times with 0 offset # Return base times with 0 offset
return [(t, 0) for t in base_times] return [(t, 0) for t in base_times]
# Get user's timezone # Get current time in user's timezone (works in both request and scheduler context)
prefs = postgres.select("user_preferences", {"user_uuid": user_uuid}) user_current_time = user_now_for(user_uuid)
offset_minutes = prefs[0].get("timezone_offset", 0) if prefs else 0
# Get current time in user's timezone
user_current_time = user_now(offset_minutes)
today = user_current_time.date() today = user_current_time.date()
# Determine wake time # Determine wake time
@@ -270,7 +298,7 @@ def should_send_nag(
return False, "User offline" return False, "User offline"
# Get today's schedule record for this specific time slot # Get today's schedule record for this specific time slot
today = current_time.date() today = user_today_for(user_uuid)
query = {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today} query = {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}
if scheduled_time is not None: if scheduled_time is not None:
query["adjusted_time"] = scheduled_time query["adjusted_time"] = scheduled_time
@@ -291,30 +319,89 @@ def should_send_nag(
nag_interval = settings.get("nag_interval_minutes", 15) nag_interval = settings.get("nag_interval_minutes", 15)
if last_nag: if last_nag:
if isinstance(last_nag, datetime) and last_nag.tzinfo is None:
last_nag = last_nag.replace(tzinfo=timezone.utc)
time_since_last_nag = (current_time - last_nag).total_seconds() / 60 time_since_last_nag = (current_time - last_nag).total_seconds() / 60
if time_since_last_nag < nag_interval: if time_since_last_nag < nag_interval:
return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)" return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)"
else:
# First nag: require at least nag_interval minutes since the scheduled dose time
if scheduled_time:
sched_hour, sched_min = int(scheduled_time[:2]), int(scheduled_time[3:5])
sched_dt = current_time.replace(hour=sched_hour, minute=sched_min, second=0, microsecond=0)
minutes_since_dose = (current_time - sched_dt).total_seconds() / 60
if minutes_since_dose < nag_interval:
return False, f"Too soon after dose time ({int(minutes_since_dose)} < {nag_interval} min)"
# Check if this specific dose was already taken today # Check if this specific dose was already taken or skipped today
logs = postgres.select( logs = postgres.select(
"med_logs", "med_logs",
{ {
"medication_id": med_id, "medication_id": med_id,
"user_uuid": user_uuid, "user_uuid": user_uuid,
"action": "taken",
"scheduled_time": scheduled_time,
}, },
) )
# Filter to today's logs for this time slot # Get medication times to calculate dose interval for proximity check
today_logs = [ med = postgres.select_one("medications", {"id": med_id})
log dose_interval_minutes = 60 # default fallback
for log in logs if med and med.get("times"):
if log.get("created_at") and log["created_at"].date() == today times = med["times"]
] if len(times) >= 2:
time_minutes = []
for t in times:
t = _normalize_time(t)
if t:
h, m = int(t[:2]), int(t[3:5])
time_minutes.append(h * 60 + m)
time_minutes.sort()
intervals = []
for i in range(1, len(time_minutes)):
intervals.append(time_minutes[i] - time_minutes[i - 1])
if intervals:
dose_interval_minutes = min(intervals)
if today_logs: proximity_window = max(30, dose_interval_minutes // 2)
return False, "Already taken today"
# Filter to today's logs and check for this specific dose
user_tz = tz_for_user(user_uuid)
for log in logs:
action = log.get("action")
if action not in ("taken", "skipped"):
continue
created_at = log.get("created_at")
if not created_at:
continue
# created_at is stored as UTC but timezone-naive; convert to user's timezone
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
created_at_local = created_at.astimezone(user_tz)
if created_at_local.date() != today:
continue
log_scheduled_time = log.get("scheduled_time")
if log_scheduled_time:
log_scheduled_time = _normalize_time(log_scheduled_time)
if log_scheduled_time == scheduled_time:
return False, f"Already {action} today"
else:
if scheduled_time:
log_hour = created_at_local.hour
log_min = created_at_local.minute
sched_hour, sched_min = (
int(scheduled_time[:2]),
int(scheduled_time[3:5]),
)
log_mins = log_hour * 60 + log_min
sched_mins = sched_hour * 60 + sched_min
diff_minutes = abs(log_mins - sched_mins)
# Handle midnight wraparound (e.g. 23:00 vs 00:42)
diff_minutes = min(diff_minutes, 1440 - diff_minutes)
if diff_minutes <= proximity_window:
return False, f"Already {action} today"
return True, "Time to nag" return True, "Time to nag"
@@ -335,13 +422,18 @@ def record_nag_sent(user_uuid: str, med_id: str, scheduled_time):
postgres.update( postgres.update(
"medication_schedules", "medication_schedules",
{"nag_count": new_nag_count, "last_nag_at": datetime.utcnow()}, {"nag_count": new_nag_count, "last_nag_at": datetime.now(timezone.utc)},
{"id": schedule["id"]}, {"id": schedule["id"]},
) )
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]): def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str], recalculate: bool = False):
"""Create today's medication schedule with adaptive adjustments.""" """Create today's medication schedule with adaptive adjustments.
If recalculate=True, deletes existing *pending* schedules and recreates them
with updated adaptive timing (e.g. after a wake event is detected).
Already-taken or skipped schedules are preserved.
"""
today = user_today_for(user_uuid) today = user_today_for(user_uuid)
# Check if schedule already exists # Check if schedule already exists
@@ -350,14 +442,62 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}, {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
) )
if existing: if existing and not recalculate:
return
if existing and recalculate:
# Only delete pending schedules — preserve taken/skipped
for sched in existing:
if sched.get("status") == "pending":
postgres.delete("medication_schedules", {"id": sched["id"]})
# Check if any pending remain to create
remaining = [s for s in existing if s.get("status") != "pending"]
completed_base_times = set()
for s in remaining:
bt = _normalize_time(s.get("base_time"))
if bt:
completed_base_times.add(bt)
# Only create schedules for times that haven't been taken/skipped
base_times = [t for t in base_times if t not in completed_base_times]
if not base_times:
return return
# Calculate adjusted times # Calculate adjusted times
adjusted_times = calculate_adjusted_times(user_uuid, base_times) adjusted_times = calculate_adjusted_times(user_uuid, base_times)
# Check recent med logs to skip doses already taken/skipped.
# Handles cross-midnight: if adaptive offset shifts 23:00 → 00:42 today,
# but the user already took the 23:00 dose last night, don't schedule it.
# Yesterday's logs only suppress if the scheduled_time is late-night
# (21:00+), since only those could plausibly cross midnight with an offset.
user_tz = tz_for_user(user_uuid)
yesterday = today - timedelta(days=1)
recent_logs = postgres.select("med_logs", {"medication_id": med_id, "user_uuid": user_uuid})
taken_base_times = set()
for log in recent_logs:
if log.get("action") not in ("taken", "skipped"):
continue
created_at = log.get("created_at")
if not created_at:
continue
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
log_date = created_at.astimezone(user_tz).date()
if log_date == today:
log_sched = _normalize_time(log.get("scheduled_time"))
if log_sched:
taken_base_times.add(log_sched)
elif log_date == yesterday:
# Only suppress cross-midnight doses (late-night times like 21:00+)
log_sched = _normalize_time(log.get("scheduled_time"))
if log_sched and log_sched >= "21:00":
taken_base_times.add(log_sched)
# Create schedule records for each time # Create schedule records for each time
for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times): for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times):
if base_time in taken_base_times:
continue
data = { data = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"user_uuid": user_uuid, "user_uuid": user_uuid,
@@ -373,17 +513,40 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
def mark_med_taken(user_uuid: str, med_id: str, scheduled_time): def mark_med_taken(user_uuid: str, med_id: str, scheduled_time):
"""Mark a medication as taken.""" """Mark a medication schedule as taken."""
_mark_med_status(user_uuid, med_id, scheduled_time, "taken")
def mark_med_skipped(user_uuid: str, med_id: str, scheduled_time):
"""Mark a medication schedule as skipped."""
_mark_med_status(user_uuid, med_id, scheduled_time, "skipped")
def _mark_med_status(user_uuid: str, med_id: str, scheduled_time, status: str):
"""Update a medication schedule's status for today."""
scheduled_time = _normalize_time(scheduled_time) scheduled_time = _normalize_time(scheduled_time)
today = user_today_for(user_uuid) today = user_today_for(user_uuid)
postgres.update( # Try matching by adjusted_time first
"medication_schedules", where = {
{"status": "taken"},
{
"user_uuid": user_uuid, "user_uuid": user_uuid,
"medication_id": med_id, "medication_id": med_id,
"adjustment_date": today, "adjustment_date": today,
"adjusted_time": scheduled_time, }
}, if scheduled_time is not None:
) where["adjusted_time"] = scheduled_time
schedules = postgres.select("medication_schedules", where)
if schedules:
postgres.update("medication_schedules", {"status": status}, {"id": schedules[0]["id"]})
elif scheduled_time is not None:
# Fallback: try matching by base_time (in case adjusted == base)
where_base = {
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"base_time": scheduled_time,
}
schedules_base = postgres.select("medication_schedules", where_base)
if schedules_base:
postgres.update("medication_schedules", {"status": status}, {"id": schedules_base[0]["id"]})

56
regenerate_embeddings.py Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Regenerate DBT embeddings with qwen/qwen3-embedding-8b model (384 dimensions)"""
import json
import os
from openai import OpenAI
import time
# Load config
with open("config.json", "r") as f:
config = json.load(f)
# Initialize OpenAI client with OpenRouter
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=config["openrouter_api_key"],
)
# Load text data
with open("bot/data/dbt_knowledge.text.json", "r") as f:
text_data = json.load(f)
print(f"Regenerating embeddings for {len(text_data)} chunks...")
# Generate embeddings
embeddings_data = []
for i, item in enumerate(text_data):
try:
response = client.embeddings.create(
model="qwen/qwen3-embedding-8b",
input=item["text"]
)
embedding = response.data[0].embedding
embeddings_data.append({
"id": item["id"],
"source": item["source"],
"text": item["text"],
"embedding": embedding
})
if (i + 1) % 10 == 0:
print(f"Processed {i + 1}/{len(text_data)} chunks...")
# Small delay to avoid rate limits
time.sleep(0.1)
except Exception as e:
print(f"Error processing item {i}: {e}")
continue
# Save new embeddings
with open("bot/data/dbt_knowledge.embeddings.json", "w") as f:
json.dump(embeddings_data, f)
print(f"\nDone! Generated {len(embeddings_data)} embeddings with {len(embeddings_data[0]['embedding'])} dimensions")

View File

@@ -26,6 +26,17 @@ def _user_now_for(user_uuid):
return tz.user_now_for(user_uuid) return tz.user_now_for(user_uuid)
def _utc_to_local_date(created_at, user_tz):
"""Convert a DB created_at (naive UTC datetime) to a local date string YYYY-MM-DD."""
if created_at is None:
return ""
if isinstance(created_at, datetime):
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
return created_at.astimezone(user_tz).date().isoformat()
return str(created_at)[:10]
def check_medication_reminders(): def check_medication_reminders():
"""Check for medications due now and send notifications.""" """Check for medications due now and send notifications."""
try: try:
@@ -47,6 +58,7 @@ def check_medication_reminders():
current_day = now.strftime("%a").lower() current_day = now.strftime("%a").lower()
today = now.date() today = now.date()
today_str = today.isoformat() today_str = today.isoformat()
user_tz = tz.tz_for_user(user_uuid)
for med in user_med_list: for med in user_med_list:
freq = med.get("frequency", "daily") freq = med.get("frequency", "daily")
@@ -83,13 +95,13 @@ def check_medication_reminders():
if current_time not in times: if current_time not in times:
continue continue
# Already taken today? Check by created_at date # Already taken today? Check by created_at date in user's timezone
logs = postgres.select( logs = postgres.select(
"med_logs", where={"medication_id": med["id"], "action": "taken"} "med_logs", where={"medication_id": med["id"], "action": "taken"}
) )
already_taken = any( already_taken = any(
log.get("scheduled_time") == current_time log.get("scheduled_time") == current_time
and str(log.get("created_at", ""))[:10] == today_str and _utc_to_local_date(log.get("created_at"), user_tz) == today_str
for log in logs for log in logs
) )
if already_taken: if already_taken:
@@ -111,20 +123,25 @@ def check_routine_reminders():
from datetime import date as date_type from datetime import date as date_type
schedules = postgres.select("routine_schedules", where={"remind": True}) schedules = postgres.select("routine_schedules", where={"remind": True})
logger.info(f"Routine reminders: found {len(schedules)} schedule(s) with remind=True")
for schedule in schedules: for schedule in schedules:
routine = postgres.select_one("routines", {"id": schedule["routine_id"]}) routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
if not routine: if not routine:
logger.warning(f"Routine not found for schedule {schedule['id']}")
continue continue
now = _user_now_for(routine["user_uuid"]) now = _user_now_for(routine["user_uuid"])
current_time = now.strftime("%H:%M") current_time = now.strftime("%H:%M")
today = now.date() today = now.date()
if current_time != schedule.get("time"): sched_time = schedule.get("time")
if current_time != sched_time:
continue continue
frequency = schedule.get("frequency", "weekly") logger.info(f"Routine '{routine['name']}' time match at {current_time}")
frequency = schedule.get("frequency") or "weekly"
if frequency == "every_n_days": if frequency == "every_n_days":
start = schedule.get("start_date") start = schedule.get("start_date")
interval = schedule.get("interval_days") interval = schedule.get("interval_days")
@@ -134,14 +151,19 @@ def check_routine_reminders():
if isinstance(start, date_type) if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date() else datetime.strptime(str(start), "%Y-%m-%d").date()
) )
if (today - start_d).days < 0 or (today - start_d).days % interval != 0: if (today - start_d).days < 0 or (
today - start_d
).days % interval != 0:
logger.info(f"Routine '{routine['name']}' skipped: not due today (every {interval} days from {start_d})")
continue continue
else: else:
logger.warning(f"Routine '{routine['name']}' skipped: every_n_days but missing start_date={start} or interval_days={interval}")
continue continue
else: else:
current_day = now.strftime("%a").lower() current_day = now.strftime("%a").lower()
days = schedule.get("days", []) days = schedule.get("days", [])
if current_day not in days: if current_day not in days:
logger.info(f"Routine '{routine['name']}' skipped: {current_day} not in {days}")
continue continue
user_settings = notifications.getNotificationSettings(routine["user_uuid"]) user_settings = notifications.getNotificationSettings(routine["user_uuid"])
@@ -150,8 +172,11 @@ def check_routine_reminders():
notifications._sendToEnabledChannels( notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=routine["user_uuid"] user_settings, msg, user_uuid=routine["user_uuid"]
) )
logger.info(f"Routine reminder sent for '{routine['name']}'")
else:
logger.warning(f"No notification settings for user {routine['user_uuid']}, skipping routine '{routine['name']}'")
except Exception as e: except Exception as e:
logger.error(f"Error checking routine reminders: {e}") logger.error(f"Error checking routine reminders: {e}", exc_info=True)
def check_refills(): def check_refills():
@@ -213,6 +238,7 @@ def check_adaptive_medication_reminders():
now = _user_now_for(user_uuid) now = _user_now_for(user_uuid)
current_time = now.strftime("%H:%M") current_time = now.strftime("%H:%M")
today = now.date() today = now.date()
user_tz = tz.tz_for_user(user_uuid)
# Check if adaptive timing is enabled # Check if adaptive timing is enabled
settings = adaptive_meds.get_adaptive_settings(user_uuid) settings = adaptive_meds.get_adaptive_settings(user_uuid)
@@ -248,18 +274,37 @@ def check_adaptive_medication_reminders():
else: else:
continue continue
# Get today's schedule # Get today's schedule (any status — we filter below)
schedules = postgres.select( schedules = postgres.select(
"medication_schedules", "medication_schedules",
where={ where={
"user_uuid": user_uuid, "user_uuid": user_uuid,
"medication_id": med["id"], "medication_id": med["id"],
"adjustment_date": today, "adjustment_date": today,
"status": "pending",
}, },
) )
# If no schedules exist yet, create them on demand
if not schedules:
times = med.get("times", [])
if times:
try:
adaptive_meds.create_daily_schedule(user_uuid, med["id"], times)
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med["id"],
"adjustment_date": today,
},
)
except Exception as e:
logger.warning(f"Could not create on-demand schedule for {med['id']}: {e}")
for sched in schedules: for sched in schedules:
# Skip already-taken or skipped schedules
if sched.get("status") in ("taken", "skipped"):
continue
# Check if it's time to take this med # Check if it's time to take this med
if adaptive_enabled: if adaptive_enabled:
# Use adjusted time # Use adjusted time
@@ -277,32 +322,40 @@ def check_adaptive_medication_reminders():
if check_time != current_time: if check_time != current_time:
continue continue
# Check if already taken # Check if already taken or skipped for this time slot today
logs = postgres.select( logs = postgres.select(
"med_logs", "med_logs",
where={ where={
"medication_id": med["id"], "medication_id": med["id"],
"user_uuid": user_uuid, "user_uuid": user_uuid,
"action": "taken",
}, },
) )
already_taken = any( already_handled = any(
str(log.get("created_at", ""))[:10] == today.isoformat() log.get("action") in ("taken", "skipped")
and log.get("scheduled_time") == check_time
and _utc_to_local_date(log.get("created_at"), user_tz)
== today.isoformat()
for log in logs for log in logs
) )
if already_taken: if already_handled:
continue continue
# Send notification # Send notification — display base_time to the user
base_display = sched.get("base_time")
if isinstance(base_display, time_type):
base_display = base_display.strftime("%H:%M")
elif base_display is not None:
base_display = str(base_display)[:5]
user_settings = notifications.getNotificationSettings(user_uuid) user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings: if user_settings:
offset = sched.get("adjustment_minutes", 0) offset = sched.get("adjustment_minutes", 0)
if offset > 0: if offset > 0:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time} (adjusted +{offset}min)" msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {base_display} (adjusted +{offset}min)"
else: else:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time}" msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {base_display}"
notifications._sendToEnabledChannels( notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid user_settings, msg, user_uuid=user_uuid
@@ -352,7 +405,9 @@ def check_nagging():
}, },
) )
except Exception as e: except Exception as e:
logger.warning(f"Could not query medication_schedules for {med_id}: {e}") logger.warning(
f"Could not query medication_schedules for {med_id}: {e}"
)
# Table may not exist yet # Table may not exist yet
continue continue
@@ -360,7 +415,9 @@ def check_nagging():
if not schedules: if not schedules:
if not _is_med_due_today(med, today): if not _is_med_due_today(med, today):
continue continue
logger.info(f"No schedules found for medication {med_id}, attempting to create") logger.info(
f"No schedules found for medication {med_id}, attempting to create"
)
times = med.get("times", []) times = med.get("times", [])
if times: if times:
try: try:
@@ -392,11 +449,8 @@ def check_nagging():
if not should_nag: if not should_nag:
continue continue
# Get the time to display # Always display the base_time (the user's actual dose time),
adaptive_enabled = settings.get("adaptive_timing_enabled") # not the internal adjusted_time used for scheduling.
if adaptive_enabled:
display_time = sched.get("adjusted_time")
else:
display_time = sched.get("base_time") display_time = sched.get("base_time")
# Normalize TIME objects for display # Normalize TIME objects for display
if isinstance(display_time, time_type): if isinstance(display_time, time_type):
@@ -531,9 +585,7 @@ def _check_per_user_midnight_schedules():
continue continue
times = med.get("times", []) times = med.get("times", [])
if times: if times:
adaptive_meds.create_daily_schedule( adaptive_meds.create_daily_schedule(user_uuid, med["id"], times)
user_uuid, med["id"], times
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Could not create adaptive schedules for user {user_uuid}: {e}" f"Could not create adaptive schedules for user {user_uuid}: {e}"
@@ -543,6 +595,7 @@ def _check_per_user_midnight_schedules():
def check_task_reminders(): def check_task_reminders():
"""Check one-off tasks for advance and at-time reminders.""" """Check one-off tasks for advance and at-time reminders."""
from datetime import timedelta from datetime import timedelta
try: try:
tasks = postgres.select("tasks", where={"status": "pending"}) tasks = postgres.select("tasks", where={"status": "pending"})
if not tasks: if not tasks:
@@ -575,15 +628,24 @@ def check_task_reminders():
# Advance reminder # Advance reminder
if reminder_min > 0 and not task.get("advance_notified"): if reminder_min > 0 and not task.get("advance_notified"):
adv_dt = sched_dt - timedelta(minutes=reminder_min) adv_dt = sched_dt - timedelta(minutes=reminder_min)
if adv_dt.date() == current_date and adv_dt.strftime("%H:%M") == current_hhmm: if (
adv_dt.date() == current_date
and adv_dt.strftime("%H:%M") == current_hhmm
):
if user_settings is None: if user_settings is None:
user_settings = notifications.getNotificationSettings(user_uuid) user_settings = notifications.getNotificationSettings(
user_uuid
)
if user_settings: if user_settings:
msg = f"⏰ In {reminder_min} min: {task['title']}" msg = f"⏰ In {reminder_min} min: {task['title']}"
if task.get("description"): if task.get("description"):
msg += f"{task['description']}" msg += f"{task['description']}"
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid) notifications._sendToEnabledChannels(
postgres.update("tasks", {"advance_notified": True}, {"id": task["id"]}) user_settings, msg, user_uuid=user_uuid
)
postgres.update(
"tasks", {"advance_notified": True}, {"id": task["id"]}
)
# At-time reminder # At-time reminder
if sched_date == current_date and sched_hhmm == current_hhmm: if sched_date == current_date and sched_hhmm == current_hhmm:
@@ -593,10 +655,15 @@ def check_task_reminders():
msg = f"📋 Now: {task['title']}" msg = f"📋 Now: {task['title']}"
if task.get("description"): if task.get("description"):
msg += f"\n{task['description']}" msg += f"\n{task['description']}"
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid) notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
postgres.update( postgres.update(
"tasks", "tasks",
{"status": "notified", "updated_at": datetime.utcnow().isoformat()}, {
"status": "notified",
"updated_at": datetime.utcnow().isoformat(),
},
{"id": task["id"]}, {"id": task["id"]},
) )
except Exception as e: except Exception as e:
@@ -608,13 +675,14 @@ def poll_callback():
# Create daily schedules per-user at their local midnight # Create daily schedules per-user at their local midnight
_check_per_user_midnight_schedules() _check_per_user_midnight_schedules()
# Check reminders - use both original and adaptive checks # Check medication reminders (adaptive path handles both adaptive and non-adaptive)
logger.info("Checking medication reminders") logger.info("Checking medication reminders")
check_medication_reminders()
try: try:
check_adaptive_medication_reminders() check_adaptive_medication_reminders()
except Exception as e: except Exception as e:
logger.warning(f"Adaptive medication reminder check failed: {e}") logger.warning(f"Adaptive medication reminder check failed: {e}")
# Fall back to basic reminders if adaptive check fails entirely
check_medication_reminders()
# Check for nags - log as error to help with debugging # Check for nags - log as error to help with debugging
try: try:

View File

@@ -396,14 +396,24 @@ export default function SettingsPage() {
</button> </button>
</div> </div>
{notif.discord_enabled && ( {notif.discord_enabled && (
<div className="space-y-1">
<input <input
type="text" type="text"
placeholder="Your Discord user ID" placeholder="Your Discord user ID (numbers only)"
value={notif.discord_user_id} value={notif.discord_user_id}
onChange={(e) => setNotif({ ...notif, discord_user_id: e.target.value })} onChange={(e) => {
const val = e.target.value;
if (val === '' || /^\d+$/.test(val)) {
setNotif({ ...notif, discord_user_id: val });
}
}}
onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })} onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-700" className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-700"
/> />
<p className="text-xs text-gray-500 dark:text-gray-400">
Enable Developer Mode in Discord, right-click your profile, and copy User ID
</p>
</div>
)} )}
</div> </div>
</div> </div>
@@ -831,7 +841,7 @@ export default function SettingsPage() {
/> />
<select <select
value={newContact.contact_type} value={newContact.contact_type}
onChange={(e) => setNewContact({ ...newContact, contact_type: e.target.value })} onChange={(e) => setNewContact({ ...newContact, contact_type: e.target.value, contact_value: '' })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800" className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
> >
<option value="discord">Discord</option> <option value="discord">Discord</option>
@@ -840,9 +850,18 @@ export default function SettingsPage() {
</select> </select>
<input <input
type="text" type="text"
placeholder={newContact.contact_type === 'discord' ? 'Discord User ID' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'} placeholder={newContact.contact_type === 'discord' ? 'Discord User ID (numbers only)' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
value={newContact.contact_value} value={newContact.contact_value}
onChange={(e) => setNewContact({ ...newContact, contact_value: e.target.value })} onChange={(e) => {
const val = e.target.value;
if (newContact.contact_type === 'discord') {
if (val === '' || /^\d+$/.test(val)) {
setNewContact({ ...newContact, contact_value: val });
}
} else {
setNewContact({ ...newContact, contact_value: val });
}
}}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800" className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">