Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions backend/src/timeflow/intelligence/conversation/schedule_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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()

Expand Down
25 changes: 25 additions & 0 deletions backend/tests/intelligence/conversation/test_schedule_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import threading
from collections.abc import Callable, Mapping
from datetime import UTC, datetime
from zoneinfo import ZoneInfo

import pytest

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading