diff --git a/ai/ai_config.json b/ai/ai_config.json index fb60a25..2b0a470 100644 --- a/ai/ai_config.json +++ b/ai/ai_config.json @@ -8,7 +8,7 @@ }, "command_parser": { "system": "You are a helpful AI assistant that parses user commands into structured JSON. Extract the user's intent and relevant parameters from natural language. Return ONLY valid JSON, no explanations.\n\nBe flexible with language - handle typos, slang, and casual phrasing. Consider conversation context when available.\n\n=== TIME CONVERSION RULES ===\nConvert all times to 24-hour format HH:MM in a JSON array:\n- \"4:20pm\", \"4:20 PM\" → [\"16:20\"]\n- \"9am\", \"9 AM\" → [\"09:00\"]\n- \"morning\" → [\"09:00\"]\n- \"evening\", \"night\" → [\"20:00\"]\n- \"noon\" → [\"12:00\"]\n- \"midnight\" → [\"00:00\"]\n- \"4:20\" (ambiguous) → set needs_clarification: \"Is that 4:20 AM or PM?\"\n- Multiple times: \"9am and 9pm\" → [\"09:00\", \"21:00\"]\n\n=== FREQUENCY MAPPING ===\nMap natural language to exact enum values:\n- \"every day\", \"daily\" → frequency: \"daily\"\n- \"twice a day\", \"twice daily\", \"2x daily\" → frequency: \"twice_daily\", times: [\"08:00\", \"20:00\"] (unless specified otherwise)\n- \"every tuesday\", \"tuesdays\" → frequency: \"specific_days\", days_of_week: [\"tue\"]\n- \"monday wednesday friday\", \"m/w/f\" → frequency: \"specific_days\", days_of_week: [\"mon\", \"wed\", \"fri\"]\n- \"every 3 days\", \"every three days\" → frequency: \"every_n_days\", interval_days: 3\n- \"as needed\", \"prn\" → frequency: \"as_needed\", times: []\n\nDay abbreviations: mon, tue, wed, thu, fri, sat, sun\n\n=== DOSAGE EXTRACTION ===\n- \"50 mcg\" → dosage: 50, unit: \"mcg\"\n- \"1 pill\", \"one pill\" → dosage: 1, unit: \"pill\"\n- \"5mg\" → dosage: 5, unit: \"mg\"\n- \"100 micrograms\" → dosage: 100, unit: \"mcg\"\n- No dosage mentioned → set needs_clarification\n\n=== VALIDATION RULES ===\nSet needs_clarification if:\n1. Dosage is missing for 'add' action\n2. Time is ambiguous (e.g., just \"4:20\" without AM/PM)\n3. Frequency is unclear (e.g., \"sometimes\", \"often\")\n4. Name cannot be determined\n\n=== INTERACTION TYPES ===\n- \"routine\": habits, morning routines, task sequences\n- \"medication\": pills, prescriptions, supplements, dosages\n- \"knowledge\": ANY question or request for advice/help that is not about managing medications or routines. This includes: questions about books, DBT skills, mental health, motivation, behavior, emotions, ADHD, productivity, self-improvement, and any general how-to or advice question.\n\nIMPORTANT: If the user asks ANY question (how do I..., what should I..., why do I..., how can I..., what is..., explain...) and it is not clearly about adding/taking a medication or managing a routine, classify it as knowledge with action \"query\". Do NOT return needs_clarification for questions — route them to knowledge.\n\nAvailable books: (1) DBT Skills — covers distress tolerance, emotion regulation, mindfulness, interpersonal effectiveness, opposite action, coping skills. (2) Taking Charge of Adult ADHD — covers focus, executive function, organization, motivation, procrastination, ADHD at work/home. If the question is clearly about ADHD symptoms/strategies, set book to \"adhd\". If it's clearly about emotional coping/DBT skills, set book to \"dbt\". If unsure, omit book and let the system search both.\n\n=== KNOWLEDGE BASE EXAMPLES ===\n- \"what does the book say about time management?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"time management\"}\n- \"ask atomic habits about habit formation\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"book\": \"atomic habits\", \"query\": \"habit formation\"}\n- \"list available books\" → {\"interaction_type\": \"knowledge\", \"action\": \"list\"}\n- \"select book 2\" → {\"interaction_type\": \"knowledge\", \"action\": \"select\", \"book\": \"2\"}\n- \"how does the ADHD book suggest handling procrastination?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"book\": \"adhd\", \"query\": \"handling procrastination\"}\n- \"what does taking charge of adult adhd say about sleep?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"book\": \"taking charge of adult adhd\", \"query\": \"sleep\"}\n- \"how do I handle ADHD at work according to the book?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"handling ADHD at work\"}\n- \"how do I do things I don't want to do?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"how to do things you don't want to do\"}\n- \"why do I keep avoiding tasks?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"avoiding tasks procrastination\"}\n- \"how do I cope with feeling overwhelmed?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"coping with feeling overwhelmed\"}\n- \"what is radical acceptance?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"radical acceptance\"}\n- \"give me a skill for managing anger\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"managing anger\"}\n- \"how do I focus better with ADHD?\" → {\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"focus strategies ADHD\"}", - "user_template": "Parse this command into structured JSON.\n\nCurrent conversation context:\n{history_context}\n\nUser message: \"{user_input}\"\n\nReturn JSON with these exact fields:\n{{\n \"interaction_type\": \"routine\" | \"medication\" | \"knowledge\",\n \"action\": \"string\",\n \"goal\": \"string\" (user's original goal, used with ai_compose action),\n \"name\": \"string\" (med/routine name),\n \"routine_name\": \"string\" (for step-related actions),\n \"description\": \"string\" (optional),\n \"steps\": [\"step1\", \"step2\"] (for routine creation),\n \"dosage\": number (for meds),\n \"unit\": \"string\" (mg, mcg, pill, etc),\n \"frequency\": \"daily\" | \"twice_daily\" | \"specific_days\" | \"every_n_days\" | \"as_needed\",\n \"times\": [\"HH:MM\"],\n \"days_of_week\": [\"mon\", \"tue\", ...],\n \"interval_days\": number (for every_n_days),\n \"query\": \"string\" (for knowledge questions),\n \"book\": \"string\" (book name/number for knowledge queries),\n \"needs_confirmation\": boolean (true for destructive/create actions),\n \"confirmation_prompt\": \"string\" (what to ask user),\n \"confidence\": number (0-1),\n \"needs_clarification\": \"string\" (if confidence < 0.8 or missing required fields)\n}}\n\n=== EXAMPLES ===\n\nMedication examples:\n1. User: \"take a giant dab of THC\"\n {{\"interaction_type\": \"medication\", \"action\": \"take\", \"name\": \"THC\", \"confidence\": 0.9}}\n\n2. User: \"add lsd 50 mcg daily at 9am\"\n {{\"interaction_type\": \"medication\", \"action\": \"add\", \"name\": \"lsd\", \"dosage\": 50, \"unit\": \"mcg\", \"frequency\": \"daily\", \"times\": [\"09:00\"], \"confidence\": 0.95}}\n\n3. User: \"add wellbutrin 150 mg twice daily\"\n {{\"interaction_type\": \"medication\", \"action\": \"add\", \"name\": \"wellbutrin\", \"dosage\": 150, \"unit\": \"mg\", \"frequency\": \"twice_daily\", \"times\": [\"08:00\", \"20:00\"], \"confidence\": 0.95}}\n\n4. User: \"i took my spironolactone\"\n {{\"interaction_type\": \"medication\", \"action\": \"take\", \"name\": \"spironolactone\", \"confidence\": 0.95}}\n\n5. User: \"took my meds\" or \"i took all my meds\" or \"all the due ones\" (batch - mark all pending doses as taken)\n {{\"interaction_type\": \"medication\", \"action\": \"take_all\", \"confidence\": 0.9}}\n\n6. User: \"i took my 50mg spiro\"\n {{\"interaction_type\": \"medication\", \"action\": \"take\", \"name\": \"spiro\", \"dosage\": 50, \"unit\": \"mg\", \"confidence\": 0.9}}\n\n7. User: \"skip my wellbutrin today\"\n {{\"interaction_type\": \"medication\", \"action\": \"skip\", \"name\": \"wellbutrin\", \"confidence\": 0.9}}\n\n8. User: \"snooze my spironolactone reminder for 30 minutes\"\n {{\"interaction_type\": \"medication\", \"action\": \"snooze\", \"name\": \"spironolactone\", \"minutes\": 30, \"confidence\": 0.95}}\n\nRoutine examples:\n1. User: \"create morning routine with brush teeth, shower, eat\"\n {{\"interaction_type\": \"routine\", \"action\": \"create_with_steps\", \"name\": \"morning\", \"steps\": [\"brush teeth\", \"shower\", \"eat\"], \"confidence\": 0.95}}\n\n2. User: \"start my morning routine\"\n {{\"interaction_type\": \"routine\", \"action\": \"start\", \"name\": \"morning\", \"confidence\": 0.9}}\n\n3. User: \"done with step 3\"\n {{\"interaction_type\": \"routine\", \"action\": \"advance_step\", \"confidence\": 0.9}}\n\n4. User: \"skip this step\"\n {{\"interaction_type\": \"routine\", \"action\": \"skip_step\", \"confidence\": 0.9}}\n\n5. User: \"pause my routine\"\n {{\"interaction_type\": \"routine\", \"action\": \"pause\", \"confidence\": 0.9}}\n\n6. User: \"I need to clean my room\"\n {{\"interaction_type\": \"routine\", \"action\": \"ai_compose\", \"goal\": \"clean my room\", \"name\": \"room cleaning\", \"confidence\": 0.9}}\n\n7. User: \"help me build a morning routine\"\n {{\"interaction_type\": \"routine\", \"action\": \"ai_compose\", \"goal\": \"build a morning routine\", \"name\": \"morning\", \"confidence\": 0.9}}\n\n8. User: \"create an evening wind-down routine for me\"\n {{\"interaction_type\": \"routine\", \"action\": \"ai_compose\", \"goal\": \"evening wind-down\", \"name\": \"wind-down\", \"confidence\": 0.9}}\n\nKnowledge examples:\n1. User: \"what does the book say about time management?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"time management\", \"confidence\": 0.9}}\n\n2. User: \"ask atomic habits about habit formation\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"book\": \"atomic habits\", \"query\": \"habit formation\", \"confidence\": 0.95}}\n\n3. User: \"list available books\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"list\", \"confidence\": 0.95}}\n\n4. User: \"select book 2\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"select\", \"book\": \"2\", \"confidence\": 0.95}}\n\n5. User: \"what does taking charge of adult adhd say about sleep?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"book\": \"taking charge of adult adhd\", \"query\": \"sleep\", \"confidence\": 0.95}}\n\n6. User: \"how do I handle ADHD at work according to the book?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"handling ADHD at work\", \"confidence\": 0.9}}\n\n7. User: \"how do I do things I don't want to do?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"how to do things you don't want to do\", \"confidence\": 0.9}}\n\n8. User: \"why do I keep avoiding tasks?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"avoiding tasks procrastination\", \"confidence\": 0.9}}\n\n9. User: \"how do I cope with feeling overwhelmed?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"coping with feeling overwhelmed\", \"confidence\": 0.9}}\n\n10. User: \"give me a skill for managing anger\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"managing anger\", \"confidence\": 0.9}}\n\n11. User: \"what is radical acceptance?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"radical acceptance\", \"confidence\": 0.95}}" + "user_template": "Parse this command into structured JSON.\n\nCurrent conversation context:\n{history_context}\n\nUser message: \"{user_input}\"\n\nReturn JSON with these exact fields:\n{{\n \"interaction_type\": \"routine\" | \"medication\" | \"knowledge\" | \"task\",\n \"action\": \"string\",\n \"goal\": \"string\" (user's original goal, used with ai_compose action),\n \"name\": \"string\" (med/routine name),\n \"routine_name\": \"string\" (for step-related actions),\n \"description\": \"string\" (optional),\n \"steps\": [\"step1\", \"step2\"] (for routine creation),\n \"dosage\": number (for meds),\n \"unit\": \"string\" (mg, mcg, pill, etc),\n \"frequency\": \"daily\" | \"twice_daily\" | \"specific_days\" | \"every_n_days\" | \"as_needed\",\n \"times\": [\"HH:MM\"],\n \"days_of_week\": [\"mon\", \"tue\", ...],\n \"interval_days\": number (for every_n_days),\n \"query\": \"string\" (for knowledge questions),\n \"book\": \"string\" (book name/number for knowledge queries),\n \"title\": \"string\" (task title),\n \"datetime\": \"string\" (natural language datetime for tasks, e.g. 'tomorrow 14:00'),\n \"reminder_minutes_before\": number (advance reminder minutes for tasks),\n \"needs_confirmation\": boolean (true for destructive/create actions),\n \"confirmation_prompt\": \"string\" (what to ask user),\n \"confidence\": number (0-1),\n \"needs_clarification\": \"string\" (if confidence < 0.8 or missing required fields)\n}}\n\n=== EXAMPLES ===\n\nMedication examples:\n1. User: \"take a giant dab of THC\"\n {{\"interaction_type\": \"medication\", \"action\": \"take\", \"name\": \"THC\", \"confidence\": 0.9}}\n\n2. User: \"add lsd 50 mcg daily at 9am\"\n {{\"interaction_type\": \"medication\", \"action\": \"add\", \"name\": \"lsd\", \"dosage\": 50, \"unit\": \"mcg\", \"frequency\": \"daily\", \"times\": [\"09:00\"], \"confidence\": 0.95}}\n\n3. User: \"add wellbutrin 150 mg twice daily\"\n {{\"interaction_type\": \"medication\", \"action\": \"add\", \"name\": \"wellbutrin\", \"dosage\": 150, \"unit\": \"mg\", \"frequency\": \"twice_daily\", \"times\": [\"08:00\", \"20:00\"], \"confidence\": 0.95}}\n\n4. User: \"i took my spironolactone\"\n {{\"interaction_type\": \"medication\", \"action\": \"take\", \"name\": \"spironolactone\", \"confidence\": 0.95}}\n\n5. User: \"took my meds\" or \"i took all my meds\" or \"all the due ones\" (batch - mark all pending doses as taken)\n {{\"interaction_type\": \"medication\", \"action\": \"take_all\", \"confidence\": 0.9}}\n\n6. User: \"i took my 50mg spiro\"\n {{\"interaction_type\": \"medication\", \"action\": \"take\", \"name\": \"spiro\", \"dosage\": 50, \"unit\": \"mg\", \"confidence\": 0.9}}\n\n7. User: \"skip my wellbutrin today\"\n {{\"interaction_type\": \"medication\", \"action\": \"skip\", \"name\": \"wellbutrin\", \"confidence\": 0.9}}\n\n8. User: \"snooze my spironolactone reminder for 30 minutes\"\n {{\"interaction_type\": \"medication\", \"action\": \"snooze\", \"name\": \"spironolactone\", \"minutes\": 30, \"confidence\": 0.95}}\n\nRoutine examples:\n1. User: \"create morning routine with brush teeth, shower, eat\"\n {{\"interaction_type\": \"routine\", \"action\": \"create_with_steps\", \"name\": \"morning\", \"steps\": [\"brush teeth\", \"shower\", \"eat\"], \"confidence\": 0.95}}\n\n2. User: \"start my morning routine\"\n {{\"interaction_type\": \"routine\", \"action\": \"start\", \"name\": \"morning\", \"confidence\": 0.9}}\n\n3. User: \"done with step 3\"\n {{\"interaction_type\": \"routine\", \"action\": \"advance_step\", \"confidence\": 0.9}}\n\n4. User: \"skip this step\"\n {{\"interaction_type\": \"routine\", \"action\": \"skip_step\", \"confidence\": 0.9}}\n\n5. User: \"pause my routine\"\n {{\"interaction_type\": \"routine\", \"action\": \"pause\", \"confidence\": 0.9}}\n\n6. User: \"I need to clean my room\"\n {{\"interaction_type\": \"routine\", \"action\": \"ai_compose\", \"goal\": \"clean my room\", \"name\": \"room cleaning\", \"confidence\": 0.9}}\n\n7. User: \"help me build a morning routine\"\n {{\"interaction_type\": \"routine\", \"action\": \"ai_compose\", \"goal\": \"build a morning routine\", \"name\": \"morning\", \"confidence\": 0.9}}\n\n8. User: \"create an evening wind-down routine for me\"\n {{\"interaction_type\": \"routine\", \"action\": \"ai_compose\", \"goal\": \"evening wind-down\", \"name\": \"wind-down\", \"confidence\": 0.9}}\n\nKnowledge examples:\n1. User: \"what does the book say about time management?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"time management\", \"confidence\": 0.9}}\n\n2. User: \"ask atomic habits about habit formation\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"book\": \"atomic habits\", \"query\": \"habit formation\", \"confidence\": 0.95}}\n\n3. User: \"list available books\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"list\", \"confidence\": 0.95}}\n\n4. User: \"select book 2\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"select\", \"book\": \"2\", \"confidence\": 0.95}}\n\n5. User: \"what does taking charge of adult adhd say about sleep?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"book\": \"taking charge of adult adhd\", \"query\": \"sleep\", \"confidence\": 0.95}}\n\n6. User: \"how do I handle ADHD at work according to the book?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"handling ADHD at work\", \"confidence\": 0.9}}\n\n7. User: \"how do I do things I don't want to do?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"how to do things you don't want to do\", \"confidence\": 0.9}}\n\n8. User: \"why do I keep avoiding tasks?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"avoiding tasks procrastination\", \"confidence\": 0.9}}\n\n9. User: \"how do I cope with feeling overwhelmed?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"coping with feeling overwhelmed\", \"confidence\": 0.9}}\n\n10. User: \"give me a skill for managing anger\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"managing anger\", \"confidence\": 0.9}}\n\n11. User: \"what is radical acceptance?\"\n {{\"interaction_type\": \"knowledge\", \"action\": \"query\", \"query\": \"radical acceptance\", \"confidence\": 0.95}}\n\nTask examples:\n1. User: \"remind me about my dentist appointment tomorrow at 2pm\"\n {{\"interaction_type\": \"task\", \"action\": \"add\", \"title\": \"dentist appointment\", \"datetime\": \"tomorrow 14:00\", \"reminder_minutes_before\": 30, \"confidence\": 0.9}}\n\n2. User: \"add a task: call mom on friday at 6pm\"\n {{\"interaction_type\": \"task\", \"action\": \"add\", \"title\": \"call mom\", \"datetime\": \"friday 18:00\", \"reminder_minutes_before\": 15, \"confidence\": 0.95}}\n\n3. User: \"what tasks do i have coming up\"\n {{\"interaction_type\": \"task\", \"action\": \"list\", \"confidence\": 0.9}}\n\n4. User: \"mark my dentist appointment as done\"\n {{\"interaction_type\": \"task\", \"action\": \"done\", \"title\": \"dentist\", \"confidence\": 0.85}}" } }, "validation": { diff --git a/api/main.py b/api/main.py index 4cef2fd..25a842d 100644 --- a/api/main.py +++ b/api/main.py @@ -24,6 +24,7 @@ import api.routes.victories as victories_routes import api.routes.adaptive_meds as adaptive_meds_routes import api.routes.snitch as snitch_routes import api.routes.ai as ai_routes +import api.routes.tasks as tasks_routes app = flask.Flask(__name__) CORS(app) @@ -43,6 +44,7 @@ ROUTE_MODULES = [ adaptive_meds_routes, snitch_routes, ai_routes, + tasks_routes, ] diff --git a/api/routes/tasks.py b/api/routes/tasks.py new file mode 100644 index 0000000..2aa14f6 --- /dev/null +++ b/api/routes/tasks.py @@ -0,0 +1,109 @@ +""" +api/routes/tasks.py - One-off scheduled task CRUD +""" + +import uuid +import flask +import jwt +import os +from datetime import datetime + +import core.postgres as postgres + +JWT_SECRET = os.getenv("JWT_SECRET") + + +def _get_user_uuid(request): + """Extract and validate user UUID from JWT token.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("sub") + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + +def register(app): + + @app.route("/api/tasks", methods=["GET"]) + def get_tasks(): + user_uuid = _get_user_uuid(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + status_filter = flask.request.args.get("status", "pending") + if status_filter == "all": + tasks = postgres.select( + "tasks", + where={"user_uuid": user_uuid}, + order_by="scheduled_datetime ASC", + ) + else: + tasks = postgres.select( + "tasks", + where={"user_uuid": user_uuid, "status": status_filter}, + order_by="scheduled_datetime ASC", + ) + # Serialize datetimes for JSON + for t in tasks: + for key in ("scheduled_datetime", "created_at", "updated_at"): + if key in t and hasattr(t[key], "isoformat"): + t[key] = t[key].isoformat() + return flask.jsonify(tasks), 200 + + @app.route("/api/tasks", methods=["POST"]) + def create_task(): + user_uuid = _get_user_uuid(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + if not data: + return flask.jsonify({"error": "missing body"}), 400 + title = data.get("title", "").strip() + scheduled_datetime = data.get("scheduled_datetime", "").strip() + if not title: + return flask.jsonify({"error": "title is required"}), 400 + if not scheduled_datetime: + return flask.jsonify({"error": "scheduled_datetime is required"}), 400 + task_id = str(uuid.uuid4()) + task = { + "id": task_id, + "user_uuid": user_uuid, + "title": title, + "description": data.get("description") or None, + "scheduled_datetime": scheduled_datetime, + "reminder_minutes_before": int(data.get("reminder_minutes_before", 15)), + "status": "pending", + } + postgres.insert("tasks", task) + return flask.jsonify(task), 201 + + @app.route("/api/tasks/", methods=["PATCH"]) + def update_task(task_id): + user_uuid = _get_user_uuid(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + task = postgres.select_one("tasks", {"id": task_id, "user_uuid": user_uuid}) + if not task: + return flask.jsonify({"error": "not found"}), 404 + data = flask.request.get_json() or {} + updates = {} + for field in ["title", "description", "scheduled_datetime", "reminder_minutes_before", "status"]: + if field in data: + updates[field] = data[field] + updates["updated_at"] = datetime.utcnow().isoformat() + postgres.update("tasks", updates, {"id": task_id}) + return flask.jsonify({**{k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in task.items()}, **updates}), 200 + + @app.route("/api/tasks/", methods=["DELETE"]) + def delete_task(task_id): + user_uuid = _get_user_uuid(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + task = postgres.select_one("tasks", {"id": task_id, "user_uuid": user_uuid}) + if not task: + return flask.jsonify({"error": "not found"}), 404 + postgres.delete("tasks", {"id": task_id}) + return flask.jsonify({"success": True}), 200 diff --git a/bot/bot.py b/bot/bot.py index 5eb1150..7a858e2 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -26,6 +26,7 @@ import ai.parser as ai_parser import bot.commands.routines # noqa: F401 - registers handler import bot.commands.medications # noqa: F401 - registers handler import bot.commands.knowledge # noqa: F401 - registers handler +import bot.commands.tasks # noqa: F401 - registers handler DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") API_URL = os.getenv("API_URL", "http://app:5000") diff --git a/bot/commands/tasks.py b/bot/commands/tasks.py new file mode 100644 index 0000000..63b111a --- /dev/null +++ b/bot/commands/tasks.py @@ -0,0 +1,242 @@ +""" +Tasks command handler - bot-side hooks for one-off tasks/appointments +""" + +from datetime import datetime, timedelta +from bot.command_registry import register_module +import ai.parser as ai_parser + + +def _resolve_datetime(dt_str, user_now): + """Resolve a natural language date string to an ISO datetime string. + Handles: ISO strings, 'today', 'tomorrow', day names, plus 'HH:MM' time.""" + if not dt_str: + return None + dt_str = dt_str.lower().strip() + + # Try direct ISO parse first + for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + return datetime.strptime(dt_str, fmt).isoformat(timespec="minutes") + except ValueError: + pass + + # Split into date word + time word + time_part = None + date_word = dt_str + + # Try to extract HH:MM from the end + parts = dt_str.rsplit(" ", 1) + if len(parts) == 2: + possible_time = parts[1] + if ":" in possible_time: + try: + h, m = [int(x) for x in possible_time.split(":")] + time_part = (h, m) + date_word = parts[0] + except ValueError: + pass + else: + # Might be just a bare hour like "14" + try: + h = int(possible_time) + if 0 <= h <= 23: + time_part = (h, 0) + date_word = parts[0] + except ValueError: + pass + + # Resolve the date part + target = user_now.date() + if "tomorrow" in date_word: + target = target + timedelta(days=1) + elif "today" in date_word or not date_word: + pass + else: + day_map = { + "monday": 0, "tuesday": 1, "wednesday": 2, + "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6, + } + for name, num in day_map.items(): + if name in date_word: + days_ahead = (num - target.weekday()) % 7 + if days_ahead == 0: + days_ahead = 7 # next occurrence + target = target + timedelta(days=days_ahead) + break + + if time_part: + return datetime( + target.year, target.month, target.day, time_part[0], time_part[1] + ).isoformat(timespec="minutes") + return datetime(target.year, target.month, target.day, 9, 0).isoformat(timespec="minutes") + + +def _find_task_by_title(token, title): + """Find a pending task by fuzzy title match. Returns task dict or None.""" + resp, status = api_request("get", "/api/tasks", token) + if status != 200: + return None + tasks = resp if isinstance(resp, list) else [] + title_lower = title.lower() + # Exact match first + for t in tasks: + if t.get("title", "").lower() == title_lower: + return t + # Partial match + for t in tasks: + if title_lower in t.get("title", "").lower() or t.get("title", "").lower() in title_lower: + return t + return None + + +def _format_datetime(dt_str): + """Format ISO datetime string for display.""" + try: + dt = datetime.fromisoformat(str(dt_str)) + return dt.strftime("%a %b %-d at %-I:%M %p") + except Exception: + return str(dt_str) + + +async def handle_task(message, session, parsed): + action = parsed.get("action", "unknown") + token = session["token"] + + # Get user's current time for datetime resolution + from core import tz as tz_mod + user_uuid = session.get("user_uuid") + try: + user_now = tz_mod.user_now_for(user_uuid) + except Exception: + user_now = datetime.utcnow() + + if action == "add": + title = parsed.get("title", "").strip() + dt_str = parsed.get("datetime", "") + reminder_min = parsed.get("reminder_minutes_before", 15) + + if not title: + await message.channel.send("What's the title of the task?") + return + + resolved_dt = _resolve_datetime(dt_str, user_now) if dt_str else None + if not resolved_dt: + await message.channel.send( + "When is this task? Tell me the date and time (e.g. 'tomorrow at 3pm', 'friday 14:00')." + ) + return + + task_data = { + "title": title, + "scheduled_datetime": resolved_dt, + "reminder_minutes_before": int(reminder_min), + } + description = parsed.get("description", "") + if description: + task_data["description"] = description + + resp, status = api_request("post", "/api/tasks", token, task_data) + if status == 201: + reminder_text = f" (reminder {reminder_min} min before)" if int(reminder_min) > 0 else "" + await message.channel.send( + f"✅ Added **{title}** for {_format_datetime(resolved_dt)}{reminder_text}" + ) + else: + await message.channel.send(f"Error: {resp.get('error', 'Failed to create task')}") + + elif action == "list": + resp, status = api_request("get", "/api/tasks", token) + if status == 200: + tasks = resp if isinstance(resp, list) else [] + if not tasks: + await message.channel.send("No pending tasks. Add one with 'remind me about...'") + else: + lines = [] + for t in tasks: + status_emoji = "🔔" if t.get("status") == "notified" else "📋" + lines.append(f"{status_emoji} **{t['title']}** — {_format_datetime(t.get('scheduled_datetime', ''))}") + await message.channel.send("**Your upcoming tasks:**\n" + "\n".join(lines)) + else: + await message.channel.send(f"Error: {resp.get('error', 'Failed to fetch tasks')}") + + elif action in ("done", "complete"): + title = parsed.get("title", "").strip() + if not title: + await message.channel.send("Which task is done?") + return + task = _find_task_by_title(token, title) + if not task: + await message.channel.send(f"Couldn't find a task matching '{title}'.") + return + resp, status = api_request("patch", f"/api/tasks/{task['id']}", token, {"status": "completed"}) + if status == 200: + await message.channel.send(f"✅ Marked **{task['title']}** as done!") + else: + await message.channel.send(f"Error: {resp.get('error', 'Failed to update task')}") + + elif action == "cancel": + title = parsed.get("title", "").strip() + if not title: + await message.channel.send("Which task should I cancel?") + return + task = _find_task_by_title(token, title) + if not task: + await message.channel.send(f"Couldn't find a task matching '{title}'.") + return + resp, status = api_request("patch", f"/api/tasks/{task['id']}", token, {"status": "cancelled"}) + if status == 200: + await message.channel.send(f"❌ Cancelled **{task['title']}**.") + else: + await message.channel.send(f"Error: {resp.get('error', 'Failed to cancel task')}") + + elif action == "delete": + title = parsed.get("title", "").strip() + if not title: + await message.channel.send("Which task should I delete?") + return + task = _find_task_by_title(token, title) + if not task: + await message.channel.send(f"Couldn't find a task matching '{title}'.") + return + resp, status = api_request("delete", f"/api/tasks/{task['id']}", token) + if status == 200: + await message.channel.send(f"🗑️ Deleted **{task['title']}**.") + else: + await message.channel.send(f"Error: {resp.get('error', 'Failed to delete task')}") + + else: + await message.channel.send( + f"Unknown action: {action}. Try: add, list, done, cancel." + ) + + +def api_request(method, endpoint, token, data=None): + import requests + import os + API_URL = os.getenv("API_URL", "http://app:5000") + url = f"{API_URL}{endpoint}" + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"} + try: + resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10) + try: + return resp.json(), resp.status_code + except ValueError: + return {}, resp.status_code + except requests.RequestException: + return {"error": "API unavailable"}, 503 + + +def validate_task_json(data): + errors = [] + if not isinstance(data, dict): + return ["Response must be a JSON object"] + if "error" in data: + return [] + if "action" not in data: + errors.append("Missing required field: action") + return errors + + +register_module("task", handle_task) +ai_parser.register_validator("task", validate_task_json) diff --git a/config/schema.sql b/config/schema.sql index 4715cab..8d632db 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -298,3 +298,19 @@ CREATE INDEX IF NOT EXISTS idx_snitch_contacts_user ON snitch_contacts(user_uuid -- ── Migrations ────────────────────────────────────────────── -- Add IANA timezone name to user preferences (run once on existing DBs) ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS timezone_name VARCHAR(100); + +-- ── Tasks (one-off appointments/reminders) ────────────────── +CREATE TABLE IF NOT EXISTS tasks ( + id UUID PRIMARY KEY, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + scheduled_datetime TIMESTAMP NOT NULL, + reminder_minutes_before INTEGER DEFAULT 15, + advance_notified BOOLEAN DEFAULT FALSE, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_tasks_user_scheduled ON tasks(user_uuid, scheduled_datetime); +CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status) WHERE status = 'pending'; diff --git a/scheduler/daemon.py b/scheduler/daemon.py index 048c249..5bfd13f 100644 --- a/scheduler/daemon.py +++ b/scheduler/daemon.py @@ -515,6 +515,69 @@ def _check_per_user_midnight_schedules(): ) +def check_task_reminders(): + """Check one-off tasks for advance and at-time reminders.""" + from datetime import timedelta + try: + tasks = postgres.select("tasks", where={"status": "pending"}) + if not tasks: + return + + user_tasks = {} + for task in tasks: + uid = task.get("user_uuid") + user_tasks.setdefault(uid, []).append(task) + + for user_uuid, task_list in user_tasks.items(): + now = _user_now_for(user_uuid) + current_hhmm = now.strftime("%H:%M") + current_date = now.date() + user_settings = None # lazy-load once per user + + for task in task_list: + raw_dt = task.get("scheduled_datetime") + if not raw_dt: + continue + sched_dt = ( + raw_dt + if isinstance(raw_dt, datetime) + else datetime.fromisoformat(str(raw_dt)) + ) + sched_date = sched_dt.date() + sched_hhmm = sched_dt.strftime("%H:%M") + reminder_min = task.get("reminder_minutes_before") or 0 + + # Advance reminder + if reminder_min > 0 and not task.get("advance_notified"): + adv_dt = sched_dt - timedelta(minutes=reminder_min) + if adv_dt.date() == current_date and adv_dt.strftime("%H:%M") == current_hhmm: + if user_settings is None: + user_settings = notifications.getNotificationSettings(user_uuid) + if user_settings: + msg = f"⏰ In {reminder_min} min: {task['title']}" + if task.get("description"): + msg += f" — {task['description']}" + notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid) + postgres.update("tasks", {"advance_notified": True}, {"id": task["id"]}) + + # At-time reminder + if sched_date == current_date and sched_hhmm == current_hhmm: + if user_settings is None: + user_settings = notifications.getNotificationSettings(user_uuid) + if user_settings: + msg = f"📋 Now: {task['title']}" + if task.get("description"): + msg += f"\n{task['description']}" + notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid) + postgres.update( + "tasks", + {"status": "notified", "updated_at": datetime.utcnow().isoformat()}, + {"id": task["id"]}, + ) + except Exception as e: + logger.error(f"Error checking task reminders: {e}") + + def poll_callback(): """Called every POLL_INTERVAL seconds.""" # Create daily schedules per-user at their local midnight @@ -537,6 +600,7 @@ def poll_callback(): # Original checks check_routine_reminders() check_refills() + check_task_reminders() def daemon_loop(): diff --git a/synculous-client/src/app/dashboard/layout.tsx b/synculous-client/src/app/dashboard/layout.tsx index 2705a12..72d8e5b 100644 --- a/synculous-client/src/app/dashboard/layout.tsx +++ b/synculous-client/src/app/dashboard/layout.tsx @@ -14,8 +14,7 @@ import { PillIcon, SettingsIcon, LogOutIcon, - CopyIcon, - + ClockIcon, SunIcon, MoonIcon, } from '@/components/ui/Icons'; @@ -24,7 +23,7 @@ import Link from 'next/link'; const navItems = [ { href: '/dashboard', label: 'Today', icon: HomeIcon }, { href: '/dashboard/routines', label: 'Routines', icon: ListIcon }, - { href: '/dashboard/templates', label: 'Templates', icon: CopyIcon }, + { href: '/dashboard/tasks', label: 'Tasks', icon: ClockIcon }, { href: '/dashboard/history', label: 'History', icon: CalendarIcon }, { href: '/dashboard/stats', label: 'Stats', icon: BarChartIcon }, { href: '/dashboard/medications', label: 'Meds', icon: PillIcon }, diff --git a/synculous-client/src/app/dashboard/tasks/new/page.tsx b/synculous-client/src/app/dashboard/tasks/new/page.tsx new file mode 100644 index 0000000..edfe9cb --- /dev/null +++ b/synculous-client/src/app/dashboard/tasks/new/page.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import api from '@/lib/api'; +import { ArrowLeftIcon } from '@/components/ui/Icons'; + +const REMINDER_OPTIONS = [ + { label: 'No reminder', value: 0 }, + { label: '5 minutes before', value: 5 }, + { label: '10 minutes before', value: 10 }, + { label: '15 minutes before', value: 15 }, + { label: '30 minutes before', value: 30 }, + { label: '1 hour before', value: 60 }, +]; + +function localDatetimeDefault(): string { + const now = new Date(); + now.setMinutes(now.getMinutes() + 60, 0, 0); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; +} + +export default function NewTaskPage() { + const router = useRouter(); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [scheduledDatetime, setScheduledDatetime] = useState(localDatetimeDefault); + const [reminderMinutes, setReminderMinutes] = useState(15); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) { + setError('Title is required.'); + return; + } + if (!scheduledDatetime) { + setError('Date and time are required.'); + return; + } + setIsSubmitting(true); + setError(''); + try { + await api.tasks.create({ + title: title.trim(), + description: description.trim() || undefined, + scheduled_datetime: scheduledDatetime, + reminder_minutes_before: reminderMinutes, + }); + router.push('/dashboard/tasks'); + } catch (err) { + console.error('Failed to create task:', err); + setError('Failed to create task. Please try again.'); + setIsSubmitting(false); + } + }; + + return ( +
+
+
+ + + +

New Task

+
+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + setTitle(e.target.value)} + placeholder="e.g. Doctor appointment" + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm" + autoFocus + /> +
+ +
+ +