feat(07-03): AnthropicProvider singleton + output_config + truncation — D-03/D-07/D-12/D-13
- Delete MAX_AI_CHARS constant and _client() method from anthropic_provider.py
- Add self._client = AsyncAnthropic(...) singleton in __init__ (D-07)
- Add _truncate() with 60/40 split using self._context_chars (D-13)
- Add _CLASSIFICATION_SCHEMA and _SUGGESTIONS_SCHEMA module-level constants
- classify() and suggest_topics() pass output_config with json_schema format (D-03)
- stop_reason != "end_turn" degrades to parse_classification("") (T-07-08)
- Widen __init__ signature to (api_key, model, context_chars, base_url) (uniform ctor)
- Update ai/__init__.py to pass context_chars + base_url to AnthropicProvider
- Promote test_anthropic_structured_output + add test_anthropic_stop_reason_fallback
This commit is contained in:
@@ -5,11 +5,12 @@ 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.
|
||||
|
||||
Remaining stubs (promoted in Plan 07-03): test_anthropic_structured_output.
|
||||
Wave 3 (Plan 07-03) promotes: test_anthropic_structured_output.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from ai.anthropic_provider import AnthropicProvider, _CLASSIFICATION_SCHEMA
|
||||
from ai.generic_openai_provider import GenericOpenAIProvider
|
||||
from ai.openai_provider import OpenAIProvider
|
||||
from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS
|
||||
@@ -189,9 +190,81 @@ async def test_gemini_fallback_to_parse_classification():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub: promoted in Plan 07-03
|
||||
# Task 1 (Plan 07-03): AnthropicProvider output_config structured output — D-03
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-03")
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_structured_output():
|
||||
pytest.xfail("not implemented yet — Plan 07-03")
|
||||
"""AnthropicProvider.classify() passes output_config with the classification schema (D-03).
|
||||
|
||||
Verifies:
|
||||
- output_config kwarg is present in the messages.create call
|
||||
- output_config value matches _CLASSIFICATION_SCHEMA exactly
|
||||
- Provider correctly reads response.content[0].text when stop_reason == "end_turn"
|
||||
- Singleton _client is reused (AsyncAnthropic constructed once per provider instance)
|
||||
"""
|
||||
provider = AnthropicProvider(api_key="test-key", model="claude-sonnet-4-6", context_chars=100)
|
||||
|
||||
# Build a stub response that models a successful end_turn response
|
||||
stub_content = MagicMock()
|
||||
stub_content.text = '{"assigned_topics":[],"new_topic_suggestions":[]}'
|
||||
stub_response = MagicMock()
|
||||
stub_response.content = [stub_content]
|
||||
stub_response.stop_reason = "end_turn"
|
||||
|
||||
mock_create = AsyncMock(return_value=stub_response)
|
||||
|
||||
with patch("ai.anthropic_provider.anthropic.AsyncAnthropic") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.messages = MagicMock()
|
||||
mock_client.messages.create = mock_create
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
# Re-create provider inside the patch so self._client uses the mock
|
||||
provider = AnthropicProvider(
|
||||
api_key="test-key", model="claude-sonnet-4-6", context_chars=100
|
||||
)
|
||||
result = await provider.classify("short doc text", [], "sys prompt")
|
||||
|
||||
# output_config must be present and match the schema (D-03)
|
||||
call_kwargs = mock_create.await_args.kwargs
|
||||
assert "output_config" in call_kwargs, "output_config must be passed to messages.create()"
|
||||
assert call_kwargs["output_config"] == {
|
||||
"format": {"type": "json_schema", "schema": _CLASSIFICATION_SCHEMA}
|
||||
}, "output_config must use _CLASSIFICATION_SCHEMA"
|
||||
|
||||
# AsyncAnthropic must have been constructed exactly once (D-07 singleton)
|
||||
assert mock_cls.call_count == 1, "AsyncAnthropic must be constructed once (singleton)"
|
||||
|
||||
# Result must be a valid ClassificationResult
|
||||
assert result.topics == []
|
||||
assert result.suggested_new_topics == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_stop_reason_fallback():
|
||||
"""When stop_reason is not 'end_turn', AnthropicProvider falls back to empty ClassificationResult.
|
||||
|
||||
T-07-08: refusal or max_tokens stop_reason must not crash — parse_classification("") returns
|
||||
an empty result rather than raising an exception.
|
||||
"""
|
||||
stub_content = MagicMock()
|
||||
stub_content.text = '{"assigned_topics":["should","be","ignored"]}'
|
||||
stub_response = MagicMock()
|
||||
stub_response.content = [stub_content]
|
||||
stub_response.stop_reason = "max_tokens" # simulated refusal / truncation
|
||||
|
||||
mock_create = AsyncMock(return_value=stub_response)
|
||||
|
||||
with patch("ai.anthropic_provider.anthropic.AsyncAnthropic") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.messages = MagicMock()
|
||||
mock_client.messages.create = mock_create
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
provider = AnthropicProvider(api_key="k", model="claude-sonnet-4-6", context_chars=1000)
|
||||
result = await provider.classify("doc text", [], "sys")
|
||||
|
||||
# stop_reason != "end_turn" → raw == "" → empty ClassificationResult, no crash
|
||||
assert result.topics == []
|
||||
assert result.suggested_new_topics == []
|
||||
|
||||
Reference in New Issue
Block a user