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:
2 files changed
+30
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user