Merge pull request #596 from lawrenceakka/tz-fix

Fix for missing timezone info on ingested docs. mcp schema requires TZ
This commit is contained in:
Yiorgis Gozadinos 2026-09-03 09:59:14 -05:00 committed by GitHub
commit 376dcb2e6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 57 additions and 12 deletions

View file

@ -2,6 +2,13 @@
## [Unreleased] ## [Unreleased]
### Fixed
- Document timestamps include timezone information and satisfy the MCP
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 ## [0.82.0] - 2026-09-03
### Removed ### Removed

View file

@ -2,7 +2,7 @@ import asyncio
import json import json
import logging import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from datetime import datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from docling_core.types.doc.document import DescriptionMetaField, PictureMeta from docling_core.types.doc.document import DescriptionMetaField, PictureMeta
@ -531,7 +531,7 @@ async def _flush_rebuild_batch(
if not documents: if not documents:
return return
now = datetime.now().isoformat() now = datetime.now(UTC).isoformat()
# Batch update documents and document_meta using merge_insert (one LanceDB # Batch update documents and document_meta using merge_insert (one LanceDB
# version per table). Content+blobs go to documents; mutable attributes go # version per table). Content+blobs go to documents; mutable attributes go

View file

@ -1,8 +1,8 @@
import json import json
from datetime import datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING 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 from haiku.rag.store.compression import compress_docling_split, decompress_json
@ -29,8 +29,13 @@ class Document(BaseModel):
docling_document: bytes | None = Field(default=None, exclude=True) docling_document: bytes | None = Field(default=None, exclude=True)
docling_pages: 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) docling_version: str | None = Field(default=None, exclude=True)
created_at: datetime = Field(default_factory=datetime.now) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@field_validator("created_at", "updated_at")
@classmethod
def _to_utc(cls, value: datetime) -> datetime:
return value if value.tzinfo else value.astimezone(UTC)
def set_docling(self, docling_doc: "DoclingDocument") -> None: def set_docling(self, docling_doc: "DoclingDocument") -> None:
"""Serialize and store a DoclingDocument, splitting structure and pages. """Serialize and store a DoclingDocument, splitting structure and pages.

View file

@ -1,5 +1,5 @@
import json import json
from datetime import datetime from datetime import UTC, datetime
from typing import overload from typing import overload
from uuid import uuid4 from uuid import uuid4
@ -77,8 +77,12 @@ class DocumentRepository:
docling_document=doc.docling_document, docling_document=doc.docling_document,
docling_pages=doc.docling_pages, docling_pages=doc.docling_pages,
docling_version=doc.docling_version, docling_version=doc.docling_version,
created_at=datetime.fromisoformat(created) if created else datetime.now(), created_at=datetime.fromisoformat(created)
updated_at=datetime.fromisoformat(updated) if updated else datetime.now(), 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: def _to_documents_record(self, entity: Document, doc_id: str) -> DocumentRecord:
@ -138,7 +142,7 @@ class DocumentRepository:
# document_meta) would surface. # document_meta) would surface.
if isinstance(entity, Document): if isinstance(entity, Document):
doc_id = str(uuid4()) doc_id = str(uuid4())
now = datetime.now().isoformat() now = datetime.now(UTC).isoformat()
await self.store.document_meta_table.add( await self.store.document_meta_table.add(
[self._to_meta_record(entity, doc_id, now, now)] [self._to_meta_record(entity, doc_id, now, now)]
) )
@ -159,7 +163,7 @@ class DocumentRepository:
if not documents: if not documents:
return [] return []
now = datetime.now().isoformat() now = datetime.now(UTC).isoformat()
created_at = datetime.fromisoformat(now) created_at = datetime.fromisoformat(now)
doc_records = [] doc_records = []
meta_records = [] meta_records = []
@ -272,7 +276,7 @@ class DocumentRepository:
self.store._assert_writable() self.store._assert_writable()
assert entity.id, "Document ID is required for update" assert entity.id, "Document ID is required for update"
now = datetime.now().isoformat() now = datetime.now(UTC).isoformat()
entity.updated_at = datetime.fromisoformat(now) entity.updated_at = datetime.fromisoformat(now)
created = entity.created_at.isoformat() if entity.created_at else now created = entity.created_at.isoformat() if entity.created_at else now
record = self._to_meta_record(entity, entity.id, created, now) record = self._to_meta_record(entity, entity.id, created, now)

View file

@ -1,3 +1,7 @@
import os
import time
from datetime import UTC, datetime
import pytest import pytest
from haiku.rag.store.engine import Store 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 is not None
assert retrieved.id == created_doc.id assert retrieved.id == created_doc.id
assert retrieved.uri == "Hamish and Andy's Gap Year" 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