Files
vector/backend/routers/analyze.py
T
Sadmin d5a270f3af B22: прикладное логирование vector.* + администрирование в доках (по образцу РВС)
- backend/logging_setup.py: setup_logging() (stdout INFO + файл /tmp/vector/vector.log,
  ротация 5×2 МБ, VectorLOGLEVEL/VectorLOGFILE/VectorLOGDIR), get_logger('vector.*')
- события: vector.analyze (матрасчёт с длительностью), vector.auth (login_ok/failed/
  lockout), vector.audit (каждая запись), vector.contour (ok/failed/rejected),
  vector.operations (operation_complete), vector.geocode (успех/timeout/ошибка)
- существующие логгеры переведены в namespace vector.* (geo, geo.osm, rules, water, admin)
- README §13а: таблица наблюдаемых событий + правила (без ПДн/секретов)
- SETUP_GUIDE: §15 RPO/RTO + backup≠restore, §16 grep по vector.*, §17 диагностика
  по цепочкам (БД, 500, fallback-зоны, вход), §17а чек-лист передачи проекта
- DEPLOY.md: вместо устаревшего дубля — ссылка на актуальные доки + шпаргалка стенда
- тесты: 239 passed
2026-09-25 09:38:39 +03:00

149 lines
5.8 KiB
Python

from __future__ import annotations
import logging
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.logging_setup import get_logger
from backend.routers.auth import require_roles
from services.rules_analysis import rules_analyze
from services.search_engine import (
SearchInput,
build_search_model,
)
log = get_logger('analyze')
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).
import time as _time
_t0 = _time.monotonic()
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}
# Карточка хранит координаты в tnp_lat/tnp_lon (gps_lat/lon — алиасы
# to_detail). Без этой подстройки case_id-режим терял координаты ТНП:
# rules_analyze не строил geo-зоны и отдавал fallback «Ближняя N /
# Средняя E» — источник «вверх-вправо» на карте.
if case_data.get('lat') is None:
case_data['lat'] = case_data.get('tnp_lat') or case_data.get('gps_lat')
if case_data.get('lon') is None:
case_data['lon'] = case_data.get('tnp_lon') or case_data.get('gps_lon')
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=rules_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()
log.info(
'анализ case=%s радиус=%.1fкм срочность=%s зон=%d время=%.0fмс',
payload.case_id or '—', result.get('search_radius_km') or 0,
result.get('urgency'), len(result.get('primary_zones') or []),
(_time.monotonic() - _t0) * 1000,
)
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'],
}