P1 auth, CORS, and SQL filtering
This commit is contained in:
+11
-3
@@ -5,7 +5,7 @@ from statistics import median
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
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
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -210,10 +210,18 @@ class SQLCaseRepository:
|
|||||||
session.refresh(case)
|
session.refresh(case)
|
||||||
return CaseDTO(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()
|
Case = _case_model()
|
||||||
with SessionLocal() as session:
|
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]
|
return [CaseDTO(case) for case in cases]
|
||||||
|
|
||||||
def get_case(self, case_id: str) -> CaseDTO | None:
|
def get_case(self, case_id: str) -> CaseDTO | None:
|
||||||
|
|||||||
+19
-2
@@ -1,19 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from backend.database import init_db
|
from backend.database import init_db
|
||||||
from backend.routers.admin import router as admin_router
|
from backend.routers.admin import router as admin_router
|
||||||
from backend.routers.analyze import router as analyze_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.cases import router as cases_router
|
||||||
from backend.routers.health import router as health_router
|
from backend.routers.health import router as health_router
|
||||||
from backend.routers.stats import router as stats_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 = FastAPI(title='Vector API', version='0.1.0')
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=['*'],
|
allow_origins=cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=allow_credentials,
|
||||||
allow_methods=['*'],
|
allow_methods=['*'],
|
||||||
allow_headers=['*'],
|
allow_headers=['*'],
|
||||||
)
|
)
|
||||||
@@ -25,6 +41,7 @@ def startup() -> None:
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
app.include_router(analyze_router)
|
app.include_router(analyze_router)
|
||||||
app.include_router(cases_router)
|
app.include_router(cases_router)
|
||||||
app.include_router(stats_router)
|
app.include_router(stats_router)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import re
|
import re
|
||||||
@@ -7,7 +8,7 @@ import zipfile
|
|||||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||||
|
|
||||||
from backend.database import db
|
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'])
|
router = APIRouter(prefix='/api/v1/admin', tags=['admin'])
|
||||||
|
|
||||||
|
|||||||
+142
-4
@@ -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 = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
|
||||||
|
|
||||||
|
|
||||||
@router.post('')
|
class AnalysisRequest(BaseModel):
|
||||||
def analyze_stub() -> dict:
|
case_id: UUID | None = None
|
||||||
return {'status': 'ok'}
|
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'],
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from backend.database import db
|
from backend.database import db
|
||||||
|
|||||||
@@ -1,23 +1 @@
|
|||||||
from .claude_service import analyze_case
|
# Package marker only.
|
||||||
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"
|
|
||||||
]
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
# Test-time compatibility package.n
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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': 'Нет, осторожный'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user