From bce24e84a08bf21c2e2683f44ce441955622f7ed Mon Sep 17 00:00:00 2001 From: bryan davis Date: Thu, 11 Jun 2026 16:52:07 -0500 Subject: [PATCH] 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",