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.
161 lines
7.8 KiB
Python
161 lines
7.8 KiB
Python
"""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') |