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 ──────────────────────────────────────────────────────
|
# ── External data gather ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _gather_external(user_id: int) -> dict:
|
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. "
|
"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"
|
"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:
|
if profile_body:
|
||||||
base += f"User profile:\n{profile_body}\n"
|
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:
|
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}", ""]
|
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
|
# For check-in slots, build a slimmer prompt
|
||||||
if slot in ("morning", "midday"):
|
if slot in ("morning", "midday"):
|
||||||
# Just tasks status + any weather changes
|
# Just tasks status + any weather changes
|
||||||
@@ -598,6 +697,10 @@ async def run_compilation(
|
|||||||
get_cached_weather_rows(user_id),
|
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
|
# Task change detection
|
||||||
all_tasks = internal_data.get("all_tasks_raw", [])
|
all_tasks = internal_data.get("all_tasks_raw", [])
|
||||||
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
|
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ SLOTS = [
|
|||||||
("afternoon", 16, 0),
|
("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 ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -102,6 +107,26 @@ def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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)
|
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)
|
jid = _job_id(user_id, slot_name)
|
||||||
if _scheduler.get_job(jid):
|
if _scheduler.get_job(jid):
|
||||||
_scheduler.remove_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)
|
logger.info("Removed briefing jobs for user %d", user_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user