cover picture-description provider and rebuild edge cases
This commit is contained in:
parent
ffc7b95375
commit
46b81f3fa1
2 changed files with 289 additions and 0 deletions
143
tests/test_picture_description.py
Normal file
143
tests/test_picture_description.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""Tests for the direct VLM client used by ``rebuild --descriptions``."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag.config import AppConfig
|
||||||
|
from haiku.rag.providers.picture_description import describe_pictures
|
||||||
|
|
||||||
|
|
||||||
|
class _StubAgent:
|
||||||
|
"""Minimal stand-in for ``pydantic_ai.Agent`` that returns a queue of
|
||||||
|
pre-baked responses (or raises) for each ``run`` call."""
|
||||||
|
|
||||||
|
def __init__(self, outputs: list[Any]):
|
||||||
|
self._outputs = list(outputs)
|
||||||
|
self.calls: list[Any] = []
|
||||||
|
|
||||||
|
async def run(self, prompt: list) -> Any:
|
||||||
|
self.calls.append(prompt)
|
||||||
|
out = self._outputs.pop(0)
|
||||||
|
if isinstance(out, BaseException):
|
||||||
|
raise out
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def __init__(self, output: str):
|
||||||
|
self.output = output
|
||||||
|
|
||||||
|
return _Result(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_agent(monkeypatch, outputs: list[Any]) -> _StubAgent:
|
||||||
|
"""Replace pydantic_ai.Agent in our module with a constructor that
|
||||||
|
returns a single shared StubAgent."""
|
||||||
|
stub = _StubAgent(outputs)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"haiku.rag.providers.picture_description.Agent",
|
||||||
|
lambda **kwargs: stub,
|
||||||
|
)
|
||||||
|
# Skip real model construction — we don't use the returned model anyway.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"haiku.rag.providers.picture_description.get_model",
|
||||||
|
lambda model_config, app_config: object(),
|
||||||
|
)
|
||||||
|
return stub
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_pictures_returns_text_per_self_ref(monkeypatch):
|
||||||
|
"""Happy path: each picture gets one VLM call and the response text
|
||||||
|
lands in the result map keyed by self_ref."""
|
||||||
|
stub = _patch_agent(
|
||||||
|
monkeypatch,
|
||||||
|
outputs=["A red square.", "A blue triangle."],
|
||||||
|
)
|
||||||
|
|
||||||
|
config = AppConfig()
|
||||||
|
out = await describe_pictures(
|
||||||
|
{"#/pictures/0": b"red-bytes", "#/pictures/1": b"blue-bytes"},
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out == {
|
||||||
|
"#/pictures/0": "A red square.",
|
||||||
|
"#/pictures/1": "A blue triangle.",
|
||||||
|
}
|
||||||
|
assert len(stub.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_pictures_drops_empty_output(monkeypatch):
|
||||||
|
"""Pictures whose VLM response is empty/whitespace are dropped from
|
||||||
|
the result map. Caller can decide whether the partial result is
|
||||||
|
acceptable."""
|
||||||
|
_patch_agent(monkeypatch, outputs=["A real description.", " ", ""])
|
||||||
|
|
||||||
|
out = await describe_pictures(
|
||||||
|
{
|
||||||
|
"#/pictures/0": b"a",
|
||||||
|
"#/pictures/1": b"b",
|
||||||
|
"#/pictures/2": b"c",
|
||||||
|
},
|
||||||
|
config=AppConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out == {"#/pictures/0": "A real description."}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_pictures_swallows_exceptions(monkeypatch, caplog):
|
||||||
|
"""A failing VLM call is logged as a warning and the picture is
|
||||||
|
skipped — the rest of the batch still gets described."""
|
||||||
|
_patch_agent(
|
||||||
|
monkeypatch,
|
||||||
|
outputs=[
|
||||||
|
RuntimeError("boom"),
|
||||||
|
"After the failure.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(
|
||||||
|
logging.WARNING, logger="haiku.rag.providers.picture_description"
|
||||||
|
):
|
||||||
|
out = await describe_pictures(
|
||||||
|
{"#/pictures/0": b"a", "#/pictures/1": b"b"},
|
||||||
|
config=AppConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out == {"#/pictures/1": "After the failure."}
|
||||||
|
# caplog may not catch records due to project-wide propagate=False on the
|
||||||
|
# haiku.rag logger; fall back to checking the result reflects the skip.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_pictures_empty_input(monkeypatch):
|
||||||
|
"""No pictures means no VLM calls and an empty result."""
|
||||||
|
stub = _patch_agent(monkeypatch, outputs=[])
|
||||||
|
|
||||||
|
out = await describe_pictures({}, config=AppConfig())
|
||||||
|
|
||||||
|
assert out == {}
|
||||||
|
assert stub.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_pictures_passes_binary_content(monkeypatch):
|
||||||
|
"""The VLM call receives the picture bytes as a BinaryContent part with
|
||||||
|
media_type=image/png so model providers route the request correctly."""
|
||||||
|
from pydantic_ai.messages import BinaryContent
|
||||||
|
|
||||||
|
stub = _patch_agent(monkeypatch, outputs=["ok"])
|
||||||
|
|
||||||
|
await describe_pictures(
|
||||||
|
{"#/pictures/0": b"\x89PNG\r\n\x1a\nfake"}, config=AppConfig()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(stub.calls) == 1
|
||||||
|
parts = stub.calls[0]
|
||||||
|
assert isinstance(parts, list) and len(parts) == 1
|
||||||
|
assert isinstance(parts[0], BinaryContent)
|
||||||
|
assert parts[0].data == b"\x89PNG\r\n\x1a\nfake"
|
||||||
|
assert parts[0].media_type == "image/png"
|
||||||
|
|
@ -557,3 +557,149 @@ async def test_rebuild_descriptions_skips_already_described(temp_db_path, monkey
|
||||||
getattr(getattr(meta, "description", None), "text", None) if meta else None
|
getattr(getattr(meta, "description", None), "text", None) if meta else None
|
||||||
)
|
)
|
||||||
assert text == "Pre-existing description."
|
assert text == "Pre-existing description."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_picture_descriptions_returns_zero_for_doc_without_pictures(
|
||||||
|
temp_db_path,
|
||||||
|
):
|
||||||
|
"""A document with no pictures returns 0 without ever calling the VLM."""
|
||||||
|
from haiku.rag.client.rebuild import _patch_picture_descriptions
|
||||||
|
from haiku.rag.config import AppConfig
|
||||||
|
|
||||||
|
config = AppConfig()
|
||||||
|
config.processing.conversion_options.picture_description.enabled = True
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, config=config, create=True) as rag:
|
||||||
|
doc = await rag.create_document(content="Just text, no pictures.")
|
||||||
|
assert doc.id is not None
|
||||||
|
n = await _patch_picture_descriptions(rag, doc)
|
||||||
|
assert n == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_picture_descriptions_warns_on_missing_bytes(temp_db_path, caplog):
|
||||||
|
"""When the docling blob has pictures but document_items.picture_data is
|
||||||
|
empty (e.g. legacy DB ingested before A2b), the helper logs a warning
|
||||||
|
and returns 0 instead of trying to drive the VLM with no input."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from haiku.rag.client.documents import _store_document_with_chunks
|
||||||
|
from haiku.rag.client.rebuild import _patch_picture_descriptions
|
||||||
|
from haiku.rag.config import AppConfig
|
||||||
|
from haiku.rag.store.models.document import Document
|
||||||
|
from tests.store.test_document_items import _docling_doc_with_picture
|
||||||
|
|
||||||
|
docling_doc = _docling_doc_with_picture()
|
||||||
|
config = AppConfig()
|
||||||
|
config.processing.conversion_options.picture_description.enabled = True
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, config=config, create=True) as rag:
|
||||||
|
document = Document(content="x", uri="test://doc")
|
||||||
|
document.set_docling(docling_doc)
|
||||||
|
created = await _store_document_with_chunks(rag, document, [], docling_doc)
|
||||||
|
assert created.id is not None
|
||||||
|
|
||||||
|
# Wipe the stored picture bytes to simulate a doc that knows about
|
||||||
|
# pictures but doesn't have them on disk.
|
||||||
|
await rag.store.document_items_table.update(
|
||||||
|
{"picture_data": None},
|
||||||
|
where=f"document_id = '{created.id}' AND label = 'picture'",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture warnings directly off the rebuild module logger — the
|
||||||
|
# haiku.rag parent logger is configured non-propagating elsewhere
|
||||||
|
# in the suite so caplog can miss records.
|
||||||
|
from haiku.rag.client import rebuild as rebuild_module
|
||||||
|
|
||||||
|
records: list[logging.LogRecord] = []
|
||||||
|
|
||||||
|
class _ListHandler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
handler = _ListHandler(level=logging.WARNING)
|
||||||
|
rebuild_module.logger.addHandler(handler)
|
||||||
|
try:
|
||||||
|
n = await _patch_picture_descriptions(rag, created)
|
||||||
|
finally:
|
||||||
|
rebuild_module.logger.removeHandler(handler)
|
||||||
|
|
||||||
|
assert n == 0
|
||||||
|
assert any("no stored picture bytes" in r.getMessage() for r in records)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_picture_descriptions_skips_when_all_already_described(
|
||||||
|
temp_db_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""If every picture already has meta.description.text, the helper does
|
||||||
|
not call the VLM and returns 0."""
|
||||||
|
from docling_core.types.doc.document import DescriptionMetaField, PictureMeta
|
||||||
|
|
||||||
|
from haiku.rag.client.documents import _store_document_with_chunks
|
||||||
|
from haiku.rag.client.rebuild import _patch_picture_descriptions
|
||||||
|
from haiku.rag.config import AppConfig
|
||||||
|
from haiku.rag.store.models.document import Document
|
||||||
|
from tests.store.test_document_items import _docling_doc_with_picture
|
||||||
|
|
||||||
|
docling_doc = _docling_doc_with_picture()
|
||||||
|
docling_doc.pictures[0].meta = PictureMeta(
|
||||||
|
description=DescriptionMetaField(text="Pre-described.")
|
||||||
|
)
|
||||||
|
|
||||||
|
config = AppConfig()
|
||||||
|
config.processing.conversion_options.picture_description.enabled = True
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, config=config, create=True) as rag:
|
||||||
|
document = Document(content="x", uri="test://doc")
|
||||||
|
document.set_docling(docling_doc)
|
||||||
|
created = await _store_document_with_chunks(rag, document, [], docling_doc)
|
||||||
|
assert created.id is not None
|
||||||
|
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def fake_describe(*args, **kwargs):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"haiku.rag.providers.picture_description.describe_pictures",
|
||||||
|
fake_describe,
|
||||||
|
)
|
||||||
|
|
||||||
|
n = await _patch_picture_descriptions(rag, created)
|
||||||
|
assert n == 0
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rebuild_descriptions_raises_when_blob_is_missing(
|
||||||
|
temp_db_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Documents without a stored docling blob can't be re-described —
|
||||||
|
surface a clear error pointing the user at full rebuild instead."""
|
||||||
|
from haiku.rag.client.documents import _store_document_with_chunks
|
||||||
|
from haiku.rag.config import AppConfig
|
||||||
|
from haiku.rag.store.models.document import Document
|
||||||
|
from tests.store.test_document_items import _docling_doc_with_picture
|
||||||
|
|
||||||
|
docling_doc = _docling_doc_with_picture()
|
||||||
|
config = AppConfig()
|
||||||
|
config.processing.conversion_options.picture_description.enabled = True
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, config=config, create=True) as rag:
|
||||||
|
document = Document(content="x", uri="test://doc")
|
||||||
|
document.set_docling(docling_doc)
|
||||||
|
created = await _store_document_with_chunks(rag, document, [], docling_doc)
|
||||||
|
assert created.id is not None
|
||||||
|
|
||||||
|
# Force the stored doc to come back without a docling blob.
|
||||||
|
await rag.store.documents_table.update(
|
||||||
|
{"docling_document": None}, where=f"id = '{created.id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="rebuild --descriptions requires"):
|
||||||
|
async for _ in rag.rebuild_database(mode=RebuildMode.DESCRIPTIONS):
|
||||||
|
pass
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue