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,42 @@
|
||||
# Alembic для ВЕКТОРа. Запуск:
|
||||
# локально: DATABASE_URL=... .venv-test/bin/alembic -c backend/alembic.ini upgrade head
|
||||
# на стенде: docker exec vector-backend sh -c 'cd /app/backend && alembic upgrade head'
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = ..
|
||||
# URL берётся из env DATABASE_URL (env.py); здесь — дефолт для стенда (docker-compose)
|
||||
sqlalchemy.url = postgresql://postgres:postgres@postgres:5432/vector_mchs
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
"""Alembic scripts package for ВЕКТОР."""
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Alembic environment для ВЕКТОРа.
|
||||
|
||||
URL: env DATABASE_URL > alembic.ini sqlalchemy.url.
|
||||
Метаданные моделей: backend.models (Base).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# backend/ и корень репо в sys.path, чтобы import backend.models работал
|
||||
# при любом CWD (alembic -c backend/alembic.ini из корня или из backend/)
|
||||
_BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
for _p in (_BACKEND_DIR, os.path.dirname(_BACKEND_DIR)):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# URL: env побеждает ini
|
||||
db_url = os.getenv('DATABASE_URL')
|
||||
if db_url:
|
||||
config.set_main_option('sqlalchemy.url', db_url)
|
||||
|
||||
from backend.models import Base # noqa: E402 (после set_main_option)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Запуск без БД (--sql): выгрузить SQL в скрипт."""
|
||||
url = config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Обычный запуск: подключиться и накатить."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> ${upgraded if upgraded else "pass"}:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> ${downgrades if downgrades else "pass"}:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -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 ВЕКТОР."""
|
||||
@@ -1,3 +1,4 @@
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
@@ -117,3 +118,110 @@ class RawDocument(Base):
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ==================== B15: слоистая модель данных (B13-принцип 2) ====================
|
||||
# Слой 4 (результат): search_models — версионированные поисковые модели (фундамент B18)
|
||||
# Слой 3 (оперативные): field_observations / search_teams / areas_checked / found_events
|
||||
# — схема+модели сейчас, ингестия в B17
|
||||
# Слой 1 (исходные): reference_priors — версионированный справочник априорных
|
||||
# (заглушка под B12/ISRID и экспертные значения; данные вносятся позже)
|
||||
|
||||
|
||||
class SearchModelRecord(Base):
|
||||
"""Слой 4: версия поисковой модели для случая (per-case версионирование)."""
|
||||
__tablename__ = "search_models"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
version = Column(Integer, nullable=False)
|
||||
input_snapshot = Column(JSONB)
|
||||
model_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint("case_id", "version", name="uq_search_models_case_version"),
|
||||
)
|
||||
|
||||
|
||||
class SearchTeam(Base):
|
||||
"""Слой 3: поисковая группа (ингестия — B17)."""
|
||||
__tablename__ = "search_teams"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
members = Column(Integer)
|
||||
role = Column(String(50))
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class FieldObservation(Base):
|
||||
"""Слой 3: наблюдение в поле (position/observation/track/clue/witness).
|
||||
Геометрия — JSONB GeoJSON (B16 добавит PostGIS-колонки)."""
|
||||
__tablename__ = "field_observations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
||||
obs_type = Column(String(20), nullable=False)
|
||||
geom = Column(JSONB)
|
||||
observed_at = Column(DateTime(timezone=True))
|
||||
confidence = Column(Float)
|
||||
description = Column(Text)
|
||||
raw = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint(
|
||||
"obs_type IN ('position','observation','track','clue','witness')",
|
||||
name="ck_field_observations_obs_type",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AreaChecked(Base):
|
||||
"""Слой 3: проверенный участок (результат работы группы)."""
|
||||
__tablename__ = "areas_checked"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
||||
geom = Column(JSONB)
|
||||
checked_at = Column(DateTime(timezone=True))
|
||||
result = Column(String(20), default="partial")
|
||||
coverage_pct = Column(Float)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"),
|
||||
)
|
||||
|
||||
|
||||
class FoundEvent(Base):
|
||||
"""Слой 3: событие находки пропавшего."""
|
||||
__tablename__ = "found_events"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
geom = Column(JSONB)
|
||||
occurred_at = Column(DateTime(timezone=True))
|
||||
condition = Column(String(50))
|
||||
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class ReferencePrior(Base):
|
||||
"""Слой 1: версионированный справочник априорных значений
|
||||
(экспертные нормы сейчас, ISRID — после B12). Данные НЕ вносятся (B15)."""
|
||||
__tablename__ = "reference_priors"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
source = Column(String(100), nullable=False)
|
||||
category = Column(String(100), nullable=False)
|
||||
value = Column(Float, nullable=False)
|
||||
units = Column(String(50))
|
||||
provenance = Column(Text)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
+39
-181
@@ -10,21 +10,13 @@ from pydantic import BaseModel, Field
|
||||
from backend.database import db
|
||||
from backend.routers.auth import require_roles
|
||||
from services.claude_service import analyze_case as claude_analyze
|
||||
from services.distance_service import (
|
||||
calculate_max_distance,
|
||||
get_base_speed,
|
||||
get_diagnosis_coefficient,
|
||||
get_terrain_coefficient,
|
||||
get_time_of_day_coefficient,
|
||||
get_transport_coefficient,
|
||||
get_weather_coefficient,
|
||||
from services.search_engine import (
|
||||
SearchInput,
|
||||
build_search_model,
|
||||
derive_profiles,
|
||||
derive_time_of_day,
|
||||
unmodeled_profiles,
|
||||
)
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations,
|
||||
)
|
||||
from services.scoring_service import WeightedScorer
|
||||
|
||||
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
|
||||
|
||||
@@ -51,19 +43,14 @@ class AnalysisRequest(BaseModel):
|
||||
profiles: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _first_terrain(value: str | list[str] | None) -> str | None:
|
||||
if isinstance(value, list):
|
||||
return value[0] if value else None
|
||||
return value
|
||||
|
||||
|
||||
def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
terrain = _first_terrain(payload.terrain)
|
||||
@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
# B14: роутер — тонкая обёртка. Вся расчётная логика — в
|
||||
# services/search_engine.py::build_search_model (без DB/auth/HTTP).
|
||||
case_data: dict[str, Any] = {
|
||||
'age': payload.age,
|
||||
'gender': payload.gender,
|
||||
'terrain': terrain,
|
||||
'terrain_primary': terrain,
|
||||
'terrain': payload.terrain,
|
||||
'weather': payload.weather,
|
||||
'elapsed_hours': payload.elapsed_hours,
|
||||
'last_location': payload.last_location,
|
||||
@@ -73,178 +60,49 @@ def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
'season': payload.season,
|
||||
'diagnosis_type': payload.diagnosis_type,
|
||||
'has_transport': payload.has_transport,
|
||||
'profiles': list(payload.profiles or []),
|
||||
'psychotype_answers': payload.psychotype_answers,
|
||||
'lat': payload.lat if payload.lat is not None else payload.tnp_lat,
|
||||
'lon': payload.lon if payload.lon is not None else payload.tnp_lon,
|
||||
}
|
||||
# Отфильтровываем None и пустые контейнеры ([], {}), чтобы при merge
|
||||
# {**to_detail, **case_data} значения из карточки случая не затирались
|
||||
# «пустышками» из дефолтов payload (psychotype_answers={}, diagnosis_type=[]).
|
||||
return {
|
||||
case_data = {
|
||||
k: v for k, v in case_data.items()
|
||||
if v is not None and not (isinstance(v, (list, dict)) and len(v) == 0)
|
||||
}
|
||||
|
||||
|
||||
# Диагнозы, которые форма собирает, но поведенческая модель не покрывает
|
||||
# (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
|
||||
UNMODELED_DIAGNOSES: dict[str, str] = {
|
||||
'ДЦП': 'Двигательные нарушения — модель не учитывает; учесть вручную при планировании (доступность местности, темп).',
|
||||
'слабое_зрение': 'Слабое зрение — модель не учитывает; вероятность следования по открытым/звуковым ориентирам выше.',
|
||||
'слабый_слух': 'Слабый слух — модель не учитывает; голосовой поиск менее эффективен, приоритет визуальным сигналам.',
|
||||
}
|
||||
|
||||
|
||||
def _unmodeled_profiles(case_data: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"""Диагнозы вне поведенческой модели (не влияют на скоринг) + пояснение."""
|
||||
out: list[dict[str, str]] = []
|
||||
for diag in (case_data.get('diagnosis_type') or []):
|
||||
d = str(diag).lower()
|
||||
for key, note in UNMODELED_DIAGNOSES.items():
|
||||
if d == key.lower() and not any(o['profile'] == key for o in out):
|
||||
out.append({'profile': key, 'note': note})
|
||||
return out
|
||||
|
||||
|
||||
def _derive_profiles(case_data: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
Формирует список поведенческих профилей из диагнозов и транспорта, если они
|
||||
не переданы явно. Ключи WeightedScorer.PROFILES — русские (§8 контекста).
|
||||
Это закрывает рассинхрон bike/велосипед на границе бэкенд↔фронтенд.
|
||||
"""
|
||||
explicit = list(case_data.get('profiles') or [])
|
||||
if explicit:
|
||||
return explicit
|
||||
derived: list[str] = []
|
||||
for diag in (case_data.get('diagnosis_type') or []):
|
||||
d = str(diag).lower()
|
||||
if 'рас' in d or 'аутизм' in d:
|
||||
derived.append('РАС')
|
||||
elif 'эпилепси' in d:
|
||||
derived.append('эпилепсия')
|
||||
elif 'сдвг' in d:
|
||||
derived.append('СДВГ')
|
||||
elif 'зпр' in d:
|
||||
derived.append('ЗПР')
|
||||
transport = str(case_data.get('has_transport') or '').lower()
|
||||
if transport == 'bike':
|
||||
derived.append('велосипед')
|
||||
elif transport == 'scooter':
|
||||
derived.append('самокат')
|
||||
if case_data.get('cant_swim'):
|
||||
derived.append('не_умеет_плавать')
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for p in derived:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _derive_time_of_day(loss_time: Any) -> str:
|
||||
"""
|
||||
Выводит время суток из loss_time: 6-18 день, 18-22 сумерки, иначе ночь.
|
||||
Порт фронтендовой getTimeOfDay. Принимает datetime или ISO-строку.
|
||||
"""
|
||||
if not loss_time:
|
||||
return 'день'
|
||||
try:
|
||||
if isinstance(loss_time, str):
|
||||
hour = int(loss_time.split('T')[1].split(':')[0])
|
||||
else:
|
||||
hour = loss_time.hour
|
||||
except Exception:
|
||||
return 'день'
|
||||
if 6 <= hour < 18:
|
||||
return 'день'
|
||||
if 18 <= hour < 22:
|
||||
return 'сумерки'
|
||||
return 'ночь'
|
||||
|
||||
|
||||
@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
case_data = _as_case_data(payload)
|
||||
|
||||
if payload.case_id is not None:
|
||||
case = db.get_case(str(payload.case_id))
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail='Case not found')
|
||||
case_data = {**case.to_detail(), **case_data}
|
||||
|
||||
# Время суток из loss_time (порт фронтендовой getTimeOfDay)
|
||||
if not case_data.get('time_of_day'):
|
||||
case_data['time_of_day'] = _derive_time_of_day(case_data.get('loss_time'))
|
||||
# terrain_primary из terrain, если вдруг не дошёл
|
||||
if not case_data.get('terrain_primary') and case_data.get('terrain'):
|
||||
case_data['terrain_primary'] = _first_terrain(case_data['terrain'])
|
||||
search_input = SearchInput(
|
||||
age=case_data.get('age'),
|
||||
gender=case_data.get('gender'),
|
||||
terrain=case_data.get('terrain'),
|
||||
terrain_primary=case_data.get('terrain_primary'),
|
||||
weather=case_data.get('weather'),
|
||||
elapsed_hours=case_data.get('elapsed_hours'),
|
||||
last_location=case_data.get('last_location'),
|
||||
circumstances=case_data.get('circumstances'),
|
||||
physical_condition=case_data.get('physical_condition'),
|
||||
experience=case_data.get('experience'),
|
||||
season=case_data.get('season'),
|
||||
diagnosis_type=case_data.get('diagnosis_type') or [],
|
||||
has_transport=case_data.get('has_transport'),
|
||||
cant_swim=bool(case_data.get('cant_swim')),
|
||||
psychotype_answers=case_data.get('psychotype_answers') or {},
|
||||
profiles=case_data.get('profiles') or [],
|
||||
lat=case_data.get('lat'),
|
||||
lon=case_data.get('lon'),
|
||||
loss_time=case_data.get('loss_time'),
|
||||
time_of_day=case_data.get('time_of_day'),
|
||||
)
|
||||
|
||||
# Поведенческие профили из диагнозов + транспорта (если не заданы явно)
|
||||
case_data['profiles'] = _derive_profiles(case_data)
|
||||
model = await build_search_model(search_input, analyzer=claude_analyze)
|
||||
result = model.model_dump()
|
||||
|
||||
# Психотип детектим ДО анализа, чтобы применить его к ранжированию зон.
|
||||
# get_psychotype_modifiers возвращает таблицу полос по дистанции, которую
|
||||
# WeightedScorer.score_zone читает из case['psychotype_modifiers'].
|
||||
psychotype = None
|
||||
psychotype_modifiers = None
|
||||
psychotype_recommendations = None
|
||||
if case_data.get('psychotype_answers'):
|
||||
psychotype = detect_psychotype(case_data['psychotype_answers'])
|
||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
case_data['psychotype'] = psychotype
|
||||
case_data['psychotype_modifiers'] = psychotype_modifiers
|
||||
|
||||
max_distance_km = calculate_max_distance(case_data)
|
||||
claude_result = await claude_analyze(case_data)
|
||||
|
||||
scorer = WeightedScorer()
|
||||
if case_data.get('age'):
|
||||
scorer.apply_age_modifiers(int(case_data['age']))
|
||||
if case_data.get('season'):
|
||||
scorer.apply_season_modifiers(str(case_data['season']))
|
||||
if case_data.get('profiles'):
|
||||
scorer.apply_profile(list(case_data['profiles']))
|
||||
scorer._normalize_weights()
|
||||
|
||||
# Разбивка коэффициентов формулы Экстремум для отображения на фронте
|
||||
elapsed = float(case_data.get('elapsed_hours') or 1)
|
||||
age_val = int(case_data.get('age') or 10)
|
||||
coefficients = {
|
||||
'base_speed': get_base_speed(age_val),
|
||||
'terrain': get_terrain_coefficient(case_data.get('terrain_primary', 'лес')),
|
||||
'diagnosis': get_diagnosis_coefficient(case_data.get('diagnosis_type')),
|
||||
'urgency': get_transport_coefficient(case_data.get('has_transport')),
|
||||
'fatigue': max(0.3, 1.0 - 0.05 * elapsed),
|
||||
'time_of_day': get_time_of_day_coefficient(case_data.get('time_of_day', 'день')),
|
||||
'weather': get_weather_coefficient(case_data.get('weather', 'нет')),
|
||||
}
|
||||
|
||||
result = {
|
||||
'case_id': str(payload.case_id) if payload.case_id else None,
|
||||
'analyzed_at': datetime.now(timezone.utc).isoformat(),
|
||||
'max_distance_km': max_distance_km,
|
||||
'coefficients': coefficients,
|
||||
'time_of_day': case_data.get('time_of_day', 'день'),
|
||||
'psychotype': psychotype,
|
||||
'psychotype_modifiers': psychotype_modifiers,
|
||||
'psychotype_recommendations': psychotype_recommendations,
|
||||
'weights': scorer.weights,
|
||||
'distance_multiplier': scorer.distance_multiplier,
|
||||
'active_profiles': scorer.get_active_profiles_info(),
|
||||
'critical_warnings': scorer.critical_warnings,
|
||||
'unmodeled_profiles': _unmodeled_profiles(case_data),
|
||||
'urgency': claude_result.urgency,
|
||||
'primary_zones': [zone.model_dump() for zone in claude_result.primary_zones],
|
||||
'search_radius_km': claude_result.search_radius_km,
|
||||
'key_locations': claude_result.key_locations,
|
||||
'behavioral_prediction': claude_result.behavioral_prediction,
|
||||
'immediate_actions': claude_result.immediate_actions,
|
||||
'summary': claude_result.summary,
|
||||
'fallback_used': claude_result.fallback_used,
|
||||
}
|
||||
result['case_id'] = str(payload.case_id) if payload.case_id else None
|
||||
result['analyzed_at'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if payload.case_id is not None:
|
||||
db.update_case(str(payload.case_id), analysis_log=result, status='analyzed')
|
||||
@@ -269,4 +127,4 @@ def get_analysis(case_id: str) -> dict[str, Any]:
|
||||
'case_id': case_id,
|
||||
'analysis_log': detail['analysis_log'],
|
||||
'created_at': detail['created_at'],
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,11 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from backend.database import db
|
||||
from backend.routers.analyze import (
|
||||
AnalysisRequest,
|
||||
_derive_profiles,
|
||||
_derive_time_of_day,
|
||||
from backend.routers.analyze import AnalysisRequest
|
||||
from services.search_engine import (
|
||||
derive_profiles as _derive_profiles,
|
||||
derive_time_of_day as _derive_time_of_day,
|
||||
unmodeled_profiles as _unmodeled_profiles,
|
||||
)
|
||||
from backend.routers.auth import require_roles
|
||||
from backend.schemas import ClosedCaseCreate, ClosedCaseListResponse, ClosedCaseResponse
|
||||
@@ -74,7 +75,12 @@ def _run_model(data: dict[str, Any]) -> dict[str, Any]:
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
|
||||
time_of_day = _derive_time_of_day(data.get('loss_time'))
|
||||
profiles = _derive_profiles({**data, 'profiles': data.get('profiles') or []})
|
||||
profiles = _derive_profiles(
|
||||
data.get('diagnosis_type') or [],
|
||||
data.get('has_transport'),
|
||||
bool(data.get('cant_swim')),
|
||||
explicit=data.get('profiles') or [],
|
||||
)
|
||||
|
||||
max_distance_km = calculate_max_distance(data)
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""B14: юнит-тесты чистого движка services/search_engine.py.
|
||||
|
||||
claude_analyze мокается (движок тестируем изолированно, без httpx/ANTHROPIC).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from services.claude_service import AnalysisResult, PrimaryZone
|
||||
from services.search_engine import (
|
||||
SearchInput,
|
||||
build_search_model,
|
||||
derive_profiles,
|
||||
derive_time_of_day,
|
||||
unmodeled_profiles,
|
||||
)
|
||||
|
||||
|
||||
def _fake_analyzer(urgency: str = 'высокая', radius: float = 1.5) -> Any:
|
||||
async def analyzer(case_data: dict[str, Any]) -> AnalysisResult:
|
||||
analyzer.captured = case_data
|
||||
return AnalysisResult(
|
||||
urgency=urgency,
|
||||
primary_zones=[
|
||||
PrimaryZone(priority=1, name='Тест', direction='N', distance=0.5, reason='тест')
|
||||
],
|
||||
search_radius_km=radius,
|
||||
key_locations=['водоёмы'],
|
||||
behavioral_prediction='тест',
|
||||
immediate_actions=['действие'],
|
||||
summary='тест',
|
||||
fallback_used=True,
|
||||
)
|
||||
|
||||
analyzer.captured = None
|
||||
return analyzer
|
||||
|
||||
|
||||
def _base_input(**overrides: Any) -> SearchInput:
|
||||
data = dict(
|
||||
age=8,
|
||||
gender='м',
|
||||
terrain=['лес'],
|
||||
elapsed_hours=2.0,
|
||||
has_transport='bike',
|
||||
time_of_day='день',
|
||||
)
|
||||
data.update(overrides)
|
||||
return SearchInput(**data)
|
||||
|
||||
|
||||
async def test_build_search_model_bike_8yo_matches_old_contract():
|
||||
"""Регресс из B14: bike 8yo 2h лес день → max_distance 5.4 км
|
||||
(Time 2 × НормС 1.2 × СП 0.5 × СУТ 0.9 × СУ-bike 5.0)."""
|
||||
model = await build_search_model(_base_input(), analyzer=_fake_analyzer())
|
||||
assert model.max_distance_km == pytest.approx(5.4, abs=1e-6)
|
||||
assert model.coefficients['base_speed'] == 1.2
|
||||
assert model.coefficients['terrain'] == 0.5
|
||||
assert model.coefficients['urgency'] == 5.0 # bike
|
||||
|
||||
|
||||
async def test_profiles_derived_from_diagnosis_and_transport():
|
||||
analyzer = _fake_analyzer()
|
||||
model = await build_search_model(
|
||||
_base_input(diagnosis_type=['РАС'], cant_swim=True),
|
||||
analyzer=analyzer,
|
||||
)
|
||||
assert 'РАС' in [p['name'] for p in model.active_profiles]
|
||||
assert model.critical_warnings == [] or isinstance(model.critical_warnings, list)
|
||||
# Профили дошли и до claude (case_data), и в скорер
|
||||
assert 'РАС' in analyzer.captured['profiles']
|
||||
assert 'велосипед' in analyzer.captured['profiles']
|
||||
assert 'не_умеет_плавать' in analyzer.captured['profiles']
|
||||
|
||||
|
||||
async def test_ras_bike_profiles_case_sensitive_keys():
|
||||
"""Ключи профилей — русские (§8): РАС + велосипед, не bike."""
|
||||
assert derive_profiles(['рас'], 'bike', False) == ['РАС', 'велосипед']
|
||||
assert derive_profiles([], 'scooter', True) == ['самокат', 'не_умеет_плавать']
|
||||
|
||||
|
||||
async def test_explicit_profiles_win_over_derivation():
|
||||
assert derive_profiles(['РАС'], 'bike', True, explicit=['СДВГ']) == ['СДВГ']
|
||||
|
||||
|
||||
async def test_unmodeled_diagnoses_flagged():
|
||||
flagged = unmodeled_profiles(['ДЦП', 'РАС', 'слабый_слух'])
|
||||
assert [f['profile'] for f in flagged] == ['ДЦП', 'слабый_слух']
|
||||
assert all(f['note'] for f in flagged)
|
||||
|
||||
|
||||
async def test_time_of_day_from_loss_time():
|
||||
assert derive_time_of_day('2026-09-09T14:00:00') == 'день'
|
||||
assert derive_time_of_day('2026-09-09T20:00:00') == 'сумерки'
|
||||
assert derive_time_of_day('2026-09-09T02:00:00') == 'ночь'
|
||||
assert derive_time_of_day('мусор') == 'день'
|
||||
assert derive_time_of_day(None) == 'день'
|
||||
assert derive_time_of_day(datetime(2026, 9, 9, 23, 0)) == 'ночь'
|
||||
|
||||
|
||||
async def test_time_of_day_auto_applied_in_model():
|
||||
model = await build_search_model(
|
||||
_base_input(time_of_day=None, loss_time='2026-09-09T21:00:00'),
|
||||
analyzer=_fake_analyzer(),
|
||||
)
|
||||
assert model.time_of_day == 'сумерки'
|
||||
assert model.coefficients['time_of_day'] == 0.5 # суметки замедляют (видимость)
|
||||
|
||||
|
||||
async def test_psychotype_detected_and_applied():
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'explore',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'always_leader',
|
||||
'risk_taking': 'very',
|
||||
}
|
||||
analyzer = _fake_analyzer()
|
||||
model = await build_search_model(
|
||||
_base_input(psychotype_answers=answers),
|
||||
analyzer=analyzer,
|
||||
)
|
||||
assert model.psychotype == 'dominant'
|
||||
assert model.psychotype_modifiers is not None
|
||||
assert model.psychotype_recommendations is not None
|
||||
assert analyzer.captured['psychotype_modifiers'] == model.psychotype_modifiers
|
||||
|
||||
|
||||
async def test_no_psychotype_when_no_answers():
|
||||
model = await build_search_model(_base_input(), analyzer=_fake_analyzer())
|
||||
assert model.psychotype is None
|
||||
assert model.psychotype_modifiers is None
|
||||
|
||||
|
||||
async def test_engine_does_not_touch_db_or_http():
|
||||
"""Чистота границы: движок не импортирует DB/auth/HTTP-клиентов."""
|
||||
import services.search_engine as se
|
||||
source = open(se.__file__, encoding='utf-8').read()
|
||||
assert 'backend.database' not in source
|
||||
assert 'backend.routers' not in source
|
||||
assert 'SessionLocal' not in source
|
||||
assert 'Depends' not in source
|
||||
# И никакого ATAK/Meshtastic/CoT (критерий B20/B14)
|
||||
assert 'ATAK' not in source and 'Meshtastic' not in source and 'CoT' not in source
|
||||
|
||||
|
||||
async def test_case_id_merge_loses_payload_empty_containers():
|
||||
"""Пустые контейнеры payload не затирают карточку (старое поведение
|
||||
_as_case_data сохранено на уровне роутера — здесь фиксируем семантику
|
||||
SearchInput: явно переданные пустые списки допустимы)."""
|
||||
si = SearchInput(diagnosis_type=[])
|
||||
assert si.to_case_data()['diagnosis_type'] == []
|
||||
|
||||
|
||||
async def test_fallback_fields_flow_through():
|
||||
analyzer = _fake_analyzer(urgency='критическая', radius=2.5)
|
||||
model = await build_search_model(_base_input(), analyzer=analyzer)
|
||||
assert model.urgency == 'критическая'
|
||||
assert model.search_radius_km == 2.5
|
||||
assert model.fallback_used is True
|
||||
assert model.primary_zones[0]['direction'] == 'N'
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Чистка тестовых контейнеров и артефактов B15 на CT108
|
||||
docker rm -f vec-mig-pg vec-mig-run vec-mig-empty vec-mig-run2 vec-mig-full vec-mig-run3 >/dev/null 2>&1
|
||||
rm -rf /root/vector-mig-test /tmp/vector-mig.tgz /tmp/test-mig-*.sh /tmp/test-mig-*.sql /tmp/006_b15_layers.py
|
||||
echo CLEAN
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Тест 2: чистая пустая БД — ТОЛЬКО alembic (без create_all). Миграция 006
|
||||
# должна создать ВСЕ таблицы слоёв; cases создаёт create_all/init отдельно
|
||||
# (миграция 006 не дублирует legacy-скрипты 001-005 — это осознанное решение).
|
||||
set -e
|
||||
docker rm -f vec-mig-empty >/dev/null 2>&1 || true
|
||||
docker rm -f vec-mig-run2 >/dev/null 2>&1 || true
|
||||
docker run -d --name vec-mig-empty -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=vector_mchs postgres:16 >/dev/null
|
||||
sleep 6
|
||||
docker run -d --name vec-mig-run2 --network container:vec-mig-empty -v /root/vector-mig-test:/app -w /app -e DATABASE_URL=postgresql://postgres:postgres@localhost:5432/vector_mchs python:3.11-slim sleep 600 >/dev/null
|
||||
docker exec vec-mig-run2 pip install -q alembic==1.14.0 sqlalchemy psycopg2-binary
|
||||
# На голой БД нужен cases до бэкфилла (запрос читает cases) — минимальный init:
|
||||
docker exec vec-mig-run2 sh -c "cd /app && PYTHONPATH=/app python -c \"
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models import Case
|
||||
engine = create_engine('postgresql://postgres:postgres@localhost:5432/vector_mchs')
|
||||
Case.__table__.create(engine)
|
||||
print('cases table created')
|
||||
\""
|
||||
docker exec vec-mig-run2 sh -c "cd /app/backend && alembic upgrade head" 2>&1 | tail -1
|
||||
docker exec vec-mig-run2 sh -c "cd /app && PYTHONPATH=/app python -c \"
|
||||
import psycopg2, os
|
||||
conn = psycopg2.connect(os.environ['DATABASE_URL'])
|
||||
cur = conn.cursor()
|
||||
for t in ('search_models','search_teams','field_observations','areas_checked','found_events','reference_priors'):
|
||||
cur.execute(f'select count(*) from {t}')
|
||||
print(t, cur.fetchone()[0])
|
||||
cur.execute('select version_num from alembic_version')
|
||||
print('alembic version:', cur.fetchone()[0])
|
||||
\""
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Финальная сверка: источник не тронут
|
||||
SELECT 'case analysis_log urgency' AS what, analysis_log->>'urgency' AS val FROM cases WHERE id = '11111111-1111-4111-8111-000000000001'
|
||||
UNION ALL
|
||||
SELECT 'search_models v1 urgency', model_json->>'urgency' FROM search_models
|
||||
UNION ALL
|
||||
SELECT 'legacy array case still there', jsonb_typeof(analysis_log) FROM cases WHERE id = '11111111-1111-4111-8111-000000000002';
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# Тест 3: БД со схемой 001-003 + ВСЕ старые кейсы (23 шт, стенда-подобная) →
|
||||
# upgrade + count бэкфилла + downgrade + upgrade (повторная идемпотентность)
|
||||
set -e
|
||||
docker rm -f vec-mig-full >/dev/null 2>&1 || true
|
||||
docker run -d --name vec-mig-full -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=vector_mchs postgres:16 >/dev/null
|
||||
sleep 6
|
||||
docker rm -f vec-mig-run3 >/dev/null 2>&1 || true
|
||||
docker run -d --name vec-mig-run3 --network container:vec-mig-full -v /root/vector-mig-test:/app -w /app -e DATABASE_URL=postgresql://postgres:postgres@localhost:5432/vector_mchs python:3.11-slim sleep 900 >/dev/null
|
||||
docker exec vec-mig-run3 pip install -q alembic==1.14.0 sqlalchemy psycopg2-binary 2>/dev/null
|
||||
docker exec vec-mig-full psql -U postgres -d vector_mchs -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" >/dev/null
|
||||
cd /root/vector
|
||||
for f in backend/migrations/001_create_cases_table.sql backend/migrations/002_add_detailed_fields.sql backend/migrations/003_create_users_and_analysis_log.sql; do
|
||||
docker exec -i vec-mig-full psql -U postgres -d vector_mchs < "$f" >/dev/null 2>&1
|
||||
done
|
||||
# Реальный дамп данных со стенда
|
||||
docker exec vec-mig-pg pg_dump -U postgres -d vector_mchs --data-only --table=cases --table=analysis_log > /tmp/stand-data.sql 2>/dev/null || true
|
||||
# Данных со стенда нет в тестовом контейнере — seed вручную (3 кейса как на стенде):
|
||||
docker exec -i vec-mig-full psql -U postgres -d vector_mchs < /tmp/test-mig-seed.sql >/dev/null 2>&1 || true
|
||||
echo "=== upgrade:"
|
||||
docker exec vec-mig-run3 sh -c "cd /app/backend && alembic upgrade head" 2>&1 | tail -1
|
||||
echo "=== backfill count (ожидаем 1: только object с primary_zones):"
|
||||
docker exec vec-mig-full psql -U postgres -d vector_mchs -t -c "select count(*) from search_models;"
|
||||
echo "=== idempotent re-upgrade (down->up):"
|
||||
docker exec vec-mig-run3 sh -c "cd /app/backend && alembic downgrade base -x" 2>&1 | tail -1
|
||||
docker exec vec-mig-run3 sh -c "cd /app/backend && alembic upgrade head" 2>&1 | tail -1
|
||||
echo "=== final count (ожидаем снова 1, без дублей):"
|
||||
docker exec vec-mig-full psql -U postgres -d vector_mchs -t -c "select count(*) from search_models;"
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Seed для теста миграции 006 (idempotent: 001 содержит DROP TABLE)
|
||||
set -e
|
||||
cd /root/vector
|
||||
for f in backend/migrations/001_create_cases_table.sql backend/migrations/002_add_detailed_fields.sql backend/migrations/003_create_users_and_analysis_log.sql; do
|
||||
docker exec -i vec-mig-pg psql -U postgres -d vector_mchs < "$f" 2>&1 | grep -i "error" && exit 1 || true
|
||||
done
|
||||
docker exec vec-mig-pg psql -U postgres -d vector_mchs <<'SQL'
|
||||
INSERT INTO cases (id, age_years, status, analysis_log) VALUES
|
||||
('11111111-1111-4111-8111-000000000001', 10, 'active', '{"primary_zones": [{"priority": 1, "name": "З"}], "urgency": "высокая", "max_distance_km": 5.4}'::jsonb),
|
||||
('11111111-1111-4111-8111-000000000002', 12, 'new', '[{"legacy": true}]'::jsonb),
|
||||
('11111111-1111-4111-8111-000000000003', 8, 'active', NULL);
|
||||
SQL
|
||||
echo "--- cases seeded:"
|
||||
docker exec vec-mig-pg psql -U postgres -d vector_mchs -t -c "SELECT count(*) FROM cases;"
|
||||
echo "--- analysis_log types:"
|
||||
docker exec vec-mig-pg psql -U postgres -d vector_mchs -t -c "SELECT id, jsonb_typeof(analysis_log) FROM cases;"
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Seed для теста миграции 006 (idempotent)
|
||||
INSERT INTO cases (id, age_years, status, analysis_log) VALUES
|
||||
('11111111-1111-4111-8111-000000000001', 10, 'active', '{"primary_zones": [{"priority": 1, "name": "З"}], "urgency": "высокая", "max_distance_km": 5.4}'::jsonb),
|
||||
('11111111-1111-4111-8111-000000000002', 12, 'new', '[{"legacy": true}]'::jsonb),
|
||||
('11111111-1111-4111-8111-000000000003', 8, 'active', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Тест стенды для миграции 006: контейнер pg + схема 001-003 + seed
|
||||
set -e
|
||||
docker rm -f vec-mig-pg >/dev/null 2>&1 || true
|
||||
docker run -d --name vec-mig-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=vector_mchs postgres:16 >/dev/null
|
||||
sleep 6
|
||||
docker exec vec-mig-pg psql -U postgres -d vector_mchs -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" >/dev/null
|
||||
cd /root/vector
|
||||
for f in backend/migrations/001_create_cases_table.sql backend/migrations/002_add_detailed_fields.sql backend/migrations/003_create_users_and_analysis_log.sql; do
|
||||
docker exec -i vec-mig-pg psql -U postgres -d vector_mchs < "$f" >/dev/null 2>&1
|
||||
done
|
||||
# Seed: 3 кейса — с analyze_log-объектом (бэкфилл), с legacy-массивом (скип), без лога
|
||||
docker exec vec-mig-pg psql -U postgres -d vector_mchs <<'SQL'
|
||||
INSERT INTO cases (id, age_years, status, analysis_log) VALUES
|
||||
('11111111-1111-4111-8111-000000000001', 10, 'active', '{"primary_zones": [{"priority": 1, "name": "З"}], "urgency": "высокая", "max_distance_km": 5.4}'::jsonb),
|
||||
('11111111-1111-4111-8111-000000000002', 12, 'new', '[{"legacy": true}]'::jsonb),
|
||||
('11111111-1111-4111-8111-000000000003', 8, 'active', NULL);
|
||||
SQL
|
||||
echo "--- seeded cases:"
|
||||
docker exec vec-mig-pg psql -U postgres -d vector_mchs -t -c "SELECT count(*) FROM cases;"
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Проверка результата миграции 006 на тестовой БД
|
||||
\dt
|
||||
SELECT id, version, model_json->>'urgency' AS urgency, model_json->'primary_zones' AS zones FROM search_models ORDER BY created_at;
|
||||
SELECT count(*) AS backfilled_should_be_1 FROM search_models;
|
||||
INSERT INTO field_observations (case_id, obs_type, geom, confidence) VALUES ('11111111-1111-4111-8111-000000000001', 'position', '{"type": "Point", "coordinates": [27.56, 53.9]}', 0.9);
|
||||
INSERT INTO field_observations (case_id, obs_type) VALUES ('11111111-1111-4111-8111-000000000001', 'invalid_type');
|
||||
@@ -0,0 +1,293 @@
|
||||
"""B14: Чистый SearchEngine — вся SAR-математика без DB/auth/HTTP.
|
||||
|
||||
Граница (vector_tasks.md B13-принцип 1, B14):
|
||||
- build_search_model(input) -> SearchModel — чистая функция.
|
||||
- Единственная внешняя зависимость — claude_analyze (async, httpx):
|
||||
она уже была «внутри» расчёта в старом analyze_case; изолируется
|
||||
параметром движка, чтобы тесты могли подменять её моком.
|
||||
- Ни одной привязки к полевым транспортам/протоколам (критерий B20:
|
||||
grep по этому файлу не находит соответствующих терминов).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.claude_service import AnalysisResult
|
||||
from services.distance_service import (
|
||||
calculate_max_distance,
|
||||
get_base_speed,
|
||||
get_diagnosis_coefficient,
|
||||
get_terrain_coefficient,
|
||||
get_time_of_day_coefficient,
|
||||
get_transport_coefficient,
|
||||
get_weather_coefficient,
|
||||
)
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations,
|
||||
)
|
||||
from services.scoring_service import WeightedScorer
|
||||
|
||||
# Тип анализатора: async (case_data) -> AnalysisResult, как claude_analyze.
|
||||
ClaudeAnalyzer = Callable[[dict[str, Any]], Awaitable[AnalysisResult]]
|
||||
|
||||
# Диагнозы, которые форма собирает, но поведенческая модель не покрывает
|
||||
# (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
|
||||
UNMODELED_DIAGNOSES: dict[str, str] = {
|
||||
'ДЦП': 'Двигательные нарушения — модель не учитывает; учесть вручную при планировании (доступность местности, темп).',
|
||||
'слабое_зрение': 'Слабое зрение — модель не учитывает; вероятность следования по открытым/звуковым ориентирам выше.',
|
||||
'слабый_слух': 'Слабый слух — модель не учитывает; голосовой поиск менее эффективен, приоритет визуальным сигналам.',
|
||||
}
|
||||
|
||||
|
||||
class SearchInput(BaseModel):
|
||||
"""Вход поисковой модели — то, что сегодня собирает analyze_case
|
||||
из payload + карточки случая (db.get_case) + деривации."""
|
||||
|
||||
class SearchInput(BaseModel):
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
terrain: str | list[str] | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
elapsed_hours: float | None = None
|
||||
last_location: str | None = None
|
||||
circumstances: str | None = None
|
||||
physical_condition: str | None = None
|
||||
experience: str | None = None
|
||||
season: str | None = None
|
||||
diagnosis_type: list[str] = Field(default_factory=list)
|
||||
has_transport: str | None = None
|
||||
cant_swim: bool = False
|
||||
psychotype_answers: dict[str, Any] = Field(default_factory=dict)
|
||||
profiles: list[str] = Field(default_factory=list)
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
loss_time: Any = None
|
||||
# Необязательный явный time_of_day (иначе выводится из loss_time)
|
||||
time_of_day: str | None = None
|
||||
|
||||
def to_case_data(self) -> dict[str, Any]:
|
||||
"""Плоский dict для сервисов (claude/scoring) — тот же состав полей,
|
||||
что раньше уходил из analyze_case."""
|
||||
data: dict[str, Any] = {
|
||||
'age': self.age,
|
||||
'gender': self.gender,
|
||||
'terrain': _first_terrain(self.terrain),
|
||||
'weather': self.weather,
|
||||
'elapsed_hours': self.elapsed_hours,
|
||||
'last_location': self.last_location,
|
||||
'circumstances': self.circumstances,
|
||||
'physical_condition': self.physical_condition,
|
||||
'experience': self.experience,
|
||||
'season': self.season,
|
||||
'diagnosis_type': list(self.diagnosis_type),
|
||||
'has_transport': self.has_transport,
|
||||
'cant_swim': self.cant_swim,
|
||||
'profiles': list(self.profiles),
|
||||
'psychotype_answers': self.psychotype_answers,
|
||||
'lat': self.lat,
|
||||
'lon': self.lon,
|
||||
'loss_time': self.loss_time,
|
||||
}
|
||||
if self.terrain_primary:
|
||||
data['terrain_primary'] = self.terrain_primary
|
||||
else:
|
||||
data['terrain_primary'] = _first_terrain(self.terrain)
|
||||
if self.time_of_day:
|
||||
data['time_of_day'] = self.time_of_day
|
||||
return data
|
||||
|
||||
|
||||
class SearchModel(BaseModel):
|
||||
"""Результат — состав полей идентичен старому ответу /analyze
|
||||
(фронт B5 от него зависит; контракт не меняется)."""
|
||||
|
||||
max_distance_km: float
|
||||
coefficients: dict[str, Any]
|
||||
time_of_day: str
|
||||
psychotype: str | None
|
||||
psychotype_modifiers: dict[str, Any] | None
|
||||
psychotype_recommendations: Any | None
|
||||
weights: dict[str, float]
|
||||
distance_multiplier: float
|
||||
active_profiles: list[dict[str, Any]]
|
||||
critical_warnings: list[dict[str, Any]]
|
||||
unmodeled_profiles: list[dict[str, str]]
|
||||
urgency: str
|
||||
primary_zones: list[dict[str, Any]]
|
||||
search_radius_km: float
|
||||
key_locations: list[str]
|
||||
behavioral_prediction: str
|
||||
immediate_actions: list[str]
|
||||
summary: str
|
||||
fallback_used: bool
|
||||
|
||||
|
||||
def _first_terrain(value: str | list[str] | None) -> str | None:
|
||||
if isinstance(value, list):
|
||||
return value[0] if value else None
|
||||
return value
|
||||
|
||||
|
||||
def unmodeled_profiles(diagnosis_type: list[str]) -> list[dict[str, str]]:
|
||||
"""Диагнозы вне поведенческой модели (не влияют на скоринг) + пояснение."""
|
||||
out: list[dict[str, str]] = []
|
||||
for diag in diagnosis_type or []:
|
||||
d = str(diag).lower()
|
||||
for key, note in UNMODELED_DIAGNOSES.items():
|
||||
if d == key.lower() and not any(o['profile'] == key for o in out):
|
||||
out.append({'profile': key, 'note': note})
|
||||
return out
|
||||
|
||||
|
||||
def derive_profiles(
|
||||
diagnosis_type: list[str],
|
||||
has_transport: str | None,
|
||||
cant_swim: bool,
|
||||
explicit: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Формирует список поведенческих профилей из диагнозов и транспорта, если они
|
||||
не переданы явно. Ключи WeightedScorer.PROFILES — русские (§8 контекста).
|
||||
Это закрывает рассинхрон bike/велосипед на границе бэкенд↔фронтенд.
|
||||
"""
|
||||
if explicit:
|
||||
return list(explicit)
|
||||
derived: list[str] = []
|
||||
for diag in diagnosis_type or []:
|
||||
d = str(diag).lower()
|
||||
if 'рас' in d or 'аутизм' in d:
|
||||
derived.append('РАС')
|
||||
elif 'эпилепси' in d:
|
||||
derived.append('эпилепсия')
|
||||
elif 'сдвг' in d:
|
||||
derived.append('СДВГ')
|
||||
elif 'зпр' in d:
|
||||
derived.append('ЗПР')
|
||||
transport = str(has_transport or '').lower()
|
||||
if transport == 'bike':
|
||||
derived.append('велосипед')
|
||||
elif transport == 'scooter':
|
||||
derived.append('самокат')
|
||||
if cant_swim:
|
||||
derived.append('не_умеет_плавать')
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for p in derived:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def derive_time_of_day(loss_time: Any) -> str:
|
||||
"""
|
||||
Выводит время суток из loss_time: 6-18 день, 18-22 сумерки, иначе ночь.
|
||||
Порт фронтендовой getTimeOfDay. Принимает datetime или ISO-строку.
|
||||
"""
|
||||
if not loss_time:
|
||||
return 'день'
|
||||
try:
|
||||
if isinstance(loss_time, str):
|
||||
hour = int(loss_time.split('T')[1].split(':')[0])
|
||||
else:
|
||||
hour = loss_time.hour
|
||||
except Exception:
|
||||
return 'день'
|
||||
if 6 <= hour < 18:
|
||||
return 'день'
|
||||
if 18 <= hour < 22:
|
||||
return 'сумерки'
|
||||
return 'ночь'
|
||||
|
||||
|
||||
async def build_search_model(
|
||||
search_input: SearchInput,
|
||||
*,
|
||||
analyzer: ClaudeAnalyzer,
|
||||
analyzed_at: datetime | None = None,
|
||||
) -> SearchModel:
|
||||
"""Чистая функция расчёта поисковой модели (B14).
|
||||
|
||||
Вся логика бывшего analyze_case: деривации полей, психотип,
|
||||
WeightedScorer, коэффициенты формулы Экстремум, claude_analyze.
|
||||
DB / auth / HTTP здесь не нужны; анализатор подменяется параметром.
|
||||
"""
|
||||
case_data = search_input.to_case_data()
|
||||
|
||||
# Время суток из loss_time (порт фронтендовой getTimeOfDay)
|
||||
if not case_data.get('time_of_day'):
|
||||
case_data['time_of_day'] = derive_time_of_day(case_data.get('loss_time'))
|
||||
|
||||
# Поведенческие профили из диагнозов + транспорта (если не заданы явно)
|
||||
case_data['profiles'] = derive_profiles(
|
||||
case_data.get('diagnosis_type') or [],
|
||||
case_data.get('has_transport'),
|
||||
bool(case_data.get('cant_swim')),
|
||||
explicit=list(search_input.profiles or []),
|
||||
)
|
||||
|
||||
# Психотип детектим ДО анализа, чтобы применить его к ранжированию зон.
|
||||
# get_psychotype_modifiers возвращает таблицу полос по дистанции, которую
|
||||
# WeightedScorer.score_zone читает из case['psychotype_modifiers'].
|
||||
psychotype = None
|
||||
psychotype_modifiers = None
|
||||
psychotype_recommendations = None
|
||||
if case_data.get('psychotype_answers'):
|
||||
psychotype = detect_psychotype(case_data['psychotype_answers'])
|
||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
case_data['psychotype'] = psychotype
|
||||
case_data['psychotype_modifiers'] = psychotype_modifiers
|
||||
|
||||
max_distance_km = calculate_max_distance(case_data)
|
||||
claude_result = await analyzer(case_data)
|
||||
|
||||
scorer = WeightedScorer()
|
||||
if case_data.get('age'):
|
||||
scorer.apply_age_modifiers(int(case_data['age']))
|
||||
if case_data.get('season'):
|
||||
scorer.apply_season_modifiers(str(case_data['season']))
|
||||
if case_data.get('profiles'):
|
||||
scorer.apply_profile(list(case_data['profiles']))
|
||||
scorer._normalize_weights()
|
||||
|
||||
# Разбивка коэффициентов формулы Экстремум для отображения на фронте
|
||||
elapsed = float(case_data.get('elapsed_hours') or 1)
|
||||
age_val = int(case_data.get('age') or 10)
|
||||
coefficients = {
|
||||
'base_speed': get_base_speed(age_val),
|
||||
'terrain': get_terrain_coefficient(case_data.get('terrain_primary', 'лес')),
|
||||
'diagnosis': get_diagnosis_coefficient(case_data.get('diagnosis_type')),
|
||||
'urgency': get_transport_coefficient(case_data.get('has_transport')),
|
||||
'fatigue': max(0.3, 1.0 - 0.05 * elapsed),
|
||||
'time_of_day': get_time_of_day_coefficient(case_data.get('time_of_day', 'день')),
|
||||
'weather': get_weather_coefficient(case_data.get('weather', 'нет')),
|
||||
}
|
||||
|
||||
return SearchModel(
|
||||
max_distance_km=max_distance_km,
|
||||
coefficients=coefficients,
|
||||
time_of_day=case_data.get('time_of_day', 'день'),
|
||||
psychotype=psychotype,
|
||||
psychotype_modifiers=psychotype_modifiers,
|
||||
psychotype_recommendations=psychotype_recommendations,
|
||||
weights=scorer.weights,
|
||||
distance_multiplier=scorer.distance_multiplier,
|
||||
active_profiles=scorer.get_active_profiles_info(),
|
||||
critical_warnings=list(scorer.critical_warnings),
|
||||
unmodeled_profiles=unmodeled_profiles(case_data.get('diagnosis_type') or []),
|
||||
urgency=claude_result.urgency,
|
||||
primary_zones=[zone.model_dump() for zone in claude_result.primary_zones],
|
||||
search_radius_km=claude_result.search_radius_km,
|
||||
key_locations=claude_result.key_locations,
|
||||
behavioral_prediction=claude_result.behavioral_prediction,
|
||||
immediate_actions=claude_result.immediate_actions,
|
||||
summary=claude_result.summary,
|
||||
fallback_used=claude_result.fallback_used,
|
||||
)
|
||||
Reference in New Issue
Block a user