Import Vector lab project

This commit is contained in:
root
2026-06-06 18:31:55 +00:00
commit fcc22063ed
105 changed files with 31639 additions and 0 deletions
+11
View File
@@ -0,0 +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
# 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
+51
View File
@@ -0,0 +1,51 @@
# Environment variables
.env
.env.local
# Python
__pycache__/
*.py[cod]
*.class
*.so
.Python
venv/
env/
ENV/
*.egg-info/
dist/
build/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnp/
.pnp.js
# React
/frontend/build
/frontend/.env.local
/frontend/.env.development.local
/frontend/.env.test.local
/frontend/.env.production.local
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Database
*.db
*.sqlite
postgres_data/
# Logs
*.log
logs/
+107
View File
@@ -0,0 +1,107 @@
# Analyze API - Полный пайплайн анализа
## Endpoints
### POST /api/v1/analyze/analyze
Запустить полный анализ случая.
**Пайплайн:**
1. Distance service - расчет максимальной дистанции
2. Geo service - построение зон поиска (8 направлений)
3. Scoring service - оценка и ранжирование зон
4. Psychotype service - определение психотипа (если есть данные)
5. Claude service - интеллектуальный анализ (опционально)
6. Merge results - объединение результатов
**Request:**
```json
{
"case_id": "e9b1fd0c-b05c-4084-9813-ee91e6432246"
}
```
**Response:**
```json
{
"case_id": "uuid",
"analyzed_at": "2026-05-05T07:34:11.691521",
"max_distance_km": 5.1,
"psychotype": "anxious",
"psychotype_modifiers": {
"zone_0_500": 1.4,
"zone_500_1500": 1.1,
"zone_1500_2500": 0.5,
"zone_2500plus": 0.3,
"movement_model": "stay"
},
"psychotype_recommendations": {
"priority_zones": "Ближняя зона (0-500м) - критически важна",
"search_pattern": "Тщательный осмотр ближайшей территории",
"key_locations": "Укрытия, углубления, густая растительность",
"communication": "Спокойные голосовые сигналы"
},
"zones": [
{
"priority": 1,
"name": "Сектор N",
"direction": "N",
"distance_km": 4.08,
"score": 30.0,
"reasoning": "Базовая оценка (психотип: ×0.3)"
}
],
"urgency": "средняя",
"key_locations": ["водоёмы", "дороги", "тропы"],
"immediate_actions": ["Организовать поисковые группы"],
"behavioral_prediction": "Умеренное движение",
"summary": "Случай классифицирован как средняя срочность",
"execution_time_ms": 21197.7,
"services_used": ["distance", "geo", "psychotype", "claude"]
}
```
### GET /api/v1/analyze/analyze/{case_id}
Получить сохранённый результат анализа.
**Response:**
```json
{
"case_id": "uuid",
"analysis_log": { },
"created_at": "2026-05-05T07:31:56.900783"
}
```
## Статус сервисов
| Сервис | Статус | Примечание |
|--------|--------|------------|
| Distance | ✅ Работает | Расчет дистанции по формуле |
| Geo | ✅ Работает | Построение зон (21 сек) |
| Scoring | ⚠️ Fallback | NoneType division error |
| Psychotype | ✅ Работает | Определение + модификаторы |
| Claude | ⚠️ Fallback | 403 Forbidden (нет API ключа) |
## Fallback механизмы
Если сервис недоступен, система использует fallback:
- **Geo/Scoring fail** → создаются базовые 8 секторов
- **Claude fail** → базовые рекомендации из scoring service
- **Psychotype fail** → анализ без модификаторов психотипа
## Тестирование
Протестировано 2026-05-05:
- ✅ Создание случая с психотипом
- ✅ Запуск полного анализа
- ✅ Получение сохраненного результата
- ✅ Применение модификаторов психотипа к зонам
## Известные проблемы
1. **Scoring service**: Ошибка деления на None - нужно добавить проверки на None в scoring_service.py
2. **Claude API**: Требуется настройка ANTHROPIC_API_KEY в .env
3. **Geo service**: Возвращает пустые зоны (возможно, проблема с Overpass API или кешем)
## Дата создания
2026-05-05
+83
View File
@@ -0,0 +1,83 @@
# CaseForm Steps 3-5 - Создание компонентов
## Дата: 2026-05-05
### Созданные компоненты:
#### 1. Step3Circumstances.jsx (5.5 KB)
Обстоятельства потери
Поля:
- reason (причина) - кнопки: ушёл_сам, заблудился, отстал_от_группы, конфликт, неизвестно
- lost_time (дата/время потери) - datetime-local input
- elapsed_hours (прошло часов) - number input с шагом 0.5
- last_known_place (последнее место) - text input
- direction (направление) - 8 сторон света + неизвестно (N, NE, E, SE, S, SW, W, NW, unknown)
- circumstances_description (описание) - textarea (5 строк)
#### 2. Step4Environment.jsx (9.5 KB)
Среда и местность
Поля:
- season (сезон) - кнопки: зима, весна, лето, осень
- temperature (температура) - number input (-40 до +40°C)
- precipitation (осадки) - кнопки: нет, дождь, снег, туман
- visibility (видимость) - кнопки: хорошая, средняя, плохая
- terrain (типы местности) - МУЛЬТИВЫБОР чекбоксы (10 типов):
лес, поле, болото, водоём, горы, населённый_пункт, дороги,
заброшенные_здания, сельхоз, овраги
GPS функционал:
- Кнопка получения координат через navigator.geolocation API
- tnp_lat (широта) - number input с step 0.000001
- tnp_lon (долгота) - number input с step 0.000001
- tnp_address (адрес) - text input с подсказкой об автопреобразовании
Особенности GPS:
- enableHighAccuracy: true
- timeout: 10000ms
- maximumAge: 0
- Индикатор загрузки (gps_loading)
- Обработка ошибок с alert
#### 3. Step5Resources.jsx (5.4 KB)
Ресурсы для поиска
Поля:
- search_groups (количество групп) - number input (0-100)
- search_people (количество человек) - number input (0-1000)
- has_dog (поисковая собака) - кнопки: Да/Нет (boolean)
- equipment (оборудование) - МУЛЬТИВЫБОР чекбоксы (10 типов):
дрон, тепловизор, квадроцикл, лодка, вертолёт, рации,
GPS навигаторы, медицинская бригада, прожекторы, мегафон
- resources_notes (доп. информация) - textarea (4 строки)
### Обновления CSS:
Добавлены стили:
- .gps-button - зелёная кнопка (#238636) с hover (#2ea043)
- .gps-button:disabled - серая с курсором not-allowed
- input[type=datetime-local] - стилизация под общую палитру ЦОУ
### Палитра ЦОУ:
- Background: #0a0c0f, #161b22, #0d1117
- Accent: #ff4d00
- Text: #e6edf3
- Borders: #21262d, #30363d
- GPS button: #238636 (зелёный)
### Файлы:
1. Step3Circumstances.jsx - создан (5.5 KB)
2. Step4Environment.jsx - создан (9.5 KB)
3. Step5Resources.jsx - создан (5.4 KB)
4. CaseForm.css - обновлен
### Статус:
Step3 - обстоятельства (6 полей)
Step4 - среда + GPS + мультивыбор местности (10 типов)
Step5 - ресурсы + мультивыбор оборудования (10 типов)
GPS кнопка с navigator.geolocation
Ручной ввод координат и адреса
Палитра ЦОУ применена
Дата создания: 2026-05-05
+55
View File
@@ -0,0 +1,55 @@
# CaseForm - Обновление палитры ЦОУ
## Дата: 2026-05-05
### Выполнено:
#### 1. Обновлена цветовая палитра CaseForm.css
Палитра ЦОУ:
- Background: #0a0c0f (темный фон)
- Accent: #ff4d00 (оранжевый акцент)
- Text: #e6edf3 (светлый текст)
- Secondary bg: #161b22
- Borders: #21262d, #30363d
- Secondary text: #8b949e
Применено к:
- Фон формы и шагов
- Кнопки (обычные и выбранные)
- Поля ввода (input, textarea, select)
- Чекбоксы
- Психотип кнопки (большие вертикальные)
- Навигация (Назад/Далее)
- Ошибки (красный #da3633)
#### 2. Проверены компоненты Step1-2
Step1Child.jsx - все поля на месте:
- child_name (имя)
- age_years (возраст)
- gender (пол: М/Ж)
- clothes_upper (одежда верх)
- clothes_lower (одежда низ)
- clothes_shoes (обувь)
- special_marks (особые приметы)
Step2Health.jsx - все поля на месте:
- diagnosis_type (мультивыбор): здоров, РАС, эпилепсия, СДВГ, ЗПР, ДЦП, слабое_зрение, слабый_слух
- cant_swim (не умеет плавать)
- fitness_level (физподготовка: low/medium/high)
- has_transport (транспорт: none/bike/scooter/other)
- phone_status (телефон: answers/silent/none)
Step2bPsychotype.jsx - 4 вопроса с большими кнопками:
1. Как ведёт себя в незнакомой обстановке? (4 варианта)
2. Реакция на стресс и неудачи? (3 варианта)
3. Лидер или ведомый? (3 варианта)
4. Любит рисковать? (3 варианта)
### Статус:
Палитра ЦОУ применена
Все поля из спецификации присутствуют
Психотип с большими кнопками работает
Адаптивный дизайн сохранен
Дата обновления: 2026-05-05
+117
View File
@@ -0,0 +1,117 @@
# Инструкция по развертыванию SAR-MCHS на LXC 108
## Текущее состояние
Проект развернут на LXC 108 (Lab) по адресу: **192.168.0.108**
## Доступ к сервисам
- **Backend API**: http://192.168.0.108:8000
- Swagger документация: http://192.168.0.108:8000/docs
- ReDoc: http://192.168.0.108:8000/redoc
- **Frontend**: http://192.168.0.108:3000
- **Adminer** (управление БД): http://192.168.0.108:8080
- Система: PostgreSQL
- Сервер: postgres
- Пользователь: postgres
- Пароль: postgres
- База данных: sar_mchs
- **PostgreSQL**: 192.168.0.108:5432
## Управление проектом
### Запуск проекта
```bash
cd /root/sar-mchs
docker compose up -d
```
### Остановка проекта
```bash
cd /root/sar-mchs
docker compose down
```
### Перезапуск с пересборкой
```bash
cd /root/sar-mchs
docker compose down
docker compose up -d --build
```
### Просмотр логов
```bash
# Все сервисы
docker compose logs -f
# Конкретный сервис
docker compose logs -f backend
docker compose logs -f frontend
docker compose logs -f postgres
```
### Статус сервисов
```bash
docker compose ps
```
## Настройка
Переменные окружения находятся в файле `/root/sar-mchs/.env`
Важные переменные:
- `ANTHROPIC_API_KEY` - API ключ для Claude (требуется для функции анализа)
- `JWT_SECRET` - секрет для JWT токенов
- `DATABASE_URL` - строка подключения к PostgreSQL
- `CORS_ORIGINS` - разрешенные origins для CORS
## Структура проекта
```
/root/sar-mchs/
├── backend/ # FastAPI backend
│ ├── api/v1/ # API endpoints
│ ├── main.py # Точка входа
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/ # React frontend
│ ├── src/
│ ├── public/
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml # Конфигурация Docker Compose
├── .env # Переменные окружения
└── README.md
```
## Подключение к LXC 108
Из Proxmox хоста:
```bash
pct exec 108 -- bash
```
Через SSH с вашей машины:
```bash
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
## Проверка работоспособности
Все сервисы запущены и доступны:
- ✅ PostgreSQL 16 (порт 5432)
- ✅ Adminer (порт 8080)
- ✅ Backend API (порт 8000)
- ✅ Frontend (порт 3000)
+81
View File
@@ -0,0 +1,81 @@
# Исправления ошибок - Итоговый отчет
## Дата: 2026-05-05
### Исправленные проблемы:
#### 1. ✅ Scoring Service - NoneType division error
**Проблема:** Деление на None в расчетах score_zone
**Решение:** Добавлены проверки на None для всех операций деления:
```python
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
```
**Также исправлено в `_normalize_weights()`:**
```python
# Фильтруем None значения
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
total = sum(valid_weights.values())
if total > 0:
for key in self.weights:
if self.weights[key] is not None:
self.weights[key] /= total
else:
self.weights[key] = 0.0
```
**Статус:** ✅ Исправлено - scoring service теперь в `services_used`
#### 2. ✅ Geo Service - async/await
**Проблема:** Функция `build_search_zones` async, но вызывалась без await
**Решение:** Убедились, что в `analyze.py` используется `await build_search_zones()`
**Статус:** ✅ Работает - geo service в `services_used`
#### 3. ⚠️ Claude Service - 403 Forbidden
**Проблема:** Нет API ключа Anthropic
**Решение:** Используется fallback механизм
**Статус:** ⚠️ Ожидаемо - требуется настройка ANTHROPIC_API_KEY в .env
### Результаты тестирования:
**До исправлений:**
```json
{
"services_used": ["distance", "psychotype", "claude"],
"execution_time_ms": 21197
}
```
**После исправлений:**
```json
{
"services_used": ["distance", "geo", "scoring", "psychotype", "claude"],
"execution_time_ms": ~22000
}
```
### Измененные файлы:
1. `/root/vector/backend/services/scoring_service.py`
- Исправлен `_normalize_weights()` - обработка None
- Исправлены все операции деления - добавлены проверки на None
2. `/root/vector/backend/api/v1/analyze.py`
- Проверено использование `await` для async функций
### Статус сервисов:
| Сервис | Статус | Примечание |
|--------|--------|------------|
| Distance | ✅ Работает | Расчет дистанции |
| Geo | ✅ Работает | Построение зон (async) |
| Scoring | ✅ Работает | Оценка зон (исправлено) |
| Psychotype | ✅ Работает | Определение психотипа |
| Claude | ⚠️ Fallback | Требуется API ключ |
### Дата исправления:
2026-05-05
+57
View File
@@ -0,0 +1,57 @@
# SAR-MCHS Frontend
## Структура приложения
### Страницы
- **AdminDashboard** - Десктопная админ-панель с навигацией
- **MobileFormPage** - Мобильная форма создания карточки
### Компоненты админ-панели
#### CasesList
- Таблица всех карточек пропавших
- Фильтры: пол, возраст, диагноз
- Переход к детальному просмотру
#### CaseDetail
- Детальная информация о карточке
- Психотип с рекомендациями по поиску
- Ответы на вопросы психотипирования
#### HeatmapView
- Интерактивная карта с тепловой картой находок
- Фильтры: возраст, сезон, период, исход
- Leaflet + leaflet.heat
#### Statistics
- Общая статистика по карточкам
- Разбивка по полу, возрасту, психотипам, диагнозам
### Мобильная форма
**MobileForm** - адаптивная форма для создания карточки:
- Основная информация (возраст, пол)
- Диагнозы (чекбоксы)
- Психотипирование (4 вопроса)
- Валидация и отправка на API
- Сообщения об успехе/ошибке
## Технологии
- React 18
- React Router v6
- Axios для API
- Leaflet для карт
## Доступ
- Десктоп: http://192.168.0.99:3000/admin
- Мобильная форма: http://192.168.0.99:3000/mobile
- API Backend: http://192.168.0.99:8000
- Adminer: http://192.168.0.99:8080
## Responsive дизайн
- Автоматический редирект на /mobile для мобильных устройств
- Адаптивная верстка для всех компонентов
- Breakpoint: 768px
+221
View File
@@ -0,0 +1,221 @@
# Heatmap API - Тепловые карты с кэшированием
## Endpoint
### GET /api/v1/stats/heatmap
Получить данные для тепловой карты находок с 4 типами визуализации и кэшированием на 1 час.
## Типы карт (map_type)
### 1. `all` - Все точки
Все точки с одинаковой интенсивностью (1.0).
**Использование:** Общий обзор всех находок.
**Пример:**
```bash
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=all"
```
**Response:**
```json
{
"points": [
{
"lat": 53.92,
"lon": 27.58,
"intensity": 1.0,
"case_id": "uuid",
"metadata": {
"age": 10,
"season": "лето",
"outcome": "alive"
}
}
],
"total": 1,
"filters_applied": {"map_type": "all"}
}
```
### 2. `age` - По возрасту
Интенсивность зависит от возраста: младше = выше интенсивность (более критично).
**Формула:** `intensity = max(0.3, 1.0 - (age / 18.0))`
**Использование:** Приоритизация поиска младших детей.
**Пример:**
```bash
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=age&age_group=8-11"
```
**Response:**
```json
{
"points": [
{
"lat": 53.92,
"lon": 27.58,
"intensity": 0.44,
"case_id": "uuid",
"metadata": {
"age": 10,
"age_group": "8-11"
}
}
],
"total": 1,
"filters_applied": {
"map_type": "age",
"age_group": "8-11"
}
}
```
### 3. `season` - По сезону
Интенсивность зависит от сезона (зима = критичнее).
**Интенсивности:**
- Зима: 1.0 (красный)
- Осень: 0.8 (оранжевый)
- Весна: 0.6 (желтый)
- Лето: 0.4 (зеленый)
**Использование:** Анализ сезонных паттернов.
**Пример:**
```bash
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=season&season=зима"
```
### 4. `outcome` - По исходу
Интенсивность зависит от исхода поиска.
**Интенсивности:**
- Погиб: 1.0 (красный)
- Выжил: 0.5 (зеленый)
- Неизвестно: 0.3 (серый)
**Использование:** Анализ опасных зон.
**Пример:**
```bash
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=outcome&outcome=alive"
```
**Response:**
```json
{
"points": [
{
"lat": 53.92,
"lon": 27.58,
"intensity": 0.5,
"case_id": "uuid",
"metadata": {
"outcome": "alive",
"distance_km": 2.5
}
}
],
"total": 1,
"filters_applied": {
"map_type": "outcome",
"outcome": "alive"
}
}
```
## Query Parameters (фильтры)
| Параметр | Тип | Описание | Пример |
|----------|-----|----------|--------|
| `map_type` | string | Тип карты: all, age, season, outcome | `map_type=age` |
| `age_group` | string | Возрастная группа: 0-3, 4-7, 8-11, 12-14, 15-17, 18+ | `age_group=8-11` |
| `season` | string | Сезон: зима, весна, лето, осень | `season=лето` |
| `year_from` | int | Год начала периода | `year_from=2020` |
| `year_to` | int | Год окончания периода | `year_to=2026` |
| `outcome` | string | Исход: alive, deceased | `outcome=alive` |
## Кэширование
**Механизм:** `functools.lru_cache` с автоматической инвалидацией каждый час.
**Параметры кэша:**
- Размер: 128 комбинаций параметров
- TTL: 1 час (автоматически через timestamp_hour)
- Ключ: `{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{hour}`
**Преимущества:**
- Быстрый отклик для повторных запросов
- Снижение нагрузки на БД
- Автоматическая очистка устаревших данных
## Примеры использования
### 1. Все находки за 2025 год
```bash
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=all&year_from=2025&year_to=2025"
```
### 2. Младшие дети (0-7 лет) зимой
```bash
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=age&age_group=0-3&season=зима"
```
### 3. Опасные зоны (погибшие)
```bash
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=outcome&outcome=deceased"
```
### 4. Подростки летом (последние 3 года)
```bash
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=age&age_group=12-14&season=лето&year_from=2023&year_to=2026"
```
## Интеграция с фронтендом
### Leaflet.js пример
```javascript
// Получить данные
const response = await fetch('/api/v1/stats/heatmap?map_type=age');
const data = await response.json();
// Создать heatmap слой
const heatPoints = data.points.map(p => [p.lat, p.lon, p.intensity]);
const heat = L.heatLayer(heatPoints, {
radius: 25,
blur: 15,
maxZoom: 17,
gradient: {
0.0: 'green',
0.5: 'yellow',
1.0: 'red'
}
}).addTo(map);
```
## Протестировано (2026-05-05)
✅ `map_type=all` - работает (intensity: 1.0)
✅ `map_type=age` - работает (intensity: 0.44 для 10 лет)
✅ `map_type=season` - работает
✅ `map_type=outcome` - работает (intensity: 0.5 для alive)
✅ Фильтры: age_group, season, outcome - работают
✅ Кэширование: lru_cache с TTL 1 час
## Технические детали
**Файлы:**
- `backend/services/stats_service.py` - функции `get_heatmap_with_cache()`, `get_heatmap_data_cached()`
- `backend/api/v1/stats.py` - endpoint `/heatmap`
**Зависимости:**
- `functools.lru_cache` - встроенный кэш Python
- SQLAlchemy - фильтрация по БД
- Pydantic - валидация типов
## Дата создания
2026-05-05
+123
View File
@@ -0,0 +1,123 @@
# MapView Component - Leaflet карта с зонами и тепловой картой
## Дата: 2026-05-05
### Созданные файлы:
1. MapView.jsx (9.5 KB) - основной компонент
2. MapView.css (2.1 KB) - стили
3. index.jsx - экспорт
### Функционал:
#### 1. Базовая карта
- Leaflet + OpenStreetMap тайлы
- Инициализация с центром на ТНП
- Zoom level: 13 (по умолчанию)
- maxZoom: 19
#### 2. Маркер ТНП (Точка Начала Поиска)
- Красный круглый маркер с текстом ТНП
- Цвет: #ff4d00 (ЦОУ accent)
- Белая рамка 3px
- Popup с координатами
- Размер: 40x40px (32x32px на мобильных)
#### 3. Стрелка направления
- Пунктирная линия от ТНП (1 км)
- Цвет: #ff4d00
- Стрелка на конце с поворотом
- Направления: N, NE, E, SE, S, SW, W, NW
- Скрывается если direction = unknown
#### 4. Секторные зоны (полигоны)
- НЕ окружности, а секторы (угол 90°, ±45° от направления)
- Цвета из §5 ЦОУ палитры:
Зона 1 (0-500м): #ff4d00 (красный)
Зона 2 (500-1500м): #ff9f1a (оранжевый)
Зона 3 (1500-2500м): #ffd166 (желтый)
- fillOpacity: 0.2
- weight: 2, opacity: 0.6
- Popup с информацией: название, приоритет, оценка, расстояние, reasoning
#### 5. Тепловая карта (Leaflet.heat)
- Переключаемый слой (кнопка в правом верхнем углу)
- Параметры: radius 25, blur 15, maxZoom 17, max 1.0
- Градиент (ЦОУ палитра):
0.0: #0d1117 (темный)
0.3: #238636 (зеленый)
0.5: #ffd166 (желтый)
0.7: #ff9f1a (оранжевый)
1.0: #ff4d00 (красный)
### Props:
- tnpLat: number - Широта ТНП (обязательно)
- tnpLon: number - Долгота ТНП (обязательно)
- zones: Array - Массив зон с полями: name, priority, score, distance_km, direction, reasoning
- direction: string - Направление: N, NE, E, SE, S, SW, W, NW, unknown
- heatmapData: Array - Массив точек с полями: lat, lon, intensity
- showHeatmap: boolean - Показывать тепловую карту по умолчанию
### Математика:
Вычисление точки назначения (Haversine):
- R = 6371000 м (радиус Земли)
- Преобразование lat/lon в радианы
- Вычисление новой точки по азимуту и дистанции
Секторный полигон:
- Начало в ТНП
- Угол сектора: 90° (±45° от направления)
- Шаг генерации точек: 5°
- Замыкание полигона обратно в ТНП
### Стилизация:
Контейнер:
- height: 600px (400px на мобильных)
- background: #0a0c0f
- border-radius: 12px
- border: 1px solid #21262d
Кнопка управления:
- position: absolute, top: 16px, right: 16px
- z-index: 1000
- background: #161b22
- active: #ff4d00
Popup:
- background: #161b22
- color: #e6edf3
- border: 1px solid #30363d
### Зависимости (package.json):
Требуется установить:
- leaflet: ^1.9.4
- leaflet.heat: ^0.2.0
### Особенности реализации:
1. useRef для слоев - избегаем пересоздания при ререндере
2. useEffect с зависимостями - обновление только при изменении props
3. Cleanup - удаление старых слоев перед добавлением новых
4. calculateDestination - точное вычисление координат по азимуту
5. Секторы вместо окружностей - более точное отображение зон поиска
### Файлы:
- /root/vector/frontend/src/components/MapView/MapView.jsx
- /root/vector/frontend/src/components/MapView/MapView.css
- /root/vector/frontend/src/components/MapView/index.jsx
### Статус:
Leaflet + OpenStreetMap
Маркер ТНП с popup
Стрелка направления (8 сторон света)
Секторные полигоны (не окружности)
Цвета зон из §5 ЦОУ (#ff4d00, #ff9f1a, #ffd166)
Leaflet.heat с переключением
Градиент тепловой карты (ЦОУ палитра)
Адаптивный дизайн
Дата создания: 2026-05-05
+214
View File
@@ -0,0 +1,214 @@
# MobileForm - Мобильная форма с психотипом
## Описание
Компонент `MobileForm.tsx` - мобильная форма для создания карточки пропавшего человека с блоком определения психотипа.
## Расположение
- **Компонент:** `frontend/src/components/MobileForm.tsx`
- **Стили:** `frontend/src/components/MobileForm.css`
## Структура формы
### 1. Основная информация
- Возраст (число)
- Пол (мужской/женский)
### 2. Диагнозы
Чекбоксы для выбора:
- РАС
- эпилепсия
- СДВГ
- деменция
### 3. Блок психотипа (необязательный)
**Заголовок:** "Поведение ребёнка (спросить родителей)"
**Подзаголовок:** "Помогает уточнить зону поиска"
#### Вопрос 1: Как ведёт себя в незнакомой обстановке?
- Активно исследует → `unfamiliar_env: 'explore'`
- Ждёт → `unfamiliar_env: 'wait'`
- Замирает → `unfamiliar_env: 'freeze'`
- Паникует → `unfamiliar_env: 'panic'`
#### Вопрос 2: Реакция на неудачи?
- Злится, кричит → `stress_reaction: 'active'`
- Плачет, замыкается → `stress_reaction: 'cry'`
- Спокойно ищет выход → `stress_reaction: 'calm'`
#### Вопрос 3: Лидер или ведомый?
- Лидер → `group_role: 'leader'`
- Иногда → `group_role: 'sometimes'`
- Всегда ведомый → `group_role: 'follower'`
#### Вопрос 4: Любит рисковать?
- Очень → `risk_taking: 'high'`
- Иногда → `risk_taking: 'medium'`
- Нет, осторожный → `risk_taking: 'low'`
## Формат данных
### Структура FormData
```typescript
interface PsychotypeAnswers {
unfamiliar_env?: string;
stress_reaction?: string;
group_role?: string;
risk_taking?: string;
}
interface FormData {
age?: number;
gender?: string;
diagnosis_type?: string[];
psychotype_answers?: PsychotypeAnswers;
}
```
### Пример отправляемых данных
```json
{
"age": 8,
"gender": "male",
"diagnosis_type": ["РАС"],
"psychotype_answers": {
"unfamiliar_env": "freeze",
"stress_reaction": "cry",
"group_role": "follower",
"risk_taking": "low"
}
}
```
## Интеграция с API
Для определения психотипа нужно отправить `psychotype_answers` на endpoint:
```bash
POST /api/v1/analyze/psychotype
Content-Type: application/json
{
"stress_reaction": "cry",
"group_role": "follower",
"risk_taking": "low",
"unfamiliar_env": "freeze"
}
```
**Ответ:**
```json
{
"psychotype": "anxious",
"modifiers": {
"zone_0_500": 1.4,
"zone_500_1500": 1.1,
"zone_1500_2500": 0.5,
"zone_2500plus": 0.3,
"movement_model": "stay"
},
"recommendations": {
"priority_zones": "Ближняя зона (0-500м) - критически важна",
"search_pattern": "Тщательный осмотр ближайшей территории",
"key_locations": "Укрытия, углубления, густая растительность",
"communication": "Спокойные голосовые сигналы"
}
}
```
## Дизайн и UX
### Touch-friendly кнопки
- Минимальная высота: **52px** (56px на мобильных)
- Крупный шрифт: **16px**
- Увеличенные отступы для удобного нажатия
- Визуальная обратная связь при нажатии (scale 0.98)
### Цветовая схема
- Выбранная кнопка: `#3498db` (синий)
- Hover: `#f0f8ff` (светло-голубой фон)
- Секция психотипа: `#f8f9fa` (светло-серый фон)
- Кнопка отправки: `#27ae60` (зелёный)
### Адаптивность
- Максимальная ширина: 600px
- Мобильная оптимизация для экранов < 768px
- Вертикальное расположение кнопок для удобства на телефоне
## Использование
### Вариант 1: Standalone компонент
```jsx
import MobileForm from './components/MobileForm';
function App() {
return <MobileForm />;
}
```
### Вариант 2: С обработкой отправки
```jsx
import MobileForm from './components/MobileForm';
function App() {
const handleSubmit = async (formData) => {
// Отправка на backend
const response = await fetch('/api/v1/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
// Если есть psychotype_answers, получаем психотип
if (formData.psychotype_answers) {
const psychotypeResponse = await fetch('/api/v1/analyze/psychotype', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData.psychotype_answers)
});
const psychotypeData = await psychotypeResponse.json();
console.log('Психотип:', psychotypeData);
}
};
return <MobileForm onSubmit={handleSubmit} />;
}
```
## Маппинг ответов на психотипы
| Ответы | Психотип | Модель движения |
|--------|----------|-----------------|
| active + leader + high | dominant | chaotic_far |
| calm + leader + low | harmonic | linear_landmark |
| cry + follower + low | anxious | stay |
| freeze + follower + low | introvert_passive | stay_hidden |
| active + leader + medium | introvert_active | linear_landmark |
## Валидация
- Все поля психотипа **необязательные** - можно пропустить
- Если заполнен хотя бы один вопрос, рекомендуется заполнить все 4
- Backend API валидирует значения и вернёт ошибку при невалидных данных
## TODO
- [ ] Добавить prop `onSubmit` для обработки отправки формы
- [ ] Интегрировать с реальным API endpoint
- [ ] Добавить индикатор загрузки при отправке
- [ ] Показывать результат определения психотипа после отправки
- [ ] Добавить валидацию на фронтенде
- [ ] Сохранение черновика в localStorage
- [ ] Добавить больше полей (местность, погода, обстоятельства)
## Связанные файлы
- `backend/services/psychotype_service.py` - сервис определения психотипа
- `backend/api/v1/analyze.py` - API endpoint `/psychotype`
- `PSYCHOTYPE_API.md` - документация API
+149
View File
@@ -0,0 +1,149 @@
# API для определения психотипа
## Endpoint: POST /api/v1/analyze/psychotype
Определяет психотип пропавшего человека на основе ответов на психологические вопросы и возвращает модификаторы зон поиска с рекомендациями.
**Основано на методике:** Рындина О.Г., Иванова О.Ю. (Чебоксары, 2015)
### Request Body
```json
{
"stress_reaction": "active|cry|freeze|calm",
"group_role": "leader|follower",
"risk_taking": "high|medium|low",
"unfamiliar_env": "explore|wait|freeze"
}
```
### Response
```json
{
"psychotype": "dominant|harmonic|anxious|introvert_passive|introvert_active",
"modifiers": {
"zone_0_500": 0.7,
"zone_500_1500": 1.2,
"zone_1500_2500": 1.4,
"zone_2500plus": 1.1,
"movement_model": "chaotic_far|linear_landmark|stay|stay_hidden",
"description": "Описание психотипа"
},
"recommendations": {
"priority_zones": "Приоритетные зоны поиска",
"search_pattern": "Паттерн поиска",
"key_locations": "Ключевые локации",
"communication": "Способы коммуникации"
}
}
```
## Психотипы
### 1. Dominant (Доминантный)
- **Триггер:** active + leader + high
- **Модификаторы:** Низкая вероятность в ближней зоне (0.7), высокая в средних/дальних (1.2-1.4)
- **Модель движения:** chaotic_far - хаотичное движение на большие расстояния
- **Приоритет:** Средние и дальние зоны (500-2500м)
### 2. Harmonic (Гармоничный)
- **Триггер:** calm + leader + low
- **Модификаторы:** Равномерное распределение с акцентом на средние зоны
- **Модель движения:** linear_landmark - движение по ориентирам
- **Приоритет:** Все зоны равномерно, акцент на 500-1500м
### 3. Anxious (Тревожный)
- **Триггер:** cry + follower + low
- **Модификаторы:** Очень высокая вероятность в ближней зоне (1.4), низкая в дальних (0.3)
- **Модель движения:** stay - минимальное движение
- **Приоритет:** Ближняя зона (0-500м) критически важна
### 4. Introvert Passive (Интроверт пассивный)
- **Триггер:** freeze + follower + low
- **Модификаторы:** Высокая вероятность в ближней зоне (1.3), очень низкая в дальних (0.2)
- **Модель движения:** stay_hidden - прячется, минимальное движение
- **Приоритет:** Ближняя зона, укрытия
### 5. Introvert Active (Интроверт активный)
- **Триггер:** active + leader + medium
- **Модификаторы:** Средние значения с акцентом на зоны 500-2500м
- **Модель движения:** linear_landmark - целенаправленное движение
- **Приоритет:** Средние зоны вдоль линейных ориентиров
## Примеры использования
### Пример 1: Доминантный тип
```bash
curl -X POST http://192.168.0.99:8000/api/v1/analyze/psychotype \
-H "Content-Type: application/json" \
-d '{
"stress_reaction": "active",
"group_role": "leader",
"risk_taking": "high",
"unfamiliar_env": "explore"
}'
```
**Результат:**
- Психотип: dominant
- Зона 0-500м: коэффициент 0.7 (низкий приоритет)
- Зона 1500-2500м: коэффициент 1.4 (высокий приоритет)
- Рекомендация: Широкий охват, проверка нелинейных маршрутов
### Пример 2: Тревожный тип
```bash
curl -X POST http://192.168.0.99:8000/api/v1/analyze/psychotype \
-H "Content-Type: application/json" \
-d '{
"stress_reaction": "cry",
"group_role": "follower",
"risk_taking": "low",
"unfamiliar_env": "freeze"
}'
```
**Результат:**
- Психотип: anxious
- Зона 0-500м: коэффициент 1.4 (критически важна!)
- Зона 2500+м: коэффициент 0.3 (очень низкий приоритет)
- Рекомендация: Тщательный осмотр ближайшей территории, укрытия
## Интеграция с другими сервисами
Модификаторы из психотипа можно использовать для корректировки вероятностей в:
- `scoring_service.py` - взвешивание зон поиска
- `geo_service.py` - построение приоритетных зон
- `distance_service.py` - корректировка максимальной дистанции поиска
### Пример интеграции
```python
from services.psychotype_service import detect_psychotype, get_psychotype_modifiers
# Определяем психотип
psychotype = detect_psychotype(answers)
modifiers = get_psychotype_modifiers(psychotype)
# Применяем модификаторы к базовым вероятностям
base_prob_near = 0.5
adjusted_prob = base_prob_near * modifiers['zone_0_500']
# Для anxious: 0.5 * 1.4 = 0.7 (увеличение приоритета ближней зоны)
# Для dominant: 0.5 * 0.7 = 0.35 (снижение приоритета ближней зоны)
```
## Валидация
API валидирует входные данные:
- Все поля обязательны
- Значения должны соответствовать допустимым вариантам
- При невалидных данных возвращается HTTP 400 с описанием ошибки
```json
{
"detail": "Invalid stress_reaction: unknown_value"
}
```
+183
View File
@@ -0,0 +1,183 @@
# Итоговая сводка: Функционал психотипа
**Дата:** 2026-05-01
**Проект:** SAR-MCHS (Search and Rescue Management System)
**Сервер:** LXC 108 (192.168.0.99)
## ✅ Выполненные задачи
### Backend (Полностью готов)
1. **Сервис психотипа** - `backend/services/psychotype_service.py`
- 3 функции: detect_psychotype, get_psychotype_modifiers, get_search_recommendations
- 5 психотипов с модификаторами зон
- Основано на методике Рындиной О.Г., Ивановой О.Ю. (2015)
2. **API Endpoint** - `POST /api/v1/analyze/psychotype`
- ✅ Работает и протестирован
- Возвращает психотип, модификаторы, рекомендации
- Валидация входных данных
3. **Тесты** - Все психотипы протестированы:
- Dominant: зона 1500-2500м ×1.4, модель chaotic_far
- Anxious: зона 0-500м ×1.4, модель stay
- Harmonic: равномерное распределение, модель linear_landmark
- Introvert Passive: зона 0-500м ×1.3, модель stay_hidden
- Introvert Active: зона 500-2500м ×1.2, модель linear_landmark
### Frontend (Полностью готов)
1. **Компонент формы** - `frontend/src/components/MobileForm.jsx`
- Блок психотипа с 4 вопросами
- Touch-friendly кнопки (min 52px)
- Адаптивный дизайн
- Все поля необязательные
2. **Стили** - `frontend/src/components/MobileForm.css`
- Мобильная оптимизация
- Крупные кнопки для сенсорных экранов
- Визуальная обратная связь
3. **Интеграция** - App.js обновлен для показа формы
### Документация
1. **PSYCHOTYPE_API.md** - полная документация API
2. **MOBILE_FORM_README.md** - руководство по использованию формы
3. **SERVICES_README.md** - обновлена информация о сервисах
## 🌐 Доступ к сервисам
- **Frontend:** http://192.168.0.99:3000
- **Backend API:** http://192.168.0.99:8000
- **API Docs:** http://192.168.0.99:8000/docs
- **Adminer (БД):** http://192.168.0.99:8080
## 📋 Структура данных
### Вопросы в форме
1. **Как ведёт себя в незнакомой обстановке?**
- Активно исследует → `unfamiliar_env: 'explore'`
- Ждёт → `unfamiliar_env: 'wait'`
- Замирает → `unfamiliar_env: 'freeze'`
- Паникует → `unfamiliar_env: 'panic'`
2. **Реакция на неудачи?**
- Злится, кричит → `stress_reaction: 'active'`
- Плачет, замыкается → `stress_reaction: 'cry'`
- Спокойно ищет выход → `stress_reaction: 'calm'`
3. **Лидер или ведомый?**
- Лидер → `group_role: 'leader'`
- Иногда → `group_role: 'sometimes'`
- Всегда ведомый → `group_role: 'follower'`
4. **Любит рисковать?**
- Очень → `risk_taking: 'high'`
- Иногда → `risk_taking: 'medium'`
- Нет, осторожный → `risk_taking: 'low'`
### Пример запроса
```bash
curl -X POST http://192.168.0.99:8000/api/v1/analyze/psychotype \
-H "Content-Type: application/json" \
-d '{
"stress_reaction": "cry",
"group_role": "follower",
"risk_taking": "low",
"unfamiliar_env": "freeze"
}'
```
### Пример ответа
```json
{
"psychotype": "anxious",
"modifiers": {
"zone_0_500": 1.4,
"zone_500_1500": 1.1,
"zone_1500_2500": 0.5,
"zone_2500plus": 0.3,
"movement_model": "stay",
"description": "Тревожный тип: минимальное движение, остается близко к точке потери"
},
"recommendations": {
"priority_zones": "Ближняя зона (0-500м) - критически важна",
"search_pattern": "Тщательный осмотр ближайшей территории",
"key_locations": "Укрытия, углубления, густая растительность рядом с точкой потери",
"communication": "Спокойные голосовые сигналы, избегать резких звуков"
}
}
```
## 🎯 Психотипы и их характеристики
| Психотип | Триггер | Зона 0-500м | Зона 1500-2500м | Модель движения |
|----------|---------|-------------|-----------------|-----------------|
| **Dominant** | active + leader + high | 0.7 (низкий) | 1.4 (высокий) | chaotic_far |
| **Harmonic** | calm + leader + low | 0.8 | 1.1 | linear_landmark |
| **Anxious** | cry + follower + low | 1.4 (высокий) | 0.5 (низкий) | stay |
| **Introvert Passive** | freeze + follower + low | 1.3 (высокий) | 0.6 (низкий) | stay_hidden |
| **Introvert Active** | active + leader + medium | 0.8 | 1.2 | linear_landmark |
## 🔄 Интеграция с другими модулями
Модификаторы психотипа можно использовать в:
1. **scoring_service.py** - взвешивание зон поиска
2. **geo_service.py** - построение приоритетных зон
3. **distance_service.py** - корректировка максимальной дистанции
### Пример интеграции
```python
from services.psychotype_service import detect_psychotype, get_psychotype_modifiers
# Определяем психотип
psychotype = detect_psychotype(answers)
modifiers = get_psychotype_modifiers(psychotype)
# Применяем к базовым вероятностям
base_prob = 0.5
adjusted_prob = base_prob * modifiers['zone_0_500']
```
## 📝 Следующие шаги
1. Интегрировать психотип в основной flow создания карточки
2. Добавить визуализацию модификаторов на карте
3. Сохранять психотип в БД вместе с карточкой
4. Использовать модификаторы в расчете приоритетных зон
5. Добавить статистику по эффективности психотипов
## 🧪 Тестирование
Запустить тесты:
```bash
# Backend тесты
pct exec 108 -- bash /root/test_psychotype_flow.sh
# Проверка frontend
curl http://192.168.0.99:3000
# Проверка API
curl http://192.168.0.99:8000/api/v1/analyze/psychotype \
-H "Content-Type: application/json" \
-d '{"stress_reaction":"active","group_role":"leader","risk_taking":"high","unfamiliar_env":"explore"}'
```
## 📚 Документация
- `PSYCHOTYPE_API.md` - API документация
- `MOBILE_FORM_README.md` - документация формы
- `SERVICES_README.md` - обзор всех сервисов
- `backend/services/psychotype_service.py` - docstrings в коде
---
**Статус:** ✅ Полностью готово к использованию
**Протестировано:** Backend API, Frontend компонент, Интеграция
**Источник методики:** Рындина О.Г., Иванова О.Ю., Чебоксары, 2015
+85
View File
@@ -0,0 +1,85 @@
# ResultPanel + AnalysisLayout - Двухколоночный интерфейс
## Дата: 2026-05-05
### Созданные компоненты:
1. ResultPanel.jsx (6.8 KB) - панель результатов с behavioral profile
2. AnalysisLayout.jsx (1.8 KB) - двухколоночный layout
### ResultPanel - Функционал:
#### Блок срочности (с цветами)
- КРИТИЧЕСКАЯ: #ff4d00 (красный)
- ВЫСОКАЯ: #ff9f1a (оранжевый)
- УМЕРЕННАЯ: #ffd166 (желтый)
- НИЗКАЯ: #238636 (зеленый)
#### Behavioral Profile карточки (5 типов):
1. РАС (красная #ff4d00) - 4 пункта поведения
2. Эпилепсия (оранжевая #ff9f1a) - 4 пункта рисков
3. Велосипед (синяя #58a6ff) - радиус увеличен 3-5x
4. Самокат (синяя #58a6ff) - радиус увеличен 2-3x
5. Не умеет плавать (красная #ff4d00) - критично проверить водоёмы
Grid layout: auto-fit, minWidth 280px
Hover эффект: translateY(-2px) + shadow
#### Секции:
- Психотип с рекомендациями (4 пункта)
- Немедленные действия (топ-3, нумерованные)
- Зоны с приоритетом (топ-5, с оценками)
- Ключевые объекты (теги)
- Прогноз поведения
### AnalysisLayout - Двухколоночный layout:
Структура:
- Левая колонка: CaseForm (Steps 1-5)
- Правая колонка: MapView (sticky, top: 24px)
- Снизу на всю ширину: ResultPanel
Grid: 1fr 1fr, gap 24px, max-width 1800px
Адаптивность:
- < 1400px: одна колонка
- < 768px: уменьшенные отступы
Placeholder карты до анализа:
- Пунктирная рамка
- Иконка + текст подсказки
### Цветовая палитра ЦОУ:
Фоны: #0a0c0f, #161b22, #1c2128
Границы: #21262d, #30363d
Текст: #e6edf3, #8b949e
Акценты: #ff4d00, #ff9f1a, #ffd166, #238636, #58a6ff
### Файлы:
ResultPanel:
- ResultPanel.jsx (6.8 KB)
- ResultPanel.css (5.2 KB)
- index.jsx
AnalysisLayout:
- AnalysisLayout.jsx (1.8 KB)
- AnalysisLayout.css (1.4 KB)
- index.jsx
### Статус:
Блок срочности (4 уровня)
Behavioral Profile карточки (5 типов)
Психотип секция
Немедленные действия (топ-3)
Зоны с приоритетом (топ-5)
Ключевые объекты
Прогноз поведения
Двухколоночный layout
Sticky карта
Адаптивный дизайн
Палитра ЦОУ
Дата создания: 2026-05-05
+183
View File
@@ -0,0 +1,183 @@
# Сервисы анализа SAR-MCHS
## Обзор
Созданы два основных сервиса для анализа поисково-спасательных операций:
### 1. Claude Service (`backend/services/claude_service.py`)
**Функция:** `analyze_case(case_data: dict) -> AnalysisResult`
Использует 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": "Краткое резюме"
}
```
### 2. Stats Service (`backend/services/stats_service.py`)
**Функция:** `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/combined`
Комбинированный анализ: Claude AI + статистические данные.
**Request:** Тот же формат, что и `/text`
**Response:**
```json
{
"claude_analysis": { /* AnalysisResult */ },
"statistical_data": { /* StatsResult */ }
}
```
### 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
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 запросов
- [ ] Добавить логирование запросов и ответов
+158
View File
@@ -0,0 +1,158 @@
# Stats Service API - Статистические рекомендации и агрегаты
## Endpoints
### GET /api/v1/stats/summary
Получить агрегированную статистику для дашборда.
**Response:**
```json
{
"total_cases": 8,
"active_cases": 7,
"closed_cases": 1,
"by_gender": {"М": 8},
"by_age_group": {
"8-11": 6,
"12-14": 2
},
"by_psychotype": {},
"by_diagnosis": {"здоров": 3},
"by_season": {
"зима": 3,
"весна": 1,
"лето": 1
},
"avg_distance_km": 2.3,
"avg_search_duration_hours": 4.5,
"survival_rate": 100.0
}
```
### POST /api/v1/stats/recommendation
Получить статистические рекомендации на основе похожих случаев.
**Фильтры (в порядке приоритета):**
1. Возраст ±2 года + сезон + terrain
2. Возраст ±2 года + сезон (если < 5 случаев)
3. Возраст ±2 года (если < 5 случаев)
4. Все случаи (если < 5 случаев)
**Request:**
```json
{
"age": 10,
"season": "лето",
"terrain_primary": "лес"
}
```
**Response:**
```json
{
"median_distance_km": 2.3,
"top_directions": [
{
"direction": "СВ",
"count": 1,
"percentage": 100.0
}
],
"top_location_types": ["forest"],
"survival_rate": 100.0,
"sample_size": 1,
"filters_used": {
"age_range": "8 to 12",
"season": "лето",
"terrain": "лес"
}
}
```
### GET /api/v1/stats/heatmap
Получить данные для тепловой карты находок.
**Query Parameters:**
- `age_min` (optional): Минимальный возраст
- `age_max` (optional): Максимальный возраст
- `season` (optional): Сезон
- `outcome` (optional): Исход (alive/deceased)
**Response:**
```json
{
"points": [
{
"lat": 53.9,
"lon": 27.56,
"intensity": 1.0,
"case_id": "uuid",
"distance_km": 2.3,
"outcome": "alive"
}
],
"total": 1
}
```
## Функции stats_service.py
### get_statistical_recommendation()
SQL выборка похожих случаев с фильтрами:
- Возраст ±2 года
- Сезон
- Terrain (тип местности)
- Минимум 5 случаев для выборки
Возвращает:
- `median_distance_km` - медианное расстояние находки
- `top_directions` - топ-3 направления с процентами
- `top_location_types` - топ-5 типов локаций
- `survival_rate` - процент выживаемости
- `sample_size` - размер выборки
- `filters_used` - использованные фильтры
### get_dashboard_stats()
Агрегаты для дашборда:
- Общее количество случаев (всего, активных, закрытых)
- Распределение по полу, возрасту, психотипу, диагнозам, сезонам
- Средняя дистанция находки
- Средняя длительность поиска
- Процент выживаемости
### get_heatmap_data()
Данные для тепловой карты с фильтрами:
- Возраст (min/max)
- Сезон
- Исход (alive/deceased)
## Тестирование
```bash
# 1. Получить агрегаты дашборда
curl http://localhost:8000/api/v1/stats/summary
# 2. Получить статистические рекомендации
curl -X POST http://localhost:8000/api/v1/stats/recommendation \
-H "Content-Type: application/json" \
-d '{"age": 10, "season": "лето", "terrain_primary": "лес"}'
# 3. Получить данные для тепловой карты
curl "http://localhost:8000/api/v1/stats/heatmap?age_min=8&age_max=12"
```
## Протестировано (2026-05-05)
✅ GET /api/v1/stats/summary - работает
✅ POST /api/v1/stats/recommendation - работает
✅ GET /api/v1/stats/heatmap - работает
## Технические детали
- **Синхронная SQLAlchemy** (не async)
- **PostgreSQL ARRAY** - используется `any()` вместо `contains()`
- **Fallback механизм** - если выборка < 5 случаев, расширяются фильтры
- **Pydantic модели** - валидация входных/выходных данных
## Дата создания
2026-05-05
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y gcc postgresql-client && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
View File
+347
View File
@@ -0,0 +1,347 @@
"""
Analysis API endpoints for full case analysis pipeline.
Pipeline: distance → geo → scoring → psychotype → claude → merged result
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import time
from database import get_db
from models import Case, AnalysisLog, User
from api.v1.auth import get_current_user
# Import services
from services.distance_service import calculate_max_distance
from services.geo_service import build_search_zones
from services.scoring_service import WeightedScorer
from services.psychotype_service import (
detect_psychotype,
get_psychotype_modifiers,
get_search_recommendations
)
from services.claude_service import analyze_case as claude_analyze
router = APIRouter()
class AnalysisRequest(BaseModel):
"""Request for full case analysis"""
case_id: UUID = Field(..., description="ID случая для анализа")
class ZoneResult(BaseModel):
"""Search zone with score and recommendations"""
priority: int
name: str
direction: str
distance_km: float
score: float
reasoning: str
forest_pct: Optional[float] = None
road_density: Optional[float] = None
water_distance_km: Optional[float] = None
class AnalysisResponse(BaseModel):
"""Full analysis result"""
case_id: UUID
analyzed_at: datetime
# Distance calculation
max_distance_km: float
# Psychotype (if available)
psychotype: Optional[str] = None
psychotype_modifiers: Optional[Dict[str, Any]] = None
psychotype_recommendations: Optional[Dict[str, Any]] = None
# Zones
zones: List[ZoneResult]
# Claude analysis (if available)
urgency: Optional[str] = None
key_locations: Optional[List[str]] = None
immediate_actions: Optional[List[str]] = None
behavioral_prediction: Optional[str] = None
summary: Optional[str] = None
# Meta
execution_time_ms: float
services_used: List[str]
class SavedAnalysisResponse(BaseModel):
"""Saved analysis result from database"""
case_id: UUID
analysis_log: Dict[str, Any]
created_at: datetime
@router.post("/analyze", response_model=AnalysisResponse, status_code=200)
async def analyze_full_case(
request: AnalysisRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Запустить полный анализ случая.
Пайплайн:
1. Distance service - расчет максимальной дистанции
2. Geo service - построение зон поиска
3. Scoring service - оценка и ранжирование зон
4. Psychotype service - определение психотипа (если есть данные)
5. Claude service - интеллектуальный анализ (опционально)
6. Merge results - объединение результатов
Требуется аутентификация (operator, field, admin).
"""
start_time = time.time()
services_used = []
# 1. Получить случай из БД
case = db.query(Case).filter(Case.id == request.case_id).first()
if not case:
raise HTTPException(status_code=404, detail=f"Case {request.case_id} not found")
# Подготовить данные для анализа
case_data = {
'age': case.age_years,
'gender': case.gender,
'elapsed_hours': case.elapsed_hours or 1.0,
'terrain_primary': case.terrain[0] if case.terrain else 'лес',
'season': case.season or 'лето',
'temperature_c': case.temperature_c or 20.0,
'has_transport': case.has_transport,
'has_diagnosis': case.has_diagnosis,
'diagnosis_type': case.diagnosis_type or [],
'tnp_lat': case.tnp_lat,
'tnp_lon': case.tnp_lon,
}
# 2. Distance service - расчет максимальной дистанции
try:
max_distance = calculate_max_distance(case_data)
services_used.append('distance')
except Exception as e:
raise HTTPException(status_code=500, detail=f"Distance calculation failed: {str(e)}")
# 3. Geo service - построение зон поиска (если есть координаты)
zones_data = []
if case.tnp_lat and case.tnp_lon:
try:
zones_data = await build_search_zones(
lat=case.tnp_lat,
lon=case.tnp_lon,
case_data=case_data,
max_distance_km=max_distance
)
services_used.append('geo')
except Exception as e:
# Geo service опционален, продолжаем без него
print(f"Geo service failed: {e}")
# 4. Scoring service - оценка и ранжирование зон
scored_zones = []
if zones_data:
try:
scorer = WeightedScorer()
for zone in zones_data:
zone_dict = zone.model_dump() if hasattr(zone, 'model_dump') else zone
score = scorer.score_zone(zone_dict, case_data, max_distance_km=max_distance)
zone_dict['score'] = score
zone_dict['reasoning'] = f"Оценка на основе {len(case_data)} факторов"
scored_zones.append(zone_dict)
# Сортировать по score
scored_zones.sort(key=lambda z: z.get('score', 0), reverse=True)
services_used.append('scoring')
except Exception as e:
print(f"Scoring service failed: {e}")
scored_zones = _create_fallback_zones(max_distance)
else:
# Если geo не работает, создаем базовые зоны
scored_zones = _create_fallback_zones(max_distance)
# 5. Psychotype service - определение психотипа
psychotype = None
psychotype_modifiers = None
psychotype_recommendations = None
if case.psychotype_answers:
try:
psychotype = detect_psychotype(case.psychotype_answers)
psychotype_modifiers = get_psychotype_modifiers(psychotype)
psychotype_recommendations = get_search_recommendations(psychotype)
services_used.append('psychotype')
# Применить модификаторы психотипа к зонам
scored_zones = _apply_psychotype_modifiers(scored_zones, psychotype_modifiers)
except Exception as e:
print(f"Psychotype service failed: {e}")
# 6. Claude service - интеллектуальный анализ (опционально)
urgency = None
key_locations = None
immediate_actions = None
behavioral_prediction = None
summary = None
try:
claude_result = await claude_analyze(case_data)
urgency = claude_result.urgency
key_locations = claude_result.key_locations
immediate_actions = claude_result.immediate_actions
behavioral_prediction = claude_result.behavioral_prediction
summary = claude_result.summary
services_used.append('claude')
except Exception as e:
# Claude опционален, продолжаем без него
print(f"Claude service failed: {e}")
# 7. Формируем результат
zones_result = [
ZoneResult(
priority=i + 1,
name=zone.get('name', f"Зона {zone.get('direction', 'N')}"),
direction=zone.get('direction', 'N'),
distance_km=zone.get('distance_km', 0),
score=zone.get('score', 0),
reasoning=zone.get('reasoning', 'Автоматическая оценка'),
forest_pct=zone.get('forest_pct'),
road_density=zone.get('road_density'),
water_distance_km=zone.get('water_distance_km')
)
for i, zone in enumerate(scored_zones[:10]) # Топ-10 зон
]
execution_time = (time.time() - start_time) * 1000
# 8. Сохранить результат в analysis_log
analysis_result = {
'max_distance_km': max_distance,
'psychotype': psychotype,
'psychotype_modifiers': psychotype_modifiers,
'zones': [z.model_dump() for z in zones_result],
'urgency': urgency,
'key_locations': key_locations,
'immediate_actions': immediate_actions,
'summary': summary,
'services_used': services_used,
'execution_time_ms': execution_time
}
# Обновить case.analysis_log
case.analysis_log = analysis_result
db.commit()
# Создать запись в AnalysisLog
log_entry = AnalysisLog(
case_id=case.id,
user_id=current_user.id,
analysis_type='full_pipeline',
input_data={'case_id': str(case.id)},
output_data=analysis_result,
execution_time=execution_time / 1000,
status='success'
)
db.add(log_entry)
db.commit()
return AnalysisResponse(
case_id=case.id,
analyzed_at=datetime.utcnow(),
max_distance_km=max_distance,
psychotype=psychotype,
psychotype_modifiers=psychotype_modifiers,
psychotype_recommendations=psychotype_recommendations,
zones=zones_result,
urgency=urgency,
key_locations=key_locations,
immediate_actions=immediate_actions,
behavioral_prediction=behavioral_prediction,
summary=summary,
execution_time_ms=execution_time,
services_used=services_used
)
@router.get("/analyze/{case_id}", response_model=SavedAnalysisResponse)
async def get_saved_analysis(
case_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Получить сохранённый результат анализа.
Возвращает последний analysis_log из таблицы cases.
Требуется аутентификация (operator, field, admin).
"""
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
if not case.analysis_log:
raise HTTPException(
status_code=404,
detail=f"No analysis found for case {case_id}. Run POST /analyze first."
)
return SavedAnalysisResponse(
case_id=case.id,
analysis_log=case.analysis_log,
created_at=case.created_at
)
def _create_fallback_zones(max_distance: float) -> List[Dict[str, Any]]:
"""Create basic zones when geo/scoring services fail"""
directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
zones = []
for i, direction in enumerate(directions):
zones.append({
'direction': direction,
'distance_km': max_distance * 0.8,
'score': 100 - (i * 10),
'name': f"Сектор {direction}",
'reasoning': 'Базовая оценка (сервисы недоступны)'
})
return zones
def _apply_psychotype_modifiers(zones: List[Dict], modifiers: Dict) -> List[Dict]:
"""Apply psychotype modifiers to zone scores"""
if not modifiers:
return zones
# Применяем модификаторы зон из психотипа
for zone in zones:
distance = zone.get('distance_km', 0)
# Определяем зону дистанции
if distance < 0.5:
modifier = modifiers.get('zone_0_500', 1.0)
elif distance < 1.5:
modifier = modifiers.get('zone_500_1500', 1.0)
elif distance < 2.5:
modifier = modifiers.get('zone_1500_2500', 1.0)
else:
modifier = modifiers.get('zone_2500plus', 1.0)
# Применяем модификатор к score
zone['score'] = zone.get('score', 0) * modifier
zone['reasoning'] += f" (психотип: ×{modifier:.1f})"
# Пересортировать по score
zones.sort(key=lambda z: z.get('score', 0), reverse=True)
return zones
+23
View File
@@ -0,0 +1,23 @@
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class AnalysisRequest(BaseModel):
age: int
gender: str
terrain: str
class AnalysisResult(BaseModel):
recommendation: str
estimated_radius_km: float
@router.post("/text", response_model=AnalysisResult)
async def analyze_text(request: AnalysisRequest):
return AnalysisResult(
recommendation="Placeholder analysis",
estimated_radius_km=2.5
)
+196
View File
@@ -0,0 +1,196 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional
from pydantic import BaseModel
import os
import bcrypt
from database import get_db
from models import User
from schemas import UserOut
router = APIRouter()
# JWT настройки
SECRET_KEY = os.getenv("JWT_SECRET", "change-me-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
role: Optional[str] = None
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверка пароля через bcrypt напрямую"""
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
def get_password_hash(password: str) -> str:
"""Хеширование пароля через bcrypt напрямую"""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Создание JWT токена"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def authenticate_user(db: Session, username: str, password: str):
"""Аутентификация пользователя"""
user = db.query(User).filter(User.username == username).first()
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
"""Получение текущего пользователя из JWT токена"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username, role=payload.get("role"))
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == token_data.username).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
"""Проверка активности пользователя"""
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def require_role(allowed_roles: list[str]):
"""Dependency для проверки роли пользователя"""
async def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required roles: {', '.join(allowed_roles)}"
)
return current_user
return role_checker
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""
Аутентификация и получение JWT токена.
Используйте username и password для получения access_token.
Токен действителен 24 часа.
Тестовые пользователи:
- operator / pass123
- field / pass123
- admin / pass123
"""
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Обновляем last_login
user.last_login = datetime.utcnow()
db.commit()
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username, "role": user.role},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserOut)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
"""
Получить информацию о текущем пользователе.
Требуется валидный JWT токен в заголовке Authorization: Bearer <token>
"""
return current_user
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register_user(
username: str,
email: str,
password: str,
full_name: Optional[str] = None,
role: str = "operator",
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Регистрация нового пользователя (только для admin).
Доступные роли: operator, field, admin
"""
# Проверка существования пользователя
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="Username already registered")
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=400, detail="Email already registered")
# Создание пользователя
hashed_password = get_password_hash(password)
db_user = User(
username=username,
email=email,
hashed_password=hashed_password,
full_name=full_name,
role=role
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
+212
View File
@@ -0,0 +1,212 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List, Optional
from uuid import UUID
from database import get_db
from models import Case, User
from schemas import CaseCreate, CaseOut
from pydantic import BaseModel
# Импортируем auth dependencies
import sys
sys.path.append('/app/api/v1')
from auth import get_current_user, require_role
router = APIRouter()
class CaseUpdate(BaseModel):
"""Schema for updating case fields"""
# Ребёнок
child_name: Optional[str] = None
age_years: Optional[int] = None
gender: Optional[str] = None
height_build: Optional[str] = None
clothes_upper: Optional[str] = None
clothes_lower: Optional[str] = None
shoes: Optional[str] = None
clothes_description: Optional[str] = None
special_marks: Optional[str] = None
phone_status: Optional[str] = None
# Здоровье
has_diagnosis: Optional[bool] = None
diagnosis_type: Optional[List[str]] = None
fitness_level: Optional[str] = None
has_transport: Optional[str] = None
cant_swim: Optional[bool] = None
# Психотип
psychotype: Optional[str] = None
psychotype_answers: Optional[dict] = None
# Обстоятельства
loss_reason: Optional[str] = None
loss_time: Optional[str] = None
elapsed_hours: Optional[float] = None
last_seen_direction: Optional[str] = None
last_seen_reliability: Optional[str] = None
last_seen_description: Optional[str] = None
behavior_description: Optional[str] = None
familiar_places: Optional[str] = None
lost_before: Optional[str] = None
# Среда
season: Optional[str] = None
temperature_c: Optional[float] = None
precipitation: Optional[str] = None
visibility: Optional[str] = None
wind: Optional[str] = None
terrain: Optional[List[str]] = None
# GPS
tnp_lat: Optional[float] = None
tnp_lon: Optional[float] = None
tnp_address: Optional[str] = None
# Ресурсы
teams_count: Optional[int] = None
team_size: Optional[int] = None
has_dog: Optional[bool] = None
extra_resources: Optional[List[str]] = None
# Исход
found_alive: Optional[bool] = None
found_distance_km: Optional[float] = None
found_direction: Optional[str] = None
found_location_type: Optional[str] = None
found_lat: Optional[float] = None
found_lon: Optional[float] = None
search_duration_hours: Optional[float] = None
who_found: Optional[str] = None
# Статус
status: Optional[str] = None
class CaseListResponse(BaseModel):
"""Response for list endpoint with pagination"""
total: int
skip: int
limit: int
cases: List[CaseOut]
@router.post("/cases", response_model=CaseOut, status_code=201)
async def create_case(
case_data: CaseCreate,
db: Session = Depends(get_db)
):
"""
Создать новый случай поиска.
Принимает все поля из формы опроса (5 шагов).
Публичный эндпоинт - не требует аутентификации.
"""
db_case = Case(**case_data.model_dump(exclude_unset=True))
db.add(db_case)
db.commit()
db.refresh(db_case)
return db_case
@router.get("/cases", response_model=CaseListResponse)
async def list_cases(
skip: int = Query(0, ge=0, description="Количество пропускаемых записей"),
limit: int = Query(50, ge=1, le=100, description="Максимум записей на страницу"),
status: Optional[str] = Query(None, description="Фильтр по статусу: active/closed/archived"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Получить список случаев с пагинацией и фильтрацией.
- **skip**: смещение (для пагинации)
- **limit**: количество записей (макс 100)
- **status**: фильтр по статусу (active/closed/archived)
Требуется аутентификация (operator, field, admin).
"""
query = db.query(Case)
if status:
query = query.filter(Case.status == status)
total = query.count()
cases = query.order_by(desc(Case.created_at)).offset(skip).limit(limit).all()
return {
"total": total,
"skip": skip,
"limit": limit,
"cases": cases
}
@router.get("/cases/{case_id}", response_model=CaseOut)
async def get_case(
case_id: UUID,
db: Session = Depends(get_db)
):
"""
Получить случай по ID.
Возвращает все поля случая включая исход (если заполнен).
Публичный эндпоинт - не требует аутентификации.
"""
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
return case
@router.patch("/cases/{case_id}", response_model=CaseOut)
async def update_case(
case_id: UUID,
case_update: CaseUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Обновить случай (частичное обновление).
Используется для:
- Корректировки данных опроса
- Внесения исхода поиска (found_alive, found_distance_km и т.д.)
- Изменения статуса (active → closed)
Требуется аутентификация (operator, field, admin).
"""
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
update_data = case_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(case, field, value)
db.commit()
db.refresh(case)
return case
@router.delete("/cases/{case_id}", status_code=204)
async def delete_case(
case_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
"""
Удалить случай (только для admin).
В продакшене рекомендуется использовать архивацию вместо удаления.
"""
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
db.delete(case)
db.commit()
return None
+159
View File
@@ -0,0 +1,159 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Dict, List, Optional, Literal
from database import get_db
from models import Case
from services.stats_service import (
get_dashboard_stats,
get_statistical_recommendation,
get_heatmap_with_cache,
DashboardStats,
StatisticalRecommendation
)
router = APIRouter()
class HeatmapPoint(BaseModel):
lat: float
lon: float
intensity: float
case_id: str
metadata: Dict
class HeatmapResponse(BaseModel):
points: List[HeatmapPoint]
total: int
filters_applied: Dict
class StatisticalRecommendationRequest(BaseModel):
age: int
season: Optional[str] = None
terrain_primary: Optional[str] = None
@router.get("/summary", response_model=DashboardStats)
async def get_summary(db: Session = Depends(get_db)):
"""
Получить агрегированную статистику для дашборда.
Возвращает:
- Общее количество случаев (всего, активных, закрытых)
- Распределение по полу, возрасту, психотипу, диагнозам, сезонам
- Средняя дистанция находки
- Средняя длительность поиска
- Процент выживаемости
"""
return get_dashboard_stats(db)
@router.post("/recommendation", response_model=StatisticalRecommendation)
async def get_recommendation(
request: StatisticalRecommendationRequest,
db: Session = Depends(get_db)
):
"""
Получить статистические рекомендации на основе похожих случаев.
Фильтры (в порядке приоритета):
1. Возраст ±2 года + сезон + terrain
2. Возраст ±2 года + сезон (если < 5 случаев)
3. Возраст ±2 года (если < 5 случаев)
4. Все случаи (если < 5 случаев)
Возвращает:
- Медианное расстояние находки
- Топ-3 направления с процентами
- Топ-5 типов локаций
- Процент выживаемости
- Размер выборки
- Использованные фильтры
"""
case_data = {
'age': request.age,
'season': request.season,
'terrain_primary': request.terrain_primary
}
return get_statistical_recommendation(case_data, db)
@router.get("/heatmap", response_model=HeatmapResponse)
async def get_heatmap(
map_type: Literal['all', 'age', 'season', 'outcome'] = Query(
'all',
description="Тип карты: all (все точки), age (по возрасту), season (по сезону), outcome (по исходу)"
),
age_group: Optional[str] = Query(
None,
description="Возрастная группа: 0-3, 4-7, 8-11, 12-14, 15-17, 18+"
),
season: Optional[str] = Query(
None,
description="Сезон: зима, весна, лето, осень"
),
year_from: Optional[int] = Query(
None,
description="Год начала периода (например, 2020)"
),
year_to: Optional[int] = Query(
None,
description="Год окончания периода (например, 2026)"
),
outcome: Optional[str] = Query(
None,
description="Исход: alive (выжил), deceased (погиб)"
),
db: Session = Depends(get_db)
):
"""
Получить данные для тепловой карты находок с кэшированием (1 час).
**Типы карт:**
- `all` - все точки с одинаковой интенсивностью
- `age` - интенсивность зависит от возраста (младше = выше)
- `season` - интенсивность зависит от сезона (зима = выше)
- `outcome` - интенсивность зависит от исхода (погиб = выше)
**Фильтры:**
- `age_group` - возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17, 18+)
- `season` - сезон (зима, весна, лето, осень)
- `year_from`, `year_to` - период по годам
- `outcome` - исход (alive, deceased)
**Кэширование:**
Результаты кэшируются на 1 час для ускорения повторных запросов.
**Возвращает:**
- `points` - массив точек с координатами, интенсивностью и метаданными
- `total` - общее количество точек
- `filters_applied` - примененные фильтры
"""
result = get_heatmap_with_cache(
db=db,
map_type=map_type,
age_group=age_group,
season=season,
year_from=year_from,
year_to=year_to,
outcome=outcome
)
points = [
HeatmapPoint(
lat=p['lat'],
lon=p['lon'],
intensity=p['intensity'],
case_id=p['case_id'],
metadata=p['metadata']
)
for p in result['points']
]
return HeatmapResponse(
points=points,
total=result['total'],
filters_applied=result['filters_applied']
)
+39
View File
@@ -0,0 +1,39 @@
import models
from sqlalchemy import inspect
mapper = inspect(models.Case)
columns = [c.key for c in mapper.columns]
print(f"Всего полей в модели Case: {len(columns)}")
print("\nПоля по категориям:")
print("\nРебёнок (Шаг 1):")
for c in columns:
if c in ["child_name", "age_years", "gender", "height_build", "clothes_upper", "clothes_lower", "shoes", "clothes_description", "special_marks", "phone_status"]:
print(f" - {c}")
print("\nЗдоровье (Шаг 2):")
for c in columns:
if c in ["has_diagnosis", "diagnosis_type", "fitness_level", "has_transport", "cant_swim"]:
print(f" - {c}")
print("\nПсихотип (Шаг 2б):")
for c in columns:
if c in ["psychotype", "psychotype_answers"]:
print(f" - {c}")
print("\nОбстоятельства (Шаг 3):")
for c in columns:
if c in ["loss_reason", "loss_time", "elapsed_hours", "last_seen_direction", "last_seen_reliability", "last_seen_description", "behavior_description", "familiar_places", "lost_before"]:
print(f" - {c}")
print("\nСреда (Шаг 4):")
for c in columns:
if c in ["season", "temperature_c", "precipitation", "visibility", "wind", "terrain"]:
print(f" - {c}")
print("\nGPS (Шаг 4):")
for c in columns:
if c in ["tnp_lat", "tnp_lon", "tnp_address"]:
print(f" - {c}")
print("\nРесурсы (Шаг 5):")
for c in columns:
if c in ["teams_count", "team_size", "has_dog", "extra_resources"]:
print(f" - {c}")
print("\nИсход:")
for c in columns:
if c in ["found_alive", "found_distance_km", "found_direction", "found_location_type", "found_lat", "found_lon", "search_duration_hours", "who_found"]:
print(f" - {c}")
+26
View File
@@ -0,0 +1,26 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
import os
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@postgres:5432/vector_mchs"
)
engine = create_engine(DATABASE_URL, echo=False)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
Base.metadata.create_all(bind=engine)
+47
View File
@@ -0,0 +1,47 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import os
from api.v1 import cases, analyze, auth, stats
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(
title="SAR-MCHS API",
description="Search and Rescue Management System API",
version="1.0.0",
lifespan=lifespan
)
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
app.include_router(cases.router, prefix="/api/v1", tags=["Cases"])
app.include_router(analyze.router, prefix="/api/v1/analyze", tags=["Analysis"])
app.include_router(stats.router, prefix="/api/v1/stats", tags=["Statistics"])
@app.get("/")
async def root():
return {
"message": "SAR-MCHS API",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@@ -0,0 +1,78 @@
-- Миграция: создание таблицы cases согласно §10 контекста
DROP TABLE IF EXISTS search_results CASCADE;
DROP TABLE IF EXISTS search_cases CASCADE;
CREATE TABLE IF NOT EXISTS cases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'active',
child_name VARCHAR(255),
age_years INTEGER NOT NULL,
gender CHAR(1),
clothes_description TEXT,
special_marks TEXT,
phone_status VARCHAR(20),
has_diagnosis BOOLEAN DEFAULT FALSE,
diagnosis_type VARCHAR[],
fitness_level VARCHAR(20),
has_transport VARCHAR(20) DEFAULT 'none',
cant_swim BOOLEAN DEFAULT FALSE,
psychotype VARCHAR(50),
psychotype_answers JSONB,
loss_reason VARCHAR(100),
loss_time TIMESTAMP,
elapsed_hours FLOAT,
last_seen_direction VARCHAR(10),
last_seen_reliability VARCHAR(20),
last_seen_description TEXT,
behavior_description TEXT,
familiar_places TEXT,
lost_before VARCHAR(20),
season VARCHAR(20),
temperature_c FLOAT,
precipitation VARCHAR(20),
visibility VARCHAR(20),
wind VARCHAR(20),
terrain VARCHAR[],
tnp_lat FLOAT,
tnp_lon FLOAT,
tnp_address TEXT,
teams_count INTEGER,
team_size INTEGER,
has_dog BOOLEAN DEFAULT FALSE,
extra_resources VARCHAR[],
found_alive BOOLEAN,
found_distance_km FLOAT,
found_direction VARCHAR(10),
found_location_type VARCHAR(50),
found_lat FLOAT,
found_lon FLOAT,
search_duration_hours FLOAT,
who_found VARCHAR(50),
confidence_avg FLOAT,
raw_text TEXT,
analysis_log JSONB
);
CREATE INDEX IF NOT EXISTS idx_cases_coords ON cases (found_lat, found_lon);
CREATE INDEX IF NOT EXISTS idx_cases_age_season ON cases (age_years, season);
CREATE INDEX IF NOT EXISTS idx_cases_status ON cases (status);
DROP TABLE IF EXISTS raw_documents CASCADE;
CREATE TABLE IF NOT EXISTS raw_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename VARCHAR(255) NOT NULL,
raw_text TEXT,
extracted_json JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
@@ -0,0 +1,12 @@
-- Миграция: добавление детализированных полей одежды и телосложения
-- Дата: 2026-05-04
ALTER TABLE cases ADD COLUMN IF NOT EXISTS height_build TEXT;
ALTER TABLE cases ADD COLUMN IF NOT EXISTS clothes_upper TEXT;
ALTER TABLE cases ADD COLUMN IF NOT EXISTS clothes_lower TEXT;
ALTER TABLE cases ADD COLUMN IF NOT EXISTS shoes TEXT;
COMMENT ON COLUMN cases.height_build IS 'Рост / телосложение (Шаг 1)';
COMMENT ON COLUMN cases.clothes_upper IS 'Одежда: верх (цвет, тип) (Шаг 1)';
COMMENT ON COLUMN cases.clothes_lower IS 'Одежда: низ (цвет, тип) (Шаг 1)';
COMMENT ON COLUMN cases.shoes IS 'Обувь (тип, цвет) (Шаг 1)';
@@ -0,0 +1,41 @@
-- Миграция: создание таблиц users и analysis_log
-- Дата: 2026-05-04
-- Таблица пользователей для аутентификации
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'operator', -- operator/field/admin
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
last_login TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users (role);
-- Таблица логов анализа
CREATE TABLE IF NOT EXISTS analysis_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
analysis_type VARCHAR(50) NOT NULL, -- scoring/claude/geo/psychotype
input_data JSONB,
output_data JSONB,
execution_time FLOAT, -- в секундах
status VARCHAR(20) DEFAULT 'success', -- success/error/partial
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_analysis_log_case_id ON analysis_log (case_id);
CREATE INDEX IF NOT EXISTS idx_analysis_log_user_id ON analysis_log (user_id);
CREATE INDEX IF NOT EXISTS idx_analysis_log_created_at ON analysis_log (created_at);
CREATE INDEX IF NOT EXISTS idx_analysis_log_type ON analysis_log (analysis_type);
COMMENT ON TABLE users IS 'Пользователи системы с ролями operator/field/admin';
COMMENT ON TABLE analysis_log IS 'Лог всех анализов случаев для аудита и отладки';
+16
View File
@@ -0,0 +1,16 @@
-- Seed: создание тестовых пользователей
-- Пароль для всех: pass123
-- Удаляем существующих пользователей (если есть)
TRUNCATE TABLE users CASCADE;
-- Создаём трёх тестовых пользователей
-- Хеш для пароля "pass123" (bcrypt)
INSERT INTO users (id, username, email, hashed_password, full_name, role, is_active, created_at)
VALUES
(gen_random_uuid(), 'operator', 'operator@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Оператор ЦОУ', 'operator', true, NOW()),
(gen_random_uuid(), 'field', 'field@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Полевой работник', 'field', true, NOW()),
(gen_random_uuid(), 'admin', 'admin@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Администратор', 'admin', true, NOW());
-- Проверка
SELECT username, email, role, is_active FROM users ORDER BY role;
@@ -0,0 +1,9 @@
-- Обновление хешей паролей для тестовых пользователей
-- Новый хеш для пароля "pass123" через bcrypt напрямую
UPDATE users
SET hashed_password = '$2b$12$gldqRRQhjH6yx3ymbZXxbOGfKYwd27cnzf6gQAKP7rho4PDSXT2Oy'
WHERE username IN ('operator', 'field', 'admin');
-- Проверка
SELECT username, role, substring(hashed_password, 1, 20) as hash_prefix FROM users;
+125
View File
@@ -0,0 +1,125 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import uuid
from database import Base
class User(Base):
"""User model for authentication"""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(100), unique=True, nullable=False)
email = Column(String(255), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255))
role = Column(String(20), nullable=False, default="operator")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
last_login = Column(DateTime)
class Case(Base):
"""Unified case model - combines search case and result"""
__tablename__ = "cases"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at = Column(DateTime, server_default=func.now())
status = Column(String(20), default="active")
# Ребёнок (Шаг 1)
child_name = Column(String(255))
age_years = Column(Integer, nullable=False)
gender = Column(String(1))
height_build = Column(Text)
clothes_upper = Column(Text)
clothes_lower = Column(Text)
shoes = Column(Text)
clothes_description = Column(Text)
special_marks = Column(Text)
phone_status = Column(String(20))
# Здоровье (Шаг 2)
has_diagnosis = Column(Boolean, default=False)
diagnosis_type = Column(ARRAY(String))
fitness_level = Column(String(20))
has_transport = Column(String(20), default="none")
cant_swim = Column(Boolean, default=False)
# Психотип (Шаг 2б)
psychotype = Column(String(50))
psychotype_answers = Column(JSONB)
# Обстоятельства (Шаг 3)
loss_reason = Column(String(100))
loss_time = Column(DateTime)
elapsed_hours = Column(Float)
last_seen_direction = Column(String(10))
last_seen_reliability = Column(String(20))
last_seen_description = Column(Text)
behavior_description = Column(Text)
familiar_places = Column(Text)
lost_before = Column(String(20))
# Среда (Шаг 4)
season = Column(String(20))
temperature_c = Column(Float)
precipitation = Column(String(20))
visibility = Column(String(20))
wind = Column(String(20))
terrain = Column(ARRAY(String))
# GPS (Шаг 4)
tnp_lat = Column(Float)
tnp_lon = Column(Float)
tnp_address = Column(Text)
# Ресурсы (Шаг 5)
teams_count = Column(Integer)
team_size = Column(Integer)
has_dog = Column(Boolean, default=False)
extra_resources = Column(ARRAY(String))
# Исход (заполняется после завершения)
found_alive = Column(Boolean)
found_distance_km = Column(Float)
found_direction = Column(String(10))
found_location_type = Column(String(50))
found_lat = Column(Float)
found_lon = Column(Float)
search_duration_hours = Column(Float)
who_found = Column(String(50))
# Мета
confidence_avg = Column(Float)
raw_text = Column(Text)
analysis_log = Column(JSONB)
class AnalysisLog(Base):
"""Analysis log for audit and debugging"""
__tablename__ = "analysis_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
analysis_type = Column(String(50), nullable=False)
input_data = Column(JSONB)
output_data = Column(JSONB)
execution_time = Column(Float)
status = Column(String(20), default="success")
error_message = Column(Text)
created_at = Column(DateTime, server_default=func.now())
class RawDocument(Base):
"""Raw document storage for parsed reports"""
__tablename__ = "raw_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String(255), nullable=False)
raw_text = Column(Text)
extracted_json = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
+89
View File
@@ -0,0 +1,89 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
import uuid
from database import Base
class Case(Base):
"""Unified case model - combines search case and result"""
__tablename__ = "cases"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at = Column(DateTime, server_default=func.now())
status = Column(String(20), default='active') # active/closed/archived
# Ребёнок
child_name = Column(String(255))
age_years = Column(Integer, nullable=False)
gender = Column(String(1)) # М / Ж
clothes_description = Column(Text)
special_marks = Column(Text)
phone_status = Column(String(20)) # answers/silent/none
# Здоровье
has_diagnosis = Column(Boolean, default=False)
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР...
fitness_level = Column(String(20)) # low/medium/high
has_transport = Column(String(20), default='none') # none/bike/scooter/other
cant_swim = Column(Boolean, default=False)
# Психотип
psychotype = Column(String(50)) # dominant/harmonic/anxious/...
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
# Обстоятельства
loss_reason = Column(String(100))
loss_time = Column(DateTime)
elapsed_hours = Column(Float)
last_seen_direction = Column(String(10))
last_seen_reliability = Column(String(20)) # exact/approx/unknown
last_seen_description = Column(Text)
behavior_description = Column(Text)
familiar_places = Column(Text)
lost_before = Column(String(20)) # yes/no/unknown
# Среда
season = Column(String(20))
temperature_c = Column(Float)
precipitation = Column(String(20))
visibility = Column(String(20))
wind = Column(String(20))
terrain = Column(ARRAY(String))
# GPS
tnp_lat = Column(Float)
tnp_lon = Column(Float)
tnp_address = Column(Text)
# Ресурсы
teams_count = Column(Integer)
team_size = Column(Integer)
has_dog = Column(Boolean, default=False)
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal
# Исход (заполняется после завершения)
found_alive = Column(Boolean)
found_distance_km = Column(Float)
found_direction = Column(String(10))
found_location_type = Column(String(50)) # forest/road/building/water/field
found_lat = Column(Float)
found_lon = Column(Float)
search_duration_hours = Column(Float)
who_found = Column(String(50)) # mchs/mvd/volunteers/self
# Мета
confidence_avg = Column(Float)
raw_text = Column(Text)
analysis_log = Column(JSONB)
class RawDocument(Base):
"""Raw document storage for parsed reports"""
__tablename__ = "raw_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String(255), nullable=False)
raw_text = Column(Text)
extracted_json = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
+89
View File
@@ -0,0 +1,89 @@
from sqlalchemy import Column, String, Float, Boolean, DateTime, Text, ARRAY
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
import uuid
from database import Base
class Case(Base):
"""Unified case model - combines search case and result"""
__tablename__ = "cases"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at = Column(DateTime, server_default=func.now())
status = Column(String(20), default='active') # active/closed/archived
# Ребёнок
child_name = Column(String(255))
age_years = Column(Integer, nullable=False)
gender = Column(String(1)) # М / Ж
clothes_description = Column(Text)
special_marks = Column(Text)
phone_status = Column(String(20)) # answers/silent/none
# Здоровье
has_diagnosis = Column(Boolean, default=False)
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР...
fitness_level = Column(String(20)) # low/medium/high
has_transport = Column(String(20), default='none') # none/bike/scooter/other
cant_swim = Column(Boolean, default=False)
# Психотип
psychotype = Column(String(50)) # dominant/harmonic/anxious/...
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
# Обстоятельства
loss_reason = Column(String(100))
loss_time = Column(DateTime)
elapsed_hours = Column(Float)
last_seen_direction = Column(String(10))
last_seen_reliability = Column(String(20)) # exact/approx/unknown
last_seen_description = Column(Text)
behavior_description = Column(Text)
familiar_places = Column(Text)
lost_before = Column(String(20)) # yes/no/unknown
# Среда
season = Column(String(20))
temperature_c = Column(Float)
precipitation = Column(String(20))
visibility = Column(String(20))
wind = Column(String(20))
terrain = Column(ARRAY(String))
# GPS
tnp_lat = Column(Float)
tnp_lon = Column(Float)
tnp_address = Column(Text)
# Ресурсы
teams_count = Column(Integer)
team_size = Column(Integer)
has_dog = Column(Boolean, default=False)
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal
# Исход (заполняется после завершения)
found_alive = Column(Boolean)
found_distance_km = Column(Float)
found_direction = Column(String(10))
found_location_type = Column(String(50)) # forest/road/building/water/field
found_lat = Column(Float)
found_lon = Column(Float)
search_duration_hours = Column(Float)
who_found = Column(String(50)) # mchs/mvd/volunteers/self
# Мета
confidence_avg = Column(Float)
raw_text = Column(Text)
analysis_log = Column(JSONB)
class RawDocument(Base):
"""Raw document storage for parsed reports"""
__tablename__ = "raw_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String(255), nullable=False)
raw_text = Column(Text)
extracted_json = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
+93
View File
@@ -0,0 +1,93 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
import uuid
from database import Base
class Case(Base):
Unified case model - combines search case and result
__tablename__ = "cases"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at = Column(DateTime, server_default=func.now())
status = Column(String(20), default="active") # active/closed/archived
# Ребёнок (Шаг 1)
child_name = Column(String(255))
age_years = Column(Integer, nullable=False)
gender = Column(String(1)) # М / Ж
height_build = Column(Text) # Рост / телосложение
clothes_upper = Column(Text) # Одежда: верх (цвет, тип)
clothes_lower = Column(Text) # Одежда: низ (цвет, тип)
shoes = Column(Text) # Обувь (тип, цвет)
clothes_description = Column(Text) # Общее описание одежды (legacy)
special_marks = Column(Text) # Особые приметы
phone_status = Column(String(20)) # answers/silent/none
# Здоровье (Шаг 2)
has_diagnosis = Column(Boolean, default=False)
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР, слабое зрение, слабый слух...
fitness_level = Column(String(20)) # low/medium/high
has_transport = Column(String(20), default="none") # none/bike/scooter/other
cant_swim = Column(Boolean, default=False)
# Психотип (Шаг 2б)
psychotype = Column(String(50)) # dominant/harmonic/anxious/introvert_passive/introvert_active
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
# Обстоятельства (Шаг 3)
loss_reason = Column(String(100)) # потерялся в лесу/ушёл из дома/в городе/не вернулся с прогулки/на мероприятии/другое
loss_time = Column(DateTime)
elapsed_hours = Column(Float)
last_seen_direction = Column(String(10)) # С/СВ/В/ЮВ/Ю/ЮЗ/З/СЗ/неизвестно
last_seen_reliability = Column(String(20)) # exact/approx/unknown
last_seen_description = Column(Text)
behavior_description = Column(Text) # Поведение при стрессе
familiar_places = Column(Text) # Знакомые места
lost_before = Column(String(20)) # yes/no/unknown
# Среда (Шаг 4)
season = Column(String(20)) # зима/весна/лето/осень
temperature_c = Column(Float)
precipitation = Column(String(20)) # нет/морось/дождь/ливень/снег/гроза/туман
visibility = Column(String(20)) # хорошая/ограниченная/плохая
wind = Column(String(20)) # штиль/слабый/умеренный/сильный
terrain = Column(ARRAY(String)) # густой лес, редкий лес, лесная дорога, поле, болото, водоём, город...
# GPS (Шаг 4)
tnp_lat = Column(Float)
tnp_lon = Column(Float)
tnp_address = Column(Text)
# Ресурсы (Шаг 5)
teams_count = Column(Integer)
team_size = Column(Integer)
has_dog = Column(Boolean, default=False)
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal/quadbike
# Исход (заполняется после завершения)
found_alive = Column(Boolean)
found_distance_km = Column(Float)
found_direction = Column(String(10))
found_location_type = Column(String(50)) # forest/road/building/water/field
found_lat = Column(Float)
found_lon = Column(Float)
search_duration_hours = Column(Float)
who_found = Column(String(50)) # mchs/mvd/volunteers/self
# Мета
confidence_avg = Column(Float)
raw_text = Column(Text)
analysis_log = Column(JSONB)
class RawDocument(Base):
"""Raw document storage for parsed reports"""
__tablename__ = "raw_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String(255), nullable=False)
raw_text = Column(Text)
extracted_json = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
+15
View File
@@ -0,0 +1,15 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
httpx==0.28.1
python-docx==1.1.2
python-multipart==0.0.17
pydantic==2.10.3
pydantic-settings==2.6.1
email-validator==2.1.0
psycopg2-binary==2.9.12
pytest==8.3.4
+258
View File
@@ -0,0 +1,258 @@
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any
from datetime import datetime
from uuid import UUID
class CaseCreate(BaseModel):
"""Schema for creating a new case"""
# Ребёнок (Шаг 1)
child_name: Optional[str] = None
age_years: int = Field(..., ge=0, le=18)
gender: Optional[str] = Field(None, pattern="^[МЖ]$")
height_build: Optional[str] = None
clothes_upper: Optional[str] = None
clothes_lower: Optional[str] = None
shoes: Optional[str] = None
clothes_description: Optional[str] = None
special_marks: Optional[str] = None
phone_status: Optional[str] = Field(None, pattern="^(answers|silent|none)$")
# Здоровье (Шаг 2)
has_diagnosis: bool = False
diagnosis_type: Optional[List[str]] = None
fitness_level: Optional[str] = Field(None, pattern="^(low|medium|high)$")
has_transport: str = Field(default="none", pattern="^(none|bike|scooter|other)$")
cant_swim: bool = False
# Психотип (Шаг 2б)
psychotype: Optional[str] = None
psychotype_answers: Optional[Dict[str, Any]] = None
# Обстоятельства (Шаг 3)
loss_reason: Optional[str] = None
loss_time: Optional[datetime] = None
elapsed_hours: Optional[float] = Field(None, ge=0)
last_seen_direction: Optional[str] = None
last_seen_reliability: Optional[str] = Field(None, pattern="^(exact|approx|unknown)$")
last_seen_description: Optional[str] = None
behavior_description: Optional[str] = None
familiar_places: Optional[str] = None
lost_before: Optional[str] = Field(None, pattern="^(yes|no|unknown)$")
# Среда (Шаг 4)
season: Optional[str] = None
temperature_c: Optional[float] = None
precipitation: Optional[str] = None
visibility: Optional[str] = None
wind: Optional[str] = None
terrain: Optional[List[str]] = None
# GPS (Шаг 4)
tnp_lat: Optional[float] = Field(None, ge=-90, le=90)
tnp_lon: Optional[float] = Field(None, ge=-180, le=180)
tnp_address: Optional[str] = None
# Ресурсы (Шаг 5)
teams_count: Optional[int] = Field(None, ge=0)
team_size: Optional[int] = Field(None, ge=0)
has_dog: bool = False
extra_resources: Optional[List[str]] = None
model_config = ConfigDict(json_schema_extra={
"example": {
"child_name": "Иван",
"age_years": 8,
"gender": "М",
"loss_reason": "потерялся в лесу",
"season": "лето",
"temperature_c": 22.0,
"tnp_lat": 53.9,
"tnp_lon": 27.56,
"teams_count": 3,
"team_size": 5
}
})
class CaseOut(BaseModel):
"""Schema for case output"""
id: UUID
created_at: datetime
status: str
# Ребёнок
child_name: Optional[str] = None
age_years: int
gender: Optional[str] = None
height_build: Optional[str] = None
clothes_upper: Optional[str] = None
clothes_lower: Optional[str] = None
shoes: Optional[str] = None
clothes_description: Optional[str] = None
special_marks: Optional[str] = None
phone_status: Optional[str] = None
# Здоровье
has_diagnosis: bool
diagnosis_type: Optional[List[str]] = None
fitness_level: Optional[str] = None
has_transport: str
cant_swim: bool
# Психотип
psychotype: Optional[str] = None
psychotype_answers: Optional[Dict[str, Any]] = None
# Обстоятельства
loss_reason: Optional[str] = None
loss_time: Optional[datetime] = None
elapsed_hours: Optional[float] = None
last_seen_direction: Optional[str] = None
last_seen_reliability: Optional[str] = None
last_seen_description: Optional[str] = None
behavior_description: Optional[str] = None
familiar_places: Optional[str] = None
lost_before: Optional[str] = None
# Среда
season: Optional[str] = None
temperature_c: Optional[float] = None
precipitation: Optional[str] = None
visibility: Optional[str] = None
wind: Optional[str] = None
terrain: Optional[List[str]] = None
# GPS
tnp_lat: Optional[float] = None
tnp_lon: Optional[float] = None
tnp_address: Optional[str] = None
# Ресурсы
teams_count: Optional[int] = None
team_size: Optional[int] = None
has_dog: bool
extra_resources: Optional[List[str]] = None
# Исход
found_alive: Optional[bool] = None
found_distance_km: Optional[float] = None
found_direction: Optional[str] = None
found_location_type: Optional[str] = None
found_lat: Optional[float] = None
found_lon: Optional[float] = None
search_duration_hours: Optional[float] = None
who_found: Optional[str] = None
# Мета
confidence_avg: Optional[float] = None
raw_text: Optional[str] = None
analysis_log: Optional[Dict[str, Any]] = None
model_config = ConfigDict(from_attributes=True)
class SearchZone(BaseModel):
"""Search zone with priority and details"""
priority: int = Field(..., ge=1, le=3)
name: str
direction: str
distance_km: float
score: float
reasoning: str
coordinates: Optional[List[List[float]]] = None
class BehavioralProfile(BaseModel):
"""Active behavioral profile"""
type: str
title: str
recommendations: List[str]
modifiers: Dict[str, float]
class AnalysisResult(BaseModel):
"""Complete analysis result"""
case_id: UUID
urgency_level: str = Field(..., pattern="^(КРИТИЧЕСКИЙ|ВЫСОКИЙ|УМЕРЕННЫЙ)$")
urgency_reason: str
time_window_hours: Optional[float] = None
active_profiles: List[BehavioralProfile]
immediate_actions: List[str] = Field(..., min_length=3, max_length=3)
search_zones: List[SearchZone] = Field(..., min_length=1, max_length=3)
key_objects: List[str]
team_assignments: Optional[Dict[str, str]] = None
behavioral_forecast: str
dog_recommendations: Optional[List[str]] = None
max_distance_km: float
confidence_score: float = Field(..., ge=0, le=1)
created_at: datetime
execution_time: Optional[float] = None
model_config = ConfigDict(json_schema_extra={
"example": {
"case_id": "123e4567-e89b-12d3-a456-426614174000",
"urgency_level": "ВЫСОКИЙ",
"urgency_reason": "Ребёнок 8 лет, прошло 4 часа, температура +15°C",
"time_window_hours": 12.0,
"active_profiles": [
{
"type": "age_8_12",
"title": "Возраст 8-12 лет",
"recommendations": ["Радиус поиска до 3 км", "Проверить дороги и тропы"],
"modifiers": {"distance": 1.0, "roads": 1.2}
}
],
"immediate_actions": [
"Перекрыть все дороги в радиусе 2 км",
"Проверить водоёмы в радиусе 1 км",
"Организовать оклик по имени"
],
"search_zones": [
{
"priority": 1,
"name": "Лесной массив северо-восток",
"direction": "СВ",
"distance_km": 1.2,
"score": 0.85,
"reasoning": "Последнее направление движения, густой лес"
}
],
"key_objects": ["Озеро Круглое (800м СВ)", "Лесная дорога (500м С)"],
"behavioral_forecast": "Ребёнок скорее всего движется вдоль дороги или тропы",
"max_distance_km": 3.5,
"confidence_score": 0.82,
"created_at": "2026-05-04T13:45:00Z"
}
})
class UserCreate(BaseModel):
"""Schema for creating a new user"""
username: str = Field(..., min_length=3, max_length=100)
email: str = Field(..., pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
password: str = Field(..., min_length=8)
full_name: Optional[str] = None
role: str = Field(default="operator", pattern="^(operator|field|admin)$")
class UserOut(BaseModel):
"""Schema for user output"""
id: UUID
username: str
email: str
full_name: Optional[str] = None
role: str
is_active: bool
created_at: datetime
last_login: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
+85
View File
@@ -0,0 +1,85 @@
"""
Seed script для создания тестовых пользователей
Создаёт трёх пользователей с разными ролями: operator, field, admin
"""
import sys
sys.path.append('/app')
from sqlalchemy.orm import Session
from passlib.context import CryptContext
from database import SessionLocal
from models import User
import uuid
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_password_hash(password: str) -> str:
# Обрезаем пароль до 72 байт для bcrypt
return pwd_context.hash(password[:72])
def create_test_users():
"""Создание тестовых пользователей"""
db = SessionLocal()
try:
# Проверяем, есть ли уже пользователи
existing_users = db.query(User).count()
if existing_users > 0:
print(f"В базе уже есть {existing_users} пользователей. Пропускаем seed.")
return
test_users = [
{
"username": "operator",
"email": "operator@mchs.by",
"password": "pass123",
"full_name": "Оператор ЦОУ",
"role": "operator"
},
{
"username": "field",
"email": "field@mchs.by",
"password": "pass123",
"full_name": "Полевой работник",
"role": "field"
},
{
"username": "admin",
"email": "admin@mchs.by",
"password": "pass123",
"full_name": "Администратор",
"role": "admin"
}
]
print("Создание тестовых пользователей...")
for user_data in test_users:
password = user_data.pop("password")
hashed_password = get_password_hash(password)
db_user = User(
id=uuid.uuid4(),
hashed_password=hashed_password,
is_active=True,
**user_data
)
db.add(db_user)
print(f" + {user_data['username']} ({user_data['role']})")
db.commit()
print("\nТестовые пользователи созданы!")
print("\nДля входа используйте пароль: pass123")
print(" operator / pass123")
print(" field / pass123")
print(" admin / pass123")
except Exception as e:
print(f"Ошибка: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
create_test_users()
+23
View File
@@ -0,0 +1,23 @@
from .claude_service import analyze_case
from .stats_service import get_statistical_recommendation
from .scoring_service import WeightedScorer, create_scorer_for_case, get_weight_explanation
from .geo_service import build_search_zones, haversine, Zone
from .distance_service import calculate_max_distance, get_distance_priors, get_distance_statistics
from .psychotype_service import detect_psychotype, get_psychotype_modifiers, get_search_recommendations
__all__ = [
"analyze_case",
"get_statistical_recommendation",
"WeightedScorer",
"create_scorer_for_case",
"get_weight_explanation",
"build_search_zones",
"haversine",
"Zone",
"calculate_max_distance",
"get_distance_priors",
"get_distance_statistics",
"detect_psychotype",
"get_psychotype_modifiers",
"get_search_recommendations"
]
+312
View File
@@ -0,0 +1,312 @@
"""
Claude AI service for case analysis with fallback to scoring service.
Integrates geo_service zones and scoring_service rankings.
"""
import os
import json
from typing import Dict, List, Optional
from pydantic import BaseModel
import httpx
from .geo_service import build_search_zones
from .scoring_service import WeightedScorer
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
print(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:
print(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-4-20250514",
"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:
print(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
)
+316
View File
@@ -0,0 +1,316 @@
"""
Distance calculation service based on search and rescue statistics.
Implements distance formulas and prior probabilities based on:
- PSO EXTREMUM data (400 cases, 2015)
- Age-based movement speeds
- Terrain and weather modifiers
"""
from typing import Dict
def calculate_max_distance(case_data: dict) -> float:
"""
Calculate maximum probable distance using the formula:
Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП
Where:
- Time: elapsed time in hours
- НормС: base speed by age (km/h)
- СП: terrain coefficient
- СКД: coefficient for diagnosis (not implemented yet)
- СУ: coefficient for urgency (not implemented yet)
- СУТ: fatigue coefficient (5% reduction per hour)
- ВВС: time of day coefficient
- ВП: weather coefficient
Args:
case_data: Dictionary with case information
- age: age in years
- elapsed_hours: time elapsed since last seen
- terrain_primary: terrain type
- time_of_day: time of day (день/ночь/сумерки)
- weather: weather conditions
Returns:
Maximum probable distance in kilometers
"""
# Extract data
age = case_data.get('age', 10)
elapsed_hours = case_data.get('elapsed_hours', 1.0)
terrain = case_data.get('terrain_primary', 'лес')
time_of_day = case_data.get('time_of_day', 'день')
weather = case_data.get('weather', 'нет')
# НормС - Base speed by age (km/h)
base_speed = get_base_speed(age)
# СП - Terrain coefficient
terrain_coef = get_terrain_coefficient(terrain)
# СКД - Diagnosis coefficient (placeholder, can be expanded)
diagnosis_coef = 1.0
# СУ - Urgency coefficient (placeholder, can be expanded)
urgency_coef = 1.0
# СУТ - Fatigue coefficient (5% reduction per hour)
fatigue_coef = max(0.3, 1.0 - (0.05 * elapsed_hours))
# ВВС - Time of day coefficient
time_coef = get_time_of_day_coefficient(time_of_day)
# ВП - Weather coefficient
weather_coef = get_weather_coefficient(weather)
# Calculate distance
distance = (
elapsed_hours *
base_speed *
terrain_coef *
diagnosis_coef *
urgency_coef *
fatigue_coef *
time_coef *
weather_coef
)
return round(distance, 2)
def get_base_speed(age: int) -> float:
"""
Get base movement speed by age (НормС).
Args:
age: Age in years
Returns:
Base speed in km/h
"""
# Скорость смещения потерявшегося ребёнка (не скорость ходьбы)
# ПСО ЭКСТРЕМУМ: 94% найдены в пределах 3 км
if age <= 2:
return 0.3
elif age <= 5:
return 0.7
elif age <= 8:
return 1.2
elif age <= 12:
return 1.5
elif age <= 15:
return 2.0
elif age <= 17:
return 2.5
elif age <= 64:
return 2.5
else: # 65+
return 1.5
def get_terrain_coefficient(terrain: str) -> float:
"""
Get terrain movement coefficient (СП).
Args:
terrain: Terrain type
Returns:
Terrain coefficient (0.0 - 1.0)
"""
terrain_lower = terrain.lower()
terrain_map = {
'лесная дорога': 0.8,
'сложный лес': 0.25,
'густой лес': 0.25,
'простой лес': 0.5,
'лес': 0.5,
'дорога': 0.8,
'тропа': 0.8,
'болото': 0.2,
'поле': 0.9,
'луг': 0.9,
'город': 1.0,
'населённый пункт': 1.0,
'горы': 0.3,
'овраг': 0.3
}
for key, value in terrain_map.items():
if key in terrain_lower:
return value
# Default for unknown terrain
return 0.5
def get_time_of_day_coefficient(time_of_day: str) -> float:
"""
Get time of day movement coefficient (ВВС).
Args:
time_of_day: Time of day
Returns:
Time coefficient (0.0 - 1.0)
"""
time_lower = time_of_day.lower()
if 'ночь' in time_lower:
return 0.5
elif 'сумерки' in time_lower or 'вечер' in time_lower:
return 0.5
else: # день
return 1.0
def get_weather_coefficient(weather: str) -> float:
"""
Get weather movement coefficient (ВП).
Args:
weather: Weather conditions
Returns:
Weather coefficient (0.0 - 1.0)
"""
weather_lower = weather.lower()
if 'ливень' in weather_lower or 'сильный дождь' in weather_lower:
return 0.6
elif 'дождь' in weather_lower:
return 0.8
elif 'туман' in weather_lower:
return 0.7
elif 'снег' in weather_lower or 'метель' in weather_lower:
return 0.6
elif 'жара' in weather_lower:
return 0.8
else: # нет / ясно
return 1.0
def get_distance_priors(age_years: int) -> Dict[str, float]:
"""
Get prior probabilities for distance zones based on age.
Based on PSO EXTREMUM data (400 cases, 2015).
Args:
age_years: Age in years
Returns:
Dictionary with distance zone probabilities
"""
if age_years < 8:
# До 8 лет - дети младшего возраста
return {
'0_500m': 0.45,
'500_1500m': 0.35,
'1500_2500m': 0.15,
'2500_3500m': 0.04,
'3500_plus': 0.01
}
elif age_years <= 12:
# 8-12 лет - дети среднего возраста
return {
'0_500m': 0.28,
'500_1500m': 0.25,
'1500_2500m': 0.22,
'2500_3500m': 0.19,
'3500_5000m': 0.03,
'5000_plus': 0.03
}
elif age_years <= 17:
# 13-17 лет - подростки
return {
'0_500m': 0.15,
'500_1500m': 0.20,
'1500_2500m': 0.25,
'2500_3500m': 0.20,
'3500_5000m': 0.12,
'5000_plus': 0.08
}
elif age_years <= 64:
# 18-64 года - взрослые
return {
'0_500m': 0.12,
'500_1500m': 0.18,
'1500_2500m': 0.22,
'2500_3500m': 0.20,
'3500_5000m': 0.15,
'5000_plus': 0.13
}
else:
# 65+ лет - пожилые
return {
'0_500m': 0.35,
'500_1500m': 0.30,
'1500_2500m': 0.20,
'2500_3500m': 0.10,
'3500_5000m': 0.03,
'5000_plus': 0.02
}
def get_distance_zone(distance_km: float) -> str:
"""
Get distance zone name for a given distance.
Args:
distance_km: Distance in kilometers
Returns:
Zone name
"""
if distance_km < 0.5:
return '0_500m'
elif distance_km < 1.5:
return '500_1500m'
elif distance_km < 2.5:
return '1500_2500m'
elif distance_km < 3.5:
return '2500_3500m'
elif distance_km < 5.0:
return '3500_5000m'
else:
return '5000_plus'
def get_distance_statistics(age_years: int, elapsed_hours: float, terrain: str) -> Dict:
"""
Get comprehensive distance statistics for a case.
Args:
age_years: Age in years
elapsed_hours: Time elapsed since last seen
terrain: Terrain type
Returns:
Dictionary with distance statistics
"""
# Calculate max distance
case_data = {
'age': age_years,
'elapsed_hours': elapsed_hours,
'terrain_primary': terrain,
'time_of_day': 'день',
'weather': 'нет'
}
max_distance = calculate_max_distance(case_data)
# Get priors
priors = get_distance_priors(age_years)
# Get current zone
current_zone = get_distance_zone(max_distance)
return {
'max_distance_km': max_distance,
'current_zone': current_zone,
'zone_probability': priors.get(current_zone, 0.0),
'all_priors': priors,
'base_speed_kmh': get_base_speed(age_years),
'terrain_coefficient': get_terrain_coefficient(terrain)
}
+381
View File
@@ -0,0 +1,381 @@
"""
Geo service for building search zones and querying OpenStreetMap data via Overpass API.
"""
import math
import json
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Optional, Tuple
import httpx
from pydantic import BaseModel
class Zone(BaseModel):
"""Search zone with geographic features."""
direction: str # N, NE, E, SE, S, SW, W, NW
distance_km: float
forest_pct: float
road_density: float # km of roads per km²
water_distance_km: Optional[float]
settlement_distance_km: Optional[float]
# Cache configuration
CACHE_DIR = Path("/tmp/overpass_cache")
CACHE_TTL_HOURS = 24
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Direction mappings
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
DIRECTION_ANGLES = {
"N": 0,
"NE": 45,
"E": 90,
"SE": 135,
"S": 180,
"SW": 225,
"W": 270,
"NW": 315
}
# Search distances computed dynamically from max_distance_km
def _build_search_distances(max_distance_km: float) -> list:
max_m = int(max_distance_km * 1000)
raw = [int(max_m * 0.25), int(max_m * 0.50), int(max_m * 0.75), max_m]
clamped = [max(200, min(5000, d)) for d in raw]
return sorted(set(clamped))
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate distance between two points on Earth using Haversine formula.
Args:
lat1, lon1: First point coordinates
lat2, lon2: Second point coordinates
Returns:
Distance in kilometers
"""
R = 6371 # Earth radius in km
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
c = 2 * math.asin(math.sqrt(a))
return R * c
def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]:
"""
Calculate bounding box for a sector.
Args:
lat, lon: Center point
direction: Sector direction (N, NE, E, etc.)
radius_m: Radius in meters
Returns:
(min_lat, min_lon, max_lat, max_lon)
"""
# Convert radius to degrees (approximate)
radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator
angle = DIRECTION_ANGLES[direction]
angle_rad = math.radians(angle)
# Calculate sector boundaries (45° sectors)
angle_start = angle - 22.5
angle_end = angle + 22.5
# Simple bounding box (can be optimized for actual sector shape)
lat_offset = radius_deg * math.cos(angle_rad)
lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat))
min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5
max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5
min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5
max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5
return (min_lat, min_lon, max_lat, max_lon)
def get_cache_key(query: str) -> str:
"""Generate cache key from query."""
return hashlib.md5(query.encode()).hexdigest()
def get_cached_result(cache_key: str) -> Optional[Dict]:
"""Get cached Overpass API result if not expired."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
if not cache_file.exists():
return None
try:
with open(cache_file, 'r') as f:
cached = json.load(f)
cached_time = datetime.fromisoformat(cached['timestamp'])
if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS):
cache_file.unlink()
return None
return cached['data']
except Exception:
return None
def save_to_cache(cache_key: str, data: Dict):
"""Save Overpass API result to cache."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
try:
with open(cache_file, 'w') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'data': data
}, f)
except Exception:
pass
async def query_overpass(query: str) -> Dict:
"""
Query Overpass API with caching.
Args:
query: Overpass QL query
Returns:
API response as dict
"""
cache_key = get_cache_key(query)
# Check cache
cached = get_cached_result(cache_key)
if cached is not None:
return cached
# Query API
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
OVERPASS_URL,
data={'data': query},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
response.raise_for_status()
data = response.json()
# Save to cache
save_to_cache(cache_key, data)
return data
except Exception as e:
# Return empty result on error
return {'elements': []}
def calculate_road_length(elements: List[Dict]) -> float:
"""
Calculate total road length from Overpass way elements.
Args:
elements: List of way elements from Overpass
Returns:
Total length in kilometers
"""
total_length = 0.0
for element in elements:
if element.get('type') != 'way':
continue
nodes = element.get('geometry', [])
if len(nodes) < 2:
continue
# Calculate length by summing distances between consecutive nodes
for i in range(len(nodes) - 1):
lat1, lon1 = nodes[i]['lat'], nodes[i]['lon']
lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon']
total_length += haversine(lat1, lon1, lat2, lon2)
return total_length
def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]:
"""
Find distance to nearest element.
Args:
lat, lon: Reference point
elements: List of node elements from Overpass
Returns:
Distance in kilometers, or None if no elements
"""
if not elements:
return None
min_distance = float('inf')
for element in elements:
if element.get('type') != 'node':
continue
elem_lat = element.get('lat')
elem_lon = element.get('lon')
if elem_lat is None or elem_lon is None:
continue
distance = haversine(lat, lon, elem_lat, elem_lon)
min_distance = min(min_distance, distance)
return min_distance if min_distance != float('inf') else None
def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float:
"""
Estimate forest coverage percentage.
Args:
elements: List of way elements from Overpass
radius_m: Search radius in meters
Returns:
Forest coverage as percentage (0-100)
"""
if not elements:
return 0.0
# Approximate: count forest ways and estimate coverage
# This is a simplified calculation
forest_ways = len([e for e in elements if e.get('type') == 'way'])
# Rough heuristic: each forest way covers ~0.1 km²
# Total search area = π * r²
search_area_km2 = math.pi * (radius_m / 1000) ** 2
estimated_forest_km2 = forest_ways * 0.1
coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100)
return round(coverage_pct, 1)
async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict:
"""
Get geographic features for a zone using Overpass API.
Args:
lat, lon: Center point
direction: Sector direction
radius_m: Search radius in meters
Returns:
Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct
"""
# Query roads
roads_query = f"""
[out:json];
(
way[highway](around:{radius_m},{lat},{lon});
);
out geom;
"""
roads_data = await query_overpass(roads_query)
roads_km = calculate_road_length(roads_data.get('elements', []))
# Query water bodies
water_query = f"""
[out:json];
(
node[natural=water](around:{radius_m},{lat},{lon});
way[natural=water](around:{radius_m},{lat},{lon});
);
out center;
"""
water_data = await query_overpass(water_query)
water_distance = find_nearest_distance(lat, lon, water_data.get('elements', []))
# Query settlements
settlement_query = f"""
[out:json];
(
node[place~"village|town|city"](around:{radius_m},{lat},{lon});
);
out;
"""
settlement_data = await query_overpass(settlement_query)
settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', []))
# Query forests
forest_query = f"""
[out:json];
(
way[landuse=forest](around:{radius_m},{lat},{lon});
way[natural=wood](around:{radius_m},{lat},{lon});
);
out geom;
"""
forest_data = await query_overpass(forest_query)
forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m)
# Calculate road density (km of roads per km²)
search_area_km2 = math.pi * (radius_m / 1000) ** 2
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
return {
'roads_km': roads_km,
'road_density': round(road_density, 2),
'water_distance_km': water_distance,
'settlement_distance_km': settlement_distance,
'forest_pct': forest_pct
}
async def build_search_zones(lat: float, lon: float, case_data: dict, max_distance_km: float = 3.0) -> List[Zone]:
"""
Build search zones around a point.
Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances
(500m, 1000m, 2000m, 5000m) and queries geographic features for each.
Args:
lat: Latitude of search origin
lon: Longitude of search origin
case_data: Case information (for future enhancements)
Returns:
List of Zone objects with geographic features
"""
zones = []
for distance_m in _build_search_distances(max_distance_km):
for direction in DIRECTIONS:
# Get features for this zone
features = await get_zone_features(lat, lon, direction, distance_m)
zone = Zone(
direction=direction,
distance_km=distance_m / 1000,
forest_pct=features['forest_pct'],
road_density=features['road_density'],
water_distance_km=features['water_distance_km'],
settlement_distance_km=features['settlement_distance_km']
)
zones.append(zone)
return zones
+377
View File
@@ -0,0 +1,377 @@
"""
Geo service for building search zones and querying OpenStreetMap data via Overpass API.
"""
import math
import json
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Optional, Tuple
import httpx
from pydantic import BaseModel
class Zone(BaseModel):
"""Search zone with geographic features."""
direction: str # N, NE, E, SE, S, SW, W, NW
distance_km: float
forest_pct: float
road_density: float # km of roads per km²
water_distance_km: Optional[float]
settlement_distance_km: Optional[float]
# Cache configuration
CACHE_DIR = Path("/tmp/overpass_cache")
CACHE_TTL_HOURS = 24
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Direction mappings
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
DIRECTION_ANGLES = {
"N": 0,
"NE": 45,
"E": 90,
"SE": 135,
"S": 180,
"SW": 225,
"W": 270,
"NW": 315
}
# Search distances in meters
SEARCH_DISTANCES = [500, 1000, 2000, 5000]
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate distance between two points on Earth using Haversine formula.
Args:
lat1, lon1: First point coordinates
lat2, lon2: Second point coordinates
Returns:
Distance in kilometers
"""
R = 6371 # Earth radius in km
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
c = 2 * math.asin(math.sqrt(a))
return R * c
def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]:
"""
Calculate bounding box for a sector.
Args:
lat, lon: Center point
direction: Sector direction (N, NE, E, etc.)
radius_m: Radius in meters
Returns:
(min_lat, min_lon, max_lat, max_lon)
"""
# Convert radius to degrees (approximate)
radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator
angle = DIRECTION_ANGLES[direction]
angle_rad = math.radians(angle)
# Calculate sector boundaries (45° sectors)
angle_start = angle - 22.5
angle_end = angle + 22.5
# Simple bounding box (can be optimized for actual sector shape)
lat_offset = radius_deg * math.cos(angle_rad)
lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat))
min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5
max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5
min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5
max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5
return (min_lat, min_lon, max_lat, max_lon)
def get_cache_key(query: str) -> str:
"""Generate cache key from query."""
return hashlib.md5(query.encode()).hexdigest()
def get_cached_result(cache_key: str) -> Optional[Dict]:
"""Get cached Overpass API result if not expired."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
if not cache_file.exists():
return None
try:
with open(cache_file, 'r') as f:
cached = json.load(f)
cached_time = datetime.fromisoformat(cached['timestamp'])
if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS):
cache_file.unlink()
return None
return cached['data']
except Exception:
return None
def save_to_cache(cache_key: str, data: Dict):
"""Save Overpass API result to cache."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
try:
with open(cache_file, 'w') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'data': data
}, f)
except Exception:
pass
async def query_overpass(query: str) -> Dict:
"""
Query Overpass API with caching.
Args:
query: Overpass QL query
Returns:
API response as dict
"""
cache_key = get_cache_key(query)
# Check cache
cached = get_cached_result(cache_key)
if cached is not None:
return cached
# Query API
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
OVERPASS_URL,
data={'data': query},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
response.raise_for_status()
data = response.json()
# Save to cache
save_to_cache(cache_key, data)
return data
except Exception as e:
# Return empty result on error
return {'elements': []}
def calculate_road_length(elements: List[Dict]) -> float:
"""
Calculate total road length from Overpass way elements.
Args:
elements: List of way elements from Overpass
Returns:
Total length in kilometers
"""
total_length = 0.0
for element in elements:
if element.get('type') != 'way':
continue
nodes = element.get('geometry', [])
if len(nodes) < 2:
continue
# Calculate length by summing distances between consecutive nodes
for i in range(len(nodes) - 1):
lat1, lon1 = nodes[i]['lat'], nodes[i]['lon']
lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon']
total_length += haversine(lat1, lon1, lat2, lon2)
return total_length
def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]:
"""
Find distance to nearest element.
Args:
lat, lon: Reference point
elements: List of node elements from Overpass
Returns:
Distance in kilometers, or None if no elements
"""
if not elements:
return None
min_distance = float('inf')
for element in elements:
if element.get('type') != 'node':
continue
elem_lat = element.get('lat')
elem_lon = element.get('lon')
if elem_lat is None or elem_lon is None:
continue
distance = haversine(lat, lon, elem_lat, elem_lon)
min_distance = min(min_distance, distance)
return min_distance if min_distance != float('inf') else None
def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float:
"""
Estimate forest coverage percentage.
Args:
elements: List of way elements from Overpass
radius_m: Search radius in meters
Returns:
Forest coverage as percentage (0-100)
"""
if not elements:
return 0.0
# Approximate: count forest ways and estimate coverage
# This is a simplified calculation
forest_ways = len([e for e in elements if e.get('type') == 'way'])
# Rough heuristic: each forest way covers ~0.1 km²
# Total search area = π * r²
search_area_km2 = math.pi * (radius_m / 1000) ** 2
estimated_forest_km2 = forest_ways * 0.1
coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100)
return round(coverage_pct, 1)
async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict:
"""
Get geographic features for a zone using Overpass API.
Args:
lat, lon: Center point
direction: Sector direction
radius_m: Search radius in meters
Returns:
Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct
"""
# Query roads
roads_query = f"""
[out:json];
(
way[highway](around:{radius_m},{lat},{lon});
);
out geom;
"""
roads_data = await query_overpass(roads_query)
roads_km = calculate_road_length(roads_data.get('elements', []))
# Query water bodies
water_query = f"""
[out:json];
(
node[natural=water](around:{radius_m},{lat},{lon});
way[natural=water](around:{radius_m},{lat},{lon});
);
out center;
"""
water_data = await query_overpass(water_query)
water_distance = find_nearest_distance(lat, lon, water_data.get('elements', []))
# Query settlements
settlement_query = f"""
[out:json];
(
node[place~"village|town|city"](around:{radius_m},{lat},{lon});
);
out;
"""
settlement_data = await query_overpass(settlement_query)
settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', []))
# Query forests
forest_query = f"""
[out:json];
(
way[landuse=forest](around:{radius_m},{lat},{lon});
way[natural=wood](around:{radius_m},{lat},{lon});
);
out geom;
"""
forest_data = await query_overpass(forest_query)
forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m)
# Calculate road density (km of roads per km²)
search_area_km2 = math.pi * (radius_m / 1000) ** 2
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
return {
'roads_km': roads_km,
'road_density': round(road_density, 2),
'water_distance_km': water_distance,
'settlement_distance_km': settlement_distance,
'forest_pct': forest_pct
}
async def build_search_zones(lat: float, lon: float, case_data: dict) -> List[Zone]:
"""
Build search zones around a point.
Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances
(500m, 1000m, 2000m, 5000m) and queries geographic features for each.
Args:
lat: Latitude of search origin
lon: Longitude of search origin
case_data: Case information (for future enhancements)
Returns:
List of Zone objects with geographic features
"""
zones = []
for distance_m in SEARCH_DISTANCES:
for direction in DIRECTIONS:
# Get features for this zone
features = await get_zone_features(lat, lon, direction, distance_m)
zone = Zone(
direction=direction,
distance_km=distance_m / 1000,
forest_pct=features['forest_pct'],
road_density=features['road_density'],
water_distance_km=features['water_distance_km'],
settlement_distance_km=features['settlement_distance_km']
)
zones.append(zone)
return zones
+237
View File
@@ -0,0 +1,237 @@
"""
Сервис определения психотипа пропавшего ребёнка.
Маппинг согласно §6 контекста ВЕКТОР (Шаг 2б).
Основан на методике Рындиной О.Г., Ивановой О.Ю. (Чебоксары, 2015)
8 психотипов по Грановской–Никольской (Кеттелл).
"""
from typing import Dict, List, Literal
PsychotypeStr = Literal[
'dominant',
'harmonic',
'anxious',
'introvert_passive',
'introvert_active'
]
def detect_psychotype(answers: Dict[str, str]) -> PsychotypeStr:
"""
Определяет психотип на основе 4 вопросов родителям (§6 контекста).
Args:
answers: Словарь с ответами:
- unfamiliar_behavior: 'explore' | 'wait' | 'freeze' | 'panic'
- stress_reaction: 'angry' | 'cry' | 'calm'
- leadership: 'always_leader' | 'sometimes' | 'always_follower'
- risk_taking: 'very' | 'sometimes' | 'no_cautious'
Returns:
Определенный психотип
Маппинг из §6:
- активно + лидер + рискует → dominant
- спокойно + лидер + осторожный → harmonic
- плачет + ведомый + осторожный → anxious
- замирает + ведомый + осторожный → introvert_passive
- активно/паникует + иногда → introvert_active
"""
unfamiliar = answers.get('unfamiliar_behavior', '').lower()
stress = answers.get('stress_reaction', '').lower()
leadership = answers.get('leadership', '').lower()
risk = answers.get('risk_taking', '').lower()
# Маппинг согласно §6 контекста
# активно + лидер + рискует → dominant
if unfamiliar == 'explore' and leadership == 'always_leader' and risk == 'very':
return 'dominant'
# спокойно + лидер + осторожный → harmonic
if stress == 'calm' and leadership == 'always_leader' and risk == 'no_cautious':
return 'harmonic'
# плачет + ведомый + осторожный → anxious
if stress == 'cry' and leadership == 'always_follower' and risk == 'no_cautious':
return 'anxious'
# замирает + ведомый + осторожный → introvert_passive
if unfamiliar == 'freeze' and leadership == 'always_follower' and risk == 'no_cautious':
return 'introvert_passive'
# активно/паникует + иногда → introvert_active
if (unfamiliar in ['explore', 'panic']) and leadership == 'sometimes':
return 'introvert_active'
# Дополнительные правила для неоднозначных случаев
# Лидер + рискует → скорее dominant
if leadership == 'always_leader' and risk in ['very', 'sometimes']:
return 'dominant'
# Ведомый + осторожный + плачет/замирает → anxious или introvert_passive
if leadership == 'always_follower' and risk == 'no_cautious':
if stress == 'cry':
return 'anxious'
elif unfamiliar == 'freeze':
return 'introvert_passive'
# Активно исследует + иногда лидер → introvert_active
if unfamiliar == 'explore' and leadership == 'sometimes':
return 'introvert_active'
# Дефолт: harmonic (сбалансированный)
return 'harmonic'
def get_psychotype_modifiers(psychotype: PsychotypeStr) -> Dict:
"""
Возвращает модификаторы вероятности для зон поиска и модель движения.
Args:
psychotype: Определенный психотип
Returns:
Словарь с модификаторами зон и моделью движения
"""
modifiers = {
'dominant': {
'zone_0_500': 0.7,
'zone_500_1500': 1.2,
'zone_1500_2500': 1.4,
'zone_2500plus': 1.1,
'movement_model': 'chaotic_far',
'description': 'Доминантный: активное движение, большие расстояния, хаотичное поведение'
},
'harmonic': {
'zone_0_500': 0.8,
'zone_500_1500': 1.0,
'zone_1500_2500': 1.1,
'zone_2500plus': 0.9,
'movement_model': 'linear_landmark',
'description': 'Гармоничный: рациональное движение по ориентирам, средние расстояния'
},
'anxious': {
'zone_0_500': 1.4,
'zone_500_1500': 1.1,
'zone_1500_2500': 0.5,
'zone_2500plus': 0.3,
'movement_model': 'stay',
'description': 'Тревожный: минимальное движение, остается близко к точке потери'
},
'introvert_passive': {
'zone_0_500': 1.3,
'zone_500_1500': 0.9,
'zone_1500_2500': 0.6,
'zone_2500plus': 0.2,
'movement_model': 'stay_hidden',
'description': 'Интроверт пассивный: прячется, минимальное движение, близко к точке потери'
},
'introvert_active': {
'zone_0_500': 0.8,
'zone_500_1500': 1.1,
'zone_1500_2500': 1.2,
'zone_2500plus': 0.9,
'movement_model': 'linear_landmark',
'description': 'Интроверт активный: целенаправленное движение по ориентирам, средние расстояния'
}
}
return modifiers.get(psychotype, modifiers['harmonic'])
def get_search_recommendations(psychotype: PsychotypeStr) -> Dict[str, str]:
"""
Возвращает рекомендации по тактике поиска для данного психотипа.
Args:
psychotype: Определенный психотип
Returns:
Словарь с рекомендациями по поиску
"""
recommendations = {
'dominant': {
'priority_zones': 'Средние и дальние зоны (500-2500м)',
'search_pattern': 'Широкий охват, проверка нелинейных маршрутов',
'key_locations': 'Возвышенности, открытые пространства, необычные объекты',
'communication': 'Громкие сигналы, яркие маркеры'
},
'harmonic': {
'priority_zones': 'Все зоны равномерно, акцент на 500-1500м',
'search_pattern': 'Систематический поиск вдоль троп и ориентиров',
'key_locations': 'Тропы, дороги, видимые ориентиры, укрытия',
'communication': 'Стандартные сигналы, информационные знаки'
},
'anxious': {
'priority_zones': 'Ближняя зона (0-500м) - критически важна',
'search_pattern': 'Тщательный осмотр ближайшей территории',
'key_locations': 'Укрытия, углубления, густая растительность рядом с точкой потери',
'communication': 'Спокойные голосовые сигналы, избегать резких звуков'
},
'introvert_passive': {
'priority_zones': 'Ближняя зона (0-500м), укрытия',
'search_pattern': 'Детальный осмотр укрытий и труднодоступных мест',
'key_locations': 'Заросли, ямы, под деревьями, за камнями',
'communication': 'Мягкие голосовые сигналы, визуальный контакт важнее звука'
},
'introvert_active': {
'priority_zones': 'Средние зоны (500-2500м) вдоль линейных ориентиров',
'search_pattern': 'Поиск вдоль троп, ручьев, границ леса',
'key_locations': 'Линейные ориентиры, перекрестки троп, характерные объекты',
'communication': 'Стандартные сигналы вдоль вероятных маршрутов'
}
}
return recommendations.get(psychotype, recommendations['harmonic'])
def get_psychotype_questions() -> List[Dict]:
"""
Возвращает 4 вопроса для определения психотипа (§6 контекста).
Returns:
Список вопросов с вариантами ответов
"""
return [
{
'id': 'unfamiliar_behavior',
'question': 'Как ведёт себя в незнакомой обстановке?',
'options': [
{'value': 'explore', 'label': 'Активно исследует'},
{'value': 'wait', 'label': 'Ждёт и наблюдает'},
{'value': 'freeze', 'label': 'Замирает'},
{'value': 'panic', 'label': 'Паникует'}
]
},
{
'id': 'stress_reaction',
'question': 'Реакция на стресс и неудачи?',
'options': [
{'value': 'angry', 'label': 'Злится, кричит'},
{'value': 'cry', 'label': 'Плачет, замыкается'},
{'value': 'calm', 'label': 'Спокойно ищет выход'}
]
},
{
'id': 'leadership',
'question': 'Лидер или ведомый?',
'options': [
{'value': 'always_leader', 'label': 'Всегда лидер'},
{'value': 'sometimes', 'label': 'Иногда'},
{'value': 'always_follower', 'label': 'Всегда ведомый'}
]
},
{
'id': 'risk_taking',
'question': 'Любит рисковать?',
'options': [
{'value': 'very', 'label': 'Очень'},
{'value': 'sometimes', 'label': 'Иногда'},
{'value': 'no_cautious', 'label': 'Нет, осторожный'}
]
}
]
+403
View File
@@ -0,0 +1,403 @@
"""
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
Реализация согласно §8 и §9 контекста ВЕКТОР.
"""
from typing import Dict, List, Optional
from copy import deepcopy
class WeightedScorer:
"""
Система взвешенной оценки зон поиска с учетом множественных факторов.
Базовые веса из §9 контекста.
"""
# Базовые веса факторов из §9 (сумма = 1.0)
BASE_WEIGHTS = {
'forest': 0.25,
'water': 0.20,
'roads': 0.18,
'settlement': 0.15,
'historical': 0.12,
'direction': 0.07,
'shelter': 0.03
}
# Возрастные модификаторы
AGE_MODIFIERS = {
'0-4': {
'forest': 0.6,
'water': 2.5,
'roads': 1.3,
'settlement': 1.8,
'shelter': 1.5,
'distance_mult': 0.3
},
'5-7': {
'forest': 0.8,
'water': 2.2,
'roads': 1.4,
'settlement': 1.6,
'shelter': 1.4,
'distance_mult': 0.5
},
'8-11': {
'forest': 1.1,
'water': 1.8,
'roads': 1.2,
'settlement': 1.3,
'shelter': 1.2,
'distance_mult': 0.8
},
'12-14': {
'forest': 1.3,
'water': 1.4,
'roads': 1.1,
'settlement': 1.0,
'shelter': 1.0,
'distance_mult': 1.2
},
'15-17': {
'forest': 1.4,
'water': 1.2,
'roads': 1.3,
'settlement': 0.9,
'shelter': 0.9,
'distance_mult': 1.5
}
}
# Сезонные модификаторы
SEASON_MODIFIERS = {
'зима': {
'forest': 0.8,
'water': 0.6,
'roads': 1.3,
'settlement': 1.5,
'shelter': 2.0,
'distance_mult': 0.7
},
'весна': {
'forest': 1.1,
'water': 1.8,
'roads': 1.0,
'settlement': 1.0,
'shelter': 1.2,
'distance_mult': 1.0
},
'лето': {
'forest': 1.2,
'water': 1.3,
'roads': 0.9,
'settlement': 0.8,
'shelter': 0.8,
'distance_mult': 1.3
},
'осень': {
'forest': 1.3,
'water': 1.1,
'roads': 1.0,
'settlement': 1.1,
'shelter': 1.1,
'distance_mult': 1.0
}
}
# Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста
BEHAVIORAL_PROFILES = {
'РАС': {
'water': 3.0,
'railway': 2.5,
'shelter': 2.0,
'settlement': 0.4,
'distance_mult': 2.0,
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
},
'эпилепсия': {
'water': 3.5,
'shelter': 2.5,
'distance_mult': 0.6,
'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.'
},
'СДВГ': {
'roads': 1.6,
'distance_mult': 1.4,
'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.'
},
'ЗПР': {
'settlement': 0.7,
'shelter': 1.5,
'distance_mult': 0.8,
'note': 'Не ориентируется в пространстве'
},
'велосипед': {
'distance_mult': 5.0,
'roads': 1.8,
'forest': 0.8,
'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.'
},
'самокат': {
'distance_mult': 3.0,
'roads': 1.6,
'forest': 0.9
},
'намеренный_уход': {
'forest': 0.2,
'roads': 2.5,
'settlement': 3.0,
'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.'
}
}
def __init__(self):
"""Инициализация скорера с базовыми весами."""
self.weights = deepcopy(self.BASE_WEIGHTS)
self.distance_multiplier = 1.0
self.active_profiles = []
self.critical_warnings = []
def _get_age_group(self, age: int) -> str:
"""Определяет возрастную группу."""
if age <= 4:
return '0-4'
elif age <= 7:
return '5-7'
elif age <= 11:
return '8-11'
elif age <= 14:
return '12-14'
elif age <= 17:
return '15-17'
else:
return '18-64'
def apply_age_modifiers(self, age: int):
"""Применяет возрастные модификаторы к весам."""
age_group = self._get_age_group(age)
modifiers = self.AGE_MODIFIERS.get(age_group, {})
for factor, modifier in modifiers.items():
if factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
def apply_season_modifiers(self, season: str):
"""Применяет сезонные модификаторы к весам."""
season_lower = season.lower() if season else 'лето'
modifiers = self.SEASON_MODIFIERS.get(season_lower, {})
for factor, modifier in modifiers.items():
if factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
def apply_profile(self, profile_list: List[str]):
"""
Применяет поведенческие профили к весам.
Точные коэффициенты из §8 контекста.
Args:
profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.)
"""
if not profile_list:
return
for profile_name in profile_list:
profile = self.BEHAVIORAL_PROFILES.get(profile_name)
if not profile:
continue
self.active_profiles.append(profile_name)
# Сохранить критические предупреждения
if 'critical_warning' in profile:
self.critical_warnings.append({
'profile': profile_name,
'warning': profile['critical_warning']
})
for factor, modifier in profile.items():
if factor in ['critical_warning', 'note']:
continue
elif factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
elif factor == 'railway':
# Ж/д пути — добавляем как отдельный фактор для РАС
if 'railway' not in self.weights:
self.weights['railway'] = 0.05
self.weights['railway'] *= modifier
def _normalize_weights(self):
"""Нормализует веса так, чтобы их сумма была 1.0."""
# Фильтруем None значения
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
total = sum(valid_weights.values())
if total > 0:
for key in self.weights:
if self.weights[key] is not None:
self.weights[key] /= total
else:
self.weights[key] = 0.0
def score_zone(self, zone: Dict, case: Dict, max_distance_km: float = 2.0) -> float:
"""
Оценивает зону поиска на основе её характеристик и данных случая.
Args:
zone: Словарь с характеристиками зоны
case: Данные случая (age, season, profiles и т.д.)
Returns:
float: Оценка зоны (0-100)
"""
# Сбрасываем веса к базовым
self.weights = deepcopy(self.BASE_WEIGHTS)
self.distance_multiplier = 1.0
self.active_profiles = []
self.critical_warnings = []
# Применяем модификаторы
if 'age' in case and case['age']:
self.apply_age_modifiers(case['age'])
if 'season' in case and case['season']:
self.apply_season_modifiers(case['season'])
if 'profiles' in case and case['profiles']:
self.apply_profile(case['profiles'])
# Нормализуем веса
self._normalize_weights()
# Вычисляем оценку
score = 0.0
# Лес
forest_score = zone.get('forest_pct', 0.5)
score += self.weights['forest'] * forest_score
# Вода (чем ближе, тем важнее)
water_dist = zone.get('water_distance_km', 5.0)
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
score += self.weights['water'] * water_score
# Дороги
road_density = zone.get('road_density', 0.5)
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
score += self.weights['roads'] * road_score
# Населенные пункты
settlement_dist = zone.get('settlement_distance_km', 10.0)
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
score += self.weights['settlement'] * settlement_score
# Историческая частота
historical_score = zone.get('historical_freq', 0.5)
score += self.weights['historical'] * historical_score
# Совпадение направления
direction_score = zone.get('direction_match', 0.5)
score += self.weights['direction'] * direction_score
# Укрытия
shelter_score = zone.get('shelter_pct', 0.3)
score += self.weights['shelter'] * shelter_score
# Ж/д пути (для РАС)
if 'railway' in self.weights:
railway_dist = zone.get('railway_distance_km', 10.0)
railway_score = max(0, 1.0 - (railway_dist / 5.0))
score += self.weights['railway'] * railway_score
# Применяем множитель расстояния
zone_distance = zone.get('distance_km', 1.0)
expected_distance = max_distance_km * self.distance_multiplier
distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2)
distance_factor = max(0.3, min(1.0, distance_factor))
score *= distance_factor
# Конвертируем в шкалу 0-100
return round(score * 100, 2)
def rank_zones(self, zones: List[Dict], case: Dict, max_distance_km: float = 2.0) -> List[Dict]:
"""
Ранжирует зоны по приоритету на основе оценок.
Args:
zones: Список зон с характеристиками
case: Данные случая
Returns:
List[Dict]: Отсортированный список зон с оценками и приоритетами
"""
scored_zones = []
for zone in zones:
zone_copy = deepcopy(zone)
zone_copy['score'] = self.score_zone(zone, case, max_distance_km=max_distance_km)
scored_zones.append(zone_copy)
scored_zones.sort(key=lambda x: x['score'], reverse=True)
for i, zone in enumerate(scored_zones):
zone['priority'] = i + 1
return scored_zones
def get_active_profiles_info(self) -> List[Dict]:
"""Возвращает информацию об активных профилях с предупреждениями."""
profiles_info = []
for profile_name in self.active_profiles:
profile = self.BEHAVIORAL_PROFILES.get(profile_name, {})
info = {
'name': profile_name,
'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']},
}
if 'critical_warning' in profile:
info['critical_warning'] = profile['critical_warning']
if 'note' in profile:
info['note'] = profile['note']
profiles_info.append(info)
return profiles_info
def create_scorer_for_case(case: Dict) -> WeightedScorer:
"""Создает и настраивает скорер для конкретного случая."""
scorer = WeightedScorer()
if 'age' in case and case['age']:
scorer.apply_age_modifiers(case['age'])
if 'season' in case and case['season']:
scorer.apply_season_modifiers(case['season'])
if 'profiles' in case and case['profiles']:
scorer.apply_profile(case['profiles'])
scorer._normalize_weights()
return scorer
def get_weight_explanation(case: Dict) -> Dict:
"""Возвращает объяснение весов для данного случая."""
scorer = create_scorer_for_case(case)
return {
'weights': scorer.weights,
'distance_multiplier': scorer.distance_multiplier,
'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None,
'season': case.get('season'),
'profiles': scorer.get_active_profiles_info(),
'critical_warnings': scorer.critical_warnings
}
+403
View File
@@ -0,0 +1,403 @@
"""
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
Реализация согласно §8 и §9 контекста ВЕКТОР.
"""
from typing import Dict, List, Optional
from copy import deepcopy
class WeightedScorer:
"""
Система взвешенной оценки зон поиска с учетом множественных факторов.
Базовые веса из §9 контекста.
"""
# Базовые веса факторов из §9 (сумма = 1.0)
BASE_WEIGHTS = {
'forest': 0.25,
'water': 0.20,
'roads': 0.18,
'settlement': 0.15,
'historical': 0.12,
'direction': 0.07,
'shelter': 0.03
}
# Возрастные модификаторы
AGE_MODIFIERS = {
'0-4': {
'forest': 0.6,
'water': 2.5,
'roads': 1.3,
'settlement': 1.8,
'shelter': 1.5,
'distance_mult': 0.3
},
'5-7': {
'forest': 0.8,
'water': 2.2,
'roads': 1.4,
'settlement': 1.6,
'shelter': 1.4,
'distance_mult': 0.5
},
'8-11': {
'forest': 1.1,
'water': 1.8,
'roads': 1.2,
'settlement': 1.3,
'shelter': 1.2,
'distance_mult': 0.8
},
'12-14': {
'forest': 1.3,
'water': 1.4,
'roads': 1.1,
'settlement': 1.0,
'shelter': 1.0,
'distance_mult': 1.2
},
'15-17': {
'forest': 1.4,
'water': 1.2,
'roads': 1.3,
'settlement': 0.9,
'shelter': 0.9,
'distance_mult': 1.5
}
}
# Сезонные модификаторы
SEASON_MODIFIERS = {
'зима': {
'forest': 0.8,
'water': 0.6,
'roads': 1.3,
'settlement': 1.5,
'shelter': 2.0,
'distance_mult': 0.7
},
'весна': {
'forest': 1.1,
'water': 1.8,
'roads': 1.0,
'settlement': 1.0,
'shelter': 1.2,
'distance_mult': 1.0
},
'лето': {
'forest': 1.2,
'water': 1.3,
'roads': 0.9,
'settlement': 0.8,
'shelter': 0.8,
'distance_mult': 1.3
},
'осень': {
'forest': 1.3,
'water': 1.1,
'roads': 1.0,
'settlement': 1.1,
'shelter': 1.1,
'distance_mult': 1.0
}
}
# Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста
BEHAVIORAL_PROFILES = {
'РАС': {
'water': 3.0,
'railway': 2.5,
'shelter': 2.0,
'settlement': 0.4,
'distance_mult': 2.0,
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
},
'эпилепсия': {
'water': 3.5,
'shelter': 2.5,
'distance_mult': 0.6,
'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.'
},
'СДВГ': {
'roads': 1.6,
'distance_mult': 1.4,
'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.'
},
'ЗПР': {
'settlement': 0.7,
'shelter': 1.5,
'distance_mult': 0.8,
'note': 'Не ориентируется в пространстве'
},
'велосипед': {
'distance_mult': 5.0,
'roads': 1.8,
'forest': 0.8,
'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.'
},
'самокат': {
'distance_mult': 3.0,
'roads': 1.6,
'forest': 0.9
},
'намеренный_уход': {
'forest': 0.2,
'roads': 2.5,
'settlement': 3.0,
'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.'
}
}
def __init__(self):
"""Инициализация скорера с базовыми весами."""
self.weights = deepcopy(self.BASE_WEIGHTS)
self.distance_multiplier = 1.0
self.active_profiles = []
self.critical_warnings = []
def _get_age_group(self, age: int) -> str:
"""Определяет возрастную группу."""
if age <= 4:
return '0-4'
elif age <= 7:
return '5-7'
elif age <= 11:
return '8-11'
elif age <= 14:
return '12-14'
elif age <= 17:
return '15-17'
else:
return '18-64'
def apply_age_modifiers(self, age: int):
"""Применяет возрастные модификаторы к весам."""
age_group = self._get_age_group(age)
modifiers = self.AGE_MODIFIERS.get(age_group, {})
for factor, modifier in modifiers.items():
if factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
def apply_season_modifiers(self, season: str):
"""Применяет сезонные модификаторы к весам."""
season_lower = season.lower() if season else 'лето'
modifiers = self.SEASON_MODIFIERS.get(season_lower, {})
for factor, modifier in modifiers.items():
if factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
def apply_profile(self, profile_list: List[str]):
"""
Применяет поведенческие профили к весам.
Точные коэффициенты из §8 контекста.
Args:
profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.)
"""
if not profile_list:
return
for profile_name in profile_list:
profile = self.BEHAVIORAL_PROFILES.get(profile_name)
if not profile:
continue
self.active_profiles.append(profile_name)
# Сохранить критические предупреждения
if 'critical_warning' in profile:
self.critical_warnings.append({
'profile': profile_name,
'warning': profile['critical_warning']
})
for factor, modifier in profile.items():
if factor in ['critical_warning', 'note']:
continue
elif factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
elif factor == 'railway':
# Ж/д пути — добавляем как отдельный фактор для РАС
if 'railway' not in self.weights:
self.weights['railway'] = 0.05
self.weights['railway'] *= modifier
def _normalize_weights(self):
"""Нормализует веса так, чтобы их сумма была 1.0."""
# Фильтруем None значения
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
total = sum(valid_weights.values())
if total > 0:
for key in self.weights:
if self.weights[key] is not None:
self.weights[key] /= total
else:
self.weights[key] = 0.0
def score_zone(self, zone: Dict, case: Dict) -> float:
"""
Оценивает зону поиска на основе её характеристик и данных случая.
Args:
zone: Словарь с характеристиками зоны
case: Данные случая (age, season, profiles и т.д.)
Returns:
float: Оценка зоны (0-100)
"""
# Сбрасываем веса к базовым
self.weights = deepcopy(self.BASE_WEIGHTS)
self.distance_multiplier = 1.0
self.active_profiles = []
self.critical_warnings = []
# Применяем модификаторы
if 'age' in case and case['age']:
self.apply_age_modifiers(case['age'])
if 'season' in case and case['season']:
self.apply_season_modifiers(case['season'])
if 'profiles' in case and case['profiles']:
self.apply_profile(case['profiles'])
# Нормализуем веса
self._normalize_weights()
# Вычисляем оценку
score = 0.0
# Лес
forest_score = zone.get('forest_pct', 0.5)
score += self.weights['forest'] * forest_score
# Вода (чем ближе, тем важнее)
water_dist = zone.get('water_distance_km', 5.0)
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
score += self.weights['water'] * water_score
# Дороги
road_density = zone.get('road_density', 0.5)
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
score += self.weights['roads'] * road_score
# Населенные пункты
settlement_dist = zone.get('settlement_distance_km', 10.0)
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
score += self.weights['settlement'] * settlement_score
# Историческая частота
historical_score = zone.get('historical_freq', 0.5)
score += self.weights['historical'] * historical_score
# Совпадение направления
direction_score = zone.get('direction_match', 0.5)
score += self.weights['direction'] * direction_score
# Укрытия
shelter_score = zone.get('shelter_pct', 0.3)
score += self.weights['shelter'] * shelter_score
# Ж/д пути (для РАС)
if 'railway' in self.weights:
railway_dist = zone.get('railway_distance_km', 10.0)
railway_score = max(0, 1.0 - (railway_dist / 5.0))
score += self.weights['railway'] * railway_score
# Применяем множитель расстояния
zone_distance = zone.get('distance_km', 1.0)
expected_distance = 2.0 * self.distance_multiplier
distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2)
distance_factor = max(0.3, min(1.0, distance_factor))
score *= distance_factor
# Конвертируем в шкалу 0-100
return round(score * 100, 2)
def rank_zones(self, zones: List[Dict], case: Dict) -> List[Dict]:
"""
Ранжирует зоны по приоритету на основе оценок.
Args:
zones: Список зон с характеристиками
case: Данные случая
Returns:
List[Dict]: Отсортированный список зон с оценками и приоритетами
"""
scored_zones = []
for zone in zones:
zone_copy = deepcopy(zone)
zone_copy['score'] = self.score_zone(zone, case)
scored_zones.append(zone_copy)
scored_zones.sort(key=lambda x: x['score'], reverse=True)
for i, zone in enumerate(scored_zones):
zone['priority'] = i + 1
return scored_zones
def get_active_profiles_info(self) -> List[Dict]:
"""Возвращает информацию об активных профилях с предупреждениями."""
profiles_info = []
for profile_name in self.active_profiles:
profile = self.BEHAVIORAL_PROFILES.get(profile_name, {})
info = {
'name': profile_name,
'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']},
}
if 'critical_warning' in profile:
info['critical_warning'] = profile['critical_warning']
if 'note' in profile:
info['note'] = profile['note']
profiles_info.append(info)
return profiles_info
def create_scorer_for_case(case: Dict) -> WeightedScorer:
"""Создает и настраивает скорер для конкретного случая."""
scorer = WeightedScorer()
if 'age' in case and case['age']:
scorer.apply_age_modifiers(case['age'])
if 'season' in case and case['season']:
scorer.apply_season_modifiers(case['season'])
if 'profiles' in case and case['profiles']:
scorer.apply_profile(case['profiles'])
scorer._normalize_weights()
return scorer
def get_weight_explanation(case: Dict) -> Dict:
"""Возвращает объяснение весов для данного случая."""
scorer = create_scorer_for_case(case)
return {
'weights': scorer.weights,
'distance_multiplier': scorer.distance_multiplier,
'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None,
'season': case.get('season'),
'profiles': scorer.get_active_profiles_info(),
'critical_warnings': scorer.critical_warnings
}
+588
View File
@@ -0,0 +1,588 @@
"""
Statistics service for case analysis and dashboard aggregates.
Provides statistical recommendations based on historical data:
- Similar cases filtering (age ±2 years, season, terrain)
- Median distance, top directions, survival rate
- Dashboard aggregates
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_, text
from models import Case
class DirectionFrequency(BaseModel):
"""Direction frequency statistics"""
direction: str
count: int
percentage: float
class StatisticalRecommendation(BaseModel):
"""Statistical recommendation based on historical data"""
median_distance_km: float
top_directions: List[DirectionFrequency]
top_location_types: List[str]
survival_rate: float
sample_size: int
filters_used: Dict[str, Any]
class DashboardStats(BaseModel):
"""Dashboard aggregate statistics"""
total_cases: int
active_cases: int
closed_cases: int
by_gender: Dict[str, int]
by_age_group: Dict[str, int]
by_psychotype: Dict[str, int]
by_diagnosis: Dict[str, int]
by_season: Dict[str, int]
avg_distance_km: Optional[float]
avg_search_duration_hours: Optional[float]
survival_rate: float
def get_statistical_recommendation(
case_data: dict,
db: Session,
min_sample_size: int = 5
) -> StatisticalRecommendation:
"""
Получает статистические рекомендации на основе похожих исторических случаев.
Фильтры (в порядке приоритета):
1. Возраст ±2 года + сезон + terrain
2. Возраст ±2 года + сезон (если < 5 случаев)
3. Возраст ±2 года (если < 5 случаев)
4. Все случаи (если < 5 случаев)
Args:
case_data: Словарь с данными случая
- age: возраст (обязательно)
- season: сезон (опционально)
- terrain_primary: тип местности (опционально)
db: SQLAlchemy Session
min_sample_size: Минимальный размер выборки (по умолчанию 5)
Returns:
StatisticalRecommendation: Статистические рекомендации
"""
age = case_data.get('age')
season = case_data.get('season')
terrain = case_data.get('terrain_primary')
if not age:
raise ValueError("Age is required for statistical recommendation")
# Попытка 1: Возраст ±2 года + сезон + terrain
filters_used = {'age_range': f"{age-2} to {age+2}"}
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
if season:
query = query.filter(Case.season == season)
filters_used['season'] = season
if terrain and season:
query = query.filter(Case.terrain.any(terrain))
filters_used['terrain'] = terrain
cases = query.all()
sample_size = len(cases)
# Попытка 2: Убираем terrain, если мало данных
if sample_size < min_sample_size and terrain:
filters_used.pop('terrain', None)
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
if season:
query = query.filter(Case.season == season)
cases = query.all()
sample_size = len(cases)
# Попытка 3: Убираем season, если мало данных
if sample_size < min_sample_size and season:
filters_used.pop('season', None)
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
cases = query.all()
sample_size = len(cases)
# Попытка 4: Все случаи с найденными детьми
if sample_size < min_sample_size:
filters_used = {"age_range": "all"}
query = db.query(Case).filter(
Case.found_distance_km.isnot(None)
)
cases = query.all()
sample_size = len(cases)
# Если данных нет совсем, возвращаем дефолтные значения
if sample_size == 0:
return StatisticalRecommendation(
median_distance_km=2.0,
top_directions=[
DirectionFrequency(direction="N", count=0, percentage=0.0),
DirectionFrequency(direction="S", count=0, percentage=0.0),
DirectionFrequency(direction="E", count=0, percentage=0.0)
],
top_location_types=["водоёмы", "дороги", "постройки"],
survival_rate=0.0,
sample_size=0,
filters_used=filters_used
)
# Вычисляем медианное расстояние
distances = sorted([c.found_distance_km for c in cases if c.found_distance_km])
median_distance = distances[len(distances) // 2] if distances else 2.0
# Подсчитываем частоту направлений
direction_counts = {}
for case in cases:
if case.found_direction:
direction = case.found_direction
direction_counts[direction] = direction_counts.get(direction, 0) + 1
# Топ-3 направления
sorted_directions = sorted(
direction_counts.items(),
key=lambda x: x[1],
reverse=True
)[:3]
top_directions = [
DirectionFrequency(
direction=direction,
count=count,
percentage=round(count / sample_size * 100, 1)
)
for direction, count in sorted_directions
]
# Если направлений меньше 3, добавляем пустые
while len(top_directions) < 3:
top_directions.append(
DirectionFrequency(direction="unknown", count=0, percentage=0.0)
)
# Топ типов локаций
location_counts = {}
for case in cases:
if case.found_location_type:
location_type = case.found_location_type
location_counts[location_type] = location_counts.get(location_type, 0) + 1
top_location_types = [
loc for loc, _ in sorted(
location_counts.items(),
key=lambda x: x[1],
reverse=True
)[:5]
]
if not top_location_types:
top_location_types = ["водоёмы", "дороги", "лес"]
# Процент выживаемости
survived_count = sum(1 for case in cases if case.found_alive is True)
survival_rate = round(survived_count / sample_size * 100, 1) if sample_size > 0 else 0.0
return StatisticalRecommendation(
median_distance_km=round(median_distance, 2),
top_directions=top_directions,
top_location_types=top_location_types,
survival_rate=survival_rate,
sample_size=sample_size,
filters_used=filters_used
)
def get_dashboard_stats(db: Session) -> DashboardStats:
"""
Получает агрегированную статистику для дашборда.
Args:
db: SQLAlchemy Session
Returns:
DashboardStats: Агрегированная статистика
"""
cases = db.query(Case).all()
total = len(cases)
active = sum(1 for c in cases if c.status == 'active')
closed = sum(1 for c in cases if c.status == 'closed')
by_gender = {}
by_age_group = {}
by_psychotype = {}
by_diagnosis = {}
by_season = {}
distances = []
durations = []
survived = 0
total_with_outcome = 0
for case in cases:
# Gender
gender = case.gender or 'unknown'
by_gender[gender] = by_gender.get(gender, 0) + 1
# Age groups
age = case.age_years
if age < 4:
age_group = '0-3'
elif age < 8:
age_group = '4-7'
elif age < 12:
age_group = '8-11'
elif age < 15:
age_group = '12-14'
elif age < 18:
age_group = '15-17'
else:
age_group = '18+'
by_age_group[age_group] = by_age_group.get(age_group, 0) + 1
# Psychotype
if case.psychotype:
by_psychotype[case.psychotype] = by_psychotype.get(case.psychotype, 0) + 1
# Diagnosis
if case.diagnosis_type:
for diag in case.diagnosis_type:
by_diagnosis[diag] = by_diagnosis.get(diag, 0) + 1
# Season
if case.season:
by_season[case.season] = by_season.get(case.season, 0) + 1
# Distance
if case.found_distance_km:
distances.append(case.found_distance_km)
# Duration
if case.search_duration_hours:
durations.append(case.search_duration_hours)
# Survival rate
if case.found_alive is not None:
total_with_outcome += 1
if case.found_alive:
survived += 1
avg_distance = round(sum(distances) / len(distances), 2) if distances else None
avg_duration = round(sum(durations) / len(durations), 2) if durations else None
survival_rate = round(survived / total_with_outcome * 100, 1) if total_with_outcome > 0 else 0.0
return DashboardStats(
total_cases=total,
active_cases=active,
closed_cases=closed,
by_gender=by_gender,
by_age_group=by_age_group,
by_psychotype=by_psychotype,
by_diagnosis=by_diagnosis,
by_season=by_season,
avg_distance_km=avg_distance,
avg_search_duration_hours=avg_duration,
survival_rate=survival_rate
)
def get_heatmap_data(db: Session, filters: Optional[Dict] = None) -> List[Dict]:
"""
Получает данные для тепловой карты находок.
Args:
db: SQLAlchemy Session
filters: Опциональные фильтры (age_min, age_max, season, outcome)
Returns:
List[Dict]: Список точек с координатами и интенсивностью
"""
query = db.query(Case).filter(
Case.found_lat.isnot(None),
Case.found_lon.isnot(None)
)
if filters:
if 'age_min' in filters:
query = query.filter(Case.age_years >= filters['age_min'])
if 'age_max' in filters:
query = query.filter(Case.age_years <= filters['age_max'])
if 'season' in filters:
query = query.filter(Case.season == filters['season'])
if 'outcome' in filters:
if filters['outcome'] == 'alive':
query = query.filter(Case.found_alive == True)
elif filters['outcome'] == 'deceased':
query = query.filter(Case.found_alive == False)
cases = query.all()
points = []
for case in cases:
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': 1.0,
'case_id': str(case.id),
'distance_km': case.found_distance_km,
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
})
return points
"""
Extended heatmap functions with caching and multiple map types.
"""
from functools import lru_cache
from typing import Dict, List, Optional, Literal
from datetime import datetime
from sqlalchemy.orm import Session
from models import Case
HeatmapType = Literal['all', 'age', 'season', 'outcome']
def get_heatmap_data_cached(
db: Session,
map_type: HeatmapType = 'all',
age_group: Optional[str] = None,
season: Optional[str] = None,
year_from: Optional[int] = None,
year_to: Optional[int] = None,
outcome: Optional[str] = None
) -> Dict:
"""
Получает данные для тепловой карты с кэшированием.
Args:
db: SQLAlchemy Session
map_type: Тип карты (all, age, season, outcome)
age_group: Возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17)
season: Сезон (зима, весна, лето, осень)
year_from: Год начала периода
year_to: Год окончания периода
outcome: Исход (alive, deceased)
Returns:
Dict: {points: List[Dict], total: int, filters_applied: Dict}
"""
# Базовый запрос
query = db.query(Case).filter(
Case.found_lat.isnot(None),
Case.found_lon.isnot(None)
)
filters_applied = {'map_type': map_type}
# Фильтр по возрастной группе
if age_group:
age_ranges = {
'0-3': (0, 3),
'4-7': (4, 7),
'8-11': (8, 11),
'12-14': (12, 14),
'15-17': (15, 17),
'18+': (18, 100)
}
if age_group in age_ranges:
min_age, max_age = age_ranges[age_group]
query = query.filter(Case.age_years.between(min_age, max_age))
filters_applied['age_group'] = age_group
# Фильтр по сезону
if season:
query = query.filter(Case.season == season)
filters_applied['season'] = season
# Фильтр по периоду (годы)
if year_from:
query = query.filter(
db.func.extract('year', Case.created_at) >= year_from
)
filters_applied['year_from'] = year_from
if year_to:
query = query.filter(
db.func.extract('year', Case.created_at) <= year_to
)
filters_applied['year_to'] = year_to
# Фильтр по исходу
if outcome:
if outcome == 'alive':
query = query.filter(Case.found_alive == True)
elif outcome == 'deceased':
query = query.filter(Case.found_alive == False)
filters_applied['outcome'] = outcome
cases = query.all()
# Формируем точки в зависимости от типа карты
points = []
if map_type == 'all':
# Все точки с одинаковой интенсивностью
for case in cases:
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': 1.0,
'case_id': str(case.id),
'metadata': {
'age': case.age_years,
'season': case.season,
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
}
})
elif map_type == 'age':
# Интенсивность зависит от возраста (младше = выше интенсивность)
for case in cases:
# Младшие дети = выше интенсивность (более критично)
intensity = max(0.3, 1.0 - (case.age_years / 18.0))
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': round(intensity, 2),
'case_id': str(case.id),
'metadata': {
'age': case.age_years,
'age_group': _get_age_group(case.age_years)
}
})
elif map_type == 'season':
# Интенсивность зависит от сезона (зима = выше)
season_intensity = {
'зима': 1.0,
'осень': 0.8,
'весна': 0.6,
'лето': 0.4
}
for case in cases:
intensity = season_intensity.get(case.season, 0.5)
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': intensity,
'case_id': str(case.id),
'metadata': {
'season': case.season
}
})
elif map_type == 'outcome':
# Интенсивность зависит от исхода
for case in cases:
if case.found_alive is True:
intensity = 0.5 # Зеленый (выжил)
elif case.found_alive is False:
intensity = 1.0 # Красный (погиб)
else:
intensity = 0.3 # Серый (неизвестно)
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': intensity,
'case_id': str(case.id),
'metadata': {
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown',
'distance_km': case.found_distance_km
}
})
return {
'points': points,
'total': len(points),
'filters_applied': filters_applied
}
def _get_age_group(age: int) -> str:
"""Определяет возрастную группу"""
if age <= 3:
return '0-3'
elif age <= 7:
return '4-7'
elif age <= 11:
return '8-11'
elif age <= 14:
return '12-14'
elif age <= 17:
return '15-17'
else:
return '18+'
# Кэшированная версия для быстрого доступа
# Кэш на 1 час (3600 секунд), максимум 128 комбинаций параметров
@lru_cache(maxsize=128)
def _get_heatmap_cache_key(
map_type: str,
age_group: Optional[str],
season: Optional[str],
year_from: Optional[int],
year_to: Optional[int],
outcome: Optional[str],
timestamp_hour: int # Меняется каждый час
) -> str:
"""Генерирует ключ кэша для heatmap"""
return f"{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{timestamp_hour}"
def get_heatmap_with_cache(
db: Session,
map_type: HeatmapType = 'all',
age_group: Optional[str] = None,
season: Optional[str] = None,
year_from: Optional[int] = None,
year_to: Optional[int] = None,
outcome: Optional[str] = None
) -> Dict:
"""
Обертка с кэшированием на 1 час.
Кэш инвалидируется каждый час автоматически через timestamp_hour.
"""
# Текущий час для кэша (меняется каждый час)
current_hour = datetime.utcnow().hour
# Генерируем ключ кэша
cache_key = _get_heatmap_cache_key(
map_type,
age_group,
season,
year_from,
year_to,
outcome,
current_hour
)
# Получаем данные (кэш работает через lru_cache на уровне ключа)
return get_heatmap_data_cached(
db,
map_type,
age_group,
season,
year_from,
year_to,
outcome
)
View File
+371
View File
@@ -0,0 +1,371 @@
"""
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
)
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)
+341
View File
@@ -0,0 +1,341 @@
"""
Tests for distance_service.py
Tests the distance calculation formulas and prior probabilities
based on §9 ВЕКТОР-контекст.md specifications.
"""
import pytest
from services.distance_service import (
calculate_max_distance,
get_base_speed,
get_terrain_coefficient,
get_time_of_day_coefficient,
get_weather_coefficient,
get_distance_priors,
get_distance_zone,
get_distance_statistics
)
class TestCalculateMaxDistance:
"""Test the main distance calculation function."""
def test_boy_10_years_4_hours_forest_rain(self):
"""
Test case from requirements:
Мальчик 10 лет, 4 часа, лес, дождь → ~5 км
"""
case_data = {
'age': 10,
'elapsed_hours': 4.0,
'terrain_primary': 'лес',
'time_of_day': 'день',
'weather': 'дождь'
}
distance = calculate_max_distance(case_data)
# Expected calculation:
# Time = 4 hours
# НормС (age 10) = 4.0 km/h
# СП (лес) = 0.5
# СУТ (4 hours) = 1.0 - (0.05 * 4) = 0.8
# ВВС (день) = 1.0
# ВП (дождь) = 0.8
# Distance = 4 * 4.0 * 0.5 * 0.8 * 1.0 * 0.8 = 5.12 km
assert 4.5 <= distance <= 5.5, f"Expected ~5 km, got {distance} km"
assert distance == pytest.approx(5.12, rel=0.01)
def test_young_child_short_time(self):
"""Test for young child (3 years) with short elapsed time."""
case_data = {
'age': 3,
'elapsed_hours': 1.0,
'terrain_primary': 'лес',
'time_of_day': 'день',
'weather': 'нет'
}
distance = calculate_max_distance(case_data)
# Expected: 1 * 2.0 * 0.5 * 0.95 * 1.0 * 1.0 = 0.95 km
assert distance == pytest.approx(0.95, rel=0.01)
def test_teenager_long_time_road(self):
"""Test for teenager on road with longer elapsed time."""
case_data = {
'age': 15,
'elapsed_hours': 6.0,
'terrain_primary': 'дорога',
'time_of_day': 'день',
'weather': 'нет'
}
distance = calculate_max_distance(case_data)
# Expected: 6 * 5.0 * 0.8 * 0.7 * 1.0 * 1.0 = 16.8 km
assert distance == pytest.approx(16.8, rel=0.01)
def test_elderly_night_swamp(self):
"""Test for elderly person at night in swamp."""
case_data = {
'age': 70,
'elapsed_hours': 3.0,
'terrain_primary': 'болото',
'time_of_day': 'ночь',
'weather': 'туман'
}
distance = calculate_max_distance(case_data)
# Expected: 3 * 3.0 * 0.2 * 0.85 * 0.5 * 0.7 = 0.54 km
assert distance == pytest.approx(0.54, rel=0.01)
def test_adult_heavy_rain_field(self):
"""Test for adult in heavy rain on field."""
case_data = {
'age': 35,
'elapsed_hours': 2.0,
'terrain_primary': 'поле',
'time_of_day': 'день',
'weather': 'ливень'
}
distance = calculate_max_distance(case_data)
# Expected: 2 * 5.0 * 0.9 * 0.9 * 1.0 * 0.6 = 4.86 km
assert distance == pytest.approx(4.86, rel=0.01)
class TestBaseSpeed:
"""Test НормС (base speed) by age."""
def test_infant(self):
assert get_base_speed(1) == 1.0
assert get_base_speed(2) == 1.0
def test_preschool(self):
assert get_base_speed(3) == 2.0
assert get_base_speed(5) == 2.0
def test_young_child(self):
assert get_base_speed(6) == 3.0
assert get_base_speed(8) == 3.0
def test_preteen(self):
assert get_base_speed(10) == 4.0
assert get_base_speed(12) == 4.0
def test_teenager(self):
assert get_base_speed(13) == 5.0
assert get_base_speed(15) == 5.0
assert get_base_speed(16) == 5.5
assert get_base_speed(17) == 5.5
def test_adult(self):
assert get_base_speed(25) == 5.0
assert get_base_speed(50) == 5.0
assert get_base_speed(64) == 5.0
def test_elderly(self):
assert get_base_speed(65) == 3.0
assert get_base_speed(80) == 3.0
class TestTerrainCoefficient:
"""Test СП (terrain coefficient)."""
def test_road_terrain(self):
assert get_terrain_coefficient('дорога') == 0.8
assert get_terrain_coefficient('лесная дорога') == 0.8
assert get_terrain_coefficient('тропа') == 0.8
def test_forest_terrain(self):
assert get_terrain_coefficient('лес') == 0.5
assert get_terrain_coefficient('простой лес') == 0.5
assert get_terrain_coefficient('сложный лес') == 0.25
assert get_terrain_coefficient('густой лес') == 0.25
def test_open_terrain(self):
assert get_terrain_coefficient('поле') == 0.9
assert get_terrain_coefficient('луг') == 0.9
def test_difficult_terrain(self):
assert get_terrain_coefficient('болото') == 0.2
assert get_terrain_coefficient('горы') == 0.3
assert get_terrain_coefficient('овраг') == 0.3
def test_urban_terrain(self):
assert get_terrain_coefficient('город') == 1.0
assert get_terrain_coefficient('населённый пункт') == 1.0
def test_unknown_terrain(self):
assert get_terrain_coefficient('неизвестно') == 0.5
class TestTimeOfDayCoefficient:
"""Test ВВС (time of day coefficient)."""
def test_day(self):
assert get_time_of_day_coefficient('день') == 1.0
def test_night(self):
assert get_time_of_day_coefficient('ночь') == 0.5
def test_twilight(self):
assert get_time_of_day_coefficient('сумерки') == 0.5
assert get_time_of_day_coefficient('вечер') == 0.5
class TestWeatherCoefficient:
"""Test ВП (weather coefficient)."""
def test_clear_weather(self):
assert get_weather_coefficient('нет') == 1.0
assert get_weather_coefficient('ясно') == 1.0
def test_rain(self):
assert get_weather_coefficient('дождь') == 0.8
assert get_weather_coefficient('ливень') == 0.6
assert get_weather_coefficient('сильный дождь') == 0.6
def test_fog(self):
assert get_weather_coefficient('туман') == 0.7
def test_snow(self):
assert get_weather_coefficient('снег') == 0.6
assert get_weather_coefficient('метель') == 0.6
def test_heat(self):
assert get_weather_coefficient('жара') == 0.8
class TestDistancePriors:
"""Test get_distance_priors() - априорные вероятности зон."""
def test_young_child_priors(self):
"""Children under 8 stay close."""
priors = get_distance_priors(5)
assert priors['0_500m'] == 0.45
assert priors['500_1500m'] == 0.35
assert priors['1500_2500m'] == 0.15
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
def test_preteen_priors(self):
"""Children 8-12 have more even distribution."""
priors = get_distance_priors(10)
assert priors['0_500m'] == 0.28
assert priors['500_1500m'] == 0.25
assert priors['1500_2500m'] == 0.22
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
def test_teenager_priors(self):
"""Teenagers can go farther."""
priors = get_distance_priors(15)
assert priors['1500_2500m'] == 0.25
assert priors['5000_plus'] == 0.08
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
def test_adult_priors(self):
"""Adults have highest far-distance probability."""
priors = get_distance_priors(35)
assert priors['0_500m'] == 0.12
assert priors['5000_plus'] == 0.13
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
def test_elderly_priors(self):
"""Elderly stay closer like young children."""
priors = get_distance_priors(70)
assert priors['0_500m'] == 0.35
assert priors['5000_plus'] == 0.02
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
class TestDistanceZone:
"""Test get_distance_zone() classification."""
def test_zone_classification(self):
assert get_distance_zone(0.3) == '0_500m'
assert get_distance_zone(0.5) == '500_1500m'
assert get_distance_zone(1.0) == '500_1500m'
assert get_distance_zone(1.5) == '1500_2500m'
assert get_distance_zone(2.5) == '2500_3500m'
assert get_distance_zone(4.0) == '3500_5000m'
assert get_distance_zone(6.0) == '5000_plus'
class TestDistanceStatistics:
"""Test get_distance_statistics() comprehensive output."""
def test_statistics_structure(self):
stats = get_distance_statistics(
age_years=10,
elapsed_hours=4.0,
terrain='лес'
)
assert 'max_distance_km' in stats
assert 'current_zone' in stats
assert 'zone_probability' in stats
assert 'all_priors' in stats
assert 'base_speed_kmh' in stats
assert 'terrain_coefficient' in stats
def test_statistics_values(self):
stats = get_distance_statistics(
age_years=10,
elapsed_hours=4.0,
terrain='лес'
)
assert stats['base_speed_kmh'] == 4.0
assert stats['terrain_coefficient'] == 0.5
assert stats['max_distance_km'] > 0
assert 0 <= stats['zone_probability'] <= 1.0
class TestFatigueCoefficient:
"""Test СУТ (fatigue coefficient) behavior."""
def test_fatigue_progression(self):
"""Fatigue increases with time (5% per hour)."""
case_1h = {
'age': 30,
'elapsed_hours': 1.0,
'terrain_primary': 'поле',
'time_of_day': 'день',
'weather': 'нет'
}
dist_1h = calculate_max_distance(case_1h)
case_5h = case_1h.copy()
case_5h['elapsed_hours'] = 5.0
dist_5h = calculate_max_distance(case_5h)
case_10h = case_1h.copy()
case_10h['elapsed_hours'] = 10.0
dist_10h = calculate_max_distance(case_10h)
assert dist_5h < dist_1h * 5
assert dist_10h < dist_5h * 2
def test_fatigue_minimum(self):
"""Fatigue coefficient has minimum of 0.3."""
case_data = {
'age': 30,
'elapsed_hours': 20.0,
'terrain_primary': 'поле',
'time_of_day': 'день',
'weather': 'нет'
}
distance = calculate_max_distance(case_data)
# 20 * 5.0 * 0.9 * 0.3 * 1.0 * 1.0 = 27.0
assert distance == pytest.approx(27.0, rel=0.01)
+399
View File
@@ -0,0 +1,399 @@
"""
Tests for geo_service.py
Tests the geographic zone building and Overpass API integration.
"""
import pytest
import math
from unittest.mock import AsyncMock, patch, MagicMock
from pathlib import Path
from datetime import datetime, timedelta
import json
from services.geo_service import (
haversine,
get_sector_bounds,
get_cache_key,
get_cached_result,
save_to_cache,
calculate_road_length,
find_nearest_distance,
calculate_forest_coverage,
build_search_zones,
DIRECTIONS,
SEARCH_DISTANCES,
CACHE_DIR
)
class TestHaversine:
"""Test haversine distance calculation."""
def test_same_point(self):
"""Test distance between same point is zero."""
distance = haversine(53.9, 27.5, 53.9, 27.5)
assert distance == pytest.approx(0.0, abs=0.01)
def test_known_distance(self):
"""Test known distance between cities."""
# Minsk to Brest approximately 350 km
minsk_lat, minsk_lon = 53.9, 27.5
brest_lat, brest_lon = 52.1, 23.7
distance = haversine(minsk_lat, minsk_lon, brest_lat, brest_lon)
# Should be around 350 km
assert 300 < distance < 400
def test_short_distance(self):
"""Test short distance calculation."""
# 1 km north
lat1, lon1 = 53.9, 27.5
lat2 = lat1 + 0.009 # ~1 km
lon2 = lon1
distance = haversine(lat1, lon1, lat2, lon2)
assert distance == pytest.approx(1.0, abs=0.1)
class TestSectorBounds:
"""Test sector boundary calculations."""
def test_north_sector(self):
"""Test north sector bounds."""
lat, lon = 53.9, 27.5
bounds = get_sector_bounds(lat, lon, "N", 1000)
min_lat, min_lon, max_lat, max_lon = bounds
# North sector should extend north
assert max_lat > lat
assert isinstance(min_lat, float)
assert isinstance(max_lat, float)
def test_all_directions(self):
"""Test all 8 directions return valid bounds."""
lat, lon = 53.9, 27.5
for direction in DIRECTIONS:
bounds = get_sector_bounds(lat, lon, direction, 1000)
min_lat, min_lon, max_lat, max_lon = bounds
assert min_lat < max_lat
assert min_lon < max_lon
assert all(isinstance(x, float) for x in bounds)
def test_different_radii(self):
"""Test different radii produce different bounds."""
lat, lon = 53.9, 27.5
bounds_500 = get_sector_bounds(lat, lon, "N", 500)
bounds_5000 = get_sector_bounds(lat, lon, "N", 5000)
# Larger radius should have larger bounds
assert (bounds_5000[2] - bounds_5000[0]) > (bounds_500[2] - bounds_500[0])
class TestCaching:
"""Test caching functionality."""
def test_cache_key_generation(self):
"""Test cache key is consistent."""
query = "test query"
key1 = get_cache_key(query)
key2 = get_cache_key(query)
assert key1 == key2
assert len(key1) == 32 # MD5 hash length
def test_cache_key_different_queries(self):
"""Test different queries produce different keys."""
key1 = get_cache_key("query 1")
key2 = get_cache_key("query 2")
assert key1 != key2
def test_save_and_get_cache(self):
"""Test saving and retrieving from cache."""
cache_key = "test_key_123"
test_data = {'elements': [{'id': 1, 'type': 'node'}]}
# Save to cache
save_to_cache(cache_key, test_data)
# Retrieve from cache
cached = get_cached_result(cache_key)
assert cached is not None
assert cached == test_data
# Cleanup
cache_file = CACHE_DIR / f"{cache_key}.json"
if cache_file.exists():
cache_file.unlink()
def test_cache_expiration(self):
"""Test cache expires after TTL."""
cache_key = "test_key_expired"
test_data = {'elements': []}
# Save to cache with old timestamp
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
old_time = datetime.now() - timedelta(hours=25)
with open(cache_file, 'w') as f:
json.dump({
'timestamp': old_time.isoformat(),
'data': test_data
}, f)
# Should return None (expired)
cached = get_cached_result(cache_key)
assert cached is None
# Cleanup
if cache_file.exists():
cache_file.unlink()
def test_cache_not_found(self):
"""Test cache returns None for non-existent key."""
cached = get_cached_result("nonexistent_key_xyz")
assert cached is None
class TestRoadLength:
"""Test road length calculation."""
def test_empty_elements(self):
"""Test empty elements returns zero."""
length = calculate_road_length([])
assert length == 0.0
def test_single_way(self):
"""Test single way calculation."""
elements = [
{
'type': 'way',
'geometry': [
{'lat': 53.9, 'lon': 27.5},
{'lat': 53.91, 'lon': 27.5}
]
}
]
length = calculate_road_length(elements)
# Should be approximately 1.1 km
assert 0.5 < length < 2.0
def test_multiple_ways(self):
"""Test multiple ways are summed."""
elements = [
{
'type': 'way',
'geometry': [
{'lat': 53.9, 'lon': 27.5},
{'lat': 53.91, 'lon': 27.5}
]
},
{
'type': 'way',
'geometry': [
{'lat': 53.9, 'lon': 27.5},
{'lat': 53.9, 'lon': 27.51}
]
}
]
length = calculate_road_length(elements)
assert length > 0
def test_ignores_non_ways(self):
"""Test non-way elements are ignored."""
elements = [
{'type': 'node', 'lat': 53.9, 'lon': 27.5},
{
'type': 'way',
'geometry': [
{'lat': 53.9, 'lon': 27.5},
{'lat': 53.91, 'lon': 27.5}
]
}
]
length = calculate_road_length(elements)
assert length > 0
class TestNearestDistance:
"""Test nearest distance calculation."""
def test_empty_elements(self):
"""Test empty elements returns None."""
distance = find_nearest_distance(53.9, 27.5, [])
assert distance is None
def test_single_node(self):
"""Test single node distance."""
elements = [
{'type': 'node', 'lat': 53.91, 'lon': 27.5}
]
distance = find_nearest_distance(53.9, 27.5, elements)
assert distance is not None
assert distance > 0
def test_finds_nearest(self):
"""Test finds nearest among multiple nodes."""
elements = [
{'type': 'node', 'lat': 53.95, 'lon': 27.5}, # Far
{'type': 'node', 'lat': 53.901, 'lon': 27.5}, # Near
{'type': 'node', 'lat': 54.0, 'lon': 27.5} # Very far
]
distance = find_nearest_distance(53.9, 27.5, elements)
# Should find the nearest (53.901)
assert distance < 0.2
def test_ignores_non_nodes(self):
"""Test non-node elements are ignored."""
elements = [
{'type': 'way', 'geometry': []},
{'type': 'node', 'lat': 53.91, 'lon': 27.5}
]
distance = find_nearest_distance(53.9, 27.5, elements)
assert distance is not None
class TestForestCoverage:
"""Test forest coverage calculation."""
def test_no_forest(self):
"""Test no forest returns 0%."""
coverage = calculate_forest_coverage([], 1000)
assert coverage == 0.0
def test_some_forest(self):
"""Test forest coverage calculation."""
elements = [
{'type': 'way', 'tags': {'landuse': 'forest'}},
{'type': 'way', 'tags': {'natural': 'wood'}}
]
coverage = calculate_forest_coverage(elements, 1000)
assert 0 < coverage <= 100
def test_coverage_capped_at_100(self):
"""Test coverage is capped at 100%."""
# Many forest ways
elements = [{'type': 'way'} for _ in range(1000)]
coverage = calculate_forest_coverage(elements, 100)
assert coverage <= 100.0
class TestBuildSearchZones:
"""Test search zone building."""
@pytest.mark.asyncio
async def test_zone_count(self):
"""Test correct number of zones are created."""
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
mock_features.return_value = {
'roads_km': 5.0,
'road_density': 2.0,
'water_distance_km': 1.5,
'settlement_distance_km': 3.0,
'forest_pct': 40.0
}
zones = await build_search_zones(53.9, 27.5, {})
# 8 directions × 4 distances = 32 zones
assert len(zones) == 32
@pytest.mark.asyncio
async def test_all_directions_covered(self):
"""Test all 8 directions are included."""
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
mock_features.return_value = {
'roads_km': 5.0,
'road_density': 2.0,
'water_distance_km': 1.5,
'settlement_distance_km': 3.0,
'forest_pct': 40.0
}
zones = await build_search_zones(53.9, 27.5, {})
directions_found = set(z.direction for z in zones)
assert directions_found == set(DIRECTIONS)
@pytest.mark.asyncio
async def test_all_distances_covered(self):
"""Test all 4 distances are included."""
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
mock_features.return_value = {
'roads_km': 5.0,
'road_density': 2.0,
'water_distance_km': 1.5,
'settlement_distance_km': 3.0,
'forest_pct': 40.0
}
zones = await build_search_zones(53.9, 27.5, {})
distances_found = set(z.distance_km for z in zones)
expected_distances = set(d / 1000 for d in SEARCH_DISTANCES)
assert distances_found == expected_distances
@pytest.mark.asyncio
async def test_zone_structure(self):
"""Test zone objects have correct structure."""
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
mock_features.return_value = {
'roads_km': 5.0,
'road_density': 2.0,
'water_distance_km': 1.5,
'settlement_distance_km': 3.0,
'forest_pct': 40.0
}
zones = await build_search_zones(53.9, 27.5, {})
for zone in zones:
assert hasattr(zone, 'direction')
assert hasattr(zone, 'distance_km')
assert hasattr(zone, 'forest_pct')
assert hasattr(zone, 'road_density')
assert hasattr(zone, 'water_distance_km')
assert hasattr(zone, 'settlement_distance_km')
class TestConstants:
"""Test module constants."""
def test_directions_count(self):
"""Test there are 8 directions."""
assert len(DIRECTIONS) == 8
def test_directions_values(self):
"""Test direction values are correct."""
expected = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
assert DIRECTIONS == expected
def test_search_distances(self):
"""Test search distances are correct."""
expected = [500, 1000, 2000, 5000]
assert SEARCH_DISTANCES == expected
def test_cache_dir_path(self):
"""Test cache directory path is set."""
assert isinstance(CACHE_DIR, Path)
assert str(CACHE_DIR) == "/tmp/overpass_cache"
+294
View File
@@ -0,0 +1,294 @@
"""
Tests for psychotype_service.py
Tests the psychotype detection logic and modifiers
based on §6 ВЕКТОР-контекст.md specifications.
"""
import pytest
from services.psychotype_service import (
detect_psychotype,
get_psychotype_modifiers,
get_search_recommendations,
get_psychotype_questions
)
class TestDetectPsychotype:
"""Test psychotype detection from answers."""
def test_dominant_profile(self):
"""Test dominant psychotype: активно + лидер + рискует."""
answers = {
'unfamiliar_behavior': 'explore',
'stress_reaction': 'angry',
'leadership': 'always_leader',
'risk_taking': 'very'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'dominant'
def test_harmonic_profile(self):
"""Test harmonic psychotype: спокойно + лидер + осторожный."""
answers = {
'unfamiliar_behavior': 'wait',
'stress_reaction': 'calm',
'leadership': 'always_leader',
'risk_taking': 'no_cautious'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'harmonic'
def test_anxious_profile(self):
"""Test anxious psychotype: плачет + ведомый + осторожный."""
answers = {
'unfamiliar_behavior': 'wait',
'stress_reaction': 'cry',
'leadership': 'always_follower',
'risk_taking': 'no_cautious'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'anxious'
def test_introvert_passive_profile(self):
"""Test introvert_passive: замирает + ведомый + осторожный."""
answers = {
'unfamiliar_behavior': 'freeze',
'stress_reaction': 'angry',
'leadership': 'always_follower',
'risk_taking': 'no_cautious'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'introvert_passive'
def test_introvert_active_profile(self):
"""Test introvert_active: активно + иногда лидер."""
answers = {
'unfamiliar_behavior': 'explore',
'stress_reaction': 'calm',
'leadership': 'sometimes',
'risk_taking': 'sometimes'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'introvert_active'
def test_introvert_active_panic_variant(self):
"""Test introvert_active: паникует + иногда лидер."""
answers = {
'unfamiliar_behavior': 'panic',
'stress_reaction': 'angry',
'leadership': 'sometimes',
'risk_taking': 'sometimes'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'introvert_active'
def test_case_insensitive(self):
"""Test that detection is case-insensitive."""
answers = {
'unfamiliar_behavior': 'EXPLORE',
'stress_reaction': 'ANGRY',
'leadership': 'ALWAYS_LEADER',
'risk_taking': 'VERY'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'dominant'
def test_partial_answers_default_harmonic(self):
"""Test that incomplete answers default to harmonic."""
answers = {
'unfamiliar_behavior': 'wait',
'stress_reaction': 'calm'
}
psychotype = detect_psychotype(answers)
assert psychotype == 'harmonic'
class TestGetPsychotypeModifiers:
"""Test psychotype modifiers for search zones."""
def test_dominant_modifiers(self):
"""Test dominant modifiers: far zones emphasized."""
modifiers = get_psychotype_modifiers('dominant')
assert modifiers['zone_0_500'] == 0.7
assert modifiers['zone_1500_2500'] == 1.4
assert modifiers['movement_model'] == 'chaotic_far'
assert 'description' in modifiers
def test_harmonic_modifiers(self):
"""Test harmonic modifiers: balanced distribution."""
modifiers = get_psychotype_modifiers('harmonic')
assert modifiers['zone_0_500'] == 0.8
assert modifiers['zone_500_1500'] == 1.0
assert modifiers['zone_1500_2500'] == 1.1
assert modifiers['movement_model'] == 'linear_landmark'
def test_anxious_modifiers(self):
"""Test anxious modifiers: near zone emphasized."""
modifiers = get_psychotype_modifiers('anxious')
assert modifiers['zone_0_500'] == 1.4
assert modifiers['zone_1500_2500'] == 0.5
assert modifiers['zone_2500plus'] == 0.3
assert modifiers['movement_model'] == 'stay'
def test_introvert_passive_modifiers(self):
"""Test introvert_passive modifiers: very near zone."""
modifiers = get_psychotype_modifiers('introvert_passive')
assert modifiers['zone_0_500'] == 1.3
assert modifiers['zone_2500plus'] == 0.2
assert modifiers['movement_model'] == 'stay_hidden'
def test_introvert_active_modifiers(self):
"""Test introvert_active modifiers: medium zones."""
modifiers = get_psychotype_modifiers('introvert_active')
assert modifiers['zone_0_500'] == 0.8
assert modifiers['zone_500_1500'] == 1.1
assert modifiers['zone_1500_2500'] == 1.2
assert modifiers['movement_model'] == 'linear_landmark'
def test_unknown_psychotype_defaults_harmonic(self):
"""Test that unknown psychotype returns harmonic modifiers."""
modifiers = get_psychotype_modifiers('unknown_type')
harmonic_modifiers = get_psychotype_modifiers('harmonic')
assert modifiers == harmonic_modifiers
def test_all_modifiers_have_required_fields(self):
"""Test that all psychotypes have required modifier fields."""
psychotypes = ['dominant', 'harmonic', 'anxious', 'introvert_passive', 'introvert_active']
required_fields = ['zone_0_500', 'zone_500_1500', 'zone_1500_2500',
'zone_2500plus', 'movement_model', 'description']
for psychotype in psychotypes:
modifiers = get_psychotype_modifiers(psychotype)
for field in required_fields:
assert field in modifiers, f"{psychotype} missing {field}"
class TestGetSearchRecommendations:
"""Test search recommendations for each psychotype."""
def test_dominant_recommendations(self):
"""Test dominant search recommendations."""
recs = get_search_recommendations('dominant')
assert 'priority_zones' in recs
assert 'search_pattern' in recs
assert 'key_locations' in recs
assert 'communication' in recs
def test_anxious_recommendations(self):
"""Test anxious search recommendations."""
recs = get_search_recommendations('anxious')
assert '0-500' in recs['priority_zones']
def test_all_psychotypes_have_recommendations(self):
"""Test that all psychotypes have complete recommendations."""
psychotypes = ['dominant', 'harmonic', 'anxious', 'introvert_passive', 'introvert_active']
required_fields = ['priority_zones', 'search_pattern', 'key_locations', 'communication']
for psychotype in psychotypes:
recs = get_search_recommendations(psychotype)
for field in required_fields:
assert field in recs, f"{psychotype} missing {field}"
assert len(recs[field]) > 0, f"{psychotype} {field} is empty"
class TestGetPsychotypeQuestions:
"""Test psychotype questions structure."""
def test_questions_count(self):
"""Test that there are exactly 4 questions."""
questions = get_psychotype_questions()
assert len(questions) == 4
def test_questions_structure(self):
"""Test that each question has required fields."""
questions = get_psychotype_questions()
for q in questions:
assert 'id' in q
assert 'question' in q
assert 'options' in q
assert len(q['options']) >= 3
def test_question_ids(self):
"""Test that question IDs match expected fields."""
questions = get_psychotype_questions()
expected_ids = ['unfamiliar_behavior', 'stress_reaction', 'leadership', 'risk_taking']
actual_ids = [q['id'] for q in questions]
assert actual_ids == expected_ids
def test_options_structure(self):
"""Test that each option has value and label."""
questions = get_psychotype_questions()
for q in questions:
for option in q['options']:
assert 'value' in option
assert 'label' in option
assert len(option['value']) > 0
assert len(option['label']) > 0
class TestPsychotypeIntegration:
"""Integration tests for complete psychotype workflow."""
def test_full_workflow_dominant(self):
"""Test complete workflow for dominant type."""
answers = {
'unfamiliar_behavior': 'explore',
'stress_reaction': 'angry',
'leadership': 'always_leader',
'risk_taking': 'very'
}
psychotype = detect_psychotype(answers)
modifiers = get_psychotype_modifiers(psychotype)
recommendations = get_search_recommendations(psychotype)
assert psychotype == 'dominant'
assert modifiers['zone_1500_2500'] > modifiers['zone_0_500']
assert 'priority_zones' in recommendations
def test_full_workflow_anxious(self):
"""Test complete workflow for anxious type."""
answers = {
'unfamiliar_behavior': 'freeze',
'stress_reaction': 'cry',
'leadership': 'always_follower',
'risk_taking': 'no_cautious'
}
psychotype = detect_psychotype(answers)
modifiers = get_psychotype_modifiers(psychotype)
recommendations = get_search_recommendations(psychotype)
assert psychotype in ['anxious', 'introvert_passive']
assert modifiers['zone_0_500'] > 1.0
assert '0-500' in recommendations['priority_zones']
def test_modifier_distributions_differ(self):
"""Test that different psychotypes have different modifier distributions."""
dominant_mods = get_psychotype_modifiers('dominant')
anxious_mods = get_psychotype_modifiers('anxious')
# Dominant emphasizes far zones
assert dominant_mods['zone_1500_2500'] > anxious_mods['zone_1500_2500']
# Anxious emphasizes near zones
assert anxious_mods['zone_0_500'] > dominant_mods['zone_0_500']
+440
View File
@@ -0,0 +1,440 @@
"""
Tests for scoring_service.py
Tests the WeightedScorer class and zone ranking logic
based on §8 and §9 ВЕКТОР-контекст.md specifications.
"""
import pytest
from services.scoring_service import (
WeightedScorer,
create_scorer_for_case,
get_weight_explanation
)
class TestWeightedScorerBasics:
"""Test basic WeightedScorer functionality."""
def test_initialization(self):
"""Test scorer initializes with base weights."""
scorer = WeightedScorer()
assert scorer.weights['forest'] == 0.25
assert scorer.weights['water'] == 0.20
assert scorer.weights['roads'] == 0.18
assert scorer.weights['settlement'] == 0.15
assert scorer.weights['historical'] == 0.12
assert scorer.weights['direction'] == 0.07
assert scorer.weights['shelter'] == 0.03
assert scorer.distance_multiplier == 1.0
assert scorer.active_profiles == []
def test_base_weights_sum_to_one(self):
"""Test that base weights sum to 1.0."""
scorer = WeightedScorer()
total = sum(scorer.BASE_WEIGHTS.values())
assert total == pytest.approx(1.0, rel=0.01)
class TestAgeModifiers:
"""Test age-based modifiers."""
def test_age_group_0_4(self):
"""Test young children (0-4) modifiers."""
scorer = WeightedScorer()
scorer.apply_age_modifiers(3)
# Young children: high water risk, low distance
assert scorer.distance_multiplier == 0.3
# Water weight should be increased
assert scorer.weights['water'] > scorer.BASE_WEIGHTS['water']
def test_age_group_8_11(self):
"""Test preteen (8-11) modifiers."""
scorer = WeightedScorer()
scorer.apply_age_modifiers(10)
assert scorer.distance_multiplier == 0.8
def test_age_group_15_17(self):
"""Test teenager (15-17) modifiers."""
scorer = WeightedScorer()
scorer.apply_age_modifiers(16)
# Teenagers: higher distance multiplier
assert scorer.distance_multiplier == 1.5
class TestSeasonModifiers:
"""Test seasonal modifiers."""
def test_winter_modifiers(self):
"""Test winter season modifiers."""
scorer = WeightedScorer()
scorer.apply_season_modifiers('зима')
# Winter: shelter more important, distance reduced
assert scorer.distance_multiplier == 0.7
assert scorer.weights['shelter'] > scorer.BASE_WEIGHTS['shelter']
def test_summer_modifiers(self):
"""Test summer season modifiers."""
scorer = WeightedScorer()
scorer.apply_season_modifiers('лето')
# Summer: increased distance
assert scorer.distance_multiplier == 1.3
class TestBehavioralProfiles:
"""Test behavioral profiles from §8."""
def test_ras_profile(self):
"""Test РАС (autism) profile with critical water/railway emphasis."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС'])
# РАС: water x3.0, railway x2.5, distance x2.0
assert scorer.distance_multiplier == 2.0
assert 'РАС' in scorer.active_profiles
assert len(scorer.critical_warnings) == 1
assert 'водоёмы' in scorer.critical_warnings[0]['warning']
def test_epilepsy_profile(self):
"""Test эпилепсия profile with reduced distance."""
scorer = WeightedScorer()
scorer.apply_profile(['эпилепсия'])
# Epilepsy: water x3.5, distance x0.6
assert scorer.distance_multiplier == 0.6
assert len(scorer.critical_warnings) == 1
def test_bicycle_profile(self):
"""Test велосипед profile with massive distance increase."""
scorer = WeightedScorer()
scorer.apply_profile(['велосипед'])
# Bicycle: distance x5.0, roads x1.8
assert scorer.distance_multiplier == 5.0
assert 'велосипед' in scorer.active_profiles
assert len(scorer.critical_warnings) == 1
assert '10-15 км' in scorer.critical_warnings[0]['warning']
def test_scooter_profile(self):
"""Test самокат profile."""
scorer = WeightedScorer()
scorer.apply_profile(['самокат'])
# Scooter: distance x3.0
assert scorer.distance_multiplier == 3.0
def test_intentional_runaway_profile(self):
"""Test намеренный_уход profile."""
scorer = WeightedScorer()
base_roads = scorer.weights['roads']
scorer.apply_profile(['намеренный_уход'])
# Intentional runaway: roads x2.5, settlement x3.0
assert scorer.weights['roads'] > base_roads
def test_multiple_profiles(self):
"""Test applying multiple profiles."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС', 'велосипед'])
# Both multipliers should compound: 2.0 * 5.0 = 10.0
assert scorer.distance_multiplier == 10.0
assert len(scorer.active_profiles) == 2
class TestScoreZone:
"""Test zone scoring functionality."""
def test_score_zone_basic(self):
"""Test basic zone scoring."""
scorer = WeightedScorer()
zone = {
'forest_pct': 0.7,
'water_distance_km': 2.0,
'road_density': 1.0,
'settlement_distance_km': 5.0,
'historical_freq': 0.6,
'direction_match': 0.8,
'shelter_pct': 0.4,
'distance_km': 2.0
}
case = {'age': 10, 'season': 'лето', 'profiles': []}
score = scorer.score_zone(zone, case)
assert 0 <= score <= 100
assert isinstance(score, float)
def test_score_zone_with_ras(self):
"""Test zone scoring with РАС profile."""
scorer = WeightedScorer()
zone_near_water = {
'forest_pct': 0.5,
'water_distance_km': 0.5, # Very close to water
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.3,
'distance_km': 2.0
}
zone_far_water = {
'forest_pct': 0.5,
'water_distance_km': 5.0, # Far from water
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.3,
'distance_km': 2.0
}
case = {'age': 8, 'season': 'лето', 'profiles': ['РАС']}
score_near = scorer.score_zone(zone_near_water, case)
score_far = scorer.score_zone(zone_far_water, case)
# Zone near water should score higher for РАС
assert score_near > score_far
def test_score_zone_with_bicycle(self):
"""Test zone scoring with bicycle profile."""
scorer = WeightedScorer()
zone_with_roads = {
'forest_pct': 0.3,
'water_distance_km': 5.0,
'road_density': 2.0, # High road density
'settlement_distance_km': 5.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.2,
'distance_km': 8.0 # Far distance
}
case = {'age': 12, 'season': 'лето', 'profiles': ['велосипед']}
score = scorer.score_zone(zone_with_roads, case)
assert score > 0
class TestRankZones:
"""Test zone ranking functionality."""
def test_rank_zones_basic(self):
"""Test basic zone ranking."""
scorer = WeightedScorer()
zones = [
{
'id': 'zone_a',
'forest_pct': 0.8,
'water_distance_km': 1.0,
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.7,
'direction_match': 0.9,
'shelter_pct': 0.5,
'distance_km': 2.0
},
{
'id': 'zone_b',
'forest_pct': 0.3,
'water_distance_km': 8.0,
'road_density': 0.2,
'settlement_distance_km': 15.0,
'historical_freq': 0.2,
'direction_match': 0.3,
'shelter_pct': 0.1,
'distance_km': 5.0
},
{
'id': 'zone_c',
'forest_pct': 0.6,
'water_distance_km': 3.0,
'road_density': 1.0,
'settlement_distance_km': 5.0,
'historical_freq': 0.8,
'direction_match': 0.7,
'shelter_pct': 0.4,
'distance_km': 1.5
}
]
case = {'age': 10, 'season': 'лето', 'profiles': []}
ranked = scorer.rank_zones(zones, case)
assert len(ranked) == 3
assert all('score' in z for z in ranked)
assert all('priority' in z for z in ranked)
# Check priorities are 1, 2, 3
priorities = [z['priority'] for z in ranked]
assert priorities == [1, 2, 3]
# Check scores are descending
scores = [z['score'] for z in ranked]
assert scores == sorted(scores, reverse=True)
def test_rank_zones_with_profiles(self):
"""Test zone ranking with behavioral profiles."""
scorer = WeightedScorer()
zones = [
{
'id': 'near_water',
'forest_pct': 0.5,
'water_distance_km': 0.3,
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.3,
'distance_km': 2.0
},
{
'id': 'far_water',
'forest_pct': 0.5,
'water_distance_km': 8.0,
'road_density': 0.5,
'settlement_distance_km': 10.0,
'historical_freq': 0.5,
'direction_match': 0.5,
'shelter_pct': 0.3,
'distance_km': 2.0
}
]
case = {'age': 8, 'season': 'лето', 'profiles': ['РАС']}
ranked = scorer.rank_zones(zones, case)
# Zone near water should be priority 1 for РАС
assert ranked[0]['id'] == 'near_water'
assert ranked[0]['priority'] == 1
class TestHelperFunctions:
"""Test helper functions."""
def test_create_scorer_for_case(self):
"""Test scorer creation for a case."""
case = {
'age': 10,
'season': 'зима',
'profiles': ['велосипед']
}
scorer = create_scorer_for_case(case)
assert scorer.distance_multiplier > 1.0
assert 'велосипед' in scorer.active_profiles
def test_get_weight_explanation(self):
"""Test weight explanation generation."""
case = {
'age': 8,
'season': 'лето',
'profiles': ['РАС']
}
explanation = get_weight_explanation(case)
assert 'weights' in explanation
assert 'distance_multiplier' in explanation
assert 'age_group' in explanation
assert 'profiles' in explanation
assert 'critical_warnings' in explanation
assert explanation['age_group'] == '8-11'
assert len(explanation['profiles']) == 1
assert len(explanation['critical_warnings']) == 1
def test_get_active_profiles_info(self):
"""Test active profiles info retrieval."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС', 'велосипед'])
profiles_info = scorer.get_active_profiles_info()
assert len(profiles_info) == 2
assert profiles_info[0]['name'] == 'РАС'
assert profiles_info[1]['name'] == 'велосипед'
assert 'critical_warning' in profiles_info[0]
assert 'critical_warning' in profiles_info[1]
class TestWeightNormalization:
"""Test weight normalization."""
def test_weights_normalized_after_modifiers(self):
"""Test that weights sum to 1.0 after applying modifiers."""
scorer = WeightedScorer()
scorer.apply_age_modifiers(10)
scorer.apply_season_modifiers('зима')
scorer._normalize_weights()
total = sum(scorer.weights.values())
assert total == pytest.approx(1.0, rel=0.01)
def test_weights_normalized_after_profiles(self):
"""Test that weights sum to 1.0 after applying profiles."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС'])
scorer._normalize_weights()
total = sum(scorer.weights.values())
assert total == pytest.approx(1.0, rel=0.01)
class TestCriticalWarnings:
"""Test critical warning system."""
def test_ras_critical_warning(self):
"""Test РАС generates critical warning."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС'])
assert len(scorer.critical_warnings) == 1
warning = scorer.critical_warnings[0]
assert warning['profile'] == 'РАС'
assert 'водоёмы' in warning['warning']
assert 'громкоговоритель' in warning['warning']
def test_epilepsy_critical_warning(self):
"""Test эпилепсия generates critical warning."""
scorer = WeightedScorer()
scorer.apply_profile(['эпилепсия'])
assert len(scorer.critical_warnings) == 1
warning = scorer.critical_warnings[0]
assert warning['profile'] == 'эпилепсия'
assert 'Медицинский' in warning['warning']
def test_bicycle_critical_warning(self):
"""Test велосипед generates critical warning."""
scorer = WeightedScorer()
scorer.apply_profile(['велосипед'])
assert len(scorer.critical_warnings) == 1
warning = scorer.critical_warnings[0]
assert warning['profile'] == 'велосипед'
assert '10-15 км' in warning['warning']
def test_multiple_critical_warnings(self):
"""Test multiple profiles generate multiple warnings."""
scorer = WeightedScorer()
scorer.apply_profile(['РАС', 'велосипед'])
assert len(scorer.critical_warnings) == 2
+64
View File
@@ -0,0 +1,64 @@
version: '3.8'
services:
postgres:
image: postgres:16
container_name: vector-postgres
environment:
POSTGRES_DB: vector_mchs
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
adminer:
image: adminer
container_name: vector-adminer
ports:
- "8080:8080"
depends_on:
- postgres
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: vector-backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/vector_mchs}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: vector-frontend
ports:
- "3000:3000"
environment:
- REACT_APP_API_URL=http://localhost:8000
depends_on:
- backend
volumes:
- ./frontend:/app
- /app/node_modules
volumes:
postgres_data:
+13
View File
@@ -0,0 +1,13 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
+14645
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "sar-mchs-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@types/leaflet": "^1.9.8",
"@types/leaflet.heat": "^0.2.2",
"axios": "^1.6.0",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.20.0",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://backend:8000"
}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="SAR-MCHS - Search and Rescue Management System" />
<title>SAR-MCHS</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.App {
min-height: 100vh;
}
/* Mobile-first responsive breakpoints */
@media (max-width: 768px) {
.admin-dashboard .sidebar {
position: fixed;
left: -260px;
z-index: 1000;
transition: left 0.3s ease;
}
.admin-dashboard .sidebar.open {
left: 0;
}
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useEffect } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom";
import AdminDashboard from "./pages/AdminDashboard";
import MobileFormPage from "./pages/MobileFormPage";
import DesktopSARPage from "./pages/DesktopSARPage";
import AnalysisResult from "./pages/analysis/AnalysisResult";
import "./App.css";
function ThemeProvider({ children }) {
const location = useLocation();
useEffect(() => {
let theme = "desktop";
if (location.pathname.startsWith("/mobile") || location.pathname.startsWith("/m")) {
theme = "mobile";
} else if (location.pathname.startsWith("/admin")) {
theme = "admin";
} else if (location.pathname.startsWith("/sar") || location.pathname.startsWith("/desktop") || location.pathname.startsWith("/analysis")) {
theme = "desktop";
}
document.documentElement.setAttribute("data-theme", theme);
}, [location.pathname]);
return children;
}
function App() {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || window.innerWidth < 768;
return (
<Router>
<ThemeProvider>
<Routes>
<Route path="/" element={<Navigate to={isMobile ? "/mobile" : "/desktop"} replace />} />
<Route path="/sar" element={<DesktopSARPage />} />
<Route path="/desktop" element={<DesktopSARPage />} />
<Route path="/analysis/:caseId" element={<AnalysisResult />} />
<Route path="/admin/*" element={<AdminDashboard />} />
<Route path="/mobile" element={<MobileFormPage />} />
</Routes>
</ThemeProvider>
</Router>
);
}
export default App;
@@ -0,0 +1,112 @@
.analysis-layout {
min-height: 100vh;
background: #0a0c0f;
padding: 24px;
}
/* Двухколоночный layout */
.layout-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
max-width: 1800px;
margin: 0 auto;
}
/* Левая колонка - Форма */
.layout-left {
background: #0a0c0f;
}
/* Правая колонка - Карта */
.layout-right {
position: relative;
}
.map-container-sticky {
position: sticky;
top: 24px;
height: fit-content;
}
/* Placeholder для карты */
.map-placeholder {
height: 600px;
background: #161b22;
border: 2px dashed #30363d;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-content {
text-align: center;
padding: 40px;
}
.placeholder-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.placeholder-content h3 {
margin: 0 0 12px 0;
font-size: 20px;
color: #e6edf3;
font-weight: 600;
}
.placeholder-content p {
margin: 0;
font-size: 14px;
color: #8b949e;
line-height: 1.5;
max-width: 300px;
}
/* Результаты снизу на всю ширину */
.layout-bottom {
max-width: 1800px;
margin: 0 auto;
width: 100%;
}
/* Адаптивность */
@media (max-width: 1400px) {
.layout-container {
grid-template-columns: 1fr;
}
.map-container-sticky {
position: relative;
top: 0;
}
.map-placeholder {
height: 400px;
}
}
@media (max-width: 768px) {
.analysis-layout {
padding: 16px;
}
.layout-container {
gap: 16px;
}
.placeholder-icon {
font-size: 48px;
}
.placeholder-content h3 {
font-size: 18px;
}
.placeholder-content p {
font-size: 13px;
}
}
@@ -0,0 +1,67 @@
import React, { useState } from "react";
import CaseForm from "../CaseForm/CaseForm";
import MapView from "../MapView/MapView";
import ResultPanel from "../ResultPanel/ResultPanel";
import "./AnalysisLayout.css";
const AnalysisLayout = () => {
const [analysisResult, setAnalysisResult] = useState(null);
const [heatmapData, setHeatmapData] = useState([]);
const [showMap, setShowMap] = useState(false);
const handleAnalysisComplete = (result) => {
setAnalysisResult(result);
setShowMap(true);
};
const handleHeatmapLoad = (data) => {
setHeatmapData(data);
};
return (
<div className="analysis-layout">
<div className="layout-container">
{/* Левая колонка - Форма */}
<div className="layout-left">
<CaseForm
onAnalysisComplete={handleAnalysisComplete}
onHeatmapLoad={handleHeatmapLoad}
/>
</div>
{/* Правая колонка - Карта */}
<div className="layout-right">
{showMap && analysisResult ? (
<div className="map-container-sticky">
<MapView
tnpLat={analysisResult.tnp_lat || 53.9}
tnpLon={analysisResult.tnp_lon || 27.56}
zones={analysisResult.zones || []}
direction={analysisResult.direction}
heatmapData={heatmapData}
showHeatmap={false}
/>
</div>
) : (
<div className="map-placeholder">
<div className="placeholder-content">
<div className="placeholder-icon">🗺️</div>
<h3>Карта поиска</h3>
<p>Заполните форму и запустите анализ для отображения зон поиска</p>
</div>
</div>
)}
</div>
</div>
{/* Результаты снизу на всю ширину */}
{analysisResult && (
<div className="layout-bottom">
<ResultPanel analysisResult={analysisResult} />
</div>
)}
</div>
);
};
export default AnalysisLayout;
@@ -0,0 +1 @@
export { default } from "./AnalysisLayout";
@@ -0,0 +1,186 @@
.behavioral-profiles {
margin: 20px 0;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
.profiles-title {
margin: 0 0 15px 0;
font-size: 1.3rem;
color: #333;
font-weight: 600;
}
.profiles-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
}
.profile-card {
background: white;
border-radius: 8px;
padding: 15px;
border-left: 4px solid;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.profile-card-red {
border-left-color: #dc3545;
}
.profile-card-orange {
border-left-color: #fd7e14;
}
.profile-card-yellow {
border-left-color: #ffc107;
}
.profile-card-blue {
border-left-color: #0d6efd;
}
.profile-card-purple {
border-left-color: #6f42c1;
}
.profile-card-gray {
border-left-color: #6c757d;
}
.profile-card-green {
border-left-color: #198754;
}
.profile-header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #e9ecef;
}
.profile-title {
margin: 0;
font-size: 1.1rem;
color: #333;
font-weight: 600;
}
.profile-recommendations {
margin-bottom: 12px;
}
.profile-recommendations h5,
.profile-modifiers h5 {
margin: 0 0 8px 0;
font-size: 0.9rem;
color: #666;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.recommendations-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.recommendations-list li {
margin-bottom: 6px;
font-size: 0.95rem;
color: #555;
line-height: 1.4;
}
.profile-modifiers {
padding-top: 10px;
border-top: 1px solid #e9ecef;
}
.modifiers-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.modifier-item {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
}
.modifier-zone {
color: #666;
font-weight: 500;
}
.modifier-value {
color: #0d6efd;
font-weight: 700;
}
/* Mobile styles */
@media (max-width: 768px) {
.profiles-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.profile-card {
padding: 12px;
}
.profiles-title {
font-size: 1.1rem;
}
.profile-title {
font-size: 1rem;
}
.recommendations-list li.mobile-hidden {
display: none;
}
.profile-recommendations h5,
.profile-modifiers h5 {
font-size: 0.85rem;
}
.recommendations-list li {
font-size: 0.9rem;
margin-bottom: 5px;
}
.modifier-item {
font-size: 0.85rem;
padding: 3px 8px;
}
}
/* Desktop - full cards */
@media (min-width: 769px) {
.mobile-hidden {
display: list-item !important;
}
}
/* Tablet */
@media (min-width: 769px) and (max-width: 1024px) {
.profiles-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Large desktop */
@media (min-width: 1400px) {
.profiles-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@@ -0,0 +1,268 @@
import React from 'react';
import './BehavioralProfile.css';
interface BehavioralProfileProps {
diagnosis_type?: string[];
has_transport?: string;
age_years?: number;
reason?: string;
}
interface ProfileCardData {
id: string;
title: string;
color: string;
recommendations: string[];
zoneModifiers: { zone: string; multiplier: number }[];
}
const BehavioralProfile: React.FC<BehavioralProfileProps> = ({
diagnosis_type = [],
has_transport,
age_years,
reason
}) => {
const getActiveProfiles = (): ProfileCardData[] => {
const profiles: ProfileCardData[] = [];
// РАС (Расстройство аутистического спектра)
if (diagnosis_type.includes('РАС') || diagnosis_type.includes('аутизм')) {
profiles.push({
id: 'ras',
title: 'РАС (Расстройство аутистического спектра)',
color: 'red',
recommendations: [
'Не кричать имя — может спрятаться от громких звуков',
'Приоритет: водоёмы, тихие места, укрытия',
'Перекрыть доступ к воде (пруды, реки, колодцы)',
'Проверить железнодорожные пути и технические помещения',
'Искать в радиусе до 5 км, часто движется по прямой'
],
zoneModifiers: [
{ zone: 'Водоём', multiplier: 3.0 },
{ zone: 'Укрытие', multiplier: 2.0 },
{ zone: 'Населённый пункт', multiplier: 0.4 }
]
});
}
// Эпилепсия
if (diagnosis_type.includes('эпилепсия')) {
profiles.push({
id: 'epilepsy',
title: 'Эпилепсия',
color: 'orange',
recommendations: [
'Искать близко к точке потери — радиус до 1 км',
'Проверить канавы, овраги, низины (риск падения)',
'Приоритет: водоёмы и опасные участки',
'Срочная медицинская помощь при обнаружении',
'Проверить укрытия (мог укрыться после приступа)'
],
zoneModifiers: [
{ zone: 'Водоём', multiplier: 3.5 },
{ zone: 'Укрытие', multiplier: 2.5 },
{ zone: 'Радиус поиска', multiplier: 0.6 }
]
});
}
// СДВГ (Синдром дефицита внимания и гиперактивности)
if (diagnosis_type.includes('СДВГ') || diagnosis_type.includes('гиперактивность')) {
profiles.push({
id: 'adhd',
title: 'СДВГ (Гиперактивность)',
color: 'yellow',
recommendations: [
'Расширенный радиус поиска — может уйти далеко',
'Приоритет: дороги, тропы, интересные объекты',
'Проверить игровые площадки, стройки, заброшенные здания',
'Может отвлечься и изменить направление',
'Искать вдоль дорог и троп'
],
zoneModifiers: [
{ zone: 'Дороги', multiplier: 1.6 },
{ zone: 'Радиус поиска', multiplier: 1.4 }
]
});
}
// Деменция
if (diagnosis_type.includes('деменция') || diagnosis_type.includes('Альцгеймер')) {
profiles.push({
id: 'dementia',
title: 'Деменция / Альцгеймер',
color: 'purple',
recommendations: [
'Искать в радиусе до 2 км от дома',
'Проверить знакомые места (бывший дом, работа)',
'Приоритет: дороги, тропы (идёт по привычному маршруту)',
'Может не откликаться на имя',
'Проверить водоёмы и опасные участки'
],
zoneModifiers: [
{ zone: 'Дороги', multiplier: 1.8 },
{ zone: 'Исторические места', multiplier: 2.0 },
{ zone: 'Водоём', multiplier: 1.5 }
]
});
}
// Велосипед
if (has_transport === 'велосипед') {
profiles.push({
id: 'bicycle',
title: 'Транспорт: Велосипед',
color: 'blue',
recommendations: [
'Радиус поиска ×5 — может уехать на 10-25 км',
'Приоритет: дороги, велодорожки, тропы',
'Запросить записи с камер на дорогах',
'Проверить популярные велосипедные маршруты',
'Лес и бездорожье менее вероятны'
],
zoneModifiers: [
{ zone: 'Радиус поиска', multiplier: 5.0 },
{ zone: 'Дороги', multiplier: 1.8 },
{ zone: 'Лес', multiplier: 0.8 }
]
});
}
// Автомобиль
if (has_transport === 'автомобиль' || has_transport === 'машина') {
profiles.push({
id: 'car',
title: 'Транспорт: Автомобиль',
color: 'blue',
recommendations: [
'Радиус поиска ×10 — может уехать на 50+ км',
'Запросить записи с камер ГИБДД и дорожных камер',
'Проверить заправки, парковки, придорожные кафе',
'Ориентация на дороги и трассы',
'Розыск по номеру автомобиля'
],
zoneModifiers: [
{ zone: 'Радиус поиска', multiplier: 10.0 },
{ zone: 'Дороги', multiplier: 2.5 },
{ zone: 'Лес', multiplier: 0.3 }
]
});
}
// Подросток, ушедший из дома
if (age_years && age_years >= 13 && age_years <= 17 &&
(reason?.includes('ушёл из дома') || reason?.includes('конфликт'))) {
profiles.push({
id: 'runaway',
title: 'Подросток: Уход из дома',
color: 'gray',
recommendations: [
'Розыск через соцсети и друзей',
'Проверить дома друзей, торговые центры, вокзалы',
'Лес и удалённые места менее вероятны',
'Может иметь телефон — запросить геолокацию',
'Проверить популярные места встреч подростков'
],
zoneModifiers: [
{ zone: 'Населённый пункт', multiplier: 2.0 },
{ zone: 'Лес', multiplier: 0.3 },
{ zone: 'Дороги', multiplier: 1.5 }
]
});
}
// Грибник
if (reason?.includes('грибы') || reason?.includes('грибник')) {
profiles.push({
id: 'mushroom',
title: 'Активность: Сбор грибов',
color: 'green',
recommendations: [
'Приоритет: лесные массивы, особенно смешанные леса',
'Радиус до 3 км от точки входа в лес',
'Проверить популярные грибные места',
'Может углубиться в лес, потеряв ориентацию',
'Искать вдоль просек и лесных дорог'
],
zoneModifiers: [
{ zone: 'Лес', multiplier: 3.0 },
{ zone: 'Дороги', multiplier: 0.6 },
{ zone: 'Радиус поиска', multiplier: 1.2 }
]
});
}
// Охотник
if (reason?.includes('охота') || reason?.includes('охотник')) {
profiles.push({
id: 'hunter',
title: 'Активность: Охота',
color: 'green',
recommendations: [
'Радиус до 5-10 км — охотники перемещаются активно',
'Приоритет: лесные массивы, поля, болота',
'Проверить охотничьи вышки, избушки, кормушки',
'Может иметь оружие — соблюдать осторожность',
'Искать вдоль звериных троп и просек'
],
zoneModifiers: [
{ zone: 'Лес', multiplier: 2.5 },
{ zone: 'Радиус поиска', multiplier: 2.0 },
{ zone: 'Укрытие', multiplier: 1.5 }
]
});
}
return profiles;
};
const activeProfiles = getActiveProfiles();
if (activeProfiles.length === 0) {
return null;
}
return (
<div className="behavioral-profiles">
<h3 className="profiles-title">Поведенческие профили</h3>
<div className="profiles-grid">
{activeProfiles.map((profile) => (
<div
key={profile.id}
className={`profile-card profile-card-${profile.color}`}
>
<div className="profile-header">
<h4 className="profile-title">{profile.title}</h4>
</div>
<div className="profile-recommendations">
<h5>Рекомендации:</h5>
<ul className="recommendations-list">
{profile.recommendations.map((rec, idx) => (
<li key={idx} className={idx >= 2 ? 'mobile-hidden' : ''}>
{rec}
</li>
))}
</ul>
</div>
<div className="profile-modifiers">
<h5>Модификаторы зон:</h5>
<div className="modifiers-list">
{profile.zoneModifiers.map((mod, idx) => (
<div key={idx} className="modifier-item">
<span className="modifier-zone">{mod.zone}:</span>
<span className="modifier-value">×{mod.multiplier.toFixed(1)}</span>
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default BehavioralProfile;
@@ -0,0 +1,344 @@
.case-form {
max-width: 800px;
margin: 0 auto;
padding: 24px;
background: #0a0c0f;
min-height: 100vh;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #21262d;
}
.form-header h1 {
margin: 0;
font-size: 28px;
color: #e6edf3;
font-weight: 700;
}
.step-indicator {
font-size: 14px;
color: #8b949e;
font-weight: 500;
}
.form-content {
margin-bottom: 24px;
}
.form-step {
background: #161b22;
border: 1px solid #21262d;
border-radius: 12px;
padding: 24px;
}
.form-step h2 {
margin: 0 0 20px 0;
font-size: 20px;
color: #e6edf3;
font-weight: 600;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #e6edf3;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #ff4d00;
}
.form-group textarea {
resize: vertical;
font-family: inherit;
}
.button-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.button-group button {
padding: 10px 16px;
background: #21262d;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.button-group button:hover {
background: #30363d;
border-color: #ff4d00;
}
.button-group button.selected {
background: #ff4d00;
color: #ffffff;
border-color: #ff4d00;
font-weight: 600;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.checkbox-label:hover {
background: #161b22;
border-color: #ff4d00;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #ff4d00;
}
.form-navigation {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.form-navigation button {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.form-navigation button:first-child {
background: #21262d;
color: #e6edf3;
border: 1px solid #30363d;
}
.form-navigation button:first-child:hover {
background: #30363d;
}
.form-navigation button:last-child {
background: #ff4d00;
color: #ffffff;
}
.form-navigation button:last-child:hover {
background: #ff6a2e;
}
.form-navigation button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-banner {
background: #da3633;
color: white;
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
}
.error-banner div {
margin-bottom: 4px;
}
.error-banner div:last-child {
margin-bottom: 0;
}
/* Психотип - улучшенная читаемость */
.psychotype-section {
background: #161b22;
border-left: 4px solid #ff4d00;
padding: 24px;
}
.section-header {
margin-bottom: 24px;
}
.section-subtitle {
margin: 8px 0 0 0;
font-size: 14px;
color: #8b949e;
font-style: italic;
}
.psychotype-question {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #21262d;
}
.psychotype-question:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.question-label {
display: block;
margin-bottom: 16px;
font-size: 16px;
color: #e6edf3;
font-weight: 600;
}
.button-group-vertical {
display: flex;
flex-direction: column;
gap: 12px;
}
.psychotype-btn {
padding: 16px 20px;
background: #0d1117;
color: #e6edf3;
border: 2px solid #30363d;
border-radius: 8px;
font-size: 15px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.psychotype-btn:hover {
background: #161b22;
border-color: #ff4d00;
transform: translateX(4px);
}
.psychotype-btn.selected {
background: #ff4d00;
color: #ffffff;
border-color: #ff4d00;
font-weight: 600;
}
@media (max-width: 768px) {
.case-form {
padding: 16px;
}
.form-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.form-step {
padding: 16px;
}
.form-navigation {
flex-direction: column;
}
.form-navigation button {
width: 100%;
}
.button-group {
flex-direction: column;
}
.button-group button {
width: 100%;
}
}
/* GPS Button */
.gps-button {
width: 100%;
padding: 12px 16px;
background: #238636;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.gps-button:hover {
background: #2ea043;
}
.gps-button:disabled {
background: #21262d;
color: #8b949e;
cursor: not-allowed;
}
/* Datetime input */
input[type="datetime-local"] {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 14px;
transition: border-color 0.2s;
}
input[type="datetime-local"]:focus {
outline: none;
border-color: #ff4d00;
}
@@ -0,0 +1,212 @@
import React, { useState } from "react";
import Step1Child from "./Step1Child";
import Step2Health from "./Step2Health";
import Step2bPsychotype from "./Step2bPsychotype";
import Step3Circumstances from "./Step3Circumstances";
import Step4Environment from "./Step4Environment";
import "./CaseForm.css";
const INITIAL_DATA = {
has_diagnosis: false,
diagnosis_type: [],
cant_swim: false,
terrain: [],
has_dog: false,
extra_resources: []
};
const CaseForm = ({ isMobile = false }) => {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState(INITIAL_DATA);
const [errors, setErrors] = useState([]);
const [submitting, setSubmitting] = useState(false);
// Мобильная: 3 шага, Десктоп: 5 шагов
const totalSteps = isMobile ? 3 : 5;
const updateFormData = (updates) => {
setFormData(prev => ({ ...prev, ...updates }));
};
const validateStep = (step) => {
const newErrors = [];
if (step === 1) {
if (!formData.age_years || formData.age_years < 0 || formData.age_years > 120) {
newErrors.push("Укажите корректный возраст");
}
if (!formData.gender) {
newErrors.push("Выберите пол");
}
}
// Для десктопа: Шаг 4 = Обстоятельства
if (step === 4 && !isMobile) {
if (!formData.reason) {
newErrors.push("Укажите причину потери");
}
if (!formData.lost_time) {
newErrors.push("Укажите дату и время потери");
}
if (!formData.last_known_place) {
newErrors.push("Укажите последнее известное место");
}
}
// Для десктопа: Шаг 5 = Среда и местность
if (step === 5 && !isMobile) {
if (!formData.terrain || formData.terrain.length === 0) {
newErrors.push("Выберите хотя бы один тип местности");
}
if (!formData.tnp_lat || !formData.tnp_lon) {
newErrors.push("Укажите GPS координаты точки потери");
}
}
// Для мобильной: Шаг 3 = Обстоятельства (с GPS)
if (step === 3 && isMobile) {
if (!formData.reason) {
newErrors.push("Укажите причину потери");
}
if (!formData.tnp_lat || !formData.tnp_lon) {
newErrors.push("Укажите GPS координаты ТНП");
}
}
setErrors(newErrors);
return newErrors.length === 0;
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
};
const handleSubmit = async () => {
if (!validateStep(currentStep)) return;
setSubmitting(true);
setErrors([]);
try {
const payload = {
...formData,
clothes_description: [
formData.clothes_upper,
formData.clothes_lower,
formData.clothes_shoes
].filter(Boolean).join(", ")
};
// Создаем карточку
const response = await fetch("/api/v1/cases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Ошибка при создании карточки");
}
const result = await response.json();
console.log("Case created:", result);
// Перенаправляем на страницу анализа с ID карточки
window.location.href = `/analysis/${result.id}`;
} catch (err) {
setErrors([err.message || "Ошибка при отправке данных"]);
} finally {
setSubmitting(false);
}
};
const renderStep = () => {
// Мобильная версия: 3 шага
// 1 = Ребёнок, 2 = Здоровье, 3 = Обстоятельства
if (isMobile) {
switch (currentStep) {
case 1: return <Step1Child data={formData} updateData={updateFormData} />;
case 2: return <Step2Health data={formData} updateData={updateFormData} />;
case 3: return <Step3Circumstances data={formData} updateData={updateFormData} />;
default: return null;
}
}
// Десктопная версия: 5 шагов
// 1 = Ребёнок, 2 = Здоровье, 3 = Психотип, 4 = Обстоятельства, 5 = Среда
switch (currentStep) {
case 1: return <Step1Child data={formData} updateData={updateFormData} />;
case 2: return <Step2Health data={formData} updateData={updateFormData} />;
case 3: return <Step2bPsychotype data={formData} updateData={updateFormData} />;
case 4: return <Step3Circumstances data={formData} updateData={updateFormData} />;
case 5: return <Step4Environment data={formData} updateData={updateFormData} />;
default: return null;
}
};
return (
<div className="case-form">
<div className="form-header">
<h1>Карточка поиска</h1>
<div className="step-indicator">
Шаг {currentStep} из {totalSteps}
</div>
</div>
{errors.length > 0 && (
<div className="error-banner">
{errors.map((err, idx) => (
<div key={idx}>{err}</div>
))}
</div>
)}
<div className="form-content">
{renderStep()}
</div>
<div className="form-navigation">
{currentStep > 1 && (
<button
type="button"
className="btn-secondary"
onClick={handleBack}
disabled={submitting}
>
Назад
</button>
)}
{currentStep < totalSteps ? (
<button
type="button"
className="btn-primary"
onClick={handleNext}
disabled={submitting}
>
Далее
</button>
) : (
<button
type="button"
className="btn-primary submit-btn"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Создание карточки..." : "Создать карточку"}
</button>
)}
</div>
</div>
);
};
export default CaseForm;
@@ -0,0 +1,92 @@
import React from "react";
const Step1Child = ({ data, updateData }) => {
return (
<div className="form-step">
<h2>Шаг 1: Ребёнок</h2>
<div className="form-group">
<label>Имя *</label>
<input
type="text"
value={data.child_name || ""}
onChange={(e) => updateData({ child_name: e.target.value })}
placeholder="Имя ребёнка"
/>
</div>
<div className="form-group">
<label>Возраст (лет) *</label>
<input
type="number"
value={data.age_years || ""}
onChange={(e) => updateData({ age_years: parseInt(e.target.value) })}
min="0"
max="120"
/>
</div>
<div className="form-group">
<label>Пол *</label>
<div className="button-group">
<button
type="button"
className={data.gender === "М" ? "selected" : ""}
onClick={() => updateData({ gender: "М" })}
>
Мужской
</button>
<button
type="button"
className={data.gender === "Ж" ? "selected" : ""}
onClick={() => updateData({ gender: "Ж" })}
>
Женский
</button>
</div>
</div>
<div className="form-group">
<label>Одежда: верх (цвет, тип) *</label>
<input
type="text"
value={data.clothes_upper || ""}
onChange={(e) => updateData({ clothes_upper: e.target.value })}
placeholder="Красная куртка"
/>
</div>
<div className="form-group">
<label>Одежда: низ (цвет, тип)</label>
<input
type="text"
value={data.clothes_lower || ""}
onChange={(e) => updateData({ clothes_lower: e.target.value })}
placeholder="Синие джинсы"
/>
</div>
<div className="form-group">
<label>Обувь (тип, цвет)</label>
<input
type="text"
value={data.clothes_shoes || ""}
onChange={(e) => updateData({ clothes_shoes: e.target.value })}
placeholder="Чёрные кроссовки"
/>
</div>
<div className="form-group">
<label>Особые приметы</label>
<textarea
value={data.special_marks || ""}
onChange={(e) => updateData({ special_marks: e.target.value })}
placeholder="Родинка на щеке, носит очки..."
rows={3}
/>
</div>
</div>
);
};
export default Step1Child;
@@ -0,0 +1,188 @@
import React from "react";
const Step2Health = ({ data, updateData }) => {
const toggleDiagnosis = (diagnosis) => {
const current = data.diagnosis_type || [];
const updated = current.includes(diagnosis)
? current.filter((d) => d !== diagnosis)
: [...current, diagnosis];
updateData({
diagnosis_type: updated,
has_diagnosis: updated.length > 0
});
};
return (
<div className="form-step">
<h2>Шаг 2: Здоровье и особенности</h2>
<div className="form-group">
<label>Заболевания / диагнозы *</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("здоров")}
onChange={() => toggleDiagnosis("здоров")}
/>
Здоров
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("РАС")}
onChange={() => toggleDiagnosis("РАС")}
/>
РАС / Аутизм
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("эпилепсия")}
onChange={() => toggleDiagnosis("эпилепсия")}
/>
Эпилепсия
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("СДВГ")}
onChange={() => toggleDiagnosis("СДВГ")}
/>
СДВГ
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("ЗПР")}
onChange={() => toggleDiagnosis("ЗПР")}
/>
Задержка ПР / ЗПР
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("ДЦП")}
onChange={() => toggleDiagnosis("ДЦП")}
/>
ДЦП
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("слабое_зрение")}
onChange={() => toggleDiagnosis("слабое_зрение")}
/>
Слабое зрение
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("слабый_слух")}
onChange={() => toggleDiagnosis("слабый_слух")}
/>
Слабый слух
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.cant_swim}
onChange={(e) => updateData({ cant_swim: e.target.checked })}
/>
Не умеет плавать
</label>
</div>
</div>
<div className="form-group">
<label>Уровень физподготовки</label>
<div className="button-group">
<button
type="button"
className={data.fitness_level === "low" ? "selected" : ""}
onClick={() => updateData({ fitness_level: "low" })}
>
Низкий
</button>
<button
type="button"
className={data.fitness_level === "medium" ? "selected" : ""}
onClick={() => updateData({ fitness_level: "medium" })}
>
Средний
</button>
<button
type="button"
className={data.fitness_level === "high" ? "selected" : ""}
onClick={() => updateData({ fitness_level: "high" })}
>
Высокий
</button>
</div>
</div>
<div className="form-group">
<label>Наличие транспорта *</label>
<div className="button-group">
<button
type="button"
className={data.has_transport === "none" ? "selected" : ""}
onClick={() => updateData({ has_transport: "none" })}
>
Нет
</button>
<button
type="button"
className={data.has_transport === "bike" ? "selected" : ""}
onClick={() => updateData({ has_transport: "bike" })}
>
Велосипед
</button>
<button
type="button"
className={data.has_transport === "scooter" ? "selected" : ""}
onClick={() => updateData({ has_transport: "scooter" })}
>
Самокат
</button>
<button
type="button"
className={data.has_transport === "other" ? "selected" : ""}
onClick={() => updateData({ has_transport: "other" })}
>
Другое
</button>
</div>
</div>
<div className="form-group">
<label>Телефон *</label>
<div className="button-group">
<button
type="button"
className={data.phone_status === "answers" ? "selected" : ""}
onClick={() => updateData({ phone_status: "answers" })}
>
Есть, отвечает
</button>
<button
type="button"
className={data.phone_status === "silent" ? "selected" : ""}
onClick={() => updateData({ phone_status: "silent" })}
>
Есть, не отвечает
</button>
<button
type="button"
className={data.phone_status === "none" ? "selected" : ""}
onClick={() => updateData({ phone_status: "none" })}
>
Нет
</button>
</div>
</div>
</div>
);
};
export default Step2Health;
@@ -0,0 +1,152 @@
import React from "react";
const Step2bPsychotype = ({ data, updateData }) => {
const updateAnswer = (question, value) => {
updateData({
psychotype_answers: {
...data.psychotype_answers,
[question]: value
}
});
};
const isSelected = (question, value) => {
return data.psychotype_answers?.[question] === value;
};
return (
<div className="form-step psychotype-section">
<div className="section-header">
<h2>Шаг 2б: Поведение ребёнка</h2>
<p className="section-subtitle">
Спросить у родителей. Помогает уточнить зону поиска. Можно пропустить.
</p>
</div>
<div className="psychotype-question">
<label className="question-label">
Как ведёт себя в незнакомой обстановке?
</label>
<div className="button-group-vertical">
<button
type="button"
className={isSelected("unfamiliar_behavior", "explore") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("unfamiliar_behavior", "explore")}
>
Активно исследует
</button>
<button
type="button"
className={isSelected("unfamiliar_behavior", "wait") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("unfamiliar_behavior", "wait")}
>
Ждёт и наблюдает
</button>
<button
type="button"
className={isSelected("unfamiliar_behavior", "freeze") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("unfamiliar_behavior", "freeze")}
>
Замирает
</button>
<button
type="button"
className={isSelected("unfamiliar_behavior", "panic") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("unfamiliar_behavior", "panic")}
>
Паникует
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">
Реакция на стресс и неудачи?
</label>
<div className="button-group-vertical">
<button
type="button"
className={isSelected("stress_reaction", "angry") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("stress_reaction", "angry")}
>
Злится, кричит
</button>
<button
type="button"
className={isSelected("stress_reaction", "cry") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("stress_reaction", "cry")}
>
Плачет, замыкается
</button>
<button
type="button"
className={isSelected("stress_reaction", "calm") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("stress_reaction", "calm")}
>
Спокойно ищет выход
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">
Лидер или ведомый?
</label>
<div className="button-group-vertical">
<button
type="button"
className={isSelected("leadership", "always_leader") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("leadership", "always_leader")}
>
Всегда лидер
</button>
<button
type="button"
className={isSelected("leadership", "sometimes") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("leadership", "sometimes")}
>
Иногда
</button>
<button
type="button"
className={isSelected("leadership", "always_follower") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("leadership", "always_follower")}
>
Всегда ведомый
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">
Любит рисковать?
</label>
<div className="button-group-vertical">
<button
type="button"
className={isSelected("risk_taking", "very") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("risk_taking", "very")}
>
Очень
</button>
<button
type="button"
className={isSelected("risk_taking", "sometimes") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("risk_taking", "sometimes")}
>
Иногда
</button>
<button
type="button"
className={isSelected("risk_taking", "no_cautious") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("risk_taking", "no_cautious")}
>
Нет, осторожный
</button>
</div>
</div>
</div>
);
};
export default Step2bPsychotype;
@@ -0,0 +1,162 @@
import React from "react";
const Step3Circumstances = ({ data, updateData }) => {
return (
<div className="form-step">
<h2>Шаг 3: Обстоятельства потери</h2>
<div className="form-group">
<label>Причина потери *</label>
<div className="button-group">
<button
type="button"
className={data.reason === "ушёл_сам" ? "selected" : ""}
onClick={() => updateData({ reason: "ушёл_сам" })}
>
Ушёл сам
</button>
<button
type="button"
className={data.reason === "заблудился" ? "selected" : ""}
onClick={() => updateData({ reason: "заблудился" })}
>
Заблудился
</button>
<button
type="button"
className={data.reason === "отстал_от_группы" ? "selected" : ""}
onClick={() => updateData({ reason: "отстал_от_группы" })}
>
Отстал от группы
</button>
<button
type="button"
className={data.reason === "конфликт" ? "selected" : ""}
onClick={() => updateData({ reason: "конфликт" })}
>
После конфликта
</button>
<button
type="button"
className={data.reason === "неизвестно" ? "selected" : ""}
onClick={() => updateData({ reason: "неизвестно" })}
>
Неизвестно
</button>
</div>
</div>
<div className="form-group">
<label>Дата и время потери *</label>
<input
type="datetime-local"
value={data.lost_time || ""}
onChange={(e) => updateData({ lost_time: e.target.value })}
/>
</div>
<div className="form-group">
<label>Прошло времени с момента потери (часов)</label>
<input
type="number"
value={data.elapsed_hours || ""}
onChange={(e) => updateData({ elapsed_hours: parseFloat(e.target.value) })}
min="0"
step="0.5"
placeholder="Например: 2.5"
/>
</div>
<div className="form-group">
<label>Последнее известное место *</label>
<input
type="text"
value={data.last_known_place || ""}
onChange={(e) => updateData({ last_known_place: e.target.value })}
placeholder="Деревня Заречье, у магазина"
/>
</div>
<div className="form-group">
<label>Предполагаемое направление движения</label>
<div className="button-group">
<button
type="button"
className={data.direction === "N" ? "selected" : ""}
onClick={() => updateData({ direction: "N" })}
>
Север
</button>
<button
type="button"
className={data.direction === "NE" ? "selected" : ""}
onClick={() => updateData({ direction: "NE" })}
>
Северо-Восток
</button>
<button
type="button"
className={data.direction === "E" ? "selected" : ""}
onClick={() => updateData({ direction: "E" })}
>
Восток
</button>
<button
type="button"
className={data.direction === "SE" ? "selected" : ""}
onClick={() => updateData({ direction: "SE" })}
>
Юго-Восток
</button>
<button
type="button"
className={data.direction === "S" ? "selected" : ""}
onClick={() => updateData({ direction: "S" })}
>
Юг
</button>
<button
type="button"
className={data.direction === "SW" ? "selected" : ""}
onClick={() => updateData({ direction: "SW" })}
>
Юго-Запад
</button>
<button
type="button"
className={data.direction === "W" ? "selected" : ""}
onClick={() => updateData({ direction: "W" })}
>
Запад
</button>
<button
type="button"
className={data.direction === "NW" ? "selected" : ""}
onClick={() => updateData({ direction: "NW" })}
>
Северо-Запад
</button>
<button
type="button"
className={data.direction === "unknown" ? "selected" : ""}
onClick={() => updateData({ direction: "unknown" })}
>
Неизвестно
</button>
</div>
</div>
<div className="form-group">
<label>Описание обстоятельств</label>
<textarea
value={data.circumstances_description || ""}
onChange={(e) => updateData({ circumstances_description: e.target.value })}
placeholder="Подробное описание: что произошло, кто видел последним, что говорил ребёнок..."
rows={5}
/>
</div>
</div>
);
};
export default Step3Circumstances;
@@ -0,0 +1,274 @@
import React, { useEffect } from "react";
const Step4Environment = ({ data, updateData }) => {
const toggleTerrain = (terrain) => {
const current = data.terrain || [];
const updated = current.includes(terrain)
? current.filter((t) => t !== terrain)
: [...current, terrain];
updateData({ terrain: updated });
};
// Автоматически определяем сезон из даты потери
useEffect(() => {
if (data.lost_time && !data.season) {
const date = new Date(data.lost_time);
const month = date.getMonth() + 1; // 1-12
let season = 'лето';
if (month >= 3 && month <= 5) season = 'весна';
else if (month >= 6 && month <= 8) season = 'лето';
else if (month >= 9 && month <= 11) season = 'осень';
else season = 'зима'; // 12, 1, 2
updateData({ season });
}
}, [data.lost_time]);
const handleGetLocation = () => {
if (!navigator.geolocation) {
alert("Геолокация не поддерживается вашим браузером");
return;
}
updateData({ gps_loading: true });
navigator.geolocation.getCurrentPosition(
(position) => {
updateData({
tnp_lat: position.coords.latitude.toFixed(6),
tnp_lon: position.coords.longitude.toFixed(6),
gps_loading: false
});
},
(error) => {
alert("Ошибка получения координат: " + error.message);
updateData({ gps_loading: false });
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
};
return (
<div className="form-step">
<h2>Шаг 5: Среда и местность</h2>
{/* Сезон определяется автоматически, показываем только для информации */}
{data.season && (
<div className="form-group">
<label>Сезон (определен автоматически)</label>
<div style={{ padding: "12px", background: "#161b22", borderRadius: "6px", color: "#8b949e" }}>
{data.season.charAt(0).toUpperCase() + data.season.slice(1)}
</div>
</div>
)}
<div className="form-group">
<label>Температура (°C)</label>
<input
type="number"
value={data.temperature || ""}
onChange={(e) => updateData({ temperature: parseFloat(e.target.value) })}
min="-40"
max="40"
step="1"
placeholder="Например: 15"
/>
</div>
<div className="form-group">
<label>Осадки</label>
<div className="button-group">
<button
type="button"
className={data.precipitation === "нет" ? "selected" : ""}
onClick={() => updateData({ precipitation: "нет" })}
>
Нет
</button>
<button
type="button"
className={data.precipitation === "дождь" ? "selected" : ""}
onClick={() => updateData({ precipitation: "дождь" })}
>
Дождь
</button>
<button
type="button"
className={data.precipitation === "снег" ? "selected" : ""}
onClick={() => updateData({ precipitation: "снег" })}
>
Снег
</button>
<button
type="button"
className={data.precipitation === "туман" ? "selected" : ""}
onClick={() => updateData({ precipitation: "туман" })}
>
Туман
</button>
</div>
</div>
<div className="form-group">
<label>Видимость</label>
<div className="button-group">
<button
type="button"
className={data.visibility === "хорошая" ? "selected" : ""}
onClick={() => updateData({ visibility: "хорошая" })}
>
Хорошая
</button>
<button
type="button"
className={data.visibility === "средняя" ? "selected" : ""}
onClick={() => updateData({ visibility: "средняя" })}
>
Средняя
</button>
<button
type="button"
className={data.visibility === "плохая" ? "selected" : ""}
onClick={() => updateData({ visibility: "плохая" })}
>
Плохая
</button>
</div>
</div>
<div className="form-group">
<label>Типы местности (можно выбрать несколько) *</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("лес")}
onChange={() => toggleTerrain("лес")}
/>
Лес
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("поле")}
onChange={() => toggleTerrain("поле")}
/>
Поле
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("болото")}
onChange={() => toggleTerrain("болото")}
/>
Болото
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("водоём")}
onChange={() => toggleTerrain("водоём")}
/>
Водоём (река, озеро)
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("населённый_пункт")}
onChange={() => toggleTerrain("населённый_пункт")}
/>
Населённый пункт
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("дороги")}
onChange={() => toggleTerrain("дороги")}
/>
Дороги / тропы
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("заброшенные_здания")}
onChange={() => toggleTerrain("заброшенные_здания")}
/>
Заброшенные здания
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("сельхоз")}
onChange={() => toggleTerrain("сельхоз")}
/>
Сельхоз угодья
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("овраги")}
onChange={() => toggleTerrain("овраги")}
/>
Овраги / ямы
</label>
</div>
</div>
<div className="form-group">
<label>Координаты точки потери (GPS) *</label>
<div style={{ marginBottom: "12px" }}>
<button
type="button"
className="gps-button"
onClick={handleGetLocation}
disabled={data.gps_loading}
>
{data.gps_loading ? "Получение координат..." : "📍 Получить текущие координаты"}
</button>
</div>
<div style={{ display: "flex", gap: "12px" }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "12px", color: "#8b949e" }}>Широта</label>
<input
type="number"
value={data.tnp_lat || ""}
onChange={(e) => updateData({ tnp_lat: e.target.value })}
placeholder="53.9000"
step="0.000001"
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "12px", color: "#8b949e" }}>Долгота</label>
<input
type="number"
value={data.tnp_lon || ""}
onChange={(e) => updateData({ tnp_lon: e.target.value })}
placeholder="27.5667"
step="0.000001"
/>
</div>
</div>
</div>
<div className="form-group">
<label>Или введите адрес</label>
<input
type="text"
value={data.tnp_address || ""}
onChange={(e) => updateData({ tnp_address: e.target.value })}
placeholder="Минск, ул. Ленина 10"
/>
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
Адрес будет преобразован в координаты автоматически
</p>
</div>
</div>
);
};
export default Step4Environment;
@@ -0,0 +1,159 @@
import React from "react";
const Step5Resources = ({ data, updateData }) => {
const toggleEquipment = (equipment) => {
const current = data.equipment || [];
const updated = current.includes(equipment)
? current.filter((e) => e !== equipment)
: [...current, equipment];
updateData({ equipment: updated });
};
return (
<div className="form-step">
<h2>Шаг 5: Ресурсы для поиска</h2>
<div className="form-group">
<label>Количество поисковых групп</label>
<input
type="number"
value={data.search_groups || ""}
onChange={(e) => updateData({ search_groups: parseInt(e.target.value) })}
min="0"
max="100"
placeholder="Например: 3"
/>
</div>
<div className="form-group">
<label>Количество человек в поиске</label>
<input
type="number"
value={data.search_people || ""}
onChange={(e) => updateData({ search_people: parseInt(e.target.value) })}
min="0"
max="1000"
placeholder="Например: 25"
/>
</div>
<div className="form-group">
<label>Поисковая собака</label>
<div className="button-group">
<button
type="button"
className={data.has_dog === true ? "selected" : ""}
onClick={() => updateData({ has_dog: true })}
>
Да
</button>
<button
type="button"
className={data.has_dog === false ? "selected" : ""}
onClick={() => updateData({ has_dog: false })}
>
Нет
</button>
</div>
</div>
<div className="form-group">
<label>Дополнительное оборудование (можно выбрать несколько)</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("дрон")}
onChange={() => toggleEquipment("дрон")}
/>
Дрон / БПЛА
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("тепловизор")}
onChange={() => toggleEquipment("тепловизор")}
/>
Тепловизор
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("квадроцикл")}
onChange={() => toggleEquipment("квадроцикл")}
/>
Квадроцикл / мотовездеход
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("лодка")}
onChange={() => toggleEquipment("лодка")}
/>
Лодка
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("вертолёт")}
onChange={() => toggleEquipment("вертолёт")}
/>
Вертолёт
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("рация")}
onChange={() => toggleEquipment("рация")}
/>
Рации
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("gps")}
onChange={() => toggleEquipment("gps")}
/>
GPS навигаторы
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("медик")}
onChange={() => toggleEquipment("медик")}
/>
Медицинская бригада
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("прожектор")}
onChange={() => toggleEquipment("прожектор")}
/>
Прожекторы / фонари
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("мегафон")}
onChange={() => toggleEquipment("мегафон")}
/>
Мегафон / громкоговоритель
</label>
</div>
</div>
<div className="form-group">
<label>Дополнительная информация о ресурсах</label>
<textarea
value={data.resources_notes || ""}
onChange={(e) => updateData({ resources_notes: e.target.value })}
placeholder="Особенности организации поиска, доступные ресурсы, ограничения..."
rows={4}
/>
</div>
</div>
);
};
export default Step5Resources;
@@ -0,0 +1 @@
export { default } from "./CaseForm";
+146
View File
@@ -0,0 +1,146 @@
.map-view-container {
position: relative;
width: 100%;
height: 600px;
background: #0a0c0f;
border-radius: 12px;
overflow: hidden;
border: 1px solid #21262d;
}
.map-view {
width: 100%;
height: 100%;
}
.map-controls {
position: absolute;
top: 16px;
right: 16px;
z-index: 1000;
display: flex;
gap: 8px;
}
.control-btn {
padding: 10px 16px;
background: #161b22;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.control-btn:hover:not(:disabled) {
background: #21262d;
border-color: #ff4d00;
}
.control-btn.active {
background: #ff4d00;
color: #ffffff;
border-color: #ff4d00;
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ТНП маркер */
.tnp-marker {
background: transparent;
border: none;
}
.tnp-marker-inner {
width: 40px;
height: 40px;
background: #ff4d00;
color: #ffffff;
border: 3px solid #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
box-shadow: 0 2px 8px rgba(255, 77, 0, 0.5);
}
/* Стрелка направления */
.direction-arrow {
background: transparent;
border: none;
}
.direction-arrow div {
font-size: 30px;
color: #ff4d00;
text-shadow: 0 0 4px rgba(255, 77, 0, 0.8);
}
/* Leaflet popup стилизация */
.leaflet-popup-content-wrapper {
background: #161b22;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.leaflet-popup-content {
margin: 12px;
font-size: 13px;
line-height: 1.5;
}
.leaflet-popup-content b {
color: #ff4d00;
font-size: 14px;
}
.leaflet-popup-content small {
color: #8b949e;
font-size: 11px;
}
.leaflet-popup-tip {
background: #161b22;
border: 1px solid #30363d;
}
.leaflet-popup-close-button {
color: #e6edf3 !important;
}
.leaflet-popup-close-button:hover {
color: #ff4d00 !important;
}
/* Адаптивность */
@media (max-width: 768px) {
.map-view-container {
height: 400px;
}
.map-controls {
top: 8px;
right: 8px;
}
.control-btn {
padding: 8px 12px;
font-size: 12px;
}
.tnp-marker-inner {
width: 32px;
height: 32px;
font-size: 10px;
}
}
+273
View File
@@ -0,0 +1,273 @@
import React, { useEffect, useRef, useState } from "react";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet.heat";
import "./MapView.css";
// Fix Leaflet default marker icons
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
const MapView = ({
tnpLat,
tnpLon,
zones = [],
direction = null,
heatmapData = [],
showHeatmap = false
}) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const zonesLayerRef = useRef(null);
const heatmapLayerRef = useRef(null);
const tnpMarkerRef = useRef(null);
const directionArrowRef = useRef(null);
const [heatmapVisible, setHeatmapVisible] = useState(showHeatmap);
// Цвета зон из §5 ЦОУ палитры
const ZONE_COLORS = {
0: "#ff4d00", // Зона 1 (0-500м) - красный
1: "#ff9f1a", // Зона 2 (500-1500м) - оранжевый
2: "#ffd166", // Зона 3 (1500-2500м) - желтый
};
// Направления в градусы
const DIRECTION_ANGLES = {
N: 0,
NE: 45,
E: 90,
SE: 135,
S: 180,
SW: 225,
W: 270,
NW: 315,
};
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
// Инициализация карты
const map = L.map(mapRef.current).setView([tnpLat || 53.9, tnpLon || 27.56], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
maxZoom: 19,
}).addTo(map);
mapInstanceRef.current = map;
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, []);
// Обновление ТНП маркера
useEffect(() => {
if (!mapInstanceRef.current || !tnpLat || !tnpLon) return;
// Удаляем старый маркер
if (tnpMarkerRef.current) {
mapInstanceRef.current.removeLayer(tnpMarkerRef.current);
}
// Создаем красный маркер ТНП
const tnpIcon = L.divIcon({
className: "tnp-marker",
html: "<div class=\"tnp-marker-inner\">ТНП</div>",
iconSize: [40, 40],
iconAnchor: [20, 20],
});
const marker = L.marker([tnpLat, tnpLon], { icon: tnpIcon }).addTo(mapInstanceRef.current);
marker.bindPopup("<b>Точка начала поиска</b><br>" + tnpLat.toFixed(6) + ", " + tnpLon.toFixed(6));
tnpMarkerRef.current = marker;
// Центрируем карту
mapInstanceRef.current.setView([tnpLat, tnpLon], 13);
}, [tnpLat, tnpLon]);
// Обновление стрелки направления
useEffect(() => {
if (!mapInstanceRef.current || !tnpLat || !tnpLon || !direction || direction === "unknown") {
if (directionArrowRef.current) {
mapInstanceRef.current.removeLayer(directionArrowRef.current);
directionArrowRef.current = null;
}
return;
}
// Удаляем старую стрелку
if (directionArrowRef.current) {
mapInstanceRef.current.removeLayer(directionArrowRef.current);
}
const angle = DIRECTION_ANGLES[direction];
if (angle === undefined) return;
// Создаем стрелку (линия 1км в направлении)
const distance = 1000; // метров
const endPoint = calculateDestination(tnpLat, tnpLon, angle, distance);
const arrow = L.polyline(
[[tnpLat, tnpLon], endPoint],
{
color: "#ff4d00",
weight: 3,
opacity: 0.8,
dashArray: "10, 5",
}
).addTo(mapInstanceRef.current);
// Добавляем стрелку на конце
const arrowHead = L.marker(endPoint, {
icon: L.divIcon({
className: "direction-arrow",
html: "<div style=\"transform: rotate(" + angle + "deg)\">➤</div>",
iconSize: [30, 30],
iconAnchor: [15, 15],
}),
}).addTo(mapInstanceRef.current);
directionArrowRef.current = L.layerGroup([arrow, arrowHead]).addTo(mapInstanceRef.current);
}, [tnpLat, tnpLon, direction]);
// Обновление зон (секторные полигоны)
useEffect(() => {
if (!mapInstanceRef.current || !tnpLat || !tnpLon || zones.length === 0) {
if (zonesLayerRef.current) {
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
zonesLayerRef.current = null;
}
return;
}
// Удаляем старые зоны
if (zonesLayerRef.current) {
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
}
const zonePolygons = [];
zones.forEach((zone, index) => {
const color = ZONE_COLORS[index] || "#cccccc";
const directionAngle = DIRECTION_ANGLES[zone.direction] || 0;
// Создаем секторный полигон (угол ±45° от направления)
const sectorAngle = 90; // ширина сектора
const startAngle = directionAngle - sectorAngle / 2;
const endAngle = directionAngle + sectorAngle / 2;
const radiusMeters = zone.distance_km * 1000;
const sectorPoints = [[tnpLat, tnpLon]]; // Начало в ТНП
// Генерируем точки по дуге
for (let angle = startAngle; angle <= endAngle; angle += 5) {
const point = calculateDestination(tnpLat, tnpLon, angle, radiusMeters);
sectorPoints.push(point);
}
sectorPoints.push([tnpLat, tnpLon]); // Замыкаем полигон
const polygon = L.polygon(sectorPoints, {
color: color,
fillColor: color,
fillOpacity: 0.2,
weight: 2,
opacity: 0.6,
});
polygon.bindPopup(
"<b>" + zone.name + "</b><br>" +
"Приоритет: " + zone.priority + "<br>" +
"Оценка: " + zone.score + "<br>" +
"Расстояние: " + zone.distance_km.toFixed(2) + " км<br>" +
"<small>" + zone.reasoning + "</small>"
);
zonePolygons.push(polygon);
});
zonesLayerRef.current = L.layerGroup(zonePolygons).addTo(mapInstanceRef.current);
}, [tnpLat, tnpLon, zones]);
// Обновление тепловой карты
useEffect(() => {
if (!mapInstanceRef.current) return;
// Удаляем старый слой
if (heatmapLayerRef.current) {
mapInstanceRef.current.removeLayer(heatmapLayerRef.current);
heatmapLayerRef.current = null;
}
if (!heatmapVisible || heatmapData.length === 0) return;
// Создаем heatmap слой
const heatPoints = heatmapData.map(point => [
point.lat,
point.lon,
point.intensity || 1.0
]);
const heatLayer = L.heatLayer(heatPoints, {
radius: 25,
blur: 15,
maxZoom: 17,
max: 1.0,
gradient: {
0.0: "#0d1117",
0.3: "#238636",
0.5: "#ffd166",
0.7: "#ff9f1a",
1.0: "#ff4d00"
}
}).addTo(mapInstanceRef.current);
heatmapLayerRef.current = heatLayer;
}, [heatmapVisible, heatmapData]);
// Вычисление точки назначения (lat, lon, angle, distance)
const calculateDestination = (lat, lon, bearing, distance) => {
const R = 6371000; // Радиус Земли в метрах
const φ1 = (lat * Math.PI) / 180;
const λ1 = (lon * Math.PI) / 180;
const θ = (bearing * Math.PI) / 180;
const φ2 = Math.asin(
Math.sin(φ1) * Math.cos(distance / R) +
Math.cos(φ1) * Math.sin(distance / R) * Math.cos(θ)
);
const λ2 = λ1 + Math.atan2(
Math.sin(θ) * Math.sin(distance / R) * Math.cos(φ1),
Math.cos(distance / R) - Math.sin(φ1) * Math.sin(φ2)
);
return [(φ2 * 180) / Math.PI, (λ2 * 180) / Math.PI];
};
return (
<div className="map-view-container">
<div className="map-controls">
<button
className={heatmapVisible ? "control-btn active" : "control-btn"}
onClick={() => setHeatmapVisible(!heatmapVisible)}
disabled={heatmapData.length === 0}
>
{heatmapVisible ? "🔥 Скрыть тепловую карту" : "🔥 Показать тепловую карту"}
</button>
</div>
<div ref={mapRef} className="map-view" />
</div>
);
};
export default MapView;
@@ -0,0 +1 @@
export { default } from "./MapView";
+353
View File
@@ -0,0 +1,353 @@
/* Мобильная форма — палитра из §5 контекста */
.mobile-form {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background: var(--bg-primary);
min-height: 100vh;
}
.mobile-form h1 {
margin: 0 0 24px 0;
font-size: 24px;
color: var(--text-primary);
text-align: center;
font-weight: 700;
}
.form-section {
background: var(--bg-secondary);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
border: 1px solid var(--border);
}
.form-section h2 {
margin: 0 0 16px 0;
font-size: 18px;
color: var(--text-primary);
font-weight: 600;
}
.section-header {
margin-bottom: 20px;
}
.section-subtitle {
margin: 8px 0 0 0;
font-size: 14px;
color: var(--text-secondary);
font-style: italic;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
}
.form-group input[type=number],
.form-group input[type=text],
.form-group input[type=time],
.form-group textarea {
width: 100%;
padding: 12px;
background: var(--input-bg);
color: var(--text-primary);
border: 2px solid var(--input-border);
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s;
min-height: var(--btn-min-height, 52px);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.button-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.button-group button {
padding: 12px;
background: var(--input-bg);
color: var(--text-secondary);
border: 2px solid var(--input-border);
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
min-height: var(--btn-min-height, 52px);
}
.button-group button:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.button-group button.selected {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.checkbox-label {
display: flex;
align-items: center;
padding: 12px;
background: var(--input-bg);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
min-height: var(--btn-min-height, 52px);
border: 1px solid var(--input-border);
}
.checkbox-label:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.checkbox-label input[type=checkbox] {
width: 24px;
height: 24px;
margin-right: 12px;
cursor: pointer;
accent-color: var(--accent);
}
.psychotype-section {
background: var(--bg-secondary);
border-left: 4px solid var(--accent);
}
.psychotype-question {
margin-bottom: 24px;
}
.psychotype-question:last-child {
margin-bottom: 0;
}
.question-label {
display: block;
margin-bottom: 12px;
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
.button-group-vertical {
display: flex;
flex-direction: column;
gap: 10px;
}
.psychotype-btn {
padding: 14px 16px;
background: var(--input-bg);
color: var(--text-secondary);
border: 2px solid var(--input-border);
border-radius: 8px;
font-size: 15px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
min-height: var(--btn-min-height, 52px);
}
.psychotype-btn:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.psychotype-btn.selected {
background: var(--accent);
color: white;
border-color: var(--accent);
font-weight: 500;
}
/* GPS кнопка */
.btn-gps {
width: 100%;
padding: 16px;
background: var(--btn-gps, #0a84ff);
color: white;
border: none;
border-radius: 12px;
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
min-height: var(--btn-min-height, 52px);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-gps:active {
transform: scale(0.98);
}
/* Кнопка подтверждения */
.submit-btn {
width: 100%;
padding: 16px;
background: var(--btn-confirm, var(--accent));
color: white;
border: none;
border-radius: 12px;
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 24px;
min-height: var(--btn-min-height, 52px);
}
.submit-btn:hover {
opacity: 0.9;
}
.submit-btn:active {
transform: scale(0.98);
}
.submit-btn:disabled {
background: var(--text-secondary);
cursor: not-allowed;
opacity: 0.5;
}
/* Success and Error messages */
.success-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.success-icon {
width: 80px;
height: 80px;
background: var(--success);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
margin-bottom: 24px;
animation: scaleIn 0.3s ease-out;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.success-message h2 {
margin: 0 0 12px 0;
font-size: 24px;
color: var(--text-primary);
}
.success-message p {
margin: 0;
font-size: 16px;
color: var(--text-secondary);
}
.error-banner {
padding: 16px;
background: var(--danger);
border-left: 4px solid var(--danger);
border-radius: 8px;
color: white;
margin-bottom: 20px;
font-size: 14px;
font-weight: 500;
}
/* Responsive для больших экранов */
@media (min-width: 768px) {
.mobile-form {
padding: 40px 20px;
}
}
/* Оптимизация для маленьких экранов */
@media (max-width: 480px) {
.mobile-form {
padding: 16px;
}
.mobile-form h1 {
font-size: 20px;
}
.form-section {
padding: 16px;
}
.form-section h2 {
font-size: 16px;
}
}
/* Зоны поиска с цветами из палитры */
.zone-card {
border-left: 4px solid;
padding: 16px;
margin-bottom: 12px;
background: var(--bg-secondary);
border-radius: 8px;
}
.zone-card.priority-1 {
border-left-color: var(--zone-1);
}
.zone-card.priority-2 {
border-left-color: var(--zone-2);
}
.zone-card.priority-3 {
border-left-color: var(--zone-3);
}
+361
View File
@@ -0,0 +1,361 @@
import React, { useState } from 'react';
import './MobileForm.css';
const MobileForm = () => {
const [formData, setFormData] = useState({
age: '',
gender: '',
terrain: '',
weather: '',
time_missing: '',
last_location: '',
circumstances: '',
physical_condition: '',
experience: '',
season: '',
diagnosis_type: [],
psychotype_answers: {}
});
const handlePsychotypeAnswer = (question, value) => {
setFormData(prev => ({
...prev,
psychotype_answers: {
...prev.psychotype_answers,
[question]: value
}
}));
};
const isPsychotypeSelected = (question, value) => {
return formData.psychotype_answers?.[question] === value;
};
const handleDiagnosisChange = (diagnosis, checked) => {
const current = formData.diagnosis_type || [];
setFormData({
...formData,
diagnosis_type: checked
? [...current, diagnosis]
: current.filter(d => d !== diagnosis)
});
};
const handleSubmit = async () => {
console.log('Form data:', formData);
// Отправка на backend
try {
const response = await fetch('http://192.168.0.99:8000/api/v1/analyze/combined', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
console.log('Analysis result:', result);
alert('Анализ выполнен! Смотри консоль для результатов.');
} catch (error) {
console.error('Error:', error);
alert('Ошибка при отправке данных');
}
};
return (
<div className="mobile-form">
<h1>Карточка пропавшего</h1>
{/* Базовая информация */}
<section className="form-section">
<h2>Основная информация</h2>
<div className="form-group">
<label>Возраст</label>
<input
type="number"
placeholder="Лет"
value={formData.age}
onChange={(e) => setFormData({...formData, age: e.target.value})}
/>
</div>
<div className="form-group">
<label>Пол</label>
<div className="button-group">
<button
className={formData.gender === 'мужской' ? 'selected' : ''}
onClick={() => setFormData({...formData, gender: 'мужской'})}
>
Мужской
</button>
<button
className={formData.gender === 'женский' ? 'selected' : ''}
onClick={() => setFormData({...formData, gender: 'женский'})}
>
Женский
</button>
</div>
</div>
<div className="form-group">
<label>Сезон</label>
<div className="button-group">
<button
className={formData.season === 'winter' ? 'selected' : ''}
onClick={() => setFormData({...formData, season: 'winter'})}
>
Зима
</button>
<button
className={formData.season === 'spring' ? 'selected' : ''}
onClick={() => setFormData({...formData, season: 'spring'})}
>
Весна
</button>
<button
className={formData.season === 'summer' ? 'selected' : ''}
onClick={() => setFormData({...formData, season: 'summer'})}
>
Лето
</button>
<button
className={formData.season === 'autumn' ? 'selected' : ''}
onClick={() => setFormData({...formData, season: 'autumn'})}
>
Осень
</button>
</div>
</div>
<div className="form-group">
<label>Физическое состояние</label>
<input
type="text"
placeholder="Например: хорошее, слабое, травмы"
value={formData.physical_condition}
onChange={(e) => setFormData({...formData, physical_condition: e.target.value})}
/>
</div>
</section>
{/* Обстоятельства пропажи */}
<section className="form-section">
<h2>Обстоятельства пропажи</h2>
<div className="form-group">
<label>Местность</label>
<select
value={formData.terrain}
onChange={(e) => setFormData({...formData, terrain: e.target.value})}
>
<option value="">Выберите...</option>
<option value="лес">Лес</option>
<option value="поле">Поле</option>
<option value="горы">Горы</option>
<option value="город">Город</option>
<option value="болото">Болото</option>
<option value="водоём">Водоём</option>
</select>
</div>
<div className="form-group">
<label>Погода</label>
<select
value={formData.weather}
onChange={(e) => setFormData({...formData, weather: e.target.value})}
>
<option value="">Выберите...</option>
<option value="ясно">Ясно</option>
<option value="облачно">Облачно</option>
<option value="дождь">Дождь</option>
<option value="снег">Снег</option>
<option value="туман">Туман</option>
<option value="жара">Жара</option>
<option value="холод">Холод</option>
</select>
</div>
<div className="form-group">
<label>Время пропажи</label>
<input
type="text"
placeholder="Например: 2 часа, 6 часов, 1 день"
value={formData.time_missing}
onChange={(e) => setFormData({...formData, time_missing: e.target.value})}
/>
</div>
<div className="form-group">
<label>Последнее известное местоположение</label>
<input
type="text"
placeholder="Например: лесная тропа у озера"
value={formData.last_location}
onChange={(e) => setFormData({...formData, last_location: e.target.value})}
/>
</div>
<div className="form-group">
<label>Обстоятельства</label>
<textarea
placeholder="Например: вышел за грибами, не вернулся к вечеру"
value={formData.circumstances}
onChange={(e) => setFormData({...formData, circumstances: e.target.value})}
rows="3"
/>
</div>
<div className="form-group">
<label>Опыт нахождения на природе</label>
<select
value={formData.experience}
onChange={(e) => setFormData({...formData, experience: e.target.value})}
>
<option value="">Выберите...</option>
<option value="опытный">Опытный</option>
<option value="средний">Средний опыт</option>
<option value="новичок">Новичок</option>
<option value="нет опыта">Нет опыта</option>
</select>
</div>
</section>
{/* Диагнозы */}
<section className="form-section">
<h2>Диагнозы (если есть)</h2>
<div className="checkbox-group">
{['РАС', 'эпилепсия', 'СДВГ', 'деменция', 'Альцгеймер'].map(diagnosis => (
<label key={diagnosis} className="checkbox-label">
<input
type="checkbox"
checked={formData.diagnosis_type?.includes(diagnosis)}
onChange={(e) => handleDiagnosisChange(diagnosis, e.target.checked)}
/>
{diagnosis}
</label>
))}
</div>
</section>
{/* Блок психотипа */}
<section className="form-section psychotype-section">
<div className="section-header">
<h2>Поведение ребёнка (спросить родителей)</h2>
<p className="section-subtitle">Помогает уточнить зону поиска</p>
</div>
{/* Вопрос 1: Незнакомая обстановка */}
<div className="psychotype-question">
<label className="question-label">Как ведёт себя в незнакомой обстановке?</label>
<div className="button-group-vertical">
<button
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'explore') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'explore')}
>
Активно исследует
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'wait') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'wait')}
>
Ждёт
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'freeze') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'freeze')}
>
Замирает
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'panic') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'panic')}
>
Паникует
</button>
</div>
</div>
{/* Вопрос 2: Реакция на неудачи */}
<div className="psychotype-question">
<label className="question-label">Реакция на неудачи?</label>
<div className="button-group-vertical">
<button
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'active') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'active')}
>
Злится, кричит
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'cry') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'cry')}
>
Плачет, замыкается
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'calm') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'calm')}
>
Спокойно ищет выход
</button>
</div>
</div>
{/* Вопрос 3: Лидер или ведомый */}
<div className="psychotype-question">
<label className="question-label">Лидер или ведомый?</label>
<div className="button-group-vertical">
<button
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'leader') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('group_role', 'leader')}
>
Лидер
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'sometimes') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('group_role', 'sometimes')}
>
Иногда
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'follower') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('group_role', 'follower')}
>
Всегда ведомый
</button>
</div>
</div>
{/* Вопрос 4: Любит рисковать */}
<div className="psychotype-question">
<label className="question-label">Любит рисковать?</label>
<div className="button-group-vertical">
<button
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'high') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('risk_taking', 'high')}
>
Очень
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'medium') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('risk_taking', 'medium')}
>
Иногда
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'low') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('risk_taking', 'low')}
>
Нет, осторожный
</button>
</div>
</div>
</section>
{/* Кнопка отправки */}
<button className="submit-btn" onClick={handleSubmit}>
Создать карточку и получить анализ
</button>
</div>
);
};
export default MobileForm;
+296
View File
@@ -0,0 +1,296 @@
import React, { useState } from 'react';
import axios from 'axios';
import './MobileForm.css';
interface PsychotypeAnswers {
unfamiliar_env?: string;
stress_reaction?: string;
group_role?: string;
risk_taking?: string;
}
interface FormData {
age?: number;
gender?: string;
diagnosis_type?: string[];
psychotype_answers?: PsychotypeAnswers;
}
const MobileForm: React.FC = () => {
const [formData, setFormData] = useState<FormData>({
diagnosis_type: [],
psychotype_answers: {}
});
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePsychotypeAnswer = (question: keyof PsychotypeAnswers, value: string) => {
setFormData(prev => ({
...prev,
psychotype_answers: {
...prev.psychotype_answers,
[question]: value
}
}));
};
const isPsychotypeSelected = (question: keyof PsychotypeAnswers, value: string) => {
return formData.psychotype_answers?.[question] === value;
};
const validateForm = (): string | null => {
if (!formData.age || formData.age < 0 || formData.age > 120) {
return 'Укажите корректный возраст';
}
if (!formData.gender) {
return 'Выберите пол';
}
return null;
};
const handleSubmit = async () => {
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
setSubmitting(true);
setError(null);
try {
const response = await axios.post('/api/v1/cases', formData);
console.log('Case created:', response.data);
setSuccess(true);
setTimeout(() => {
setFormData({
diagnosis_type: [],
psychotype_answers: {}
});
setSuccess(false);
}, 2000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка при создании карточки');
console.error('Submit error:', err);
} finally {
setSubmitting(false);
}
};
if (success) {
return (
<div className="mobile-form">
<div className="success-message">
<div className="success-icon">✓</div>
<h2>Карточка создана!</h2>
<p>Данные успешно сохранены</p>
</div>
</div>
);
}
return (
<div className="mobile-form">
<h1>Карточка пропавшего</h1>
{error && (
<div className="error-banner">
{error}
</div>
)}
<section className="form-section">
<h2>Основная информация</h2>
<div className="form-group">
<label>Возраст *</label>
<input
type="number"
placeholder="Лет"
value={formData.age || ''}
onChange={(e) => setFormData({...formData, age: parseInt(e.target.value)})}
required
/>
</div>
<div className="form-group">
<label>Пол *</label>
<div className="button-group">
<button
type="button"
className={formData.gender === 'male' ? 'selected' : ''}
onClick={() => setFormData({...formData, gender: 'male'})}
>
Мужской
</button>
<button
type="button"
className={formData.gender === 'female' ? 'selected' : ''}
onClick={() => setFormData({...formData, gender: 'female'})}
>
Женский
</button>
</div>
</div>
</section>
<section className="form-section">
<h2>Диагнозы (если есть)</h2>
<div className="checkbox-group">
{['РАС', 'эпилепсия', 'СДВГ', 'деменция'].map(diagnosis => (
<label key={diagnosis} className="checkbox-label">
<input
type="checkbox"
checked={formData.diagnosis_type?.includes(diagnosis)}
onChange={(e) => {
const current = formData.diagnosis_type || [];
setFormData({
...formData,
diagnosis_type: e.target.checked
? [...current, diagnosis]
: current.filter(d => d !== diagnosis)
});
}}
/>
{diagnosis}
</label>
))}
</div>
</section>
<section className="form-section psychotype-section">
<div className="section-header">
<h2>Поведение ребёнка (спросить родителей)</h2>
<p className="section-subtitle">Помогает уточнить зону поиска</p>
</div>
<div className="psychotype-question">
<label className="question-label">Как ведёт себя в незнакомой обстановке?</label>
<div className="button-group-vertical">
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'explore') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'explore')}
>
Активно исследует
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'wait') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'wait')}
>
Ждёт
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'freeze') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'freeze')}
>
Замирает
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'panic') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'panic')}
>
Паникует
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">Реакция на неудачи?</label>
<div className="button-group-vertical">
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'active') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'active')}
>
Злится, кричит
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'cry') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'cry')}
>
Плачет, замыкается
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'calm') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'calm')}
>
Спокойно ищет выход
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">Лидер или ведомый?</label>
<div className="button-group-vertical">
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'leader') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('group_role', 'leader')}
>
Лидер
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'sometimes') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('group_role', 'sometimes')}
>
Иногда
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'follower') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('group_role', 'follower')}
>
Всегда ведомый
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">Любит рисковать?</label>
<div className="button-group-vertical">
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('risk_taking', 'high') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('risk_taking', 'high')}
>
Очень
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsycotypeSelected('risk_taking', 'medium') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('risk_taking', 'medium')}
>
Иногда
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('risk_taking', 'low') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('risk_taking', 'low')}
>
Нет, осторожный
</button>
</div>
</div>
</section>
<button
className="submit-btn"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? 'Создание...' : 'Создать карточку'}
</button>
</div>
);
};
export default MobileForm;
@@ -0,0 +1,416 @@
.result-panel {
background: #0a0c0f;
border: 1px solid #21262d;
border-radius: 12px;
padding: 24px;
margin-top: 24px;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #21262d;
}
.result-header h2 {
margin: 0;
font-size: 24px;
color: #e6edf3;
font-weight: 700;
}
.result-meta {
display: flex;
gap: 12px;
align-items: center;
}
.execution-time {
font-size: 13px;
color: #8b949e;
padding: 4px 10px;
background: #161b22;
border-radius: 6px;
}
.services-badge {
font-size: 13px;
color: #e6edf3;
padding: 4px 10px;
background: #238636;
border-radius: 6px;
font-weight: 600;
}
/* Блок срочности */
.urgency-block {
display: flex;
gap: 16px;
padding: 20px;
border-radius: 8px;
border-left: 4px solid;
margin-bottom: 24px;
}
.urgency-icon {
font-size: 32px;
line-height: 1;
}
.urgency-content {
flex: 1;
}
.urgency-label {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.urgency-summary {
font-size: 14px;
color: #e6edf3;
line-height: 1.5;
}
/* Behavioral Profile карточки */
.behavioral-section {
margin-bottom: 24px;
}
.behavioral-section h3 {
font-size: 18px;
color: #e6edf3;
margin: 0 0 16px 0;
font-weight: 600;
}
.behavioral-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.behavioral-card {
background: #161b22;
border: 1px solid #30363d;
border-left: 4px solid;
border-radius: 8px;
padding: 16px;
transition: all 0.2s;
}
.behavioral-card:hover {
background: #1c2128;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.card-icon {
font-size: 24px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #e6edf3;
}
.card-points {
margin: 0;
padding-left: 20px;
list-style: disc;
}
.card-points li {
font-size: 13px;
color: #8b949e;
line-height: 1.6;
margin-bottom: 6px;
}
.card-points li:last-child {
margin-bottom: 0;
}
/* Психотип */
.psychotype-section {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.psychotype-section h3 {
margin: 0 0 12px 0;
font-size: 18px;
color: #ff9f1a;
font-weight: 600;
}
.psychotype-description {
font-size: 14px;
color: #8b949e;
margin-bottom: 16px;
font-style: italic;
}
.psychotype-recommendations {
display: flex;
flex-direction: column;
gap: 10px;
}
.recommendation-item {
font-size: 13px;
color: #e6edf3;
line-height: 1.5;
}
.recommendation-item strong {
color: #ff9f1a;
font-weight: 600;
}
/* Немедленные действия */
.actions-section {
margin-bottom: 24px;
}
.actions-section h3 {
font-size: 18px;
color: #e6edf3;
margin: 0 0 16px 0;
font-weight: 600;
}
.actions-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.action-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
background: #161b22;
border: 1px solid #30363d;
border-left: 3px solid #ff4d00;
border-radius: 6px;
}
.action-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: #ff4d00;
color: #ffffff;
border-radius: 50%;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.action-text {
flex: 1;
font-size: 14px;
color: #e6edf3;
line-height: 1.6;
padding-top: 4px;
}
/* Зоны */
.zones-section {
margin-bottom: 24px;
}
.zones-section h3 {
font-size: 18px;
color: #e6edf3;
margin: 0 0 16px 0;
font-weight: 600;
}
.zones-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.zone-item {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 16px;
transition: all 0.2s;
}
.zone-item:hover {
background: #1c2128;
border-color: #ff4d00;
}
.zone-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.zone-priority {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #ff4d00;
color: #ffffff;
border-radius: 6px;
font-weight: 700;
font-size: 14px;
}
.zone-name {
flex: 1;
font-size: 16px;
font-weight: 600;
color: #e6edf3;
}
.zone-score {
font-size: 18px;
font-weight: 700;
color: #ff9f1a;
}
.zone-details {
display: flex;
gap: 16px;
margin-bottom: 8px;
font-size: 13px;
color: #8b949e;
}
.zone-distance,
.zone-direction {
display: flex;
align-items: center;
gap: 4px;
}
.zone-reasoning {
font-size: 13px;
color: #8b949e;
line-height: 1.5;
font-style: italic;
}
/* Ключевые объекты */
.locations-section {
margin-bottom: 24px;
}
.locations-section h3 {
font-size: 18px;
color: #e6edf3;
margin: 0 0 16px 0;
font-weight: 600;
}
.locations-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.location-tag {
padding: 8px 14px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
font-size: 13px;
color: #e6edf3;
font-weight: 500;
transition: all 0.2s;
}
.location-tag:hover {
background: #21262d;
border-color: #ff4d00;
}
/* Прогноз поведения */
.prediction-section {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 20px;
}
.prediction-section h3 {
margin: 0 0 12px 0;
font-size: 18px;
color: #e6edf3;
font-weight: 600;
}
.prediction-text {
margin: 0;
font-size: 14px;
color: #8b949e;
line-height: 1.6;
}
/* Адаптивность */
@media (max-width: 768px) {
.result-panel {
padding: 16px;
}
.result-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.behavioral-cards {
grid-template-columns: 1fr;
}
.urgency-block {
flex-direction: column;
gap: 12px;
}
.zone-header {
flex-wrap: wrap;
}
.locations-tags {
gap: 6px;
}
.location-tag {
font-size: 12px;
padding: 6px 10px;
}
}
@@ -0,0 +1,289 @@
import React from "react";
import "./ResultPanel.css";
const ResultPanel = ({ analysisResult }) => {
if (!analysisResult) {
return null;
}
const {
urgency,
zones = [],
key_locations = [],
immediate_actions = [],
psychotype,
psychotype_modifiers,
psychotype_recommendations,
behavioral_prediction,
summary,
execution_time_ms,
services_used = []
} = analysisResult;
// Цвета срочности
const urgencyConfig = {
"критическая": {
color: "#ff4d00",
label: "КРИТИЧЕСКАЯ",
icon: "🚨"
},
"высокая": {
color: "#ff9f1a",
label: "ВЫСОКАЯ",
icon: "⚠️"
},
"средняя": {
color: "#ffd166",
label: "УМЕРЕННАЯ",
icon: "ℹ️"
},
"низкая": {
color: "#238636",
label: "НИЗКАЯ",
icon: "✓"
}
};
const urgencyInfo = urgencyConfig[urgency] || urgencyConfig["средняя"];
// Behavioral Profile карточки
const getBehavioralCards = () => {
const cards = [];
// РАС карточка (красная)
if (analysisResult.diagnosis_type?.includes("РАС")) {
cards.push({
type: "РАС",
color: "#ff4d00",
icon: "🧩",
title: "Расстройство аутистического спектра",
points: [
"Может не откликаться на имя",
"Избегает зрительного контакта",
"Предпочитает укромные места",
"Не боится опасности"
]
});
}
// Эпилепсия карточка (оранжевая)
if (analysisResult.diagnosis_type?.includes("эпилепсия")) {
cards.push({
type: "Эпилепсия",
color: "#ff9f1a",
icon: "⚡",
title: "Эпилепсия",
points: [
"Риск приступа при стрессе",
"Может быть дезориентирован",
"Требуется медицинская помощь",
"Проверить водоёмы и ямы"
]
});
}
// Велосипед карточка (синяя)
if (analysisResult.has_transport === "bike") {
cards.push({
type: "Велосипед",
color: "#58a6ff",
icon: "🚴",
title: "Есть велосипед",
points: [
"Радиус поиска увеличен в 3-5 раз",
"Проверить дороги и тропы",
"Может преодолеть большое расстояние",
"Искать вдоль маршрутов"
]
});
}
// Самокат карточка (синяя)
if (analysisResult.has_transport === "scooter") {
cards.push({
type: "Самокат",
color: "#58a6ff",
icon: "🛴",
title: "Есть самокат",
points: [
"Радиус поиска увеличен в 2-3 раза",
"Проверить асфальтированные дороги",
"Ограничен рельефом местности",
"Искать вдоль ровных путей"
]
});
}
// Не умеет плавать (красная)
if (analysisResult.cant_swim) {
cards.push({
type: "Не плавает",
color: "#ff4d00",
icon: "🌊",
title: "Не умеет плавать",
points: [
"КРИТИЧНО: проверить водоёмы первыми",
"Высокий риск утопления",
"Привлечь водолазов",
"Обследовать берега и мосты"
]
});
}
return cards;
};
const behavioralCards = getBehavioralCards();
return (
<div className="result-panel">
<div className="result-header">
<h2>Результаты анализа</h2>
<div className="result-meta">
<span className="execution-time">
⏱ {(execution_time_ms / 1000).toFixed(2)}с
</span>
<span className="services-badge">
{services_used.length} сервисов
</span>
</div>
</div>
{/* Блок срочности */}
<div
className="urgency-block"
style={{
borderLeftColor: urgencyInfo.color,
background: urgencyInfo.color + "15"
}}
>
<div className="urgency-icon">{urgencyInfo.icon}</div>
<div className="urgency-content">
<div
className="urgency-label"
style={{ color: urgencyInfo.color }}
>
{urgencyInfo.label}
</div>
<div className="urgency-summary">{summary}</div>
</div>
</div>
{/* Behavioral Profile карточки */}
{behavioralCards.length > 0 && (
<div className="behavioral-section">
<h3>Поведенческий профиль</h3>
<div className="behavioral-cards">
{behavioralCards.map((card, index) => (
<div
key={index}
className="behavioral-card"
style={{ borderLeftColor: card.color }}
>
<div className="card-header">
<span className="card-icon">{card.icon}</span>
<span className="card-title">{card.title}</span>
</div>
<ul className="card-points">
{card.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{/* Психотип */}
{psychotype && (
<div className="psychotype-section">
<h3>Психотип: {psychotype}</h3>
{psychotype_modifiers && (
<div className="psychotype-description">
{psychotype_modifiers.description}
</div>
)}
{psychotype_recommendations && (
<div className="psychotype-recommendations">
<div className="recommendation-item">
<strong>Приоритетные зоны:</strong> {psychotype_recommendations.priority_zones}
</div>
<div className="recommendation-item">
<strong>Паттерн поиска:</strong> {psychotype_recommendations.search_pattern}
</div>
<div className="recommendation-item">
<strong>Ключевые локации:</strong> {psychotype_recommendations.key_locations}
</div>
<div className="recommendation-item">
<strong>Коммуникация:</strong> {psychotype_recommendations.communication}
</div>
</div>
)}
</div>
)}
{/* Немедленные действия */}
{immediate_actions.length > 0 && (
<div className="actions-section">
<h3>Немедленные действия</h3>
<ol className="actions-list">
{immediate_actions.slice(0, 3).map((action, index) => (
<li key={index} className="action-item">
<span className="action-number">{index + 1}</span>
<span className="action-text">{action}</span>
</li>
))}
</ol>
</div>
)}
{/* Зоны с приоритетом */}
{zones.length > 0 && (
<div className="zones-section">
<h3>Приоритетные зоны поиска</h3>
<div className="zones-list">
{zones.slice(0, 5).map((zone, index) => (
<div key={index} className="zone-item">
<div className="zone-header">
<span className="zone-priority">#{zone.priority}</span>
<span className="zone-name">{zone.name}</span>
<span className="zone-score">{zone.score}%</span>
</div>
<div className="zone-details">
<span className="zone-distance">📍 {zone.distance_km.toFixed(2)} км</span>
<span className="zone-direction">🧭 {zone.direction}</span>
</div>
<div className="zone-reasoning">{zone.reasoning}</div>
</div>
))}
</div>
</div>
)}
{/* Ключевые объекты */}
{key_locations.length > 0 && (
<div className="locations-section">
<h3>Ключевые объекты для проверки</h3>
<div className="locations-tags">
{key_locations.map((location, index) => (
<span key={index} className="location-tag">
{location}
</span>
))}
</div>
</div>
)}
{/* Прогноз поведения */}
{behavioral_prediction && (
<div className="prediction-section">
<h3>Прогноз поведения</h3>
<p className="prediction-text">{behavioral_prediction}</p>
</div>
)}
</div>
);
};
export default ResultPanel;
@@ -0,0 +1 @@
export { default } from "./ResultPanel";
+204
View File
@@ -0,0 +1,204 @@
import React from 'react';
import { MapContainer, TileLayer, Marker, Popup, Polygon } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
// Fix default marker icon issue with webpack
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
const distance = maxDistance || 5.0;
// Функция для создания точек сектора
const createSectorPoints = (centerLat, centerLon, radiusKm, startAngle, endAngle, points = 20) => {
const coords = [[centerLat, centerLon]]; // Начинаем с центра
// Конвертируем км в градусы (приблизительно)
const latPerKm = 1 / 111.0;
const lonPerKm = 1 / (111.0 * Math.cos(centerLat * Math.PI / 180));
// Создаем точки дуги
for (let i = 0; i <= points; i++) {
const angle = startAngle + (endAngle - startAngle) * (i / points);
const angleRad = angle * Math.PI / 180;
const lat = centerLat + radiusKm * latPerKm * Math.cos(angleRad);
const lon = centerLon + radiusKm * lonPerKm * Math.sin(angleRad);
coords.push([lat, lon]);
}
coords.push([centerLat, centerLon]); // Замыкаем в центре
return coords;
};
// Направления и их углы
const directionAngles = {
'N': 0,
'NE': 45,
'E': 90,
'SE': 135,
'S': 180,
'SW': 225,
'W': 270,
'NW': 315
};
// Создаем секторы на основе зон
const sectors = zones && zones.length > 0 ? zones.slice(0, 8).map((zone, idx) => {
const angle = directionAngles[zone.direction] || 0;
const startAngle = angle - 22.5; // ±22.5° = 45° сектор
const endAngle = angle + 22.5;
// Определяем радиус на основе score
let radius = distance;
if (zone.score >= 90) radius = distance * 1.0;
else if (zone.score >= 70) radius = distance * 0.8;
else if (zone.score >= 50) radius = distance * 0.6;
else radius = distance * 0.4;
// Цвет на основе приоритета
let color, fillOpacity;
if (idx === 0) {
color = '#ff4d00';
fillOpacity = 0.35;
} else if (idx === 1) {
color = '#ff9f1a';
fillOpacity = 0.25;
} else if (idx === 2) {
color = '#ffd166';
fillOpacity = 0.2;
} else {
color = '#8b949e';
fillOpacity = 0.1;
}
return {
points: createSectorPoints(tnpPosition[0], tnpPosition[1], radius, startAngle, endAngle),
color,
fillOpacity,
zone
};
}) : [];
// Если зон нет, создаем базовые концентрические круги
const createCirclePoints = (centerLat, centerLon, radiusKm, points = 40) => {
const coords = [];
const latPerKm = 1 / 111.0;
const lonPerKm = 1 / (111.0 * Math.cos(centerLat * Math.PI / 180));
for (let i = 0; i <= points; i++) {
const angle = (i / points) * 2 * Math.PI;
const lat = centerLat + radiusKm * latPerKm * Math.cos(angle);
const lon = centerLon + radiusKm * lonPerKm * Math.sin(angle);
coords.push([lat, lon]);
}
return coords;
};
const defaultZones = [
{
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance),
color: '#ffd166',
fillOpacity: 0.15,
label: `Зона 3 (${distance.toFixed(1)} км)`,
probability: 10
},
{
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance * 0.75),
color: '#ff9f1a',
fillOpacity: 0.2,
label: `Зона 2 (${(distance * 0.75).toFixed(1)} км)`,
probability: 20
},
{
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance * 0.5),
color: '#ff4d00',
fillOpacity: 0.3,
label: `Зона 1 (${(distance * 0.5).toFixed(1)} км)`,
probability: 70
}
];
return (
<MapContainer
center={tnpPosition}
zoom={12}
style={{ width: '100%', height: '500px', borderRadius: '8px' }}
>
<TileLayer
attribution=""
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Рисуем секторы или круги */}
{sectors.length > 0 ? (
sectors.map((sector, idx) => (
<Polygon
key={idx}
positions={sector.points}
pathOptions={{
color: sector.color,
fillColor: sector.color,
fillOpacity: sector.fillOpacity,
weight: 2
}}
>
<Popup>
<strong>#{idx + 1} {sector.zone.name}</strong><br/>
Направление: {sector.zone.direction}<br/>
Приоритет: {sector.zone.score}%<br/>
{sector.zone.reasoning}
</Popup>
</Polygon>
))
) : (
defaultZones.map((zone, idx) => (
<Polygon
key={idx}
positions={zone.points}
pathOptions={{
color: zone.color,
fillColor: zone.color,
fillOpacity: zone.fillOpacity,
weight: 2
}}
>
<Popup>
<strong>{zone.label}</strong><br/>
Вероятность: {zone.probability}%
</Popup>
</Polygon>
))
)}
{/* ТНП - красный маркер */}
<Marker
position={tnpPosition}
icon={L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
})}
>
<Popup>
<strong>ТНП - Точка начала поиска</strong><br/>
Координаты: {tnpLat?.toFixed(6)}, {tnpLon?.toFixed(6)}<br/>
Максимальная дистанция: {distance.toFixed(1)} км
</Popup>
</Marker>
</MapContainer>
);
};
export default SearchMap;
@@ -0,0 +1,180 @@
.case-detail {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
.detail-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 32px;
}
.btn-back {
padding: 8px 16px;
background: #e2e8f0;
color: #475569;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.btn-back:hover {
background: #cbd5e1;
}
.detail-header h1 {
margin: 0;
font-size: 28px;
color: #1e293b;
}
.detail-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.detail-section {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.detail-section h2 {
margin: 0 0 20px 0;
font-size: 20px;
color: #1e293b;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-item label {
font-size: 13px;
color: #64748b;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-item span {
font-size: 16px;
color: #1e293b;
font-weight: 500;
}
.diagnosis-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.diagnosis-badge {
padding: 8px 16px;
background: #fef3c7;
color: #92400e;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.psychotype-section {
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
border-left: 4px solid #3b82f6;
}
.psychotype-info {
display: flex;
flex-direction: column;
gap: 16px;
}
.psychotype-description {
font-size: 16px;
color: #1e293b;
margin: 0;
}
.search-tips {
padding: 16px;
background: white;
border-radius: 6px;
border-left: 3px solid #10b981;
}
.search-tips h3 {
margin: 0 0 8px 0;
font-size: 15px;
color: #059669;
font-weight: 600;
}
.search-tips p {
margin: 0;
font-size: 14px;
color: #334155;
line-height: 1.6;
}
.answers-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.answer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8fafc;
border-radius: 6px;
}
.answer-item label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.answer-item span {
font-size: 14px;
color: #1e293b;
font-weight: 600;
}
/* Responsive */
@media (max-width: 768px) {
.case-detail {
padding: 16px;
}
.detail-header {
flex-direction: column;
align-items: flex-start;
}
.info-grid {
grid-template-columns: 1fr;
}
.answer-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import './CaseDetail.css';
const CaseDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [caseData, setCase] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchCase();
}, [id]);
const fetchCase = async () => {
setLoading(true);
try {
const response = await axios.get(`/api/v1/cases/${id}`);
setCase(response.data);
setError(null);
} catch (err) {
setError('Ошибка загрузки карточки');
console.error(err);
} finally {
setLoading(false);
}
};
const formatDate = (dateString) => {
if (!dateString) return 'Не указано';
return new Date(dateString).toLocaleString('ru-RU');
};
const getPsychotypeDescription = (psychotype) => {
const descriptions = {
'explorer': {
title: 'Исследователь',
description: 'Активно исследует территорию, может уйти далеко от точки потери',
searchTips: 'Расширенный радиус поиска, проверка интересных объектов'
},
'cautious': {
title: 'Осторожный',
description: 'Осторожен, держится знакомых мест',
searchTips: 'Поиск вблизи точки потери, знакомые маршруты'
},
'passive': {
title: 'Пассивный',
description: 'Ждёт помощи, малоподвижен',
searchTips: 'Тщательный осмотр укрытий, проверка мест для отдыха'
},
'panicker': {
title: 'Паникёр',
description: 'Паникует, может двигаться хаотично',
searchTips: 'Проверка опасных мест, водоёмов, обрывов'
}
};
return descriptions[psychotype] || { title: psychotype, description: '', searchTips: '' };
};
if (loading) return <div className="loading-state">Загрузка...</div>;
if (error) return <div className="error-state">{error}</div>;
if (!caseData) return <div className="error-state">Карточка не найдена</div>;
const psychotypeInfo = caseData.psychotype_result
? getPsychotypeDescription(caseData.psychotype_result)
: null;
return (
<div className="case-detail">
<div className="detail-header">
<button className="btn-back" onClick={() => navigate('/admin/cases')}>
← Назад к списку
</button>
<h1>Карточка #{caseData.id}</h1>
</div>
<div className="detail-content">
{/* Основная информация */}
<section className="detail-section">
<h2>Основная информация</h2>
<div className="info-grid">
<div className="info-item">
<label>Возраст:</label>
<span>{caseData.age || '—'} лет</span>
</div>
<div className="info-item">
<label>Пол:</label>
<span>{caseData.gender === 'male' ? 'Мужской' : caseData.gender === 'female' ? 'Женский' : '—'}</span>
</div>
<div className="info-item">
<label>Дата создания:</label>
<span>{formatDate(caseData.created_at)}</span>
</div>
</div>
</section>
{/* Диагнозы */}
{caseData.diagnosis_type && caseData.diagnosis_type.length > 0 && (
<section className="detail-section">
<h2>Диагнозы</h2>
<div className="diagnosis-list">
{caseData.diagnosis_type.map((diagnosis, idx) => (
<span key={idx} className="diagnosis-badge">{diagnosis}</span>
))}
</div>
</section>
)}
{/* Психотип */}
{psychotypeInfo && (
<section className="detail-section psychotype-section">
<h2>Психотип: {psychotypeInfo.title}</h2>
<div className="psychotype-info">
<p className="psychotype-description">{psychotypeInfo.description}</p>
<div className="search-tips">
<h3>Рекомендации по поиску:</h3>
<p>{psychotypeInfo.searchTips}</p>
</div>
</div>
</section>
)}
{/* Ответы на вопросы психотипа */}
{caseData.psychotype_answers && Object.keys(caseData.psychotype_answers).length > 0 && (
<section className="detail-section">
<h2>Ответы на вопросы</h2>
<div className="answers-list">
{caseData.psychotype_answers.unfamiliar_env && (
<div className="answer-item">
<label>Поведение в незнакомой обстановке:</label>
<span>{caseData.psychotype_answers.unfamiliar_env}</span>
</div>
)}
{caseData.psychotype_answers.stress_reaction && (
<div className="answer-item">
<label>Реакция на стресс:</label>
<span>{caseData.psychotype_answers.stress_reaction}</span>
</div>
)}
{caseData.psychotype_answers.group_role && (
<div className="answer-item">
<label>Роль в группе:</label>
<span>{caseData.psychotype_answers.group_role}</span>
</div>
)}
{caseData.psychotype_answers.risk_taking && (
<div className="answer-item">
<label>Склонность к риску:</label>
<span>{caseData.psychotype_answers.risk_taking}</span>
</div>
)}
</div>
</section>
)}
</div>
</div>
);
};
export default CaseDetail;
+203
View File
@@ -0,0 +1,203 @@
.cases-list {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
.cases-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.cases-header h1 {
margin: 0;
font-size: 28px;
color: #1e293b;
}
.header-actions {
display: flex;
gap: 12px;
}
.btn-primary {
padding: 10px 20px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
padding: 10px 20px;
background: #e2e8f0;
color: #475569;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover {
background: #cbd5e1;
}
.filters-panel {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
min-width: 120px;
}
.filter-group input[type=number] {
min-width: 80px;
}
.table-container {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.cases-table {
width: 100%;
border-collapse: collapse;
}
.cases-table thead {
background: #f8fafc;
border-bottom: 2px solid #e2e8f0;
}
.cases-table th {
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cases-table td {
padding: 16px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
color: #334155;
}
.cases-table tbody tr:hover {
background: #f8fafc;
}
.psychotype-badge {
display: inline-block;
padding: 4px 12px;
background: #dbeafe;
color: #1e40af;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
}
.btn-link {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.btn-link:hover {
color: #2563eb;
text-decoration: underline;
}
.loading-state,
.error-state,
.empty-state {
padding: 60px 20px;
text-align: center;
color: #64748b;
font-size: 16px;
}
.error-state {
color: #dc2626;
}
.cases-footer {
margin-top: 20px;
padding: 16px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.cases-footer p {
margin: 0;
font-size: 14px;
color: #64748b;
}
/* Responsive */
@media (max-width: 768px) {
.cases-list {
padding: 16px;
}
.cases-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.filters-panel {
flex-direction: column;
}
.table-container {
overflow-x: auto;
}
.cases-table {
min-width: 800px;
}
}
+175
View File
@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import './CasesList.css';
const CasesList = () => {
const [cases, setCases] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
gender: '',
ageFrom: '',
ageTo: '',
diagnosis: '',
psychotype: ''
});
useEffect(() => {
fetchCases();
}, [filters]);
const fetchCases = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filters.gender) params.append('gender', filters.gender);
if (filters.ageFrom) params.append('age_from', filters.ageFrom);
if (filters.ageTo) params.append('age_to', filters.ageTo);
if (filters.diagnosis) params.append('diagnosis', filters.diagnosis);
if (filters.psychotype) params.append('psychotype', filters.psychotype);
const response = await axios.get(`/api/v1/cases?${params}`);
setCases(response.data);
setError(null);
} catch (err) {
setError('Ошибка загрузки данных');
console.error(err);
} finally {
setLoading(false);
}
};
const formatDate = (dateString) => {
if (!dateString) return 'Не указано';
return new Date(dateString).toLocaleDateString('ru-RU');
};
const getPsychotypeLabel = (psychotype) => {
const labels = {
'explorer': 'Исследователь',
'cautious': 'Осторожный',
'passive': 'Пассивный',
'panicker': 'Паникёр'
};
return labels[psychotype] || psychotype;
};
return (
<div className="cases-list">
<div className="cases-header">
<h1>Карточки пропавших</h1>
<div className="header-actions">
<button className="btn-primary" onClick={fetchCases}>
🔄 Обновить
</button>
</div>
</div>
{/* Фильтры */}
<div className="filters-panel">
<div className="filter-group">
<label>Пол:</label>
<select value={filters.gender} onChange={(e) => setFilters({...filters, gender: e.target.value})}>
<option value="">Все</option>
<option value="male">Мужской</option>
<option value="female">Женский</option>
</select>
</div>
<div className="filter-group">
<label>Возраст от:</label>
<input
type="number"
value={filters.ageFrom}
onChange={(e) => setFilters({...filters, ageFrom: e.target.value})}
placeholder="0"
/>
</div>
<div className="filter-group">
<label>до:</label>
<input
type="number"
value={filters.ageTo}
onChange={(e) => setFilters({...filters, ageTo: e.target.value})}
placeholder="100"
/>
</div>
<div className="filter-group">
<label>Диагноз:</label>
<select value={filters.diagnosis} onChange={(e) => setFilters({...filters, diagnosis: e.target.value})}>
<option value="">Все</option>
<option value="РАС">РАС</option>
<option value="эпилепсия">Эпилепсия</option>
<option value="СДВГ">СДВГ</option>
<option value="деменция">Деменция</option>
</select>
</div>
<button className="btn-secondary" onClick={() => setFilters({
gender: '', ageFrom: '', ageTo: '', diagnosis: '', psychotype: ''
})}>
Сбросить фильтры
</button>
</div>
{/* Таблица */}
{loading ? (
<div className="loading-state">Загрузка...</div>
) : error ? (
<div className="error-state">{error}</div>
) : cases.length === 0 ? (
<div className="empty-state">Карточки не найдены</div>
) : (
<div className="table-container">
<table className="cases-table">
<thead>
<tr>
<th>ID</th>
<th>Возраст</th>
<th>Пол</th>
<th>Диагнозы</th>
<th>Психотип</th>
<th>Дата создания</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{cases.map(caseItem => (
<tr key={caseItem.id}>
<td>{caseItem.id}</td>
<td>{caseItem.age || '—'}</td>
<td>{caseItem.gender === 'male' ? 'М' : caseItem.gender === 'female' ? 'Ж' : '—'}</td>
<td>
{caseItem.diagnosis_type && caseItem.diagnosis_type.length > 0
? caseItem.diagnosis_type.join(', ')
: '—'}
</td>
<td>
{caseItem.psychotype_result
? <span className="psychotype-badge">{getPsychotypeLabel(caseItem.psychotype_result)}</span>
: '—'}
</td>
<td>{formatDate(caseItem.created_at)}</td>
<td>
<Link to={`/admin/cases/${caseItem.id}`} className="btn-link">
Подробнее
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="cases-footer">
<p>Всего карточек: <strong>{cases.length}</strong></p>
</div>
</div>
);
};
export default CasesList;
@@ -0,0 +1,157 @@
.heatmap-view {
display: flex;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
}
.heatmap-controls {
width: 320px;
padding: 20px;
background: #f5f5f5;
border-right: 1px solid #ddd;
overflow-y: auto;
}
.heatmap-controls h2 {
margin: 0 0 20px 0;
font-size: 1.5rem;
color: #333;
}
.control-group {
margin-bottom: 20px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.button-group button {
padding: 8px 16px;
border: 1px solid #ccc;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.button-group button:hover {
background: #e9e9e9;
}
.button-group button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.control-group select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
background: white;
}
.year-range {
display: flex;
align-items: center;
gap: 8px;
}
.year-range input {
flex: 1;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
}
.year-range span {
color: #666;
}
.control-group input[type="checkbox"] {
margin-right: 8px;
}
.stats-info {
padding: 15px;
background: white;
border-radius: 4px;
margin-bottom: 20px;
}
.stats-info p {
margin: 5px 0;
color: #555;
}
.stats-info strong {
color: #007bff;
}
.loading {
color: #007bff;
font-style: italic;
}
.error {
color: #dc3545;
font-weight: 500;
}
.legend {
padding: 15px;
background: white;
border-radius: 4px;
}
.legend h3 {
margin: 0 0 10px 0;
font-size: 1rem;
color: #333;
}
.legend-gradient {
display: flex;
flex-direction: column;
gap: 8px;
}
.gradient-bar {
height: 20px;
background: linear-gradient(to right, blue, lime, yellow, red);
border-radius: 4px;
}
.legend-label {
font-size: 0.85rem;
color: #666;
}
.map-container {
flex: 1;
position: relative;
}
.map {
width: 100%;
height: 100%;
}
/* Leaflet overrides */
.leaflet-container {
font-family: inherit;
}
@@ -0,0 +1,252 @@
import React, { useState, useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.heat';
import './HeatmapView.css';
const HeatmapView = () => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const heatLayerRef = useRef(null);
const [heatmapData, setHeatmapData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [total, setTotal] = useState(0);
// Filters
const [filterType, setFilterType] = useState('all');
const [ageGroup, setAgeGroup] = useState('');
const [season, setSeason] = useState('');
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
// Layer visibility
const [showHeatmap, setShowHeatmap] = useState(true);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
// Create map centered on Russia
const map = L.map(mapRef.current).setView([55.7558, 37.6173], 5);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18
}).addTo(map);
mapInstanceRef.current = map;
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, []);
// Fetch heatmap data
const fetchHeatmapData = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
type: filterType
});
if (filterType === 'age' && ageGroup) {
params.append('age_group', ageGroup);
}
if (filterType === 'season' && season) {
params.append('season', season);
}
if (yearFrom) {
params.append('year_from', yearFrom);
}
if (yearTo) {
params.append('year_to', yearTo);
}
const response = await fetch(`http://192.168.0.99:8000/api/v1/stats/heatmap?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch heatmap data');
}
const data = await response.json();
setHeatmapData(data.points);
setTotal(data.total);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Update heatmap layer
useEffect(() => {
if (!mapInstanceRef.current) return;
// Remove existing heat layer
if (heatLayerRef.current) {
mapInstanceRef.current.removeLayer(heatLayerRef.current);
heatLayerRef.current = null;
}
// Add new heat layer if visible and data exists
if (showHeatmap && heatmapData.length > 0) {
const heatData = heatmapData.map(point => [point.lat, point.lon, point.intensity]);
const heatLayer = L.heatLayer(heatData, {
radius: 25,
blur: 15,
maxZoom: 10,
max: 1.0,
gradient: {
0.0: 'blue',
0.5: 'lime',
0.7: 'yellow',
1.0: 'red'
}
});
heatLayer.addTo(mapInstanceRef.current);
heatLayerRef.current = heatLayer;
}
}, [heatmapData, showHeatmap]);
// Load data on mount and filter change
useEffect(() => {
fetchHeatmapData();
}, [filterType, ageGroup, season, yearFrom, yearTo]);
const handleFilterTypeChange = (type) => {
setFilterType(type);
// Reset specific filters
if (type !== 'age') setAgeGroup('');
if (type !== 'season') setSeason('');
};
return (
<div className="heatmap-view">
<div className="heatmap-controls">
<h2>Тепловая карта находок</h2>
<div className="control-group">
<label>Тип фильтра:</label>
<div className="button-group">
<button
className={filterType === 'all' ? 'active' : ''}
onClick={() => handleFilterTypeChange('all')}
>
Все
</button>
<button
className={filterType === 'age' ? 'active' : ''}
onClick={() => handleFilterTypeChange('age')}
>
По возрасту
</button>
<button
className={filterType === 'season' ? 'active' : ''}
onClick={() => handleFilterTypeChange('season')}
>
По сезону
</button>
<button
className={filterType === 'outcome' ? 'active' : ''}
onClick={() => handleFilterTypeChange('outcome')}
>
По исходу
</button>
</div>
</div>
{filterType === 'age' && (
<div className="control-group">
<label>Возрастная группа:</label>
<select value={ageGroup} onChange={(e) => setAgeGroup(e.target.value)}>
<option value="">Все</option>
<option value="0-6">0-6 лет</option>
<option value="7-10">7-10 лет</option>
<option value="11-14">11-14 лет</option>
<option value="15-17">15-17 лет</option>
<option value="18+">18+ лет</option>
</select>
</div>
)}
{filterType === 'season' && (
<div className="control-group">
<label>Сезон:</label>
<select value={season} onChange={(e) => setSeason(e.target.value)}>
<option value="">Все</option>
<option value="зима">Зима</option>
<option value="весна">Весна</option>
<option value="лето">Лето</option>
<option value="осень">Осень</option>
</select>
</div>
)}
<div className="control-group">
<label>Период:</label>
<div className="year-range">
<input
type="number"
placeholder="От года"
value={yearFrom}
onChange={(e) => setYearFrom(e.target.value)}
min="2000"
max="2100"
/>
<span>—</span>
<input
type="number"
placeholder="До года"
value={yearTo}
onChange={(e) => setYearTo(e.target.value)}
min="2000"
max="2100"
/>
</div>
</div>
<div className="control-group">
<label>
<input
type="checkbox"
checked={showHeatmap}
onChange={(e) => setShowHeatmap(e.target.checked)}
/>
Показать тепловую карту
</label>
</div>
<div className="stats-info">
<p>Всего точек: <strong>{total}</strong></p>
{loading && <p className="loading">Загрузка...</p>}
{error && <p className="error">Ошибка: {error}</p>}
</div>
<div className="legend">
<h3>Легенда</h3>
<div className="legend-gradient">
<span className="legend-label">Низкая плотность</span>
<div className="gradient-bar"></div>
<span className="legend-label">Высокая плотность</span>
</div>
</div>
</div>
<div className="map-container">
<div ref={mapRef} className="map"></div>
</div>
</div>
);
};
export default HeatmapView;
@@ -0,0 +1,98 @@
.statistics {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.stats-header h1 {
margin: 0;
font-size: 28px;
color: #1e293b;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.stat-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-card.wide {
grid-column: span 2;
}
.stat-card h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: #64748b;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 48px;
font-weight: 700;
color: #3b82f6;
line-height: 1;
}
.stat-breakdown {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #f8fafc;
border-radius: 6px;
}
.stat-item span {
font-size: 14px;
color: #64748b;
}
.stat-item strong {
font-size: 18px;
color: #1e293b;
font-weight: 600;
}
/* Responsive */
@media (max-width: 768px) {
.statistics {
padding: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-card.wide {
grid-column: span 1;
}
}
@@ -0,0 +1,106 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './Statistics.css';
const Statistics = () => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchStatistics();
}, []);
const fetchStatistics = async () => {
setLoading(true);
try {
const response = await axios.get('/api/v1/stats/summary');
setStats(response.data);
setError(null);
} catch (err) {
setError('Ошибка загрузки статистики');
console.error(err);
} finally {
setLoading(false);
}
};
if (loading) return <div className="loading-state">Загрузка...</div>;
if (error) return <div className="error-state">{error}</div>;
if (!stats) return <div className="error-state">Нет данных</div>;
return (
<div className="statistics">
<div className="stats-header">
<h1>Статистика</h1>
<button className="btn-primary" onClick={fetchStatistics}>
🔄 Обновить
</button>
</div>
<div className="stats-grid">
{/* Общая статистика */}
<div className="stat-card">
<h3>Всего карточек</h3>
<div className="stat-value">{stats.total_cases || 0}</div>
</div>
{/* По полу */}
<div className="stat-card">
<h3>По полу</h3>
<div className="stat-breakdown">
<div className="stat-item">
<span>Мужской:</span>
<strong>{stats.by_gender?.male || 0}</strong>
</div>
<div className="stat-item">
<span>Женский:</span>
<strong>{stats.by_gender?.female || 0}</strong>
</div>
</div>
</div>
{/* По возрасту */}
<div className="stat-card">
<h3>По возрасту</h3>
<div className="stat-breakdown">
{stats.by_age && Object.entries(stats.by_age).map(([range, count]) => (
<div key={range} className="stat-item">
<span>{range}:</span>
<strong>{count}</strong>
</div>
))}
</div>
</div>
{/* По психотипам */}
<div className="stat-card">
<h3>По психотипам</h3>
<div className="stat-breakdown">
{stats.by_psychotype && Object.entries(stats.by_psychotype).map(([type, count]) => (
<div key={type} className="stat-item">
<span>{type}:</span>
<strong>{count}</strong>
</div>
))}
</div>
</div>
{/* По диагнозам */}
<div className="stat-card wide">
<h3>По диагнозам</h3>
<div className="stat-breakdown">
{stats.by_diagnosis && Object.entries(stats.by_diagnosis).map(([diagnosis, count]) => (
<div key={diagnosis} className="stat-item">
<span>{diagnosis}:</span>
<strong>{count}</strong>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Statistics;
+184
View File
@@ -0,0 +1,184 @@
@import 'leaflet/dist/leaflet.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/*
Цветовые палитры ВЕКТОР согласно §5 контекста
Три интерфейса: ЦОУ Десктоп, Мобильный, Админка
*/
/* ЦОУ Десктоп — аналитика, работа часами */
:root[data-theme="desktop"],
:root {
--bg-primary: #0a0c0f;
--bg-secondary: #12161b;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent: #ff4d00;
--accent-hover: #ff7a33;
--zone-1: #ff4d00;
--zone-2: #ff9f1a;
--zone-3: #ffd166;
--success: #2ea043;
--warning: #d29922;
--danger: #f85149;
--border: #30363d;
--input-bg: #161b22;
--input-border: #30363d;
}
/* Мобильный — быстрое действие под стрессом */
:root[data-theme="mobile"] {
--bg-primary: #000000;
--bg-secondary: #0d1117;
--text-primary: #ffffff;
--text-secondary: #b1bac4;
--accent: #ff3b30;
--accent-hover: #ff6b60;
--btn-confirm: #34c759;
--btn-gps: #0a84ff;
--zone-1: #ff3b30;
--zone-2: #ff9500;
--zone-3: #ffcc00;
--success: #34c759;
--warning: #ff9500;
--danger: #ff3b30;
--border: #1c1c1e;
--input-bg: #1c1c1e;
--input-border: #38383a;
/* Мобильные кнопки минимум 52px высота */
--btn-min-height: 52px;
}
/* Админка — спокойная верификация */
:root[data-theme="admin"] {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent: #2f81f7;
--accent-hover: #58a6ff;
--chart-distance: #2f81f7;
--chart-terrain: #3fb950;
--chart-outcome: #f85149;
--success: #3fb950;
--warning: #d29922;
--danger: #f85149;
--border: #30363d;
--input-bg: #161b22;
--input-border: #30363d;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary);
color: var(--text-primary);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Базовые стили для всех интерфейсов */
button {
font-family: inherit;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
input, textarea, select {
font-family: inherit;
background-color: var(--input-bg);
color: var(--text-primary);
border: 1px solid var(--input-border);
border-radius: 6px;
padding: 8px 12px;
transition: border-color 0.2s ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
}
/* Мобильные кнопки */
[data-theme="mobile"] button {
min-height: var(--btn-min-height);
font-size: 17px;
font-weight: 600;
}
/* Кнопки подтверждения */
.btn-primary {
background-color: var(--accent);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
.btn-success {
background-color: var(--success);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
}
.btn-danger {
background-color: var(--danger);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
}
/* Мобильная кнопка GPS */
[data-theme="mobile"] .btn-gps {
background-color: var(--btn-gps);
color: white;
}
/* Мобильная кнопка подтверждения */
[data-theme="mobile"] .btn-confirm {
background-color: var(--btn-confirm);
color: white;
}
/* Карточки */
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
/* Зоны поиска */
.zone-priority-1 {
color: var(--zone-1);
border-color: var(--zone-1);
}
.zone-priority-2 {
color: var(--zone-2);
border-color: var(--zone-2);
}
.zone-priority-3 {
color: var(--zone-3);
border-color: var(--zone-3);
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+332
View File
@@ -0,0 +1,332 @@
/* Админка — палитра из §5 контекста */
.admin-dashboard {
min-height: 100vh;
background: var(--bg-primary);
color: var(--text-primary);
}
.admin-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.admin-header h1 {
margin: 0;
font-size: 24px;
color: var(--text-primary);
font-weight: 700;
}
.admin-nav {
display: flex;
gap: 16px;
}
.admin-nav button {
padding: 8px 16px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.admin-nav button:hover {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--accent);
}
.admin-nav button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.admin-content {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.stat-card h3 {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.stat-change {
font-size: 14px;
margin-top: 8px;
}
.stat-change.positive {
color: var(--success);
}
.stat-change.negative {
color: var(--danger);
}
.cases-table {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.table-header h2 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
font-weight: 600;
}
.table-actions {
display: flex;
gap: 12px;
}
.btn-primary {
padding: 8px 16px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
padding: 8px 16px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--accent);
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--bg-primary);
}
thead th {
padding: 12px 20px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
tbody tr:hover {
background: var(--bg-primary);
}
tbody td {
padding: 16px 20px;
font-size: 14px;
color: var(--text-primary);
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.active {
background: var(--warning);
color: #000;
}
.status-badge.closed {
background: var(--success);
color: white;
}
.status-badge.archived {
background: var(--text-secondary);
color: white;
}
/* Графики */
.chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.chart-container h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: var(--text-primary);
font-weight: 600;
}
/* Цвета для графиков из палитры админки */
.chart-distance {
color: var(--chart-distance);
}
.chart-terrain {
color: var(--chart-terrain);
}
.chart-outcome {
color: var(--chart-outcome);
}
/* Форма загрузки документов */
.upload-zone {
background: var(--bg-secondary);
border: 2px dashed var(--border);
border-radius: 8px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover {
border-color: var(--accent);
background: var(--bg-primary);
}
.upload-zone.dragging {
border-color: var(--accent);
background: var(--bg-primary);
}
.upload-icon {
font-size: 48px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.upload-text {
font-size: 16px;
color: var(--text-primary);
margin-bottom: 8px;
}
.upload-hint {
font-size: 14px;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.admin-nav {
width: 100%;
flex-direction: column;
}
.admin-nav button {
width: 100%;
}
.stats-grid {
grid-template-columns: 1fr;
}
.table-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.table-actions {
width: 100%;
flex-direction: column;
}
.table-actions button {
width: 100%;
}
table {
font-size: 12px;
}
thead th,
tbody td {
padding: 8px 12px;
}
}
+65
View File
@@ -0,0 +1,65 @@
import React from 'react';
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import CasesList from '../components/admin/CasesList';
import CaseDetail from '../components/admin/CaseDetail';
import HeatmapView from '../components/admin/HeatmapView';
import Statistics from '../components/admin/Statistics';
import './AdminDashboard.css';
const AdminDashboard = () => {
const location = useLocation();
return (
<div className="admin-dashboard">
<aside className="sidebar">
<div className="sidebar-header">
<h1>SAR МЧС</h1>
<p className="subtitle">Система анализа поисков</p>
</div>
<nav className="sidebar-nav">
<Link
to="/admin/cases"
className={location.pathname.startsWith('/admin/cases') ? 'active' : ''}
>
<span className="icon">📋</span>
Карточки
</Link>
<Link
to="/admin/heatmap"
className={location.pathname === '/admin/heatmap' ? 'active' : ''}
>
<span className="icon">🗺️</span>
Тепловая карта
</Link>
<Link
to="/admin/statistics"
className={location.pathname === '/admin/statistics' ? 'active' : ''}
>
<span className="icon">📊</span>
Статистика
</Link>
</nav>
<div className="sidebar-footer">
<a href="/mobile" target="_blank" rel="noopener noreferrer" className="mobile-link">
<span className="icon">📱</span>
Мобильная форма
</a>
</div>
</aside>
<main className="main-content">
<Routes>
<Route path="/" element={<CasesList />} />
<Route path="/cases" element={<CasesList />} />
<Route path="/cases/:id" element={<CaseDetail />} />
<Route path="/heatmap" element={<HeatmapView />} />
<Route path="/statistics" element={<Statistics />} />
</Routes>
</main>
</div>
);
};
export default AdminDashboard;
+58
View File
@@ -0,0 +1,58 @@
.desktop-sar {
min-height: 100vh;
background: var(--bg-primary);
}
.sar-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.sar-header h1 {
margin: 0;
font-size: 24px;
color: var(--text-primary);
font-weight: 700;
}
.sar-header .logo {
color: var(--accent);
font-weight: 800;
}
.sar-status {
display: flex;
gap: 16px;
align-items: center;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.desktop-content {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
+24
View File
@@ -0,0 +1,24 @@
import React from "react";
import CaseForm from "../components/CaseForm";
import "../pages/DesktopSAR.css";
const DesktopSARPage = () => {
return (
<div className="desktop-sar">
<div className="sar-header">
<h1><span className="logo">ВЕКТОР</span> ЦОУ</h1>
<div className="sar-status">
<div className="status-indicator">
<span className="status-dot"></span>
Система активна
</div>
</div>
</div>
<div className="desktop-content">
<CaseForm isMobile={false} />
</div>
</div>
);
};
export default DesktopSARPage;
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import CaseForm from "../components/CaseForm";
const MobileFormPage = () => {
return (
<div className="mobile-page">
<CaseForm isMobile={true} />
</div>
);
};
export default MobileFormPage;

Some files were not shown because too many files have changed in this diff Show More