diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..67f8f05 --- /dev/null +++ b/backend/alembic.ini @@ -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 \ No newline at end of file diff --git a/backend/alembic/__init__.py b/backend/alembic/__init__.py new file mode 100644 index 0000000..b0771cf --- /dev/null +++ b/backend/alembic/__init__.py @@ -0,0 +1 @@ +"""Alembic scripts package for ВЕКТОР.""" \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..33a684f --- /dev/null +++ b/backend/alembic/env.py @@ -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() \ No newline at end of file diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..0672a78 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} \ No newline at end of file diff --git a/backend/alembic/versions/006_b15_layers.py b/backend/alembic/versions/006_b15_layers.py new file mode 100644 index 0000000..f510212 --- /dev/null +++ b/backend/alembic/versions/006_b15_layers.py @@ -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') \ No newline at end of file diff --git a/backend/alembic/versions/__init__.py b/backend/alembic/versions/__init__.py new file mode 100644 index 0000000..e75592f --- /dev/null +++ b/backend/alembic/versions/__init__.py @@ -0,0 +1 @@ +"""Alembic migration package for ВЕКТОР.""" \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 4e3655f..0f36b44 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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()) diff --git a/backend/routers/analyze.py b/backend/routers/analyze.py index a4483e3..dd03fc1 100644 --- a/backend/routers/analyze.py +++ b/backend/routers/analyze.py @@ -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'], - } + } \ No newline at end of file diff --git a/backend/routers/closed_cases.py b/backend/routers/closed_cases.py index ef79c7a..f0c04c1 100644 --- a/backend/routers/closed_cases.py +++ b/backend/routers/closed_cases.py @@ -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) diff --git a/backend/tests/test_search_engine.py b/backend/tests/test_search_engine.py new file mode 100644 index 0000000..b5e79c2 --- /dev/null +++ b/backend/tests/test_search_engine.py @@ -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' \ No newline at end of file diff --git a/scripts/test-mig-cleanup.sh b/scripts/test-mig-cleanup.sh new file mode 100644 index 0000000..722a7dc --- /dev/null +++ b/scripts/test-mig-cleanup.sh @@ -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 \ No newline at end of file diff --git a/scripts/test-mig-empty.sh b/scripts/test-mig-empty.sh new file mode 100644 index 0000000..d038d3f --- /dev/null +++ b/scripts/test-mig-empty.sh @@ -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]) +\"" \ No newline at end of file diff --git a/scripts/test-mig-final.sql b/scripts/test-mig-final.sql new file mode 100644 index 0000000..3604733 --- /dev/null +++ b/scripts/test-mig-final.sql @@ -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'; \ No newline at end of file diff --git a/scripts/test-mig-full.sh b/scripts/test-mig-full.sh new file mode 100644 index 0000000..75d9d57 --- /dev/null +++ b/scripts/test-mig-full.sh @@ -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;" \ No newline at end of file diff --git a/scripts/test-mig-seed.sh b/scripts/test-mig-seed.sh new file mode 100644 index 0000000..67b9d86 --- /dev/null +++ b/scripts/test-mig-seed.sh @@ -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;" \ No newline at end of file diff --git a/scripts/test-mig-seed.sql b/scripts/test-mig-seed.sql new file mode 100644 index 0000000..18f6385 --- /dev/null +++ b/scripts/test-mig-seed.sql @@ -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; \ No newline at end of file diff --git a/scripts/test-mig-setup.sh b/scripts/test-mig-setup.sh new file mode 100644 index 0000000..4e95e70 --- /dev/null +++ b/scripts/test-mig-setup.sh @@ -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;" \ No newline at end of file diff --git a/scripts/test-mig-verify.sql b/scripts/test-mig-verify.sql new file mode 100644 index 0000000..11437fe --- /dev/null +++ b/scripts/test-mig-verify.sql @@ -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'); \ No newline at end of file diff --git a/services/search_engine.py b/services/search_engine.py new file mode 100644 index 0000000..b9fa69e --- /dev/null +++ b/services/search_engine.py @@ -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, + ) \ No newline at end of file