Make backend start on PostgreSQL
This commit is contained in:
+3
-50
@@ -1,51 +1,4 @@
|
|||||||
# Environment variables
|
.venv/
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
.pytest_cache/
|
||||||
*.class
|
.env
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
venv/
|
|
||||||
env/
|
|
||||||
ENV/
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Node
|
|
||||||
node_modules/
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnp/
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# React
|
|
||||||
/frontend/build
|
|
||||||
/frontend/.env.local
|
|
||||||
/frontend/.env.development.local
|
|
||||||
/frontend/.env.test.local
|
|
||||||
/frontend/.env.production.local
|
|
||||||
|
|
||||||
# IDEs
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Database
|
|
||||||
*.db
|
|
||||||
*.sqlite
|
|
||||||
postgres_data/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
logs/
|
|
||||||
|
|||||||
+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/*
|
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
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY backend ./backend
|
||||||
|
|
||||||
EXPOSE 8000
|
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
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -9,48 +16,268 @@ Base = declarative_base()
|
|||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
db_session = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db_session
|
||||||
finally:
|
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)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
class _CompatDB:
|
def _case_model():
|
||||||
def __init__(self):
|
from backend.models import Case
|
||||||
self._cases = []
|
|
||||||
|
|
||||||
def create_case(self, **data):
|
return Case
|
||||||
raise NotImplementedError('compat db is for import only')
|
|
||||||
|
|
||||||
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
|
return None
|
||||||
|
|
||||||
def update_case(self, case_id, **patch):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def delete_case(self, case_id):
|
def _parse_datetime(value: Any) -> Any:
|
||||||
return False
|
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 {
|
return {
|
||||||
'total_cases': 0,
|
'id': str(c.id),
|
||||||
'found_alive_count': 0,
|
'created_at': c.created_at,
|
||||||
'found_deceased_count': 0,
|
'updated_at': updated_at,
|
||||||
'unknown_outcome_count': 0,
|
'age': c.age_years,
|
||||||
'median_found_distance_km': None,
|
'age_years': c.age_years,
|
||||||
'recent_activity_count': 0,
|
'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):
|
def to_detail(self) -> dict[str, Any]:
|
||||||
return []
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
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.cases import router as cases_router
|
from backend.routers.cases import router as cases_router
|
||||||
@@ -17,6 +18,12 @@ app.add_middleware(
|
|||||||
allow_headers=['*'],
|
allow_headers=['*'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event('startup')
|
||||||
|
def startup() -> None:
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(analyze_router)
|
app.include_router(analyze_router)
|
||||||
app.include_router(cases_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 import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
import uuid
|
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):
|
class User(Base):
|
||||||
"""User model for authentication"""
|
"""User model for authentication."""
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
@@ -22,14 +25,13 @@ class User(Base):
|
|||||||
|
|
||||||
|
|
||||||
class Case(Base):
|
class Case(Base):
|
||||||
"""Unified case model - combines search case and result"""
|
"""Unified case model - combines search case and result."""
|
||||||
__tablename__ = "cases"
|
__tablename__ = "cases"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
status = Column(String(20), default="active")
|
status = Column(String(20), default="active")
|
||||||
|
|
||||||
# Ребёнок (Шаг 1)
|
|
||||||
child_name = Column(String(255))
|
child_name = Column(String(255))
|
||||||
age_years = Column(Integer, nullable=False)
|
age_years = Column(Integer, nullable=False)
|
||||||
gender = Column(String(1))
|
gender = Column(String(1))
|
||||||
@@ -41,18 +43,15 @@ class Case(Base):
|
|||||||
special_marks = Column(Text)
|
special_marks = Column(Text)
|
||||||
phone_status = Column(String(20))
|
phone_status = Column(String(20))
|
||||||
|
|
||||||
# Здоровье (Шаг 2)
|
|
||||||
has_diagnosis = Column(Boolean, default=False)
|
has_diagnosis = Column(Boolean, default=False)
|
||||||
diagnosis_type = Column(ARRAY(String))
|
diagnosis_type = Column(ARRAY(String))
|
||||||
fitness_level = Column(String(20))
|
fitness_level = Column(String(20))
|
||||||
has_transport = Column(String(20), default="none")
|
has_transport = Column(String(20), default="none")
|
||||||
cant_swim = Column(Boolean, default=False)
|
cant_swim = Column(Boolean, default=False)
|
||||||
|
|
||||||
# Психотип (Шаг 2б)
|
|
||||||
psychotype = Column(String(50))
|
psychotype = Column(String(50))
|
||||||
psychotype_answers = Column(JSONB)
|
psychotype_answers = Column(JSONB)
|
||||||
|
|
||||||
# Обстоятельства (Шаг 3)
|
|
||||||
loss_reason = Column(String(100))
|
loss_reason = Column(String(100))
|
||||||
loss_time = Column(DateTime)
|
loss_time = Column(DateTime)
|
||||||
elapsed_hours = Column(Float)
|
elapsed_hours = Column(Float)
|
||||||
@@ -63,7 +62,6 @@ class Case(Base):
|
|||||||
familiar_places = Column(Text)
|
familiar_places = Column(Text)
|
||||||
lost_before = Column(String(20))
|
lost_before = Column(String(20))
|
||||||
|
|
||||||
# Среда (Шаг 4)
|
|
||||||
season = Column(String(20))
|
season = Column(String(20))
|
||||||
temperature_c = Column(Float)
|
temperature_c = Column(Float)
|
||||||
precipitation = Column(String(20))
|
precipitation = Column(String(20))
|
||||||
@@ -71,18 +69,15 @@ class Case(Base):
|
|||||||
wind = Column(String(20))
|
wind = Column(String(20))
|
||||||
terrain = Column(ARRAY(String))
|
terrain = Column(ARRAY(String))
|
||||||
|
|
||||||
# GPS (Шаг 4)
|
|
||||||
tnp_lat = Column(Float)
|
tnp_lat = Column(Float)
|
||||||
tnp_lon = Column(Float)
|
tnp_lon = Column(Float)
|
||||||
tnp_address = Column(Text)
|
tnp_address = Column(Text)
|
||||||
|
|
||||||
# Ресурсы (Шаг 5)
|
|
||||||
teams_count = Column(Integer)
|
teams_count = Column(Integer)
|
||||||
team_size = Column(Integer)
|
team_size = Column(Integer)
|
||||||
has_dog = Column(Boolean, default=False)
|
has_dog = Column(Boolean, default=False)
|
||||||
extra_resources = Column(ARRAY(String))
|
extra_resources = Column(ARRAY(String))
|
||||||
|
|
||||||
# Исход (заполняется после завершения)
|
|
||||||
found_alive = Column(Boolean)
|
found_alive = Column(Boolean)
|
||||||
found_distance_km = Column(Float)
|
found_distance_km = Column(Float)
|
||||||
found_direction = Column(String(10))
|
found_direction = Column(String(10))
|
||||||
@@ -92,14 +87,13 @@ class Case(Base):
|
|||||||
search_duration_hours = Column(Float)
|
search_duration_hours = Column(Float)
|
||||||
who_found = Column(String(50))
|
who_found = Column(String(50))
|
||||||
|
|
||||||
# Мета
|
|
||||||
confidence_avg = Column(Float)
|
confidence_avg = Column(Float)
|
||||||
raw_text = Column(Text)
|
raw_text = Column(Text)
|
||||||
analysis_log = Column(JSONB)
|
analysis_log = Column(JSONB)
|
||||||
|
|
||||||
|
|
||||||
class AnalysisLog(Base):
|
class AnalysisLog(Base):
|
||||||
"""Analysis log for audit and debugging"""
|
"""Analysis log for audit and debugging."""
|
||||||
__tablename__ = "analysis_log"
|
__tablename__ = "analysis_log"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
@@ -115,7 +109,7 @@ class AnalysisLog(Base):
|
|||||||
|
|
||||||
|
|
||||||
class RawDocument(Base):
|
class RawDocument(Base):
|
||||||
"""Raw document storage for parsed reports"""
|
"""Raw document storage for parsed reports."""
|
||||||
__tablename__ = "raw_documents"
|
__tablename__ = "raw_documents"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
|||||||
+62
-51
@@ -1,48 +1,65 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
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: int | None = None
|
||||||
|
age_years: int | None = None
|
||||||
gender: str | 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)
|
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
|
last_known_place: str | None = None
|
||||||
direction: 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_primary: str | None = None
|
||||||
|
terrain: list[str] = Field(default_factory=list)
|
||||||
weather: str | None = None
|
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
|
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_lat: float | None = None
|
||||||
gps_lon: 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
|
notes: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
@@ -52,37 +69,31 @@ class CaseUpdate(BaseModel):
|
|||||||
found_location_type: str | None = None
|
found_location_type: str | None = None
|
||||||
found_lat: float | None = None
|
found_lat: float | None = None
|
||||||
found_lon: float | None = None
|
found_lon: float | None = None
|
||||||
|
search_duration_hours: float | None = None
|
||||||
who_found: str | None = None
|
who_found: str | None = None
|
||||||
raw_text: 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
|
id: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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
|
status: str
|
||||||
found_alive: bool | None = None
|
health_flags: list[str] = Field(default_factory=list)
|
||||||
found_distance_km: float | None = None
|
diagnosis_type: list[str] = Field(default_factory=list)
|
||||||
found_direction: str | None = None
|
terrain: list[str] = Field(default_factory=list)
|
||||||
found_location_type: str | None = None
|
resources: list[str] = Field(default_factory=list)
|
||||||
found_lat: float | None = None
|
extra_resources: list[str] = Field(default_factory=list)
|
||||||
found_lon: float | None = None
|
|
||||||
who_found: str | None = None
|
|
||||||
raw_text: str | None = None
|
|
||||||
analysis_log: str | None = None
|
|
||||||
result: dict[str, Any] | None = None
|
result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ CACHE_TTL_HOURS = 24
|
|||||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
|
|
||||||
# Direction mappings
|
# Direction mappings
|
||||||
|
SEARCH_DISTANCES = [500, 1000, 2000, 5000]
|
||||||
|
|
||||||
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||||
DIRECTION_ANGLES = {
|
DIRECTION_ANGLES = {
|
||||||
"N": 0,
|
"N": 0,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from __future__ import annotations
|
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:
|
def summary() -> dict:
|
||||||
@@ -9,3 +14,26 @@ def summary() -> dict:
|
|||||||
|
|
||||||
def heatmap() -> list[dict]:
|
def heatmap() -> list[dict]:
|
||||||
return db.heatmap()
|
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 'Стандартный приоритет поиска',
|
||||||
|
}
|
||||||
|
|||||||
+5
-4
@@ -28,8 +28,8 @@ services:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
container_name: vector-backend
|
container_name: vector-backend
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -38,12 +38,13 @@ services:
|
|||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||||
|
- PYTHONPATH=/app
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app/backend
|
||||||
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
|
|||||||
Reference in New Issue
Block a user