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:
2026-09-09 15:48:29 +03:00
parent c368482d1c
commit 5bdb345e33
19 changed files with 1033 additions and 186 deletions
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Alembic scripts package for ВЕКТОР."""
+70
View File
@@ -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()
+26
View File
@@ -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"}
+161
View File
@@ -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')
+1
View File
@@ -0,0 +1 @@
"""Alembic migration package for ВЕКТОР."""
+108
View File
@@ -1,3 +1,4 @@
import sqlalchemy as sa
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -117,3 +118,110 @@ class RawDocument(Base):
raw_text = Column(Text) raw_text = Column(Text)
extracted_json = Column(JSONB) extracted_json = Column(JSONB)
created_at = Column(DateTime, server_default=func.now()) 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())
+38 -180
View File
@@ -10,21 +10,13 @@ from pydantic import BaseModel, Field
from backend.database import db from backend.database import db
from backend.routers.auth import require_roles from backend.routers.auth import require_roles
from services.claude_service import analyze_case as claude_analyze from services.claude_service import analyze_case as claude_analyze
from services.distance_service import ( from services.search_engine import (
calculate_max_distance, SearchInput,
get_base_speed, build_search_model,
get_diagnosis_coefficient, derive_profiles,
get_terrain_coefficient, derive_time_of_day,
get_time_of_day_coefficient, unmodeled_profiles,
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
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze']) router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
@@ -51,19 +43,14 @@ class AnalysisRequest(BaseModel):
profiles: list[str] = Field(default_factory=list) profiles: list[str] = Field(default_factory=list)
def _first_terrain(value: str | list[str] | None) -> str | None: @router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
if isinstance(value, list): async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
return value[0] if value else None # B14: роутер — тонкая обёртка. Вся расчётная логика — в
return value # services/search_engine.py::build_search_model (без DB/auth/HTTP).
def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
terrain = _first_terrain(payload.terrain)
case_data: dict[str, Any] = { case_data: dict[str, Any] = {
'age': payload.age, 'age': payload.age,
'gender': payload.gender, 'gender': payload.gender,
'terrain': terrain, 'terrain': payload.terrain,
'terrain_primary': terrain,
'weather': payload.weather, 'weather': payload.weather,
'elapsed_hours': payload.elapsed_hours, 'elapsed_hours': payload.elapsed_hours,
'last_location': payload.last_location, 'last_location': payload.last_location,
@@ -73,178 +60,49 @@ def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
'season': payload.season, 'season': payload.season,
'diagnosis_type': payload.diagnosis_type, 'diagnosis_type': payload.diagnosis_type,
'has_transport': payload.has_transport, 'has_transport': payload.has_transport,
'profiles': list(payload.profiles or []),
'psychotype_answers': payload.psychotype_answers, 'psychotype_answers': payload.psychotype_answers,
'lat': payload.lat if payload.lat is not None else payload.tnp_lat, '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, 'lon': payload.lon if payload.lon is not None else payload.tnp_lon,
} }
# Отфильтровываем None и пустые контейнеры ([], {}), чтобы при merge case_data = {
# {**to_detail, **case_data} значения из карточки случая не затирались
# «пустышками» из дефолтов payload (psychotype_answers={}, diagnosis_type=[]).
return {
k: v for k, v in case_data.items() k: v for k, v in case_data.items()
if v is not None and not (isinstance(v, (list, dict)) and len(v) == 0) 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: if payload.case_id is not None:
case = db.get_case(str(payload.case_id)) case = db.get_case(str(payload.case_id))
if not case: if not case:
raise HTTPException(status_code=404, detail='Case not found') raise HTTPException(status_code=404, detail='Case not found')
case_data = {**case.to_detail(), **case_data} case_data = {**case.to_detail(), **case_data}
# Время суток из loss_time (порт фронтендовой getTimeOfDay) search_input = SearchInput(
if not case_data.get('time_of_day'): age=case_data.get('age'),
case_data['time_of_day'] = _derive_time_of_day(case_data.get('loss_time')) gender=case_data.get('gender'),
# terrain_primary из terrain, если вдруг не дошёл terrain=case_data.get('terrain'),
if not case_data.get('terrain_primary') and case_data.get('terrain'): terrain_primary=case_data.get('terrain_primary'),
case_data['terrain_primary'] = _first_terrain(case_data['terrain']) 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'),
)
# Поведенческие профили из диагнозов + транспорта (если не заданы явно) model = await build_search_model(search_input, analyzer=claude_analyze)
case_data['profiles'] = _derive_profiles(case_data) result = model.model_dump()
# Психотип детектим ДО анализа, чтобы применить его к ранжированию зон. result['case_id'] = str(payload.case_id) if payload.case_id else None
# get_psychotype_modifiers возвращает таблицу полос по дистанции, которую result['analyzed_at'] = datetime.now(timezone.utc).isoformat()
# 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,
}
if payload.case_id is not None: if payload.case_id is not None:
db.update_case(str(payload.case_id), analysis_log=result, status='analyzed') db.update_case(str(payload.case_id), analysis_log=result, status='analyzed')
+11 -5
View File
@@ -24,10 +24,11 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from backend.database import db from backend.database import db
from backend.routers.analyze import ( from backend.routers.analyze import AnalysisRequest
AnalysisRequest, from services.search_engine import (
_derive_profiles, derive_profiles as _derive_profiles,
_derive_time_of_day, derive_time_of_day as _derive_time_of_day,
unmodeled_profiles as _unmodeled_profiles,
) )
from backend.routers.auth import require_roles from backend.routers.auth import require_roles
from backend.schemas import ClosedCaseCreate, ClosedCaseListResponse, ClosedCaseResponse 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) psychotype_recommendations = get_search_recommendations(psychotype)
time_of_day = _derive_time_of_day(data.get('loss_time')) 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) max_distance_km = calculate_max_distance(data)
+163
View File
@@ -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'
+5
View File
@@ -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
+30
View File
@@ -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])
\""
+6
View File
@@ -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';
+28
View File
@@ -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;"
+17
View File
@@ -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;"
+6
View File
@@ -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;
+20
View File
@@ -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;"
+6
View File
@@ -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');
+293
View File
@@ -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,
)