Files
vector/backend/routers/analyze.py
T
root 3e2692f8a5 B5: перенести расчёт анализа в бэкенд (единый источник правды)
AnalysisResult.jsx теперь тонкий клиент над POST /api/v1/analyze — локальные
формулы (calculateFullDistance / generateZonesWithPsychotype / getPsychotypeModifiers)
удалены. Бэкенд становится единственным источником правды.

distance_service:
- get_base_speed: перенесены каноничные displacement-скорости фронта (0.3–2.5)
  вместо расходящихся значений бэкенда (1.0–5.5) — согласовано с априорами ПСО
- реализованы заглушки СКД (get_diagnosis_coefficient: эпилепсия 0.6, ЗПР 0.8,
  РАС 1.0, СДВГ 1.1, здоров 1.0) и СУ (get_transport_coefficient: bike ×5,
  car ×8, иное ×3, none ×1) — критичная безопасность bike×5 теперь в бэкендe
- get_time_of_day_coefficient: сумерки/вечер 0.7 (было 0.5)

analyze.py:
- _derive_profiles: профили (РАС/эпилепсия/СДВГ/ЗПР/велосипед/самокат) формируются
  из diagnosis_type + has_transport, закрывая рассинхрон bike/велосипед на границе
- _derive_time_of_day: время суток из loss_time (порт getTimeOfDay фронта)
- психотип детектится ДО claude_analyze, модификаторы прокидываются в score_zone
- _as_case_data: фильтр пустых контейнеров ([],{}) — фикс затирания данных карточки
  дефолтами payload (psychotype_answers={} затирал реальные ответы)
- в ответ добавлены coefficients, time_of_day, active_profiles, critical_warnings

scoring_service.score_zone: применяется множитель полосы психотипа по дистанции
(порт getPsychotypeModifiers; таблица уже есть в psychotype_service).

AnalysisResult.jsx: рендер бэкенд-ответа, адаптер primary_zones→SearchMap
(score из приоритета, reasoning←reason), локализация пола m/f→М/Ж, бейдж
fallback/ИИ, блоки активных профилей + критических предупреждений + рекомендаций
по психотипу, исправлены ключи отображения (loss_time/behavior_description/
temperature_c). Поведение сохранено (bike 8yo 2h лес = 5.4 км, как во фронте).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:36:35 +00:00

250 lines
10 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from backend.database import db
from backend.routers.auth import require_roles
from services.claude_service import analyze_case as claude_analyze
from services.distance_service import (
calculate_max_distance,
get_base_speed,
get_diagnosis_coefficient,
get_terrain_coefficient,
get_time_of_day_coefficient,
get_transport_coefficient,
get_weather_coefficient,
)
from services.psychotype_service import (
detect_psychotype,
get_psychotype_modifiers,
get_search_recommendations,
)
from services.scoring_service import WeightedScorer
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
class AnalysisRequest(BaseModel):
case_id: UUID | None = None
age: int | None = None
gender: str | None = None
terrain: str | list[str] | None = None
weather: str | None = None
elapsed_hours: float | None = None
last_location: str | None = None
circumstances: str | None = None
physical_condition: str | None = None
experience: str | None = None
season: str | None = None
diagnosis_type: list[str] = Field(default_factory=list)
has_transport: str | None = None
psychotype_answers: dict[str, Any] = Field(default_factory=dict)
tnp_lat: float | None = None
tnp_lon: float | None = None
lat: float | None = None
lon: float | None = None
profiles: list[str] = Field(default_factory=list)
def _first_terrain(value: str | list[str] | None) -> str | None:
if isinstance(value, list):
return value[0] if value else None
return value
def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
terrain = _first_terrain(payload.terrain)
case_data: dict[str, Any] = {
'age': payload.age,
'gender': payload.gender,
'terrain': terrain,
'terrain_primary': terrain,
'weather': payload.weather,
'elapsed_hours': payload.elapsed_hours,
'last_location': payload.last_location,
'circumstances': payload.circumstances,
'physical_condition': payload.physical_condition,
'experience': payload.experience,
'season': payload.season,
'diagnosis_type': payload.diagnosis_type,
'has_transport': payload.has_transport,
'profiles': list(payload.profiles or []),
'psychotype_answers': payload.psychotype_answers,
'lat': payload.lat if payload.lat is not None else payload.tnp_lat,
'lon': payload.lon if payload.lon is not None else payload.tnp_lon,
}
# Отфильтровываем None и пустые контейнеры ([], {}), чтобы при merge
# {**to_detail, **case_data} значения из карточки случая не затирались
# «пустышками» из дефолтов payload (psychotype_answers={}, diagnosis_type=[]).
return {
k: v for k, v in case_data.items()
if v is not None and not (isinstance(v, (list, dict)) and len(v) == 0)
}
def _derive_profiles(case_data: dict[str, Any]) -> list[str]:
"""
Формирует список поведенческих профилей из диагнозов и транспорта, если они
не переданы явно. Ключи WeightedScorer.PROFILES — русские (§8 контекста).
Это закрывает рассинхрон bike/велосипед на границе бэкенд↔фронтенд.
"""
explicit = list(case_data.get('profiles') or [])
if explicit:
return explicit
derived: list[str] = []
for diag in (case_data.get('diagnosis_type') or []):
d = str(diag).lower()
if 'рас' in d or 'аутизм' in d:
derived.append('РАС')
elif 'эпилепси' in d:
derived.append('эпилепсия')
elif 'сдвг' in d:
derived.append('СДВГ')
elif 'зпр' in d:
derived.append('ЗПР')
transport = str(case_data.get('has_transport') or '').lower()
if transport == 'bike':
derived.append('велосипед')
elif transport == 'scooter':
derived.append('самокат')
seen: set[str] = set()
out: list[str] = []
for p in derived:
if p not in seen:
seen.add(p)
out.append(p)
return out
def _derive_time_of_day(loss_time: Any) -> str:
"""
Выводит время суток из loss_time: 6-18 день, 18-22 сумерки, иначе ночь.
Порт фронтендовой getTimeOfDay. Принимает datetime или ISO-строку.
"""
if not loss_time:
return 'день'
try:
if isinstance(loss_time, str):
hour = int(loss_time.split('T')[1].split(':')[0])
else:
hour = loss_time.hour
except Exception:
return 'день'
if 6 <= hour < 18:
return 'день'
if 18 <= hour < 22:
return 'сумерки'
return 'ночь'
@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
case_data = _as_case_data(payload)
if payload.case_id is not None:
case = db.get_case(str(payload.case_id))
if not case:
raise HTTPException(status_code=404, detail='Case not found')
case_data = {**case.to_detail(), **case_data}
# Время суток из loss_time (порт фронтендовой getTimeOfDay)
if not case_data.get('time_of_day'):
case_data['time_of_day'] = _derive_time_of_day(case_data.get('loss_time'))
# terrain_primary из terrain, если вдруг не дошёл
if not case_data.get('terrain_primary') and case_data.get('terrain'):
case_data['terrain_primary'] = _first_terrain(case_data['terrain'])
# Поведенческие профили из диагнозов + транспорта (если не заданы явно)
case_data['profiles'] = _derive_profiles(case_data)
# Психотип детектим ДО анализа, чтобы применить его к ранжированию зон.
# get_psychotype_modifiers возвращает таблицу полос по дистанции, которую
# WeightedScorer.score_zone читает из case['psychotype_modifiers'].
psychotype = None
psychotype_modifiers = None
psychotype_recommendations = None
if case_data.get('psychotype_answers'):
psychotype = detect_psychotype(case_data['psychotype_answers'])
psychotype_modifiers = get_psychotype_modifiers(psychotype)
psychotype_recommendations = get_search_recommendations(psychotype)
case_data['psychotype'] = psychotype
case_data['psychotype_modifiers'] = psychotype_modifiers
max_distance_km = calculate_max_distance(case_data)
claude_result = await claude_analyze(case_data)
scorer = WeightedScorer()
if case_data.get('age'):
scorer.apply_age_modifiers(int(case_data['age']))
if case_data.get('season'):
scorer.apply_season_modifiers(str(case_data['season']))
if case_data.get('profiles'):
scorer.apply_profile(list(case_data['profiles']))
scorer._normalize_weights()
# Разбивка коэффициентов формулы Экстремум для отображения на фронте
elapsed = float(case_data.get('elapsed_hours') or 1)
age_val = int(case_data.get('age') or 10)
coefficients = {
'base_speed': get_base_speed(age_val),
'terrain': get_terrain_coefficient(case_data.get('terrain_primary', 'лес')),
'diagnosis': get_diagnosis_coefficient(case_data.get('diagnosis_type')),
'urgency': get_transport_coefficient(case_data.get('has_transport')),
'fatigue': max(0.3, 1.0 - 0.05 * elapsed),
'time_of_day': get_time_of_day_coefficient(case_data.get('time_of_day', 'день')),
'weather': get_weather_coefficient(case_data.get('weather', 'нет')),
}
result = {
'case_id': str(payload.case_id) if payload.case_id else None,
'analyzed_at': datetime.now(timezone.utc).isoformat(),
'max_distance_km': max_distance_km,
'coefficients': coefficients,
'time_of_day': case_data.get('time_of_day', 'день'),
'psychotype': psychotype,
'psychotype_modifiers': psychotype_modifiers,
'psychotype_recommendations': psychotype_recommendations,
'weights': scorer.weights,
'distance_multiplier': scorer.distance_multiplier,
'active_profiles': scorer.get_active_profiles_info(),
'critical_warnings': scorer.critical_warnings,
'urgency': claude_result.urgency,
'primary_zones': [zone.model_dump() for zone in claude_result.primary_zones],
'search_radius_km': claude_result.search_radius_km,
'key_locations': claude_result.key_locations,
'behavioral_prediction': claude_result.behavioral_prediction,
'immediate_actions': claude_result.immediate_actions,
'summary': claude_result.summary,
'fallback_used': claude_result.fallback_used,
}
if payload.case_id is not None:
db.update_case(str(payload.case_id), analysis_log=result, status='analyzed')
return result
@router.post('/combined', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
async def analyze_combined(payload: AnalysisRequest) -> dict[str, Any]:
return await analyze_case(payload)
@router.get('/{case_id}')
def get_analysis(case_id: str) -> dict[str, Any]:
case = db.get_case(case_id)
if not case:
raise HTTPException(status_code=404, detail='Case not found')
detail = case.to_detail()
if not detail.get('analysis_log'):
raise HTTPException(status_code=404, detail=f'No analysis found for case {case_id}')
return {
'case_id': case_id,
'analysis_log': detail['analysis_log'],
'created_at': detail['created_at'],
}