Files
vector/services/rules_analysis.py
T
Sadmin 1513bad538 Согласование радиуса: summary = max_distance × средний психотип-множитель
- search_engine: max_distance_km передаётся в analyzer (case_data).
- rules_analysis: search_radius = max_distance × _psychotype_radius_multiplier
  (среднее по полосам 0-500/0.5-1.5/1.5-2.5/2.5+). Убран двойной учёт
  age/season: score_zone.distance_multiplier их уже содержит, а max_distance
  их тоже учёл — раньше radius = multiplier×2.0 (хардкод) давал 1.6 км при
  радиусе 2.4 км.
- UI: подсказка у «×0.8» — коэффициент скоринга, не радиуса.
- Тест: согласованность summary↔формула.
2026-09-09 23:25:06 +03:00

323 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Детерминированный 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,
)