B14+B15: чистый SearchEngine + слоистая модель данных

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.
This commit is contained in:
2026-09-09 15:48:29 +03:00
parent c368482d1c
commit 5bdb345e33
19 changed files with 1033 additions and 186 deletions
+108
View File
@@ -1,3 +1,4 @@
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
@@ -117,3 +118,110 @@ class RawDocument(Base):
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())