8b3a2cbf7e
_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.
351 lines
15 KiB
Python
351 lines
15 KiB
Python
"""
|
||
Claude AI service for case analysis with fallback to scoring service.
|
||
Integrates geo_service zones and scoring_service rankings.
|
||
"""
|
||
import os
|
||
import json
|
||
import logging
|
||
from typing import Dict, List, Optional
|
||
from pydantic import BaseModel
|
||
import httpx
|
||
|
||
from .geo_service import build_search_zones
|
||
from .scoring_service import WeightedScorer
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class PrimaryZone(BaseModel):
|
||
priority: int
|
||
name: str
|
||
direction: str
|
||
distance: float
|
||
reason: str
|
||
|
||
|
||
class AnalysisResult(BaseModel):
|
||
urgency: str # "критическая", "высокая", "средняя", "низкая"
|
||
primary_zones: List[PrimaryZone]
|
||
search_radius_km: float
|
||
key_locations: List[str]
|
||
behavioral_prediction: str
|
||
immediate_actions: List[str]
|
||
summary: str
|
||
fallback_used: bool = False
|
||
|
||
|
||
async def analyze_case(case_data: dict) -> AnalysisResult:
|
||
"""
|
||
Анализирует данные случая с помощью Claude API с fallback на scoring_service.
|
||
|
||
Интегрирует:
|
||
- geo_service: построение зон поиска
|
||
- scoring_service: оценка и ранжирование зон
|
||
- Claude API: интеллектуальный анализ (если доступен)
|
||
|
||
Args:
|
||
case_data: Словарь с данными случая
|
||
- age: возраст
|
||
- gender: пол
|
||
- terrain: местность
|
||
- weather: погода
|
||
- time_missing: время пропажи
|
||
- last_location: последнее местоположение
|
||
- lat, lon: координаты (опционально)
|
||
- profiles: поведенческие профили (опционально)
|
||
- season: сезон (опционально)
|
||
|
||
Returns:
|
||
AnalysisResult: Структурированный результат анализа
|
||
"""
|
||
# Попытка использовать Claude API
|
||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||
|
||
if api_key:
|
||
try:
|
||
return await analyze_with_claude(case_data, api_key)
|
||
except Exception as e:
|
||
# Логируем ошибку и переходим на fallback
|
||
logger.warning(f"Claude API unavailable: {e}. Using fallback scoring service.")
|
||
|
||
# Fallback: используем только scoring_service
|
||
return await analyze_with_fallback(case_data)
|
||
|
||
|
||
async def analyze_with_claude(case_data: dict, api_key: str) -> AnalysisResult:
|
||
"""
|
||
Анализ с помощью Claude API с интеграцией geo и scoring сервисов.
|
||
"""
|
||
# Построить зоны поиска если есть координаты
|
||
zones_data = ""
|
||
if case_data.get('lat') and case_data.get('lon'):
|
||
try:
|
||
zones = await build_search_zones(
|
||
case_data['lat'],
|
||
case_data['lon'],
|
||
case_data
|
||
)
|
||
|
||
# Ранжировать зоны
|
||
scorer = WeightedScorer()
|
||
zones_dict = [
|
||
{
|
||
'direction': z.direction,
|
||
'distance_km': z.distance_km,
|
||
'forest_pct': z.forest_pct,
|
||
'road_density': z.road_density,
|
||
'water_distance_km': z.water_distance_km,
|
||
'settlement_distance_km': z.settlement_distance_km
|
||
}
|
||
for z in zones
|
||
]
|
||
|
||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
||
|
||
# Топ-5 зон для промпта
|
||
top_zones = ranked_zones[:5]
|
||
zones_data = "\n\nТОП-5 ПРИОРИТЕТНЫХ ЗОН (по scoring_service):\n"
|
||
for zone in top_zones:
|
||
zones_data += f"- {zone['direction']} направление, {zone['distance_km']}км: "
|
||
zones_data += f"оценка {zone['score']}/100, "
|
||
zones_data += f"лес {zone['forest_pct']}%, "
|
||
zones_data += f"дороги {zone['road_density']} км/км²\n"
|
||
except Exception as e:
|
||
logger.warning(f"Geo/scoring service error: {e}")
|
||
|
||
# Формируем промпт на русском языке
|
||
prompt = f"""Ты — эксперт по поисково-спасательным операциям (ПСО) МЧС Республики Беларусь. Проанализируй следующий случай пропажи человека и дай структурированные рекомендации.
|
||
|
||
ДАННЫЕ СЛУЧАЯ:
|
||
- Возраст пропавшего: {case_data.get('age', 'не указан')} лет
|
||
- Пол: {case_data.get('gender', 'не указан')}
|
||
- Местность: {case_data.get('terrain', 'не указана')}
|
||
- Погодные условия: {case_data.get('weather', 'не указаны')}
|
||
- Время пропажи: {case_data.get('time_missing', 'не указано')}
|
||
- Последнее известное местоположение: {case_data.get('last_location', 'не указано')}
|
||
- Особые обстоятельства: {case_data.get('circumstances', 'нет')}
|
||
- Физическое состояние: {case_data.get('physical_condition', 'не указано')}
|
||
- Опыт нахождения на природе: {case_data.get('experience', 'не указан')}
|
||
- Поведенческие профили: {', '.join(case_data.get('profiles', [])) if case_data.get('profiles') else 'нет'}
|
||
- Сезон: {case_data.get('season', 'не указан')}{zones_data}
|
||
|
||
ЗАДАЧА:
|
||
Предоставь детальный анализ в формате JSON со следующими полями:
|
||
|
||
1. urgency: Уровень срочности ("критическая", "высокая", "средняя", "низкая")
|
||
2. primary_zones: Массив из 3-5 приоритетных зон поиска, каждая с полями:
|
||
- priority: номер приоритета (1 = самый высокий)
|
||
- name: название зоны (например "Ближний лес", "Водоём на севере")
|
||
- direction: направление от последней точки (N, NE, E, SE, S, SW, W, NW)
|
||
- distance: расстояние в км
|
||
- reason: обоснование выбора этой зоны
|
||
3. search_radius_km: Рекомендуемый радиус поиска в километрах
|
||
4. key_locations: Массив ключевых типов локаций для проверки (водоемы, дороги, постройки и т.д.)
|
||
5. behavioral_prediction: Прогноз поведения пропавшего на основе возраста и обстоятельств
|
||
6. immediate_actions: Массив немедленных действий, которые нужно предпринять
|
||
7. summary: Краткое резюме анализа (2-3 предложения)
|
||
|
||
ВАЖНО: Учитывай данные из scoring_service при формировании primary_zones. Отвечай ТОЛЬКО валидным JSON без дополнительного текста."""
|
||
|
||
# Вызываем Anthropic API
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
response = await client.post(
|
||
"https://api.anthropic.com/v1/messages",
|
||
headers={
|
||
"x-api-key": api_key,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json"
|
||
},
|
||
json={
|
||
"model": "claude-sonnet-5",
|
||
"max_tokens": 4096,
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": prompt
|
||
}
|
||
]
|
||
}
|
||
)
|
||
|
||
if response.status_code != 200:
|
||
raise Exception(f"Anthropic API error: {response.status_code} - {response.text}")
|
||
|
||
try:
|
||
result = response.json()
|
||
content = result["content"][0]["text"]
|
||
analysis_data = _extract_json_payload(content)
|
||
analysis_data['fallback_used'] = False
|
||
return AnalysisResult(**analysis_data)
|
||
except Exception as e:
|
||
# Ответ модели пришёл в неожидаемом виде — не роняем анализ,
|
||
# а отдаём детерминированный результат scoring_service.
|
||
logger.warning(
|
||
f"Не удалось разобрать ответ Claude ({type(e).__name__}: {e}). "
|
||
"Используется fallback scoring service."
|
||
)
|
||
return await analyze_with_fallback(case_data)
|
||
|
||
|
||
def _extract_json_payload(content: str) -> dict:
|
||
"""Extract a JSON object from a model response.
|
||
|
||
Tolerates a ```json fence, a bare ``` fence, or raw JSON with
|
||
surrounding prose. Raises ValueError if nothing parseable is found.
|
||
"""
|
||
candidates = []
|
||
stripped = (content or '').strip()
|
||
|
||
if '```' in stripped:
|
||
for marker in ('```json', '```JSON', '```'):
|
||
if marker in stripped:
|
||
after = stripped.split(marker, 1)[1]
|
||
candidates.append(after.split('```', 1)[0].strip())
|
||
break
|
||
|
||
candidates.append(stripped)
|
||
|
||
# Last resort: the widest {...} span in the text.
|
||
start, end = stripped.find('{'), stripped.rfind('}')
|
||
if start != -1 and end > start:
|
||
candidates.append(stripped[start:end + 1])
|
||
|
||
for candidate in candidates:
|
||
if not candidate:
|
||
continue
|
||
try:
|
||
parsed = json.loads(candidate)
|
||
except (json.JSONDecodeError, TypeError):
|
||
continue
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
|
||
raise ValueError('Не удалось извлечь JSON из ответа модели')
|
||
|
||
|
||
async def analyze_with_fallback(case_data: dict) -> AnalysisResult:
|
||
"""
|
||
Fallback анализ используя только scoring_service без Claude API.
|
||
"""
|
||
# Определяем срочность на основе возраста и профилей
|
||
age = case_data.get('age', 10)
|
||
profiles = case_data.get('profiles', [])
|
||
|
||
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
|
||
urgency = "критическая"
|
||
elif age <= 7 or 'велосипед' in profiles:
|
||
urgency = "высокая"
|
||
elif age <= 12:
|
||
urgency = "средняя"
|
||
else:
|
||
urgency = "средняя"
|
||
|
||
# Построить зоны если есть координаты
|
||
primary_zones = []
|
||
search_radius_km = 5.0
|
||
|
||
if case_data.get('lat') and case_data.get('lon'):
|
||
try:
|
||
zones = await build_search_zones(
|
||
case_data['lat'],
|
||
case_data['lon'],
|
||
case_data
|
||
)
|
||
|
||
# Ранжировать зоны
|
||
scorer = WeightedScorer()
|
||
zones_dict = [
|
||
{
|
||
'direction': z.direction,
|
||
'distance_km': z.distance_km,
|
||
'forest_pct': z.forest_pct,
|
||
'road_density': z.road_density,
|
||
'water_distance_km': z.water_distance_km,
|
||
'settlement_distance_km': z.settlement_distance_km
|
||
}
|
||
for z in zones
|
||
]
|
||
|
||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
||
|
||
# Топ-5 зон
|
||
for i, zone in enumerate(ranked_zones[:5]):
|
||
primary_zones.append(PrimaryZone(
|
||
priority=i + 1,
|
||
name=f"Зона {zone['direction']} {zone['distance_km']}км",
|
||
direction=zone['direction'],
|
||
distance=zone['distance_km'],
|
||
reason=f"Оценка {zone['score']}/100 по scoring_service"
|
||
))
|
||
|
||
# Радиус на основе дистанции
|
||
search_radius_km = scorer.distance_multiplier * 2.0
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Geo/scoring service error in fallback: {e}")
|
||
|
||
# Если зоны не построены, используем базовые
|
||
if not primary_zones:
|
||
primary_zones = [
|
||
PrimaryZone(
|
||
priority=1,
|
||
name="Ближняя зона",
|
||
direction="N",
|
||
distance=0.5,
|
||
reason="Базовая зона поиска"
|
||
),
|
||
PrimaryZone(
|
||
priority=2,
|
||
name="Средняя зона",
|
||
direction="E",
|
||
distance=1.0,
|
||
reason="Расширенная зона поиска"
|
||
)
|
||
]
|
||
|
||
# Ключевые локации на основе возраста
|
||
if age <= 7:
|
||
key_locations = ["водоёмы", "укрытия", "густая растительность", "ближайшие постройки"]
|
||
elif age <= 12:
|
||
key_locations = ["водоёмы", "дороги", "тропы", "лесные массивы"]
|
||
else:
|
||
key_locations = ["дороги", "населённые пункты", "транспортные узлы", "водоёмы"]
|
||
|
||
# Поведенческий прогноз
|
||
if 'РАС' in profiles:
|
||
behavioral_prediction = "Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя."
|
||
elif age <= 4:
|
||
behavioral_prediction = "Минимальное движение, вероятно находится близко к точке потери."
|
||
elif age <= 12:
|
||
behavioral_prediction = "Умеренное движение, может следовать по тропам или дорогам."
|
||
else:
|
||
behavioral_prediction = "Целенаправленное движение, возможен выход к населённым пунктам."
|
||
|
||
# Немедленные действия
|
||
immediate_actions = [
|
||
"Организовать поисковые группы",
|
||
"Проверить ближайшие водоёмы",
|
||
"Опросить свидетелей в районе последнего местоположения"
|
||
]
|
||
|
||
if 'РАС' in profiles:
|
||
immediate_actions.insert(0, "КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км")
|
||
|
||
if 'велосипед' in profiles:
|
||
immediate_actions.insert(0, "Расширить зону поиска до 10-15 км, проверить дорожные камеры")
|
||
|
||
summary = f"Случай классифицирован как {urgency} срочность. "
|
||
summary += f"Рекомендуемый радиус поиска: {search_radius_km} км. "
|
||
summary += f"Приоритет: {key_locations[0]}."
|
||
|
||
return AnalysisResult(
|
||
urgency=urgency,
|
||
primary_zones=primary_zones,
|
||
search_radius_km=search_radius_km,
|
||
key_locations=key_locations,
|
||
behavioral_prediction=behavioral_prediction,
|
||
immediate_actions=immediate_actions,
|
||
summary=summary,
|
||
fallback_used=True
|
||
)
|