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
+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}