B2: tolerant JSON extraction from Claude responses + graceful fallback
_extract_json_payload handles a json/JSON/bare fence, raw JSON and JSON embedded in prose; any unparseable response or contract violation now degrades to the deterministic scoring_service instead of raising. Also guards the response envelope itself (content[0].text). B3: single home for recommendation scoring services/recommendation_service.py holds the rules; routers/stats.py and backend/services/stats_service.py both delegate to it. Unified rules are the union of the two old copies: same weights/threshold, substring matching (superset of the old exact match), tolerant key aliases, health_flags rule kept. Endpoint response contract unchanged. Plus: Overpass circuit breaker and concurrent zone queries in geo_service - 128 sequential calls per analysis no longer each burn a connect timeout when the host has no outbound network. Tests: 152 -> 194 passed.
This commit is contained in:
@@ -11,7 +11,8 @@ from services.claude_service import (
|
||||
analyze_case,
|
||||
analyze_with_fallback,
|
||||
AnalysisResult,
|
||||
PrimaryZone
|
||||
PrimaryZone,
|
||||
_extract_json_payload
|
||||
)
|
||||
|
||||
|
||||
@@ -369,3 +370,133 @@ class TestImmediateActions:
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert any("10-15" in action or "камер" in action for action in result.immediate_actions)
|
||||
|
||||
|
||||
class TestExtractJsonPayload:
|
||||
"""Test tolerant JSON extraction from a model response (B2)."""
|
||||
|
||||
def test_bare_json(self):
|
||||
assert _extract_json_payload('{"a": 1}') == {'a': 1}
|
||||
|
||||
def test_json_fence(self):
|
||||
assert _extract_json_payload('```json\n{"a": 1}\n```') == {'a': 1}
|
||||
|
||||
def test_uppercase_json_fence(self):
|
||||
assert _extract_json_payload('```JSON\n{"a": 1}\n```') == {'a': 1}
|
||||
|
||||
def test_bare_fence(self):
|
||||
assert _extract_json_payload('```\n{"a": 1}\n```') == {'a': 1}
|
||||
|
||||
def test_prose_around_json(self):
|
||||
content = 'Вот результат анализа:\n{"a": 1}\nНадеюсь, это поможет.'
|
||||
assert _extract_json_payload(content) == {'a': 1}
|
||||
|
||||
def test_prose_around_fenced_json(self):
|
||||
content = 'Разбор:\n```json\n{"a": 1}\n```\nКонец.'
|
||||
assert _extract_json_payload(content) == {'a': 1}
|
||||
|
||||
def test_json_array_is_rejected(self):
|
||||
"""A top-level array is not a valid AnalysisResult payload."""
|
||||
with pytest.raises(ValueError):
|
||||
_extract_json_payload('[1, 2, 3]')
|
||||
|
||||
def test_plain_text_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_extract_json_payload('Извините, я не могу выполнить этот запрос.')
|
||||
|
||||
def test_empty_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_extract_json_payload('')
|
||||
|
||||
def test_none_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_extract_json_payload(None)
|
||||
|
||||
|
||||
class TestClaudeResponseFallback:
|
||||
"""Malformed Claude responses must degrade to scoring, not crash (B2)."""
|
||||
|
||||
def _mock_client(self, mock_client, text=None, payload=None):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = (
|
||||
payload if payload is not None else {"content": [{"text": text}]}
|
||||
)
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unparseable_text_falls_back(self):
|
||||
"""Model answers in prose instead of JSON -> deterministic fallback."""
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
self._mock_client(mock_client, text='Не могу помочь с этим.')
|
||||
|
||||
result = await analyze_case({'age': 10, 'terrain': 'лес'})
|
||||
|
||||
assert result.fallback_used is True
|
||||
assert isinstance(result, AnalysisResult)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncated_json_falls_back(self):
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
self._mock_client(mock_client, text='```json\n{"urgency": "высок')
|
||||
|
||||
result = await analyze_case({'age': 10})
|
||||
|
||||
assert result.fallback_used is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_json_missing_required_fields_falls_back(self):
|
||||
"""Parseable JSON that violates the AnalysisResult contract."""
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
self._mock_client(mock_client, text='{"urgency": "высокая"}')
|
||||
|
||||
result = await analyze_case({'age': 10})
|
||||
|
||||
assert result.fallback_used is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_envelope_falls_back(self):
|
||||
"""API envelope without content[0].text -> fallback, not KeyError."""
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
self._mock_client(mock_client, payload={'unexpected': 'shape'})
|
||||
|
||||
result = await analyze_case({'age': 10})
|
||||
|
||||
assert result.fallback_used is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unfenced_json_with_prose_succeeds(self):
|
||||
"""Recovery path: valid payload wrapped in prose is still used."""
|
||||
import json as _json
|
||||
|
||||
payload = {
|
||||
"urgency": "высокая",
|
||||
"primary_zones": [{
|
||||
"priority": 1,
|
||||
"name": "Лес север",
|
||||
"direction": "N",
|
||||
"distance": 1.5,
|
||||
"reason": "Вероятное направление"
|
||||
}],
|
||||
"search_radius_km": 5.0,
|
||||
"key_locations": ["водоёмы"],
|
||||
"behavioral_prediction": "Движение по тропам",
|
||||
"immediate_actions": ["Организовать поиск"],
|
||||
"summary": "Резюме"
|
||||
}
|
||||
text = 'Результат:\n' + _json.dumps(payload, ensure_ascii=False) + '\nГотово.'
|
||||
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
self._mock_client(mock_client, text=text)
|
||||
|
||||
result = await analyze_case({'age': 10})
|
||||
|
||||
assert result.fallback_used is False
|
||||
assert result.urgency == "высокая"
|
||||
|
||||
Reference in New Issue
Block a user