From d5a307c3ce9c5defd24b448cd18d19ff3964c148 Mon Sep 17 00:00:00 2001 From: yyy-router <1804384725@qq.com> Date: Fri, 28 Aug 2026 17:22:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(voice):=20composed=20agent=20=E5=A4=8D?= =?UTF-8?q?=E8=BF=B0=E6=97=A5=E7=A8=8B=E6=97=B6=E9=97=B4=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=B8=A6=E6=97=B6=E5=8C=BA=E5=81=8F=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组合式 agent 序列化日程给 LLM 时只有 start_time 的 ISO 字符串,偏移随 PostgreSQL 服务器时区漂移;服务器容器默认 UTC 时,LLM 口播直接读钟面数字,把「下午六点」说成「上午十点」,固定差 8 小时。对齐 realtime 的 _for_model,新增 starts_at_local 字段,用日程自身 IANA 时区渲染本地墙上时间。 --- .../intelligence/conversation/schedule_tools.py | 17 +++++++++++++++++ .../conversation/test_schedule_tools.py | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) 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..31759de1 100644 --- a/backend/tests/intelligence/conversation/test_schedule_tools.py +++ b/backend/tests/intelligence/conversation/test_schedule_tools.py @@ -506,6 +506,23 @@ async def test_registry_calls_service_with_injected_account_and_serializes_snaps assert "category" not in result["result"]["schedules"][0] +@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 From 62dc33c8cc0daf466e20815adeca8b914d9e9e99 Mon Sep 17 00:00:00 2001 From: yyy-router <1804384725@qq.com> Date: Fri, 28 Aug 2026 17:27:53 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(voice):=20=E8=A6=86=E7=9B=96=20=5Floca?= =?UTF-8?q?l=5Ftext=20=E7=A9=BA=E5=80=BC=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../intelligence/conversation/test_schedule_tools.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/tests/intelligence/conversation/test_schedule_tools.py b/backend/tests/intelligence/conversation/test_schedule_tools.py index 31759de1..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,12 @@ 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