diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index fc5a2f0..e1f6a08 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -327,6 +327,75 @@ async def _gather_internal(user_id: int) -> dict: } +async def _gather_weekly_review(user_id: int) -> dict: + """Collect 7-day activity summary for the weekly review.""" + from fabledassistant.services.notes import list_notes + from fabledassistant.services.projects import list_projects + from fabledassistant.services.events import list_events as list_internal_events + + tz_name = await get_setting(user_id, "user_timezone") or "UTC" + try: + user_tz = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + user_tz = ZoneInfo("UTC") + + now = datetime.now(user_tz) + week_ago = now - __import__('datetime').timedelta(days=7) + next_week = now + __import__('datetime').timedelta(days=7) + + # Tasks completed this week + completed_tasks = [] + overdue_tasks = [] + try: + all_task_objs, _ = await list_notes(user_id, is_task=True, limit=200) + for t in all_task_objs: + if t.status == "done" and t.completed_at and t.completed_at >= week_ago: + completed_tasks.append(t.title or "Untitled") + elif t.due_date and t.due_date.isoformat() < now.date().isoformat() and t.status not in ("done", "cancelled"): + overdue_tasks.append(t.title or "Untitled") + except Exception: + logger.debug("Weekly review: failed to gather tasks", exc_info=True) + + # Notes created this week + notes_created = 0 + try: + all_notes, _ = await list_notes(user_id, is_task=False, limit=200) + notes_created = sum(1 for n in all_notes if n.created_at and n.created_at >= week_ago) + except Exception: + logger.debug("Weekly review: failed to count notes", exc_info=True) + + # Project activity + active_projects = [] + try: + projects = await list_projects(user_id) + active_projects = [p.title for p in projects if p.status == "active"][:5] + except Exception: + pass + + # Events next week + upcoming_events = [] + try: + next_events = await list_internal_events( + user_id=user_id, + date_from=now, + date_to=next_week, + ) + for e in next_events[:8]: + upcoming_events.append(f"{e.get('title', 'Event')} — {e.get('start_dt', '')[:10]}") + except Exception: + pass + + return { + "completed_count": len(completed_tasks), + "completed_highlights": completed_tasks[:5], + "overdue_count": len(overdue_tasks), + "overdue_items": overdue_tasks[:5], + "notes_created": notes_created, + "active_projects": active_projects, + "upcoming_events": upcoming_events, + } + + # ── External data gather ────────────────────────────────────────────────────── async def _gather_external(user_id: int) -> dict: @@ -400,6 +469,14 @@ def _unified_system_prompt(profile_body: str, slot: str = "compilation") -> str: "Ask if there's anything to capture before tomorrow — notes, thoughts, follow-ups. " "Keep it to 2-4 sentences. Reflective and encouraging tone.\n\n" ) + elif slot == "weekly_review": + base += ( + "This is the WEEKLY REVIEW — a reflective recap of the past 7 days. " + "Summarize accomplishments, flag lingering overdue items, note how many notes were created, " + "and preview the upcoming week's key events. " + "Be reflective and encouraging — celebrate progress, gently flag what's stuck. " + "Aim for 5 to 8 sentences. This wraps up a chapter.\n\n" + ) if profile_body: base += f"User profile:\n{profile_body}\n" @@ -409,6 +486,28 @@ def _unified_system_prompt(profile_body: str, slot: str = "compilation") -> str: def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str: lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""] + # Weekly review — separate data path + if slot == "weekly_review": + weekly = internal_data.get("weekly_review") or {} + lines.append(f"COMPLETED THIS WEEK: {weekly.get('completed_count', 0)} tasks") + if weekly.get("completed_highlights"): + lines.extend(f" - {t}" for t in weekly["completed_highlights"]) + lines.append("") + if weekly.get("overdue_count", 0) > 0: + lines.append(f"STILL OVERDUE: {weekly['overdue_count']} task(s)") + lines.extend(f" - {t}" for t in weekly.get("overdue_items", [])) + lines.append("") + lines.append(f"NOTES CREATED: {weekly.get('notes_created', 0)}") + lines.append("") + if weekly.get("active_projects"): + lines.append(f"ACTIVE PROJECTS: {', '.join(weekly['active_projects'])}") + lines.append("") + if weekly.get("upcoming_events"): + lines.append("UPCOMING WEEK:") + lines.extend(f" - {e}" for e in weekly["upcoming_events"]) + lines.append("") + return "\n".join(lines) + # For check-in slots, build a slimmer prompt if slot in ("morning", "midday"): # Just tasks status + any weather changes @@ -598,6 +697,10 @@ async def run_compilation( get_cached_weather_rows(user_id), ) + # Weekly review: gather 7-day summary data + if slot == "weekly_review": + internal_data["weekly_review"] = await _gather_weekly_review(user_id) + # Task change detection all_tasks = internal_data.get("all_tasks_raw", []) changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks) diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index e2cfa44..e69fec4 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -36,6 +36,11 @@ SLOTS = [ ("afternoon", 16, 0), ] +# Weekly review runs Sunday at 6pm by default +WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format +WEEKLY_REVIEW_HOUR = 18 +WEEKLY_REVIEW_MINUTE = 0 + # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -102,6 +107,26 @@ def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None: replace_existing=True, misfire_grace_time=3600, ) + # Weekly review job — runs once per week + weekly_jid = _job_id(user_id, "weekly_review") + weekly_on = enabled_slots.get("weekly_review", True) + if weekly_on: + _scheduler.add_job( + _run_user_slot_sync, + CronTrigger( + day_of_week=WEEKLY_REVIEW_DAY, + hour=WEEKLY_REVIEW_HOUR, + minute=WEEKLY_REVIEW_MINUTE, + timezone=tz, + ), + args=[user_id, "weekly_review"], + id=weekly_jid, + replace_existing=True, + misfire_grace_time=7200, + ) + elif _scheduler.get_job(weekly_jid): + _scheduler.remove_job(weekly_jid) + logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz) @@ -113,6 +138,9 @@ def _remove_user_jobs(user_id: int) -> None: jid = _job_id(user_id, slot_name) if _scheduler.get_job(jid): _scheduler.remove_job(jid) + weekly_jid = _job_id(user_id, "weekly_review") + if _scheduler.get_job(weekly_jid): + _scheduler.remove_job(weekly_jid) logger.info("Removed briefing jobs for user %d", user_id)