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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -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()
@@ -0,0 +1,571 @@
# 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
import pytest
from haystack import Document, Pipeline
from haystack.components.extractors import LLMMetadataExtractor
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.writers import DocumentWriter
from haystack.dataclasses import ChatMessage
from haystack.document_stores.in_memory import InMemoryDocumentStore
@pytest.fixture
def ner_prompt() -> str:
return """-Goal-
Given text and a list of entity types, identify all entities of those types from the text.
-Steps-
1. Identify all entities. For each identified entity, extract the following information:
- entity_name: Name of the entity, capitalized
- entity_type: One of the following types: [organization, product, service, industry]
Format each entity as {"entity": <entity_name>, "entity_type": <entity_type>}
2. Return output in a single list with all the entities identified in steps 1.
-Examples-
######################
Example 1:
entity_types: [organization, person, partnership, financial metric, product, service, industry, investment strategy, market trend]
text: Another area of strength is our co-brand issuance. Visa is the primary network partner for eight of the top
10 co-brand partnerships in the US today and we are pleased that Visa has finalized a multi-year extension of
our successful credit co-branded partnership with Alaska Airlines, a portfolio that benefits from a loyal customer
base and high cross-border usage.
We have also had significant co-brand momentum in CEMEA. First, we launched a new co-brand card in partnership
with Qatar Airways, British Airways and the National Bank of Kuwait. Second, we expanded our strong global
Marriott relationship to launch Qatar's first hospitality co-branded card with Qatar Islamic Bank. Across the
United Arab Emirates, we now have exclusive agreements with all the leading airlines marked by a recent
agreement with Emirates Skywards.
And we also signed an inaugural Airline co-brand agreement in Morocco with Royal Air Maroc. Now newer digital
issuers are equally
------------------------
output:
{"entities": [{"entity": "Visa", "entity_type": "company"}, {"entity": "Alaska Airlines", "entity_type": "company"}, {"entity": "Qatar Airways", "entity_type": "company"}, {"entity": "British Airways", "entity_type": "company"}, {"entity": "National Bank of Kuwait", "entity_type": "company"}, {"entity": "Marriott", "entity_type": "company"}, {"entity": "Qatar Islamic Bank", "entity_type": "company"}, {"entity": "Emirates Skywards", "entity_type": "company"}, {"entity": "Royal Air Maroc", "entity_type": "company"}]}
#############################
-Real Data-
######################
entity_types: [company, organization, person, country, product, service]
text: {{ document.content }}
######################
output:
""" # noqa: E501
class TestLLMMetadataExtractor:
def test_init(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", expected_keys=["key1", "key2"], chat_generator=chat_generator
)
assert isinstance(extractor._chat_generator, OpenAIChatGenerator)
# Not testing specific model name, just that it's set (truthy)
assert extractor._chat_generator.model
assert extractor._chat_generator.generation_kwargs == {"temperature": 0.5}
assert extractor.expected_keys == ["key1", "key2"]
def test_init_missing_prompt_variable(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
with pytest.raises(ValueError):
_ = LLMMetadataExtractor(
prompt="prompt {{ wrong_variable }}", expected_keys=["key1", "key2"], chat_generator=chat_generator
)
def test_init_no_prompt_variable(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
with pytest.raises(ValueError, match="exactly one variable called 'document'.*no variables"):
_ = LLMMetadataExtractor(prompt="prompt without variables", chat_generator=chat_generator)
def test_init_too_many_prompt_variables(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator()
with pytest.raises(ValueError, match="exactly one variable called 'document'"):
_ = LLMMetadataExtractor(prompt="prompt {{ document.content }} {{ extra }}", chat_generator=chat_generator)
def test_init_fails_without_chat_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
with pytest.raises(TypeError):
_ = LLMMetadataExtractor(prompt="prompt {{document.content}}", expected_keys=["key1", "key2"])
def test_to_dict_openai(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"temperature": 0.5})
extractor = LLMMetadataExtractor(
prompt="some prompt that was used with the LLM {{document.content}}",
expected_keys=["key1", "key2"],
chat_generator=chat_generator,
raise_on_failure=True,
)
extractor_dict = extractor.to_dict()
assert extractor_dict == {
"type": "haystack.components.extractors.llm_metadata_extractor.LLMMetadataExtractor",
"init_parameters": {
"prompt": "some prompt that was used with the LLM {{document.content}}",
"expected_keys": ["key1", "key2"],
"raise_on_failure": True,
"chat_generator": chat_generator.to_dict(),
"page_range": None,
"max_workers": 3,
},
}
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.llm_metadata_extractor.LLMMetadataExtractor",
"init_parameters": {
"prompt": "some prompt that was used with the LLM {{document.content}}",
"expected_keys": ["key1", "key2"],
"chat_generator": chat_generator.to_dict(),
"raise_on_failure": True,
},
}
extractor = LLMMetadataExtractor.from_dict(extractor_dict)
assert extractor.raise_on_failure is True
assert extractor.expected_keys == ["key1", "key2"]
assert extractor.prompt == "some prompt that was used with the LLM {{document.content}}"
assert extractor._chat_generator.to_dict() == chat_generator.to_dict()
def test_extract_metadata(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator())
result = extractor._extract_metadata(llm_answer='{"output": "valid json"}')
assert result == {"output": "valid json"}
def test_extract_metadata_invalid_json(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator(), raise_on_failure=True
)
with pytest.raises(ValueError):
extractor._extract_metadata(llm_answer='{"output: "valid json"}')
def test_extract_metadata_missing_key(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator(), expected_keys=["key1"]
)
extractor._extract_metadata(llm_answer='{"output": "valid json"}')
assert "Response from the LLM is not valid JSON or missing expected keys" in caplog.text
def test_prepare_prompts(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="some_user_definer_prompt {{document.content}}", chat_generator=OpenAIChatGenerator()
)
docs = [
Document(content="deepset was founded in 2018 in Berlin, and is known for its Haystack framework"),
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers library"
),
]
prompts = extractor._prepare_prompts(docs)
assert prompts == [
ChatMessage.from_dict(
{
"_role": "user",
"_meta": {},
"_name": None,
"_content": [
{
"text": "some_user_definer_prompt deepset was founded in 2018 in Berlin, and is known for "
"its Haystack framework"
}
],
}
),
ChatMessage.from_dict(
{
"_role": "user",
"_meta": {},
"_name": None,
"_content": [
{
"text": "some_user_definer_prompt Hugging Face is a company founded in Paris, France and "
"is known for its Transformers library"
}
],
}
),
]
def test_prepare_prompts_empty_document(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="some_user_definer_prompt {{document.content}}", chat_generator=OpenAIChatGenerator()
)
docs = [
Document(content=""),
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers library"
),
]
prompts = extractor._prepare_prompts(docs)
assert prompts == [
None,
ChatMessage.from_dict(
{
"_role": "user",
"_meta": {},
"_name": None,
"_content": [
{
"text": "some_user_definer_prompt Hugging Face is a company founded in Paris, "
"France and is known for its Transformers library"
}
],
}
),
]
def test_prepare_prompts_expanded_range(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(
prompt="some_user_definer_prompt {{document.content}}",
chat_generator=OpenAIChatGenerator(),
page_range=["1-2"],
)
docs = [
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers "
"library\fPage 2\fPage 3"
)
]
prompts = extractor._prepare_prompts(docs, expanded_range=[1, 2])
assert prompts == [
ChatMessage.from_dict(
{
"_role": "user",
"_meta": {},
"_name": None,
"_content": [
{
"text": "some_user_definer_prompt Hugging Face is a company founded in Paris, France and "
"is known for its Transformers library\x0cPage 2\x0c"
}
],
}
)
]
def test_run_no_documents(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator())
result = extractor.run(documents=[])
assert result["documents"] == []
assert result["failed_documents"] == []
@pytest.mark.asyncio
async def test_run_no_documents_async(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator())
result = await extractor.run_async(documents=[])
assert result["documents"] == []
assert result["failed_documents"] == []
def test_run_with_document_content_none(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
# Mock the chat generator to prevent actual LLM calls
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", chat_generator=mock_chat_generator, expected_keys=["some_key"]
)
# Document with None content
doc_with_none_content = Document(content=None)
# also test with empty string content
doc_with_empty_content = Document(content="")
docs = [doc_with_none_content, doc_with_empty_content]
result = extractor.run(documents=docs)
# Assert that the documents are in failed_documents
assert len(result["documents"]) == 0
assert len(result["failed_documents"]) == 2
failed_doc_none = result["failed_documents"][0]
assert failed_doc_none.id == doc_with_none_content.id
assert "metadata_extraction_error" in failed_doc_none.meta
assert failed_doc_none.meta["metadata_extraction_error"] == "Document has no content, skipping LLM call."
assert "metadata_extraction_error" not in doc_with_none_content.meta
failed_doc_empty = result["failed_documents"][1]
assert failed_doc_empty.id == doc_with_empty_content.id
assert "metadata_extraction_error" in failed_doc_empty.meta
assert failed_doc_empty.meta["metadata_extraction_error"] == "Document has no content, skipping LLM call."
assert "metadata_extraction_error" not in doc_with_empty_content.meta
# Ensure no attempt was made to call the LLM
mock_chat_generator.run.assert_not_called()
@pytest.mark.asyncio
async def test_run_with_document_content_none_async(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
# Mock the chat generator to prevent actual LLM calls
mock_chat_generator = Mock(spec=OpenAIChatGenerator)
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}", chat_generator=mock_chat_generator, expected_keys=["some_key"]
)
# Document with None content
doc_with_none_content = Document(content=None)
# also test with empty string content
doc_with_empty_content = Document(content="")
docs = [doc_with_none_content, doc_with_empty_content]
result = await extractor.run_async(documents=docs)
# Assert that the documents are in failed_documents
assert len(result["documents"]) == 0
assert len(result["failed_documents"]) == 2
failed_doc_none = result["failed_documents"][0]
assert failed_doc_none.id == doc_with_none_content.id
assert "metadata_extraction_error" in failed_doc_none.meta
assert failed_doc_none.meta["metadata_extraction_error"] == "Document has no content, skipping LLM call."
assert "metadata_extraction_error" not in doc_with_none_content.meta
failed_doc_empty = result["failed_documents"][1]
assert failed_doc_empty.id == doc_with_empty_content.id
assert "metadata_extraction_error" in failed_doc_empty.meta
assert failed_doc_empty.meta["metadata_extraction_error"] == "Document has no content, skipping LLM call."
assert "metadata_extraction_error" not in doc_with_empty_content.meta
# Ensure no attempt was made to call the LLM
mock_chat_generator.run_async.assert_not_called()
@pytest.mark.asyncio
async def test_run_async_respects_max_workers(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
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('{"entities": []}')]}
finally:
in_flight -= 1
mock_chat_generator.run_async = fake_run_async
extractor = LLMMetadataExtractor(
prompt="prompt {{document.content}}",
chat_generator=mock_chat_generator,
expected_keys=["entities"],
max_workers=max_workers,
)
docs = [Document(content=f"doc {i}") 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.",
)
def test_live_run(self, in_memory_doc_store: InMemoryDocumentStore, ner_prompt: str) -> None:
docs = [
Document(content="deepset was founded in 2018 in Berlin, and is known for its Haystack framework"),
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers library"
),
]
extractor = LLMMetadataExtractor(
prompt=ner_prompt,
expected_keys=["entities"],
chat_generator=OpenAIChatGenerator(
model="gpt-4.1-nano",
generation_kwargs={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "entity_extraction",
"schema": {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"entity": {"type": "string"},
"entity_type": {"type": "string"},
},
"required": ["entity", "entity_type"],
"additionalProperties": False,
},
}
},
"required": ["entities"],
"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) == 2
assert "entities" in doc_store_docs[0].meta
assert "entities" in doc_store_docs[1].meta
@pytest.mark.asyncio
@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.",
)
async def test_live_run_async(self, in_memory_doc_store: InMemoryDocumentStore, ner_prompt: str) -> None:
docs = [
Document(content="deepset was founded in 2018 in Berlin, and is known for its Haystack framework"),
Document(
content="Hugging Face is a company founded in Paris, France and is known for its Transformers library"
),
]
extractor = LLMMetadataExtractor(
prompt=ner_prompt,
expected_keys=["entities"],
chat_generator=OpenAIChatGenerator(
model="gpt-4.1-nano",
generation_kwargs={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "entity_extraction",
"schema": {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"entity": {"type": "string"},
"entity_type": {"type": "string"},
},
"required": ["entity", "entity_type"],
"additionalProperties": False,
},
}
},
"required": ["entities"],
"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")
await pipeline.run_async(data={"documents": docs})
doc_store_docs = await in_memory_doc_store.filter_documents_async()
assert len(doc_store_docs) == 2
assert "entities" in doc_store_docs[0].meta
assert "entities" in doc_store_docs[1].meta
class TestComponentLifecycle:
def test_warm_up_delegates_to_inner_components(self):
mock_chat_generator = Mock(spec=["run", "warm_up"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "warm_up"])
extractor.warm_up()
mock_chat_generator.warm_up.assert_called_once()
extractor.splitter.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_inner_components(self):
mock_chat_generator = Mock(spec=["run", "warm_up", "warm_up_async"])
mock_chat_generator.warm_up_async = AsyncMock()
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "warm_up_async"])
extractor.splitter.warm_up_async = AsyncMock()
await extractor.warm_up_async()
mock_chat_generator.warm_up_async.assert_awaited_once()
extractor.splitter.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 = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "warm_up"])
await extractor.warm_up_async()
mock_chat_generator.warm_up.assert_called_once()
extractor.splitter.warm_up.assert_called_once()
def test_close_delegates_to_inner_components(self):
mock_chat_generator = Mock(spec=["run", "close"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "close"])
extractor.close()
mock_chat_generator.close.assert_called_once()
extractor.splitter.close.assert_called_once()
async def test_close_async_delegates_to_inner_components(self):
mock_chat_generator = Mock(spec=["run", "close_async"])
mock_chat_generator.close_async = AsyncMock()
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "close_async"])
extractor.splitter.close_async = AsyncMock()
await extractor.close_async()
mock_chat_generator.close_async.assert_awaited_once()
extractor.splitter.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
mock_chat_generator = Mock(spec=["run", "close"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run", "close"])
await extractor.close_async()
mock_chat_generator.close.assert_called_once()
extractor.splitter.close.assert_called_once()
async def test_lifecycle_is_safe_when_inner_lacks_methods(self):
mock_chat_generator = Mock(spec=["run"])
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
extractor.splitter = Mock(spec=["run"])
extractor.warm_up()
await extractor.warm_up_async()
extractor.close()
await extractor.close_async()
@@ -0,0 +1,227 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import pytest
from haystack import Pipeline
from haystack.components.extractors.regex_text_extractor import RegexTextExtractor
from haystack.dataclasses import ChatMessage
class TestRegexTextExtractor:
def test_init_with_capture_group(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
assert extractor.regex_pattern == pattern
def test_init_without_capture_group(self):
pattern = r"<issue>"
extractor = RegexTextExtractor(regex_pattern=pattern)
assert extractor.regex_pattern == pattern
def test_to_dict(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
data = extractor.to_dict()
assert data == {
"type": "haystack.components.extractors.regex_text_extractor.RegexTextExtractor",
"init_parameters": {"regex_pattern": pattern},
}
def test_from_dict(self):
data = {
"type": "haystack.components.extractors.regex_text_extractor.RegexTextExtractor",
"init_parameters": {"regex_pattern": r'<issue url="(.+?)">'},
}
extractor = RegexTextExtractor.from_dict(data=data)
assert extractor.regex_pattern == r'<issue url="(.+?)">'
def test_from_dict_with_removed_parameter(self, caplog):
caplog.set_level(logging.WARNING)
data = {
"type": "haystack.components.extractors.regex_text_extractor.RegexTextExtractor",
"init_parameters": {"regex_pattern": r'<issue url="(.+?)">', "return_empty_on_no_match": False},
}
extractor = RegexTextExtractor.from_dict(data=data)
assert extractor.regex_pattern == r'<issue url="(.+?)">'
assert "The `return_empty_on_no_match` init parameter has been removed" in caplog.text
def test_extract_from_string_with_capture_group(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
text = '<issue url="github.com/hahahaha">hahahah</issue>'
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "github.com/hahahaha"}
def test_extract_from_string_without_capture_group(self):
pattern = r"<issue>"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "This is an <issue> tag in the text"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "<issue>"}
def test_extract_from_string_no_match(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "This text has no matching pattern"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": ""}
def test_extract_from_string_empty_input(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
text = ""
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": ""}
def test_extract_from_chat_messages_single_message(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = [ChatMessage.from_user('<issue url="github.com/test">test issue</issue>')]
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": "github.com/test"}
def test_extract_from_chat_messages_multiple_messages(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = [
ChatMessage.from_user('First message with <issue url="first.com">first</issue>'),
ChatMessage.from_user('Second message with <issue url="second.com">second</issue>'),
ChatMessage.from_user('Last message with <issue url="last.com">last</issue>'),
]
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": "last.com"}
def test_extract_from_chat_messages_no_match_in_last(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = [
ChatMessage.from_user('First message with <issue url="first.com">first</issue>'),
ChatMessage.from_user("Last message with no matching pattern"),
]
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": ""}
def test_extract_from_chat_messages_empty_list(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = []
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": ""}
def test_extract_from_chat_messages_invalid_type(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = ["not a ChatMessage object"]
with pytest.raises(TypeError, match="Expected ChatMessage object, got <class 'str'>"):
extractor.run(text_or_messages=messages)
def test_extract_from_chat_messages_last_message_no_text(self):
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
messages = [ChatMessage.from_assistant(text=None)]
result = extractor.run(text_or_messages=messages)
assert result == {"captured_text": ""}
def test_multiple_capture_groups(self):
pattern = r"(\w+)@(\w+)\.(\w+)"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "Contact us at user@example.com for support"
result = extractor.run(text_or_messages=text)
# return the first capture group (username)
assert result == {"captured_text": "user"}
def test_special_characters_in_pattern(self):
"""Test regex pattern with special characters."""
pattern = r"\[(\w+)\]"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "This has [special] characters [in] brackets"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "special"}
def test_whitespace_handling(self):
"""Test regex pattern with whitespace handling."""
pattern = r"\s+(\w+)\s+"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "word1 word2 word3"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "word2"}
def test_nested_capture_groups(self):
"""Test regex with nested capture groups."""
pattern = r'<(\w+)\s+attr="([^"]+)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
text = '<div attr="value">content</div>'
result = extractor.run(text_or_messages=text)
# Should return the first capture group (tag name)
assert result == {"captured_text": "div"}
def test_optional_capture_group(self):
"""Test regex with optional capture group."""
pattern = r"(\w+)(?:@(\w+))?"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "username@domain"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "username"}
def test_optional_capture_group_no_match(self):
"""Test regex with optional capture group when optional part is missing."""
pattern = r"(\w+)(?:@(\w+))?"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "username"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "username"}
def test_pipeline_integration(self):
"""Test component integration in a Haystack pipeline."""
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
pipe = Pipeline()
pipe.add_component("extractor", extractor)
text = '<issue url="github.com/pipeline-test">pipeline test</issue>'
result = pipe.run(data={"extractor": {"text_or_messages": text}})
assert result["extractor"] == {"captured_text": "github.com/pipeline-test"}
def test_pipeline_integration_with_chat_messages(self):
"""Test component integration in pipeline with ChatMessages."""
pattern = r'<issue url="(.+?)">'
extractor = RegexTextExtractor(regex_pattern=pattern)
pipe = Pipeline()
pipe.add_component("extractor", extractor)
messages = [ChatMessage.from_user('<issue url="github.com/chat-test">chat test</issue>')]
result = pipe.run(data={"extractor": {"text_or_messages": messages}})
assert result["extractor"] == {"captured_text": "github.com/chat-test"}
def test_very_long_text(self):
pattern = r"(\d+)"
extractor = RegexTextExtractor(regex_pattern=pattern)
long_text = "a" * 10000 + "123" + "b" * 10000
result = extractor.run(text_or_messages=long_text)
assert result == {"captured_text": "123"}
def test_multiple_matches_first_is_captured(self):
pattern = r"(\d+)"
extractor = RegexTextExtractor(regex_pattern=pattern)
text = "First: 123, Second: 456, Third: 789"
result = extractor.run(text_or_messages=text)
assert result == {"captured_text": "123"}