feat: ж/д слой + cant_swim в скоринг + профили вне модели

Определение специфических рекомендаций (матрица профилей §8):

1. Ж/д слой (закрыт мёртвый railway ×2.5 у РАС):
   - /api/v1/water/{case_id} отдаёт railway=rail как LineString
     (без service/industrial/military веток), кэш общий v2;
   - railway_warning «перекрыть/проверить немедленно» по профилям;
   - SearchMap: Polyline слой ж/д (тёмно-красный), счётчики 💧/🚂.

2. cant_swim → профиль не_умеет_плавать (water ×3.0, без изменения
   радиуса, critical_warning «обследовать водоёмы НЕМЕДЛЕННО»):
   - раньше чекбокс влиял только на текст, в скоринге был пробел;
   - derive в analyze._derive_profiles — работает и для closed_cases.

3. unmodeled_profiles: ДЦП/слабое зрение/слух — честная пометка
   «вне поведенческой модели» с пояснением (vector_tasks B12:
   профили без аналога не выдавать за учтённые); блок на фронте
   в карточке здоровья.

Площадь воды: сферический эксцесс, проверен на квадрате 53° (744017 м²
vs 743272 точного). Тесты: 202 passed (новый test_cant_swim_profile).
This commit is contained in:
2026-09-09 13:12:55 +03:00
parent 98e8e58023
commit f20080305d
2069 changed files with 803865 additions and 97 deletions
+23
View File
@@ -87,6 +87,26 @@ def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
}
# Диагнозы, которые форма собирает, но поведенческая модель не покрывает
# (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
UNMODELED_DIAGNOSES: dict[str, str] = {
'ДЦП': 'Двигательные нарушения — модель не учитывает; учесть вручную при планировании (доступность местности, темп).',
'слабое_зрение': 'Слабое зрение — модель не учитывает; вероятность следования по открытым/звуковым ориентирам выше.',
'слабый_слух': 'Слабый слух — модель не учитывает; голосовой поиск менее эффективен, приоритет визуальным сигналам.',
}
def _unmodeled_profiles(case_data: dict[str, Any]) -> list[dict[str, str]]:
"""Диагнозы вне поведенческой модели (не влияют на скоринг) + пояснение."""
out: list[dict[str, str]] = []
for diag in (case_data.get('diagnosis_type') or []):
d = str(diag).lower()
for key, note in UNMODELED_DIAGNOSES.items():
if d == key.lower() and not any(o['profile'] == key for o in out):
out.append({'profile': key, 'note': note})
return out
def _derive_profiles(case_data: dict[str, Any]) -> list[str]:
"""
Формирует список поведенческих профилей из диагнозов и транспорта, если они
@@ -112,6 +132,8 @@ def _derive_profiles(case_data: dict[str, Any]) -> list[str]:
derived.append('велосипед')
elif transport == 'scooter':
derived.append('самокат')
if case_data.get('cant_swim'):
derived.append('не_умеет_плавать')
seen: set[str] = set()
out: list[str] = []
for p in derived:
@@ -213,6 +235,7 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
'distance_multiplier': scorer.distance_multiplier,
'active_profiles': scorer.get_active_profiles_info(),
'critical_warnings': scorer.critical_warnings,
'unmodeled_profiles': _unmodeled_profiles(case_data),
'urgency': claude_result.urgency,
'primary_zones': [zone.model_dump() for zone in claude_result.primary_zones],
'search_radius_km': claude_result.search_radius_km,
+128 -79
View File
@@ -1,14 +1,18 @@
"""Водоёмы в зоне поиска — геослой по рекомендациям анализа.
"""Геослой по рекомендациям анализа: водоёмы + ж/д в зоне поиска.
Рекомендации профилей (scoring_service.BEHAVIORAL_PROFILES) говорят
«водоёмы — критичный тип локаций»: РАС ×3.0 + «перекрыть ВСЕ водоёмы и ж/д»,
эпилепсия ×3.5, дети 0–7 лет ×2.2–2.5. Этот эндпоинт отдаёт полигоны водоёмов
(OSM/Overpass) вокруг ТНП случая, чтобы подсветить их на карте зон поиска
(AnalysisResult → SearchMap), а не только называть их текстом.
Рекомендации профилей (scoring_service.BEHAVIORAL_PROFILES) указывают
критичные типы локаций, но коэффициенты railway/shelter долгое время были
мёртвыми (railway_distance_km никто не вычислял — вклад в score_zone был
всегда 0). Этот эндпоинт отдаёт реальные объекты OSM вокруг ТНП случая,
чтобы подсветить их на карте зон поиска (SearchMap):
- водоёмы/болота: РАС water ×3.0, эпилепсия ×3.5, возраст 0–7 ×2.2–2.5,
критическое предупреждение «перекрыть ВСЕ водоёмы и ж/д»;
- ж/д пути: РАС railway ×2.5 — «немедленно перекрыть ВСЕ ж/д пути».
Данные: OpenStreetMap, лицензия ODbL — attribution обязателен на карте.
Кэш дисковый как у geo_service; circuit breaker переиспользуем оттуда же,
чтобы анализ и водоёмы отключались синхронно при отсутствии сети.
Кэш дисковый; circuit breaker переиспользуем от geo_service, чтобы анализ
и геослои отключались синхронно при отсутствии сети.
"""
from __future__ import annotations
@@ -16,7 +20,6 @@ import hashlib
import json
import logging
import math
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
@@ -31,14 +34,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix='/api/v1/water', tags=['water'],
dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
# Кэш водоёмов — свой TTL (медленнее устаревает, чем дорожные данные):
# гидрография OSM стабильна в масштабе дней.
# Кэш — свой TTL (гидрография/ж/д OSM стабильны в масштабе дней).
_CACHE_DIR = '/tmp/overpass_cache/water'
_CACHE_TTL_HOURS = 72
# Overpass-теги водных объектов, релевантных для поиска пропавшего.
# natural=water покрывает озера/водохранилища/пруды/реки (полигоны),
# wetland — болота (застревание, сложная проходимость).
_WATER_QUERY_TEMPLATE = """
[out:json][timeout:25];
(
@@ -50,23 +49,27 @@ _WATER_QUERY_TEMPLATE = """
out geom meta;
"""
# Порядок = приоритет отрисовки (последний рисуется сверху).
_KIND_STYLE = [
# (ключ фильтра, значение тега, цвет, заливка)
('natural', 'wetland', '#4a6a8a', 0.25),
('natural', 'water', '#2f81f7', 0.35),
]
# Ж/д: railway=rail общего пользования (без промышленных/сервисных веток).
_RAILWAY_QUERY_TEMPLATE = """
[out:json][timeout:25];
(
way["railway"="rail"](around:{radius_m},{lat},{lon});
);
out geom;
"""
_CLASS_WARNING = 'Водоём в зоне поиска — приоритетная проверка (профиль: {profiles})'
_RAIL_WARNING = 'Ж/д пути в зоне поиска — перекрыть/проверить немедленно (профиль: {profiles})'
class WaterFeature(BaseModel):
"""Полигон водоёма в GeoJSON-геометрии + атрибуты для попапа."""
kind: str # water | wetland
class GeoFeature(BaseModel):
"""Объект геослоя в GeoJSON-геометрии + атрибуты для попапа."""
layer: str # water | wetland | railway
name: Optional[str] = None
area_m2: Optional[float] = None
area_m2: Optional[float] = None # для полигонов воды
length_m: Optional[float] = None # для линий ж/д
distance_m: Optional[float] = None
geom: dict[str, Any] # GeoJSON Polygon/MultiPolygon (WGS84)
geom: dict[str, Any] # GeoJSON (WGS84)
class WaterResponse(BaseModel):
@@ -76,7 +79,8 @@ class WaterResponse(BaseModel):
count: int
profiles: list[str] = []
warning: Optional[str] = None
features: list[WaterFeature] = []
railway_warning: Optional[str] = None
features: list[GeoFeature] = []
cached: bool = False
attribution: str = '© OpenStreetMap contributors'
@@ -91,19 +95,25 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
return 2 * r * math.asin(math.sqrt(a))
def _polygon_centroid_area(ring: list[dict]) -> tuple[float, float, float]:
"""(площадь м², средняя широта, средняя долгота) по внешнему кольцу.
def _way_length_mid(geometry: list[dict]) -> tuple[float, float, float]:
"""(длина линии м, средняя широта, средняя долгота) по геометрии way."""
length = sum(
_haversine(a['lat'], a['lon'], b['lat'], b['lon'])
for a, b in zip(geometry, geometry[1:])
)
lat = sum(p['lat'] for p in geometry) / len(geometry)
lon = sum(p['lon'] for p in geometry) / len(geometry)
return length, lat, lon
Площадь — формула сферического избытка; для наших масштабов (пруды,
водохранилища в радиусе поиска) достаточно точна для порогов «крупный/мелкий».
"""
def _polygon_centroid_area(ring: list[dict]) -> tuple[float, float, float]:
"""(площадь м², средняя широта, средняя долгота) по внешнему кольцу."""
n = len(ring)
if n < 3:
return 0.0, 0.0, 0.0
lat = sum(p['lat'] for p in ring) / n
lon = sum(p['lon'] for p in ring) / n
r = 6371000.0
# spherical excess (approx, unit sphere × r²)
area = 0.0
for i in range(n - 1):
p1, p2 = ring[i], ring[i + 1]
@@ -126,25 +136,26 @@ def _way_to_polygon(element: dict) -> Optional[dict[str, Any]]:
return {'type': 'Polygon', 'coordinates': [coords]}
def _element_geoms(elements: list[dict]) -> list[tuple[str, Optional[str], dict, float, float, float]]:
"""Из ответа Overpass → (kind, name, geom, area_m2, lat, lon)."""
out: list[tuple[str, Optional[str], dict, float, float, float]] = []
def _water_geoms(elements: list[dict]) -> list[dict]:
"""Ответ Overpass → список feature-словарей воды/болот (layer, name, geom, area)."""
out: list[dict] = []
for el in elements:
tags = el.get('tags') or {}
kind = 'wetland' if tags.get('natural') == 'wetland' else 'water'
layer = 'wetland' if tags.get('natural') == 'wetland' else 'water'
name = tags.get('name')
if el.get('type') == 'way':
poly = _way_to_polygon(el)
if poly is None:
continue
ring = el['geometry']
area, lat, lon = _polygon_centroid_area(ring)
out.append((kind, name, poly, area, lat, lon))
area, lat, lon = _polygon_centroid_area(el['geometry'])
out.append({'layer': layer, 'name': name, 'geom': poly,
'area_m2': area, 'lat': lat, 'lon': lon})
elif el.get('type') == 'relation' and el.get('members'):
# Мультиполигон из внешних колец way-членов; без top-level geometry.
# Мультиполигон из внешних колец way-членов.
polygons: list[list[list[float]]] = []
areas = []
lats, lons = [], []
areas: list[float] = []
lats: list[float] = []
lons: list[float] = []
for member in el.get('members', []):
if member.get('role') not in ('outer', ''):
continue
@@ -161,16 +172,38 @@ def _element_geoms(elements: list[dict]) -> list[tuple[str, Optional[str], dict,
lons.append(lon)
if not polygons:
continue
geom: dict[str, Any] = (
{'type': 'MultiPolygon', 'coordinates': [p for p in polygons]}
geom = (
{'type': 'MultiPolygon', 'coordinates': polygons}
if len(polygons) > 1 else
{'type': 'Polygon', 'coordinates': polygons[0]}
)
out.append((kind, name, geom, sum(areas), sum(lats) / len(lats), sum(lons) / len(lons)))
out.append({'layer': layer, 'name': name, 'geom': geom,
'area_m2': sum(areas),
'lat': sum(lats) / len(lats), 'lon': sum(lons) / len(lons)})
return out
def _cache_get(key: str) -> Optional[dict]:
def _railway_geoms(elements: list[dict]) -> list[dict]:
"""Ответ Overpass → список feature-словарей ж/д (LineString, длина)."""
out: list[dict] = []
for el in elements:
if el.get('type') != 'way':
continue
tags = el.get('tags') or {}
# Только магистральные ж/д общего пользования: без сервисных/промышленных веток.
if tags.get('service') or tags.get('usage') in ('industrial', 'military', 'test'):
continue
geometry = el.get('geometry') or []
if len(geometry) < 2:
continue
length, lat, lon = _way_length_mid(geometry)
line = {'type': 'LineString', 'coordinates': [[p['lon'], p['lat']] for p in geometry]}
out.append({'layer': 'railway', 'name': tags.get('name'), 'geom': line,
'length_m': length, 'lat': lat, 'lon': lon})
return out
def _cache_get(key: str) -> Optional[list[dict]]:
import os
from datetime import datetime, timedelta
path = os.path.join(_CACHE_DIR, f'{key}.json')
@@ -188,7 +221,7 @@ def _cache_get(key: str) -> Optional[dict]:
return None
def _cache_put(key: str, data: dict) -> None:
def _cache_put(key: str, data: list[dict]) -> None:
import os
from datetime import datetime
try:
@@ -197,11 +230,11 @@ def _cache_put(key: str, data: dict) -> None:
json.dump({'timestamp': datetime.now().isoformat(), 'data': data}, f,
ensure_ascii=False)
except Exception:
logger.warning('water cache write failed', exc_info=True)
logger.warning('geo layer cache write failed', exc_info=True)
def _detect_profiles(case_detail: dict[str, Any]) -> list[str]:
"""Профили из диагнозов (та же логика, что analyze._derive_profiles, по detail)."""
"""Профили из диагнозов + флагов (логика как analyze._derive_profiles + cant_swim)."""
profiles: list[str] = []
for diag in (case_detail.get('diagnosis_type') or []):
d = str(diag).lower()
@@ -214,9 +247,20 @@ def _detect_profiles(case_detail: dict[str, Any]) -> list[str]:
return profiles
def _sort_features(features: list[dict]) -> list[dict]:
"""Крупные водные объекты вперёд (по площади), затем ж/д (по длине)."""
def key(f: dict) -> tuple[int, float]:
if f['layer'] == 'railway':
return (1, -(f.get('length_m') or 0))
return (0, -(f.get('area_m2') or 0))
return sorted(features, key=key)
@router.get('/{case_id}', response_model=WaterResponse)
async def water_for_case(case_id: str) -> WaterResponse:
"""Полигоны водоёмов вокруг ТНП случая (для слоя на карте зон поиска)."""
"""Геослой рекомендаций вокруг ТНП случая (водоёмы + ж/д, для карты зон)."""
case = db.get_case(str(case_id))
if case is None:
raise HTTPException(status_code=404, detail='Case not found')
@@ -226,44 +270,49 @@ async def water_for_case(case_id: str) -> WaterResponse:
raise HTTPException(status_code=409, detail='Case has no TNP coordinates (tnp_lat/tnp_lon)')
profiles = _detect_profiles(detail)
warning = None
if profiles:
warning = _CLASS_WARNING.format(profiles=', '.join(profiles))
warning = _CLASS_WARNING.format(profiles=', '.join(profiles)) if profiles else None
railway_warning = _RAIL_WARNING.format(profiles=', '.join(profiles)) if profiles else None
radius_m = 3000 # перекрывает дефолтную зону анализа (max_distance_km ≤ ~3)
cache_key = hashlib.md5(f'{lat:.5f}:{lon:.5f}:{radius_m}'.encode()).hexdigest()
cache_key = hashlib.md5(f'{lat:.5f}:{lon:.5f}:{radius_m}:v2'.encode()).hexdigest()
cached = _cache_get(cache_key)
if cached is not None:
features_payload = cached
is_cached = True
else:
if _overpass_circuit_open():
return WaterResponse(case_id=str(case_id), tnp={'lat': lat, 'lon': lon},
radius_m=radius_m, count=0, profiles=profiles,
features=[], cached=False)
query = _WATER_QUERY_TEMPLATE.format(lat=lat, lon=lon, radius_m=radius_m)
data = await query_overpass(query)
if isinstance(data, list):
elements = data
else:
elements = data.get('elements', [])
features_payload = []
for kind, name, geom, area, flat, flon in _element_geoms(elements):
features_payload.append({
'kind': kind,
'name': name,
'area_m2': round(area) if area else None,
'distance_m': round(_haversine(lat, lon, flat, flon)) if area else None,
'geom': geom,
})
# Крупные объекты вперёд, безымянные малые — в хвост.
features_payload.sort(key=lambda f: f['area_m2'] or 0, reverse=True)
if elements: # не кэшируем пустой ответ от трипнутого breaker'а
_cache_put(cache_key, features_payload)
if not _overpass_circuit_open():
for template, extractor in (
(_WATER_QUERY_TEMPLATE, _water_geoms),
(_RAILWAY_QUERY_TEMPLATE, _railway_geoms),
):
query = template.format(lat=lat, lon=lon, radius_m=radius_m)
data = await query_overpass(query)
elements = data if isinstance(data, list) else data.get('elements', [])
for item in extractor(elements):
distance = round(_haversine(lat, lon, item['lat'], item['lon']))
feature = {
'layer': item['layer'],
'name': item.get('name'),
'distance_m': distance,
'geom': item['geom'],
}
if item['layer'] == 'railway':
feature['length_m'] = round(item['length_m'])
else:
feature['area_m2'] = round(item['area_m2'])
features_payload.append(feature)
if not elements:
break # сеть недоступна — не тянем второй запрос впустую
if features_payload: # пустой ответ не кэшируем (breaker/сеть)
_cache_put(cache_key, features_payload)
is_cached = False
features = [WaterFeature(**f) for f in features_payload]
features_sorted = _sort_features(features_payload)
return WaterResponse(case_id=str(case_id), tnp={'lat': lat, 'lon': lon},
radius_m=radius_m, count=len(features), profiles=profiles,
warning=warning, features=features, cached=is_cached)
radius_m=radius_m, count=len(features_sorted),
profiles=profiles, warning=warning,
railway_warning=railway_warning,
features=[GeoFeature(**f) for f in features_sorted],
cached=is_cached)
+13
View File
@@ -137,6 +137,19 @@ class TestBehavioralProfiles:
# Intentional runaway: roads x2.5, settlement x3.0
assert scorer.weights['roads'] > base_roads
def test_cant_swim_profile(self):
"""Test не_умеет_плавать profile — вода ×3.0 + медицинский приоритет."""
scorer = WeightedScorer()
base_water = scorer.weights['water']
scorer.apply_profile(['не_умеет_плавать'])
# Cant swim: water x3.0, no distance change
assert scorer.distance_multiplier == 1.0
assert scorer.weights['water'] == base_water * 3.0
assert 'не_умеет_плавать' in scorer.active_profiles
assert len(scorer.critical_warnings) == 1
assert 'НЕМЕДЛЕННО' in scorer.critical_warnings[0]['warning']
def test_multiple_profiles(self):
"""Test applying multiple profiles."""
scorer = WeightedScorer()