diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/runner.py b/haiku_rag_slim/haiku/rag/agents/rlm/runner.py index 97e0047a..fe7be056 100644 --- a/haiku_rag_slim/haiku/rag/agents/rlm/runner.py +++ b/haiku_rag_slim/haiku/rag/agents/rlm/runner.py @@ -8,7 +8,7 @@ from io import StringIO from typing import Any -def build_namespace( +def build_namespace( # pragma: no cover client: Any, config: Any, context: Any, loop: asyncio.AbstractEventLoop ) -> dict[str, Any]: """Build execution namespace with haiku.rag functions injected.""" @@ -134,7 +134,7 @@ def send_response(result: dict[str, Any]) -> None: sys.stdout.flush() -async def main() -> None: +async def main() -> None: # pragma: no cover """Main entry point for container execution. Runs a loop reading length-prefixed JSON messages and executing code. diff --git a/haiku_rag_slim/haiku/rag/chat/app.py b/haiku_rag_slim/haiku/rag/chat/app.py index 7fcef2ec..8a84e05e 100644 --- a/haiku_rag_slim/haiku/rag/chat/app.py +++ b/haiku_rag_slim/haiku/rag/chat/app.py @@ -34,7 +34,7 @@ try: logfire.configure(send_to_logfire="if-token-present", console=False) logfire.instrument_pydantic_ai() -except ImportError: +except ImportError: # pragma: no cover pass try: @@ -47,7 +47,7 @@ try: from haiku.rag.chat.widgets.chat_history import ChatHistory, CitationWidget TEXTUAL_AVAILABLE = True -except ImportError: +except ImportError: # pragma: no cover TEXTUAL_AVAILABLE = False App = object # type: ignore SystemCommand = object # type: ignore diff --git a/haiku_rag_slim/haiku/rag/inspector/app.py b/haiku_rag_slim/haiku/rag/inspector/app.py index 67c45dda..6fc8d774 100644 --- a/haiku_rag_slim/haiku/rag/inspector/app.py +++ b/haiku_rag_slim/haiku/rag/inspector/app.py @@ -21,7 +21,7 @@ try: from haiku.rag.inspector.widgets.search_modal import SearchModal TEXTUAL_AVAILABLE = True -except ImportError: +except ImportError: # pragma: no cover TEXTUAL_AVAILABLE = False App = object # type: ignore diff --git a/haiku_rag_slim/haiku/rag/utils.py b/haiku_rag_slim/haiku/rag/utils.py index b038dde0..79e4f9c6 100644 --- a/haiku_rag_slim/haiku/rag/utils.py +++ b/haiku_rag_slim/haiku/rag/utils.py @@ -468,7 +468,7 @@ async def is_up_to_date() -> tuple[bool, Version, Version]: response = await client.get("https://pypi.org/pypi/haiku.rag/json") data = response.json() pypi_version = parse(data["info"]["version"]) - except Exception: + except Exception: # pragma: no cover # If no network connection, do not raise alarms. pypi_version = running_version return running_version >= pypi_version, running_version, pypi_version diff --git a/pyproject.toml b/pyproject.toml index 218ca63f..e2f72850 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,3 +128,14 @@ markers = [ # pyproject.toml filterwarnings = ["error", "ignore::UserWarning", "ignore::DeprecationWarning"] + +[tool.coverage.run] +source = ["haiku_rag_slim"] + +[tool.coverage.report] +show_missing = true +exclude_also = [ + "if TYPE_CHECKING:", + "@abstractmethod", + "raise NotImplementedError", +] diff --git a/tests/agents/rlm/test_runner.py b/tests/agents/rlm/test_runner.py new file mode 100644 index 00000000..18104917 --- /dev/null +++ b/tests/agents/rlm/test_runner.py @@ -0,0 +1,50 @@ +import json +from io import StringIO + +from haiku.rag.agents.rlm.runner import execute_code, send_response + + +def test_execute_code_success(): + namespace: dict = {} + result = execute_code("x = 1 + 1", namespace, max_output_chars=1000) + assert result["success"] is True + assert result["stderr"] == "" + + +def test_execute_code_stdout_capture(): + namespace: dict = {} + result = execute_code("print('hello')", namespace, max_output_chars=1000) + assert result["success"] is True + assert "hello" in result["stdout"] + + +def test_execute_code_exception(): + namespace: dict = {} + result = execute_code("raise ValueError('boom')", namespace, max_output_chars=1000) + assert result["success"] is False + assert "ValueError" in result["stderr"] + assert "boom" in result["stderr"] + + +def test_execute_code_output_truncation(): + namespace: dict = {} + code = "print('x' * 100)" + result = execute_code(code, namespace, max_output_chars=10) + assert result["success"] is True + assert "truncated" in result["stdout"] + assert len(result["stdout"]) < 100 + + +def test_send_response(monkeypatch): + buf = StringIO() + monkeypatch.setattr("sys.stdout", buf) + + payload = {"success": True, "stdout": "hi", "stderr": ""} + send_response(payload) + + output = buf.getvalue() + lines = output.split("\n", 1) + length = int(lines[0]) + body = lines[1] + assert json.loads(body) == payload + assert length == len(json.dumps(payload)) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..325a3ce5 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,38 @@ +import logging + +from haiku.rag.logging import configure_cli_logging, get_logger + + +def test_get_logger(): + logger = get_logger() + assert logger.name == "haiku.rag" + assert logger.level == logging.INFO + assert len(logger.handlers) == 1 + assert logger.propagate is False + + +def test_get_logger_idempotent(): + logger1 = get_logger() + logger2 = get_logger() + assert logger1 is logger2 + assert len(logger2.handlers) == 1 + + +def test_configure_cli_logging(): + logger = configure_cli_logging() + assert logger.name == "haiku.rag" + assert logger.propagate is False + + root = logging.getLogger() + assert root.level == logging.ERROR + assert len(root.handlers) == 0 + + for name in ("httpx", "httpcore", "docling", "urllib3", "asyncio"): + noisy = logging.getLogger(name) + assert noisy.level == logging.ERROR + assert noisy.propagate is False + + +def test_configure_cli_logging_custom_level(): + logger = configure_cli_logging(level=logging.DEBUG) + assert logger.level == logging.DEBUG diff --git a/tests/test_utils.py b/tests/test_utils.py index 8256cf3f..f486d120 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -318,3 +318,285 @@ def test_get_package_versions(): for key, value in versions.items(): assert isinstance(value, str) assert len(value) > 0 + + +# --- parse_datetime tests --- + + +def test_parse_datetime_iso8601(): + from haiku.rag.utils import parse_datetime + + dt = parse_datetime("2025-01-15T14:30:00") + assert dt.year == 2025 + assert dt.month == 1 + assert dt.day == 15 + assert dt.hour == 14 + assert dt.minute == 30 + + +def test_parse_datetime_date_only(): + from haiku.rag.utils import parse_datetime + + dt = parse_datetime("2025-01-15") + assert dt.year == 2025 + assert dt.month == 1 + assert dt.day == 15 + + +def test_parse_datetime_with_timezone(): + from haiku.rag.utils import parse_datetime + + dt = parse_datetime("2025-01-15T14:30:00+00:00") + assert dt.year == 2025 + assert dt.tzinfo is not None + + +def test_parse_datetime_invalid(): + from haiku.rag.utils import parse_datetime + + with pytest.raises(ValueError, match="Could not parse datetime"): + parse_datetime("not-a-date") + + +# --- to_utc tests --- + + +def test_to_utc_naive_datetime(): + from datetime import datetime + + from haiku.rag.utils import to_utc + + naive = datetime(2025, 6, 15, 12, 0, 0) + result = to_utc(naive) + assert result.tzinfo is not None + + +def test_to_utc_utc_datetime(): + from datetime import UTC, datetime + + from haiku.rag.utils import to_utc + + utc_dt = datetime(2025, 6, 15, 12, 0, 0, tzinfo=UTC) + result = to_utc(utc_dt) + assert result is utc_dt + + +def test_to_utc_aware_non_utc(): + from datetime import UTC, datetime, timedelta, timezone + + from haiku.rag.utils import to_utc + + eastern = timezone(timedelta(hours=-5)) + aware = datetime(2025, 6, 15, 12, 0, 0, tzinfo=eastern) + result = to_utc(aware) + assert result.tzinfo == UTC + assert result.hour == 17 + + +# --- 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.agents.research.models 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 "[doc1:chunk1]" 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.agents.research.models 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 "pp. 1-3" in result + + +def test_format_citations_no_title(): + from haiku.rag.agents.research.models import Citation + from haiku.rag.utils import format_citations + + citation = Citation( + document_id="doc1", + chunk_id="chunk1", + document_uri="test://doc", + content="Content", + ) + result = format_citations([citation]) + assert "test://doc" in result + + +# --- format_citations_rich tests --- + + +def test_format_citations_rich_empty(): + from haiku.rag.utils import format_citations_rich + + assert format_citations_rich([]) == [] + + +def test_format_citations_rich_with_citation(): + from rich.panel import Panel + from rich.text import Text + + from haiku.rag.agents.research.models import Citation + from haiku.rag.utils import format_citations_rich + + citation = Citation( + document_id="doc1", + chunk_id="chunk1", + document_uri="test://doc", + document_title="Test Doc", + content="Some content", + page_numbers=[1, 2], + headings=["Intro"], + ) + result = format_citations_rich([citation]) + assert len(result) == 2 + assert isinstance(result[0], Text) + assert isinstance(result[1], Panel) + + +# --- 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 --- + + +@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 diff --git a/tests/tools/test_prompts.py b/tests/tools/test_prompts.py new file mode 100644 index 00000000..ac82c28d --- /dev/null +++ b/tests/tools/test_prompts.py @@ -0,0 +1,53 @@ +from haiku.rag.tools.prompts import build_tools_prompt + + +def test_empty_features(): + result = build_tools_prompt([]) + assert result == "" + + +def test_single_feature_search(): + result = build_tools_prompt(["search"]) + assert "search" in result + assert "document_name" in result.lower() + + +def test_single_feature_documents(): + result = build_tools_prompt(["documents"]) + assert "list_documents" in result + assert "summarize_document" in result + assert "get_document" in result + + +def test_single_feature_qa(): + result = build_tools_prompt(["qa"]) + assert "ask" in result + assert "document_name" in result.lower() + + +def test_single_feature_analysis(): + result = build_tools_prompt(["analysis"]) + assert "analyze" in result + + +def test_multiple_features(): + result = build_tools_prompt(["search", "qa", "documents"]) + assert "search" in result + assert "ask" in result + assert "list_documents" in result + + +def test_search_and_qa_both_add_document_name_examples(): + result = build_tools_prompt(["search", "qa"]) + assert "search for embeddings" in result.lower() or "embeddings" in result + assert "what does the ML paper say" in result or "ML paper" in result + + +def test_unknown_features_ignored(): + result = build_tools_prompt(["nonexistent", "also_fake"]) + assert result == "" + + +def test_unknown_mixed_with_known(): + result = build_tools_prompt(["nonexistent", "search"]) + assert "search" in result