diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py index 0fa658c..32de232 100644 --- a/src/fabledassistant/services/journal_pipeline.py +++ b/src/fabledassistant/services/journal_pipeline.py @@ -134,7 +134,11 @@ async def build_journal_system_prompt( static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}" today_iso = day_date.isoformat() - tz_block = f"Today is {today_iso} ({user_timezone})." + # Include the day-of-week explicitly. LLMs are unreliable at deriving + # weekday names from ISO dates, which causes "this Friday" / "next + # Monday" to land on the wrong calendar day. + weekday = day_date.strftime("%A") + tz_block = f"Today is {weekday}, {today_iso} ({user_timezone})." profile_context = await build_profile_context(user_id) profile_section = f"\n\n{profile_context}" if profile_context else "" diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 21238c6..316936a 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -602,7 +602,12 @@ async def build_context( } assistant_name = await get_setting(user_id, "assistant_name", "Fable") - today = date_type.today().isoformat() + _today_obj = date_type.today() + today = _today_obj.isoformat() + # Day-of-week paired with the ISO date so the model doesn't have to + # derive the weekday — that derivation is a documented failure mode + # for "this Friday" / "next Monday"-style requests. + today_weekday = _today_obj.strftime("%A") has_caldav = await is_caldav_configured(user_id) # Build tool usage guidance based on available integrations @@ -678,7 +683,7 @@ async def build_context( entities_context = await get_people_and_places_context(user_id) entities_section = f"\n\n{entities_context}" if entities_context else "" - dynamic_tail = f"\n\nToday's date is {today}.{tz_line}{profile_section}{entities_section}" + dynamic_tail = f"\n\nToday is {today_weekday}, {today}.{tz_line}{profile_section}{entities_section}" # --- System message: stable content only --- # Workspace context and history summary stay here because they carry diff --git a/src/fabledassistant/services/tools/calendar.py b/src/fabledassistant/services/tools/calendar.py index 12fc43c..0218ddc 100644 --- a/src/fabledassistant/services/tools/calendar.py +++ b/src/fabledassistant/services/tools/calendar.py @@ -108,6 +108,34 @@ async def _resolve_event_end( return None +def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None: + """Verify the resolved local date falls on the expected day of the week. + + Models routinely miscompute "this Friday" / "next Monday" when the + system prompt only carries an ISO date without a weekday. When the + model passes `expected_weekday`, the backend rejects mismatches with + a self-correcting error message naming the actual weekday. + + Returns an error string on mismatch, or ``None`` when the check + passes (or no expected weekday was supplied). + """ + if not expected: + return None + expected_norm = expected.strip().lower() + valid = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} + if expected_norm not in valid: + return f"expected_weekday must be a full English weekday name; got {expected!r}." + local = start_dt_utc.astimezone(user_tz) + actual = local.strftime("%A").lower() + if actual == expected_norm: + return None + return ( + f"Date {local.date().isoformat()} falls on {actual.title()}, " + f"not {expected_norm.title()}. Recompute the date for " + f"{expected_norm.title()} or confirm with the user before retrying." + ) + + @tool( name="create_event", description=( @@ -117,7 +145,11 @@ async def _resolve_event_end( "separate fields in the user's local time — never combine them and " "never include a timezone suffix. The server attaches the user's " "configured timezone. Omit `start_time` (or set `all_day=true`) " - "for all-day events like birthdays or holidays." + "for all-day events like birthdays or holidays. " + "When the user names a weekday ('this Friday', 'next Monday'), " + "state the resolved calendar date in your reply BEFORE calling " + "this tool, and pass `expected_weekday` so the server can verify " + "the date falls on the day you intended." ), parameters={ "title": {"type": "string", "description": "A descriptive event title"}, @@ -135,6 +167,7 @@ async def _resolve_event_end( "attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"}, "calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."}, "project": {"type": "string", "description": "Optional project name to associate this event with"}, + "expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the start_date should fall on. Pass this whenever the user names a weekday so the server can verify the date is correct. Rejects with a corrective error if the date falls on a different day."}, # Legacy combined fields kept for backward compatibility with saved # tool-call payloads in conversation history. New calls should use # start_date + start_time. Hidden from typical model output via the @@ -154,6 +187,10 @@ async def create_event_tool(*, user_id, arguments, **_ctx): return {"success": False, "error": f"Invalid start: {exc}"} if start_was_date_only: all_day = True + user_tz = await get_user_tz(user_id) + weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday")) + if weekday_err: + return {"success": False, "error": weekday_err} try: end_dt = await _resolve_event_end(user_id, arguments) except ValueError as exc: @@ -254,7 +291,11 @@ async def search_events_tool(*, user_id, arguments, **_ctx): "change, move, reschedule, or modify an event. Pass `start_date` " "(YYYY-MM-DD) and `start_time` (HH:MM) as separate fields in the " "user's local time when rescheduling — never combine them, never " - "include a timezone suffix." + "include a timezone suffix. " + "When the user names a weekday ('move to Friday'), state the " + "resolved calendar date in your reply BEFORE calling this tool, " + "and pass `expected_weekday` so the server can verify the date " + "falls on the day you intended." ), parameters={ "query": {"type": "string", "description": "Search term to find the event to update (matches against title)"}, @@ -269,6 +310,7 @@ async def search_events_tool(*, user_id, arguments, **_ctx): "color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"}, "recurrence": {"type": "string", "description": "New iCalendar RRULE"}, "reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."}, + "expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the new start_date should fall on. Pass whenever the user names a weekday."}, # Legacy combined fields kept for backcompat — see create_event. "start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."}, "end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."}, @@ -296,6 +338,10 @@ async def update_event_tool(*, user_id, arguments, **_ctx): start_dt, _ = await _resolve_event_start(user_id, arguments) except (ValueError, TypeError) as exc: return {"success": False, "error": f"Invalid start: {exc}"} + user_tz = await get_user_tz(user_id) + weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday")) + if weekday_err: + return {"success": False, "error": weekday_err} fields["start_dt"] = start_dt if arguments.get("end_date") or arguments.get("end"): try: diff --git a/tests/test_calendar_tool_tz.py b/tests/test_calendar_tool_tz.py index 6d711fa..71b0e24 100644 --- a/tests/test_calendar_tool_tz.py +++ b/tests/test_calendar_tool_tz.py @@ -395,3 +395,219 @@ async def test_update_event_split_fields_reschedule_no_drift(): assert result["success"] is True utc = captured["start_dt"].astimezone(timezone.utc) assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12) + + +# ── expected_weekday verification (catches "this Friday" → Thursday bugs) ──── + + +@pytest.mark.asyncio +async def test_create_event_expected_weekday_match_succeeds(): + """When expected_weekday agrees with the resolved local date's + weekday, the event is created normally.""" + from fabledassistant.services.tools.calendar import create_event_tool + + captured = {} + + async def fake_create_event(**kwargs): + captured.update(kwargs) + event = AsyncMock() + event.to_dict.return_value = {"id": 1} + return event + + with patch( + "fabledassistant.services.tools.calendar.get_user_tz", + AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), + ), patch( + "fabledassistant.services.tools.calendar.events_create_event", + side_effect=fake_create_event, + ): + result = await create_event_tool( + user_id=1, + arguments={ + "title": "Meeting", + "start_date": "2026-05-01", # is a Friday + "start_time": "08:00", + "expected_weekday": "friday", + }, + ) + + assert result["success"] is True + assert captured["start_dt"] is not None + + +@pytest.mark.asyncio +async def test_create_event_expected_weekday_mismatch_rejects_and_names_actual(): + """The reported failure mode: model picks Thursday and calls it + Friday. With expected_weekday set, the create is rejected and the + error names the actual weekday so the model can self-correct.""" + from fabledassistant.services.tools.calendar import create_event_tool + + create_called = False + + async def fake_create_event(**kwargs): + nonlocal create_called + create_called = True + event = AsyncMock() + event.to_dict.return_value = {"id": 1} + return event + + with patch( + "fabledassistant.services.tools.calendar.get_user_tz", + AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), + ), patch( + "fabledassistant.services.tools.calendar.events_create_event", + side_effect=fake_create_event, + ): + result = await create_event_tool( + user_id=1, + arguments={ + "title": "Dentist", + "start_date": "2026-04-30", # Thursday + "start_time": "08:00", + "expected_weekday": "friday", + }, + ) + + assert result["success"] is False + assert "Thursday" in result["error"] + assert "Friday" in result["error"] + # Critical: the event must NOT have been created when the check failed. + assert create_called is False + + +@pytest.mark.asyncio +async def test_create_event_expected_weekday_omitted_skips_check(): + """Backcompat: when expected_weekday isn't passed, no validation + runs — the existing create flow is unchanged.""" + from fabledassistant.services.tools.calendar import create_event_tool + + captured = {} + + async def fake_create_event(**kwargs): + captured.update(kwargs) + event = AsyncMock() + event.to_dict.return_value = {"id": 1} + return event + + with patch( + "fabledassistant.services.tools.calendar.get_user_tz", + AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), + ), patch( + "fabledassistant.services.tools.calendar.events_create_event", + side_effect=fake_create_event, + ): + result = await create_event_tool( + user_id=1, + arguments={ + "title": "x", + "start_date": "2026-04-30", + "start_time": "08:00", + }, + ) + + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_create_event_expected_weekday_invalid_value_rejects(): + """Garbage in expected_weekday produces a clear validation error, + not a silent pass.""" + from fabledassistant.services.tools.calendar import create_event_tool + + with patch( + "fabledassistant.services.tools.calendar.get_user_tz", + AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), + ): + result = await create_event_tool( + user_id=1, + arguments={ + "title": "x", + "start_date": "2026-05-01", + "start_time": "08:00", + "expected_weekday": "fri", # abbreviation not accepted + }, + ) + + assert result["success"] is False + assert "weekday" in result["error"].lower() + + +@pytest.mark.asyncio +async def test_update_event_expected_weekday_mismatch_rejects(): + """update_event must enforce the same weekday check on reschedules.""" + from fabledassistant.services.tools.calendar import update_event_tool + + update_called = False + + async def fake_find(*, user_id, query): + ev = AsyncMock() + ev.id = 99 + ev.title = "Coffee" + return [ev] + + async def fake_update(*, user_id, event_id, **fields): + nonlocal update_called + update_called = True + ev = AsyncMock() + ev.to_dict.return_value = {"id": event_id} + return ev + + with patch( + "fabledassistant.services.tools.calendar.get_user_tz", + AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), + ), patch( + "fabledassistant.services.tools.calendar.find_events_by_query", + side_effect=fake_find, + ), patch( + "fabledassistant.services.tools.calendar.events_update_event", + side_effect=fake_update, + ): + result = await update_event_tool( + user_id=1, + arguments={ + "query": "Coffee", + "start_date": "2026-04-30", # Thursday + "start_time": "08:00", + "expected_weekday": "friday", + }, + ) + + assert result["success"] is False + assert "Thursday" in result["error"] + assert update_called is False + + +@pytest.mark.asyncio +async def test_create_event_weekday_check_uses_local_not_utc(): + """The weekday check must use the LOCAL date, not the UTC date. + A late-evening Friday event in Tokyo (UTC+9) crosses midnight UTC, + so a UTC-day check would call it Saturday — but the user's calendar + says Friday. The check must respect the user's local view.""" + from fabledassistant.services.tools.calendar import create_event_tool + + captured = {} + + async def fake_create_event(**kwargs): + captured.update(kwargs) + event = AsyncMock() + event.to_dict.return_value = {"id": 1} + return event + + with patch( + "fabledassistant.services.tools.calendar.get_user_tz", + AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")), + ), patch( + "fabledassistant.services.tools.calendar.events_create_event", + side_effect=fake_create_event, + ): + result = await create_event_tool( + user_id=1, + arguments={ + "title": "Friday night", + "start_date": "2026-05-01", # Friday in Tokyo + "start_time": "23:00", # 14:00 UTC same day; safe + "expected_weekday": "friday", + }, + ) + + assert result["success"] is True