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:
2026-09-09 10:41:33 +03:00
parent d6e6277d85
commit 3e38aae2be
7 changed files with 1065 additions and 0 deletions
+2
View File
@@ -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)
+301
View File
@@ -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}
+63
View File
@@ -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
+69
View File
@@ -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
+33
View File
@@ -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>
); );
} }
+215
View File
@@ -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;
}
+382
View File
@@ -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>
);
}