import pytest from docling_core.types.doc.document import DoclingDocument, TableData from docling_core.types.doc.labels import DocItemLabel from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig from haiku.rag.store.models import SearchResult from haiku.rag.store.models.chunk import Chunk async def create_document_with_docling( client: HaikuRAG, docling_doc: DoclingDocument, title: str ): """Helper to create a document from a DoclingDocument using import_document.""" chunks = await client.chunk(docling_doc) embedded_chunks = await client._ensure_chunks_embedded(chunks) return await client.import_document( docling_document=docling_doc, chunks=embedded_chunks, title=title, ) def create_table_document() -> DoclingDocument: """Create a document with a table that will be split across chunks.""" doc = DoclingDocument(name="table_test") doc.add_text(label=DocItemLabel.PARAGRAPH, text="Introduction paragraph.") doc.add_heading(text="Employee Data", level=1) # Create a table with enough content to span multiple chunks table_data = TableData(num_cols=3, num_rows=0) table_data.add_row(["Name", "Age", "City"]) table_data.add_row(["Alice Smith", "30", "New York"]) table_data.add_row(["Bob Johnson", "25", "Los Angeles"]) table_data.add_row(["Charlie Brown", "35", "Chicago"]) table_data.add_row(["Diana Ross", "28", "Miami"]) doc.add_table(data=table_data) doc.add_text(label=DocItemLabel.PARAGRAPH, text="Conclusion paragraph.") return doc def create_list_document() -> DoclingDocument: """Create a document with list items that will be split across chunks.""" doc = DoclingDocument(name="list_test") doc.add_text(label=DocItemLabel.PARAGRAPH, text="Shopping list for the week:") doc.add_list_item( text="Fresh organic apples from the farmers market", enumerated=False ) doc.add_list_item(text="Ripe yellow bananas for smoothies", enumerated=False) doc.add_list_item(text="Valencia oranges for fresh juice", enumerated=False) doc.add_list_item(text="Seedless red grapes as healthy snack", enumerated=False) doc.add_text(label=DocItemLabel.PARAGRAPH, text="Remember to bring reusable bags.") return doc def create_code_document() -> DoclingDocument: """Create a document with adjacent code blocks that will be split.""" doc = DoclingDocument(name="code_test") doc.add_text(label=DocItemLabel.PARAGRAPH, text="Here are several code snippets:") # Multiple adjacent code blocks - type-aware expansion should group them doc.add_text(label=DocItemLabel.CODE, text="# Part 1: Setup\nimport os\nimport sys") doc.add_text( label=DocItemLabel.CODE, text='# Part 2: Config\nCONFIG = {"debug": True}' ) doc.add_text( label=DocItemLabel.CODE, text="# Part 3: Main\ndef main():\n print(CONFIG)" ) doc.add_text(label=DocItemLabel.PARAGRAPH, text="End of code examples.") return doc @pytest.fixture def small_chunk_config() -> AppConfig: """Config with small chunk size to force splitting.""" config = AppConfig() config.processing.chunk_size = 32 config.search.max_context_items = 25 config.search.max_context_chars = 10000 return config @pytest.mark.vcr() async def test_table_expansion_includes_split_rows(temp_db_path, small_chunk_config): """Verify that table expansion retrieves rows that were split into different chunks.""" docling_doc = create_table_document() async with HaikuRAG(temp_db_path, config=small_chunk_config, create=True) as client: doc = await create_document_with_docling(client, docling_doc, "Table Test") assert doc.id is not None # Search for table content results = await client.search("Alice Smith New York employee", limit=5) table_results = [r for r in results if "table" in r.labels] assert len(table_results) > 0, ( f"No table results. Labels: {[r.labels for r in results]}" ) original = table_results[0] # Verify the original chunk does NOT contain all table data # (proving we need expansion) original_has_all = all( name in original.content for name in ["Alice", "Bob", "Charlie", "Diana"] ) # Expand context expanded = await client.expand_context(table_results[:1]) assert len(expanded) == 1 expanded_content = expanded[0].content # After expansion, we should have the complete table assert "Alice" in expanded_content assert "Bob" in expanded_content assert "Charlie" in expanded_content assert "Diana" in expanded_content # Verify expansion actually added content (unless chunk already had everything) if not original_has_all: assert len(expanded_content) > len(original.content), ( "Expansion should have added content" ) @pytest.mark.vcr() async def test_list_expansion_includes_split_items(temp_db_path, small_chunk_config): """Verify that list expansion retrieves items that were split into different chunks.""" docling_doc = create_list_document() async with HaikuRAG(temp_db_path, config=small_chunk_config, create=True) as client: doc = await create_document_with_docling(client, docling_doc, "List Test") assert doc.id is not None # Search for a list item results = await client.search("grapes healthy snack", limit=5) list_results = [r for r in results if "list_item" in r.labels] assert len(list_results) > 0, ( f"No list results. Labels: {[r.labels for r in results]}" ) original = list_results[0] # Check what the original chunk contains original_items = sum( 1 for item in ["apples", "bananas", "oranges", "grapes"] if item in original.content.lower() ) # Expand context expanded = await client.expand_context(list_results[:1]) assert len(expanded) == 1 expanded_content = expanded[0].content.lower() # Count items after expansion expanded_items = sum( 1 for item in ["apples", "bananas", "oranges", "grapes"] if item in expanded_content ) # Expansion should include at least as many items (more if split) assert expanded_items >= original_items # If original didn't have all items, expansion should have added some if original_items < 4: assert expanded_items > original_items, ( f"Expansion should have added items. Original: {original_items}, Expanded: {expanded_items}" ) @pytest.mark.vcr() async def test_code_expansion_includes_adjacent_blocks( temp_db_path, small_chunk_config ): """Verify that code expansion retrieves adjacent code blocks split across chunks.""" docling_doc = create_code_document() async with HaikuRAG(temp_db_path, config=small_chunk_config, create=True) as client: doc = await create_document_with_docling(client, docling_doc, "Code Test") assert doc.id is not None # Search for middle code block (Part 2) results = await client.search("CONFIG debug True", limit=5) code_results = [r for r in results if "code" in r.labels] assert len(code_results) > 0, ( f"No code results. Labels: {[r.labels for r in results]}" ) original = code_results[0] # Check what parts the original chunk has original_parts = sum( 1 for part in ["Part 1", "Part 2", "Part 3"] if part in original.content ) # Expand context expanded = await client.expand_context(code_results[:1]) assert len(expanded) == 1 expanded_content = expanded[0].content # Count parts after expansion expanded_parts = sum( 1 for part in ["Part 1", "Part 2", "Part 3"] if part in expanded_content ) # Expansion should include at least as many parts assert expanded_parts >= original_parts # If original didn't have all parts, expansion should have added some if original_parts < 3: assert expanded_parts > original_parts, ( f"Expansion should have added code blocks. Original: {original_parts}, Expanded: {expanded_parts}" ) @pytest.mark.vcr() async def test_text_expansion_uses_radius(temp_db_path): """Text content expansion should use radius, not structural boundaries.""" config = AppConfig() config.processing.chunk_size = 32 config.search.context_radius = 1 # Small radius # Create a document with longer paragraphs that will split doc = DoclingDocument(name="text_test") doc.add_text( label=DocItemLabel.PARAGRAPH, text="First paragraph with enough content to be its own chunk in the document.", ) doc.add_text( label=DocItemLabel.PARAGRAPH, text="Second paragraph contains different information about software testing.", ) doc.add_text( label=DocItemLabel.PARAGRAPH, text="Third paragraph discusses various topics and provides more details.", ) doc.add_text( label=DocItemLabel.PARAGRAPH, text="Fourth paragraph concludes the document with final thoughts.", ) async with HaikuRAG(temp_db_path, config=config, create=True) as client: document = await create_document_with_docling(client, doc, "Text Only") assert document.id is not None # Search for second paragraph results = await client.search("software testing", limit=1) assert len(results) > 0 original = results[0] expanded = await client.expand_context(results) # With radius=1, expansion should include adjacent paragraphs # Content length should be >= original (may add adjacent content) assert len(expanded[0].content) >= len(original.content) @pytest.mark.vcr() async def test_expansion_preserves_metadata(temp_db_path, small_chunk_config): """Expansion should preserve document metadata.""" docling_doc = create_table_document() async with HaikuRAG(temp_db_path, config=small_chunk_config, create=True) as client: doc = await create_document_with_docling(client, docling_doc, "Metadata Test") assert doc.id is not None results = await client.search("Introduction paragraph", limit=1) assert len(results) > 0 expanded = await client.expand_context(results) assert expanded[0].document_title == "Metadata Test" assert expanded[0].chunk_id == results[0].chunk_id assert expanded[0].document_id == results[0].document_id @pytest.mark.vcr() async def test_format_for_agent_output(temp_db_path, small_chunk_config): """format_for_agent should include source, type, and content sections.""" docling_doc = create_table_document() async with HaikuRAG(temp_db_path, config=small_chunk_config, create=True) as client: doc = await create_document_with_docling(client, docling_doc, "Format Test") assert doc.id is not None results = await client.search("Alice Smith employee data", limit=5) table_results = [r for r in results if "table" in r.labels] assert len(table_results) > 0 expanded = await client.expand_context(table_results[:1]) # Format with rank (the way agents use it) formatted = expanded[0].format_for_agent(rank=1, total=1) # Check format structure assert "[rank 1 of 1]" in formatted assert 'Source: "Format Test"' in formatted assert "Type: table" in formatted assert "Content:" in formatted @pytest.mark.vcr() async def test_max_items_limit_caps_expansion(temp_db_path): """Expansion should respect max_context_items limit.""" config = AppConfig() config.processing.chunk_size = 32 config.search.max_context_items = 2 # Very restrictive docling_doc = create_list_document() async with HaikuRAG(temp_db_path, config=config, create=True) as client: doc = await create_document_with_docling(client, docling_doc, "Limit Test") assert doc.id is not None results = await client.search("grapes", limit=1) assert len(results) > 0 expanded = await client.expand_context(results) # With max_items=2, expansion should be limited content = expanded[0].content.lower() item_count = sum( 1 for item in ["apples", "bananas", "oranges", "grapes"] if item in content ) # Should have at most 2 items (the limit) assert item_count <= 2, f"Expected at most 2 items, got {item_count}" @pytest.mark.vcr() async def test_search_result_get_primary_label(): """Test _get_primary_label prioritizes structural labels correctly.""" # Table should be prioritized result = SearchResult( content="test", score=0.5, chunk_id="c1", document_id="d1", labels=["paragraph", "table", "text"], ) assert result._get_primary_label() == "table" # Code should be prioritized over paragraph result = SearchResult( content="test", score=0.5, chunk_id="c2", document_id="d2", labels=["paragraph", "code"], ) assert result._get_primary_label() == "code" # list_item should be prioritized result = SearchResult( content="test", score=0.5, chunk_id="c3", document_id="d3", labels=["text", "list_item"], ) assert result._get_primary_label() == "list_item" # Returns first label when no priority match result = SearchResult( content="test", score=0.5, chunk_id="c4", document_id="d4", labels=["paragraph", "text"], ) assert result._get_primary_label() == "paragraph" # Returns None for empty labels result = SearchResult( content="test", score=0.5, chunk_id="c5", document_id="d5", labels=[], ) assert result._get_primary_label() is None @pytest.mark.vcr() async def test_expand_context_radius_zero(temp_db_path): """Test expand_context with radius 0 returns original results.""" # Default config has context_radius=0 async with HaikuRAG(temp_db_path, create=True) as client: doc = await client.create_document(content="Simple test content") assert doc.id is not None chunks = await client.chunk_repository.get_by_document_id(doc.id) search_results = [SearchResult.from_chunk(chunks[0], 0.9)] expanded_results = await client.expand_context(search_results) # Should return exactly the same results assert len(expanded_results) == 1 assert expanded_results[0].content == search_results[0].content assert expanded_results[0].score == search_results[0].score @pytest.mark.vcr() async def test_expand_context_multiple_documents(temp_db_path): """Test expand_context with results from multiple documents.""" config = AppConfig() config.search.context_radius = 1 async with HaikuRAG(temp_db_path, config=config, create=True) as client: # Create first document with manual chunks docling_doc1 = DoclingDocument(name="doc1") docling_doc1.add_text(label=DocItemLabel.TEXT, text="Doc1 content") doc1_chunks = [ Chunk(content="Doc1 Part A", order=0), Chunk(content="Doc1 Part B", order=1), Chunk(content="Doc1 Part C", order=2), ] doc1 = await client.import_document( docling_document=docling_doc1, chunks=doc1_chunks, uri="doc1.txt" ) # Create second document with manual chunks docling_doc2 = DoclingDocument(name="doc2") docling_doc2.add_text(label=DocItemLabel.TEXT, text="Doc2 content") doc2_chunks = [ Chunk(content="Doc2 Section X", order=0), Chunk(content="Doc2 Section Y", order=1), ] doc2 = await client.import_document( docling_document=docling_doc2, chunks=doc2_chunks, uri="doc2.txt" ) assert doc1.id is not None assert doc2.id is not None chunks1 = await client.chunk_repository.get_by_document_id(doc1.id) chunks2 = await client.chunk_repository.get_by_document_id(doc2.id) # Get middle chunk from doc1 (order=1) and first chunk from doc2 (order=0) chunk1 = next(c for c in chunks1 if c.order == 1) chunk2 = next(c for c in chunks2 if c.order == 0) search_results = [ SearchResult.from_chunk(chunk1, 0.8), SearchResult.from_chunk(chunk2, 0.7), ] expanded_results = await client.expand_context(search_results) assert len(expanded_results) == 2 # Check first expanded result (should include chunks 0,1,2 from doc1) expanded1 = expanded_results[0] assert expanded1.score == 0.8 assert "Doc1 Part A" in expanded1.content assert "Doc1 Part B" in expanded1.content assert "Doc1 Part C" in expanded1.content # Check second expanded result (should include chunks 0,1 from doc2) expanded2 = expanded_results[1] assert expanded2.score == 0.7 assert "Doc2 Section X" in expanded2.content assert "Doc2 Section Y" in expanded2.content @pytest.mark.vcr() async def test_expand_context_merges_overlapping_chunks(temp_db_path): """Test that overlapping expanded chunks are merged into one.""" config = AppConfig() config.search.context_radius = 1 async with HaikuRAG(temp_db_path, config=config, create=True) as client: # Create document with 5 chunks docling_doc = DoclingDocument(name="test") docling_doc.add_text(label=DocItemLabel.TEXT, text="Full document content") manual_chunks = [ Chunk(content="Chunk 0", order=0), Chunk(content="Chunk 1", order=1), Chunk(content="Chunk 2", order=2), Chunk(content="Chunk 3", order=3), Chunk(content="Chunk 4", order=4), ] doc = await client.import_document( docling_document=docling_doc, chunks=manual_chunks ) assert doc.id is not None chunks = await client.chunk_repository.get_by_document_id(doc.id) # Get adjacent chunks (orders 1 and 2) - these will overlap when expanded chunk1 = next(c for c in chunks if c.order == 1) chunk2 = next(c for c in chunks if c.order == 2) # With radius=1: # chunk1 expanded would be [0,1,2] # chunk2 expanded would be [1,2,3] # These should merge into one chunk containing [0,1,2,3] search_results = [ SearchResult.from_chunk(chunk1, 0.8), SearchResult.from_chunk(chunk2, 0.7), ] expanded_results = await client.expand_context(search_results) # Should have only 1 merged result instead of 2 overlapping ones assert len(expanded_results) == 1 merged = expanded_results[0] # Should contain all chunks from 0 to 3 assert "Chunk 0" in merged.content assert "Chunk 1" in merged.content assert "Chunk 2" in merged.content assert "Chunk 3" in merged.content assert "Chunk 4" not in merged.content # Should not include chunk 4 # Should use the higher score (0.8) assert merged.score == 0.8 @pytest.mark.vcr() async def test_expand_context_keeps_separate_non_overlapping(temp_db_path): """Test that non-overlapping expanded chunks remain separate.""" config = AppConfig() config.search.context_radius = 1 async with HaikuRAG(temp_db_path, config=config, create=True) as client: # Create document with chunks far apart docling_doc = DoclingDocument(name="test") docling_doc.add_text(label=DocItemLabel.TEXT, text="Full document content") manual_chunks = [ Chunk(content="Chunk 0", order=0), Chunk(content="Chunk 1", order=1), Chunk(content="Chunk 2", order=2), Chunk(content="Chunk 5", order=5), # Gap here Chunk(content="Chunk 6", order=6), Chunk(content="Chunk 7", order=7), ] doc = await client.import_document( docling_document=docling_doc, chunks=manual_chunks ) assert doc.id is not None chunks = await client.chunk_repository.get_by_document_id(doc.id) # Get chunks by index - they will have sequential orders 0,1,2,3,4,5 # So get chunk with order=0 and chunk with order=5 (far enough apart) chunk0 = next(c for c in chunks if c.order == 0) # Content: "Chunk 0" chunk5 = next( c for c in chunks if c.order == 5 ) # Content: "Chunk 7" but now at order 5 # chunk0 expanded: [0,1] with radius=1 (orders 0,1) # chunk5 expanded: [4,5] with radius=1 (orders 4,5) search_results = [ SearchResult.from_chunk(chunk0, 0.8), SearchResult.from_chunk(chunk5, 0.7), ] expanded_results = await client.expand_context(search_results) # Should have 2 separate results assert len(expanded_results) == 2 # Sort by score to ensure predictable order expanded_results.sort(key=lambda x: x.score, reverse=True) chunk0_expanded = expanded_results[0] chunk5_expanded = expanded_results[1] # First chunk (order=0) expanded should contain orders [0,1] # Content should be "Chunk 0" + "Chunk 1" assert "Chunk 0" in chunk0_expanded.content assert "Chunk 1" in chunk0_expanded.content assert "Chunk 5" not in chunk0_expanded.content assert chunk0_expanded.score == 0.8 # Second chunk (order=5) expanded should contain orders [4,5] # Content should be "Chunk 6" (order 4) + "Chunk 7" (order 5) assert "Chunk 6" in chunk5_expanded.content assert "Chunk 7" in chunk5_expanded.content assert "Chunk 0" not in chunk5_expanded.content assert chunk5_expanded.score == 0.7 @pytest.mark.vcr() async def test_expand_context_with_docling_merges_overlapping(temp_db_path): """Test that expand_context with DoclingDocument merges overlapping results.""" config = AppConfig() config.search.context_radius = 3 markdown_content = """# Chapter 1 This is paragraph one about topic A. This is paragraph two about topic A continued. This is paragraph three about topic B. # Chapter 2 This is paragraph four about topic C. """ async with HaikuRAG(temp_db_path, config=config, create=True) as client: doc = await client.create_document( content=markdown_content, uri="test://structured", ) assert doc.id is not None assert doc.docling_document is not None # Get chunks which should have doc_item_refs chunks = await client.chunk_repository.get_by_document_id(doc.id) assert len(chunks) >= 1 # Find chunks that have doc_item_refs (from docling chunking) chunks_with_refs = [c for c in chunks if c.get_chunk_metadata().doc_item_refs] if len(chunks_with_refs) >= 2: # Create search results from adjacent chunks search_results = [ SearchResult.from_chunk(chunks_with_refs[0], 0.9), SearchResult.from_chunk(chunks_with_refs[1], 0.8), ] # Expand with configured radius that should cause overlap expanded = await client.expand_context(search_results) # If chunks were adjacent, they should be merged # The expanded results should have merged metadata assert len(expanded) >= 1 # Check that expanded result has page_numbers populated for r in expanded: # Should have doc_item_refs from expansion assert r.doc_item_refs is not None @pytest.mark.vcr() async def test_expand_context_docling_merges_metadata(temp_db_path): """Test that expand_context properly merges metadata from multiple results.""" config = AppConfig() config.search.context_radius = 10 markdown_content = """# Introduction First paragraph of introduction. Second paragraph of introduction. # Methods First paragraph of methods section. Second paragraph of methods section. # Results First paragraph of results. """ async with HaikuRAG(temp_db_path, config=config, create=True) as client: doc = await client.create_document( content=markdown_content, uri="test://metadata-merge", ) assert doc.id is not None chunks = await client.chunk_repository.get_by_document_id(doc.id) chunks_with_refs = [c for c in chunks if c.get_chunk_metadata().doc_item_refs] if len(chunks_with_refs) >= 2: # Get chunks with different headings if possible chunk1 = chunks_with_refs[0] chunk2 = chunks_with_refs[-1] # Last chunk likely has different heading search_results = [ SearchResult.from_chunk(chunk1, 0.9), SearchResult.from_chunk(chunk2, 0.8), ] # Expand with large radius to potentially merge expanded = await client.expand_context(search_results) # Check that results have proper structure for r in expanded: # Content should be non-empty assert len(r.content) > 0 # Score should be preserved (best score) assert r.score in [0.9, 0.8] def create_picture_document() -> DoclingDocument: """Create a document with picture items for testing image handling.""" from docling_core.types.doc.labels import DocItemLabel doc = DoclingDocument(name="picture_test") doc.add_text(label=DocItemLabel.PARAGRAPH, text="Introduction with context.") doc.add_heading(text="Figures Section", level=1) # Add a picture item - this will have export_to_markdown method # that defaults to EMBEDDED mode if called without image_mode parameter doc.add_picture() doc.add_text(label=DocItemLabel.CAPTION, text="Figure 1: Sample diagram") doc.add_text(label=DocItemLabel.PARAGRAPH, text="Conclusion paragraph.") return doc @pytest.mark.vcr() async def test_expand_context_no_base64_images(temp_db_path): """Ensure expanded context does not contain base64 image data. This test verifies that when expand_context includes PictureItem objects, they are serialized with PLACEHOLDER mode (not EMBEDDED), preventing base64 image data from leaking into the expanded content. """ config = AppConfig() config.search.context_radius = 5 # Expand enough to include pictures docling_doc = create_picture_document() async with HaikuRAG(temp_db_path, config=config, create=True) as client: doc = await create_document_with_docling(client, docling_doc, "Picture Test") assert doc.id is not None # Search for content near the picture results = await client.search("Figure diagram", limit=5) assert len(results) > 0 # Expand context - this should include the picture area expanded = await client.expand_context(results) for result in expanded: # Base64 image data should never appear in expanded content assert "base64" not in result.content.lower(), ( f"Found 'base64' in expanded content: {result.content[:500]}" ) assert "data:image" not in result.content.lower(), ( f"Found 'data:image' in expanded content: {result.content[:500]}" ) @pytest.mark.vcr() async def test_expand_context_no_base64_images_docling_local(temp_db_path): """Ensure expanded context from real PDF does not contain base64 image data. Tests end-to-end with doclaynet.pdf using docling-local converter. """ from pathlib import Path config = AppConfig() config.processing.converter = "docling-local" config.processing.chunker = "docling-local" config.processing.conversion_options.do_ocr = False config.search.context_radius = 5 async with HaikuRAG(temp_db_path, config=config, create=True) as client: pdf_path = Path(__file__).parent / "data" / "doclaynet.pdf" result = await client.create_document_from_source(pdf_path) doc = result if not isinstance(result, list) else result[0] assert doc.id is not None # Search for content that might include pictures results = await client.search("figure table", limit=10) if len(results) > 0: expanded = await client.expand_context(results) for result in expanded: assert "base64" not in result.content.lower(), ( f"Found 'base64' in expanded content: {result.content[:500]}" ) assert "data:image" not in result.content.lower(), ( f"Found 'data:image' in expanded content: {result.content[:500]}" ) @pytest.mark.vcr() async def test_expand_context_no_base64_images_docling_serve(temp_db_path): """Ensure expanded context from real PDF does not contain base64 image data. Tests end-to-end with doclaynet.pdf using docling-serve converter. """ from pathlib import Path config = AppConfig() config.processing.converter = "docling-serve" config.processing.chunker = "docling-serve" config.search.context_radius = 5 async with HaikuRAG(temp_db_path, config=config, create=True) as client: pdf_path = Path(__file__).parent / "data" / "doclaynet.pdf" result = await client.create_document_from_source(pdf_path) doc = result if not isinstance(result, list) else result[0] assert doc.id is not None # Search for content that might include pictures results = await client.search("figure table", limit=10) if len(results) > 0: expanded = await client.expand_context(results) for result in expanded: assert "base64" not in result.content.lower(), ( f"Found 'base64' in expanded content: {result.content[:500]}" ) assert "data:image" not in result.content.lower(), ( f"Found 'data:image' in expanded content: {result.content[:500]}" )