Полный отказ от LLM: детерминированный rules-анализ
Решение Виктора: ИИ убран из проекта — карточки детей (ПД) не должны покиать контур. Анализ полностью локальный и детерминированный. - services/rules_analysis.py: правила вместо Claude (бывший analyze_with_fallback + rule_based_urgency из closed_cases): urgency по возрасту/профилям, зоны через geo+scoring, behavioral_prediction/immediate_actions/key_locations — справочники. - search_engine: анализатор инъекцией (CaseAnalyzer, Protocol), импорт claude_service удалён; рантайм — rules_analyze. - УДАЛЕНЫ: services/claude_service.py, backend/services/claude_service.py (устаревшая копия), test_claude_service.py; ключевые правила-кейсы перенесены в test_rules_analysis.py (+тест отсутствия LLM-упоминаний). - docker-compose/.env.example: ANTHROPIC_API_KEY удалён. - Фронт: бейдж 'ИИ-анализ' -> 'Локальный расчёт'. - Доки: DEPLOY/SERVICES_README/ANALYZE_API переписаны, FIXES_REPORT помечен историческим, vector_tasks B20: zones/probability — из движка. pytest: 187 passed (было 214; -27 удалённых LLM-тестов, +тест rules). Контракт /analyze не изменён (фронт совместим).
This commit is contained in:
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
# Anthropic API Key for Claude integration
|
|
||||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
|
||||||
|
|
||||||
# Database connection string
|
# Database connection string
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/sar_mchs
|
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/vector_mchs
|
||||||
|
|
||||||
# JWT secret for authentication (generate a secure random string)
|
# JWT secret for authentication (generate a secure random string)
|
||||||
JWT_SECRET=your_jwt_secret_here_change_in_production
|
JWT_SECRET=your_jwt_secret_here_change_in_production
|
||||||
|
|
||||||
# CORS origins (comma-separated)
|
# CORS origins (comma-separated)
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# Примечание: внешний ИИ не используется (решение Виктора: ПД-данные
|
||||||
|
# не покидают контур). Все расчёты локальные и детерминированные.
|
||||||
+5
-5
@@ -10,7 +10,7 @@
|
|||||||
2. Geo service - построение зон поиска (8 направлений)
|
2. Geo service - построение зон поиска (8 направлений)
|
||||||
3. Scoring service - оценка и ранжирование зон
|
3. Scoring service - оценка и ранжирование зон
|
||||||
4. Psychotype service - определение психотипа (если есть данные)
|
4. Psychotype service - определение психотипа (если есть данные)
|
||||||
5. Claude service - интеллектуальный анализ (опционально)
|
5. Rules analysis - детерминированные правила (urgency, зоны, действия; без LLM)
|
||||||
6. Merge results - объединение результатов
|
6. Merge results - объединение результатов
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"behavioral_prediction": "Умеренное движение",
|
"behavioral_prediction": "Умеренное движение",
|
||||||
"summary": "Случай классифицирован как средняя срочность",
|
"summary": "Случай классифицирован как средняя срочность",
|
||||||
"execution_time_ms": 21197.7,
|
"execution_time_ms": 21197.7,
|
||||||
"services_used": ["distance", "geo", "psychotype", "claude"]
|
"services_used": ["distance", "geo", "psychotype", "rules"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -80,13 +80,13 @@
|
|||||||
| Geo | ✅ Работает | Построение зон (21 сек) |
|
| Geo | ✅ Работает | Построение зон (21 сек) |
|
||||||
| Scoring | ⚠️ Fallback | NoneType division error |
|
| Scoring | ⚠️ Fallback | NoneType division error |
|
||||||
| Psychotype | ✅ Работает | Определение + модификаторы |
|
| Psychotype | ✅ Работает | Определение + модификаторы |
|
||||||
| Claude | ⚠️ Fallback | 403 Forbidden (нет API ключа) |
|
| Rules | ✅ | локальный расчёт |
|
||||||
|
|
||||||
## Fallback механизмы
|
## Fallback механизмы
|
||||||
|
|
||||||
Если сервис недоступен, система использует fallback:
|
Если сервис недоступен, система использует fallback:
|
||||||
- **Geo/Scoring fail** → создаются базовые 8 секторов
|
- **Geo/Scoring fail** → создаются базовые 8 секторов
|
||||||
- **Claude fail** → базовые рекомендации из scoring service
|
- Правила всегда доступны: LLM в пайплайне нет (ПД не покидают контур)
|
||||||
- **Psychotype fail** → анализ без модификаторов психотипа
|
- **Psychotype fail** → анализ без модификаторов психотипа
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
## Известные проблемы
|
## Известные проблемы
|
||||||
|
|
||||||
1. **Scoring service**: Ошибка деления на None - нужно добавить проверки на None в scoring_service.py
|
1. **Scoring service**: Ошибка деления на None - нужно добавить проверки на None в scoring_service.py
|
||||||
2. **Claude API**: Требуется настройка ANTHROPIC_API_KEY в .env
|
2. **LLM не используется**: API-ключи не нужны, всё локально
|
||||||
3. **Geo service**: Возвращает пустые зоны (возможно, проблема с Overpass API или кешем)
|
3. **Geo service**: Возвращает пустые зоны (возможно, проблема с Overpass API или кешем)
|
||||||
|
|
||||||
## Дата создания
|
## Дата создания
|
||||||
|
|||||||
@@ -63,11 +63,14 @@ docker compose ps
|
|||||||
Переменные окружения находятся в файле `/root/vector/.env`
|
Переменные окружения находятся в файле `/root/vector/.env`
|
||||||
|
|
||||||
Важные переменные:
|
Важные переменные:
|
||||||
- `ANTHROPIC_API_KEY` - API ключ для Claude (требуется для функции анализа)
|
|
||||||
- `JWT_SECRET` - секрет для JWT токенов
|
- `JWT_SECRET` - секрет для JWT токенов
|
||||||
- `DATABASE_URL` - строка подключения к PostgreSQL
|
- `DATABASE_URL` - строка подключения к PostgreSQL
|
||||||
- `CORS_ORIGINS` - разрешенные origins для CORS
|
- `CORS_ORIGINS` - разрешенные origins для CORS
|
||||||
|
|
||||||
|
Примечание: внешний ИИ (Claude/Anthropic) из проекта УБРАН — анализ
|
||||||
|
полностью локальный и детерминированный (services/rules_analysis.py).
|
||||||
|
API-ключи LLM не нужны.
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -101,12 +104,10 @@ ssh root@192.168.0.65 "pct exec 108 -- bash"
|
|||||||
|
|
||||||
## Следующие шаги
|
## Следующие шаги
|
||||||
|
|
||||||
1. Добавить ANTHROPIC_API_KEY в .env файл
|
1. Настроить базу данных (миграции Alembic)
|
||||||
2. Настроить базу данных (миграции Alembic)
|
2. Создать модели SQLAlchemy
|
||||||
3. Создать модели SQLAlchemy
|
3. Реализовать аутентификацию с JWT
|
||||||
4. Реализовать аутентификацию с JWT
|
4. Разработать React компоненты для frontend
|
||||||
5. Интегрировать Claude API для анализа документов
|
|
||||||
6. Разработать React компоненты для frontend
|
|
||||||
|
|
||||||
## Проверка работоспособности
|
## Проверка работоспособности
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Исправления ошибок - Итоговый отчет
|
# Исправления ошибок - Итоговый отчет
|
||||||
|
|
||||||
|
> Исторический документ (2026-05-05). Упоминания Claude Service относятся
|
||||||
|
> к удалённому с тех пор LLM-слою; текущий анализ — rules_analysis (локально).
|
||||||
|
|
||||||
## Дата: 2026-05-05
|
## Дата: 2026-05-05
|
||||||
|
|
||||||
### Исправленные проблемы:
|
### Исправленные проблемы:
|
||||||
|
|||||||
+45
-159
@@ -2,182 +2,68 @@
|
|||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
Созданы два основных сервиса для анализа поисково-спасательных операций:
|
Анализ поисково-спасательных операций — **полностью локальный и
|
||||||
|
детерминированный** (внешний ИИ убран: ПД-данные детей не покидают контур).
|
||||||
|
|
||||||
### 1. Claude Service (`backend/services/claude_service.py`)
|
### Состав
|
||||||
|
|
||||||
**Функция:** `analyze_case(case_data: dict) -> AnalysisResult`
|
| Модуль | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `services/search_engine.py` | Чистый движок: SearchInput → build_search_model → SearchModel (B14) |
|
||||||
|
| `services/rules_analysis.py` | Правила: urgency, зоны, прогноз поведения, действия (замена LLM) |
|
||||||
|
| `services/geo_service.py` | Зоны через Overpass (8 направлений × 4 дистанции) |
|
||||||
|
| `services/scoring_service.py` | WeightedScorer, поведенческие профили (§8) |
|
||||||
|
| `services/distance_service.py` | Формула Экстремум: макс. дистанция + коэффициенты |
|
||||||
|
| `services/psychotype_service.py` | Детекция психотипа, модификаторы полос |
|
||||||
|
| `services/recommendation_service.py` | Стат-рекомендации по истории (B3) |
|
||||||
|
|
||||||
Использует Claude API (модель `claude-sonnet-4-20250514`) для интеллектуального анализа случаев пропажи людей.
|
### Поток анализа
|
||||||
|
|
||||||
**Входные данные (case_data):**
|
```
|
||||||
- `age` - возраст пропавшего
|
analyze_case (роутер, тонкая обёртка)
|
||||||
- `gender` - пол
|
└─ build_search_model(SearchInput, analyzer=rules_analyze)
|
||||||
- `terrain` - тип местности
|
├─ derive_profiles / derive_time_of_day (деривации)
|
||||||
- `weather` - погодные условия
|
├─ detect_psychotype (если ответы есть)
|
||||||
- `time_missing` - время пропажи
|
├─ calculate_max_distance (формула Экстремум)
|
||||||
- `last_location` - последнее известное местоположение
|
├─ rules_analyze (urgency/зоны/действия по правилам)
|
||||||
- `circumstances` - особые обстоятельства
|
│ └─ build_search_zones + rank_zones (если есть координаты)
|
||||||
- `physical_condition` - физическое состояние
|
└─ WeightedScorer (веса, профили)
|
||||||
- `experience` - опыт нахождения на природе
|
|
||||||
|
|
||||||
**Возвращаемые данные (AnalysisResult):**
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"urgency": "критическая|высокая|средняя|низкая",
|
|
||||||
"primary_zones": [
|
|
||||||
{
|
|
||||||
"priority": 1,
|
|
||||||
"name": "Зона А",
|
|
||||||
"direction": "север",
|
|
||||||
"distance": 2.5,
|
|
||||||
"reason": "Обоснование выбора"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"search_radius_km": 5.0,
|
|
||||||
"key_locations": ["водоемы", "дороги", "постройки"],
|
|
||||||
"team_assignments": {
|
|
||||||
"команда_1": "зона А",
|
|
||||||
"команда_2": "зона Б"
|
|
||||||
},
|
|
||||||
"behavioral_prediction": "Прогноз поведения",
|
|
||||||
"immediate_actions": ["Действие 1", "Действие 2"],
|
|
||||||
"summary": "Краткое резюме"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Stats Service (`backend/services/stats_service.py`)
|
## API
|
||||||
|
|
||||||
**Функция:** `get_statistical_recommendation(case_data: dict, db_session: AsyncSession) -> StatsResult`
|
### POST `/api/v1/analyze`
|
||||||
|
Анализ случая (поле `case_id` опционально — без него анализ «на лету»).
|
||||||
Анализирует исторические данные из базы для получения статистических рекомендаций.
|
|
||||||
|
|
||||||
**Входные данные:**
|
|
||||||
- `age` - возраст (±2 года для фильтрации)
|
|
||||||
- `season` - сезон (winter, spring, summer, autumn)
|
|
||||||
- `terrain_primary` - основной тип местности
|
|
||||||
|
|
||||||
**Логика фильтрации:**
|
|
||||||
1. Первая попытка: возраст ±2 года + сезон + terrain
|
|
||||||
2. Если sample_size < 5: возраст ±2 года + сезон (без terrain)
|
|
||||||
3. Если данных нет: возвращает дефолтные значения
|
|
||||||
|
|
||||||
**Возвращаемые данные (StatsResult):**
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"median_distance_km": 2.5,
|
|
||||||
"top_directions": [
|
|
||||||
{
|
|
||||||
"direction": "север",
|
|
||||||
"count": 15,
|
|
||||||
"percentage": 35.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"top_location_types": ["водоемы", "дороги", "лесные тропы"],
|
|
||||||
"survival_rate": 87.5,
|
|
||||||
"sample_size": 43
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### POST `/api/v1/analyze/text`
|
|
||||||
|
|
||||||
Анализ случая с помощью Claude AI.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"age": 65,
|
|
||||||
"gender": "мужской",
|
|
||||||
"terrain": "лес",
|
|
||||||
"weather": "дождь",
|
|
||||||
"time_missing": "6 часов",
|
|
||||||
"last_location": "лесная тропа у озера",
|
|
||||||
"circumstances": "вышел за грибами",
|
|
||||||
"physical_condition": "хорошее",
|
|
||||||
"experience": "опытный грибник"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `AnalysisResult`
|
|
||||||
|
|
||||||
### POST `/api/v1/analyze/combined`
|
### POST `/api/v1/analyze/combined`
|
||||||
|
Синоним `/analyze` (оставлен для совместимости).
|
||||||
|
|
||||||
Комбинированный анализ: Claude AI + статистические данные.
|
### GET `/api/v1/analyze/{case_id}`
|
||||||
|
Последний сохранённый результат анализа кейса.
|
||||||
|
|
||||||
**Request:** Тот же формат, что и `/text`
|
Ответ `/analyze` (сокращённо):
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"claude_analysis": { /* AnalysisResult */ },
|
"max_distance_km": 5.4,
|
||||||
"statistical_data": { /* StatsResult */ }
|
"coefficients": {"base_speed": 1.2, "terrain": 0.5, "urgency": 5.0, ...},
|
||||||
|
"urgency": "высокая",
|
||||||
|
"primary_zones": [{"priority": 1, "name": "Зона N 0.5км", ...}],
|
||||||
|
"search_radius_km": 5.0,
|
||||||
|
"key_locations": ["водоёмы", ...],
|
||||||
|
"behavioral_prediction": "...",
|
||||||
|
"immediate_actions": ["..."],
|
||||||
|
"active_profiles": [{"name": "велосипед", ...}],
|
||||||
|
"fallback_used": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
`fallback_used: true` означает «локальный расчёт» (единственный режим).
|
||||||
### POST `/api/v1/analyze/document`
|
|
||||||
|
|
||||||
Анализ загруженного документа (пока поддерживается только .txt).
|
|
||||||
|
|
||||||
**Request:** multipart/form-data с файлом
|
|
||||||
|
|
||||||
**Response:** `AnalysisResult`
|
|
||||||
|
|
||||||
## Настройка
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
В файле `.env` должен быть установлен:
|
|
||||||
```
|
|
||||||
ANTHROPIC_API_KEY=your_api_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
### База данных
|
|
||||||
|
|
||||||
Для работы `stats_service` требуется таблица `cases` со следующими полями:
|
|
||||||
- `age` (integer)
|
|
||||||
- `season` (string)
|
|
||||||
- `terrain_primary` (string)
|
|
||||||
- `found_distance_km` (float)
|
|
||||||
- `found_direction` (string)
|
|
||||||
- `found_location_type` (string)
|
|
||||||
- `outcome` (string: 'alive', 'found_alive', etc.)
|
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
### Тест Claude Service
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/text \
|
curl -X POST http://192.168.0.99:8000/api/v1/analyze \
|
||||||
-H "Content-Type: application/json" \
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"age": 8, "gender": "м", "terrain": ["лес"], "elapsed_hours": 2.0,
|
||||||
"age": 65,
|
"has_transport": "bike", "season": "лето"}'
|
||||||
"gender": "мужской",
|
# max_distance_km == 5.4 (регресс B14)
|
||||||
"terrain": "лес",
|
|
||||||
"weather": "дождь",
|
|
||||||
"time_missing": "6 часов",
|
|
||||||
"last_location": "лесная тропа у озера"
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Тест Combined Analysis
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/combined \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"age": 65,
|
|
||||||
"season": "autumn",
|
|
||||||
"terrain_primary": "forest"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- [ ] Настроить подключение к базе данных
|
|
||||||
- [ ] Добавить миграции Alembic для таблицы cases
|
|
||||||
- [ ] Реализовать извлечение текста из .docx и .pdf
|
|
||||||
- [ ] Добавить кэширование результатов анализа
|
|
||||||
- [ ] Добавить rate limiting для API запросов
|
|
||||||
- [ ] Добавить логирование запросов и ответов
|
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from backend.database import db
|
from backend.database import db
|
||||||
from backend.routers.auth import require_roles
|
from backend.routers.auth import require_roles
|
||||||
from services.claude_service import analyze_case as claude_analyze
|
from services.rules_analysis import rules_analyze
|
||||||
from services.search_engine import (
|
from services.search_engine import (
|
||||||
SearchInput,
|
SearchInput,
|
||||||
build_search_model,
|
build_search_model,
|
||||||
derive_profiles,
|
|
||||||
derive_time_of_day,
|
|
||||||
unmodeled_profiles,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
|
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
|
||||||
@@ -98,7 +95,7 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
|||||||
time_of_day=case_data.get('time_of_day'),
|
time_of_day=case_data.get('time_of_day'),
|
||||||
)
|
)
|
||||||
|
|
||||||
model = await build_search_model(search_input, analyzer=claude_analyze)
|
model = await build_search_model(search_input, analyzer=rules_analyze)
|
||||||
result = model.model_dump()
|
result = model.model_dump()
|
||||||
|
|
||||||
result['case_id'] = str(payload.case_id) if payload.case_id else None
|
result['case_id'] = str(payload.case_id) if payload.case_id else None
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
поисков. LLM не используется: скоринг полностью детерминированный
|
поисков. LLM не используется: скоринг полностью детерминированный
|
||||||
(WeightedScorer + distance_service + психотип).
|
(WeightedScorer + distance_service + психотип).
|
||||||
|
|
||||||
Прогон модели = тот же расчёт, что /analyze, но БЕЗ claude_service:
|
Прогон модели = тот же расчёт, что /analyze, но без LLM-анализатора:
|
||||||
urgency/immediate_actions/behavioral_prediction вычисляются по правилам,
|
urgency/immediate_actions/behavioral_prediction вычисляются по правилам,
|
||||||
зоны считаются WeightedScorer-ом по фактическим данным кейса.
|
зоны считаются WeightedScorer-ом по фактическим данным кейса.
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ router = APIRouter(
|
|||||||
def _run_model(data: dict[str, Any]) -> dict[str, Any]:
|
def _run_model(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Детерминированный прогон матмодели по данным завершённого поиска.
|
"""Детерминированный прогон матмодели по данным завершённого поиска.
|
||||||
|
|
||||||
Повторяет логику analyze.analyze_case, но БЕЗ вызова claude_service.
|
Повторяет логику analyze.analyze_case, но без LLM-анализатора:
|
||||||
urgency/behavioral_prediction/immediate_actions вычисляются правилами.
|
urgency/behavioral_prediction/immediate_actions вычисляются правилами.
|
||||||
"""
|
"""
|
||||||
# Психотип из ответов или явного указания
|
# Психотип из ответов или явного указания
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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 == "высокая"
|
|
||||||
@@ -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()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""B14: юнит-тесты чистого движка services/search_engine.py.
|
"""B14: юнит-тесты чистого движка services/search_engine.py.
|
||||||
|
|
||||||
claude_analyze мокается (движок тестируем изолированно, без httpx/ANTHROPIC).
|
Анализатор мокается (движок тестируем изолированно, без geo/httpx).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from services.claude_service import AnalysisResult, PrimaryZone
|
from services.rules_analysis import AnalysisResult, PrimaryZone
|
||||||
from services.search_engine import (
|
from services.search_engine import (
|
||||||
SearchInput,
|
SearchInput,
|
||||||
build_search_model,
|
build_search_model,
|
||||||
@@ -70,7 +70,7 @@ async def test_profiles_derived_from_diagnosis_and_transport():
|
|||||||
)
|
)
|
||||||
assert 'РАС' in [p['name'] for p in model.active_profiles]
|
assert 'РАС' in [p['name'] for p in model.active_profiles]
|
||||||
assert model.critical_warnings == [] or isinstance(model.critical_warnings, list)
|
assert model.critical_warnings == [] or isinstance(model.critical_warnings, list)
|
||||||
# Профили дошли и до claude (case_data), и в скорер
|
# Профили дошли и до анализатора (case_data), и в скорер
|
||||||
assert 'РАС' in analyzer.captured['profiles']
|
assert 'РАС' in analyzer.captured['profiles']
|
||||||
assert 'велосипед' in analyzer.captured['profiles']
|
assert 'велосипед' in analyzer.captured['profiles']
|
||||||
assert 'не_умеет_плавать' in analyzer.captured['profiles']
|
assert 'не_умеет_плавать' in analyzer.captured['profiles']
|
||||||
|
|||||||
+1
-2
@@ -34,8 +34,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/vector_mchs}
|
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:***@postgres:5432/vector_mchs}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||||
- PYTHONPATH=/app
|
- PYTHONPATH=/app
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ const AnalysisResult = () => {
|
|||||||
{analysisData && (
|
{analysisData && (
|
||||||
<>
|
<>
|
||||||
<span className="separator">•</span>
|
<span className="separator">•</span>
|
||||||
<span style={{ color: analysisData.fallback_used ? 'var(--text-secondary)' : 'var(--success)' }}>
|
<span style={{ color: 'var(--success)' }}>
|
||||||
{analysisData.fallback_used ? 'Локальный расчёт' : 'ИИ-анализ'}
|
Локальный расчёт
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
"""
|
|
||||||
Claude AI service for case analysis with fallback to scoring service.
|
|
||||||
Integrates geo_service zones and scoring_service rankings.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from .geo_service import build_search_zones
|
|
||||||
from .scoring_service import WeightedScorer
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PrimaryZone(BaseModel):
|
|
||||||
priority: int
|
|
||||||
name: str
|
|
||||||
direction: str
|
|
||||||
distance: float
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
class AnalysisResult(BaseModel):
|
|
||||||
urgency: str # "критическая", "высокая", "средняя", "низкая"
|
|
||||||
primary_zones: List[PrimaryZone]
|
|
||||||
search_radius_km: float
|
|
||||||
key_locations: List[str]
|
|
||||||
behavioral_prediction: str
|
|
||||||
immediate_actions: List[str]
|
|
||||||
summary: str
|
|
||||||
fallback_used: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
async def analyze_case(case_data: dict) -> AnalysisResult:
|
|
||||||
"""
|
|
||||||
Анализирует данные случая с помощью Claude API с fallback на scoring_service.
|
|
||||||
|
|
||||||
Интегрирует:
|
|
||||||
- geo_service: построение зон поиска
|
|
||||||
- scoring_service: оценка и ранжирование зон
|
|
||||||
- Claude API: интеллектуальный анализ (если доступен)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
case_data: Словарь с данными случая
|
|
||||||
- age: возраст
|
|
||||||
- gender: пол
|
|
||||||
- terrain: местность
|
|
||||||
- weather: погода
|
|
||||||
- time_missing: время пропажи
|
|
||||||
- last_location: последнее местоположение
|
|
||||||
- lat, lon: координаты (опционально)
|
|
||||||
- profiles: поведенческие профили (опционально)
|
|
||||||
- season: сезон (опционально)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AnalysisResult: Структурированный результат анализа
|
|
||||||
"""
|
|
||||||
# Попытка использовать Claude API
|
|
||||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
|
||||||
|
|
||||||
if api_key:
|
|
||||||
try:
|
|
||||||
return await analyze_with_claude(case_data, api_key)
|
|
||||||
except Exception as e:
|
|
||||||
# Логируем ошибку и переходим на fallback
|
|
||||||
logger.warning(f"Claude API unavailable: {e}. Using fallback scoring service.")
|
|
||||||
|
|
||||||
# Fallback: используем только scoring_service
|
|
||||||
return await analyze_with_fallback(case_data)
|
|
||||||
|
|
||||||
|
|
||||||
async def analyze_with_claude(case_data: dict, api_key: str) -> AnalysisResult:
|
|
||||||
"""
|
|
||||||
Анализ с помощью Claude API с интеграцией geo и scoring сервисов.
|
|
||||||
"""
|
|
||||||
# Построить зоны поиска если есть координаты
|
|
||||||
zones_data = ""
|
|
||||||
if case_data.get('lat') and case_data.get('lon'):
|
|
||||||
try:
|
|
||||||
zones = await build_search_zones(
|
|
||||||
case_data['lat'],
|
|
||||||
case_data['lon'],
|
|
||||||
case_data
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ранжировать зоны
|
|
||||||
scorer = WeightedScorer()
|
|
||||||
zones_dict = [
|
|
||||||
{
|
|
||||||
'direction': z.direction,
|
|
||||||
'distance_km': z.distance_km,
|
|
||||||
'forest_pct': z.forest_pct,
|
|
||||||
'road_density': z.road_density,
|
|
||||||
'water_distance_km': z.water_distance_km,
|
|
||||||
'settlement_distance_km': z.settlement_distance_km
|
|
||||||
}
|
|
||||||
for z in zones
|
|
||||||
]
|
|
||||||
|
|
||||||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
|
||||||
|
|
||||||
# Топ-5 зон для промпта
|
|
||||||
top_zones = ranked_zones[:5]
|
|
||||||
zones_data = "\n\nТОП-5 ПРИОРИТЕТНЫХ ЗОН (по scoring_service):\n"
|
|
||||||
for zone in top_zones:
|
|
||||||
zones_data += f"- {zone['direction']} направление, {zone['distance_km']}км: "
|
|
||||||
zones_data += f"оценка {zone['score']}/100, "
|
|
||||||
zones_data += f"лес {zone['forest_pct']}%, "
|
|
||||||
zones_data += f"дороги {zone['road_density']} км/км²\n"
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Geo/scoring service error: {e}")
|
|
||||||
|
|
||||||
# Формируем промпт на русском языке
|
|
||||||
prompt = f"""Ты — эксперт по поисково-спасательным операциям (ПСО) МЧС Республики Беларусь. Проанализируй следующий случай пропажи человека и дай структурированные рекомендации.
|
|
||||||
|
|
||||||
ДАННЫЕ СЛУЧАЯ:
|
|
||||||
- Возраст пропавшего: {case_data.get('age', 'не указан')} лет
|
|
||||||
- Пол: {case_data.get('gender', 'не указан')}
|
|
||||||
- Местность: {case_data.get('terrain', 'не указана')}
|
|
||||||
- Погодные условия: {case_data.get('weather', 'не указаны')}
|
|
||||||
- Время пропажи: {case_data.get('time_missing', 'не указано')}
|
|
||||||
- Последнее известное местоположение: {case_data.get('last_location', 'не указано')}
|
|
||||||
- Особые обстоятельства: {case_data.get('circumstances', 'нет')}
|
|
||||||
- Физическое состояние: {case_data.get('physical_condition', 'не указано')}
|
|
||||||
- Опыт нахождения на природе: {case_data.get('experience', 'не указан')}
|
|
||||||
- Поведенческие профили: {', '.join(case_data.get('profiles', [])) if case_data.get('profiles') else 'нет'}
|
|
||||||
- Сезон: {case_data.get('season', 'не указан')}{zones_data}
|
|
||||||
|
|
||||||
ЗАДАЧА:
|
|
||||||
Предоставь детальный анализ в формате JSON со следующими полями:
|
|
||||||
|
|
||||||
1. urgency: Уровень срочности ("критическая", "высокая", "средняя", "низкая")
|
|
||||||
2. primary_zones: Массив из 3-5 приоритетных зон поиска, каждая с полями:
|
|
||||||
- priority: номер приоритета (1 = самый высокий)
|
|
||||||
- name: название зоны (например "Ближний лес", "Водоём на севере")
|
|
||||||
- direction: направление от последней точки (N, NE, E, SE, S, SW, W, NW)
|
|
||||||
- distance: расстояние в км
|
|
||||||
- reason: обоснование выбора этой зоны
|
|
||||||
3. search_radius_km: Рекомендуемый радиус поиска в километрах
|
|
||||||
4. key_locations: Массив ключевых типов локаций для проверки (водоемы, дороги, постройки и т.д.)
|
|
||||||
5. behavioral_prediction: Прогноз поведения пропавшего на основе возраста и обстоятельств
|
|
||||||
6. immediate_actions: Массив немедленных действий, которые нужно предпринять
|
|
||||||
7. summary: Краткое резюме анализа (2-3 предложения)
|
|
||||||
|
|
||||||
ВАЖНО: Учитывай данные из scoring_service при формировании primary_zones. Отвечай ТОЛЬКО валидным JSON без дополнительного текста."""
|
|
||||||
|
|
||||||
# Вызываем Anthropic API
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
response = await client.post(
|
|
||||||
"https://api.anthropic.com/v1/messages",
|
|
||||||
headers={
|
|
||||||
"x-api-key": api_key,
|
|
||||||
"anthropic-version": "2023-06-01",
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
"model": "claude-sonnet-5",
|
|
||||||
"max_tokens": 4096,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": prompt
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise Exception(f"Anthropic API error: {response.status_code} - {response.text}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = response.json()
|
|
||||||
content = result["content"][0]["text"]
|
|
||||||
analysis_data = _extract_json_payload(content)
|
|
||||||
analysis_data['fallback_used'] = False
|
|
||||||
return AnalysisResult(**analysis_data)
|
|
||||||
except Exception as e:
|
|
||||||
# Ответ модели пришёл в неожидаемом виде — не роняем анализ,
|
|
||||||
# а отдаём детерминированный результат scoring_service.
|
|
||||||
logger.warning(
|
|
||||||
f"Не удалось разобрать ответ Claude ({type(e).__name__}: {e}). "
|
|
||||||
"Используется fallback scoring service."
|
|
||||||
)
|
|
||||||
return await analyze_with_fallback(case_data)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_payload(content: str) -> dict:
|
|
||||||
"""Extract a JSON object from a model response.
|
|
||||||
|
|
||||||
Tolerates a ```json fence, a bare ``` fence, or raw JSON with
|
|
||||||
surrounding prose. Raises ValueError if nothing parseable is found.
|
|
||||||
"""
|
|
||||||
candidates = []
|
|
||||||
stripped = (content or '').strip()
|
|
||||||
|
|
||||||
if '```' in stripped:
|
|
||||||
for marker in ('```json', '```JSON', '```'):
|
|
||||||
if marker in stripped:
|
|
||||||
after = stripped.split(marker, 1)[1]
|
|
||||||
candidates.append(after.split('```', 1)[0].strip())
|
|
||||||
break
|
|
||||||
|
|
||||||
candidates.append(stripped)
|
|
||||||
|
|
||||||
# Last resort: the widest {...} span in the text.
|
|
||||||
start, end = stripped.find('{'), stripped.rfind('}')
|
|
||||||
if start != -1 and end > start:
|
|
||||||
candidates.append(stripped[start:end + 1])
|
|
||||||
|
|
||||||
for candidate in candidates:
|
|
||||||
if not candidate:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
parsed = json.loads(candidate)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
continue
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
raise ValueError('Не удалось извлечь JSON из ответа модели')
|
|
||||||
|
|
||||||
|
|
||||||
async def analyze_with_fallback(case_data: dict) -> AnalysisResult:
|
|
||||||
"""
|
|
||||||
Fallback анализ используя только scoring_service без Claude API.
|
|
||||||
"""
|
|
||||||
# Определяем срочность на основе возраста и профилей
|
|
||||||
age = case_data.get('age', 10)
|
|
||||||
profiles = case_data.get('profiles', [])
|
|
||||||
|
|
||||||
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
|
|
||||||
urgency = "критическая"
|
|
||||||
elif age <= 7 or 'велосипед' in profiles:
|
|
||||||
urgency = "высокая"
|
|
||||||
elif age <= 12:
|
|
||||||
urgency = "средняя"
|
|
||||||
else:
|
|
||||||
urgency = "средняя"
|
|
||||||
|
|
||||||
# Построить зоны если есть координаты
|
|
||||||
primary_zones = []
|
|
||||||
search_radius_km = 5.0
|
|
||||||
|
|
||||||
if case_data.get('lat') and case_data.get('lon'):
|
|
||||||
try:
|
|
||||||
zones = await build_search_zones(
|
|
||||||
case_data['lat'],
|
|
||||||
case_data['lon'],
|
|
||||||
case_data
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ранжировать зоны
|
|
||||||
scorer = WeightedScorer()
|
|
||||||
zones_dict = [
|
|
||||||
{
|
|
||||||
'direction': z.direction,
|
|
||||||
'distance_km': z.distance_km,
|
|
||||||
'forest_pct': z.forest_pct,
|
|
||||||
'road_density': z.road_density,
|
|
||||||
'water_distance_km': z.water_distance_km,
|
|
||||||
'settlement_distance_km': z.settlement_distance_km
|
|
||||||
}
|
|
||||||
for z in zones
|
|
||||||
]
|
|
||||||
|
|
||||||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
|
||||||
|
|
||||||
# Топ-5 зон
|
|
||||||
for i, zone in enumerate(ranked_zones[:5]):
|
|
||||||
primary_zones.append(PrimaryZone(
|
|
||||||
priority=i + 1,
|
|
||||||
name=f"Зона {zone['direction']} {zone['distance_km']}км",
|
|
||||||
direction=zone['direction'],
|
|
||||||
distance=zone['distance_km'],
|
|
||||||
reason=f"Оценка {zone['score']}/100 по scoring_service"
|
|
||||||
))
|
|
||||||
|
|
||||||
# Радиус на основе дистанции
|
|
||||||
search_radius_km = scorer.distance_multiplier * 2.0
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Geo/scoring service error in fallback: {e}")
|
|
||||||
|
|
||||||
# Если зоны не построены, используем базовые
|
|
||||||
if not primary_zones:
|
|
||||||
primary_zones = [
|
|
||||||
PrimaryZone(
|
|
||||||
priority=1,
|
|
||||||
name="Ближняя зона",
|
|
||||||
direction="N",
|
|
||||||
distance=0.5,
|
|
||||||
reason="Базовая зона поиска"
|
|
||||||
),
|
|
||||||
PrimaryZone(
|
|
||||||
priority=2,
|
|
||||||
name="Средняя зона",
|
|
||||||
direction="E",
|
|
||||||
distance=1.0,
|
|
||||||
reason="Расширенная зона поиска"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Ключевые локации на основе возраста
|
|
||||||
if age <= 7:
|
|
||||||
key_locations = ["водоёмы", "укрытия", "густая растительность", "ближайшие постройки"]
|
|
||||||
elif age <= 12:
|
|
||||||
key_locations = ["водоёмы", "дороги", "тропы", "лесные массивы"]
|
|
||||||
else:
|
|
||||||
key_locations = ["дороги", "населённые пункты", "транспортные узлы", "водоёмы"]
|
|
||||||
|
|
||||||
# Поведенческий прогноз
|
|
||||||
if 'РАС' in profiles:
|
|
||||||
behavioral_prediction = "Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя."
|
|
||||||
elif age <= 4:
|
|
||||||
behavioral_prediction = "Минимальное движение, вероятно находится близко к точке потери."
|
|
||||||
elif age <= 12:
|
|
||||||
behavioral_prediction = "Умеренное движение, может следовать по тропам или дорогам."
|
|
||||||
else:
|
|
||||||
behavioral_prediction = "Целенаправленное движение, возможен выход к населённым пунктам."
|
|
||||||
|
|
||||||
# Немедленные действия
|
|
||||||
immediate_actions = [
|
|
||||||
"Организовать поисковые группы",
|
|
||||||
"Проверить ближайшие водоёмы",
|
|
||||||
"Опросить свидетелей в районе последнего местоположения"
|
|
||||||
]
|
|
||||||
|
|
||||||
if 'РАС' in profiles:
|
|
||||||
immediate_actions.insert(0, "КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км")
|
|
||||||
|
|
||||||
if 'велосипед' in profiles:
|
|
||||||
immediate_actions.insert(0, "Расширить зону поиска до 10-15 км, проверить дорожные камеры")
|
|
||||||
|
|
||||||
summary = f"Случай классифицирован как {urgency} срочность. "
|
|
||||||
summary += f"Рекомендуемый радиус поиска: {search_radius_km} км. "
|
|
||||||
summary += f"Приоритет: {key_locations[0]}."
|
|
||||||
|
|
||||||
return AnalysisResult(
|
|
||||||
urgency=urgency,
|
|
||||||
primary_zones=primary_zones,
|
|
||||||
search_radius_km=search_radius_km,
|
|
||||||
key_locations=key_locations,
|
|
||||||
behavioral_prediction=behavioral_prediction,
|
|
||||||
immediate_actions=immediate_actions,
|
|
||||||
summary=summary,
|
|
||||||
fallback_used=True
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Детерминированный rules-анализ (замена Claude API, решение Виктора:
|
||||||
|
ИИ из проекта убран — карточки детей не покидают контур).
|
||||||
|
|
||||||
|
Логика — бывший analyze_with_fallback из claude_service (правила по возрасту,
|
||||||
|
профилям, координатам) + urgency по правилам closed_cases. Никаких внешних
|
||||||
|
вызовов: только geo_service (Overpass) + scoring_service + справочники.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from services.geo_service import build_search_zones
|
||||||
|
from services.scoring_service import WeightedScorer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PrimaryZone(BaseModel):
|
||||||
|
priority: int
|
||||||
|
name: str
|
||||||
|
direction: str
|
||||||
|
distance: float
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisResult(BaseModel):
|
||||||
|
urgency: str # "критическая", "высокая", "средняя", "низкая"
|
||||||
|
primary_zones: list[PrimaryZone]
|
||||||
|
search_radius_km: float
|
||||||
|
key_locations: list[str]
|
||||||
|
behavioral_prediction: str
|
||||||
|
immediate_actions: list[str]
|
||||||
|
summary: str
|
||||||
|
fallback_used: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
def rule_based_urgency(elapsed: float, age: int, diagnosis: list[str]) -> str:
|
||||||
|
"""urgency по правилам: чем меньше ребёнок и дольше поиск — тем выше срочность."""
|
||||||
|
score = 0
|
||||||
|
if elapsed >= 24:
|
||||||
|
score += 2
|
||||||
|
elif elapsed >= 8:
|
||||||
|
score += 1
|
||||||
|
if age <= 7:
|
||||||
|
score += 2
|
||||||
|
elif age <= 12:
|
||||||
|
score += 1
|
||||||
|
if diagnosis:
|
||||||
|
score += 1
|
||||||
|
if score >= 4:
|
||||||
|
return 'критическая'
|
||||||
|
if score >= 2:
|
||||||
|
return 'высокая'
|
||||||
|
return 'средняя'
|
||||||
|
|
||||||
|
|
||||||
|
def _age_urgency(age: int, profiles: list[str]) -> str:
|
||||||
|
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
|
||||||
|
return 'критическая'
|
||||||
|
if age <= 7 or 'велосипед' in profiles:
|
||||||
|
return 'высокая'
|
||||||
|
return 'средняя'
|
||||||
|
|
||||||
|
|
||||||
|
def _key_locations(age: int) -> list[str]:
|
||||||
|
if age <= 7:
|
||||||
|
return ['водоёмы', 'укрытия', 'густая растительность', 'ближайшие постройки']
|
||||||
|
if age <= 12:
|
||||||
|
return ['водоёмы', 'дороги', 'тропы', 'лесные массивы']
|
||||||
|
return ['дороги', 'населённые пункты', 'транспортные узлы', 'водоёмы']
|
||||||
|
|
||||||
|
|
||||||
|
def _behavioral_prediction(age: int, profiles: list[str]) -> str:
|
||||||
|
if 'РАС' in profiles:
|
||||||
|
return 'Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя.'
|
||||||
|
if age <= 4:
|
||||||
|
return 'Минимальное движение, вероятно находится близко к точке потери.'
|
||||||
|
if age <= 12:
|
||||||
|
return 'Умеренное движение, может следовать по тропам или дорогам.'
|
||||||
|
return 'Целенаправленное движение, возможен выход к населённым пунктам.'
|
||||||
|
|
||||||
|
|
||||||
|
def _immediate_actions(profiles: list[str]) -> list[str]:
|
||||||
|
actions = [
|
||||||
|
'Организовать поисковые группы',
|
||||||
|
'Проверить ближайшие водоёмы',
|
||||||
|
'Опросить свидетелей в районе последнего местоположения',
|
||||||
|
]
|
||||||
|
if 'РАС' in profiles:
|
||||||
|
actions.insert(0, 'КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км')
|
||||||
|
if 'велосипед' in profiles:
|
||||||
|
actions.insert(0, 'Расширить зону поиска до 10-15 км, проверить дорожные камеры')
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
async def rules_analyze(case_data: dict[str, Any]) -> AnalysisResult:
|
||||||
|
"""Детерминированный анализ: scoring_service + geo_service, без LLM."""
|
||||||
|
age = int(case_data.get('age') or 10)
|
||||||
|
profiles = list(case_data.get('profiles') or [])
|
||||||
|
|
||||||
|
urgency = _age_urgency(age, profiles)
|
||||||
|
|
||||||
|
primary_zones: list[PrimaryZone] = []
|
||||||
|
search_radius_km = 5.0
|
||||||
|
|
||||||
|
if case_data.get('lat') and case_data.get('lon'):
|
||||||
|
try:
|
||||||
|
zones = await build_search_zones(
|
||||||
|
case_data['lat'],
|
||||||
|
case_data['lon'],
|
||||||
|
case_data,
|
||||||
|
)
|
||||||
|
scorer = WeightedScorer()
|
||||||
|
zones_dict = [
|
||||||
|
{
|
||||||
|
'direction': z.direction,
|
||||||
|
'distance_km': z.distance_km,
|
||||||
|
'forest_pct': z.forest_pct,
|
||||||
|
'road_density': z.road_density,
|
||||||
|
'water_distance_km': z.water_distance_km,
|
||||||
|
'settlement_distance_km': z.settlement_distance_km,
|
||||||
|
}
|
||||||
|
for z in zones
|
||||||
|
]
|
||||||
|
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
||||||
|
for i, zone in enumerate(ranked_zones[:5]):
|
||||||
|
primary_zones.append(PrimaryZone(
|
||||||
|
priority=i + 1,
|
||||||
|
name=f"Зона {zone['direction']} {zone['distance_km']}км",
|
||||||
|
direction=zone['direction'],
|
||||||
|
distance=zone['distance_km'],
|
||||||
|
reason=f"Оценка {zone['score']}/100 по scoring_service",
|
||||||
|
))
|
||||||
|
search_radius_km = scorer.distance_multiplier * 2.0
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'Geo/scoring service error: {e}')
|
||||||
|
|
||||||
|
if not primary_zones:
|
||||||
|
primary_zones = [
|
||||||
|
PrimaryZone(priority=1, name='Ближняя зона', direction='N',
|
||||||
|
distance=0.5, reason='Базовая зона поиска'),
|
||||||
|
PrimaryZone(priority=2, name='Средняя зона', direction='E',
|
||||||
|
distance=1.0, reason='Расширенная зона поиска'),
|
||||||
|
]
|
||||||
|
|
||||||
|
key_locations = _key_locations(age)
|
||||||
|
behavioral_prediction = _behavioral_prediction(age, profiles)
|
||||||
|
immediate_actions = _immediate_actions(profiles)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f'Случай классифицирован как {urgency} срочность. '
|
||||||
|
f'Рекомендуемый радиус поиска: {search_radius_km} км. '
|
||||||
|
f'Приоритет: {key_locations[0]}.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return AnalysisResult(
|
||||||
|
urgency=urgency,
|
||||||
|
primary_zones=primary_zones,
|
||||||
|
search_radius_km=search_radius_km,
|
||||||
|
key_locations=key_locations,
|
||||||
|
behavioral_prediction=behavioral_prediction,
|
||||||
|
immediate_actions=immediate_actions,
|
||||||
|
summary=summary,
|
||||||
|
fallback_used=True,
|
||||||
|
)
|
||||||
+33
-19
@@ -2,20 +2,20 @@
|
|||||||
|
|
||||||
Граница (vector_tasks.md B13-принцип 1, B14):
|
Граница (vector_tasks.md B13-принцип 1, B14):
|
||||||
- build_search_model(input) -> SearchModel — чистая функция.
|
- build_search_model(input) -> SearchModel — чистая функция.
|
||||||
- Единственная внешняя зависимость — claude_analyze (async, httpx):
|
- Анализатор (нарратив/зоны) инъекцией: рантайм — services.rules_analysis
|
||||||
она уже была «внутри» расчёта в старом analyze_case; изолируется
|
(детерминированные правила, без LLM — карточки детей не покидают контур);
|
||||||
параметром движка, чтобы тесты могли подменять её моком.
|
тесты подменяют моком. Анализатору достаточно вернуть объект с полями
|
||||||
|
urgency/primary_zones/search_radius_km/... (structurally typed).
|
||||||
- Ни одной привязки к полевым транспортам/протоколам (критерий B20:
|
- Ни одной привязки к полевым транспортам/протоколам (критерий B20:
|
||||||
grep по этому файлу не находит соответствующих терминов).
|
grep по этому файлу не находит соответствующих терминов).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable, Protocol
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from services.claude_service import AnalysisResult
|
|
||||||
from services.distance_service import (
|
from services.distance_service import (
|
||||||
calculate_max_distance,
|
calculate_max_distance,
|
||||||
get_base_speed,
|
get_base_speed,
|
||||||
@@ -32,8 +32,22 @@ from services.psychotype_service import (
|
|||||||
)
|
)
|
||||||
from services.scoring_service import WeightedScorer
|
from services.scoring_service import WeightedScorer
|
||||||
|
|
||||||
# Тип анализатора: async (case_data) -> AnalysisResult, как claude_analyze.
|
|
||||||
ClaudeAnalyzer = Callable[[dict[str, Any]], Awaitable[AnalysisResult]]
|
class AnalysisOutcome(Protocol):
|
||||||
|
"""Структурный контракт результата анализатора (duck-typed)."""
|
||||||
|
|
||||||
|
urgency: str
|
||||||
|
primary_zones: list[Any]
|
||||||
|
search_radius_km: float
|
||||||
|
key_locations: list[str]
|
||||||
|
behavioral_prediction: str
|
||||||
|
immediate_actions: list[str]
|
||||||
|
summary: str
|
||||||
|
fallback_used: bool
|
||||||
|
|
||||||
|
|
||||||
|
# Тип анализатора: async (case_data) -> объект с полями AnalysisOutcome.
|
||||||
|
CaseAnalyzer = Callable[[dict[str, Any]], Awaitable[AnalysisOutcome]]
|
||||||
|
|
||||||
# Диагнозы, которые форма собирает, но поведенческая модель не покрывает
|
# Диагнозы, которые форма собирает, но поведенческая модель не покрывает
|
||||||
# (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
|
# (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
|
||||||
@@ -72,7 +86,7 @@ class SearchInput(BaseModel):
|
|||||||
time_of_day: str | None = None
|
time_of_day: str | None = None
|
||||||
|
|
||||||
def to_case_data(self) -> dict[str, Any]:
|
def to_case_data(self) -> dict[str, Any]:
|
||||||
"""Плоский dict для сервисов (claude/scoring) — тот же состав полей,
|
"""Плоский dict для сервисов (анализатор/скоринг) — тот же состав полей,
|
||||||
что раньше уходил из analyze_case."""
|
что раньше уходил из analyze_case."""
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
'age': self.age,
|
'age': self.age,
|
||||||
@@ -209,13 +223,13 @@ def derive_time_of_day(loss_time: Any) -> str:
|
|||||||
async def build_search_model(
|
async def build_search_model(
|
||||||
search_input: SearchInput,
|
search_input: SearchInput,
|
||||||
*,
|
*,
|
||||||
analyzer: ClaudeAnalyzer,
|
analyzer: CaseAnalyzer,
|
||||||
analyzed_at: datetime | None = None,
|
analyzed_at: datetime | None = None,
|
||||||
) -> SearchModel:
|
) -> SearchModel:
|
||||||
"""Чистая функция расчёта поисковой модели (B14).
|
"""Чистая функция расчёта поисковой модели (B14).
|
||||||
|
|
||||||
Вся логика бывшего analyze_case: деривации полей, психотип,
|
Вся логика бывшего analyze_case: деривации полей, психотип,
|
||||||
WeightedScorer, коэффициенты формулы Экстремум, claude_analyze.
|
WeightedScorer, коэффициенты формулы Экстремум, analyzer (rules/мок).
|
||||||
DB / auth / HTTP здесь не нужны; анализатор подменяется параметром.
|
DB / auth / HTTP здесь не нужны; анализатор подменяется параметром.
|
||||||
"""
|
"""
|
||||||
case_data = search_input.to_case_data()
|
case_data = search_input.to_case_data()
|
||||||
@@ -246,7 +260,7 @@ async def build_search_model(
|
|||||||
case_data['psychotype_modifiers'] = psychotype_modifiers
|
case_data['psychotype_modifiers'] = psychotype_modifiers
|
||||||
|
|
||||||
max_distance_km = calculate_max_distance(case_data)
|
max_distance_km = calculate_max_distance(case_data)
|
||||||
claude_result = await analyzer(case_data)
|
analysis = await analyzer(case_data)
|
||||||
|
|
||||||
scorer = WeightedScorer()
|
scorer = WeightedScorer()
|
||||||
if case_data.get('age'):
|
if case_data.get('age'):
|
||||||
@@ -282,12 +296,12 @@ async def build_search_model(
|
|||||||
active_profiles=scorer.get_active_profiles_info(),
|
active_profiles=scorer.get_active_profiles_info(),
|
||||||
critical_warnings=list(scorer.critical_warnings),
|
critical_warnings=list(scorer.critical_warnings),
|
||||||
unmodeled_profiles=unmodeled_profiles(case_data.get('diagnosis_type') or []),
|
unmodeled_profiles=unmodeled_profiles(case_data.get('diagnosis_type') or []),
|
||||||
urgency=claude_result.urgency,
|
urgency=analysis.urgency,
|
||||||
primary_zones=[zone.model_dump() for zone in claude_result.primary_zones],
|
primary_zones=[zone.model_dump() if hasattr(zone, "model_dump") else dict(zone) for zone in analysis.primary_zones],
|
||||||
search_radius_km=claude_result.search_radius_km,
|
search_radius_km=analysis.search_radius_km,
|
||||||
key_locations=claude_result.key_locations,
|
key_locations=analysis.key_locations,
|
||||||
behavioral_prediction=claude_result.behavioral_prediction,
|
behavioral_prediction=analysis.behavioral_prediction,
|
||||||
immediate_actions=claude_result.immediate_actions,
|
immediate_actions=analysis.immediate_actions,
|
||||||
summary=claude_result.summary,
|
summary=analysis.summary,
|
||||||
fallback_used=claude_result.fallback_used,
|
fallback_used=analysis.fallback_used,
|
||||||
)
|
)
|
||||||
@@ -908,6 +908,10 @@ sed -i 's/sar_mchs/vector_mchs/g; s#/root/sar-mchs#/root/vector#g' DEPLOY.md
|
|||||||
## B20 — Контракт обмена Вектор ↔ Контур (граница проектов)
|
## B20 — Контракт обмена Вектор ↔ Контур (граница проектов)
|
||||||
**Тип:** spec / architecture, needs-context. Фундамент развязки (B13-принцип 1): единый версионированный формат данных на границе, без зависимости от ATAK/Meshtastic. От него зависят B17 (ингестия) и B18 (выдача зон) — они реализуются поверх этого контракта.
|
**Тип:** spec / architecture, needs-context. Фундамент развязки (B13-принцип 1): единый версионированный формат данных на границе, без зависимости от ATAK/Meshtastic. От него зависят B17 (ингестия) и B18 (выдача зон) — они реализуются поверх этого контракта.
|
||||||
|
|
||||||
|
> Решение Виктора (2026-09-09): LLM убран из проекта полностью. Zones/
|
||||||
|
> probability для outbound отдаёт детерминированный движок (rules_analysis +
|
||||||
|
> scoring + гео), без Claude/Anthropic. Нарративные поля /analyze — правила.
|
||||||
|
|
||||||
**Что:** Зафиксировать контракт обмена между Вектором и Контуром — два направления, оба версионированные (`schema_version`), протокольно-агностичные. Транспорт (ATAK CoT / Meshtastic / HTTP / websocket) — дело адаптеров Контура, не Вектора; движок работает только с форматом.
|
**Что:** Зафиксировать контракт обмена между Вектором и Контуром — два направления, оба версионированные (`schema_version`), протокольно-агностичные. Транспорт (ATAK CoT / Meshtastic / HTTP / websocket) — дело адаптеров Контура, не Вектора; движок работает только с форматом.
|
||||||
|
|
||||||
**Вектор → Контур (outbound):**
|
**Вектор → Контур (outbound):**
|
||||||
|
|||||||
Reference in New Issue
Block a user