chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,694 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Document, Pipeline
|
||||
from haystack.components.converters.image.document_to_image import DocumentToImageContent
|
||||
from haystack.components.extractors.image import LLMDocumentContentExtractor
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.components.writers import DocumentWriter
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.dataclasses.chat_message import ChatMessage, ImageContent
|
||||
|
||||
|
||||
class TestLLMDocumentContentExtractor:
|
||||
def test_init(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
|
||||
|
||||
extractor = LLMDocumentContentExtractor(
|
||||
chat_generator=chat_generator,
|
||||
prompt="Extract content from this image",
|
||||
file_path_meta_field="file_path",
|
||||
root_path="/test/path",
|
||||
detail="high",
|
||||
size=(800, 600),
|
||||
raise_on_failure=True,
|
||||
max_workers=5,
|
||||
)
|
||||
|
||||
assert isinstance(extractor._chat_generator, OpenAIChatGenerator)
|
||||
# Not testing specific model name, just that it's set
|
||||
assert extractor._chat_generator.model is not None
|
||||
assert extractor._chat_generator.generation_kwargs == {"temperature": 0.5}
|
||||
assert extractor.prompt == "Extract content from this image"
|
||||
assert extractor.file_path_meta_field == "file_path"
|
||||
assert extractor.root_path == "/test/path"
|
||||
assert extractor.detail == "high"
|
||||
assert extractor.size == (800, 600)
|
||||
assert extractor.raise_on_failure is True
|
||||
assert extractor.max_workers == 5
|
||||
|
||||
def test_init_with_defaults(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
chat_generator = OpenAIChatGenerator()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=chat_generator)
|
||||
assert extractor.prompt.startswith("\nYou are part of an information extraction pipeline")
|
||||
assert extractor.file_path_meta_field == "file_path"
|
||||
assert extractor.root_path == ""
|
||||
assert extractor.detail is None
|
||||
assert extractor.size is None
|
||||
assert extractor.raise_on_failure is False
|
||||
assert extractor.max_workers == 3
|
||||
|
||||
def test_init_with_variables_in_prompt(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
chat_generator = OpenAIChatGenerator()
|
||||
|
||||
with pytest.raises(ValueError, match="The prompt must not have any variables"):
|
||||
LLMDocumentContentExtractor(
|
||||
chat_generator=chat_generator, prompt="Extract content from {{document.content}}"
|
||||
)
|
||||
|
||||
def test_init_fails_without_chat_generator(self):
|
||||
with pytest.raises(TypeError):
|
||||
LLMDocumentContentExtractor()
|
||||
|
||||
def test_to_dict_openai(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
|
||||
|
||||
extractor = LLMDocumentContentExtractor(
|
||||
chat_generator=chat_generator,
|
||||
prompt="Custom extraction prompt",
|
||||
file_path_meta_field="custom_path",
|
||||
root_path="/custom/root",
|
||||
detail="low",
|
||||
size=(1024, 768),
|
||||
raise_on_failure=True,
|
||||
max_workers=4,
|
||||
)
|
||||
|
||||
extractor_dict = extractor.to_dict()
|
||||
|
||||
assert extractor_dict == {
|
||||
"type": "haystack.components.extractors.image.llm_document_content_extractor.LLMDocumentContentExtractor",
|
||||
"init_parameters": {
|
||||
"chat_generator": component_to_dict(chat_generator, "chat_generator"),
|
||||
"prompt": "Custom extraction prompt",
|
||||
"file_path_meta_field": "custom_path",
|
||||
"root_path": "/custom/root",
|
||||
"detail": "low",
|
||||
"size": (1024, 768),
|
||||
"raise_on_failure": True,
|
||||
"max_workers": 4,
|
||||
},
|
||||
}
|
||||
|
||||
def test_from_dict_openai(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
|
||||
|
||||
extractor_dict = {
|
||||
"type": "haystack.components.extractors.image.llm_document_content_extractor.LLMDocumentContentExtractor",
|
||||
"init_parameters": {
|
||||
"chat_generator": component_to_dict(chat_generator, "chat_generator"),
|
||||
"prompt": "Custom extraction prompt",
|
||||
"file_path_meta_field": "custom_path",
|
||||
"root_path": "/custom/root",
|
||||
"detail": "low",
|
||||
"size": (1024, 768),
|
||||
"raise_on_failure": True,
|
||||
"max_workers": 4,
|
||||
},
|
||||
}
|
||||
|
||||
extractor = LLMDocumentContentExtractor.from_dict(extractor_dict)
|
||||
|
||||
assert extractor.prompt == "Custom extraction prompt"
|
||||
assert extractor.file_path_meta_field == "custom_path"
|
||||
assert extractor.root_path == "/custom/root"
|
||||
assert extractor.detail == "low"
|
||||
assert extractor.size == (1024, 768)
|
||||
assert extractor.raise_on_failure is True
|
||||
assert extractor.max_workers == 4
|
||||
assert component_to_dict(extractor._chat_generator, "name") == component_to_dict(chat_generator, "name")
|
||||
|
||||
def test_run_no_documents(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
chat_generator = OpenAIChatGenerator()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=chat_generator)
|
||||
result = extractor.run(documents=[])
|
||||
assert result["documents"] == []
|
||||
assert result["failed_documents"] == []
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_with_failed_image_conversion(self, mock_doc_to_image_run, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
|
||||
# Mock DocumentToImageContent to return None (failed conversion)
|
||||
mock_doc_to_image_run.return_value = {"image_contents": [None]}
|
||||
|
||||
doc = Document(content="", meta={"file_path": "/path/to/image.pdf"})
|
||||
docs = [doc]
|
||||
result = extractor.run(documents=docs)
|
||||
|
||||
# Document should be in failed_documents
|
||||
assert len(result["documents"]) == 0
|
||||
assert len(result["failed_documents"]) == 1
|
||||
|
||||
failed_doc = result["failed_documents"][0]
|
||||
assert failed_doc.id == doc.id
|
||||
assert "extraction_error" in failed_doc.meta
|
||||
assert failed_doc.meta["extraction_error"] == "Document has no content, skipping LLM call."
|
||||
|
||||
# Ensure no attempt was made to call the LLM
|
||||
mock_chat_generator.run.assert_not_called()
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_with_llm_success(self, mock_doc_to_image_run):
|
||||
# Mock successful LLM response (JSON with document_content)
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.return_value = {
|
||||
"replies": [ChatMessage.from_assistant(text='{"document_content": "Extracted content from the image"}')]
|
||||
}
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
|
||||
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
|
||||
result = extractor.run(documents=docs)
|
||||
|
||||
assert len(result["documents"]) == 1
|
||||
assert len(result["failed_documents"]) == 0
|
||||
processed_doc = result["documents"][0]
|
||||
assert processed_doc.id == docs[0].id
|
||||
assert processed_doc.content == "Extracted content from the image"
|
||||
assert "extraction_error" not in processed_doc.meta
|
||||
mock_chat_generator.run.assert_called_once()
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_plain_string_response_goes_to_content(self, mock_doc_to_image_run):
|
||||
"""When LLM returns plain string (non-JSON), it is written to document content."""
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant(text="Plain text, not JSON")]}
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
|
||||
result = extractor.run(documents=docs)
|
||||
assert len(result["documents"]) == 1
|
||||
assert len(result["failed_documents"]) == 0
|
||||
assert result["documents"][0].content == "Plain text, not JSON"
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_valid_json_not_object_reports_error(self, mock_doc_to_image_run):
|
||||
"""When LLM returns valid JSON that is not an object (e.g. array or primitive), report error."""
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.return_value = {
|
||||
"replies": [ChatMessage.from_assistant(text='["array", "not", "object"]')]
|
||||
}
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
|
||||
result = extractor.run(documents=docs)
|
||||
assert len(result["documents"]) == 0
|
||||
assert len(result["failed_documents"]) == 1
|
||||
assert "extraction_error" in result["failed_documents"][0].meta
|
||||
assert "JSON object" in result["failed_documents"][0].meta["extraction_error"]
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_with_content_and_metadata_extraction(self, mock_doc_to_image_run):
|
||||
"""When content mode gets JSON with document_content and other keys, other keys are merged into metadata."""
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.return_value = {
|
||||
"replies": [
|
||||
ChatMessage.from_assistant(text='{"document_content": "Main text", "title": "Doc Title", "page": "1"}')
|
||||
]
|
||||
}
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
|
||||
result = extractor.run(documents=docs)
|
||||
assert len(result["documents"]) == 1
|
||||
processed = result["documents"][0]
|
||||
assert processed.content == "Main text"
|
||||
assert processed.meta["title"] == "Doc Title"
|
||||
assert processed.meta["page"] == "1"
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_with_llm_failure_raise_on_failure_false(self, mock_doc_to_image_run, caplog):
|
||||
# Mock LLM failure
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.side_effect = Exception("LLM API error")
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=False)
|
||||
|
||||
# Mock DocumentToImageContent to return valid image content
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
|
||||
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
|
||||
result = extractor.run(documents=docs)
|
||||
|
||||
# Document should be in failed_documents
|
||||
assert len(result["documents"]) == 0
|
||||
assert len(result["failed_documents"]) == 1
|
||||
|
||||
failed_doc = result["failed_documents"][0]
|
||||
assert failed_doc.id == docs[0].id
|
||||
assert "extraction_error" in failed_doc.meta
|
||||
assert "LLM failed with exception: LLM API error" in failed_doc.meta["extraction_error"]
|
||||
|
||||
# Check that error was logged
|
||||
assert "LLM" in caplog.text
|
||||
assert "execution failed" in caplog.text
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_with_llm_failure_raise_on_failure_true(self, mock_doc_to_image_run):
|
||||
# Mock LLM failure
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.side_effect = Exception("LLM API error")
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=True)
|
||||
# Mock DocumentToImageContent to return valid image content
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
with pytest.raises(Exception, match="LLM API error"):
|
||||
extractor.run(documents=[Document(content="", meta={"file_path": "/path/to/image.pdf"})])
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_removes_extraction_error_from_previous_runs(self, mock_doc_to_image_run):
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.return_value = {
|
||||
"replies": [ChatMessage.from_assistant(text='{"document_content": "Successfully extracted content"}')]
|
||||
}
|
||||
# Mock DocumentToImageContent to return valid image content
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
|
||||
# Document with previous extraction error
|
||||
docs = [
|
||||
Document(
|
||||
content="",
|
||||
meta={
|
||||
"file_path": "/path/to/image.pdf",
|
||||
"extraction_error": "Previous error",
|
||||
"other_meta": "should_remain",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
result = extractor.run(documents=docs)
|
||||
|
||||
# Document should be successfully processed
|
||||
assert len(result["documents"]) == 1
|
||||
assert len(result["failed_documents"]) == 0
|
||||
|
||||
processed_doc = result["documents"][0]
|
||||
assert processed_doc.content == "Successfully extracted content"
|
||||
assert "extraction_error" not in processed_doc.meta
|
||||
assert processed_doc.meta["other_meta"] == "should_remain"
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_with_mixed_success_and_failure(self, mock_doc_to_image_run):
|
||||
# Mock successful LLM response for first call, failure for second
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.side_effect = [
|
||||
{"replies": [ChatMessage.from_assistant(text='{"document_content": "Successfully extracted content"}')]},
|
||||
Exception("LLM API error"),
|
||||
]
|
||||
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=False)
|
||||
|
||||
# Mock DocumentToImageContent - first succeeds, second fails
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg"), None]
|
||||
}
|
||||
|
||||
doc1 = Document(content="", meta={"file_path": "./test/test_files/images/apple.jpg"})
|
||||
doc2 = Document(content="", meta={"file_path": "/path/to/image.jpg"})
|
||||
docs = [doc1, doc2]
|
||||
|
||||
result = extractor.run(documents=docs)
|
||||
|
||||
# One document should succeed, one should fail
|
||||
assert len(result["documents"]) == 1
|
||||
assert len(result["failed_documents"]) == 1
|
||||
|
||||
successful_doc = result["documents"][0]
|
||||
assert successful_doc.id == doc1.id
|
||||
assert successful_doc.content == "Successfully extracted content"
|
||||
|
||||
failed_doc = result["failed_documents"][0]
|
||||
assert failed_doc.id == doc2.id
|
||||
assert "extraction_error" in failed_doc.meta
|
||||
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
def test_run_json_multiple_keys_metadata_merged(self, mock_doc_to_image_run):
|
||||
"""When LLM returns JSON with multiple keys and no document_content, all keys are merged into metadata."""
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run.return_value = {
|
||||
"replies": [
|
||||
ChatMessage.from_assistant(text='{"title": "Sample Doc", "author": "Test", "document_type": "report"}')
|
||||
]
|
||||
}
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
original_content = "Original content"
|
||||
docs = [Document(content=original_content, meta={"file_path": "/path/to/image.pdf"})]
|
||||
result = extractor.run(documents=docs)
|
||||
assert len(result["documents"]) == 1
|
||||
assert len(result["failed_documents"]) == 0
|
||||
processed = result["documents"][0]
|
||||
assert processed.content == original_content
|
||||
assert processed.meta["title"] == "Sample Doc"
|
||||
assert processed.meta["author"] == "Test"
|
||||
assert processed.meta["document_type"] == "report"
|
||||
|
||||
def test_run_on_thread_with_none_prompt(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator())
|
||||
result = extractor._run_on_thread(None)
|
||||
assert "error" in result
|
||||
assert result["error"] == "Document has no content, skipping LLM call."
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY", None),
|
||||
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
|
||||
)
|
||||
def test_live_run(self, in_memory_doc_store):
|
||||
docs = [Document(content="", meta={"file_path": "./test/test_files/images/apple.jpg"})]
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
|
||||
writer = DocumentWriter(document_store=in_memory_doc_store)
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("extractor", extractor)
|
||||
pipeline.add_component("doc_writer", writer)
|
||||
pipeline.connect("extractor.documents", "doc_writer.documents")
|
||||
pipeline.run(data={"documents": docs})
|
||||
|
||||
doc_store_docs = in_memory_doc_store.filter_documents()
|
||||
assert len(doc_store_docs) >= 1
|
||||
assert len(doc_store_docs[0].content) > 0
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY", None),
|
||||
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
|
||||
)
|
||||
def test_live_run_on_image_with_metadata(self, in_memory_doc_store):
|
||||
"""
|
||||
Live test using image_metadata.png: single prompt; LLM can return JSON with document_content
|
||||
and metadata keys (author, date, document_type, topic) in one response.
|
||||
"""
|
||||
|
||||
prompt = """
|
||||
You are part of an information extraction pipeline that extracts the content of image-based documents.
|
||||
|
||||
Extract the content from the provided image.
|
||||
You need to extract the content exactly.
|
||||
Format everything as markdown.
|
||||
Make sure to retain the reading order of the document.
|
||||
|
||||
**Visual Elements**
|
||||
Do not extract figures, drawings, maps, graphs or any other visual elements.
|
||||
Instead, add a caption that describes briefly what you see in the visual element.
|
||||
You must describe each visual element.
|
||||
If you only see a visual element without other content, you must describe this visual element.
|
||||
Enclose each image caption with [img-caption][/img-caption]
|
||||
|
||||
**Tables**
|
||||
Make sure to format the table in markdown.
|
||||
Add a short caption below the table that describes the table's content.
|
||||
Enclose each table caption with [table-caption][/table-caption].
|
||||
The caption must be placed below the extracted table.
|
||||
|
||||
**Forms**
|
||||
Reproduce checkbox selections with markdown.
|
||||
|
||||
Return a single JSON object. It must contain the key "document_content" with the extracted text as value.
|
||||
|
||||
Include all other extracted information as keys for metadata. All metadata should be returned as separate keys
|
||||
in the JSON object. For example, if you extract the document type and date, you should return:
|
||||
|
||||
{"title": "Example Document", "author": "John Doe", "date": "2024-01-15", "document_type": "invoice"}
|
||||
|
||||
Don't include any metadata in the "document_content" field. The "document_content" field should only contain the
|
||||
image description and any possible text extracted from the image.
|
||||
|
||||
No markdown, no code fence, only raw JSON.
|
||||
|
||||
Document:"""
|
||||
|
||||
image_path = "./test/test_files/images/image_metadata.png"
|
||||
docs = [Document(content="", meta={"file_path": image_path})]
|
||||
extractor = LLMDocumentContentExtractor(
|
||||
prompt=prompt,
|
||||
chat_generator=OpenAIChatGenerator(
|
||||
model="gpt-4.1-nano",
|
||||
generation_kwargs={
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "entity_extraction",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"author": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
"document_type": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
writer = DocumentWriter(document_store=in_memory_doc_store)
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("extractor", extractor)
|
||||
pipeline.add_component("doc_writer", writer)
|
||||
pipeline.connect("extractor.documents", "doc_writer.documents")
|
||||
pipeline.run(data={"documents": docs})
|
||||
|
||||
doc_store_docs = in_memory_doc_store.filter_documents()
|
||||
assert len(doc_store_docs) >= 1
|
||||
doc = doc_store_docs[0]
|
||||
assert len(doc.content) > 0, "Expected non-empty content (image/document description)"
|
||||
assert "extraction_error" not in doc.meta
|
||||
assert "author" in doc.meta, "Expected 'author' key in metadata"
|
||||
assert "date" in doc.meta, "Expected 'date' key in metadata"
|
||||
assert "document_type" in doc.meta, "Expected 'document_type' key in metadata"
|
||||
assert "title" in doc.meta, "Expected 'title' key in metadata"
|
||||
|
||||
|
||||
class TestLLMDocumentContentExtractorAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_no_documents(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
chat_generator = OpenAIChatGenerator()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=chat_generator)
|
||||
result = await extractor.run_async(documents=[])
|
||||
assert result["documents"] == []
|
||||
assert result["failed_documents"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
async def test_run_async_success(self, mock_doc_to_image_run):
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run_async = AsyncMock(
|
||||
return_value={
|
||||
"replies": [ChatMessage.from_assistant(text='{"document_content": "Extracted content from the image"}')]
|
||||
}
|
||||
)
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [
|
||||
ImageContent.from_file_path("./test/test_files/images/apple.jpg"),
|
||||
ImageContent.from_file_path("./test/test_files/images/apple.jpg"),
|
||||
]
|
||||
}
|
||||
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
|
||||
docs = [
|
||||
Document(content="", meta={"file_path": "/path/to/image1.pdf"}),
|
||||
Document(content="", meta={"file_path": "/path/to/image2.pdf"}),
|
||||
]
|
||||
result = await extractor.run_async(documents=docs)
|
||||
|
||||
assert len(result["documents"]) == 2
|
||||
assert len(result["failed_documents"]) == 0
|
||||
for processed_doc in result["documents"]:
|
||||
assert processed_doc.content == "Extracted content from the image"
|
||||
assert "extraction_error" not in processed_doc.meta
|
||||
# one async LLM call per document
|
||||
assert mock_chat_generator.run_async.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
async def test_run_async_with_llm_failure_raise_on_failure_false(self, mock_doc_to_image_run):
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run_async = AsyncMock(side_effect=Exception("LLM API error"))
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=False)
|
||||
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
|
||||
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
|
||||
result = await extractor.run_async(documents=docs)
|
||||
|
||||
assert len(result["documents"]) == 0
|
||||
assert len(result["failed_documents"]) == 1
|
||||
|
||||
failed_doc = result["failed_documents"][0]
|
||||
assert failed_doc.id == docs[0].id
|
||||
assert "extraction_error" in failed_doc.meta
|
||||
assert "LLM failed with exception: LLM API error" in failed_doc.meta["extraction_error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
async def test_run_async_with_llm_failure_raise_on_failure_true(self, mock_doc_to_image_run):
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_chat_generator.run_async = AsyncMock(side_effect=Exception("LLM API error"))
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, raise_on_failure=True)
|
||||
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
|
||||
with pytest.raises(Exception, match="LLM API error"):
|
||||
await extractor.run_async(documents=[Document(content="", meta={"file_path": "/path/to/image.pdf"})])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
async def test_run_async_falls_back_to_sync_run(self, mock_doc_to_image_run):
|
||||
"""If the chat generator has no run_async, the sync run is used and output is still correct."""
|
||||
|
||||
class SyncOnlyChatGenerator:
|
||||
def run(self, messages, **kwargs):
|
||||
return {"replies": [ChatMessage.from_assistant(text='{"document_content": "Extracted via sync run"}')]}
|
||||
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg")]
|
||||
}
|
||||
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=SyncOnlyChatGenerator())
|
||||
|
||||
docs = [Document(content="", meta={"file_path": "/path/to/image.pdf"})]
|
||||
result = await extractor.run_async(documents=docs)
|
||||
|
||||
assert len(result["documents"]) == 1
|
||||
assert len(result["failed_documents"]) == 0
|
||||
assert result["documents"][0].content == "Extracted via sync run"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(DocumentToImageContent, "run")
|
||||
async def test_run_async_respects_max_workers(self, mock_doc_to_image_run):
|
||||
max_workers = 2
|
||||
in_flight = 0
|
||||
peak_in_flight = 0
|
||||
|
||||
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
|
||||
|
||||
async def fake_run_async(messages, **kwargs):
|
||||
nonlocal in_flight, peak_in_flight
|
||||
in_flight += 1
|
||||
peak_in_flight = max(peak_in_flight, in_flight)
|
||||
try:
|
||||
await asyncio.sleep(0.01)
|
||||
return {"replies": [ChatMessage.from_assistant(text='{"document_content": "content"}')]}
|
||||
finally:
|
||||
in_flight -= 1
|
||||
|
||||
mock_chat_generator.run_async = fake_run_async
|
||||
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator, max_workers=max_workers)
|
||||
|
||||
mock_doc_to_image_run.return_value = {
|
||||
"image_contents": [ImageContent.from_file_path("./test/test_files/images/apple.jpg") for _ in range(10)]
|
||||
}
|
||||
|
||||
docs = [Document(content="", meta={"file_path": f"/path/to/image{i}.pdf"}) for i in range(10)]
|
||||
result = await extractor.run_async(documents=docs)
|
||||
|
||||
assert len(result["documents"]) == 10
|
||||
assert peak_in_flight <= max_workers
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY", None),
|
||||
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_run_async(self):
|
||||
docs = [Document(content="", meta={"file_path": "./test/test_files/images/apple.jpg"})]
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
|
||||
|
||||
result = await extractor.run_async(documents=docs)
|
||||
|
||||
assert len(result["failed_documents"]) == 0
|
||||
assert len(result["documents"]) == 1
|
||||
assert len(result["documents"][0].content) > 0
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_chat_generator(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
extractor.warm_up()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_chat_generator(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up_async"])
|
||||
mock_chat_generator.warm_up_async = AsyncMock()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
await extractor.warm_up_async()
|
||||
mock_chat_generator.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
await extractor.warm_up_async()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_chat_generator(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
extractor.close()
|
||||
mock_chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_chat_generator(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close_async"])
|
||||
mock_chat_generator.close_async = AsyncMock()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
await extractor.close_async()
|
||||
mock_chat_generator.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
await extractor.close_async()
|
||||
mock_chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
|
||||
mock_chat_generator = Mock(spec=["run"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
extractor.warm_up()
|
||||
await extractor.warm_up_async()
|
||||
extractor.close()
|
||||
await extractor.close_async()
|
||||
Reference in New Issue
Block a user