Files
vector/services/recommendation_service.py
T
root 8b3a2cbf7e 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.
2026-07-25 13:01:02 +00:00

81 lines
3.1 KiB
Python

"""
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'),
)