9f8b451d15
Two filtering issues that made the daily prep noisy and trained the
user to ignore it.
## Tasks: bucket into due-today / upcoming / overdue
The prep was calling `list_notes(due_before=day_date)` and labeling the
result as "tasks due today". That filter is strictly less-than, so it
returned only OVERDUE tasks (a single 68-day-stale task in this user's
case), while the prompt still framed them as fresh today's work. Each
day of the prep treated the same overdue task as new — the user
learned to ignore the line entirely.
`gather_daily_sections` now runs three queries:
- `tasks_due_today` — `due_after=day_date AND due_before=day_date+1`
- `tasks_upcoming` — next 7 days, exclusive of today
- `tasks_overdue` — strictly before today
Overdue entries carry a `days_overdue` count. `_render_sections_for_prompt`
emits three labeled headers ("TASKS DUE TODAY", "UPCOMING TASKS",
"OVERDUE TASKS (still on the list, not currently due)"). The system
prompt has a new TASK BUCKETS rule telling the model: don't call
overdue items "due today"; surface them with their staleness duration
("still on the list 68 days") and frame as a backlog reminder rather
than today's work.
Backwards-compat: `sections["tasks"]` still exists, now as the union
of all three buckets — strictly more useful than the prior overdue-
only behavior any frontend consumer was getting before.
## Events: tz-aware window + proximity filter
The user's "Birthday — 2026-09-29 (FREQ=YEARLY)" event was surfacing
in every daily prep, 5 months out. Root cause: `gather_daily_sections`
built `day_start`/`day_end` as NAIVE datetimes; `list_events` then
called `rrulestr(...).between(naive_from, naive_to)` against an
aware `dtstart`, which throws TypeError, hits the `except Exception`
fallback, and appends the canonical event row — regardless of whether
today is anywhere near a recurrence.
Fix:
1. Construct the day window as TZ-aware in the user's local timezone
and convert to UTC before the query. RRULE expansion now runs
correctly.
2. Defense-in-depth `_filter_proximate_events` drops events whose
start_dt is more than 7 days from `day_date` (in the user's local
TZ — not UTC, so a Friday 23:00 NY event isn't misclassified as
Saturday). If list_events ever leaks a far-future row again, the
prep doesn't surface it.
10 new tests in `tests/test_journal_prep_filtering.py` cover task
bucketing (overdue marker, due-today no-marker, no-due-date), the
proximity filter (the 4/29 reproducer, in-window keeps, local-vs-UTC
boundary, unparseable dates kept rather than suppressed), and the
rendering (overdue staleness shown, due-today doesn't repeat the date,
correct section ordering).
53 tests pass across journal_prep + journal_search + record_moment +
calendar_tool + events. Ruff clean.
Closes Fable task #159.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
500 lines
20 KiB
Python
500 lines
20 KiB
Python
"""Daily prep generator for the Journal.
|
|
|
|
Runs once per day per user (scheduled, or lazy on first journal-open of a
|
|
new day). Two phases:
|
|
|
|
1. Gather structured data (tasks/events/weather/projects/recent moments/
|
|
open threads) — deterministic, no LLM call.
|
|
2. Hand the structured data to the LLM and ask it for a direct, informative
|
|
conversational opener — flowing prose, briefing-style. Result is persisted
|
|
as the first *assistant* message in today's journal Conversation, so it
|
|
renders with the standard Illuminated Transcript bubble styling alongside
|
|
the rest of the conversation.
|
|
|
|
The structured data is preserved on ``Message.msg_metadata.sections`` for
|
|
provenance and future tooling.
|
|
|
|
Message shape:
|
|
role: 'assistant'
|
|
content: <prose opener>
|
|
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from sqlalchemy import select
|
|
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.models import Conversation, Message, async_session
|
|
from fabledassistant.services.events import list_events
|
|
from fabledassistant.services.journal_search import search_journal
|
|
from fabledassistant.services.notes import list_notes
|
|
from fabledassistant.services.projects import list_projects
|
|
from fabledassistant.services.settings import get_setting
|
|
from fabledassistant.services.weather import get_cached_weather_rows
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# How many days out from today an event needs to be before the prep treats
|
|
# it as too far-future to surface. Catches recurring-event canonical rows
|
|
# whose RRULE expansion missed (an `rrulestr` failure falls back to the
|
|
# canonical event in `list_events`, which leaks far-future occurrences
|
|
# into today's prep).
|
|
_EVENT_PROXIMITY_DAYS = 7
|
|
|
|
|
|
def _task_to_prep_dict(task, today: datetime.date) -> dict:
|
|
"""Render a Note row as a prep-payload task entry, tagging overdue
|
|
staleness when relevant so the prompt can frame it correctly."""
|
|
d = {
|
|
"id": task.id,
|
|
"title": task.title,
|
|
"status": task.status,
|
|
"priority": task.priority,
|
|
"due_date": task.due_date.isoformat() if task.due_date else None,
|
|
}
|
|
if task.due_date and task.due_date < today:
|
|
d["days_overdue"] = (today - task.due_date).days
|
|
return d
|
|
|
|
|
|
def _filter_proximate_events(
|
|
events: list[dict], *, day_date: datetime.date, user_tz: ZoneInfo
|
|
) -> list[dict]:
|
|
"""Drop events whose start_dt is more than ``_EVENT_PROXIMITY_DAYS``
|
|
away from ``day_date`` in the user's local timezone.
|
|
|
|
Belt-and-suspenders against `list_events` returning a canonical
|
|
far-future event (e.g. when RRULE expansion fails and the loop falls
|
|
back to the original event row, regardless of date). The user
|
|
observed "Birthday — 2026-09-29 (FREQ=YEARLY)" surfacing in every
|
|
daily prep 5 months out; this filter keeps the prep proximate.
|
|
"""
|
|
proximate: list[dict] = []
|
|
for e in events:
|
|
raw = e.get("start_dt") or ""
|
|
try:
|
|
start_dt = datetime.datetime.fromisoformat(
|
|
raw.replace("Z", "+00:00") if isinstance(raw, str) else ""
|
|
)
|
|
local_date = start_dt.astimezone(user_tz).date()
|
|
delta = abs((local_date - day_date).days)
|
|
except (ValueError, TypeError, AttributeError):
|
|
# Unparseable date — keep the event rather than suppress real data.
|
|
proximate.append(e)
|
|
continue
|
|
if delta <= _EVENT_PROXIMITY_DAYS:
|
|
proximate.append(e)
|
|
else:
|
|
logger.info(
|
|
"daily_prep: dropping non-proximate event %r start_local=%s "
|
|
"(%d days from day %s)",
|
|
e.get("title"), local_date.isoformat(), delta, day_date.isoformat(),
|
|
)
|
|
return proximate
|
|
|
|
|
|
async def gather_daily_sections(
|
|
*,
|
|
user_id: int,
|
|
day_date: datetime.date,
|
|
user_timezone: str,
|
|
) -> dict:
|
|
"""Gather all daily-prep sections and return them as a dict.
|
|
|
|
Pure data fetching — no LLM call. Each section degrades to an empty
|
|
list/dict on failure so the caller always gets a complete shape.
|
|
|
|
Tasks are returned in three explicit buckets so the prompt can frame
|
|
overdue items correctly (instead of calling them "due today" — the
|
|
pre-2026-04-29 behavior, before this rewrite).
|
|
"""
|
|
sections: dict = {}
|
|
|
|
next_day = day_date + datetime.timedelta(days=1)
|
|
upcoming_end = day_date + datetime.timedelta(days=8)
|
|
try:
|
|
due_today_rows, _ = await list_notes(
|
|
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
|
due_after=day_date, due_before=next_day,
|
|
limit=20, sort="due_date", order="asc",
|
|
)
|
|
upcoming_rows, _ = await list_notes(
|
|
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
|
due_after=next_day, due_before=upcoming_end,
|
|
limit=20, sort="due_date", order="asc",
|
|
)
|
|
overdue_rows, _ = await list_notes(
|
|
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
|
due_before=day_date,
|
|
limit=20, sort="due_date", order="asc",
|
|
)
|
|
sections["tasks_due_today"] = [_task_to_prep_dict(t, day_date) for t in due_today_rows]
|
|
sections["tasks_upcoming"] = [_task_to_prep_dict(t, day_date) for t in upcoming_rows]
|
|
sections["tasks_overdue"] = [_task_to_prep_dict(t, day_date) for t in overdue_rows]
|
|
# Backwards-compat alias for any consumers still reading sections["tasks"].
|
|
# The combined view is more useful than the prior overdue-only behavior.
|
|
sections["tasks"] = (
|
|
sections["tasks_due_today"]
|
|
+ sections["tasks_upcoming"]
|
|
+ sections["tasks_overdue"]
|
|
)
|
|
except Exception:
|
|
logger.exception("daily_prep tasks section failed for user %d", user_id)
|
|
sections["tasks_due_today"] = []
|
|
sections["tasks_upcoming"] = []
|
|
sections["tasks_overdue"] = []
|
|
sections["tasks"] = []
|
|
|
|
try:
|
|
try:
|
|
user_tz = ZoneInfo(user_timezone)
|
|
except Exception:
|
|
logger.warning("daily_prep: invalid user_timezone %r — defaulting to UTC", user_timezone)
|
|
user_tz = ZoneInfo("UTC")
|
|
# Build the local-day window in the user's TZ, then convert to UTC
|
|
# for the DB / RRULE expansion. A naive datetime here previously
|
|
# caused rrule.between() to throw, falling back to the canonical
|
|
# event row regardless of date — the source of stale recurring
|
|
# events polluting every daily prep.
|
|
day_start_local = datetime.datetime.combine(day_date, datetime.time.min, tzinfo=user_tz)
|
|
day_end_local = datetime.datetime.combine(day_date, datetime.time.max, tzinfo=user_tz)
|
|
day_start = day_start_local.astimezone(datetime.timezone.utc)
|
|
day_end = day_end_local.astimezone(datetime.timezone.utc)
|
|
all_events = await list_events(
|
|
user_id=user_id,
|
|
date_from=day_start,
|
|
date_to=day_end,
|
|
)
|
|
sections["events"] = _filter_proximate_events(
|
|
all_events, day_date=day_date, user_tz=user_tz,
|
|
)
|
|
except Exception:
|
|
logger.exception("daily_prep events section failed for user %d", user_id)
|
|
sections["events"] = []
|
|
|
|
try:
|
|
weather_rows = await get_cached_weather_rows(user_id)
|
|
sections["weather"] = [w.to_dict() for w in weather_rows]
|
|
except Exception:
|
|
logger.exception("daily_prep weather section failed for user %d", user_id)
|
|
sections["weather"] = []
|
|
|
|
try:
|
|
projects = await list_projects(user_id=user_id, status="active")
|
|
sections["projects"] = [
|
|
{
|
|
"id": p.id,
|
|
"title": p.title,
|
|
"auto_summary": p.auto_summary,
|
|
}
|
|
for p in projects[:5]
|
|
]
|
|
except Exception:
|
|
logger.exception("daily_prep projects section failed for user %d", user_id)
|
|
sections["projects"] = []
|
|
|
|
try:
|
|
sections["recent_moments"] = await search_journal(
|
|
user_id=user_id,
|
|
date_from=day_date - datetime.timedelta(days=3),
|
|
date_to=day_date - datetime.timedelta(days=1),
|
|
limit=10,
|
|
)
|
|
except Exception:
|
|
logger.exception("daily_prep recent_moments section failed for user %d", user_id)
|
|
sections["recent_moments"] = []
|
|
|
|
try:
|
|
sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date)
|
|
except Exception:
|
|
logger.exception("daily_prep open_threads section failed for user %d", user_id)
|
|
sections["open_threads"] = []
|
|
|
|
return sections
|
|
|
|
|
|
async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
|
|
"""Heuristic: moments from the last 7 days that look unresolved.
|
|
|
|
Treated as 'unresolved' when they have no linked tasks/notes and aren't
|
|
pinned. Starting heuristic — refine empirically.
|
|
"""
|
|
candidates = await search_journal(
|
|
user_id=user_id,
|
|
date_from=day_date - datetime.timedelta(days=7),
|
|
date_to=day_date - datetime.timedelta(days=1),
|
|
limit=50,
|
|
)
|
|
return [
|
|
m for m in candidates
|
|
if not m.get("task_ids")
|
|
and not m.get("note_ids")
|
|
and not m.get("pinned")
|
|
]
|
|
|
|
|
|
def _render_task_line(t: dict, *, include_due: bool, include_overdue: bool) -> str:
|
|
line = f" - {t.get('title', '?')}"
|
|
if include_overdue and t.get("days_overdue"):
|
|
line += f" (due {t['due_date']}, {t['days_overdue']} days ago)"
|
|
elif include_due and t.get("due_date"):
|
|
line += f" (due {t['due_date']})"
|
|
if t.get("priority") and t["priority"] not in (None, "none"):
|
|
line += f" [{t['priority']} priority]"
|
|
if t.get("status") == "in_progress":
|
|
line += " [in progress]"
|
|
return line
|
|
|
|
|
|
def _render_sections_for_prompt(sections: dict) -> str:
|
|
"""Render the gathered sections as a structured plain-text block for the LLM."""
|
|
lines: list[str] = []
|
|
|
|
due_today = sections.get("tasks_due_today") or []
|
|
upcoming = sections.get("tasks_upcoming") or []
|
|
overdue = sections.get("tasks_overdue") or []
|
|
if due_today:
|
|
lines.append("TASKS DUE TODAY:")
|
|
for t in due_today[:8]:
|
|
lines.append(_render_task_line(t, include_due=False, include_overdue=False))
|
|
lines.append("")
|
|
if upcoming:
|
|
lines.append("UPCOMING TASKS (next 7 days):")
|
|
for t in upcoming[:8]:
|
|
lines.append(_render_task_line(t, include_due=True, include_overdue=False))
|
|
lines.append("")
|
|
if overdue:
|
|
lines.append("OVERDUE TASKS (still on the list, not currently due):")
|
|
for t in overdue[:8]:
|
|
lines.append(_render_task_line(t, include_due=False, include_overdue=True))
|
|
lines.append("")
|
|
|
|
events = sections.get("events") or []
|
|
if events:
|
|
lines.append("CALENDAR EVENTS TODAY:")
|
|
for e in events[:8]:
|
|
title = e.get("title", "Untitled")
|
|
when = e.get("start_dt", "?")
|
|
location = e.get("location") or ""
|
|
line = f" - {title} at {when}"
|
|
if location:
|
|
line += f" ({location})"
|
|
lines.append(line)
|
|
lines.append("")
|
|
|
|
weather = sections.get("weather") or []
|
|
if weather:
|
|
lines.append("WEATHER:")
|
|
for w in weather:
|
|
label = w.get("location_label") or w.get("location_key") or "Location"
|
|
forecast_json = w.get("forecast_json") or {}
|
|
daily = forecast_json.get("daily") or {}
|
|
today_max = (daily.get("temperature_2m_max") or [None])[0]
|
|
today_min = (daily.get("temperature_2m_min") or [None])[0]
|
|
precip = (daily.get("precipitation_probability_max") or [None])[0]
|
|
bits = [label]
|
|
if today_max is not None and today_min is not None:
|
|
bits.append(f"high {today_max}° / low {today_min}°")
|
|
if precip is not None:
|
|
bits.append(f"{precip}% chance of precipitation")
|
|
lines.append(" - " + ", ".join(bits))
|
|
lines.append("")
|
|
|
|
projects = sections.get("projects") or []
|
|
if projects:
|
|
lines.append("ACTIVE PROJECTS:")
|
|
for p in projects[:5]:
|
|
line = f" - {p.get('title', '?')}"
|
|
if p.get("auto_summary"):
|
|
summary = p["auto_summary"][:160]
|
|
line += f" — {summary}"
|
|
lines.append(line)
|
|
lines.append("")
|
|
|
|
recent_moments = sections.get("recent_moments") or []
|
|
if recent_moments:
|
|
lines.append("RECENT JOURNAL MOMENTS (last few days):")
|
|
for m in recent_moments[:8]:
|
|
day = m.get("day_date", "?")
|
|
content = (m.get("content") or "").strip()
|
|
lines.append(f" - [{day}] {content}")
|
|
lines.append("")
|
|
|
|
open_threads = sections.get("open_threads") or []
|
|
if open_threads:
|
|
lines.append("OPEN THREADS (mentioned recently but not resolved):")
|
|
for m in open_threads[:5]:
|
|
day = m.get("day_date", "?")
|
|
content = (m.get("content") or "").strip()
|
|
lines.append(f" - [{day}] {content}")
|
|
lines.append("")
|
|
|
|
if not lines:
|
|
return "(No data for today — quiet morning.)"
|
|
return "\n".join(lines).rstrip()
|
|
|
|
|
|
_PREP_SYSTEM_PROMPT = (
|
|
"You are briefing the user on their day. Direct and informative — tell them what's "
|
|
"actually on their plate so they can step into the day with a clear picture.\n\n"
|
|
"Rules:\n"
|
|
"- LEAD with the practical data: tasks due today, calendar events, weather.\n"
|
|
"- Be specific and concrete. Use real task titles, event times, temperatures, "
|
|
"precipitation chances. Don't paraphrase data into vague summaries.\n"
|
|
"- Write in flowing sentences — no markdown, no bullet points, no headers — but "
|
|
"keep the prose factual and useful, not sentimental.\n"
|
|
"- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" "
|
|
"greetings unless the actual content warrants two clauses' worth.\n"
|
|
"- TASK BUCKETS — three sections may appear: TASKS DUE TODAY, UPCOMING TASKS, "
|
|
"OVERDUE TASKS. Lead with TASKS DUE TODAY when present. Do NOT call overdue items "
|
|
"\"due today\" — they aren't. When OVERDUE TASKS appears, surface it with the "
|
|
"staleness duration (\"still on the list 68 days\") and frame it as something to "
|
|
"revisit, not as today's work. If the only data is overdue, lead with it but "
|
|
"frame it as a backlog reminder.\n"
|
|
"- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY "
|
|
"at the end as context — not as the lead. Skip them if nothing notable.\n"
|
|
"- Close with one short invitation to journal: \"What's on your mind?\", "
|
|
"\"Anything to set down?\", \"How's the morning shaping up?\" — pick one, keep it under 8 words.\n"
|
|
"- Don't fabricate. Skip categories with no data; don't acknowledge their absence.\n"
|
|
"- Voice is competent assistant briefing the user. Not a friend writing a letter."
|
|
)
|
|
|
|
|
|
def _fallback_prep_text(day_date: datetime.date) -> str:
|
|
"""If the LLM call fails, return a minimal greeting so the user still sees something."""
|
|
weekday = day_date.strftime("%A")
|
|
return f"{weekday}, {day_date.isoformat()}. What's on your mind?"
|
|
|
|
|
|
async def _generate_prep_prose(
|
|
*,
|
|
sections: dict,
|
|
day_date: datetime.date,
|
|
user_id: int,
|
|
) -> str:
|
|
"""Ask the LLM for a direct conversational journal opener built from the sections."""
|
|
from fabledassistant.services.llm import generate_completion
|
|
|
|
model = (await get_setting(user_id, "default_model", "")) or Config.OLLAMA_MODEL
|
|
if not model:
|
|
logger.warning("No LLM model configured for daily prep — using fallback text")
|
|
return _fallback_prep_text(day_date)
|
|
|
|
rendered = _render_sections_for_prompt(sections)
|
|
user_trigger = (
|
|
f"Today is {day_date.strftime('%A, %B %-d, %Y')} ({day_date.isoformat()}).\n\n"
|
|
f"Here is what I gathered for you:\n\n{rendered}\n\n"
|
|
f"Write the opener for today's journal."
|
|
)
|
|
|
|
messages = [
|
|
{"role": "system", "content": _PREP_SYSTEM_PROMPT},
|
|
{"role": "user", "content": user_trigger},
|
|
]
|
|
|
|
try:
|
|
prose = await generate_completion(
|
|
messages=messages,
|
|
model=model,
|
|
max_tokens=400,
|
|
)
|
|
except Exception:
|
|
logger.exception("Daily prep prose generation failed for day %s", day_date)
|
|
return _fallback_prep_text(day_date)
|
|
|
|
prose = (prose or "").strip()
|
|
if not prose:
|
|
logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date)
|
|
return _fallback_prep_text(day_date)
|
|
return prose
|
|
|
|
|
|
async def ensure_daily_prep_message(
|
|
*,
|
|
user_id: int,
|
|
day_date: datetime.date,
|
|
user_timezone: str,
|
|
force: bool = False,
|
|
) -> Message:
|
|
"""Get or create today's journal Conversation, then ensure the prep message exists.
|
|
|
|
The prep message is an *assistant* role message containing the prose opener,
|
|
with the structured sections preserved on ``msg_metadata``. If a legacy
|
|
system-role prep exists from an earlier version, it gets upgraded in place
|
|
on the next call.
|
|
|
|
With ``force=True`` the prose is regenerated even when a prep already exists.
|
|
Used by the manual /api/journal/trigger-prep endpoint.
|
|
"""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Conversation).where(
|
|
Conversation.user_id == user_id,
|
|
Conversation.conversation_type == "journal",
|
|
Conversation.day_date == day_date,
|
|
)
|
|
)
|
|
conv = result.scalar_one_or_none()
|
|
if conv is None:
|
|
conv = Conversation(
|
|
user_id=user_id,
|
|
conversation_type="journal",
|
|
day_date=day_date,
|
|
title=day_date.isoformat(),
|
|
)
|
|
session.add(conv)
|
|
await session.flush()
|
|
|
|
# Find any existing prep (system or assistant role from any version).
|
|
prep_stmt = select(Message).where(Message.conversation_id == conv.id)
|
|
existing_prep = None
|
|
for msg in (await session.execute(prep_stmt)).scalars():
|
|
if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep":
|
|
existing_prep = msg
|
|
break
|
|
|
|
# If we already have an assistant-role prep with prose content and the
|
|
# caller didn't ask to force regeneration, we're done.
|
|
if (
|
|
existing_prep
|
|
and not force
|
|
and existing_prep.role == "assistant"
|
|
and (existing_prep.content or "").strip()
|
|
):
|
|
return existing_prep
|
|
|
|
sections = await gather_daily_sections(
|
|
user_id=user_id, day_date=day_date, user_timezone=user_timezone
|
|
)
|
|
prose = await _generate_prep_prose(
|
|
sections=sections, day_date=day_date, user_id=user_id
|
|
)
|
|
new_metadata = {"kind": "daily_prep", "sections": sections}
|
|
|
|
if existing_prep:
|
|
# Upgrade in place: bump role, replace content + metadata.
|
|
existing_prep.role = "assistant"
|
|
existing_prep.content = prose
|
|
existing_prep.msg_metadata = new_metadata
|
|
await session.commit()
|
|
return existing_prep
|
|
|
|
prep_msg = Message(
|
|
conversation_id=conv.id,
|
|
role="assistant",
|
|
content=prose,
|
|
msg_metadata=new_metadata,
|
|
)
|
|
session.add(prep_msg)
|
|
await session.commit()
|
|
return prep_msg
|
|
|
|
|
|
# Backwards-compat alias — older imports may use the old name.
|
|
generate_daily_prep = gather_daily_sections
|