Files
Sadmin 5bdb345e33 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.
2026-09-09 15:48:29 +03:00

161 lines
7.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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')