fix(episode): read series timestamps back in local time (#94)

Series episode memory_at used datetime.utcfromtimestamp() on values produced
by datetime.timestamp(). That yields a naive UTC datetime, which the vector
payload later re-interprets as local time, shifting the stored timestamp by
the machine's UTC offset (8 hours on Asia/Shanghai).

Round-trip through datetime.fromtimestamp() instead, and cover the wall-clock
round trip with a non-UTC TZ regression.

Co-authored-by: qiye.sjh <shijiahao_0802@youzan.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 12:19:51 +08:00
1 parent 14fcc6fe38
commit 55bf946bcc
2 files changed
+30 -3

No files matched your search

+13 -3
View File
@@ -39,6 +39,7 @@ import logging
import hashlib
import json
import re
from datetime import datetime
from typing import Any, Dict, List, Optional
import uuid
@@ -252,6 +253,17 @@ def _series_episode_id(
return str(uuid.uuid5(uuid.NAMESPACE_URL, "hy-memory:series-episode:" + raw))
def _series_memory_at(times: List[int]) -> Optional[datetime]:
"""Earliest member timestamp as the same local wall clock it came from.
``_node_timestamp`` produces ``datetime.timestamp()`` values. Reading them
back with ``utcfromtimestamp`` turns them into a naive *UTC* datetime, which
the vector payload later re-interprets as local time and shifts by the
machine's UTC offset.
"""
return datetime.fromtimestamp(min(times)) if times else None
def _node_timestamp(node: Any) -> Optional[int]:
value = getattr(node, "memory_at", None) or getattr(node, "gmt_created", None)
if value is None:
@@ -974,8 +986,6 @@ async def finalize_series_episode_containers(
Stored text is only a title plus a structural intro. Exact L2 bodies remain in
their canonical nodes and are referenced by ID from dated timeline segments.
"""
from datetime import datetime
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus, SourceType
from ..pipelines._retrieval.session_episode import (
add_parent_episode_membership,
@@ -1061,7 +1071,7 @@ async def finalize_series_episode_containers(
segment["memory_at"] for segment in plan["timeline"]
if segment.get("memory_at") is not None
]
memory_at = datetime.utcfromtimestamp(min(times)) if times else None
memory_at = _series_memory_at(times)
content = f"{plan['title']}. {plan['intro']}".strip()
series = MemoryNode(
node_id=series_id, user_id=user_id,
+17
View File
@@ -710,3 +710,20 @@ class TestInstrumentation:
assert logged[1]["count"] == 5, "累计条数要落下来"
assert logged[2]["reused"] is False
assert all(r["gap"] == 1800.0 for r in logged)
def test_series_memory_at_round_trips_local_wall_clock(monkeypatch):
"""Series timestamps must not shift by the machine's UTC offset."""
import time as _time
from hy_memory.agent.episode_summary import _series_memory_at
monkeypatch.setenv("TZ", "Asia/Shanghai")
_time.tzset()
try:
expected = datetime(2026, 1, 1, 12, 0)
assert _series_memory_at([int(expected.timestamp())]) == expected
assert _series_memory_at([]) is None
finally:
monkeypatch.delenv("TZ", raising=False)
_time.tzset()