"""Детерминированный 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 _terrain_phrase(forest_pct: float | None) -> str: """Человеческая характеристика лесистости сектора.""" if forest_pct is None: return None pct = round(forest_pct * 100) if pct >= 70: return f'густой лес ({pct}% сектора)' if pct >= 40: return f'лесистость умеренная ({pct}%)' if pct >= 15: return f'редкий лес ({pct}%)' return 'преобладает открытое поле' def _water_phrase(water_distance_km: float | None, profiles: list[str]) -> str: """Водоёмы: расстояние + тактический акцент для профилей.""" if water_distance_km is None: return None dist = round(water_distance_km, 1) if dist <= 0.6: phrase = f'водоём в {dist} км — проверить берега' elif dist <= 2.0: phrase = f'водоём в {dist} км' else: phrase = f'вода далеко ({dist} км)' if 'не_умеет_плавать' in profiles and dist <= 3.0: phrase += ' (внимание: не умеет плавать — приоритет береговой линии)' return phrase def _roads_phrase(road_density: float | None, profiles: list[str]) -> str: """Дорожная сеть: плотность + подсказка по транспорту.""" if road_density is None: return None density = round(road_density, 1) if density >= 2.0: phrase = f'плотная сеть дорог ({density} км/км²)' elif density >= 0.8: phrase = f'дороги умеренные ({density} км/км²)' else: phrase = f'дороги редки ({density} км/км²) — движение затруднено' if 'велосипед' in profiles: phrase += ' (велосипед — вероятное следование вдоль дорог)' return phrase def _settlement_phrase(settlement_distance_km: float | None) -> str: """Населённый пункт в секторе: только ориентир (в скоринге не приоритет).""" if settlement_distance_km is None: return None dist = round(settlement_distance_km, 1) if dist <= 1.0: return f'населённый пункт в {dist} км — исключить выход к жилью' if dist <= 3.0: return f'жильё в {dist} км' return None def _direction_phrase(zone_direction: str, case_data: dict) -> str | None: """Совпадение с направлением последнего свидетеля.""" seen = (case_data.get('last_seen_direction') or '').upper() if not seen: return None if zone_direction == seen: return 'совпадает с направлением движения последнего свидетеля' # Соседние румбы (±45°) — частичное совпадение RU = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] if zone_direction in RU and seen in RU: d = abs(RU.index(zone_direction) - RU.index(seen)) if d == 1 or d == 7: return 'близко к направлению последнего свидетеля' return None def _psychotype_phrase(case_data: dict, distance_km: float) -> str | None: """Психотип: полоса дистанции, если множитель отличается от нейтрального.""" mods = case_data.get('psychotype_modifiers') if not mods: return None zdist = distance_km if zdist < 0.5: band, key = mods.get('zone_0_500', 1.0), '0-500 м' elif zdist < 1.5: band, key = mods.get('zone_500_1500', 1.0), '0.5-1.5 км' elif zdist < 2.5: band, key = mods.get('zone_1500_2500', 1.0), '1.5-2.5 км' else: band, key = mods.get('zone_2500plus', 1.0), 'свыше 2.5 км' if band > 1.05: return f'психотип повышает вероятность в полосе {key} (×{band:.2f})' if band < 0.95: return f'психотип снижает вероятность в полосе {key} (×{band:.2f})' return None def _psychotype_radius_multiplier(case_data: dict) -> float: """Средний множитель психотипа по полосам дистанции (без двойного учёта возраст/сезон — они уже в max_distance).""" mods = case_data.get('psychotype_modifiers') if not mods: return 1.0 vals = [mods.get(k, 1.0) for k in ('zone_0_500', 'zone_500_1500', 'zone_1500_2500', 'zone_2500plus')] return sum(vals) / len(vals) def _build_zone_reason(zone: dict, case_data: dict) -> str: """Словесное пояснение приоритета зоны — по тем же факторам, что считает WeightedScorer (детерминированно, без выдумок): рельеф, вода, дороги, НП, направление свидетеля, психотип. Складывается в одно предложение.""" profiles = list(case_data.get('profiles') or []) parts: list[str] = [] terrain = _terrain_phrase(zone.get('forest_pct')) if terrain: parts.append(terrain) water = _water_phrase(zone.get('water_distance_km'), profiles) if water: parts.append(water) roads = _roads_phrase(zone.get('road_density'), profiles) if roads: parts.append(roads) settlement = _settlement_phrase(zone.get('settlement_distance_km')) if settlement: parts.append(settlement) direction = _direction_phrase(zone.get('direction', ''), case_data) if direction: parts.append(direction) psycho = _psychotype_phrase(case_data, zone.get('distance_km', 1.0)) if psycho: parts.append(psycho) if not parts: return f'Оценка {zone.get("score", 0):.0f}/100 по весам модели' # Первые два фактора с запятой, дальше — точкой с запятой; финальный балл в конце. reason = '; '.join(parts) return f'{reason}. Оценка {zone.get("score", 0):.0f}/100.' 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] = [] base_km = float(case_data.get('max_distance_km') or 5.0) search_radius_km = round(base_km * 1.0, 1) 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() # Направление последнего свидетеля: вес 'direction' (0.07) работает # только при direction_match в данных зоны (иначе всегда дефолт 0.5). seen_dir = (case_data.get('last_seen_direction') or '').upper() 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, 'direction_match': 1.0 if seen_dir and z.direction == seen_dir else (0.5 if seen_dir else 0.5), } 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=_build_zone_reason(zone, case_data), )) # Радиус = max_distance (НормС×коэффициенты) × средний психотип-множитель. # score_zone.distance_multiplier НЕ используется: он накопил age/season, # уже учтённые в max_distance — двойной учёт. base_km = float(case_data.get('max_distance_km') or 2.0) search_radius_km = round(base_km * _psychotype_radius_multiplier(case_data), 1) 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, )