Полный отказ от LLM: детерминированный rules-анализ
Решение Виктора: ИИ убран из проекта — карточки детей (ПД) не должны покиать контур. Анализ полностью локальный и детерминированный. - services/rules_analysis.py: правила вместо Claude (бывший analyze_with_fallback + rule_based_urgency из closed_cases): urgency по возрасту/профилям, зоны через geo+scoring, behavioral_prediction/immediate_actions/key_locations — справочники. - search_engine: анализатор инъекцией (CaseAnalyzer, Protocol), импорт claude_service удалён; рантайм — rules_analyze. - УДАЛЕНЫ: services/claude_service.py, backend/services/claude_service.py (устаревшая копия), test_claude_service.py; ключевые правила-кейсы перенесены в test_rules_analysis.py (+тест отсутствия LLM-упоминаний). - docker-compose/.env.example: ANTHROPIC_API_KEY удалён. - Фронт: бейдж 'ИИ-анализ' -> 'Локальный расчёт'. - Доки: DEPLOY/SERVICES_README/ANALYZE_API переписаны, FIXES_REPORT помечен историческим, vector_tasks B20: zones/probability — из движка. pytest: 187 passed (было 214; -27 удалённых LLM-тестов, +тест rules). Контракт /analyze не изменён (фронт совместим).
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Детерминированный rules-анализ (замена Claude API, решение Виктора:
|
||||
ИИ из проекта убран — карточки детей не покидают контур).
|
||||
|
||||
Логика — бывший analyze_with_fallback из claude_service (правила по возрасту,
|
||||
профилям, координатам) + urgency по правилам closed_cases. Никаких внешних
|
||||
вызовов: только geo_service (Overpass) + scoring_service + справочники.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.geo_service import build_search_zones
|
||||
from services.scoring_service import WeightedScorer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrimaryZone(BaseModel):
|
||||
priority: int
|
||||
name: str
|
||||
direction: str
|
||||
distance: float
|
||||
reason: str
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
urgency: str # "критическая", "высокая", "средняя", "низкая"
|
||||
primary_zones: list[PrimaryZone]
|
||||
search_radius_km: float
|
||||
key_locations: list[str]
|
||||
behavioral_prediction: str
|
||||
immediate_actions: list[str]
|
||||
summary: str
|
||||
fallback_used: bool = Field(default=True)
|
||||
|
||||
|
||||
def rule_based_urgency(elapsed: float, age: int, diagnosis: list[str]) -> str:
|
||||
"""urgency по правилам: чем меньше ребёнок и дольше поиск — тем выше срочность."""
|
||||
score = 0
|
||||
if elapsed >= 24:
|
||||
score += 2
|
||||
elif elapsed >= 8:
|
||||
score += 1
|
||||
if age <= 7:
|
||||
score += 2
|
||||
elif age <= 12:
|
||||
score += 1
|
||||
if diagnosis:
|
||||
score += 1
|
||||
if score >= 4:
|
||||
return 'критическая'
|
||||
if score >= 2:
|
||||
return 'высокая'
|
||||
return 'средняя'
|
||||
|
||||
|
||||
def _age_urgency(age: int, profiles: list[str]) -> str:
|
||||
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
|
||||
return 'критическая'
|
||||
if age <= 7 or 'велосипед' in profiles:
|
||||
return 'высокая'
|
||||
return 'средняя'
|
||||
|
||||
|
||||
def _key_locations(age: int) -> list[str]:
|
||||
if age <= 7:
|
||||
return ['водоёмы', 'укрытия', 'густая растительность', 'ближайшие постройки']
|
||||
if age <= 12:
|
||||
return ['водоёмы', 'дороги', 'тропы', 'лесные массивы']
|
||||
return ['дороги', 'населённые пункты', 'транспортные узлы', 'водоёмы']
|
||||
|
||||
|
||||
def _behavioral_prediction(age: int, profiles: list[str]) -> str:
|
||||
if 'РАС' in profiles:
|
||||
return 'Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя.'
|
||||
if age <= 4:
|
||||
return 'Минимальное движение, вероятно находится близко к точке потери.'
|
||||
if age <= 12:
|
||||
return 'Умеренное движение, может следовать по тропам или дорогам.'
|
||||
return 'Целенаправленное движение, возможен выход к населённым пунктам.'
|
||||
|
||||
|
||||
def _immediate_actions(profiles: list[str]) -> list[str]:
|
||||
actions = [
|
||||
'Организовать поисковые группы',
|
||||
'Проверить ближайшие водоёмы',
|
||||
'Опросить свидетелей в районе последнего местоположения',
|
||||
]
|
||||
if 'РАС' in profiles:
|
||||
actions.insert(0, 'КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км')
|
||||
if 'велосипед' in profiles:
|
||||
actions.insert(0, 'Расширить зону поиска до 10-15 км, проверить дорожные камеры')
|
||||
return actions
|
||||
|
||||
|
||||
async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
|
||||
"""Детерминированный анализ: scoring_service + geo_service, без LLM."""
|
||||
age = int(case_data.get('age') or 10)
|
||||
profiles = list(case_data.get('profiles') or [])
|
||||
|
||||
urgency = _age_urgency(age, profiles)
|
||||
|
||||
primary_zones: list[PrimaryZone] = []
|
||||
search_radius_km = 5.0
|
||||
|
||||
if case_data.get('lat') and case_data.get('lon'):
|
||||
try:
|
||||
zones = await build_search_zones(
|
||||
case_data['lat'],
|
||||
case_data['lon'],
|
||||
case_data,
|
||||
)
|
||||
scorer = WeightedScorer()
|
||||
zones_dict = [
|
||||
{
|
||||
'direction': z.direction,
|
||||
'distance_km': z.distance_km,
|
||||
'forest_pct': z.forest_pct,
|
||||
'road_density': z.road_density,
|
||||
'water_distance_km': z.water_distance_km,
|
||||
'settlement_distance_km': z.settlement_distance_km,
|
||||
}
|
||||
for z in zones
|
||||
]
|
||||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
||||
for i, zone in enumerate(ranked_zones[:5]):
|
||||
primary_zones.append(PrimaryZone(
|
||||
priority=i + 1,
|
||||
name=f"Зона {zone['direction']} {zone['distance_km']}км",
|
||||
direction=zone['direction'],
|
||||
distance=zone['distance_km'],
|
||||
reason=f"Оценка {zone['score']}/100 по scoring_service",
|
||||
))
|
||||
search_radius_km = scorer.distance_multiplier * 2.0
|
||||
except Exception as e:
|
||||
logger.warning(f'Geo/scoring service error: {e}')
|
||||
|
||||
if not primary_zones:
|
||||
primary_zones = [
|
||||
PrimaryZone(priority=1, name='Ближняя зона', direction='N',
|
||||
distance=0.5, reason='Базовая зона поиска'),
|
||||
PrimaryZone(priority=2, name='Средняя зона', direction='E',
|
||||
distance=1.0, reason='Расширенная зона поиска'),
|
||||
]
|
||||
|
||||
key_locations = _key_locations(age)
|
||||
behavioral_prediction = _behavioral_prediction(age, profiles)
|
||||
immediate_actions = _immediate_actions(profiles)
|
||||
|
||||
summary = (
|
||||
f'Случай классифицирован как {urgency} срочность. '
|
||||
f'Рекомендуемый радиус поиска: {search_radius_km} км. '
|
||||
f'Приоритет: {key_locations[0]}.'
|
||||
)
|
||||
|
||||
return AnalysisResult(
|
||||
urgency=urgency,
|
||||
primary_zones=primary_zones,
|
||||
search_radius_km=search_radius_km,
|
||||
key_locations=key_locations,
|
||||
behavioral_prediction=behavioral_prediction,
|
||||
immediate_actions=immediate_actions,
|
||||
summary=summary,
|
||||
fallback_used=True,
|
||||
)
|
||||
Reference in New Issue
Block a user