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>
This commit is contained in:
+113
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user