From 9c56f566601477121744dfcc2bfc3091b25bf18b Mon Sep 17 00:00:00 2001 From: Lawrence Akka Date: Tue, 1 Sep 2026 17:00:08 +0200 Subject: [PATCH 1/3] Fix missing timezone info on ingested docs. mcp schema requires TZ --- haiku_rag_slim/haiku/rag/client/rebuild.py | 4 ++-- .../haiku/rag/store/models/document.py | 19 +++++++++++++++---- .../haiku/rag/store/repositories/document.py | 12 ++++++------ 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/client/rebuild.py b/haiku_rag_slim/haiku/rag/client/rebuild.py index 4de95b14..e1ac734e 100644 --- a/haiku_rag_slim/haiku/rag/client/rebuild.py +++ b/haiku_rag_slim/haiku/rag/client/rebuild.py @@ -2,7 +2,7 @@ import asyncio import json import logging from collections.abc import AsyncGenerator -from datetime import datetime +from datetime import UTC, datetime from typing import TYPE_CHECKING from docling_core.types.doc.document import DescriptionMetaField, PictureMeta @@ -531,7 +531,7 @@ async def _flush_rebuild_batch( if not documents: return - now = datetime.now().isoformat() + now = datetime.now(UTC).isoformat() # Batch update documents and document_meta using merge_insert (one LanceDB # version per table). Content+blobs go to documents; mutable attributes go diff --git a/haiku_rag_slim/haiku/rag/store/models/document.py b/haiku_rag_slim/haiku/rag/store/models/document.py index 44343a20..e52e7354 100644 --- a/haiku_rag_slim/haiku/rag/store/models/document.py +++ b/haiku_rag_slim/haiku/rag/store/models/document.py @@ -1,8 +1,8 @@ import json -from datetime import datetime +from datetime import UTC, datetime from typing import TYPE_CHECKING -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from haiku.rag.store.compression import compress_docling_split, decompress_json @@ -29,8 +29,19 @@ class Document(BaseModel): docling_document: bytes | None = Field(default=None, exclude=True) docling_pages: bytes | None = Field(default=None, exclude=True) docling_version: str | None = Field(default=None, exclude=True) - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + @field_validator("created_at", "updated_at") + @classmethod + def _assume_utc(cls, value: datetime) -> datetime: + """Attach UTC to a timezone-less timestamp instead of leaving it ambiguous. + + A row written before timestamps carried an explicit offset parses back + with no tzinfo. Treat it as UTC rather than pass it on naive, which the + MCP tools' declared JSON schema rejects as an invalid date-time. + """ + return value if value.tzinfo else value.replace(tzinfo=UTC) def set_docling(self, docling_doc: "DoclingDocument") -> None: """Serialize and store a DoclingDocument, splitting structure and pages. diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document.py b/haiku_rag_slim/haiku/rag/store/repositories/document.py index 86f724c1..56209713 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/document.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/document.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import UTC, datetime from typing import overload from uuid import uuid4 @@ -77,8 +77,8 @@ class DocumentRepository: docling_document=doc.docling_document, docling_pages=doc.docling_pages, docling_version=doc.docling_version, - created_at=datetime.fromisoformat(created) if created else datetime.now(), - updated_at=datetime.fromisoformat(updated) if updated else datetime.now(), + created_at=datetime.fromisoformat(created) if created else datetime.now(UTC), + updated_at=datetime.fromisoformat(updated) if updated else datetime.now(UTC), ) def _to_documents_record(self, entity: Document, doc_id: str) -> DocumentRecord: @@ -138,7 +138,7 @@ class DocumentRepository: # document_meta) would surface. if isinstance(entity, Document): doc_id = str(uuid4()) - now = datetime.now().isoformat() + now = datetime.now(UTC).isoformat() await self.store.document_meta_table.add( [self._to_meta_record(entity, doc_id, now, now)] ) @@ -159,7 +159,7 @@ class DocumentRepository: if not documents: return [] - now = datetime.now().isoformat() + now = datetime.now(UTC).isoformat() created_at = datetime.fromisoformat(now) doc_records = [] meta_records = [] @@ -272,7 +272,7 @@ class DocumentRepository: self.store._assert_writable() assert entity.id, "Document ID is required for update" - now = datetime.now().isoformat() + now = datetime.now(UTC).isoformat() entity.updated_at = datetime.fromisoformat(now) created = entity.created_at.isoformat() if entity.created_at else now record = self._to_meta_record(entity, entity.id, created, now) From 880551deba5bc24272f1240af20860cdb5a4d920 Mon Sep 17 00:00:00 2001 From: Lawrence Akka Date: Wed, 2 Sep 2026 12:05:21 +0200 Subject: [PATCH 2/3] Use astimezone, add timezone tests, linting --- CHANGELOG.md | 5 ++++ .../haiku/rag/store/models/document.py | 10 ++----- .../haiku/rag/store/repositories/document.py | 8 +++-- tests/test_document.py | 29 +++++++++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be861255..b3df0b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Document timestamps include timezone information and satisfy the MCP + date-time output schema. + ## [0.82.0] - 2026-09-03 ### Removed diff --git a/haiku_rag_slim/haiku/rag/store/models/document.py b/haiku_rag_slim/haiku/rag/store/models/document.py index e52e7354..72f3ac6d 100644 --- a/haiku_rag_slim/haiku/rag/store/models/document.py +++ b/haiku_rag_slim/haiku/rag/store/models/document.py @@ -34,14 +34,8 @@ class Document(BaseModel): @field_validator("created_at", "updated_at") @classmethod - def _assume_utc(cls, value: datetime) -> datetime: - """Attach UTC to a timezone-less timestamp instead of leaving it ambiguous. - - A row written before timestamps carried an explicit offset parses back - with no tzinfo. Treat it as UTC rather than pass it on naive, which the - MCP tools' declared JSON schema rejects as an invalid date-time. - """ - return value if value.tzinfo else value.replace(tzinfo=UTC) + def _to_utc(cls, value: datetime) -> datetime: + return value if value.tzinfo else value.astimezone(UTC) def set_docling(self, docling_doc: "DoclingDocument") -> None: """Serialize and store a DoclingDocument, splitting structure and pages. diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document.py b/haiku_rag_slim/haiku/rag/store/repositories/document.py index 56209713..e5022afb 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/document.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/document.py @@ -77,8 +77,12 @@ class DocumentRepository: docling_document=doc.docling_document, docling_pages=doc.docling_pages, docling_version=doc.docling_version, - created_at=datetime.fromisoformat(created) if created else datetime.now(UTC), - updated_at=datetime.fromisoformat(updated) if updated else datetime.now(UTC), + created_at=datetime.fromisoformat(created) + if created + else datetime.now(UTC), + updated_at=datetime.fromisoformat(updated) + if updated + else datetime.now(UTC), ) def _to_documents_record(self, entity: Document, doc_id: str) -> DocumentRecord: diff --git a/tests/test_document.py b/tests/test_document.py index 4f10b9f2..64caa22c 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -1,3 +1,7 @@ +import os +import time +from datetime import UTC, datetime + import pytest from haiku.rag.store.engine import Store @@ -549,3 +553,28 @@ async def test_document_get_by_uri_with_special_characters( assert retrieved is not None assert retrieved.id == created_doc.id assert retrieved.uri == "Hamish and Andy's Gap Year" + + +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="time.tzset is POSIX-only") +def test_naive_timestamp_is_converted_from_local_time(): + original_tz = os.environ.get("TZ") + os.environ["TZ"] = "Europe/Athens" + time.tzset() + try: + created = datetime(2026, 1, 1, 14, 0, 0) # January is EET, UTC+2 + updated = datetime(2026, 1, 1, 14, 30, 0) + doc = Document(content="x", created_at=created, updated_at=updated) + assert doc.created_at == datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + assert doc.updated_at == datetime(2026, 1, 1, 12, 30, 0, tzinfo=UTC) + finally: + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + time.tzset() + + +def test_created_at_serializes_with_timezone_offset(): + doc = Document(content="x") + value = doc.model_dump(mode="json")["created_at"] + assert datetime.fromisoformat(value).utcoffset() is not None From 4bf4699ee07c4b1bf515e88896196333b46500a3 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 3 Sep 2026 17:48:18 +0300 Subject: [PATCH 3/3] Note that offsetless stored timestamps are read as host-local time --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3df0b0c..0bb4bcc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ ### Fixed - Document timestamps include timezone information and satisfy the MCP - date-time output schema. + date-time output schema. Stored timestamps without an offset are read as + host-local time; a database written under a different host timezone shifts + by that offset. ## [0.82.0] - 2026-09-03