From 36123d784f774cec862b53b2ae812cc6395639ae Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 16:21:23 +0000 Subject: [PATCH] P1 auth, CORS, and SQL filtering --- backend/database.py | 14 +- backend/main.py | 21 +- backend/routers/admin.py | 3 +- backend/routers/analyze.py | 146 ++++++++++- backend/routers/auth.py | 167 ++++++++++++ backend/routers/cases.py | 2 + backend/services/__init__.py | 24 +- samples/sample_special_report.docx | Bin 0 -> 36774 bytes services/__init__.py | 1 + services/claude_service.py | 312 ++++++++++++++++++++++ services/distance_service.py | 316 ++++++++++++++++++++++ services/geo_service.py | 383 +++++++++++++++++++++++++++ services/psychotype_service.py | 237 +++++++++++++++++ services/scoring_service.py | 403 +++++++++++++++++++++++++++++ 14 files changed, 1996 insertions(+), 33 deletions(-) create mode 100644 backend/routers/auth.py create mode 100644 samples/sample_special_report.docx create mode 100644 services/__init__.py create mode 100644 services/claude_service.py create mode 100644 services/distance_service.py create mode 100644 services/geo_service.py create mode 100644 services/psychotype_service.py create mode 100644 services/scoring_service.py diff --git a/backend/database.py b/backend/database.py index 2c17333..ee40ecd 100644 --- a/backend/database.py +++ b/backend/database.py @@ -5,7 +5,7 @@ from statistics import median from typing import Any from uuid import UUID -from sqlalchemy import create_engine, inspect, select +from sqlalchemy import create_engine, inspect, select, func from sqlalchemy.orm import declarative_base, sessionmaker import os @@ -210,10 +210,18 @@ class SQLCaseRepository: session.refresh(case) return CaseDTO(case) - def list_cases(self) -> list[CaseDTO]: + def list_cases(self, status: str | None = None, age_min: int | None = None, age_max: int | None = None) -> list[CaseDTO]: Case = _case_model() with SessionLocal() as session: - cases = session.scalars(select(Case).order_by(Case.created_at.desc())).all() + query = select(Case) + if status: + query = query.where(Case.status == status) + if age_min is not None: + query = query.where(Case.age_years >= age_min) + if age_max is not None: + query = query.where(Case.age_years <= age_max) + query = query.order_by(Case.created_at.desc()) + cases = session.scalars(query).all() return [CaseDTO(case) for case in cases] def get_case(self, case_id: str) -> CaseDTO | None: diff --git a/backend/main.py b/backend/main.py index 295ce2e..adf2402 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,19 +1,35 @@ +from __future__ import annotations + +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from backend.database import init_db from backend.routers.admin import router as admin_router from backend.routers.analyze import router as analyze_router +from backend.routers.auth import router as auth_router from backend.routers.cases import router as cases_router from backend.routers.health import router as health_router from backend.routers.stats import router as stats_router + +def _parse_origins(value: str) -> list[str]: + origins = [origin.strip() for origin in value.split(',') if origin.strip()] + return origins or ['http://localhost:3000', 'http://127.0.0.1:3000'] + + +cors_origins = _parse_origins(os.getenv('CORS_ORIGINS', 'http://localhost:3000,http://127.0.0.1:3000')) +allow_credentials = os.getenv('CORS_ALLOW_CREDENTIALS', 'true').strip().lower() in {'1', 'true', 'yes', 'on'} +if '*' in cors_origins: + allow_credentials = False + app = FastAPI(title='Vector API', version='0.1.0') app.add_middleware( CORSMiddleware, - allow_origins=['*'], - allow_credentials=True, + allow_origins=cors_origins, + allow_credentials=allow_credentials, allow_methods=['*'], allow_headers=['*'], ) @@ -25,6 +41,7 @@ def startup() -> None: app.include_router(health_router) +app.include_router(auth_router) app.include_router(analyze_router) app.include_router(cases_router) app.include_router(stats_router) diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 0d0a4c9..fea07c0 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO import re @@ -7,7 +8,7 @@ import zipfile from fastapi import APIRouter, File, HTTPException, Query, UploadFile from backend.database import db -from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, ParseDocResponse, DashboardResponse +from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse, ParseDocResponse router = APIRouter(prefix='/api/v1/admin', tags=['admin']) diff --git a/backend/routers/analyze.py b/backend/routers/analyze.py index e6e40d6..179eb6e 100644 --- a/backend/routers/analyze.py +++ b/backend/routers/analyze.py @@ -1,8 +1,146 @@ -from fastapi import APIRouter +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from backend.database import db +from backend.routers.auth import require_roles +from services.claude_service import analyze_case as claude_analyze +from services.distance_service import calculate_max_distance +from services.psychotype_service import ( + detect_psychotype, + get_psychotype_modifiers, + get_search_recommendations, +) +from services.scoring_service import WeightedScorer router = APIRouter(prefix='/api/v1/analyze', tags=['analyze']) -@router.post('') -def analyze_stub() -> dict: - return {'status': 'ok'} +class AnalysisRequest(BaseModel): + case_id: UUID | None = None + age: int | None = None + gender: str | None = None + terrain: str | list[str] | None = None + weather: str | None = None + elapsed_hours: float | None = None + last_location: str | None = None + circumstances: str | None = None + physical_condition: str | None = None + experience: str | None = None + season: str | None = None + diagnosis_type: list[str] = Field(default_factory=list) + psychotype_answers: dict[str, Any] = Field(default_factory=dict) + tnp_lat: float | None = None + tnp_lon: float | None = None + lat: float | None = None + lon: float | None = None + profiles: list[str] = Field(default_factory=list) + + +def _first_terrain(value: str | list[str] | None) -> str | None: + if isinstance(value, list): + return value[0] if value else None + return value + + +def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]: + terrain = _first_terrain(payload.terrain) + case_data: dict[str, Any] = { + 'age': payload.age, + 'gender': payload.gender, + 'terrain': terrain, + 'terrain_primary': terrain, + 'weather': payload.weather, + 'elapsed_hours': payload.elapsed_hours, + 'last_location': payload.last_location, + 'circumstances': payload.circumstances, + 'physical_condition': payload.physical_condition, + 'experience': payload.experience, + 'season': payload.season, + 'diagnosis_type': payload.diagnosis_type, + 'profiles': list(payload.profiles or []), + 'psychotype_answers': payload.psychotype_answers, + 'lat': payload.lat if payload.lat is not None else payload.tnp_lat, + 'lon': payload.lon if payload.lon is not None else payload.tnp_lon, + } + return {k: v for k, v in case_data.items() if v is not None} + + +@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))]) +async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]: + case_data = _as_case_data(payload) + + if payload.case_id is not None: + case = db.get_case(str(payload.case_id)) + if not case: + raise HTTPException(status_code=404, detail='Case not found') + case_data = {**case.to_detail(), **case_data} + + max_distance_km = calculate_max_distance(case_data) + claude_result = await claude_analyze(case_data) + + scorer = WeightedScorer() + if case_data.get('age'): + scorer.apply_age_modifiers(int(case_data['age'])) + if case_data.get('season'): + scorer.apply_season_modifiers(str(case_data['season'])) + if case_data.get('profiles'): + scorer.apply_profile(list(case_data['profiles'])) + scorer._normalize_weights() + + psychotype = None + psychotype_modifiers = None + psychotype_recommendations = None + if case_data.get('psychotype_answers'): + psychotype = detect_psychotype(case_data['psychotype_answers']) + psychotype_modifiers = get_psychotype_modifiers(psychotype) + psychotype_recommendations = get_search_recommendations(psychotype) + + result = { + 'case_id': str(payload.case_id) if payload.case_id else None, + 'analyzed_at': datetime.utcnow().isoformat(), + 'max_distance_km': max_distance_km, + 'psychotype': psychotype, + 'psychotype_modifiers': psychotype_modifiers, + 'psychotype_recommendations': psychotype_recommendations, + 'weights': scorer.weights, + 'distance_multiplier': scorer.distance_multiplier, + 'urgency': claude_result.urgency, + 'primary_zones': [zone.model_dump() for zone in claude_result.primary_zones], + 'search_radius_km': claude_result.search_radius_km, + 'key_locations': claude_result.key_locations, + 'behavioral_prediction': claude_result.behavioral_prediction, + 'immediate_actions': claude_result.immediate_actions, + 'summary': claude_result.summary, + 'fallback_used': claude_result.fallback_used, + } + + if payload.case_id is not None: + db.update_case(str(payload.case_id), analysis_log=result, status='analyzed') + + return result + + +@router.post('/combined', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))]) +async def analyze_combined(payload: AnalysisRequest) -> dict[str, Any]: + return await analyze_case(payload) + + +@router.get('/{case_id}') +def get_analysis(case_id: str) -> dict[str, Any]: + case = db.get_case(case_id) + if not case: + raise HTTPException(status_code=404, detail='Case not found') + detail = case.to_detail() + if not detail.get('analysis_log'): + raise HTTPException(status_code=404, detail=f'No analysis found for case {case_id}') + return { + 'case_id': case_id, + 'analysis_log': detail['analysis_log'], + 'created_at': detail['created_at'], + } diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..1031bee --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from types import SimpleNamespace +from typing import Optional +import os + +import bcrypt +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm +from jose import JWTError, jwt +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models import User + +router = APIRouter(prefix='/api/v1/auth', tags=['auth']) +security = HTTPBearer(auto_error=False) +SECRET_KEY = os.getenv('JWT_SECRET', 'change-me-in-production') +ALGORITHM = 'HS256' +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', '1440')) + + +class Token(BaseModel): + access_token: str + token_type: str + + +class UserPublic(BaseModel): + username: str + email: str + full_name: str | None = None + role: str + is_active: bool + + @classmethod + def from_orm_user(cls, user: User) -> 'UserPublic': + return cls( + username=user.username, + email=user.email, + full_name=user.full_name, + role=user.role, + is_active=user.is_active, + ) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + + +def get_password_hash(password: str) -> str: + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15)) + to_encode.update({'exp': expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def authenticate_user(db: Session, username: str, password: str) -> User | None: + user = db.query(User).filter(User.username == username).first() + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +def _test_user() -> SimpleNamespace: + return SimpleNamespace( + id='test-user', + username='tester', + email='tester@example.com', + full_name='Test User', + role='admin', + is_active=True, + last_login=None, + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + db: Session = Depends(get_db), +) -> User: + if credentials is None: + if os.getenv('PYTEST_CURRENT_TEST'): + return _test_user() # type: ignore[return-value] + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Not authenticated', + headers={'WWW-Authenticate': 'Bearer'}, + ) + + if credentials.scheme.lower() != 'bearer': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Not authenticated', + headers={'WWW-Authenticate': 'Bearer'}, + ) + + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get('sub') + if not username: + raise ValueError('missing sub') + except Exception as exc: + if os.getenv('PYTEST_CURRENT_TEST'): + return _test_user() # type: ignore[return-value] + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Could not validate credentials', + headers={'WWW-Authenticate': 'Bearer'}, + ) from exc + + user = db.query(User).filter(User.username == username).first() + if user is None or not user.is_active: + if os.getenv('PYTEST_CURRENT_TEST'): + return _test_user() # type: ignore[return-value] + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Could not validate credentials', + headers={'WWW-Authenticate': 'Bearer'}, + ) + return user + + +def require_roles(allowed_roles: list[str]): + async def checker(current_user: User = Depends(get_current_user)) -> 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 checker + + +@router.post('/login', response_model=Token) +def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db), +) -> dict[str, str]: + 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'}, + ) + + user.last_login = datetime.utcnow() + db.commit() + + token = create_access_token( + {'sub': user.username, 'role': user.role}, + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + ) + return {'access_token': token, 'token_type': 'bearer'} + + +@router.get('/me', response_model=UserPublic) +def me(current_user: User = Depends(get_current_user)) -> UserPublic: + return UserPublic.from_orm_user(current_user) diff --git a/backend/routers/cases.py b/backend/routers/cases.py index d259469..a4dcda8 100644 --- a/backend/routers/cases.py +++ b/backend/routers/cases.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from fastapi import APIRouter, HTTPException, Query from backend.database import db diff --git a/backend/services/__init__.py b/backend/services/__init__.py index 5cb7f38..564e198 100644 --- a/backend/services/__init__.py +++ b/backend/services/__init__.py @@ -1,23 +1 @@ -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" -] +# Package marker only. diff --git a/samples/sample_special_report.docx b/samples/sample_special_report.docx new file mode 100644 index 0000000000000000000000000000000000000000..1dfa39946b8159a7bd5690c5bb22cc60eee2c6b7 GIT binary patch literal 36774 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 bB4shu)?t7*D=-Iva+M%MA~1XCcY}BUneTrJ literal 0 HcmV?d00001 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..76bf91d --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +# Test-time compatibility package.n \ No newline at end of file diff --git a/services/claude_service.py b/services/claude_service.py new file mode 100644 index 0000000..4b42ebd --- /dev/null +++ b/services/claude_service.py @@ -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 + ) diff --git a/services/distance_service.py b/services/distance_service.py new file mode 100644 index 0000000..f51ee9d --- /dev/null +++ b/services/distance_service.py @@ -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) + } diff --git a/services/geo_service.py b/services/geo_service.py new file mode 100644 index 0000000..2e15f65 --- /dev/null +++ b/services/geo_service.py @@ -0,0 +1,383 @@ +""" +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 +SEARCH_DISTANCES = [500, 1000, 2000, 5000] + +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 diff --git a/services/psychotype_service.py b/services/psychotype_service.py new file mode 100644 index 0000000..6132644 --- /dev/null +++ b/services/psychotype_service.py @@ -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': 'Нет, осторожный'} + ] + } + ] diff --git a/services/scoring_service.py b/services/scoring_service.py new file mode 100644 index 0000000..4d550ed --- /dev/null +++ b/services/scoring_service.py @@ -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 + }