Make backend start on PostgreSQL
This commit is contained in:
+3
-3
@@ -4,11 +4,11 @@ WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y gcc postgresql-client && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
COPY backend ./backend
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
+254
-27
@@ -1,4 +1,11 @@
|
||||
from sqlalchemy import create_engine
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import create_engine, inspect, select
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
import os
|
||||
|
||||
@@ -9,48 +16,268 @@ Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
db_session = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
yield db_session
|
||||
finally:
|
||||
db.close()
|
||||
db_session.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
def init_db() -> None:
|
||||
from backend import models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
class _CompatDB:
|
||||
def __init__(self):
|
||||
self._cases = []
|
||||
def _case_model():
|
||||
from backend.models import Case
|
||||
|
||||
def create_case(self, **data):
|
||||
raise NotImplementedError('compat db is for import only')
|
||||
return Case
|
||||
|
||||
def list_cases(self):
|
||||
return []
|
||||
|
||||
def get_case(self, case_id):
|
||||
def _coerce_uuid(value: str | UUID) -> UUID | None:
|
||||
if isinstance(value, UUID):
|
||||
return value
|
||||
try:
|
||||
return UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def update_case(self, case_id, **patch):
|
||||
return None
|
||||
|
||||
def delete_case(self, case_id):
|
||||
return False
|
||||
def _parse_datetime(value: Any) -> Any:
|
||||
if not isinstance(value, str) or not value:
|
||||
return value
|
||||
try:
|
||||
normalized = value.replace('Z', '+00:00')
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
def stats(self):
|
||||
|
||||
def _normalize_case_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = dict(data)
|
||||
normalized.pop('mobile', None)
|
||||
|
||||
aliases = {
|
||||
'age': 'age_years',
|
||||
'direction': 'last_seen_direction',
|
||||
'weather': 'precipitation',
|
||||
'gps_lat': 'tnp_lat',
|
||||
'gps_lon': 'tnp_lon',
|
||||
'last_known_place': 'tnp_address',
|
||||
'reason': 'loss_reason',
|
||||
'clothes_shoes': 'shoes',
|
||||
}
|
||||
for source, target in aliases.items():
|
||||
if source in normalized and target not in normalized:
|
||||
normalized[target] = normalized[source]
|
||||
|
||||
terrain_primary = normalized.get('terrain_primary')
|
||||
if terrain_primary and not normalized.get('terrain'):
|
||||
normalized['terrain'] = [terrain_primary]
|
||||
|
||||
resources = normalized.get('resources')
|
||||
if resources and not normalized.get('extra_resources'):
|
||||
normalized['extra_resources'] = resources
|
||||
|
||||
health_flags = normalized.get('health_flags')
|
||||
if health_flags and not normalized.get('diagnosis_type'):
|
||||
normalized['diagnosis_type'] = health_flags
|
||||
normalized['has_diagnosis'] = True
|
||||
|
||||
gender = normalized.get('gender')
|
||||
if isinstance(gender, str) and len(gender) > 1:
|
||||
gender_map = {
|
||||
'male': 'm',
|
||||
'female': 'f',
|
||||
'мужской': 'm',
|
||||
'женский': 'f',
|
||||
'м': 'm',
|
||||
'ж': 'f',
|
||||
}
|
||||
normalized['gender'] = gender_map.get(gender.strip().lower(), gender.strip()[:1])
|
||||
|
||||
for field in ('loss_time',):
|
||||
if field in normalized:
|
||||
normalized[field] = _parse_datetime(normalized[field])
|
||||
|
||||
Case = _case_model()
|
||||
columns = set(inspect(Case).columns.keys())
|
||||
return {key: value for key, value in normalized.items() if key in columns}
|
||||
|
||||
|
||||
class CaseDTO:
|
||||
def __init__(self, case: Any):
|
||||
self._case = case
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._case, name)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return str(self._case.id)
|
||||
|
||||
def to_summary(self) -> dict[str, Any]:
|
||||
c = self._case
|
||||
updated_at = c.created_at or datetime.now(timezone.utc)
|
||||
return {
|
||||
'total_cases': 0,
|
||||
'found_alive_count': 0,
|
||||
'found_deceased_count': 0,
|
||||
'unknown_outcome_count': 0,
|
||||
'median_found_distance_km': None,
|
||||
'recent_activity_count': 0,
|
||||
'id': str(c.id),
|
||||
'created_at': c.created_at,
|
||||
'updated_at': updated_at,
|
||||
'age': c.age_years,
|
||||
'age_years': c.age_years,
|
||||
'gender': c.gender,
|
||||
'status': c.status,
|
||||
'found_alive': c.found_alive,
|
||||
'found_distance_km': c.found_distance_km,
|
||||
'found_direction': c.found_direction,
|
||||
'found_location_type': c.found_location_type,
|
||||
'who_found': c.who_found,
|
||||
'last_known_place': c.tnp_address,
|
||||
'direction': c.last_seen_direction,
|
||||
'terrain_primary': c.terrain[0] if c.terrain else None,
|
||||
'weather': c.precipitation,
|
||||
}
|
||||
|
||||
def heatmap(self):
|
||||
return []
|
||||
def to_detail(self) -> dict[str, Any]:
|
||||
c = self._case
|
||||
data = self.to_summary()
|
||||
data.update({
|
||||
'child_name': c.child_name,
|
||||
'height_build': c.height_build,
|
||||
'clothes_upper': c.clothes_upper,
|
||||
'clothes_lower': c.clothes_lower,
|
||||
'shoes': c.shoes,
|
||||
'clothes_description': c.clothes_description,
|
||||
'special_marks': c.special_marks,
|
||||
'phone_status': c.phone_status,
|
||||
'has_diagnosis': c.has_diagnosis,
|
||||
'diagnosis_type': list(c.diagnosis_type or []),
|
||||
'health_flags': list(c.diagnosis_type or []),
|
||||
'fitness_level': c.fitness_level,
|
||||
'has_transport': c.has_transport,
|
||||
'cant_swim': c.cant_swim,
|
||||
'psychotype': c.psychotype,
|
||||
'psychotype_answers': c.psychotype_answers,
|
||||
'loss_reason': c.loss_reason,
|
||||
'reason': c.loss_reason,
|
||||
'loss_time': c.loss_time,
|
||||
'elapsed_hours': c.elapsed_hours,
|
||||
'last_seen_direction': c.last_seen_direction,
|
||||
'last_seen_reliability': c.last_seen_reliability,
|
||||
'last_seen_description': c.last_seen_description,
|
||||
'behavior_description': c.behavior_description,
|
||||
'familiar_places': c.familiar_places,
|
||||
'lost_before': c.lost_before,
|
||||
'season': c.season,
|
||||
'temperature_c': c.temperature_c,
|
||||
'precipitation': c.precipitation,
|
||||
'visibility': c.visibility,
|
||||
'wind': c.wind,
|
||||
'terrain': list(c.terrain or []),
|
||||
'tnp_lat': c.tnp_lat,
|
||||
'tnp_lon': c.tnp_lon,
|
||||
'tnp_address': c.tnp_address,
|
||||
'gps_lat': c.tnp_lat,
|
||||
'gps_lon': c.tnp_lon,
|
||||
'teams_count': c.teams_count,
|
||||
'team_size': c.team_size,
|
||||
'has_dog': c.has_dog,
|
||||
'extra_resources': list(c.extra_resources or []),
|
||||
'resources': list(c.extra_resources or []),
|
||||
'found_lat': c.found_lat,
|
||||
'found_lon': c.found_lon,
|
||||
'search_duration_hours': c.search_duration_hours,
|
||||
'confidence_avg': c.confidence_avg,
|
||||
'raw_text': c.raw_text,
|
||||
'analysis_log': c.analysis_log,
|
||||
'result': None,
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
db = _CompatDB()
|
||||
class SQLCaseRepository:
|
||||
def create_case(self, **data: Any) -> CaseDTO:
|
||||
Case = _case_model()
|
||||
cleaned = _normalize_case_data(data)
|
||||
if cleaned.get('age_years') is None:
|
||||
cleaned['age_years'] = 0
|
||||
cleaned.setdefault('status', 'new')
|
||||
with SessionLocal() as session:
|
||||
case = Case(**cleaned)
|
||||
session.add(case)
|
||||
session.commit()
|
||||
session.refresh(case)
|
||||
return CaseDTO(case)
|
||||
|
||||
def list_cases(self) -> list[CaseDTO]:
|
||||
Case = _case_model()
|
||||
with SessionLocal() as session:
|
||||
cases = session.scalars(select(Case).order_by(Case.created_at.desc())).all()
|
||||
return [CaseDTO(case) for case in cases]
|
||||
|
||||
def get_case(self, case_id: str) -> CaseDTO | None:
|
||||
uuid_value = _coerce_uuid(case_id)
|
||||
if uuid_value is None:
|
||||
return None
|
||||
Case = _case_model()
|
||||
with SessionLocal() as session:
|
||||
case = session.get(Case, uuid_value)
|
||||
return CaseDTO(case) if case else None
|
||||
|
||||
def update_case(self, case_id: str, **patch: Any) -> CaseDTO | None:
|
||||
uuid_value = _coerce_uuid(case_id)
|
||||
if uuid_value is None:
|
||||
return None
|
||||
Case = _case_model()
|
||||
cleaned = _normalize_case_data(patch)
|
||||
with SessionLocal() as session:
|
||||
case = session.get(Case, uuid_value)
|
||||
if not case:
|
||||
return None
|
||||
for key, value in cleaned.items():
|
||||
if value is not None:
|
||||
setattr(case, key, value)
|
||||
session.commit()
|
||||
session.refresh(case)
|
||||
return CaseDTO(case)
|
||||
|
||||
def delete_case(self, case_id: str) -> bool:
|
||||
uuid_value = _coerce_uuid(case_id)
|
||||
if uuid_value is None:
|
||||
return False
|
||||
Case = _case_model()
|
||||
with SessionLocal() as session:
|
||||
case = session.get(Case, uuid_value)
|
||||
if not case:
|
||||
return False
|
||||
session.delete(case)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
cases = self.list_cases()
|
||||
total = len(cases)
|
||||
found_alive = sum(1 for c in cases if c.found_alive is True)
|
||||
found_deceased = sum(1 for c in cases if c.found_alive is False)
|
||||
distances = [c.found_distance_km for c in cases if c.found_distance_km is not None]
|
||||
return {
|
||||
'total_cases': total,
|
||||
'found_alive_count': found_alive,
|
||||
'found_deceased_count': found_deceased,
|
||||
'unknown_outcome_count': total - found_alive - found_deceased,
|
||||
'median_found_distance_km': median(distances) if distances else None,
|
||||
'recent_activity_count': total,
|
||||
}
|
||||
|
||||
def heatmap(self) -> list[dict[str, Any]]:
|
||||
items = []
|
||||
for case in self.list_cases():
|
||||
lat = case.found_lat if case.found_lat is not None else case.tnp_lat
|
||||
lon = case.found_lon if case.found_lon is not None else case.tnp_lon
|
||||
if lat is not None and lon is not None:
|
||||
items.append({'lat': lat, 'lon': lon, 'weight': 1})
|
||||
return items
|
||||
|
||||
|
||||
db = SQLCaseRepository()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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.cases import router as cases_router
|
||||
@@ -17,6 +18,12 @@ app.add_middleware(
|
||||
allow_headers=['*'],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event('startup')
|
||||
def startup() -> None:
|
||||
init_db()
|
||||
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(analyze_router)
|
||||
app.include_router(cases_router)
|
||||
|
||||
+9
-15
@@ -1,13 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
from database import Base
|
||||
|
||||
try:
|
||||
from backend.database import Base
|
||||
except ImportError: # pragma: no cover - compatibility for direct module runs
|
||||
from database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication"""
|
||||
"""User model for authentication."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
@@ -22,14 +25,13 @@ class User(Base):
|
||||
|
||||
|
||||
class Case(Base):
|
||||
"""Unified case model - combines search case and result"""
|
||||
"""Unified case model - combines search case and result."""
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
status = Column(String(20), default="active")
|
||||
|
||||
# Ребёнок (Шаг 1)
|
||||
child_name = Column(String(255))
|
||||
age_years = Column(Integer, nullable=False)
|
||||
gender = Column(String(1))
|
||||
@@ -41,18 +43,15 @@ class Case(Base):
|
||||
special_marks = Column(Text)
|
||||
phone_status = Column(String(20))
|
||||
|
||||
# Здоровье (Шаг 2)
|
||||
has_diagnosis = Column(Boolean, default=False)
|
||||
diagnosis_type = Column(ARRAY(String))
|
||||
fitness_level = Column(String(20))
|
||||
has_transport = Column(String(20), default="none")
|
||||
cant_swim = Column(Boolean, default=False)
|
||||
|
||||
# Психотип (Шаг 2б)
|
||||
psychotype = Column(String(50))
|
||||
psychotype_answers = Column(JSONB)
|
||||
|
||||
# Обстоятельства (Шаг 3)
|
||||
loss_reason = Column(String(100))
|
||||
loss_time = Column(DateTime)
|
||||
elapsed_hours = Column(Float)
|
||||
@@ -63,7 +62,6 @@ class Case(Base):
|
||||
familiar_places = Column(Text)
|
||||
lost_before = Column(String(20))
|
||||
|
||||
# Среда (Шаг 4)
|
||||
season = Column(String(20))
|
||||
temperature_c = Column(Float)
|
||||
precipitation = Column(String(20))
|
||||
@@ -71,18 +69,15 @@ class Case(Base):
|
||||
wind = Column(String(20))
|
||||
terrain = Column(ARRAY(String))
|
||||
|
||||
# GPS (Шаг 4)
|
||||
tnp_lat = Column(Float)
|
||||
tnp_lon = Column(Float)
|
||||
tnp_address = Column(Text)
|
||||
|
||||
# Ресурсы (Шаг 5)
|
||||
teams_count = Column(Integer)
|
||||
team_size = Column(Integer)
|
||||
has_dog = Column(Boolean, default=False)
|
||||
extra_resources = Column(ARRAY(String))
|
||||
|
||||
# Исход (заполняется после завершения)
|
||||
found_alive = Column(Boolean)
|
||||
found_distance_km = Column(Float)
|
||||
found_direction = Column(String(10))
|
||||
@@ -92,14 +87,13 @@ class Case(Base):
|
||||
search_duration_hours = Column(Float)
|
||||
who_found = Column(String(50))
|
||||
|
||||
# Мета
|
||||
confidence_avg = Column(Float)
|
||||
raw_text = Column(Text)
|
||||
analysis_log = Column(JSONB)
|
||||
|
||||
|
||||
class AnalysisLog(Base):
|
||||
"""Analysis log for audit and debugging"""
|
||||
"""Analysis log for audit and debugging."""
|
||||
__tablename__ = "analysis_log"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
@@ -115,7 +109,7 @@ class AnalysisLog(Base):
|
||||
|
||||
|
||||
class RawDocument(Base):
|
||||
"""Raw document storage for parsed reports"""
|
||||
"""Raw document storage for parsed reports."""
|
||||
__tablename__ = "raw_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
+62
-51
@@ -1,48 +1,65 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CaseCreate(BaseModel):
|
||||
class CaseBase(BaseModel):
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
age: int | None = None
|
||||
age_years: int | None = None
|
||||
gender: str | None = None
|
||||
child_name: str | None = None
|
||||
height_build: str | None = None
|
||||
clothes_upper: str | None = None
|
||||
clothes_lower: str | None = None
|
||||
clothes_shoes: str | None = None
|
||||
shoes: str | None = None
|
||||
clothes_description: str | None = None
|
||||
special_marks: str | None = None
|
||||
phone_status: str | None = None
|
||||
health_flags: list[str] = Field(default_factory=list)
|
||||
elapsed_hours: int | None = None
|
||||
has_diagnosis: bool | None = None
|
||||
diagnosis_type: list[str] = Field(default_factory=list)
|
||||
fitness_level: str | None = None
|
||||
has_transport: str | None = None
|
||||
cant_swim: bool | None = None
|
||||
psychotype: str | None = None
|
||||
psychotype_answers: dict[str, Any] | None = None
|
||||
elapsed_hours: float | None = None
|
||||
last_known_place: str | None = None
|
||||
direction: str | None = None
|
||||
reason: str | None = None
|
||||
loss_reason: str | None = None
|
||||
loss_time: datetime | None = None
|
||||
last_seen_direction: str | None = None
|
||||
last_seen_reliability: str | None = None
|
||||
last_seen_description: str | None = None
|
||||
behavior_description: str | None = None
|
||||
familiar_places: str | None = None
|
||||
lost_before: str | None = None
|
||||
terrain_primary: str | None = None
|
||||
terrain: list[str] = Field(default_factory=list)
|
||||
weather: str | None = None
|
||||
resources: list[str] = Field(default_factory=list)
|
||||
season: str | None = None
|
||||
temperature_c: float | None = None
|
||||
precipitation: str | None = None
|
||||
visibility: str | None = None
|
||||
wind: str | None = None
|
||||
gps: dict[str, float] | None = None
|
||||
notes: str | None = None
|
||||
note: str | None = None
|
||||
status: str = 'new'
|
||||
found_alive: bool | None = None
|
||||
found_distance_km: float | None = None
|
||||
found_direction: str | None = None
|
||||
found_location_type: str | None = None
|
||||
found_lat: float | None = None
|
||||
found_lon: float | None = None
|
||||
who_found: str | None = None
|
||||
mobile: bool = False
|
||||
|
||||
|
||||
class CaseUpdate(BaseModel):
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
health_flags: list[str] | None = None
|
||||
elapsed_hours: int | None = None
|
||||
last_known_place: str | None = None
|
||||
direction: str | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
resources: list[str] | None = None
|
||||
gps_lat: float | None = None
|
||||
gps_lon: float | None = None
|
||||
tnp_lat: float | None = None
|
||||
tnp_lon: float | None = None
|
||||
tnp_address: str | None = None
|
||||
resources: list[str] = Field(default_factory=list)
|
||||
teams_count: int | None = None
|
||||
team_size: int | None = None
|
||||
has_dog: bool | None = None
|
||||
extra_resources: list[str] = Field(default_factory=list)
|
||||
notes: str | None = None
|
||||
note: str | None = None
|
||||
status: str | None = None
|
||||
@@ -52,37 +69,31 @@ class CaseUpdate(BaseModel):
|
||||
found_location_type: str | None = None
|
||||
found_lat: float | None = None
|
||||
found_lon: float | None = None
|
||||
search_duration_hours: float | None = None
|
||||
who_found: str | None = None
|
||||
raw_text: str | None = None
|
||||
analysis_log: str | None = None
|
||||
analysis_log: dict[str, Any] | None = None
|
||||
mobile: bool = False
|
||||
|
||||
|
||||
class CaseResponse(BaseModel):
|
||||
class CaseCreate(CaseBase):
|
||||
status: str | None = 'new'
|
||||
|
||||
|
||||
class CaseUpdate(CaseBase):
|
||||
pass
|
||||
|
||||
|
||||
class CaseResponse(CaseBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
health_flags: list[str]
|
||||
elapsed_hours: int | None = None
|
||||
last_known_place: str | None = None
|
||||
direction: str | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
resources: list[str]
|
||||
gps_lat: float | None = None
|
||||
gps_lon: float | None = None
|
||||
notes: str | None = None
|
||||
status: str
|
||||
found_alive: bool | None = None
|
||||
found_distance_km: float | None = None
|
||||
found_direction: str | None = None
|
||||
found_location_type: str | None = None
|
||||
found_lat: float | None = None
|
||||
found_lon: float | None = None
|
||||
who_found: str | None = None
|
||||
raw_text: str | None = None
|
||||
analysis_log: str | None = None
|
||||
health_flags: list[str] = Field(default_factory=list)
|
||||
diagnosis_type: list[str] = Field(default_factory=list)
|
||||
terrain: list[str] = Field(default_factory=list)
|
||||
resources: list[str] = Field(default_factory=list)
|
||||
extra_resources: list[str] = Field(default_factory=list)
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ 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,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.database import db
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from backend.database import db
|
||||
except ImportError: # pragma: no cover - compatibility for direct service imports
|
||||
from database import db
|
||||
|
||||
|
||||
def summary() -> dict:
|
||||
@@ -9,3 +14,26 @@ def summary() -> dict:
|
||||
|
||||
def heatmap() -> list[dict]:
|
||||
return db.heatmap()
|
||||
|
||||
|
||||
def get_statistical_recommendation(case_data: dict[str, Any]) -> dict[str, Any]:
|
||||
score = 0
|
||||
age = case_data.get('age') or case_data.get('age_years')
|
||||
elapsed = case_data.get('elapsed_hours')
|
||||
terrain = str(case_data.get('terrain') or case_data.get('terrain_primary') or '').lower()
|
||||
weather = str(case_data.get('weather') or case_data.get('precipitation') or '').lower()
|
||||
|
||||
if age is not None and age < 12:
|
||||
score += 20
|
||||
if elapsed is not None and elapsed >= 12:
|
||||
score += 20
|
||||
if any(token in terrain for token in ('лес', 'болото', 'вода')):
|
||||
score += 15
|
||||
if any(token in weather for token in ('дождь', 'туман', 'снег', 'ночь')):
|
||||
score += 15
|
||||
|
||||
return {
|
||||
'score': score,
|
||||
'priority': 'high' if score >= 40 else 'normal',
|
||||
'recommendation': 'Высокий приоритет на прочёс и дрон' if score >= 40 else 'Стандартный приоритет поиска',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user