574 lines
19 KiB
Python
574 lines
19 KiB
Python
import pytest
|
|
|
|
from haiku.rag.client import HaikuRAG
|
|
from haiku.rag.config import Config
|
|
from haiku.rag.store.models.chunk import Chunk, ChunkMetadata, SearchResult
|
|
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_chunk_repository_operations(
|
|
qa_corpus: list[dict[str, str]], temp_db_path
|
|
):
|
|
"""Test ChunkRepository operations."""
|
|
async with HaikuRAG(db_path=temp_db_path, config=Config, create=True) as client:
|
|
# Get the first document from the corpus
|
|
first_doc = qa_corpus[0]
|
|
document_text = first_doc["document_extracted"]
|
|
|
|
# Create a document first with chunks
|
|
created_document = await client.create_document(
|
|
content=document_text, metadata={"source": "test"}
|
|
)
|
|
assert created_document.id is not None
|
|
|
|
# Test getting chunks by document ID
|
|
chunks = await client.chunk_repository.get_by_document_id(created_document.id)
|
|
assert len(chunks) > 0
|
|
assert all(chunk.document_id == created_document.id for chunk in chunks)
|
|
|
|
# Test chunk search
|
|
results = await client.chunk_repository.search(
|
|
"election", limit=2, search_type="vector"
|
|
)
|
|
assert len(results) <= 2
|
|
assert all(hasattr(chunk, "content") for chunk, _ in results)
|
|
|
|
# Test deleting chunks by document ID
|
|
deleted = await client.chunk_repository.delete_by_document_id(
|
|
created_document.id
|
|
)
|
|
assert deleted is True
|
|
|
|
# Verify chunks are gone
|
|
chunks_after_delete = await client.chunk_repository.get_by_document_id(
|
|
created_document.id
|
|
)
|
|
assert len(chunks_after_delete) == 0
|
|
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_chunk_repository_pagination(
|
|
qa_corpus: list[dict[str, str]], temp_db_path
|
|
):
|
|
"""Test ChunkRepository pagination with get_by_document_id and count_by_document_id."""
|
|
async with HaikuRAG(db_path=temp_db_path, config=Config, create=True) as client:
|
|
# Get the first document from the corpus (should produce multiple chunks)
|
|
first_doc = qa_corpus[0]
|
|
document_text = first_doc["document_extracted"]
|
|
|
|
# Create a document with chunks
|
|
created_document = await client.create_document(
|
|
content=document_text, metadata={"source": "test"}
|
|
)
|
|
assert created_document.id is not None
|
|
|
|
# Get total chunk count
|
|
total_count = await client.chunk_repository.count_by_document_id(
|
|
created_document.id
|
|
)
|
|
assert total_count > 0
|
|
|
|
# Get all chunks without pagination
|
|
all_chunks = await client.chunk_repository.get_by_document_id(
|
|
created_document.id
|
|
)
|
|
assert len(all_chunks) == total_count
|
|
|
|
# Test pagination with limit
|
|
limit = min(2, total_count)
|
|
first_batch = await client.chunk_repository.get_by_document_id(
|
|
created_document.id, limit=limit
|
|
)
|
|
assert len(first_batch) == limit
|
|
assert first_batch[0].id == all_chunks[0].id
|
|
|
|
# Test pagination with offset
|
|
if total_count > limit:
|
|
second_batch = await client.chunk_repository.get_by_document_id(
|
|
created_document.id, limit=limit, offset=limit
|
|
)
|
|
assert len(second_batch) <= limit
|
|
assert second_batch[0].id == all_chunks[limit].id
|
|
|
|
# Test offset beyond available chunks
|
|
empty_batch = await client.chunk_repository.get_by_document_id(
|
|
created_document.id, limit=10, offset=total_count + 100
|
|
)
|
|
assert len(empty_batch) == 0
|
|
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_chunking_pipeline(qa_corpus: list[dict[str, str]], temp_db_path):
|
|
"""Test document chunking using client primitives."""
|
|
from haiku.rag.client import HaikuRAG
|
|
from haiku.rag.embeddings import embed_chunks
|
|
|
|
async with HaikuRAG(db_path=temp_db_path, create=True) as client:
|
|
# Get the first document from the corpus
|
|
first_doc = qa_corpus[0]
|
|
document_text = first_doc["document_extracted"]
|
|
|
|
# Use client primitives: convert → chunk → embed
|
|
docling_document = await client.convert(document_text)
|
|
chunks = await client.chunk(docling_document)
|
|
embedded_chunks = await embed_chunks(chunks, client.embedder)
|
|
|
|
# Verify chunks were created with embeddings
|
|
assert len(chunks) > 0
|
|
assert all(chunk.embedding is None for chunk in chunks) # Before embedding
|
|
assert all(chunk.embedding is not None for chunk in embedded_chunks) # After
|
|
|
|
# Verify chunk order
|
|
for i, chunk in enumerate(chunks):
|
|
assert chunk.order == i
|
|
|
|
|
|
def test_chunk_metadata_parsing():
|
|
"""Test ChunkMetadata parsing from chunk metadata dict."""
|
|
metadata_dict = {
|
|
"doc_item_refs": ["#/texts/0", "#/texts/1", "#/tables/0"],
|
|
"headings": ["Chapter 1", "Section 1.1"],
|
|
"labels": ["paragraph", "paragraph", "table"],
|
|
"page_numbers": [1, 1, 2],
|
|
}
|
|
|
|
chunk = Chunk(
|
|
content="Test content",
|
|
metadata=metadata_dict,
|
|
)
|
|
|
|
chunk_meta = chunk.get_chunk_metadata()
|
|
|
|
assert isinstance(chunk_meta, ChunkMetadata)
|
|
assert chunk_meta.doc_item_refs == ["#/texts/0", "#/texts/1", "#/tables/0"]
|
|
assert chunk_meta.headings == ["Chapter 1", "Section 1.1"]
|
|
assert chunk_meta.labels == ["paragraph", "paragraph", "table"]
|
|
assert chunk_meta.page_numbers == [1, 1, 2]
|
|
|
|
|
|
def test_chunk_metadata_defaults():
|
|
"""Test ChunkMetadata with empty/default values."""
|
|
chunk = Chunk(content="Test content", metadata={})
|
|
chunk_meta = chunk.get_chunk_metadata()
|
|
|
|
assert chunk_meta.doc_item_refs == []
|
|
assert chunk_meta.headings is None
|
|
assert chunk_meta.labels == []
|
|
assert chunk_meta.page_numbers == []
|
|
|
|
|
|
def test_chunk_metadata_resolve_doc_items():
|
|
"""Test resolving doc_item_refs to actual DocItem objects."""
|
|
from docling_core.types.doc.document import DoclingDocument
|
|
|
|
# Create a minimal DoclingDocument with some text items
|
|
doc_json = {
|
|
"name": "test_doc",
|
|
"texts": [
|
|
{
|
|
"self_ref": "#/texts/0",
|
|
"text": "First text",
|
|
"orig": "First text",
|
|
"label": "paragraph",
|
|
},
|
|
{
|
|
"self_ref": "#/texts/1",
|
|
"text": "Second text",
|
|
"orig": "Second text",
|
|
"label": "title",
|
|
},
|
|
],
|
|
"tables": [],
|
|
"pictures": [],
|
|
"groups": [],
|
|
"body": {"self_ref": "#/body", "children": []},
|
|
"furniture": {"self_ref": "#/furniture", "children": []},
|
|
}
|
|
docling_doc = DoclingDocument.model_validate(doc_json)
|
|
|
|
# Create chunk metadata with refs
|
|
chunk_meta = ChunkMetadata(
|
|
doc_item_refs=["#/texts/0", "#/texts/1"],
|
|
labels=["paragraph", "title"],
|
|
)
|
|
|
|
# Resolve refs
|
|
doc_items = chunk_meta.resolve_doc_items(docling_doc)
|
|
|
|
assert len(doc_items) == 2
|
|
assert getattr(doc_items[0], "text") == "First text"
|
|
assert getattr(doc_items[1], "text") == "Second text"
|
|
|
|
|
|
def test_chunk_metadata_resolve_doc_items_graceful_degradation():
|
|
"""Test that invalid refs are skipped gracefully."""
|
|
from docling_core.types.doc.document import DoclingDocument
|
|
|
|
doc_json = {
|
|
"name": "test_doc",
|
|
"texts": [
|
|
{
|
|
"self_ref": "#/texts/0",
|
|
"text": "Only text",
|
|
"orig": "Only text",
|
|
"label": "paragraph",
|
|
},
|
|
],
|
|
"tables": [],
|
|
"pictures": [],
|
|
"groups": [],
|
|
"body": {"self_ref": "#/body", "children": []},
|
|
"furniture": {"self_ref": "#/furniture", "children": []},
|
|
}
|
|
docling_doc = DoclingDocument.model_validate(doc_json)
|
|
|
|
# Create chunk metadata with one valid and one invalid ref
|
|
chunk_meta = ChunkMetadata(
|
|
doc_item_refs=["#/texts/0", "#/texts/999", "#/invalid/path"],
|
|
)
|
|
|
|
# Resolve refs - invalid ones should be skipped
|
|
doc_items = chunk_meta.resolve_doc_items(docling_doc)
|
|
|
|
assert len(doc_items) == 1
|
|
assert getattr(doc_items[0], "text") == "Only text"
|
|
|
|
|
|
def test_chunk_metadata_resolve_empty_refs():
|
|
"""Test resolving with no refs returns empty list."""
|
|
from docling_core.types.doc.document import DoclingDocument
|
|
|
|
doc_json = {
|
|
"name": "test_doc",
|
|
"texts": [],
|
|
"tables": [],
|
|
"pictures": [],
|
|
"groups": [],
|
|
"body": {"self_ref": "#/body", "children": []},
|
|
"furniture": {"self_ref": "#/furniture", "children": []},
|
|
}
|
|
docling_doc = DoclingDocument.model_validate(doc_json)
|
|
|
|
chunk_meta = ChunkMetadata()
|
|
doc_items = chunk_meta.resolve_doc_items(docling_doc)
|
|
|
|
assert doc_items == []
|
|
|
|
|
|
def test_search_result_from_chunk_preserves_document_meta():
|
|
"""Document metadata flows from Chunk to SearchResult for citation
|
|
consumers (UIs)."""
|
|
chunk = Chunk(
|
|
id="chunk-1",
|
|
document_id="doc-1",
|
|
content="Some content.",
|
|
document_uri="file:///docs/report.pdf",
|
|
document_meta={"source_url": "https://example.org/report/view"},
|
|
)
|
|
|
|
result = SearchResult.from_chunk(chunk, score=0.9)
|
|
|
|
assert result.document_meta == {"source_url": "https://example.org/report/view"}
|
|
|
|
|
|
def test_search_result_format_for_agent_omits_document_meta():
|
|
"""Document metadata is UI plumbing, never shown to the model."""
|
|
result = SearchResult(
|
|
content="Some content.",
|
|
score=0.9,
|
|
chunk_id="chunk-1",
|
|
document_meta={"source_url": "https://example.org/report/view"},
|
|
)
|
|
|
|
formatted = result.format_for_agent(rank=1, total=1)
|
|
|
|
assert "source_url" not in formatted
|
|
assert "https://example.org/report/view" not in formatted
|
|
|
|
|
|
def test_search_result_format_for_agent_with_rank():
|
|
"""Test format_for_agent with rank and total parameters."""
|
|
result = SearchResult(
|
|
content="This is the chunk content about elections.",
|
|
score=0.02, # Low RRF score that would confuse agents
|
|
chunk_id="chunk-123",
|
|
document_id="doc-456",
|
|
document_uri="file:///docs/report.pdf",
|
|
document_title="Annual Report 2024",
|
|
headings=["Chapter 1", "Section 1.1", "Elections"],
|
|
labels=["paragraph", "table"],
|
|
page_numbers=[1, 2],
|
|
)
|
|
|
|
formatted = result.format_for_agent(rank=1, total=5)
|
|
|
|
assert "[chunk-123]" in formatted
|
|
assert "[rank 1 of 5]" in formatted
|
|
assert "score:" not in formatted # Score should NOT appear when rank is provided
|
|
assert (
|
|
'Source: "Annual Report 2024" > Chapter 1 > Section 1.1 > Elections'
|
|
in formatted
|
|
)
|
|
assert "Type: table" in formatted
|
|
assert "Content:\nThis is the chunk content about elections." in formatted
|
|
|
|
|
|
def test_search_result_format_for_agent_picture_captions():
|
|
"""Picture captions render as labelled lines so the model can correlate them
|
|
with binary parts (BinaryContent.identifier doesn't survive serialization
|
|
to the OpenAI vision API; insertion order is the only reliable signal)."""
|
|
result = SearchResult(
|
|
content="...surrounding text...",
|
|
score=0.5,
|
|
chunk_id="chunk-xyz",
|
|
labels=["picture", "text"],
|
|
picture_captions={
|
|
"#/pictures/0": "Figure 1. Results from each model.",
|
|
"#/pictures/1": "Figure 2. Projected annual emissions.",
|
|
},
|
|
)
|
|
|
|
formatted = result.format_for_agent(rank=1, total=2)
|
|
|
|
lines = formatted.splitlines()
|
|
cap0 = next(
|
|
i for i, line in enumerate(lines) if "Figure caption (#/pictures/0)" in line
|
|
)
|
|
cap1 = next(
|
|
i for i, line in enumerate(lines) if "Figure caption (#/pictures/1)" in line
|
|
)
|
|
content_line = next(
|
|
i for i, line in enumerate(lines) if line.startswith("Content:")
|
|
)
|
|
assert cap0 < cap1 < content_line
|
|
assert "Figure 1. Results from each model." in formatted
|
|
assert "Figure 2. Projected annual emissions." in formatted
|
|
|
|
|
|
def test_search_result_format_for_agent_no_captions_no_line():
|
|
"""Without picture_captions, no caption lines appear (zero-overhead for text chunks)."""
|
|
result = SearchResult(
|
|
content="prose",
|
|
score=0.5,
|
|
chunk_id="chunk-abc",
|
|
labels=["text"],
|
|
)
|
|
formatted = result.format_for_agent(rank=1, total=1)
|
|
assert "Figure caption" not in formatted
|
|
|
|
|
|
def test_search_result_format_for_agent_rank_only():
|
|
"""Test format_for_agent with rank but no total."""
|
|
result = SearchResult(
|
|
content="Some content.",
|
|
score=0.03,
|
|
chunk_id="chunk-abc",
|
|
)
|
|
|
|
formatted = result.format_for_agent(rank=2)
|
|
|
|
assert "[chunk-abc]" in formatted
|
|
assert "[rank 2]" in formatted
|
|
assert "score:" not in formatted
|
|
|
|
|
|
def test_search_result_format_for_agent_fallback():
|
|
"""Test format_for_agent falls back to score when no rank provided."""
|
|
result = SearchResult(
|
|
content="This is the chunk content about elections.",
|
|
score=0.85,
|
|
chunk_id="chunk-123",
|
|
document_id="doc-456",
|
|
document_uri="file:///docs/report.pdf",
|
|
document_title="Annual Report 2024",
|
|
headings=["Chapter 1", "Section 1.1", "Elections"],
|
|
labels=["paragraph", "table"],
|
|
page_numbers=[1, 2],
|
|
)
|
|
|
|
formatted = result.format_for_agent()
|
|
|
|
assert "[chunk-123]" in formatted
|
|
assert "(score: 0.85)" in formatted
|
|
assert (
|
|
'Source: "Annual Report 2024" > Chapter 1 > Section 1.1 > Elections'
|
|
in formatted
|
|
)
|
|
assert "Type: table" in formatted # table has higher priority than paragraph
|
|
assert "Content:\nThis is the chunk content about elections." in formatted
|
|
|
|
|
|
def test_search_result_format_for_agent_minimal():
|
|
"""Test format_for_agent with minimal metadata."""
|
|
result = SearchResult(
|
|
content="Some content here.",
|
|
score=0.72,
|
|
chunk_id="chunk-abc",
|
|
)
|
|
|
|
formatted = result.format_for_agent()
|
|
|
|
assert "[chunk-abc]" in formatted
|
|
assert "(score: 0.72)" in formatted
|
|
assert "Source:" not in formatted # No title or headings
|
|
assert "Type:" not in formatted # No labels
|
|
assert "Content:\nSome content here." in formatted
|
|
|
|
|
|
def test_search_result_format_for_agent_title_only():
|
|
"""Test format_for_agent with only document title."""
|
|
result = SearchResult(
|
|
content="Content text.",
|
|
score=0.60,
|
|
chunk_id="chunk-xyz",
|
|
document_title="My Document",
|
|
)
|
|
|
|
formatted = result.format_for_agent()
|
|
|
|
assert 'Source: "My Document"' in formatted
|
|
|
|
|
|
def test_search_result_format_for_agent_headings_only():
|
|
"""Test format_for_agent with only headings (no title)."""
|
|
result = SearchResult(
|
|
content="Content text.",
|
|
score=0.60,
|
|
chunk_id="chunk-xyz",
|
|
headings=["Introduction", "Background"],
|
|
)
|
|
|
|
formatted = result.format_for_agent()
|
|
|
|
assert "Source: Introduction > Background" in formatted
|
|
|
|
|
|
def test_search_result_get_primary_label():
|
|
"""Test _get_primary_label prioritization."""
|
|
# Table takes priority over text labels
|
|
result = SearchResult(content="x", score=0.5, labels=["paragraph", "table", "text"])
|
|
assert result._get_primary_label() == "table"
|
|
|
|
# Code takes priority over list_item
|
|
result = SearchResult(content="x", score=0.5, labels=["list_item", "code"])
|
|
assert result._get_primary_label() == "code"
|
|
|
|
# Text labels fall through to first
|
|
result = SearchResult(content="x", score=0.5, labels=["paragraph", "text"])
|
|
assert result._get_primary_label() == "paragraph"
|
|
|
|
# Empty labels
|
|
result = SearchResult(content="x", score=0.5, labels=[])
|
|
assert result._get_primary_label() is None
|
|
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_chunk_content_fts_populated(temp_db_path):
|
|
"""Test that content_fts column is populated with contextualized content."""
|
|
from haiku.rag.embeddings import get_embedder
|
|
|
|
async with HaikuRAG(db_path=temp_db_path, config=Config, create=True) as client:
|
|
# Create a chunk with headings
|
|
chunk = Chunk(
|
|
document_id="test-doc",
|
|
content="This is the raw chunk content.",
|
|
metadata={"headings": ["Chapter 1", "Section 1.1"]},
|
|
order=0,
|
|
)
|
|
|
|
# Generate embedding
|
|
embedder = get_embedder(Config)
|
|
embedding = (await embedder.embed_documents([chunk.content]))[0]
|
|
chunk.embedding = embedding
|
|
|
|
# Store the chunk
|
|
await client.chunk_repository.create(chunk)
|
|
|
|
# Read the raw record from the database
|
|
records = (
|
|
await client.store.chunks_table.query()
|
|
.where(f"id = '{chunk.id}'")
|
|
.limit(1)
|
|
.to_arrow()
|
|
).to_pylist()
|
|
|
|
assert len(records) == 1
|
|
record = records[0]
|
|
|
|
# Verify content is raw (no headings)
|
|
assert record["content"] == "This is the raw chunk content."
|
|
|
|
# Verify content_fts is contextualized (headings + content)
|
|
assert (
|
|
record["content_fts"]
|
|
== "Chapter 1\nSection 1.1\nThis is the raw chunk content."
|
|
)
|
|
|
|
|
|
@pytest.mark.vcr()
|
|
async def test_chunk_content_fts_without_headings(temp_db_path):
|
|
"""Test that content_fts equals content when no headings present."""
|
|
from haiku.rag.embeddings import get_embedder
|
|
|
|
async with HaikuRAG(db_path=temp_db_path, config=Config, create=True) as client:
|
|
# Create a chunk without headings
|
|
chunk = Chunk(
|
|
document_id="test-doc",
|
|
content="Plain content without headings.",
|
|
metadata={},
|
|
order=0,
|
|
)
|
|
|
|
# Generate embedding
|
|
embedder = get_embedder(Config)
|
|
embedding = (await embedder.embed_documents([chunk.content]))[0]
|
|
chunk.embedding = embedding
|
|
|
|
# Store the chunk
|
|
await client.chunk_repository.create(chunk)
|
|
|
|
# Read the raw record from the database
|
|
records = (
|
|
await client.store.chunks_table.query()
|
|
.where(f"id = '{chunk.id}'")
|
|
.limit(1)
|
|
.to_arrow()
|
|
).to_pylist()
|
|
|
|
assert len(records) == 1
|
|
record = records[0]
|
|
|
|
# Both should be the same when no headings
|
|
assert record["content"] == "Plain content without headings."
|
|
assert record["content_fts"] == "Plain content without headings."
|
|
|
|
|
|
async def test_ensure_fts_index_warns_on_failure(temp_db_path):
|
|
"""A failed FTS index build is surfaced at WARNING, not swallowed silently."""
|
|
import logging
|
|
|
|
from haiku.rag.store.repositories import chunk as chunk_module
|
|
|
|
async with HaikuRAG(db_path=temp_db_path, config=Config, create=True) as client:
|
|
repo = client.chunk_repository
|
|
|
|
async def _boom(*_args, **_kwargs):
|
|
raise RuntimeError("index build failed")
|
|
|
|
repo.store.chunks_table.create_index = _boom
|
|
|
|
records: list[logging.LogRecord] = []
|
|
|
|
class _Capture(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
records.append(record)
|
|
|
|
handler = _Capture(level=logging.WARNING)
|
|
chunk_module.logger.addHandler(handler)
|
|
try:
|
|
await repo._ensure_fts_index()
|
|
finally:
|
|
chunk_module.logger.removeHandler(handler)
|
|
|
|
assert [r for r in records if r.levelno == logging.WARNING]
|
|
assert any("index build failed" in r.getMessage() for r in records)
|