From 3e38aae2beda9abf33285ec1a1f51b37516ca652 Mon Sep 17 00:00:00 2001 From: viktot14-ai Date: Wed, 9 Sep 2026 10:41:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=20=E2=80=94=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B2=D0=B2=D0=BE=D0=B4=20+=20=D0=B4=D0=B5=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20(=D0=B1=D0=B5=D0=B7=20=D0=98?= =?UTF-8?q?=D0=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд: - 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) не связаны с изменениями — падали до них. --- backend/main.py | 2 + backend/routers/closed_cases.py | 301 ++++++++++++++++++++ backend/schemas.py | 63 +++++ backend/tests/test_closed_cases.py | 69 +++++ frontend/src/pages/AdminDashboard.jsx | 33 +++ frontend/src/pages/ClosedCases.css | 215 +++++++++++++++ frontend/src/pages/ClosedCases.jsx | 382 ++++++++++++++++++++++++++ 7 files changed, 1065 insertions(+) create mode 100644 backend/routers/closed_cases.py create mode 100644 backend/tests/test_closed_cases.py create mode 100644 frontend/src/pages/ClosedCases.css create mode 100644 frontend/src/pages/ClosedCases.jsx diff --git a/backend/main.py b/backend/main.py index ba0588d..315727f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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.auth import router as auth_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.stats import router as stats_router @@ -47,6 +48,7 @@ app.include_router(health_router) app.include_router(auth_router) app.include_router(analyze_router) app.include_router(cases_router) +app.include_router(closed_cases_router) app.include_router(stats_router) app.include_router(admin_router) diff --git a/backend/routers/closed_cases.py b/backend/routers/closed_cases.py new file mode 100644 index 0000000..ef79c7a --- /dev/null +++ b/backend/routers/closed_cases.py @@ -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} \ No newline at end of file diff --git a/backend/schemas.py b/backend/schemas.py index e4a7b03..d53b5e2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -138,3 +138,66 @@ class ParseDocResponse(BaseModel): parsed: bool preview: dict[str, Any] 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 diff --git a/backend/tests/test_closed_cases.py b/backend/tests/test_closed_cases.py new file mode 100644 index 0000000..9e60ca5 --- /dev/null +++ b/backend/tests/test_closed_cases.py @@ -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 \ No newline at end of file diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index 2b586f4..d070eae 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { apiFetch } from '../api/client'; import { useAuth } from '../context/AuthContext'; +import ClosedCases from './ClosedCases'; import './AdminDashboard.css'; const PAGE_SIZE = 10; @@ -31,6 +32,7 @@ function Badge({ children, tone = 'neutral' }) { function AdminDashboard() { const { user, logout } = useAuth(); + const [activeTab, setActiveTab] = useState('cases'); // 'cases' | 'closed' const [cases, setCases] = useState([]); const [dashboard, setDashboard] = useState(null); const [loading, setLoading] = useState(false); @@ -338,6 +340,35 @@ function AdminDashboard() { +
+ {[ + { id: 'cases', label: 'Кейсы' }, + { id: 'closed', label: 'Завершённые поиски' }, + ].map((t) => ( + + ))} +
+ + {activeTab === 'closed' ? ( + + ) : ( + <>
Всего кейсов @@ -685,6 +716,8 @@ function AdminDashboard() { )}
+ + )} ); } diff --git a/frontend/src/pages/ClosedCases.css b/frontend/src/pages/ClosedCases.css new file mode 100644 index 0000000..d603858 --- /dev/null +++ b/frontend/src/pages/ClosedCases.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/pages/ClosedCases.jsx b/frontend/src/pages/ClosedCases.jsx new file mode 100644 index 0000000..7e888ef --- /dev/null +++ b/frontend/src/pages/ClosedCases.jsx @@ -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 ( +
+ {options.map((opt) => { + const isOn = selected.includes(opt); + return ( + + ); + })} +
+ ); +} + +function Field({ label, children, wide = false }) { + return ( + + ); +} + +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 ( +
+ {/* ---------- Форма внесения ---------- */} +
+
+

Завершённый поиск — ручной ввод

+

Данные субъекта, среды и реальный исход. Модель считается без ИИ, детерминированно.

+
+ +
+

Субъект

+
+ + update('age_years', e.target.value)} /> + + + + + + + + + + +
+ + toggleChip('diagnosis_type', o)} /> + + +

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

+
+ + update('elapsed_hours', e.target.value)} /> + + + update('loss_time', e.target.value)} /> + + + + + + + + + update('temperature_c', e.target.value)} /> + + + update('tnp_address', e.target.value)} /> + + + update('tnp_lat', e.target.value)} /> + + + update('tnp_lon', e.target.value)} /> + +
+ + toggleChip('terrain', o)} /> + + +

Ресурсы

+
+ + update('teams_count', e.target.value)} /> + + + update('team_size', e.target.value)} /> + + + + +
+ +

Исход (факт)

+
+ + + + + update('found_distance_km', e.target.value)} /> + + + update('found_direction', e.target.value)} /> + + + + + + update('who_found', e.target.value)} /> + + + update('search_duration_hours', e.target.value)} /> + +
+ +
+ + update('source', e.target.value)} /> + + +