diff --git a/backend/routers/analyze.py b/backend/routers/analyze.py index 93c6f8f..2e1842b 100644 --- a/backend/routers/analyze.py +++ b/backend/routers/analyze.py @@ -10,8 +10,16 @@ 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 -from services.psychotype_service import ( +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, @@ -34,6 +42,7 @@ class AnalysisRequest(BaseModel): 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 @@ -63,12 +72,74 @@ def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]: '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, } - return {k: v for k, v in case_data.items() if v is not None} + # Отфильтровываем 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']))]) @@ -81,6 +152,29 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]: 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) @@ -93,23 +187,32 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]: scorer.apply_profile(list(case_data['profiles'])) scorer._normalize_weights() - 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) + # Разбивка коэффициентов формулы Экстремум для отображения на фронте + 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, diff --git a/frontend/src/pages/analysis/AnalysisResult.jsx b/frontend/src/pages/analysis/AnalysisResult.jsx index e2379c5..5b130c3 100644 --- a/frontend/src/pages/analysis/AnalysisResult.jsx +++ b/frontend/src/pages/analysis/AnalysisResult.jsx @@ -4,6 +4,29 @@ import SearchMap from '../../components/SearchMap'; import { apiFetch } from "../../api/client"; import "./AnalysisResult.css"; +const GENDER_LABEL = { m: 'М', f: 'Ж' }; + +// Маппинг уровня срочности бэкенда → CSS-класс (фронтовые классы в Cyrillic) +const urgencyClass = (urgency) => { + const u = (urgency || '').toLowerCase(); + if (u.includes('высок')) return 'высокий'; + if (u.includes('сред')) return 'средний'; + return 'стандартный'; +}; + +// Адаптер зон бэкенда (PrimaryZone: priority/name/direction/distance/reason) +// → формат SearchMap (нужен score для радиуса и reasoning для попапа). +// score выводим из приоритета — даёт визуальный градиент (топ-зоны крупнее), +// как в прежней синтетической версии. +const adaptZones = (primaryZones) => (primaryZones || []).map(z => ({ + priority: z.priority, + name: z.name, + direction: z.direction, + distance: `${z.distance} км`, + score: Math.max(10, 100 - ((z.priority || 1) - 1) * 10), + reasoning: z.reason || '', +})); + const AnalysisResult = () => { const { caseId } = useParams(); const [loading, setLoading] = useState(true); @@ -17,14 +40,19 @@ const AnalysisResult = () => { setLoading(true); setError(null); + // 1. Карточка случая — для шапки и обстоятельств const caseResponse = await apiFetch(`/api/v1/cases/${caseId}`); - if (!caseResponse.ok) { - throw new Error("Не удалось загрузить карточку"); - } + if (!caseResponse.ok) throw new Error("Не удалось загрузить карточку"); const caseResult = await caseResponse.json(); setCaseData(caseResult); - const analysis = calculateFullDistance(caseResult); + // 2. Анализ — единый источник правды на бэкенде + const analyzeResponse = await apiFetch(`/api/v1/analyze`, { + method: 'POST', + body: JSON.stringify({ case_id: caseId }), + }); + if (!analyzeResponse.ok) throw new Error("Не удалось запустить анализ"); + const analysis = await analyzeResponse.json(); setAnalysisData(analysis); } catch (err) { @@ -40,205 +68,12 @@ const AnalysisResult = () => { } }, [caseId]); - const getTimeOfDay = (lostTime) => { - if (!lostTime) return 'день'; - // Значение может прийти как "YYYY-MM-DDTHH:MM" (input type="datetime-local") - // или как "HH:MM" — берём часы из обоих форматов. - const timePart = lostTime.includes('T') ? lostTime.split('T')[1] : lostTime; - const hour = parseInt(timePart.split(':')[0]); - if (hour >= 6 && hour < 18) return 'день'; - if (hour >= 18 && hour < 22) return 'сумерки'; - return 'ночь'; - }; - - const getPsychotypeModifiers = (psychotype) => { - const modifiers = { - 'dominant': { - zone_0_500: 0.7, - zone_500_1500: 1.2, - zone_1500_2500: 1.4, - zone_2500plus: 1.1, - description: 'Доминантный: активное движение, большие расстояния' - }, - 'harmonic': { - zone_0_500: 0.8, - zone_500_1500: 1.0, - zone_1500_2500: 1.1, - zone_2500plus: 0.9, - description: 'Гармоничный: рациональное движение по ориентирам' - }, - 'anxious': { - zone_0_500: 1.4, - zone_500_1500: 1.1, - zone_1500_2500: 0.5, - zone_2500plus: 0.3, - description: 'Тревожный: минимальное движение, остается близко' - }, - 'introvert_passive': { - zone_0_500: 1.3, - zone_500_1500: 0.9, - zone_1500_2500: 0.6, - zone_2500plus: 0.2, - description: 'Интроверт пассивный: прячется, минимальное движение' - }, - 'introvert_active': { - zone_0_500: 0.8, - zone_500_1500: 1.1, - zone_1500_2500: 1.2, - zone_2500plus: 0.9, - description: 'Интроверт активный: целенаправленное движение' - } - }; - - return modifiers[psychotype] || modifiers['harmonic']; - }; - - const calculateFullDistance = (caseData) => { - const age = caseData.age_years || 10; - const elapsed = caseData.elapsed_hours || 1; - const terrain = caseData.terrain?.[0] || 'лес'; - const timeOfDay = getTimeOfDay(caseData.lost_time); - const weather = caseData.precipitation || 'нет'; - - // НормС - Скорость смещения потерявшегося ребёнка (км/ч) - // Не скорость ходьбы, а реальное смещение: испуг, кружение, остановки - // Основа: ПСО ЭКСТРЕМУМ, 400 случаев — 94% найдены в пределах 3 км - let baseSpeed = 1.5; - if (age <= 2) baseSpeed = 0.3; - else if (age <= 5) baseSpeed = 0.7; - else if (age <= 8) baseSpeed = 1.2; - else if (age <= 12) baseSpeed = 1.5; - else if (age <= 15) baseSpeed = 2.0; - else if (age <= 17) baseSpeed = 2.5; - else if (age <= 64) baseSpeed = 2.5; - else baseSpeed = 1.5; - - // СП - Коэффициент местности - let terrainCoef = 0.5; - const terrainLower = terrain.toLowerCase(); - if (terrainLower.includes('дорог') || terrainLower.includes('троп')) terrainCoef = 0.8; - else if (terrainLower.includes('поле') || terrainLower.includes('луг')) terrainCoef = 0.9; - else if (terrainLower.includes('болот')) terrainCoef = 0.2; - else if (terrainLower.includes('лес')) terrainCoef = 0.5; - else if (terrainLower.includes('населённый')) terrainCoef = 1.0; - else if (terrainLower.includes('овраг')) terrainCoef = 0.3; - - // СКД - Коэффициент диагноза (влияние на физическую способность двигаться) - let diagnosisCoef = 1.0; - const diagTypes = (caseData.diagnosis_type || []) - .filter(d => d && d !== 'нет' && d !== 'none') - .map(d => d.toLowerCase()); - if (caseData.has_diagnosis && diagTypes.length > 0) { - if (diagTypes.some(d => d.includes('эпилепси'))) diagnosisCoef = 0.6; - else if (diagTypes.some(d => d.includes('зпр'))) diagnosisCoef = 0.8; - else if (diagTypes.some(d => d.includes('рас') || d.includes('аутизм'))) diagnosisCoef = 1.0; - else if (diagTypes.some(d => d.includes('сдвг'))) diagnosisCoef = 1.1; - else diagnosisCoef = 0.9; - } - - // СУ - Коэффициент транспорта - // 'none' — это строка, проверяем явно чтобы не получить false positive - let urgencyCoef = 1.0; - if (caseData.has_transport && caseData.has_transport !== 'none') { - if (caseData.has_transport === 'bike') urgencyCoef = 5.0; - else if (caseData.has_transport === 'car') urgencyCoef = 8.0; - else urgencyCoef = 3.0; - } - - // СУТ - Коэффициент усталости - const fatigueCoef = Math.max(0.3, 1.0 - (0.05 * elapsed)); - - // ВВС - Коэффициент времени суток - let timeCoef = 1.0; - if (timeOfDay === 'ночь') timeCoef = 0.5; - else if (timeOfDay === 'сумерки') timeCoef = 0.7; - - // ВП - Коэффициент погоды - let weatherCoef = 1.0; - const weatherLower = weather.toLowerCase(); - if (weatherLower.includes('дождь')) weatherCoef = 0.8; - else if (weatherLower.includes('снег')) weatherCoef = 0.6; - else if (weatherLower.includes('туман')) weatherCoef = 0.7; - - // Полная формула - const distance = elapsed * baseSpeed * terrainCoef * diagnosisCoef * urgencyCoef * fatigueCoef * timeCoef * weatherCoef; - - // Получаем модификаторы психотипа - const psychotypeModifiers = caseData.psychotype ? getPsychotypeModifiers(caseData.psychotype) : null; - - return { - max_distance_km: distance.toFixed(2), - coefficients: { - base_speed: baseSpeed, - terrain: terrainCoef, - diagnosis: diagnosisCoef, - urgency: urgencyCoef, - fatigue: fatigueCoef, - time_of_day: timeCoef, - weather: weatherCoef - }, - time_of_day: timeOfDay, - psychotype_modifiers: psychotypeModifiers, - zones: generateZonesWithPsychotype(distance, caseData, psychotypeModifiers) - }; - }; - - const generateZonesWithPsychotype = (maxDist, caseData, psychotypeModifiers) => { - const directions = [ - { dir: 'N', name: 'Север' }, - { dir: 'NE', name: 'Северо-Восток' }, - { dir: 'E', name: 'Восток' }, - { dir: 'SE', name: 'Юго-Восток' }, - { dir: 'S', name: 'Юг' }, - { dir: 'SW', name: 'Юго-Запад' }, - { dir: 'W', name: 'Запад' }, - { dir: 'NW', name: 'Северо-Запад' } - ]; - - const preferredDir = caseData.direction; - - return directions.map((d, i) => { - let score = 100 - (i * 10); - - // Применяем модификатор направления - if (preferredDir && d.dir === preferredDir) { - score += 30; - } - - // Применяем модификатор психотипа в зависимости от дистанции - if (psychotypeModifiers) { - const distKm = maxDist * 0.75; // средняя дистанция сектора - let psychoMod = 1.0; - - if (distKm < 0.5) psychoMod = psychotypeModifiers.zone_0_500; - else if (distKm < 1.5) psychoMod = psychotypeModifiers.zone_500_1500; - else if (distKm < 2.5) psychoMod = psychotypeModifiers.zone_1500_2500; - else psychoMod = psychotypeModifiers.zone_2500plus; - - score = score * psychoMod; - } - - let reasoning = 'Расчет по алгоритму Экстремум'; - if (preferredDir === d.dir) reasoning += ' (указанное направление)'; - if (psychotypeModifiers) reasoning += ` (психотип учтен)`; - - return { - priority: i + 1, - name: `Сектор ${d.name}`, - direction: d.dir, - distance: `${(maxDist * 0.5).toFixed(1)}-${maxDist.toFixed(1)} км`, - score: Math.min(100, Math.round(score)), - reasoning - }; - }).sort((a, b) => b.score - a.score); - }; - if (loading) { return (
Загрузка данных и расчет зон поиска...
+Загрузка данных и расчёт зон поиска...
Формула: Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП
-Время (Time): {caseData.elapsed_hours || 1} ч
Базовая скорость (НормС): {coeffs.base_speed} км/ч (возраст {caseData.age_years} лет)
Местность (СП): {coeffs.terrain} ({caseData.terrain?.join(', ') || 'не указана'})
Диагноз (СКД): {coeffs.diagnosis} {caseData.has_diagnosis ? `(${caseData.diagnosis_type?.join(', ')})` : '(нет)'}
-Транспорт (СУ): {coeffs.urgency} {caseData.has_transport ? `(${caseData.has_transport})` : '(нет)'}
+Транспорт (СУ): {coeffs.urgency} {caseData.has_transport && caseData.has_transport !== 'none' ? `(${caseData.has_transport})` : '(нет)'}
Усталость (СУТ): {coeffs.fatigue?.toFixed(2)} (снижение 5%/час)
Время суток (ВВС): {coeffs.time_of_day} ({analysisData?.time_of_day})
-Погода (ВП): {coeffs.weather} ({caseData.precipitation || 'нет'})
+Погода (ВП): {coeffs.weather} ({caseData.weather || caseData.precipitation || 'нет'})
+Радиус поиска: {analysisData?.search_radius_km} км × {analysisData?.distance_multiplier} (профили)
Психотип: {caseData.psychotype}
-Модель движения: {analysisData.psychotype_modifiers.description}
-- Модификаторы зон: -
-
- • 0-500м: ×{analysisData.psychotype_modifiers.zone_0_500}
- • 500-1500м: ×{analysisData.psychotype_modifiers.zone_500_1500}
- • 1500-2500м: ×{analysisData.psychotype_modifiers.zone_1500_2500}
- • 2500м+: ×{analysisData.psychotype_modifiers.zone_2500plus}
-
{p.name}
+ {p.note &&{p.note}
} + {p.critical_warning && ( +🚨 {p.critical_warning}
+ )} +Психотип: {analysisData.psychotype}
+Модель движения: {psyMods.description}
+Модификаторы зон:
+
+ • 0-500м: ×{psyMods.zone_0_500}
+ • 500-1500м: ×{psyMods.zone_500_1500}
+ • 1500-2500м: ×{psyMods.zone_1500_2500}
+ • 2500м+: ×{psyMods.zone_2500plus}
+
Тактика поиска:
+Приоритетные зоны: {psyRec.priority_zones}
+Патрун: {psyRec.search_pattern}
+Ключевые объекты: {psyRec.key_locations}
+Связь: {psyRec.communication}
+Причина: {caseData.reason || 'не указана'}
+Причина: {caseData.reason || caseData.loss_reason || 'не указана'}
Последнее известное место: {caseData.last_known_place || 'не указано'}
{caseData.direction &&Направление движения: {caseData.direction}
} - {caseData.circumstances_description && ( -Описание: {caseData.circumstances_description}
+ {caseData.behavior_description && ( +Описание: {caseData.behavior_description}
+ )} + {analysisData?.behavioral_prediction && ( +Поведенческий прогноз: {analysisData.behavioral_prediction}
)}Координаты ТНП: {caseData.tnp_lat}, {caseData.tnp_lon}
+Координаты ТНП: {tnpLat}, {tnpLon}
{caseData.tnp_address &&Адрес: {caseData.tnp_address}
}Физическая подготовка: {caseData.fitness_level}
)} {caseData.cant_swim &&⚠️ Не умеет плавать
} - {caseData.has_transport && ( + {caseData.has_transport && caseData.has_transport !== 'none' && (Транспорт: {caseData.has_transport}
)}Сезон: {caseData.season || 'не указан'}
- {caseData.temperature &&Температура: {caseData.temperature}°C
} -Осадки: {caseData.precipitation || 'нет'}
+ {caseData.temperature_c != null &&Температура: {caseData.temperature_c}°C
} +Осадки: {caseData.weather || caseData.precipitation || 'нет'}
{caseData.visibility &&Видимость: {caseData.visibility}
}