"""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')