- README: верхняя граница задержки settle, шаг 5 «Полного круга»; критерии ошибочного файла (корень не объект) - CLAUDE.md: по одному json на каждый распознанный документ; критерии ошибочного файла - design.md: D-5 — оговорка про нестроковый error.code отдельным предложением; №79 — источник указания, SQL уборки в порядке FK - open-questions-proposal.md п.20: замены ../docs/dev/README.md (185–187, 224–226, 252–253) и комментарий docker-compose.yml:184 - план: Task 6 — пометка про замену down -v (R-27) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
218 lines
68 KiB
Markdown
218 lines
68 KiB
Markdown
# Design: Срез 0 — скелет scan-gateway
|
||
|
||
## Context
|
||
|
||
См. `proposal.md`. Исходное состояние — копия каркаса `arm-core` (`a9159aa`). Протокол ИС Сканирование (FNR02,
|
||
`reference-old-arm/api/src/core/PDFWorker`): файл с префиксом UUID кладётся во входящий каталог SMB-шары, ответ появляется
|
||
в исходящем каталоге в подкаталоге с тем же UUID, формат ответа — Content AI (`file_uid`, `error`, `data_ocr` — объект, OQ-77), по одному json на распознанный документ, таймаут
|
||
5 мин без повторов (ТЗ 3, OQ-15). Старый код держал задания в in-memory кэше и опрашивал шару двумя поллерами в одном
|
||
процессе с ядром — это и есть то, от чего уходит DECISION-4. Платформа: Python 3.11, NATS без JetStream, Nexus недоступен
|
||
до прода, CI в Gitea нет.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:** конвейер «NATS → ack → S3 → горячая папка → ответ → S3 → NATS» работает против эмулятора в compose и в
|
||
unit-тестах без Docker; таймаут и ошибки ИС всегда превращаются в результат; сырой ответ сохраняется для разбора инцидентов.
|
||
**Non-Goals:** см. proposal.
|
||
|
||
## Decisions
|
||
|
||
### D-1. Горячие папки — протокол с двумя реализациями, имена каталогов настраиваемы
|
||
`HotFolder.put(name, data)`, `poll(job_id) -> list[AnswerFile] | None` (`AnswerFile(name, data)`), `healthy() -> bool`.
|
||
`poll` возвращает `None`, пока ответ не готов, и все `*.json` каталога `<output>/<job_id>/` по возрастанию имени, когда
|
||
готов (OQ-78: ИС кладёт все распознанные документы задания одновременно, по одному json на документ; другие файлы
|
||
каталога игнорируются). Готовность — по стабилизации: есть хотя бы один `*.json`, а набор имён и размеров `*.json` не
|
||
менялся `SCAN_SETTLE_SECONDS` (2) — защита от недописанного набора. `LocalHotFolder(root)` работает с
|
||
обычным каталогом (dev, compose-том `hotfolder`, unit-тесты через `tmp_path`); `SmbHotFolder(host, path, login, password)`
|
||
— через `smbclient` шаблона (UNC-пути). Подкаталоги `SCAN_INPUT_DIR`/`SCAN_OUTPUT_DIR` по умолчанию `Input`/`Output`
|
||
(как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Временный
|
||
файл атомарной записи (`.part`) лежит не в `Input`, а в служебном каталоге `SCAN_TMP_DIR` (`Tmp`) рядом с `Input`/`Output`
|
||
на той же шаре/ФС, чтобы `rename` в `Input` оставался атомарным, а ИС без маски не забрала недописанный файл (OQ-79);
|
||
каталог создаётся при первой записи. Режим — `SCAN_HOTFOLDER_MODE = local | smb`.
|
||
|
||
### D-2. Без реестра заданий на диске: состояние — в процессе и в горячей папке, ack — сразу
|
||
NATS без JetStream: сообщение доставляется один раз, повторы делает ядро, не получив ack за 30 с. Поэтому по получении
|
||
запроса шлюз сразу публикует ack `processing` и держит process-local множество `job_id` «в работе/обработано» (LRU,
|
||
`DONE_CACHE_SIZE=1000`): повторный запрос (ядро не дождалось ack) → повторный ack, второй файл в ИС не уходит. Падение
|
||
инстанции после ack → ядро закроет задание по `reply_deadline` (`timeout`); повторной отправки в ИС не будет (OQ-15,
|
||
0 повторов). *Альтернатива* — хранить задания в Redis/файле: лишняя инфраструктура ради ситуации, которую ядро уже обрабатывает.
|
||
|
||
### D-3. Параллелизм — `SCAN_MAX_INFLIGHT` задач, каждая ждёт свой ответ
|
||
Подписка `queue="scan-gateway"`; `on_request` валидирует, публикует ack и кладёт задание во внутреннюю очередь; семафор
|
||
`SCAN_MAX_INFLIGHT` (4) ограничивает одновременные обработки; ожидание — опрос `poll(job_id)` каждые `SCAN_POLL_SECONDS`
|
||
(5) до `deadline = min(request.reply_deadline, start + SCAN_TIMEOUT_SECONDS)`; ответ принимается, когда `poll` вернул
|
||
готовый набор файлов (settle, D-1). При таймауте (в т.ч. если набор так и не стабилизировался) — результат `TIMEOUT`,
|
||
файл во входящем каталоге не удаляется (ИС может ответить позже; «поздний» ответ никто не опрашивает; уборка каталога —
|
||
задача эксплуатации, вне среза). Очередь заданий, ждущих слот, не ограничена — ядро не публикует больше, чем `queued` заданий.
|
||
|
||
### D-4. Сырой ответ — склейка всех файлов, в S3 `raw/{job_id}.json`, в конверте `raw_s3_key`
|
||
По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов (контракт v1.0
|
||
не меняется: `raw_s3_key` — по-прежнему одна строка `raw/{job_id}.json`). Объект — **склеенный** ответ ИС:
|
||
`{"job_id": "<job_id>", "files": [{"name": "<имя файла>", "response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}`,
|
||
`files` — все `*.json` каталога ответа по возрастанию имени. Каждый файл разбирается `json.loads` независимо: не JSON →
|
||
`response = null`, `parse_error` заполнен (задание не падает и файл не перечитывается — к моменту склейки набор уже
|
||
стабилен). Сохраняется **до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3.
|
||
|
||
### D-5. Нормализатор — интерфейс; в Срезе 0 сводит ошибки по файлам и отвечает `NOT_IMPLEMENTED`
|
||
`Normalizer.normalize(raw: object) -> NormalizeOutcome(status, documents, error, metrics, warnings)` (`raw` — склеенный ответ,
|
||
разобранная структура D-4; JSON каждого файла разобран при склейке, не-объект на входе -> `INVALID_FORMAT`).
|
||
`NullNormalizer` относит файл к «ошибочным», если `parse_error` не `null` либо `response.error.code` — непустая строка. Кроме того, `error.code` не строка и не `null`/`""` (число, список, bool) — тоже ошибка файла, код `UNKNOWN_ERROR` (R-32, design #71);
|
||
**успех файла — `error.code == ""`** (реальный успешный ответ ИС несёт `error = {"code": "", "message": ""}`, поэтому
|
||
проверка «`error` не `null`» неверна; отсутствующий или `null` `error` ошибкой не считается). Если ошибочны **все**
|
||
файлы — `status = error`, `error.code` — код первого ошибочного файла по имени (коды Content AI совпадают с перечнем
|
||
контракта; для `parse_error` — `INVALID_FORMAT`; код вне перечня — `UNKNOWN_ERROR` с исходным кодом в сообщении;
|
||
корень ответа файла не объект JSON — ошибка этого файла `INVALID_FORMAT`),
|
||
`error.message` — его сообщение; `metrics.warnings` — по строке на каждый ошибочный файл вида `<имя>: <код>: <сообщение>`,
|
||
где `<код>` — исходный код ИС как получен (для `parse_error` и корня ответа, не являющегося объектом, — `INVALID_FORMAT`);
|
||
если ошибочных файлов нет, поле `warnings` опускается. `NormalizeOutcome.warnings: list[str]` (по умолчанию пуст,
|
||
R-25) — внутреннее поле: воркер дописывает его в конец `metrics.warnings` конверта и файла результата (R-30, R-33).
|
||
Если есть хотя бы один успешный файл — `status = error`, `error.code = NOT_IMPLEMENTED` с сообщением «нормализация ответа
|
||
ИС Сканирование реализуется в Срезе 3», ошибочные файлы — в `metrics.warnings` так же. Срез 3 подменяет реализацию
|
||
(§11, OQ-13/14), транспорт не меняется.
|
||
|
||
### D-6. Эмулятор — тот же репозиторий и образ, другой entrypoint
|
||
`python -m src.scan_emulator`: цикл по `Input/` каждую секунду; новый файл `<uuid>_<name>.<ext>` → набор фикстур из
|
||
`SCAN_FIXTURES_DIR`: все `<name>.json` и `<name>_*.json` (не префикс `<name>*`: `aep_3` не должен забирать `aep_30*`) →
|
||
через `SCAN_EMULATOR_DELAY` запись набора в `Output/<uuid>/` под именами `<uuid>.json` для `<name>.json` и
|
||
`<uuid>_<suffix>.json` для `<name>_<suffix>.json`, с подстановкой `file_uid = <uuid>`. Набор появляется в `Output/<uuid>/`
|
||
атомарно целиком: пишется во временный каталог эмулятора, затем каталог переименовывается. Нет пары → один ответ
|
||
Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", "message": "..."}, "data_ocr": {...}}`, где `data_ocr` —
|
||
объект с пустыми полями, как у реальных ответов-ошибок `aep_5.json` и `aep_8_form4.json` (`label`, `organization`, `inn`,
|
||
`units`, `date` — `{"form_name": "", "confidence": 0}`, `table_structure: []`; R-28). Обработанные входящие файлы переименовываются в
|
||
`<uuid>_<name>.<ext>.done`, чтобы не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его.
|
||
|
||
### D-7. Ошибки транспорта — результат, не молчание
|
||
Запрос не по схеме / `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (без ack); S3 get упал → `FILE_READ_ERROR`; `put` в горячую папку
|
||
упал → `RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); файл ответа не JSON → `parse_error` файла (D-4), а
|
||
если ошибочны все файлы — `INVALID_FORMAT` (D-5); прочее →
|
||
`UNKNOWN_ERROR`. Конверт публикуется всегда с повторами публикации до `PUBLISH_RETRIES=3`; не удалось — лог error,
|
||
ядро закроет по `reply_deadline`.
|
||
|
||
### D-8. NATS — как у ядра и парсера, без JetStream
|
||
Без учётных данных при пустых `NATS_SEED/NATS_JWT`, иначе nkeys/JWT шаблона; автопереподключение `nats-py`; подписка
|
||
queue group восстанавливается клиентом; `/healthcheck` отдаёт `nats` и `hotfolder`.
|
||
|
||
### D-9. Тесты
|
||
Unit: `LocalHotFolder` на `tmp_path` (в `Input` нет `.part`; `poll` — нет каталога, один файл, два файла, дописываемый
|
||
файл, посторонний файл); склейка и `NullNormalizer` — на реальных `aep_3.json`, `aep_3_ofr.json`, `aep_5.json`,
|
||
`aep_6.json`; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end «put → эмулятор → poll
|
||
(с settle) → склейка» без Docker; обработчик воркера с фейками S3/NATS — ack до обработки, повторный запрос, все коды
|
||
D-7 и таймаут (с укороченными настройками). `SmbHotFolder` — unit с подменённым `smbclient`. Интеграционный (маркер
|
||
`integration`): настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (R-7; контейнера
|
||
`scan-emulator` в compose пока нет) — `request-image.json` для `aep_3.pdf` → ack, затем `NOT_IMPLEMENTED` с
|
||
`metrics.warnings` про `<job_id>_ofr.json` и `raw/{job_id}.json` с двумя файлами; `aep_6.pdf` → `NOT_IMPLEMENTED` без
|
||
warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/` в MinIO. Синтетический успешный ответ (R-10) не
|
||
нужен: реальные успешные образцы `aep_3.json`, `aep_6.json` добавлены человеком 04.10.2026 (OQ-76).
|
||
|
||
### D-10. Dev без Nexus, Python 3.11
|
||
`Dockerfile` — `python:3.11-slim`; `make venv` ставит `requirements.txt` без строк `raisa_*`. Конфигураций CI нет:
|
||
`make check` / `make check-full`.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- [SMB в проде ведёт себя иначе, чем локальный каталог (права, кодировки имён)] → `SmbHotFolder` минимален и повторяет
|
||
вызовы старого `PDFWorker`; первый прогон на реальной шаре — отдельная задача Среза 3 с доступом к стенду.
|
||
- [Имена каталогов ИС отличаются от `Input/Output`] → настройки, не код.
|
||
- [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре.
|
||
- [Падение шлюза после ack теряет задание до `reply_deadline`] → осознанно: без JetStream иначе нельзя без своей очереди;
|
||
ядро переводит в `timeout`, replay — Срез 3+.
|
||
- [Эмулятор знает только пары из `fixtures/ocr`] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые
|
||
пары и новые файлы набора (`<name>_*.json`) добавляются в `fixtures/ocr` без изменения кода.
|
||
- [ИС пишет набор с паузой больше `SCAN_SETTLE_SECONDS` между файлами] → шлюз склеит неполный набор, недостающие
|
||
документы потеряны (повторов нет, OQ-15) → значение настраивается; проверить на стенде в Срезе 3.
|
||
- [`Tmp` и `Input` на разных ФС/шарах] → `rename` перестаёт быть атомарным → `SCAN_TMP_DIR` обязан лежать на той же шаре.
|
||
|
||
## Migration Plan
|
||
|
||
Нет данных. Удаление каталогов — отдельным первым коммитом. Откат — revert.
|
||
|
||
## Open Questions
|
||
|
||
Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего/служебного каталогов,
|
||
путь SMB и подходящее `SCAN_SETTLE_SECONDS` на стенде ИС Сканирование — нужны к Срезу 3. OQ-76/77/78/79 решены человеком
|
||
04.10.2026 (см. `open-questions-proposal.md`).
|
||
|
||
## Decisions made without discussion
|
||
|
||
| № | Дата | Решение | Обоснование | Цена ошибки |
|
||
|---|---|---|---|---|
|
||
| 1 | 2026-10-03 | D-1: протокол `HotFolder`, режимы `local`/`smb`, имена каталогов `Input`/`Output` по умолчанию | dev без SMB; имена из старого кода | смена настройки |
|
||
| 2 | 2026-10-03 | D-2: queue group `scan-gateway`, ack сразу, process-local LRU `job_id`, без реестра на диске | NATS без JetStream (ответ 03.10); OQ-15 | добавить хранилище состояния, если понадобятся повторы OCR |
|
||
| 3 | 2026-10-03 | D-3: `SCAN_MAX_INFLIGHT = 4`, опрос 5 с, таймаут 300 с | ТЗ 3 (5 мин) | смена настроек |
|
||
| 4 | 2026-10-03 | D-4: сырой ответ сохраняется до нормализации | данные для инцидентов даже при падении нормализатора | нет |
|
||
| 5 | 2026-10-03 | D-5: `NullNormalizer` пробрасывает ошибки ИС, успех → `NOT_IMPLEMENTED` **[частично заменено №57–58]** | честный статус; коды Content AI совпадают с контрактом | нет |
|
||
| 6 | 2026-10-03 | D-6: эмулятор в этом репозитории, тот же образ, поиск фикстуры по имени/префиксу, `.done` после обработки **[частично заменено №61–62]** | общие соглашения горячих папок; `aep_8.pdf` ↔ `aep_8_form4.json` | нет |
|
||
| 7 | 2026-10-03 | D-7: таблица транспортных ошибок, публикация с 3 повторами | ядро не ждёт таймаута, если шлюз жив | нет |
|
||
| 8 | 2026-10-03 | `external_task_id` в ядро не передаётся (идентификатор ИС = `job_id` в имени файла) | у протокола горячих папок нет отдельного id задания | поле DBML остаётся NULL |
|
||
| 9 | 2026-10-03 | D-10: `python:3.11-slim`, `make check`/`check-full` вместо CI | ответы пользователя | смена тега |
|
||
| 10 | 2026-10-04 | R-1: `.env.example` создаёт задача 1.2 (минимальный рабочий local-режим), задача 2.2 дополняет и добавляет `.env.test` | проверка 1.2 требует `.env.example`, а план создаёт его в 2.2 | переделка файла |
|
||
| 11 | 2026-10-04 | R-2: из `app.py` убирается модульный код nkeys/NATS (переезжает в `src/core/nats.py`); `/healthcheck` в 1.2 отдаёт `uptime`+`version`, поля `nats`/`hotfolder` добавляет 5.3 | пустые `NATS_SEED/NATS_JWT` не должны ломать импорт (spec «NATS без JetStream») | нет |
|
||
| 12 | 2026-10-04 | R-3: задача 1.3 (README/CLAUDE.md) выполняется после 6.1, до 6.2 | проверка «упомянутые файлы существуют» невыполнима до появления файлов | нет |
|
||
| 13 | 2026-10-04 | R-4: `LocalHotFolder` открывает `root`, `input_dir`, `output_dir`, `answer_path(job_id)`; эмулятор работает с ними обычными операциями ФС, протокол `HotFolder` остаётся `put/poll/healthy` | у протокола нет листинга входящих и записи ответа, нужных эмулятору (D-6) | рефакторинг эмулятора |
|
||
| 14 | 2026-10-04 | R-5: `put(name, data)` пишет файл `name` как есть, имя `<job_id>_<original_name>` собирает вызывающий (`inbox_name`); запись атомарна (`.part` + rename), `Input/` создаётся, корень — нет; `healthy()` = корень существует; эмулятор игнорирует dot-файлы и `.done` **[частично заменено №59: `.part` — в `SCAN_TMP_DIR`]** | неоднозначность плана («put пишет <uuid>_<name>»); спека требует `RECOGNITION_FAILED` при отсутствии корня | нет |
|
||
| 15 | 2026-10-04 | R-6: фабрика `create_hotfolder(settings)` в `src/gateway/__init__.py` (задача 4.2), smb импортируется лениво | ни одна задача не владела фабрикой; в `local` smb-модуль не должен импортироваться | нет |
|
||
| 16 | 2026-10-04 | R-7: интеграционный тест 6.1 запускает воркер шлюза и эмулятор в процессе теста против compose NATS/MinIO с tmp-каталогом; точный фрагмент compose для `scan-emulator` — в `open-questions-proposal.md` | контейнера `scan-emulator` в compose пока нет (его добавит docs в фазе 2) | контейнерный путь не проверен |
|
||
| 17 | 2026-10-04 | R-8: `argon2-cffi==23.1.0` и `argon2-cffi-bindings==25.1.0` остаются в `requirements.txt` (транзитивные зависимости `miniopy-async`) | спека запрещает только SQLAlchemy/asyncpg/alembic/raisa_fastapi_protected_api; формулировка плана «без argon2-cffi» убрала бы фиксацию версий в freeze-файле (T2.1) | две лишние строки в requirements |
|
||
| 18 | 2026-10-04 | Задача 1.2: вложенные модели `OPENBAO` остаются `BaseSettings`, блок SMB — `SMBSettings()` с пустыми значениями по умолчанию, полноту проверяет `Settings` в режиме `smb` | иначе pydantic-settings не разбирает вложенные ENV-ключи | нет |
|
||
| 19 | 2026-10-04 | Задача 1.2: добавлено `MINIO_SECURE` (в `.env.example` `false`); 5.2 передаёт `secure=` в `Minio(...)` | MinIO в compose работает по http | нет |
|
||
| 20 | 2026-10-04 | Задача 1.2: `SCAN_POLL_SECONDS`/`SCAN_TIMEOUT_SECONDS` — `PositiveFloat` | укороченные таймауты в тестах (дробные секунды) | нет |
|
||
| 21 | 2026-10-04 | Задача 1.2: из lifespan убраны S3 и NATS (вернутся в 5.2/5.3) | подключение переезжает в `src/core/nats.py`/воркер (R-2) | нет |
|
||
| 22 | 2026-10-04 | Задача 1.2: `app.py` — фабрика `create_app(settings)`, модульный `settings` убран; конфиг читается лениво (`get_settings()` с `lru_cache`) | тесты без побочных эффектов при импорте | нет |
|
||
| 23 | 2026-10-04 | Задача 2.1: `Makefile` в корне репозитория (рецепты `cd api && …`, как в arm-core); строгие флаги mypy заданы заранее для `src.contract`, `src.gateway`, `src.scan_emulator`, код шаблона — нестрогий; `httpx` не дублируется в `requirements-dev.txt` (приходит из `requirements.txt`) | единообразие с arm-core; новые пакеты пишутся строго с первого дня | нет |
|
||
| 24 | 2026-10-04 | Задача 2.2: строка `.dockerignore` убрана из `.gitignore` (как в arm-core), файл закоммичен; `conftest.py` берёт окружение из `.env.test` (`ENV_FILE=.env.test` в `pytest.ini`), `setdefault` убран; тесты запускаются из `api/` | шаблонная строка в `.gitignore` не давала закоммитить `.dockerignore`; единый источник окружения тестов | тесты зависят от cwd=`api/` |
|
||
| 25 | 2026-10-04 | Задача 3.1: модели `src/contract` скопированы из arm-core (включая модели ОСВ и tabular-подписки, не используемые шлюзом); `extra="ignore"` вместо `forbid` | схемы не содержат `additionalProperties: false`, MINOR добавляет необязательные поля; спека требует валидировать все примеры docs; близость к arm-core упрощает синхронизацию | при расхождении arm-core копию синхронизируют вручную |
|
||
| 26 | 2026-10-04 | Задача 3.1: конструкторы конвертов `build_ack_message/build_error_message/build_done_message` принимают `ServiceInfo` параметром (`ServiceInfo(name=settings.SERVICE_NAME, version=settings.VERSION)`); `is_supported_major` — `fullmatch` по шаблону схемы без `int()` | конструкторы не зависят от окружения; `int()` падает на сверхдлинной строке (T6.1) | нет |
|
||
| 27 | 2026-10-04 | R-9: методы протокола `HotFolder` синхронные; воркер вызывает их через `asyncio.to_thread` | блокирующие вызовы ФС/`smbclient`; общий контракт для двух реализаций | обернуть в async позже |
|
||
| 28 | 2026-10-04 | Задача 4.1: `answer_path` бросает `ValueError` для небезопасного `job_id`; общие `validate_name`/`validate_job_id` в `hotfolder.py`; `put` отвергает имена с разделителями, NUL, точкой в начале | `job_id` и `original_name` приходят из запроса NATS | воркер не должен принимать `ValueError` за недоступность ИС |
|
||
| 29 | 2026-10-04 | Задача 4.2: `inbox_name` заменяет символы, недопустимые в Windows/SMB (`: < > " \| ? *`, управляющие), на `_` и отбрасывает завершающие точки/пробелы; `put` остаётся строгим (T8.1) | допустимое имя пользователя (`Отчёт: 2024.pdf`) не должно проваливать задание; для ИС имя ничего не значит | имя во входящем каталоге отличается от загруженного |
|
||
| 30 | 2026-10-04 | Задача 4.2: `SMB_PATH` — относительный (`share/dir`, склеивается с хостом) или полный UNC; учётные данные передаются в каждый вызов `smbclient`, если задан логин; `poll` возвращает `None` на `STATUS_SHARING_VIOLATION`/`STATUS_DELETE_PENDING`; монопольное открытие ответа сохранено; `mkdir` существующего `Input` с `ACCESS_DENIED` не роняет `put`, если каталог существует | повторяет вызовы старого `PDFWorker`; ИС может ещё писать ответ; урезанные ACL шары | проверить на реальной шаре в Срезе 3 |
|
||
| 31 | 2026-10-04 | Задача 4.2: временный файл `.part` лежит прямо в `Input`; читается один `<job_id>.json` (старый код читал все файлы `Output/<id>/`) **[заменено №54–55 и №59]** | спека; реальная ИС может забирать файлы без маски — риск вынесен в `open-questions-proposal.md` | запись `.part` в служебный каталог, чтение всех файлов подкаталога |
|
||
| 32 | 2026-10-04 | R-10 (наблюдение O-1): оба реальных ответа `fixtures/ocr/aep_5.json` и `aep_8_form4.json` сами являются ответами-ошибками ИС (`error = INVALID_DOCUMENT`; `data_ocr` — объект с пустыми полями (`form_name` пусто, `confidence 0.0`) и `table_structure: []`), поэтому путь «успех -> `NOT_IMPLEMENTED`» проверяется на синтетическом успешном ответе (копия `aep_5.json` с `error = null` и одной строкой таблицы) — в unit-тестах 5.1 и интеграционном тесте 6.1 (временный каталог фикстур с `ok_doc.json`); реальные фикстуры используются для сценария «ошибка ИС проброшена» **[заменено D-9: реальные успешные образцы aep_3/aep_6, OQ-76]** | невозможно получить успешный сценарий из имеющихся фикстур; спека не меняется | если владелец ждёт, что `aep_5.json` — успешный образец, нужны реальные успешные ответы (открытый вопрос Среза 3) |
|
||
| 33 | 2026-10-04 | R-11: эмулятор читает обычные переменные окружения через собственные настройки (`SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR`, `SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_DELAY`, `SCAN_EMULATOR_POLL_SECONDS`), а не `Settings` шлюза; `process_once` синхронный, `delay` спится один раз за проход, ответ пишется атомарно (`.part` + rename), затем входящий файл переименовывается в `.done` **[запись ответа — частично заменено №62: набор целиком через временный каталог]** | контейнеру эмулятора не нужны переменные MinIO/NATS | нет |
|
||
| 34 | 2026-10-04 | Задача 4.3: без аргументов `from_env` подмешивает `.env`/`ENV_FILE` под `os.environ` (чтобы `make emulator` работал с `api/.env`); сообщение «нет пары» — `<имя без uuid-префикса, с расширением>: нет эталонного ответа в фикстурах`; битая фикстура -> ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре, вход получает `.done`; нет `Input/` — не ошибка, нет корня при старте — код выхода 2, нет каталога фикстур — warning | удобство dev; защита от зацикливания | нет |
|
||
| 35 | 2026-10-04 | R-12: `Normalizer.normalize(raw: object)` получает уже разобранный JSON ответа ИС; `json.loads` и отображение ошибки разбора в `INVALID_FORMAT` — на стороне воркера (D-7); `NullNormalizer` возвращает `INVALID_FORMAT` для не-объекта JSON, пробрасывает код ошибки ИС, если он входит в `ErrorCode` контракта (регистр значим), иначе `UNKNOWN_ERROR` с исходным кодом в сообщении; пустой `message` = отсутствует (текст по умолчанию) **[частично заменено №57, №60]** | D-5 писал `raw: dict`, а «не JSON» из плана 5.1 нельзя разобрать, не имея байтов | если воркер захочет отдать декодирование нормализатору — перенести `json.loads` |
|
||
| 36 | 2026-10-04 | Задача 5.1: `NormalizeOutcome` (pydantic, frozen) — инвариант: `done`/`needs_check` требуют непустой `documents`, `error` запрещает `documents`, `error` обязателен тогда и только тогда, когда `status = error` | повторяет `minItems: 1` схемы `recognition-result` для успешных статусов; ошибка конструктора раньше, чем ошибка сборки `result.json` | нет |
|
||
| 37 | 2026-10-04 | R-13: `PUBLISH_RETRIES=3` — число повторов после первой попытки (до 4 попыток всего); `PublishFailed.attempts` — фактическое число попыток; tabular-parser должен толковать так же | буквальный текст спеки «повторить до трёх раз» и D-7 | одна попытка больше/меньше |
|
||
| 38 | 2026-10-04 | Задача 5.2: если задан только один из `NATS_SEED`/`NATS_JWT` — `NatsConfigError` (как в arm-core); шаблонный `nats_callback.py` удалён; ошибки S3: `S3ObjectNotFoundError` ⊂ `S3ReadError` ⊂ `S3StorageError`, `S3WriteError`; после `publish`/`subscribe` вызывается `flush`; `connect()` блокирует до доступности NATS — вызывать в фоновой задаче | fail-fast на неполные учётные данные; без flush nats-py буферизует публикации при переподключении | нет |
|
||
| 39 | 2026-10-04 | R-14: исключения `hotfolder.poll` логируются и опрос продолжается до дедлайна; если все попытки опроса до дедлайна завершились исключением — `RECOGNITION_FAILED` («горячая папка недоступна»), иначе `TIMEOUT` | ошибки SMB (в т.ч. не-ENOENT) переходящие | выбор кода при постоянной ошибке |
|
||
| 40 | 2026-10-04 | R-15: `ValueError` из `inbox_name` (непригодное `original_name`) -> `RECOGNITION_FAILED` «недопустимое имя файла» | в D-7 нет кода для этого случая | выбор кода |
|
||
| 41 | 2026-10-04 | R-16: запрос не по схеме / MAJOR ≠ 1 / `pipeline` ≠ `image` -> `SCHEMA_MISMATCH` без ack; конверт ошибки публикуется только если из сырого JSON извлекаются валидные `job_id` и `file_id` (`attempt` из тела, если целое ≥ 1, иначе 1), иначе — лог и отбрасывание | схема конверта требует `job_id`/`file_id`; ядро закроет задание по `reply_deadline` | ядро не узнает об ошибке неразбираемого тела |
|
||
| 42 | 2026-10-04 | R-17: ключ дедупликации — `job_id` (по спеке); повторный запрос публикует ack с `attempt` повторного запроса, второй файл не пишется; LRU размером `DONE_CACHE_SIZE` | спека, D-2, OQ-15 | более высокий `attempt` того же `job_id` не перезапускает обработку |
|
||
| 43 | 2026-10-04 | R-18: ошибка записи `raw/{job_id}.json` -> `UNKNOWN_ERROR` без `raw_s3_key`; ответ не JSON — сырые байты сохраняются как есть, затем `INVALID_FORMAT` с `raw_s3_key`; исключение нормализатора -> `UNKNOWN_ERROR` с `raw_s3_key` **[частично заменено №55–56: файл ответа не JSON даёт `parse_error` в склейке, `INVALID_FORMAT` — только если ошибочны все файлы]** | D-4, D-7, сценарий «Нормализатор упал» | нет |
|
||
| 44 | 2026-10-04 | R-19: один бакет (глобальное ограничение): `request.s3.bucket` ≠ настроенного -> `FILE_READ_ERROR` после ack; `raw/` и `results/` — в настроенном бакете | «один бакет S3» | развёртывания с несколькими бакетами отвергаются |
|
||
| 45 | 2026-10-04 | R-20: дедлайн = `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, `старт` — момент записи файла в горячую папку (после получения слота); `on_request` не блокируется: валидирует, регистрирует `job_id` и запускает задачу задания (ack -> семафор -> обработка) | ожидание слота входит в `reply_deadline` через `min`; колбэк NATS должен возвращаться быстро (T11.4) | нет |
|
||
| 46 | 2026-10-04 | Задача 5.3 (подробно — 15 решений в отчёте исполнителя): задание, получившее слот после `reply_deadline`, отвечает `TIMEOUT` без записи файла и чтения S3 (ack уже опубликован); ответ ИС, не являющийся JSON, перечитывается через `SCAN_POLL_SECONDS` и принимается, когда стал JSON или перестал меняться (на дедлайне — последний прочитанный; затем `INVALID_FORMAT`); дедупликация в двух структурах (`_active` не вытесняется, `_done` — LRU `DONE_CACHE_SIZE`); `SCHEMA_MISMATCH` не регистрирует `job_id`; `error.message` ≤ 4000 символов, суррогаты заменяются; `raw/` пишется с `application/json` (не-JSON — `application/octet-stream`); нормализатор вызывается через `to_thread`; `stop()` не публикует конверты отменённых заданий (D-2); сбой SMB-сессии виден через `healthy()` (listdir с теми же учётными данными), без флага; проба `hotfolder`: таймаут 2 с, кэш 5 с, одна проба за раз; NATS подключается в фоновой задаче lifespan, ошибка `connect`/`subscribe` -> `nats = down` до перезапуска; порядок остановки: отмена подключения -> `worker.stop()` -> drain NATS -> S3 -> SMB; `/healthcheck` всегда 200 **[в части перечитывания не-JSON ответа заменено №56]** | экономия ИС; недописанный ответ на шаре без атомарного rename; ограничения размера публикации | поздний результат отброшен; неподвижная ошибка NATS требует перезапуска пода; зависший `poll` по SMB (до 60 с) не прерывается дедлайном (T12.2) |
|
||
| 47 | 2026-10-04 | Задача 5.4: общая обвязка тестов воркера — `api/tests/worker_helpers.py` и `api/tests/unit/conftest.py`; ответ ИС для освобождения одного слота пишет тест (не `process_once`); отсутствие утечки слотов проверяется поведением (повторный `start()`); отмена воркера во время `to_thread(hotfolder.put)` не прерывает запись в потоке (файл уже в ИС, конверта нет, D-2) | эмулятор ответил бы на все четыре файла сразу; образец arm-core | возможна повторная отправка в ИС после рестарта (OQ-15) |
|
||
| 48 | 2026-10-04 | Задача 6.1: интеграционный тест поднимает настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (tmp-каталог горячей папки); синтетический успешный ответ `ok_doc.json` строится по форме §11 `05-domain-rules.md`; сценарии 1–2 пропускаются без `../fixtures`, недоступная инфраструктура — падение; тест ждёт подписку шлюза по фоновой задаче `nats-connect`; `ResourceWarning` aiohttp о теле > 1 МиБ отфильтрован в одном модуле (загрузка скана 1,29 МБ) **[заменено D-9: реальные успешные образцы aep_3/aep_6, OQ-76]** | контейнера `scan-emulator` ещё нет (R-7); fixtures не содержат успешных ответов (R-10) | интеграционный тест может забрать чужие запросы из общей queue group `scan-gateway` на общем NATS (T14.1) |
|
||
| 49 | 2026-10-04 | R-21: финальная волна исправлений = ровно список финального ревью (C-1, I-1, T12.1, T12.4, T7.3+T7.5, T2.2, неточности документов, строки в предложении для docs); I-2 (compose-фрагмент не применён) и I-3 (replay с `attempt+1` для `job_id` из LRU) оставлены человеку | второй волны нет; I-2 и I-3 меняют спеку/другой репозиторий | остальные находки — follow-up в `open-questions-proposal.md` |
|
||
| 50 | 2026-10-04 | C-1: пакет `src` не создаёт приложение при импорте (`api/src/__init__.py` без импортов; `run_server.py` импортирует `src.app`); эмулятор стартует без `OPENBAO__*` и завершается кодом 2 при отсутствии корня горячей папки | R-11: контейнеру эмулятора не нужны настройки шлюза; обнаружено финальным ревью, тесты работали с полным `.env.test` | любой код, импортировавший `src:app`, должен перейти на `src.app:app` |
|
||
| 51 | 2026-10-04 | I-1: `make up` после `up -d --wait postgres nats minio` выполняет `run --rm minio-init` (идемпотентно) | на свежем томе бакет `arm-ka` иначе не создаётся и `make check-full` падает на `NoSuchBucket` | `tabular-parser` — тот же пробел (чужой репозиторий, в предложении) |
|
||
| 52 | 2026-10-04 | T12.1: `SCHEMA_MISMATCH` не публикуется для `job_id` (нормализованного через `uuid.UUID`), который в работе или в LRU | тот же `Nats-Msg-Id`, что у настоящего результата, привёл бы к потере настоящего результата при дедупликации ядром | повторный битый запрос того же задания остаётся без ответа (лог warning) |
|
||
| 53 | 2026-10-04 | T7.3: `inbox_name` укорачивает основу имени так, чтобы `.<job_id>_<имя>.part` ≤ 240 байт UTF-8 (по code points, расширение ≤ 16 символов сохраняется, `surrogatepass`); `put` подавляет `OSError` при удалении `.part` | ENAMETOOLONG на ext4/NTFS давал ложный `RECOGNITION_FAILED` на допустимое имя; имя для ИС значения не имеет | эмулятор не найдёт фикстуру по усечённому имени (> ~200 байт — нереалистично) |
|
||
| 54 | 2026-10-04 | Готовность набора ответов: `poll` отдаёт список, когда есть хотя бы один `*.json` и набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (2); таймаут задания по-прежнему `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, ненастоявшийся набор на дедлайне — `TIMEOUT` | OQ-78 (решение человека: читать все json, готовность — по стабилизации) не задаёт момента готовности; ИС кладёт файлы без общего маркера завершения | ИС пишет набор с паузой > 2 с — часть документов потеряна, повторов нет (OQ-15); значение настраивается |
|
||
| 55 | 2026-10-04 | Склеенный ответ `{"job_id", "files": [{"name", "response", "parse_error"}]}`, файлы по возрастанию имени, в `files` — все `*.json` каталога (другие игнорируются); `raw/{job_id}.json` = склеенный ответ; `raw_s3_key` остаётся одной строкой, контракт v1.0 не меняется | OQ-78 требует «склеить в один ответ»; структура — наша; сохранение имён файлов нужно для разбора инцидентов и `metrics.warnings` | Срез 3 нормализатор читает склеенную структуру, а не одиночный ответ ИС — единственный вход, переделка локальна |
|
||
| 56 | 2026-10-04 | R-24 (design #56): файл, не разобранный `json.loads` после настаивания набора, не перечитывается и не роняет задание: `response = null`, `parse_error` заполнен; при ошибочности всех файлов — `INVALID_FORMAT` | набор уже стабилен — повторное чтение не изменит файл; перечитывание «не-JSON» из решения №46 имело смысл для единственного файла без атомарного rename и заменено settle | недописанный, но стабильный 2 с файл будет принят как `parse_error`, а не дождётся конца записи |
|
||
| 57 | 2026-10-04 | Успех файла — `error.code == ""`; ошибочный — непустой `error.code` либо `parse_error`; отсутствующий/`null` `error` ошибкой не считается; не-объект на корне склейки → `INVALID_FORMAT`; код вне перечня контракта → `UNKNOWN_ERROR` с исходным кодом в сообщении (R-12 сохранён) | образцы `aep_3.json`, `aep_6.json` (OQ-77): у успеха `error = {"code": "", "message": ""}`; проверка `error != null` принимала успех за ошибку | ИС, возвращающая `error` строкой/другого типа, будет трактоваться по правилу «нет `error.code` — успех» |
|
||
| 58 | 2026-10-04 | Сводка по файлам в `NullNormalizer`: все файлы ошибочны → `status = error`, `error.code`/`message` первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`); есть хотя бы один успешный → `error.code = NOT_IMPLEMENTED`; каждый ошибочный файл — строка `<имя>: <код>: <сообщение>` в `metrics.warnings` в обоих случаях | OQ-78: ошибка одного документа не отменяет остальные; `NOT_IMPLEMENTED` — единственный честный статус, пока нормализации нет (Срез 3); `metrics.warnings` уже есть в контракте (`array<string>`), расширять контракт не нужно | потребитель, ждущий `error.code` ошибочного файла при частичном успехе, увидит `NOT_IMPLEMENTED` — до Среза 3 различия не нужны |
|
||
| 59 | 2026-10-04 | `SCAN_TMP_DIR` (`Tmp`) — служебный каталог на корне горячей папки; `.part` создаётся в `<корень>/Tmp/`, каталог создаётся при записи, `rename` в `Input/<name>`; обе реализации (`LocalHotFolder`, `SmbHotFolder`) | OQ-79 (решение человека: временный файл — вне `Input`); корень остаётся единственной шарой, чтобы `rename` был атомарным | `Tmp` на другой ФС/шаре — `rename` не атомарен; ИС с маской, захватывающей `Tmp`, — неизвестно, проверить на стенде |
|
||
| 60 | 2026-10-04 | `Normalizer.normalize(raw)` получает склеенный ответ (D-4), а `json.loads` каждого файла выполняет функция склейки `merge_answers` (на стороне воркера); воркер больше не отображает «не JSON» в `INVALID_FORMAT` сам — это делает нормализатор по `parse_error` | R-12 писал вход как «ответ ИС» (один файл); с несколькими файлами решение «ошибка или нет» принимается по набору | нет |
|
||
| 61 | 2026-10-04 | Правило выбора фикстур эмулятора: `<name>.json` и `<name>_*.json` (не префикс `<name>*`); имена в каталоге ответа — `<uuid>.json` и `<uuid>_<suffix>.json`; `aep_8.pdf` → `aep_8_form4.json` покрывается `<name>_*` | `<name>*` забрал бы `aep_30*` при запросе `aep_3`; набор из нескольких файлов нужен для `aep_3.json` + `aep_3_ofr.json` | фикстура с именем вида `<name>-x.json` не будет найдена |
|
||
| 62 | 2026-10-04 | R-26 (design #62): набор ответов эмулятор записывает во временный каталог и переименовывает каталог в `Output/<uuid>/` целиком; «готовность по settle» в шлюзе проверяется отдельным тестом (файл дописывается во время опроса) | шлюз не должен видеть частичный набор от эмулятора; поведение settle всё равно нужно тестировать на подмене | rename каталога на нестандартной ФС (compose-том) может быть неатомарным — тогда набор виден по частям, settle закрывает |
|
||
| 63 | 2026-10-04 | R-22 (design #63): задача 7.1 переходно адаптирует вызов `poll` в воркере и фейки в тестах к `list[AnswerFile]`; вся склейка и нормализация — задача 7.2 | `poll` меняет тип результата, воркер не должен ломаться между задачами | небольшой переходный код, удаляемый в 7.2 |
|
||
| 64 | 2026-10-04 | R-23 (design #64): существующие тесты воркера, таймаута и параллелизма ставят `SCAN_SETTLE_SECONDS=0`; settle тестируется отдельно с внедряемыми часами | не удлинять тесты на 2 с каждый и не делать их зависимыми от реального времени | если 0 не отключает проверку стабилизации полностью, тесты таймаута флапают |
|
||
| 65 | 2026-10-04 | R-25 (design #65): `NormalizeOutcome` получает необязательное поле `warnings`, которое воркер кладёт в `metrics.warnings` конверта; поле опускается, когда ошибочных файлов нет | `metrics.warnings` в контракте есть (`array<string>`), а нормализатор — единственное место, знающее об ошибках по файлам | внутренний тип, контракт v1.0 не меняется; Срез 3 может заменить |
|
||
| 66 | 2026-10-04 | R-28 (design #66): ответ эмулятора «нет пары» — `error.code = INVALID_DOCUMENT` и `data_ocr` — **объект** с пустыми полями (`label`/`organization`/`inn`/`units`/`date` — `{form_name: "", confidence: 0}`, `table_structure: []`) как у реальных ответов-ошибок `aep_5.json`, `aep_8_form4.json`, а не `[]` | OQ-77: `data_ocr` — объект; эмулятор должен воспроизводить реальную форму | нет |
|
||
| 67 | 2026-10-04 | R-29 (design #67): готовность набора по settle отсчитывается от первого опроса, при котором наблюдается окончательный набор (не по mtime); сценарий «Ответ из одного файла»: набор передаётся не позднее `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после этого опроса | часы SMB-сервера и шлюза могут расходиться, mtime ненадёжен; внедряемые монотонные часы делают поведение тестируемым; прежняя формулировка «не позднее `SCAN_POLL_SECONDS`» недостижима без mtime (Concern Task 2) | ответ приходит до settle + poll позже стабилизации набора (боевые 2 + 5 с) |
|
||
| 68 | 2026-10-04 | Решения исполнителя Task 2 (7.1): состояние settle ограничено 1024 заданиями с вытеснением самых давних и сбрасывается при выдаче набора или пустом каталоге, `forget` в протокол `HotFolder` не входит; `*.json` сравнивается без учёта регистра; dot-файлы с расширением `.json` входят в набор; settle — от первого наблюдения в этом процессе; файл изменился между листингом и чтением (размер) или исчез → `None` и набор настаивается заново; в `put` `Tmp` создаётся раньше `Input`; сигнатуры: `tmp_dir` — четвёртый позиционный, `settle_seconds`/`clock` — только именованные, публичные `answer_dir`, `settle_seconds`, `pending_jobs`, `answer_path` сохранён | ИС на Windows/SMB (маски регистронезависимы); память не растёт от заданий, ушедших по таймауту; не трогать протокол и фейки ради редкого случая; защита от гонки «листинг → чтение» | вытесненное активное задание настаивается заново (ответ позже на settle); служебный `X.JSON` или `.tmp.json` ИС попадёт в набор |
|
||
| 69 | 2026-10-04 | R-30 (design #69): `metrics.warnings` конверта ограничено: не больше 100 строк (`MAX_WARNINGS`) по 1000 символов (`MAX_WARNING_CHARS`, `clean_text`: суррогаты заменяются, длинная строка обрезается с «…»); при избытке первые 99 строк + сводная «ещё предупреждений: N; все ошибки файлов — в raw/{job_id}.json» | конверт NATS ≤ 1 МБ при любом числе файлов и длине сообщений ИС (как T10.1 для `error.message`); полные данные остаются в сыром ответе | потребитель не увидит в конверте ошибки файлов сверх 99-го и хвост длинного сообщения — только в `raw/` |
|
||
| 70 | 2026-10-04 | R-31 (design #70): склейка — `src/gateway/merge.py` (`merge_answers`, `MergedResponse`/`MergedFile` — `TypedDict`, т. е. обычный dict); тот же объект пишется в `raw/{job_id}.json` (`dump_merged`: UTF-8 с читаемой кириллицей, при одиночном суррогате — ASCII-экранирование, всегда `application/json`) и передаётся нормализатору; `parse_error` — `<тип исключения>: <текст>` ≤ 300 символов; `merge_answers` сам сортирует файлы по имени | склейка — транспортная часть (вход — файлы горячей папки), не зависит от реализации нормализатора, которую подменит Срез 3; нормализатор видит ровно то, что сохранено в `raw/` | Срез 3 может захотеть типизированную модель вместо dict — правка локальна |
|
||
| 71 | 2026-10-04 | R-32 (design #71): крайние случаи `NullNormalizer`: `error.code` не строка и не `null`/`""` (число, список, `true`, `0`) — ошибка `UNKNOWN_ERROR`, в warnings — `repr` кода; пустое/отсутствующее сообщение ошибки ИС — «ИС Сканирование вернула ошибку <код> без сообщения» (и в `error.message`, и в warnings); `parse_error` — сообщение «ответ ИС не разбирается как JSON: <parse_error>»; вход не dict или `files` не список — `INVALID_FORMAT` «склеенный ответ ИС не соответствует структуре…»; `files` пуст — `INVALID_FORMAT` «в ответе ИС нет ни одного файла»; элемент `files` не объект или без непустого `name` — ошибочный файл `files[<i>]` с `INVALID_FORMAT`; «первый по имени» — первый в порядке `files` (склейка уже отсортировала) | спека задаёт только «непустой `error.code`»; нормализатор не бросает ни на каком входе; Срез 3 заменит реализацию | ИС, кодирующая успех как `0`/`false`, будет понята как ошибка `UNKNOWN_ERROR` (реальные образцы — строка `""`) |
|
||
| 72 | 2026-10-04 | R-33 (design #72): `warnings` исхода дописываются в конец `metrics.warnings` исхода; итоговые `metrics` одинаковы в конверте и в файле результата (done/needs_check); пустой список не порождает `metrics` | одно место правды для предупреждений; файл результата и конверт не расходятся | нет |
|
||
| 73 | 2026-10-04 | T2.1, T2.2, T2.4 (design #73): поправки по ревью Task 2 — T2.1: после чтения набора каталог ответа листается повторно (Local и SMB), снимок отличается от настоявшегося → `None` (набор настаивается заново); T2.2: `SmbHotFolder` — ENOTDIR из листинга `Output/<job_id>` → `None`; T2.4: тест сброса состояния settle при ENOENT/SHARING_VIOLATION/ENOTDIR | файл, появившийся или пропавший за время чтения, иначе дал бы неполный или устаревший набор; поведение SMB = Local | один лишний листинг на каждый выданный набор |
|
||
| 74 | 2026-10-04 | R-34 (design #74): интеграционный `tests/integration/test_roundtrip.py` минимально переведён на формат склейки `raw/` и успех `error = {"code": "", "message": ""}` (без новых сценариев — они в 7.4) | иначе он проверял бы старый формат `raw/`; полноценная переработка — задача 7.4 | не прогнан: в поднятом compose нет бакета `arm-ka` (`NoSuchBucket`), проверка — 7.4 после `make up` |
|
||
| 75 | 2026-10-04 | R-35 (design #75): решения исполнителя Task 4 (7.3): эмулятор читает `SCAN_TMP_DIR` (по умолчанию `Tmp`) через `EmulatorSettings.tmp_dir`, как шлюз, не завися от настроек шлюза; набор собирается в `<root>/<SCAN_TMP_DIR>/.<uuid>.answer` и целиком переименовывается в `Output/<uuid>/`; остаток прерванной сборки удаляется, ответ с тем же uuid, уже лежащий в `Output`, заменяется (rmtree + rename); повреждённая фикстура набора даёт ответ-ошибку `INVALID_DOCUMENT` на своём месте, остальные файлы набора остаются; фикстуры с одним `suffix` (`a.JSON`/`a.json`) — берётся первая по имени; `aep_8.pdf` теперь даёт `<uuid>_form4.json`, а не `<uuid>.json` (прежнее правило `<name>*`) | spec scan-emulator: набор появляется атомарно, rename каталога возможен только в пределах одной ФС, служебная папка корня уже есть у шлюза (`Tmp`) | повторная публикация uuid на миг убирает каталог; потребители, ждавшие `aep_8` как `<uuid>.json`, должны брать набор целиком |
|
||
| 76 | 2026-10-04 | R-36 (design #76): решения исполнителя Task 5 (7.4): интеграционный `test_roundtrip.py` с `SCAN_SETTLE_SECONDS=0.2` и `SCAN_POLL_SECONDS=0.1` (`.env.test` держит settle = 0 для остальных тестов); эмулятору теста отдаются копии только `aep_3.json`, `aep_3_ofr.json`, `aep_5.json`, `aep_6.json` и синтетический `ok_doc.json` (не весь `fixtures/ocr`); сценарий `aep_5` берёт реальное имя `aep_5.PDF` (расширение в верхнем регистре) и проверяет, что единственный ошибочный файл попадает и в итоговую ошибку, и в `metrics.warnings` (D-5); проверка `raw/` общая для всех сценариев: имена файлов склейки = все `*.json` каталога ответа по возрастанию, тела совпадают с файлами; в сценарии `unknown` устаревшее ожидание `data_ocr = []` заменено на объект с `table_structure: []` (R-28) — расхождение обнаружено первым прогоном (в Task 4 тест не запускался, №74); T3.3: D-5 уточнён — не строковый, не `null`/`""` `error.code` (число, список, `true`) — тоже ошибка файла с `UNKNOWN_ERROR` (R-32); T4.1: сквозной тест `TestAnswerSet` на реальных фикстурах помечен `fixtures` | тест должен проверять то, что есть в коде и спеке, а не прежнюю форму ответа; settle 0.2 с не удлиняет тест заметно; полная копия `fixtures/ocr` подтянула бы лишние наборы | тесты `fixtures` пропускаются без репозитория `../fixtures`; интеграционные тесты делят NATS и queue group `scan-gateway` с другими экземплярами шлюза |
|
||
| 77 | 2026-10-04 | R-27 (design #77): уборка после сквозной проверки Task 6 (7.5) — `docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka --profile services rm -sfv` только своих сервисов (`scan-gateway`, `scan-emulator`, `arm-core`, `arm-core-migrate`, одноразовый `minio-init`) и удаление тома `arm-ka_hotfolder` после проверки, что его не использует ни один контейнер, вместо `down -v` из плана; `postgres`/`nats`/`minio` и их тома не тронуты | проект compose `arm-ka` общий для всех worktree: `down -v` удалил бы и тома `postgres-data`/`minio-data` с данными другой работы | человек хотел полный сброс — одна команда `docker compose --profile services down -v` из `docs/dev` |
|
||
| 78 | 2026-10-04 | R-37 (design #78): состав и сборка сервисов в Task 6 (7.5): `up -d --build arm-core scan-gateway scan-emulator`, затем `up -d --wait` по тем же именам; `tabular-parser` не поднимался (конвейер `image` его не использует); контекст сборки `scan-gateway` в compose — `../../scan-gateway` от `docs/dev` worktree, т. е. сам worktree ветки (`.wt/slice-0/scan-gateway`), поэтому отдельный `make docker-build` не нужен; `arm-core:dev` пересобран из worktree `arm-core` (кэш, ID образа не изменился) | проверяется шлюз; лишний сервис только добавил бы шум на шине; правка compose не требовалась | если `tabular-parser` нужен для проверки чего-то ещё — отдельный прогон |
|
||
| 79 | 2026-10-04 | R-38 (design #79): после Task 6 тестовые строки БД оставлены (`legal_entity` ИНН `7700000001`, четыре `s3_object`/`stored_file`/`recognition_job` — id в отчёте Task 6), а объекты `uploads/…` и `raw/…` в MinIO удалены (`mc rm` поимённо); строки `s3_object` поэтому указывают на несуществующие ключи | по указанию контроллера (dispatch Task 6): строки БД — оставить, объекты MinIO — удалить; бакет до прогона был пуст | чтение этих `s3_object` даст `NoSuchKey`; убрать — SQL в порядке FK (подставить id из отчёта Task 6: `<job_id>` — `015ea48c-d91f-4d90-a7d8-0d559a8739a3`, `1bc7743c-d902-40c5-9009-09bda2de5f72`, `0ea16933-4cc1-4b50-bda9-a97089a918c5`, `9942727c-7f7d-43e8-a82d-925e0140c2e7`; `<file_id>` — `92b342b2-2d65-489e-b252-e26569780373`, `072e922f-b550-461c-9bfc-7cbb1961a46b`, `61a53aaf-af6c-4b30-8651-97c03bd0ba8e`, `e2d4f412-ecd7-4fd3-b2e3-91693432a186`; `<s3_object_id>` — `d569c917-c764-4591-be16-8058a688abf7`, `2ef2d832-d4f1-4819-9467-36bd24bf4c93`, `cd61b5f0-9582-4da5-bc55-77e505a5b32e`, `c81c8d72-9c6f-48f8-bbbb-3c6ce7a43e4f`): `begin; delete from dkb.recognition_job where id in (<job_id>, …); delete from dkb.stored_file where id in (<file_id>, …); delete from dkb.s3_object where id in (<s3_object_id>, …); delete from dkb.legal_entity where inn = '7700000001'; commit;` (порядок: `recognition_job.file_id → stored_file`, `stored_file.s3_object_id → s3_object`, `stored_file.legal_entity_id → legal_entity`; `stored_file.current_recognition_job_id` у этих строк пуст, `document`/`osv_entry` не создавались) либо полный сброс |
|