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)
+8 -14
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
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,
+28
View File
@@ -1,6 +1,11 @@
from __future__ import annotations
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: