diff --git a/.env.example b/.env.example
index fc0fc06..d165bd7 100644
--- a/.env.example
+++ b/.env.example
@@ -1,11 +1,11 @@
-# Anthropic API Key for Claude integration
-ANTHROPIC_API_KEY=your_anthropic_api_key_here
-
# 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=your_jwt_secret_here_change_in_production
# CORS origins (comma-separated)
-CORS_ORIGINS=http://localhost:3000,http://localhost:8000
+CORS_ORIGINS=http://localhost:3000
+
+# Примечание: внешний ИИ не используется (решение Виктора: ПД-данные
+# не покидают контур). Все расчёты локальные и детерминированные.
\ No newline at end of file
diff --git a/ANALYZE_API.md b/ANALYZE_API.md
index 73e9596..2079851 100644
--- a/ANALYZE_API.md
+++ b/ANALYZE_API.md
@@ -10,7 +10,7 @@
2. Geo service - построение зон поиска (8 направлений)
3. Scoring service - оценка и ранжирование зон
4. Psychotype service - определение психотипа (если есть данные)
-5. Claude service - интеллектуальный анализ (опционально)
+5. Rules analysis - детерминированные правила (urgency, зоны, действия; без LLM)
6. Merge results - объединение результатов
**Request:**
@@ -56,7 +56,7 @@
"behavioral_prediction": "Умеренное движение",
"summary": "Случай классифицирован как средняя срочность",
"execution_time_ms": 21197.7,
- "services_used": ["distance", "geo", "psychotype", "claude"]
+ "services_used": ["distance", "geo", "psychotype", "rules"]
}
```
@@ -80,13 +80,13 @@
| Geo | ✅ Работает | Построение зон (21 сек) |
| Scoring | ⚠️ Fallback | NoneType division error |
| Psychotype | ✅ Работает | Определение + модификаторы |
-| Claude | ⚠️ Fallback | 403 Forbidden (нет API ключа) |
+| Rules | ✅ | локальный расчёт |
## Fallback механизмы
Если сервис недоступен, система использует fallback:
- **Geo/Scoring fail** → создаются базовые 8 секторов
-- **Claude fail** → базовые рекомендации из scoring service
+- Правила всегда доступны: LLM в пайплайне нет (ПД не покидают контур)
- **Psychotype fail** → анализ без модификаторов психотипа
## Тестирование
@@ -100,7 +100,7 @@
## Известные проблемы
1. **Scoring service**: Ошибка деления на None - нужно добавить проверки на None в scoring_service.py
-2. **Claude API**: Требуется настройка ANTHROPIC_API_KEY в .env
+2. **LLM не используется**: API-ключи не нужны, всё локально
3. **Geo service**: Возвращает пустые зоны (возможно, проблема с Overpass API или кешем)
## Дата создания
diff --git a/DEPLOY.md b/DEPLOY.md
index 7832333..7527564 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -63,11 +63,14 @@ docker compose ps
Переменные окружения находятся в файле `/root/vector/.env`
Важные переменные:
-- `ANTHROPIC_API_KEY` - API ключ для Claude (требуется для функции анализа)
- `JWT_SECRET` - секрет для JWT токенов
- `DATABASE_URL` - строка подключения к PostgreSQL
- `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 файл
-2. Настроить базу данных (миграции Alembic)
-3. Создать модели SQLAlchemy
-4. Реализовать аутентификацию с JWT
-5. Интегрировать Claude API для анализа документов
-6. Разработать React компоненты для frontend
+1. Настроить базу данных (миграции Alembic)
+2. Создать модели SQLAlchemy
+3. Реализовать аутентификацию с JWT
+4. Разработать React компоненты для frontend
## Проверка работоспособности
diff --git a/FIXES_REPORT.md b/FIXES_REPORT.md
index 854967d..14341b5 100644
--- a/FIXES_REPORT.md
+++ b/FIXES_REPORT.md
@@ -1,5 +1,8 @@
# Исправления ошибок - Итоговый отчет
+> Исторический документ (2026-05-05). Упоминания Claude Service относятся
+> к удалённому с тех пор LLM-слою; текущий анализ — rules_analysis (локально).
+
## Дата: 2026-05-05
### Исправленные проблемы:
diff --git a/SERVICES_README.md b/SERVICES_README.md
index aaa97e4..192063f 100644
--- a/SERVICES_README.md
+++ b/SERVICES_README.md
@@ -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` - возраст пропавшего
-- `gender` - пол
-- `terrain` - тип местности
-- `weather` - погодные условия
-- `time_missing` - время пропажи
-- `last_location` - последнее известное местоположение
-- `circumstances` - особые обстоятельства
-- `physical_condition` - физическое состояние
-- `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": "Краткое резюме"
-}
+```
+analyze_case (роутер, тонкая обёртка)
+ └─ build_search_model(SearchInput, analyzer=rules_analyze)
+ ├─ derive_profiles / derive_time_of_day (деривации)
+ ├─ detect_psychotype (если ответы есть)
+ ├─ calculate_max_distance (формула Экстремум)
+ ├─ rules_analyze (urgency/зоны/действия по правилам)
+ │ └─ build_search_zones + rank_zones (если есть координаты)
+ └─ WeightedScorer (веса, профили)
```
-### 2. Stats Service (`backend/services/stats_service.py`)
+## API
-**Функция:** `get_statistical_recommendation(case_data: dict, db_session: AsyncSession) -> StatsResult`
-
-Анализирует исторические данные из базы для получения статистических рекомендаций.
-
-**Входные данные:**
-- `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`
+Анализ случая (поле `case_id` опционально — без него анализ «на лету»).
### POST `/api/v1/analyze/combined`
+Синоним `/analyze` (оставлен для совместимости).
-Комбинированный анализ: Claude AI + статистические данные.
+### GET `/api/v1/analyze/{case_id}`
+Последний сохранённый результат анализа кейса.
-**Request:** Тот же формат, что и `/text`
-
-**Response:**
+Ответ `/analyze` (сокращённо):
```json
{
- "claude_analysis": { /* AnalysisResult */ },
- "statistical_data": { /* StatsResult */ }
+ "max_distance_km": 5.4,
+ "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
}
```
-
-### 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.)
+`fallback_used: true` означает «локальный расчёт» (единственный режим).
## Тестирование
-### Тест Claude Service
-
```bash
-curl -X POST http://192.168.0.99:8000/api/v1/analyze/text \
- -H "Content-Type: application/json" \
- -d '{
- "age": 65,
- "gender": "мужской",
- "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 запросов
-- [ ] Добавить логирование запросов и ответов
+curl -X POST http://192.168.0.99:8000/api/v1/analyze \
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
+ -d '{"age": 8, "gender": "м", "terrain": ["лес"], "elapsed_hours": 2.0,
+ "has_transport": "bike", "season": "лето"}'
+# max_distance_km == 5.4 (регресс B14)
+```
\ No newline at end of file
diff --git a/backend/routers/analyze.py b/backend/routers/analyze.py
index dd03fc1..12693c8 100644
--- a/backend/routers/analyze.py
+++ b/backend/routers/analyze.py
@@ -9,13 +9,10 @@ from pydantic import BaseModel, Field
from backend.database import db
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 (
SearchInput,
build_search_model,
- derive_profiles,
- derive_time_of_day,
- unmodeled_profiles,
)
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'),
)
- 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['case_id'] = str(payload.case_id) if payload.case_id else None
diff --git a/backend/routers/closed_cases.py b/backend/routers/closed_cases.py
index f0c04c1..b5ecf13 100644
--- a/backend/routers/closed_cases.py
+++ b/backend/routers/closed_cases.py
@@ -4,7 +4,7 @@
поисков. LLM не используется: скоринг полностью детерминированный
(WeightedScorer + distance_service + психотип).
-Прогон модели = тот же расчёт, что /analyze, но БЕЗ claude_service:
+Прогон модели = тот же расчёт, что /analyze, но без LLM-анализатора:
urgency/immediate_actions/behavioral_prediction вычисляются по правилам,
зоны считаются WeightedScorer-ом по фактическим данным кейса.
@@ -58,7 +58,7 @@ router = APIRouter(
def _run_model(data: dict[str, Any]) -> dict[str, Any]:
"""Детерминированный прогон матмодели по данным завершённого поиска.
- Повторяет логику analyze.analyze_case, но БЕЗ вызова claude_service.
+ Повторяет логику analyze.analyze_case, но без LLM-анализатора:
urgency/behavioral_prediction/immediate_actions вычисляются правилами.
"""
# Психотип из ответов или явного указания
diff --git a/backend/services/claude_service.py b/backend/services/claude_service.py
deleted file mode 100644
index 299ae09..0000000
--- a/backend/services/claude_service.py
+++ /dev/null
@@ -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
- )
diff --git a/backend/tests/test_claude_service.py b/backend/tests/test_claude_service.py
deleted file mode 100644
index 1270342..0000000
--- a/backend/tests/test_claude_service.py
+++ /dev/null
@@ -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 == "высокая"
diff --git a/backend/tests/test_rules_analysis.py b/backend/tests/test_rules_analysis.py
new file mode 100644
index 0000000..1178a60
--- /dev/null
+++ b/backend/tests/test_rules_analysis.py
@@ -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()
\ No newline at end of file
diff --git a/backend/tests/test_search_engine.py b/backend/tests/test_search_engine.py
index b5e79c2..c37ef38 100644
--- a/backend/tests/test_search_engine.py
+++ b/backend/tests/test_search_engine.py
@@ -1,6 +1,6 @@
"""B14: юнит-тесты чистого движка services/search_engine.py.
-claude_analyze мокается (движок тестируем изолированно, без httpx/ANTHROPIC).
+Анализатор мокается (движок тестируем изолированно, без geo/httpx).
"""
from __future__ import annotations
@@ -9,7 +9,7 @@ from typing import Any
import pytest
-from services.claude_service import AnalysisResult, PrimaryZone
+from services.rules_analysis import AnalysisResult, PrimaryZone
from services.search_engine import (
SearchInput,
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 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']
diff --git a/docker-compose.yml b/docker-compose.yml
index 4e93976..cbea4a9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,8 +34,7 @@ services:
ports:
- "8000:8000"
environment:
- - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/vector_mchs}
- - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
+ - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:***@postgres:5432/vector_mchs}
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
- PYTHONPATH=/app
diff --git a/frontend/src/pages/analysis/AnalysisResult.jsx b/frontend/src/pages/analysis/AnalysisResult.jsx
index 7580142..110c42f 100644
--- a/frontend/src/pages/analysis/AnalysisResult.jsx
+++ b/frontend/src/pages/analysis/AnalysisResult.jsx
@@ -157,8 +157,8 @@ const AnalysisResult = () => {
{analysisData && (
<>
•
-
- {analysisData.fallback_used ? 'Локальный расчёт' : 'ИИ-анализ'}
+
+ Локальный расчёт
>
)}
diff --git a/services/claude_service.py b/services/claude_service.py
deleted file mode 100644
index 61a0a06..0000000
--- a/services/claude_service.py
+++ /dev/null
@@ -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
- )
diff --git a/services/rules_analysis.py b/services/rules_analysis.py
new file mode 100644
index 0000000..6b11549
--- /dev/null
+++ b/services/rules_analysis.py
@@ -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,
+ )
\ No newline at end of file
diff --git a/services/search_engine.py b/services/search_engine.py
index b9fa69e..5807cd1 100644
--- a/services/search_engine.py
+++ b/services/search_engine.py
@@ -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,
)
\ No newline at end of file
diff --git a/vector_tasks.md b/vector_tasks.md
index a1ccc9a..87fd0a1 100644
--- a/vector_tasks.md
+++ b/vector_tasks.md
@@ -908,6 +908,10 @@ sed -i 's/sar_mchs/vector_mchs/g; s#/root/sar-mchs#/root/vector#g' DEPLOY.md
## B20 — Контракт обмена Вектор ↔ Контур (граница проектов)
**Тип:** 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) — дело адаптеров Контура, не Вектора; движок работает только с форматом.
**Вектор → Контур (outbound):**