d5a270f3af
- 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
149 lines
5.8 KiB
Python
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'],
|
|
} |