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() { +
Данные субъекта, среды и реальный исход. Модель считается без ИИ, детерминированно.
+Загрузка…
: items.length === 0 ? ( +Пока ничего не внесено.
+ ) : ( +| Возраст | Пол | Часов | Рельеф | +Найден | Факт, км | Прогноз, км | Точность | + |
|---|---|---|---|---|---|---|---|---|
| {item.age_years} | +{item.gender} | +{item.elapsed_hours} | +{(item.terrain || []).join(', ') || '—'} | +{item.found_alive ? 'живым' : 'погиб'} | +{item.found_distance_km ?? '—'} | +{pred != null ? Number(pred).toFixed(1) : '—'} | ++ {item.accuracy_note ? (item.accuracy_note.includes('ЗА') ? 'мимо' : 'в радиусе') : '—'} + | ++ + | +