haiku.rag/tests/test_utils.py
Yiorgis Gozadinos f96a428ef1
Fix defects found reviewing the coverage work
check_source_accessible narrowed its handler to ValueError, but Path.exists
re-raises errno values outside its ignored set (EACCES, ENAMETOOLONG). Those
were swallowed before and now escaped into the rebuild sweep the guard exists
to protect. Catch OSError too.

Restore the arity guard in _common_path_prefix: without it an empty list
raises from min() and a single label yields a prefix covering the whole path.

Two tests would have hung rather than failed on regression (the vacuum skip
and the protected-wait cancellation); both are now bounded. The import
vacuum test raced against the done-callback that discards the task, and now
spies on the call instead, with a negative control.

Replace assertions that could not fail: blank-query search against an empty
corpus, a batch flush counted against an empty table, a picture description
asserting its own input state, and an FS scheme check with nothing on disk to
resolve. The get_model matrix asserted only the returned type across 26
cases and now pins the per-provider settings. The three batching tests now
count flushes, which revealed embed-only writes through chunks_table.add
rather than _flush_rebuild_batch.
2026-07-27 10:44:32 +03:00

909 lines
28 KiB
Python

import importlib.util
import pytest
from pydantic_ai.models.openai import OpenAIChatModel
from haiku.rag.config import Config
from haiku.rag.config.models import ModelConfig
from haiku.rag.converters import get_converter
from haiku.rag.utils import get_model
# Check for optional dependencies
HAS_ANTHROPIC = importlib.util.find_spec("anthropic") is not None
HAS_GOOGLE = importlib.util.find_spec("google.genai") is not None
HAS_GROQ = importlib.util.find_spec("groq") is not None
HAS_BEDROCK = importlib.util.find_spec("botocore") is not None
@pytest.mark.asyncio
async def test_text_to_docling_document():
"""Test text to DoclingDocument conversion."""
# Test basic text conversion
simple_text = "This is a simple text document."
converter = get_converter(Config)
doc = await converter.convert_text(simple_text)
# Verify it returns a DoclingDocument
from docling_core.types.doc.document import DoclingDocument
assert isinstance(doc, DoclingDocument)
# Verify the content can be exported back to markdown
markdown = doc.export_to_markdown()
assert "This is a simple text document." in markdown
@pytest.mark.asyncio
async def test_text_to_docling_document_with_custom_name():
"""Test text to DoclingDocument conversion with custom name parameter."""
code_text = """# Python Code
```python
def hello():
print("Hello, World!")
return True
```"""
converter = get_converter(Config)
doc = await converter.convert_text(code_text, name="hello.md")
# Verify it's a valid DoclingDocument
from docling_core.types.doc.document import DoclingDocument
assert isinstance(doc, DoclingDocument)
# Verify the content is preserved
markdown = doc.export_to_markdown()
assert "def hello():" in markdown
assert "Hello, World!" in markdown
@pytest.mark.asyncio
async def test_text_to_docling_document_markdown_content():
"""Test text to DoclingDocument conversion with markdown content."""
markdown_text = """# Test Document
This is a test document with:
- List item 1
- List item 2
## Code Example
```python
def test():
return "Hello"
```
**Bold text** and *italic text*."""
converter = get_converter(Config)
doc = await converter.convert_text(markdown_text, name="test.md")
# Verify it's a DoclingDocument
from docling_core.types.doc.document import DoclingDocument
assert isinstance(doc, DoclingDocument)
# Verify the markdown structure is preserved
result_markdown = doc.export_to_markdown()
assert "# Test Document" in result_markdown
assert "List item 1" in result_markdown
assert "def test():" in result_markdown
@pytest.mark.asyncio
async def test_text_to_docling_document_empty_content():
"""Test text to DoclingDocument conversion with empty content."""
converter = get_converter(Config)
doc = await converter.convert_text("")
# Should still create a valid DoclingDocument
from docling_core.types.doc.document import DoclingDocument
assert isinstance(doc, DoclingDocument)
# Export should work even with empty content
markdown = doc.export_to_markdown()
assert isinstance(markdown, str)
@pytest.mark.asyncio
async def test_text_to_docling_document_unicode_content():
"""Test text to DoclingDocument conversion with unicode content."""
unicode_text = """# 测试文档
这是一个包含中文的测试文档。
## Código en Español
```javascript
function saludar() {
return "¡Hola mundo!";
}
```
Emoji test: 🚀 ✅ 📝"""
converter = get_converter(Config)
doc = await converter.convert_text(unicode_text, name="unicode.md")
# Verify it's a DoclingDocument
from docling_core.types.doc.document import DoclingDocument
assert isinstance(doc, DoclingDocument)
# Verify unicode content is preserved
result_markdown = doc.export_to_markdown()
assert "测试文档" in result_markdown
assert "¡Hola mundo!" in result_markdown
assert "🚀" in result_markdown
@pytest.mark.parametrize(
"kwargs,expected_settings",
[
({"provider": "ollama", "name": "llama3"}, None),
(
{"provider": "ollama", "name": "gpt-oss", "enable_thinking": False},
{"openai_reasoning_effort": "low"},
),
(
{"provider": "ollama", "name": "gpt-oss", "enable_thinking": True},
{"openai_reasoning_effort": "high"},
),
(
{
"provider": "ollama",
"name": "llama3",
"temperature": 0.5,
"max_tokens": 100,
},
{"temperature": 0.5, "max_tokens": 100},
),
({"provider": "openai", "name": "gpt-4o"}, None),
(
{"provider": "openai", "name": "o1", "enable_thinking": True},
{"openai_reasoning_effort": "high"},
),
(
{"provider": "openai", "name": "o1", "enable_thinking": False},
{"openai_reasoning_effort": "low"},
),
(
{
"provider": "openai",
"name": "gpt-4o",
"enable_thinking": False,
"temperature": 0.7,
"max_tokens": 500,
},
# gpt-4o is not a reasoning model, so only the common settings land.
{"temperature": 0.7, "max_tokens": 500},
),
],
ids=[
"ollama",
"ollama_thinking_off",
"ollama_thinking_on",
"ollama_with_settings",
"openai",
"openai_reasoning_thinking_on",
"openai_reasoning_thinking_off",
"openai_all_settings",
],
)
def test_get_model_openai_chat_settings(kwargs, expected_settings):
"""Each ollama/openai configuration maps onto the expected model settings."""
result = get_model(ModelConfig(**kwargs))
assert isinstance(result, OpenAIChatModel)
if expected_settings is None:
assert result.settings is None
return
assert result.settings is not None
for key, value in expected_settings.items():
assert result.settings.get(key) == value
def test_get_model_ollama_appends_v1_to_per_model_base_url():
"""Per-model base_url without /v1 should get it appended."""
model_config = ModelConfig(
provider="ollama", name="qwen3.6", base_url="http://my-ollama:11434"
)
result = get_model(model_config)
assert isinstance(result, OpenAIChatModel)
assert str(result.client.base_url).rstrip("/").endswith("/v1")
def test_get_model_ollama_does_not_double_append_v1():
"""If the per-model base_url already ends with /v1, leave it alone."""
model_config = ModelConfig(
provider="ollama", name="qwen3.6", base_url="http://my-ollama:11434/v1"
)
result = get_model(model_config)
url = str(result.client.base_url).rstrip("/")
assert url.endswith("/v1")
assert not url.endswith("/v1/v1")
def test_get_model_openai_non_reasoning_model_ignores_thinking():
"""Test that non-reasoning OpenAI models don't get reasoning_effort setting."""
model_config = ModelConfig(
provider="openai", name="gpt-4o-mini", enable_thinking=False
)
result = get_model(model_config)
assert isinstance(result, OpenAIChatModel)
# Non-reasoning models should not have reasoning_effort set
assert result._settings is None
def test_get_model_openai_extra_body_forwarded():
"""`extra_body` on ModelConfig is forwarded to ModelSettings.extra_body.
pydantic-ai's OpenAI model branch reads `model_settings["extra_body"]`
and passes it verbatim to the OpenAI SDK. Enables vLLM-specific keys
like `chat_template_kwargs.enable_thinking` without coupling them to
the high-level `enable_thinking` flag.
"""
extra = {"chat_template_kwargs": {"enable_thinking": False}}
model_config = ModelConfig(
provider="openai",
name="qwen3.6-35b",
base_url="http://localhost:11430/v1",
extra_body=extra,
)
result = get_model(model_config)
assert isinstance(result, OpenAIChatModel)
assert result._settings is not None
assert result._settings.get("extra_body") == extra
@pytest.mark.asyncio
async def test_get_model_merges_system_messages_for_openai_compatible():
"""Instruction parts from multiple sources (agent preamble, capability
instructions, dynamic notices) each map to their own system message.
Strict OpenAI-compatible templates (e.g. Qwen on vLLM) reject more than
one leading system message, so OpenAI-compatible endpoints merge them;
plain OpenAI keeps them separate."""
from pydantic_ai.messages import InstructionPart, ModelRequest, UserPromptPart
from pydantic_ai.models import ModelRequestParameters
parts = [
InstructionPart(content="Base instructions."),
InstructionPart(content="Limit notice.", dynamic=True),
]
messages = [ModelRequest(parts=[UserPromptPart("hi")])]
async def mapped_for(model):
return await model._map_messages(
messages, ModelRequestParameters(instruction_parts=parts)
)
vllm = get_model(
ModelConfig(provider="openai", name="qwen3.6", base_url="http://vllm:1/v1")
)
mapped = await mapped_for(vllm)
assert [m["role"] for m in mapped] == ["system", "user"]
assert mapped[0]["content"] == "Base instructions.\n\nLimit notice."
ollama = get_model(ModelConfig(provider="ollama", name="llama3"))
assert [m["role"] for m in await mapped_for(ollama)] == ["system", "user"]
openai_native = get_model(ModelConfig(provider="openai", name="gpt-4o"))
assert [m["role"] for m in await mapped_for(openai_native)] == [
"system",
"system",
"user",
]
def test_get_model_ollama_extra_body_forwarded():
"""`extra_body` is forwarded through the Ollama (openai-compatible) branch too."""
extra = {"chat_template_kwargs": {"enable_thinking": False}}
model_config = ModelConfig(provider="ollama", name="qwen3", extra_body=extra)
result = get_model(model_config)
assert isinstance(result, OpenAIChatModel)
assert result._settings is not None
assert result._settings.get("extra_body") == extra
def test_get_model_extra_body_absent_when_unset():
"""No `extra_body` key appears on the settings when the config omits it."""
model_config = ModelConfig(provider="openai", name="gpt-4o-mini", temperature=0.3)
result = get_model(model_config)
assert isinstance(result, OpenAIChatModel)
# temperature triggers settings construction; extra_body should not be there.
assert result._settings is not None
assert "extra_body" not in result._settings
@pytest.mark.skipif(not HAS_ANTHROPIC, reason="Anthropic not installed")
def test_get_model_anthropic():
"""Test get_model returns AnthropicModel for Anthropic."""
from pydantic_ai.models.anthropic import AnthropicModel
model_config = ModelConfig(provider="anthropic", name="claude-3-5-sonnet-20241022")
result = get_model(model_config)
assert isinstance(result, AnthropicModel)
@pytest.mark.skipif(not HAS_ANTHROPIC, reason="Anthropic not installed")
@pytest.mark.parametrize(
"enable_thinking,expected_thinking",
[
(True, {"type": "enabled", "budget_tokens": 4096}),
(False, {"type": "disabled"}),
],
)
def test_get_model_anthropic_with_thinking(enable_thinking, expected_thinking):
"""Test get_model configures thinking for Anthropic."""
from pydantic_ai.models.anthropic import AnthropicModel
model_config = ModelConfig(
provider="anthropic",
name="claude-3-5-sonnet-20241022",
enable_thinking=enable_thinking,
)
result = get_model(model_config)
assert isinstance(result, AnthropicModel)
assert result.settings is not None
assert result.settings.get("anthropic_thinking") == expected_thinking
@pytest.mark.skipif(not HAS_GOOGLE, reason="Google not installed")
def test_get_model_gemini():
"""Test get_model returns GoogleModel for Gemini."""
from pydantic_ai.models.google import GoogleModel
model_config = ModelConfig(provider="gemini", name="gemini-2.0-flash-exp")
result = get_model(model_config)
assert isinstance(result, GoogleModel)
@pytest.mark.skipif(not HAS_GOOGLE, reason="Google not installed")
def test_get_model_gemini_with_thinking():
"""Test get_model configures thinking for Gemini."""
from pydantic_ai.models.google import GoogleModel
model_config = ModelConfig(
provider="gemini", name="gemini-2.0-flash-thinking-exp", enable_thinking=True
)
result = get_model(model_config)
assert isinstance(result, GoogleModel)
@pytest.mark.skipif(not HAS_GROQ, reason="Groq not installed")
def test_get_model_groq():
"""Test get_model returns GroqModel for Groq."""
from pydantic_ai.models.groq import GroqModel
model_config = ModelConfig(provider="groq", name="llama-3.3-70b-versatile")
result = get_model(model_config)
assert isinstance(result, GroqModel)
@pytest.mark.skipif(not HAS_GROQ, reason="Groq not installed")
@pytest.mark.parametrize(
"enable_thinking,expected_format", [(True, "parsed"), (False, "hidden")]
)
def test_get_model_groq_with_thinking(enable_thinking, expected_format):
"""Test get_model configures thinking format for Groq."""
from pydantic_ai.models.groq import GroqModel
model_config = ModelConfig(
provider="groq",
name="llama-3.3-70b-versatile",
enable_thinking=enable_thinking,
)
result = get_model(model_config)
assert isinstance(result, GroqModel)
assert result.settings is not None
assert result.settings.get("groq_reasoning_format") == expected_format
@pytest.mark.skipif(not HAS_BEDROCK, reason="Bedrock not installed")
def test_get_model_bedrock():
"""Test get_model returns BedrockConverseModel for Bedrock."""
from pydantic_ai.models.bedrock import BedrockConverseModel
model_config = ModelConfig(
provider="bedrock", name="anthropic.claude-3-5-sonnet-20241022-v2:0"
)
result = get_model(model_config)
assert isinstance(result, BedrockConverseModel)
@pytest.mark.skipif(not HAS_BEDROCK, reason="Bedrock not installed")
@pytest.mark.parametrize(
"name,enable_thinking,expected_fields",
[
(
"anthropic.claude-3-5-sonnet-20241022-v2:0",
True,
{"thinking": {"type": "enabled", "budget_tokens": 4096}},
),
(
"anthropic.claude-3-5-sonnet-20241022-v2:0",
False,
{"thinking": {"type": "disabled"}},
),
("openai.o3-mini-v1:0", True, {"reasoning_effort": "high"}),
("openai.o3-mini-v1:0", False, {"reasoning_effort": "low"}),
("qwen.qwen3-32b-v1:0", True, {"reasoning_config": "high"}),
("qwen.qwen3-32b-v1:0", False, {"reasoning_config": "low"}),
# A family with no reasoning mapping leaves the request fields untouched.
("meta.llama3-70b-instruct-v1:0", True, None),
("meta.llama3-70b-instruct-v1:0", False, None),
],
ids=[
"claude_on",
"claude_off",
"o_series_on",
"o_series_off",
"qwen_on",
"qwen_off",
"unmapped_on",
"unmapped_off",
],
)
def test_get_model_bedrock_with_thinking(name, enable_thinking, expected_fields):
"""Each Bedrock model family maps thinking onto its own request field."""
from pydantic_ai.models.bedrock import BedrockConverseModel
model_config = ModelConfig(
provider="bedrock",
name=name,
enable_thinking=enable_thinking,
)
result = get_model(model_config)
assert isinstance(result, BedrockConverseModel)
if expected_fields is None:
assert result.settings is None
return
assert result.settings is not None
assert (
result.settings.get("bedrock_additional_model_requests_fields")
== expected_fields
)
def test_get_model_unknown_provider():
"""Test get_model returns string format for unknown providers."""
model_config = ModelConfig(provider="mistral", name="mistral-large-latest")
result = get_model(model_config)
assert isinstance(result, str)
assert result == "mistral:mistral-large-latest"
def test_get_package_versions():
"""Test get_package_versions returns expected keys."""
from haiku.rag.utils import get_package_versions
versions = get_package_versions()
assert "haiku_rag" in versions
assert "lancedb" in versions
assert "docling" in versions
assert "pydantic_ai" in versions
assert "docling_document_schema" in versions
# All should be non-empty strings
for key, value in versions.items():
assert isinstance(value, str)
assert len(value) > 0
# --- apply_common_settings tests ---
def test_apply_common_settings_no_settings():
from haiku.rag.config.models import ModelConfig
from haiku.rag.utils import apply_common_settings
mc = ModelConfig(provider="openai", name="gpt-4o")
result = apply_common_settings(None, dict, mc)
assert result is None
def test_apply_common_settings_temperature():
from haiku.rag.config.models import ModelConfig
from haiku.rag.utils import apply_common_settings
mc = ModelConfig(provider="openai", name="gpt-4o", temperature=0.7)
result = apply_common_settings(None, dict, mc)
assert result is not None
assert result["temperature"] == 0.7
def test_apply_common_settings_max_tokens():
from haiku.rag.config.models import ModelConfig
from haiku.rag.utils import apply_common_settings
mc = ModelConfig(provider="openai", name="gpt-4o", max_tokens=500)
result = apply_common_settings(None, dict, mc)
assert result is not None
assert result["max_tokens"] == 500
def test_apply_common_settings_existing():
from haiku.rag.config.models import ModelConfig
from haiku.rag.utils import apply_common_settings
mc = ModelConfig(provider="openai", name="gpt-4o", temperature=0.5)
existing = {"some_key": "value"}
result = apply_common_settings(existing, dict, mc)
assert result is not None
assert result["temperature"] == 0.5
assert result["some_key"] == "value"
# --- format_bytes tests ---
def test_format_bytes():
from haiku.rag.utils import format_bytes
assert format_bytes(0) == "0.0 B"
assert format_bytes(512) == "512.0 B"
assert format_bytes(1024) == "1.0 KB"
assert format_bytes(1048576) == "1.0 MB"
assert format_bytes(1073741824) == "1.0 GB"
assert format_bytes(1099511627776) == "1.0 TB"
assert format_bytes(1125899906842624) == "1.0 PB"
# --- format_citations tests ---
def test_format_citations_empty():
from haiku.rag.utils import format_citations
assert format_citations([]) == ""
def test_format_citations_with_citation():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
document_title="Test Doc",
content="Some content",
page_numbers=[1],
headings=["Intro"],
)
result = format_citations([citation])
assert "[1] Test Doc" in result
assert "doc1" not in result
assert "chunk1" not in result
assert "test://doc" in result
assert "p. 1" in result
assert "Section: Intro" in result
assert "Some content" in result
def test_format_citations_multiple_pages():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
content="Content",
page_numbers=[1, 2, 3],
)
result = format_citations([citation])
assert "[1] test://doc" in result
assert "pp. 1-3" in result
# No title: the URI stands in, and the document id never leaks.
assert "doc1" not in result
def test_format_citations_with_index():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citation = Citation(
index=5,
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
document_title="Test Doc",
content="Content",
)
result = format_citations([citation])
assert "[5] Test Doc" in result
def test_format_citations_sequential_indices():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citations = [
Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc1",
document_title="First",
content="Content 1",
),
Citation(
document_id="doc2",
chunk_id="chunk2",
document_uri="test://doc2",
document_title="Second",
content="Content 2",
),
]
result = format_citations(citations)
assert "[1] First" in result
assert "[2] Second" in result
# --- format_citations tests (pictures) ---
def test_format_citations_picture_refs_render_as_markers():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
document_title="Test Doc",
content="text body",
picture_refs=["#/pictures/0", "#/pictures/3"],
)
result = format_citations([citation])
assert "[Figure: #/pictures/0]" in result
assert "[Figure: #/pictures/3]" in result
# --- format_citations_rich tests ---
def _render_rich(renderables: list) -> str:
from rich.console import Console
console = Console(record=True, width=200)
for r in renderables:
console.print(r)
return console.export_text()
async def test_format_citations_rich_empty():
from haiku.rag.utils import format_citations_rich
assert await format_citations_rich([]) == []
async def test_format_citations_rich_header_and_footer():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations_rich
citation = Citation(
document_id="doc-uuid-1",
chunk_id="chunk-uuid-1",
document_uri="test://doc",
document_title="Test Doc",
content="Body",
page_numbers=[1, 2, 3],
headings=["Intro", "Background"],
)
output = _render_rich(await format_citations_rich([citation]))
assert "Citations" in output
assert "[1] Test Doc (test://doc)" in output
assert "pp. 1-3" in output
assert "§Background" in output
assert "doc: doc-uuid-1" in output
assert "chunk: chunk-uuid-1" in output
async def test_format_citations_rich_truncates_long_content():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import CITATION_PREVIEW_CHARS, format_citations_rich
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
content="A" * (CITATION_PREVIEW_CHARS + 200),
)
output = _render_rich(await format_citations_rich([citation]))
assert "" in output
assert "A" * (CITATION_PREVIEW_CHARS + 1) not in output
async def test_format_citations_rich_picture_marker_without_client():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations_rich
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
content="body",
picture_refs=["#/pictures/0"],
)
output = _render_rich(await format_citations_rich([citation]))
assert "[Figure: #/pictures/0]" in output
# --- get_default_data_dir tests ---
def test_get_default_data_dir():
from pathlib import Path
from haiku.rag.utils import get_default_data_dir
result = get_default_data_dir()
assert isinstance(result, Path)
assert "haiku.rag" in str(result)
# --- build_prompt tests ---
def test_build_prompt_without_preamble():
from haiku.rag.config.models import AppConfig
from haiku.rag.utils import build_prompt
config = AppConfig()
result = build_prompt("Base prompt", config)
assert result == "Base prompt"
def test_build_prompt_with_preamble():
from haiku.rag.config.models import AppConfig, PromptsConfig
from haiku.rag.utils import build_prompt
config = AppConfig(prompts=PromptsConfig(domain_preamble="You are a legal expert."))
result = build_prompt("Base prompt", config)
assert result == "You are a legal expert.\n\nBase prompt"
# --- is_up_to_date tests ---
def test_cosine_similarity_zero_norm():
from haiku.rag.utils import cosine_similarity
assert cosine_similarity([0, 0, 0], [1, 2, 3]) == 0.0
assert cosine_similarity([1, 2, 3], [0, 0, 0]) == 0.0
assert cosine_similarity([0, 0], [0, 0]) == 0.0
@pytest.mark.asyncio
async def test_is_up_to_date(monkeypatch):
from unittest.mock import AsyncMock, MagicMock
import httpx
from haiku.rag.utils import is_up_to_date
mock_response = MagicMock()
mock_response.json.return_value = {"info": {"version": "0.0.1"}}
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
monkeypatch.setattr(httpx, "AsyncClient", lambda: mock_client)
is_current, running, latest = await is_up_to_date()
assert is_current is True
assert running >= latest
# --- parse_model_option tests ---
def test_parse_model_option():
from haiku.rag.utils import parse_model_option
result = parse_model_option("anthropic:claude-sonnet-4-20250514")
assert result.provider == "anthropic"
assert result.name == "claude-sonnet-4-20250514"
# Colons in name are preserved
assert parse_model_option("openai:gpt-4o:latest").name == "gpt-4o:latest"
for bad in ["just-a-name", ":model", "provider:"]:
with pytest.raises(ValueError, match="Invalid model format"):
parse_model_option(bad)
def test_cosine_similarity_identical_vectors():
from haiku.rag.utils import cosine_similarity
assert cosine_similarity([1.0, 0.0], [1.0, 0.0]) == pytest.approx(1.0)
assert cosine_similarity([1.0, 0.0], [0.0, 1.0]) == pytest.approx(0.0)
async def test_format_citations_rich_separates_multiple_citations():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations_rich
citations = [
Citation(
document_id=f"doc{i}",
chunk_id=f"chunk{i}",
document_uri=f"test://doc{i}",
document_title=f"Doc {i}",
content=f"Body {i}",
)
for i in (1, 2)
]
output = _render_rich(await format_citations_rich(citations))
assert "[1] Doc 1 (test://doc1)" in output
assert "[2] Doc 2 (test://doc2)" in output
@pytest.mark.parametrize(
"stored,renders",
[
(None, False),
(b"not a real image", False),
("png", True),
],
ids=["no_bytes", "undecodable_bytes", "valid_png"],
)
async def test_render_picture_handles_stored_bytes(stored, renders):
from unittest.mock import AsyncMock
from haiku.rag.utils import _render_picture
if stored == "png":
from io import BytesIO
from PIL import Image as PILImage
buf = BytesIO()
PILImage.new("RGB", (4, 4), "red").save(buf, format="PNG")
stored = buf.getvalue()
client = AsyncMock()
client.document_item_repository.get_picture_bytes = AsyncMock(return_value=stored)
result = await _render_picture(client, "doc1", "#/pictures/0")
if renders:
from textual_image.renderable import Image as RichImage
assert isinstance(result, RichImage)
else:
assert result is None
async def test_render_picture_without_client_returns_none():
from haiku.rag.utils import _render_picture
assert await _render_picture(None, "doc1", "#/pictures/0") is None
def test_get_package_versions_reports_missing_docling(monkeypatch):
from importlib import metadata as importlib_metadata
from haiku.rag.utils import get_package_versions
real_version = importlib_metadata.version
def fake_version(name):
if name == "docling":
raise importlib_metadata.PackageNotFoundError(name)
return real_version(name)
monkeypatch.setattr(importlib_metadata, "version", fake_version)
assert get_package_versions()["docling"] == "not installed"