diff --git a/backend/src/timeflow/intelligence/conversation/schedule_tools.py b/backend/src/timeflow/intelligence/conversation/schedule_tools.py index 5baf61da..9815a52f 100644 --- a/backend/src/timeflow/intelligence/conversation/schedule_tools.py +++ b/backend/src/timeflow/intelligence/conversation/schedule_tools.py @@ -10,6 +10,7 @@ from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING, Protocol, TypeVar, cast +from zoneinfo import ZoneInfo from timeflow.business.calendar import ( CreateScheduleCommand, @@ -678,6 +679,7 @@ def _schedule_for_llm(snapshot: ScheduleSnapshot) -> dict[str, object]: Account scoping, status, timestamps, coordinates, and reminder disposition are internal noise that would bloat every turn's conversation history. """ + tz = ZoneInfo(snapshot.timezone) return { "id": snapshot.id, "title": snapshot.title, @@ -686,6 +688,7 @@ def _schedule_for_llm(snapshot: ScheduleSnapshot) -> dict[str, object]: "is_all_day": snapshot.is_all_day, "start_time": _iso_or_none(snapshot.start_time), "end_time": _iso_or_none(snapshot.end_time), + "starts_at_local": _local_text(snapshot.start_time, tz), "recurrence_rule": snapshot.recurrence_rule, "location_name": snapshot.location_name, "reminder_type": None if snapshot.reminder_type is None else snapshot.reminder_type.value, @@ -695,6 +698,20 @@ def _schedule_for_llm(snapshot: ScheduleSnapshot) -> dict[str, object]: } +def _local_text(instant: datetime | None, tz: ZoneInfo) -> str: + """Render a stored instant as local wall-clock text for the model to speak. + + The ISO ``start_time`` keeps the exact offset for tool arguments, but the model + tends to read the clock digits verbatim when answering, ignoring the offset. A + UTC-stored instant (``10:00+00:00``) would then be spoken as "上午十点" instead of + the user's "下午六点". This companion field renders the same instant in the + schedule's own IANA zone, so the digits already match what the user meant. + """ + if instant is None: + return "" + return instant.astimezone(tz).strftime("%Y-%m-%d %H:%M") + + def _iso_or_none(value: datetime | None) -> str | None: return None if value is None else value.isoformat() diff --git a/backend/tests/intelligence/conversation/test_schedule_tools.py b/backend/tests/intelligence/conversation/test_schedule_tools.py index 45dfac24..670b0769 100644 --- a/backend/tests/intelligence/conversation/test_schedule_tools.py +++ b/backend/tests/intelligence/conversation/test_schedule_tools.py @@ -7,6 +7,7 @@ import threading from collections.abc import Callable, Mapping from datetime import UTC, datetime +from zoneinfo import ZoneInfo import pytest @@ -32,6 +33,7 @@ ) from timeflow.intelligence.conversation.schedule_tools import ( ScheduleToolInputError, + _local_text, map_create_schedule_command, map_delete_schedule_command, map_find_schedules_query, @@ -506,6 +508,29 @@ async def test_registry_calls_service_with_injected_account_and_serializes_snaps assert "category" not in result["result"]["schedules"][0] +def test_local_text_renders_empty_for_a_schedule_without_a_start_time() -> None: + """A schedule without ``start_time`` (e.g. all-day or pending location) yields an + empty spoken field rather than a spurious wall-clock string.""" + assert _local_text(None, ZoneInfo("Asia/Shanghai")) == "" + + +@pytest.mark.asyncio +async def test_serialized_snapshot_renders_local_wall_clock_for_the_model() -> None: + """The LLM-facing result adds a local wall-clock field so the model speaks the + user's clock digits, not the UTC digits it would otherwise read from the ISO. + + FakeScheduleService stores ``07:00+00:00`` with timezone ``Asia/Shanghai``, so the + spoken field must render ``15:00`` -- the same instant on the user's clock.""" + service = FakeScheduleService() + tool = build_agent_tool_registry(service, "account-1").get("schedule_create") + + result = json.loads(await tool.execute(create_arguments())) + + schedule = result["result"]["schedules"][0] + assert schedule["start_time"] == "2026-08-12T07:00:00+00:00" + assert schedule["starts_at_local"] == "2026-08-12 15:00" + + @pytest.mark.asyncio async def test_serialized_snapshot_trims_internal_fields() -> None: """The LLM-facing result keeps only reference/report fields and drops the