Files
Sadmin 5a86755e90 Завершение поиска: исход → архив + матмодель + операция completed
- POST /operations/{id}/complete: found_alive/дистанция/направление/кто нашёл/
  длительность → кейс status=closed + фактические данные + прогон детерминированной
  матмодели (model_prediction, accuracy_note в analysis_log) → операция
  status=completed + closed_at. Аудит: operation_complete + case_closed.
- ФИКС list_closed_cases: фильтр по to_summary().analysis_log всегда давал None
  (analysis_log только в to_detail) → список завершённых был пуст; теперь
  фильтр по status='closed' (совместимо с TestDB).
- UI: кнопка «✓ Завершить поиск» на странице анализа (для активных операций),
  модал с исходом, после завершения — плашка «Поиск завершён», в архиве
  (/admin завершённые) появляется запись с прогнозом модели и точностью.
- Тесты (4): полный флоу, повторное завершение 400, аудит, появление в
  /closed-cases. 230 passed.
2026-09-09 21:55:12 +03:00

309 lines
14 KiB
Python

"""Завершённые поиски — ручной ввод данных + прогон матмодели БЕЗ ИИ.
Цель: датасет для калибровки зонального скоринга (B12) и архив исторических
поисков. LLM не используется: скоринг полностью детерминированный
(WeightedScorer + distance_service + психотип).
Прогон модели = тот же расчёт, что /analyze, но без LLM-анализатора:
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
from services.search_engine import (
derive_profiles as _derive_profiles,
derive_time_of_day as _derive_time_of_day,
unmodeled_profiles as _unmodeled_profiles,
)
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, но без LLM-анализатора:
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.get('diagnosis_type') or [],
data.get('has_transport'),
bool(data.get('cant_swim')),
explicit=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:
# status='closed' — надёжный признак архивной записи
# (analysis_log в to_summary не входит; фильтр в питоне — совместимо с TestDB)
cases = [c for c in db.list_cases() if c.to_summary().get('status') == 'closed']
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}