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
|
||||
)
|
||||
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