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 8f052caa..02175f7e 100644 --- a/haiku_rag_slim/haiku/rag/client/documents.py +++ b/haiku_rag_slim/haiku/rag/client/documents.py @@ -332,16 +332,25 @@ 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``, 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) - 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 +517,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..0cf52175 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,24 @@ 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): + """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 + + 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) + + with caplog.at_level(logging.WARNING, logger="haiku.rag.client.documents"): + await _reconcile_pdf_attachments(client, parent, pdf_bytes, depth=0) + + assert await client.list_documents(filter=parent_uri_filter(parent_uri)) == [] + + async def test_cascade_delete_removes_reconciled_children(temp_db_path, monkeypatch): monkeypatch.setattr( "haiku.rag.client.documents._ingest_fetch_result",