From 4e80a0bdfe6a12ab6ec37cb4dd011bf5b328d48f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 11:50:29 +0000 Subject: [PATCH] Make backend start on PostgreSQL --- .gitignore | 53 +----- backend/Dockerfile | 6 +- backend/__init__.py | 0 backend/database.py | 281 +++++++++++++++++++++++++++--- backend/main.py | 7 + backend/models.py | 24 +-- backend/schemas.py | 113 ++++++------ backend/services/geo_service.py | 2 + backend/services/stats_service.py | 30 +++- docker-compose.yml | 9 +- 10 files changed, 374 insertions(+), 151 deletions(-) create mode 100644 backend/__init__.py diff --git a/.gitignore b/.gitignore index 69a943e..12a0d3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,51 +1,4 @@ -# Environment variables -.env -.env.local - -# Python +.venv/ __pycache__/ -*.py[cod] -*.class -*.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/ +.pytest_cache/ +.env diff --git a/backend/Dockerfile b/backend/Dockerfile index 5cc745b..93606db 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/database.py b/backend/database.py index ae56beb..2c17333 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py index b0a3521..295ce2e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py index ecb7f79..4e3655f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/schemas.py b/backend/schemas.py index 4fb2081..e4a7b03 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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 diff --git a/backend/services/geo_service.py b/backend/services/geo_service.py index 015cd79..2e15f65 100644 --- a/backend/services/geo_service.py +++ b/backend/services/geo_service.py @@ -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, diff --git a/backend/services/stats_service.py b/backend/services/stats_service.py index b8fa118..437b53c 100644 --- a/backend/services/stats_service.py +++ b/backend/services/stats_service.py @@ -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 'Стандартный приоритет поиска', + } diff --git a/docker-compose.yml b/docker-compose.yml index 5a8f072..46901a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,8 +28,8 @@ services: backend: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile container_name: vector-backend ports: - "8000:8000" @@ -38,12 +38,13 @@ services: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - JWT_SECRET=${JWT_SECRET:-change-me-in-production} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000} + - PYTHONPATH=/app depends_on: postgres: condition: service_healthy volumes: - - ./backend:/app - command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + - ./backend:/app/backend + command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload frontend: build: