Import Vector lab project
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Analysis API endpoints for full case analysis pipeline.
|
||||
|
||||
Pipeline: distance → geo → scoring → psychotype → claude → merged result
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from database import get_db
|
||||
from models import Case, AnalysisLog, User
|
||||
from api.v1.auth import get_current_user
|
||||
|
||||
# Import services
|
||||
from services.distance_service import calculate_max_distance
|
||||
from services.geo_service import build_search_zones
|
||||
from services.scoring_service import WeightedScorer
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations
|
||||
)
|
||||
from services.claude_service import analyze_case as claude_analyze
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
"""Request for full case analysis"""
|
||||
case_id: UUID = Field(..., description="ID случая для анализа")
|
||||
|
||||
|
||||
class ZoneResult(BaseModel):
|
||||
"""Search zone with score and recommendations"""
|
||||
priority: int
|
||||
name: str
|
||||
direction: str
|
||||
distance_km: float
|
||||
score: float
|
||||
reasoning: str
|
||||
forest_pct: Optional[float] = None
|
||||
road_density: Optional[float] = None
|
||||
water_distance_km: Optional[float] = None
|
||||
|
||||
|
||||
class AnalysisResponse(BaseModel):
|
||||
"""Full analysis result"""
|
||||
case_id: UUID
|
||||
analyzed_at: datetime
|
||||
|
||||
# Distance calculation
|
||||
max_distance_km: float
|
||||
|
||||
# Psychotype (if available)
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_modifiers: Optional[Dict[str, Any]] = None
|
||||
psychotype_recommendations: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Zones
|
||||
zones: List[ZoneResult]
|
||||
|
||||
# Claude analysis (if available)
|
||||
urgency: Optional[str] = None
|
||||
key_locations: Optional[List[str]] = None
|
||||
immediate_actions: Optional[List[str]] = None
|
||||
behavioral_prediction: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
|
||||
# Meta
|
||||
execution_time_ms: float
|
||||
services_used: List[str]
|
||||
|
||||
|
||||
class SavedAnalysisResponse(BaseModel):
|
||||
"""Saved analysis result from database"""
|
||||
case_id: UUID
|
||||
analysis_log: Dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=AnalysisResponse, status_code=200)
|
||||
async def analyze_full_case(
|
||||
request: AnalysisRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Запустить полный анализ случая.
|
||||
|
||||
Пайплайн:
|
||||
1. Distance service - расчет максимальной дистанции
|
||||
2. Geo service - построение зон поиска
|
||||
3. Scoring service - оценка и ранжирование зон
|
||||
4. Psychotype service - определение психотипа (если есть данные)
|
||||
5. Claude service - интеллектуальный анализ (опционально)
|
||||
6. Merge results - объединение результатов
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
start_time = time.time()
|
||||
services_used = []
|
||||
|
||||
# 1. Получить случай из БД
|
||||
case = db.query(Case).filter(Case.id == request.case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {request.case_id} not found")
|
||||
|
||||
# Подготовить данные для анализа
|
||||
case_data = {
|
||||
'age': case.age_years,
|
||||
'gender': case.gender,
|
||||
'elapsed_hours': case.elapsed_hours or 1.0,
|
||||
'terrain_primary': case.terrain[0] if case.terrain else 'лес',
|
||||
'season': case.season or 'лето',
|
||||
'temperature_c': case.temperature_c or 20.0,
|
||||
'has_transport': case.has_transport,
|
||||
'has_diagnosis': case.has_diagnosis,
|
||||
'diagnosis_type': case.diagnosis_type or [],
|
||||
'tnp_lat': case.tnp_lat,
|
||||
'tnp_lon': case.tnp_lon,
|
||||
}
|
||||
|
||||
# 2. Distance service - расчет максимальной дистанции
|
||||
try:
|
||||
max_distance = calculate_max_distance(case_data)
|
||||
services_used.append('distance')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Distance calculation failed: {str(e)}")
|
||||
|
||||
# 3. Geo service - построение зон поиска (если есть координаты)
|
||||
zones_data = []
|
||||
if case.tnp_lat and case.tnp_lon:
|
||||
try:
|
||||
zones_data = await build_search_zones(
|
||||
lat=case.tnp_lat,
|
||||
lon=case.tnp_lon,
|
||||
case_data=case_data,
|
||||
max_distance_km=max_distance
|
||||
)
|
||||
services_used.append('geo')
|
||||
except Exception as e:
|
||||
# Geo service опционален, продолжаем без него
|
||||
print(f"Geo service failed: {e}")
|
||||
|
||||
# 4. Scoring service - оценка и ранжирование зон
|
||||
scored_zones = []
|
||||
if zones_data:
|
||||
try:
|
||||
scorer = WeightedScorer()
|
||||
for zone in zones_data:
|
||||
zone_dict = zone.model_dump() if hasattr(zone, 'model_dump') else zone
|
||||
score = scorer.score_zone(zone_dict, case_data, max_distance_km=max_distance)
|
||||
zone_dict['score'] = score
|
||||
zone_dict['reasoning'] = f"Оценка на основе {len(case_data)} факторов"
|
||||
scored_zones.append(zone_dict)
|
||||
|
||||
# Сортировать по score
|
||||
scored_zones.sort(key=lambda z: z.get('score', 0), reverse=True)
|
||||
services_used.append('scoring')
|
||||
except Exception as e:
|
||||
print(f"Scoring service failed: {e}")
|
||||
scored_zones = _create_fallback_zones(max_distance)
|
||||
else:
|
||||
# Если geo не работает, создаем базовые зоны
|
||||
scored_zones = _create_fallback_zones(max_distance)
|
||||
|
||||
# 5. Psychotype service - определение психотипа
|
||||
psychotype = None
|
||||
psychotype_modifiers = None
|
||||
psychotype_recommendations = None
|
||||
|
||||
if case.psychotype_answers:
|
||||
try:
|
||||
psychotype = detect_psychotype(case.psychotype_answers)
|
||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
services_used.append('psychotype')
|
||||
|
||||
# Применить модификаторы психотипа к зонам
|
||||
scored_zones = _apply_psychotype_modifiers(scored_zones, psychotype_modifiers)
|
||||
except Exception as e:
|
||||
print(f"Psychotype service failed: {e}")
|
||||
|
||||
# 6. Claude service - интеллектуальный анализ (опционально)
|
||||
urgency = None
|
||||
key_locations = None
|
||||
immediate_actions = None
|
||||
behavioral_prediction = None
|
||||
summary = None
|
||||
|
||||
try:
|
||||
claude_result = await claude_analyze(case_data)
|
||||
urgency = claude_result.urgency
|
||||
key_locations = claude_result.key_locations
|
||||
immediate_actions = claude_result.immediate_actions
|
||||
behavioral_prediction = claude_result.behavioral_prediction
|
||||
summary = claude_result.summary
|
||||
services_used.append('claude')
|
||||
except Exception as e:
|
||||
# Claude опционален, продолжаем без него
|
||||
print(f"Claude service failed: {e}")
|
||||
|
||||
# 7. Формируем результат
|
||||
zones_result = [
|
||||
ZoneResult(
|
||||
priority=i + 1,
|
||||
name=zone.get('name', f"Зона {zone.get('direction', 'N')}"),
|
||||
direction=zone.get('direction', 'N'),
|
||||
distance_km=zone.get('distance_km', 0),
|
||||
score=zone.get('score', 0),
|
||||
reasoning=zone.get('reasoning', 'Автоматическая оценка'),
|
||||
forest_pct=zone.get('forest_pct'),
|
||||
road_density=zone.get('road_density'),
|
||||
water_distance_km=zone.get('water_distance_km')
|
||||
)
|
||||
for i, zone in enumerate(scored_zones[:10]) # Топ-10 зон
|
||||
]
|
||||
|
||||
execution_time = (time.time() - start_time) * 1000
|
||||
|
||||
# 8. Сохранить результат в analysis_log
|
||||
analysis_result = {
|
||||
'max_distance_km': max_distance,
|
||||
'psychotype': psychotype,
|
||||
'psychotype_modifiers': psychotype_modifiers,
|
||||
'zones': [z.model_dump() for z in zones_result],
|
||||
'urgency': urgency,
|
||||
'key_locations': key_locations,
|
||||
'immediate_actions': immediate_actions,
|
||||
'summary': summary,
|
||||
'services_used': services_used,
|
||||
'execution_time_ms': execution_time
|
||||
}
|
||||
|
||||
# Обновить case.analysis_log
|
||||
case.analysis_log = analysis_result
|
||||
db.commit()
|
||||
|
||||
# Создать запись в AnalysisLog
|
||||
log_entry = AnalysisLog(
|
||||
case_id=case.id,
|
||||
user_id=current_user.id,
|
||||
analysis_type='full_pipeline',
|
||||
input_data={'case_id': str(case.id)},
|
||||
output_data=analysis_result,
|
||||
execution_time=execution_time / 1000,
|
||||
status='success'
|
||||
)
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
|
||||
return AnalysisResponse(
|
||||
case_id=case.id,
|
||||
analyzed_at=datetime.utcnow(),
|
||||
max_distance_km=max_distance,
|
||||
psychotype=psychotype,
|
||||
psychotype_modifiers=psychotype_modifiers,
|
||||
psychotype_recommendations=psychotype_recommendations,
|
||||
zones=zones_result,
|
||||
urgency=urgency,
|
||||
key_locations=key_locations,
|
||||
immediate_actions=immediate_actions,
|
||||
behavioral_prediction=behavioral_prediction,
|
||||
summary=summary,
|
||||
execution_time_ms=execution_time,
|
||||
services_used=services_used
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analyze/{case_id}", response_model=SavedAnalysisResponse)
|
||||
async def get_saved_analysis(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Получить сохранённый результат анализа.
|
||||
|
||||
Возвращает последний analysis_log из таблицы cases.
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
if not case.analysis_log:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No analysis found for case {case_id}. Run POST /analyze first."
|
||||
)
|
||||
|
||||
return SavedAnalysisResponse(
|
||||
case_id=case.id,
|
||||
analysis_log=case.analysis_log,
|
||||
created_at=case.created_at
|
||||
)
|
||||
|
||||
|
||||
def _create_fallback_zones(max_distance: float) -> List[Dict[str, Any]]:
|
||||
"""Create basic zones when geo/scoring services fail"""
|
||||
directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
zones = []
|
||||
|
||||
for i, direction in enumerate(directions):
|
||||
zones.append({
|
||||
'direction': direction,
|
||||
'distance_km': max_distance * 0.8,
|
||||
'score': 100 - (i * 10),
|
||||
'name': f"Сектор {direction}",
|
||||
'reasoning': 'Базовая оценка (сервисы недоступны)'
|
||||
})
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
def _apply_psychotype_modifiers(zones: List[Dict], modifiers: Dict) -> List[Dict]:
|
||||
"""Apply psychotype modifiers to zone scores"""
|
||||
if not modifiers:
|
||||
return zones
|
||||
|
||||
# Применяем модификаторы зон из психотипа
|
||||
for zone in zones:
|
||||
distance = zone.get('distance_km', 0)
|
||||
|
||||
# Определяем зону дистанции
|
||||
if distance < 0.5:
|
||||
modifier = modifiers.get('zone_0_500', 1.0)
|
||||
elif distance < 1.5:
|
||||
modifier = modifiers.get('zone_500_1500', 1.0)
|
||||
elif distance < 2.5:
|
||||
modifier = modifiers.get('zone_1500_2500', 1.0)
|
||||
else:
|
||||
modifier = modifiers.get('zone_2500plus', 1.0)
|
||||
|
||||
# Применяем модификатор к score
|
||||
zone['score'] = zone.get('score', 0) * modifier
|
||||
zone['reasoning'] += f" (психотип: ×{modifier:.1f})"
|
||||
|
||||
# Пересортировать по score
|
||||
zones.sort(key=lambda z: z.get('score', 0), reverse=True)
|
||||
|
||||
return zones
|
||||
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
age: int
|
||||
gender: str
|
||||
terrain: str
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
recommendation: str
|
||||
estimated_radius_km: float
|
||||
|
||||
|
||||
@router.post("/text", response_model=AnalysisResult)
|
||||
async def analyze_text(request: AnalysisRequest):
|
||||
return AnalysisResult(
|
||||
recommendation="Placeholder analysis",
|
||||
estimated_radius_km=2.5
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError, jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import bcrypt
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# JWT настройки
|
||||
SECRET_KEY = os.getenv("JWT_SECRET", "change-me-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверка пароля через bcrypt напрямую"""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хеширование пароля через bcrypt напрямую"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Создание JWT токена"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str):
|
||||
"""Аутентификация пользователя"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return False
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Получение текущего пользователя из JWT токена"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username, role=payload.get("role"))
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == token_data.username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Проверка активности пользователя"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(allowed_roles: list[str]):
|
||||
"""Dependency для проверки роли пользователя"""
|
||||
async def role_checker(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied. Required roles: {', '.join(allowed_roles)}"
|
||||
)
|
||||
return current_user
|
||||
return role_checker
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Аутентификация и получение JWT токена.
|
||||
|
||||
Используйте username и password для получения access_token.
|
||||
Токен действителен 24 часа.
|
||||
|
||||
Тестовые пользователи:
|
||||
- operator / pass123
|
||||
- field / pass123
|
||||
- admin / pass123
|
||||
"""
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Обновляем last_login
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username, "role": user.role},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def read_users_me(current_user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
Получить информацию о текущем пользователе.
|
||||
|
||||
Требуется валидный JWT токен в заголовке Authorization: Bearer <token>
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register_user(
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: Optional[str] = None,
|
||||
role: str = "operator",
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Регистрация нового пользователя (только для admin).
|
||||
|
||||
Доступные роли: operator, field, admin
|
||||
"""
|
||||
# Проверка существования пользователя
|
||||
if db.query(User).filter(User.username == username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
if db.query(User).filter(User.email == email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Создание пользователя
|
||||
hashed_password = get_password_hash(password)
|
||||
db_user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=full_name,
|
||||
role=role
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
@@ -0,0 +1,212 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from database import get_db
|
||||
from models import Case, User
|
||||
from schemas import CaseCreate, CaseOut
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Импортируем auth dependencies
|
||||
import sys
|
||||
sys.path.append('/app/api/v1')
|
||||
from auth import get_current_user, require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CaseUpdate(BaseModel):
|
||||
"""Schema for updating case fields"""
|
||||
# Ребёнок
|
||||
child_name: Optional[str] = None
|
||||
age_years: Optional[int] = None
|
||||
gender: Optional[str] = None
|
||||
height_build: Optional[str] = None
|
||||
clothes_upper: Optional[str] = None
|
||||
clothes_lower: Optional[str] = None
|
||||
shoes: Optional[str] = None
|
||||
clothes_description: Optional[str] = None
|
||||
special_marks: Optional[str] = None
|
||||
phone_status: Optional[str] = None
|
||||
|
||||
# Здоровье
|
||||
has_diagnosis: Optional[bool] = None
|
||||
diagnosis_type: Optional[List[str]] = None
|
||||
fitness_level: Optional[str] = None
|
||||
has_transport: Optional[str] = None
|
||||
cant_swim: Optional[bool] = None
|
||||
|
||||
# Психотип
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_answers: Optional[dict] = None
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason: Optional[str] = None
|
||||
loss_time: Optional[str] = None
|
||||
elapsed_hours: Optional[float] = None
|
||||
last_seen_direction: Optional[str] = None
|
||||
last_seen_reliability: Optional[str] = None
|
||||
last_seen_description: Optional[str] = None
|
||||
behavior_description: Optional[str] = None
|
||||
familiar_places: Optional[str] = None
|
||||
lost_before: Optional[str] = None
|
||||
|
||||
# Среда
|
||||
season: Optional[str] = None
|
||||
temperature_c: Optional[float] = None
|
||||
precipitation: Optional[str] = None
|
||||
visibility: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
terrain: Optional[List[str]] = None
|
||||
|
||||
# GPS
|
||||
tnp_lat: Optional[float] = None
|
||||
tnp_lon: Optional[float] = None
|
||||
tnp_address: Optional[str] = None
|
||||
|
||||
# Ресурсы
|
||||
teams_count: Optional[int] = None
|
||||
team_size: Optional[int] = None
|
||||
has_dog: Optional[bool] = None
|
||||
extra_resources: Optional[List[str]] = None
|
||||
|
||||
# Исход
|
||||
found_alive: Optional[bool] = None
|
||||
found_distance_km: Optional[float] = None
|
||||
found_direction: Optional[str] = None
|
||||
found_location_type: Optional[str] = None
|
||||
found_lat: Optional[float] = None
|
||||
found_lon: Optional[float] = None
|
||||
search_duration_hours: Optional[float] = None
|
||||
who_found: Optional[str] = None
|
||||
|
||||
# Статус
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class CaseListResponse(BaseModel):
|
||||
"""Response for list endpoint with pagination"""
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
cases: List[CaseOut]
|
||||
|
||||
|
||||
@router.post("/cases", response_model=CaseOut, status_code=201)
|
||||
async def create_case(
|
||||
case_data: CaseCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Создать новый случай поиска.
|
||||
|
||||
Принимает все поля из формы опроса (5 шагов).
|
||||
Публичный эндпоинт - не требует аутентификации.
|
||||
"""
|
||||
db_case = Case(**case_data.model_dump(exclude_unset=True))
|
||||
db.add(db_case)
|
||||
db.commit()
|
||||
db.refresh(db_case)
|
||||
return db_case
|
||||
|
||||
|
||||
@router.get("/cases", response_model=CaseListResponse)
|
||||
async def list_cases(
|
||||
skip: int = Query(0, ge=0, description="Количество пропускаемых записей"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Максимум записей на страницу"),
|
||||
status: Optional[str] = Query(None, description="Фильтр по статусу: active/closed/archived"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Получить список случаев с пагинацией и фильтрацией.
|
||||
|
||||
- **skip**: смещение (для пагинации)
|
||||
- **limit**: количество записей (макс 100)
|
||||
- **status**: фильтр по статусу (active/closed/archived)
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
query = db.query(Case)
|
||||
|
||||
if status:
|
||||
query = query.filter(Case.status == status)
|
||||
|
||||
total = query.count()
|
||||
cases = query.order_by(desc(Case.created_at)).offset(skip).limit(limit).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"cases": cases
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}", response_model=CaseOut)
|
||||
async def get_case(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить случай по ID.
|
||||
|
||||
Возвращает все поля случая включая исход (если заполнен).
|
||||
Публичный эндпоинт - не требует аутентификации.
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
return case
|
||||
|
||||
|
||||
@router.patch("/cases/{case_id}", response_model=CaseOut)
|
||||
async def update_case(
|
||||
case_id: UUID,
|
||||
case_update: CaseUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Обновить случай (частичное обновление).
|
||||
|
||||
Используется для:
|
||||
- Корректировки данных опроса
|
||||
- Внесения исхода поиска (found_alive, found_distance_km и т.д.)
|
||||
- Изменения статуса (active → closed)
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
update_data = case_update.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(case, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(case)
|
||||
return case
|
||||
|
||||
|
||||
@router.delete("/cases/{case_id}", status_code=204)
|
||||
async def delete_case(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
"""
|
||||
Удалить случай (только для admin).
|
||||
|
||||
В продакшене рекомендуется использовать архивацию вместо удаления.
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
db.delete(case)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -0,0 +1,159 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Optional, Literal
|
||||
from database import get_db
|
||||
from models import Case
|
||||
from services.stats_service import (
|
||||
get_dashboard_stats,
|
||||
get_statistical_recommendation,
|
||||
get_heatmap_with_cache,
|
||||
DashboardStats,
|
||||
StatisticalRecommendation
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HeatmapPoint(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
intensity: float
|
||||
case_id: str
|
||||
metadata: Dict
|
||||
|
||||
|
||||
class HeatmapResponse(BaseModel):
|
||||
points: List[HeatmapPoint]
|
||||
total: int
|
||||
filters_applied: Dict
|
||||
|
||||
|
||||
class StatisticalRecommendationRequest(BaseModel):
|
||||
age: int
|
||||
season: Optional[str] = None
|
||||
terrain_primary: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/summary", response_model=DashboardStats)
|
||||
async def get_summary(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Получить агрегированную статистику для дашборда.
|
||||
|
||||
Возвращает:
|
||||
- Общее количество случаев (всего, активных, закрытых)
|
||||
- Распределение по полу, возрасту, психотипу, диагнозам, сезонам
|
||||
- Средняя дистанция находки
|
||||
- Средняя длительность поиска
|
||||
- Процент выживаемости
|
||||
"""
|
||||
return get_dashboard_stats(db)
|
||||
|
||||
|
||||
@router.post("/recommendation", response_model=StatisticalRecommendation)
|
||||
async def get_recommendation(
|
||||
request: StatisticalRecommendationRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить статистические рекомендации на основе похожих случаев.
|
||||
|
||||
Фильтры (в порядке приоритета):
|
||||
1. Возраст ±2 года + сезон + terrain
|
||||
2. Возраст ±2 года + сезон (если < 5 случаев)
|
||||
3. Возраст ±2 года (если < 5 случаев)
|
||||
4. Все случаи (если < 5 случаев)
|
||||
|
||||
Возвращает:
|
||||
- Медианное расстояние находки
|
||||
- Топ-3 направления с процентами
|
||||
- Топ-5 типов локаций
|
||||
- Процент выживаемости
|
||||
- Размер выборки
|
||||
- Использованные фильтры
|
||||
"""
|
||||
case_data = {
|
||||
'age': request.age,
|
||||
'season': request.season,
|
||||
'terrain_primary': request.terrain_primary
|
||||
}
|
||||
return get_statistical_recommendation(case_data, db)
|
||||
|
||||
|
||||
@router.get("/heatmap", response_model=HeatmapResponse)
|
||||
async def get_heatmap(
|
||||
map_type: Literal['all', 'age', 'season', 'outcome'] = Query(
|
||||
'all',
|
||||
description="Тип карты: all (все точки), age (по возрасту), season (по сезону), outcome (по исходу)"
|
||||
),
|
||||
age_group: Optional[str] = Query(
|
||||
None,
|
||||
description="Возрастная группа: 0-3, 4-7, 8-11, 12-14, 15-17, 18+"
|
||||
),
|
||||
season: Optional[str] = Query(
|
||||
None,
|
||||
description="Сезон: зима, весна, лето, осень"
|
||||
),
|
||||
year_from: Optional[int] = Query(
|
||||
None,
|
||||
description="Год начала периода (например, 2020)"
|
||||
),
|
||||
year_to: Optional[int] = Query(
|
||||
None,
|
||||
description="Год окончания периода (например, 2026)"
|
||||
),
|
||||
outcome: Optional[str] = Query(
|
||||
None,
|
||||
description="Исход: alive (выжил), deceased (погиб)"
|
||||
),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить данные для тепловой карты находок с кэшированием (1 час).
|
||||
|
||||
**Типы карт:**
|
||||
- `all` - все точки с одинаковой интенсивностью
|
||||
- `age` - интенсивность зависит от возраста (младше = выше)
|
||||
- `season` - интенсивность зависит от сезона (зима = выше)
|
||||
- `outcome` - интенсивность зависит от исхода (погиб = выше)
|
||||
|
||||
**Фильтры:**
|
||||
- `age_group` - возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17, 18+)
|
||||
- `season` - сезон (зима, весна, лето, осень)
|
||||
- `year_from`, `year_to` - период по годам
|
||||
- `outcome` - исход (alive, deceased)
|
||||
|
||||
**Кэширование:**
|
||||
Результаты кэшируются на 1 час для ускорения повторных запросов.
|
||||
|
||||
**Возвращает:**
|
||||
- `points` - массив точек с координатами, интенсивностью и метаданными
|
||||
- `total` - общее количество точек
|
||||
- `filters_applied` - примененные фильтры
|
||||
"""
|
||||
result = get_heatmap_with_cache(
|
||||
db=db,
|
||||
map_type=map_type,
|
||||
age_group=age_group,
|
||||
season=season,
|
||||
year_from=year_from,
|
||||
year_to=year_to,
|
||||
outcome=outcome
|
||||
)
|
||||
|
||||
points = [
|
||||
HeatmapPoint(
|
||||
lat=p['lat'],
|
||||
lon=p['lon'],
|
||||
intensity=p['intensity'],
|
||||
case_id=p['case_id'],
|
||||
metadata=p['metadata']
|
||||
)
|
||||
for p in result['points']
|
||||
]
|
||||
|
||||
return HeatmapResponse(
|
||||
points=points,
|
||||
total=result['total'],
|
||||
filters_applied=result['filters_applied']
|
||||
)
|
||||
Reference in New Issue
Block a user