From 1fe3b37d1ad6ba0b24673405a6083041fe89e8b0 Mon Sep 17 00:00:00 2001 From: viktot14-ai Date: Wed, 9 Sep 2026 23:14:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BB=D0=BE=D0=B2=D0=B5=D1=81=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D0=BE=D1=8F=D1=81=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BD=D0=B0=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20(=D0=B4=D0=BE=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=20=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=D1=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- backend/tests/test_zone_reason.py | 72 +++++++++++++++ services/rules_analysis.py | 141 +++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_zone_reason.py diff --git a/backend/tests/test_zone_reason.py b/backend/tests/test_zone_reason.py new file mode 100644 index 0000000..77c4f47 --- /dev/null +++ b/backend/tests/test_zone_reason.py @@ -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 \ No newline at end of file diff --git a/services/rules_analysis.py b/services/rules_analysis.py index 0ba8b82..efc20d1 100644 --- a/services/rules_analysis.py +++ b/services/rules_analysis.py @@ -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: