phase: 126_pdf_support
Build and Push Containers / build-and-push-app (push) Failing after 2m36s
Build and Push Containers / build-and-push-db (push) Successful in 1m39s

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:
2026-09-27 09:36:40 -04:00
parent e0307fe3ff
commit e0f11e7aa8
40 changed files with 4092 additions and 0 deletions
+89
View File
@@ -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