Files
vector/services/rules_analysis.py
T
Sadmin 5794cb74a9 UI: «перекрыть» → «проверить» (рекомендации МЧС); геоформат «пункт, район, адрес»
1. Рекомендации не предписывают перекрывать водоёмы/ж/д — оператор ПРОВЕРЯЕТ
   их (правки в rules_analysis, scoring_service ×2 копии, water.py + доки).
2. Геокодирование: район нужен (дубли улиц между районами) — подсказка формата
   «пункт, район, адрес» и текст ошибки.

198 passed.
2026-09-09 20:21:18 +03:00

168 lines
6.7 KiB
Python

"""Детерминированный rules-анализ (замена Claude API, решение Виктора:
ИИ из проекта убран — карточки детей не покидают контур).
Логика — бывший analyze_with_fallback из claude_service (правила по возрасту,
профилям, координатам) + urgency по правилам closed_cases. Никаких внешних
вызовов: только geo_service (Overpass) + scoring_service + справочники.
"""
from __future__ import annotations
import logging
from typing import Any
from pydantic import BaseModel, Field
from services.geo_service import build_search_zones
from services.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 = Field(default=True)
def rule_based_urgency(elapsed: float, age: int, diagnosis: list[str]) -> str:
"""urgency по правилам: чем меньше ребёнок и дольше поиск — тем выше срочность."""
score = 0
if elapsed >= 24:
score += 2
elif elapsed >= 8:
score += 1
if age <= 7:
score += 2
elif age <= 12:
score += 1
if diagnosis:
score += 1
if score >= 4:
return 'критическая'
if score >= 2:
return 'высокая'
return 'средняя'
def _age_urgency(age: int, profiles: list[str]) -> str:
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
return 'критическая'
if age <= 7 or 'велосипед' in profiles:
return 'высокая'
return 'средняя'
def _key_locations(age: int) -> list[str]:
if age <= 7:
return ['водоёмы', 'укрытия', 'густая растительность', 'ближайшие постройки']
if age <= 12:
return ['водоёмы', 'дороги', 'тропы', 'лесные массивы']
return ['дороги', 'населённые пункты', 'транспортные узлы', 'водоёмы']
def _behavioral_prediction(age: int, profiles: list[str]) -> str:
if 'РАС' in profiles:
return 'Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя.'
if age <= 4:
return 'Минимальное движение, вероятно находится близко к точке потери.'
if age <= 12:
return 'Умеренное движение, может следовать по тропам или дорогам.'
return 'Целенаправленное движение, возможен выход к населённым пунктам.'
def _immediate_actions(profiles: list[str]) -> list[str]:
actions = [
'Организовать поисковые группы',
'Проверить ближайшие водоёмы',
'Опросить свидетелей в районе последнего местоположения',
]
if 'РАС' in profiles:
actions.insert(0, 'КРИТИЧНО: Проверить все водоёмы и ж/д пути в радиусе 5 км')
if 'велосипед' in profiles:
actions.insert(0, 'Расширить зону поиска до 10-15 км, проверить дорожные камеры')
return actions
async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
"""Детерминированный анализ: scoring_service + geo_service, без LLM."""
age = int(case_data.get('age') or 10)
profiles = list(case_data.get('profiles') or [])
urgency = _age_urgency(age, profiles)
primary_zones: list[PrimaryZone] = []
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)
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: {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='Расширенная зона поиска'),
]
key_locations = _key_locations(age)
behavioral_prediction = _behavioral_prediction(age, profiles)
immediate_actions = _immediate_actions(profiles)
summary = (
f'Случай классифицирован как {urgency} срочность. '
f'Рекомендуемый радиус поиска: {search_radius_km} км. '
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,
)