Import Vector lab project

This commit is contained in:
root
2026-06-06 18:31:55 +00:00
commit fcc22063ed
105 changed files with 31639 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y gcc postgresql-client && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
View File
+347
View File
@@ -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
+23
View File
@@ -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
)
+196
View File
@@ -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
+212
View File
@@ -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
+159
View File
@@ -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']
)
+39
View File
@@ -0,0 +1,39 @@
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}")
+26
View File
@@ -0,0 +1,26 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
import os
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@postgres:5432/vector_mchs"
)
engine = create_engine(DATABASE_URL, echo=False)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
Base.metadata.create_all(bind=engine)
+47
View File
@@ -0,0 +1,47 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import os
from api.v1 import cases, analyze, auth, stats
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(
title="SAR-MCHS API",
description="Search and Rescue Management System API",
version="1.0.0",
lifespan=lifespan
)
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
app.include_router(cases.router, prefix="/api/v1", tags=["Cases"])
app.include_router(analyze.router, prefix="/api/v1/analyze", tags=["Analysis"])
app.include_router(stats.router, prefix="/api/v1/stats", tags=["Statistics"])
@app.get("/")
async def root():
return {
"message": "SAR-MCHS API",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@@ -0,0 +1,78 @@
-- Миграция: создание таблицы cases согласно §10 контекста
DROP TABLE IF EXISTS search_results CASCADE;
DROP TABLE IF EXISTS search_cases CASCADE;
CREATE TABLE IF NOT EXISTS cases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'active',
child_name VARCHAR(255),
age_years INTEGER NOT NULL,
gender CHAR(1),
clothes_description TEXT,
special_marks TEXT,
phone_status VARCHAR(20),
has_diagnosis BOOLEAN DEFAULT FALSE,
diagnosis_type VARCHAR[],
fitness_level VARCHAR(20),
has_transport VARCHAR(20) DEFAULT 'none',
cant_swim BOOLEAN DEFAULT FALSE,
psychotype VARCHAR(50),
psychotype_answers JSONB,
loss_reason VARCHAR(100),
loss_time TIMESTAMP,
elapsed_hours FLOAT,
last_seen_direction VARCHAR(10),
last_seen_reliability VARCHAR(20),
last_seen_description TEXT,
behavior_description TEXT,
familiar_places TEXT,
lost_before VARCHAR(20),
season VARCHAR(20),
temperature_c FLOAT,
precipitation VARCHAR(20),
visibility VARCHAR(20),
wind VARCHAR(20),
terrain VARCHAR[],
tnp_lat FLOAT,
tnp_lon FLOAT,
tnp_address TEXT,
teams_count INTEGER,
team_size INTEGER,
has_dog BOOLEAN DEFAULT FALSE,
extra_resources VARCHAR[],
found_alive BOOLEAN,
found_distance_km FLOAT,
found_direction VARCHAR(10),
found_location_type VARCHAR(50),
found_lat FLOAT,
found_lon FLOAT,
search_duration_hours FLOAT,
who_found VARCHAR(50),
confidence_avg FLOAT,
raw_text TEXT,
analysis_log JSONB
);
CREATE INDEX IF NOT EXISTS idx_cases_coords ON cases (found_lat, found_lon);
CREATE INDEX IF NOT EXISTS idx_cases_age_season ON cases (age_years, season);
CREATE INDEX IF NOT EXISTS idx_cases_status ON cases (status);
DROP TABLE IF EXISTS raw_documents CASCADE;
CREATE TABLE IF NOT EXISTS raw_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename VARCHAR(255) NOT NULL,
raw_text TEXT,
extracted_json JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
@@ -0,0 +1,12 @@
-- Миграция: добавление детализированных полей одежды и телосложения
-- Дата: 2026-05-04
ALTER TABLE cases ADD COLUMN IF NOT EXISTS height_build TEXT;
ALTER TABLE cases ADD COLUMN IF NOT EXISTS clothes_upper TEXT;
ALTER TABLE cases ADD COLUMN IF NOT EXISTS clothes_lower TEXT;
ALTER TABLE cases ADD COLUMN IF NOT EXISTS shoes TEXT;
COMMENT ON COLUMN cases.height_build IS 'Рост / телосложение (Шаг 1)';
COMMENT ON COLUMN cases.clothes_upper IS 'Одежда: верх (цвет, тип) (Шаг 1)';
COMMENT ON COLUMN cases.clothes_lower IS 'Одежда: низ (цвет, тип) (Шаг 1)';
COMMENT ON COLUMN cases.shoes IS 'Обувь (тип, цвет) (Шаг 1)';
@@ -0,0 +1,41 @@
-- Миграция: создание таблиц users и analysis_log
-- Дата: 2026-05-04
-- Таблица пользователей для аутентификации
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'operator', -- operator/field/admin
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
last_login TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users (role);
-- Таблица логов анализа
CREATE TABLE IF NOT EXISTS analysis_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
analysis_type VARCHAR(50) NOT NULL, -- scoring/claude/geo/psychotype
input_data JSONB,
output_data JSONB,
execution_time FLOAT, -- в секундах
status VARCHAR(20) DEFAULT 'success', -- success/error/partial
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_analysis_log_case_id ON analysis_log (case_id);
CREATE INDEX IF NOT EXISTS idx_analysis_log_user_id ON analysis_log (user_id);
CREATE INDEX IF NOT EXISTS idx_analysis_log_created_at ON analysis_log (created_at);
CREATE INDEX IF NOT EXISTS idx_analysis_log_type ON analysis_log (analysis_type);
COMMENT ON TABLE users IS 'Пользователи системы с ролями operator/field/admin';
COMMENT ON TABLE analysis_log IS 'Лог всех анализов случаев для аудита и отладки';
+16
View File
@@ -0,0 +1,16 @@
-- Seed: создание тестовых пользователей
-- Пароль для всех: pass123
-- Удаляем существующих пользователей (если есть)
TRUNCATE TABLE users CASCADE;
-- Создаём трёх тестовых пользователей
-- Хеш для пароля "pass123" (bcrypt)
INSERT INTO users (id, username, email, hashed_password, full_name, role, is_active, created_at)
VALUES
(gen_random_uuid(), 'operator', 'operator@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Оператор ЦОУ', 'operator', true, NOW()),
(gen_random_uuid(), 'field', 'field@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Полевой работник', 'field', true, NOW()),
(gen_random_uuid(), 'admin', 'admin@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Администратор', 'admin', true, NOW());
-- Проверка
SELECT username, email, role, is_active FROM users ORDER BY role;
@@ -0,0 +1,9 @@
-- Обновление хешей паролей для тестовых пользователей
-- Новый хеш для пароля "pass123" через bcrypt напрямую
UPDATE users
SET hashed_password = '$2b$12$gldqRRQhjH6yx3ymbZXxbOGfKYwd27cnzf6gQAKP7rho4PDSXT2Oy'
WHERE username IN ('operator', 'field', 'admin');
-- Проверка
SELECT username, role, substring(hashed_password, 1, 20) as hash_prefix FROM users;
+125
View File
@@ -0,0 +1,125 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import uuid
from database import Base
class User(Base):
"""User model for authentication"""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(100), unique=True, nullable=False)
email = Column(String(255), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255))
role = Column(String(20), nullable=False, default="operator")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
last_login = Column(DateTime)
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")
# Ребёнок (Шаг 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)
special_marks = Column(Text)
phone_status = Column(String(20))
# Здоровье (Шаг 2)
has_diagnosis = Column(Boolean, default=False)
diagnosis_type = Column(ARRAY(String))
fitness_level = Column(String(20))
has_transport = Column(String(20), default="none")
cant_swim = Column(Boolean, default=False)
# Психотип (Шаг 2б)
psychotype = Column(String(50))
psychotype_answers = Column(JSONB)
# Обстоятельства (Шаг 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))
last_seen_description = Column(Text)
behavior_description = Column(Text)
familiar_places = Column(Text)
lost_before = Column(String(20))
# Среда (Шаг 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))
# Исход (заполняется после завершения)
found_alive = Column(Boolean)
found_distance_km = Column(Float)
found_direction = Column(String(10))
found_location_type = Column(String(50))
found_lat = Column(Float)
found_lon = Column(Float)
search_duration_hours = Column(Float)
who_found = Column(String(50))
# Мета
confidence_avg = Column(Float)
raw_text = Column(Text)
analysis_log = Column(JSONB)
class AnalysisLog(Base):
"""Analysis log for audit and debugging"""
__tablename__ = "analysis_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
analysis_type = Column(String(50), nullable=False)
input_data = Column(JSONB)
output_data = Column(JSONB)
execution_time = Column(Float)
status = Column(String(20), default="success")
error_message = Column(Text)
created_at = Column(DateTime, server_default=func.now())
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())
+89
View File
@@ -0,0 +1,89 @@
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())
+89
View File
@@ -0,0 +1,89 @@
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())
+93
View File
@@ -0,0 +1,93 @@
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())
+15
View File
@@ -0,0 +1,15 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
httpx==0.28.1
python-docx==1.1.2
python-multipart==0.0.17
pydantic==2.10.3
pydantic-settings==2.6.1
email-validator==2.1.0
psycopg2-binary==2.9.12
pytest==8.3.4
+258
View File
@@ -0,0 +1,258 @@
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any
from datetime import datetime
from uuid import UUID
class CaseCreate(BaseModel):
"""Schema for creating a new case"""
# Ребёнок (Шаг 1)
child_name: Optional[str] = None
age_years: int = Field(..., ge=0, le=18)
gender: Optional[str] = Field(None, pattern="^[МЖ]$")
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] = Field(None, pattern="^(answers|silent|none)$")
# Здоровье (Шаг 2)
has_diagnosis: bool = False
diagnosis_type: Optional[List[str]] = None
fitness_level: Optional[str] = Field(None, pattern="^(low|medium|high)$")
has_transport: str = Field(default="none", pattern="^(none|bike|scooter|other)$")
cant_swim: bool = False
# Психотип (Шаг 2б)
psychotype: Optional[str] = None
psychotype_answers: Optional[Dict[str, Any]] = None
# Обстоятельства (Шаг 3)
loss_reason: Optional[str] = None
loss_time: Optional[datetime] = None
elapsed_hours: Optional[float] = Field(None, ge=0)
last_seen_direction: Optional[str] = None
last_seen_reliability: Optional[str] = Field(None, pattern="^(exact|approx|unknown)$")
last_seen_description: Optional[str] = None
behavior_description: Optional[str] = None
familiar_places: Optional[str] = None
lost_before: Optional[str] = Field(None, pattern="^(yes|no|unknown)$")
# Среда (Шаг 4)
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 (Шаг 4)
tnp_lat: Optional[float] = Field(None, ge=-90, le=90)
tnp_lon: Optional[float] = Field(None, ge=-180, le=180)
tnp_address: Optional[str] = None
# Ресурсы (Шаг 5)
teams_count: Optional[int] = Field(None, ge=0)
team_size: Optional[int] = Field(None, ge=0)
has_dog: bool = False
extra_resources: Optional[List[str]] = None
model_config = ConfigDict(json_schema_extra={
"example": {
"child_name": "Иван",
"age_years": 8,
"gender": "М",
"loss_reason": "потерялся в лесу",
"season": "лето",
"temperature_c": 22.0,
"tnp_lat": 53.9,
"tnp_lon": 27.56,
"teams_count": 3,
"team_size": 5
}
})
class CaseOut(BaseModel):
"""Schema for case output"""
id: UUID
created_at: datetime
status: str
# Ребёнок
child_name: Optional[str] = None
age_years: int
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: bool
diagnosis_type: Optional[List[str]] = None
fitness_level: Optional[str] = None
has_transport: str
cant_swim: bool
# Психотип
psychotype: Optional[str] = None
psychotype_answers: Optional[Dict[str, Any]] = None
# Обстоятельства
loss_reason: Optional[str] = None
loss_time: Optional[datetime] = 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: bool
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
# Мета
confidence_avg: Optional[float] = None
raw_text: Optional[str] = None
analysis_log: Optional[Dict[str, Any]] = None
model_config = ConfigDict(from_attributes=True)
class SearchZone(BaseModel):
"""Search zone with priority and details"""
priority: int = Field(..., ge=1, le=3)
name: str
direction: str
distance_km: float
score: float
reasoning: str
coordinates: Optional[List[List[float]]] = None
class BehavioralProfile(BaseModel):
"""Active behavioral profile"""
type: str
title: str
recommendations: List[str]
modifiers: Dict[str, float]
class AnalysisResult(BaseModel):
"""Complete analysis result"""
case_id: UUID
urgency_level: str = Field(..., pattern="^(КРИТИЧЕСКИЙ|ВЫСОКИЙ|УМЕРЕННЫЙ)$")
urgency_reason: str
time_window_hours: Optional[float] = None
active_profiles: List[BehavioralProfile]
immediate_actions: List[str] = Field(..., min_length=3, max_length=3)
search_zones: List[SearchZone] = Field(..., min_length=1, max_length=3)
key_objects: List[str]
team_assignments: Optional[Dict[str, str]] = None
behavioral_forecast: str
dog_recommendations: Optional[List[str]] = None
max_distance_km: float
confidence_score: float = Field(..., ge=0, le=1)
created_at: datetime
execution_time: Optional[float] = None
model_config = ConfigDict(json_schema_extra={
"example": {
"case_id": "123e4567-e89b-12d3-a456-426614174000",
"urgency_level": "ВЫСОКИЙ",
"urgency_reason": "Ребёнок 8 лет, прошло 4 часа, температура +15°C",
"time_window_hours": 12.0,
"active_profiles": [
{
"type": "age_8_12",
"title": "Возраст 8-12 лет",
"recommendations": ["Радиус поиска до 3 км", "Проверить дороги и тропы"],
"modifiers": {"distance": 1.0, "roads": 1.2}
}
],
"immediate_actions": [
"Перекрыть все дороги в радиусе 2 км",
"Проверить водоёмы в радиусе 1 км",
"Организовать оклик по имени"
],
"search_zones": [
{
"priority": 1,
"name": "Лесной массив северо-восток",
"direction": "СВ",
"distance_km": 1.2,
"score": 0.85,
"reasoning": "Последнее направление движения, густой лес"
}
],
"key_objects": ["Озеро Круглое (800м СВ)", "Лесная дорога (500м С)"],
"behavioral_forecast": "Ребёнок скорее всего движется вдоль дороги или тропы",
"max_distance_km": 3.5,
"confidence_score": 0.82,
"created_at": "2026-05-04T13:45:00Z"
}
})
class UserCreate(BaseModel):
"""Schema for creating a new user"""
username: str = Field(..., min_length=3, max_length=100)
email: str = Field(..., pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
password: str = Field(..., min_length=8)
full_name: Optional[str] = None
role: str = Field(default="operator", pattern="^(operator|field|admin)$")
class UserOut(BaseModel):
"""Schema for user output"""
id: UUID
username: str
email: str
full_name: Optional[str] = None
role: str
is_active: bool
created_at: datetime
last_login: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
+85
View File
@@ -0,0 +1,85 @@
"""
Seed script для создания тестовых пользователей
Создаёт трёх пользователей с разными ролями: operator, field, admin
"""
import sys
sys.path.append('/app')
from sqlalchemy.orm import Session
from passlib.context import CryptContext
from database import SessionLocal
from models import User
import uuid
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_password_hash(password: str) -> str:
# Обрезаем пароль до 72 байт для bcrypt
return pwd_context.hash(password[:72])
def create_test_users():
"""Создание тестовых пользователей"""
db = SessionLocal()
try:
# Проверяем, есть ли уже пользователи
existing_users = db.query(User).count()
if existing_users > 0:
print(f"В базе уже есть {existing_users} пользователей. Пропускаем seed.")
return
test_users = [
{
"username": "operator",
"email": "operator@mchs.by",
"password": "pass123",
"full_name": "Оператор ЦОУ",
"role": "operator"
},
{
"username": "field",
"email": "field@mchs.by",
"password": "pass123",
"full_name": "Полевой работник",
"role": "field"
},
{
"username": "admin",
"email": "admin@mchs.by",
"password": "pass123",
"full_name": "Администратор",
"role": "admin"
}
]
print("Создание тестовых пользователей...")
for user_data in test_users:
password = user_data.pop("password")
hashed_password = get_password_hash(password)
db_user = User(
id=uuid.uuid4(),
hashed_password=hashed_password,
is_active=True,
**user_data
)
db.add(db_user)
print(f" + {user_data['username']} ({user_data['role']})")
db.commit()
print("\nТестовые пользователи созданы!")
print("\nДля входа используйте пароль: pass123")
print(" operator / pass123")
print(" field / pass123")
print(" admin / pass123")
except Exception as e:
print(f"Ошибка: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
create_test_users()
+23
View File
@@ -0,0 +1,23 @@
from .claude_service import analyze_case
from .stats_service import get_statistical_recommendation
from .scoring_service import WeightedScorer, create_scorer_for_case, get_weight_explanation
from .geo_service import build_search_zones, haversine, Zone
from .distance_service import calculate_max_distance, get_distance_priors, get_distance_statistics
from .psychotype_service import detect_psychotype, get_psychotype_modifiers, get_search_recommendations
__all__ = [
"analyze_case",
"get_statistical_recommendation",
"WeightedScorer",
"create_scorer_for_case",
"get_weight_explanation",
"build_search_zones",
"haversine",
"Zone",
"calculate_max_distance",
"get_distance_priors",
"get_distance_statistics",
"detect_psychotype",
"get_psychotype_modifiers",
"get_search_recommendations"
]
+312
View File
@@ -0,0 +1,312 @@
"""
Claude AI service for case analysis with fallback to scoring service.
Integrates geo_service zones and scoring_service rankings.
"""
import os
import json
from typing import Dict, List, Optional
from pydantic import BaseModel
import httpx
from .geo_service import build_search_zones
from .scoring_service import WeightedScorer
class PrimaryZone(BaseModel):
priority: int
name: str
direction: str
distance: float
reason: str
class AnalysisResult(BaseModel):
urgency: str # "критическая", "высокая", "средняя", "низкая"
primary_zones: List[PrimaryZone]
search_radius_km: float
key_locations: List[str]
behavioral_prediction: str
immediate_actions: List[str]
summary: str
fallback_used: bool = False
async def analyze_case(case_data: dict) -> AnalysisResult:
"""
Анализирует данные случая с помощью Claude API с fallback на scoring_service.
Интегрирует:
- geo_service: построение зон поиска
- scoring_service: оценка и ранжирование зон
- Claude API: интеллектуальный анализ (если доступен)
Args:
case_data: Словарь с данными случая
- age: возраст
- gender: пол
- terrain: местность
- weather: погода
- time_missing: время пропажи
- last_location: последнее местоположение
- lat, lon: координаты (опционально)
- profiles: поведенческие профили (опционально)
- season: сезон (опционально)
Returns:
AnalysisResult: Структурированный результат анализа
"""
# Попытка использовать Claude API
api_key = os.getenv("ANTHROPIC_API_KEY")
if api_key:
try:
return await analyze_with_claude(case_data, api_key)
except Exception as e:
# Логируем ошибку и переходим на fallback
print(f"Claude API unavailable: {e}. Using fallback scoring service.")
# Fallback: используем только scoring_service
return await analyze_with_fallback(case_data)
async def analyze_with_claude(case_data: dict, api_key: str) -> AnalysisResult:
"""
Анализ с помощью Claude API с интеграцией geo и scoring сервисов.
"""
# Построить зоны поиска если есть координаты
zones_data = ""
if case_data.get('lat') and case_data.get('lon'):
try:
zones = await build_search_zones(
case_data['lat'],
case_data['lon'],
case_data
)
# Ранжировать зоны
scorer = WeightedScorer()
zones_dict = [
{
'direction': z.direction,
'distance_km': z.distance_km,
'forest_pct': z.forest_pct,
'road_density': z.road_density,
'water_distance_km': z.water_distance_km,
'settlement_distance_km': z.settlement_distance_km
}
for z in zones
]
ranked_zones = scorer.rank_zones(zones_dict, case_data)
# Топ-5 зон для промпта
top_zones = ranked_zones[:5]
zones_data = "\n\nТОП-5 ПРИОРИТЕТНЫХ ЗОН (по scoring_service):\n"
for zone in top_zones:
zones_data += f"- {zone['direction']} направление, {zone['distance_km']}км: "
zones_data += f"оценка {zone['score']}/100, "
zones_data += f"лес {zone['forest_pct']}%, "
zones_data += f"дороги {zone['road_density']} км/км²\n"
except Exception as e:
print(f"Geo/scoring service error: {e}")
# Формируем промпт на русском языке
prompt = f"""Ты — эксперт по поисково-спасательным операциям (ПСО) МЧС Республики Беларусь. Проанализируй следующий случай пропажи человека и дай структурированные рекомендации.
ДАННЫЕ СЛУЧАЯ:
- Возраст пропавшего: {case_data.get('age', 'не указан')} лет
- Пол: {case_data.get('gender', 'не указан')}
- Местность: {case_data.get('terrain', 'не указана')}
- Погодные условия: {case_data.get('weather', 'не указаны')}
- Время пропажи: {case_data.get('time_missing', 'не указано')}
- Последнее известное местоположение: {case_data.get('last_location', 'не указано')}
- Особые обстоятельства: {case_data.get('circumstances', 'нет')}
- Физическое состояние: {case_data.get('physical_condition', 'не указано')}
- Опыт нахождения на природе: {case_data.get('experience', 'не указан')}
- Поведенческие профили: {', '.join(case_data.get('profiles', [])) if case_data.get('profiles') else 'нет'}
- Сезон: {case_data.get('season', 'не указан')}{zones_data}
ЗАДАЧА:
Предоставь детальный анализ в формате JSON со следующими полями:
1. urgency: Уровень срочности ("критическая", "высокая", "средняя", "низкая")
2. primary_zones: Массив из 3-5 приоритетных зон поиска, каждая с полями:
- priority: номер приоритета (1 = самый высокий)
- name: название зоны (например "Ближний лес", "Водоём на севере")
- direction: направление от последней точки (N, NE, E, SE, S, SW, W, NW)
- distance: расстояние в км
- reason: обоснование выбора этой зоны
3. search_radius_km: Рекомендуемый радиус поиска в километрах
4. key_locations: Массив ключевых типов локаций для проверки (водоемы, дороги, постройки и т.д.)
5. behavioral_prediction: Прогноз поведения пропавшего на основе возраста и обстоятельств
6. immediate_actions: Массив немедленных действий, которые нужно предпринять
7. summary: Краткое резюме анализа (2-3 предложения)
ВАЖНО: Учитывай данные из scoring_service при формировании primary_zones. Отвечай ТОЛЬКО валидным JSON без дополнительного текста."""
# Вызываем Anthropic API
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
json={
"model": "claude-sonnet-4-20250514",
"max_tokens": 4096,
"messages": [
{
"role": "user",
"content": prompt
}
]
}
)
if response.status_code != 200:
raise Exception(f"Anthropic API error: {response.status_code} - {response.text}")
result = response.json()
content = result["content"][0]["text"]
# Парсим JSON из ответа
# Убираем возможные markdown блоки кода
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
analysis_data = json.loads(content)
analysis_data['fallback_used'] = False
# Преобразуем в Pydantic модель
return AnalysisResult(**analysis_data)
async def analyze_with_fallback(case_data: dict) -> AnalysisResult:
"""
Fallback анализ используя только scoring_service без Claude API.
"""
# Определяем срочность на основе возраста и профилей
age = case_data.get('age', 10)
profiles = case_data.get('profiles', [])
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
urgency = "критическая"
elif age <= 7 or 'велосипед' in profiles:
urgency = "высокая"
elif age <= 12:
urgency = "средняя"
else:
urgency = "средняя"
# Построить зоны если есть координаты
primary_zones = []
search_radius_km = 5.0
if case_data.get('lat') and case_data.get('lon'):
try:
zones = await build_search_zones(
case_data['lat'],
case_data['lon'],
case_data
)
# Ранжировать зоны
scorer = WeightedScorer()
zones_dict = [
{
'direction': z.direction,
'distance_km': z.distance_km,
'forest_pct': z.forest_pct,
'road_density': z.road_density,
'water_distance_km': z.water_distance_km,
'settlement_distance_km': z.settlement_distance_km
}
for z in zones
]
ranked_zones = scorer.rank_zones(zones_dict, case_data)
# Топ-5 зон
for i, zone in enumerate(ranked_zones[:5]):
primary_zones.append(PrimaryZone(
priority=i + 1,
name=f"Зона {zone['direction']} {zone['distance_km']}км",
direction=zone['direction'],
distance=zone['distance_km'],
reason=f"Оценка {zone['score']}/100 по scoring_service"
))
# Радиус на основе дистанции
search_radius_km = scorer.distance_multiplier * 2.0
except Exception as e:
print(f"Geo/scoring service error in fallback: {e}")
# Если зоны не построены, используем базовые
if not primary_zones:
primary_zones = [
PrimaryZone(
priority=1,
name="Ближняя зона",
direction="N",
distance=0.5,
reason="Базовая зона поиска"
),
PrimaryZone(
priority=2,
name="Средняя зона",
direction="E",
distance=1.0,
reason="Расширенная зона поиска"
)
]
# Ключевые локации на основе возраста
if age <= 7:
key_locations = ["водоёмы", "укрытия", "густая растительность", "ближайшие постройки"]
elif age <= 12:
key_locations = ["водоёмы", "дороги", "тропы", "лесные массивы"]
else:
key_locations = ["дороги", "населённые пункты", "транспортные узлы", "водоёмы"]
# Поведенческий прогноз
if 'РАС' in profiles:
behavioral_prediction = "Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя."
elif age <= 4:
behavioral_prediction = "Минимальное движение, вероятно находится близко к точке потери."
elif age <= 12:
behavioral_prediction = "Умеренное движение, может следовать по тропам или дорогам."
else:
behavioral_prediction = "Целенаправленное движение, возможен выход к населённым пунктам."
# Немедленные действия
immediate_actions = [
"Организовать поисковые группы",
"Проверить ближайшие водоёмы",
"Опросить свидетелей в районе последнего местоположения"
]
if 'РАС' in profiles:
immediate_actions.insert(0, "КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км")
if 'велосипед' in profiles:
immediate_actions.insert(0, "Расширить зону поиска до 10-15 км, проверить дорожные камеры")
summary = f"Случай классифицирован как {urgency} срочность. "
summary += f"Рекомендуемый радиус поиска: {search_radius_km} км. "
summary += f"Приоритет: {key_locations[0]}."
return AnalysisResult(
urgency=urgency,
primary_zones=primary_zones,
search_radius_km=search_radius_km,
key_locations=key_locations,
behavioral_prediction=behavioral_prediction,
immediate_actions=immediate_actions,
summary=summary,
fallback_used=True
)
+316
View File
@@ -0,0 +1,316 @@
"""
Distance calculation service based on search and rescue statistics.
Implements distance formulas and prior probabilities based on:
- PSO EXTREMUM data (400 cases, 2015)
- Age-based movement speeds
- Terrain and weather modifiers
"""
from typing import Dict
def calculate_max_distance(case_data: dict) -> float:
"""
Calculate maximum probable distance using the formula:
Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП
Where:
- Time: elapsed time in hours
- НормС: base speed by age (km/h)
- СП: terrain coefficient
- СКД: coefficient for diagnosis (not implemented yet)
- СУ: coefficient for urgency (not implemented yet)
- СУТ: fatigue coefficient (5% reduction per hour)
- ВВС: time of day coefficient
- ВП: weather coefficient
Args:
case_data: Dictionary with case information
- age: age in years
- elapsed_hours: time elapsed since last seen
- terrain_primary: terrain type
- time_of_day: time of day (день/ночь/сумерки)
- weather: weather conditions
Returns:
Maximum probable distance in kilometers
"""
# Extract data
age = case_data.get('age', 10)
elapsed_hours = case_data.get('elapsed_hours', 1.0)
terrain = case_data.get('terrain_primary', 'лес')
time_of_day = case_data.get('time_of_day', 'день')
weather = case_data.get('weather', 'нет')
# НормС - Base speed by age (km/h)
base_speed = get_base_speed(age)
# СП - Terrain coefficient
terrain_coef = get_terrain_coefficient(terrain)
# СКД - Diagnosis coefficient (placeholder, can be expanded)
diagnosis_coef = 1.0
# СУ - Urgency coefficient (placeholder, can be expanded)
urgency_coef = 1.0
# СУТ - Fatigue coefficient (5% reduction per hour)
fatigue_coef = max(0.3, 1.0 - (0.05 * elapsed_hours))
# ВВС - Time of day coefficient
time_coef = get_time_of_day_coefficient(time_of_day)
# ВП - Weather coefficient
weather_coef = get_weather_coefficient(weather)
# Calculate distance
distance = (
elapsed_hours *
base_speed *
terrain_coef *
diagnosis_coef *
urgency_coef *
fatigue_coef *
time_coef *
weather_coef
)
return round(distance, 2)
def get_base_speed(age: int) -> float:
"""
Get base movement speed by age (НормС).
Args:
age: Age in years
Returns:
Base speed in km/h
"""
# Скорость смещения потерявшегося ребёнка (не скорость ходьбы)
# ПСО ЭКСТРЕМУМ: 94% найдены в пределах 3 км
if age <= 2:
return 0.3
elif age <= 5:
return 0.7
elif age <= 8:
return 1.2
elif age <= 12:
return 1.5
elif age <= 15:
return 2.0
elif age <= 17:
return 2.5
elif age <= 64:
return 2.5
else: # 65+
return 1.5
def get_terrain_coefficient(terrain: str) -> float:
"""
Get terrain movement coefficient (СП).
Args:
terrain: Terrain type
Returns:
Terrain coefficient (0.0 - 1.0)
"""
terrain_lower = terrain.lower()
terrain_map = {
'лесная дорога': 0.8,
'сложный лес': 0.25,
'густой лес': 0.25,
'простой лес': 0.5,
'лес': 0.5,
'дорога': 0.8,
'тропа': 0.8,
'болото': 0.2,
'поле': 0.9,
'луг': 0.9,
'город': 1.0,
'населённый пункт': 1.0,
'горы': 0.3,
'овраг': 0.3
}
for key, value in terrain_map.items():
if key in terrain_lower:
return value
# Default for unknown terrain
return 0.5
def get_time_of_day_coefficient(time_of_day: str) -> float:
"""
Get time of day movement coefficient (ВВС).
Args:
time_of_day: Time of day
Returns:
Time coefficient (0.0 - 1.0)
"""
time_lower = time_of_day.lower()
if 'ночь' in time_lower:
return 0.5
elif 'сумерки' in time_lower or 'вечер' in time_lower:
return 0.5
else: # день
return 1.0
def get_weather_coefficient(weather: str) -> float:
"""
Get weather movement coefficient (ВП).
Args:
weather: Weather conditions
Returns:
Weather coefficient (0.0 - 1.0)
"""
weather_lower = weather.lower()
if 'ливень' in weather_lower or 'сильный дождь' in weather_lower:
return 0.6
elif 'дождь' in weather_lower:
return 0.8
elif 'туман' in weather_lower:
return 0.7
elif 'снег' in weather_lower or 'метель' in weather_lower:
return 0.6
elif 'жара' in weather_lower:
return 0.8
else: # нет / ясно
return 1.0
def get_distance_priors(age_years: int) -> Dict[str, float]:
"""
Get prior probabilities for distance zones based on age.
Based on PSO EXTREMUM data (400 cases, 2015).
Args:
age_years: Age in years
Returns:
Dictionary with distance zone probabilities
"""
if age_years < 8:
# До 8 лет - дети младшего возраста
return {
'0_500m': 0.45,
'500_1500m': 0.35,
'1500_2500m': 0.15,
'2500_3500m': 0.04,
'3500_plus': 0.01
}
elif age_years <= 12:
# 8-12 лет - дети среднего возраста
return {
'0_500m': 0.28,
'500_1500m': 0.25,
'1500_2500m': 0.22,
'2500_3500m': 0.19,
'3500_5000m': 0.03,
'5000_plus': 0.03
}
elif age_years <= 17:
# 13-17 лет - подростки
return {
'0_500m': 0.15,
'500_1500m': 0.20,
'1500_2500m': 0.25,
'2500_3500m': 0.20,
'3500_5000m': 0.12,
'5000_plus': 0.08
}
elif age_years <= 64:
# 18-64 года - взрослые
return {
'0_500m': 0.12,
'500_1500m': 0.18,
'1500_2500m': 0.22,
'2500_3500m': 0.20,
'3500_5000m': 0.15,
'5000_plus': 0.13
}
else:
# 65+ лет - пожилые
return {
'0_500m': 0.35,
'500_1500m': 0.30,
'1500_2500m': 0.20,
'2500_3500m': 0.10,
'3500_5000m': 0.03,
'5000_plus': 0.02
}
def get_distance_zone(distance_km: float) -> str:
"""
Get distance zone name for a given distance.
Args:
distance_km: Distance in kilometers
Returns:
Zone name
"""
if distance_km < 0.5:
return '0_500m'
elif distance_km < 1.5:
return '500_1500m'
elif distance_km < 2.5:
return '1500_2500m'
elif distance_km < 3.5:
return '2500_3500m'
elif distance_km < 5.0:
return '3500_5000m'
else:
return '5000_plus'
def get_distance_statistics(age_years: int, elapsed_hours: float, terrain: str) -> Dict:
"""
Get comprehensive distance statistics for a case.
Args:
age_years: Age in years
elapsed_hours: Time elapsed since last seen
terrain: Terrain type
Returns:
Dictionary with distance statistics
"""
# Calculate max distance
case_data = {
'age': age_years,
'elapsed_hours': elapsed_hours,
'terrain_primary': terrain,
'time_of_day': 'день',
'weather': 'нет'
}
max_distance = calculate_max_distance(case_data)
# Get priors
priors = get_distance_priors(age_years)
# Get current zone
current_zone = get_distance_zone(max_distance)
return {
'max_distance_km': max_distance,
'current_zone': current_zone,
'zone_probability': priors.get(current_zone, 0.0),
'all_priors': priors,
'base_speed_kmh': get_base_speed(age_years),
'terrain_coefficient': get_terrain_coefficient(terrain)
}
+381
View File
@@ -0,0 +1,381 @@
"""
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 computed dynamically from max_distance_km
def _build_search_distances(max_distance_km: float) -> list:
max_m = int(max_distance_km * 1000)
raw = [int(max_m * 0.25), int(max_m * 0.50), int(max_m * 0.75), max_m]
clamped = [max(200, min(5000, d)) for d in raw]
return sorted(set(clamped))
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, max_distance_km: float = 3.0) -> 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 _build_search_distances(max_distance_km):
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
+377
View File
@@ -0,0 +1,377 @@
"""
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
+237
View File
@@ -0,0 +1,237 @@
"""
Сервис определения психотипа пропавшего ребёнка.
Маппинг согласно §6 контекста ВЕКТОР (Шаг 2б).
Основан на методике Рындиной О.Г., Ивановой О.Ю. (Чебоксары, 2015)
8 психотипов по Грановской–Никольской (Кеттелл).
"""
from typing import Dict, List, Literal
PsychotypeStr = Literal[
'dominant',
'harmonic',
'anxious',
'introvert_passive',
'introvert_active'
]
def detect_psychotype(answers: Dict[str, str]) -> PsychotypeStr:
"""
Определяет психотип на основе 4 вопросов родителям (§6 контекста).
Args:
answers: Словарь с ответами:
- unfamiliar_behavior: 'explore' | 'wait' | 'freeze' | 'panic'
- stress_reaction: 'angry' | 'cry' | 'calm'
- leadership: 'always_leader' | 'sometimes' | 'always_follower'
- risk_taking: 'very' | 'sometimes' | 'no_cautious'
Returns:
Определенный психотип
Маппинг из §6:
- активно + лидер + рискует → dominant
- спокойно + лидер + осторожный → harmonic
- плачет + ведомый + осторожный → anxious
- замирает + ведомый + осторожный → introvert_passive
- активно/паникует + иногда → introvert_active
"""
unfamiliar = answers.get('unfamiliar_behavior', '').lower()
stress = answers.get('stress_reaction', '').lower()
leadership = answers.get('leadership', '').lower()
risk = answers.get('risk_taking', '').lower()
# Маппинг согласно §6 контекста
# активно + лидер + рискует → dominant
if unfamiliar == 'explore' and leadership == 'always_leader' and risk == 'very':
return 'dominant'
# спокойно + лидер + осторожный → harmonic
if stress == 'calm' and leadership == 'always_leader' and risk == 'no_cautious':
return 'harmonic'
# плачет + ведомый + осторожный → anxious
if stress == 'cry' and leadership == 'always_follower' and risk == 'no_cautious':
return 'anxious'
# замирает + ведомый + осторожный → introvert_passive
if unfamiliar == 'freeze' and leadership == 'always_follower' and risk == 'no_cautious':
return 'introvert_passive'
# активно/паникует + иногда → introvert_active
if (unfamiliar in ['explore', 'panic']) and leadership == 'sometimes':
return 'introvert_active'
# Дополнительные правила для неоднозначных случаев
# Лидер + рискует → скорее dominant
if leadership == 'always_leader' and risk in ['very', 'sometimes']:
return 'dominant'
# Ведомый + осторожный + плачет/замирает → anxious или introvert_passive
if leadership == 'always_follower' and risk == 'no_cautious':
if stress == 'cry':
return 'anxious'
elif unfamiliar == 'freeze':
return 'introvert_passive'
# Активно исследует + иногда лидер → introvert_active
if unfamiliar == 'explore' and leadership == 'sometimes':
return 'introvert_active'
# Дефолт: harmonic (сбалансированный)
return 'harmonic'
def get_psychotype_modifiers(psychotype: PsychotypeStr) -> Dict:
"""
Возвращает модификаторы вероятности для зон поиска и модель движения.
Args:
psychotype: Определенный психотип
Returns:
Словарь с модификаторами зон и моделью движения
"""
modifiers = {
'dominant': {
'zone_0_500': 0.7,
'zone_500_1500': 1.2,
'zone_1500_2500': 1.4,
'zone_2500plus': 1.1,
'movement_model': 'chaotic_far',
'description': 'Доминантный: активное движение, большие расстояния, хаотичное поведение'
},
'harmonic': {
'zone_0_500': 0.8,
'zone_500_1500': 1.0,
'zone_1500_2500': 1.1,
'zone_2500plus': 0.9,
'movement_model': 'linear_landmark',
'description': 'Гармоничный: рациональное движение по ориентирам, средние расстояния'
},
'anxious': {
'zone_0_500': 1.4,
'zone_500_1500': 1.1,
'zone_1500_2500': 0.5,
'zone_2500plus': 0.3,
'movement_model': 'stay',
'description': 'Тревожный: минимальное движение, остается близко к точке потери'
},
'introvert_passive': {
'zone_0_500': 1.3,
'zone_500_1500': 0.9,
'zone_1500_2500': 0.6,
'zone_2500plus': 0.2,
'movement_model': 'stay_hidden',
'description': 'Интроверт пассивный: прячется, минимальное движение, близко к точке потери'
},
'introvert_active': {
'zone_0_500': 0.8,
'zone_500_1500': 1.1,
'zone_1500_2500': 1.2,
'zone_2500plus': 0.9,
'movement_model': 'linear_landmark',
'description': 'Интроверт активный: целенаправленное движение по ориентирам, средние расстояния'
}
}
return modifiers.get(psychotype, modifiers['harmonic'])
def get_search_recommendations(psychotype: PsychotypeStr) -> Dict[str, str]:
"""
Возвращает рекомендации по тактике поиска для данного психотипа.
Args:
psychotype: Определенный психотип
Returns:
Словарь с рекомендациями по поиску
"""
recommendations = {
'dominant': {
'priority_zones': 'Средние и дальние зоны (500-2500м)',
'search_pattern': 'Широкий охват, проверка нелинейных маршрутов',
'key_locations': 'Возвышенности, открытые пространства, необычные объекты',
'communication': 'Громкие сигналы, яркие маркеры'
},
'harmonic': {
'priority_zones': 'Все зоны равномерно, акцент на 500-1500м',
'search_pattern': 'Систематический поиск вдоль троп и ориентиров',
'key_locations': 'Тропы, дороги, видимые ориентиры, укрытия',
'communication': 'Стандартные сигналы, информационные знаки'
},
'anxious': {
'priority_zones': 'Ближняя зона (0-500м) - критически важна',
'search_pattern': 'Тщательный осмотр ближайшей территории',
'key_locations': 'Укрытия, углубления, густая растительность рядом с точкой потери',
'communication': 'Спокойные голосовые сигналы, избегать резких звуков'
},
'introvert_passive': {
'priority_zones': 'Ближняя зона (0-500м), укрытия',
'search_pattern': 'Детальный осмотр укрытий и труднодоступных мест',
'key_locations': 'Заросли, ямы, под деревьями, за камнями',
'communication': 'Мягкие голосовые сигналы, визуальный контакт важнее звука'
},
'introvert_active': {
'priority_zones': 'Средние зоны (500-2500м) вдоль линейных ориентиров',
'search_pattern': 'Поиск вдоль троп, ручьев, границ леса',
'key_locations': 'Линейные ориентиры, перекрестки троп, характерные объекты',
'communication': 'Стандартные сигналы вдоль вероятных маршрутов'
}
}
return recommendations.get(psychotype, recommendations['harmonic'])
def get_psychotype_questions() -> List[Dict]:
"""
Возвращает 4 вопроса для определения психотипа (§6 контекста).
Returns:
Список вопросов с вариантами ответов
"""
return [
{
'id': 'unfamiliar_behavior',
'question': 'Как ведёт себя в незнакомой обстановке?',
'options': [
{'value': 'explore', 'label': 'Активно исследует'},
{'value': 'wait', 'label': 'Ждёт и наблюдает'},
{'value': 'freeze', 'label': 'Замирает'},
{'value': 'panic', 'label': 'Паникует'}
]
},
{
'id': 'stress_reaction',
'question': 'Реакция на стресс и неудачи?',
'options': [
{'value': 'angry', 'label': 'Злится, кричит'},
{'value': 'cry', 'label': 'Плачет, замыкается'},
{'value': 'calm', 'label': 'Спокойно ищет выход'}
]
},
{
'id': 'leadership',
'question': 'Лидер или ведомый?',
'options': [
{'value': 'always_leader', 'label': 'Всегда лидер'},
{'value': 'sometimes', 'label': 'Иногда'},
{'value': 'always_follower', 'label': 'Всегда ведомый'}
]
},
{
'id': 'risk_taking',
'question': 'Любит рисковать?',
'options': [
{'value': 'very', 'label': 'Очень'},
{'value': 'sometimes', 'label': 'Иногда'},
{'value': 'no_cautious', 'label': 'Нет, осторожный'}
]
}
]
+403
View File
@@ -0,0 +1,403 @@
"""
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
Реализация согласно §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, max_distance_km: float = 2.0) -> 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 = max_distance_km * 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, max_distance_km: float = 2.0) -> 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, max_distance_km=max_distance_km)
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
}
+403
View File
@@ -0,0 +1,403 @@
"""
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
Реализация согласно §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
}
+588
View File
@@ -0,0 +1,588 @@
"""
Statistics service for case analysis and dashboard aggregates.
Provides statistical recommendations based on historical data:
- Similar cases filtering (age ±2 years, season, terrain)
- Median distance, top directions, survival rate
- Dashboard aggregates
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_, text
from models import Case
class DirectionFrequency(BaseModel):
"""Direction frequency statistics"""
direction: str
count: int
percentage: float
class StatisticalRecommendation(BaseModel):
"""Statistical recommendation based on historical data"""
median_distance_km: float
top_directions: List[DirectionFrequency]
top_location_types: List[str]
survival_rate: float
sample_size: int
filters_used: Dict[str, Any]
class DashboardStats(BaseModel):
"""Dashboard aggregate statistics"""
total_cases: int
active_cases: int
closed_cases: int
by_gender: Dict[str, int]
by_age_group: Dict[str, int]
by_psychotype: Dict[str, int]
by_diagnosis: Dict[str, int]
by_season: Dict[str, int]
avg_distance_km: Optional[float]
avg_search_duration_hours: Optional[float]
survival_rate: float
def get_statistical_recommendation(
case_data: dict,
db: Session,
min_sample_size: int = 5
) -> StatisticalRecommendation:
"""
Получает статистические рекомендации на основе похожих исторических случаев.
Фильтры (в порядке приоритета):
1. Возраст ±2 года + сезон + terrain
2. Возраст ±2 года + сезон (если < 5 случаев)
3. Возраст ±2 года (если < 5 случаев)
4. Все случаи (если < 5 случаев)
Args:
case_data: Словарь с данными случая
- age: возраст (обязательно)
- season: сезон (опционально)
- terrain_primary: тип местности (опционально)
db: SQLAlchemy Session
min_sample_size: Минимальный размер выборки (по умолчанию 5)
Returns:
StatisticalRecommendation: Статистические рекомендации
"""
age = case_data.get('age')
season = case_data.get('season')
terrain = case_data.get('terrain_primary')
if not age:
raise ValueError("Age is required for statistical recommendation")
# Попытка 1: Возраст ±2 года + сезон + terrain
filters_used = {'age_range': f"{age-2} to {age+2}"}
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
if season:
query = query.filter(Case.season == season)
filters_used['season'] = season
if terrain and season:
query = query.filter(Case.terrain.any(terrain))
filters_used['terrain'] = terrain
cases = query.all()
sample_size = len(cases)
# Попытка 2: Убираем terrain, если мало данных
if sample_size < min_sample_size and terrain:
filters_used.pop('terrain', None)
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
if season:
query = query.filter(Case.season == season)
cases = query.all()
sample_size = len(cases)
# Попытка 3: Убираем season, если мало данных
if sample_size < min_sample_size and season:
filters_used.pop('season', None)
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
cases = query.all()
sample_size = len(cases)
# Попытка 4: Все случаи с найденными детьми
if sample_size < min_sample_size:
filters_used = {"age_range": "all"}
query = db.query(Case).filter(
Case.found_distance_km.isnot(None)
)
cases = query.all()
sample_size = len(cases)
# Если данных нет совсем, возвращаем дефолтные значения
if sample_size == 0:
return StatisticalRecommendation(
median_distance_km=2.0,
top_directions=[
DirectionFrequency(direction="N", count=0, percentage=0.0),
DirectionFrequency(direction="S", count=0, percentage=0.0),
DirectionFrequency(direction="E", count=0, percentage=0.0)
],
top_location_types=["водоёмы", "дороги", "постройки"],
survival_rate=0.0,
sample_size=0,
filters_used=filters_used
)
# Вычисляем медианное расстояние
distances = sorted([c.found_distance_km for c in cases if c.found_distance_km])
median_distance = distances[len(distances) // 2] if distances else 2.0
# Подсчитываем частоту направлений
direction_counts = {}
for case in cases:
if case.found_direction:
direction = case.found_direction
direction_counts[direction] = direction_counts.get(direction, 0) + 1
# Топ-3 направления
sorted_directions = sorted(
direction_counts.items(),
key=lambda x: x[1],
reverse=True
)[:3]
top_directions = [
DirectionFrequency(
direction=direction,
count=count,
percentage=round(count / sample_size * 100, 1)
)
for direction, count in sorted_directions
]
# Если направлений меньше 3, добавляем пустые
while len(top_directions) < 3:
top_directions.append(
DirectionFrequency(direction="unknown", count=0, percentage=0.0)
)
# Топ типов локаций
location_counts = {}
for case in cases:
if case.found_location_type:
location_type = case.found_location_type
location_counts[location_type] = location_counts.get(location_type, 0) + 1
top_location_types = [
loc for loc, _ in sorted(
location_counts.items(),
key=lambda x: x[1],
reverse=True
)[:5]
]
if not top_location_types:
top_location_types = ["водоёмы", "дороги", "лес"]
# Процент выживаемости
survived_count = sum(1 for case in cases if case.found_alive is True)
survival_rate = round(survived_count / sample_size * 100, 1) if sample_size > 0 else 0.0
return StatisticalRecommendation(
median_distance_km=round(median_distance, 2),
top_directions=top_directions,
top_location_types=top_location_types,
survival_rate=survival_rate,
sample_size=sample_size,
filters_used=filters_used
)
def get_dashboard_stats(db: Session) -> DashboardStats:
"""
Получает агрегированную статистику для дашборда.
Args:
db: SQLAlchemy Session
Returns:
DashboardStats: Агрегированная статистика
"""
cases = db.query(Case).all()
total = len(cases)
active = sum(1 for c in cases if c.status == 'active')
closed = sum(1 for c in cases if c.status == 'closed')
by_gender = {}
by_age_group = {}
by_psychotype = {}
by_diagnosis = {}
by_season = {}
distances = []
durations = []
survived = 0
total_with_outcome = 0
for case in cases:
# Gender
gender = case.gender or 'unknown'
by_gender[gender] = by_gender.get(gender, 0) + 1
# Age groups
age = case.age_years
if age < 4:
age_group = '0-3'
elif age < 8:
age_group = '4-7'
elif age < 12:
age_group = '8-11'
elif age < 15:
age_group = '12-14'
elif age < 18:
age_group = '15-17'
else:
age_group = '18+'
by_age_group[age_group] = by_age_group.get(age_group, 0) + 1
# Psychotype
if case.psychotype:
by_psychotype[case.psychotype] = by_psychotype.get(case.psychotype, 0) + 1
# Diagnosis
if case.diagnosis_type:
for diag in case.diagnosis_type:
by_diagnosis[diag] = by_diagnosis.get(diag, 0) + 1
# Season
if case.season:
by_season[case.season] = by_season.get(case.season, 0) + 1
# Distance
if case.found_distance_km:
distances.append(case.found_distance_km)
# Duration
if case.search_duration_hours:
durations.append(case.search_duration_hours)
# Survival rate
if case.found_alive is not None:
total_with_outcome += 1
if case.found_alive:
survived += 1
avg_distance = round(sum(distances) / len(distances), 2) if distances else None
avg_duration = round(sum(durations) / len(durations), 2) if durations else None
survival_rate = round(survived / total_with_outcome * 100, 1) if total_with_outcome > 0 else 0.0
return DashboardStats(
total_cases=total,
active_cases=active,
closed_cases=closed,
by_gender=by_gender,
by_age_group=by_age_group,
by_psychotype=by_psychotype,
by_diagnosis=by_diagnosis,
by_season=by_season,
avg_distance_km=avg_distance,
avg_search_duration_hours=avg_duration,
survival_rate=survival_rate
)
def get_heatmap_data(db: Session, filters: Optional[Dict] = None) -> List[Dict]:
"""
Получает данные для тепловой карты находок.
Args:
db: SQLAlchemy Session
filters: Опциональные фильтры (age_min, age_max, season, outcome)
Returns:
List[Dict]: Список точек с координатами и интенсивностью
"""
query = db.query(Case).filter(
Case.found_lat.isnot(None),
Case.found_lon.isnot(None)
)
if filters:
if 'age_min' in filters:
query = query.filter(Case.age_years >= filters['age_min'])
if 'age_max' in filters:
query = query.filter(Case.age_years <= filters['age_max'])
if 'season' in filters:
query = query.filter(Case.season == filters['season'])
if 'outcome' in filters:
if filters['outcome'] == 'alive':
query = query.filter(Case.found_alive == True)
elif filters['outcome'] == 'deceased':
query = query.filter(Case.found_alive == False)
cases = query.all()
points = []
for case in cases:
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': 1.0,
'case_id': str(case.id),
'distance_km': case.found_distance_km,
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
})
return points
"""
Extended heatmap functions with caching and multiple map types.
"""
from functools import lru_cache
from typing import Dict, List, Optional, Literal
from datetime import datetime
from sqlalchemy.orm import Session
from models import Case
HeatmapType = Literal['all', 'age', 'season', 'outcome']
def get_heatmap_data_cached(
db: Session,
map_type: HeatmapType = 'all',
age_group: Optional[str] = None,
season: Optional[str] = None,
year_from: Optional[int] = None,
year_to: Optional[int] = None,
outcome: Optional[str] = None
) -> Dict:
"""
Получает данные для тепловой карты с кэшированием.
Args:
db: SQLAlchemy Session
map_type: Тип карты (all, age, season, outcome)
age_group: Возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17)
season: Сезон (зима, весна, лето, осень)
year_from: Год начала периода
year_to: Год окончания периода
outcome: Исход (alive, deceased)
Returns:
Dict: {points: List[Dict], total: int, filters_applied: Dict}
"""
# Базовый запрос
query = db.query(Case).filter(
Case.found_lat.isnot(None),
Case.found_lon.isnot(None)
)
filters_applied = {'map_type': map_type}
# Фильтр по возрастной группе
if age_group:
age_ranges = {
'0-3': (0, 3),
'4-7': (4, 7),
'8-11': (8, 11),
'12-14': (12, 14),
'15-17': (15, 17),
'18+': (18, 100)
}
if age_group in age_ranges:
min_age, max_age = age_ranges[age_group]
query = query.filter(Case.age_years.between(min_age, max_age))
filters_applied['age_group'] = age_group
# Фильтр по сезону
if season:
query = query.filter(Case.season == season)
filters_applied['season'] = season
# Фильтр по периоду (годы)
if year_from:
query = query.filter(
db.func.extract('year', Case.created_at) >= year_from
)
filters_applied['year_from'] = year_from
if year_to:
query = query.filter(
db.func.extract('year', Case.created_at) <= year_to
)
filters_applied['year_to'] = year_to
# Фильтр по исходу
if outcome:
if outcome == 'alive':
query = query.filter(Case.found_alive == True)
elif outcome == 'deceased':
query = query.filter(Case.found_alive == False)
filters_applied['outcome'] = outcome
cases = query.all()
# Формируем точки в зависимости от типа карты
points = []
if map_type == 'all':
# Все точки с одинаковой интенсивностью
for case in cases:
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': 1.0,
'case_id': str(case.id),
'metadata': {
'age': case.age_years,
'season': case.season,
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
}
})
elif map_type == 'age':
# Интенсивность зависит от возраста (младше = выше интенсивность)
for case in cases:
# Младшие дети = выше интенсивность (более критично)
intensity = max(0.3, 1.0 - (case.age_years / 18.0))
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': round(intensity, 2),
'case_id': str(case.id),
'metadata': {
'age': case.age_years,
'age_group': _get_age_group(case.age_years)
}
})
elif map_type == 'season':
# Интенсивность зависит от сезона (зима = выше)
season_intensity = {
'зима': 1.0,
'осень': 0.8,
'весна': 0.6,
'лето': 0.4
}
for case in cases:
intensity = season_intensity.get(case.season, 0.5)
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': intensity,
'case_id': str(case.id),
'metadata': {
'season': case.season
}
})
elif map_type == 'outcome':
# Интенсивность зависит от исхода
for case in cases:
if case.found_alive is True:
intensity = 0.5 # Зеленый (выжил)
elif case.found_alive is False:
intensity = 1.0 # Красный (погиб)
else:
intensity = 0.3 # Серый (неизвестно)
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': intensity,
'case_id': str(case.id),
'metadata': {
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown',
'distance_km': case.found_distance_km
}
})
return {
'points': points,
'total': len(points),
'filters_applied': filters_applied
}
def _get_age_group(age: int) -> str:
"""Определяет возрастную группу"""
if age <= 3:
return '0-3'
elif age <= 7:
return '4-7'
elif age <= 11:
return '8-11'
elif age <= 14:
return '12-14'
elif age <= 17:
return '15-17'
else:
return '18+'
# Кэшированная версия для быстрого доступа
# Кэш на 1 час (3600 секунд), максимум 128 комбинаций параметров
@lru_cache(maxsize=128)
def _get_heatmap_cache_key(
map_type: str,
age_group: Optional[str],
season: Optional[str],
year_from: Optional[int],
year_to: Optional[int],
outcome: Optional[str],
timestamp_hour: int # Меняется каждый час
) -> str:
"""Генерирует ключ кэша для heatmap"""
return f"{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{timestamp_hour}"
def get_heatmap_with_cache(
db: Session,
map_type: HeatmapType = 'all',
age_group: Optional[str] = None,
season: Optional[str] = None,
year_from: Optional[int] = None,
year_to: Optional[int] = None,
outcome: Optional[str] = None
) -> Dict:
"""
Обертка с кэшированием на 1 час.
Кэш инвалидируется каждый час автоматически через timestamp_hour.
"""
# Текущий час для кэша (меняется каждый час)
current_hour = datetime.utcnow().hour
# Генерируем ключ кэша
cache_key = _get_heatmap_cache_key(
map_type,
age_group,
season,
year_from,
year_to,
outcome,
current_hour
)
# Получаем данные (кэш работает через lru_cache на уровне ключа)
return get_heatmap_data_cached(
db,
map_type,
age_group,
season,
year_from,
year_to,
outcome
)
View File
+371
View File
@@ -0,0 +1,371 @@
"""
Tests for claude_service.py
Tests the Claude AI analysis with fallback to scoring service.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import os
from services.claude_service import (
analyze_case,
analyze_with_fallback,
AnalysisResult,
PrimaryZone
)
class TestAnalysisResult:
"""Test AnalysisResult model."""
def test_analysis_result_structure(self):
"""Test AnalysisResult has correct structure."""
result = AnalysisResult(
urgency="высокая",
primary_zones=[
PrimaryZone(
priority=1,
name="Зона А",
direction="N",
distance=1.0,
reason="Тест"
)
],
search_radius_km=5.0,
key_locations=["водоёмы"],
behavioral_prediction="Тест",
immediate_actions=["Действие 1"],
summary="Тест",
fallback_used=False
)
assert result.urgency == "высокая"
assert len(result.primary_zones) == 1
assert result.search_radius_km == 5.0
assert result.fallback_used is False
class TestFallbackAnalysis:
"""Test fallback analysis without Claude API."""
@pytest.mark.asyncio
async def test_fallback_young_child(self):
"""Test fallback for young child (critical urgency)."""
case_data = {
'age': 3,
'gender': 'мужской',
'terrain': 'лес',
'weather': 'ясно'
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
assert result.fallback_used is True
assert len(result.primary_zones) >= 2
assert "водоёмы" in result.key_locations
@pytest.mark.asyncio
async def test_fallback_with_ras_profile(self):
"""Test fallback with РАС profile."""
case_data = {
'age': 8,
'gender': 'мужской',
'profiles': ['РАС']
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
assert "РАС" in result.behavioral_prediction or "водоём" in result.behavioral_prediction
assert any("водоём" in action.lower() for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_fallback_with_bicycle(self):
"""Test fallback with bicycle profile."""
case_data = {
'age': 12,
'gender': 'мужской',
'profiles': ['велосипед']
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "высокая"
assert any("10-15 км" in action or "камер" in action for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_fallback_teenager(self):
"""Test fallback for teenager."""
case_data = {
'age': 15,
'gender': 'мужской',
'terrain': 'лес'
}
result = await analyze_with_fallback(case_data)
assert result.urgency in ["средняя", "высокая"]
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
@pytest.mark.asyncio
async def test_fallback_with_coordinates(self):
"""Test fallback with coordinates (geo service integration)."""
case_data = {
'age': 10,
'lat': 53.9,
'lon': 27.5,
'terrain': 'лес'
}
with patch('services.claude_service.build_search_zones', new_callable=AsyncMock) as mock_zones:
# Mock zones
from services.geo_service import Zone
mock_zones.return_value = [
Zone(
direction="N",
distance_km=0.5,
forest_pct=60.0,
road_density=1.0,
water_distance_km=2.0,
settlement_distance_km=5.0
),
Zone(
direction="E",
distance_km=1.0,
forest_pct=40.0,
road_density=2.0,
water_distance_km=1.0,
settlement_distance_km=3.0
)
]
result = await analyze_with_fallback(case_data)
assert result.fallback_used is True
assert len(result.primary_zones) > 0
mock_zones.assert_called_once()
@pytest.mark.asyncio
async def test_fallback_without_coordinates(self):
"""Test fallback without coordinates (basic zones)."""
case_data = {
'age': 10,
'terrain': 'лес'
}
result = await analyze_with_fallback(case_data)
assert result.fallback_used is True
assert len(result.primary_zones) >= 2
assert result.primary_zones[0].priority == 1
class TestAnalyzeCase:
"""Test main analyze_case function."""
@pytest.mark.asyncio
async def test_analyze_without_api_key(self):
"""Test analyze falls back when no API key."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес'
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}, clear=True):
result = await analyze_case(case_data)
assert result.fallback_used is True
assert isinstance(result, AnalysisResult)
@pytest.mark.asyncio
async def test_analyze_with_api_error(self):
"""Test analyze falls back on API error."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес'
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Server error"
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
result = await analyze_case(case_data)
# Should fallback
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_analyze_with_successful_api(self):
"""Test analyze with successful Claude API response."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес',
'weather': 'дождь'
}
mock_api_response = {
"urgency": "высокая",
"primary_zones": [
{
"priority": 1,
"name": "Лесной массив север",
"direction": "N",
"distance": 1.5,
"reason": "Наиболее вероятное направление"
}
],
"search_radius_km": 5.0,
"key_locations": ["водоёмы", "дороги"],
"behavioral_prediction": "Ребёнок может двигаться по тропам",
"immediate_actions": ["Организовать поиск", "Проверить водоёмы"],
"summary": "Случай высокой срочности"
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
import json
json_text = json.dumps(mock_api_response, ensure_ascii=False)
mock_response.json.return_value = {
"content": [
{
"text": f"```json\n{json_text}\n```"
}
]
}
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
result = await analyze_case(case_data)
assert result.fallback_used is False
assert result.urgency == "высокая"
class TestUrgencyClassification:
"""Test urgency classification logic."""
@pytest.mark.asyncio
async def test_critical_urgency_young_child(self):
"""Test critical urgency for very young children."""
case_data = {'age': 2}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
@pytest.mark.asyncio
async def test_critical_urgency_epilepsy(self):
"""Test critical urgency for epilepsy."""
case_data = {'age': 10, 'profiles': ['эпилепсия']}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
@pytest.mark.asyncio
async def test_high_urgency_bicycle(self):
"""Test high urgency for bicycle."""
case_data = {'age': 12, 'profiles': ['велосипед']}
result = await analyze_with_fallback(case_data)
assert result.urgency == "высокая"
@pytest.mark.asyncio
async def test_medium_urgency_preteen(self):
"""Test medium urgency for preteen."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert result.urgency == "средняя"
class TestKeyLocations:
"""Test key locations based on age."""
@pytest.mark.asyncio
async def test_young_child_locations(self):
"""Test key locations for young children."""
case_data = {'age': 5}
result = await analyze_with_fallback(case_data)
assert "водоёмы" in result.key_locations
assert "укрытия" in result.key_locations
@pytest.mark.asyncio
async def test_preteen_locations(self):
"""Test key locations for preteens."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert "водоёмы" in result.key_locations
assert "дороги" in result.key_locations or "тропы" in result.key_locations
@pytest.mark.asyncio
async def test_teenager_locations(self):
"""Test key locations for teenagers."""
case_data = {'age': 15}
result = await analyze_with_fallback(case_data)
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
class TestBehavioralPrediction:
"""Test behavioral prediction logic."""
@pytest.mark.asyncio
async def test_ras_prediction(self):
"""Test РАС behavioral prediction."""
case_data = {'age': 8, 'profiles': ['РАС']}
result = await analyze_with_fallback(case_data)
assert "водоём" in result.behavioral_prediction.lower() or "рас" in result.behavioral_prediction.lower()
@pytest.mark.asyncio
async def test_young_child_prediction(self):
"""Test young child behavioral prediction."""
case_data = {'age': 3}
result = await analyze_with_fallback(case_data)
assert "минимальное" in result.behavioral_prediction.lower() or "близко" in result.behavioral_prediction.lower()
@pytest.mark.asyncio
async def test_teenager_prediction(self):
"""Test teenager behavioral prediction."""
case_data = {'age': 15}
result = await analyze_with_fallback(case_data)
assert "целенаправленное" in result.behavioral_prediction.lower() or "населённ" in result.behavioral_prediction.lower()
class TestImmediateActions:
"""Test immediate actions generation."""
@pytest.mark.asyncio
async def test_basic_actions(self):
"""Test basic immediate actions are present."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert len(result.immediate_actions) >= 3
assert any("поиск" in action.lower() for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_ras_critical_action(self):
"""Test РАС critical action is first."""
case_data = {'age': 8, 'profiles': ['РАС']}
result = await analyze_with_fallback(case_data)
first_action = result.immediate_actions[0]
assert "водоём" in first_action.lower() or "критично" in first_action.lower()
@pytest.mark.asyncio
async def test_bicycle_action(self):
"""Test bicycle specific action."""
case_data = {'age': 12, 'profiles': ['велосипед']}
result = await analyze_with_fallback(case_data)
assert any("10-15" in action or "камер" in action for action in result.immediate_actions)
+341
View File
@@ -0,0 +1,341 @@
"""
Tests for distance_service.py
Tests the distance calculation formulas and prior probabilities
based on §9 ВЕКТОР-контекст.md specifications.
"""
import pytest
from services.distance_service import (
calculate_max_distance,
get_base_speed,
get_terrain_coefficient,
get_time_of_day_coefficient,
get_weather_coefficient,
get_distance_priors,
get_distance_zone,
get_distance_statistics
)
class TestCalculateMaxDistance:
"""Test the main distance calculation function."""
def test_boy_10_years_4_hours_forest_rain(self):
"""
Test case from requirements:
Мальчик 10 лет, 4 часа, лес, дождь → ~5 км
"""
case_data = {
'age': 10,
'elapsed_hours': 4.0,
'terrain_primary': 'лес',
'time_of_day': 'день',
'weather': 'дождь'
}
distance = calculate_max_distance(case_data)
# Expected calculation:
# Time = 4 hours
# НормС (age 10) = 4.0 km/h
# СП (лес) = 0.5
# СУТ (4 hours) = 1.0 - (0.05 * 4) = 0.8
# ВВС (день) = 1.0
# ВП (дождь) = 0.8
# Distance = 4 * 4.0 * 0.5 * 0.8 * 1.0 * 0.8 = 5.12 km
assert 4.5 <= distance <= 5.5, f"Expected ~5 km, got {distance} km"
assert distance == pytest.approx(5.12, rel=0.01)
def test_young_child_short_time(self):
"""Test for young child (3 years) with short elapsed time."""
case_data = {
'age': 3,
'elapsed_hours': 1.0,
'terrain_primary': 'лес',
'time_of_day': 'день',
'weather': 'нет'
}
distance = calculate_max_distance(case_data)
# Expected: 1 * 2.0 * 0.5 * 0.95 * 1.0 * 1.0 = 0.95 km
assert distance == pytest.approx(0.95, rel=0.01)
def test_teenager_long_time_road(self):
"""Test for teenager on road with longer elapsed time."""
case_data = {
'age': 15,
'elapsed_hours': 6.0,
'terrain_primary': 'дорога',
'time_of_day': 'день',
'weather': 'нет'
}
distance = calculate_max_distance(case_data)
# Expected: 6 * 5.0 * 0.8 * 0.7 * 1.0 * 1.0 = 16.8 km
assert distance == pytest.approx(16.8, rel=0.01)
def test_elderly_night_swamp(self):
"""Test for elderly person at night in swamp."""
case_data = {
'age': 70,
'elapsed_hours': 3.0,
'terrain_primary': 'болото',
'time_of_day': 'ночь',
'weather': 'туман'
}
distance = calculate_max_distance(case_data)
# Expected: 3 * 3.0 * 0.2 * 0.85 * 0.5 * 0.7 = 0.54 km
assert distance == pytest.approx(0.54, rel=0.01)
def test_adult_heavy_rain_field(self):
"""Test for adult in heavy rain on field."""
case_data = {
'age': 35,
'elapsed_hours': 2.0,
'terrain_primary': 'поле',
'time_of_day': 'день',
'weather': 'ливень'
}
distance = calculate_max_distance(case_data)
# Expected: 2 * 5.0 * 0.9 * 0.9 * 1.0 * 0.6 = 4.86 km
assert distance == pytest.approx(4.86, rel=0.01)
class TestBaseSpeed:
"""Test НормС (base speed) by age."""
def test_infant(self):
assert get_base_speed(1) == 1.0
assert get_base_speed(2) == 1.0
def test_preschool(self):
assert get_base_speed(3) == 2.0
assert get_base_speed(5) == 2.0
def test_young_child(self):
assert get_base_speed(6) == 3.0
assert get_base_speed(8) == 3.0
def test_preteen(self):
assert get_base_speed(10) == 4.0
assert get_base_speed(12) == 4.0
def test_teenager(self):
assert get_base_speed(13) == 5.0
assert get_base_speed(15) == 5.0
assert get_base_speed(16) == 5.5
assert get_base_speed(17) == 5.5
def test_adult(self):
assert get_base_speed(25) == 5.0
assert get_base_speed(50) == 5.0
assert get_base_speed(64) == 5.0
def test_elderly(self):
assert get_base_speed(65) == 3.0
assert get_base_speed(80) == 3.0
class TestTerrainCoefficient:
"""Test СП (terrain coefficient)."""
def test_road_terrain(self):
assert get_terrain_coefficient('дорога') == 0.8
assert get_terrain_coefficient('лесная дорога') == 0.8
assert get_terrain_coefficient('тропа') == 0.8
def test_forest_terrain(self):
assert get_terrain_coefficient('лес') == 0.5
assert get_terrain_coefficient('простой лес') == 0.5
assert get_terrain_coefficient('сложный лес') == 0.25
assert get_terrain_coefficient('густой лес') == 0.25
def test_open_terrain(self):
assert get_terrain_coefficient('поле') == 0.9
assert get_terrain_coefficient('луг') == 0.9
def test_difficult_terrain(self):
assert get_terrain_coefficient('болото') == 0.2
assert get_terrain_coefficient('горы') == 0.3
assert get_terrain_coefficient('овраг') == 0.3
def test_urban_terrain(self):
assert get_terrain_coefficient('город') == 1.0
assert get_terrain_coefficient('населённый пункт') == 1.0
def test_unknown_terrain(self):
assert get_terrain_coefficient('неизвестно') == 0.5
class TestTimeOfDayCoefficient:
"""Test ВВС (time of day coefficient)."""
def test_day(self):
assert get_time_of_day_coefficient('день') == 1.0
def test_night(self):
assert get_time_of_day_coefficient('ночь') == 0.5
def test_twilight(self):
assert get_time_of_day_coefficient('сумерки') == 0.5
assert get_time_of_day_coefficient('вечер') == 0.5
class TestWeatherCoefficient:
"""Test ВП (weather coefficient)."""
def test_clear_weather(self):
assert get_weather_coefficient('нет') == 1.0
assert get_weather_coefficient('ясно') == 1.0
def test_rain(self):
assert get_weather_coefficient('дождь') == 0.8
assert get_weather_coefficient('ливень') == 0.6
assert get_weather_coefficient('сильный дождь') == 0.6
def test_fog(self):
assert get_weather_coefficient('туман') == 0.7
def test_snow(self):
assert get_weather_coefficient('снег') == 0.6
assert get_weather_coefficient('метель') == 0.6
def test_heat(self):
assert get_weather_coefficient('жара') == 0.8
class TestDistancePriors:
"""Test get_distance_priors() - априорные вероятности зон."""
def test_young_child_priors(self):
"""Children under 8 stay close."""
priors = get_distance_priors(5)
assert priors['0_500m'] == 0.45
assert priors['500_1500m'] == 0.35
assert priors['1500_2500m'] == 0.15
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
def test_preteen_priors(self):
"""Children 8-12 have more even distribution."""
priors = get_distance_priors(10)
assert priors['0_500m'] == 0.28
assert priors['500_1500m'] == 0.25
assert priors['1500_2500m'] == 0.22
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
def test_teenager_priors(self):
"""Teenagers can go farther."""
priors = get_distance_priors(15)
assert priors['1500_2500m'] == 0.25
assert priors['5000_plus'] == 0.08
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
def test_adult_priors(self):
"""Adults have highest far-distance probability."""
priors = get_distance_priors(35)
assert priors['0_500m'] == 0.12
assert priors['5000_plus'] == 0.13
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
def test_elderly_priors(self):
"""Elderly stay closer like young children."""
priors = get_distance_priors(70)
assert priors['0_500m'] == 0.35
assert priors['5000_plus'] == 0.02
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
class TestDistanceZone:
"""Test get_distance_zone() classification."""
def test_zone_classification(self):
assert get_distance_zone(0.3) == '0_500m'
assert get_distance_zone(0.5) == '500_1500m'
assert get_distance_zone(1.0) == '500_1500m'
assert get_distance_zone(1.5) == '1500_2500m'
assert get_distance_zone(2.5) == '2500_3500m'
assert get_distance_zone(4.0) == '3500_5000m'
assert get_distance_zone(6.0) == '5000_plus'
class TestDistanceStatistics:
"""Test get_distance_statistics() comprehensive output."""
def test_statistics_structure(self):
stats = get_distance_statistics(
age_years=10,
elapsed_hours=4.0,
terrain='лес'
)
assert 'max_distance_km' in stats
assert 'current_zone' in stats
assert 'zone_probability' in stats
assert 'all_priors' in stats
assert 'base_speed_kmh' in stats
assert 'terrain_coefficient' in stats
def test_statistics_values(self):
stats = get_distance_statistics(
age_years=10,
elapsed_hours=4.0,
terrain='лес'
)
assert stats['base_speed_kmh'] == 4.0
assert stats['terrain_coefficient'] == 0.5
assert stats['max_distance_km'] > 0
assert 0 <= stats['zone_probability'] <= 1.0
class TestFatigueCoefficient:
"""Test СУТ (fatigue coefficient) behavior."""
def test_fatigue_progression(self):
"""Fatigue increases with time (5% per hour)."""
case_1h = {
'age': 30,
'elapsed_hours': 1.0,
'terrain_primary': 'поле',
'time_of_day': 'день',
'weather': 'нет'
}
dist_1h = calculate_max_distance(case_1h)
case_5h = case_1h.copy()
case_5h['elapsed_hours'] = 5.0
dist_5h = calculate_max_distance(case_5h)
case_10h = case_1h.copy()
case_10h['elapsed_hours'] = 10.0
dist_10h = calculate_max_distance(case_10h)
assert dist_5h < dist_1h * 5
assert dist_10h < dist_5h * 2
def test_fatigue_minimum(self):
"""Fatigue coefficient has minimum of 0.3."""
case_data = {
'age': 30,
'elapsed_hours': 20.0,
'terrain_primary': 'поле',
'time_of_day': 'день',
'weather': 'нет'
}
distance = calculate_max_distance(case_data)
# 20 * 5.0 * 0.9 * 0.3 * 1.0 * 1.0 = 27.0
assert distance == pytest.approx(27.0, rel=0.01)
+399
View File
@@ -0,0 +1,399 @@
"""
Tests for geo_service.py
Tests the geographic zone building and Overpass API integration.
"""
import pytest
import math
from unittest.mock import AsyncMock, patch, MagicMock
from pathlib import Path
from datetime import datetime, timedelta
import json
from services.geo_service import (
haversine,
get_sector_bounds,
get_cache_key,
get_cached_result,
save_to_cache,
calculate_road_length,
find_nearest_distance,
calculate_forest_coverage,
build_search_zones,
DIRECTIONS,
SEARCH_DISTANCES,
CACHE_DIR
)
class TestHaversine:
"""Test haversine distance calculation."""
def test_same_point(self):
"""Test distance between same point is zero."""
distance = haversine(53.9, 27.5, 53.9, 27.5)
assert distance == pytest.approx(0.0, abs=0.01)
def test_known_distance(self):
"""Test known distance between cities."""
# Minsk to Brest approximately 350 km
minsk_lat, minsk_lon = 53.9, 27.5
brest_lat, brest_lon = 52.1, 23.7
distance = haversine(minsk_lat, minsk_lon, brest_lat, brest_lon)
# Should be around 350 km
assert 300 < distance < 400
def test_short_distance(self):
"""Test short distance calculation."""
# 1 km north
lat1, lon1 = 53.9, 27.5
lat2 = lat1 + 0.009 # ~1 km
lon2 = lon1
distance = haversine(lat1, lon1, lat2, lon2)
assert distance == pytest.approx(1.0, abs=0.1)
class TestSectorBounds:
"""Test sector boundary calculations."""
def test_north_sector(self):
"""Test north sector bounds."""
lat, lon = 53.9, 27.5
bounds = get_sector_bounds(lat, lon, "N", 1000)
min_lat, min_lon, max_lat, max_lon = bounds
# North sector should extend north
assert max_lat > lat
assert isinstance(min_lat, float)
assert isinstance(max_lat, float)
def test_all_directions(self):
"""Test all 8 directions return valid bounds."""
lat, lon = 53.9, 27.5
for direction in DIRECTIONS:
bounds = get_sector_bounds(lat, lon, direction, 1000)
min_lat, min_lon, max_lat, max_lon = bounds
assert min_lat < max_lat
assert min_lon < max_lon
assert all(isinstance(x, float) for x in bounds)
def test_different_radii(self):
"""Test different radii produce different bounds."""
lat, lon = 53.9, 27.5
bounds_500 = get_sector_bounds(lat, lon, "N", 500)
bounds_5000 = get_sector_bounds(lat, lon, "N", 5000)
# Larger radius should have larger bounds
assert (bounds_5000[2] - bounds_5000[0]) > (bounds_500[2] - bounds_500[0])
class TestCaching:
"""Test caching functionality."""
def test_cache_key_generation(self):
"""Test cache key is consistent."""
query = "test query"
key1 = get_cache_key(query)
key2 = get_cache_key(query)
assert key1 == key2
assert len(key1) == 32 # MD5 hash length
def test_cache_key_different_queries(self):
"""Test different queries produce different keys."""
key1 = get_cache_key("query 1")
key2 = get_cache_key("query 2")
assert key1 != key2
def test_save_and_get_cache(self):
"""Test saving and retrieving from cache."""
cache_key = "test_key_123"
test_data = {'elements': [{'id': 1, 'type': 'node'}]}
# Save to cache
save_to_cache(cache_key, test_data)
# Retrieve from cache
cached = get_cached_result(cache_key)
assert cached is not None
assert cached == test_data
# Cleanup
cache_file = CACHE_DIR / f"{cache_key}.json"
if cache_file.exists():
cache_file.unlink()
def test_cache_expiration(self):
"""Test cache expires after TTL."""
cache_key = "test_key_expired"
test_data = {'elements': []}
# Save to cache with old timestamp
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
old_time = datetime.now() - timedelta(hours=25)
with open(cache_file, 'w') as f:
json.dump({
'timestamp': old_time.isoformat(),
'data': test_data
}, f)
# Should return None (expired)
cached = get_cached_result(cache_key)
assert cached is None
# Cleanup
if cache_file.exists():
cache_file.unlink()
def test_cache_not_found(self):
"""Test cache returns None for non-existent key."""
cached = get_cached_result("nonexistent_key_xyz")
assert cached is None
class TestRoadLength:
"""Test road length calculation."""
def test_empty_elements(self):
"""Test empty elements returns zero."""
length = calculate_road_length([])
assert length == 0.0
def test_single_way(self):
"""Test single way calculation."""
elements = [
{
'type': 'way',
'geometry': [
{'lat': 53.9, 'lon': 27.5},
{'lat': 53.91, 'lon': 27.5}
]
}
]
length = calculate_road_length(elements)
# Should be approximately 1.1 km
assert 0.5 < length < 2.0
def test_multiple_ways(self):
"""Test multiple ways are summed."""
elements = [
{
'type': 'way',
'geometry': [
{'lat': 53.9, 'lon': 27.5},
{'lat': 53.91, 'lon': 27.5}
]
},
{
'type': 'way',
'geometry': [
{'lat': 53.9, 'lon': 27.5},
{'lat': 53.9, 'lon': 27.51}
]
}
]
length = calculate_road_length(elements)
assert length > 0
def test_ignores_non_ways(self):
"""Test non-way elements are ignored."""
elements = [
{'type': 'node', 'lat': 53.9, 'lon': 27.5},
{
'type': 'way',
'geometry': [
{'lat': 53.9, 'lon': 27.5},
{'lat': 53.91, 'lon': 27.5}
]
}
]
length = calculate_road_length(elements)
assert length > 0
class TestNearestDistance:
"""Test nearest distance calculation."""
def test_empty_elements(self):
"""Test empty elements returns None."""
distance = find_nearest_distance(53.9, 27.5, [])
assert distance is None
def test_single_node(self):
"""Test single node distance."""
elements = [
{'type': 'node', 'lat': 53.91, 'lon': 27.5}
]
distance = find_nearest_distance(53.9, 27.5, elements)
assert distance is not None
assert distance > 0
def test_finds_nearest(self):
"""Test finds nearest among multiple nodes."""
elements = [
{'type': 'node', 'lat': 53.95, 'lon': 27.5}, # Far
{'type': 'node', 'lat': 53.901, 'lon': 27.5}, # Near
{'type': 'node', 'lat': 54.0, 'lon': 27.5} # Very far
]
distance = find_nearest_distance(53.9, 27.5, elements)
# Should find the nearest (53.901)
assert distance < 0.2
def test_ignores_non_nodes(self):
"""Test non-node elements are ignored."""
elements = [
{'type': 'way', 'geometry': []},
{'type': 'node', 'lat': 53.91, 'lon': 27.5}
]
distance = find_nearest_distance(53.9, 27.5, elements)
assert distance is not None
class TestForestCoverage:
"""Test forest coverage calculation."""
def test_no_forest(self):
"""Test no forest returns 0%."""
coverage = calculate_forest_coverage([], 1000)
assert coverage == 0.0
def test_some_forest(self):
"""Test forest coverage calculation."""
elements = [
{'type': 'way', 'tags': {'landuse': 'forest'}},
{'type': 'way', 'tags': {'natural': 'wood'}}
]
coverage = calculate_forest_coverage(elements, 1000)
assert 0 < coverage <= 100
def test_coverage_capped_at_100(self):
"""Test coverage is capped at 100%."""
# Many forest ways
elements = [{'type': 'way'} for _ in range(1000)]
coverage = calculate_forest_coverage(elements, 100)
assert coverage <= 100.0
class TestBuildSearchZones:
"""Test search zone building."""
@pytest.mark.asyncio
async def test_zone_count(self):
"""Test correct number of zones are created."""
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
mock_features.return_value = {
'roads_km': 5.0,
'road_density': 2.0,
'water_distance_km': 1.5,
'settlement_distance_km': 3.0,
'forest_pct': 40.0
}
zones = await build_search_zones(53.9, 27.5, {})
# 8 directions × 4 distances = 32 zones
assert len(zones) == 32
@pytest.mark.asyncio
async def test_all_directions_covered(self):
"""Test all 8 directions are included."""
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
mock_features.return_value = {
'roads_km': 5.0,
'road_density': 2.0,
'water_distance_km': 1.5,
'settlement_distance_km': 3.0,
'forest_pct': 40.0
}
zones = await build_search_zones(53.9, 27.5, {})
directions_found = set(z.direction for z in zones)
assert directions_found == set(DIRECTIONS)
@pytest.mark.asyncio
async def test_all_distances_covered(self):
"""Test all 4 distances are included."""
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
mock_features.return_value = {
'roads_km': 5.0,
'road_density': 2.0,
'water_distance_km': 1.5,
'settlement_distance_km': 3.0,
'forest_pct': 40.0
}
zones = await build_search_zones(53.9, 27.5, {})
distances_found = set(z.distance_km for z in zones)
expected_distances = set(d / 1000 for d in SEARCH_DISTANCES)
assert distances_found == expected_distances
@pytest.mark.asyncio
async def test_zone_structure(self):
"""Test zone objects have correct structure."""
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
mock_features.return_value = {
'roads_km': 5.0,
'road_density': 2.0,
'water_distance_km': 1.5,
'settlement_distance_km': 3.0,
'forest_pct': 40.0
}
zones = await build_search_zones(53.9, 27.5, {})
for zone in zones:
assert hasattr(zone, 'direction')
assert hasattr(zone, 'distance_km')
assert hasattr(zone, 'forest_pct')
assert hasattr(zone, 'road_density')
assert hasattr(zone, 'water_distance_km')
assert hasattr(zone, 'settlement_distance_km')
class TestConstants:
"""Test module constants."""
def test_directions_count(self):
"""Test there are 8 directions."""
assert len(DIRECTIONS) == 8
def test_directions_values(self):
"""Test direction values are correct."""
expected = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
assert DIRECTIONS == expected
def test_search_distances(self):
"""Test search distances are correct."""
expected = [500, 1000, 2000, 5000]
assert SEARCH_DISTANCES == expected
def test_cache_dir_path(self):
"""Test cache directory path is set."""
assert isinstance(CACHE_DIR, Path)
assert str(CACHE_DIR) == "/tmp/overpass_cache"
+294
View File
@@ -0,0 +1,294 @@
"""
Tests for psychotype_service.py
Tests the psychotype detection logic and modifiers
based on §6 ВЕКТОР-контекст.md specifications.
"""
import pytest
from services.psychotype_service import (
detect_psychotype,
get_psychotype_modifiers,
get_search_recommendations,
get_psychotype_questions
)
class TestDetectPsychotype:
"""Test psychotype detection from answers."""
def test_dominant_profile(self):
"""Test dominant psychotype: активно + лидер + рискует."""
answers = {
'unfamiliar_behavior': 'explore',
'stress_reaction': 'angry',
'leadership': 'always_leader',
'risk_taking': 'very'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'dominant'
def test_harmonic_profile(self):
"""Test harmonic psychotype: спокойно + лидер + осторожный."""
answers = {
'unfamiliar_behavior': 'wait',
'stress_reaction': 'calm',
'leadership': 'always_leader',
'risk_taking': 'no_cautious'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'harmonic'
def test_anxious_profile(self):
"""Test anxious psychotype: плачет + ведомый + осторожный."""
answers = {
'unfamiliar_behavior': 'wait',
'stress_reaction': 'cry',
'leadership': 'always_follower',
'risk_taking': 'no_cautious'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'anxious'
def test_introvert_passive_profile(self):
"""Test introvert_passive: замирает + ведомый + осторожный."""
answers = {
'unfamiliar_behavior': 'freeze',
'stress_reaction': 'angry',
'leadership': 'always_follower',
'risk_taking': 'no_cautious'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'introvert_passive'
def test_introvert_active_profile(self):
"""Test introvert_active: активно + иногда лидер."""
answers = {
'unfamiliar_behavior': 'explore',
'stress_reaction': 'calm',
'leadership': 'sometimes',
'risk_taking': 'sometimes'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'introvert_active'
def test_introvert_active_panic_variant(self):
"""Test introvert_active: паникует + иногда лидер."""
answers = {
'unfamiliar_behavior': 'panic',
'stress_reaction': 'angry',
'leadership': 'sometimes',
'risk_taking': 'sometimes'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'introvert_active'
def test_case_insensitive(self):
"""Test that detection is case-insensitive."""
answers = {
'unfamiliar_behavior': 'EXPLORE',
'stress_reaction': 'ANGRY',
'leadership': 'ALWAYS_LEADER',
'risk_taking': 'VERY'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'dominant'
def test_partial_answers_default_harmonic(self):
"""Test that incomplete answers default to harmonic."""
answers = {
'unfamiliar_behavior': 'wait',
'stress_reaction': 'calm'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'harmonic'
class TestGetPsychotypeModifiers:
"""Test psychotype modifiers for search zones."""
def test_dominant_modifiers(self):
"""Test dominant modifiers: far zones emphasized."""
modifiers = get_psychotype_modifiers('dominant')
assert modifiers['zone_0_500'] == 0.7
assert modifiers['zone_1500_2500'] == 1.4
assert modifiers['movement_model'] == 'chaotic_far'
assert 'description' in modifiers
def test_harmonic_modifiers(self):
"""Test harmonic modifiers: balanced distribution."""
modifiers = get_psychotype_modifiers('harmonic')
assert modifiers['zone_0_500'] == 0.8
assert modifiers['zone_500_1500'] == 1.0
assert modifiers['zone_1500_2500'] == 1.1
assert modifiers['movement_model'] == 'linear_landmark'
def test_anxious_modifiers(self):
"""Test anxious modifiers: near zone emphasized."""
modifiers = get_psychotype_modifiers('anxious')
assert modifiers['zone_0_500'] == 1.4
assert modifiers['zone_1500_2500'] == 0.5
assert modifiers['zone_2500plus'] == 0.3
assert modifiers['movement_model'] == 'stay'
def test_introvert_passive_modifiers(self):
"""Test introvert_passive modifiers: very near zone."""
modifiers = get_psychotype_modifiers('introvert_passive')
assert modifiers['zone_0_500'] == 1.3
assert modifiers['zone_2500plus'] == 0.2
assert modifiers['movement_model'] == 'stay_hidden'
def test_introvert_active_modifiers(self):
"""Test introvert_active modifiers: medium zones."""
modifiers = get_psychotype_modifiers('introvert_active')
assert modifiers['zone_0_500'] == 0.8
assert modifiers['zone_500_1500'] == 1.1
assert modifiers['zone_1500_2500'] == 1.2
assert modifiers['movement_model'] == 'linear_landmark'
def test_unknown_psychotype_defaults_harmonic(self):
"""Test that unknown psychotype returns harmonic modifiers."""
modifiers = get_psychotype_modifiers('unknown_type')
harmonic_modifiers = get_psychotype_modifiers('harmonic')
assert modifiers == harmonic_modifiers
def test_all_modifiers_have_required_fields(self):
"""Test that all psychotypes have required modifier fields."""
psychotypes = ['dominant', 'harmonic', 'anxious', 'introvert_passive', 'introvert_active']
required_fields = ['zone_0_500', 'zone_500_1500', 'zone_1500_2500',
'zone_2500plus', 'movement_model', 'description']
for psychotype in psychotypes:
modifiers = get_psychotype_modifiers(psychotype)
for field in required_fields:
assert field in modifiers, f"{psychotype} missing {field}"
class TestGetSearchRecommendations:
"""Test search recommendations for each psychotype."""
def test_dominant_recommendations(self):
"""Test dominant search recommendations."""
recs = get_search_recommendations('dominant')
assert 'priority_zones' in recs
assert 'search_pattern' in recs
assert 'key_locations' in recs
assert 'communication' in recs
def test_anxious_recommendations(self):
"""Test anxious search recommendations."""
recs = get_search_recommendations('anxious')
assert '0-500' in recs['priority_zones']
def test_all_psychotypes_have_recommendations(self):
"""Test that all psychotypes have complete recommendations."""
psychotypes = ['dominant', 'harmonic', 'anxious', 'introvert_passive', 'introvert_active']
required_fields = ['priority_zones', 'search_pattern', 'key_locations', 'communication']
for psychotype in psychotypes:
recs = get_search_recommendations(psychotype)
for field in required_fields:
assert field in recs, f"{psychotype} missing {field}"
assert len(recs[field]) > 0, f"{psychotype} {field} is empty"
class TestGetPsychotypeQuestions:
"""Test psychotype questions structure."""
def test_questions_count(self):
"""Test that there are exactly 4 questions."""
questions = get_psychotype_questions()
assert len(questions) == 4
def test_questions_structure(self):
"""Test that each question has required fields."""
questions = get_psychotype_questions()
for q in questions:
assert 'id' in q
assert 'question' in q
assert 'options' in q
assert len(q['options']) >= 3
def test_question_ids(self):
"""Test that question IDs match expected fields."""
questions = get_psychotype_questions()
expected_ids = ['unfamiliar_behavior', 'stress_reaction', 'leadership', 'risk_taking']
actual_ids = [q['id'] for q in questions]
assert actual_ids == expected_ids
def test_options_structure(self):
"""Test that each option has value and label."""
questions = get_psychotype_questions()
for q in questions:
for option in q['options']:
assert 'value' in option
assert 'label' in option
assert len(option['value']) > 0
assert len(option['label']) > 0
class TestPsychotypeIntegration:
"""Integration tests for complete psychotype workflow."""
def test_full_workflow_dominant(self):
"""Test complete workflow for dominant type."""
answers = {
'unfamiliar_behavior': 'explore',
'stress_reaction': 'angry',
'leadership': 'always_leader',
'risk_taking': 'very'
}
psychotype = detect_psychotype(answers)
modifiers = get_psychotype_modifiers(psychotype)
recommendations = get_search_recommendations(psychotype)
assert psychotype == 'dominant'
assert modifiers['zone_1500_2500'] > modifiers['zone_0_500']
assert 'priority_zones' in recommendations
def test_full_workflow_anxious(self):
"""Test complete workflow for anxious type."""
answers = {
'unfamiliar_behavior': 'freeze',
'stress_reaction': 'cry',
'leadership': 'always_follower',
'risk_taking': 'no_cautious'
}
psychotype = detect_psychotype(answers)
modifiers = get_psychotype_modifiers(psychotype)
recommendations = get_search_recommendations(psychotype)
assert psychotype in ['anxious', 'introvert_passive']
assert modifiers['zone_0_500'] > 1.0
assert '0-500' in recommendations['priority_zones']
def test_modifier_distributions_differ(self):
"""Test that different psychotypes have different modifier distributions."""
dominant_mods = get_psychotype_modifiers('dominant')
anxious_mods = get_psychotype_modifiers('anxious')
# Dominant emphasizes far zones
assert dominant_mods['zone_1500_2500'] > anxious_mods['zone_1500_2500']
# Anxious emphasizes near zones
assert anxious_mods['zone_0_500'] > dominant_mods['zone_0_500']
+440
View File
@@ -0,0 +1,440 @@
"""
Tests for scoring_service.py
Tests the WeightedScorer class and zone ranking logic
based on §8 and §9 ВЕКТОР-контекст.md specifications.
"""
import pytest
from services.scoring_service import (
WeightedScorer,
create_scorer_for_case,
get_weight_explanation
)
class TestWeightedScorerBasics:
"""Test basic WeightedScorer functionality."""
def test_initialization(self):
"""Test scorer initializes with base weights."""
scorer = WeightedScorer()
assert scorer.weights['forest'] == 0.25
assert scorer.weights['water'] == 0.20
assert scorer.weights['roads'] == 0.18
assert scorer.weights['settlement'] == 0.15
assert scorer.weights['historical'] == 0.12
assert scorer.weights['direction'] == 0.07
assert scorer.weights['shelter'] == 0.03
assert scorer.distance_multiplier == 1.0
assert scorer.active_profiles == []
def test_base_weights_sum_to_one(self):
"""Test that base weights sum to 1.0."""
scorer = WeightedScorer()
total = sum(scorer.BASE_WEIGHTS.values())
assert total == pytest.approx(1.0, rel=0.01)
class TestAgeModifiers:
"""Test age-based modifiers."""
def test_age_group_0_4(self):
"""Test young children (0-4) modifiers."""
scorer = WeightedScorer()
scorer.apply_age_modifiers(3)
# Young children: high water risk, low distance
assert scorer.distance_multiplier == 0.3
# Water weight should be increased
assert scorer.weights['water'] > scorer.BASE_WEIGHTS['water']
def test_age_group_8_11(self):
"""Test preteen (8-11) modifiers."""
scorer = WeightedScorer()
scorer.apply_age_modifiers(10)
assert scorer.distance_multiplier == 0.8
def test_age_group_15_17(self):
"""Test teenager (15-17) modifiers."""
scorer = WeightedScorer()
scorer.apply_age_modifiers(16)
# Teenagers: higher distance multiplier
assert scorer.distance_multiplier == 1.5
class TestSeasonModifiers:
"""Test seasonal modifiers."""
def test_winter_modifiers(self):
"""Test winter season modifiers."""
scorer = WeightedScorer()
scorer.apply_season_modifiers('зима')
# Winter: shelter more important, distance reduced
assert scorer.distance_multiplier == 0.7
assert scorer.weights['shelter'] > scorer.BASE_WEIGHTS['shelter']
def test_summer_modifiers(self):
"""Test summer season modifiers."""
scorer = WeightedScorer()
scorer.apply_season_modifiers('лето')
# Summer: increased distance
assert scorer.distance_multiplier == 1.3
class TestBehavioralProfiles:
"""Test behavioral profiles from §8."""
def test_ras_profile(self):
"""Test РАС (autism) profile with critical water/railway emphasis."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС'])
# РАС: water x3.0, railway x2.5, distance x2.0
assert scorer.distance_multiplier == 2.0
assert 'РАС' in scorer.active_profiles
assert len(scorer.critical_warnings) == 1
assert 'водоёмы' in scorer.critical_warnings[0]['warning']
def test_epilepsy_profile(self):
"""Test эпилепсия profile with reduced distance."""
scorer = WeightedScorer()
scorer.apply_profile(['эпилепсия'])
# Epilepsy: water x3.5, distance x0.6
assert scorer.distance_multiplier == 0.6
assert len(scorer.critical_warnings) == 1
def test_bicycle_profile(self):
"""Test велосипед profile with massive distance increase."""
scorer = WeightedScorer()
scorer.apply_profile(['велосипед'])
# Bicycle: distance x5.0, roads x1.8
assert scorer.distance_multiplier == 5.0
assert 'велосипед' in scorer.active_profiles
assert len(scorer.critical_warnings) == 1
assert '10-15 км' in scorer.critical_warnings[0]['warning']
def test_scooter_profile(self):
"""Test самокат profile."""
scorer = WeightedScorer()
scorer.apply_profile(['самокат'])
# Scooter: distance x3.0
assert scorer.distance_multiplier == 3.0
def test_intentional_runaway_profile(self):
"""Test намеренный_уход profile."""
scorer = WeightedScorer()
base_roads = scorer.weights['roads']
scorer.apply_profile(['намеренный_уход'])
# Intentional runaway: roads x2.5, settlement x3.0
assert scorer.weights['roads'] > base_roads
def test_multiple_profiles(self):
"""Test applying multiple profiles."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС', 'велосипед'])
# Both multipliers should compound: 2.0 * 5.0 = 10.0
assert scorer.distance_multiplier == 10.0
assert len(scorer.active_profiles) == 2
class TestScoreZone:
"""Test zone scoring functionality."""
def test_score_zone_basic(self):
"""Test basic zone scoring."""
scorer = WeightedScorer()
zone = {
'forest_pct': 0.7,
'water_distance_km': 2.0,
'road_density': 1.0,
'settlement_distance_km': 5.0,
'historical_freq': 0.6,
'direction_match': 0.8,
'shelter_pct': 0.4,
'distance_km': 2.0
}
case = {'age': 10, 'season': 'лето', 'profiles': []}
score = scorer.score_zone(zone, case)
assert 0 <= score <= 100
assert isinstance(score, float)
def test_score_zone_with_ras(self):
"""Test zone scoring with РАС profile."""
scorer = WeightedScorer()
zone_near_water = {
'forest_pct': 0.5,
'water_distance_km': 0.5, # Very close to water
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.3,
'distance_km': 2.0
}
zone_far_water = {
'forest_pct': 0.5,
'water_distance_km': 5.0, # Far from water
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.3,
'distance_km': 2.0
}
case = {'age': 8, 'season': 'лето', 'profiles': ['РАС']}
score_near = scorer.score_zone(zone_near_water, case)
score_far = scorer.score_zone(zone_far_water, case)
# Zone near water should score higher for РАС
assert score_near > score_far
def test_score_zone_with_bicycle(self):
"""Test zone scoring with bicycle profile."""
scorer = WeightedScorer()
zone_with_roads = {
'forest_pct': 0.3,
'water_distance_km': 5.0,
'road_density': 2.0, # High road density
'settlement_distance_km': 5.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.2,
'distance_km': 8.0 # Far distance
}
case = {'age': 12, 'season': 'лето', 'profiles': ['велосипед']}
score = scorer.score_zone(zone_with_roads, case)
assert score > 0
class TestRankZones:
"""Test zone ranking functionality."""
def test_rank_zones_basic(self):
"""Test basic zone ranking."""
scorer = WeightedScorer()
zones = [
{
'id': 'zone_a',
'forest_pct': 0.8,
'water_distance_km': 1.0,
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.7,
'direction_match': 0.9,
'shelter_pct': 0.5,
'distance_km': 2.0
},
{
'id': 'zone_b',
'forest_pct': 0.3,
'water_distance_km': 8.0,
'road_density': 0.2,
'settlement_distance_km': 15.0,
'historical_freq': 0.2,
'direction_match': 0.3,
'shelter_pct': 0.1,
'distance_km': 5.0
},
{
'id': 'zone_c',
'forest_pct': 0.6,
'water_distance_km': 3.0,
'road_density': 1.0,
'settlement_distance_km': 5.0,
'historical_freq': 0.8,
'direction_match': 0.7,
'shelter_pct': 0.4,
'distance_km': 1.5
}
]
case = {'age': 10, 'season': 'лето', 'profiles': []}
ranked = scorer.rank_zones(zones, case)
assert len(ranked) == 3
assert all('score' in z for z in ranked)
assert all('priority' in z for z in ranked)
# Check priorities are 1, 2, 3
priorities = [z['priority'] for z in ranked]
assert priorities == [1, 2, 3]
# Check scores are descending
scores = [z['score'] for z in ranked]
assert scores == sorted(scores, reverse=True)
def test_rank_zones_with_profiles(self):
"""Test zone ranking with behavioral profiles."""
scorer = WeightedScorer()
zones = [
{
'id': 'near_water',
'forest_pct': 0.5,
'water_distance_km': 0.3,
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.3,
'distance_km': 2.0
},
{
'id': 'far_water',
'forest_pct': 0.5,
'water_distance_km': 8.0,
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.3,
'distance_km': 2.0
}
]
case = {'age': 8, 'season': 'лето', 'profiles': ['РАС']}
ranked = scorer.rank_zones(zones, case)
# Zone near water should be priority 1 for РАС
assert ranked[0]['id'] == 'near_water'
assert ranked[0]['priority'] == 1
class TestHelperFunctions:
"""Test helper functions."""
def test_create_scorer_for_case(self):
"""Test scorer creation for a case."""
case = {
'age': 10,
'season': 'зима',
'profiles': ['велосипед']
}
scorer = create_scorer_for_case(case)
assert scorer.distance_multiplier > 1.0
assert 'велосипед' in scorer.active_profiles
def test_get_weight_explanation(self):
"""Test weight explanation generation."""
case = {
'age': 8,
'season': 'лето',
'profiles': ['РАС']
}
explanation = get_weight_explanation(case)
assert 'weights' in explanation
assert 'distance_multiplier' in explanation
assert 'age_group' in explanation
assert 'profiles' in explanation
assert 'critical_warnings' in explanation
assert explanation['age_group'] == '8-11'
assert len(explanation['profiles']) == 1
assert len(explanation['critical_warnings']) == 1
def test_get_active_profiles_info(self):
"""Test active profiles info retrieval."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС', 'велосипед'])
profiles_info = scorer.get_active_profiles_info()
assert len(profiles_info) == 2
assert profiles_info[0]['name'] == 'РАС'
assert profiles_info[1]['name'] == 'велосипед'
assert 'critical_warning' in profiles_info[0]
assert 'critical_warning' in profiles_info[1]
class TestWeightNormalization:
"""Test weight normalization."""
def test_weights_normalized_after_modifiers(self):
"""Test that weights sum to 1.0 after applying modifiers."""
scorer = WeightedScorer()
scorer.apply_age_modifiers(10)
scorer.apply_season_modifiers('зима')
scorer._normalize_weights()
total = sum(scorer.weights.values())
assert total == pytest.approx(1.0, rel=0.01)
def test_weights_normalized_after_profiles(self):
"""Test that weights sum to 1.0 after applying profiles."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС'])
scorer._normalize_weights()
total = sum(scorer.weights.values())
assert total == pytest.approx(1.0, rel=0.01)
class TestCriticalWarnings:
"""Test critical warning system."""
def test_ras_critical_warning(self):
"""Test РАС generates critical warning."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС'])
assert len(scorer.critical_warnings) == 1
warning = scorer.critical_warnings[0]
assert warning['profile'] == 'РАС'
assert 'водоёмы' in warning['warning']
assert 'громкоговоритель' in warning['warning']
def test_epilepsy_critical_warning(self):
"""Test эпилепсия generates critical warning."""
scorer = WeightedScorer()
scorer.apply_profile(['эпилепсия'])
assert len(scorer.critical_warnings) == 1
warning = scorer.critical_warnings[0]
assert warning['profile'] == 'эпилепсия'
assert 'Медицинский' in warning['warning']
def test_bicycle_critical_warning(self):
"""Test велосипед generates critical warning."""
scorer = WeightedScorer()
scorer.apply_profile(['велосипед'])
assert len(scorer.critical_warnings) == 1
warning = scorer.critical_warnings[0]
assert warning['profile'] == 'велосипед'
assert '10-15 км' in warning['warning']
def test_multiple_critical_warnings(self):
"""Test multiple profiles generate multiple warnings."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС', 'велосипед'])
assert len(scorer.critical_warnings) == 2