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):**