diff --git a/backend/tests/test_ai_providers.py b/backend/tests/test_ai_providers.py index 9775c2a..9d73992 100644 --- a/backend/tests/test_ai_providers.py +++ b/backend/tests/test_ai_providers.py @@ -1,46 +1,197 @@ """ -Wave 0 xfail stubs for Phase 7 AI provider tests. +Tests for Phase 7 AI provider refactor. -Each function is a placeholder for a test that will be promoted to green -in a later plan wave (per 07-VALIDATION.md per-task-verification map). +Wave 2 (Plan 07-02) promotes: test_get_provider_typed, test_client_singleton, +test_generic_openai_json_mode, test_context_chars_truncation, test_smart_truncation, +test_gemini_fallback_to_parse_classification. -Stub policy (STATE.md decision: xfail(strict=False) for Wave 0): - - Body is a single pytest.xfail() call — no assertion code. - - strict=False so unexpected passes (xpass) never break CI. +Remaining stubs (promoted in Plan 07-03): test_anthropic_structured_output. """ import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from ai.generic_openai_provider import GenericOpenAIProvider +from ai.openai_provider import OpenAIProvider +from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS +from ai.utils import parse_classification -@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-02") +# --------------------------------------------------------------------------- +# Task 3: Registry-based get_provider(config: ProviderConfig) — D-06 +# --------------------------------------------------------------------------- + +def test_get_provider_typed(): + """get_provider() accepts a ProviderConfig and returns the correct provider class.""" + from ai import get_provider + + # Groq → GenericOpenAIProvider with supports_json_mode=True + config = ProviderConfig(provider_id="groq") + result = get_provider(config) + assert isinstance(result, GenericOpenAIProvider) + assert result.supports_json_mode is True + assert result._context_chars == PROVIDER_DEFAULTS["groq"]["context_chars"] + + # Gemini → GenericOpenAIProvider with supports_json_mode=False (D-02) + config_gemini = ProviderConfig(provider_id="gemini") + result_gemini = get_provider(config_gemini) + assert isinstance(result_gemini, GenericOpenAIProvider) + assert result_gemini.supports_json_mode is False + + # Unknown provider → ValueError + config_bogus = ProviderConfig(provider_id="bogus") + with pytest.raises(ValueError, match="Unknown AI provider"): + get_provider(config_bogus) + + +# --------------------------------------------------------------------------- +# Task 4: Singleton client lifecycle — D-07 +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_client_singleton(): + """AsyncOpenAI is instantiated exactly once per OpenAIProvider instance (D-07).""" + with patch("ai.openai_provider.AsyncOpenAI") as mock_cls: + # mock_cls() returns a mock instance — configure chat.completions.create + mock_instance = MagicMock() + mock_instance.chat = MagicMock() + mock_instance.chat.completions = MagicMock() + mock_instance.chat.completions.create = AsyncMock( + return_value=MagicMock( + choices=[MagicMock(message=MagicMock(content='{"assigned_topics":[],"new_topic_suggestions":[]}'))] + ) + ) + mock_cls.return_value = mock_instance + + provider = OpenAIProvider(api_key="test-key", model="gpt-4o", base_url=None, context_chars=1000) + + # Call classify twice on the same instance + await provider.classify("doc text", [], "sys") + await provider.classify("doc text", [], "sys") + + # AsyncOpenAI class should have been called exactly once (in __init__) + assert mock_cls.call_count == 1 + + +# --------------------------------------------------------------------------- +# Task 4: JSON-mode conditional — D-01 +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio async def test_generic_openai_json_mode(): - pytest.xfail("not implemented yet — Plan 07-02") + """GenericOpenAIProvider passes response_format only when supports_json_mode=True.""" + synthetic_response = MagicMock( + choices=[MagicMock(message=MagicMock( + content='{"assigned_topics":["finance"],"new_topic_suggestions":[]}' + ))] + ) + # supports_json_mode=True → response_format present in call kwargs + with patch("ai.openai_provider.AsyncOpenAI") as mock_cls: + mock_instance = MagicMock() + mock_instance.chat.completions.create = AsyncMock(return_value=synthetic_response) + mock_cls.return_value = mock_instance + + provider_json = GenericOpenAIProvider( + api_key="key", model="gpt-4o", base_url=None, + context_chars=1000, supports_json_mode=True + ) + await provider_json.classify("text", [], "sys") + call_kwargs = mock_instance.chat.completions.create.call_args.kwargs + assert "response_format" in call_kwargs + assert call_kwargs["response_format"] == {"type": "json_object"} + + # supports_json_mode=False → response_format absent from call kwargs (Gemini preset path) + with patch("ai.openai_provider.AsyncOpenAI") as mock_cls2: + mock_instance2 = MagicMock() + mock_instance2.chat.completions.create = AsyncMock(return_value=synthetic_response) + mock_cls2.return_value = mock_instance2 + + provider_no_json = GenericOpenAIProvider( + api_key="key", model="gemini-2.0-flash", + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + context_chars=1000, supports_json_mode=False + ) + await provider_no_json.classify("text", [], "sys") + call_kwargs2 = mock_instance2.chat.completions.create.call_args.kwargs + assert "response_format" not in call_kwargs2 + + +# --------------------------------------------------------------------------- +# Task 4: Smart truncation — D-12/D-13 +# --------------------------------------------------------------------------- + +def test_context_chars_truncation(): + """Provider with context_chars=100 truncates a 500-char input.""" + provider = OpenAIProvider(api_key="", model="gpt-4o", base_url=None, context_chars=100) + long_text = "a" * 500 + result = provider._truncate(long_text) + assert len(result) < 500 + assert "[...truncated...]" in result + + +def test_smart_truncation(): + """_truncate uses 60% head + 40% tail of context_chars.""" + provider = OpenAIProvider(api_key="", model="gpt-4o", base_url=None, context_chars=1000) + # Build a distinguishable input where head and tail chars differ + input_text = "H" * 5000 + "T" * 5000 # 10000 chars total + result = provider._truncate(input_text) + # head = int(1000 * 0.6) = 600, tail = 1000 - 600 = 400 + assert result.startswith("H" * 600) + assert result.endswith("T" * 400) + assert "[...truncated...]" in result + + +# --------------------------------------------------------------------------- +# Task 4: Gemini fallback to parse_classification (D-02 contract enforcement) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_gemini_fallback_to_parse_classification(): + """D-02: GenericOpenAIProvider(supports_json_mode=False) calls parse_classification() + and does NOT send response_format to the API. + """ + raw_content = '{"assigned_topics":["x"],"new_topic_suggestions":[],"reasoning":"r"}' + + with patch("ai.openai_provider.AsyncOpenAI") as mock_cls: + mock_create = AsyncMock( + return_value=MagicMock( + choices=[MagicMock(message=MagicMock(content=raw_content))] + ) + ) + mock_instance = MagicMock() + mock_instance.chat.completions.create = mock_create + mock_cls.return_value = mock_instance + + # Wrap parse_classification so we can assert it was called + with patch( + "ai.generic_openai_provider.parse_classification", + wraps=parse_classification, + ) as mock_parse: + provider = GenericOpenAIProvider( + api_key="", + model="gemini-2.0-flash", + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + context_chars=8000, + supports_json_mode=False, + ) + result = await provider.classify("doc text", [], "sys") + + # Result must be a valid ClassificationResult + assert result.topics == ["x"] + + # parse_classification was called with the raw content (D-02 contract) + assert mock_parse.called + mock_parse.assert_called_once_with(raw_content) + + # response_format must NOT have been sent to the API (Gemini preset path) + call_kwargs = mock_create.call_args.kwargs + assert "response_format" not in call_kwargs + + +# --------------------------------------------------------------------------- +# Stub: promoted in Plan 07-03 +# --------------------------------------------------------------------------- @pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-03") async def test_anthropic_structured_output(): pytest.xfail("not implemented yet — Plan 07-03") - - -@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-02") -async def test_get_provider_typed(): - pytest.xfail("not implemented yet — Plan 07-02") - - -@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-02") -async def test_client_singleton(): - pytest.xfail("not implemented yet — Plan 07-02") - - -@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-03") -async def test_context_chars_truncation(): - pytest.xfail("not implemented yet — Plan 07-03") - - -@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-03") -async def test_smart_truncation(): - pytest.xfail("not implemented yet — Plan 07-03") - - -@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-02 Task 4 (D-02 Gemini fallback path)") -async def test_gemini_fallback_to_parse_classification(): - pytest.xfail("not implemented yet — Plan 07-02")