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:
@@ -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')
|
||||
@@ -0,0 +1 @@
|
||||
"""Alembic migration package for ВЕКТОР."""
|
||||
Reference in New Issue
Block a user