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
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
@ -8,11 +9,49 @@ import httpx
|
||||||
from haiku.rag.config import AppConfig
|
from haiku.rag.config import AppConfig
|
||||||
from haiku.rag.converters import get_converter
|
from haiku.rag.converters import get_converter
|
||||||
from haiku.rag.store.models.chunk import Chunk
|
from haiku.rag.store.models.chunk import Chunk
|
||||||
|
from haiku.rag.store.models.document_item import _picture_description_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from docling_core.types.doc.document import DoclingDocument
|
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(
|
async def convert(
|
||||||
config: AppConfig, source: Path | str, *, format: str = "md"
|
config: AppConfig, source: Path | str, *, format: str = "md"
|
||||||
) -> "DoclingDocument":
|
) -> "DoclingDocument":
|
||||||
|
|
@ -44,7 +83,9 @@ async def convert(
|
||||||
raise ValueError(f"File does not exist: {source}")
|
raise ValueError(f"File does not exist: {source}")
|
||||||
if source.suffix.lower() not in converter.supported_extensions:
|
if source.suffix.lower() not in converter.supported_extensions:
|
||||||
raise ValueError(f"Unsupported file extension: {source.suffix}")
|
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
|
# String - check if URL or text
|
||||||
parsed = urlparse(source)
|
parsed = urlparse(source)
|
||||||
|
|
@ -73,7 +114,9 @@ async def convert(
|
||||||
temp_path = Path(temp_file.name)
|
temp_path = Path(temp_file.name)
|
||||||
|
|
||||||
try:
|
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:
|
finally:
|
||||||
temp_path.unlink(missing_ok=True)
|
temp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
@ -84,7 +127,9 @@ async def convert(
|
||||||
raise ValueError(f"File does not exist: {file_path}")
|
raise ValueError(f"File does not exist: {file_path}")
|
||||||
if file_path.suffix.lower() not in converter.supported_extensions:
|
if file_path.suffix.lower() not in converter.supported_extensions:
|
||||||
raise ValueError(f"Unsupported file extension: {file_path.suffix}")
|
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:
|
else:
|
||||||
# Treat as text content
|
# 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