A8: remove dead backend code (old api/ copy, models_* duplicates, .bak files)
This commit is contained in:
@@ -1,347 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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']
|
|
||||||
)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import models
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
mapper = inspect(models.Case)
|
|
||||||
columns = [c.key for c in mapper.columns]
|
|
||||||
print(f"Всего полей в модели Case: {len(columns)}")
|
|
||||||
print("\nПоля по категориям:")
|
|
||||||
print("\nРебёнок (Шаг 1):")
|
|
||||||
for c in columns:
|
|
||||||
if c in ["child_name", "age_years", "gender", "height_build", "clothes_upper", "clothes_lower", "shoes", "clothes_description", "special_marks", "phone_status"]:
|
|
||||||
print(f" - {c}")
|
|
||||||
print("\nЗдоровье (Шаг 2):")
|
|
||||||
for c in columns:
|
|
||||||
if c in ["has_diagnosis", "diagnosis_type", "fitness_level", "has_transport", "cant_swim"]:
|
|
||||||
print(f" - {c}")
|
|
||||||
print("\nПсихотип (Шаг 2б):")
|
|
||||||
for c in columns:
|
|
||||||
if c in ["psychotype", "psychotype_answers"]:
|
|
||||||
print(f" - {c}")
|
|
||||||
print("\nОбстоятельства (Шаг 3):")
|
|
||||||
for c in columns:
|
|
||||||
if c in ["loss_reason", "loss_time", "elapsed_hours", "last_seen_direction", "last_seen_reliability", "last_seen_description", "behavior_description", "familiar_places", "lost_before"]:
|
|
||||||
print(f" - {c}")
|
|
||||||
print("\nСреда (Шаг 4):")
|
|
||||||
for c in columns:
|
|
||||||
if c in ["season", "temperature_c", "precipitation", "visibility", "wind", "terrain"]:
|
|
||||||
print(f" - {c}")
|
|
||||||
print("\nGPS (Шаг 4):")
|
|
||||||
for c in columns:
|
|
||||||
if c in ["tnp_lat", "tnp_lon", "tnp_address"]:
|
|
||||||
print(f" - {c}")
|
|
||||||
print("\nРесурсы (Шаг 5):")
|
|
||||||
for c in columns:
|
|
||||||
if c in ["teams_count", "team_size", "has_dog", "extra_resources"]:
|
|
||||||
print(f" - {c}")
|
|
||||||
print("\nИсход:")
|
|
||||||
for c in columns:
|
|
||||||
if c in ["found_alive", "found_distance_km", "found_direction", "found_location_type", "found_lat", "found_lon", "search_duration_hours", "who_found"]:
|
|
||||||
print(f" - {c}")
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import uuid
|
|
||||||
from database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Case(Base):
|
|
||||||
"""Unified case model - combines search case and result"""
|
|
||||||
__tablename__ = "cases"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
|
||||||
status = Column(String(20), default='active') # active/closed/archived
|
|
||||||
|
|
||||||
# Ребёнок
|
|
||||||
child_name = Column(String(255))
|
|
||||||
age_years = Column(Integer, nullable=False)
|
|
||||||
gender = Column(String(1)) # М / Ж
|
|
||||||
clothes_description = Column(Text)
|
|
||||||
special_marks = Column(Text)
|
|
||||||
phone_status = Column(String(20)) # answers/silent/none
|
|
||||||
|
|
||||||
# Здоровье
|
|
||||||
has_diagnosis = Column(Boolean, default=False)
|
|
||||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР...
|
|
||||||
fitness_level = Column(String(20)) # low/medium/high
|
|
||||||
has_transport = Column(String(20), default='none') # none/bike/scooter/other
|
|
||||||
cant_swim = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Психотип
|
|
||||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/...
|
|
||||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
|
||||||
|
|
||||||
# Обстоятельства
|
|
||||||
loss_reason = Column(String(100))
|
|
||||||
loss_time = Column(DateTime)
|
|
||||||
elapsed_hours = Column(Float)
|
|
||||||
last_seen_direction = Column(String(10))
|
|
||||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
|
||||||
last_seen_description = Column(Text)
|
|
||||||
behavior_description = Column(Text)
|
|
||||||
familiar_places = Column(Text)
|
|
||||||
lost_before = Column(String(20)) # yes/no/unknown
|
|
||||||
|
|
||||||
# Среда
|
|
||||||
season = Column(String(20))
|
|
||||||
temperature_c = Column(Float)
|
|
||||||
precipitation = Column(String(20))
|
|
||||||
visibility = Column(String(20))
|
|
||||||
wind = Column(String(20))
|
|
||||||
terrain = Column(ARRAY(String))
|
|
||||||
|
|
||||||
# GPS
|
|
||||||
tnp_lat = Column(Float)
|
|
||||||
tnp_lon = Column(Float)
|
|
||||||
tnp_address = Column(Text)
|
|
||||||
|
|
||||||
# Ресурсы
|
|
||||||
teams_count = Column(Integer)
|
|
||||||
team_size = Column(Integer)
|
|
||||||
has_dog = Column(Boolean, default=False)
|
|
||||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal
|
|
||||||
|
|
||||||
# Исход (заполняется после завершения)
|
|
||||||
found_alive = Column(Boolean)
|
|
||||||
found_distance_km = Column(Float)
|
|
||||||
found_direction = Column(String(10))
|
|
||||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
|
||||||
found_lat = Column(Float)
|
|
||||||
found_lon = Column(Float)
|
|
||||||
search_duration_hours = Column(Float)
|
|
||||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
|
||||||
|
|
||||||
# Мета
|
|
||||||
confidence_avg = Column(Float)
|
|
||||||
raw_text = Column(Text)
|
|
||||||
analysis_log = Column(JSONB)
|
|
||||||
|
|
||||||
|
|
||||||
class RawDocument(Base):
|
|
||||||
"""Raw document storage for parsed reports"""
|
|
||||||
__tablename__ = "raw_documents"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
filename = Column(String(255), nullable=False)
|
|
||||||
raw_text = Column(Text)
|
|
||||||
extracted_json = Column(JSONB)
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Text, ARRAY
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import uuid
|
|
||||||
from database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Case(Base):
|
|
||||||
"""Unified case model - combines search case and result"""
|
|
||||||
__tablename__ = "cases"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
|
||||||
status = Column(String(20), default='active') # active/closed/archived
|
|
||||||
|
|
||||||
# Ребёнок
|
|
||||||
child_name = Column(String(255))
|
|
||||||
age_years = Column(Integer, nullable=False)
|
|
||||||
gender = Column(String(1)) # М / Ж
|
|
||||||
clothes_description = Column(Text)
|
|
||||||
special_marks = Column(Text)
|
|
||||||
phone_status = Column(String(20)) # answers/silent/none
|
|
||||||
|
|
||||||
# Здоровье
|
|
||||||
has_diagnosis = Column(Boolean, default=False)
|
|
||||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР...
|
|
||||||
fitness_level = Column(String(20)) # low/medium/high
|
|
||||||
has_transport = Column(String(20), default='none') # none/bike/scooter/other
|
|
||||||
cant_swim = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Психотип
|
|
||||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/...
|
|
||||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
|
||||||
|
|
||||||
# Обстоятельства
|
|
||||||
loss_reason = Column(String(100))
|
|
||||||
loss_time = Column(DateTime)
|
|
||||||
elapsed_hours = Column(Float)
|
|
||||||
last_seen_direction = Column(String(10))
|
|
||||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
|
||||||
last_seen_description = Column(Text)
|
|
||||||
behavior_description = Column(Text)
|
|
||||||
familiar_places = Column(Text)
|
|
||||||
lost_before = Column(String(20)) # yes/no/unknown
|
|
||||||
|
|
||||||
# Среда
|
|
||||||
season = Column(String(20))
|
|
||||||
temperature_c = Column(Float)
|
|
||||||
precipitation = Column(String(20))
|
|
||||||
visibility = Column(String(20))
|
|
||||||
wind = Column(String(20))
|
|
||||||
terrain = Column(ARRAY(String))
|
|
||||||
|
|
||||||
# GPS
|
|
||||||
tnp_lat = Column(Float)
|
|
||||||
tnp_lon = Column(Float)
|
|
||||||
tnp_address = Column(Text)
|
|
||||||
|
|
||||||
# Ресурсы
|
|
||||||
teams_count = Column(Integer)
|
|
||||||
team_size = Column(Integer)
|
|
||||||
has_dog = Column(Boolean, default=False)
|
|
||||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal
|
|
||||||
|
|
||||||
# Исход (заполняется после завершения)
|
|
||||||
found_alive = Column(Boolean)
|
|
||||||
found_distance_km = Column(Float)
|
|
||||||
found_direction = Column(String(10))
|
|
||||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
|
||||||
found_lat = Column(Float)
|
|
||||||
found_lon = Column(Float)
|
|
||||||
search_duration_hours = Column(Float)
|
|
||||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
|
||||||
|
|
||||||
# Мета
|
|
||||||
confidence_avg = Column(Float)
|
|
||||||
raw_text = Column(Text)
|
|
||||||
analysis_log = Column(JSONB)
|
|
||||||
|
|
||||||
|
|
||||||
class RawDocument(Base):
|
|
||||||
"""Raw document storage for parsed reports"""
|
|
||||||
__tablename__ = "raw_documents"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
filename = Column(String(255), nullable=False)
|
|
||||||
raw_text = Column(Text)
|
|
||||||
extracted_json = Column(JSONB)
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import uuid
|
|
||||||
from database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Case(Base):
|
|
||||||
Unified case model - combines search case and result
|
|
||||||
__tablename__ = "cases"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
|
||||||
status = Column(String(20), default="active") # active/closed/archived
|
|
||||||
|
|
||||||
# Ребёнок (Шаг 1)
|
|
||||||
child_name = Column(String(255))
|
|
||||||
age_years = Column(Integer, nullable=False)
|
|
||||||
gender = Column(String(1)) # М / Ж
|
|
||||||
height_build = Column(Text) # Рост / телосложение
|
|
||||||
clothes_upper = Column(Text) # Одежда: верх (цвет, тип)
|
|
||||||
clothes_lower = Column(Text) # Одежда: низ (цвет, тип)
|
|
||||||
shoes = Column(Text) # Обувь (тип, цвет)
|
|
||||||
clothes_description = Column(Text) # Общее описание одежды (legacy)
|
|
||||||
special_marks = Column(Text) # Особые приметы
|
|
||||||
phone_status = Column(String(20)) # answers/silent/none
|
|
||||||
|
|
||||||
# Здоровье (Шаг 2)
|
|
||||||
has_diagnosis = Column(Boolean, default=False)
|
|
||||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР, слабое зрение, слабый слух...
|
|
||||||
fitness_level = Column(String(20)) # low/medium/high
|
|
||||||
has_transport = Column(String(20), default="none") # none/bike/scooter/other
|
|
||||||
cant_swim = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Психотип (Шаг 2б)
|
|
||||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/introvert_passive/introvert_active
|
|
||||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
|
||||||
|
|
||||||
# Обстоятельства (Шаг 3)
|
|
||||||
loss_reason = Column(String(100)) # потерялся в лесу/ушёл из дома/в городе/не вернулся с прогулки/на мероприятии/другое
|
|
||||||
loss_time = Column(DateTime)
|
|
||||||
elapsed_hours = Column(Float)
|
|
||||||
last_seen_direction = Column(String(10)) # С/СВ/В/ЮВ/Ю/ЮЗ/З/СЗ/неизвестно
|
|
||||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
|
||||||
last_seen_description = Column(Text)
|
|
||||||
behavior_description = Column(Text) # Поведение при стрессе
|
|
||||||
familiar_places = Column(Text) # Знакомые места
|
|
||||||
lost_before = Column(String(20)) # yes/no/unknown
|
|
||||||
|
|
||||||
# Среда (Шаг 4)
|
|
||||||
season = Column(String(20)) # зима/весна/лето/осень
|
|
||||||
temperature_c = Column(Float)
|
|
||||||
precipitation = Column(String(20)) # нет/морось/дождь/ливень/снег/гроза/туман
|
|
||||||
visibility = Column(String(20)) # хорошая/ограниченная/плохая
|
|
||||||
wind = Column(String(20)) # штиль/слабый/умеренный/сильный
|
|
||||||
terrain = Column(ARRAY(String)) # густой лес, редкий лес, лесная дорога, поле, болото, водоём, город...
|
|
||||||
|
|
||||||
# GPS (Шаг 4)
|
|
||||||
tnp_lat = Column(Float)
|
|
||||||
tnp_lon = Column(Float)
|
|
||||||
tnp_address = Column(Text)
|
|
||||||
|
|
||||||
# Ресурсы (Шаг 5)
|
|
||||||
teams_count = Column(Integer)
|
|
||||||
team_size = Column(Integer)
|
|
||||||
has_dog = Column(Boolean, default=False)
|
|
||||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal/quadbike
|
|
||||||
|
|
||||||
# Исход (заполняется после завершения)
|
|
||||||
found_alive = Column(Boolean)
|
|
||||||
found_distance_km = Column(Float)
|
|
||||||
found_direction = Column(String(10))
|
|
||||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
|
||||||
found_lat = Column(Float)
|
|
||||||
found_lon = Column(Float)
|
|
||||||
search_duration_hours = Column(Float)
|
|
||||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
|
||||||
|
|
||||||
# Мета
|
|
||||||
confidence_avg = Column(Float)
|
|
||||||
raw_text = Column(Text)
|
|
||||||
analysis_log = Column(JSONB)
|
|
||||||
|
|
||||||
|
|
||||||
class RawDocument(Base):
|
|
||||||
"""Raw document storage for parsed reports"""
|
|
||||||
__tablename__ = "raw_documents"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
filename = Column(String(255), nullable=False)
|
|
||||||
raw_text = Column(Text)
|
|
||||||
extracted_json = Column(JSONB)
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
"""
|
|
||||||
Geo service for building search zones and querying OpenStreetMap data via Overpass API.
|
|
||||||
"""
|
|
||||||
import math
|
|
||||||
import json
|
|
||||||
import hashlib
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Optional, Tuple
|
|
||||||
import httpx
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class Zone(BaseModel):
|
|
||||||
"""Search zone with geographic features."""
|
|
||||||
direction: str # N, NE, E, SE, S, SW, W, NW
|
|
||||||
distance_km: float
|
|
||||||
forest_pct: float
|
|
||||||
road_density: float # km of roads per km²
|
|
||||||
water_distance_km: Optional[float]
|
|
||||||
settlement_distance_km: Optional[float]
|
|
||||||
|
|
||||||
|
|
||||||
# Cache configuration
|
|
||||||
CACHE_DIR = Path("/tmp/overpass_cache")
|
|
||||||
CACHE_TTL_HOURS = 24
|
|
||||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
|
||||||
|
|
||||||
# Direction mappings
|
|
||||||
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
|
||||||
DIRECTION_ANGLES = {
|
|
||||||
"N": 0,
|
|
||||||
"NE": 45,
|
|
||||||
"E": 90,
|
|
||||||
"SE": 135,
|
|
||||||
"S": 180,
|
|
||||||
"SW": 225,
|
|
||||||
"W": 270,
|
|
||||||
"NW": 315
|
|
||||||
}
|
|
||||||
|
|
||||||
# Search distances in meters
|
|
||||||
SEARCH_DISTANCES = [500, 1000, 2000, 5000]
|
|
||||||
|
|
||||||
|
|
||||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
||||||
"""
|
|
||||||
Calculate distance between two points on Earth using Haversine formula.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lat1, lon1: First point coordinates
|
|
||||||
lat2, lon2: Second point coordinates
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Distance in kilometers
|
|
||||||
"""
|
|
||||||
R = 6371 # Earth radius in km
|
|
||||||
|
|
||||||
lat1_rad = math.radians(lat1)
|
|
||||||
lat2_rad = math.radians(lat2)
|
|
||||||
dlat = math.radians(lat2 - lat1)
|
|
||||||
dlon = math.radians(lon2 - lon1)
|
|
||||||
|
|
||||||
a = (math.sin(dlat / 2) ** 2 +
|
|
||||||
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
|
|
||||||
c = 2 * math.asin(math.sqrt(a))
|
|
||||||
|
|
||||||
return R * c
|
|
||||||
|
|
||||||
|
|
||||||
def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]:
|
|
||||||
"""
|
|
||||||
Calculate bounding box for a sector.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lat, lon: Center point
|
|
||||||
direction: Sector direction (N, NE, E, etc.)
|
|
||||||
radius_m: Radius in meters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(min_lat, min_lon, max_lat, max_lon)
|
|
||||||
"""
|
|
||||||
# Convert radius to degrees (approximate)
|
|
||||||
radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator
|
|
||||||
|
|
||||||
angle = DIRECTION_ANGLES[direction]
|
|
||||||
angle_rad = math.radians(angle)
|
|
||||||
|
|
||||||
# Calculate sector boundaries (45° sectors)
|
|
||||||
angle_start = angle - 22.5
|
|
||||||
angle_end = angle + 22.5
|
|
||||||
|
|
||||||
# Simple bounding box (can be optimized for actual sector shape)
|
|
||||||
lat_offset = radius_deg * math.cos(angle_rad)
|
|
||||||
lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat))
|
|
||||||
|
|
||||||
min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5
|
|
||||||
max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5
|
|
||||||
min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5
|
|
||||||
max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5
|
|
||||||
|
|
||||||
return (min_lat, min_lon, max_lat, max_lon)
|
|
||||||
|
|
||||||
|
|
||||||
def get_cache_key(query: str) -> str:
|
|
||||||
"""Generate cache key from query."""
|
|
||||||
return hashlib.md5(query.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_result(cache_key: str) -> Optional[Dict]:
|
|
||||||
"""Get cached Overpass API result if not expired."""
|
|
||||||
CACHE_DIR.mkdir(exist_ok=True)
|
|
||||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
|
||||||
|
|
||||||
if not cache_file.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(cache_file, 'r') as f:
|
|
||||||
cached = json.load(f)
|
|
||||||
|
|
||||||
cached_time = datetime.fromisoformat(cached['timestamp'])
|
|
||||||
if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS):
|
|
||||||
cache_file.unlink()
|
|
||||||
return None
|
|
||||||
|
|
||||||
return cached['data']
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_to_cache(cache_key: str, data: Dict):
|
|
||||||
"""Save Overpass API result to cache."""
|
|
||||||
CACHE_DIR.mkdir(exist_ok=True)
|
|
||||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(cache_file, 'w') as f:
|
|
||||||
json.dump({
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'data': data
|
|
||||||
}, f)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def query_overpass(query: str) -> Dict:
|
|
||||||
"""
|
|
||||||
Query Overpass API with caching.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Overpass QL query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
API response as dict
|
|
||||||
"""
|
|
||||||
cache_key = get_cache_key(query)
|
|
||||||
|
|
||||||
# Check cache
|
|
||||||
cached = get_cached_result(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
# Query API
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
try:
|
|
||||||
response = await client.post(
|
|
||||||
OVERPASS_URL,
|
|
||||||
data={'data': query},
|
|
||||||
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Save to cache
|
|
||||||
save_to_cache(cache_key, data)
|
|
||||||
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
|
||||||
# Return empty result on error
|
|
||||||
return {'elements': []}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_road_length(elements: List[Dict]) -> float:
|
|
||||||
"""
|
|
||||||
Calculate total road length from Overpass way elements.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
elements: List of way elements from Overpass
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Total length in kilometers
|
|
||||||
"""
|
|
||||||
total_length = 0.0
|
|
||||||
|
|
||||||
for element in elements:
|
|
||||||
if element.get('type') != 'way':
|
|
||||||
continue
|
|
||||||
|
|
||||||
nodes = element.get('geometry', [])
|
|
||||||
if len(nodes) < 2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Calculate length by summing distances between consecutive nodes
|
|
||||||
for i in range(len(nodes) - 1):
|
|
||||||
lat1, lon1 = nodes[i]['lat'], nodes[i]['lon']
|
|
||||||
lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon']
|
|
||||||
total_length += haversine(lat1, lon1, lat2, lon2)
|
|
||||||
|
|
||||||
return total_length
|
|
||||||
|
|
||||||
|
|
||||||
def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Find distance to nearest element.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lat, lon: Reference point
|
|
||||||
elements: List of node elements from Overpass
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Distance in kilometers, or None if no elements
|
|
||||||
"""
|
|
||||||
if not elements:
|
|
||||||
return None
|
|
||||||
|
|
||||||
min_distance = float('inf')
|
|
||||||
|
|
||||||
for element in elements:
|
|
||||||
if element.get('type') != 'node':
|
|
||||||
continue
|
|
||||||
|
|
||||||
elem_lat = element.get('lat')
|
|
||||||
elem_lon = element.get('lon')
|
|
||||||
|
|
||||||
if elem_lat is None or elem_lon is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
distance = haversine(lat, lon, elem_lat, elem_lon)
|
|
||||||
min_distance = min(min_distance, distance)
|
|
||||||
|
|
||||||
return min_distance if min_distance != float('inf') else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float:
|
|
||||||
"""
|
|
||||||
Estimate forest coverage percentage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
elements: List of way elements from Overpass
|
|
||||||
radius_m: Search radius in meters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Forest coverage as percentage (0-100)
|
|
||||||
"""
|
|
||||||
if not elements:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
# Approximate: count forest ways and estimate coverage
|
|
||||||
# This is a simplified calculation
|
|
||||||
forest_ways = len([e for e in elements if e.get('type') == 'way'])
|
|
||||||
|
|
||||||
# Rough heuristic: each forest way covers ~0.1 km²
|
|
||||||
# Total search area = π * r²
|
|
||||||
search_area_km2 = math.pi * (radius_m / 1000) ** 2
|
|
||||||
estimated_forest_km2 = forest_ways * 0.1
|
|
||||||
|
|
||||||
coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100)
|
|
||||||
|
|
||||||
return round(coverage_pct, 1)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict:
|
|
||||||
"""
|
|
||||||
Get geographic features for a zone using Overpass API.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lat, lon: Center point
|
|
||||||
direction: Sector direction
|
|
||||||
radius_m: Search radius in meters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct
|
|
||||||
"""
|
|
||||||
# Query roads
|
|
||||||
roads_query = f"""
|
|
||||||
[out:json];
|
|
||||||
(
|
|
||||||
way[highway](around:{radius_m},{lat},{lon});
|
|
||||||
);
|
|
||||||
out geom;
|
|
||||||
"""
|
|
||||||
roads_data = await query_overpass(roads_query)
|
|
||||||
roads_km = calculate_road_length(roads_data.get('elements', []))
|
|
||||||
|
|
||||||
# Query water bodies
|
|
||||||
water_query = f"""
|
|
||||||
[out:json];
|
|
||||||
(
|
|
||||||
node[natural=water](around:{radius_m},{lat},{lon});
|
|
||||||
way[natural=water](around:{radius_m},{lat},{lon});
|
|
||||||
);
|
|
||||||
out center;
|
|
||||||
"""
|
|
||||||
water_data = await query_overpass(water_query)
|
|
||||||
water_distance = find_nearest_distance(lat, lon, water_data.get('elements', []))
|
|
||||||
|
|
||||||
# Query settlements
|
|
||||||
settlement_query = f"""
|
|
||||||
[out:json];
|
|
||||||
(
|
|
||||||
node[place~"village|town|city"](around:{radius_m},{lat},{lon});
|
|
||||||
);
|
|
||||||
out;
|
|
||||||
"""
|
|
||||||
settlement_data = await query_overpass(settlement_query)
|
|
||||||
settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', []))
|
|
||||||
|
|
||||||
# Query forests
|
|
||||||
forest_query = f"""
|
|
||||||
[out:json];
|
|
||||||
(
|
|
||||||
way[landuse=forest](around:{radius_m},{lat},{lon});
|
|
||||||
way[natural=wood](around:{radius_m},{lat},{lon});
|
|
||||||
);
|
|
||||||
out geom;
|
|
||||||
"""
|
|
||||||
forest_data = await query_overpass(forest_query)
|
|
||||||
forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m)
|
|
||||||
|
|
||||||
# Calculate road density (km of roads per km²)
|
|
||||||
search_area_km2 = math.pi * (radius_m / 1000) ** 2
|
|
||||||
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
'roads_km': roads_km,
|
|
||||||
'road_density': round(road_density, 2),
|
|
||||||
'water_distance_km': water_distance,
|
|
||||||
'settlement_distance_km': settlement_distance,
|
|
||||||
'forest_pct': forest_pct
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def build_search_zones(lat: float, lon: float, case_data: dict) -> List[Zone]:
|
|
||||||
"""
|
|
||||||
Build search zones around a point.
|
|
||||||
|
|
||||||
Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances
|
|
||||||
(500m, 1000m, 2000m, 5000m) and queries geographic features for each.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lat: Latitude of search origin
|
|
||||||
lon: Longitude of search origin
|
|
||||||
case_data: Case information (for future enhancements)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of Zone objects with geographic features
|
|
||||||
"""
|
|
||||||
zones = []
|
|
||||||
|
|
||||||
for distance_m in SEARCH_DISTANCES:
|
|
||||||
for direction in DIRECTIONS:
|
|
||||||
# Get features for this zone
|
|
||||||
features = await get_zone_features(lat, lon, direction, distance_m)
|
|
||||||
|
|
||||||
zone = Zone(
|
|
||||||
direction=direction,
|
|
||||||
distance_km=distance_m / 1000,
|
|
||||||
forest_pct=features['forest_pct'],
|
|
||||||
road_density=features['road_density'],
|
|
||||||
water_distance_km=features['water_distance_km'],
|
|
||||||
settlement_distance_km=features['settlement_distance_km']
|
|
||||||
)
|
|
||||||
|
|
||||||
zones.append(zone)
|
|
||||||
|
|
||||||
return zones
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
"""
|
|
||||||
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
|
|
||||||
Реализация согласно §8 и §9 контекста ВЕКТОР.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from copy import deepcopy
|
|
||||||
|
|
||||||
|
|
||||||
class WeightedScorer:
|
|
||||||
"""
|
|
||||||
Система взвешенной оценки зон поиска с учетом множественных факторов.
|
|
||||||
Базовые веса из §9 контекста.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Базовые веса факторов из §9 (сумма = 1.0)
|
|
||||||
BASE_WEIGHTS = {
|
|
||||||
'forest': 0.25,
|
|
||||||
'water': 0.20,
|
|
||||||
'roads': 0.18,
|
|
||||||
'settlement': 0.15,
|
|
||||||
'historical': 0.12,
|
|
||||||
'direction': 0.07,
|
|
||||||
'shelter': 0.03
|
|
||||||
}
|
|
||||||
|
|
||||||
# Возрастные модификаторы
|
|
||||||
AGE_MODIFIERS = {
|
|
||||||
'0-4': {
|
|
||||||
'forest': 0.6,
|
|
||||||
'water': 2.5,
|
|
||||||
'roads': 1.3,
|
|
||||||
'settlement': 1.8,
|
|
||||||
'shelter': 1.5,
|
|
||||||
'distance_mult': 0.3
|
|
||||||
},
|
|
||||||
'5-7': {
|
|
||||||
'forest': 0.8,
|
|
||||||
'water': 2.2,
|
|
||||||
'roads': 1.4,
|
|
||||||
'settlement': 1.6,
|
|
||||||
'shelter': 1.4,
|
|
||||||
'distance_mult': 0.5
|
|
||||||
},
|
|
||||||
'8-11': {
|
|
||||||
'forest': 1.1,
|
|
||||||
'water': 1.8,
|
|
||||||
'roads': 1.2,
|
|
||||||
'settlement': 1.3,
|
|
||||||
'shelter': 1.2,
|
|
||||||
'distance_mult': 0.8
|
|
||||||
},
|
|
||||||
'12-14': {
|
|
||||||
'forest': 1.3,
|
|
||||||
'water': 1.4,
|
|
||||||
'roads': 1.1,
|
|
||||||
'settlement': 1.0,
|
|
||||||
'shelter': 1.0,
|
|
||||||
'distance_mult': 1.2
|
|
||||||
},
|
|
||||||
'15-17': {
|
|
||||||
'forest': 1.4,
|
|
||||||
'water': 1.2,
|
|
||||||
'roads': 1.3,
|
|
||||||
'settlement': 0.9,
|
|
||||||
'shelter': 0.9,
|
|
||||||
'distance_mult': 1.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Сезонные модификаторы
|
|
||||||
SEASON_MODIFIERS = {
|
|
||||||
'зима': {
|
|
||||||
'forest': 0.8,
|
|
||||||
'water': 0.6,
|
|
||||||
'roads': 1.3,
|
|
||||||
'settlement': 1.5,
|
|
||||||
'shelter': 2.0,
|
|
||||||
'distance_mult': 0.7
|
|
||||||
},
|
|
||||||
'весна': {
|
|
||||||
'forest': 1.1,
|
|
||||||
'water': 1.8,
|
|
||||||
'roads': 1.0,
|
|
||||||
'settlement': 1.0,
|
|
||||||
'shelter': 1.2,
|
|
||||||
'distance_mult': 1.0
|
|
||||||
},
|
|
||||||
'лето': {
|
|
||||||
'forest': 1.2,
|
|
||||||
'water': 1.3,
|
|
||||||
'roads': 0.9,
|
|
||||||
'settlement': 0.8,
|
|
||||||
'shelter': 0.8,
|
|
||||||
'distance_mult': 1.3
|
|
||||||
},
|
|
||||||
'осень': {
|
|
||||||
'forest': 1.3,
|
|
||||||
'water': 1.1,
|
|
||||||
'roads': 1.0,
|
|
||||||
'settlement': 1.1,
|
|
||||||
'shelter': 1.1,
|
|
||||||
'distance_mult': 1.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста
|
|
||||||
BEHAVIORAL_PROFILES = {
|
|
||||||
'РАС': {
|
|
||||||
'water': 3.0,
|
|
||||||
'railway': 2.5,
|
|
||||||
'shelter': 2.0,
|
|
||||||
'settlement': 0.4,
|
|
||||||
'distance_mult': 2.0,
|
|
||||||
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
|
|
||||||
},
|
|
||||||
'эпилепсия': {
|
|
||||||
'water': 3.5,
|
|
||||||
'shelter': 2.5,
|
|
||||||
'distance_mult': 0.6,
|
|
||||||
'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.'
|
|
||||||
},
|
|
||||||
'СДВГ': {
|
|
||||||
'roads': 1.6,
|
|
||||||
'distance_mult': 1.4,
|
|
||||||
'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.'
|
|
||||||
},
|
|
||||||
'ЗПР': {
|
|
||||||
'settlement': 0.7,
|
|
||||||
'shelter': 1.5,
|
|
||||||
'distance_mult': 0.8,
|
|
||||||
'note': 'Не ориентируется в пространстве'
|
|
||||||
},
|
|
||||||
'велосипед': {
|
|
||||||
'distance_mult': 5.0,
|
|
||||||
'roads': 1.8,
|
|
||||||
'forest': 0.8,
|
|
||||||
'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.'
|
|
||||||
},
|
|
||||||
'самокат': {
|
|
||||||
'distance_mult': 3.0,
|
|
||||||
'roads': 1.6,
|
|
||||||
'forest': 0.9
|
|
||||||
},
|
|
||||||
'намеренный_уход': {
|
|
||||||
'forest': 0.2,
|
|
||||||
'roads': 2.5,
|
|
||||||
'settlement': 3.0,
|
|
||||||
'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Инициализация скорера с базовыми весами."""
|
|
||||||
self.weights = deepcopy(self.BASE_WEIGHTS)
|
|
||||||
self.distance_multiplier = 1.0
|
|
||||||
self.active_profiles = []
|
|
||||||
self.critical_warnings = []
|
|
||||||
|
|
||||||
def _get_age_group(self, age: int) -> str:
|
|
||||||
"""Определяет возрастную группу."""
|
|
||||||
if age <= 4:
|
|
||||||
return '0-4'
|
|
||||||
elif age <= 7:
|
|
||||||
return '5-7'
|
|
||||||
elif age <= 11:
|
|
||||||
return '8-11'
|
|
||||||
elif age <= 14:
|
|
||||||
return '12-14'
|
|
||||||
elif age <= 17:
|
|
||||||
return '15-17'
|
|
||||||
else:
|
|
||||||
return '18-64'
|
|
||||||
|
|
||||||
def apply_age_modifiers(self, age: int):
|
|
||||||
"""Применяет возрастные модификаторы к весам."""
|
|
||||||
age_group = self._get_age_group(age)
|
|
||||||
modifiers = self.AGE_MODIFIERS.get(age_group, {})
|
|
||||||
|
|
||||||
for factor, modifier in modifiers.items():
|
|
||||||
if factor == 'distance_mult':
|
|
||||||
self.distance_multiplier *= modifier
|
|
||||||
elif factor in self.weights:
|
|
||||||
self.weights[factor] *= modifier
|
|
||||||
|
|
||||||
def apply_season_modifiers(self, season: str):
|
|
||||||
"""Применяет сезонные модификаторы к весам."""
|
|
||||||
season_lower = season.lower() if season else 'лето'
|
|
||||||
modifiers = self.SEASON_MODIFIERS.get(season_lower, {})
|
|
||||||
|
|
||||||
for factor, modifier in modifiers.items():
|
|
||||||
if factor == 'distance_mult':
|
|
||||||
self.distance_multiplier *= modifier
|
|
||||||
elif factor in self.weights:
|
|
||||||
self.weights[factor] *= modifier
|
|
||||||
|
|
||||||
def apply_profile(self, profile_list: List[str]):
|
|
||||||
"""
|
|
||||||
Применяет поведенческие профили к весам.
|
|
||||||
Точные коэффициенты из §8 контекста.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.)
|
|
||||||
"""
|
|
||||||
if not profile_list:
|
|
||||||
return
|
|
||||||
|
|
||||||
for profile_name in profile_list:
|
|
||||||
profile = self.BEHAVIORAL_PROFILES.get(profile_name)
|
|
||||||
if not profile:
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.active_profiles.append(profile_name)
|
|
||||||
|
|
||||||
# Сохранить критические предупреждения
|
|
||||||
if 'critical_warning' in profile:
|
|
||||||
self.critical_warnings.append({
|
|
||||||
'profile': profile_name,
|
|
||||||
'warning': profile['critical_warning']
|
|
||||||
})
|
|
||||||
|
|
||||||
for factor, modifier in profile.items():
|
|
||||||
if factor in ['critical_warning', 'note']:
|
|
||||||
continue
|
|
||||||
elif factor == 'distance_mult':
|
|
||||||
self.distance_multiplier *= modifier
|
|
||||||
elif factor in self.weights:
|
|
||||||
self.weights[factor] *= modifier
|
|
||||||
elif factor == 'railway':
|
|
||||||
# Ж/д пути — добавляем как отдельный фактор для РАС
|
|
||||||
if 'railway' not in self.weights:
|
|
||||||
self.weights['railway'] = 0.05
|
|
||||||
self.weights['railway'] *= modifier
|
|
||||||
|
|
||||||
def _normalize_weights(self):
|
|
||||||
"""Нормализует веса так, чтобы их сумма была 1.0."""
|
|
||||||
# Фильтруем None значения
|
|
||||||
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
|
|
||||||
total = sum(valid_weights.values())
|
|
||||||
|
|
||||||
if total > 0:
|
|
||||||
for key in self.weights:
|
|
||||||
if self.weights[key] is not None:
|
|
||||||
self.weights[key] /= total
|
|
||||||
else:
|
|
||||||
self.weights[key] = 0.0
|
|
||||||
|
|
||||||
def score_zone(self, zone: Dict, case: Dict) -> float:
|
|
||||||
"""
|
|
||||||
Оценивает зону поиска на основе её характеристик и данных случая.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
zone: Словарь с характеристиками зоны
|
|
||||||
case: Данные случая (age, season, profiles и т.д.)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
float: Оценка зоны (0-100)
|
|
||||||
"""
|
|
||||||
# Сбрасываем веса к базовым
|
|
||||||
self.weights = deepcopy(self.BASE_WEIGHTS)
|
|
||||||
self.distance_multiplier = 1.0
|
|
||||||
self.active_profiles = []
|
|
||||||
self.critical_warnings = []
|
|
||||||
|
|
||||||
# Применяем модификаторы
|
|
||||||
if 'age' in case and case['age']:
|
|
||||||
self.apply_age_modifiers(case['age'])
|
|
||||||
|
|
||||||
if 'season' in case and case['season']:
|
|
||||||
self.apply_season_modifiers(case['season'])
|
|
||||||
|
|
||||||
if 'profiles' in case and case['profiles']:
|
|
||||||
self.apply_profile(case['profiles'])
|
|
||||||
|
|
||||||
# Нормализуем веса
|
|
||||||
self._normalize_weights()
|
|
||||||
|
|
||||||
# Вычисляем оценку
|
|
||||||
score = 0.0
|
|
||||||
|
|
||||||
# Лес
|
|
||||||
forest_score = zone.get('forest_pct', 0.5)
|
|
||||||
score += self.weights['forest'] * forest_score
|
|
||||||
|
|
||||||
# Вода (чем ближе, тем важнее)
|
|
||||||
water_dist = zone.get('water_distance_km', 5.0)
|
|
||||||
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
|
|
||||||
score += self.weights['water'] * water_score
|
|
||||||
|
|
||||||
# Дороги
|
|
||||||
road_density = zone.get('road_density', 0.5)
|
|
||||||
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
|
|
||||||
score += self.weights['roads'] * road_score
|
|
||||||
|
|
||||||
# Населенные пункты
|
|
||||||
settlement_dist = zone.get('settlement_distance_km', 10.0)
|
|
||||||
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
|
|
||||||
score += self.weights['settlement'] * settlement_score
|
|
||||||
|
|
||||||
# Историческая частота
|
|
||||||
historical_score = zone.get('historical_freq', 0.5)
|
|
||||||
score += self.weights['historical'] * historical_score
|
|
||||||
|
|
||||||
# Совпадение направления
|
|
||||||
direction_score = zone.get('direction_match', 0.5)
|
|
||||||
score += self.weights['direction'] * direction_score
|
|
||||||
|
|
||||||
# Укрытия
|
|
||||||
shelter_score = zone.get('shelter_pct', 0.3)
|
|
||||||
score += self.weights['shelter'] * shelter_score
|
|
||||||
|
|
||||||
# Ж/д пути (для РАС)
|
|
||||||
if 'railway' in self.weights:
|
|
||||||
railway_dist = zone.get('railway_distance_km', 10.0)
|
|
||||||
railway_score = max(0, 1.0 - (railway_dist / 5.0))
|
|
||||||
score += self.weights['railway'] * railway_score
|
|
||||||
|
|
||||||
# Применяем множитель расстояния
|
|
||||||
zone_distance = zone.get('distance_km', 1.0)
|
|
||||||
expected_distance = 2.0 * self.distance_multiplier
|
|
||||||
distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2)
|
|
||||||
distance_factor = max(0.3, min(1.0, distance_factor))
|
|
||||||
|
|
||||||
score *= distance_factor
|
|
||||||
|
|
||||||
# Конвертируем в шкалу 0-100
|
|
||||||
return round(score * 100, 2)
|
|
||||||
|
|
||||||
def rank_zones(self, zones: List[Dict], case: Dict) -> List[Dict]:
|
|
||||||
"""
|
|
||||||
Ранжирует зоны по приоритету на основе оценок.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
zones: Список зон с характеристиками
|
|
||||||
case: Данные случая
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[Dict]: Отсортированный список зон с оценками и приоритетами
|
|
||||||
"""
|
|
||||||
scored_zones = []
|
|
||||||
for zone in zones:
|
|
||||||
zone_copy = deepcopy(zone)
|
|
||||||
zone_copy['score'] = self.score_zone(zone, case)
|
|
||||||
scored_zones.append(zone_copy)
|
|
||||||
|
|
||||||
scored_zones.sort(key=lambda x: x['score'], reverse=True)
|
|
||||||
|
|
||||||
for i, zone in enumerate(scored_zones):
|
|
||||||
zone['priority'] = i + 1
|
|
||||||
|
|
||||||
return scored_zones
|
|
||||||
|
|
||||||
def get_active_profiles_info(self) -> List[Dict]:
|
|
||||||
"""Возвращает информацию об активных профилях с предупреждениями."""
|
|
||||||
profiles_info = []
|
|
||||||
|
|
||||||
for profile_name in self.active_profiles:
|
|
||||||
profile = self.BEHAVIORAL_PROFILES.get(profile_name, {})
|
|
||||||
info = {
|
|
||||||
'name': profile_name,
|
|
||||||
'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']},
|
|
||||||
}
|
|
||||||
|
|
||||||
if 'critical_warning' in profile:
|
|
||||||
info['critical_warning'] = profile['critical_warning']
|
|
||||||
if 'note' in profile:
|
|
||||||
info['note'] = profile['note']
|
|
||||||
|
|
||||||
profiles_info.append(info)
|
|
||||||
|
|
||||||
return profiles_info
|
|
||||||
|
|
||||||
|
|
||||||
def create_scorer_for_case(case: Dict) -> WeightedScorer:
|
|
||||||
"""Создает и настраивает скорер для конкретного случая."""
|
|
||||||
scorer = WeightedScorer()
|
|
||||||
|
|
||||||
if 'age' in case and case['age']:
|
|
||||||
scorer.apply_age_modifiers(case['age'])
|
|
||||||
|
|
||||||
if 'season' in case and case['season']:
|
|
||||||
scorer.apply_season_modifiers(case['season'])
|
|
||||||
|
|
||||||
if 'profiles' in case and case['profiles']:
|
|
||||||
scorer.apply_profile(case['profiles'])
|
|
||||||
|
|
||||||
scorer._normalize_weights()
|
|
||||||
|
|
||||||
return scorer
|
|
||||||
|
|
||||||
|
|
||||||
def get_weight_explanation(case: Dict) -> Dict:
|
|
||||||
"""Возвращает объяснение весов для данного случая."""
|
|
||||||
scorer = create_scorer_for_case(case)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'weights': scorer.weights,
|
|
||||||
'distance_multiplier': scorer.distance_multiplier,
|
|
||||||
'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None,
|
|
||||||
'season': case.get('season'),
|
|
||||||
'profiles': scorer.get_active_profiles_info(),
|
|
||||||
'critical_warnings': scorer.critical_warnings
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user