warn when picture descriptions silently fail to come back
This commit is contained in:
parent
2038d43435
commit
5fad0aa5c6
2 changed files with 212 additions and 3 deletions
|
|
@ -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 "<provider default>",
|
||||
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
|
||||
|
|
|
|||
164
tests/test_processing.py
Normal file
164
tests/test_processing.py
Normal file
|
|
@ -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
|
||||
)
|
||||
Loading…
Reference in a new issue