Spaces:
Running
Running
| from pathlib import Path | |
| import sys | |
| import types | |
| import pytest | |
| from app.rag import chunker | |
| from app.rag.chunker import _table_to_markdown, chunk_document, get_page_count | |
| def test_txt_extraction_and_chunking(tmp_path): | |
| file_path = tmp_path / "notes.txt" | |
| file_path.write_text("This is a sample text file for chunking.", encoding="utf-8") | |
| chunks = chunk_document(str(file_path)) | |
| assert len(chunks) >= 1 | |
| assert chunks[0]["page"] == 1 | |
| assert "sample text file" in chunks[0]["text"] | |
| def test_empty_txt_returns_no_chunks(tmp_path): | |
| file_path = tmp_path / "empty.txt" | |
| file_path.write_text(" \n", encoding="utf-8") | |
| assert chunk_document(str(file_path)) == [] | |
| def test_unsupported_extension_raises_value_error(tmp_path): | |
| file_path = tmp_path / "data.csv" | |
| file_path.write_text("a,b,c", encoding="utf-8") | |
| with pytest.raises(ValueError, match="Unsupported file type"): | |
| chunk_document(str(file_path)) | |
| def test_get_page_count_for_txt_returns_one(tmp_path): | |
| file_path = tmp_path / "single.txt" | |
| file_path.write_text("hello", encoding="utf-8") | |
| assert get_page_count(str(file_path)) == 1 | |
| def test_table_to_markdown_cleans_cells_and_escapes_pipes(): | |
| rows = [ | |
| ["Name", "Age", "Role"], | |
| [" Asha\nRao ", 24, "Admin | Owner"], | |
| [None, " ", None], | |
| ["Ravi", 28], | |
| ] | |
| assert _table_to_markdown(rows) == "\n".join( | |
| [ | |
| "| Name | Age | Role |", | |
| "| --- | --- | --- |", | |
| "| Asha Rao | 24 | Admin \\| Owner |", | |
| "| Ravi | 28 | |", | |
| ] | |
| ) | |
| def test_pdf_table_detection_separates_table_from_paragraph(monkeypatch): | |
| class FakeTable: | |
| bbox = (40, 90, 300, 160) | |
| def extract(self): | |
| return [["Name", "Amount"], ["Alpha", "$10"]] | |
| class FakePage: | |
| width = 400 | |
| height = 200 | |
| def find_tables(self): | |
| return [FakeTable()] | |
| def extract_words(self): | |
| return [ | |
| {"text": "Intro", "x0": 40, "x1": 70, "top": 20, "bottom": 30}, | |
| {"text": "paragraph", "x0": 75, "x1": 140, "top": 20, "bottom": 30}, | |
| {"text": "Name", "x0": 45, "x1": 80, "top": 100, "bottom": 110}, | |
| {"text": "Amount", "x0": 160, "x1": 220, "top": 100, "bottom": 110}, | |
| {"text": "Alpha", "x0": 45, "x1": 85, "top": 125, "bottom": 135}, | |
| {"text": "$10", "x0": 160, "x1": 185, "top": 125, "bottom": 135}, | |
| ] | |
| class FakePdf: | |
| pages = [FakePage()] | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, exc_type, exc, traceback): | |
| return False | |
| fake_pdfplumber = types.SimpleNamespace(open=lambda _filepath: FakePdf()) | |
| monkeypatch.setitem(sys.modules, "pdfplumber", fake_pdfplumber) | |
| monkeypatch.setattr(chunker, "extract_pdf_images", lambda _filepath: []) | |
| chunks = chunk_document("report.pdf") | |
| assert len(chunks) == 2 | |
| assert chunks[0]["chunk_type"] == "text" | |
| assert chunks[0]["text"] == "Intro paragraph" | |
| assert "Name" not in chunks[0]["text"] | |
| assert chunks[1]["chunk_type"] == "table" | |
| assert chunks[1]["bbox"] == "[0.1, 0.45, 0.75, 0.8]" | |
| assert "| Name | Amount |" in chunks[1]["text"] | |
| assert "| Alpha | $10 |" in chunks[1]["text"] | |
| def test_unstructured_table_detection(monkeypatch): | |
| # Create fake Unstructured Table and Text element classes | |
| class FakeTableClass: | |
| pass | |
| class FakeTable(FakeTableClass): | |
| def __init__(self): | |
| self.rows = [["Name", "Amount"], ["Delta", "$40"]] | |
| self.page_number = 3 | |
| class FakeText: | |
| def __init__(self): | |
| self.text = "Intro paragraph" | |
| self.page_number = 3 | |
| def fake_partition_pdf(filename): | |
| return [FakeText(), FakeTable()] | |
| # Insert fake unstructured modules | |
| monkeypatch.setitem(sys.modules, "unstructured", types.SimpleNamespace()) | |
| monkeypatch.setitem(sys.modules, "unstructured.partition", types.SimpleNamespace()) | |
| monkeypatch.setitem( | |
| sys.modules, | |
| "unstructured.partition.pdf", | |
| types.SimpleNamespace(partition_pdf=fake_partition_pdf), | |
| ) | |
| monkeypatch.setitem(sys.modules, "unstructured.documents", types.SimpleNamespace()) | |
| monkeypatch.setitem( | |
| sys.modules, | |
| "unstructured.documents.elements", | |
| types.SimpleNamespace(Table=FakeTableClass), | |
| ) | |
| monkeypatch.setattr(chunker, "extract_pdf_images", lambda _filepath: []) | |
| chunks = chunk_document("sample.pdf") | |
| # Expect two chunks: text then table | |
| assert len(chunks) >= 2 | |
| assert chunks[0]["chunk_type"] == "text" | |
| assert "Intro paragraph" in chunks[0]["text"] | |
| # find a table chunk | |
| table_chunks = [c for c in chunks if c.get("chunk_type") == "table"] | |
| assert table_chunks, "No table chunks produced by Unstructured path" | |
| assert table_chunks[0]["page"] == 3 | |
| assert "| Name | Amount |" in table_chunks[0]["text"] | |
| assert "| Delta | $40 |" in table_chunks[0]["text"] | |
| # ── _table_to_markdown edge cases ──────────────────────────────────────────── | |
| def test_table_to_markdown_empty_rows_returns_empty(): | |
| assert _table_to_markdown([]) == "" | |
| def test_table_to_markdown_all_blank_cells_returns_empty(): | |
| rows = [[None, None], [" ", ""], [None, " "]] | |
| assert _table_to_markdown(rows) == "" | |
| def test_table_to_markdown_single_row_acts_as_header(): | |
| rows = [["Product", "Price"]] | |
| result = _table_to_markdown(rows) | |
| assert result == "\n".join( | |
| [ | |
| "| Product | Price |", | |
| "| --- | --- |", | |
| ] | |
| ) | |
| def test_table_to_markdown_ragged_rows_padded_to_max_width(): | |
| """Rows shorter than the widest row must be right-padded with empty strings.""" | |
| rows = [ | |
| ["A", "B", "C"], | |
| ["X"], | |
| ["Y", "Z"], | |
| ] | |
| result = _table_to_markdown(rows) | |
| lines = result.splitlines() | |
| # Every line should have the same number of pipe characters | |
| pipe_counts = [line.count("|") for line in lines] | |
| assert len(set(pipe_counts)) == 1, "All rows must have equal column count" | |
| def test_table_to_markdown_whitespace_normalised_in_cells(): | |
| rows = [["Col\t1", "Col\n2"], ["val a", "val\tb"]] | |
| result = _table_to_markdown(rows) | |
| assert "Col 1" in result | |
| assert "Col 2" in result | |
| assert "val a" in result | |
| assert "val b" in result | |
| def test_table_to_markdown_pipe_in_cell_is_escaped(): | |
| rows = [["A|B", "C"], ["x|y|z", "w"]] | |
| result = _table_to_markdown(rows) | |
| assert "A\\|B" in result | |
| assert "x\\|y\\|z" in result | |
| def test_table_to_markdown_separator_row_uses_triple_dash(): | |
| rows = [["H1", "H2"], ["v1", "v2"]] | |
| lines = _table_to_markdown(rows).splitlines() | |
| assert lines[1] == "| --- | --- |" | |
| # ── pdfplumber path — multi-page ───────────────────────────────────────────── | |
| def test_pdf_table_multi_page_produces_chunk_per_page(monkeypatch): | |
| """Tables on different pages must produce separate table chunks with correct page numbers.""" | |
| class FakeTable: | |
| def __init__(self, page_num): | |
| self._page_num = page_num | |
| self.bbox = (0, 50, 200, 150) | |
| def extract(self): | |
| return [["Item", "Qty"], [f"Row-p{self._page_num}", "1"]] | |
| class FakePage: | |
| def __init__(self, page_num): | |
| self._page_num = page_num | |
| self.width = 200 | |
| self.height = 200 | |
| def find_tables(self): | |
| return [FakeTable(self._page_num)] | |
| def extract_words(self): | |
| # No paragraph words — all words are inside the table bbox | |
| return [] | |
| class FakePdf: | |
| pages = [FakePage(1), FakePage(2)] | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, *_): | |
| return False | |
| fake_pdfplumber = types.SimpleNamespace(open=lambda _: FakePdf()) | |
| monkeypatch.setitem(sys.modules, "pdfplumber", fake_pdfplumber) | |
| monkeypatch.setattr(chunker, "extract_pdf_images", lambda _: []) | |
| chunks = chunk_document("multipage.pdf") | |
| table_chunks = [c for c in chunks if c.get("chunk_type") == "table"] | |
| assert len(table_chunks) == 2 | |
| assert table_chunks[0]["page"] == 1 | |
| assert table_chunks[1]["page"] == 2 | |
| assert "Row-p1" in table_chunks[0]["text"] | |
| assert "Row-p2" in table_chunks[1]["text"] | |
| def test_pdf_empty_table_is_not_emitted(monkeypatch): | |
| """A table whose cells are all blank must produce no chunk.""" | |
| class FakeEmptyTable: | |
| bbox = (0, 50, 200, 150) | |
| def extract(self): | |
| return [[None, ""], [" ", None]] | |
| class FakePage: | |
| width = 200 | |
| height = 200 | |
| def find_tables(self): | |
| return [FakeEmptyTable()] | |
| def extract_words(self): | |
| return [] | |
| class FakePdf: | |
| pages = [FakePage()] | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, *_): | |
| return False | |
| fake_pdfplumber = types.SimpleNamespace(open=lambda _: FakePdf()) | |
| monkeypatch.setitem(sys.modules, "pdfplumber", fake_pdfplumber) | |
| monkeypatch.setattr(chunker, "extract_pdf_images", lambda _: []) | |
| chunks = chunk_document("empty_table.pdf") | |
| table_chunks = [c for c in chunks if c.get("chunk_type") == "table"] | |
| assert table_chunks == [], "Empty tables must not produce chunks" | |
| def test_pdf_table_index_increments_per_page(monkeypatch): | |
| """table_index must restart at 0 for each page (pdfplumber path).""" | |
| class FakeTable: | |
| bbox = (0, 50, 200, 150) | |
| def extract(self): | |
| return [["H"], ["V"]] | |
| class FakePage: | |
| width = 200 | |
| height = 200 | |
| def find_tables(self): | |
| return [FakeTable(), FakeTable()] | |
| def extract_words(self): | |
| return [] | |
| class FakePdf: | |
| pages = [FakePage()] | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, *_): | |
| return False | |
| fake_pdfplumber = types.SimpleNamespace(open=lambda _: FakePdf()) | |
| monkeypatch.setitem(sys.modules, "pdfplumber", fake_pdfplumber) | |
| monkeypatch.setattr(chunker, "extract_pdf_images", lambda _: []) | |
| chunks = chunk_document("two_tables.pdf") | |
| table_chunks = [c for c in chunks if c.get("chunk_type") == "table"] | |
| assert len(table_chunks) == 2 | |
| assert table_chunks[0]["table_index"] == 0 | |
| assert table_chunks[1]["table_index"] == 1 | |
| def test_pdf_table_bbox_normalised_to_unit_range(monkeypatch): | |
| """Stored bbox values must each be within [0.0, 1.0].""" | |
| class FakeTable: | |
| bbox = (20, 40, 180, 160) | |
| def extract(self): | |
| return [["X", "Y"], ["1", "2"]] | |
| class FakePage: | |
| width = 200 | |
| height = 200 | |
| def find_tables(self): | |
| return [FakeTable()] | |
| def extract_words(self): | |
| return [] | |
| class FakePdf: | |
| pages = [FakePage()] | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, *_): | |
| return False | |
| import json as _json | |
| fake_pdfplumber = types.SimpleNamespace(open=lambda _: FakePdf()) | |
| monkeypatch.setitem(sys.modules, "pdfplumber", fake_pdfplumber) | |
| monkeypatch.setattr(chunker, "extract_pdf_images", lambda _: []) | |
| chunks = chunk_document("bbox_check.pdf") | |
| table_chunks = [c for c in chunks if c.get("chunk_type") == "table"] | |
| assert table_chunks, "Expected at least one table chunk" | |
| bbox = _json.loads(table_chunks[0]["bbox"]) | |
| assert len(bbox) == 4 | |
| for val in bbox: | |
| assert 0.0 <= val <= 1.0, f"bbox value {val} out of [0, 1] range" | |
| # ── chunk_index continuity ──────────────────────────────────────────────────── | |
| def test_chunk_index_is_monotonically_increasing(monkeypatch): | |
| """chunk_index must be a 0-based counter that never resets or skips mid-document.""" | |
| class FakeTable: | |
| bbox = (0, 50, 200, 150) | |
| def extract(self): | |
| return [["H1", "H2"], ["r1", "r2"]] | |
| class FakePage: | |
| width = 200 | |
| height = 200 | |
| def find_tables(self): | |
| return [FakeTable()] | |
| def extract_words(self): | |
| return [ | |
| {"text": "Intro", "x0": 0, "x1": 40, "top": 10, "bottom": 20}, | |
| ] | |
| class FakePdf: | |
| pages = [FakePage()] | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, *_): | |
| return False | |
| fake_pdfplumber = types.SimpleNamespace(open=lambda _: FakePdf()) | |
| monkeypatch.setitem(sys.modules, "pdfplumber", fake_pdfplumber) | |
| monkeypatch.setattr(chunker, "extract_pdf_images", lambda _: []) | |
| chunks = chunk_document("index_check.pdf") | |
| indices = [c["chunk_index"] for c in chunks] | |
| assert indices == list( | |
| range(len(indices)) | |
| ), f"chunk_index must be 0-based and contiguous, got {indices}" | |
| def test_pdf_image_captioning_on_the_fly(monkeypatch): | |
| # Mock extract_pdf_images to yield one image on page 1 | |
| def fake_extract_images(doc_or_path, **kwargs): | |
| yield { | |
| "image_bytes": b"fake_png_bytes", | |
| "page": 1, | |
| "width": 100, | |
| "height": 100, | |
| } | |
| monkeypatch.setattr(chunker, "extract_pdf_images", fake_extract_images) | |
| # Mock caption_image to return a custom caption | |
| from app.rag import vision | |
| monkeypatch.setattr(vision, "caption_image", lambda img_bytes, page=None: f"Captured image on page {page}") | |
| # Mock extract_pdf to return a text page | |
| def fake_extract_pdf(filepath): | |
| return [{"text": "Hello world on page 1", "page": 1, "chunk_type": "text"}] | |
| monkeypatch.setattr(chunker, "extract_pdf", fake_extract_pdf) | |
| # Mock fitz.open | |
| class FakePage: | |
| rect = type('Rect', (), {'width': 100, 'height': 100})() | |
| def search_for(self, text): | |
| return [] | |
| class FakePdf: | |
| def __init__(self, *args, **kwargs): | |
| pass | |
| def __len__(self): | |
| return 1 | |
| def __getitem__(self, idx): | |
| return FakePage() | |
| def close(self): | |
| pass | |
| import fitz | |
| monkeypatch.setattr(fitz, "open", lambda *args, **kwargs: FakePdf()) | |
| # Run chunk_document | |
| chunks = chunk_document("dummy.pdf") | |
| # The result should contain the text chunk and the image chunk | |
| assert len(chunks) == 2 | |
| assert chunks[0]["text"] == "Hello world on page 1" | |
| assert chunks[0]["page"] == 1 | |
| assert chunks[1]["text"] == "Captured image on page 1" | |
| assert chunks[1]["page"] == 1 | |
| assert chunks[1]["is_image"] is True | |
| assert chunks[1]["image_caption"] == "Captured image on page 1" | |
| # Ensure image_bytes is not in the chunk dictionary (preventing memory leak) | |
| assert "image_bytes" not in chunks[1] | |
| def test_pdf_multi_column_layout_parsing_order(monkeypatch): | |
| """Ensure multi-column layout extracts text column-by-column rather than row-by-row.""" | |
| class FakePage: | |
| width = 600 | |
| height = 800 | |
| def find_tables(self): | |
| return [] | |
| def extract_words(self): | |
| # Simulated 2-column layout with 2 sequential reading rows | |
| return [ | |
| # Left Column - Line 1 | |
| {"text": "Left1", "x0": 50, "x1": 100, "top": 100, "bottom": 115}, | |
| # Right Column - Line 1 | |
| {"text": "Right1", "x0": 350, "x1": 400, "top": 100, "bottom": 115}, | |
| # Left Column - Line 2 | |
| {"text": "Left2", "x0": 50, "x1": 100, "top": 130, "bottom": 145}, | |
| # Right Column - Line 2 | |
| {"text": "Right2", "x0": 350, "x1": 400, "top": 130, "bottom": 145}, | |
| ] | |
| class FakePdf: | |
| pages = [FakePage()] | |
| def __enter__(self): return self | |
| def __exit__(self, exc_type, exc, traceback): return False | |
| fake_pdfplumber = types.SimpleNamespace(open=lambda _filepath: FakePdf()) | |
| monkeypatch.setitem(sys.modules, "pdfplumber", fake_pdfplumber) | |
| monkeypatch.setattr(chunker, "extract_pdf_images", lambda _filepath: []) | |
| chunks = chunk_document("multi_column.pdf") | |
| assert len(chunks) == 1 | |
| text_lines = chunks[0]["text"].splitlines() | |
| # Correct reading order reads entire Column 1 downward, then moves to Column 2 | |
| assert text_lines == ["Left1", "Left2", "Right1", "Right2"] | |