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
+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'