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
+161
View File
@@ -0,0 +1,161 @@
"""B15: слоистая модель данных — search_models (слой 4, версионирование) +
оперативные таблицы (слой 3) + reference_priors (слой 1, пустой справочник).
Бэкфилл: существующие результаты анализа из cases.analysis_log (формат
analyze_case: dict с primary_zones и т.д.) → search_models v1. Строки
analysis_log НЕ трогаем (другой формат — pipeline-журнал legacy-анализов).
Только JSONB-объекты; массивы/скаляры (legacy-формат) пропускаются.
Revision ID: 006_b15_layers
Revises: None (первая alembic-миграция; схема до этого накатывалась
SQL-скриптами migrations/001–005 и Base.metadata.create_all)
"""
from __future__ import annotations
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql as pg
revision = '006_b15_layers'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ---------- Слой 4: версионированные поисковые модели ----------
op.create_table(
'search_models',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
server_default=sa.text('gen_random_uuid()')),
sa.Column('case_id', pg.UUID(as_uuid=True),
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('input_snapshot', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
sa.Column('model_json', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
sa.UniqueConstraint('case_id', 'version', name='uq_search_models_case_version'),
)
op.create_index('idx_search_models_case_id', 'search_models', ['case_id'])
# ---------- Слой 3: оперативные данные (схема сейчас, ингестия — B17) ----------
op.create_table(
'search_teams',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
server_default=sa.text('gen_random_uuid()')),
sa.Column('case_id', pg.UUID(as_uuid=True),
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('members', sa.Integer()),
sa.Column('role', sa.String(50)),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
)
op.create_index('idx_search_teams_case_id', 'search_teams', ['case_id'])
op.create_table(
'field_observations',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
server_default=sa.text('gen_random_uuid()')),
sa.Column('case_id', pg.UUID(as_uuid=True),
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
sa.Column('team_id', pg.UUID(as_uuid=True),
sa.ForeignKey('search_teams.id', ondelete='SET NULL')),
sa.Column('obs_type', sa.String(20), nullable=False),
sa.Column('geom', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
sa.Column('observed_at', sa.DateTime(timezone=True)),
sa.Column('confidence', sa.Float()),
sa.Column('description', sa.Text()),
sa.Column('raw', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
sa.CheckConstraint(
"obs_type IN ('position','observation','track','clue','witness')",
name='ck_field_observations_obs_type',
),
)
op.create_index('idx_field_observations_case_id', 'field_observations', ['case_id'])
op.create_index('idx_field_observations_type', 'field_observations', ['obs_type'])
op.create_table(
'areas_checked',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
server_default=sa.text('gen_random_uuid()')),
sa.Column('case_id', pg.UUID(as_uuid=True),
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
sa.Column('team_id', pg.UUID(as_uuid=True),
sa.ForeignKey('search_teams.id', ondelete='SET NULL')),
sa.Column('geom', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
sa.Column('checked_at', sa.DateTime(timezone=True)),
sa.Column('result', sa.String(20), server_default='partial'),
sa.Column('coverage_pct', sa.Float()),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
sa.CheckConstraint("result IN ('clear','found','partial')",
name='ck_areas_checked_result'),
)
op.create_index('idx_areas_checked_case_id', 'areas_checked', ['case_id'])
op.create_table(
'found_events',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
server_default=sa.text('gen_random_uuid()')),
sa.Column('case_id', pg.UUID(as_uuid=True),
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
sa.Column('geom', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
sa.Column('occurred_at', sa.DateTime(timezone=True)),
sa.Column('condition', sa.String(50)),
sa.Column('team_id', pg.UUID(as_uuid=True),
sa.ForeignKey('search_teams.id', ondelete='SET NULL')),
sa.Column('description', sa.Text()),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
)
op.create_index('idx_found_events_case_id', 'found_events', ['case_id'])
# ---------- Слой 1: справочник априорных (пустой, B12 заблокирован) ----------
op.create_table(
'reference_priors',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
server_default=sa.text('gen_random_uuid()')),
sa.Column('source', sa.String(100), nullable=False),
sa.Column('category', sa.String(100), nullable=False),
sa.Column('value', sa.Float(), nullable=False),
sa.Column('units', sa.String(50)),
sa.Column('provenance', sa.Text()),
sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
)
# ---------- Бэкфилл: cases.analysis_log → search_models v1 ----------
conn = op.get_bind()
rows = conn.execute(sa.text(
"SELECT id, analysis_log FROM cases "
"WHERE analysis_log IS NOT NULL "
"AND jsonb_typeof(analysis_log) = 'object' "
"AND analysis_log ? 'primary_zones'"
)).fetchall()
for case_id, log in rows:
# Идемпотентность: если v1 для кейса уже есть — не создаём дубль
exists = conn.execute(
sa.text("SELECT 1 FROM search_models WHERE case_id = :cid AND version = 1"),
{'cid': str(case_id)},
).first()
if exists:
continue
conn.execute(
sa.text(
"INSERT INTO search_models (case_id, version, input_snapshot, model_json) "
"VALUES (:cid, 1, NULL, CAST(:model AS jsonb))"
),
{'cid': str(case_id), 'model': json.dumps(log, ensure_ascii=False)},
)
def downgrade() -> None:
# Бэкфилл-данные (search_models v1) удаляются вместе с таблицей:
# cases.analysis_log не модифицировался, потерь нет.
op.drop_table('reference_priors')
op.drop_table('found_events')
op.drop_table('areas_checked')
op.drop_table('field_observations')
op.drop_table('search_teams')
op.drop_table('search_models')
+1
View File
@@ -0,0 +1 @@
"""Alembic migration package for ВЕКТОР."""