phase: 125_image_anchor_context
All completion criteria verified. The repository is left functional with all changes in the working tree (no commits made, per the harness protocol). ## Final report — phase 125 `image_anchor_context` (verification pass) All 6 tasks were already complete; this pass verified every criterion and ran the full gates: - **Migration 0023** `documents.image_context` (TEXT nullable, reversible) — applied (`alembic current` = 0023 head); round-trip test (4 passed) exercises real upgrade → downgrade 0022 → upgrade. - **Criterion 1** (referenced image enriched + context line in retrieval): integration `test_markdown_referenced_image_gets_context_and_enriched_chunks` + E2E `test_sync_enriches_the_image_doc_with_markdown_context`, `test_chat_answer_quotes_the_context_line` (wire prompt pinned: `<document>` block leads with the one-line `Referenced in …: "alt"` line) — verified present and passing. - **Criterion 2** (standalone image byte-identical): E2E `test_standalone_*` (NULL `image_context`, block body exactly the bare description, no `Referenced in` anywhere in the prompt) + unit `test_describe_image_without_context_is_byte_identical` — passing. - **Criterion 3** (markdown-first: record stored on markdown doc, image picks up context later): integration `test_markdown_indexed_before_image_stores_record_and_image_picks_it_up` + post-index enrichment pass test (markdown file gone from disk) — passing. - **Gates:** `uv run pytest` → 2938 passed, exit 0 · `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL **99%** (>90%) · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors, 0 warnings. - **Phase E2E in isolation:** `uv run pytest tests/e2e/test_image_anchor_context.py -v --no-cov` → 4 passed. - **Mock-LLM change regression check:** neighboring image E2E suites green in isolation — `test_smoke` (3), `test_image_documents` (16), `test_chat_image_questions` (17), `test_answer_images` (17). - No defects found; no code changes needed. No live-infrastructure changes (local dev DB only). No commit made (harness commits). - Next pending phase: check `.agents/phases/todo/` after the harness moves this phase.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
# Phase 126 — PDF support: PDFs become first-class, retrievable documents
|
||||
|
||||
**Source:** `TODO.md` — "add PDF support" (implied by the image handling assessment: "PDFs: Not supported. No PDF parsing library is in pyproject.toml.")
|
||||
**Story:** n/a (new file format support).
|
||||
**Context:** `app/config.py` — `Settings` (`import_extensions` L343, `import_extension_set` L733 — the extension filter; `images` L379, `image_extensions` L390, `image_dir` L413 — the image pipeline); `app/rag/importer.py` — `iter_importable_files` (L235, extension walk via `match_extension`), `import_sources` (L279, `prune` L283), `_index_file` (L435 — text branch: `read_text`, sha256 over text, Document upsert, chunk + embed pipeline), `_index_image_file` (L801 — binary branch: bytes digest, persistent copy, `describe_image`), `_store_summary` (L582), `_prune` (L649). `app/rag/chunker.py` — format-aware chunker (markdown, yaml, json, py, txt dispatch). `app/rag/summarizer.py` — `describe_image` (L208, vision model), `generate_summary` (L182, lite model). `app/rag/retriever.py` — `_selection_order` (L666), `select_suggested` (L864), `source_ref_with_image` (L945). `app/models.py` — `Document` (L102), `Chunk` (L171). `app/rag/llm.py` — the LLM client. `app/api/docs.py` — document content endpoint. `app/rag/archive_upload.py` — archive unpack (PDFs in archives should also be indexed).
|
||||
|
||||
## Objective
|
||||
PDF files become first-class, retrievable documents: text is extracted page by page, embedded images are extracted and described (joining the standalone image pipeline), and the full PDF content is chunked, embedded, and summarized — all behind a new `BOR_IMPORT_EXTENSIONS` token (`pdf`).
|
||||
|
||||
## Dependencies
|
||||
- `122_image_documents` (complete) — the image-doc pipeline: `describe_image`, `Document.is_image`/`image_path`, the vision model description path. Embedded PDF images reuse this pipeline.
|
||||
- `125_image_anchor_context` (complete) — the `image_context` field and enrichment pipeline (PDF-embedded images can be enriched if referenced by a markdown file in the same source).
|
||||
- Code dependencies (all complete): phase 30 summary pipeline, phase 118 summary seeding, the chunker, the LLM client.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **Dependency (task 01):** add `pymupdf` (PyMuPDF, the `fitz` package) to `pyproject.toml` dependencies. PyMuPDF is chosen over `pdfplumber` because it natively supports both text extraction AND embedded image extraction in a single API, and it's a well-maintained package with good performance. The `fitz` module is the import name.
|
||||
- **PDF parsing module (task 02):** `app/rag/pdf.py` — new module with:
|
||||
- `def extract_pdf_text(pdf_path: Path) -> str`: extracts text from all pages, joined with `---PAGE-N---` separators (where N is 1-indexed). The separators enable the chunker to respect page boundaries.
|
||||
- `def extract_pdf_images(pdf_path: Path) -> list[PdfImage]`: extracts embedded images from all pages. Returns a list of `PdfImage` dataclasses: `page: int`, `index: int` (image index on the page), `width: int`, `height: int`, `data: bytes`, `mime: str` (derived from the image type code via PyMuPDF's `page.get_image_info()`).
|
||||
- `_mime_from_image_info(info: dict) -> str`: maps PyMuPDF's image type codes to MIME types (e.g., `2` → `image/jpeg`, `3` → `image/png`, `4` → `image/gif`, `8` → `image/webp`, `9` → `image/tiff`). Falls back to `application/octet-stream` for unknown types.
|
||||
- All functions are pure (except I/O for reading the PDF file) — fully unit-testable with a temp PDF fixture.
|
||||
- **PDF chunking (task 03):** extend `app/rag/chunker.py`'s `chunk_document` dispatch to handle `.pdf` files. The PDF chunker:
|
||||
- Splits on `---PAGE-N---` separators first (one chunk per page, or sub-split if a page is too large).
|
||||
- Each chunk keeps its page anchor (e.g., "Page 3:" prefix) so hits are readable in context.
|
||||
- Code blocks within PDFs are handled by the existing paragraph packing (PDFs don't have code fences, so paragraph boundaries = blank lines).
|
||||
- The `HARD_MAX_CHARS` cap (1200) still applies.
|
||||
- For very large PDFs (>50 pages), the chunk target is reduced to keep individual chunks manageable.
|
||||
- **PDF indexing (task 04):** in `app/rag/importer.py`'s `_index_file`:
|
||||
- Add a new branch for `.pdf` files (checked AFTER the image branch, BEFORE the text branch):
|
||||
```python
|
||||
if full_path.suffix.lower() == ".pdf":
|
||||
return await _index_pdf_file(session, source, rel, full_path, llm, summary, raw_date)
|
||||
```
|
||||
- `_index_pdf_file`:
|
||||
1. Extract text via `extract_pdf_text` (task 02).
|
||||
2. Extract embedded images via `extract_pdf_images` (task 02).
|
||||
3. Create the PDF document row with the extracted text as `content`, the file's sha256 (over the text) as `content_hash`, and the title from the PDF's metadata (if available) or the file stem.
|
||||
4. Chunk and embed the text (task 03's chunker handles `.pdf`).
|
||||
5. Generate a lite summary of the PDF content.
|
||||
6. For each embedded image:
|
||||
- If the image extension matches `BOR_IMAGE_EXTENSIONS` and `BOR_IMAGES` is on: create a standalone image document (reuse `_index_image_file`'s logic, but the image bytes come from the PDF, not a file on disk). The image's `path` is `<pdf_source>/<pdf_path>#page=<N>#img=<index>` (a synthetic path that uniquely identifies the embedded image).
|
||||
- If the image extension doesn't match or `BOR_IMAGES` is off: log a warning and skip the image.
|
||||
- The embedded image's description becomes the image doc's content+summary (the phase-122 pipeline).
|
||||
7. The PDF document's content includes inline references to embedded images: after each page's text, append `[Embedded image: <image_type> on page <N>]` (a placeholder that the LLM can use to know images exist, even if they're not described).
|
||||
- **PDF in archives (task 05):** PDFs inside uploaded archives should be indexed too. The archive unpack (`app/rag/archive_upload.py`) already extracts all files matching the import extensions. Add `pdf` to the archive's accepted file types (archives already unpack everything; the extension filter in `iter_importable_files` handles the rest).
|
||||
- **NOT touched:** the `BOR_IMAGES` toggle (PDF embedded images reuse the existing image pipeline, gated by it), the `show_image` tool (unchanged), the image serve route (unchanged), the document viewer (PDFs are served as text content, not as binary PDFs — the viewer renders the extracted text), and the prune logic (unchanged).
|
||||
- **Locked assumptions:** **A1** — `pymupdf` is the PDF parser (text + image extraction). **A2** — one document per PDF (not per page); pages are separated by `---PAGE-N---` markers. **A3** — embedded images become standalone image documents (reusing the phase-122 pipeline). **A4** — PDFs are served as extracted text in the document viewer (not as binary PDFs).
|
||||
|
||||
## Tasks
|
||||
1. `01_pdf_dependency.md` — add `pymupdf` to `pyproject.toml`.
|
||||
2. `02_pdf_parser.md` — `app/rag/pdf.py`: text extraction, image extraction, MIME mapping.
|
||||
3. `03_pdf_chunking.md` — extend the chunker to handle `.pdf` files.
|
||||
4. `04_pdf_indexing.md` — `_index_pdf_file` in the importer: text + embedded images + summary.
|
||||
5. `05_pdf_archive_and_tests.md` — PDFs in archives, unit + integration + E2E tests.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_pdf.py` (new, task 05) — `extract_pdf_text` (multi-page PDF → text with page separators; single-page PDF → text without extra separators; empty PDF → empty string); `extract_pdf_images` (PDF with embedded images → list of `PdfImage` with correct page/index/size/data/mime; PDF with no images → empty list); `_mime_from_image_info` (known types → correct MIME; unknown type → fallback).
|
||||
- Integration: `tests/integration/test_importer.py` (extend) — seed a source with a PDF file → sync → the PDF document has extracted text, chunks, and embedding; embedded images (if `BOR_IMAGES=true`) become image documents; a PDF without embedded images → no image docs.
|
||||
- E2E: `tests/e2e/test_pdf_support.py` (new, task 05) — isolated run: upload a PDF via the admin upload flow → sync → the PDF appears on the Sources page → open the document viewer → the extracted text renders; ask a question the PDF answers → the chat grounds on the PDF content.
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] A PDF file in a git/local source or uploaded archive becomes a document with extracted text, chunks, embedding, and summary.
|
||||
- [ ] With `BOR_IMAGES=true`: embedded images in the PDF become standalone image documents (described by the vision model).
|
||||
- [ ] With `BOR_IMAGES=false`: embedded images are skipped (logged), the PDF text is indexed normally.
|
||||
- [ ] The PDF renders as extracted text in the document viewer.
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate.
|
||||
|
||||
## Locked decisions
|
||||
- **A1 — `pymupdf` (PyMuPDF) is the PDF parser; it handles both text and embedded image extraction (owner-confirmed 2026-09-26, roadmap confirmation).**
|
||||
- **A2 — one document per PDF; pages are separated by `---PAGE-N---` markers in the content (owner-confirmed 2026-09-26, roadmap confirmation).**
|
||||
- **A3 — embedded images become standalone image documents, reusing the phase-122 pipeline (gated by `BOR_IMAGES`) (owner-confirmed 2026-09-26, roadmap confirmation).**
|
||||
- **A4 — PDFs are served as extracted text in the document viewer (not as binary PDFs) (owner-confirmed 2026-09-26, roadmap confirmation).**
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add app/ pyproject.toml tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(import): add PDF support — text extraction, embedded images, chunking"
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# Task 01 — Add `pymupdf` dependency
|
||||
|
||||
**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — the `pymupdf` dependency
|
||||
|
||||
## Objective
|
||||
Add `pymupdf` to `pyproject.toml` dependencies. PyMuPDF provides both PDF text extraction and embedded image extraction via the `fitz` module.
|
||||
|
||||
## Work
|
||||
1. `pyproject.toml` — add `pymupdf>=1.24,<2.0` to the `dependencies` list (after the existing dependencies, before the dev group). Version pin: `1.24` is a stable release with good text and image extraction support; `<2.0` follows the project's convention of pinning major versions.
|
||||
2. ASSUMPTION: none. `pymupdf` is a pure-Python package with optional C extensions (the binary wheels are available for common platforms).
|
||||
|
||||
## Testing & Quality
|
||||
- No code tests needed — this is a dependency addition.
|
||||
- Verify: `uv sync` completes without errors; `import fitz` works in a Python shell.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv sync` completes successfully.
|
||||
- [ ] `import fitz` works; `fitz.VersionBind` shows the installed version.
|
||||
- [ ] `uv run pytest` still green (no regression from the dependency addition).
|
||||
@@ -0,0 +1,173 @@
|
||||
# Task 02 — PDF parser module: `app/rag/pdf.py`
|
||||
|
||||
**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — text extraction, image extraction, MIME mapping
|
||||
|
||||
## Objective
|
||||
A new `app/rag/pdf.py` module with pure functions for PDF text extraction, embedded image extraction, and MIME type mapping. All functions are I/O-bound only (read a PDF file, return parsed data) — fully unit-testable with temp PDF fixtures.
|
||||
|
||||
## Work
|
||||
1. `app/rag/pdf.py` — new module:
|
||||
```python
|
||||
"""PDF parsing utilities (phase 126).
|
||||
|
||||
Pure functions for extracting text and embedded images from PDF files
|
||||
using PyMuPDF (the ``fitz`` module).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # pymupdf
|
||||
|
||||
logger = logging.getLogger("app.pdf")
|
||||
|
||||
#: Page separator used in extracted text (chunker splits on this).
|
||||
PAGE_SEPARATOR = "---PAGE-{page}---"
|
||||
|
||||
@dataclass
|
||||
class PdfImage:
|
||||
"""One embedded image from a PDF page."""
|
||||
page: int #: 1-indexed page number
|
||||
index: int #: image index on the page (0-based, from fitz)
|
||||
width: int
|
||||
height: int
|
||||
data: bytes #: raw image bytes
|
||||
mime: str #: MIME type (e.g., "image/png")
|
||||
|
||||
#: Mapping from PyMuPDF image type codes to MIME types.
|
||||
#: Type codes: 2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP (per fitz docs).
|
||||
_TYPE_TO_MIME: dict[int, str] = {
|
||||
2: "image/jpeg",
|
||||
3: "image/png",
|
||||
4: "image/gif",
|
||||
8: "image/tiff",
|
||||
9: "image/webp",
|
||||
}
|
||||
|
||||
def _mime_from_image_info(info: dict) -> str:
|
||||
"""Map a PyMuPDF image info dict to a MIME type.
|
||||
|
||||
Falls back to ``application/octet-stream`` for unknown types.
|
||||
"""
|
||||
# Try the 'ext' key first (file extension), then the 'type' code.
|
||||
ext = info.get("ext", "").lower()
|
||||
if ext == "png":
|
||||
return "image/png"
|
||||
if ext in ("jpg", "jpeg"):
|
||||
return "image/jpeg"
|
||||
if ext == "gif":
|
||||
return "image/gif"
|
||||
if ext == "webp":
|
||||
return "image/webp"
|
||||
if ext == "tiff" or ext == "tif":
|
||||
return "image/tiff"
|
||||
# Fallback to type code.
|
||||
type_code = info.get("type")
|
||||
if type_code is not None:
|
||||
return _TYPE_TO_MIME.get(type_code, "application/octet-stream")
|
||||
return "application/octet-stream"
|
||||
|
||||
def extract_pdf_text(pdf_path: Path) -> str:
|
||||
"""Extract text from all pages of a PDF, joined with page separators.
|
||||
|
||||
Returns the full text with ``---PAGE-N---`` separators between pages.
|
||||
Returns an empty string if the PDF has no text or cannot be opened.
|
||||
"""
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception as e:
|
||||
logger.warning("pdf: could not open %s — %s", pdf_path, e)
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
text = page.get_text("text").strip()
|
||||
if text:
|
||||
parts.append(f"{PAGE_SEPARATOR.format(page=page_num + 1)}\n{text}")
|
||||
else:
|
||||
# Even empty pages get a separator (for chunker consistency).
|
||||
parts.append(f"{PAGE_SEPARATOR.format(page=page_num + 1)}")
|
||||
doc.close()
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def extract_pdf_images(pdf_path: Path) -> list[PdfImage]:
|
||||
"""Extract all embedded images from a PDF.
|
||||
|
||||
Returns a list of ``PdfImage`` dataclasses, one per embedded image.
|
||||
Images are extracted in page order, then by their index on the page.
|
||||
Returns an empty list if the PDF has no embedded images or cannot
|
||||
be opened.
|
||||
"""
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception as e:
|
||||
logger.warning("pdf: could not open %s for image extraction — %s", pdf_path, e)
|
||||
return []
|
||||
|
||||
images: list[PdfImage] = []
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
image_list = page.get_images(full=True)
|
||||
for img_index, img_info in enumerate(image_list):
|
||||
try:
|
||||
xref = img_info[0]
|
||||
# Get the raw image bytes.
|
||||
pix = fitz.Pixmap(doc, xref)
|
||||
# Convert to RGB if needed (CMYK → RGB).
|
||||
if pix.n - pix.alpha > 3:
|
||||
pix = fitz.Pixmap(fitz.csRGB, pix)
|
||||
data = pix.tobytes("png") if pix.n == 4 else pix.tobytes("jpeg")
|
||||
# Determine the best bytes representation.
|
||||
# If the image is grayscale or has alpha, use PNG.
|
||||
# Otherwise, use JPEG for smaller size.
|
||||
if pix.alpha > 0 or pix.n <= 2:
|
||||
data = pix.tobytes("png")
|
||||
mime = "image/png"
|
||||
else:
|
||||
data = pix.tobytes("jpeg")
|
||||
mime = "image/jpeg"
|
||||
# Fallback: if tobytes fails, use the raw data.
|
||||
if not data:
|
||||
data = pix.tobytes()
|
||||
mime = _mime_from_image_info(img_info)
|
||||
|
||||
images.append(PdfImage(
|
||||
page=page_num + 1,
|
||||
index=img_index,
|
||||
width=pix.width,
|
||||
height=pix.height,
|
||||
data=data,
|
||||
mime=mime,
|
||||
))
|
||||
pix = None # free the pixmap
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"pdf: could not extract image page=%d index=%d — %s",
|
||||
page_num + 1, img_index, e,
|
||||
)
|
||||
doc.close()
|
||||
return images
|
||||
```
|
||||
2. ASSUMPTION: none. The module uses only `fitz` (from `pymupdf`) and stdlib.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_pdf.py` (task 05) — create temp PDF fixtures using `fitz` itself:
|
||||
- A single-page PDF with text → `extract_pdf_text` returns the text with one page separator.
|
||||
- A multi-page PDF → text with multiple page separators.
|
||||
- A PDF with no text (image-only) → empty string.
|
||||
- A PDF with embedded images → `extract_pdf_images` returns the correct list.
|
||||
- A PDF with no embedded images → empty list.
|
||||
- `_mime_from_image_info` (known types → correct MIME; unknown → fallback).
|
||||
- Error handling: non-existent PDF → empty string / empty list (no exception).
|
||||
- Coverage: **>90%** on the new module.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `extract_pdf_text` returns correct text with page separators for a multi-page PDF.
|
||||
- [ ] `extract_pdf_images` returns correct image data for a PDF with embedded images.
|
||||
- [ ] `_mime_from_image_info` maps known type codes correctly; unknown types get the fallback.
|
||||
- [ ] Non-existent or unreadable PDFs return empty results (no exceptions).
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Task 03 — PDF chunking: extend the chunker for `.pdf` files
|
||||
|
||||
**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — extend the chunker to handle `.pdf` files
|
||||
|
||||
## Objective
|
||||
Extend `app/rag/chunker.py`'s `chunk_document` dispatch to handle `.pdf` files. PDF chunks are split on page separators (`---PAGE-N---`), with each page as a logical unit.
|
||||
|
||||
## Work
|
||||
1. `app/rag/chunker.py` — extend the `chunk_document` dispatch:
|
||||
- Add `.pdf` to the suffix check that dispatches to the paragraph packer (PDFs don't have a format-specific splitter like markdown's heading-based splitting).
|
||||
- The PDF chunker uses the same `chunk_text` function as `txt` (paragraph packing), but with page-aware splitting:
|
||||
```python
|
||||
def _chunk_pdf(content: str, rel: str, target: int, overlap: int) -> list[str]:
|
||||
"""Chunk PDF content, respecting page separators.
|
||||
|
||||
Pages are the primary split boundary. Each page's text is packed
|
||||
into chunks of ``target`` chars with ``overlap`` chars carry-over.
|
||||
"""
|
||||
# Split on page separators.
|
||||
pages = re.split(r'---PAGE-\d+---', content)
|
||||
# pages[0] is empty (content starts with the separator).
|
||||
result: list[str] = []
|
||||
for i, page_text in enumerate(pages[1:], start=1):
|
||||
page_text = page_text.strip()
|
||||
if not page_text:
|
||||
continue
|
||||
# Prepend the page anchor for readability.
|
||||
anchored = f"Page {i}:\n{page_text}"
|
||||
# Chunk the anchored text using paragraph packing.
|
||||
chunks = chunk_text(anchored, target=target, overlap=overlap)
|
||||
result.extend(chunks)
|
||||
return result
|
||||
```
|
||||
- Register `.pdf` in the `chunk_document` dispatch table:
|
||||
```python
|
||||
CHUNKERS: dict[str, Callable] = {
|
||||
...
|
||||
".pdf": _chunk_pdf,
|
||||
}
|
||||
```
|
||||
2. ASSUMPTION: none. The PDF chunker reuses the existing `chunk_text` paragraph packer — it only adds page-aware splitting.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_chunker.py` (extend) — add PDF-specific tests:
|
||||
- A single-page PDF → one or more chunks with "Page 1:" prefix.
|
||||
- A multi-page PDF → chunks with correct "Page N:" prefixes.
|
||||
- An empty PDF → empty list.
|
||||
- A very large page (>HARD_MAX_CHARS) → split by line (the `chunk_text` behavior).
|
||||
- Verify that page separators in the input are removed from the output chunks.
|
||||
- Coverage: **>90%** on the touched code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `chunk_document` dispatches `.pdf` files to the page-aware chunker.
|
||||
- [ ] Chunks have "Page N:" prefixes and no page separator markers.
|
||||
- [ ] Multi-page PDFs produce chunks with correct page anchors.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,262 @@
|
||||
# Task 04 — PDF indexing: `_index_pdf_file` in the importer
|
||||
|
||||
**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — text + embedded images + summary
|
||||
|
||||
## Objective
|
||||
Add PDF indexing to the importer: `_index_pdf_file` extracts text, creates the document, chunks + embeds, generates a summary, and handles embedded images.
|
||||
|
||||
## Work
|
||||
1. `app/rag/importer.py` — add the import:
|
||||
```python
|
||||
from app.rag.pdf import extract_pdf_text, extract_pdf_images, PdfImage
|
||||
```
|
||||
2. `app/rag/importer.py` — in `_index_file`, add the PDF branch (AFTER the image branch, BEFORE the text branch):
|
||||
```python
|
||||
# Phase 126: PDF files take the PDF index path.
|
||||
if full_path.suffix.lower() == ".pdf":
|
||||
return await _index_pdf_file(
|
||||
session, source=source, rel=rel, full_path=full_path,
|
||||
llm=llm, summary=summary, raw_date=raw_date,
|
||||
)
|
||||
```
|
||||
3. `app/rag/importer.py` — new `_index_pdf_file` function (follow the `_index_file` text branch pattern):
|
||||
```python
|
||||
async def _index_pdf_file(
|
||||
session: Session,
|
||||
*,
|
||||
source: str,
|
||||
rel: str,
|
||||
full_path: Path,
|
||||
llm: Embedder,
|
||||
summary: ImportSummary,
|
||||
raw_date: datetime | None = None,
|
||||
) -> None:
|
||||
"""Index one PDF file: extract text, create doc, chunk + embed,
|
||||
summarize, and extract embedded images.
|
||||
"""
|
||||
settings = llm.settings
|
||||
# Extract text.
|
||||
content = extract_pdf_text(full_path)
|
||||
if not content:
|
||||
# No text in the PDF — still create a doc (it might have
|
||||
# embedded images), but with empty content.
|
||||
logger.warning("import: PDF has no extractable text source=%s path=%s", source, rel)
|
||||
|
||||
# Digest over the extracted text (content identity).
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest() if content else hashlib.sha256(b"").hexdigest()
|
||||
|
||||
# Get PDF metadata for the title.
|
||||
title = _extract_pdf_title(full_path, fallback=full_path.stem)
|
||||
|
||||
# Date: use the file's mtime (D2 fallback) — PDFs don't have
|
||||
# standard creation dates accessible via the filesystem.
|
||||
if raw_date is None:
|
||||
raw_date = file_mtime_datetime(full_path)
|
||||
|
||||
# Upsert the document row.
|
||||
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
|
||||
if doc is None:
|
||||
doc = Document(
|
||||
source=source,
|
||||
path=rel,
|
||||
full_path=str(full_path),
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash=digest,
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=normalize_doc_date(raw_date),
|
||||
)
|
||||
session.add(doc)
|
||||
else:
|
||||
# Check for content change.
|
||||
if doc.content_hash == digest and doc.content == content:
|
||||
summary.unchanged += 1
|
||||
logger.info("import: unchanged source=%s path=%s", source, rel)
|
||||
# Summary backfill (phase 118, A2).
|
||||
if doc.summary is None:
|
||||
await _store_summary(
|
||||
session, doc=doc, source=source, rel=rel,
|
||||
content=content, llm=llm, summary=summary, backfill=True,
|
||||
)
|
||||
if doc.created_at_manual:
|
||||
return
|
||||
target = normalize_doc_date(raw_date)
|
||||
if target != doc.created_at:
|
||||
doc.created_at = target
|
||||
session.commit()
|
||||
summary.dates_updated += 1
|
||||
return
|
||||
|
||||
doc.full_path = str(full_path)
|
||||
doc.title = title
|
||||
doc.content = content
|
||||
doc.content_hash = digest
|
||||
doc.indexed_at = datetime.now(UTC)
|
||||
doc.created_at = normalize_doc_date(raw_date)
|
||||
doc.created_at_manual = False
|
||||
|
||||
session.flush()
|
||||
|
||||
# Chunk and embed.
|
||||
if content:
|
||||
target = max(400, settings.chunk_target_chars)
|
||||
while True:
|
||||
chunks_text = chunk_document(content, rel, target, settings.chunk_overlap_chars)
|
||||
doc.chunks = [
|
||||
Chunk(document_id=doc.id, position=i, content=c)
|
||||
for i, c in enumerate(chunks_text)
|
||||
]
|
||||
session.flush()
|
||||
if not doc.chunks:
|
||||
break
|
||||
try:
|
||||
vectors = await llm.embed([c.content for c in doc.chunks])
|
||||
for row, vec in zip(doc.chunks, vectors, strict=True):
|
||||
row.embedding = vec
|
||||
break
|
||||
except EmbeddingError as e:
|
||||
if "token cap" not in str(e) or target <= 400:
|
||||
raise
|
||||
logger.info(
|
||||
"import: re-chunking PDF at %d chars after endpoint token cap: %s",
|
||||
target // 2, rel,
|
||||
)
|
||||
target //= 2
|
||||
|
||||
session.commit()
|
||||
|
||||
# Verb count.
|
||||
if doc.content_hash == digest and doc.content == content and doc.id in [d.id for d in []]:
|
||||
# This is a bit awkward — we need to track whether this was
|
||||
# new or updated. Use the session's identity map.
|
||||
pass # handled by the unchanged path above
|
||||
else:
|
||||
summary.added += 1 if doc.id not in session.identity_map else 0
|
||||
# Actually, let's just use the verb tracking from _index_file.
|
||||
# The simplest approach: track added/updated before the upsert.
|
||||
|
||||
# Generate summary (phase 30, A2).
|
||||
await _store_summary(
|
||||
session, doc=doc, source=source, rel=rel,
|
||||
content=content, llm=llm, summary=summary, backfill=False,
|
||||
)
|
||||
|
||||
# Extract and index embedded images.
|
||||
await _index_pdf_embedded_images(
|
||||
session, source=source, rel=rel, full_path=full_path,
|
||||
llm=llm, summary=summary, raw_date=raw_date,
|
||||
)
|
||||
|
||||
logger.info("import: %s source=%s path=%s chunks=%d",
|
||||
"added" if newly_created else "updated", source, rel, len(doc.chunks))
|
||||
```
|
||||
|
||||
Wait — the above is getting complex. Let me simplify. The key insight is that `_index_pdf_file` should follow the same pattern as `_index_file` but with PDF-specific extraction. Let me refactor.
|
||||
|
||||
Actually, let me simplify this significantly. The `_index_pdf_file` function should:
|
||||
1. Extract text
|
||||
2. Upsert the document
|
||||
3. Chunk + embed
|
||||
4. Generate summary
|
||||
5. Extract embedded images
|
||||
6. Log the result
|
||||
|
||||
The unchanged/hash path is the same as `_index_file` — check the hash, skip if unchanged.
|
||||
|
||||
4. `app/rag/importer.py` — `_extract_pdf_title(full_path, fallback) -> str`:
|
||||
- Open the PDF with `fitz`, try to get the metadata title (`doc.metadata.get("title", "")`).
|
||||
- If no title in metadata, return the file stem (the fallback).
|
||||
- Strip whitespace and limit to 500 chars (the `Document.title` column width).
|
||||
|
||||
5. `app/rag/importer.py` — `_index_pdf_embedded_images(session, source, rel, full_path, llm, summary, raw_date)`:
|
||||
- Extract embedded images via `extract_pdf_images(full_path)`.
|
||||
- For each image:
|
||||
- If `settings.images` is False → skip (log a debug message).
|
||||
- If the image extension doesn't match `settings.image_extension_set` → skip (log a warning).
|
||||
- Create a synthetic path for the embedded image: `<rel>#page=<N>#img=<index>.<ext>` (e.g., `report.pdf#page=3#img=0.png`).
|
||||
- Call `_index_image_file` with the synthetic path and the image bytes (the `_index_image_file` function expects a `full_path` on disk — we need to either write a temp file or refactor `_index_image_file` to accept bytes directly).
|
||||
- **Alternative approach:** instead of calling `_index_image_file`, handle the embedded image inline: copy the bytes to `settings.image_dir`, describe them via `describe_image`, create the image doc row, chunk + embed the description. This avoids the temp file and the `_index_image_file` refactoring.
|
||||
|
||||
Let me go with the inline approach — it's simpler and avoids refactoring `_index_image_file`:
|
||||
```python
|
||||
async def _index_pdf_embedded_images(...):
|
||||
images = extract_pdf_images(full_path)
|
||||
for img in images:
|
||||
ext = _ext_from_mime(img.mime) # "image/png" → ".png"
|
||||
if ext not in settings.image_extension_set:
|
||||
logger.warning("import: PDF embedded image has unsupported extension: %s", img.mime)
|
||||
continue
|
||||
|
||||
# Write to image_dir.
|
||||
img_id = uuid.uuid4()
|
||||
img_filename = f"{img_id}{ext}"
|
||||
img_dir_path = Path(settings.image_dir)
|
||||
img_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
img_path = str(img_dir_path / img_filename)
|
||||
Path(img_path).write_bytes(img.data)
|
||||
|
||||
# Describe the image.
|
||||
description = await _describe_or_skip(
|
||||
llm, data=img.data, source=source, rel=f"{rel}#page={img.page}#img={img.index}",
|
||||
mime=img.mime, full_path=Path(img_path),
|
||||
)
|
||||
if description is None:
|
||||
summary.images_failed += 1
|
||||
Path(img_path).unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
# Create the image doc row.
|
||||
img_doc = Document(
|
||||
source=source,
|
||||
path=f"{rel}#page={img.page}#img={img.index}{ext}",
|
||||
full_path=img_path,
|
||||
title=f"Embedded image from {full_path.stem} page {img.page}",
|
||||
content=description,
|
||||
content_hash=hashlib.sha256(img.data).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=normalize_doc_date(raw_date),
|
||||
is_image=True,
|
||||
image_path=img_path,
|
||||
)
|
||||
session.add(img_doc)
|
||||
session.flush()
|
||||
|
||||
# Chunk + embed the description.
|
||||
chunks_text = chunk_document(description, img_doc.path,
|
||||
settings.chunk_target_chars, settings.chunk_overlap_chars)
|
||||
img_doc.chunks = [
|
||||
Chunk(document_id=img_doc.id, position=i, content=c)
|
||||
for i, c in enumerate(chunks_text)
|
||||
]
|
||||
session.flush()
|
||||
try:
|
||||
vectors = await llm.embed([c.content for c in img_doc.chunks])
|
||||
for row, vec in zip(img_doc.chunks, vectors, strict=True):
|
||||
row.embedding = vec
|
||||
except EmbeddingError:
|
||||
pass # fail soft, like the text path
|
||||
|
||||
# Summary = description (phase-122 convention for image docs).
|
||||
img_doc.summary = description
|
||||
is_summary_chunk = Chunk(
|
||||
document_id=img_doc.id, position=-1, content=description,
|
||||
is_summary=True,
|
||||
)
|
||||
img_doc.chunks.append(is_summary_chunk)
|
||||
session.flush()
|
||||
|
||||
session.commit()
|
||||
```
|
||||
|
||||
6. ASSUMPTION: none. The embedded image indexing reuses the same patterns as standalone image indexing (phase 122).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_pdf.py` (extend) — `_extract_pdf_title` (PDF with metadata title → returns it; PDF without → returns fallback).
|
||||
- Integration: `tests/integration/test_importer.py` (extend) — seed a source with a PDF file → sync → the PDF document has extracted text, chunks, and embedding; embedded images (if `BOR_IMAGES=true`) become image documents.
|
||||
- Coverage: **>90%** on the touched modules.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] A PDF file in a source → the PDF document has extracted text, chunks, embedding, and summary.
|
||||
- [ ] With `BOR_IMAGES=true`: embedded images become image documents.
|
||||
- [ ] With `BOR_IMAGES=false`: embedded images are skipped.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task 05 — PDFs in archives and full test suite
|
||||
|
||||
**Phase:** `126_pdf_support` · **Source:** `00_phase.md` — PDFs in archives, unit + integration + E2E tests
|
||||
|
||||
## Objective
|
||||
Ensure PDFs inside uploaded archives are indexed, and write the full test suite (unit, integration, E2E).
|
||||
|
||||
## Work
|
||||
1. `app/rag/archive_upload.py` — no code changes needed. Archives are unpacked into `upload_dir`, and the extension walk (`iter_importable_files`) already filters by `import_extension_set`. Adding `pdf` to the set means PDFs in archives are automatically picked up. Verify that the archive unpack doesn't skip `.pdf` files (it shouldn't — archives unpack all files).
|
||||
|
||||
2. `app/config.py` — no code changes needed. `pdf` is added to `BOR_IMPORT_EXTENSIONS` by the user (the config already accepts any well-formed extension via `import_extensions`). Document it in `.env.example` alongside the existing extension list:
|
||||
```
|
||||
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,pdf
|
||||
```
|
||||
|
||||
3. `tests/unit/test_pdf.py` (new) — comprehensive unit tests:
|
||||
- Create temp PDF fixtures using `fitz`:
|
||||
- Single-page PDF with text → `extract_pdf_text` returns text with one page separator.
|
||||
- Multi-page PDF (3 pages) → text with 3 page separators.
|
||||
- PDF with no text (blank pages) → empty string.
|
||||
- PDF with embedded images (draw shapes, add text) → `extract_pdf_images` returns the images.
|
||||
- PDF with no embedded images → empty list.
|
||||
- `_mime_from_image_info` (all known type codes → correct MIME; unknown → fallback).
|
||||
- `_extract_pdf_title` (PDF with metadata → returns title; PDF without → returns fallback).
|
||||
- Error handling: non-existent PDF → empty results.
|
||||
- `tests/unit/test_chunker.py` (extend) — PDF-specific chunking tests:
|
||||
- Single-page PDF → correct chunks with "Page 1:" prefix.
|
||||
- Multi-page PDF → correct chunks with "Page N:" prefixes.
|
||||
- Empty PDF → empty list.
|
||||
- Large page → split by line.
|
||||
|
||||
4. `tests/integration/test_importer.py` (extend) — integration tests:
|
||||
- Seed a source with a PDF file (create a temp PDF fixture) → sync → the PDF document has extracted text, chunks, and embedding.
|
||||
- A PDF with embedded images + `BOR_IMAGES=true` → embedded images become image documents.
|
||||
- A PDF with embedded images + `BOR_IMAGES=false` → embedded images are skipped.
|
||||
- A PDF with no text → document is created with empty content (no chunks, no embedding).
|
||||
- A PDF referenced in a markdown file → the markdown doc stores the reference info (phase 125 enrichment path).
|
||||
|
||||
5. `tests/e2e/test_pdf_support.py` (new) — isolated E2E suite:
|
||||
- Module apps: one app with `BOR_IMAGES=true`, one with `BOR_IMAGES=false`.
|
||||
- KB fixture: create a test PDF with text and an embedded image (using `fitz` in the test setup). Upload via the admin upload flow → sync.
|
||||
- Verify: the PDF appears on the Sources page → open the document viewer → the extracted text renders → ask a question the PDF answers → the chat grounds on the PDF content.
|
||||
- With `BOR_IMAGES=true`: the embedded image appears as a source chip (if retrieved).
|
||||
- With `BOR_IMAGES=false`: no image chip, the PDF text is indexed normally.
|
||||
- Run in isolation: `uv run pytest tests/e2e/test_pdf_support.py -v --no-cov`.
|
||||
|
||||
6. Run the full test suite: `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Testing & Quality
|
||||
- All new logic covered by unit tests (PDF parsing, chunking, title extraction).
|
||||
- Integration tests cover the full import flow with seeded PDF data.
|
||||
- E2E tests cover the real user path: upload → sync → view → ask.
|
||||
- No regression in existing tests (run `test_image_documents.py` and `test_image_anchor_context.py` to verify).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_pdf.py -v --no-cov` passes.
|
||||
- [ ] `uv run pytest tests/integration/test_importer.py -v --no-cov` passes (extended).
|
||||
- [ ] `uv run pytest tests/e2e/test_pdf_support.py -v --no-cov` passes in isolation.
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%; `ruff` + `pyright` clean.
|
||||
- [ ] No regression in existing E2E suites.
|
||||
Reference in New Issue
Block a user