Словесное пояснение приоритетных направлений (дополнение к матрасчёту)

- rules_analysis._build_zone_reason: детерминированный текст по тем же
  факторам, что считает WeightedScorer — рельеф (лесистость %), вода
  (расстояние + акцент берегов при не_умеет_плавать), дороги (плотность +
  вероятное следование велосипеда), НП (ориентир), совпадение с направлением
  последнего свидетеля (точное/соседний румб), полосы психотипа. Финал —
  балл оценки.
- ФИКС: weight 'direction' (0.07) был мёртвым — direction_match никогда не
  передавался в score_zone (всегда дефолт 0.5); теперь 1.0 при совпадении
  с last_seen_direction.
- Фронт уже отображает reasoning на карточках зон — бэкенд-фикс достаточен.
- Тесты: 7 новых на пояснения (детерминированность, факторы, фолбэк). 238 passed.
This commit is contained in:
2026-09-09 23:14:00 +03:00
parent c44bc46667
commit 1fe3b37d1a
2 changed files with 212 additions and 1 deletions
+72
View File
@@ -0,0 +1,72 @@
"""Тесты словесных пояснений приоритетных направлений (_build_zone_reason)."""
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from services.rules_analysis import ( # noqa: E402
_build_zone_reason,
_direction_phrase,
_psychotype_phrase,
_water_phrase,
)
class TestZoneReason:
def test_reason_mentions_key_factors(self):
zone = {'direction': 'NE', 'distance_km': 2.0, 'forest_pct': 0.68,
'road_density': 0.5, 'water_distance_km': 0.4,
'settlement_distance_km': 1.2, 'score': 78.4}
case = {'profiles': ['не_умеет_плавать'], 'last_seen_direction': 'ne'}
reason = _build_zone_reason(zone, case)
assert 'лесистость умеренная (68%)' in reason
assert '0.4 км — проверить берега' in reason
assert 'не умеет плавать' in reason
assert 'совпадает с направлением' in reason
assert reason.endswith('Оценка 78/100.')
def test_reason_open_field_with_bike(self):
zone = {'direction': 'SE', 'distance_km': 1.0, 'forest_pct': 0.12,
'road_density': 2.6, 'water_distance_km': 6.0,
'settlement_distance_km': None, 'score': 41.0}
reason = _build_zone_reason(zone, {'profiles': ['велосипед']})
assert 'преобладает открытое поле' in reason
assert 'вода далеко' in reason
assert 'велосипед' in reason
def test_reason_direction_neighbor(self):
zone = {'direction': 'NE', 'distance_km': 1.0, 'score': 60.0}
case = {'last_seen_direction': 'N'}
reason = _build_zone_reason(zone, case)
assert 'близко к направлению' in reason
def test_reason_without_factors_falls_back_to_score(self):
reason = _build_zone_reason({'direction': 'N', 'score': 50.0, 'distance_km': 1.0}, {})
assert reason == 'Оценка 50/100 по весам модели'
def test_water_phrase_no_swimmer_far_water_no_warning(self):
# Не умеет плавать, но вода далеко — акцент не добавляется
phrase = _water_phrase(8.0, ['не_умеет_плавать'])
assert phrase is not None and 'не умеет плавать' not in phrase
def test_psychotype_phrase_bands(self):
mods = {'zone_0_500': 1.0, 'zone_500_1500': 1.25,
'zone_1500_2500': 1.0, 'zone_2500plus': 0.8}
up = _psychotype_phrase({'psychotype_modifiers': mods}, 1.0)
assert 'повышает' in up and '0.5-1.5 км' in up
down = _psychotype_phrase({'psychotype_modifiers': mods}, 3.0)
assert 'снижает' in down
def test_reason_is_deterministic(self):
zone = {'direction': 'E', 'distance_km': 1.8, 'forest_pct': 0.5,
'road_density': 1.0, 'water_distance_km': 2.0,
'settlement_distance_km': 3.0, 'score': 55.5}
case = {'profiles': [], 'last_seen_direction': 'E'}
r1 = _build_zone_reason(zone, case)
r2 = _build_zone_reason(dict(zone), dict(case))
assert r1 == r2
assert 'совпадает с направлением' in r1