diff --git a/vector_tasks.md b/vector_tasks.md new file mode 100644 index 0000000..7eb4315 --- /dev/null +++ b/vector_tasks.md @@ -0,0 +1,638 @@ +# ВЕКТОР — очередь технического долга для делегирования + +**Проект:** ВЕКТОР (Система определения приоритетных направлений поиска, SAR/МЧС) +**Стек:** FastAPI + PostgreSQL 16 + React (CRA), docker-compose +**Расположение:** LXC 108 (`Lab`, 192.168.0.108), путь `/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 + +> Канон (сверено с `docker-compose.yml` и реальным LXC): адрес **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»). Заголовок вводит в заблуждение и конфликтует с динамическим индикатором. Убери жёсткий номер шага из заголовка, оставив только название раздела. + +Замени строку: +