5bdb345e33
B14 (поведение-сохраняющий рефакторинг): - services/search_engine.py — вся SAR-математика из analyze.py: SearchInput/SearchModel, build_search_model (чистая функция, без DB/auth/HTTP), деривации профилей/времени суток, unmodeled. - analyze.py — тонкая обёртка: сборка SearchInput + запись БД. - closed_cases.py — импорты хелперов из движка. - 12 юнит-тестов движка (claude_analyze мокается). - Контракт /analyze не изменён; регресс спеки подтверждён: bike 8yo 2h лес день -> 5.4 км. B15 (слои данных, Alembic): - backend/alembic (env из DATABASE_URL) + миграция 006_b15_layers. - Слой 4: search_models (case_id, version, input_snapshot, model_json). - Слой 3: search_teams, field_observations, areas_checked, found_events (geom JSONB GeoJSON, PostGIS в B16). - Слой 1: reference_priors (пустой, B12 заблокирован). - Бэкфилл: cases.analysis_log (объект с primary_zones) -> search_models v1; legacy-массивы и analysis_log-таблица не тронуты. - Проверено на CT108 в одноразовых pg16-контейнерах: чистая БД (без данных и с ними), бэкфилл=1 из 3 seed-кейсов, downgrade->upgrade идемпотентен, check-constraints работают, источник не модифицирован. pytest: 214 passed.
228 lines
8.9 KiB
Python
228 lines
8.9 KiB
Python
import sqlalchemy as sa
|
|
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
from sqlalchemy.sql import func
|
|
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."""
|
|
__tablename__ = "users"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
username = Column(String(100), unique=True, nullable=False)
|
|
email = Column(String(255), unique=True, nullable=False)
|
|
hashed_password = Column(String(255), nullable=False)
|
|
full_name = Column(String(255))
|
|
role = Column(String(20), nullable=False, default="operator")
|
|
is_active = Column(Boolean, default=True)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
last_login = Column(DateTime)
|
|
|
|
|
|
class Case(Base):
|
|
"""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")
|
|
|
|
child_name = Column(String(255))
|
|
age_years = Column(Integer, nullable=False)
|
|
gender = Column(String(1))
|
|
height_build = Column(Text)
|
|
clothes_upper = Column(Text)
|
|
clothes_lower = Column(Text)
|
|
shoes = Column(Text)
|
|
clothes_description = Column(Text)
|
|
special_marks = Column(Text)
|
|
phone_status = Column(String(20))
|
|
|
|
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)
|
|
|
|
psychotype = Column(String(50))
|
|
psychotype_answers = Column(JSONB)
|
|
|
|
loss_reason = Column(String(100))
|
|
loss_time = Column(DateTime)
|
|
elapsed_hours = Column(Float)
|
|
last_seen_direction = Column(String(10))
|
|
last_seen_reliability = Column(String(20))
|
|
last_seen_description = Column(Text)
|
|
behavior_description = Column(Text)
|
|
familiar_places = Column(Text)
|
|
lost_before = Column(String(20))
|
|
|
|
season = Column(String(20))
|
|
temperature_c = Column(Float)
|
|
precipitation = Column(String(20))
|
|
visibility = Column(String(20))
|
|
wind = Column(String(20))
|
|
terrain = Column(ARRAY(String))
|
|
|
|
tnp_lat = Column(Float)
|
|
tnp_lon = Column(Float)
|
|
tnp_address = Column(Text)
|
|
|
|
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))
|
|
found_location_type = Column(String(50))
|
|
found_lat = Column(Float)
|
|
found_lon = Column(Float)
|
|
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."""
|
|
__tablename__ = "analysis_log"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
|
analysis_type = Column(String(50), nullable=False)
|
|
input_data = Column(JSONB)
|
|
output_data = Column(JSONB)
|
|
execution_time = Column(Float)
|
|
status = Column(String(20), default="success")
|
|
error_message = Column(Text)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
|
|
class RawDocument(Base):
|
|
"""Raw document storage for parsed reports."""
|
|
__tablename__ = "raw_documents"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
filename = Column(String(255), nullable=False)
|
|
raw_text = Column(Text)
|
|
extracted_json = Column(JSONB)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
|
|
# ==================== B15: слоистая модель данных (B13-принцип 2) ====================
|
|
# Слой 4 (результат): search_models — версионированные поисковые модели (фундамент B18)
|
|
# Слой 3 (оперативные): field_observations / search_teams / areas_checked / found_events
|
|
# — схема+модели сейчас, ингестия в B17
|
|
# Слой 1 (исходные): reference_priors — версионированный справочник априорных
|
|
# (заглушка под B12/ISRID и экспертные значения; данные вносятся позже)
|
|
|
|
|
|
class SearchModelRecord(Base):
|
|
"""Слой 4: версия поисковой модели для случая (per-case версионирование)."""
|
|
__tablename__ = "search_models"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
|
version = Column(Integer, nullable=False)
|
|
input_snapshot = Column(JSONB)
|
|
model_json = Column(JSONB)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
__table_args__ = (
|
|
sa.UniqueConstraint("case_id", "version", name="uq_search_models_case_version"),
|
|
)
|
|
|
|
|
|
class SearchTeam(Base):
|
|
"""Слой 3: поисковая группа (ингестия — B17)."""
|
|
__tablename__ = "search_teams"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
|
name = Column(String(255), nullable=False)
|
|
members = Column(Integer)
|
|
role = Column(String(50))
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
|
|
class FieldObservation(Base):
|
|
"""Слой 3: наблюдение в поле (position/observation/track/clue/witness).
|
|
Геометрия — JSONB GeoJSON (B16 добавит PostGIS-колонки)."""
|
|
__tablename__ = "field_observations"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
|
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
|
obs_type = Column(String(20), nullable=False)
|
|
geom = Column(JSONB)
|
|
observed_at = Column(DateTime(timezone=True))
|
|
confidence = Column(Float)
|
|
description = Column(Text)
|
|
raw = Column(JSONB)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
__table_args__ = (
|
|
sa.CheckConstraint(
|
|
"obs_type IN ('position','observation','track','clue','witness')",
|
|
name="ck_field_observations_obs_type",
|
|
),
|
|
)
|
|
|
|
|
|
class AreaChecked(Base):
|
|
"""Слой 3: проверенный участок (результат работы группы)."""
|
|
__tablename__ = "areas_checked"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
|
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
|
geom = Column(JSONB)
|
|
checked_at = Column(DateTime(timezone=True))
|
|
result = Column(String(20), default="partial")
|
|
coverage_pct = Column(Float)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
__table_args__ = (
|
|
sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"),
|
|
)
|
|
|
|
|
|
class FoundEvent(Base):
|
|
"""Слой 3: событие находки пропавшего."""
|
|
__tablename__ = "found_events"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
|
geom = Column(JSONB)
|
|
occurred_at = Column(DateTime(timezone=True))
|
|
condition = Column(String(50))
|
|
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
|
description = Column(Text)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
|
|
class ReferencePrior(Base):
|
|
"""Слой 1: версионированный справочник априорных значений
|
|
(экспертные нормы сейчас, ISRID — после B12). Данные НЕ вносятся (B15)."""
|
|
__tablename__ = "reference_priors"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
source = Column(String(100), nullable=False)
|
|
category = Column(String(100), nullable=False)
|
|
value = Column(Float, nullable=False)
|
|
units = Column(String(50))
|
|
provenance = Column(Text)
|
|
version = Column(Integer, nullable=False, default=1)
|
|
created_at = Column(DateTime, server_default=func.now())
|