Files
vector/backend/tests/test_search_engine.py
T
Sadmin d8723582c9 Полный отказ от LLM: детерминированный rules-анализ
Решение Виктора: ИИ убран из проекта — карточки детей (ПД) не должны
покиать контур. Анализ полностью локальный и детерминированный.

- services/rules_analysis.py: правила вместо Claude (бывший
  analyze_with_fallback + rule_based_urgency из closed_cases):
  urgency по возрасту/профилям, зоны через geo+scoring,
  behavioral_prediction/immediate_actions/key_locations — справочники.
- search_engine: анализатор инъекцией (CaseAnalyzer, Protocol),
  импорт claude_service удалён; рантайм — rules_analyze.
- УДАЛЕНЫ: services/claude_service.py, backend/services/claude_service.py
  (устаревшая копия), test_claude_service.py; ключевые правила-кейсы
  перенесены в test_rules_analysis.py (+тест отсутствия LLM-упоминаний).
- docker-compose/.env.example: ANTHROPIC_API_KEY удалён.
- Фронт: бейдж 'ИИ-анализ' -> 'Локальный расчёт'.
- Доки: DEPLOY/SERVICES_README/ANALYZE_API переписаны, FIXES_REPORT
  помечен историческим, vector_tasks B20: zones/probability — из движка.

pytest: 187 passed (было 214; -27 удалённых LLM-тестов, +тест rules).
Контракт /analyze не изменён (фронт совместим).
2026-09-09 18:30:44 +03:00

163 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B14: юнит-тесты чистого движка services/search_engine.py.
Анализатор мокается (движок тестируем изолированно, без geo/httpx).
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
import pytest
from services.rules_analysis 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)
# Профили дошли и до анализатора (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'