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) } # Диагнозы, которые форма собирает, но поведенческая модель не покрывает # (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»). UNMODELED_DIAGNOSES: dict[str, str] = { 'ДЦП': 'Двигательные нарушения — модель не учитывает; учесть вручную при планировании (доступность местности, темп).', 'слабое_зрение': 'Слабое зрение — модель не учитывает; вероятность следования по открытым/звуковым ориентирам выше.', 'слабый_слух': 'Слабый слух — модель не учитывает; голосовой поиск менее эффективен, приоритет визуальным сигналам.', } def _unmodeled_profiles(case_data: dict[str, Any]) -> list[dict[str, str]]: """Диагнозы вне поведенческой модели (не влияют на скоринг) + пояснение.""" out: list[dict[str, str]] = [] for diag in (case_data.get('diagnosis_type') or []): d = str(diag).lower() for key, note in UNMODELED_DIAGNOSES.items(): if d == key.lower() and not any(o['profile'] == key for o in out): out.append({'profile': key, 'note': note}) return out 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('самокат') if case_data.get('cant_swim'): 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, 'unmodeled_profiles': _unmodeled_profiles(case_data), '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'], }