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:
@@ -26,6 +26,11 @@ per-format policies:
|
||||
endpoint / j2** (A9 revised 2026-08-27) — quadlet unit files (TOML) and
|
||||
Jinja templates; plain-text paragraph packing (``chunk_text``) — no
|
||||
format-specific splitter (owner decision).
|
||||
* **pdf** (phase 126, LOCKED A2) — page-aware: the extracted text's
|
||||
``---PAGE-N---`` markers are the primary split boundary (one logical
|
||||
unit per text-bearing page; a long page sub-splits through the
|
||||
paragraph packer), every chunk keeps its ``Page N:`` anchor, and the
|
||||
markers themselves never appear in a chunk.
|
||||
|
||||
Every format honors :data:`HARD_MAX_CHARS` (1200 — the aipi ~1024-token
|
||||
request cap) and the target/overlap settings; oversized blocks are split
|
||||
@@ -324,6 +329,47 @@ def chunk_text(content: str, target_chars: int = 2000, overlap_chars: int = 200)
|
||||
return _pack_blocks(_paragraph_blocks(lines, [False] * len(lines)), target, overlap)
|
||||
|
||||
|
||||
#: The page marker of the extracted PDF text (phase 126, A2 —
|
||||
#: ``app.rag.pdf.extract_pdf_text`` prefixes every text-bearing page
|
||||
#: block with it; the number is the 1-indexed page number).
|
||||
_PDF_PAGE_MARKER_RE = re.compile(r"---PAGE-(\d+)---")
|
||||
|
||||
|
||||
def _chunk_pdf(content: str, target_chars: int = 2000, overlap_chars: int = 200) -> list[str]:
|
||||
"""Page-aware chunking of one PDF's extracted text (phase 126, A2).
|
||||
|
||||
A page is the primary split boundary: each ``---PAGE-N---`` block is
|
||||
packed on its own (the plain-text paragraph packer — PDFs have no
|
||||
code fences, so paragraph boundaries = blank lines) into chunks of
|
||||
at most ``target_chars`` with the ``overlap_chars`` carry-over, and
|
||||
every chunk of the page is prefixed with the ``Page N:`` anchor so a
|
||||
hit is readable in context. The marker number is the page number
|
||||
(blank pages carry no marker — the numbering follows the markers,
|
||||
not the chunk count). The markers themselves never appear in a
|
||||
chunk, and the anchor-aware budget keeps the anchored chunk under
|
||||
the target — :data:`HARD_MAX_CHARS` included.
|
||||
"""
|
||||
target, overlap = _normalize_target_overlap(target_chars, overlap_chars)
|
||||
# re.split with a capturing group keeps the markers: the pieces are
|
||||
# [before-first, page, text, page, text, …].
|
||||
pieces = _PDF_PAGE_MARKER_RE.split(content)
|
||||
chunks: list[str] = []
|
||||
for i in range(1, len(pieces) - 1, 2):
|
||||
page = int(pieces[i])
|
||||
text = pieces[i + 1].strip()
|
||||
if not text:
|
||||
continue
|
||||
anchor = f"Page {page}:"
|
||||
# Budget the anchor out of the page's packing target so the
|
||||
# anchored chunk cannot exceed ``target`` (the markdown
|
||||
# section-anchor precedent, the ``budget`` rule).
|
||||
budget = max(1, target - len(anchor) - 1)
|
||||
chunks.extend(
|
||||
f"{anchor}\n{part}" for part in chunk_text(text, budget, overlap)
|
||||
)
|
||||
return chunks
|
||||
|
||||
|
||||
def _normalize_target_overlap(target_chars: int, overlap_chars: int) -> tuple[int, int]:
|
||||
"""Validate + clamp the size policy (shared by every format)."""
|
||||
if target_chars <= 0:
|
||||
@@ -358,6 +404,9 @@ _FORMAT_CHUNKERS = {
|
||||
".os": chunk_text,
|
||||
".endpoint": chunk_text,
|
||||
".j2": chunk_text,
|
||||
# Phase 126 (LOCKED A2): the page-aware PDF chunker — the extracted
|
||||
# text's ``---PAGE-N---`` markers are the primary split boundary.
|
||||
".pdf": _chunk_pdf,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,30 @@ summary (stored verbatim, no ``lite`` call, no pointer line), so the
|
||||
``is_summary`` position −1 chunk mirrors ``Document.summary``, which
|
||||
equals ``Document.content``.
|
||||
|
||||
PDF files (phase 126, LOCKED A1/A2/A3/A4): a ``.pdf`` file (the
|
||||
``pdf`` token of ``BOR_IMPORT_EXTENSIONS``) takes the PDF index path
|
||||
(:func:`_index_pdf_file` — the branch sits in :func:`_index_file`
|
||||
AFTER the image branch, BEFORE any text read — a PDF is never
|
||||
``read_text``-ed; the binary is owned by the phase-126 parser). The
|
||||
document's ``content`` is the extracted text (every text-bearing page
|
||||
prefixed with the ``---PAGE-N---`` marker — A2, one document per PDF),
|
||||
the digest is sha256 over that text (the digest rule is unchanged —
|
||||
content identity), the title is the PDF's embedded metadata title or
|
||||
the file stem, and the page-aware chunker (task 03) chunks it with a
|
||||
``Page N:`` anchor. With the phase-122 ``images`` toggle ON, the
|
||||
embedded images join the image pipeline (A3, :func:
|
||||
`_index_pdf_embedded_images`): each is described through the SAME
|
||||
``_describe_or_skip`` seam (the bytes as a data URL), copied to
|
||||
``settings.image_dir`` (the copy is written only AFTER a successful
|
||||
description — a failure never leaves an orphan), and indexed as a
|
||||
standalone ``is_image`` document under the synthetic path
|
||||
``<pdf path>#page=<N>#img=<index>.<ext>`` (the description IS the
|
||||
content and summary — the phase-122 convention). Toggle OFF → the
|
||||
embedded images are skipped (debug-logged) and the prune guard keeps
|
||||
any existing image docs; an unsupported image mime (outside
|
||||
``BOR_IMAGE_EXTENSIONS``) is skipped with a warning. A PDF's viewer
|
||||
presentation is its extracted text (A4 — nothing binary is served).
|
||||
|
||||
Image-anchor context (phase 125, task 03, LOCKED A1/A2/A3): BEFORE the
|
||||
description, the same source's importable markdown files are scanned for
|
||||
```` references that name the image (the task-02
|
||||
@@ -135,6 +159,7 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Protocol
|
||||
|
||||
import pymupdf as fitz # PyMuPDF (phase 126, locked A1); `fitz` = the stable API name
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -145,6 +170,7 @@ from app.rag.chunker import chunk_document, extract_title
|
||||
from app.rag.doc_dates import file_mtime_datetime, normalize_doc_date
|
||||
from app.rag.llm import EmbeddingError, LLMError
|
||||
from app.rag.md_images import ImageRef, extract_image_refs
|
||||
from app.rag.pdf import PdfImage, extract_pdf_images, extract_pdf_text
|
||||
from app.rag.summarizer import (
|
||||
IMAGE_FALLBACK_MIME,
|
||||
IMAGE_MIMES,
|
||||
@@ -652,6 +678,14 @@ async def _index_file(
|
||||
session, source=source, rel=rel, full_path=full_path, llm=llm,
|
||||
summary=summary, raw_date=raw_date,
|
||||
)
|
||||
# Phase 126: PDF files take the PDF index path — AFTER the image
|
||||
# branch, BEFORE any text read (a PDF is binary; ``extract_pdf_text``
|
||||
# owns it and ``read_text`` is never called for one).
|
||||
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,
|
||||
)
|
||||
content = full_path.read_text(encoding="utf-8", errors="replace").replace("\x00", "")
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
|
||||
@@ -855,6 +889,461 @@ async def _store_summary(
|
||||
logger.error("import: summary failed source=%s path=%s — %s", source, rel, e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF indexing (phase 126, LOCKED A1/A2/A3/A4).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The ``Document.title`` column width (``app.models``) — the metadata
|
||||
#: title is capped to it (an over-long PDF title must not blow up the
|
||||
#: upsert).
|
||||
_PDF_TITLE_MAX_CHARS = 500
|
||||
|
||||
#: MIME → extension for the phase-122 image family (phase 126, task 04)
|
||||
#: — the reverse of the ``app.rag.summarizer.IMAGE_MIMES`` values for the
|
||||
#: formats PyMuPDF reports. An unlisted mime takes ``.bin`` (never a
|
||||
#: member of ``BOR_IMAGE_EXTENSIONS`` — the image is skipped with a
|
||||
#: warning, the honest fail-soft outcome).
|
||||
_MIME_TO_EXT: dict[str, str] = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp",
|
||||
"image/tiff": ".tiff",
|
||||
}
|
||||
|
||||
|
||||
def _extract_pdf_title(full_path: Path, fallback: str) -> str:
|
||||
"""The title of one PDF document (phase 126, task 04).
|
||||
|
||||
The embedded metadata title (``fitz``'s ``doc.metadata["title"]``)
|
||||
— trimmed and capped at the :data:`_PDF_TITLE_MAX_CHARS` column
|
||||
width. An unreadable PDF, an absent metadata dict, or an
|
||||
empty/whitespace-only title falls back to *fallback* (the file stem
|
||||
— the non-markdown title rule; the metadata title is a convenience,
|
||||
never a requirement).
|
||||
"""
|
||||
try:
|
||||
doc = fitz.open(str(full_path))
|
||||
except Exception:
|
||||
return fallback
|
||||
with doc:
|
||||
meta = doc.metadata
|
||||
title = str(meta.get("title") or "").strip() if meta else ""
|
||||
if not title:
|
||||
return fallback
|
||||
return title[:_PDF_TITLE_MAX_CHARS]
|
||||
|
||||
|
||||
async def _index_pdf_file(
|
||||
session: Session,
|
||||
*,
|
||||
source: str,
|
||||
rel: str,
|
||||
full_path: Path,
|
||||
llm: Embedder,
|
||||
summary: ImportSummary,
|
||||
raw_date: datetime | None = None,
|
||||
) -> None:
|
||||
"""The phase-126 PDF branch of :func:`_index_file` — a PDF is
|
||||
indexed from its EXTRACTED TEXT (LOCKED A1/A2/A4), one transaction
|
||||
for the document, then the embedded images (A3):
|
||||
|
||||
* ``content`` is the parser's text — every text-bearing page
|
||||
prefixed with the ``---PAGE-N---`` marker (A2 — one document per
|
||||
PDF, the markers are the page anchors the page-aware chunker
|
||||
(task 03) splits on); an unopenable/empty PDF yields ``""``
|
||||
(still a document — it may carry embedded images — with no
|
||||
content chunks; a warning is the signal).
|
||||
* the sha256 digest is over the extracted text (the digest rule is
|
||||
content identity — the same text is the same document version;
|
||||
the empty case digests the empty byte string).
|
||||
* the title is the metadata title or the file stem
|
||||
(:func:`_extract_pdf_title`); the date follows the text path
|
||||
exactly (the phase-106 D2 mtime fallback — PDFs carry no
|
||||
filesystem-visible creation date — plus the D1 manual-date lock
|
||||
and the D4 refresh on the unchanged path).
|
||||
* chunk + embed runs through the shared
|
||||
:func:`_replace_content_chunks` helper (the endpoint token-cap
|
||||
retry lives there), and the phase-30/118 (A2) ``lite`` summary
|
||||
path runs on the text — PDFs are no more excluded than any other
|
||||
non-markdown format.
|
||||
* the unchanged path (same text digest) skips everything — no
|
||||
re-extract, no re-embed, no image pass — and runs the same
|
||||
backfill/manual-date/date-refresh dance as the text path.
|
||||
* THEN the embedded images: :func:`_index_pdf_embedded_images`
|
||||
(A3 — the phase-122 pipeline, gated by the ``images`` toggle).
|
||||
|
||||
``raw_date`` follows the text path: the caller's git last-commit
|
||||
date or ``None`` (the mtime fallback, read before the unchanged
|
||||
early-return — the unchanged path refreshes from the same source).
|
||||
"""
|
||||
# Extract the text (fail-soft: an unopenable PDF yields "" — the
|
||||
# document may still carry embedded images worth indexing).
|
||||
content = extract_pdf_text(full_path)
|
||||
if not content:
|
||||
logger.warning(
|
||||
"import: PDF has no extractable text source=%s path=%s", source, rel
|
||||
)
|
||||
# Content identity: the digest is over the extracted text (the
|
||||
# empty case is the empty byte string's digest).
|
||||
digest = (
|
||||
hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
if content
|
||||
else hashlib.sha256(b"").hexdigest()
|
||||
)
|
||||
title = _extract_pdf_title(full_path, fallback=full_path.stem)
|
||||
if raw_date is None:
|
||||
# D2 fallback (same as the text path): no source date in the
|
||||
# map → the file's mtime (one stat). Read before the unchanged
|
||||
# early-return — the unchanged path refreshes from it.
|
||||
raw_date = file_mtime_datetime(full_path)
|
||||
|
||||
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
|
||||
if doc is not None and doc.content_hash == digest:
|
||||
# Unchanged PDF (text digest) — the text path's unchanged branch,
|
||||
# unmodified in shape: no re-extract, no re-embed, no image pass.
|
||||
summary.unchanged += 1
|
||||
logger.info("import: unchanged source=%s path=%s", source, rel)
|
||||
# Phase 118 (A2): an unchanged doc whose summary is still NULL
|
||||
# gets the same best-effort summary pass (see the text path).
|
||||
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:
|
||||
# D1/D4: the owner's correction survives the sync — no write
|
||||
# at all (the text path's manual-date early-return).
|
||||
return
|
||||
# D4: the date refreshes on every sync and may go OLDER (no
|
||||
# monotonic guard); a date-only refresh counts unchanged.
|
||||
target = normalize_doc_date(raw_date)
|
||||
if target != doc.created_at:
|
||||
doc.created_at = target
|
||||
session.commit()
|
||||
summary.dates_updated += 1
|
||||
logger.info(
|
||||
"import: date-refreshed source=%s path=%s date=%s",
|
||||
source, rel, doc.created_at.isoformat(),
|
||||
)
|
||||
return
|
||||
|
||||
verb = "updated" if doc is not None else "added"
|
||||
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:
|
||||
doc.full_path = str(full_path)
|
||||
doc.title = title
|
||||
doc.content = content
|
||||
doc.content_hash = digest
|
||||
doc.indexed_at = datetime.now(UTC)
|
||||
# Phase 106 (D4): a content change is a new document version —
|
||||
# the date is re-sourced and a manual correction reset.
|
||||
doc.created_at = normalize_doc_date(raw_date)
|
||||
doc.created_at_manual = False
|
||||
|
||||
session.flush() # guarantees doc.id even for brand-new rows
|
||||
|
||||
# Phase 1+2 — the shared chunk/replace/embed pipeline (the page-
|
||||
# aware chunker dispatches on the ``.pdf`` suffix; empty content
|
||||
# clears the chunk rows and returns 0), committed atomically with
|
||||
# the document.
|
||||
chunks_n = await _replace_content_chunks(
|
||||
session, doc=doc, llm=llm, content=content, rel=rel
|
||||
)
|
||||
session.commit()
|
||||
|
||||
if verb == "added":
|
||||
summary.added += 1
|
||||
else:
|
||||
summary.updated += 1
|
||||
summary.chunks += chunks_n
|
||||
logger.info("import: %s source=%s path=%s chunks=%d", verb, source, rel, chunks_n)
|
||||
|
||||
# Phase 30; phase 118 (A2): the ``lite`` summary of the extracted
|
||||
# text (best-effort — a failure leaves the PDF indexed without a
|
||||
# summary, counted in ``summary_errors``).
|
||||
await _store_summary(
|
||||
session, doc=doc, source=source, rel=rel, content=content, llm=llm, summary=summary
|
||||
)
|
||||
|
||||
# Phase 126 (A3): the embedded images join the phase-122 pipeline
|
||||
# (gated by the ``images`` toggle — skipped, debug-logged, while it
|
||||
# is off). Runs after the document commit, so a per-image failure
|
||||
# never loses the PDF's text index.
|
||||
await _index_pdf_embedded_images(
|
||||
session, source=source, rel=rel, full_path=full_path,
|
||||
llm=llm, summary=summary, raw_date=raw_date,
|
||||
)
|
||||
|
||||
|
||||
async def _index_pdf_embedded_images(
|
||||
session: Session,
|
||||
*,
|
||||
source: str,
|
||||
rel: str,
|
||||
full_path: Path,
|
||||
llm: Embedder,
|
||||
summary: ImportSummary,
|
||||
raw_date: datetime,
|
||||
) -> None:
|
||||
"""The phase-126 (A3) embedded-image pass — one call per (re)indexed
|
||||
PDF, after its document commit.
|
||||
|
||||
Each embedded image (page order, 0-based index on the page — the
|
||||
parser's order) is indexed as a STANDALONE image document through
|
||||
the phase-122 mechanics, inline (no temp file, no
|
||||
``_index_image_file`` refactor):
|
||||
|
||||
* gated by the phase-122 ``images`` toggle (the single knob, LOCKED
|
||||
A3): while it is OFF the whole pass is skipped (one debug line —
|
||||
the extraction itself is not worth running) and the prune guard
|
||||
keeps any existing image docs;
|
||||
* an image whose mime maps to no ``BOR_IMAGE_EXTENSIONS`` member
|
||||
(:data:`_MIME_TO_EXT`) is skipped with a warning (the unsupported-
|
||||
extension rule, same as a standalone image's);
|
||||
* the document's synthetic ``path`` is
|
||||
``<pdf path>#page=<N>#img=<index>.<ext>`` — unique per image
|
||||
occurrence, stable across re-indexes (the upsert key); the title
|
||||
is ``Embedded image from <pdf stem> page <N>``;
|
||||
* the digest is sha256 over the image BYTES (the digest rule is
|
||||
content identity); an image doc whose digest matches is skipped
|
||||
(no re-describe, no re-embed — unchanged);
|
||||
* the description comes through the SAME single seam
|
||||
(:func:`_describe_or_skip` — the bytes as a data URL whose mime
|
||||
comes from the copy path's extension); a failed/empty description
|
||||
SKIPS the image entirely (no row, no copy) — ``images_failed`` +
|
||||
a warning, the sync continues (LOCKED A3 fail-soft). The
|
||||
``image_dir`` copy (``<doc-id>.<ext>`` — a new image's id is the
|
||||
uuid4 chosen here; a changed image keeps its id and its stale
|
||||
copy is deleted before replacement) is written only AFTER a
|
||||
successful description (a failure never leaves an orphan);
|
||||
* phase 125 parity (the standalone image's task-03 scan): a same-
|
||||
source markdown file that references the image by its synthetic
|
||||
path (````) enriches
|
||||
the description — the combined context goes to the vision call
|
||||
and is stored on the doc's ``image_context``, and ``content``
|
||||
becomes ``<context>\\n\\n<description>`` (as does ``summary`` —
|
||||
the image convention); no reference keeps the bare shape
|
||||
(byte-identical to the un-enriched case). The markdown-FIRST case
|
||||
(a markdown indexed before the PDF recorded its reference on its
|
||||
own row) is closed by the shared post-index pass
|
||||
(:func:`_enrich_pending_images`), exactly as for a standalone
|
||||
image;
|
||||
* the description IS the content and the summary (the phase-122
|
||||
image convention — :func:`_store_summary` stores it verbatim),
|
||||
chunked + embedded through :func:`_replace_content_chunks` (one
|
||||
transaction per image); a per-image embed failure rolls back that
|
||||
image only (``errors`` + the error line) and the pass moves on —
|
||||
the PDF's text index and the other images are already committed.
|
||||
* a new/updated image doc counts ``added``/``updated`` (it really
|
||||
changed the KB — the sources-version bump fires on it, like a
|
||||
standalone image's).
|
||||
|
||||
``raw_date`` is the PDF's resolved source date (the pass runs only
|
||||
for (re)indexed PDFs, so it is always concrete) — the image docs
|
||||
inherit the PDF's date (they are part of it).
|
||||
"""
|
||||
settings = llm.settings
|
||||
if not settings.images:
|
||||
# Phase-122 toggle off: embedded images are skipped (debug-
|
||||
# logged) — and the prune guard keeps any existing image docs
|
||||
# (they are invisible to the walk, not deleted files).
|
||||
logger.debug(
|
||||
"import: PDF embedded images skipped (images toggle off) "
|
||||
"source=%s path=%s",
|
||||
source, rel,
|
||||
)
|
||||
return
|
||||
images = extract_pdf_images(full_path)
|
||||
for img in images:
|
||||
await _index_one_embedded_image(
|
||||
session, img, source=source, rel=rel, full_path=full_path,
|
||||
llm=llm, summary=summary, raw_date=raw_date,
|
||||
)
|
||||
|
||||
|
||||
async def _index_one_embedded_image(
|
||||
session: Session,
|
||||
img: PdfImage,
|
||||
*,
|
||||
source: str,
|
||||
rel: str,
|
||||
full_path: Path,
|
||||
llm: Embedder,
|
||||
summary: ImportSummary,
|
||||
raw_date: datetime,
|
||||
) -> None:
|
||||
"""One embedded image of one PDF → the phase-122 image pipeline,
|
||||
with the phase-125 task-03 markdown context scan (parity with the
|
||||
standalone image's :func:`_index_image_file` — see
|
||||
:func:`_index_pdf_embedded_images` for the full contract)."""
|
||||
settings = llm.settings
|
||||
ext = _MIME_TO_EXT.get(img.mime, ".bin")
|
||||
if ext not in settings.image_extension_set:
|
||||
logger.warning(
|
||||
"import: PDF embedded image skipped (unsupported extension) "
|
||||
"source=%s path=%s page=%d index=%d mime=%s",
|
||||
source, rel, img.page, img.index, img.mime,
|
||||
)
|
||||
return
|
||||
image_rel = f"{rel}#page={img.page}#img={img.index}{ext}"
|
||||
digest = hashlib.sha256(img.data).hexdigest()
|
||||
image_doc = session.scalar(
|
||||
select(Document).where(Document.source == source, Document.path == image_rel)
|
||||
)
|
||||
if image_doc is not None and image_doc.content_hash == digest:
|
||||
# Unchanged embedded image (byte digest) — no re-describe, no
|
||||
# re-embed (the standalone image's unchanged rule).
|
||||
logger.debug(
|
||||
"import: PDF embedded image unchanged source=%s path=%s",
|
||||
source, image_rel,
|
||||
)
|
||||
return
|
||||
verb = "updated" if image_doc is not None else "added"
|
||||
# Named by the doc id (the phase-122 copy rule): a new image's id is
|
||||
# the uuid4 chosen here (row and copy agree); a changed image keeps
|
||||
# its id (the copy path is stable, the stale copy is deleted below).
|
||||
doc_id = image_doc.id if image_doc is not None else uuid.uuid4()
|
||||
image_dir = Path(settings.image_dir).expanduser()
|
||||
copy_path = image_dir / f"{doc_id}{ext}"
|
||||
# Phase 125 (task 03, LOCKED A1/A2) — the embedded image's parity
|
||||
# with the standalone image's path (phase 126): BEFORE the
|
||||
# description, the markdown reference context for THIS image — a
|
||||
# ```` reference in a
|
||||
# same-source markdown file (the synthetic path is the doc's
|
||||
# ``path``, and the reference resolves to it through the shared
|
||||
# :func:`_resolve_ref_path` rule) — or ``None`` (no reference —
|
||||
# the description call and the row stay byte-identical to the
|
||||
# un-enriched shape). It goes to the vision model (a context-aware
|
||||
# description, LOCKED A3) AND is stored on the doc (``image_context``
|
||||
# — the retrieval display's context line, task 05). A re-indexed
|
||||
# image re-scans: the context follows the current source state
|
||||
# (a deleted referencing markdown drops it back to ``None``).
|
||||
image_context = _markdown_image_context(
|
||||
_source_root_for(full_path, rel), llm, source=source, image_rel=image_rel
|
||||
)
|
||||
# The description through the single seam (the mime resolves from
|
||||
# the copy path's extension — the extension was derived from the
|
||||
# mime, so the round-trip is lossless for the default family). The
|
||||
# description is generated BEFORE anything is written, so a failure
|
||||
# skips the image with no row and no copy.
|
||||
description = await _describe_or_skip(
|
||||
llm, data=img.data, source=source, rel=image_rel, full_path=copy_path,
|
||||
context=image_context,
|
||||
)
|
||||
if description is None:
|
||||
# LOCKED A3 fail-soft: an undescribed image is unsearchable
|
||||
# noise — skip it (no row, no copy).
|
||||
summary.images_failed += 1
|
||||
logger.warning(
|
||||
"import: PDF embedded image description failed source=%s path=%s "
|
||||
"page=%d index=%d",
|
||||
source, image_rel, img.page, img.index,
|
||||
)
|
||||
return
|
||||
if image_doc is not None:
|
||||
# A CHANGED image: the stale copy is deleted before replacement.
|
||||
_delete_image_copy(image_doc.image_path)
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
copy_path.write_bytes(img.data)
|
||||
|
||||
# Phase 125 (task 03): the enriched content — the reference context
|
||||
# FIRST (for retrieval), then the description (the standalone image's
|
||||
# rule; ``summary = content`` below keeps the image convention).
|
||||
content = (
|
||||
f"{image_context}\n\n{description}" if image_context is not None else description
|
||||
)
|
||||
|
||||
title = f"Embedded image from {full_path.stem} page {img.page}"
|
||||
if image_doc is None:
|
||||
image_doc = Document(
|
||||
id=doc_id,
|
||||
source=source,
|
||||
path=image_rel,
|
||||
full_path=str(copy_path),
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash=digest,
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=normalize_doc_date(raw_date),
|
||||
is_image=True,
|
||||
image_path=str(copy_path),
|
||||
image_context=image_context,
|
||||
)
|
||||
session.add(image_doc)
|
||||
else:
|
||||
image_doc.full_path = str(copy_path)
|
||||
image_doc.title = title
|
||||
image_doc.content = content
|
||||
image_doc.content_hash = digest
|
||||
image_doc.indexed_at = datetime.now(UTC)
|
||||
# Phase 106 (D4): a content change is a new document version —
|
||||
# the date is re-sourced and a manual correction reset.
|
||||
image_doc.created_at = normalize_doc_date(raw_date)
|
||||
image_doc.created_at_manual = False
|
||||
image_doc.is_image = True
|
||||
image_doc.image_path = str(copy_path)
|
||||
# Phase 125 (task 03): a re-indexed image re-scans its markdown
|
||||
# references — the context follows the current source state.
|
||||
image_doc.image_context = image_context
|
||||
|
||||
session.flush() # guarantees doc.id even for brand-new rows
|
||||
|
||||
# Phase 1+2 — the shared pipeline on the description (one
|
||||
# transaction per image; a per-image embed failure rolls back THIS
|
||||
# image only — the PDF doc and the other images are already
|
||||
# committed — and the pass moves on).
|
||||
try:
|
||||
chunks_n = await _replace_content_chunks(
|
||||
session, doc=image_doc, llm=llm, content=content, rel=image_rel
|
||||
)
|
||||
session.commit()
|
||||
except EmbeddingError as e:
|
||||
session.rollback()
|
||||
# The description succeeded (the copy was written), but the
|
||||
# embed gave up: delete the copy so a failure never leaves an
|
||||
# orphan (the phase-122 no-orphan rule).
|
||||
_delete_image_copy(str(copy_path))
|
||||
summary.errors += 1
|
||||
logger.error(
|
||||
"import: PDF embedded image embed failed source=%s path=%s "
|
||||
"page=%d index=%d — %s",
|
||||
source, image_rel, img.page, img.index, e,
|
||||
)
|
||||
return
|
||||
|
||||
if verb == "added":
|
||||
summary.added += 1
|
||||
else:
|
||||
summary.updated += 1
|
||||
summary.chunks += chunks_n
|
||||
logger.info(
|
||||
"import: %s source=%s path=%s chunks=%d", verb, source, image_rel, chunks_n
|
||||
)
|
||||
# Phase 122 (task 03) shape on the description — image-aware: the
|
||||
# description IS the summary (stored verbatim, no ``lite`` call), so
|
||||
# ``doc.summary`` == ``doc.content`` and the ``is_summary`` position
|
||||
# −1 chunk mirrors it. With a markdown reference (phase 125) the
|
||||
# summary is the ENRICHED content — the same value the standalone
|
||||
# image's path stores.
|
||||
await _store_summary(
|
||||
session, doc=image_doc, source=source, rel=image_rel,
|
||||
content=content, llm=llm, summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def _source_root_for(full_path: Path, rel: str) -> Path:
|
||||
"""The source root of one walked file (phase 125, task 03).
|
||||
|
||||
@@ -1517,6 +2006,12 @@ async def _index_image_file(
|
||||
)
|
||||
|
||||
|
||||
#: The synthetic path marker of a PDF-embedded image doc (phase 126,
|
||||
#: LOCKED A3 — ``<pdf path>#page=<N>#img=<index>.<ext>``):
|
||||
#: :data:`_EMBEDDED_IMAGE_RE` is the prune's parent rule below.
|
||||
_EMBEDDED_IMAGE_RE = re.compile(r"#page=\d+#img=\d+")
|
||||
|
||||
|
||||
def _prune(
|
||||
session: Session,
|
||||
source_names: set[str],
|
||||
@@ -1532,6 +2027,19 @@ def _prune(
|
||||
first images-off sync. Toggle ON → normal semantics: a deleted image
|
||||
file prunes its doc, and the pruned image's ``image_dir`` copy is
|
||||
deleted with it.
|
||||
|
||||
Phase 126 (LOCKED A3) — the PDF-embedded image's parent rule: an
|
||||
embedded image doc's synthetic path (``#page=<N>#img=<index>``)
|
||||
NEVER appears in the walk's *seen* — the image is derived from its
|
||||
parent PDF, whose on-disk file is the walked file. Pruning it on the
|
||||
plain rule would destroy it in the very sync that (re)indexes it,
|
||||
so it is skipped while its parent PDF's rel is still in *seen* (the
|
||||
image is invisible to the walk, not deleted). A pruned/absent parent
|
||||
PDF prunes its embedded image docs with it (row + ``image_dir`` copy
|
||||
— no orphans when the PDF leaves the source). The rule holds in BOTH
|
||||
toggle states (toggle off, the phase-122 guard already skips every
|
||||
``is_image`` doc; this one is the toggle-on half of the same
|
||||
"invisible, not deleted" contract).
|
||||
"""
|
||||
if not source_names:
|
||||
return 0
|
||||
@@ -1542,6 +2050,14 @@ def _prune(
|
||||
if doc.is_image and not images:
|
||||
# Prune guard: invisible to the walk, not deleted.
|
||||
continue
|
||||
if doc.is_image and _EMBEDDED_IMAGE_RE.search(doc.path):
|
||||
# Phase 126 (A3): the parent PDF's rel is the path minus
|
||||
# its ``#page=…#img=…`` fragment — the walked file.
|
||||
parent_rel = doc.path.split("#", 1)[0]
|
||||
if (doc.source, parent_rel) in seen:
|
||||
# Parent PDF still in the source — the image is
|
||||
# invisible to the walk, not deleted.
|
||||
continue
|
||||
_delete_image_copy(doc.image_path)
|
||||
session.delete(doc)
|
||||
pruned += 1
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
"""PDF parsing utilities (phase 126 — PDF support, locked decision A1).
|
||||
|
||||
Pure functions (I/O = reading the PDF file only) for extracting text
|
||||
and embedded images from PDF files using PyMuPDF (the ``pymupdf``
|
||||
package, imported as ``fitz`` — its stable API name).
|
||||
|
||||
* :func:`extract_pdf_text` — the text of every page that has text,
|
||||
each prefixed with :data:`PAGE_SEPARATOR` (``---PAGE-N---``, N is the
|
||||
1-indexed page number). The PDF chunker (phase 126 task 03) splits on
|
||||
these markers so every chunk keeps its page anchor. Pages without
|
||||
text (image-only pages) contribute nothing, so a PDF with no
|
||||
extractable text returns ``""``.
|
||||
* :func:`extract_pdf_images` — one :class:`PdfImage` per embedded-image
|
||||
occurrence, in page order then 0-based index on the page. ``data`` is
|
||||
the image's original encoded bytes as stored in the PDF (no
|
||||
re-encoding), ``mime`` is derived via :func:`_mime_from_image_info`.
|
||||
* :func:`_mime_from_image_info` — maps a PyMuPDF image-info dict to a
|
||||
MIME type: the embedded file extension (``ext``) first, then the
|
||||
image type code (``type``: 2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP),
|
||||
else the ``application/octet-stream`` fallback.
|
||||
|
||||
Every failure mode is fail-soft: an unreadable file returns an empty
|
||||
result, an unreadable page or image is logged and skipped — this module
|
||||
never raises for malformed input (the importer owns the loud failures).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pymupdf as fitz # PyMuPDF (locked A1); `fitz` is the stable API name
|
||||
|
||||
logger = logging.getLogger("app.rag.pdf")
|
||||
|
||||
#: Page separator used in extracted text (the chunker splits on this).
|
||||
#: ``{page}`` is the 1-indexed page number.
|
||||
PAGE_SEPARATOR = "---PAGE-{page}---"
|
||||
|
||||
#: Mapping from PyMuPDF image type codes to MIME types
|
||||
#: (2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP — phase-126 design).
|
||||
_TYPE_TO_MIME: dict[int, str] = {
|
||||
2: "image/jpeg",
|
||||
3: "image/png",
|
||||
4: "image/gif",
|
||||
8: "image/tiff",
|
||||
9: "image/webp",
|
||||
}
|
||||
|
||||
#: Mapping from embedded-image file extensions to MIME types.
|
||||
_EXT_TO_MIME: dict[str, str] = {
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
"tif": "image/tiff",
|
||||
"tiff": "image/tiff",
|
||||
}
|
||||
|
||||
|
||||
@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, occurrence order)
|
||||
width: int #: native pixel width
|
||||
height: int #: native pixel height
|
||||
data: bytes #: original encoded bytes as stored in the PDF
|
||||
mime: str #: MIME type (e.g. ``image/png``)
|
||||
|
||||
|
||||
def _mime_from_image_info(info: dict[str, Any]) -> str:
|
||||
"""Map a PyMuPDF image-info dict to a MIME type.
|
||||
|
||||
The embedded file extension (``ext``) wins over the image type code
|
||||
(``type``); unknown/missing both fall back to
|
||||
``application/octet-stream``.
|
||||
"""
|
||||
ext = str(info.get("ext") or "").lower()
|
||||
if ext in _EXT_TO_MIME:
|
||||
return _EXT_TO_MIME[ext]
|
||||
type_code = info.get("type")
|
||||
if isinstance(type_code, int):
|
||||
return _TYPE_TO_MIME.get(type_code, "application/octet-stream")
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def _page_image_entries(page: Any) -> list[dict[str, Any]]:
|
||||
"""One info dict per embedded-image occurrence on *page*.
|
||||
|
||||
Prefers ``get_image_info(xrefs=True)`` (one entry per occurrence,
|
||||
with the ``xref`` object number); older PyMuPDF releases without the
|
||||
``xrefs`` keyword are served from ``get_images(full=True)`` (one
|
||||
entry per unique image).
|
||||
"""
|
||||
try:
|
||||
infos: list[dict[str, Any]] = page.get_image_info(xrefs=True)
|
||||
except TypeError:
|
||||
infos = [
|
||||
{"xref": entry[0], "width": entry[2], "height": entry[3]}
|
||||
for entry in page.get_images(full=True)
|
||||
]
|
||||
return [dict(info) for info in infos]
|
||||
|
||||
|
||||
def extract_pdf_text(pdf_path: Path) -> str:
|
||||
"""Extract the text of all pages of *pdf_path*.
|
||||
|
||||
Every page that has text is prefixed with :data:`PAGE_SEPARATOR`
|
||||
(``---PAGE-N---``, N 1-indexed) and the page blocks are joined with
|
||||
a blank line. Pages without text contribute nothing, so a PDF with
|
||||
no extractable text (or a zero-page file) returns ``""``.
|
||||
|
||||
Fail-soft: an unreadable file returns ``""``; a page that cannot be
|
||||
read is logged and skipped.
|
||||
"""
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception as exc:
|
||||
logger.warning("pdf: could not open %s — %s", pdf_path, exc)
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
with doc:
|
||||
for page_num in range(len(doc)):
|
||||
try:
|
||||
raw = doc[page_num].get_text("text")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"pdf: could not read text of page %d of %s — %s",
|
||||
page_num + 1, pdf_path, exc,
|
||||
)
|
||||
continue
|
||||
# get_text("text") always returns a str; the union return type
|
||||
# comes from pymupdf's other flavors (dict/words/blocks).
|
||||
text = raw.strip() if isinstance(raw, str) else ""
|
||||
if text:
|
||||
parts.append(f"{PAGE_SEPARATOR.format(page=page_num + 1)}\n{text}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def extract_pdf_images(pdf_path: Path) -> list[PdfImage]:
|
||||
"""Extract all embedded images from *pdf_path*.
|
||||
|
||||
Returns one :class:`PdfImage` per image occurrence, in page order
|
||||
then 0-based index on the page. ``data`` is the original encoded
|
||||
bytes as stored in the PDF; ``mime`` is derived from the image's
|
||||
extension/type via :func:`_mime_from_image_info`.
|
||||
|
||||
Fail-soft: an unreadable file returns ``[]``; an image without an
|
||||
``xref`` or that cannot be extracted is logged and skipped.
|
||||
"""
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception as exc:
|
||||
logger.warning("pdf: could not open %s for image extraction — %s", pdf_path, exc)
|
||||
return []
|
||||
|
||||
images: list[PdfImage] = []
|
||||
with doc:
|
||||
for page_num in range(len(doc)):
|
||||
try:
|
||||
entries = _page_image_entries(doc[page_num])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"pdf: could not list images on page %d of %s — %s",
|
||||
page_num + 1, pdf_path, exc,
|
||||
)
|
||||
continue
|
||||
for index, info in enumerate(entries):
|
||||
xref = int(info.get("xref") or 0)
|
||||
if not xref:
|
||||
logger.warning(
|
||||
"pdf: skipping image page=%d index=%d of %s — no xref",
|
||||
page_num + 1, index, pdf_path,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
extracted = doc.extract_image(xref)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"pdf: could not extract image page=%d index=%d of %s — %s",
|
||||
page_num + 1, index, pdf_path, exc,
|
||||
)
|
||||
continue
|
||||
data = extracted.get("image") or b""
|
||||
if not data:
|
||||
logger.warning(
|
||||
"pdf: skipping image page=%d index=%d of %s — empty data",
|
||||
page_num + 1, index, pdf_path,
|
||||
)
|
||||
continue
|
||||
# The image info's own `ext` is unreliable in some
|
||||
# PyMuPDF versions (None); `extract_image` reports the
|
||||
# actual format of the returned bytes, so it wins.
|
||||
mime = _mime_from_image_info({**info, "ext": extracted.get("ext") or ""})
|
||||
images.append(
|
||||
PdfImage(
|
||||
page=page_num + 1,
|
||||
index=index,
|
||||
width=int(extracted.get("width") or info.get("width") or 0),
|
||||
height=int(extracted.get("height") or info.get("height") or 0),
|
||||
data=data,
|
||||
mime=mime,
|
||||
)
|
||||
)
|
||||
return images
|
||||
Reference in New Issue
Block a user