feat: завершённые поиски — ручной ввод + детерминированный прогон модели (без ИИ)
Бэкенд:
- schemas.py: ClosedCaseCreate/Response — форма ~25 полей (субъект, среда,
ресурсы, исход) для внесения завершённых поисков вручную
- routers/closed_cases.py: CRUD + POST {id}/reanalyze. _run_model() —
тот же скоринг, что /analyze (WeightedScorer + distance_service +
психотип), НО БЕЗ claude_service: urgency/behavior/actions по правилам
- _accuracy_note(): прогнозный радиус vs факт (в радиусе / мимо)
- main.py: роутер подключён
Фронтенд:
- pages/ClosedCases.jsx + .css: форма внесения (VCL-density, чипы диагнозов/
рельефа), блок 'Прогноз vs факт', таблица архива (в радиусе/мимо)
- AdminDashboard.jsx: вкладки Кейсы | Завершённые поиски
Тесты: test_closed_cases.py — 6 passed. Существующие 15 failed
(test_distance_service) не связаны с изменениями — падали до них.
This commit is contained in:
@@ -11,6 +11,7 @@ from backend.routers.admin import router as admin_router
|
|||||||
from backend.routers.analyze import router as analyze_router
|
from backend.routers.analyze import router as analyze_router
|
||||||
from backend.routers.auth import router as auth_router
|
from backend.routers.auth import router as auth_router
|
||||||
from backend.routers.cases import router as cases_router
|
from backend.routers.cases import router as cases_router
|
||||||
|
from backend.routers.closed_cases import router as closed_cases_router
|
||||||
from backend.routers.health import router as health_router
|
from backend.routers.health import router as health_router
|
||||||
from backend.routers.stats import router as stats_router
|
from backend.routers.stats import router as stats_router
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ app.include_router(health_router)
|
|||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(analyze_router)
|
app.include_router(analyze_router)
|
||||||
app.include_router(cases_router)
|
app.include_router(cases_router)
|
||||||
|
app.include_router(closed_cases_router)
|
||||||
app.include_router(stats_router)
|
app.include_router(stats_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""Завершённые поиски — ручной ввод данных + прогон матмодели БЕЗ ИИ.
|
||||||
|
|
||||||
|
Цель: датасет для калибровки зонального скоринга (B12) и архив исторических
|
||||||
|
поисков. LLM не используется: скоринг полностью детерминированный
|
||||||
|
(WeightedScorer + distance_service + психотип).
|
||||||
|
|
||||||
|
Прогон модели = тот же расчёт, что /analyze, но БЕЗ claude_service:
|
||||||
|
urgency/immediate_actions/behavioral_prediction вычисляются по правилам,
|
||||||
|
зоны считаются WeightedScorer-ом по фактическим данным кейса.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/v1/closed-cases — внести завершённый поиск (+авто-прогон модели)
|
||||||
|
GET /api/v1/closed-cases — список (для таблицы в админке)
|
||||||
|
GET /api/v1/closed-cases/{id} — карточка
|
||||||
|
DELETE /api/v1/closed-cases/{id} — удалить (опечатка)
|
||||||
|
POST /api/v1/closed-cases/{id}/reanalyze — перегнать модель по внесённым данным
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from backend.database import db
|
||||||
|
from backend.routers.analyze import (
|
||||||
|
AnalysisRequest,
|
||||||
|
_derive_profiles,
|
||||||
|
_derive_time_of_day,
|
||||||
|
)
|
||||||
|
from backend.routers.auth import require_roles
|
||||||
|
from backend.schemas import ClosedCaseCreate, ClosedCaseListResponse, ClosedCaseResponse
|
||||||
|
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/closed-cases',
|
||||||
|
tags=['closed-cases'],
|
||||||
|
dependencies=[Depends(require_roles(['operator', 'admin']))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_model(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Детерминированный прогон матмодели по данным завершённого поиска.
|
||||||
|
|
||||||
|
Повторяет логику analyze.analyze_case, но БЕЗ вызова claude_service.
|
||||||
|
urgency/behavioral_prediction/immediate_actions вычисляются правилами.
|
||||||
|
"""
|
||||||
|
# Психотип из ответов или явного указания
|
||||||
|
psychotype = None
|
||||||
|
psychotype_modifiers = None
|
||||||
|
psychotype_recommendations = None
|
||||||
|
if data.get('psychotype_answers'):
|
||||||
|
psychotype = detect_psychotype(data['psychotype_answers'])
|
||||||
|
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||||
|
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||||
|
elif data.get('psychotype'):
|
||||||
|
psychotype = data['psychotype']
|
||||||
|
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||||
|
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||||
|
|
||||||
|
time_of_day = _derive_time_of_day(data.get('loss_time'))
|
||||||
|
profiles = _derive_profiles({**data, 'profiles': data.get('profiles') or []})
|
||||||
|
|
||||||
|
max_distance_km = calculate_max_distance(data)
|
||||||
|
|
||||||
|
scorer = WeightedScorer()
|
||||||
|
if data.get('age_years'):
|
||||||
|
scorer.apply_age_modifiers(int(data['age_years']))
|
||||||
|
if data.get('season'):
|
||||||
|
scorer.apply_season_modifiers(str(data['season']))
|
||||||
|
if profiles:
|
||||||
|
scorer.apply_profile(profiles)
|
||||||
|
if psychotype_modifiers:
|
||||||
|
data['psychotype_modifiers'] = psychotype_modifiers
|
||||||
|
scorer._normalize_weights()
|
||||||
|
|
||||||
|
elapsed = float(data.get('elapsed_hours') or 1)
|
||||||
|
age_val = int(data.get('age_years') or 10)
|
||||||
|
|
||||||
|
coefficients = {
|
||||||
|
'base_speed': get_base_speed(age_val),
|
||||||
|
'terrain': get_terrain_coefficient(data.get('terrain_primary') or (data.get('terrain') or ['лес'])[0]),
|
||||||
|
'diagnosis': get_diagnosis_coefficient(data.get('diagnosis_type')),
|
||||||
|
'urgency': get_transport_coefficient(data.get('has_transport')),
|
||||||
|
'fatigue': max(0.3, 1.0 - 0.05 * elapsed),
|
||||||
|
'time_of_day': get_time_of_day_coefficient(_derive_time_of_day(data.get('loss_time'))),
|
||||||
|
'weather': get_weather_coefficient(data.get('weather') or 'нет'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Правила вместо LLM: urgency по elapsed + возраст + диагноз
|
||||||
|
urgency = _rule_based_urgency(elapsed, age_val, data.get('diagnosis_type') or [])
|
||||||
|
behavioral = _rule_based_behavior(data, profiles, psychotype)
|
||||||
|
actions = _rule_based_actions(data, profiles, psychotype)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'max_distance_km': max_distance_km,
|
||||||
|
'coefficients': coefficients,
|
||||||
|
'time_of_day': 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': urgency,
|
||||||
|
'behavioral_prediction': behavioral,
|
||||||
|
'immediate_actions': actions,
|
||||||
|
'summary': 'Детерминированный расчёт (без LLM) — форма «Завершённый поиск»',
|
||||||
|
'fallback_used': False,
|
||||||
|
'llm_used': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_based_urgency(elapsed: float, age: int, diagnosis: list[str]) -> str:
|
||||||
|
"""urgency по правилам: чем меньше ребёнок и дольше поиск — тем выше срочность."""
|
||||||
|
score = 0
|
||||||
|
if elapsed >= 24:
|
||||||
|
score += 2
|
||||||
|
elif elapsed >= 8:
|
||||||
|
score += 1
|
||||||
|
if age <= 7:
|
||||||
|
score += 2
|
||||||
|
elif age <= 12:
|
||||||
|
score += 1
|
||||||
|
if diagnosis:
|
||||||
|
score += 1
|
||||||
|
if score >= 4:
|
||||||
|
return 'критическая'
|
||||||
|
if score >= 2:
|
||||||
|
return 'высокая'
|
||||||
|
return 'средняя'
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_based_behavior(data: dict, profiles: list[str], psychotype: str | None) -> str:
|
||||||
|
"""Краткое поведенческое предсказание по профилям (справочник WeightedScorer)."""
|
||||||
|
parts: list[str] = []
|
||||||
|
if psychotype:
|
||||||
|
parts.append(f'Психотип: {psychotype}.')
|
||||||
|
if 'РАС' in profiles:
|
||||||
|
parts.append('Склонен держаться линейных ориентиров (дороги, заборы, ручьи); может прятаться от поисковиков.')
|
||||||
|
if 'эпилепсия' in profiles:
|
||||||
|
parts.append('Риск дезориентации после приступа; искать вблизи водных преград и уклонов.')
|
||||||
|
if 'СДВГ' in profiles:
|
||||||
|
parts.append('Импульсивное движение вперёд по линии наименьшего сопротивления, большие дистанции.')
|
||||||
|
if 'ЗПР' in profiles:
|
||||||
|
parts.append('Держится знакомых мест; вероятен возврат к точке потери или знакомым объектам.')
|
||||||
|
if str(data.get('has_transport') or '') == 'bike':
|
||||||
|
parts.append('Транспорт (велик) сильно расширяет радиус — проверить линейные объекты вдоль дорог.')
|
||||||
|
if data.get('cant_swim'):
|
||||||
|
parts.append('Не умеет плавать — приоритет ближним водоёмам.')
|
||||||
|
if not parts:
|
||||||
|
parts.append('Типовое поведение для возраста: линейное движение по дороге/тропе с постепенным отклонением.')
|
||||||
|
return ' '.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_based_actions(data: dict, profiles: list[str], psychotype: str | None) -> list[str]:
|
||||||
|
"""Первоочередные действия по правилам (справочник по профилям)."""
|
||||||
|
actions: list[str] = []
|
||||||
|
elapsed = float(data.get('elapsed_hours') or 0)
|
||||||
|
if elapsed >= 8:
|
||||||
|
actions.append('Подключить кинолога к зоне ТНП в первую очередь')
|
||||||
|
if 'РАС' in profiles:
|
||||||
|
actions.append('Проверить укрытия, строения, ёмкости с водой — обследование с физическим осмотром')
|
||||||
|
actions.append('Опросить соседей о ближайших «точках притяжения» (гараж, сарай, теплица)')
|
||||||
|
if data.get('cant_swim'):
|
||||||
|
actions.append('Обследовать ближайшие водоёмы немедленно')
|
||||||
|
if elapsed >= 24:
|
||||||
|
actions.append('Расширить поиск за пределы расчётного радиуса')
|
||||||
|
actions.append('Опросить жителей и водителей, проверив направления от ТНП')
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def _accuracy_note(prediction: dict[str, Any], fact: ClosedCaseCreate) -> str | None:
|
||||||
|
"""Сравнить прогноз модели с реальным исходом (текстово, человекочитаемо)."""
|
||||||
|
if fact.found_distance_km is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
max_d = float(prediction.get('max_distance_km') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if max_d <= 0:
|
||||||
|
return 'Прогноз не построен'
|
||||||
|
found_km = float(fact.found_distance_km)
|
||||||
|
ratio = found_km / max_d if max_d else 0
|
||||||
|
if ratio <= 1.0:
|
||||||
|
note = f'Найден в {found_km:.1f} км — внутри прогнозного радиуса {max_d:.1f} км (соотношение {ratio:.2f}).'
|
||||||
|
else:
|
||||||
|
note = f'Найден в {found_km:.1f} км — ЗА пределами прогнозного радиуса {max_d:.1f} км (соотношение {ratio:.2f}).'
|
||||||
|
if not fact.found_alive:
|
||||||
|
note += ' Исход неблагоприятный.'
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Endpoints ----------------
|
||||||
|
|
||||||
|
@router.post('', response_model=ClosedCaseResponse)
|
||||||
|
def create_closed_case(payload: ClosedCaseCreate) -> dict[str, Any]:
|
||||||
|
data = payload.model_dump()
|
||||||
|
prediction = _run_model(data)
|
||||||
|
accuracy = _accuracy_note(prediction, payload)
|
||||||
|
|
||||||
|
case = db.create_case(
|
||||||
|
**{k: v for k, v in data.items() if v is not None},
|
||||||
|
status='closed',
|
||||||
|
)
|
||||||
|
# Прогноз и заметку о точности храним в analysis_log (JSONB) —
|
||||||
|
# схема cases не меняется, отдельной таблицы не нужно.
|
||||||
|
db.update_case(
|
||||||
|
str(case.id),
|
||||||
|
analysis_log={
|
||||||
|
'closed_case': True,
|
||||||
|
'model_prediction': prediction,
|
||||||
|
'accuracy_note': accuracy,
|
||||||
|
'source': payload.source,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
detail = case.to_detail()
|
||||||
|
return {**data, 'id': str(case.id), 'created_at': detail.get('created_at'),
|
||||||
|
'model_prediction': prediction, 'accuracy_note': accuracy}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('', response_model=ClosedCaseListResponse)
|
||||||
|
def list_closed_cases(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
|
) -> dict:
|
||||||
|
cases = [c for c in db.list_cases() if (c.to_summary().get('analysis_log') or {}).get('closed_case')]
|
||||||
|
items = []
|
||||||
|
for c in cases:
|
||||||
|
detail = c.to_detail()
|
||||||
|
log = detail.get('analysis_log') or {}
|
||||||
|
if not log.get('closed_case'):
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
**{k: detail.get(k) for k in ClosedCaseCreate.model_fields},
|
||||||
|
'id': detail['id'],
|
||||||
|
'created_at': detail.get('created_at'),
|
||||||
|
'model_prediction': log.get('model_prediction'),
|
||||||
|
'accuracy_note': log.get('accuracy_note'),
|
||||||
|
})
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
return {'items': items[start:start + page_size], 'total': len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/{case_id}', response_model=ClosedCaseResponse)
|
||||||
|
def get_closed_case(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()
|
||||||
|
log = detail.get('analysis_log') or {}
|
||||||
|
if not log.get('closed_case'):
|
||||||
|
raise HTTPException(status_code=404, detail='Не завершённый поиск')
|
||||||
|
return {**{k: detail.get(k) for k in ClosedCaseCreate.model_fields},
|
||||||
|
'id': detail['id'], 'created_at': detail.get('created_at'),
|
||||||
|
'model_prediction': log.get('model_prediction'),
|
||||||
|
'accuracy_note': log.get('accuracy_note')}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/{case_id}/reanalyze')
|
||||||
|
def reanalyze_closed_case(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()
|
||||||
|
log = detail.get('analysis_log') or {}
|
||||||
|
if not log.get('closed_case'):
|
||||||
|
raise HTTPException(status_code=400, detail='Не завершённый поиск')
|
||||||
|
|
||||||
|
data = {k: detail.get(k) for k in ClosedCaseCreate.model_fields}
|
||||||
|
prediction = _run_model(data)
|
||||||
|
fact = ClosedCaseCreate(**data)
|
||||||
|
accuracy = _accuracy_note(prediction, fact)
|
||||||
|
new_log = {**log, 'model_prediction': prediction, 'accuracy_note': accuracy}
|
||||||
|
db.update_case(case_id, analysis_log=new_log)
|
||||||
|
return {'model_prediction': prediction, 'accuracy_note': accuracy}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/{case_id}')
|
||||||
|
def delete_closed_case(case_id: str) -> dict[str, bool]:
|
||||||
|
deleted = db.delete_case(case_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail='Case not found')
|
||||||
|
return {'deleted': True}
|
||||||
@@ -138,3 +138,66 @@ class ParseDocResponse(BaseModel):
|
|||||||
parsed: bool
|
parsed: bool
|
||||||
preview: dict[str, Any]
|
preview: dict[str, Any]
|
||||||
raw_text: str | None = None
|
raw_text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ================= ЗАВЕРШЁННЫЕ ПОИСКИ (ручной ввод, без ИИ) =================
|
||||||
|
# Оператор руками вносит данные ЗАВЕРШЁННОГО поиска: параметры субъекта/среды
|
||||||
|
# (вход матмодели) + реальный исход. Цель — датасет для калибровки модели
|
||||||
|
# (сравнение прогноза ВЕКТОРА с фактом) и архив 14 лет без LLM.
|
||||||
|
|
||||||
|
class ClosedCaseCreate(BaseModel):
|
||||||
|
# --- Субъект (вход модели) ---
|
||||||
|
age_years: int
|
||||||
|
gender: str = 'м' # 'м' | 'ж'
|
||||||
|
diagnosis_type: list[str] = Field(default_factory=list) # РАС, эпилепсия, СДВГ, ЗПР...
|
||||||
|
has_transport: str = 'none' # none|bike|scooter|other
|
||||||
|
cant_swim: bool = False
|
||||||
|
fitness_level: str | None = None # низкий|средний|высокий
|
||||||
|
|
||||||
|
# --- Обстоятельства (вход матмодели) ---
|
||||||
|
elapsed_hours: float
|
||||||
|
loss_time: datetime | None = None # время потери (для time_of_day)
|
||||||
|
terrain: list[str] = Field(default_factory=list) # лес, болото, поле...
|
||||||
|
weather: str | None = None
|
||||||
|
season: str | None = None
|
||||||
|
temperature_c: float | None = None
|
||||||
|
tnp_lat: float | None = None
|
||||||
|
tnp_lon: float | None = None
|
||||||
|
tnp_address: str | None = None
|
||||||
|
|
||||||
|
# --- Психотип (опционально, вход скоринга) ---
|
||||||
|
psychotype: str | None = None
|
||||||
|
psychotype_answers: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
# --- Ресурсы ---
|
||||||
|
teams_count: int | None = None
|
||||||
|
team_size: int | None = None
|
||||||
|
has_dog: bool = False
|
||||||
|
|
||||||
|
# --- Исход (факт, для сравнения с прогнозом) ---
|
||||||
|
found_alive: bool
|
||||||
|
found_distance_km: float | None = None
|
||||||
|
found_direction: str | None = None
|
||||||
|
found_location_type: str | None = None
|
||||||
|
found_lat: float | None = None
|
||||||
|
found_lon: float | None = None
|
||||||
|
search_duration_hours: float | None = None
|
||||||
|
who_found: str | None = None
|
||||||
|
|
||||||
|
# --- Мета ---
|
||||||
|
notes: str | None = None
|
||||||
|
source: str | None = None # откуда: спецдонесение 2015, бумажный журнал...
|
||||||
|
|
||||||
|
|
||||||
|
class ClosedCaseResponse(ClosedCaseCreate):
|
||||||
|
id: str
|
||||||
|
created_at: datetime
|
||||||
|
# Сохранённый прогноз модели на момент внесения (для калибровки)
|
||||||
|
model_prediction: dict[str, Any] | None = None
|
||||||
|
# Насколько прогноз совпал с фактом
|
||||||
|
accuracy_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClosedCaseListResponse(BaseModel):
|
||||||
|
items: list[ClosedCaseResponse]
|
||||||
|
total: int
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Тесты завершённых поисков (ручной ввод, без ИИ)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.routers.closed_cases import (
|
||||||
|
_accuracy_note,
|
||||||
|
_rule_based_actions,
|
||||||
|
_rule_based_behavior,
|
||||||
|
_rule_based_urgency,
|
||||||
|
_run_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_based_urgency_levels():
|
||||||
|
# Младенец, сутки поиска, диагноз → критическая
|
||||||
|
assert _rule_based_urgency(30, 3, ['РАС']) == 'критическая'
|
||||||
|
# Подросток, 2 часа, без диагнозов → средняя
|
||||||
|
assert _rule_based_urgency(2, 15, []) == 'средняя'
|
||||||
|
# 10 часов, 6 лет → высокая
|
||||||
|
assert _rule_based_urgency(10, 6, []) == 'высокая'
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_based_behavior_profiles():
|
||||||
|
data = {'has_transport': 'bike', 'cant_swim': True}
|
||||||
|
text = _rule_based_behavior(data, ['РАС'], None)
|
||||||
|
assert 'линейных ориентиров' in text
|
||||||
|
assert 'велик' in text
|
||||||
|
assert 'плавать' in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_based_actions_ras():
|
||||||
|
actions = _rule_based_actions({'elapsed_hours': 10, 'cant_swim': False}, ['РАС'], None)
|
||||||
|
assert any('кинолог' in a for a in actions)
|
||||||
|
assert any('укрыти' in a for a in actions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_accuracy_note_inside_radius():
|
||||||
|
pred = {'max_distance_km': 5.4}
|
||||||
|
fact = type('F', (), {'found_distance_km': 2.0, 'found_alive': True})()
|
||||||
|
note = _accuracy_note(pred, fact)
|
||||||
|
assert 'внутри прогнозного радиуса' in note
|
||||||
|
assert '5.4' in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_accuracy_note_outside_radius():
|
||||||
|
pred = {'max_distance_km': 3.0}
|
||||||
|
fact = type('F', (), {'found_distance_km': 7.0, 'found_alive': False})()
|
||||||
|
note = _accuracy_note(pred, fact)
|
||||||
|
assert 'ЗА пределами' in note
|
||||||
|
assert 'неблагоприятный' in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_model_no_llm():
|
||||||
|
"""Ключевой контракт: прогон модели работает БЕЗ вызова LLM."""
|
||||||
|
data = {
|
||||||
|
'age_years': 8, 'gender': 'м', 'diagnosis_type': [],
|
||||||
|
'has_transport': 'none', 'cant_swim': False,
|
||||||
|
'elapsed_hours': 3, 'terrain': ['лес'], 'terrain_primary': 'лес',
|
||||||
|
'weather': 'нет', 'season': 'лето',
|
||||||
|
}
|
||||||
|
result = _run_model(data)
|
||||||
|
assert result['llm_used'] is False
|
||||||
|
assert result['fallback_used'] is False
|
||||||
|
assert result['max_distance_km'] > 0
|
||||||
|
assert 'coefficients' in result
|
||||||
|
assert result['coefficients']['base_speed'] > 0
|
||||||
|
# Для 8 лет, 3 часа, лес, день — радиус порядка нескольких км
|
||||||
|
assert 1 <= result['max_distance_km'] <= 15
|
||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { apiFetch } from '../api/client';
|
import { apiFetch } from '../api/client';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import ClosedCases from './ClosedCases';
|
||||||
import './AdminDashboard.css';
|
import './AdminDashboard.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
@@ -31,6 +32,7 @@ function Badge({ children, tone = 'neutral' }) {
|
|||||||
|
|
||||||
function AdminDashboard() {
|
function AdminDashboard() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const [activeTab, setActiveTab] = useState('cases'); // 'cases' | 'closed'
|
||||||
const [cases, setCases] = useState([]);
|
const [cases, setCases] = useState([]);
|
||||||
const [dashboard, setDashboard] = useState(null);
|
const [dashboard, setDashboard] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -338,6 +340,35 @@ function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="admin-tabs" style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
|
||||||
|
{[
|
||||||
|
{ id: 'cases', label: 'Кейсы' },
|
||||||
|
{ id: 'closed', label: 'Завершённые поиски' },
|
||||||
|
].map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(t.id)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '1px solid #c2cad4',
|
||||||
|
borderBottom: activeTab === t.id ? 'none' : '1px solid #c2cad4',
|
||||||
|
background: activeTab === t.id ? '#fff' : '#eef2f7',
|
||||||
|
fontWeight: activeTab === t.id ? 600 : 400,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'closed' ? (
|
||||||
|
<ClosedCases />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<section className="admin-metrics-grid">
|
<section className="admin-metrics-grid">
|
||||||
<article className="admin-metric-card">
|
<article className="admin-metric-card">
|
||||||
<span className="admin-metric-label">Всего кейсов</span>
|
<span className="admin-metric-label">Всего кейсов</span>
|
||||||
@@ -685,6 +716,8 @@ function AdminDashboard() {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/* Завершённые поиски — VCL-density, без скруглений/теней */
|
||||||
|
|
||||||
|
.cc-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d5dbe3;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-header h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-header p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-form {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-group-title {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #e3e8ee;
|
||||||
|
color: #234;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-field-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-field span {
|
||||||
|
color: #456;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-field input,
|
||||||
|
.cc-field select,
|
||||||
|
.cc-field textarea {
|
||||||
|
border: 1px solid #c2cad4;
|
||||||
|
background: #fff;
|
||||||
|
padding: 5px 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-field input:focus,
|
||||||
|
.cc-field select:focus,
|
||||||
|
.cc-field textarea:focus {
|
||||||
|
outline: 2px solid #2b6cb0;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-chip-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-chip {
|
||||||
|
border: 1px solid #b8c2ce;
|
||||||
|
background: #f4f6f9;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-chip-on {
|
||||||
|
background: #2b6cb0;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2b6cb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-primary-btn {
|
||||||
|
background: #2b6cb0;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-primary-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-alert {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-alert-error {
|
||||||
|
background: #fde8e8;
|
||||||
|
border: 1px solid #e53e3e;
|
||||||
|
color: #9b2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Прогноз vs факт */
|
||||||
|
.cc-pred-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pred-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pred-item span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #567;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pred-item strong {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #1a365d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-accuracy {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f0f5fb;
|
||||||
|
border: 1px solid #b8d0ea;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Таблица архива */
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table th,
|
||||||
|
.cc-table td {
|
||||||
|
border: 1px solid #dde3ea;
|
||||||
|
padding: 5px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table th {
|
||||||
|
background: #eef2f7;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table tbody tr:hover {
|
||||||
|
background: #f7fafd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-hit {
|
||||||
|
color: #276749;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-miss {
|
||||||
|
color: #c53030;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-link-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #c53030;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-empty {
|
||||||
|
color: #678;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiFetch } from '../api/client';
|
||||||
|
import './ClosedCases.css';
|
||||||
|
|
||||||
|
const DIAGNOSIS_OPTIONS = ['РАС', 'эпилепсия', 'СДВГ', 'ЗПР'];
|
||||||
|
const TERRAIN_OPTIONS = ['лес', 'болото', 'поле', 'город', 'село', 'горы', 'водоём'];
|
||||||
|
const TRANSPORT_OPTIONS = [
|
||||||
|
{ value: 'none', label: 'нет' },
|
||||||
|
{ value: 'bike', label: 'велосипед' },
|
||||||
|
{ value: 'scooter', label: 'самокат' },
|
||||||
|
{ value: 'other', label: 'другое' },
|
||||||
|
];
|
||||||
|
const SEASON_OPTIONS = ['зима', 'весна', 'лето', 'осень'];
|
||||||
|
const WEATHER_OPTIONS = ['нет', 'дождь', 'ливень', 'туман', 'снег', 'жара'];
|
||||||
|
const LOCATION_TYPES = ['дорога', 'тропа', 'лес', 'дом/стройка', 'вода/берег', 'открытое поле', 'не указано'];
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
age_years: '',
|
||||||
|
gender: 'м',
|
||||||
|
diagnosis_type: [],
|
||||||
|
has_transport: 'none',
|
||||||
|
cant_swim: false,
|
||||||
|
fitness_level: '',
|
||||||
|
elapsed_hours: '',
|
||||||
|
loss_time: '',
|
||||||
|
terrain: [],
|
||||||
|
weather: '',
|
||||||
|
season: '',
|
||||||
|
temperature_c: '',
|
||||||
|
tnp_lat: '',
|
||||||
|
tnp_lon: '',
|
||||||
|
tnp_address: '',
|
||||||
|
teams_count: '',
|
||||||
|
team_size: '',
|
||||||
|
has_dog: false,
|
||||||
|
found_alive: 'true',
|
||||||
|
found_distance_km: '',
|
||||||
|
found_direction: '',
|
||||||
|
found_location_type: '',
|
||||||
|
found_lat: '',
|
||||||
|
found_lon: '',
|
||||||
|
search_duration_hours: '',
|
||||||
|
who_found: '',
|
||||||
|
notes: '',
|
||||||
|
source: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function num(value) {
|
||||||
|
if (value === '' || value === null || value === undefined) return null;
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CleanForm(form) {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
age_years: num(form.age_years) ?? 0,
|
||||||
|
elapsed_hours: num(form.elapsed_hours) ?? 0,
|
||||||
|
loss_time: form.loss_time ? new Date(form.loss_time).toISOString() : null,
|
||||||
|
temperature_c: num(form.temperature_c),
|
||||||
|
tnp_lat: num(form.tnp_lat),
|
||||||
|
tnp_lon: num(form.tnp_lon),
|
||||||
|
teams_count: num(form.teams_count),
|
||||||
|
team_size: num(form.team_size),
|
||||||
|
found_distance_km: num(form.found_distance_km),
|
||||||
|
found_lat: num(form.found_lat),
|
||||||
|
found_lon: num(form.found_lon),
|
||||||
|
search_duration_hours: num(form.search_duration_hours),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipGroup({ options, selected, onToggle }) {
|
||||||
|
return (
|
||||||
|
<div className="cc-chip-group">
|
||||||
|
{options.map((opt) => {
|
||||||
|
const isOn = selected.includes(opt);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
type="button"
|
||||||
|
className={`cc-chip ${isOn ? 'cc-chip-on' : ''}`}
|
||||||
|
onClick={() => onToggle(opt)}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children, wide = false }) {
|
||||||
|
return (
|
||||||
|
<label className={`cc-field ${wide ? 'cc-field-wide' : ''}`}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClosedCases() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [lastPrediction, setLastPrediction] = useState(null);
|
||||||
|
const [expandedId, setExpandedId] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/api/v1/closed-cases?page=1&page_size=200');
|
||||||
|
setItems(data.items || []);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Ошибка загрузки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const update = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
const toggleChip = (key, opt) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
[key]: f[key].includes(opt) ? f[key].filter((x) => x !== opt) : [...f[key], opt],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const submit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!form.age_years || !form.elapsed_hours) {
|
||||||
|
setError('Обязательны: возраст и часы с момента потери');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await apiFetch('/api/v1/closed-cases', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(CleanForm(form)),
|
||||||
|
});
|
||||||
|
setLastPrediction(created);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Ошибка сохранения');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id) => {
|
||||||
|
if (!window.confirm('Удалить запись?')) return;
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/v1/closed-cases/${id}`, { method: 'DELETE' });
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cc-wrap">
|
||||||
|
{/* ---------- Форма внесения ---------- */}
|
||||||
|
<section className="cc-panel">
|
||||||
|
<div className="cc-panel-header">
|
||||||
|
<h2>Завершённый поиск — ручной ввод</h2>
|
||||||
|
<p>Данные субъекта, среды и реальный исход. Модель считается без ИИ, детерминированно.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="cc-form">
|
||||||
|
<h3 className="cc-group-title">Субъект</h3>
|
||||||
|
<div className="cc-grid">
|
||||||
|
<Field label="Возраст, лет *">
|
||||||
|
<input type="number" min="0" max="120" value={form.age_years}
|
||||||
|
onChange={(e) => update('age_years', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Пол">
|
||||||
|
<select value={form.gender} onChange={(e) => update('gender', e.target.value)}>
|
||||||
|
<option value="м">Мужской</option>
|
||||||
|
<option value="ж">Женский</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Транспорт">
|
||||||
|
<select value={form.has_transport} onChange={(e) => update('has_transport', e.target.value)}>
|
||||||
|
{TRANSPORT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Не умеет плавать">
|
||||||
|
<select value={form.cant_swim ? '1' : '0'} onChange={(e) => update('cant_swim', e.target.value === '1')}>
|
||||||
|
<option value="0">Нет</option>
|
||||||
|
<option value="1">Да</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Диагнозы / особенности" wide>
|
||||||
|
<ChipGroup options={DIAGNOSIS_OPTIONS} selected={form.diagnosis_type} onToggle={(o) => toggleChip('diagnosis_type', o)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<h3 className="cc-group-title">Обстоятельства и среда</h3>
|
||||||
|
<div className="cc-grid">
|
||||||
|
<Field label="Часов с момента потери *">
|
||||||
|
<input type="number" min="0" step="0.5" value={form.elapsed_hours}
|
||||||
|
onChange={(e) => update('elapsed_hours', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Время потери">
|
||||||
|
<input type="datetime-local" value={form.loss_time}
|
||||||
|
onChange={(e) => update('loss_time', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Погода">
|
||||||
|
<select value={form.weather} onChange={(e) => update('weather', e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{WEATHER_OPTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Сезон">
|
||||||
|
<select value={form.season} onChange={(e) => update('season', e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{SEASON_OPTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Температура, °C">
|
||||||
|
<input type="number" step="1" value={form.temperature_c}
|
||||||
|
onChange={(e) => update('temperature_c', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="ТНП (адрес/место)">
|
||||||
|
<input value={form.tnp_address} onChange={(e) => update('tnp_address', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="ТНП широта">
|
||||||
|
<input type="number" step="0.000001" value={form.tnp_lat}
|
||||||
|
onChange={(e) => update('tnp_lat', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="ТНП долгота">
|
||||||
|
<input type="number" step="0.000001" value={form.tnp_lon}
|
||||||
|
onChange={(e) => update('tnp_lon', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Рельеф / местность" wide>
|
||||||
|
<ChipGroup options={TERRAIN_OPTIONS} selected={form.terrain} onToggle={(o) => toggleChip('terrain', o)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<h3 className="cc-group-title">Ресурсы</h3>
|
||||||
|
<div className="cc-grid">
|
||||||
|
<Field label="Групп">
|
||||||
|
<input type="number" min="0" value={form.teams_count}
|
||||||
|
onChange={(e) => update('teams_count', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Человек в группе">
|
||||||
|
<input type="number" min="0" value={form.team_size}
|
||||||
|
onChange={(e) => update('team_size', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Кинолог">
|
||||||
|
<select value={form.has_dog ? '1' : '0'} onChange={(e) => update('has_dog', e.target.value === '1')}>
|
||||||
|
<option value="0">Нет</option>
|
||||||
|
<option value="1">Да</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="cc-group-title">Исход (факт)</h3>
|
||||||
|
<div className="cc-grid">
|
||||||
|
<Field label="Найден живым">
|
||||||
|
<select value={form.found_alive} onChange={(e) => update('found_alive', e.target.value)}>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет (погиб)</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Дистанция находки, км">
|
||||||
|
<input type="number" step="0.1" min="0" value={form.found_distance_km}
|
||||||
|
onChange={(e) => update('found_distance_km', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Направление от ТНП">
|
||||||
|
<input value={form.found_direction} placeholder="С, СВ, В…"
|
||||||
|
onChange={(e) => update('found_direction', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Тип места находки">
|
||||||
|
<select value={form.found_location_type} onChange={(e) => update('found_location_type', e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{LOCATION_TYPES.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Кто нашёл">
|
||||||
|
<input value={form.who_found} placeholder="группа, кинолог, местные…"
|
||||||
|
onChange={(e) => update('who_found', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Длительность поиска, ч">
|
||||||
|
<input type="number" step="0.5" value={form.search_duration_hours}
|
||||||
|
onChange={(e) => update('search_duration_hours', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cc-grid">
|
||||||
|
<Field label="Источник (спецдонесение №, год)" wide>
|
||||||
|
<input value={form.source} placeholder="напр. Спецдонесение 2015-07, бумажный журнал"
|
||||||
|
onChange={(e) => update('source', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Заметки" wide>
|
||||||
|
<textarea rows="2" value={form.notes} onChange={(e) => update('notes', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="cc-alert cc-alert-error">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="cc-actions">
|
||||||
|
<button type="submit" className="cc-primary-btn" disabled={saving}>
|
||||||
|
{saving ? 'Сохраняю…' : 'Внести и прогнать модель'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- Результат последнего прогона ---------- */}
|
||||||
|
{lastPrediction && (
|
||||||
|
<section className="cc-panel cc-prediction">
|
||||||
|
<h2>Прогноз модели vs факт</h2>
|
||||||
|
<div className="cc-pred-grid">
|
||||||
|
<div className="cc-pred-item">
|
||||||
|
<span>Прогнозный радиус</span>
|
||||||
|
<strong>{lastPrediction.model_prediction?.max_distance_km ?? '—'} км</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cc-pred-item">
|
||||||
|
<span>Факт (дистанция находки)</span>
|
||||||
|
<strong>{lastPrediction.found_distance_km ?? '—'} км</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cc-pred-item">
|
||||||
|
<span>Срочность (по правилам)</span>
|
||||||
|
<strong>{lastPrediction.model_prediction?.urgency ?? '—'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{lastPrediction.accuracy_note && (
|
||||||
|
<div className="cc-accuracy">{lastPrediction.accuracy_note}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------- Список внесённых ---------- */}
|
||||||
|
<section className="cc-panel">
|
||||||
|
<div className="cc-panel-header">
|
||||||
|
<h2>Архив завершённых поисков ({items.length})</h2>
|
||||||
|
</div>
|
||||||
|
{loading ? <p>Загрузка…</p> : items.length === 0 ? (
|
||||||
|
<p className="cc-empty">Пока ничего не внесено.</p>
|
||||||
|
) : (
|
||||||
|
<table className="cc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Возраст</th><th>Пол</th><th>Часов</th><th>Рельеф</th>
|
||||||
|
<th>Найден</th><th>Факт, км</th><th>Прогноз, км</th><th>Точность</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => {
|
||||||
|
const pred = item.model_prediction?.max_distance_km;
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.age_years}</td>
|
||||||
|
<td>{item.gender}</td>
|
||||||
|
<td>{item.elapsed_hours}</td>
|
||||||
|
<td>{(item.terrain || []).join(', ') || '—'}</td>
|
||||||
|
<td>{item.found_alive ? 'живым' : 'погиб'}</td>
|
||||||
|
<td>{item.found_distance_km ?? '—'}</td>
|
||||||
|
<td>{pred != null ? Number(pred).toFixed(1) : '—'}</td>
|
||||||
|
<td className={item.accuracy_note && item.accuracy_note.includes('ЗА') ? 'cc-miss' : 'cc-hit'}>
|
||||||
|
{item.accuracy_note ? (item.accuracy_note.includes('ЗА') ? 'мимо' : 'в радиусе') : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="cc-link-btn" onClick={() => remove(item.id)}>✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user