diff --git a/haiku_rag_slim/haiku/rag/client/processing.py b/haiku_rag_slim/haiku/rag/client/processing.py index 1ab9895f..9513733a 100644 --- a/haiku_rag_slim/haiku/rag/client/processing.py +++ b/haiku_rag_slim/haiku/rag/client/processing.py @@ -1,3 +1,4 @@ +import logging import tempfile from pathlib import Path from typing import TYPE_CHECKING @@ -8,11 +9,49 @@ import httpx from haiku.rag.config import AppConfig from haiku.rag.converters import get_converter from haiku.rag.store.models.chunk import Chunk +from haiku.rag.store.models.document_item import _picture_description_text if TYPE_CHECKING: from docling_core.types.doc.document import DoclingDocument +logger = logging.getLogger(__name__) + + +def _warn_if_descriptions_missing( + config: AppConfig, doc: "DoclingDocument", source: str +) -> None: + """Warn when picture-description was requested but produced nothing. + + docling-serve swallows VLM errors (network failures, missing models, + etc.) and returns a successful conversion with empty descriptions. + docling-local can do the same when the VLM endpoint is unreachable. + Surface the silent failure: when ``picture_description.enabled=True`` + AND the document has at least one picture AND zero descriptions came + back, log a clear warning so the user can fix their VLM config before + a thousand-document ingest produces an empty corpus. + """ + if not config.processing.conversion_options.picture_description.enabled: + return + pictures = list(doc.pictures or []) + if not pictures: + return + described = sum(1 for p in pictures if _picture_description_text(p)) + if described == 0: + model = config.processing.conversion_options.picture_description.model + logger.warning( + "picture_description.enabled is True but no descriptions came back " + "for %s (%d pictures, 0 described). The VLM call likely failed " + "silently inside the converter. Check that the VLM at %s is " + "reachable from the converter and that the model name '%s' " + "resolves on the server.", + source, + len(pictures), + model.base_url or "", + model.name, + ) + + async def convert( config: AppConfig, source: Path | str, *, format: str = "md" ) -> "DoclingDocument": @@ -44,7 +83,9 @@ async def convert( raise ValueError(f"File does not exist: {source}") if source.suffix.lower() not in converter.supported_extensions: raise ValueError(f"Unsupported file extension: {source.suffix}") - return await converter.convert_file(source) + doc = await converter.convert_file(source) + _warn_if_descriptions_missing(config, doc, str(source)) + return doc # String - check if URL or text parsed = urlparse(source) @@ -73,7 +114,9 @@ async def convert( temp_path = Path(temp_file.name) try: - return await converter.convert_file(temp_path) + doc = await converter.convert_file(temp_path) + _warn_if_descriptions_missing(config, doc, source) + return doc finally: temp_path.unlink(missing_ok=True) @@ -84,7 +127,9 @@ async def convert( raise ValueError(f"File does not exist: {file_path}") if file_path.suffix.lower() not in converter.supported_extensions: raise ValueError(f"Unsupported file extension: {file_path.suffix}") - return await converter.convert_file(file_path) + doc = await converter.convert_file(file_path) + _warn_if_descriptions_missing(config, doc, str(file_path)) + return doc else: # Treat as text content diff --git a/tests/test_processing.py b/tests/test_processing.py new file mode 100644 index 00000000..fe3019ff --- /dev/null +++ b/tests/test_processing.py @@ -0,0 +1,164 @@ +"""Tests for haiku.rag.client.processing helpers.""" + +import logging +from pathlib import Path + +import pytest + +from haiku.rag.client.processing import _warn_if_descriptions_missing, convert +from haiku.rag.config import AppConfig + + +def _doc_with_pictures(*, with_descriptions: bool): + """Build a tiny DoclingDocument carrying one PictureItem. + + When ``with_descriptions=True`` the picture's ``meta.description.text`` + is populated, simulating a successful VLM call. Otherwise it's left + empty, simulating a silent VLM failure. + """ + from docling_core.types.doc.document import ( + DescriptionMetaField, + DoclingDocument, + PictureItem, + PictureMeta, + ) + from docling_core.types.doc.labels import DocItemLabel + + doc = DoclingDocument(name="test") + pic = PictureItem( + self_ref="#/pictures/0", + label=DocItemLabel.PICTURE, + ) + if with_descriptions: + pic.meta = PictureMeta( + description=DescriptionMetaField(text="A red square."), + ) + doc.pictures.append(pic) + return doc + + +def _doc_without_pictures(): + from docling_core.types.doc.document import DoclingDocument + + return DoclingDocument(name="test") + + +@pytest.fixture +def caplog_warnings(caplog): + """Capture WARNING-level records from the processing logger.""" + caplog.set_level(logging.WARNING, logger="haiku.rag.client.processing") + # The haiku.rag parent logger sets propagate=False after get_logger() runs, + # which can break caplog under xdist when other tests have already + # configured logging. Attach directly to the module logger. + from haiku.rag.client.processing import logger as proc_logger + + records: list[logging.LogRecord] = [] + + class _Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Capture(level=logging.WARNING) + proc_logger.addHandler(handler) + yield records + proc_logger.removeHandler(handler) + + +def test_no_warning_when_picture_description_disabled(caplog_warnings): + """Default config has picture_description.enabled=False — even a + document full of pictures with no descriptions should not warn.""" + config = AppConfig() + assert config.processing.conversion_options.picture_description.enabled is False + doc = _doc_with_pictures(with_descriptions=False) + + _warn_if_descriptions_missing(config, doc, "fake.pdf") + + assert caplog_warnings == [] + + +def test_no_warning_when_doc_has_no_pictures(caplog_warnings): + """A picture-less document under enabled=True shouldn't warn — there + was simply nothing to describe.""" + config = AppConfig() + config.processing.conversion_options.picture_description.enabled = True + doc = _doc_without_pictures() + + _warn_if_descriptions_missing(config, doc, "no-pictures.txt") + + assert caplog_warnings == [] + + +def test_warns_when_pictures_present_but_no_descriptions(caplog_warnings): + """The silent-failure case: VLM was requested, the doc has pictures, + but the converter returned zero descriptions. Warn loudly so the user + can fix their VLM config before a long ingest.""" + config = AppConfig() + config.processing.conversion_options.picture_description.enabled = True + config.processing.conversion_options.picture_description.model.name = "qwen3.6" + config.processing.conversion_options.picture_description.model.base_url = ( + "http://host.docker.internal:11434" + ) + doc = _doc_with_pictures(with_descriptions=False) + + _warn_if_descriptions_missing(config, doc, "doclaynet.pdf") + + assert len(caplog_warnings) == 1 + msg = caplog_warnings[0].getMessage() + assert "doclaynet.pdf" in msg + assert "1 pictures" in msg + assert "0 described" in msg + assert "qwen3.6" in msg + assert "host.docker.internal" in msg + + +def test_no_warning_when_at_least_one_description_came_back(caplog_warnings): + """Partial coverage (some pictures described, some not) is acceptable + and does not warn — the user might have area-threshold filtering or + classification gating.""" + config = AppConfig() + config.processing.conversion_options.picture_description.enabled = True + doc = _doc_with_pictures(with_descriptions=True) + + _warn_if_descriptions_missing(config, doc, "fine.pdf") + + assert caplog_warnings == [] + + +@pytest.mark.asyncio +async def test_convert_emits_warning_via_chokepoint( + monkeypatch, tmp_path, caplog_warnings +): + """End-to-end: ``convert(...)`` invokes the guard after the converter + returns, so a silent VLM failure surfaces as a warning to the user + regardless of which converter (local vs serve) actually ran.""" + from haiku.rag.converters.base import DocumentConverter + + pdf = tmp_path / "fake.pdf" + pdf.write_bytes(b"%PDF-1.4 stub") + + class StubConverter(DocumentConverter): + @property + def supported_extensions(self) -> list[str]: + return [".pdf"] + + async def convert_file(self, path: Path): + return _doc_with_pictures(with_descriptions=False) + + async def convert_text( + self, text: str, name: str = "content.md", format: str = "md" + ): + return _doc_without_pictures() + + monkeypatch.setattr( + "haiku.rag.client.processing.get_converter", lambda config: StubConverter() + ) + + config = AppConfig() + config.processing.conversion_options.picture_description.enabled = True + + await convert(config, pdf) + + assert any( + "0 described" in r.getMessage() and "fake.pdf" in r.getMessage() + for r in caplog_warnings + )