Make backend start on PostgreSQL

This commit is contained in:
root
2026-06-08 11:50:29 +00:00
parent 3548733019
commit 4e80a0bdfe
10 changed files with 374 additions and 151 deletions
+3 -50
View File
@@ -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
+3 -3
View File
@@ -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"]
View File
+254 -27
View File
@@ -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()
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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,
+29 -1
View File
@@ -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 'Стандартный приоритет поиска',
}
+5 -4
View File
@@ -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: