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 (
-

Загрузка данных и расчет зон поиска...

+

Загрузка данных и расчёт зон поиска...

); @@ -267,84 +102,127 @@ const AnalysisResult = () => { ); } - const urgencyLevel = caseData.elapsed_hours > 6 ? "ВЫСОКИЙ" : caseData.elapsed_hours > 3 ? "СРЕДНИЙ" : "СТАНДАРТНЫЙ"; - const urgencyReason = `Прошло ${caseData.elapsed_hours || 1} часов. ${caseData.has_transport ? `Транспорт: ${caseData.has_transport} - расширенный радиус.` : ''} ${caseData.has_diagnosis ? 'Есть диагноз - требуется особое внимание.' : ''}`; - - const zones = analysisData?.zones || []; + const zones = adaptZones(analysisData?.primary_zones); const coeffs = analysisData?.coefficients || {}; + const psyMods = analysisData?.psychotype_modifiers; + const psyRec = analysisData?.psychotype_recommendations; + const genderLabel = GENDER_LABEL[caseData.gender] || caseData.gender || ''; + const urgencyLevel = urgencyClass(analysisData?.urgency); + const profiles = analysisData?.active_profiles || []; + const warnings = analysisData?.critical_warnings || []; + const tnpLat = caseData.tnp_lat || caseData.gps_lat; + const tnpLon = caseData.tnp_lon || caseData.gps_lon; return (

ВЕКТОР Результаты анализа

- {caseData.child_name || 'Ребенок'}, {caseData.age_years} лет ({caseData.gender}) + {caseData.child_name || 'Ребёнок'}, {caseData.age_years} лет{genderLabel ? ` (${genderLabel})` : ''} • - Время потери: {caseData.lost_time || 'не указано'} + Время потери: {caseData.loss_time || 'не указано'} • Прошло: {caseData.elapsed_hours || 1} ч + {analysisData && ( + <> + • + + {analysisData.fallback_used ? 'Локальный расчёт' : 'ИИ-анализ'} + + + )}
{/* Блок 1: Уровень срочности */} -
-
{urgencyLevel}
-
{urgencyReason}
-
+ {analysisData?.urgency && ( +
+
{analysisData.urgency}
+
{analysisData.summary || ''}
+
+ )} - {/* Блок 2: Расчет по алгоритму Экстремум */} + {/* Блок 2: Расчёт по алгоритму Экстремум */}
-

📏 Расчет по алгоритму Экстремум

+

📏 Расчёт по алгоритму Экстремум

{analysisData?.max_distance_km} км

Формула: 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} (профили)

- {/* Блок 3: Психотип */} - {caseData.psychotype && analysisData?.psychotype_modifiers && ( + {/* Блок 3: Активные профили и критические предупреждения */} + {(profiles.length > 0 || warnings.length > 0) && (
-

🧠 Психотип и модель поведения

-

Психотип: {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} -

-
+

⚠️ Активные профили

+ {profiles.map((p, i) => ( +
+

{p.name}

+ {p.note &&

{p.note}

} + {p.critical_warning && ( +

🚨 {p.critical_warning}

+ )} +
+ ))}
)} - {/* Блок 4: Обстоятельства */} + {/* Блок 4: Психотип и модель поведения */} + {analysisData?.psychotype && psyMods && ( +
+

🧠 Психотип и модель поведения

+

Психотип: {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 && ( +
+

Тактика поиска:

+

Приоритетные зоны: {psyRec.priority_zones}

+

Патрун: {psyRec.search_pattern}

+

Ключевые объекты: {psyRec.key_locations}

+

Связь: {psyRec.communication}

+
+ )} +
+ )} + + {/* Блок 5: Обстоятельства потери */}

📋 Обстоятельства потери

-

Причина: {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}

)}
- {/* Блок 5: Зоны поиска */} + {/* Блок 6: Зоны поиска */}

Приоритетные зоны поиска

@@ -368,24 +246,36 @@ const AnalysisResult = () => {
- {/* Блок 6: Карта */} - {caseData.tnp_lat && caseData.tnp_lon && ( + {/* Блок 7: Немедленные действия */} + {analysisData?.immediate_actions?.length > 0 && ( +
+

🚑 Немедленные действия

+
    + {analysisData.immediate_actions.map((a, i) => ( +
  • {a}
  • + ))} +
+
+ )} + + {/* Блок 8: Карта */} + {tnpLat && tnpLon && (

Карта зон поиска

-
-

Координаты ТНП: {caseData.tnp_lat}, {caseData.tnp_lon}

+

Координаты ТНП: {tnpLat}, {tnpLon}

{caseData.tnp_address &&

Адрес: {caseData.tnp_address}

}
)} - {/* Блок 7: Здоровье и особенности */} - {(caseData.has_diagnosis || caseData.cant_swim || caseData.fitness_level) && ( + {/* Блок 9: Здоровье и особенности */} + {(caseData.has_diagnosis || caseData.cant_swim || caseData.fitness_level || caseData.has_transport) && (

🏥 Здоровье и особенности

{caseData.has_diagnosis && ( @@ -395,18 +285,18 @@ const AnalysisResult = () => {

Физическая подготовка: {caseData.fitness_level}

)} {caseData.cant_swim &&

⚠️ Не умеет плавать

} - {caseData.has_transport && ( + {caseData.has_transport && caseData.has_transport !== 'none' && (

Транспорт: {caseData.has_transport}

)}
)} - {/* Блок 8: Условия среды */} + {/* Блок 10: Условия среды */}

🌤 Условия среды

Сезон: {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}

}
diff --git a/services/distance_service.py b/services/distance_service.py index 12767bc..607c72d 100644 --- a/services/distance_service.py +++ b/services/distance_service.py @@ -48,11 +48,11 @@ def calculate_max_distance(case_data: dict) -> float: # СП - Terrain coefficient terrain_coef = get_terrain_coefficient(terrain) - # СКД - Diagnosis coefficient (placeholder, can be expanded) - diagnosis_coef = 1.0 + # СКД - Коэффициент диагноза (влияние на способность двигаться) + diagnosis_coef = get_diagnosis_coefficient(case_data.get('diagnosis_type')) - # СУ - Urgency coefficient (placeholder, can be expanded) - urgency_coef = 1.0 + # СУ - Коэффициент транспорта (велосипед/машина расширяют зону) + urgency_coef = get_transport_coefficient(case_data.get('has_transport')) # СУТ - Fatigue coefficient (5% reduction per hour) fatigue_coef = max(0.3, 1.0 - (0.05 * elapsed_hours)) @@ -90,22 +90,26 @@ def get_base_speed(age: int) -> float: """ # Скорость смещения потерявшегося ребёнка (не скорость ходьбы) # ПСО ЭКСТРЕМУМ: 94% найдены в пределах 3 км + # Скорость смещения потерявшегося ребёнка (не скорость ходьбы). + # Реальное смещение: испуг, кружение, остановки. Основа — ПСО ЭКСТРЕМУМ + # (400 случаев, 94% найдены в пределах 3 км). Значения перенесены из + # фронтендового расчёта как каноничные (согласовано с методологией). if age <= 2: - return 1.0 + return 0.3 elif age <= 5: - return 2.0 + return 0.7 elif age <= 8: - return 3.0 + return 1.2 elif age <= 12: - return 4.0 + return 1.5 elif age <= 15: - return 5.0 + return 2.0 elif age <= 17: - return 5.5 + return 2.5 elif age <= 64: - return 5.0 + return 2.5 else: # 65+ - return 3.0 + return 1.5 def get_terrain_coefficient(terrain: str) -> float: @@ -147,6 +151,56 @@ def get_terrain_coefficient(terrain: str) -> float: return 0.5 +def get_diagnosis_coefficient(diagnosis_types) -> float: + """ + Коэффициент диагноза (СКД) — влияние на физическую способность двигаться. + Порт фронтендовой логики (§6 контекста ВЕКТОР). + + Args: + diagnosis_types: список диагнозов (diagnosis_type) или строка/None + + Returns: + Коэффициент: эпилепсия 0.6, ЗПР 0.8, РАС 1.0, СДВГ 1.1, иной 0.9, нет 1.0 + """ + if not diagnosis_types: + return 1.0 + if isinstance(diagnosis_types, str): + diagnosis_types = [diagnosis_types] + diags = [str(d).lower() for d in diagnosis_types if d and str(d).lower() not in ('нет', 'none', 'здоров', 'здоровый')] + if not diags: + return 1.0 + if any('эпилепси' in d for d in diags): + return 0.6 + if any('зпр' in d for d in diags): + return 0.8 + if any(('рас' in d) or ('аутизм' in d) for d in diags): + return 1.0 + if any('сдвг' in d for d in diags): + return 1.1 + return 0.9 + + +def get_transport_coefficient(has_transport) -> float: + """ + Коэффициент транспорта (СУ) — расширяет зону поиска. + Порт фронтендовой логики. bike ×5 — критично: «расширить зону до 10-15 км». + + Args: + has_transport: 'bike' | 'scooter' | 'car' | 'other' | 'none' | None + + Returns: + Коэффициент: bike 5.0, car 8.0, иное 3.0, none/пусто 1.0 + """ + if not has_transport or str(has_transport).lower() == 'none': + return 1.0 + t = str(has_transport).lower() + if t == 'bike': + return 5.0 + if t == 'car': + return 8.0 + return 3.0 + + def get_time_of_day_coefficient(time_of_day: str) -> float: """ Get time of day movement coefficient (ВВС). @@ -164,7 +218,7 @@ def get_time_of_day_coefficient(time_of_day: str) -> float: if 'ночь' in time_lower: return 0.5 elif 'сумерки' in time_lower or 'вечер' in time_lower: - return 0.5 + return 0.7 else: # день return 1.0 diff --git a/services/scoring_service.py b/services/scoring_service.py index 4d550ed..fbf94a9 100644 --- a/services/scoring_service.py +++ b/services/scoring_service.py @@ -322,6 +322,23 @@ class WeightedScorer: distance_factor = max(0.3, min(1.0, distance_factor)) score *= distance_factor + + # Психотип: множитель полосы дистанции (§6 контекста) — порт + # фронтендовой getPsychotypeModifiers. Таблицу формирует бэкенд + # (psychotype_service.get_psychotype_modifiers) и прокидывает через + # case['psychotype_modifiers']. Без психотипа — множитель 1.0. + mods = case.get('psychotype_modifiers') + if mods: + zdist = zone.get('distance_km', 1.0) + if zdist < 0.5: + band = mods.get('zone_0_500', 1.0) + elif zdist < 1.5: + band = mods.get('zone_500_1500', 1.0) + elif zdist < 2.5: + band = mods.get('zone_1500_2500', 1.0) + else: + band = mods.get('zone_2500plus', 1.0) + score *= band # Конвертируем в шкалу 0-100 return round(score * 100, 2)