Files
vector/vector_tasks.md
T
root d6e6277d85 B20: контракт обмена Вектор↔Контур (граница проектов)
Версионированный протокольно-агностичный формат на границе: outbound
(SearchZones/RecommendedRoutes/ResourceTasks/SearchModelMeta) и inbound
(Positions/Tracks/AreasChecked/Observations/FoundEvent). Движок работает
только с форматом, транспорт — адаптеры Контура. От B20 зависят B17 и B18.
Рекоменд.порядок: B14→B15→B20→(B16,B17)→B18.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:53:06 +00:00

951 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ВЕКТОР — очередь технического долга для делегирования
**Проект:** ВЕКТОР (Система определения приоритетных направлений поиска, 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]. Но на вход приходит значение из <input type="datetime-local"> в формате "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»). Заголовок вводит в заблуждение и конфликтует с динамическим индикатором. Убери жёсткий номер шага из заголовка, оставив только название раздела.
Замени строку:
<h2>Шаг 5: Среда и местность</h2>
на:
<h2>Среда и местность</h2>
Верни ТОЛЬКО изменённую строку.
````
**Критерий приёмки:**
- Строка 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` не содержит вложенных `<Routes>`, поэтому весь набор недостижим
- `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-состояния, хотя приложение уже в `<Router>`. Замена на `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 (выдача зон) — они реализуются поверх этого контракта.
**Что:** Зафиксировать контракт обмена между Вектором и Контуром — два направления, оба версионированные (`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) — под ревью человека на каждом шаге.