PDF-Assit_RAG / backend /tests /test_chunker.py
Param20h's picture
deploy: pure backend API with keywords fix
7c46845 unverified
Raw
History Blame Contribute Delete
16.6 kB
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"]