Files
vector/backend/routers/analyze.py
T
Sadmin 5bdb345e33 B14+B15: чистый SearchEngine + слоистая модель данных
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.
2026-09-09 15:48:29 +03:00

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'],
}