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:
root
2026-07-25 13:01:02 +00:00
parent 76278f5fe1
commit 8b3a2cbf7e
7 changed files with 509 additions and 59 deletions
+10 -13
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from backend.database import db
from backend.schemas import DashboardResponse, RecommendationRequest
from services.recommendation_service import score_recommendation
router = APIRouter(prefix='/api/v1/stats', tags=['stats'])
@@ -18,18 +19,14 @@ def stats_heatmap() -> dict:
@router.post('/recommendation')
def stats_recommendation(payload: RecommendationRequest) -> dict:
score = 0
if payload.age is not None and payload.age < 12:
score += 20
if payload.elapsed_hours is not None and payload.elapsed_hours >= 12:
score += 20
if payload.terrain_primary in {'лес', 'болото', 'вода'}:
score += 15
if payload.weather in {'дождь', 'туман', 'снег', 'ночь'}:
score += 15
if len(payload.health_flags) >= 2:
score += 15
result = score_recommendation(
age=payload.age,
elapsed_hours=payload.elapsed_hours,
terrain=payload.terrain_primary,
weather=payload.weather,
health_flags=payload.health_flags,
)
return {
'recommendation': 'Высокий приоритет на прочёс и дрон' if score >= 40 else 'Стандартный приоритет поиска',
'score': score,
'recommendation': result['recommendation'],
'score': result['score'],
}
+7 -25
View File
@@ -1,12 +1,17 @@
from __future__ import annotations
from typing import Any
try:
from backend.database import db
except ImportError: # pragma: no cover - compatibility for direct service imports
from database import db
# Scoring rules live in services/recommendation_service.py (B3) — this module
# only re-exports them so existing `stats_service.get_statistical_recommendation`
# callers keep working.
from services.recommendation_service import get_statistical_recommendation
__all__ = ['summary', 'heatmap', 'get_statistical_recommendation']
def summary() -> dict:
return db.stats()
@@ -14,26 +19,3 @@ def summary() -> dict:
def heatmap() -> list[dict]:
return db.heatmap()
def get_statistical_recommendation(case_data: dict[str, Any]) -> dict[str, Any]:
score = 0
age = case_data.get('age') or case_data.get('age_years')
elapsed = case_data.get('elapsed_hours')
terrain = str(case_data.get('terrain') or case_data.get('terrain_primary') or '').lower()
weather = str(case_data.get('weather') or case_data.get('precipitation') or '').lower()
if age is not None and age < 12:
score += 20
if elapsed is not None and elapsed >= 12:
score += 20
if any(token in terrain for token in ('лес', 'болото', 'вода')):
score += 15
if any(token in weather for token in ('дождь', 'туман', 'снег', 'ночь')):
score += 15
return {
'score': score,
'priority': 'high' if score >= 40 else 'normal',
'recommendation': 'Высокий приоритет на прочёс и дрон' if score >= 40 else 'Стандартный приоритет поиска',
}
+132 -1
View File
@@ -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 == "высокая"
@@ -0,0 +1,161 @@
"""
Tests for the unified recommendation scoring (B3).
Covers the shared scorer directly, the dict-based alias entry point, and the
/api/v1/stats/recommendation endpoint that now delegates to it.
"""
import pytest
from fastapi.testclient import TestClient
from backend.main import app
from services.recommendation_service import (
HIGH_PRIORITY_TEXT,
NORMAL_PRIORITY_TEXT,
get_statistical_recommendation,
score_recommendation,
)
client = TestClient(app)
class TestScoreRecommendation:
"""Weights and threshold of the shared scorer."""
def test_empty_case_scores_zero(self):
result = score_recommendation()
assert result['score'] == 0
assert result['priority'] == 'normal'
assert result['recommendation'] == NORMAL_PRIORITY_TEXT
def test_young_child(self):
assert score_recommendation(age=8)['score'] == 20
def test_age_at_threshold_not_counted(self):
assert score_recommendation(age=12)['score'] == 0
def test_long_elapsed(self):
assert score_recommendation(elapsed_hours=12)['score'] == 20
def test_short_elapsed_not_counted(self):
assert score_recommendation(elapsed_hours=11)['score'] == 0
def test_risky_terrain(self):
assert score_recommendation(terrain='лес')['score'] == 15
def test_adverse_weather(self):
assert score_recommendation(weather='дождь')['score'] == 15
def test_multiple_health_flags(self):
assert score_recommendation(health_flags=['эпилепсия', 'РАС'])['score'] == 15
def test_single_health_flag_not_counted(self):
assert score_recommendation(health_flags=['эпилепсия'])['score'] == 0
def test_terrain_matches_as_substring(self):
"""Substring match — the router previously required an exact match."""
assert score_recommendation(terrain='смешанный лес')['score'] == 15
def test_weather_matches_as_substring(self):
assert score_recommendation(weather='сильный дождь')['score'] == 15
def test_terrain_case_insensitive(self):
assert score_recommendation(terrain='ЛЕС')['score'] == 15
def test_unknown_terrain_scores_zero(self):
assert score_recommendation(terrain='поле')['score'] == 0
def test_high_priority_at_threshold(self):
result = score_recommendation(age=8, elapsed_hours=14)
assert result['score'] == 40
assert result['priority'] == 'high'
assert result['recommendation'] == HIGH_PRIORITY_TEXT
def test_just_below_threshold_is_normal(self):
result = score_recommendation(age=8, terrain='лес')
assert result['score'] == 35
assert result['priority'] == 'normal'
def test_all_factors(self):
result = score_recommendation(
age=6,
elapsed_hours=24,
terrain='болото',
weather='туман',
health_flags=['РАС', 'эпилепсия'],
)
assert result['score'] == 85
assert result['priority'] == 'high'
class TestGetStatisticalRecommendation:
"""Dict entry point and its field aliases."""
def test_age_alias(self):
assert get_statistical_recommendation({'age_years': 8})['score'] == 20
def test_age_preferred_over_alias(self):
assert get_statistical_recommendation({'age': 8, 'age_years': 30})['score'] == 20
def test_terrain_alias(self):
assert get_statistical_recommendation({'terrain_primary': 'лес'})['score'] == 15
def test_weather_alias(self):
assert get_statistical_recommendation({'precipitation': 'снег'})['score'] == 15
def test_health_flags_counted(self):
payload = {'health_flags': ['РАС', 'эпилепсия']}
assert get_statistical_recommendation(payload)['score'] == 15
def test_missing_keys_are_safe(self):
assert get_statistical_recommendation({})['score'] == 0
def test_none_values_are_safe(self):
payload = {'age': None, 'elapsed_hours': None, 'terrain': None, 'weather': None}
assert get_statistical_recommendation(payload)['score'] == 0
class TestRecommendationEndpoint:
"""The endpoint keeps its response contract while delegating."""
def test_returns_score_and_recommendation(self):
response = client.post(
'/api/v1/stats/recommendation',
json={'age': 8, 'elapsed_hours': 14},
)
assert response.status_code == 200
body = response.json()
assert body['score'] == 40
assert body['recommendation'] == HIGH_PRIORITY_TEXT
def test_normal_priority_case(self):
response = client.post('/api/v1/stats/recommendation', json={'age': 30})
assert response.status_code == 200
body = response.json()
assert body['score'] == 0
assert body['recommendation'] == NORMAL_PRIORITY_TEXT
def test_empty_payload_accepted(self):
response = client.post('/api/v1/stats/recommendation', json={})
assert response.status_code == 200
assert response.json()['score'] == 0
def test_endpoint_matches_shared_scorer(self):
payload = {
'age': 6,
'elapsed_hours': 24,
'terrain_primary': 'болото',
'weather': 'туман',
'health_flags': ['РАС', 'эпилепсия'],
}
response = client.post('/api/v1/stats/recommendation', json=payload)
assert response.status_code == 200
expected = score_recommendation(
age=payload['age'],
elapsed_hours=payload['elapsed_hours'],
terrain=payload['terrain_primary'],
weather=payload['weather'],
health_flags=payload['health_flags'],
)
assert response.json()['score'] == expected['score']
assert response.json()['recommendation'] == expected['recommendation']