Полный отказ от 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:
@@ -1,350 +0,0 @@
|
||||
"""
|
||||
Claude AI service for case analysis with fallback to scoring service.
|
||||
Integrates geo_service zones and scoring_service rankings.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
|
||||
from .geo_service import build_search_zones
|
||||
from .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 = False
|
||||
|
||||
|
||||
async def analyze_case(case_data: dict) -> AnalysisResult:
|
||||
"""
|
||||
Анализирует данные случая с помощью Claude API с fallback на scoring_service.
|
||||
|
||||
Интегрирует:
|
||||
- geo_service: построение зон поиска
|
||||
- scoring_service: оценка и ранжирование зон
|
||||
- Claude API: интеллектуальный анализ (если доступен)
|
||||
|
||||
Args:
|
||||
case_data: Словарь с данными случая
|
||||
- age: возраст
|
||||
- gender: пол
|
||||
- terrain: местность
|
||||
- weather: погода
|
||||
- time_missing: время пропажи
|
||||
- last_location: последнее местоположение
|
||||
- lat, lon: координаты (опционально)
|
||||
- profiles: поведенческие профили (опционально)
|
||||
- season: сезон (опционально)
|
||||
|
||||
Returns:
|
||||
AnalysisResult: Структурированный результат анализа
|
||||
"""
|
||||
# Попытка использовать Claude API
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
|
||||
if api_key:
|
||||
try:
|
||||
return await analyze_with_claude(case_data, api_key)
|
||||
except Exception as e:
|
||||
# Логируем ошибку и переходим на fallback
|
||||
logger.warning(f"Claude API unavailable: {e}. Using fallback scoring service.")
|
||||
|
||||
# Fallback: используем только scoring_service
|
||||
return await analyze_with_fallback(case_data)
|
||||
|
||||
|
||||
async def analyze_with_claude(case_data: dict, api_key: str) -> AnalysisResult:
|
||||
"""
|
||||
Анализ с помощью Claude API с интеграцией geo и scoring сервисов.
|
||||
"""
|
||||
# Построить зоны поиска если есть координаты
|
||||
zones_data = ""
|
||||
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)
|
||||
|
||||
# Топ-5 зон для промпта
|
||||
top_zones = ranked_zones[:5]
|
||||
zones_data = "\n\nТОП-5 ПРИОРИТЕТНЫХ ЗОН (по scoring_service):\n"
|
||||
for zone in top_zones:
|
||||
zones_data += f"- {zone['direction']} направление, {zone['distance_km']}км: "
|
||||
zones_data += f"оценка {zone['score']}/100, "
|
||||
zones_data += f"лес {zone['forest_pct']}%, "
|
||||
zones_data += f"дороги {zone['road_density']} км/км²\n"
|
||||
except Exception as e:
|
||||
logger.warning(f"Geo/scoring service error: {e}")
|
||||
|
||||
# Формируем промпт на русском языке
|
||||
prompt = f"""Ты — эксперт по поисково-спасательным операциям (ПСО) МЧС Республики Беларусь. Проанализируй следующий случай пропажи человека и дай структурированные рекомендации.
|
||||
|
||||
ДАННЫЕ СЛУЧАЯ:
|
||||
- Возраст пропавшего: {case_data.get('age', 'не указан')} лет
|
||||
- Пол: {case_data.get('gender', 'не указан')}
|
||||
- Местность: {case_data.get('terrain', 'не указана')}
|
||||
- Погодные условия: {case_data.get('weather', 'не указаны')}
|
||||
- Время пропажи: {case_data.get('time_missing', 'не указано')}
|
||||
- Последнее известное местоположение: {case_data.get('last_location', 'не указано')}
|
||||
- Особые обстоятельства: {case_data.get('circumstances', 'нет')}
|
||||
- Физическое состояние: {case_data.get('physical_condition', 'не указано')}
|
||||
- Опыт нахождения на природе: {case_data.get('experience', 'не указан')}
|
||||
- Поведенческие профили: {', '.join(case_data.get('profiles', [])) if case_data.get('profiles') else 'нет'}
|
||||
- Сезон: {case_data.get('season', 'не указан')}{zones_data}
|
||||
|
||||
ЗАДАЧА:
|
||||
Предоставь детальный анализ в формате JSON со следующими полями:
|
||||
|
||||
1. urgency: Уровень срочности ("критическая", "высокая", "средняя", "низкая")
|
||||
2. primary_zones: Массив из 3-5 приоритетных зон поиска, каждая с полями:
|
||||
- priority: номер приоритета (1 = самый высокий)
|
||||
- name: название зоны (например "Ближний лес", "Водоём на севере")
|
||||
- direction: направление от последней точки (N, NE, E, SE, S, SW, W, NW)
|
||||
- distance: расстояние в км
|
||||
- reason: обоснование выбора этой зоны
|
||||
3. search_radius_km: Рекомендуемый радиус поиска в километрах
|
||||
4. key_locations: Массив ключевых типов локаций для проверки (водоемы, дороги, постройки и т.д.)
|
||||
5. behavioral_prediction: Прогноз поведения пропавшего на основе возраста и обстоятельств
|
||||
6. immediate_actions: Массив немедленных действий, которые нужно предпринять
|
||||
7. summary: Краткое резюме анализа (2-3 предложения)
|
||||
|
||||
ВАЖНО: Учитывай данные из scoring_service при формировании primary_zones. Отвечай ТОЛЬКО валидным JSON без дополнительного текста."""
|
||||
|
||||
# Вызываем Anthropic API
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": "claude-sonnet-5",
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Anthropic API error: {response.status_code} - {response.text}")
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
content = result["content"][0]["text"]
|
||||
analysis_data = _extract_json_payload(content)
|
||||
analysis_data['fallback_used'] = False
|
||||
return AnalysisResult(**analysis_data)
|
||||
except Exception as e:
|
||||
# Ответ модели пришёл в неожидаемом виде — не роняем анализ,
|
||||
# а отдаём детерминированный результат scoring_service.
|
||||
logger.warning(
|
||||
f"Не удалось разобрать ответ Claude ({type(e).__name__}: {e}). "
|
||||
"Используется fallback scoring service."
|
||||
)
|
||||
return await analyze_with_fallback(case_data)
|
||||
|
||||
|
||||
def _extract_json_payload(content: str) -> dict:
|
||||
"""Extract a JSON object from a model response.
|
||||
|
||||
Tolerates a ```json fence, a bare ``` fence, or raw JSON with
|
||||
surrounding prose. Raises ValueError if nothing parseable is found.
|
||||
"""
|
||||
candidates = []
|
||||
stripped = (content or '').strip()
|
||||
|
||||
if '```' in stripped:
|
||||
for marker in ('```json', '```JSON', '```'):
|
||||
if marker in stripped:
|
||||
after = stripped.split(marker, 1)[1]
|
||||
candidates.append(after.split('```', 1)[0].strip())
|
||||
break
|
||||
|
||||
candidates.append(stripped)
|
||||
|
||||
# Last resort: the widest {...} span in the text.
|
||||
start, end = stripped.find('{'), stripped.rfind('}')
|
||||
if start != -1 and end > start:
|
||||
candidates.append(stripped[start:end + 1])
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
|
||||
raise ValueError('Не удалось извлечь JSON из ответа модели')
|
||||
|
||||
|
||||
async def analyze_with_fallback(case_data: dict) -> AnalysisResult:
|
||||
"""
|
||||
Fallback анализ используя только scoring_service без Claude API.
|
||||
"""
|
||||
# Определяем срочность на основе возраста и профилей
|
||||
age = case_data.get('age', 10)
|
||||
profiles = case_data.get('profiles', [])
|
||||
|
||||
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
|
||||
urgency = "критическая"
|
||||
elif age <= 7 or 'велосипед' in profiles:
|
||||
urgency = "высокая"
|
||||
elif age <= 12:
|
||||
urgency = "средняя"
|
||||
else:
|
||||
urgency = "средняя"
|
||||
|
||||
# Построить зоны если есть координаты
|
||||
primary_zones = []
|
||||
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)
|
||||
|
||||
# Топ-5 зон
|
||||
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 in fallback: {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="Расширенная зона поиска"
|
||||
)
|
||||
]
|
||||
|
||||
# Ключевые локации на основе возраста
|
||||
if age <= 7:
|
||||
key_locations = ["водоёмы", "укрытия", "густая растительность", "ближайшие постройки"]
|
||||
elif age <= 12:
|
||||
key_locations = ["водоёмы", "дороги", "тропы", "лесные массивы"]
|
||||
else:
|
||||
key_locations = ["дороги", "населённые пункты", "транспортные узлы", "водоёмы"]
|
||||
|
||||
# Поведенческий прогноз
|
||||
if 'РАС' in profiles:
|
||||
behavioral_prediction = "Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя."
|
||||
elif age <= 4:
|
||||
behavioral_prediction = "Минимальное движение, вероятно находится близко к точке потери."
|
||||
elif age <= 12:
|
||||
behavioral_prediction = "Умеренное движение, может следовать по тропам или дорогам."
|
||||
else:
|
||||
behavioral_prediction = "Целенаправленное движение, возможен выход к населённым пунктам."
|
||||
|
||||
# Немедленные действия
|
||||
immediate_actions = [
|
||||
"Организовать поисковые группы",
|
||||
"Проверить ближайшие водоёмы",
|
||||
"Опросить свидетелей в районе последнего местоположения"
|
||||
]
|
||||
|
||||
if 'РАС' in profiles:
|
||||
immediate_actions.insert(0, "КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км")
|
||||
|
||||
if 'велосипед' in profiles:
|
||||
immediate_actions.insert(0, "Расширить зону поиска до 10-15 км, проверить дорожные камеры")
|
||||
|
||||
summary = f"Случай классифицирован как {urgency} срочность. "
|
||||
summary += f"Рекомендуемый радиус поиска: {search_radius_km} км. "
|
||||
summary += 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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
+33
-19
@@ -2,20 +2,20 @@
|
||||
|
||||
Граница (vector_tasks.md B13-принцип 1, B14):
|
||||
- build_search_model(input) -> SearchModel — чистая функция.
|
||||
- Единственная внешняя зависимость — claude_analyze (async, httpx):
|
||||
она уже была «внутри» расчёта в старом analyze_case; изолируется
|
||||
параметром движка, чтобы тесты могли подменять её моком.
|
||||
- Анализатор (нарратив/зоны) инъекцией: рантайм — services.rules_analysis
|
||||
(детерминированные правила, без LLM — карточки детей не покидают контур);
|
||||
тесты подменяют моком. Анализатору достаточно вернуть объект с полями
|
||||
urgency/primary_zones/search_radius_km/... (structurally typed).
|
||||
- Ни одной привязки к полевым транспортам/протоколам (критерий B20:
|
||||
grep по этому файлу не находит соответствующих терминов).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Awaitable, Callable
|
||||
from typing import Any, Awaitable, Callable, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.claude_service import AnalysisResult
|
||||
from services.distance_service import (
|
||||
calculate_max_distance,
|
||||
get_base_speed,
|
||||
@@ -32,8 +32,22 @@ from services.psychotype_service import (
|
||||
)
|
||||
from services.scoring_service import WeightedScorer
|
||||
|
||||
# Тип анализатора: async (case_data) -> AnalysisResult, как claude_analyze.
|
||||
ClaudeAnalyzer = Callable[[dict[str, Any]], Awaitable[AnalysisResult]]
|
||||
|
||||
class AnalysisOutcome(Protocol):
|
||||
"""Структурный контракт результата анализатора (duck-typed)."""
|
||||
|
||||
urgency: str
|
||||
primary_zones: list[Any]
|
||||
search_radius_km: float
|
||||
key_locations: list[str]
|
||||
behavioral_prediction: str
|
||||
immediate_actions: list[str]
|
||||
summary: str
|
||||
fallback_used: bool
|
||||
|
||||
|
||||
# Тип анализатора: async (case_data) -> объект с полями AnalysisOutcome.
|
||||
CaseAnalyzer = Callable[[dict[str, Any]], Awaitable[AnalysisOutcome]]
|
||||
|
||||
# Диагнозы, которые форма собирает, но поведенческая модель не покрывает
|
||||
# (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
|
||||
@@ -72,7 +86,7 @@ class SearchInput(BaseModel):
|
||||
time_of_day: str | None = None
|
||||
|
||||
def to_case_data(self) -> dict[str, Any]:
|
||||
"""Плоский dict для сервисов (claude/scoring) — тот же состав полей,
|
||||
"""Плоский dict для сервисов (анализатор/скоринг) — тот же состав полей,
|
||||
что раньше уходил из analyze_case."""
|
||||
data: dict[str, Any] = {
|
||||
'age': self.age,
|
||||
@@ -209,13 +223,13 @@ def derive_time_of_day(loss_time: Any) -> str:
|
||||
async def build_search_model(
|
||||
search_input: SearchInput,
|
||||
*,
|
||||
analyzer: ClaudeAnalyzer,
|
||||
analyzer: CaseAnalyzer,
|
||||
analyzed_at: datetime | None = None,
|
||||
) -> SearchModel:
|
||||
"""Чистая функция расчёта поисковой модели (B14).
|
||||
|
||||
Вся логика бывшего analyze_case: деривации полей, психотип,
|
||||
WeightedScorer, коэффициенты формулы Экстремум, claude_analyze.
|
||||
WeightedScorer, коэффициенты формулы Экстремум, analyzer (rules/мок).
|
||||
DB / auth / HTTP здесь не нужны; анализатор подменяется параметром.
|
||||
"""
|
||||
case_data = search_input.to_case_data()
|
||||
@@ -246,7 +260,7 @@ async def build_search_model(
|
||||
case_data['psychotype_modifiers'] = psychotype_modifiers
|
||||
|
||||
max_distance_km = calculate_max_distance(case_data)
|
||||
claude_result = await analyzer(case_data)
|
||||
analysis = await analyzer(case_data)
|
||||
|
||||
scorer = WeightedScorer()
|
||||
if case_data.get('age'):
|
||||
@@ -282,12 +296,12 @@ async def build_search_model(
|
||||
active_profiles=scorer.get_active_profiles_info(),
|
||||
critical_warnings=list(scorer.critical_warnings),
|
||||
unmodeled_profiles=unmodeled_profiles(case_data.get('diagnosis_type') or []),
|
||||
urgency=claude_result.urgency,
|
||||
primary_zones=[zone.model_dump() for zone in claude_result.primary_zones],
|
||||
search_radius_km=claude_result.search_radius_km,
|
||||
key_locations=claude_result.key_locations,
|
||||
behavioral_prediction=claude_result.behavioral_prediction,
|
||||
immediate_actions=claude_result.immediate_actions,
|
||||
summary=claude_result.summary,
|
||||
fallback_used=claude_result.fallback_used,
|
||||
urgency=analysis.urgency,
|
||||
primary_zones=[zone.model_dump() if hasattr(zone, "model_dump") else dict(zone) for zone in analysis.primary_zones],
|
||||
search_radius_km=analysis.search_radius_km,
|
||||
key_locations=analysis.key_locations,
|
||||
behavioral_prediction=analysis.behavioral_prediction,
|
||||
immediate_actions=analysis.immediate_actions,
|
||||
summary=analysis.summary,
|
||||
fallback_used=analysis.fallback_used,
|
||||
)
|
||||
Reference in New Issue
Block a user