canonical metadata keys: source_revision + content_type, bump to 0.50.0

This commit is contained in:
Yiorgis Gozadinos 2026-05-22 15:21:38 +03:00
parent fa672c298a
commit 13cbadeb6f
No known key found for this signature in database
20 changed files with 366 additions and 51 deletions

View file

@ -12,6 +12,7 @@
### Changed ### Changed
- `haiku-rag serve` renamed to `haiku-rag mcp` (only MCP is left). `--mcp-port` renamed to `--port`. Update any `claude_desktop_config.json` from `["serve", "--mcp", "--stdio"]` to `["mcp", "--stdio"]`. - `haiku-rag serve` renamed to `haiku-rag mcp` (only MCP is left). `--mcp-port` renamed to `--port`. Update any `claude_desktop_config.json` from `["serve", "--mcp", "--stdio"]` to `["mcp", "--stdio"]`.
- `document.metadata` now uses source-agnostic keys: `source_revision` (was `etag` — S3-only and never populated for FS, so periodic sweeps re-ingested every file) and `content_type` (was `contentType`, snake_case for consistency). The v0.50.0 startup migration rewrites existing documents. All four source adapters (FS, HTTP, S3, future WebDAV) now write their native revision (mtime_ns, ETag, etc.) under the same key, fixing the regression where FS sources never short-circuited on unchanged files.
- Drop `list_documents` and `get_document` from the default RAG skill's tool set; the skill now exposes only `search` and `cite`. Both tools dumped unbounded content into the agent's context (full document lists, full document bodies) and `get_document` returned no chunk_ids so its output was structurally uncitable. The analysis skill already covers these uses programmatically — `await list_documents()` and `Path('/documents/{id}/content.txt').read_text()` inside `execute_code`. The tool branches remain in `create_skill_tools` and the `skill_generator` `AVAILABLE_TOOLS` set so users can still opt in when building custom skills. - Drop `list_documents` and `get_document` from the default RAG skill's tool set; the skill now exposes only `search` and `cite`. Both tools dumped unbounded content into the agent's context (full document lists, full document bodies) and `get_document` returned no chunk_ids so its output was structurally uncitable. The analysis skill already covers these uses programmatically — `await list_documents()` and `Path('/documents/{id}/content.txt').read_text()` inside `execute_code`. The tool branches remain in `create_skill_tools` and the `skill_generator` `AVAILABLE_TOOLS` set so users can still opt in when building custom skills.
## [0.48.1] - 2026-05-21 ## [0.48.1] - 2026-05-21

View file

@ -80,10 +80,10 @@ ingester:
``` ```
ETags are the cheap-skip key. Each sweep lists the prefix, compares ETags are the cheap-skip key. Each sweep lists the prefix, compares
the listed ETag against the document's stored `metadata["etag"]`, and the listed ETag against the document's stored `metadata["source_revision"]`,
only fetches keys whose ETag has changed. If the bytes turn out to and only fetches keys whose ETag has changed. If the bytes turn out to
match the stored MD5 (multipart re-upload landing a new ETag on the match the stored MD5 (multipart re-upload landing a new ETag on the
same content), only the etag is refreshed — no re-chunk. same content), only the revision is refreshed — no re-chunk.
`storage_options` follows the same convention as `lancedb.storage_options` `storage_options` follows the same convention as `lancedb.storage_options`
the dict is passed straight to obstore (the Rust `object_store` library the dict is passed straight to obstore (the Rust `object_store` library

View file

@ -231,10 +231,12 @@ async def _ingest_fetch_result(
) )
source_metadata: dict = { source_metadata: dict = {
"contentType": result.content_type, "content_type": result.content_type,
"md5": result.content_hash, "md5": result.content_hash,
**result.extra_metadata, **result.extra_metadata,
} }
if result.revision is not None:
source_metadata["source_revision"] = result.revision
if result.disk_path is not None: if result.disk_path is not None:
target_path = result.disk_path target_path = result.disk_path
@ -384,10 +386,11 @@ async def create_document_from_source(
existing_doc = await client.get_document_by_uri(stored_uri) existing_doc = await client.get_document_by_uri(stored_uri)
# Cheap revision-based short-circuit: only worth a HEAD when we have a # Cheap revision-based short-circuit: only worth a HEAD when we have a
# stored revision to compare against. S3 doc metadata persists "etag"; # stored revision to compare against. All sources persist their native
# FS/HTTP currently don't, so this branch is effectively S3-only today. # revision (mtime_ns for FS, ETag for S3, ETag/Last-Modified for HTTP)
# under the canonical "source_revision" metadata key.
stored_revision = ( stored_revision = (
(existing_doc.metadata or {}).get("etag") if existing_doc else None (existing_doc.metadata or {}).get("source_revision") if existing_doc else None
) )
if existing_doc and stored_revision: if existing_doc and stored_revision:
current_revision = await fetcher.head(source_str) current_revision = await fetcher.head(source_str)
@ -406,14 +409,16 @@ async def create_document_from_source(
fetch_span.set_attribute("content_hash", result.content_hash) fetch_span.set_attribute("content_hash", result.content_hash)
# MD5 short-circuit: the bytes are unchanged even if the revision wasn't. # MD5 short-circuit: the bytes are unchanged even if the revision wasn't.
# Refresh the source-derived metadata (etag may have rolled) but skip # Refresh the source-derived metadata (revision may have rolled) but skip
# convert/embed/store entirely. # convert/embed/store entirely.
if existing_doc and existing_doc.metadata.get("md5") == result.content_hash: if existing_doc and existing_doc.metadata.get("md5") == result.content_hash:
source_meta: dict = { source_meta: dict = {
"contentType": result.content_type, "content_type": result.content_type,
"md5": result.content_hash, "md5": result.content_hash,
**result.extra_metadata, **result.extra_metadata,
} }
if result.revision is not None:
source_meta["source_revision"] = result.revision
return await _refresh_doc_metadata( return await _refresh_doc_metadata(
client, client,
existing_doc, existing_doc,

View file

@ -14,14 +14,15 @@ from haiku.rag.ingester.sources.base import (
def _extract_revision(headers: httpx.Headers) -> tuple[str | None, dict[str, str]]: def _extract_revision(headers: httpx.Headers) -> tuple[str | None, dict[str, str]]:
"""Return (canonical_revision, extras). ETag is the stronger validator
and is the canonical revision when present; Last-Modified backs it up.
Last-Modified always goes into extras as separate per-source provenance
(some pipelines want both signals)."""
extra: dict[str, str] = {} extra: dict[str, str] = {}
etag = (headers.get("etag") or "").strip('"').strip() etag = (headers.get("etag") or "").strip('"').strip()
last_modified = (headers.get("last-modified") or "").strip() last_modified = (headers.get("last-modified") or "").strip()
if etag:
extra["etag"] = etag
if last_modified: if last_modified:
extra["last_modified"] = last_modified extra["last_modified"] = last_modified
# Prefer ETag — it's a stronger validator. Fall back to Last-Modified.
revision = etag or last_modified or None revision = etag or last_modified or None
return revision, extra return revision, extra

View file

@ -93,17 +93,12 @@ class S3Source:
if not content_type: if not content_type:
content_type = "application/octet-stream" content_type = "application/octet-stream"
extra: dict[str, str] = {}
if etag is not None:
extra["etag"] = etag
return FetchResult( return FetchResult(
uri=uri, uri=uri,
body=body, body=body,
content_type=content_type, content_type=content_type,
content_hash=hashlib.md5(body, usedforsecurity=False).hexdigest(), content_hash=hashlib.md5(body, usedforsecurity=False).hexdigest(),
revision=etag, revision=etag,
extra_metadata=extra,
) )
async def discover( async def discover(

View file

@ -114,7 +114,7 @@ async def run_job(client: "HaikuRAG", job: Job) -> JobResult:
metadata = result.metadata or {} metadata = result.metadata or {}
return JobResult( return JobResult(
document_id=result.id, document_id=result.id,
revision=metadata.get("etag"), revision=metadata.get("source_revision"),
content_hash=metadata.get("md5"), content_hash=metadata.get("md5"),
) )
except BaseException as exc: except BaseException as exc:

View file

@ -90,6 +90,9 @@ from haiku.rag.store.upgrades.v0_45_0 import (
from haiku.rag.store.upgrades.v0_48_0 import ( from haiku.rag.store.upgrades.v0_48_0 import (
upgrade_backfill_heading_hierarchy as upgrade_0_48_0_heading_hierarchy, upgrade_backfill_heading_hierarchy as upgrade_0_48_0_heading_hierarchy,
) )
from haiku.rag.store.upgrades.v0_50_0 import (
upgrade_canonical_metadata_keys as upgrade_0_50_0_canonical_metadata_keys,
)
upgrades.append(upgrade_0_20_0_docling) upgrades.append(upgrade_0_20_0_docling)
upgrades.append(upgrade_0_23_1_contextualize) upgrades.append(upgrade_0_23_1_contextualize)
@ -98,3 +101,4 @@ upgrades.append(upgrade_0_38_0_split_pages)
upgrades.append(upgrade_0_40_0_document_items) upgrades.append(upgrade_0_40_0_document_items)
upgrades.append(upgrade_0_45_0_extract_picture_bytes) upgrades.append(upgrade_0_45_0_extract_picture_bytes)
upgrades.append(upgrade_0_48_0_heading_hierarchy) upgrades.append(upgrade_0_48_0_heading_hierarchy)
upgrades.append(upgrade_0_50_0_canonical_metadata_keys)

View file

@ -0,0 +1,85 @@
import json
import logging
from haiku.rag.store.engine import Store
from haiku.rag.store.upgrades import Upgrade
from haiku.rag.utils import escape_sql_string
logger = logging.getLogger(__name__)
PROGRESS_INTERVAL = 50
def _normalize_metadata(meta: dict) -> tuple[dict, bool]:
"""Return (normalized_metadata, changed). Renames `etag` → `source_revision`
and `contentType` `content_type`. Pre-existing canonical keys win on
conflict so repeat migrations are no-ops."""
changed = False
out = dict(meta)
if "etag" in out:
if "source_revision" not in out:
out["source_revision"] = out["etag"]
del out["etag"]
changed = True
if "contentType" in out:
if "content_type" not in out:
out["content_type"] = out["contentType"]
del out["contentType"]
changed = True
return out, changed
async def _apply_canonical_metadata_keys(store: Store) -> None:
"""Rewrite document.metadata so revision lives under the source-agnostic
`source_revision` key and `contentType` becomes `content_type`. Documents
whose metadata already uses the canonical keys are untouched."""
rows = (
await store.documents_table.query().select(["id", "metadata"]).to_arrow()
).to_pylist()
total = len(rows)
logger.info("Normalising document metadata keys across %d documents", total)
rewritten = 0
skipped = 0
for idx, row in enumerate(rows, 1):
doc_id = row["id"]
raw = row.get("metadata") or "{}"
try:
meta = json.loads(raw)
except Exception: # pragma: no cover
logger.warning(
"Could not parse metadata JSON for document %s; skipping", doc_id
)
skipped += 1
continue
normalized, changed = _normalize_metadata(meta)
if not changed:
continue
safe_id = escape_sql_string(doc_id)
await store.documents_table.update(
{"metadata": json.dumps(normalized)},
where=f"id = '{safe_id}'",
)
rewritten += 1
if idx % PROGRESS_INTERVAL == 0 or idx == total:
logger.info("Progress: %d/%d (%d rewritten)", idx, total, rewritten)
logger.info(
"Metadata key normalisation complete: %d rewritten, %d skipped of %d",
rewritten,
skipped,
total,
)
upgrade_canonical_metadata_keys = Upgrade(
version="0.50.0",
apply=_apply_canonical_metadata_keys,
description="Rename document.metadata keys: etag→source_revision, contentType→content_type",
)

View file

@ -2,7 +2,7 @@
name = "haiku.rag-slim" name = "haiku.rag-slim"
description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling - Minimal dependencies" description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling - Minimal dependencies"
version = "0.48.1" version = "0.50.0"
authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }] authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }]
license = { text = "MIT" } license = { text = "MIT" }
readme = { file = "README.md", content-type = "text/markdown" } readme = { file = "README.md", content-type = "text/markdown" }

View file

@ -2,7 +2,7 @@
name = "haiku.rag" name = "haiku.rag"
description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling" description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling"
version = "0.48.1" version = "0.50.0"
authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }] authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }]
license = { text = "MIT" } license = { text = "MIT" }
readme = { file = "README.md", content-type = "text/markdown" } readme = { file = "README.md", content-type = "text/markdown" }
@ -30,7 +30,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"haiku.rag-slim[docling,voyageai,mxbai,cohere,zeroentropy,tui,cross-encoder]==0.48.1", "haiku.rag-slim[docling,voyageai,mxbai,cohere,zeroentropy,tui,cross-encoder]==0.50.0",
] ]
[project.scripts] [project.scripts]
@ -38,9 +38,9 @@ haiku-rag = "haiku.rag.cli:cli"
[project.optional-dependencies] [project.optional-dependencies]
tui = ["textual>=8.2.4"] tui = ["textual>=8.2.4"]
s3 = ["haiku.rag-slim[s3]==0.48.1"] s3 = ["haiku.rag-slim[s3]==0.50.0"]
cross-encoder = ["haiku.rag-slim[cross-encoder]==0.48.1"] cross-encoder = ["haiku.rag-slim[cross-encoder]==0.50.0"]
ingester = ["haiku.rag-slim[ingester]==0.48.1"] ingester = ["haiku.rag-slim[ingester]==0.50.0"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]

View file

@ -60,7 +60,7 @@ async def test_fetch_returns_bytes_and_md5_and_etag():
assert result.content_type == "text/markdown" assert result.content_type == "text/markdown"
# etag preferred over last-modified, surrounding quotes stripped # etag preferred over last-modified, surrounding quotes stripped
assert result.revision == "abc123" assert result.revision == "abc123"
assert result.extra_metadata["etag"] == "abc123" assert "etag" not in result.extra_metadata
assert result.extra_metadata["last_modified"] == "Wed, 21 Oct 2025 07:28:00 GMT" assert result.extra_metadata["last_modified"] == "Wed, 21 Oct 2025 07:28:00 GMT"

View file

@ -44,7 +44,11 @@ async def test_upsert_calls_create_document_from_source_and_returns_metadata():
id="doc-42", id="doc-42",
content="x", content="x",
uri="https://example.com/a.pdf", uri="https://example.com/a.pdf",
metadata={"md5": "abcd", "etag": "xyz", "contentType": "application/pdf"}, metadata={
"md5": "abcd",
"source_revision": "xyz",
"content_type": "application/pdf",
},
) )
result = await run_job( result = await run_job(

View file

@ -0,0 +1,98 @@
"""Regression: every source's revision must round-trip from FetchResult →
document.metadata sync_state, so the next sweep can recognise the file
as unchanged. Catches the FS-specific bug where revision was lost in the
pipeline and every periodic sweep re-enqueued every file forever.
"""
import pytest
from haiku.rag.client import HaikuRAG
from haiku.rag.ingester.sources.base import SourceEventKind
from haiku.rag.ingester.sources.fs import FSSource
@pytest.mark.asyncio
async def test_fs_ingest_writes_source_revision_to_metadata(temp_db_path, tmp_path):
file_path = tmp_path / "doc.md"
file_path.write_text("hello")
expected_revision = str(file_path.stat().st_mtime_ns)
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document_from_source(file_path)
assert doc.metadata["source_revision"] == expected_revision
@pytest.mark.asyncio
async def test_fs_second_sweep_emits_unchanged_after_ingest(temp_db_path, tmp_path):
"""The full round-trip: ingest a file, build a sync_state-shaped snapshot
from document.metadata, hand it to FSSource.discover() must see
UNCHANGED, not UPSERT. This is exactly what the periodic poller does."""
file_path = tmp_path / "doc.md"
file_path.write_text("hello")
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document_from_source(file_path)
assert doc.uri is not None
snapshot = {doc.uri: doc.metadata["source_revision"]}
src = FSSource(root=tmp_path)
kinds: list[SourceEventKind] = []
async for event in src.discover(since=snapshot):
kinds.append(event.kind)
assert kinds == [SourceEventKind.UNCHANGED]
@pytest.mark.asyncio
async def test_fs_second_sweep_emits_upsert_when_file_changes(temp_db_path, tmp_path):
"""Counterpart to the unchanged test: a file modified after ingest still
triggers UPSERT. Ensures the round-trip doesn't accidentally over-skip."""
file_path = tmp_path / "doc.md"
file_path.write_text("hello")
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document_from_source(file_path)
# Bump mtime by writing different content; snapshot still has the old revision.
assert doc.uri is not None
snapshot = {doc.uri: doc.metadata["source_revision"]}
file_path.write_text("hello world")
# st_mtime_ns has nanosecond resolution; a write_text always advances it
# on any sane filesystem, but assert anyway to make the intent explicit.
assert str(file_path.stat().st_mtime_ns) != doc.metadata["source_revision"]
src = FSSource(root=tmp_path)
kinds: list[SourceEventKind] = []
async for event in src.discover(since=snapshot):
kinds.append(event.kind)
assert kinds == [SourceEventKind.UPSERT]
@pytest.mark.asyncio
async def test_fs_head_short_circuit_skips_fetch_for_unchanged_revision(
temp_db_path, tmp_path, monkeypatch
):
"""The one-shot (add-src) path: if the existing doc has source_revision
and HEAD reports the same value, fetch must not run."""
file_path = tmp_path / "doc.md"
file_path.write_text("hello")
async with HaikuRAG(temp_db_path, create=True) as client:
first = await client.create_document_from_source(file_path)
fetch_calls: list[str] = []
original_fetch = FSSource.fetch
async def _track_fetch(self: FSSource, uri: str): # type: ignore[no-untyped-def]
fetch_calls.append(uri)
return await original_fetch(self, uri)
monkeypatch.setattr(FSSource, "fetch", _track_fetch)
second = await client.create_document_from_source(file_path)
assert second.id == first.id
assert fetch_calls == []

View file

@ -107,7 +107,7 @@ async def test_fetch_returns_bytes_md5_etag(fake_obstore_io):
assert result.content_hash == hashlib.md5(body, usedforsecurity=False).hexdigest() assert result.content_hash == hashlib.md5(body, usedforsecurity=False).hexdigest()
assert result.content_type == "text/plain" assert result.content_type == "text/plain"
assert result.revision == "abc123" assert result.revision == "abc123"
assert result.extra_metadata["etag"] == "abc123" assert result.extra_metadata == {}
head_async.assert_awaited_once() head_async.assert_awaited_once()
get_async.assert_awaited_once() get_async.assert_awaited_once()
@ -121,7 +121,7 @@ async def test_fetch_handles_missing_etag(fake_obstore_io):
src = S3Source(uri="s3://bucket/") src = S3Source(uri="s3://bucket/")
result = await src.fetch("s3://bucket/file.txt") result = await src.fetch("s3://bucket/file.txt")
assert result.revision is None assert result.revision is None
assert "etag" not in result.extra_metadata assert result.extra_metadata == {}
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -52,7 +52,7 @@ async def _wait_for(predicate, *, timeout: float = 5.0, interval: float = 0.05):
def _mock_client(docs_root) -> AsyncMock: def _mock_client(docs_root) -> AsyncMock:
"""A HaikuRAG mock that returns a fresh Document for each URI it's asked """A HaikuRAG mock that returns a fresh Document for each URI it's asked
to ingest, mirroring real metadata shape (contentType + md5).""" to ingest, mirroring real metadata shape (content_type + md5)."""
client = AsyncMock(spec=HaikuRAG) client = AsyncMock(spec=HaikuRAG)
counter = {"n": 0} counter = {"n": 0}
@ -63,7 +63,7 @@ def _mock_client(docs_root) -> AsyncMock:
id=f"doc-{counter['n']}", id=f"doc-{counter['n']}",
content="x", content="x",
uri=uri, uri=uri,
metadata={"contentType": "text/markdown", "md5": f"md5-{counter['n']}"}, metadata={"content_type": "text/markdown", "md5": f"md5-{counter['n']}"},
) )
client.create_document_from_source.side_effect = _fake_create client.create_document_from_source.side_effect = _fake_create

View file

@ -59,7 +59,10 @@ def _pool(client, jobs, sync, **kwargs) -> WorkerPool:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_marks_job_succeeded_and_writes_sync_state(client, jobs, sync): async def test_drain_marks_job_succeeded_and_writes_sync_state(client, jobs, sync):
client.create_document_from_source.return_value = Document( client.create_document_from_source.return_value = Document(
id="doc-1", content="x", uri="s3://b/k.md", metadata={"md5": "m1", "etag": "e1"} id="doc-1",
content="x",
uri="s3://b/k.md",
metadata={"md5": "m1", "source_revision": "e1"},
) )
job = await jobs.enqueue("src", "s3://b/k.md", JobOp.UPSERT, revision="e0") job = await jobs.enqueue("src", "s3://b/k.md", JobOp.UPSERT, revision="e0")
assert job is not None assert job is not None
@ -179,7 +182,7 @@ async def test_unknown_exception_caught_and_marked_dead(client, jobs, sync):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_workers_drain_queue_after_start(client, jobs, sync): async def test_workers_drain_queue_after_start(client, jobs, sync):
client.create_document_from_source.return_value = Document( client.create_document_from_source.return_value = Document(
id="doc", content="x", uri="u", metadata={"md5": "m", "etag": "e"} id="doc", content="x", uri="u", metadata={"md5": "m", "source_revision": "e"}
) )
for i in range(5): for i in range(5):
await jobs.enqueue("src", f"u{i}", JobOp.UPSERT) await jobs.enqueue("src", f"u{i}", JobOp.UPSERT)

View file

@ -0,0 +1,119 @@
import json
import pytest
from haiku.rag.store.engine import DocumentRecord, Store
@pytest.mark.asyncio
class TestV0_50_0Migration:
"""v0.50.0 normalises document.metadata to source-agnostic keys."""
async def test_renames_etag_and_content_type(self, temp_db_path):
async with Store(temp_db_path, create=True, skip_migration_check=True) as store:
await store.set_haiku_version("0.48.1")
await store.documents_table.add(
[
DocumentRecord(
id="doc-s3",
content="x",
uri="s3://b/k",
metadata=json.dumps(
{
"etag": "abc123",
"contentType": "application/pdf",
"md5": "deadbeef",
}
),
),
DocumentRecord(
id="doc-fs",
content="y",
uri="file:///tmp/x.md",
metadata=json.dumps(
{
"contentType": "text/markdown",
"md5": "feedface",
}
),
),
]
)
async with Store(temp_db_path, skip_migration_check=True) as store:
applied = await store.migrate()
assert any("0.50.0" in d for d in applied)
rows = await store.documents_table.query().to_list()
by_id = {r["id"]: json.loads(r["metadata"]) for r in rows}
assert by_id["doc-s3"] == {
"source_revision": "abc123",
"content_type": "application/pdf",
"md5": "deadbeef",
}
assert by_id["doc-fs"] == {
"content_type": "text/markdown",
"md5": "feedface",
}
async def test_idempotent_on_already_migrated(self, temp_db_path):
async with Store(temp_db_path, create=True, skip_migration_check=True) as store:
await store.set_haiku_version("0.48.1")
await store.documents_table.add(
[
DocumentRecord(
id="d",
content="x",
uri="s3://b/k",
metadata=json.dumps(
{
"source_revision": "abc",
"content_type": "text/plain",
"md5": "m",
}
),
)
]
)
async with Store(temp_db_path, skip_migration_check=True) as store:
await store.migrate()
rows = await store.documents_table.query().to_list()
assert json.loads(rows[0]["metadata"]) == {
"source_revision": "abc",
"content_type": "text/plain",
"md5": "m",
}
async def test_preserves_existing_canonical_keys_on_collision(self, temp_db_path):
"""If both legacy and canonical keys are present, the canonical wins
and the legacy is dropped defends against partial-migration states."""
async with Store(temp_db_path, create=True, skip_migration_check=True) as store:
await store.set_haiku_version("0.48.1")
await store.documents_table.add(
[
DocumentRecord(
id="d",
content="x",
uri="s3://b/k",
metadata=json.dumps(
{
"etag": "legacy",
"source_revision": "canonical",
"contentType": "text/old",
"content_type": "text/new",
}
),
)
]
)
async with Store(temp_db_path, skip_migration_check=True) as store:
await store.migrate()
rows = await store.documents_table.query().to_list()
meta = json.loads(rows[0]["metadata"])
assert meta == {
"source_revision": "canonical",
"content_type": "text/new",
}

View file

@ -209,9 +209,9 @@ async def test_client_create_document_from_source(temp_db_path):
assert doc.id is not None assert doc.id is not None
assert doc.content == test_content assert doc.content == test_content
assert doc.uri == temp_path.as_uri() assert doc.uri == temp_path.as_uri()
assert "contentType" in doc.metadata assert "content_type" in doc.metadata
assert "md5" in doc.metadata assert "md5" in doc.metadata
assert doc.metadata["contentType"] == "text/plain" assert doc.metadata["content_type"] == "text/plain"
# Test create_document_from_source with string path # Test create_document_from_source with string path
doc2 = await client.create_document_from_source(source=str(temp_path)) doc2 = await client.create_document_from_source(source=str(temp_path))
@ -220,7 +220,7 @@ async def test_client_create_document_from_source(temp_db_path):
assert doc2.id is not None assert doc2.id is not None
assert doc2.content == test_content assert doc2.content == test_content
assert doc2.uri == temp_path.as_uri() assert doc2.uri == temp_path.as_uri()
assert "contentType" in doc2.metadata assert "content_type" in doc2.metadata
assert "md5" in doc2.metadata assert "md5" in doc2.metadata
@ -374,7 +374,7 @@ async def test_client_create_document_from_directory(temp_db_path):
assert doc.id is not None assert doc.id is not None
assert doc.uri is not None assert doc.uri is not None
assert "md5" in doc.metadata assert "md5" in doc.metadata
assert "contentType" in doc.metadata assert "content_type" in doc.metadata
uris = [doc.uri for doc in result if doc.uri] uris = [doc.uri for doc in result if doc.uri]
assert any("doc1.txt" in uri for uri in uris) assert any("doc1.txt" in uri for uri in uris)
@ -404,9 +404,9 @@ async def test_client_create_document_from_url(temp_db_path):
assert "test content" in doc.content assert "test content" in doc.content
assert doc.uri == "https://example.com/test.html" assert doc.uri == "https://example.com/test.html"
assert doc.metadata["source_type"] == "web" assert doc.metadata["source_type"] == "web"
assert "contentType" in doc.metadata assert "content_type" in doc.metadata
assert "md5" in doc.metadata assert "md5" in doc.metadata
assert doc.metadata["contentType"] == "text/html" assert doc.metadata["content_type"] == "text/html"
@pytest.mark.vcr() @pytest.mark.vcr()
@ -432,9 +432,9 @@ async def test_client_create_document_from_url_with_different_content_types(
assert doc.id is not None assert doc.id is not None
assert "Test JSON" in doc.content assert "Test JSON" in doc.content
assert doc.uri == "https://api.example.com/data.json" assert doc.uri == "https://api.example.com/data.json"
assert "contentType" in doc.metadata assert "content_type" in doc.metadata
assert "md5" in doc.metadata assert "md5" in doc.metadata
assert doc.metadata["contentType"] == "application/json" assert doc.metadata["content_type"] == "application/json"
# Test plain text content # Test plain text content
mock_text_response = AsyncMock() mock_text_response = AsyncMock()
@ -451,9 +451,9 @@ async def test_client_create_document_from_url_with_different_content_types(
assert doc.id is not None assert doc.id is not None
assert doc.content == "This is plain text content from a URL." assert doc.content == "This is plain text content from a URL."
assert doc.uri == "https://example.com/readme.txt" assert doc.uri == "https://example.com/readme.txt"
assert "contentType" in doc.metadata assert "content_type" in doc.metadata
assert "md5" in doc.metadata assert "md5" in doc.metadata
assert doc.metadata["contentType"] == "text/plain" assert doc.metadata["content_type"] == "text/plain"
@pytest.mark.vcr() @pytest.mark.vcr()
@ -523,7 +523,7 @@ def test_get_extension_from_content_type_or_url():
@pytest.mark.vcr() @pytest.mark.vcr()
async def test_client_metadata_content_type_and_md5(temp_db_path): async def test_client_metadata_content_type_and_md5(temp_db_path):
"""Test that contentType and md5 metadata are correctly set.""" """Test that content_type and md5 metadata are correctly set."""
import hashlib import hashlib
async with HaikuRAG(temp_db_path, create=True) as client: async with HaikuRAG(temp_db_path, create=True) as client:
@ -538,7 +538,7 @@ async def test_client_metadata_content_type_and_md5(temp_db_path):
doc = await client.create_document_from_source(temp_path) doc = await client.create_document_from_source(temp_path)
assert isinstance(doc, Document) assert isinstance(doc, Document)
assert doc.metadata["contentType"] == "text/plain" assert doc.metadata["content_type"] == "text/plain"
assert doc.metadata["md5"] == expected_md5 assert doc.metadata["md5"] == expected_md5
mock_response = AsyncMock() mock_response = AsyncMock()
@ -552,7 +552,7 @@ async def test_client_metadata_content_type_and_md5(temp_db_path):
) )
assert isinstance(url_doc, Document) assert isinstance(url_doc, Document)
assert url_doc.metadata["contentType"] == "text/plain" assert url_doc.metadata["content_type"] == "text/plain"
assert url_doc.metadata["md5"] == expected_md5 assert url_doc.metadata["md5"] == expected_md5

View file

@ -77,7 +77,7 @@ async def test_create_document_from_s3_new(fake_obstore_io, temp_db_path):
doc = await client.create_document_from_source("s3://my-bucket/folder/file.txt") doc = await client.create_document_from_source("s3://my-bucket/folder/file.txt")
assert doc.uri == "s3://my-bucket/folder/file.txt" assert doc.uri == "s3://my-bucket/folder/file.txt"
assert doc.metadata["etag"] == "abc123" # quotes stripped assert doc.metadata["source_revision"] == "abc123" # quotes stripped
assert doc.metadata["md5"] # real content MD5 assert doc.metadata["md5"] # real content MD5
assert doc.metadata["md5"] != "abc123" assert doc.metadata["md5"] != "abc123"
head_async.assert_awaited_once() head_async.assert_awaited_once()
@ -114,7 +114,7 @@ async def test_create_document_from_s3_etag_changed_md5_same_skips_rechunk(
): ):
"""Multipart re-upload of same content: etag changes, MD5 doesn't. """Multipart re-upload of same content: etag changes, MD5 doesn't.
Expected: GET runs to verify, but no re-chunk; only metadata.etag updates. Expected: GET runs to verify, but no re-chunk; only metadata.source_revision updates.
""" """
head_async, get_async = fake_obstore_io head_async, get_async = fake_obstore_io
text = b"S3 hosted content" text = b"S3 hosted content"
@ -134,7 +134,7 @@ async def test_create_document_from_s3_etag_changed_md5_same_skips_rechunk(
assert second.id == first.id assert second.id == first.id
assert second.metadata["md5"] == original_md5 assert second.metadata["md5"] == original_md5
assert second.metadata["etag"] == "def456-2" assert second.metadata["source_revision"] == "def456-2"
assert second.updated_at >= original_updated_at assert second.updated_at >= original_updated_at
assert get_async.await_count == 2 # initial create + etag-changed compare assert get_async.await_count == 2 # initial create + etag-changed compare
@ -158,7 +158,7 @@ async def test_create_document_from_s3_etag_changed_md5_changed_rechunks(
assert second.id == first.id assert second.id == first.id
assert second.metadata["md5"] != first.metadata["md5"] assert second.metadata["md5"] != first.metadata["md5"]
assert second.metadata["etag"] == "new999" assert second.metadata["source_revision"] == "new999"
assert "different text now" in second.content assert "different text now" in second.content

View file

@ -1466,7 +1466,7 @@ wheels = [
[[package]] [[package]]
name = "haiku-rag" name = "haiku-rag"
version = "0.48.1" version = "0.50.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "haiku-rag-slim", extra = ["cohere", "cross-encoder", "docling", "mxbai", "tui", "voyageai", "zeroentropy"] }, { name = "haiku-rag-slim", extra = ["cohere", "cross-encoder", "docling", "mxbai", "tui", "voyageai", "zeroentropy"] },
@ -1558,7 +1558,7 @@ requires-dist = [
[[package]] [[package]]
name = "haiku-rag-slim" name = "haiku-rag-slim"
version = "0.48.1" version = "0.50.0"
source = { editable = "haiku_rag_slim" } source = { editable = "haiku_rag_slim" }
dependencies = [ dependencies = [
{ name = "docling-core" }, { name = "docling-core" },