feat(briefing): weekly review — 7-day recap with task/note/project summary and upcoming preview
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user