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,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"}
|
||||
Reference in New Issue
Block a user