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
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']
)