From 8d954870a5990ebfdcccd5c470e373f2adfa4ea1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:41:10 +0000 Subject: [PATCH] fix: Ensure datetime serialization includes timezone info for RFC 3339 compliance Add field_serializer methods to all datetime fields in memory schemas to ensure proper timezone-aware datetime serialization. This fixes validation errors in MCP tools like build_context where datetime strings were missing timezone information. - Added ensure_timezone_aware utility function - Updated EntitySummary, RelationSummary, ObservationSummary, MemoryMetadata - All datetime fields now serialize with timezone suffix (Z or +00:00) - Handles both timezone-aware and naive datetimes by converting to UTC Fixes #253 Co-authored-by: jope-bm --- src/basic_memory/schemas/memory.py | 31 +++++++++- test_datetime_fix.py | 93 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 test_datetime_fix.py diff --git a/src/basic_memory/schemas/memory.py b/src/basic_memory/schemas/memory.py index ef4681d9..37e010d2 100644 --- a/src/basic_memory/schemas/memory.py +++ b/src/basic_memory/schemas/memory.py @@ -1,14 +1,21 @@ """Schemas for memory context.""" -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Annotated, Sequence, Literal, Union from annotated_types import MinLen, MaxLen -from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter +from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter, field_serializer from basic_memory.schemas.search import SearchItemType +def ensure_timezone_aware(dt: datetime) -> datetime: + """Ensure datetime is timezone-aware by converting to UTC if naive.""" + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + def validate_memory_url_path(path: str) -> bool: """Validate that a memory URL path is well-formed. @@ -125,6 +132,11 @@ class EntitySummary(BaseModel): file_path: str created_at: datetime + @field_serializer('created_at') + def serialize_created_at(self, dt: datetime) -> str: + """Serialize datetime as RFC 3339 compliant string with timezone.""" + return ensure_timezone_aware(dt).isoformat() + class RelationSummary(BaseModel): """Simplified relation representation.""" @@ -138,6 +150,11 @@ class RelationSummary(BaseModel): to_entity: Optional[str] = None created_at: datetime + @field_serializer('created_at') + def serialize_created_at(self, dt: datetime) -> str: + """Serialize datetime as RFC 3339 compliant string with timezone.""" + return ensure_timezone_aware(dt).isoformat() + class ObservationSummary(BaseModel): """Simplified observation representation.""" @@ -150,6 +167,11 @@ class ObservationSummary(BaseModel): content: str created_at: datetime + @field_serializer('created_at') + def serialize_created_at(self, dt: datetime) -> str: + """Serialize datetime as RFC 3339 compliant string with timezone.""" + return ensure_timezone_aware(dt).isoformat() + class MemoryMetadata(BaseModel): """Simplified response metadata.""" @@ -165,6 +187,11 @@ class MemoryMetadata(BaseModel): total_relations: Optional[int] = None total_observations: Optional[int] = None + @field_serializer('generated_at') + def serialize_generated_at(self, dt: datetime) -> str: + """Serialize datetime as RFC 3339 compliant string with timezone.""" + return ensure_timezone_aware(dt).isoformat() + class ContextResult(BaseModel): """Context result containing a primary item with its observations and related items.""" diff --git a/test_datetime_fix.py b/test_datetime_fix.py new file mode 100644 index 00000000..f52ad65d --- /dev/null +++ b/test_datetime_fix.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Test script to verify datetime serialization fix.""" + +import json +from datetime import datetime, timezone +from basic_memory.schemas.memory import EntitySummary, RelationSummary, ObservationSummary, MemoryMetadata + +def test_datetime_serialization(): + """Test that datetime fields are serialized with timezone information.""" + + # Test EntitySummary + entity = EntitySummary( + permalink="/test", + title="Test Entity", + file_path="/test.md", + created_at=datetime.now(timezone.utc) + ) + + entity_json = entity.model_dump_json() + entity_dict = json.loads(entity_json) + + print("EntitySummary created_at:", entity_dict["created_at"]) + assert entity_dict["created_at"].endswith("+00:00") or entity_dict["created_at"].endswith("Z"), \ + f"EntitySummary created_at should have timezone: {entity_dict['created_at']}" + + # Test RelationSummary + relation = RelationSummary( + title="Test Relation", + file_path="/test.md", + permalink="/test-rel", + relation_type="relates_to", + created_at=datetime.now(timezone.utc) + ) + + relation_json = relation.model_dump_json() + relation_dict = json.loads(relation_json) + + print("RelationSummary created_at:", relation_dict["created_at"]) + assert relation_dict["created_at"].endswith("+00:00") or relation_dict["created_at"].endswith("Z"), \ + f"RelationSummary created_at should have timezone: {relation_dict['created_at']}" + + # Test ObservationSummary + observation = ObservationSummary( + title="Test Observation", + file_path="/test.md", + permalink="/test-obs", + category="test", + content="Test content", + created_at=datetime.now(timezone.utc) + ) + + observation_json = observation.model_dump_json() + observation_dict = json.loads(observation_json) + + print("ObservationSummary created_at:", observation_dict["created_at"]) + assert observation_dict["created_at"].endswith("+00:00") or observation_dict["created_at"].endswith("Z"), \ + f"ObservationSummary created_at should have timezone: {observation_dict['created_at']}" + + # Test MemoryMetadata + metadata = MemoryMetadata( + depth=1, + generated_at=datetime.now(timezone.utc) + ) + + metadata_json = metadata.model_dump_json() + metadata_dict = json.loads(metadata_json) + + print("MemoryMetadata generated_at:", metadata_dict["generated_at"]) + assert metadata_dict["generated_at"].endswith("+00:00") or metadata_dict["generated_at"].endswith("Z"), \ + f"MemoryMetadata generated_at should have timezone: {metadata_dict['generated_at']}" + + print("✅ All datetime serialization tests passed!") + + # Test with naive datetime to ensure it gets converted to UTC + naive_dt = datetime(2025, 8, 22, 15, 30, 45, 123456) + entity_naive = EntitySummary( + permalink="/test-naive", + title="Test Naive Entity", + file_path="/test-naive.md", + created_at=naive_dt + ) + + entity_naive_json = entity_naive.model_dump_json() + entity_naive_dict = json.loads(entity_naive_json) + + print("Naive datetime converted to:", entity_naive_dict["created_at"]) + assert entity_naive_dict["created_at"].endswith("+00:00") or entity_naive_dict["created_at"].endswith("Z"), \ + f"Naive datetime should be converted to UTC: {entity_naive_dict['created_at']}" + + print("✅ Naive datetime conversion test passed!") + +if __name__ == "__main__": + test_datetime_serialization() \ No newline at end of file