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.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)
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user