Словесное пояснение приоритетных направлений (дополнение к матрасчёту)
- 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:
@@ -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
|
||||||
+140
-1
@@ -83,6 +83,140 @@ def _behavioral_prediction(age: int, profiles: list[str]) -> str:
|
|||||||
return 'Целенаправленное движение, возможен выход к населённым пунктам.'
|
return 'Целенаправленное движение, возможен выход к населённым пунктам.'
|
||||||
|
|
||||||
|
|
||||||
|
def _terrain_phrase(forest_pct: float | None) -> str:
|
||||||
|
"""Человеческая характеристика лесистости сектора."""
|
||||||
|
if forest_pct is None:
|
||||||
|
return None
|
||||||
|
pct = round(forest_pct * 100)
|
||||||
|
if pct >= 70:
|
||||||
|
return f'густой лес ({pct}% сектора)'
|
||||||
|
if pct >= 40:
|
||||||
|
return f'лесистость умеренная ({pct}%)'
|
||||||
|
if pct >= 15:
|
||||||
|
return f'редкий лес ({pct}%)'
|
||||||
|
return 'преобладает открытое поле'
|
||||||
|
|
||||||
|
|
||||||
|
def _water_phrase(water_distance_km: float | None, profiles: list[str]) -> str:
|
||||||
|
"""Водоёмы: расстояние + тактический акцент для профилей."""
|
||||||
|
if water_distance_km is None:
|
||||||
|
return None
|
||||||
|
dist = round(water_distance_km, 1)
|
||||||
|
if dist <= 0.6:
|
||||||
|
phrase = f'водоём в {dist} км — проверить берега'
|
||||||
|
elif dist <= 2.0:
|
||||||
|
phrase = f'водоём в {dist} км'
|
||||||
|
else:
|
||||||
|
phrase = f'вода далеко ({dist} км)'
|
||||||
|
if 'не_умеет_плавать' in profiles and dist <= 3.0:
|
||||||
|
phrase += ' (внимание: не умеет плавать — приоритет береговой линии)'
|
||||||
|
return phrase
|
||||||
|
|
||||||
|
|
||||||
|
def _roads_phrase(road_density: float | None, profiles: list[str]) -> str:
|
||||||
|
"""Дорожная сеть: плотность + подсказка по транспорту."""
|
||||||
|
if road_density is None:
|
||||||
|
return None
|
||||||
|
density = round(road_density, 1)
|
||||||
|
if density >= 2.0:
|
||||||
|
phrase = f'плотная сеть дорог ({density} км/км²)'
|
||||||
|
elif density >= 0.8:
|
||||||
|
phrase = f'дороги умеренные ({density} км/км²)'
|
||||||
|
else:
|
||||||
|
phrase = f'дороги редки ({density} км/км²) — движение затруднено'
|
||||||
|
if 'велосипед' in profiles:
|
||||||
|
phrase += ' (велосипед — вероятное следование вдоль дорог)'
|
||||||
|
return phrase
|
||||||
|
|
||||||
|
|
||||||
|
def _settlement_phrase(settlement_distance_km: float | None) -> str:
|
||||||
|
"""Населённый пункт в секторе: только ориентир (в скоринге не приоритет)."""
|
||||||
|
if settlement_distance_km is None:
|
||||||
|
return None
|
||||||
|
dist = round(settlement_distance_km, 1)
|
||||||
|
if dist <= 1.0:
|
||||||
|
return f'населённый пункт в {dist} км — исключить выход к жилью'
|
||||||
|
if dist <= 3.0:
|
||||||
|
return f'жильё в {dist} км'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _direction_phrase(zone_direction: str, case_data: dict) -> str | None:
|
||||||
|
"""Совпадение с направлением последнего свидетеля."""
|
||||||
|
seen = (case_data.get('last_seen_direction') or '').upper()
|
||||||
|
if not seen:
|
||||||
|
return None
|
||||||
|
if zone_direction == seen:
|
||||||
|
return 'совпадает с направлением движения последнего свидетеля'
|
||||||
|
# Соседние румбы (±45°) — частичное совпадение
|
||||||
|
RU = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||||
|
if zone_direction in RU and seen in RU:
|
||||||
|
d = abs(RU.index(zone_direction) - RU.index(seen))
|
||||||
|
if d == 1 or d == 7:
|
||||||
|
return 'близко к направлению последнего свидетеля'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _psychotype_phrase(case_data: dict, distance_km: float) -> str | None:
|
||||||
|
"""Психотип: полоса дистанции, если множитель отличается от нейтрального."""
|
||||||
|
mods = case_data.get('psychotype_modifiers')
|
||||||
|
if not mods:
|
||||||
|
return None
|
||||||
|
zdist = distance_km
|
||||||
|
if zdist < 0.5:
|
||||||
|
band, key = mods.get('zone_0_500', 1.0), '0-500 м'
|
||||||
|
elif zdist < 1.5:
|
||||||
|
band, key = mods.get('zone_500_1500', 1.0), '0.5-1.5 км'
|
||||||
|
elif zdist < 2.5:
|
||||||
|
band, key = mods.get('zone_1500_2500', 1.0), '1.5-2.5 км'
|
||||||
|
else:
|
||||||
|
band, key = mods.get('zone_2500plus', 1.0), 'свыше 2.5 км'
|
||||||
|
if band > 1.05:
|
||||||
|
return f'психотип повышает вероятность в полосе {key} (×{band:.2f})'
|
||||||
|
if band < 0.95:
|
||||||
|
return f'психотип снижает вероятность в полосе {key} (×{band:.2f})'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_zone_reason(zone: dict, case_data: dict) -> str:
|
||||||
|
"""Словесное пояснение приоритета зоны — по тем же факторам, что считает
|
||||||
|
WeightedScorer (детерминированно, без выдумок): рельеф, вода, дороги, НП,
|
||||||
|
направление свидетеля, психотип. Складывается в одно предложение."""
|
||||||
|
profiles = list(case_data.get('profiles') or [])
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
terrain = _terrain_phrase(zone.get('forest_pct'))
|
||||||
|
if terrain:
|
||||||
|
parts.append(terrain)
|
||||||
|
|
||||||
|
water = _water_phrase(zone.get('water_distance_km'), profiles)
|
||||||
|
if water:
|
||||||
|
parts.append(water)
|
||||||
|
|
||||||
|
roads = _roads_phrase(zone.get('road_density'), profiles)
|
||||||
|
if roads:
|
||||||
|
parts.append(roads)
|
||||||
|
|
||||||
|
settlement = _settlement_phrase(zone.get('settlement_distance_km'))
|
||||||
|
if settlement:
|
||||||
|
parts.append(settlement)
|
||||||
|
|
||||||
|
direction = _direction_phrase(zone.get('direction', ''), case_data)
|
||||||
|
if direction:
|
||||||
|
parts.append(direction)
|
||||||
|
|
||||||
|
psycho = _psychotype_phrase(case_data, zone.get('distance_km', 1.0))
|
||||||
|
if psycho:
|
||||||
|
parts.append(psycho)
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return f'Оценка {zone.get("score", 0):.0f}/100 по весам модели'
|
||||||
|
|
||||||
|
# Первые два фактора с запятой, дальше — точкой с запятой; финальный балл в конце.
|
||||||
|
reason = '; '.join(parts)
|
||||||
|
return f'{reason}. Оценка {zone.get("score", 0):.0f}/100.'
|
||||||
|
|
||||||
|
|
||||||
def _immediate_actions(profiles: list[str]) -> list[str]:
|
def _immediate_actions(profiles: list[str]) -> list[str]:
|
||||||
actions = [
|
actions = [
|
||||||
'Организовать поисковые группы',
|
'Организовать поисковые группы',
|
||||||
@@ -114,6 +248,9 @@ async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
|
|||||||
case_data,
|
case_data,
|
||||||
)
|
)
|
||||||
scorer = WeightedScorer()
|
scorer = WeightedScorer()
|
||||||
|
# Направление последнего свидетеля: вес 'direction' (0.07) работает
|
||||||
|
# только при direction_match в данных зоны (иначе всегда дефолт 0.5).
|
||||||
|
seen_dir = (case_data.get('last_seen_direction') or '').upper()
|
||||||
zones_dict = [
|
zones_dict = [
|
||||||
{
|
{
|
||||||
'direction': z.direction,
|
'direction': z.direction,
|
||||||
@@ -122,6 +259,8 @@ async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
|
|||||||
'road_density': z.road_density,
|
'road_density': z.road_density,
|
||||||
'water_distance_km': z.water_distance_km,
|
'water_distance_km': z.water_distance_km,
|
||||||
'settlement_distance_km': z.settlement_distance_km,
|
'settlement_distance_km': z.settlement_distance_km,
|
||||||
|
'direction_match': 1.0 if seen_dir and z.direction == seen_dir
|
||||||
|
else (0.5 if seen_dir else 0.5),
|
||||||
}
|
}
|
||||||
for z in zones
|
for z in zones
|
||||||
]
|
]
|
||||||
@@ -132,7 +271,7 @@ async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
|
|||||||
name=f"Зона {zone['direction']} {zone['distance_km']}км",
|
name=f"Зона {zone['direction']} {zone['distance_km']}км",
|
||||||
direction=zone['direction'],
|
direction=zone['direction'],
|
||||||
distance=zone['distance_km'],
|
distance=zone['distance_km'],
|
||||||
reason=f"Оценка {zone['score']}/100 по scoring_service",
|
reason=_build_zone_reason(zone, case_data),
|
||||||
))
|
))
|
||||||
search_radius_km = scorer.distance_multiplier * 2.0
|
search_radius_km = scorer.distance_multiplier * 2.0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user