phase: 126_pdf_support
All gates verified green — no code changes were needed on this pass. Final report: ## Phase 126 — PDF support: final verification pass **Verified (all tasks 01–05 already complete in working tree):** - `pymupdf` dep in `pyproject.toml`/`uv.lock`; `.env.example` documents the `pdf` token - `app/rag/pdf.py` (text + image extraction, MIME mapping), page-aware `_chunk_pdf`, importer `_index_pdf_file` + embedded-image pass, archives covered via extension walk **Test / lint / coverage results:** - `uv run pytest` → 2997 passed - `uv run pytest tests/e2e/test_pdf_support.py -v --no-cov` (isolation) → 6 passed - `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90% gate), 2997 passed - `uv run ruff check . && uv run pyright` → clean (0 errors) **Completion criteria:** 1. PDF → doc with text/chunks/embedding/summary — ✅ (integration + E2E Sources-page tests) 2. `BOR_IMAGES=true`: embedded images → standalone image docs — ✅ (integration + E2E chat-chip test) 3. `BOR_IMAGES=false`: images skipped, text indexed — ✅ (both suites) 4. PDF renders as extracted text in viewer — ✅ (E2E) 5. Test/coverage/lint gates — ✅ (above) 6. Commit + phase move — harness handles (per protocol, no `git` on this pass) **Decisions:** none new; inline `[Embedded image: …]` placeholders were a documented task-04 deviation (simplified spec wins) — not a defect. No defects found; repo left functional with changes in the working tree. **Next pending phase:** none — `todo/` is empty once 126 is moved.
This commit is contained in:
@@ -419,6 +419,93 @@ def test_dispatch_still_falls_back_for_unknown_suffix() -> None:
|
||||
assert chunk_document(content, "x/notes.whatever") == chunk_text(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pdf (phase 126, task 03, LOCKED A2) — the page-aware chunker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pdf_single_page_chunks_with_page_anchor() -> None:
|
||||
content = "---PAGE-1---\nHello single page.\n"
|
||||
chunks = chunk_document(content, "report.pdf")
|
||||
assert chunks == ["Page 1:\nHello single page."]
|
||||
assert "---PAGE-" not in "\n".join(chunks)
|
||||
|
||||
|
||||
def test_pdf_multi_page_keeps_the_marker_page_number() -> None:
|
||||
"""Blank pages carry no marker — the anchor follows the MARKER's
|
||||
number, not the chunk count (page 3 stays page 3 when page 2 was
|
||||
blank)."""
|
||||
content = (
|
||||
"---PAGE-1---\nAlpha text one.\n\n"
|
||||
"---PAGE-3---\nGamma text three.\n"
|
||||
)
|
||||
chunks = chunk_document(content, "report.pdf")
|
||||
assert chunks == ["Page 1:\nAlpha text one.", "Page 3:\nGamma text three."]
|
||||
assert "Page 2:" not in "\n".join(chunks)
|
||||
|
||||
|
||||
def test_pdf_markers_never_appear_in_chunks() -> None:
|
||||
content = (
|
||||
"---PAGE-1---\nAlpha text one.\n\n"
|
||||
"---PAGE-2---\nBeta text two.\n\n"
|
||||
"---PAGE-3---\nGamma text three.\n"
|
||||
)
|
||||
chunks = chunk_document(content, "deep/dir/report.pdf")
|
||||
assert len(chunks) == 3
|
||||
for chunk in chunks:
|
||||
assert "---PAGE-" not in chunk
|
||||
assert chunks[0].startswith("Page 1:")
|
||||
assert chunks[1].startswith("Page 2:")
|
||||
assert chunks[2].startswith("Page 3:")
|
||||
|
||||
|
||||
def test_pdf_empty_and_markerless_content_yield_no_chunks() -> None:
|
||||
assert chunk_document("", "report.pdf") == []
|
||||
# No marker at all (the parser always emits one for text — belt and
|
||||
# braces): nothing to anchor, no chunks (never plain-text fallback —
|
||||
# a PDF chunk must carry its page).
|
||||
assert chunk_document("just words, no marker", "report.pdf") == []
|
||||
assert chunk_document("---PAGE-1---\n\n---PAGE-2---\n ", "report.pdf") == []
|
||||
|
||||
|
||||
def test_pdf_long_page_sub_splits_and_every_chunk_keeps_the_anchor() -> None:
|
||||
"""A page larger than the target sub-splits through the paragraph
|
||||
packer — EVERY chunk of the page keeps the ``Page N:`` anchor and
|
||||
the anchored chunk never exceeds the target."""
|
||||
page = "\n\n".join(f"paragraph {i} " + "l" * 300 for i in range(6))
|
||||
content = f"---PAGE-1---\n{page}\n\n---PAGE-2---\nshort second page\n"
|
||||
chunks = chunk_document(content, "report.pdf", target_chars=800, overlap_chars=100)
|
||||
assert len(chunks) >= 3 # the long page sub-splits + the short page
|
||||
page1 = [c for c in chunks if c.startswith("Page 1:")]
|
||||
page2 = [c for c in chunks if c.startswith("Page 2:")]
|
||||
assert len(page1) >= 2 and page2 == ["Page 2:\nshort second page"]
|
||||
for chunk in chunks:
|
||||
assert len(chunk) <= 800, f"target exceeded: {len(chunk)}"
|
||||
# All six paragraphs survive the split.
|
||||
joined = "\n".join(chunks)
|
||||
for i in range(6):
|
||||
assert f"paragraph {i}" in joined
|
||||
|
||||
|
||||
def test_pdf_overlap_carryover_stays_within_the_page() -> None:
|
||||
"""The tail carry-over packs paragraphs INSIDE one page; a new page
|
||||
never inherits the previous page's tail (the page is the boundary)."""
|
||||
a = "\n\n".join(f"para-a-{i} " + "l" * 250 for i in range(3))
|
||||
b = "\n\n".join(f"para-b-{i} " + "l" * 250 for i in range(3))
|
||||
content = f"---PAGE-1---\n{a}\n\n---PAGE-2---\n{b}\n"
|
||||
chunks = chunk_document(content, "r.pdf", target_chars=700, overlap_chars=120)
|
||||
first_of_page2 = next(c for c in chunks if c.startswith("Page 2:"))
|
||||
assert "para-a-" not in first_of_page2, "page 2 must not inherit page 1's tail"
|
||||
assert all(len(c) <= 700 for c in chunks)
|
||||
|
||||
|
||||
def test_pdf_dispatch_matches_direct_call() -> None:
|
||||
content = "---PAGE-1---\nalpha\n\n---PAGE-2---\nbeta\n"
|
||||
from app.rag.chunker import _chunk_pdf
|
||||
|
||||
assert chunk_document(content, "x/report.PDF") == _chunk_pdf(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hard cap across every format (aipi ~1024-token request cap)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -434,6 +521,8 @@ def test_dispatch_still_falls_back_for_unknown_suffix() -> None:
|
||||
("line of text\n\n" * 800, "big.txt"),
|
||||
("[Unit]\n" + "key=" + "v" * 5000 + "\n", "big.container"), # phase 47
|
||||
("{% for x in y %}" + "{{ x }} " * 400 + "{% endfor %}", "big.j2"), # phase 47
|
||||
# phase 126: one long page (a single 12 500-char line) + one short.
|
||||
("---PAGE-1---\n" + "word " * 2500 + "\n\n---PAGE-2---\nshort\n", "big.pdf"),
|
||||
],
|
||||
)
|
||||
def test_hard_cap_holds_for_every_format(content: str, path: str) -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user