From 2e3f90384d7497bafcc14f51a5a89b7eb6d5f8c2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 8 Apr 2026 20:33:16 -0400 Subject: [PATCH 01/10] =?UTF-8?q?feat(briefing):=20actionable=20intelligen?= =?UTF-8?q?ce=20=E2=80=94=20days=20overdue,=20project=20context,=20suggest?= =?UTF-8?q?=20next=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/briefing_pipeline.py | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index 95f33b7..0d2988d 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -23,14 +23,25 @@ SLOT_NAMES = ("compilation", "morning", "midday", "afternoon") -def format_task(task: dict) -> str: +def format_task(task: dict, today: str | None = None) -> str: parts = [task.get("title", "Untitled")] if task.get("status"): parts.append(f"[{task['status'].replace('_', ' ').title()}]") if task.get("due_date"): parts.append(f"due {task['due_date']}") + if today and task["due_date"] < today and task.get("status") not in ("done", "cancelled"): + from datetime import date + try: + due = date.fromisoformat(task["due_date"]) + td = date.fromisoformat(today) + days = (td - due).days + parts.append(f"({days} day{'s' if days != 1 else ''} overdue)") + except ValueError: + pass if task.get("priority") and task["priority"] not in ("none", ""): parts.append(f"priority: {task['priority']}") + if task.get("project_title"): + parts.append(f"project: {task['project_title']}") return " — ".join(parts) @@ -134,6 +145,13 @@ async def _gather_internal(user_id: int) -> dict: all_tasks: list[dict] = [] try: all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100) + # Build project title lookup for enrichment + project_titles: dict[int, str] = {} + try: + for p in await list_projects(user_id): + project_titles[p.id] = p.title or "Untitled" + except Exception: + pass all_tasks = [ { "task_id": t.id, @@ -141,21 +159,22 @@ async def _gather_internal(user_id: int) -> dict: "status": t.status, "due_date": t.due_date.isoformat() if t.due_date else None, "priority": t.priority, + "project_title": project_titles.get(t.project_id) if t.project_id else None, } for t in all_task_objs ] overdue = [ - format_task(t) for t in all_tasks - if t.get("due_date") and t["due_date"] < today and t.get("status") != "done" + format_task(t, today) for t in all_tasks + if t.get("due_date") and t["due_date"] < today and t.get("status") not in ("done", "cancelled") ] due_today = [ - format_task(t) for t in all_tasks - if t.get("due_date") == today and t.get("status") != "done" + format_task(t, today) for t in all_tasks + if t.get("due_date") == today and t.get("status") not in ("done", "cancelled") ] high_priority = [ - format_task(t) for t in all_tasks - if t.get("priority") == "high" and t.get("status") not in ("done",) and - format_task(t) not in overdue and format_task(t) not in due_today + format_task(t, today) for t in all_tasks + if t.get("priority") == "high" and t.get("status") not in ("done", "cancelled") and + format_task(t, today) not in overdue and format_task(t, today) not in due_today ][:5] except Exception: logger.warning("Failed to gather tasks for briefing", exc_info=True) @@ -267,8 +286,11 @@ def _unified_system_prompt(profile_body: str) -> str: "Weave together what matters today: mention the weather in a sentence, note any calendar " "events or tasks due today, and briefly reference one or two noteworthy news stories. " "Only mention projects if a task from one is specifically due today. " - "Be warm, concise, and human — aim for 3 to 5 sentences. " - "Future context like emails and messages will be added over time — keep the tone open and helpful.\n\n" + "Be warm, concise, and human — aim for 4 to 8 sentences.\n\n" + "IMPORTANT: Be actionable, not just informational. For overdue or due-today tasks, " + "suggest a concrete next step — offer to reschedule, mark in progress, or break it down. " + "For upcoming events, mention if preparation might help. " + "The user can reply to act on your suggestions via tool calls.\n\n" + (f"User profile:\n{profile_body}\n" if profile_body else "") ) @@ -300,17 +322,23 @@ def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, te # Tasks due today if internal_data.get("due_today"): - lines.append("DUE TODAY:") + lines.append("DUE TODAY (suggest next steps — offer to mark in progress or help prioritize):") lines.extend(f" - {t}" for t in internal_data["due_today"]) lines.append("") - # Overdue tasks (brief mention only) + # Overdue tasks if internal_data.get("overdue_tasks"): overdue = internal_data["overdue_tasks"] - lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):") - lines.extend(f" - {t}" for t in overdue[:3]) - if len(overdue) > 3: - lines.append(f" (and {len(overdue) - 3} more)") + lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}) — suggest rescheduling or breaking down:") + lines.extend(f" - {t}" for t in overdue[:5]) + if len(overdue) > 5: + lines.append(f" (and {len(overdue) - 5} more)") + lines.append("") + + # High priority in-progress + if internal_data.get("high_priority"): + lines.append("HIGH PRIORITY:") + lines.extend(f" - {t}" for t in internal_data["high_priority"]) lines.append("") # News highlights (top 3 with excerpts — right panel shows full list) From 304affb8374124216357d6d910e61b9980ca2eb7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 8 Apr 2026 20:59:06 -0400 Subject: [PATCH 02/10] =?UTF-8?q?feat(briefing):=20daily=20planning=20?= =?UTF-8?q?=E2=80=94=20prioritized=20focus=20list=20and=20calendar=20gap?= =?UTF-8?q?=20analysis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/briefing_pipeline.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index 0d2988d..19f3be4 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -182,6 +182,7 @@ async def _gather_internal(user_id: int) -> dict: # Calendar events today — internal store calendar_events: list[str] = [] + internal_events: list[dict] = [] try: from fabledassistant.services.events import list_events as list_internal_events today_date = datetime.now(user_tz).date() @@ -225,6 +226,51 @@ async def _gather_internal(user_id: int) -> dict: except Exception: logger.warning("Failed to gather projects for briefing", exc_info=True) + # Build prioritized "Your Day" focus list + # Priority: overdue > due today > high priority in-progress > other in-progress + focus_tasks: list[str] = [] + seen_titles: set[str] = set() + for t in sorted(all_tasks, key=lambda x: ( + 0 if (x.get("due_date") and x["due_date"] < today and x.get("status") not in ("done", "cancelled")) else + 1 if (x.get("due_date") == today and x.get("status") not in ("done", "cancelled")) else + 2 if (x.get("priority") == "high" and x.get("status") not in ("done", "cancelled")) else + 3 if (x.get("status") == "in_progress") else 4 + )): + if t.get("status") in ("done", "cancelled"): + continue + formatted = format_task(t, today) + if formatted not in seen_titles: + seen_titles.add(formatted) + focus_tasks.append(formatted) + if len(focus_tasks) >= 5: + break + + # Calendar gap analysis — extract event times for LLM + event_time_blocks: list[str] = [] + for e in internal_events: + try: + if e.get("all_day"): + continue + start = e.get("start_dt") + end = e.get("end_dt") + if not start: + continue + from datetime import datetime as _dt + if isinstance(start, str): + start = _dt.fromisoformat(start) + if isinstance(end, str): + end = _dt.fromisoformat(end) + s_local = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz) + s_str = s_local.strftime("%-I:%M %p") + if end: + e_local = end.astimezone(user_tz) if end.tzinfo else end.replace(tzinfo=timezone.utc).astimezone(user_tz) + e_str = e_local.strftime("%-I:%M %p") + event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}–{e_str}") + else: + event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}–?") + except Exception: + pass + return { "date": today, "overdue_tasks": overdue, @@ -233,6 +279,8 @@ async def _gather_internal(user_id: int) -> dict: "calendar_events": calendar_events, "active_projects": projects_summary, "all_tasks_raw": all_tasks, + "focus_tasks": focus_tasks, + "event_time_blocks": event_time_blocks, } @@ -341,6 +389,18 @@ def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, te lines.extend(f" - {t}" for t in internal_data["high_priority"]) lines.append("") + # Your Day — prioritized focus + calendar gaps (compilation slot only) + if internal_data.get("focus_tasks") and slot == "compilation": + lines.append("YOUR DAY — top tasks to focus on (mention these as a suggested plan):") + for i, t in enumerate(internal_data["focus_tasks"], 1): + lines.append(f" {i}. {t}") + if internal_data.get("event_time_blocks"): + lines.append("") + lines.append(" Scheduled time blocks:") + lines.extend(f" {b}" for b in internal_data["event_time_blocks"]) + lines.append(" (Identify free blocks between events for focused work.)") + lines.append("") + # News highlights (top 3 with excerpts — right panel shows full list) rss = external_data.get("rss_items") or [] if rss: From 8647f52fbc56b585bc461d9d205094e62804d2d0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 8 Apr 2026 21:33:27 -0400 Subject: [PATCH 03/10] feat(weather): live current conditions endpoint + polling display in briefing view --- frontend/src/views/BriefingView.vue | 63 ++++++++++++++++++++++++- src/fabledassistant/routes/briefing.py | 38 +++++++++++++++ src/fabledassistant/services/weather.py | 48 +++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index 67fe1aa..9653ecc 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -60,6 +60,23 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value) const weatherData = ref([]) const tempUnit = ref('C') +interface CurrentConditions { + temperature: number | null; + windspeed: number | null; + description: string; + precip_next_3h: number[]; + temp_unit: string; + location: string; +} +const currentConditions = ref(null) +let currentWeatherTimer: ReturnType | null = null + +async function loadCurrentConditions() { + try { + currentConditions.value = await apiGet('/api/briefing/weather/current') + } catch { /* silent — endpoint may not have locations configured */ } +} + async function loadWeather() { try { const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather') @@ -90,6 +107,7 @@ async function loadAll() { getBriefingToday().catch(() => null), loadWeather(), loadNews(), + loadCurrentConditions(), ]) conversations.value = convList if (today) { @@ -198,11 +216,18 @@ useBackgroundRefresh( ) let _mounted = true -onUnmounted(() => { _mounted = false }) +onUnmounted(() => { + _mounted = false + if (currentWeatherTimer) clearInterval(currentWeatherTimer) +}) onMounted(async () => { await checkSetup() - if (!showWizard.value) await loadAll() + if (!showWizard.value) { + await loadAll() + // Poll current conditions every 30 minutes + currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000) + } }) @@ -235,6 +260,15 @@ onMounted(async () => {
Weather
+ +
+
{{ currentConditions.temperature }}°{{ currentConditions.temp_unit }}
+
{{ currentConditions.description }}
+
+ Rain next 3h: {{ currentConditions.precip_next_3h.map((p: number) => `${p}%`).join(', ') }} +
+
+