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:
curo1305
2026-06-04 19:10:05 +02:00
parent ea8df02491
commit efc177a155
3 changed files with 178 additions and 24 deletions
+77 -4
View File
@@ -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 == []