From bce24e84a08bf21c2e2683f44ce441955622f7ed Mon Sep 17 00:00:00 2001 From: bryan davis Date: Thu, 11 Jun 2026 16:52:07 -0500 Subject: [PATCH 1/2] fix for issue with parsing pdf attachment such as *.joboptions files --- haiku_rag_slim/haiku/rag/client/documents.py | 27 ++++++++++--- tests/test_pdf_attachments.py | 41 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/client/documents.py b/haiku_rag_slim/haiku/rag/client/documents.py index 8f052caa..0faf2ceb 100644 --- a/haiku_rag_slim/haiku/rag/client/documents.py +++ b/haiku_rag_slim/haiku/rag/client/documents.py @@ -332,16 +332,30 @@ async def _ingest_fetch_result( stored_uri: str, existing_doc: Document | None, depth: int = 0, + filename: str | None = None, ) -> Document: """Convert / chunk / embed / store a fetched document. Replaces an existing document if one is supplied. ``depth`` tracks position in an - attachment chain so the reconciliation step can bound recursion.""" + attachment chain so the reconciliation step can bound recursion. + + ``filename`` makes that name's suffix the authoritative source of the + file extension (hence the docling format). Callers pass it when the + result's ``uri`` cannot yield the right extension -- notably embedded + PDF attachments, whose synthetic ``...#attachment=`` URI carries + the attachment name in the fragment, so the URL-suffix fallback would + otherwise inherit the *parent* PDF's ``.pdf`` and feed ASCII payloads + (e.g. ``Press Quality.joboptions``) to docling's PDF backend. An empty + or unsupported suffix then fails the guard below and is rejected, + instead of being silently misrouted.""" from haiku.rag.embeddings import embed_chunks converter = get_converter(client._config) - file_extension = get_extension_from_content_type_or_url( - result.uri, result.content_type - ) + if filename is not None: + file_extension = Path(filename).suffix.lower() + else: + file_extension = get_extension_from_content_type_or_url( + result.uri, result.content_type + ) if file_extension not in converter.supported_extensions: raise UnsupportedSourceError( f"Unsupported content type/extension: {result.content_type}/{file_extension}" @@ -508,12 +522,15 @@ async def _reconcile_pdf_attachments( stored_uri=child_uri, existing_doc=existing_child, depth=depth + 1, + filename=name, ) except UnsupportedSourceError: logger.warning( - "Skipping attachment %r in %s: unsupported content type %r", + "Skipping attachment %r in %s: unsupported extension %r " + "(content type %r)", name, parent_doc.uri, + Path(name).suffix.lower(), content_type, ) diff --git a/tests/test_pdf_attachments.py b/tests/test_pdf_attachments.py index 1feb1b49..2e4cfaf4 100644 --- a/tests/test_pdf_attachments.py +++ b/tests/test_pdf_attachments.py @@ -33,6 +33,7 @@ async def fake_ingest_fetch_result( stored_uri, existing_doc, depth=0, + filename=None, ): """A stand-in for ``_ingest_fetch_result`` that skips docling/embedder entirely: it writes the document with content_type/md5/parent_uri set @@ -306,6 +307,7 @@ async def test_unsupported_attachment_continues_loop(temp_db_path, monkeypatch): stored_uri, existing_doc, depth=0, + filename=None, ): if stored_uri.endswith("unsupported.xyz"): raise UnsupportedSourceError("nope") @@ -329,6 +331,45 @@ async def test_unsupported_attachment_continues_loop(temp_db_path, monkeypatch): assert {c.uri for c in children} == {f"{parent_uri}#attachment=ok.txt"} +async def test_joboptions_attachment_skipped_not_routed_as_pdf(temp_db_path, caplog): + """Regression: an Adobe ``.joboptions`` preset is ASCII text, embedded by + name only. Its synthetic ``...#attachment=Press%20Quality.joboptions`` URI + carries the name in a *fragment*, so the URL-suffix fallback used to inherit + the PARENT PDF's ``.pdf`` and hand the ASCII bytes to docling's PDF backend. + + The attachment's own name must now drive the extension: ``.joboptions`` is + unsupported, so the child is skipped -- never ingested, never sent to + docling. This runs the REAL ``_ingest_fetch_result`` (no monkeypatch); its + unsupported-extension guard short-circuits before any converter/embedder + call, so the sole attachment needs no LLM.""" + import logging + + from haiku.rag.client.processing import get_extension_from_content_type_or_url + + pdf_bytes = build_pdf([("Press Quality.joboptions", b"/CompressObjects /Tags\n")]) + async with HaikuRAG(temp_db_path, create=True) as client: + parent_uri = "file:///fixtures/brochure.pdf" + parent = await _make_parent(client, parent_uri, pdf_bytes) + + # The latent trap: octet-stream + the fragment URI still resolves to the + # parent's ``.pdf`` -- which is exactly what the fix must no longer use. + child_uri = f"{parent_uri}#attachment=Press%20Quality.joboptions" + assert ( + get_extension_from_content_type_or_url( + child_uri, "application/octet-stream" + ) + == ".pdf" + ) + + with caplog.at_level(logging.WARNING, logger="haiku.rag.client.documents"): + await _reconcile_pdf_attachments(client, parent, pdf_bytes, depth=0) + + # No child created: the ASCII preset was skipped, not parsed as a PDF. + assert await client.list_documents(filter=parent_uri_filter(parent_uri)) == [] + assert "unsupported extension" in caplog.text + assert ".joboptions" in caplog.text + + async def test_cascade_delete_removes_reconciled_children(temp_db_path, monkeypatch): monkeypatch.setattr( "haiku.rag.client.documents._ingest_fetch_result", From 8ac670714d2719caa3c609c97299fdb576d45383 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 12 Jun 2026 09:31:42 +0300 Subject: [PATCH 2/2] Tidy PDF attachment extension fix --- CHANGELOG.md | 4 +++ haiku_rag_slim/haiku/rag/client/documents.py | 13 +++------ tests/test_pdf_attachments.py | 29 +++----------------- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca167fd2..753f3b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Fixed + +- Embedded PDF attachment extension is derived from the attachment filename, not the parent's synthetic `...#attachment=` URI; non-PDF attachments (e.g. `.joboptions`) are no longer misrouted to docling's PDF backend, and unsupported extensions are skipped. + ## [0.57.0] - 2026-06-11 ### Added diff --git a/haiku_rag_slim/haiku/rag/client/documents.py b/haiku_rag_slim/haiku/rag/client/documents.py index 0faf2ceb..02175f7e 100644 --- a/haiku_rag_slim/haiku/rag/client/documents.py +++ b/haiku_rag_slim/haiku/rag/client/documents.py @@ -338,15 +338,10 @@ async def _ingest_fetch_result( existing document if one is supplied. ``depth`` tracks position in an attachment chain so the reconciliation step can bound recursion. - ``filename`` makes that name's suffix the authoritative source of the - file extension (hence the docling format). Callers pass it when the - result's ``uri`` cannot yield the right extension -- notably embedded - PDF attachments, whose synthetic ``...#attachment=`` URI carries - the attachment name in the fragment, so the URL-suffix fallback would - otherwise inherit the *parent* PDF's ``.pdf`` and feed ASCII payloads - (e.g. ``Press Quality.joboptions``) to docling's PDF backend. An empty - or unsupported suffix then fails the guard below and is rejected, - instead of being silently misrouted.""" + ``filename``, when given, makes its suffix authoritative for the file + extension (and thus the docling format), overriding the URI/content-type + fallback. Callers pass it when ``result.uri`` cannot yield the right + extension, e.g. embedded attachments whose name lives in a URI fragment.""" from haiku.rag.embeddings import embed_chunks converter = get_converter(client._config) diff --git a/tests/test_pdf_attachments.py b/tests/test_pdf_attachments.py index 2e4cfaf4..0cf52175 100644 --- a/tests/test_pdf_attachments.py +++ b/tests/test_pdf_attachments.py @@ -332,42 +332,21 @@ async def test_unsupported_attachment_continues_loop(temp_db_path, monkeypatch): async def test_joboptions_attachment_skipped_not_routed_as_pdf(temp_db_path, caplog): - """Regression: an Adobe ``.joboptions`` preset is ASCII text, embedded by - name only. Its synthetic ``...#attachment=Press%20Quality.joboptions`` URI - carries the name in a *fragment*, so the URL-suffix fallback used to inherit - the PARENT PDF's ``.pdf`` and hand the ASCII bytes to docling's PDF backend. - - The attachment's own name must now drive the extension: ``.joboptions`` is - unsupported, so the child is skipped -- never ingested, never sent to - docling. This runs the REAL ``_ingest_fetch_result`` (no monkeypatch); its - unsupported-extension guard short-circuits before any converter/embedder - call, so the sole attachment needs no LLM.""" + """An attachment's own name drives its extension, not the synthetic + ``...#attachment=`` URI (whose fragment the URL-suffix fallback drops, + inheriting the parent's ``.pdf``). ``.joboptions`` is unsupported, so the + child is skipped before any converter/embedder call (real ingest, no fake).""" import logging - from haiku.rag.client.processing import get_extension_from_content_type_or_url - pdf_bytes = build_pdf([("Press Quality.joboptions", b"/CompressObjects /Tags\n")]) async with HaikuRAG(temp_db_path, create=True) as client: parent_uri = "file:///fixtures/brochure.pdf" parent = await _make_parent(client, parent_uri, pdf_bytes) - # The latent trap: octet-stream + the fragment URI still resolves to the - # parent's ``.pdf`` -- which is exactly what the fix must no longer use. - child_uri = f"{parent_uri}#attachment=Press%20Quality.joboptions" - assert ( - get_extension_from_content_type_or_url( - child_uri, "application/octet-stream" - ) - == ".pdf" - ) - with caplog.at_level(logging.WARNING, logger="haiku.rag.client.documents"): await _reconcile_pdf_attachments(client, parent, pdf_bytes, depth=0) - # No child created: the ASCII preset was skipped, not parsed as a PDF. assert await client.list_documents(filter=parent_uri_filter(parent_uri)) == [] - assert "unsupported extension" in caplog.text - assert ".joboptions" in caplog.text async def test_cascade_delete_removes_reconciled_children(temp_db_path, monkeypatch):