# ВЕКТОР — очередь технического долга для делегирования **Проект:** ВЕКТОР (Система определения приоритетных направлений поиска, SAR/МЧС) **Стек:** FastAPI + PostgreSQL 16 + React (CRA), docker-compose **Расположение:** LXC 108 (`Lab`, 192.168.0.99), путь `/root/vector` **Дата анализа:** 2026-07-21 **Состояние на момент анализа:** git чистый, ветка `master`, `126 passed, 26 skipped` (async-тесты пропущены — нет `pytest-asyncio`). ## Как пользоваться этим файлом - **Раздел A — ATOMIC.** Задачи для локальной LLM (Qwen2.5-coder, 12GB VRAM, ~16–32k контекста). Каждый промт самодостаточен: весь нужный код встроен в промт, модель НЕ читает остальной проект. Бери задачу, копируй блок `PROMPT` целиком в модель, применяй ответ, проверяй по `Критерий приёмки`. - **Раздел B — NEEDS-CONTEXT.** НЕ давать локальной модели. Требуют понимания нескольких файлов, контрактов данных, роутинга или бизнес-логики SAR. Оставлены для более сильной модели / человека. ## Жёсткие исключения (соблюдены во всём файле) Ни одна ATOMIC-задача не трогает: - ❌ алгоритм зонального скоринга (7 факторов) — весь `backend/services/scoring_service.py` вынесен в раздел B; - ❌ схему БД / миграции (`backend/models.py`, `backend/migrations/*`); - ❌ auth/security (`backend/routers/auth.py`, JWT, bcrypt, роли); - ❌ бизнес-логику МЧС/SAR (коэффициенты дистанции, поведенческие профили, психотипы как методику). --- # РАЗДЕЛ A — ATOMIC (для локальной LLM) Отсортировано по модулю. Внутри модуля — по порядку файла. --- ## A1 — `backend/services/distance_service.py` · защита от None - **Файл / строки:** `backend/services/distance_service.py:111-121`, `148-158`, `168-178` - **Тип:** missing validation - **Сложность:** atomic **PROMPT (скопировать в модель целиком):** ```` Ты правишь один Python-файл. Ниже три функции. У каждой первый аргумент — строка, и сразу вызывается `.lower()`. Если аргумент окажется None, будет AttributeError. Добавь защиту: если аргумент None или пустой, используй значение по умолчанию (для terrain → вернуть 0.5; для time_of_day → вести себя как 'день' → вернуть 1.0; для weather → вести себя как отсутствие осадков → вернуть 1.0). Не меняй числовые коэффициенты и словари. Верни ТОЛЬКО три изменённые функции целиком, в блоке ```python. def get_terrain_coefficient(terrain: str) -> float: 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 return 0.5 def get_time_of_day_coefficient(time_of_day: str) -> float: 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: 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 ```` **Критерий приёмки:** - `get_terrain_coefficient(None) == 0.5`, `get_time_of_day_coefficient(None) == 1.0`, `get_weather_coefficient(None) == 1.0`. - Все существующие значения не изменились: `get_terrain_coefficient('густой лес') == 0.25`, `get_weather_coefficient('туман') == 0.7`. - `PYTHONPATH=/root/vector python -m pytest backend/tests/test_distance_service.py -q` — зелёный. --- ## A2 — `backend/services/claude_service.py` · заменить `print()` на логирование - **Файл / строки:** `backend/services/claude_service.py:66`, `111`, `247` (и добавить импорт/логгер вверху файла, строки 5-9) - **Тип:** missing error handling (диагностика в stdout вместо логгера) - **Сложность:** atomic **PROMPT:** ```` В Python-файле три места пишут диагностику через print(). Замени их на стандартный модуль logging. Вверху файла (рядом с существующими импортами `import os`, `import json`) добавь `import logging` и создай модульный логгер `logger = logging.getLogger(__name__)`. Затем замени каждый print на соответствующий вызов logger. Для строк с ошибками используй logger.warning(...). Не меняй никакую другую логику. Верни ТОЛЬКО unified diff (формат `diff`), затрагивающий импорты и три строки. Текущие строки: (строка 66) — внутри except при недоступности Claude API: print(f"Claude API unavailable: {e}. Using fallback scoring service.") (строка 111) — внутри except geo/scoring в analyze_with_claude: print(f"Geo/scoring service error: {e}") (строка 247) — внутри except geo/scoring в analyze_with_fallback: print(f"Geo/scoring service error in fallback: {e}") Существующие импорты вверху файла: import os import json from typing import Dict, List, Optional from pydantic import BaseModel import httpx ```` **Критерий приёмки:** - `grep -n "print(" backend/services/claude_service.py` → пусто. - В начале файла есть `import logging` и `logger = logging.getLogger(__name__)`. - `PYTHONPATH=/root/vector python -c "import backend.services.claude_service"` — без ошибок. --- ## A3 — `backend/services/claude_service.py` · устаревший ID модели Claude - **Файл / строки:** `backend/services/claude_service.py:157` - **Тип:** bug (несуществующая/устаревшая модель) - **Сложность:** atomic **PROMPT:** ```` В Python-файле в теле POST-запроса к Anthropic API указан устаревший идентификатор модели. Замени строковое значение модели с "claude-sonnet-4-20250514" на "claude-sonnet-5". Больше НИЧЕГО не меняй (max_tokens, заголовки, структуру messages оставь как есть). Верни ТОЛЬКО одну изменённую строку. Текущая строка (157): "model": "claude-sonnet-4-20250514", ```` **Критерий приёмки:** - Строка 157 содержит `"model": "claude-sonnet-5",`. - `grep -n "claude-sonnet-4" backend/services/claude_service.py` → пусто. > Примечание для проверяющего: актуальные ID на дату анализа — `claude-sonnet-5` (баланс скорость/качество), `claude-opus-4-8` (максимум качества). Для JSON-анализа кейса `claude-sonnet-5` — разумный дефолт. --- ## A4 — `backend/routers/analyze.py` · устаревший `datetime.utcnow()` - **Файл / строки:** `backend/routers/analyze.py:3` (импорт) и `:106` - **Тип:** bug (deprecated API, начиная с Python 3.12 `datetime.utcnow()` deprecated) - **Сложность:** atomic **PROMPT:** ```` В Python-файле используется устаревший `datetime.utcnow()`. Замени его на timezone-aware вариант `datetime.now(timezone.utc)`. Для этого: 1. В строке импорта `from datetime import datetime` добавь `timezone`: `from datetime import datetime, timezone`. 2. Замени `datetime.utcnow().isoformat()` на `datetime.now(timezone.utc).isoformat()`. Больше ничего не меняй. Верни ТОЛЬКО две изменённые строки с указанием, какую на какую. Текущий импорт (строка 3): from datetime import datetime Текущая строка (106), внутри словаря result: 'analyzed_at': datetime.utcnow().isoformat(), ```` **Критерий приёмки:** - `grep -n "utcnow" backend/routers/analyze.py` → пусто. - Импорт содержит `timezone`. - `PYTHONPATH=/root/vector python -c "import backend.routers.analyze"` — без ошибок. --- ## A5 — `backend/main.py` · миграция `@app.on_event('startup')` → lifespan - **Файл / строки:** `backend/main.py:38-40` - **Тип:** bug (deprecated в текущих версиях FastAPI/Starlette) - **Сложность:** atomic **PROMPT:** ```` В FastAPI-приложении используется устаревший декоратор `@app.on_event('startup')`. Перепиши его на современный lifespan-контекст. Требования: 1. Добавь `from contextlib import asynccontextmanager` в импорты. 2. Создай асинхронную функцию lifespan, которая на старте вызывает init_db(), затем yield. 3. Передай её в конструктор: `app = FastAPI(title='Vector API', version='0.1.0', lifespan=lifespan)`. 4. Удали старый блок `@app.on_event('startup')` / `def startup()`. Функция lifespan должна быть объявлена ДО создания app. init_db уже импортирован как `from backend.database import init_db`. Верни ТОЛЬКО изменённые фрагменты в блоке ```python. Текущий релевантный код: app = FastAPI(title='Vector API', version='0.1.0') app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_credentials=allow_credentials, allow_methods=['*'], allow_headers=['*'], ) @app.on_event('startup') def startup() -> None: init_db() ```` **Критерий приёмки:** - `grep -n "on_event" backend/main.py` → пусто. - Есть `@asynccontextmanager` и `lifespan=lifespan` в вызове `FastAPI(...)`. - `PYTHONPATH=/root/vector python -c "import backend.main"` — без ошибок; приложение стартует, `GET /api/v1/health` → `{"status":"ok"}`. --- ## A6 — `backend/routers/admin.py` · обработка ошибок парсинга .docx - **Файл / строки:** `backend/routers/admin.py:28-38` (функция `_extract_docx_text`) и `:134-139` (эндпоинт `admin_parse_doc`) - **Тип:** missing error handling - **Сложность:** atomic > Не трогает auth: декоратор роутера и `require_roles` остаются без изменений. **PROMPT:** ```` В этом FastAPI-роутере функция `_extract_docx_text` разбирает .docx (это zip с XML). Если загружен НЕ .docx (битый файл, не-zip, нет word/document.xml), `zipfile.ZipFile` или `archive.read` бросят исключение, и клиент получит непонятную 500-ю. Оберни разбор так, чтобы при ошибке чтения архива/XML эндпоинт возвращал HTTP 400 с понятным сообщением. Сделай ДВА изменения, не трогая ничего другого: 1. В функции `_extract_docx_text` оберни блок чтения zip и парсинга XML в try/except (перехватывай `zipfile.BadZipFile`, `KeyError`, `ET.ParseError`). При любой из этих ошибок брось `ValueError("Не удалось прочитать .docx: файл повреждён или имеет неверный формат")`. 2. В эндпоинте `admin_parse_doc` оберни вызов `_extract_docx_text(content)` в try/except ValueError и при ошибке брось `raise HTTPException(status_code=400, detail=str(e))`. `HTTPException` уже импортирован. `zipfile`, `ET` (xml.etree.ElementTree) уже импортированы. Верни ТОЛЬКО две изменённые функции целиком в блоке ```python. Текущий код: def _extract_docx_text(content: bytes) -> str: with zipfile.ZipFile(BytesIO(content)) as archive: xml = archive.read('word/document.xml') root = ET.fromstring(xml) ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} paragraphs: list[str] = [] for paragraph in root.findall('.//w:body/w:p', ns): texts = [node.text for node in paragraph.findall('.//w:t', ns) if node.text] if texts: paragraphs.append(''.join(texts).strip()) return '\n'.join(paragraphs).strip() @router.post('/parse-doc', response_model=ParseDocResponse) async def admin_parse_doc(file: UploadFile = File(...)) -> dict: content = await file.read() raw_text = _extract_docx_text(content) preview = _coerce_preview(raw_text) return {'filename': file.filename, 'parsed': True, 'preview': preview, 'raw_text': raw_text} ```` **Критерий приёмки:** - Загрузка валидного .docx по-прежнему возвращает 200 и корректный `preview` (тест `backend/tests/test_parse_doc_api.py` зелёный). - Загрузка мусорного файла (не-zip) → HTTP 400 с сообщением, а не 500. - `PYTHONPATH=/root/vector python -m pytest backend/tests/test_parse_doc_api.py -q` — зелёный. --- ## A7 — тесты: включить `pytest-asyncio` (26 пропущенных тестов) - **Файл / строки:** `backend/requirements.txt:15` (добавить строку) + новый файл `pytest.ini` в корне репозитория - **Тип:** missing test (тесты есть, но молча пропускаются) - **Сложность:** atomic **PROMPT:** ```` В проекте есть async-тесты, помеченные @pytest.mark.asyncio, но плагин pytest-asyncio не установлен, поэтому 26 тестов молча пропускаются. Нужно: 1. В конец файла requirements (см. содержимое ниже) добавить строку: pytest-asyncio==0.24.0 2. Создать НОВЫЙ файл pytest.ini в корне репозитория со следующим содержимым (включает автоматический режim asyncio и регистрирует маркер): [pytest] asyncio_mode = auto testpaths = backend/tests markers = asyncio: async test powered by pytest-asyncio Верни: (а) финальную строку, которую добавить в requirements.txt; (б) полное содержимое нового файла pytest.ini. Текущий requirements.txt (последние строки): pydantic==2.10.3 pydantic-settings==2.6.1 email-validator==2.1.0 psycopg2-binary==2.9.12 pytest==8.3.4 ```` **Критерий приёмки:** - После `pip install pytest-asyncio==0.24.0` (внутри `.venv`) и добавления `pytest.ini`: `PYTHONPATH=/root/vector python -m pytest backend/tests -q` показывает **0 skipped** по причине "async def not natively supported" (число passed вырастает с 126 в сторону ~150). - Никаких новых падений; предупреждение `PytestUnknownMarkWarning` для `asyncio` исчезает. --- ## A8 — удаление мёртвого кода backend (механическое) - **Файлы (удалить целиком):** - `backend/api/v1/analyze.py`, `backend/api/v1/auth.py`, `backend/api/v1/cases.py`, `backend/api/v1/stats.py`, `backend/api/v1/__init__.py`, `backend/api/v1/analyze.py.backup`, и пустой каталог `backend/api/` (весь `api/` — старая копия, `main.py` подключает только `backend/routers/*`) - `backend/models_new.py`, `backend/models_updated.py`, `backend/models_backup.py` (используется только `backend/models.py`) - `backend/services/geo_service.py.bak`, `backend/services/scoring_service.py.bak` - `backend/check_model.py` (отладочный скрипт с битым `import models`) - **Тип:** dead code - **Сложность:** atomic (чисто механическое удаление — не требует чтения кода) > Подтверждено анализом: `main.py` импортирует `backend.routers.*`; ничего вне каталога `backend/api/` его не импортирует; `models_*.py` не импортируются нигде; `.bak/.backup` — резервные копии. **PROMPT (это shell-задача, не правка кода):** ```` Выполни удаление перечисленных файлов и проверь, что тесты не сломались. Не удаляй ничего сверх списка. git rm backend/api/v1/analyze.py backend/api/v1/auth.py backend/api/v1/cases.py backend/api/v1/stats.py backend/api/v1/__init__.py backend/api/v1/analyze.py.backup git rm backend/api/__init__.py git rm backend/models_new.py backend/models_updated.py backend/models_backup.py git rm backend/services/geo_service.py.bak backend/services/scoring_service.py.bak git rm backend/check_model.py Затем: PYTHONPATH=/root/vector python -m pytest backend/tests -q ```` **Критерий приёмки:** - Перечисленные файлы отсутствуют; каталог `backend/api/` удалён. - `PYTHONPATH=/root/vector python -m pytest backend/tests -q` — то же число passed, что и до удаления (никаких новых ошибок импорта). - `PYTHONPATH=/root/vector python -c "import backend.main"` — без ошибок. --- ## A9 — документация: единый адрес и имя БД - **Файлы / строки:** `PSYCHOTYPE_SUMMARY.md` (везде `192.168.0.99`), `SERVICES_README.md`, `PSYCHOTYPE_API.md`, `MOBILE_FORM_README.md` (адреса `.99`); `DEPLOY.md` (имя БД `sar_mchs` и путь `/root/sar-mchs`) - **Тип:** naming / консистентность документации - **Сложность:** atomic > ⚠️ УСТАРЕЛО (2026-07-25): канон ниже оказался неверным — адрес **192.168.0.108** взят из номера VMID, а не из реальной сети. Контейнер на DHCP и фактически имеет **192.168.0.99**; замена .99 → .108 была ошибочной и откачена. Раздел сохранён как история выполненной задачи — не выполнять повторно. > Канон (исходный, НЕВЕРНЫЙ): адрес **192.168.0.108**, БД **vector_mchs**, путь проекта **/root/vector**. **PROMPT:** ```` Задача — привести документацию к единым значениям. В markdown-файлах замени по всему тексту: - 192.168.0.99 → 192.168.0.108 - имя базы данных sar_mchs → vector_mchs - путь /root/sar-mchs → /root/vector - в командах `cd /root/sar-mchs` → `cd /root/vector` Это простая текстовая замена, кода не касается. Выполни sed-заменой по указанным .md-файлам и верни список выполненных команд: sed -i 's/192\.168\.0\.99/192.168.0.108/g' PSYCHOTYPE_SUMMARY.md SERVICES_README.md PSYCHOTYPE_API.md MOBILE_FORM_README.md sed -i 's/sar_mchs/vector_mchs/g; s#/root/sar-mchs#/root/vector#g' DEPLOY.md ```` **Критерий приёмки:** - `grep -rn "192.168.0.99\|sar_mchs\|sar-mchs" *.md` → пусто. - Смысл документов не изменился, только адреса/имена. --- ## A10 — `frontend/src/pages/analysis/AnalysisResult.jsx` · неверный парсинг времени суток - **Файл / строки:** `frontend/src/pages/analysis/AnalysisResult.jsx:42-48` - **Тип:** bug - **Сложность:** atomic **PROMPT:** ```` В React-компоненте функция getTimeOfDay ожидает строку "HH:MM" и берёт часы через lostTime.split(':')[0]. Но на вход приходит значение из в формате "YYYY-MM-DDTHH:MM" (например "2026-07-21T14:30"). Тогда split(':')[0] === "2026-07-21T14", а parseInt даёт 2026 → время суток определяется неверно почти всегда. Почини getTimeOfDay так, чтобы она корректно извлекала час из обоих форматов: - если строка содержит 'T' — берём часть после 'T', затем часы до ':'; - иначе — берём часы до ':' как раньше. Логика диапазонов (день/сумерки/ночь) и возвращаемые строки не меняются. Верни ТОЛЬКО исправленную функцию в блоке ```jsx. Текущий код: const getTimeOfDay = (lostTime) => { if (!lostTime) return 'день'; const hour = parseInt(lostTime.split(':')[0]); if (hour >= 6 && hour < 18) return 'день'; if (hour >= 18 && hour < 22) return 'сумерки'; return 'ночь'; }; ```` **Критерий приёмки:** - `getTimeOfDay("2026-07-21T14:30")` → `'день'`; `getTimeOfDay("2026-07-21T23:00")` → `'ночь'`; `getTimeOfDay("2026-07-21T20:00")` → `'сумерки'`. - `getTimeOfDay("08:15")` (старый формат) → `'день'`; `getTimeOfDay("")` → `'день'`. --- ## A11 — `frontend/src/components/CaseForm/Step4Environment.jsx` · неверный номер шага в заголовке - **Файл / строки:** `frontend/src/components/CaseForm/Step4Environment.jsx:58` - **Тип:** bug (текст UI противоречит динамическому индикатору «Шаг X из Y») - **Сложность:** atomic **PROMPT:** ```` В React-компоненте захардкожен заголовок "Шаг 5: Среда и местность", но этот компонент рендерится как шаг 4 (в родителе CaseForm он идёт под индексом, отображаемым как «Шаг 4»). Заголовок вводит в заблуждение и конфликтует с динамическим индикатором. Убери жёсткий номер шага из заголовка, оставив только название раздела. Замени строку:

Шаг 5: Среда и местность

на:

Среда и местность

Верни ТОЛЬКО изменённую строку. ```` **Критерий приёмки:** - Строка 58 больше не содержит «Шаг 5». - Никакой другой разметки не тронуто. --- ## A12 — `frontend/src/components/CaseForm/Step4Environment.jsx` · обработка ошибок геолокации - **Файл / строки:** `frontend/src/components/CaseForm/Step4Environment.jsx:28-54` - **Тип:** missing error handling (используется блокирующий `alert`, нет структурного состояния ошибки) - **Сложность:** atomic **PROMPT:** ```` В React-компоненте функция handleGetLocation сообщает об ошибках геолокации через блокирующий alert(). Замени alert на запись сообщения об ошибке в состояние формы через updateData (ключ gps_error), чтобы UI мог показать ошибку не блокируя поток. Требования: - при отсутствии navigator.geolocation: updateData({ gps_error: 'Геолокация не поддерживается вашим браузером' }); return; - перед запросом координат сбрасывай ошибку: updateData({ gps_loading: true, gps_error: null }); - в success-колбэке помимо координат ставь gps_error: null; - в error-колбэке: updateData({ gps_error: 'Ошибка получения координат: ' + error.message, gps_loading: false }); Не удаляй gps_loading, не меняй параметры getCurrentPosition (enableHighAccuracy/timeout/maximumAge). Верни ТОЛЬКО изменённую функцию handleGetLocation в блоке ```jsx. Текущий код: 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 } ); }; ```` **Критерий приёмки:** - В функции больше нет `alert(`. - При ошибке/отказе в правах в состоянии формы появляется `gps_error` с текстом; при успехе `gps_error === null`. - Координаты по-прежнему пишутся в `tnp_lat`/`tnp_lon` с 6 знаками. --- ## A13 — `frontend/src/components/SearchMap.jsx` · CDN-иконки и рассинхрон версии Leaflet - **Файл / строки:** `frontend/src/components/SearchMap.jsx:186-187` - **Тип:** bug (внешние `raw.githubusercontent`/`cdnjs`, версия 1.7.1 против 1.9.4 в остальном коде) - **Сложность:** atomic **PROMPT:** ```` В React-компоненте иконки маркеров Leaflet грузятся с ненадёжных внешних CDN (raw.githubusercontent.com и cdnjs) и с версией 1.7.1, тогда как остальной код использует unpkg leaflet 1.9.4. Замени оба URL на unpkg-адреса версии 1.9.4 (стандартный маркер Leaflet), чтобы источник был единым и стабильным. Замени: 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', на: iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', Верни ТОЛЬКО две изменённые строки. ```` **Критерий приёмки:** - В строках 186-187 нет `raw.githubusercontent.com` и `cdnjs.cloudflare.com`. - Оба URL указывают на `unpkg.com/leaflet@1.9.4`. - Карта на странице анализа отображает маркеры (визуальная проверка). --- ## A14 — `frontend/src/components/CaseForm/CaseForm.jsx` · убрать отладочный `console.log` - **Файл / строки:** `frontend/src/components/CaseForm/CaseForm.jsx:119` - **Тип:** dead code - **Сложность:** atomic **PROMPT:** ```` В React-компоненте в боевом пути отправки формы остался отладочный вывод. Удали строку: console.log("Case created:", result); Ничего кроме этой строки не меняй. Верни подтверждение и номер удалённой строки. ```` **Критерий приёмки:** - `grep -n "console.log" frontend/src/components/CaseForm/CaseForm.jsx` → пусто. - Логика отправки/навигации не изменена. --- ## A15 — `frontend/src/pages/AdminDashboard.jsx` · молча проглоченная ошибка загрузки дашборда - **Файл / строки:** `frontend/src/pages/AdminDashboard.jsx:98-121` - **Тип:** missing error handling - **Сложность:** atomic **PROMPT:** ```` В React-компоненте эффект загрузки дашборда при ошибке просто ставит dashboard=null и не показывает пользователю ничего (в отличие от загрузчика кейсов, который вызывает setError). Добавь пользовательское сообщение об ошибке через уже существующий сеттер setError. В catch-блоке эффекта загрузки дашборда, внутри `if (err.name !== 'AbortError') { ... }`, помимо `setDashboard(null);` добавь `setError(err.message || 'Ошибка загрузки дашборда');`. Ничего другого не меняй. Верни ТОЛЬКО изменённый catch/finally блок в ```jsx. Текущий код: } catch (err) { if (err.name !== 'AbortError') { setDashboard(null); } } finally { setDashboardLoading(false); } ```` **Критерий приёмки:** - При падении запроса дашборда в состоянии выставляется `error` с сообщением. - `setDashboard(null)` сохранён; `AbortError` по-прежнему игнорируется. > Проверяющему: убедиться, что `setError` в этом компоненте — общий стейт с загрузчиком кейсов и его отображение не конфликтует. Если нужно раздельное сообщение — см. B-раздел (рефакторинг стейта AdminDashboard). --- ## A16 — `frontend/src/App.js` · `isMobile` не реагирует на ресайз (опционально) - **Файл / строки:** `frontend/src/App.js:30` - **Тип:** simple refactor - **Сложность:** atomic **PROMPT:** ```` В React-приложении признак мобильного вычисляется один раз при монтировании и не обновляется при ресайзе/повороте экрана: const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || window.innerWidth < 768; Замени на React-состояние с подпиской на событие resize. Используй хуки useState и useEffect (проверь, что они импортированы из 'react'; если нет — добавь в существующий импорт). Реализация: const [isMobile, setIsMobile] = useState( () => /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || window.innerWidth < 768 ); useEffect(() => { const onResize = () => setIsMobile( /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || window.innerWidth < 768 ); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); Не меняй, как isMobile передаётся дальше. Верни изменённый фрагмент и, при необходимости, обновлённую строку импорта из 'react', в блоке ```jsx. ```` **Критерий приёмки:** - `isMobile` пересчитывается при изменении ширины окна (проверка в DevTools: сузить окно < 768 → рендерится мобильный путь без перезагрузки). - Слушатель события снимается в cleanup (нет утечки). - Импорт из `react` содержит `useState` и `useEffect`. --- # РАЗДЕЛ B — NEEDS-CONTEXT (не давать локальной модели) Требуют понимания нескольких файлов, контрактов данных, роутинга или бизнес-логики. Оставлено для сильной модели / человека. --- ## B1 — Весь `backend/services/scoring_service.py` (ядро: 7 факторов) **Тип:** исключено по правилам. Любые правки (в т.ч. неиспользуемый импорт `Optional` на строке 6) делать вручную: файл — ядро зонального скоринга, риск задеть коэффициенты. Отдельно: неиспользуемый `Optional` — тривиально, но НЕ делегировать. ## B2 — `backend/services/claude_service.py:174-181` · хрупкий парсинг JSON из ответа Claude **Тип:** missing error handling. `content.split("```json")[1]...` упадёт при ином форматировании ответа модели; `json.loads` без try/except уронит весь анализ. Нужно устойчивое извлечение JSON + graceful fallback на scoring — требует понимания контракта `AnalysisResult` и того, как `analyze_case` переключается на fallback. ## B3 — `backend/services/stats_service.py:19-39` vs `backend/routers/stats.py:19-35` · дублированная логика скоринга рекомендаций **Тип:** simple refactor, но needs-context. Две почти одинаковые реализации `score`-логики (одна в сервисе, одна прямо в роутере) с разными правилами. Свести к одной — требует решения, какая версия «правильная», и затрагивает контракт `RecommendationRequest`. ## B4 — Удаление/подключение мёртвых frontend-компонентов **Тип:** dead code, needs-context (продуктовое решение «удалить или доподключить»). Не подключены ни к одному маршруту (цепочка `index.js → App.js`): - `components/MobileForm.jsx` **и** `components/MobileForm.tsx` (дубликаты друг друга; ни один не импортируется — живая мобильная форма это `CaseForm`), `components/MobileForm.css` - `components/BehavioralProfile.tsx` + `.css` - `components/AnalysisLayout/*`, `components/MapView/*`, `components/ResultPanel/*` (MapView/ResultPanel импортируются только из неиспользуемого AnalysisLayout) - `components/admin/CasesList*`, `components/admin/CaseDetail*`, `components/admin/Statistics*`, `components/admin/HeatmapView*` — `AdminDashboard` не содержит вложенных ``, поэтому весь набор недостижим - `components/CaseForm/Step5Resources.jsx` — не импортируется в `CaseForm.jsx` (рендерятся только Step1-4 + Step2b) Решение (удалить vs довести маршрутизацию `/admin/*` и шаг ресурсов) — за человеком. Внутри этих файлов есть свои баги (напр. опечатка `isPsycotypeSelected` в MobileForm.tsx:269, захардкоженные URL `http://192.168.0.99:8000` в MobileForm.jsx:49 и HeatmapView.jsx:73, NaN-гварды в ResultPanel), но чинить их бессмысленно, пока не решён вопрос удаления. ## B5 — Рассинхрон контрактов данных между тремя поддоменами (desktop/mobile/admin) **Тип:** bug, needs-context. Одни и те же поля кодируются по-разному: - **gender:** `'мужской'/'женский'` (MobileForm.jsx) vs `'male'/'female'` (MobileForm.tsx, admin) vs `'М'/'Ж'` (Step1Child.jsx) vs `'m'/'f'` (backend `_normalize_case_data`) - **ключи психотипа:** `unfamiliar_behavior`/`leadership` (Step2bPsychotype.jsx, и так же ждёт backend `detect_psychotype`) vs `unfamiliar_env`/`group_role` (MobileForm, admin CaseDetail) — из-за этого ответы психотипа десктоп-формы не отображаются в админке - **транспорт:** `'велосипед'/'автомобиль'` (BehavioralProfile.tsx) vs `'bike'/'scooter'/'other'/'none'` (Step2Health.jsx) vs профили `'велосипед'/'самокат'` (backend scoring) Нужна единая согласованная схема через границу frontend↔backend — сознательное проектное решение. ## B6 — `frontend/src/components/CaseForm/CaseForm.jsx:90-129` · навигация через `window.location.href` **Тип:** bug, needs-context. После создания кейса используется `window.location.href = '/analysis/${id}'` — полная перезагрузка и потеря SPA-состояния, хотя приложение уже в ``. Замена на `useNavigate` затрагивает роутинг и структуру компонента. ## B7 — `frontend/src/pages/AdminDashboard.jsx:58-96` · повторная загрузка из-за `selectedCaseId` в deps **Тип:** bug, needs-context. Эффект загрузки списка зависит от `selectedCaseId` и сам же его выставляет внутри → двойной fetch при первом рендере и после сброса фильтров. Исправление требует понимания взаимодействия эффектов и стейта дашборда. ## B8 — `note` vs `notes` — рассогласование имени поля **Тип:** naming/bug, needs-context. Frontend (`AdminDashboard.jsx:215-216`, `handleSave`) и backend (`schemas.py` имеет и `note`, и `notes`; роутеры вручную маппят `note→notes`) по-разному называют одно поле. Нужно унифицировать по всей цепочке (schema + роутеры + фронт) — контрактное изменение. ## B9 — Отсутствие тестов и валидации пропсов на фронтенде **Тип:** missing test / missing PropTypes, needs-context. Под `frontend/src` нет ни одного теста; ни один `.jsx`-компонент не использует PropTypes. Расчёт зон/времени в `AnalysisResult.jsx` и многошаговая валидация `CaseForm` не покрыты. Объём — целая инициатива, не атомарная задача. ## B10 — Секреты и деплой (`.env`) **Тип:** security/deploy, исключено из делегирования. `.env` содержит плейсхолдеры `ANTHROPIC_API_KEY=your_...` и слабый `JWT_SECRET=your_jwt_secret_here_change_in_production`. Установка реального ключа Claude (для выхода из fallback) и генерация стойкого `JWT_SECRET` — ручная операция с секретами. ## B11 — Переход с Anthropic API на локальный Ollama **Тип:** архитектура / приватность, needs-context. Исключено из делегирования: затрагивает контракт `AnalysisResult` и границу «где вообще нужна LLM». **Мотив:** данные о пропавших без вести (особенно о детях) не должны покидать контур. Сейчас `backend/services/claude_service.py` ходит во внешний Anthropic API. Целевой провайдер — локальная Ollama (lxc200 на pve-node2, 192.168.0.17, уже поднята под OpenHands). **Ключевой факт для решения:** LLM НЕ считает приоритеты. Вся математика уже в `geo_service.build_search_zones` + `scoring_service.WeightedScorer` (7 взвешенных факторов + множители по возрасту/сезону/профилю) + `psychotype_service`. Метод `analyze_with_fallback` уже отдаёт полноценный `AnalysisResult` вообще без API. Единственная уникальная роль модели в hot-path — разбор неструктурированного текста донесения в поля `profiles`/`circumstances`; остальное (`behavioral_prediction`, `immediate_actions`, `summary`) — переупаковка уже отранжированного топ-5. **Отсюда два независимых рычага (не взаимоисключающие):** 1. Строгая категоризованная форма ввода (чипы/дропдауны/числа) — убирает саму НЕОБХОДИМОСТЬ в LLM, скоринг становится чистой математикой. Свободнотекстовое поле `circumstances` оставить для чтения человеком. 2. Локальная модель — сохраняет LLM, но переносит провайдера внутрь контура. Недетерминизм и риск галлюцинаций при этом НЕ исчезают. **Рекомендуемая слоёная схема:** скоринг ВСЕГДА детерминированный (то есть инвертировать текущую логику — сегодняшний fallback становится основным путём, а не аварийным); форма — способ ввода по умолчанию; Ollama — опциональный ЧЕРНОВОЙ парсер, который предзаполняет форму для подтверждения оператором, никогда не имеет решающего слова и никогда не считает приоритеты. **Объём работ:** абстрагировать провайдера за интерфейсом (сейчас класс жёстко завязан на пакет `anthropic`), вынести выбор в конфиг (`LLM_PROVIDER`, `OLLAMA_BASE_URL`, имя модели), учесть отсутствие у Ollama нативного tool-use и более слабое следование формату JSON (из-за этого B2 — устойчивое извлечение JSON — становится критичным), покрыть тестами оба провайдера. Отдельно решить судьбу `ANTHROPIC_API_KEY` в `.env` (см. B10). **Зависимости:** пересекается с B2 (парсинг JSON) и B10 (секреты). Делать после решения по B4 (мёртвая мобильная форма) — иначе неясно, какая форма ввода целевая. ## B12 — Внесение данных из ISRID (международная база инцидентов ПСР) **Тип:** данные / доменная модель, needs-context. Исключено из делегирования: напрямую затрагивает коэффициенты скоринга и схему БД. **Что это:** ISRID (International Search & Rescue Incident Database, Robert Koester, «Lost Person Behavior») — международная база инцидентов поисково-спасательных операций со статистикой поведения потерявшихся по категориям субъекта: кольцевые модели и модели рассеивания (дистанции по процентилям от точки потери), типовые места обнаружения, зависимость от рельефа и категории субъекта. **Зачем именно нам:** сейчас веса зонального скоринга (`forest 0.25`, `water 0.20`, …) и радиусы зон заданы вручную, экспертно. ISRID даёт внешние ЭМПИРИЧЕСКИЕ априорные значения для тех же величин по международной выборке — это позволяет заменить часть догадок статистикой и резко снижает объём собственных данных, нужный для калибровки. **Как стыкуется с планом по 14-летнему архиву спецдонесений:** ISRID — источник априорных значений, собственный архив — источник локальной калибровки (местная специфика: рельеф, климат, структура вызовов). Правильный порядок — сначала ISRID как базовая линия, затем поправки по своим данным. Именно эта комбинация делает переход к чисто статистической модели реалистичным. **Что нужно решить до начала:** - **Правовой статус — блокирующий вопрос.** ISRID не является свободно распространяемой базой: доступ к данным и производным моделям регулируется автором/правообладателем. Условия использования (в частности, допустимость зашивать производные коэффициенты в наш продукт) нужно выяснить ДО внесения каких-либо данных в репозиторий. Вопрос не технический. - **Единицы и категории.** Категории субъекта в ISRID (hiker, dementia, child по возрастным группам, autism, …) не совпадают один-к-одному с нашими профилями; дистанции даны в своих единицах и привязаны к типу местности. Нужны явные словари соответствия и решение, что делать с профилями без аналога. - **Редкие профили.** По РАС и эпилепсии выборки малы даже в ISRID — там сохранить экспертные коэффициенты и помечать их как «не выведенные из данных». - **Куда кладём.** Отдельный версионированный справочник априорных значений с указанием источника на каждую величину, а не правка констант в коде: нужно всегда понимать, какое число откуда взялось (ISRID / свой архив / эксперт). **Жёсткое правило:** ни ISRID, ни архив не дают права автоматически перезаписывать формулу весов — изменение коэффициентов проходит через ревью человека (см. B1 и «Жёсткие исключения» в начале файла). ## B13 — Структурирование «Вектора» в систему подсистем (видение-каркас) **Тип:** архитектура / реструктуризация, needs-context. Исключено из делегирования локальной модели: затрагивает контракты данных, схему БД, бизнес-логику SAR. **Цель:** ВЕКТОР — не один монолит, а система относительно независимых подсистем с чистыми границами. Упрощает разработку, тестирование и будущее подключение ATAK/Meshtastic. **Подсистемы:** ``` ВЕКТОР │ ├── Core │ ├── модель поиска │ ├── расчёт вероятностей │ ├── ISRID │ ├── Ring Model / dispersion │ ├── категории пропавшего │ └── временная динамика │ ├── GIS │ ├── OpenStreetMap │ ├── DEM / рельеф │ ├── гидрография │ ├── лес / болота / поля │ ├── дороги / тропы │ └── здания │ ├── Search Area │ ├── исходная точка │ ├── вероятностная поверхность │ ├── зоны поиска │ ├── маршруты │ └── исключённые территории │ ├── Field Data │ ├── GPS групп │ ├── обнаружения │ ├── следы │ ├── свидетельства │ ├── проверенные участки │ └── результаты поиска │ ├── Real-time │ ├── ATAK │ ├── Meshtastic │ ├── треки групп │ ├── события │ └── перерасчёт модели │ ├── Backend │ ├── API │ ├── БД │ ├── очереди задач │ └── авторизация │ └── UI ├── карта ├── карточка поиска ├── вероятностная карта ├── группы └── статистика ``` **Ключевые принципы (добавлены к изначальному скелету B13):** 1. **Развязка движка.** Search Engine — чистая функция: исходные данные + данные поиска + параметры человека → `(ProbabilitySurface, SearchAreas, Recommendations)`. Он ничего не знает об ATAK, Meshtastic, веб-интерфейсе, БД, auth. Доставка (web/ATAK/Mesh) — отдельный слой, подписанный на результат. 2. **Четыре слоя данных, не смешиваются:** - **Слой 1 — исходные (неизменяемые):** OSM, DEM, гидрография, лес, дороги, здания, ISRID, исторические поиски. - **Слой 2 — конкретный поиск:** исходная точка, время исчезновения, категория, возраст/пол/состояние, известный маршрут, погодные условия, доп. факторы. - **Слой 3 — оперативные (во время операции):** Position, Observation, Track, Clue, Team, AreaChecked, WitnessReport, FoundPerson. - **Слой 4 — результат модели:** ProbabilitySurface, SearchZone, Priority, Confidence, RecommendedRoute. **Результат не смешивается с исходными данными.** 3. **Итеративная модель.** Не «Вектор один раз нарисовал круг», а постоянный пересчёт наиболее вероятного положения по мере поступления полевых данных: расчёт → поисковые группы → найдено/не найдено → новые данные → новый расчёт. Сюда же естественно ложатся ATAK + Meshtastic. 4. **Очередь задач** для тяжёлых расчётов (terrain analysis, probability calculation, routing, recalculation) — сейчас `analyze_case` синхронно ждёт Overpass + Claude. **Технический контур:** ``` PostgreSQL │ ├── PostGIS │ └── данные поисков ▲ │ ┌──────┴────────┐ │ Vector Backend │ ├────────────────┤ │ Search Engine │ │ GIS Engine │ │ ISRID Engine │ │ Routing │ │ Event Engine │ └──────┬─────────┘ │ ├── Web UI ├── ATAK └── Meshtastic ``` **Зависимости / блокеры:** - **ISRID (B12) — внешний правовой блокер.** Итеративная модель (B18) строится на экспертных коэффициентах сейчас; ISRID подключается позже как источник априорных, когда вопрос правового статуса решён. - **B1 (ядро скоринга) — коэффициенты под ревью человека.** Экстракция движка (B14) их НЕ меняет; любая калибровка — отдельно через B1. - **Реал-тайм (B19) — фактически отдельный проект**, оформляется после ядра и Field Data. **Декомпозиция в фазы:** B14 → B15 → (B16, B17 параллельно) → B18 → B19. См. ниже. --- ## B14 — Чистый SearchEngine (поведение-сохраняющий) **Тип:** refactor / архитектура, needs-context. Фундамент всей развязки (B13-принцип 1). Низкий риск: поведение НЕ меняется, коэффициенты НЕ трогаются (B1-safe). **Что:** Вынести расчёт поисковой модели из `backend/routers/analyze.py` в отдельный модуль `services/search_engine.py` с чистой границей: - `SearchInput` (pydantic): age, gender, terrain, weather, elapsed_hours, season, diagnosis_type, has_transport, psychotype_answers, profiles, lat/lon, loss_time, … — то, что сегодня собирается в `analyze_case` из payload + `db.get_case`. - `SearchModel` (результат): max_distance_km, coefficients, time_of_day, psychotype (+modifiers +recommendations), weights, distance_multiplier, active_profiles, critical_warnings, urgency, primary_zones, search_radius_km, key_locations, behavioral_prediction, immediate_actions, summary, fallback_used. - `build_search_model(input: SearchInput) -> SearchModel` — чистая функция, БЕЗ DB / auth / HTTP. Внутри вызывает `calculate_max_distance`, `claude_analyze`, `WeightedScorer`, `detect_psychotype` (вся логика из `analyze_case` переезжает сюда). - `analyze.py::analyze_case` становится тонкой обёрткой: собрать `SearchInput` из payload + `db.get_case` → `build_search_model` → записать `analysis_log` → вернуть. Вся SAR-математика — в `search_engine.py`. **Границы (НЕ делать в B14):** - НЕ менять коэффициенты (`distance_service`, `scoring_service`) — это B1. - НЕ менять схему БД / миграции. - НЕ добавлять Field Data / итеративность / PostGIS — это B15–B18. - НЕ менять внешний контракт ответа `/analyze` (фронт B5 от него зависит) — состав полей тот же. **Критерий приёмки:** - `analyze.py` не содержит расчётной логики (только сборка `SearchInput` + запись БД + возврат). - Поведение `/analyze` идентично: bike 8yo 2h лес день → max_distance 5.4; РАС+bike → профили [РАС, велосипед]; psychotype-answers → detect. Регресс-кейсы из B5 проходят. - `PYTHONPATH=/app python -m pytest backend/tests -q` — зелёный; добавлены юнит-тесты на `build_search_model` (моки на `claude_analyze`). --- ## B15 — Четыре слоя данных: схема + миграция **Тип:** schema / migration, needs-context. Исключено из делегирования: миграции БД. **Что:** Ввести слоистую модель данных (B13-принцип 2) через Alembic-миграцию: - **Слой 2 (поиск):** `cases` — уже есть; оставить как карточку поиска (исходная точка, время, категория, параметры человека), вынеся поля других слоёв. - **Слой 4 (результат):** `analysis_log` → явная таблица `search_models` (case_id, version, input_snapshot, model_json, created_at) с **версионированием** (несколько версий на случай — фундамент для B18). - **Слой 3 (оперативные):** НОВЫЕ таблицы `field_observations` (position/observation/track/clue/witness), `search_teams`, `areas_checked`, `found_events` — пока схема + пустые + модели; ингестия в B17. - **Слой 1 (исходные):** версионированный справочник `reference_priors` (source, category, value, units, provenance) — заглушка под B12 (ISRID) и экспертные значения; данные вносятся позже, сейчас структура. - Бэкфилл существующих `cases`/`analysis_log` в новую схему без потери данных. - API `/analyze` и `/cases` продолжают работать (адаптер поверх новой схемы). **Границы:** НЕ заполнять `reference_priors` (B12 заблокирован). НЕ реализовывать ингестию полевых данных (B17). Коэффициенты не трогать. **Критерий приёмки:** - `alembic upgrade head` проходит чисто на пустой и на текущей БД; даун-миграция работает. - Существующие кейсы и `analysis_log` доступны после миграции (бэкфилл проверен на тестовом наборе). - `/analyze` и `/cases` отвечают тем же контрактом (фронт B5 не сломался). - `pytest backend/tests` — зелёный. --- ## B16 — GIS: PostGIS + локальный OSM (замена живого Overpass) **Тип:** infra / architecture, needs-context. Крупная инфра-задача. **Мотив:** `services/geo_service.py` дёргает живой Overpass API (8 направлений × 4 дистанции = 32 запроса на анализ) — медленно и ненадёжно: при недоступности Overpass зоны получают одинаковые нейтральные оценки (см. тест B5 — все зоны с одинаковым скором). Локальный OSM в PostGIS даёт быстрые пространственные запросы, рельеф (DEM), гидрографию — фундамент для качественной вероятностной поверхности. **Что:** - Подключить PostGIS к `vector-postgres` (расширение `postgis`, docker-compose). - Импорт OSM-экстракта региона (Беларусь / округа) через `osm2pgsql` в таблицы PostGIS (roads, water, forest/wood, buildings, landuse). - Переписать `geo_service.build_search_zones` на PostGIS-запросы (`ST_DWithin`, `ST_Intersects`, длина дорог в буфере, расстояние до ближайших water/settlement/forest) вместо Overpass. - Опционально DEM / рельеф (уклон, проходимость) — можно вынести в B16b. - Сохранить контракт `Zone` (forest_pct, road_density, water_distance_km, settlement_distance_km), чтобы `scoring_service` не менялся. **Границы:** Коэффициенты скоринга не трогать. DEM — опционально. ISRID — отдельно (B12). **Критерий приёмки:** - `vector-postgres` имеет PostGIS; OSM-данные загружены. - `build_search_zones(53.9, 27.56, case, 1.08)` отдаёт зоны с РЕАЛЬНЫМИ характеристиками (road_density/water/forest различаются между направлениями) без обращений во внешний интернет. - Время анализа < 1с (без Overpass-латентности и без Claude fallback). - `test_geo_service` адаптированы и зелёные. --- ## B17 — Field Data: модель + ингестия операционных данных **Тип:** new subsystem, needs-context. Зависит от B15 (оперативные таблицы). **Что:** Реализовать слой 3 (B13-принцип 2) — приём и хранение операционных данных во время поиска: - Модели/таблицы: `field_observations` (тип: position/observation/track/clue/witness, geom, timestamp, team_id, confidence, raw), `search_teams` (id, name, members, role), `areas_checked` (geom-полигон, team_id, checked_at, result), `found_events`. - API приёма: `POST /api/v1/cases/{id}/observations`, `…/teams`, `…/areas_checked`; `GET` для списка/карты. Auth: operator/field/admin. - Валидация (тип наблюдения, геом в разумных пределах, таймстемп). - События новых наблюдений — источник для B18 (перерасчёт). **Границы:** НЕ реализовывать сам пересчёт (B18). НЕ подключать ATAK/Meshtastic (B19) — только API-ингестия. Коэффициенты не трогать. **Критерий приёмки:** - Создание/чтение наблюдений/групп/проверенных участков через API работает (тесты). - Геометрии в PostGIS (если B16 готов) или в JSONB-заглушке (оговорить, если B16 ещё нет). - Событие нового наблюдения попадает в очередь/лог для B18. --- ## B18 — Итеративный пересчёт модели + очередь задач **Тип:** architecture / core value, needs-context. Зависит от B14 (движок), B15 (версионирование), B17 (источник событий). Самая ценная фаза (B13-принцип 3). **Что:** - Job-queue (arq / Celery / RQ — выбрать) для тяжёлых расчётов: probability, routing, recalculation. Сейчас `analyze_case` синхронный — не масштабируется на итерации. - Event-driven пересчёт: поступление новых полевых данных (B17) → задача «пересчитать модель для case_id» → `build_search_model` с учётом оперативных данных (исключённые территории из `areas_checked`, уточнения из `observations`/`clues`) → новая версия `search_models` (B15). - Версионирование: история моделей на случай; UI/ATAK показывают актуальную. - Учёт «найдено/не найдено» — подтверждение/опровержение наблюдений корректирует апостериорную поверхность. - Работает на экспертных коэффициентах; ISRID (B12) подключается как источник априорных, когда разблокируется. **Границы:** Коэффициенты — B1 (ревью). ATAK/Mesh — B19. Без ISRID-данных пока (экспертные априоры). **Критерий приёмки:** - Поступление observation/area_checked ставит задачу пересчёта; новая версия модели сохраняется. - Тяжёлый расчёт не блокирует HTTP-запрос (async через очередь). - История версий модели доступна; можно откатиться/сравнить. - Демонстрация: кейс → отметка «сектор N проверен, никого нет» → пересчёт снижает приоритет N, поднимает соседние. --- ## B19 — «Контур» — отдельный полевой проект (взаимодействует с Вектором) **Тип:** отдельный проект, **не фаза Вектора**. Вектор-сторона взаимодействия = B17 + B18 + контракт обмена. Сам Контур (ATAK/Meshtastic/GPS) — собственная спецификация и репозиторий, здесь не детализируется. **Разделение ролей:** - **Вектор** — «где искать?»: вероятностное моделирование, приоритетные зоны, перерасчёт по новым данным. - **Контур** — «как эффективно выполнять поиск в поле?»: полевая координация и ситуационная осведомлённость. - Проекты **независимы**: Вектор работает без Контура (подготовка/расчёт/планирование), Контур — без Вектора (обычный поиск, ручное распределение, GPS-трекинг, работа без связи с сервером). **Компоненты Контура:** - **ATAK** — ситуационная карта, визуализация, GPS-треки, поисковые зоны, маркеры, координация участников, геоданные. - **Meshtastic** — автономный LoRa-mesh-канал: координаты, короткие сообщения, полевые события; отказоустойчивая связь при отсутствии инфраструктуры. - **GPS** — положение групп, кинологов, БПЛА, ресурсов. **Возможности Контура:** объективный контроль прохождения зон (покрытие вместо «вроде прошли»), передача обнаружений (след/предмет/место/препятствие), фиксация препятствий (болота/завалы/водные преграды/склоны/закрытые территории), координация ресурсов в реальном времени (ближайший кинолог к следу, БПЛА к участку). **Замкнутый цикл:** ``` ВЕКТОР → зоны/приоритеты/маршруты/задачи → КОНТУР → выполнение в поле → наблюдения (треки/обнаружения/препятствия/покрытие) → ВЕКТОР → перерасчёт → обновлённые задачи → КОНТУР ``` **Контракт обмена (граница проектов):** - **Вектор → Контур:** поисковые зоны, приоритеты, вероятностные значения, рекомендуемые маршруты, задачи для ресурсов, направления поиска. - **Контур → Вектор:** GPS-треки, обследованные территории, обнаруженные следы/предметы, препятствия, новые наблюдения, данные кинологов/БПЛА. **Развязка:** движок Вектора не знает протоколов ATAK/Meshtastic — он читает/пишет только контракт; адаптеры Контура транслируют. Конкретизация B13-принципа 1. **Что в репо Вектора:** B17 (приём полевых данных, Контур→Вектор), B18 (перерасчёт + выдача зон, Вектор→Контур), и отдельная задача **«Контракт обмена»** — версионированный формат зон/приоритетов/наблюдений на границе, без зависимости от ATAK/Mesh. **Что НЕ в репо Вектора:** ATAK-плагины, Meshtastic-firmware/протокол, полевой UI Контура — отдельный проект. --- ## B20 — Контракт обмена Вектор ↔ Контур (граница проектов) **Тип:** spec / architecture, needs-context. Фундамент развязки (B13-принцип 1): единый версионированный формат данных на границе, без зависимости от ATAK/Meshtastic. От него зависят B17 (ингестия) и B18 (выдача зон) — они реализуются поверх этого контракта. > Решение Виктора (2026-09-09): LLM убран из проекта полностью. Zones/ > probability для outbound отдаёт детерминированный движок (rules_analysis + > scoring + гео), без Claude/Anthropic. Нарративные поля /analyze — правила. **Что:** Зафиксировать контракт обмена между Вектором и Контуром — два направления, оба версионированные (`schema_version`), протокольно-агностичные. Транспорт (ATAK CoT / Meshtastic / HTTP / websocket) — дело адаптеров Контура, не Вектора; движок работает только с форматом. **Вектор → Контур (outbound):** - `SearchZones[]`: `{zone_id, geom (Polygon GeoJSON), priority (1..N), probability (0..1), reasoning, recommended_resources[]}` - `RecommendedRoutes[]`: `{route_id, geom (LineString), for_role, estimated_time}` - `ResourceTasks[]`: `{task_id, target_zone_id, resource_type (team|dog|uav), instruction}` - `SearchModelMeta`: `{case_id, model_version, generated_at, valid_until, max_distance_km}` - Событие: «новая версия модели доступна». **Контур → Вектор (inbound):** - `Positions` (поток): `{team_id, geom (Point), timestamp, accuracy}` - `Tracks`: `{team_id, geom (LineString), from, to}` - `AreasChecked`: `{area_id, geom (Polygon), team_id, checked_at, result (clear|found|partial), coverage_pct}` - `Observations`: `{obs_id, type (clue|item|place|obstacle|witness), geom, timestamp, team_id, confidence, description, media_ref?}` - `FoundEvent`: `{case_id, geom, timestamp, condition}` - Событие: «новое наблюдение» / «зона проверена» (триггерит перерасчёт B18). **Реализация:** Pydantic-схемы в `backend/schemas/contract.py`, сериализация GeoJSON, `schema_version` на каждом сообщении. Вектор отдаёт outbound через endpoint (SSE / websocket / poll — выбрать) и принимает inbound через endpoints B17. `build_search_model` (B14) отдаёт результат в формате outbound-контракта — в коде движка ни одного упоминания ATAK/Mesh. **Границы:** НЕ реализовывать ATAK-плагины / Meshtastic-протокол (это Контур, отдельный репо). НЕ менять коэффициенты (B1). НЕ привязывать к конкретному транспортному протоколу — контракт = формат данных, транспорт = адаптер. **Критерий приёмки:** - Pydantic-схемы обоих направлений определены, валидируются (round-trip: объект → JSON → объект). - `build_search_model` (B14) отдаёт результат в формате outbound-контракта (SearchZones + meta); grep по `services/search_engine.py` не находит «ATAK»/«Meshtastic»/«CoT». - B17 endpoints приёма принимают inbound-контракт и складывают в Field Data таблицы (B15). - Человекочитаемая документация контракта (markdown или OpenAPI-схема) — для разработчиков Контура. - Тесты: round-trip сериализация + валидация кейсов (clue / obstacle / area_checked / zone). --- # Сводка | Категория | Кол-во | Куда | |---|---|---| | Atomic (backend) | A1–A9 | локальной LLM | | Atomic (frontend) | A10–A16 | локальной LLM | | Needs-context | B1–B12 | сильной модели / человеку | | Архитектура / рефакторинг | B13 (каркас), B14 (SearchEngine), B15 (слои данных), B16 (GIS/PostGIS), B17 (Field Data), B18 (итеративность + queue), B20 (контракт Вектор↔Контур) | сильной модели / человеку | | Отдельный проект | B19 «Контур» (ATAK+Meshtastic, полевая система) | отдельный репо/спецификация | **Рекомендуемый порядок:** B14 (чистый движок) → B15 (слои данных) → **B20 (контракт Вектор↔Контур)** → B16 (GIS) и B17 (Field Data) параллельно → B18 (итеративность + queue). B12 (ISRID) разблокируется внешне (правовой статус) и подключается как источник априорных к B18. B19 «Контур» — отдельный проект (свой репо); со стороны Вектора: B17+B18+B20. Коэффициенты (B1) — под ревью человека на каждом шаге. --- ## B21 — Мультипоиск и многопользовательский доступ (масштаб на республику) > Статус: ПЛАН на ревью Виктора. Основа — паттерн админки РВС > (rbac-admin-schema-pattern: RBAC + сессии + аудит) и домен КОНТУРа > (SEARCH_OPERATION + OPERATION_MEMBERSHIP). Написан 2026-09-09. ### 1. Контекст и требование ВЕКТОР как программное средство на всю республику МЧС: - **Несколько поисков одновременно** — сегодня API работает с произвольным case_id, но UI предполагает «текущий поиск»: дашборд, карта, анализ привязаны к одному кейсу без понятия «операция». - **Много пользователей** — каждое подразделение (ОУМЧС / Г(Р)ОЧС) = аккаунты; РЦУ РЧС видит все поиски, подразделения — свои. - **КОНТУР подключается к каждому поиску** — outbound-контракт B20 операционно: зоны по конкретной операции, не «в абстракт». Основа: админка РВС (RBAC 4 роли × 9 прав, server-side sessions, audit 90 дней, password policy) — паттерн проверен в проде РВС. ### 2. Модель данных (новые таблицы + алиасы) **2.1 Поисковая операция (мультипоиск)** ``` search_operations: id uuid PK title text -- «Поиск: Иванов П., Минский р-н» case_id fk → cases -- карточка (матмодель живёт в cases) status enum(planned, active, paused, completed, archived) unit_id fk → mchs_units -- ответственное подразделение (ОУМЧС/Г(Р)ОЧС) contour_operation_id uuid null -- заполняется при первой отправке зон в КОНТУР created_by fk → users created_at / updated_at / closed_at ``` Решение по «case vs operation»: НЕ дублируем SEARCH_OPERATION из КОНТУРа — `cases` остаётся карточкой+расчётом (B15-слои на месте), `search_operations` — надстройка со статусом/владением/участниками. КОНТУР-маппинг: поле `contour_operation_id`, заполняется при первой отправке зон. **2.2 Пользователи и подразделения (RBAC по образцу РВС)** ``` mchs_units: -- справочник подразделений МЧС id uuid PK, name, region (область), kind (rcu|oblast|gor_rayon), parent_id null -- РЦУ РЧС (kind=rcu) → ОУМЧС (oblast) → Г(Р)ОЧС (gor_rayon) users (расширение существующей): + unit_id fk → mchs_units, null -- подразделение (у РЦУ РЧС-admin = null) + position text + status enum(active, locked, disabled) + failed_login_count, locked_until, must_change_password + created_by, created_at, updated_at roles / user_roles / role_permissions / permissions -- как в РВС sessions (server-side, token, ip, ua, expires_at, idle_timeout) auth_events (login/logout/lockout, indexed DESC) audit_events + audit_changes (field-level old→new, immutable, retention 90d) security_settings (password policy, lockout, timeouts) ``` Роли (стартовые, is_system): - `admin` — РЦУ РЧС: все права, все операции - `coordinator` — ОУМЧС: все поиски своей области, управление пользователями подчинённых Г(Р)ОЧС - `operator` — Г(Р)ОЧС: свои поиски (create/update) - `observer` — только просмотр (старшие смены, аналитика) **2.2.1 Скоуп данных (отличие от РВС)** К функциональным правам РВС добавляется **географический скоуп**: какие поиски видны. Фильтр по `unit_id ∈ {своё подразделение + подчинённые}` (дерево mchs_units); admin (РЦУ РЧС) — без фильтра. Тот же скоуп в cases/analyze/water. ### 3. Мультипоиск в UI - **Дашборд «Активные поиски»** (главная после логина): карточки активных операций (title, подразделение, elapsed, статус), фильтры по статусу/области, счётчики. Клик → рабочее пространство операции (`/op/{id}/…`). - **Создание поиска** = CaseForm + шаг «операция» (title, подразделение — по умолчанию подразделение создателя). - **Завершение**: статус completed + поля исхода (переезд из cases). ### 4. КОНТУР-интеграция (мультипоиск) - Импорт зон готов на КОНТУРе (этап 9): operation_id задаётся параметром импорта; ВЕКТОР хранит contour_operation_id и передаёт при отправке. - Отправка зон: кнопка/авто при новой версии модели → POST /api/v1/vector/zones. - Обратный поток: GET /api/v1/vector/areas-checked — «проверенные квадраты» на карте ВЕКТОРа. - Межсервисный токен — в secrets, не в репо. ### 5. Этапы внедрения | # | Этап | Состав | Зависимости | |---|------|--------|-------------| | E1 | RBAC-ядро | mchs_units, расширение users, роли/права, сессии, auth-журнал, seed; логин по пользователям, /me, смена пароля | паттерн РВС | | E2 | Скоуп и аудит | гео-скоуп в эндпоинтах, audit_events, security_settings, админ-панель пользователей (как РВС) | E1 | | E3 | Операции | search_operations + статусная машина, дашборд активных поисков, переключение | E1 | | E4 | КОНТУР-мультипоиск | contour_operation_id, отправка зон, areas-checked на карте | E3 | | E5 | B17 (лёгкий) | ингестия field_observations от КОНТУРа (tracks/areas_checked) | E4 | ### 6. Границы (НЕ делать сейчас) - Саморегистрация подразделений — аккаунты создаёт РЦУ РЧС/ОУМЧС. - SSO/ActiveDirectory — после пилота. - B18/B19 — отдельные задачи. - WebSockets для списка поисков — polling на старте. ### 7. Критерии приёмки (сводно) - Два одновременных активных поиска у двух подразделений: РЦУ РЧС видит оба, Г(Р)ОЧС — только свой; переключение без потери контекста. - Вход через server-side сессии; аудит фиксирует входы и админ-действия, ретенция 90 дней. - Зоны операции уезжают в КОНТУР и видны в штаб-панели CT130:8080 под своим operation_id; вторая операция не смешивается. --- ## B24 — Мёртвые веса скоринга: анализ зафиксирован, данные решают (2026-09-25) **Тип:** анализ завершён, РЕШЕНИЕ ЗАФИКСИРОВАНО. НЕ чистить веса «по чутью» — оживлять данными. ### Диагностика (проверено прогонами, A/B-тест) `BASE_WEIGHTS` (services/scoring_service.py, методика §9) содержит 7 факторов. Реально на ранжирование зон влияют только 5: | Фактор | Базовый вес | Подставляется в zone_dict? | Реальный вклад | |--------|-------------|---------------------------|----------------| | forest | 0.25 | да (forest_pct из OSM) | 29.4% | | water | 0.20 | да (water_distance_km из OSM) | 23.5% | | roads | 0.18 | да (road_density из OSM) | 21.2% | | settlement | 0.15 | да (settlement_distance_km) | 17.6% | | historical | 0.12 | **НЕТ** — всегда дефолт 0.5 | 0% (константа) | | direction | 0.07 | да (direction_match при last_seen_direction) | 8.2% | | shelter | 0.03 | **НЕТ** — всегда дефолт 0.3 | 0% (константа) | Третья разновидность мёртвого — **railway**: вес добавляется динамически (профиль РАС, ×2.5, `apply_profile` → `weights['railway'] = 0.05`), но `railway_distance_km` никто не вычисляет → вклад в score всегда 0. При этом critical_warning РАС требует «немедленно проверить ВСЕ ж/д пути». **A/B-прогон подтверждает**: приоритеты 8 секторов с dead-весами и без них попарно идентичны (константа historical 0.5 / shelter 0.3 добавляет всем зонам одинаковую добавку, на разницу оценок не влияет). ### РЕШЕНИЕ (фиксируем) 1. **НЕ удалять веса из BASE_WEIGHTS.** Методика §9 согласована с руководством; историческая частота — осмысленный фактор, у него нет ДАННЫХ, а не смысла. Правка весов руками = калибровка «по чутью», запрещена политикой («коэффициенты меняются только по явному решению руководства»). 2. **Оживать по мере появления данных:** - **railway_distance_km** — можно сразу: railway=rail уже есть в локальном OSM (planet_osm_line, water-эндпоинт выгружает ж/д объекты). Нужна функция «расстояние до ближайшего ж/д пути в секторе» в osm_local (аналог settlement/water-кандидатов) + подстановка в zones_dict в rules_analysis. Тогда railway-фактор для РАС заработает, как обещает critical_warning. - **shelter_pct** — из локального OSM (доля укрытий в секторе: лес + отдельные объекты natural=scrub/tree/wetland). Требует выбора методики «укрытие» — согласовать с руководством, потом код. - **historical_freq** — ТОЛЬКО после набора архива «прогноз vs факт» (сейчас 2 записи — данных нет, выдумывать нельзя). Когда закрытых поисков станет десятки: посчитать места находок вокруг ТНП по секторам и подавать в скоринг. Это ЗАКРЫВАЕТ калибровку settlement_score из «в планах». 3. **Порядок работы (предложение):** a) railway из OSM (данные есть, ~1 день) — оживает critical_warning РАС; b) копить архив закрытых кейсов (каждый поиск — точка); c) historical_freq по архиву + калибровка весов по фактам (решение руководства); d) shelter — по согласованной методике. 4. **Тест-страж:** при любых правках скоринга сверять контрольные точки матрасчёта (README §7) и прогонять A/B (ранжирование до/после) — приоритеты не должны меняться «молча». **Не делать:** подстановку «правдоподобных» констант, включение weights без источника данных, изменение BASE_WEIGHTS без решения руководства.