"""Завершённые поиски — ручной ввод данных + прогон матмодели БЕЗ ИИ. Цель: датасет для калибровки зонального скоринга (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}