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

- 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
+140 -1
View File
@@ -83,6 +83,140 @@ def _behavioral_prediction(age: int, profiles: list[str]) -> str:
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]:
actions = [
'Организовать поисковые группы',
@@ -114,6 +248,9 @@ async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
case_data,
)
scorer = WeightedScorer()
# Направление последнего свидетеля: вес 'direction' (0.07) работает
# только при direction_match в данных зоны (иначе всегда дефолт 0.5).
seen_dir = (case_data.get('last_seen_direction') or '').upper()
zones_dict = [
{
'direction': z.direction,
@@ -122,6 +259,8 @@ async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
'road_density': z.road_density,
'water_distance_km': z.water_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
]
@@ -132,7 +271,7 @@ async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
name=f"Зона {zone['direction']} {zone['distance_km']}км",
direction=zone['direction'],
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
except Exception as e: