fix(journal-prep): bucket tasks + drop non-proximate events (#159)

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>
This commit is contained in:
2026-04-29 09:31:12 -04:00
parent 4f18023284
commit 9f8b451d15
2 changed files with 340 additions and 33 deletions
+193
View File
@@ -0,0 +1,193 @@
"""Tests for the journal prep filtering helpers added in #159."""
import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
# ── _task_to_prep_dict ────────────────────────────────────────────────────────
def test_task_to_prep_dict_marks_overdue_with_days_count():
from fabledassistant.services.journal_prep import _task_to_prep_dict
today = datetime.date(2026, 4, 29)
task = SimpleNamespace(
id=2, title="Research Weston's ADHD Evaluation",
status="todo", priority="high",
due_date=datetime.date(2026, 2, 20),
)
d = _task_to_prep_dict(task, today)
assert d["days_overdue"] == 68
assert d["due_date"] == "2026-02-20"
def test_task_to_prep_dict_due_today_no_overdue_marker():
from fabledassistant.services.journal_prep import _task_to_prep_dict
today = datetime.date(2026, 4, 29)
task = SimpleNamespace(
id=10, title="Pick up dry cleaning",
status="todo", priority="none",
due_date=today,
)
d = _task_to_prep_dict(task, today)
assert "days_overdue" not in d
def test_task_to_prep_dict_handles_no_due_date():
from fabledassistant.services.journal_prep import _task_to_prep_dict
task = SimpleNamespace(
id=11, title="Long-running side project",
status="in_progress", priority="medium",
due_date=None,
)
d = _task_to_prep_dict(task, datetime.date(2026, 4, 29))
assert d["due_date"] is None
assert "days_overdue" not in d
# ── _filter_proximate_events ──────────────────────────────────────────────────
def test_filter_proximate_drops_far_future_recurring_event():
"""The reported failure: a recurring Birthday event with start_dt
2026-09-29 surfaced in every daily prep. The proximity filter drops it."""
from fabledassistant.services.journal_prep import _filter_proximate_events
events = [
{"id": 13, "title": "Birthday", "start_dt": "2026-09-29T00:00:00+00:00"},
]
kept = _filter_proximate_events(
events,
day_date=datetime.date(2026, 4, 29),
user_tz=ZoneInfo("America/New_York"),
)
assert kept == []
def test_filter_proximate_keeps_events_within_window():
"""Events within ±7 days of the prep date stay."""
from fabledassistant.services.journal_prep import _filter_proximate_events
events = [
{"id": 1, "title": "Today", "start_dt": "2026-04-29T13:00:00+00:00"},
{"id": 2, "title": "Tomorrow", "start_dt": "2026-04-30T12:00:00+00:00"},
{"id": 3, "title": "Next week", "start_dt": "2026-05-05T15:00:00+00:00"},
{"id": 4, "title": "Two weeks out", "start_dt": "2026-05-15T15:00:00+00:00"},
]
kept = _filter_proximate_events(
events,
day_date=datetime.date(2026, 4, 29),
user_tz=ZoneInfo("America/New_York"),
)
kept_ids = [e["id"] for e in kept]
assert 1 in kept_ids
assert 2 in kept_ids
assert 3 in kept_ids
assert 4 not in kept_ids
def test_filter_proximate_uses_local_date_not_utc():
"""An event at 04:30 UTC on 2026-04-30 = 00:30 local on 2026-04-30 in
NY (EDT, UTC-4). Local date is 2026-04-30, delta from 2026-04-29 = 1
day. Must NOT cross-classify based on UTC date alone."""
from fabledassistant.services.journal_prep import _filter_proximate_events
events = [
{"id": 99, "title": "Late-night dentist", "start_dt": "2026-04-30T04:30:00+00:00"},
]
kept = _filter_proximate_events(
events,
day_date=datetime.date(2026, 4, 29),
user_tz=ZoneInfo("America/New_York"),
)
assert len(kept) == 1
def test_filter_proximate_keeps_unparseable_dates():
"""Bad date strings are kept rather than silently suppressing real
events on a parser bug."""
from fabledassistant.services.journal_prep import _filter_proximate_events
events = [
{"id": 50, "title": "Garbage", "start_dt": "not-a-date"},
{"id": 51, "title": "Empty", "start_dt": ""},
{"id": 52, "title": "Missing"},
]
kept = _filter_proximate_events(
events,
day_date=datetime.date(2026, 4, 29),
user_tz=ZoneInfo("America/New_York"),
)
assert len(kept) == 3
# ── _render_sections_for_prompt — overdue framing ────────────────────────────
def test_render_overdue_includes_staleness_duration():
"""The rendered prompt block must surface days_overdue so the LLM
can frame stale tasks correctly."""
from fabledassistant.services.journal_prep import _render_sections_for_prompt
sections = {
"tasks_due_today": [],
"tasks_upcoming": [],
"tasks_overdue": [{
"id": 2, "title": "Research Weston's ADHD Evaluation",
"status": "todo", "priority": "high",
"due_date": "2026-02-20", "days_overdue": 68,
}],
}
rendered = _render_sections_for_prompt(sections)
assert "OVERDUE TASKS" in rendered
assert "68 days ago" in rendered
assert "2026-02-20" in rendered
# Crucially, NOT framed as due today.
assert "DUE TODAY" not in rendered
def test_render_due_today_no_due_date_repetition():
"""Tasks in the DUE TODAY bucket don't need the (due 2026-04-29)
parenthetical — the section header already says 'today'."""
from fabledassistant.services.journal_prep import _render_sections_for_prompt
sections = {
"tasks_due_today": [{
"id": 1, "title": "Pick up dry cleaning",
"status": "todo", "priority": "none",
"due_date": "2026-04-29",
}],
"tasks_upcoming": [],
"tasks_overdue": [],
}
rendered = _render_sections_for_prompt(sections)
assert "TASKS DUE TODAY" in rendered
assert "Pick up dry cleaning" in rendered
# The line itself shouldn't repeat the due date.
line = next(
(l for l in rendered.splitlines() if "Pick up dry cleaning" in l),
"",
)
assert "2026-04-29" not in line
def test_render_three_buckets_in_correct_order():
"""When all three buckets have content, the prompt sees them in
DUE TODAY → UPCOMING → OVERDUE order so the LLM leads with today."""
from fabledassistant.services.journal_prep import _render_sections_for_prompt
sections = {
"tasks_due_today": [{"id": 1, "title": "Today task", "status": "todo", "priority": "none", "due_date": "2026-04-29"}],
"tasks_upcoming": [{"id": 2, "title": "Upcoming task", "status": "todo", "priority": "none", "due_date": "2026-05-02"}],
"tasks_overdue": [{"id": 3, "title": "Stale task", "status": "todo", "priority": "none", "due_date": "2026-02-20", "days_overdue": 68}],
}
rendered = _render_sections_for_prompt(sections)
today_idx = rendered.index("TASKS DUE TODAY")
upcoming_idx = rendered.index("UPCOMING TASKS")
overdue_idx = rendered.index("OVERDUE TASKS")
assert today_idx < upcoming_idx < overdue_idx