Полный отказ от 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:
2026-09-09 18:30:44 +03:00
parent 11015a7883
commit d8723582c9
17 changed files with 362 additions and 1377 deletions
+5 -5
View File
@@ -1,11 +1,11 @@
# Anthropic API Key for Claude integration
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# Database connection string # Database connection string
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/sar_mchs DATABASE_URL=postgresql://postgres:postgres@postgres:5432/vector_mchs
# JWT secret for authentication (generate a secure random string) # JWT secret for authentication (generate a secure random string)
JWT_SECRET=your_jwt_secret_here_change_in_production JWT_SECRET=your_jwt_secret_here_change_in_production
# CORS origins (comma-separated) # CORS origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://localhost:8000 CORS_ORIGINS=http://localhost:3000
# Примечание: внешний ИИ не используется (решение Виктора: ПД-данные
# не покидают контур). Все расчёты локальные и детерминированные.
+5 -5
View File
@@ -10,7 +10,7 @@
2. Geo service - построение зон поиска (8 направлений) 2. Geo service - построение зон поиска (8 направлений)
3. Scoring service - оценка и ранжирование зон 3. Scoring service - оценка и ранжирование зон
4. Psychotype service - определение психотипа (если есть данные) 4. Psychotype service - определение психотипа (если есть данные)
5. Claude service - интеллектуальный анализ (опционально) 5. Rules analysis - детерминированные правила (urgency, зоны, действия; без LLM)
6. Merge results - объединение результатов 6. Merge results - объединение результатов
**Request:** **Request:**
@@ -56,7 +56,7 @@
"behavioral_prediction": "Умеренное движение", "behavioral_prediction": "Умеренное движение",
"summary": "Случай классифицирован как средняя срочность", "summary": "Случай классифицирован как средняя срочность",
"execution_time_ms": 21197.7, "execution_time_ms": 21197.7,
"services_used": ["distance", "geo", "psychotype", "claude"] "services_used": ["distance", "geo", "psychotype", "rules"]
} }
``` ```
@@ -80,13 +80,13 @@
| Geo | ✅ Работает | Построение зон (21 сек) | | Geo | ✅ Работает | Построение зон (21 сек) |
| Scoring | ⚠️ Fallback | NoneType division error | | Scoring | ⚠️ Fallback | NoneType division error |
| Psychotype | ✅ Работает | Определение + модификаторы | | Psychotype | ✅ Работает | Определение + модификаторы |
| Claude | ⚠️ Fallback | 403 Forbidden (нет API ключа) | | Rules | ✅ | локальный расчёт |
## Fallback механизмы ## Fallback механизмы
Если сервис недоступен, система использует fallback: Если сервис недоступен, система использует fallback:
- **Geo/Scoring fail** → создаются базовые 8 секторов - **Geo/Scoring fail** → создаются базовые 8 секторов
- **Claude fail** → базовые рекомендации из scoring service - Правила всегда доступны: LLM в пайплайне нет (ПД не покидают контур)
- **Psychotype fail** → анализ без модификаторов психотипа - **Psychotype fail** → анализ без модификаторов психотипа
## Тестирование ## Тестирование
@@ -100,7 +100,7 @@
## Известные проблемы ## Известные проблемы
1. **Scoring service**: Ошибка деления на None - нужно добавить проверки на None в scoring_service.py 1. **Scoring service**: Ошибка деления на None - нужно добавить проверки на None в scoring_service.py
2. **Claude API**: Требуется настройка ANTHROPIC_API_KEY в .env 2. **LLM не используется**: API-ключи не нужны, всё локально
3. **Geo service**: Возвращает пустые зоны (возможно, проблема с Overpass API или кешем) 3. **Geo service**: Возвращает пустые зоны (возможно, проблема с Overpass API или кешем)
## Дата создания ## Дата создания
+8 -7
View File
@@ -63,11 +63,14 @@ docker compose ps
Переменные окружения находятся в файле `/root/vector/.env` Переменные окружения находятся в файле `/root/vector/.env`
Важные переменные: Важные переменные:
- `ANTHROPIC_API_KEY` - API ключ для Claude (требуется для функции анализа)
- `JWT_SECRET` - секрет для JWT токенов - `JWT_SECRET` - секрет для JWT токенов
- `DATABASE_URL` - строка подключения к PostgreSQL - `DATABASE_URL` - строка подключения к PostgreSQL
- `CORS_ORIGINS` - разрешенные origins для CORS - `CORS_ORIGINS` - разрешенные origins для CORS
Примечание: внешний ИИ (Claude/Anthropic) из проекта УБРАН — анализ
полностью локальный и детерминированный (services/rules_analysis.py).
API-ключи LLM не нужны.
## Структура проекта ## Структура проекта
``` ```
@@ -101,12 +104,10 @@ ssh root@192.168.0.65 "pct exec 108 -- bash"
## Следующие шаги ## Следующие шаги
1. Добавить ANTHROPIC_API_KEY в .env файл 1. Настроить базу данных (миграции Alembic)
2. Настроить базу данных (миграции Alembic) 2. Создать модели SQLAlchemy
3. Создать модели SQLAlchemy 3. Реализовать аутентификацию с JWT
4. Реализовать аутентификацию с JWT 4. Разработать React компоненты для frontend
5. Интегрировать Claude API для анализа документов
6. Разработать React компоненты для frontend
## Проверка работоспособности ## Проверка работоспособности
+3
View File
@@ -1,5 +1,8 @@
# Исправления ошибок - Итоговый отчет # Исправления ошибок - Итоговый отчет
> Исторический документ (2026-05-05). Упоминания Claude Service относятся
> к удалённому с тех пор LLM-слою; текущий анализ — rules_analysis (локально).
## Дата: 2026-05-05 ## Дата: 2026-05-05
### Исправленные проблемы: ### Исправленные проблемы:
+46 -160
View File
@@ -2,182 +2,68 @@
## Обзор ## Обзор
Созданы два основных сервиса для анализа поисково-спасательных операций: Анализ поисково-спасательных операций — **полностью локальный и
детерминированный** (внешний ИИ убран: ПД-данные детей не покидают контур).
### 1. Claude Service (`backend/services/claude_service.py`) ### Состав
**Функция:** `analyze_case(case_data: dict) -> AnalysisResult` | Модуль | Назначение |
|---|---|
| `services/search_engine.py` | Чистый движок: SearchInput → build_search_model → SearchModel (B14) |
| `services/rules_analysis.py` | Правила: urgency, зоны, прогноз поведения, действия (замена LLM) |
| `services/geo_service.py` | Зоны через Overpass (8 направлений × 4 дистанции) |
| `services/scoring_service.py` | WeightedScorer, поведенческие профили (§8) |
| `services/distance_service.py` | Формула Экстремум: макс. дистанция + коэффициенты |
| `services/psychotype_service.py` | Детекция психотипа, модификаторы полос |
| `services/recommendation_service.py` | Стат-рекомендации по истории (B3) |
Использует Claude API (модель `claude-sonnet-4-20250514`) для интеллектуального анализа случаев пропажи людей. ### Поток анализа
**Входные данные (case_data):** ```
- `age` - возраст пропавшего analyze_case (роутер, тонкая обёртка)
- `gender` - пол └─ build_search_model(SearchInput, analyzer=rules_analyze)
- `terrain` - тип местности ├─ derive_profiles / derive_time_of_day (деривации)
- `weather` - погодные условия ├─ detect_psychotype (если ответы есть)
- `time_missing` - время пропажи ├─ calculate_max_distance (формула Экстремум)
- `last_location` - последнее известное местоположение ├─ rules_analyze (urgency/зоны/действия по правилам)
- `circumstances` - особые обстоятельства │ └─ build_search_zones + rank_zones (если есть координаты)
- `physical_condition` - физическое состояние └─ WeightedScorer (веса, профили)
- `experience` - опыт нахождения на природе
**Возвращаемые данные (AnalysisResult):**
```python
{
"urgency": "критическая|высокая|средняя|низкая",
"primary_zones": [
{
"priority": 1,
"name": "Зона А",
"direction": "север",
"distance": 2.5,
"reason": "Обоснование выбора"
}
],
"search_radius_km": 5.0,
"key_locations": ["водоемы", "дороги", "постройки"],
"team_assignments": {
"команда_1": "зона А",
"команда_2": "зона Б"
},
"behavioral_prediction": "Прогноз поведения",
"immediate_actions": ["Действие 1", "Действие 2"],
"summary": "Краткое резюме"
}
``` ```
### 2. Stats Service (`backend/services/stats_service.py`) ## API
**Функция:** `get_statistical_recommendation(case_data: dict, db_session: AsyncSession) -> StatsResult` ### POST `/api/v1/analyze`
Анализ случая (поле `case_id` опционально — без него анализ «на лету»).
Анализирует исторические данные из базы для получения статистических рекомендаций.
**Входные данные:**
- `age` - возраст (±2 года для фильтрации)
- `season` - сезон (winter, spring, summer, autumn)
- `terrain_primary` - основной тип местности
**Логика фильтрации:**
1. Первая попытка: возраст ±2 года + сезон + terrain
2. Если sample_size < 5: возраст ±2 года + сезон (без terrain)
3. Если данных нет: возвращает дефолтные значения
**Возвращаемые данные (StatsResult):**
```python
{
"median_distance_km": 2.5,
"top_directions": [
{
"direction": "север",
"count": 15,
"percentage": 35.0
}
],
"top_location_types": ["водоемы", "дороги", "лесные тропы"],
"survival_rate": 87.5,
"sample_size": 43
}
```
## API Endpoints
### POST `/api/v1/analyze/text`
Анализ случая с помощью Claude AI.
**Request:**
```json
{
"age": 65,
"gender": "мужской",
"terrain": "лес",
"weather": "дождь",
"time_missing": "6 часов",
"last_location": "лесная тропа у озера",
"circumstances": "вышел за грибами",
"physical_condition": "хорошее",
"experience": "опытный грибник"
}
```
**Response:** `AnalysisResult`
### POST `/api/v1/analyze/combined` ### POST `/api/v1/analyze/combined`
Синоним `/analyze` (оставлен для совместимости).
Комбинированный анализ: Claude AI + статистические данные. ### GET `/api/v1/analyze/{case_id}`
Последний сохранённый результат анализа кейса.
**Request:** Тот же формат, что и `/text` Ответ `/analyze` (сокращённо):
**Response:**
```json ```json
{ {
"claude_analysis": { /* AnalysisResult */ }, "max_distance_km": 5.4,
"statistical_data": { /* StatsResult */ } "coefficients": {"base_speed": 1.2, "terrain": 0.5, "urgency": 5.0, ...},
"urgency": "высокая",
"primary_zones": [{"priority": 1, "name": "Зона N 0.5км", ...}],
"search_radius_km": 5.0,
"key_locations": ["водоёмы", ...],
"behavioral_prediction": "...",
"immediate_actions": ["..."],
"active_profiles": [{"name": "велосипед", ...}],
"fallback_used": true
} }
``` ```
`fallback_used: true` означает «локальный расчёт» (единственный режим).
### POST `/api/v1/analyze/document`
Анализ загруженного документа (пока поддерживается только .txt).
**Request:** multipart/form-data с файлом
**Response:** `AnalysisResult`
## Настройка
### Переменные окружения
В файле `.env` должен быть установлен:
```
ANTHROPIC_API_KEY=your_api_key_here
```
### База данных
Для работы `stats_service` требуется таблица `cases` со следующими полями:
- `age` (integer)
- `season` (string)
- `terrain_primary` (string)
- `found_distance_km` (float)
- `found_direction` (string)
- `found_location_type` (string)
- `outcome` (string: 'alive', 'found_alive', etc.)
## Тестирование ## Тестирование
### Тест Claude Service
```bash ```bash
curl -X POST http://192.168.0.99:8000/api/v1/analyze/text \ curl -X POST http://192.168.0.99:8000/api/v1/analyze \
-H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ -d '{"age": 8, "gender": "м", "terrain": ["лес"], "elapsed_hours": 2.0,
"age": 65, "has_transport": "bike", "season": "лето"}'
"gender": "мужской", # max_distance_km == 5.4 (регресс B14)
"terrain": "лес", ```
"weather": "дождь",
"time_missing": "6 часов",
"last_location": "лесная тропа у озера"
}'
```
### Тест Combined Analysis
```bash
curl -X POST http://192.168.0.99:8000/api/v1/analyze/combined \
-H "Content-Type: application/json" \
-d '{
"age": 65,
"season": "autumn",
"terrain_primary": "forest"
}'
```
## TODO
- [ ] Настроить подключение к базе данных
- [ ] Добавить миграции Alembic для таблицы cases
- [ ] Реализовать извлечение текста из .docx и .pdf
- [ ] Добавить кэширование результатов анализа
- [ ] Добавить rate limiting для API запросов
- [ ] Добавить логирование запросов и ответов
+2 -5
View File
@@ -9,13 +9,10 @@ from pydantic import BaseModel, Field
from backend.database import db from backend.database import db
from backend.routers.auth import require_roles from backend.routers.auth import require_roles
from services.claude_service import analyze_case as claude_analyze from services.rules_analysis import rules_analyze
from services.search_engine import ( from services.search_engine import (
SearchInput, SearchInput,
build_search_model, build_search_model,
derive_profiles,
derive_time_of_day,
unmodeled_profiles,
) )
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze']) router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
@@ -98,7 +95,7 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
time_of_day=case_data.get('time_of_day'), time_of_day=case_data.get('time_of_day'),
) )
model = await build_search_model(search_input, analyzer=claude_analyze) model = await build_search_model(search_input, analyzer=rules_analyze)
result = model.model_dump() result = model.model_dump()
result['case_id'] = str(payload.case_id) if payload.case_id else None result['case_id'] = str(payload.case_id) if payload.case_id else None
+2 -2
View File
@@ -4,7 +4,7 @@
поисков. LLM не используется: скоринг полностью детерминированный поисков. LLM не используется: скоринг полностью детерминированный
(WeightedScorer + distance_service + психотип). (WeightedScorer + distance_service + психотип).
Прогон модели = тот же расчёт, что /analyze, но БЕЗ claude_service: Прогон модели = тот же расчёт, что /analyze, но без LLM-анализатора:
urgency/immediate_actions/behavioral_prediction вычисляются по правилам, urgency/immediate_actions/behavioral_prediction вычисляются по правилам,
зоны считаются WeightedScorer-ом по фактическим данным кейса. зоны считаются WeightedScorer-ом по фактическим данным кейса.
@@ -58,7 +58,7 @@ router = APIRouter(
def _run_model(data: dict[str, Any]) -> dict[str, Any]: def _run_model(data: dict[str, Any]) -> dict[str, Any]:
"""Детерминированный прогон матмодели по данным завершённого поиска. """Детерминированный прогон матмодели по данным завершённого поиска.
Повторяет логику analyze.analyze_case, но БЕЗ вызова claude_service. Повторяет логику analyze.analyze_case, но без LLM-анализатора:
urgency/behavioral_prediction/immediate_actions вычисляются правилами. urgency/behavioral_prediction/immediate_actions вычисляются правилами.
""" """
# Психотип из ответов или явного указания # Психотип из ответов или явного указания
-315
View File
@@ -1,315 +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}")
result = response.json()
content = result["content"][0]["text"]
# Парсим JSON из ответа
# Убираем возможные markdown блоки кода
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
analysis_data = json.loads(content)
analysis_data['fallback_used'] = False
# Преобразуем в Pydantic модель
return AnalysisResult(**analysis_data)
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
)
-502
View File
@@ -1,502 +0,0 @@
"""
Tests for claude_service.py
Tests the Claude AI analysis with fallback to scoring service.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import os
from services.claude_service import (
analyze_case,
analyze_with_fallback,
AnalysisResult,
PrimaryZone,
_extract_json_payload
)
class TestAnalysisResult:
"""Test AnalysisResult model."""
def test_analysis_result_structure(self):
"""Test AnalysisResult has correct structure."""
result = AnalysisResult(
urgency="высокая",
primary_zones=[
PrimaryZone(
priority=1,
name="Зона А",
direction="N",
distance=1.0,
reason="Тест"
)
],
search_radius_km=5.0,
key_locations=["водоёмы"],
behavioral_prediction="Тест",
immediate_actions=["Действие 1"],
summary="Тест",
fallback_used=False
)
assert result.urgency == "высокая"
assert len(result.primary_zones) == 1
assert result.search_radius_km == 5.0
assert result.fallback_used is False
class TestFallbackAnalysis:
"""Test fallback analysis without Claude API."""
@pytest.mark.asyncio
async def test_fallback_young_child(self):
"""Test fallback for young child (critical urgency)."""
case_data = {
'age': 3,
'gender': 'мужской',
'terrain': 'лес',
'weather': 'ясно'
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
assert result.fallback_used is True
assert len(result.primary_zones) >= 2
assert "водоёмы" in result.key_locations
@pytest.mark.asyncio
async def test_fallback_with_ras_profile(self):
"""Test fallback with РАС profile."""
case_data = {
'age': 8,
'gender': 'мужской',
'profiles': ['РАС']
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
assert "РАС" in result.behavioral_prediction or "водоём" in result.behavioral_prediction
assert any("водоём" in action.lower() for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_fallback_with_bicycle(self):
"""Test fallback with bicycle profile."""
case_data = {
'age': 12,
'gender': 'мужской',
'profiles': ['велосипед']
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "высокая"
assert any("10-15 км" in action or "камер" in action for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_fallback_teenager(self):
"""Test fallback for teenager."""
case_data = {
'age': 15,
'gender': 'мужской',
'terrain': 'лес'
}
result = await analyze_with_fallback(case_data)
assert result.urgency in ["средняя", "высокая"]
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
@pytest.mark.asyncio
async def test_fallback_with_coordinates(self):
"""Test fallback with coordinates (geo service integration)."""
case_data = {
'age': 10,
'lat': 53.9,
'lon': 27.5,
'terrain': 'лес'
}
with patch('services.claude_service.build_search_zones', new_callable=AsyncMock) as mock_zones:
# Mock zones
from services.geo_service import Zone
mock_zones.return_value = [
Zone(
direction="N",
distance_km=0.5,
forest_pct=60.0,
road_density=1.0,
water_distance_km=2.0,
settlement_distance_km=5.0
),
Zone(
direction="E",
distance_km=1.0,
forest_pct=40.0,
road_density=2.0,
water_distance_km=1.0,
settlement_distance_km=3.0
)
]
result = await analyze_with_fallback(case_data)
assert result.fallback_used is True
assert len(result.primary_zones) > 0
mock_zones.assert_called_once()
@pytest.mark.asyncio
async def test_fallback_without_coordinates(self):
"""Test fallback without coordinates (basic zones)."""
case_data = {
'age': 10,
'terrain': 'лес'
}
result = await analyze_with_fallback(case_data)
assert result.fallback_used is True
assert len(result.primary_zones) >= 2
assert result.primary_zones[0].priority == 1
class TestAnalyzeCase:
"""Test main analyze_case function."""
@pytest.mark.asyncio
async def test_analyze_without_api_key(self):
"""Test analyze falls back when no API key."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес'
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}, clear=True):
result = await analyze_case(case_data)
assert result.fallback_used is True
assert isinstance(result, AnalysisResult)
@pytest.mark.asyncio
async def test_analyze_with_api_error(self):
"""Test analyze falls back on API error."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес'
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Server error"
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
result = await analyze_case(case_data)
# Should fallback
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_analyze_with_successful_api(self):
"""Test analyze with successful Claude API response."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес',
'weather': 'дождь'
}
mock_api_response = {
"urgency": "высокая",
"primary_zones": [
{
"priority": 1,
"name": "Лесной массив север",
"direction": "N",
"distance": 1.5,
"reason": "Наиболее вероятное направление"
}
],
"search_radius_km": 5.0,
"key_locations": ["водоёмы", "дороги"],
"behavioral_prediction": "Ребёнок может двигаться по тропам",
"immediate_actions": ["Организовать поиск", "Проверить водоёмы"],
"summary": "Случай высокой срочности"
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
import json
json_text = json.dumps(mock_api_response, ensure_ascii=False)
mock_response.json.return_value = {
"content": [
{
"text": f"```json\n{json_text}\n```"
}
]
}
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
result = await analyze_case(case_data)
assert result.fallback_used is False
assert result.urgency == "высокая"
class TestUrgencyClassification:
"""Test urgency classification logic."""
@pytest.mark.asyncio
async def test_critical_urgency_young_child(self):
"""Test critical urgency for very young children."""
case_data = {'age': 2}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
@pytest.mark.asyncio
async def test_critical_urgency_epilepsy(self):
"""Test critical urgency for epilepsy."""
case_data = {'age': 10, 'profiles': ['эпилепсия']}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
@pytest.mark.asyncio
async def test_high_urgency_bicycle(self):
"""Test high urgency for bicycle."""
case_data = {'age': 12, 'profiles': ['велосипед']}
result = await analyze_with_fallback(case_data)
assert result.urgency == "высокая"
@pytest.mark.asyncio
async def test_medium_urgency_preteen(self):
"""Test medium urgency for preteen."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert result.urgency == "средняя"
class TestKeyLocations:
"""Test key locations based on age."""
@pytest.mark.asyncio
async def test_young_child_locations(self):
"""Test key locations for young children."""
case_data = {'age': 5}
result = await analyze_with_fallback(case_data)
assert "водоёмы" in result.key_locations
assert "укрытия" in result.key_locations
@pytest.mark.asyncio
async def test_preteen_locations(self):
"""Test key locations for preteens."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert "водоёмы" in result.key_locations
assert "дороги" in result.key_locations or "тропы" in result.key_locations
@pytest.mark.asyncio
async def test_teenager_locations(self):
"""Test key locations for teenagers."""
case_data = {'age': 15}
result = await analyze_with_fallback(case_data)
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
class TestBehavioralPrediction:
"""Test behavioral prediction logic."""
@pytest.mark.asyncio
async def test_ras_prediction(self):
"""Test РАС behavioral prediction."""
case_data = {'age': 8, 'profiles': ['РАС']}
result = await analyze_with_fallback(case_data)
assert "водоём" in result.behavioral_prediction.lower() or "рас" in result.behavioral_prediction.lower()
@pytest.mark.asyncio
async def test_young_child_prediction(self):
"""Test young child behavioral prediction."""
case_data = {'age': 3}
result = await analyze_with_fallback(case_data)
assert "минимальное" in result.behavioral_prediction.lower() or "близко" in result.behavioral_prediction.lower()
@pytest.mark.asyncio
async def test_teenager_prediction(self):
"""Test teenager behavioral prediction."""
case_data = {'age': 15}
result = await analyze_with_fallback(case_data)
assert "целенаправленное" in result.behavioral_prediction.lower() or "населённ" in result.behavioral_prediction.lower()
class TestImmediateActions:
"""Test immediate actions generation."""
@pytest.mark.asyncio
async def test_basic_actions(self):
"""Test basic immediate actions are present."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert len(result.immediate_actions) >= 3
assert any("поиск" in action.lower() for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_ras_critical_action(self):
"""Test РАС critical action is first."""
case_data = {'age': 8, 'profiles': ['РАС']}
result = await analyze_with_fallback(case_data)
first_action = result.immediate_actions[0]
assert "водоём" in first_action.lower() or "критично" in first_action.lower()
@pytest.mark.asyncio
async def test_bicycle_action(self):
"""Test bicycle specific action."""
case_data = {'age': 12, 'profiles': ['велосипед']}
result = await analyze_with_fallback(case_data)
assert any("10-15" in action or "камер" in action for action in result.immediate_actions)
class TestExtractJsonPayload:
"""Test tolerant JSON extraction from a model response (B2)."""
def test_bare_json(self):
assert _extract_json_payload('{"a": 1}') == {'a': 1}
def test_json_fence(self):
assert _extract_json_payload('```json\n{"a": 1}\n```') == {'a': 1}
def test_uppercase_json_fence(self):
assert _extract_json_payload('```JSON\n{"a": 1}\n```') == {'a': 1}
def test_bare_fence(self):
assert _extract_json_payload('```\n{"a": 1}\n```') == {'a': 1}
def test_prose_around_json(self):
content = 'Вот результат анализа:\n{"a": 1}\nНадеюсь, это поможет.'
assert _extract_json_payload(content) == {'a': 1}
def test_prose_around_fenced_json(self):
content = 'Разбор:\n```json\n{"a": 1}\n```\nКонец.'
assert _extract_json_payload(content) == {'a': 1}
def test_json_array_is_rejected(self):
"""A top-level array is not a valid AnalysisResult payload."""
with pytest.raises(ValueError):
_extract_json_payload('[1, 2, 3]')
def test_plain_text_raises(self):
with pytest.raises(ValueError):
_extract_json_payload('Извините, я не могу выполнить этот запрос.')
def test_empty_raises(self):
with pytest.raises(ValueError):
_extract_json_payload('')
def test_none_raises(self):
with pytest.raises(ValueError):
_extract_json_payload(None)
class TestClaudeResponseFallback:
"""Malformed Claude responses must degrade to scoring, not crash (B2)."""
def _mock_client(self, mock_client, text=None, payload=None):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = (
payload if payload is not None else {"content": [{"text": text}]}
)
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
return_value=mock_response
)
@pytest.mark.asyncio
async def test_unparseable_text_falls_back(self):
"""Model answers in prose instead of JSON -> deterministic fallback."""
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, text='Не могу помочь с этим.')
result = await analyze_case({'age': 10, 'terrain': 'лес'})
assert result.fallback_used is True
assert isinstance(result, AnalysisResult)
@pytest.mark.asyncio
async def test_truncated_json_falls_back(self):
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, text='```json\n{"urgency": "высок')
result = await analyze_case({'age': 10})
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_valid_json_missing_required_fields_falls_back(self):
"""Parseable JSON that violates the AnalysisResult contract."""
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, text='{"urgency": "высокая"}')
result = await analyze_case({'age': 10})
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_unexpected_envelope_falls_back(self):
"""API envelope without content[0].text -> fallback, not KeyError."""
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, payload={'unexpected': 'shape'})
result = await analyze_case({'age': 10})
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_unfenced_json_with_prose_succeeds(self):
"""Recovery path: valid payload wrapped in prose is still used."""
import json as _json
payload = {
"urgency": "высокая",
"primary_zones": [{
"priority": 1,
"name": "Лес север",
"direction": "N",
"distance": 1.5,
"reason": "Вероятное направление"
}],
"search_radius_km": 5.0,
"key_locations": ["водоёмы"],
"behavioral_prediction": "Движение по тропам",
"immediate_actions": ["Организовать поиск"],
"summary": "Резюме"
}
text = 'Результат:\n' + _json.dumps(payload, ensure_ascii=False) + '\nГотово.'
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, text=text)
result = await analyze_case({'age': 10})
assert result.fallback_used is False
assert result.urgency == "высокая"
+80
View File
@@ -0,0 +1,80 @@
"""Тесты детерминированного rules-анализа (services/rules_analysis.py).
Ключевые кейсы перенесены из test_claude_service.py (удалён вместе с LLM);
плюс проверка отсутствия внешних вызовов.
"""
from __future__ import annotations
from services.rules_analysis import (
AnalysisResult,
PrimaryZone,
rule_based_urgency,
rules_analyze,
)
class TestUrgency:
async def test_young_child_critical(self):
# _age_urgency: возраст ≤4 — критическая вне зависимости от elapsed
r = await rules_analyze({'age': 3})
assert r.urgency == 'критическая'
def test_ras_critical(self):
assert rule_based_urgency(1.0, 8, ['РАС']) == 'высокая' # возраст 8 не даёт +2
def test_long_search_raises(self):
assert rule_based_urgency(30.0, 15, []) == 'высокая'
assert rule_based_urgency(30.0, 5, ['РАС']) == 'критическая'
class TestFallbackRules:
async def test_young_child(self):
result = await rules_analyze({'age': 3, 'gender': 'мужской', 'terrain': 'лес'})
assert isinstance(result, AnalysisResult)
assert result.urgency == 'критическая'
assert result.fallback_used is True
assert len(result.primary_zones) >= 2
assert 'водоёмы' in result.key_locations
async def test_ras_profile(self):
result = await rules_analyze({'age': 8, 'profiles': ['РАС']})
assert result.urgency == 'критическая'
assert 'РАС' in result.behavioral_prediction or 'водоём' in result.behavioral_prediction
assert any('водоём' in a.lower() for a in result.immediate_actions)
async def test_bicycle_profile(self):
result = await rules_analyze({'age': 12, 'profiles': ['велосипед']})
assert result.urgency == 'высокая'
assert any('10-15 км' in a or 'камер' in a for a in result.immediate_actions)
async def test_teenager(self):
result = await rules_analyze({'age': 15, 'terrain': 'лес'})
assert result.urgency in ['средняя', 'высокая']
assert 'дороги' in result.key_locations or 'населённые пункты' in result.key_locations
async def test_behavioral_predictions_by_age(self):
r4 = await rules_analyze({'age': 4, 'profiles': []})
r9 = await rules_analyze({'age': 9, 'profiles': []})
r16 = await rules_analyze({'age': 16, 'profiles': []})
assert 'близко к точке потери' in r4.behavioral_prediction
assert 'тропам или дорогам' in r9.behavioral_prediction
assert 'населённым пунктам' in r16.behavioral_prediction
class TestNoLLM:
def test_no_anthropic_in_services(self):
"""В services/ не осталось ссылок на LLM-провайдеров."""
import pathlib
for p in pathlib.Path('services').glob('*.py'):
src = p.read_text(encoding='utf-8')
for word in ('anthropic', 'ANTHROPIC', 'api.anthropic.com', 'openai', 'OpenAI'):
assert word not in src, f'{p.name} contains {word}'
def test_no_llm_keys_in_compose(self):
src = open('docker-compose.yml', encoding='utf-8').read()
assert 'ANTHROPIC' not in src and 'OPENAI' not in src
async def test_result_is_deterministic(self):
a = await rules_analyze({'age': 10, 'profiles': []})
b = await rules_analyze({'age': 10, 'profiles': []})
assert a.model_dump() == b.model_dump()
+3 -3
View File
@@ -1,6 +1,6 @@
"""B14: юнит-тесты чистого движка services/search_engine.py. """B14: юнит-тесты чистого движка services/search_engine.py.
claude_analyze мокается (движок тестируем изолированно, без httpx/ANTHROPIC). Анализатор мокается (движок тестируем изолированно, без geo/httpx).
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,7 +9,7 @@ from typing import Any
import pytest import pytest
from services.claude_service import AnalysisResult, PrimaryZone from services.rules_analysis import AnalysisResult, PrimaryZone
from services.search_engine import ( from services.search_engine import (
SearchInput, SearchInput,
build_search_model, build_search_model,
@@ -70,7 +70,7 @@ async def test_profiles_derived_from_diagnosis_and_transport():
) )
assert 'РАС' in [p['name'] for p in model.active_profiles] assert 'РАС' in [p['name'] for p in model.active_profiles]
assert model.critical_warnings == [] or isinstance(model.critical_warnings, list) assert model.critical_warnings == [] or isinstance(model.critical_warnings, list)
# Профили дошли и до claude (case_data), и в скорер # Профили дошли и до анализатора (case_data), и в скорер
assert 'РАС' in analyzer.captured['profiles'] assert 'РАС' in analyzer.captured['profiles']
assert 'велосипед' in analyzer.captured['profiles'] assert 'велосипед' in analyzer.captured['profiles']
assert 'не_умеет_плавать' in analyzer.captured['profiles'] assert 'не_умеет_плавать' in analyzer.captured['profiles']
+1 -2
View File
@@ -34,8 +34,7 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/vector_mchs} - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:***@postgres:5432/vector_mchs}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- JWT_SECRET=${JWT_SECRET:-change-me-in-production} - JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
- PYTHONPATH=/app - PYTHONPATH=/app
@@ -157,8 +157,8 @@ const AnalysisResult = () => {
{analysisData && ( {analysisData && (
<> <>
<span className="separator">•</span> <span className="separator">•</span>
<span style={{ color: analysisData.fallback_used ? 'var(--text-secondary)' : 'var(--success)' }}> <span style={{ color: 'var(--success)' }}>
{analysisData.fallback_used ? 'Локальный расчёт' : 'ИИ-анализ'} Локальный расчёт
</span> </span>
</> </>
)} )}
-350
View File
@@ -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
)
+168
View File
@@ -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
View File
@@ -2,20 +2,20 @@
Граница (vector_tasks.md B13-принцип 1, B14): Граница (vector_tasks.md B13-принцип 1, B14):
- build_search_model(input) -> SearchModel — чистая функция. - build_search_model(input) -> SearchModel — чистая функция.
- Единственная внешняя зависимость — claude_analyze (async, httpx): - Анализатор (нарратив/зоны) инъекцией: рантайм — services.rules_analysis
она уже была «внутри» расчёта в старом analyze_case; изолируется (детерминированные правила, без LLM — карточки детей не покидают контур);
параметром движка, чтобы тесты могли подменять её моком. тесты подменяют моком. Анализатору достаточно вернуть объект с полями
urgency/primary_zones/search_radius_km/... (structurally typed).
- Ни одной привязки к полевым транспортам/протоколам (критерий B20: - Ни одной привязки к полевым транспортам/протоколам (критерий B20:
grep по этому файлу не находит соответствующих терминов). grep по этому файлу не находит соответствующих терминов).
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable, Protocol
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from services.claude_service import AnalysisResult
from services.distance_service import ( from services.distance_service import (
calculate_max_distance, calculate_max_distance,
get_base_speed, get_base_speed,
@@ -32,8 +32,22 @@ from services.psychotype_service import (
) )
from services.scoring_service import WeightedScorer 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: профили без аналога/данных — честно помечать «вне модели»). # (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
@@ -72,7 +86,7 @@ class SearchInput(BaseModel):
time_of_day: str | None = None time_of_day: str | None = None
def to_case_data(self) -> dict[str, Any]: def to_case_data(self) -> dict[str, Any]:
"""Плоский dict для сервисов (claude/scoring) — тот же состав полей, """Плоский dict для сервисов (анализатор/скоринг) — тот же состав полей,
что раньше уходил из analyze_case.""" что раньше уходил из analyze_case."""
data: dict[str, Any] = { data: dict[str, Any] = {
'age': self.age, 'age': self.age,
@@ -209,13 +223,13 @@ def derive_time_of_day(loss_time: Any) -> str:
async def build_search_model( async def build_search_model(
search_input: SearchInput, search_input: SearchInput,
*, *,
analyzer: ClaudeAnalyzer, analyzer: CaseAnalyzer,
analyzed_at: datetime | None = None, analyzed_at: datetime | None = None,
) -> SearchModel: ) -> SearchModel:
"""Чистая функция расчёта поисковой модели (B14). """Чистая функция расчёта поисковой модели (B14).
Вся логика бывшего analyze_case: деривации полей, психотип, Вся логика бывшего analyze_case: деривации полей, психотип,
WeightedScorer, коэффициенты формулы Экстремум, claude_analyze. WeightedScorer, коэффициенты формулы Экстремум, analyzer (rules/мок).
DB / auth / HTTP здесь не нужны; анализатор подменяется параметром. DB / auth / HTTP здесь не нужны; анализатор подменяется параметром.
""" """
case_data = search_input.to_case_data() case_data = search_input.to_case_data()
@@ -246,7 +260,7 @@ async def build_search_model(
case_data['psychotype_modifiers'] = psychotype_modifiers case_data['psychotype_modifiers'] = psychotype_modifiers
max_distance_km = calculate_max_distance(case_data) max_distance_km = calculate_max_distance(case_data)
claude_result = await analyzer(case_data) analysis = await analyzer(case_data)
scorer = WeightedScorer() scorer = WeightedScorer()
if case_data.get('age'): if case_data.get('age'):
@@ -282,12 +296,12 @@ async def build_search_model(
active_profiles=scorer.get_active_profiles_info(), active_profiles=scorer.get_active_profiles_info(),
critical_warnings=list(scorer.critical_warnings), critical_warnings=list(scorer.critical_warnings),
unmodeled_profiles=unmodeled_profiles(case_data.get('diagnosis_type') or []), unmodeled_profiles=unmodeled_profiles(case_data.get('diagnosis_type') or []),
urgency=claude_result.urgency, urgency=analysis.urgency,
primary_zones=[zone.model_dump() for zone in claude_result.primary_zones], primary_zones=[zone.model_dump() if hasattr(zone, "model_dump") else dict(zone) for zone in analysis.primary_zones],
search_radius_km=claude_result.search_radius_km, search_radius_km=analysis.search_radius_km,
key_locations=claude_result.key_locations, key_locations=analysis.key_locations,
behavioral_prediction=claude_result.behavioral_prediction, behavioral_prediction=analysis.behavioral_prediction,
immediate_actions=claude_result.immediate_actions, immediate_actions=analysis.immediate_actions,
summary=claude_result.summary, summary=analysis.summary,
fallback_used=claude_result.fallback_used, fallback_used=analysis.fallback_used,
) )
+4
View File
@@ -908,6 +908,10 @@ sed -i 's/sar_mchs/vector_mchs/g; s#/root/sar-mchs#/root/vector#g' DEPLOY.md
## B20 — Контракт обмена Вектор ↔ Контур (граница проектов) ## B20 — Контракт обмена Вектор ↔ Контур (граница проектов)
**Тип:** spec / architecture, needs-context. Фундамент развязки (B13-принцип 1): единый версионированный формат данных на границе, без зависимости от ATAK/Meshtastic. От него зависят B17 (ингестия) и B18 (выдача зон) — они реализуются поверх этого контракта. **Тип:** spec / architecture, needs-context. Фундамент развязки (B13-принцип 1): единый версионированный формат данных на границе, без зависимости от ATAK/Meshtastic. От него зависят B17 (ингестия) и B18 (выдача зон) — они реализуются поверх этого контракта.
> Решение Виктора (2026-09-09): LLM убран из проекта полностью. Zones/
> probability для outbound отдаёт детерминированный движок (rules_analysis +
> scoring + гео), без Claude/Anthropic. Нарративные поля /analyze — правила.
**Что:** Зафиксировать контракт обмена между Вектором и Контуром — два направления, оба версионированные (`schema_version`), протокольно-агностичные. Транспорт (ATAK CoT / Meshtastic / HTTP / websocket) — дело адаптеров Контура, не Вектора; движок работает только с форматом. **Что:** Зафиксировать контракт обмена между Вектором и Контуром — два направления, оба версионированные (`schema_version`), протокольно-агностичные. Транспорт (ATAK CoT / Meshtastic / HTTP / websocket) — дело адаптеров Контура, не Вектора; движок работает только с форматом.
**Вектор → Контур (outbound):** **Вектор → Контур (outbound):**