diff --git a/backend/api/__init__.py b/backend/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/api/v1/analyze.py b/backend/api/v1/analyze.py deleted file mode 100644 index 73a1af0..0000000 --- a/backend/api/v1/analyze.py +++ /dev/null @@ -1,347 +0,0 @@ -""" -Analysis API endpoints for full case analysis pipeline. - -Pipeline: distance → geo → scoring → psychotype → claude → merged result -""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any -from uuid import UUID -from datetime import datetime -import time - -from database import get_db -from models import Case, AnalysisLog, User -from api.v1.auth import get_current_user - -# Import services -from services.distance_service import calculate_max_distance -from services.geo_service import build_search_zones -from services.scoring_service import WeightedScorer -from services.psychotype_service import ( - detect_psychotype, - get_psychotype_modifiers, - get_search_recommendations -) -from services.claude_service import analyze_case as claude_analyze - - -router = APIRouter() - - -class AnalysisRequest(BaseModel): - """Request for full case analysis""" - case_id: UUID = Field(..., description="ID случая для анализа") - - -class ZoneResult(BaseModel): - """Search zone with score and recommendations""" - priority: int - name: str - direction: str - distance_km: float - score: float - reasoning: str - forest_pct: Optional[float] = None - road_density: Optional[float] = None - water_distance_km: Optional[float] = None - - -class AnalysisResponse(BaseModel): - """Full analysis result""" - case_id: UUID - analyzed_at: datetime - - # Distance calculation - max_distance_km: float - - # Psychotype (if available) - psychotype: Optional[str] = None - psychotype_modifiers: Optional[Dict[str, Any]] = None - psychotype_recommendations: Optional[Dict[str, Any]] = None - - # Zones - zones: List[ZoneResult] - - # Claude analysis (if available) - urgency: Optional[str] = None - key_locations: Optional[List[str]] = None - immediate_actions: Optional[List[str]] = None - behavioral_prediction: Optional[str] = None - summary: Optional[str] = None - - # Meta - execution_time_ms: float - services_used: List[str] - - -class SavedAnalysisResponse(BaseModel): - """Saved analysis result from database""" - case_id: UUID - analysis_log: Dict[str, Any] - created_at: datetime - - -@router.post("/analyze", response_model=AnalysisResponse, status_code=200) -async def analyze_full_case( - request: AnalysisRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Запустить полный анализ случая. - - Пайплайн: - 1. Distance service - расчет максимальной дистанции - 2. Geo service - построение зон поиска - 3. Scoring service - оценка и ранжирование зон - 4. Psychotype service - определение психотипа (если есть данные) - 5. Claude service - интеллектуальный анализ (опционально) - 6. Merge results - объединение результатов - - Требуется аутентификация (operator, field, admin). - """ - start_time = time.time() - services_used = [] - - # 1. Получить случай из БД - case = db.query(Case).filter(Case.id == request.case_id).first() - if not case: - raise HTTPException(status_code=404, detail=f"Case {request.case_id} not found") - - # Подготовить данные для анализа - case_data = { - 'age': case.age_years, - 'gender': case.gender, - 'elapsed_hours': case.elapsed_hours or 1.0, - 'terrain_primary': case.terrain[0] if case.terrain else 'лес', - 'season': case.season or 'лето', - 'temperature_c': case.temperature_c or 20.0, - 'has_transport': case.has_transport, - 'has_diagnosis': case.has_diagnosis, - 'diagnosis_type': case.diagnosis_type or [], - 'tnp_lat': case.tnp_lat, - 'tnp_lon': case.tnp_lon, - } - - # 2. Distance service - расчет максимальной дистанции - try: - max_distance = calculate_max_distance(case_data) - services_used.append('distance') - except Exception as e: - raise HTTPException(status_code=500, detail=f"Distance calculation failed: {str(e)}") - - # 3. Geo service - построение зон поиска (если есть координаты) - zones_data = [] - if case.tnp_lat and case.tnp_lon: - try: - zones_data = await build_search_zones( - lat=case.tnp_lat, - lon=case.tnp_lon, - case_data=case_data, - max_distance_km=max_distance - ) - services_used.append('geo') - except Exception as e: - # Geo service опционален, продолжаем без него - print(f"Geo service failed: {e}") - - # 4. Scoring service - оценка и ранжирование зон - scored_zones = [] - if zones_data: - try: - scorer = WeightedScorer() - for zone in zones_data: - zone_dict = zone.model_dump() if hasattr(zone, 'model_dump') else zone - score = scorer.score_zone(zone_dict, case_data, max_distance_km=max_distance) - zone_dict['score'] = score - zone_dict['reasoning'] = f"Оценка на основе {len(case_data)} факторов" - scored_zones.append(zone_dict) - - # Сортировать по score - scored_zones.sort(key=lambda z: z.get('score', 0), reverse=True) - services_used.append('scoring') - except Exception as e: - print(f"Scoring service failed: {e}") - scored_zones = _create_fallback_zones(max_distance) - else: - # Если geo не работает, создаем базовые зоны - scored_zones = _create_fallback_zones(max_distance) - - # 5. Psychotype service - определение психотипа - psychotype = None - psychotype_modifiers = None - psychotype_recommendations = None - - if case.psychotype_answers: - try: - psychotype = detect_psychotype(case.psychotype_answers) - psychotype_modifiers = get_psychotype_modifiers(psychotype) - psychotype_recommendations = get_search_recommendations(psychotype) - services_used.append('psychotype') - - # Применить модификаторы психотипа к зонам - scored_zones = _apply_psychotype_modifiers(scored_zones, psychotype_modifiers) - except Exception as e: - print(f"Psychotype service failed: {e}") - - # 6. Claude service - интеллектуальный анализ (опционально) - urgency = None - key_locations = None - immediate_actions = None - behavioral_prediction = None - summary = None - - try: - claude_result = await claude_analyze(case_data) - urgency = claude_result.urgency - key_locations = claude_result.key_locations - immediate_actions = claude_result.immediate_actions - behavioral_prediction = claude_result.behavioral_prediction - summary = claude_result.summary - services_used.append('claude') - except Exception as e: - # Claude опционален, продолжаем без него - print(f"Claude service failed: {e}") - - # 7. Формируем результат - zones_result = [ - ZoneResult( - priority=i + 1, - name=zone.get('name', f"Зона {zone.get('direction', 'N')}"), - direction=zone.get('direction', 'N'), - distance_km=zone.get('distance_km', 0), - score=zone.get('score', 0), - reasoning=zone.get('reasoning', 'Автоматическая оценка'), - forest_pct=zone.get('forest_pct'), - road_density=zone.get('road_density'), - water_distance_km=zone.get('water_distance_km') - ) - for i, zone in enumerate(scored_zones[:10]) # Топ-10 зон - ] - - execution_time = (time.time() - start_time) * 1000 - - # 8. Сохранить результат в analysis_log - analysis_result = { - 'max_distance_km': max_distance, - 'psychotype': psychotype, - 'psychotype_modifiers': psychotype_modifiers, - 'zones': [z.model_dump() for z in zones_result], - 'urgency': urgency, - 'key_locations': key_locations, - 'immediate_actions': immediate_actions, - 'summary': summary, - 'services_used': services_used, - 'execution_time_ms': execution_time - } - - # Обновить case.analysis_log - case.analysis_log = analysis_result - db.commit() - - # Создать запись в AnalysisLog - log_entry = AnalysisLog( - case_id=case.id, - user_id=current_user.id, - analysis_type='full_pipeline', - input_data={'case_id': str(case.id)}, - output_data=analysis_result, - execution_time=execution_time / 1000, - status='success' - ) - db.add(log_entry) - db.commit() - - return AnalysisResponse( - case_id=case.id, - analyzed_at=datetime.utcnow(), - max_distance_km=max_distance, - psychotype=psychotype, - psychotype_modifiers=psychotype_modifiers, - psychotype_recommendations=psychotype_recommendations, - zones=zones_result, - urgency=urgency, - key_locations=key_locations, - immediate_actions=immediate_actions, - behavioral_prediction=behavioral_prediction, - summary=summary, - execution_time_ms=execution_time, - services_used=services_used - ) - - -@router.get("/analyze/{case_id}", response_model=SavedAnalysisResponse) -async def get_saved_analysis( - case_id: UUID, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Получить сохранённый результат анализа. - - Возвращает последний analysis_log из таблицы cases. - Требуется аутентификация (operator, field, admin). - """ - case = db.query(Case).filter(Case.id == case_id).first() - if not case: - raise HTTPException(status_code=404, detail=f"Case {case_id} not found") - - if not case.analysis_log: - raise HTTPException( - status_code=404, - detail=f"No analysis found for case {case_id}. Run POST /analyze first." - ) - - return SavedAnalysisResponse( - case_id=case.id, - analysis_log=case.analysis_log, - created_at=case.created_at - ) - - -def _create_fallback_zones(max_distance: float) -> List[Dict[str, Any]]: - """Create basic zones when geo/scoring services fail""" - directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] - zones = [] - - for i, direction in enumerate(directions): - zones.append({ - 'direction': direction, - 'distance_km': max_distance * 0.8, - 'score': 100 - (i * 10), - 'name': f"Сектор {direction}", - 'reasoning': 'Базовая оценка (сервисы недоступны)' - }) - - return zones - - -def _apply_psychotype_modifiers(zones: List[Dict], modifiers: Dict) -> List[Dict]: - """Apply psychotype modifiers to zone scores""" - if not modifiers: - return zones - - # Применяем модификаторы зон из психотипа - for zone in zones: - distance = zone.get('distance_km', 0) - - # Определяем зону дистанции - if distance < 0.5: - modifier = modifiers.get('zone_0_500', 1.0) - elif distance < 1.5: - modifier = modifiers.get('zone_500_1500', 1.0) - elif distance < 2.5: - modifier = modifiers.get('zone_1500_2500', 1.0) - else: - modifier = modifiers.get('zone_2500plus', 1.0) - - # Применяем модификатор к score - zone['score'] = zone.get('score', 0) * modifier - zone['reasoning'] += f" (психотип: ×{modifier:.1f})" - - # Пересортировать по score - zones.sort(key=lambda z: z.get('score', 0), reverse=True) - - return zones diff --git a/backend/api/v1/analyze.py.backup b/backend/api/v1/analyze.py.backup deleted file mode 100644 index 48b5239..0000000 --- a/backend/api/v1/analyze.py.backup +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import APIRouter -from pydantic import BaseModel - -router = APIRouter() - - -class AnalysisRequest(BaseModel): - age: int - gender: str - terrain: str - - -class AnalysisResult(BaseModel): - recommendation: str - estimated_radius_km: float - - -@router.post("/text", response_model=AnalysisResult) -async def analyze_text(request: AnalysisRequest): - return AnalysisResult( - recommendation="Placeholder analysis", - estimated_radius_km=2.5 - ) diff --git a/backend/api/v1/auth.py b/backend/api/v1/auth.py deleted file mode 100644 index 8b1abdf..0000000 --- a/backend/api/v1/auth.py +++ /dev/null @@ -1,196 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from sqlalchemy.orm import Session -from jose import JWTError, jwt -from datetime import datetime, timedelta -from typing import Optional -from pydantic import BaseModel -import os -import bcrypt - -from database import get_db -from models import User -from schemas import UserOut - -router = APIRouter() - -# JWT настройки -SECRET_KEY = os.getenv("JWT_SECRET", "change-me-in-production") -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") - - -class Token(BaseModel): - access_token: str - token_type: str - - -class TokenData(BaseModel): - username: Optional[str] = None - role: Optional[str] = None - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Проверка пароля через bcrypt напрямую""" - return bcrypt.checkpw( - plain_password.encode('utf-8'), - hashed_password.encode('utf-8') - ) - - -def get_password_hash(password: str) -> str: - """Хеширование пароля через bcrypt напрямую""" - salt = bcrypt.gensalt() - return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') - - -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - """Создание JWT токена""" - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - - -def authenticate_user(db: Session, username: str, password: str): - """Аутентификация пользователя""" - user = db.query(User).filter(User.username == username).first() - if not user: - return False - if not verify_password(password, user.hashed_password): - return False - return user - - -async def get_current_user( - token: str = Depends(oauth2_scheme), - db: Session = Depends(get_db) -) -> User: - """Получение текущего пользователя из JWT токена""" - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username: str = payload.get("sub") - if username is None: - raise credentials_exception - token_data = TokenData(username=username, role=payload.get("role")) - except JWTError: - raise credentials_exception - - user = db.query(User).filter(User.username == token_data.username).first() - if user is None: - raise credentials_exception - if not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - return user - - -async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: - """Проверка активности пользователя""" - if not current_user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - return current_user - - -def require_role(allowed_roles: list[str]): - """Dependency для проверки роли пользователя""" - async def role_checker(current_user: User = Depends(get_current_user)): - if current_user.role not in allowed_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Access denied. Required roles: {', '.join(allowed_roles)}" - ) - return current_user - return role_checker - - -@router.post("/login", response_model=Token) -async def login( - form_data: OAuth2PasswordRequestForm = Depends(), - db: Session = Depends(get_db) -): - """ - Аутентификация и получение JWT токена. - - Используйте username и password для получения access_token. - Токен действителен 24 часа. - - Тестовые пользователи: - - operator / pass123 - - field / pass123 - - admin / pass123 - """ - user = authenticate_user(db, form_data.username, form_data.password) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Обновляем last_login - user.last_login = datetime.utcnow() - db.commit() - - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": user.username, "role": user.role}, - expires_delta=access_token_expires - ) - return {"access_token": access_token, "token_type": "bearer"} - - -@router.get("/me", response_model=UserOut) -async def read_users_me(current_user: User = Depends(get_current_active_user)): - """ - Получить информацию о текущем пользователе. - - Требуется валидный JWT токен в заголовке Authorization: Bearer - """ - 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 diff --git a/backend/api/v1/cases.py b/backend/api/v1/cases.py deleted file mode 100644 index 8032ee8..0000000 --- a/backend/api/v1/cases.py +++ /dev/null @@ -1,212 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from sqlalchemy import desc -from typing import List, Optional -from uuid import UUID -from database import get_db -from models import Case, User -from schemas import CaseCreate, CaseOut -from pydantic import BaseModel - -# Импортируем auth dependencies -import sys -sys.path.append('/app/api/v1') -from auth import get_current_user, require_role - -router = APIRouter() - - -class CaseUpdate(BaseModel): - """Schema for updating case fields""" - # Ребёнок - child_name: Optional[str] = None - age_years: Optional[int] = None - gender: Optional[str] = None - height_build: Optional[str] = None - clothes_upper: Optional[str] = None - clothes_lower: Optional[str] = None - shoes: Optional[str] = None - clothes_description: Optional[str] = None - special_marks: Optional[str] = None - phone_status: Optional[str] = None - - # Здоровье - has_diagnosis: Optional[bool] = None - diagnosis_type: Optional[List[str]] = None - fitness_level: Optional[str] = None - has_transport: Optional[str] = None - cant_swim: Optional[bool] = None - - # Психотип - psychotype: Optional[str] = None - psychotype_answers: Optional[dict] = None - - # Обстоятельства - loss_reason: Optional[str] = None - loss_time: Optional[str] = None - elapsed_hours: Optional[float] = None - last_seen_direction: Optional[str] = None - last_seen_reliability: Optional[str] = None - last_seen_description: Optional[str] = None - behavior_description: Optional[str] = None - familiar_places: Optional[str] = None - lost_before: Optional[str] = None - - # Среда - season: Optional[str] = None - temperature_c: Optional[float] = None - precipitation: Optional[str] = None - visibility: Optional[str] = None - wind: Optional[str] = None - terrain: Optional[List[str]] = None - - # GPS - tnp_lat: Optional[float] = None - tnp_lon: Optional[float] = None - tnp_address: Optional[str] = None - - # Ресурсы - teams_count: Optional[int] = None - team_size: Optional[int] = None - has_dog: Optional[bool] = None - extra_resources: Optional[List[str]] = None - - # Исход - found_alive: Optional[bool] = None - found_distance_km: Optional[float] = None - found_direction: Optional[str] = None - found_location_type: Optional[str] = None - found_lat: Optional[float] = None - found_lon: Optional[float] = None - search_duration_hours: Optional[float] = None - who_found: Optional[str] = None - - # Статус - status: Optional[str] = None - - -class CaseListResponse(BaseModel): - """Response for list endpoint with pagination""" - total: int - skip: int - limit: int - cases: List[CaseOut] - - -@router.post("/cases", response_model=CaseOut, status_code=201) -async def create_case( - case_data: CaseCreate, - db: Session = Depends(get_db) -): - """ - Создать новый случай поиска. - - Принимает все поля из формы опроса (5 шагов). - Публичный эндпоинт - не требует аутентификации. - """ - db_case = Case(**case_data.model_dump(exclude_unset=True)) - db.add(db_case) - db.commit() - db.refresh(db_case) - return db_case - - -@router.get("/cases", response_model=CaseListResponse) -async def list_cases( - skip: int = Query(0, ge=0, description="Количество пропускаемых записей"), - limit: int = Query(50, ge=1, le=100, description="Максимум записей на страницу"), - status: Optional[str] = Query(None, description="Фильтр по статусу: active/closed/archived"), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Получить список случаев с пагинацией и фильтрацией. - - - **skip**: смещение (для пагинации) - - **limit**: количество записей (макс 100) - - **status**: фильтр по статусу (active/closed/archived) - - Требуется аутентификация (operator, field, admin). - """ - query = db.query(Case) - - if status: - query = query.filter(Case.status == status) - - total = query.count() - cases = query.order_by(desc(Case.created_at)).offset(skip).limit(limit).all() - - return { - "total": total, - "skip": skip, - "limit": limit, - "cases": cases - } - - -@router.get("/cases/{case_id}", response_model=CaseOut) -async def get_case( - case_id: UUID, - db: Session = Depends(get_db) -): - """ - Получить случай по ID. - - Возвращает все поля случая включая исход (если заполнен). - Публичный эндпоинт - не требует аутентификации. - """ - case = db.query(Case).filter(Case.id == case_id).first() - if not case: - raise HTTPException(status_code=404, detail=f"Case {case_id} not found") - return case - - -@router.patch("/cases/{case_id}", response_model=CaseOut) -async def update_case( - case_id: UUID, - case_update: CaseUpdate, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Обновить случай (частичное обновление). - - Используется для: - - Корректировки данных опроса - - Внесения исхода поиска (found_alive, found_distance_km и т.д.) - - Изменения статуса (active → closed) - - Требуется аутентификация (operator, field, admin). - """ - case = db.query(Case).filter(Case.id == case_id).first() - if not case: - raise HTTPException(status_code=404, detail=f"Case {case_id} not found") - - update_data = case_update.model_dump(exclude_unset=True) - - for field, value in update_data.items(): - setattr(case, field, value) - - db.commit() - db.refresh(case) - return case - - -@router.delete("/cases/{case_id}", status_code=204) -async def delete_case( - case_id: UUID, - db: Session = Depends(get_db), - current_user: User = Depends(require_role(["admin"])) -): - """ - Удалить случай (только для admin). - - В продакшене рекомендуется использовать архивацию вместо удаления. - """ - case = db.query(Case).filter(Case.id == case_id).first() - if not case: - raise HTTPException(status_code=404, detail=f"Case {case_id} not found") - - db.delete(case) - db.commit() - return None diff --git a/backend/api/v1/stats.py b/backend/api/v1/stats.py deleted file mode 100644 index e6b6393..0000000 --- a/backend/api/v1/stats.py +++ /dev/null @@ -1,159 +0,0 @@ -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session -from pydantic import BaseModel -from typing import Dict, List, Optional, Literal -from database import get_db -from models import Case -from services.stats_service import ( - get_dashboard_stats, - get_statistical_recommendation, - get_heatmap_with_cache, - DashboardStats, - StatisticalRecommendation -) - -router = APIRouter() - - -class HeatmapPoint(BaseModel): - lat: float - lon: float - intensity: float - case_id: str - metadata: Dict - - -class HeatmapResponse(BaseModel): - points: List[HeatmapPoint] - total: int - filters_applied: Dict - - -class StatisticalRecommendationRequest(BaseModel): - age: int - season: Optional[str] = None - terrain_primary: Optional[str] = None - - -@router.get("/summary", response_model=DashboardStats) -async def get_summary(db: Session = Depends(get_db)): - """ - Получить агрегированную статистику для дашборда. - - Возвращает: - - Общее количество случаев (всего, активных, закрытых) - - Распределение по полу, возрасту, психотипу, диагнозам, сезонам - - Средняя дистанция находки - - Средняя длительность поиска - - Процент выживаемости - """ - return get_dashboard_stats(db) - - -@router.post("/recommendation", response_model=StatisticalRecommendation) -async def get_recommendation( - request: StatisticalRecommendationRequest, - db: Session = Depends(get_db) -): - """ - Получить статистические рекомендации на основе похожих случаев. - - Фильтры (в порядке приоритета): - 1. Возраст ±2 года + сезон + terrain - 2. Возраст ±2 года + сезон (если < 5 случаев) - 3. Возраст ±2 года (если < 5 случаев) - 4. Все случаи (если < 5 случаев) - - Возвращает: - - Медианное расстояние находки - - Топ-3 направления с процентами - - Топ-5 типов локаций - - Процент выживаемости - - Размер выборки - - Использованные фильтры - """ - case_data = { - 'age': request.age, - 'season': request.season, - 'terrain_primary': request.terrain_primary - } - return get_statistical_recommendation(case_data, db) - - -@router.get("/heatmap", response_model=HeatmapResponse) -async def get_heatmap( - map_type: Literal['all', 'age', 'season', 'outcome'] = Query( - 'all', - description="Тип карты: all (все точки), age (по возрасту), season (по сезону), outcome (по исходу)" - ), - age_group: Optional[str] = Query( - None, - description="Возрастная группа: 0-3, 4-7, 8-11, 12-14, 15-17, 18+" - ), - season: Optional[str] = Query( - None, - description="Сезон: зима, весна, лето, осень" - ), - year_from: Optional[int] = Query( - None, - description="Год начала периода (например, 2020)" - ), - year_to: Optional[int] = Query( - None, - description="Год окончания периода (например, 2026)" - ), - outcome: Optional[str] = Query( - None, - description="Исход: alive (выжил), deceased (погиб)" - ), - db: Session = Depends(get_db) -): - """ - Получить данные для тепловой карты находок с кэшированием (1 час). - - **Типы карт:** - - `all` - все точки с одинаковой интенсивностью - - `age` - интенсивность зависит от возраста (младше = выше) - - `season` - интенсивность зависит от сезона (зима = выше) - - `outcome` - интенсивность зависит от исхода (погиб = выше) - - **Фильтры:** - - `age_group` - возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17, 18+) - - `season` - сезон (зима, весна, лето, осень) - - `year_from`, `year_to` - период по годам - - `outcome` - исход (alive, deceased) - - **Кэширование:** - Результаты кэшируются на 1 час для ускорения повторных запросов. - - **Возвращает:** - - `points` - массив точек с координатами, интенсивностью и метаданными - - `total` - общее количество точек - - `filters_applied` - примененные фильтры - """ - result = get_heatmap_with_cache( - db=db, - map_type=map_type, - age_group=age_group, - season=season, - year_from=year_from, - year_to=year_to, - outcome=outcome - ) - - points = [ - HeatmapPoint( - lat=p['lat'], - lon=p['lon'], - intensity=p['intensity'], - case_id=p['case_id'], - metadata=p['metadata'] - ) - for p in result['points'] - ] - - return HeatmapResponse( - points=points, - total=result['total'], - filters_applied=result['filters_applied'] - ) diff --git a/backend/check_model.py b/backend/check_model.py deleted file mode 100644 index 68c4647..0000000 --- a/backend/check_model.py +++ /dev/null @@ -1,39 +0,0 @@ -import models -from sqlalchemy import inspect - -mapper = inspect(models.Case) -columns = [c.key for c in mapper.columns] -print(f"Всего полей в модели Case: {len(columns)}") -print("\nПоля по категориям:") -print("\nРебёнок (Шаг 1):") -for c in columns: - if c in ["child_name", "age_years", "gender", "height_build", "clothes_upper", "clothes_lower", "shoes", "clothes_description", "special_marks", "phone_status"]: - print(f" - {c}") -print("\nЗдоровье (Шаг 2):") -for c in columns: - if c in ["has_diagnosis", "diagnosis_type", "fitness_level", "has_transport", "cant_swim"]: - print(f" - {c}") -print("\nПсихотип (Шаг 2б):") -for c in columns: - if c in ["psychotype", "psychotype_answers"]: - print(f" - {c}") -print("\nОбстоятельства (Шаг 3):") -for c in columns: - if c in ["loss_reason", "loss_time", "elapsed_hours", "last_seen_direction", "last_seen_reliability", "last_seen_description", "behavior_description", "familiar_places", "lost_before"]: - print(f" - {c}") -print("\nСреда (Шаг 4):") -for c in columns: - if c in ["season", "temperature_c", "precipitation", "visibility", "wind", "terrain"]: - print(f" - {c}") -print("\nGPS (Шаг 4):") -for c in columns: - if c in ["tnp_lat", "tnp_lon", "tnp_address"]: - print(f" - {c}") -print("\nРесурсы (Шаг 5):") -for c in columns: - if c in ["teams_count", "team_size", "has_dog", "extra_resources"]: - print(f" - {c}") -print("\nИсход:") -for c in columns: - if c in ["found_alive", "found_distance_km", "found_direction", "found_location_type", "found_lat", "found_lon", "search_duration_hours", "who_found"]: - print(f" - {c}") diff --git a/backend/models_backup.py b/backend/models_backup.py deleted file mode 100644 index aa10099..0000000 --- a/backend/models_backup.py +++ /dev/null @@ -1,89 +0,0 @@ -from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.sql import func -import uuid -from database import Base - - -class Case(Base): - """Unified case model - combines search case and result""" - __tablename__ = "cases" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - created_at = Column(DateTime, server_default=func.now()) - status = Column(String(20), default='active') # active/closed/archived - - # Ребёнок - child_name = Column(String(255)) - age_years = Column(Integer, nullable=False) - gender = Column(String(1)) # М / Ж - clothes_description = Column(Text) - special_marks = Column(Text) - phone_status = Column(String(20)) # answers/silent/none - - # Здоровье - has_diagnosis = Column(Boolean, default=False) - diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР... - fitness_level = Column(String(20)) # low/medium/high - has_transport = Column(String(20), default='none') # none/bike/scooter/other - cant_swim = Column(Boolean, default=False) - - # Психотип - psychotype = Column(String(50)) # dominant/harmonic/anxious/... - psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса - - # Обстоятельства - loss_reason = Column(String(100)) - loss_time = Column(DateTime) - elapsed_hours = Column(Float) - last_seen_direction = Column(String(10)) - last_seen_reliability = Column(String(20)) # exact/approx/unknown - last_seen_description = Column(Text) - behavior_description = Column(Text) - familiar_places = Column(Text) - lost_before = Column(String(20)) # yes/no/unknown - - # Среда - season = Column(String(20)) - temperature_c = Column(Float) - precipitation = Column(String(20)) - visibility = Column(String(20)) - wind = Column(String(20)) - terrain = Column(ARRAY(String)) - - # GPS - tnp_lat = Column(Float) - tnp_lon = Column(Float) - tnp_address = Column(Text) - - # Ресурсы - teams_count = Column(Integer) - team_size = Column(Integer) - has_dog = Column(Boolean, default=False) - extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal - - # Исход (заполняется после завершения) - found_alive = Column(Boolean) - found_distance_km = Column(Float) - found_direction = Column(String(10)) - found_location_type = Column(String(50)) # forest/road/building/water/field - found_lat = Column(Float) - found_lon = Column(Float) - search_duration_hours = Column(Float) - who_found = Column(String(50)) # mchs/mvd/volunteers/self - - # Мета - confidence_avg = Column(Float) - raw_text = Column(Text) - analysis_log = Column(JSONB) - - -class RawDocument(Base): - """Raw document storage for parsed reports""" - __tablename__ = "raw_documents" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - filename = Column(String(255), nullable=False) - raw_text = Column(Text) - extracted_json = Column(JSONB) - created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/models_new.py b/backend/models_new.py deleted file mode 100644 index 694b0f2..0000000 --- a/backend/models_new.py +++ /dev/null @@ -1,89 +0,0 @@ -from sqlalchemy import Column, String, Float, Boolean, DateTime, Text, ARRAY -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.sql import func -import uuid -from database import Base - - -class Case(Base): - """Unified case model - combines search case and result""" - __tablename__ = "cases" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - created_at = Column(DateTime, server_default=func.now()) - status = Column(String(20), default='active') # active/closed/archived - - # Ребёнок - child_name = Column(String(255)) - age_years = Column(Integer, nullable=False) - gender = Column(String(1)) # М / Ж - clothes_description = Column(Text) - special_marks = Column(Text) - phone_status = Column(String(20)) # answers/silent/none - - # Здоровье - has_diagnosis = Column(Boolean, default=False) - diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР... - fitness_level = Column(String(20)) # low/medium/high - has_transport = Column(String(20), default='none') # none/bike/scooter/other - cant_swim = Column(Boolean, default=False) - - # Психотип - psychotype = Column(String(50)) # dominant/harmonic/anxious/... - psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса - - # Обстоятельства - loss_reason = Column(String(100)) - loss_time = Column(DateTime) - elapsed_hours = Column(Float) - last_seen_direction = Column(String(10)) - last_seen_reliability = Column(String(20)) # exact/approx/unknown - last_seen_description = Column(Text) - behavior_description = Column(Text) - familiar_places = Column(Text) - lost_before = Column(String(20)) # yes/no/unknown - - # Среда - season = Column(String(20)) - temperature_c = Column(Float) - precipitation = Column(String(20)) - visibility = Column(String(20)) - wind = Column(String(20)) - terrain = Column(ARRAY(String)) - - # GPS - tnp_lat = Column(Float) - tnp_lon = Column(Float) - tnp_address = Column(Text) - - # Ресурсы - teams_count = Column(Integer) - team_size = Column(Integer) - has_dog = Column(Boolean, default=False) - extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal - - # Исход (заполняется после завершения) - found_alive = Column(Boolean) - found_distance_km = Column(Float) - found_direction = Column(String(10)) - found_location_type = Column(String(50)) # forest/road/building/water/field - found_lat = Column(Float) - found_lon = Column(Float) - search_duration_hours = Column(Float) - who_found = Column(String(50)) # mchs/mvd/volunteers/self - - # Мета - confidence_avg = Column(Float) - raw_text = Column(Text) - analysis_log = Column(JSONB) - - -class RawDocument(Base): - """Raw document storage for parsed reports""" - __tablename__ = "raw_documents" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - filename = Column(String(255), nullable=False) - raw_text = Column(Text) - extracted_json = Column(JSONB) - created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/models_updated.py b/backend/models_updated.py deleted file mode 100644 index a8a882d..0000000 --- a/backend/models_updated.py +++ /dev/null @@ -1,93 +0,0 @@ -from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.sql import func -import uuid -from database import Base - - -class Case(Base): - Unified case model - combines search case and result - __tablename__ = "cases" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - created_at = Column(DateTime, server_default=func.now()) - status = Column(String(20), default="active") # active/closed/archived - - # Ребёнок (Шаг 1) - child_name = Column(String(255)) - age_years = Column(Integer, nullable=False) - gender = Column(String(1)) # М / Ж - height_build = Column(Text) # Рост / телосложение - clothes_upper = Column(Text) # Одежда: верх (цвет, тип) - clothes_lower = Column(Text) # Одежда: низ (цвет, тип) - shoes = Column(Text) # Обувь (тип, цвет) - clothes_description = Column(Text) # Общее описание одежды (legacy) - special_marks = Column(Text) # Особые приметы - phone_status = Column(String(20)) # answers/silent/none - - # Здоровье (Шаг 2) - has_diagnosis = Column(Boolean, default=False) - diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР, слабое зрение, слабый слух... - fitness_level = Column(String(20)) # low/medium/high - has_transport = Column(String(20), default="none") # none/bike/scooter/other - cant_swim = Column(Boolean, default=False) - - # Психотип (Шаг 2б) - psychotype = Column(String(50)) # dominant/harmonic/anxious/introvert_passive/introvert_active - psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса - - # Обстоятельства (Шаг 3) - loss_reason = Column(String(100)) # потерялся в лесу/ушёл из дома/в городе/не вернулся с прогулки/на мероприятии/другое - loss_time = Column(DateTime) - elapsed_hours = Column(Float) - last_seen_direction = Column(String(10)) # С/СВ/В/ЮВ/Ю/ЮЗ/З/СЗ/неизвестно - last_seen_reliability = Column(String(20)) # exact/approx/unknown - last_seen_description = Column(Text) - behavior_description = Column(Text) # Поведение при стрессе - familiar_places = Column(Text) # Знакомые места - lost_before = Column(String(20)) # yes/no/unknown - - # Среда (Шаг 4) - season = Column(String(20)) # зима/весна/лето/осень - temperature_c = Column(Float) - precipitation = Column(String(20)) # нет/морось/дождь/ливень/снег/гроза/туман - visibility = Column(String(20)) # хорошая/ограниченная/плохая - wind = Column(String(20)) # штиль/слабый/умеренный/сильный - terrain = Column(ARRAY(String)) # густой лес, редкий лес, лесная дорога, поле, болото, водоём, город... - - # GPS (Шаг 4) - tnp_lat = Column(Float) - tnp_lon = Column(Float) - tnp_address = Column(Text) - - # Ресурсы (Шаг 5) - teams_count = Column(Integer) - team_size = Column(Integer) - has_dog = Column(Boolean, default=False) - extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal/quadbike - - # Исход (заполняется после завершения) - found_alive = Column(Boolean) - found_distance_km = Column(Float) - found_direction = Column(String(10)) - found_location_type = Column(String(50)) # forest/road/building/water/field - found_lat = Column(Float) - found_lon = Column(Float) - search_duration_hours = Column(Float) - who_found = Column(String(50)) # mchs/mvd/volunteers/self - - # Мета - confidence_avg = Column(Float) - raw_text = Column(Text) - analysis_log = Column(JSONB) - - -class RawDocument(Base): - """Raw document storage for parsed reports""" - __tablename__ = "raw_documents" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - filename = Column(String(255), nullable=False) - raw_text = Column(Text) - extracted_json = Column(JSONB) - created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/services/geo_service.py.bak b/backend/services/geo_service.py.bak deleted file mode 100644 index 3c8030f..0000000 --- a/backend/services/geo_service.py.bak +++ /dev/null @@ -1,377 +0,0 @@ -""" -Geo service for building search zones and querying OpenStreetMap data via Overpass API. -""" -import math -import json -import hashlib -from datetime import datetime, timedelta -from pathlib import Path -from typing import List, Dict, Optional, Tuple -import httpx -from pydantic import BaseModel - - -class Zone(BaseModel): - """Search zone with geographic features.""" - direction: str # N, NE, E, SE, S, SW, W, NW - distance_km: float - forest_pct: float - road_density: float # km of roads per km² - water_distance_km: Optional[float] - settlement_distance_km: Optional[float] - - -# Cache configuration -CACHE_DIR = Path("/tmp/overpass_cache") -CACHE_TTL_HOURS = 24 -OVERPASS_URL = "https://overpass-api.de/api/interpreter" - -# Direction mappings -DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] -DIRECTION_ANGLES = { - "N": 0, - "NE": 45, - "E": 90, - "SE": 135, - "S": 180, - "SW": 225, - "W": 270, - "NW": 315 -} - -# Search distances in meters -SEARCH_DISTANCES = [500, 1000, 2000, 5000] - - -def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """ - Calculate distance between two points on Earth using Haversine formula. - - Args: - lat1, lon1: First point coordinates - lat2, lon2: Second point coordinates - - Returns: - Distance in kilometers - """ - R = 6371 # Earth radius in km - - lat1_rad = math.radians(lat1) - lat2_rad = math.radians(lat2) - dlat = math.radians(lat2 - lat1) - dlon = math.radians(lon2 - lon1) - - a = (math.sin(dlat / 2) ** 2 + - math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2) - c = 2 * math.asin(math.sqrt(a)) - - return R * c - - -def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]: - """ - Calculate bounding box for a sector. - - Args: - lat, lon: Center point - direction: Sector direction (N, NE, E, etc.) - radius_m: Radius in meters - - Returns: - (min_lat, min_lon, max_lat, max_lon) - """ - # Convert radius to degrees (approximate) - radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator - - angle = DIRECTION_ANGLES[direction] - angle_rad = math.radians(angle) - - # Calculate sector boundaries (45° sectors) - angle_start = angle - 22.5 - angle_end = angle + 22.5 - - # Simple bounding box (can be optimized for actual sector shape) - lat_offset = radius_deg * math.cos(angle_rad) - lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat)) - - min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5 - max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5 - min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5 - max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5 - - return (min_lat, min_lon, max_lat, max_lon) - - -def get_cache_key(query: str) -> str: - """Generate cache key from query.""" - return hashlib.md5(query.encode()).hexdigest() - - -def get_cached_result(cache_key: str) -> Optional[Dict]: - """Get cached Overpass API result if not expired.""" - CACHE_DIR.mkdir(exist_ok=True) - cache_file = CACHE_DIR / f"{cache_key}.json" - - if not cache_file.exists(): - return None - - try: - with open(cache_file, 'r') as f: - cached = json.load(f) - - cached_time = datetime.fromisoformat(cached['timestamp']) - if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS): - cache_file.unlink() - return None - - return cached['data'] - except Exception: - return None - - -def save_to_cache(cache_key: str, data: Dict): - """Save Overpass API result to cache.""" - CACHE_DIR.mkdir(exist_ok=True) - cache_file = CACHE_DIR / f"{cache_key}.json" - - try: - with open(cache_file, 'w') as f: - json.dump({ - 'timestamp': datetime.now().isoformat(), - 'data': data - }, f) - except Exception: - pass - - -async def query_overpass(query: str) -> Dict: - """ - Query Overpass API with caching. - - Args: - query: Overpass QL query - - Returns: - API response as dict - """ - cache_key = get_cache_key(query) - - # Check cache - cached = get_cached_result(cache_key) - if cached is not None: - return cached - - # Query API - async with httpx.AsyncClient(timeout=30.0) as client: - try: - response = await client.post( - OVERPASS_URL, - data={'data': query}, - headers={'Content-Type': 'application/x-www-form-urlencoded'} - ) - response.raise_for_status() - data = response.json() - - # Save to cache - save_to_cache(cache_key, data) - - return data - except Exception as e: - # Return empty result on error - return {'elements': []} - - -def calculate_road_length(elements: List[Dict]) -> float: - """ - Calculate total road length from Overpass way elements. - - Args: - elements: List of way elements from Overpass - - Returns: - Total length in kilometers - """ - total_length = 0.0 - - for element in elements: - if element.get('type') != 'way': - continue - - nodes = element.get('geometry', []) - if len(nodes) < 2: - continue - - # Calculate length by summing distances between consecutive nodes - for i in range(len(nodes) - 1): - lat1, lon1 = nodes[i]['lat'], nodes[i]['lon'] - lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon'] - total_length += haversine(lat1, lon1, lat2, lon2) - - return total_length - - -def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]: - """ - Find distance to nearest element. - - Args: - lat, lon: Reference point - elements: List of node elements from Overpass - - Returns: - Distance in kilometers, or None if no elements - """ - if not elements: - return None - - min_distance = float('inf') - - for element in elements: - if element.get('type') != 'node': - continue - - elem_lat = element.get('lat') - elem_lon = element.get('lon') - - if elem_lat is None or elem_lon is None: - continue - - distance = haversine(lat, lon, elem_lat, elem_lon) - min_distance = min(min_distance, distance) - - return min_distance if min_distance != float('inf') else None - - -def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float: - """ - Estimate forest coverage percentage. - - Args: - elements: List of way elements from Overpass - radius_m: Search radius in meters - - Returns: - Forest coverage as percentage (0-100) - """ - if not elements: - return 0.0 - - # Approximate: count forest ways and estimate coverage - # This is a simplified calculation - forest_ways = len([e for e in elements if e.get('type') == 'way']) - - # Rough heuristic: each forest way covers ~0.1 km² - # Total search area = π * r² - search_area_km2 = math.pi * (radius_m / 1000) ** 2 - estimated_forest_km2 = forest_ways * 0.1 - - coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100) - - return round(coverage_pct, 1) - - -async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict: - """ - Get geographic features for a zone using Overpass API. - - Args: - lat, lon: Center point - direction: Sector direction - radius_m: Search radius in meters - - Returns: - Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct - """ - # Query roads - roads_query = f""" - [out:json]; - ( - way[highway](around:{radius_m},{lat},{lon}); - ); - out geom; - """ - roads_data = await query_overpass(roads_query) - roads_km = calculate_road_length(roads_data.get('elements', [])) - - # Query water bodies - water_query = f""" - [out:json]; - ( - node[natural=water](around:{radius_m},{lat},{lon}); - way[natural=water](around:{radius_m},{lat},{lon}); - ); - out center; - """ - water_data = await query_overpass(water_query) - water_distance = find_nearest_distance(lat, lon, water_data.get('elements', [])) - - # Query settlements - settlement_query = f""" - [out:json]; - ( - node[place~"village|town|city"](around:{radius_m},{lat},{lon}); - ); - out; - """ - settlement_data = await query_overpass(settlement_query) - settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', [])) - - # Query forests - forest_query = f""" - [out:json]; - ( - way[landuse=forest](around:{radius_m},{lat},{lon}); - way[natural=wood](around:{radius_m},{lat},{lon}); - ); - out geom; - """ - forest_data = await query_overpass(forest_query) - forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m) - - # Calculate road density (km of roads per km²) - search_area_km2 = math.pi * (radius_m / 1000) ** 2 - road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0 - - return { - 'roads_km': roads_km, - 'road_density': round(road_density, 2), - 'water_distance_km': water_distance, - 'settlement_distance_km': settlement_distance, - 'forest_pct': forest_pct - } - - -async def build_search_zones(lat: float, lon: float, case_data: dict) -> List[Zone]: - """ - Build search zones around a point. - - Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances - (500m, 1000m, 2000m, 5000m) and queries geographic features for each. - - Args: - lat: Latitude of search origin - lon: Longitude of search origin - case_data: Case information (for future enhancements) - - Returns: - List of Zone objects with geographic features - """ - zones = [] - - for distance_m in SEARCH_DISTANCES: - for direction in DIRECTIONS: - # Get features for this zone - features = await get_zone_features(lat, lon, direction, distance_m) - - zone = Zone( - direction=direction, - distance_km=distance_m / 1000, - forest_pct=features['forest_pct'], - road_density=features['road_density'], - water_distance_km=features['water_distance_km'], - settlement_distance_km=features['settlement_distance_km'] - ) - - zones.append(zone) - - return zones diff --git a/backend/services/scoring_service.py.bak b/backend/services/scoring_service.py.bak deleted file mode 100644 index c83c2fa..0000000 --- a/backend/services/scoring_service.py.bak +++ /dev/null @@ -1,403 +0,0 @@ -""" -Сервис оценки и ранжирования зон поиска на основе взвешенных факторов. -Реализация согласно §8 и §9 контекста ВЕКТОР. -""" - -from typing import Dict, List, Optional -from copy import deepcopy - - -class WeightedScorer: - """ - Система взвешенной оценки зон поиска с учетом множественных факторов. - Базовые веса из §9 контекста. - """ - - # Базовые веса факторов из §9 (сумма = 1.0) - BASE_WEIGHTS = { - 'forest': 0.25, - 'water': 0.20, - 'roads': 0.18, - 'settlement': 0.15, - 'historical': 0.12, - 'direction': 0.07, - 'shelter': 0.03 - } - - # Возрастные модификаторы - AGE_MODIFIERS = { - '0-4': { - 'forest': 0.6, - 'water': 2.5, - 'roads': 1.3, - 'settlement': 1.8, - 'shelter': 1.5, - 'distance_mult': 0.3 - }, - '5-7': { - 'forest': 0.8, - 'water': 2.2, - 'roads': 1.4, - 'settlement': 1.6, - 'shelter': 1.4, - 'distance_mult': 0.5 - }, - '8-11': { - 'forest': 1.1, - 'water': 1.8, - 'roads': 1.2, - 'settlement': 1.3, - 'shelter': 1.2, - 'distance_mult': 0.8 - }, - '12-14': { - 'forest': 1.3, - 'water': 1.4, - 'roads': 1.1, - 'settlement': 1.0, - 'shelter': 1.0, - 'distance_mult': 1.2 - }, - '15-17': { - 'forest': 1.4, - 'water': 1.2, - 'roads': 1.3, - 'settlement': 0.9, - 'shelter': 0.9, - 'distance_mult': 1.5 - } - } - - # Сезонные модификаторы - SEASON_MODIFIERS = { - 'зима': { - 'forest': 0.8, - 'water': 0.6, - 'roads': 1.3, - 'settlement': 1.5, - 'shelter': 2.0, - 'distance_mult': 0.7 - }, - 'весна': { - 'forest': 1.1, - 'water': 1.8, - 'roads': 1.0, - 'settlement': 1.0, - 'shelter': 1.2, - 'distance_mult': 1.0 - }, - 'лето': { - 'forest': 1.2, - 'water': 1.3, - 'roads': 0.9, - 'settlement': 0.8, - 'shelter': 0.8, - 'distance_mult': 1.3 - }, - 'осень': { - 'forest': 1.3, - 'water': 1.1, - 'roads': 1.0, - 'settlement': 1.1, - 'shelter': 1.1, - 'distance_mult': 1.0 - } - } - - # Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста - BEHAVIORAL_PROFILES = { - 'РАС': { - 'water': 3.0, - 'railway': 2.5, - 'shelter': 2.0, - 'settlement': 0.4, - 'distance_mult': 2.0, - 'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.' - }, - 'эпилепсия': { - 'water': 3.5, - 'shelter': 2.5, - 'distance_mult': 0.6, - 'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.' - }, - 'СДВГ': { - 'roads': 1.6, - 'distance_mult': 1.4, - 'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.' - }, - 'ЗПР': { - 'settlement': 0.7, - 'shelter': 1.5, - 'distance_mult': 0.8, - 'note': 'Не ориентируется в пространстве' - }, - 'велосипед': { - 'distance_mult': 5.0, - 'roads': 1.8, - 'forest': 0.8, - 'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.' - }, - 'самокат': { - 'distance_mult': 3.0, - 'roads': 1.6, - 'forest': 0.9 - }, - 'намеренный_уход': { - 'forest': 0.2, - 'roads': 2.5, - 'settlement': 3.0, - 'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.' - } - } - - def __init__(self): - """Инициализация скорера с базовыми весами.""" - self.weights = deepcopy(self.BASE_WEIGHTS) - self.distance_multiplier = 1.0 - self.active_profiles = [] - self.critical_warnings = [] - - def _get_age_group(self, age: int) -> str: - """Определяет возрастную группу.""" - if age <= 4: - return '0-4' - elif age <= 7: - return '5-7' - elif age <= 11: - return '8-11' - elif age <= 14: - return '12-14' - elif age <= 17: - return '15-17' - else: - return '18-64' - - def apply_age_modifiers(self, age: int): - """Применяет возрастные модификаторы к весам.""" - age_group = self._get_age_group(age) - modifiers = self.AGE_MODIFIERS.get(age_group, {}) - - for factor, modifier in modifiers.items(): - if factor == 'distance_mult': - self.distance_multiplier *= modifier - elif factor in self.weights: - self.weights[factor] *= modifier - - def apply_season_modifiers(self, season: str): - """Применяет сезонные модификаторы к весам.""" - season_lower = season.lower() if season else 'лето' - modifiers = self.SEASON_MODIFIERS.get(season_lower, {}) - - for factor, modifier in modifiers.items(): - if factor == 'distance_mult': - self.distance_multiplier *= modifier - elif factor in self.weights: - self.weights[factor] *= modifier - - def apply_profile(self, profile_list: List[str]): - """ - Применяет поведенческие профили к весам. - Точные коэффициенты из §8 контекста. - - Args: - profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.) - """ - if not profile_list: - return - - for profile_name in profile_list: - profile = self.BEHAVIORAL_PROFILES.get(profile_name) - if not profile: - continue - - self.active_profiles.append(profile_name) - - # Сохранить критические предупреждения - if 'critical_warning' in profile: - self.critical_warnings.append({ - 'profile': profile_name, - 'warning': profile['critical_warning'] - }) - - for factor, modifier in profile.items(): - if factor in ['critical_warning', 'note']: - continue - elif factor == 'distance_mult': - self.distance_multiplier *= modifier - elif factor in self.weights: - self.weights[factor] *= modifier - elif factor == 'railway': - # Ж/д пути — добавляем как отдельный фактор для РАС - if 'railway' not in self.weights: - self.weights['railway'] = 0.05 - self.weights['railway'] *= modifier - - def _normalize_weights(self): - """Нормализует веса так, чтобы их сумма была 1.0.""" - # Фильтруем None значения - valid_weights = {k: v for k, v in self.weights.items() if v is not None} - total = sum(valid_weights.values()) - - if total > 0: - for key in self.weights: - if self.weights[key] is not None: - self.weights[key] /= total - else: - self.weights[key] = 0.0 - - def score_zone(self, zone: Dict, case: Dict) -> float: - """ - Оценивает зону поиска на основе её характеристик и данных случая. - - Args: - zone: Словарь с характеристиками зоны - case: Данные случая (age, season, profiles и т.д.) - - Returns: - float: Оценка зоны (0-100) - """ - # Сбрасываем веса к базовым - self.weights = deepcopy(self.BASE_WEIGHTS) - self.distance_multiplier = 1.0 - self.active_profiles = [] - self.critical_warnings = [] - - # Применяем модификаторы - if 'age' in case and case['age']: - self.apply_age_modifiers(case['age']) - - if 'season' in case and case['season']: - self.apply_season_modifiers(case['season']) - - if 'profiles' in case and case['profiles']: - self.apply_profile(case['profiles']) - - # Нормализуем веса - self._normalize_weights() - - # Вычисляем оценку - score = 0.0 - - # Лес - forest_score = zone.get('forest_pct', 0.5) - score += self.weights['forest'] * forest_score - - # Вода (чем ближе, тем важнее) - water_dist = zone.get('water_distance_km', 5.0) - water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5 - score += self.weights['water'] * water_score - - # Дороги - road_density = zone.get('road_density', 0.5) - road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5 - score += self.weights['roads'] * road_score - - # Населенные пункты - settlement_dist = zone.get('settlement_distance_km', 10.0) - settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5 - score += self.weights['settlement'] * settlement_score - - # Историческая частота - historical_score = zone.get('historical_freq', 0.5) - score += self.weights['historical'] * historical_score - - # Совпадение направления - direction_score = zone.get('direction_match', 0.5) - score += self.weights['direction'] * direction_score - - # Укрытия - shelter_score = zone.get('shelter_pct', 0.3) - score += self.weights['shelter'] * shelter_score - - # Ж/д пути (для РАС) - if 'railway' in self.weights: - railway_dist = zone.get('railway_distance_km', 10.0) - railway_score = max(0, 1.0 - (railway_dist / 5.0)) - score += self.weights['railway'] * railway_score - - # Применяем множитель расстояния - zone_distance = zone.get('distance_km', 1.0) - expected_distance = 2.0 * self.distance_multiplier - distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2) - distance_factor = max(0.3, min(1.0, distance_factor)) - - score *= distance_factor - - # Конвертируем в шкалу 0-100 - return round(score * 100, 2) - - def rank_zones(self, zones: List[Dict], case: Dict) -> List[Dict]: - """ - Ранжирует зоны по приоритету на основе оценок. - - Args: - zones: Список зон с характеристиками - case: Данные случая - - Returns: - List[Dict]: Отсортированный список зон с оценками и приоритетами - """ - scored_zones = [] - for zone in zones: - zone_copy = deepcopy(zone) - zone_copy['score'] = self.score_zone(zone, case) - scored_zones.append(zone_copy) - - scored_zones.sort(key=lambda x: x['score'], reverse=True) - - for i, zone in enumerate(scored_zones): - zone['priority'] = i + 1 - - return scored_zones - - def get_active_profiles_info(self) -> List[Dict]: - """Возвращает информацию об активных профилях с предупреждениями.""" - profiles_info = [] - - for profile_name in self.active_profiles: - profile = self.BEHAVIORAL_PROFILES.get(profile_name, {}) - info = { - 'name': profile_name, - 'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']}, - } - - if 'critical_warning' in profile: - info['critical_warning'] = profile['critical_warning'] - if 'note' in profile: - info['note'] = profile['note'] - - profiles_info.append(info) - - return profiles_info - - -def create_scorer_for_case(case: Dict) -> WeightedScorer: - """Создает и настраивает скорер для конкретного случая.""" - scorer = WeightedScorer() - - if 'age' in case and case['age']: - scorer.apply_age_modifiers(case['age']) - - if 'season' in case and case['season']: - scorer.apply_season_modifiers(case['season']) - - if 'profiles' in case and case['profiles']: - scorer.apply_profile(case['profiles']) - - scorer._normalize_weights() - - return scorer - - -def get_weight_explanation(case: Dict) -> Dict: - """Возвращает объяснение весов для данного случая.""" - scorer = create_scorer_for_case(case) - - return { - 'weights': scorer.weights, - 'distance_multiplier': scorer.distance_multiplier, - 'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None, - 'season': case.get('season'), - 'profiles': scorer.get_active_profiles_info(), - 'critical_warnings': scorer.critical_warnings - }