5bdb345e33
B14 (поведение-сохраняющий рефакторинг): - services/search_engine.py — вся SAR-математика из analyze.py: SearchInput/SearchModel, build_search_model (чистая функция, без DB/auth/HTTP), деривации профилей/времени суток, unmodeled. - analyze.py — тонкая обёртка: сборка SearchInput + запись БД. - closed_cases.py — импорты хелперов из движка. - 12 юнит-тестов движка (claude_analyze мокается). - Контракт /analyze не изменён; регресс спеки подтверждён: bike 8yo 2h лес день -> 5.4 км. B15 (слои данных, Alembic): - backend/alembic (env из DATABASE_URL) + миграция 006_b15_layers. - Слой 4: search_models (case_id, version, input_snapshot, model_json). - Слой 3: search_teams, field_observations, areas_checked, found_events (geom JSONB GeoJSON, PostGIS в B16). - Слой 1: reference_priors (пустой, B12 заблокирован). - Бэкфилл: cases.analysis_log (объект с primary_zones) -> search_models v1; legacy-массивы и analysis_log-таблица не тронуты. - Проверено на CT108 в одноразовых pg16-контейнерах: чистая БД (без данных и с ними), бэкфилл=1 из 3 seed-кейсов, downgrade->upgrade идемпотентен, check-constraints работают, источник не модифицирован. pytest: 214 passed.
130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from backend.database import db
|
|
from backend.routers.auth import require_roles
|
|
from services.claude_service import analyze_case as claude_analyze
|
|
from services.search_engine import (
|
|
SearchInput,
|
|
build_search_model,
|
|
derive_profiles,
|
|
derive_time_of_day,
|
|
unmodeled_profiles,
|
|
)
|
|
|
|
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
|
|
|
|
|
|
class AnalysisRequest(BaseModel):
|
|
case_id: UUID | None = None
|
|
age: int | None = None
|
|
gender: str | None = None
|
|
terrain: str | list[str] | None = None
|
|
weather: str | None = None
|
|
elapsed_hours: float | None = None
|
|
last_location: str | None = None
|
|
circumstances: str | None = None
|
|
physical_condition: str | None = None
|
|
experience: str | None = None
|
|
season: str | None = None
|
|
diagnosis_type: list[str] = Field(default_factory=list)
|
|
has_transport: str | None = None
|
|
psychotype_answers: dict[str, Any] = Field(default_factory=dict)
|
|
tnp_lat: float | None = None
|
|
tnp_lon: float | None = None
|
|
lat: float | None = None
|
|
lon: float | None = None
|
|
profiles: list[str] = Field(default_factory=list)
|
|
|
|
|
|
@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
|
async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
|
# B14: роутер — тонкая обёртка. Вся расчётная логика — в
|
|
# services/search_engine.py::build_search_model (без DB/auth/HTTP).
|
|
case_data: dict[str, Any] = {
|
|
'age': payload.age,
|
|
'gender': payload.gender,
|
|
'terrain': payload.terrain,
|
|
'weather': payload.weather,
|
|
'elapsed_hours': payload.elapsed_hours,
|
|
'last_location': payload.last_location,
|
|
'circumstances': payload.circumstances,
|
|
'physical_condition': payload.physical_condition,
|
|
'experience': payload.experience,
|
|
'season': payload.season,
|
|
'diagnosis_type': payload.diagnosis_type,
|
|
'has_transport': payload.has_transport,
|
|
'psychotype_answers': payload.psychotype_answers,
|
|
'lat': payload.lat if payload.lat is not None else payload.tnp_lat,
|
|
'lon': payload.lon if payload.lon is not None else payload.tnp_lon,
|
|
}
|
|
case_data = {
|
|
k: v for k, v in case_data.items()
|
|
if v is not None and not (isinstance(v, (list, dict)) and len(v) == 0)
|
|
}
|
|
|
|
if payload.case_id is not None:
|
|
case = db.get_case(str(payload.case_id))
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail='Case not found')
|
|
case_data = {**case.to_detail(), **case_data}
|
|
|
|
search_input = SearchInput(
|
|
age=case_data.get('age'),
|
|
gender=case_data.get('gender'),
|
|
terrain=case_data.get('terrain'),
|
|
terrain_primary=case_data.get('terrain_primary'),
|
|
weather=case_data.get('weather'),
|
|
elapsed_hours=case_data.get('elapsed_hours'),
|
|
last_location=case_data.get('last_location'),
|
|
circumstances=case_data.get('circumstances'),
|
|
physical_condition=case_data.get('physical_condition'),
|
|
experience=case_data.get('experience'),
|
|
season=case_data.get('season'),
|
|
diagnosis_type=case_data.get('diagnosis_type') or [],
|
|
has_transport=case_data.get('has_transport'),
|
|
cant_swim=bool(case_data.get('cant_swim')),
|
|
psychotype_answers=case_data.get('psychotype_answers') or {},
|
|
profiles=case_data.get('profiles') or [],
|
|
lat=case_data.get('lat'),
|
|
lon=case_data.get('lon'),
|
|
loss_time=case_data.get('loss_time'),
|
|
time_of_day=case_data.get('time_of_day'),
|
|
)
|
|
|
|
model = await build_search_model(search_input, analyzer=claude_analyze)
|
|
result = model.model_dump()
|
|
|
|
result['case_id'] = str(payload.case_id) if payload.case_id else None
|
|
result['analyzed_at'] = datetime.now(timezone.utc).isoformat()
|
|
|
|
if payload.case_id is not None:
|
|
db.update_case(str(payload.case_id), analysis_log=result, status='analyzed')
|
|
|
|
return result
|
|
|
|
|
|
@router.post('/combined', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
|
async def analyze_combined(payload: AnalysisRequest) -> dict[str, Any]:
|
|
return await analyze_case(payload)
|
|
|
|
|
|
@router.get('/{case_id}')
|
|
def get_analysis(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()
|
|
if not detail.get('analysis_log'):
|
|
raise HTTPException(status_code=404, detail=f'No analysis found for case {case_id}')
|
|
return {
|
|
'case_id': case_id,
|
|
'analysis_log': detail['analysis_log'],
|
|
'created_at': detail['created_at'],
|
|
} |