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:
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Statistical search-priority recommendation.
|
||||
|
||||
Single home for the recommendation scoring rules (B3). Previously duplicated
|
||||
between `backend/routers/stats.py` (inline, exact-match on terrain/weather,
|
||||
plus a health_flags rule) and `backend/services/stats_service.py` (substring
|
||||
match on tolerant key aliases, no health_flags rule).
|
||||
|
||||
The unified rules below are the union of the two: identical weights and
|
||||
threshold, substring matching (a superset of the old exact match), tolerant
|
||||
input keys, and the health_flags rule kept. This is NOT the 7-factor zonal
|
||||
scoring of `scoring_service` — it only labels a case high/normal priority.
|
||||
|
||||
Deliberately free of any database import so both the router and the service
|
||||
layer can use it without side effects.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Scoring weights and threshold — unchanged from both previous implementations.
|
||||
WEIGHT_YOUNG_CHILD = 20
|
||||
WEIGHT_LONG_ELAPSED = 20
|
||||
WEIGHT_RISKY_TERRAIN = 15
|
||||
WEIGHT_ADVERSE_WEATHER = 15
|
||||
WEIGHT_MULTIPLE_HEALTH_FLAGS = 15
|
||||
|
||||
HIGH_PRIORITY_THRESHOLD = 40
|
||||
|
||||
YOUNG_CHILD_AGE = 12
|
||||
LONG_ELAPSED_HOURS = 12
|
||||
MULTIPLE_HEALTH_FLAGS = 2
|
||||
|
||||
RISKY_TERRAIN_TOKENS = ('лес', 'болото', 'вода')
|
||||
ADVERSE_WEATHER_TOKENS = ('дождь', 'туман', 'снег', 'ночь')
|
||||
|
||||
HIGH_PRIORITY_TEXT = 'Высокий приоритет на прочёс и дрон'
|
||||
NORMAL_PRIORITY_TEXT = 'Стандартный приоритет поиска'
|
||||
|
||||
|
||||
def score_recommendation(
|
||||
age: int | None = None,
|
||||
elapsed_hours: int | None = None,
|
||||
terrain: str | None = None,
|
||||
weather: str | None = None,
|
||||
health_flags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Score a case and return {'score', 'priority', 'recommendation'}."""
|
||||
score = 0
|
||||
|
||||
if age is not None and age < YOUNG_CHILD_AGE:
|
||||
score += WEIGHT_YOUNG_CHILD
|
||||
if elapsed_hours is not None and elapsed_hours >= LONG_ELAPSED_HOURS:
|
||||
score += WEIGHT_LONG_ELAPSED
|
||||
if any(token in str(terrain or '').lower() for token in RISKY_TERRAIN_TOKENS):
|
||||
score += WEIGHT_RISKY_TERRAIN
|
||||
if any(token in str(weather or '').lower() for token in ADVERSE_WEATHER_TOKENS):
|
||||
score += WEIGHT_ADVERSE_WEATHER
|
||||
if len(health_flags or []) >= MULTIPLE_HEALTH_FLAGS:
|
||||
score += WEIGHT_MULTIPLE_HEALTH_FLAGS
|
||||
|
||||
is_high = score >= HIGH_PRIORITY_THRESHOLD
|
||||
|
||||
return {
|
||||
'score': score,
|
||||
'priority': 'high' if is_high else 'normal',
|
||||
'recommendation': HIGH_PRIORITY_TEXT if is_high else NORMAL_PRIORITY_TEXT,
|
||||
}
|
||||
|
||||
|
||||
def get_statistical_recommendation(case_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Dict-based entry point, tolerant of the field aliases used across
|
||||
the desktop / mobile / admin payloads."""
|
||||
return score_recommendation(
|
||||
age=case_data.get('age') if case_data.get('age') is not None else case_data.get('age_years'),
|
||||
elapsed_hours=case_data.get('elapsed_hours'),
|
||||
terrain=case_data.get('terrain') or case_data.get('terrain_primary'),
|
||||
weather=case_data.get('weather') or case_data.get('precipitation'),
|
||||
health_flags=case_data.get('health_flags'),
|
||||
)
|
||||
Reference in New Issue
Block a user