Files
privateGPT/tests/components/multimodality/test_multimodality.py
Javier Martinez 183cd03857 feat!: PrivateGPT revamp v1 (#2230)
* feat!: PrivateGPT revamp v1

* chore(docs): update nodejs
2026-06-02 16:55:46 +02:00

573 lines
18 KiB
Python

from typing import TYPE_CHECKING, Any
import pytest
from llama_index.core.base.llms.types import (
AudioBlock,
ImageBlock,
TextBlock,
)
from llama_index.core.llms import LLM, ChatMessage, MessageRole
from pydantic import Field
from private_gpt.components.chat.processors.chat_history.multimodality.audio_preprocessor import (
preprocess_audio_message,
)
from private_gpt.components.chat.processors.chat_history.multimodality.image_preprocessor import (
preprocess_image_message,
)
from private_gpt.components.chat.processors.chat_history.multimodality.multimodality_preprocessor import (
preprocess_multimodal_history,
preprocess_multimodal_message,
)
from private_gpt.components.chat.processors.chat_history.multimodality.utils import (
extract_audio_blocks,
extract_image_blocks,
requires_image_preprocessing,
)
from private_gpt.components.llm.custom.mock import FunctionCallingLLMMock
from private_gpt.components.multimodality.image_handler import (
ExtractionContent,
ExtractionEvaluation,
ExtractionStrategy,
)
if TYPE_CHECKING:
from private_gpt.components.chat.processors.chat_history.multimodality.models import (
MultimodalProcessingResponse,
)
class MockLLM(FunctionCallingLLMMock):
responses: list[Any] = Field(default_factory=list)
call_count: int = Field(default=0)
messages_history: list[list[ChatMessage]] = Field(default_factory=list)
def __init__(self, responses: list[Any] | None = None, **kwargs: Any) -> None:
# Initialize parent class with only its expected parameters
super().__init__(**kwargs)
# Set our custom fields after parent initialization
if responses is not None:
self.responses = responses
self.responses = self.responses or []
self.call_count = 0
self.messages_history = []
async def astructured_chat(
self, output_cls: type, messages: list[ChatMessage], **kwargs: Any
) -> Any:
self.messages_history.append(messages)
if self.call_count < len(self.responses):
response = self.responses[self.call_count]
self.call_count += 1
return response
raise IndexError("No more mock responses available")
@pytest.fixture
def main_llm() -> LLM:
return MockLLM()
@pytest.fixture
def image_llm() -> LLM:
mock_responses = [
# Strategy inference
ExtractionStrategy(
type="table",
confidence=0.95,
language="en",
has_structure=True,
increase_contrast=False,
),
# Content extraction (complete)
ExtractionContent(
markdown="Describe these images",
is_complete=True,
),
# Evaluation (passes)
ExtractionEvaluation(
score=0.9,
issues_found=[],
),
]
llm = MockLLM(responses=mock_responses)
return llm
@pytest.fixture
def audio_llm() -> LLM:
from private_gpt.components.multimodality.audio_handler import (
TimestampSegment,
TranscriptionContent,
TranscriptionEvaluation,
TranscriptionStrategy,
)
mock_responses = [
TranscriptionStrategy(
type="speech",
confidence=0.9,
language="en",
has_multiple_speakers=False,
has_background_noise=False,
enhance_audio=False,
speaker_diarization=False,
),
TranscriptionContent(
timestamps=[
TimestampSegment(
start=0.0,
end=2.5,
speaker="Speaker 1",
text="Hello, how are you today?",
)
],
is_complete=True,
),
TranscriptionContent(
timestamps=[
TimestampSegment(
start=0.0,
end=2.5,
speaker="Speaker 1",
text="This is chunk two.",
)
],
is_complete=True,
),
TranscriptionContent(
timestamps=[
TimestampSegment(
start=0.0,
end=2.5,
speaker="Speaker 1",
text="This is chunk three.",
)
],
is_complete=True,
),
TranscriptionContent(
timestamps=[
TimestampSegment(
start=0.0,
end=2.5,
speaker="Speaker 1",
text="This is chunk four.",
)
],
is_complete=True,
),
TranscriptionContent(
timestamps=[
TimestampSegment(
start=0.0,
end=2.5,
speaker="Speaker 1",
text="This is chunk five.",
)
],
is_complete=True,
),
TranscriptionEvaluation(
score=0.9,
issues_found=[],
clarity=0.85,
),
]
llm = MockLLM(responses=mock_responses)
return llm
@pytest.fixture
def text_message() -> ChatMessage:
return ChatMessage(
role=MessageRole.USER,
blocks=[TextBlock(text="Hello, how are you?")],
)
@pytest.fixture
def image_message() -> ChatMessage:
return ChatMessage(
role=MessageRole.USER,
blocks=[
TextBlock(text="Describe these images"),
ImageBlock(url="https://picsum.photos/200/300"),
ImageBlock(url="https://picsum.photos/200/300"),
],
)
@pytest.fixture
def audio_message() -> ChatMessage:
return ChatMessage(
role=MessageRole.USER,
blocks=[
TextBlock(text="Transcribe this audio"),
AudioBlock(
url="https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3"
),
],
)
@pytest.fixture
def multimodal_message() -> ChatMessage:
return ChatMessage(
role=MessageRole.USER,
blocks=[
TextBlock(text="Process this media"),
ImageBlock(url="https://picsum.photos/200/300"),
AudioBlock(
url="https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3"
),
],
)
class TestImagePreprocessing:
@pytest.mark.asyncio
async def test_same_llm_no_preprocessing(
self, main_llm: LLM, image_message: ChatMessage
) -> None:
responses = []
async for response in preprocess_image_message(
main_llm, image_message, main_llm
):
responses.append(response)
result = responses[-1].message
assert result is image_message
@pytest.mark.asyncio
async def test_different_llm_preprocessing_occurs(
self, main_llm: LLM, image_llm: LLM, image_message: ChatMessage
) -> None:
responses = []
async for response in preprocess_image_message(
main_llm, image_message, image_llm
):
responses.append(response)
result = responses[-1].message
assert result is not None
assert "images in their message" in str(result.blocks[-1].text)
assert result.role == MessageRole.USER
assert len(result.blocks) >= 2
assert isinstance(result.blocks[-1], TextBlock)
@pytest.mark.asyncio
async def test_text_message_passes_through(
self, main_llm: LLM, image_llm: LLM, text_message: ChatMessage
) -> None:
responses = []
async for response in preprocess_image_message(
main_llm, text_message, image_llm
):
responses.append(response)
result = responses[-1].message
assert result is text_message
@pytest.mark.asyncio
async def test_missing_image_llm_raises_error(
self, main_llm: LLM, image_message: ChatMessage
) -> None:
with pytest.raises(
ValueError, match="Image blocks found but no image-capable LLM"
):
async for _ in preprocess_image_message(main_llm, image_message, None):
pass
class TestAudioPreprocessing:
@pytest.mark.asyncio
async def test_same_llm_no_preprocessing(
self, main_llm: LLM, audio_message: ChatMessage
) -> None:
responses = []
async for response in preprocess_audio_message(
main_llm, audio_message, main_llm
):
responses.append(response)
result = responses[-1].message
assert result is audio_message
@pytest.mark.asyncio
async def test_different_llm_preprocessing_occurs(
self, main_llm: LLM, audio_llm: LLM, audio_message: ChatMessage
) -> None:
responses = []
async for response in preprocess_audio_message(
main_llm, audio_message, audio_llm
):
responses.append(response)
result = responses[-1].message
assert result is not None
assert "audios in their message" in str(result.blocks[-1].text)
assert result.role == MessageRole.USER
assert len(result.blocks) >= 2
assert isinstance(result.blocks[-1], TextBlock)
@pytest.mark.asyncio
async def test_text_message_passes_through(
self, main_llm: LLM, audio_llm: LLM, text_message: ChatMessage
) -> None:
responses = []
async for response in preprocess_audio_message(
main_llm, text_message, audio_llm
):
responses.append(response)
result = responses[-1].message
assert result is text_message
@pytest.mark.asyncio
async def test_missing_audio_llm_raises_error(
self, main_llm: LLM, audio_message: ChatMessage
) -> None:
with pytest.raises(
ValueError, match="Audio blocks found but no audio-capable LLM"
):
async for _ in preprocess_audio_message(main_llm, audio_message, None):
pass
class TestMultimodalPreprocessing:
@pytest.mark.asyncio
async def test_same_llms_no_preprocessing(
self, main_llm: LLM, multimodal_message: ChatMessage
) -> None:
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_message(
main_llm,
multimodal_message.model_copy(),
image_multimodal_llm=main_llm,
audio_multimodal_llm=main_llm,
):
responses.append(response)
result = responses[-1].modified_message
assert result.content == multimodal_message.content
assert result.role == multimodal_message.role
@pytest.mark.asyncio
async def test_image_only_preprocessing(
self, main_llm: LLM, image_llm: LLM, image_message: ChatMessage
) -> None:
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_message(
main_llm,
image_message,
image_multimodal_llm=image_llm,
audio_multimodal_llm=main_llm,
):
responses.append(response)
result = responses[-1].modified_message
assert len(result.blocks) >= 2
assert isinstance(result.blocks[-1], TextBlock)
assert "images in their message" in result.blocks[-1].text
@pytest.mark.asyncio
async def test_audio_only_preprocessing(
self,
main_llm: LLM,
image_llm: LLM,
audio_llm: LLM,
multimodal_message: ChatMessage,
) -> None:
# Audio processing now yields failed status instead of raising directly
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_message(
main_llm,
multimodal_message.model_copy(),
image_multimodal_llm=image_llm,
audio_multimodal_llm=audio_llm,
):
responses.append(response)
result = responses[-1].modified_message
assert len(result.blocks) >= 2
assert isinstance(result.blocks[-1], TextBlock)
assert "audios in their message" in result.blocks[-1].text
@pytest.mark.asyncio
async def test_text_message_passes_through(
self,
main_llm: LLM,
image_llm: LLM,
audio_llm: LLM,
text_message: ChatMessage,
) -> None:
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_message(
main_llm,
text_message,
image_multimodal_llm=image_llm,
audio_multimodal_llm=audio_llm,
):
responses.append(response)
result = responses[-1].modified_message
assert result.content == text_message.content
assert result.role == text_message.role
class TestHistoryPreprocessing:
@pytest.mark.asyncio
async def test_same_llm_no_preprocessing(
self, main_llm: LLM, text_message: ChatMessage, image_message: ChatMessage
) -> None:
history = [text_message, image_message]
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_history(
main_llm,
history,
image_multimodal_llm=main_llm,
audio_multimodal_llm=main_llm,
):
responses.append(response)
result = responses[-1].chat_history
assert len(result) == len(history)
assert result[0].content == text_message.content
assert result[1].content == image_message.content
@pytest.mark.asyncio
async def test_processes_most_recent_user_message(
self,
main_llm: LLM,
image_llm: LLM,
text_message: ChatMessage,
image_message: ChatMessage,
) -> None:
history = [
ChatMessage(
role=MessageRole.USER,
content="First",
blocks=[ImageBlock(url="https://picsum.photos/300/300")],
),
ChatMessage(role=MessageRole.ASSISTANT, content="Response", blocks=[]),
image_message,
]
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_history(
main_llm, history, image_multimodal_llm=image_llm, audio_multimodal_llm=None
):
responses.append(response)
result = responses[-1].chat_history
assert len(result) == 3
assert len(result[2].blocks) >= 2
assert isinstance(result[2].blocks[-1], TextBlock)
assert "images in their message" in result[2].blocks[-1].text
assert result[0].content == "First"
assert all(isinstance(block, TextBlock) for block in result[0].blocks)
@pytest.mark.asyncio
async def test_empty_history_returns_none(
self, main_llm: LLM, image_llm: LLM
) -> None:
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_history(
main_llm, None, image_multimodal_llm=image_llm, audio_multimodal_llm=None
):
responses.append(response)
assert len(responses) == 1
assert responses[0].chat_history is None
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_history(
main_llm, [], image_multimodal_llm=image_llm, audio_multimodal_llm=None
):
responses.append(response)
assert len(responses) == 1
assert responses[0].chat_history == []
class TestTypeChecking:
def test_llm_objects_identity_comparison(
self, main_llm: LLM, image_llm: LLM
) -> None:
assert not requires_image_preprocessing(main_llm, main_llm)
assert requires_image_preprocessing(main_llm, image_llm)
assert requires_image_preprocessing(main_llm, None)
def test_extract_functions_return_correct_types(
self, multimodal_message: ChatMessage
) -> None:
images = extract_image_blocks(multimodal_message)
audios = extract_audio_blocks(multimodal_message)
assert isinstance(images, list)
assert isinstance(audios, list)
assert len(images) == 1
assert len(audios) == 1
assert isinstance(images[0], ImageBlock)
assert isinstance(audios[0], AudioBlock)
@pytest.mark.parametrize(
("same_image_llm", "same_audio_llm", "expected_preprocessing"),
[
(True, True, False),
(True, False, True),
(False, True, True),
(False, False, True),
],
)
class TestParametrizedPreprocessing:
@pytest.mark.asyncio
async def test_preprocessing_decision_logic(
self,
main_llm: LLM,
image_llm: LLM,
audio_llm: LLM,
multimodal_message: ChatMessage,
same_image_llm: bool,
same_audio_llm: bool,
expected_preprocessing: bool,
) -> None:
actual_image_llm = main_llm if same_image_llm else image_llm
actual_audio_llm = main_llm if same_audio_llm else audio_llm
responses: list[MultimodalProcessingResponse] = []
async for response in preprocess_multimodal_message(
main_llm,
multimodal_message.model_copy(),
image_multimodal_llm=actual_image_llm,
audio_multimodal_llm=actual_audio_llm,
):
responses.append(response)
result = responses[-1].modified_message
if expected_preprocessing:
assert result.content != multimodal_message.content
if not same_image_llm:
assert len(result.blocks) >= 2
assert any(
"images in their message" in block.text
for block in result.blocks
if isinstance(block, TextBlock)
)
if not same_audio_llm:
assert any(
"audios in their message" in block.text
for block in result.blocks
if isinstance(block, TextBlock)
)
else:
assert result.content == multimodal_message.content
assert result.role == multimodal_message.role