Срез 0: скелет scan-gateway — без фронта и БД, горячие папки local/smb, воркер NATS без JetStream, эмулятор ИС Сканирование #3

Merged
aep merged 68 commits from feat/1-slice-0-skeleton into main 2026-10-04 18:41:20 +03:00
7 changed files with 220 additions and 80 deletions
Showing only changes of commit 5dbb4dab20 - Show all commits

View File

@ -33,7 +33,7 @@
### Task 1: [R1] Обновить change под решения человека
- [ ] В `openspec/changes/slice-0-skeleton/`: обновить `proposal.md` (разделы «Горячие папки», «Воркер», «Нормализатор», «Эмулятор»), `design.md` (D-1 горячие папки, D-3/D-4 сырой ответ и склейка, D-5 нормализатор, D-6 эмулятор; новые решения — продолжением нумерации таблицы «Decisions made without discussion», сейчас последняя строка №53), `specs/image-recognition-gateway/spec.md` (требования «Передача файла…», «Сырой ответ…», «Результат по контракту» — склейка всех `*.json` каталога, готовность по settle, `SCAN_TMP_DIR`, per-file ошибки в `metrics.warnings`, успех = `error.code == ""`) и `specs/scan-emulator/spec.md` (набор файлов на задание); отметить в `open-questions-proposal.md` пункты про OQ-77/78/79/76 как решённые человеком 04.10.2026 (с фактами выше); `tasks.md` — добавить группу «7. Доработка по реальным ответам ИС» с задачами 7.0–7.5 (7.0 = Task 1 этого плана, 7.1–7.5 = Task 2–6... по числу задач плана). Проверка: `openspec validate slice-0-skeleton --strict` — valid; в spec нет упоминаний «читает ровно `<job_id>.json`» и `.part` в `Input`.
- [x] В `openspec/changes/slice-0-skeleton/`: обновить `proposal.md` (разделы «Горячие папки», «Воркер», «Нормализатор», «Эмулятор»), `design.md` (D-1 горячие папки, D-3/D-4 сырой ответ и склейка, D-5 нормализатор, D-6 эмулятор; новые решения — продолжением нумерации таблицы «Decisions made without discussion», сейчас последняя строка №53), `specs/image-recognition-gateway/spec.md` (требования «Передача файла…», «Сырой ответ…», «Результат по контракту» — склейка всех `*.json` каталога, готовность по settle, `SCAN_TMP_DIR`, per-file ошибки в `metrics.warnings`, успех = `error.code == ""`) и `specs/scan-emulator/spec.md` (набор файлов на задание); отметить в `open-questions-proposal.md` пункты про OQ-77/78/79/76 как решённые человеком 04.10.2026 (с фактами выше); `tasks.md` — добавить группу «7. Доработка по реальным ответам ИС» с задачами 7.0–7.5 (7.0 = Task 1 этого плана, 7.1–7.5 = Task 2–6... по числу задач плана). Проверка: `openspec validate slice-0-skeleton --strict` — valid; в spec нет упоминаний «читает ровно `<job_id>.json`» и `.part` в `Input`.
### Task 2: [R2] Горячие папки: чтение всех файлов, готовность, служебный каталог

View File

@ -4,7 +4,7 @@
См. `proposal.md`. Исходное состояние — копия каркаса `arm-core` (`a9159aa`). Протокол ИС Сканирование (FNR02,
`reference-old-arm/api/src/core/PDFWorker`): файл с префиксом UUID кладётся во входящий каталог SMB-шары, ответ появляется
в исходящем каталоге в подкаталоге с тем же UUID, формат ответа — Content AI (`file_uid`, `error`, `data_ocr[]`), таймаут
в исходящем каталоге в подкаталоге с тем же 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 нет.
@ -18,11 +18,17 @@ unit-тестах без Docker; таймаут и ошибки ИС всегд
## Decisions
### D-1. Горячие папки — протокол с двумя реализациями, имена каталогов настраиваемы
`HotFolder.put(name, data)`, `poll(job_id) -> bytes | None`, `healthy() -> bool`. `LocalHotFolder(root)` работает с
`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`
(как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Режим —
`SCAN_HOTFOLDER_MODE = local | smb`.
(как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Временный
файл атомарной записи (`.part`) лежит не в `Input`, а в служебном каталоге `SCAN_TMP_DIR` (`Tmp`) рядом с `Input`/`Output`
на той же шаре/ФС, чтобы `rename` в `Input` оставался атомарным, а ИС без маски не забрала недописанный файл (OQ-79);
каталог создаётся при первой записи. Режим — `SCAN_HOTFOLDER_MODE = local | smb`.
### D-2. Без реестра заданий на диске: состояние — в процессе и в горячей папке, ack — сразу
NATS без JetStream: сообщение доставляется один раз, повторы делает ядро, не получив ack за 30 с. Поэтому по получении
@ -34,30 +40,46 @@ NATS без JetStream: сообщение доставляется один ра
### 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)`. При таймауте — результат `TIMEOUT`, файл во
входящем каталоге не удаляется (ИС может ответить позже; «поздний» ответ никто не опрашивает; уборка каталога — задача
эксплуатации, вне среза). Очередь заданий, ждущих слот, не ограничена — ядро не публикует больше, чем `queued` заданий.
(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 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов. Сохраняется
**до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3.
### 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)` (`raw` — уже разобранный JSON ответа ИС: `json.loads` и отображение ошибки разбора в `INVALID_FORMAT` — на стороне воркера, не-объект JSON -> `INVALID_FORMAT` в нормализаторе). `NullNormalizer`: если в ответе
`error` не `null` — результат `status = error` с кодом из ответа (коды Content AI совпадают с перечнем контракта); иначе —
`NOT_IMPLEMENTED` с сообщением «нормализация ответа ИС Сканирование реализуется в Срезе 3». Срез 3 подменяет реализацию
### D-5. Нормализатор — интерфейс; в Срезе 0 сводит ошибки по файлам и отвечает `NOT_IMPLEMENTED`
`Normalizer.normalize(raw: object) -> NormalizeOutcome(status, documents, error, metrics)` (`raw` — склеенный ответ,
разобранная структура D-4; JSON каждого файла разобран при склейке, не-объект на входе -> `INVALID_FORMAT`).
`NullNormalizer` относит файл к «ошибочным», если `parse_error` не `null` либо `response.error.code` — непустая строка;
**успех файла — `error.code == ""`** (реальный успешный ответ ИС несёт `error = {"code": "", "message": ""}`, поэтому
проверка «`error` не `null`» неверна; отсутствующий или `null` `error` ошибкой не считается). Если ошибочны **все**
файлы — `status = error`, `error.code` — код первого ошибочного файла по имени (коды Content AI совпадают с перечнем
контракта; для `parse_error` — `INVALID_FORMAT`; код вне перечня — `UNKNOWN_ERROR` с исходным кодом в сообщении),
`error.message` — его сообщение; `metrics.warnings` — по строке на каждый ошибочный файл вида `<имя>: <код>: <сообщение>`.
Если есть хотя бы один успешный файл — `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` (точное имя, затем префикс) → через `SCAN_EMULATOR_DELAY` запись `Output/<uuid>/<uuid>.json`
(с подстановкой `file_uid = <uuid>`); нет пары → ответ Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT",
"message": "..."}, "data_ocr": []}`. Обработанные входящие файлы переименовываются в `<uuid>_<name>.<ext>.done`, чтобы
не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его.
`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": []}`
как раньше. Обработанные входящие файлы переименовываются в
`<uuid>_<name>.<ext>.done`, чтобы не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его.
### D-7. Ошибки транспорта — результат, не молчание
Запрос не по схеме / `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (без ack); S3 get упал → `FILE_READ_ERROR`; `put` в горячую папку
упал → `RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); ответ не JSON → `INVALID_FORMAT`; прочее →
упал → `RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); файл ответа не JSON → `parse_error` файла (D-4), а
если ошибочны все файлы — `INVALID_FORMAT` (D-5); прочее →
`UNKNOWN_ERROR`. Конверт публикуется всегда с повторами публикации до `PUBLISH_RETRIES=3`; не удалось — лог error,
ядро закроет по `reply_deadline`.
@ -66,13 +88,16 @@ NATS без JetStream: сообщение доставляется один ра
queue group восстанавливается клиентом; `/healthcheck` отдаёт `nats` и `hotfolder`.
### D-9. Тесты
Unit: `LocalHotFolder` на `tmp_path`; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end
«put → эмулятор → poll» без Docker; обработчик воркера с фейками S3/NATS — ack до обработки, повторный запрос, все коды
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_5.pdf` → ack, затем ошибка ИС `INVALID_DOCUMENT` с
`raw_s3_key`; для синтетического успешного ответа (`ok_doc.pdf`, R-10) — конверт `NOT_IMPLEMENTED` с `raw_s3_key`;
объект `raw/` в MinIO.
`scan-emulator` в compose пока нет) — `request-image.json` для `aep_3.pdf` → ack, затем `NOT_IMPLEMENTED` с
`metrics.warnings` про `aep_3_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 нет:
@ -86,8 +111,11 @@ D-7 и таймаут (с укороченными настройками). `Smb
- [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре.
- [Падение шлюза после ack теряет задание до `reply_deadline`] → осознанно: без JetStream иначе нельзя без своей очереди;
ядро переводит в `timeout`, replay — Срез 3+.
- [Эмулятор знает только две пары фикстур] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые
пары добавляются в `fixtures/ocr` без изменения кода.
- [Эмулятор знает только пары из `fixtures/ocr`] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые
пары и новые файлы набора (`<name>_*.json`) добавляются в `fixtures/ocr` без изменения кода.
- [ИС пишет набор с паузой больше `SCAN_SETTLE_SECONDS` между файлами] → шлюз склеит неполный набор, недостающие
документы потеряны (повторов нет, OQ-15) → значение настраивается; проверить на стенде в Срезе 3.
- [`Tmp` и `Input` на разных ФС/шарах] → `rename` перестаёт быть атомарным → `SCAN_TMP_DIR` обязан лежать на той же шаре.
## Migration Plan
@ -95,8 +123,9 @@ D-7 и таймаут (с укороченными настройками). `Smb
## Open Questions
Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего каталогов и путь SMB
на стенде ИС Сканирование — нужны к Срезу 3.
Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего/служебного каталогов,
путь SMB и подходящее `SCAN_SETTLE_SECONDS` на стенде ИС Сканирование — нужны к Срезу 3. OQ-76/77/78/79 решены человеком
04.10.2026 (см. `open-questions-proposal.md`).
## Decisions made without discussion
@ -106,8 +135,8 @@ D-7 и таймаут (с укороченными настройками). `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` | честный статус; коды Content AI совпадают с контрактом | нет |
| 6 | 2026-10-03 | D-6: эмулятор в этом репозитории, тот же образ, поиск фикстуры по имени/префиксу, `.done` после обработки | общие соглашения горячих папок; `aep_8.pdf` ↔ `aep_8_form4.json` | нет |
| 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 | ответы пользователя | смена тега |
@ -115,7 +144,7 @@ D-7 и таймаут (с укороченными настройками). `Smb
| 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` | неоднозначность плана («put пишет <uuid>_<name>»); спека требует `RECOGNITION_FAILED` при отсутствии корня | нет |
| 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 |
@ -132,11 +161,11 @@ D-7 и таймаут (с укороченными настройками). `Smb
| 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>/`) | спека; реальная ИС может забирать файлы без маски — риск вынесен в `open-questions-proposal.md` | запись `.part` в служебный каталог, чтение всех файлов подкаталога |
| 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`); реальные фикстуры используются для сценария «ошибка ИС проброшена» | невозможно получить успешный сценарий из имеющихся фикстур; спека не меняется | если владелец ждёт, что `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` | контейнеру эмулятора не нужны переменные MinIO/NATS | нет |
| 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` = отсутствует (текст по умолчанию) | D-5 писал `raw: dict`, а «не JSON» из плана 5.1 нельзя разобрать, не имея байтов | если воркер захочет отдать декодирование нормализатору — перенести `json.loads` |
| 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]** | 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 буферизует публикации при переподключении | нет |
@ -147,7 +176,7 @@ D-7 и таймаут (с укороченными настройками). `Smb
| 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` | 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 | экономия ИС; недописанный ответ на шаре без атомарного rename; ограничения размера публикации | поздний результат отброшен; неподвижная ошибка NATS требует перезапуска пода; зависший `poll` по SMB (до 60 с) не прерывается дедлайном (T12.2) |
| 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 МБ) | контейнера `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` |
@ -155,3 +184,13 @@ D-7 и таймаут (с укороченными настройками). `Smb
| 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: файл, не разобранный `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: набор ответов эмулятор записывает во временный каталог и переименовывает каталог в `Output/<uuid>/` целиком; «готовность по settle» в шлюзе проверяется отдельным тестом (файл дописывается во время опроса) | шлюз не должен видеть частичный набор от эмулятора; поведение settle всё равно нужно тестировать на подмене | rename каталога на нестандартной ФС (compose-том) может быть неатомарным — тогда набор виден по частям, settle закрывает |

View File

@ -118,10 +118,10 @@ python -m src.scan_emulator` завершается кодом 2 с сообще
|---|---|---|---|
| 1 | `PUBLISH_RETRIES` = число повторов после первой попытки (1+3 = 4 попытки) | R-13 (design #37), T11.1; `api/src/core/config.py` | Согласовать с tabular-parser (там может быть «3 попытки всего»); зафиксировать в `03-architecture.md` одной фразой |
| 2 | `03-architecture.md` («Схема `schema_version`»): «неизвестный MAJOR -> `SCHEMA_MISMATCH` и ack»; спека change'а и воркер — без ack | R-16 (design #41), T12.13 | Привести документ в соответствие (ack не публикуется, конверт `error` — если из тела извлекаются `job_id` и `file_id`) либо пересмотреть правило и воркер: ack на отвергнутый запрос защищает ядро от зацикливания повторов доставки, цена — лишнее сообщение и расхождение с R-16 |
| 3 | Реальные фикстуры `fixtures/ocr/aep_5.json`, `aep_8_form4.json` — ответы-ошибки ИС (`error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`) | O-1, R-10 (design #32) | Для Среза 3 (OQ-13/14) нужны образцы **успешных** ответов Content AI; пока успех проверяется синтетическим ответом, `NullNormalizer` на реальных фикстурах пропускает ошибку ИС |
| 4 | §11 `05-domain-rules.md` описывает `data_ocr[]` (список), а в фикстурах `data_ocr` — объект с `label/organization/inn/units/date/table_structure` | O-1, T3.2 | Выяснить фактический формат на стенде ИС и поправить §11 или фикстуры; решение нужно до нормализатора Среза 3 |
| 5 | Шлюз читает ровно `<output>/<job_id>/<job_id>.json` (спека); старый код читал все файлы каталога `Output/<id>/` | T8.5 | Проверить на стенде ИС в Срезе 3: имя ответа, наличие дополнительных файлов, момент готовности |
| 6 | Временный файл `.<имя>.part` лежит в `Input` (атомарная запись `.part` + rename, R-5) — реальная ИС без маски может забрать недописанный файл | R-5, T7/T8 | Вариант: служебный подкаталог рядом (`Input/.tmp` или корень) + `rename` в `Input`; либо уточнить у владельцев ИС маску/поведение |
| 3 | **Решено человеком 04.10.2026 (OQ-76, частично):** исходно — реальные фикстуры `fixtures/ocr/aep_5.json`, `aep_8_form4.json` были ответами-ошибками ИС (`error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`). Добавлены реальные успешные образцы: `aep_3.pdf` + `aep_3.json` (успех: «Бухгалтерский баланс» 42 строки + «Отчет о финансовых результатах» 18 строк) + `aep_3_ofr.json` (второй ответ ИС по тому же файлу, `INVALID_DOCUMENT`), `aep_6.pdf` + `aep_6.json` (успех: баланс 122 строки с шапкой дат + ОФР 49 строк); `aep_5.json`, `aep_8_form4.json` — ответы-ошибки | O-1, R-10 (design #32); OQ-76 | Транспорт Среза 0 проверяется на реальных образцах (склейка, `NOT_IMPLEMENTED`, `metrics.warnings`), синтетический успешный ответ не нужен; разбор успешных ответов — Срез 3 (нормализатор, OQ-13/14) |
| 4 | **Решено человеком 04.10.2026 (OQ-77):** исходно — §11 `05-domain-rules.md` описывал `data_ocr[]` (список), а в фикстурах `data_ocr` — объект. Факт по `aep_3.json`, `aep_6.json`: `data_ocr` — **объект**, один на json-файл (`label`, `organization`, `inn`, `units`, `date` — `{form_name, confidence}` с `confidence` 0–100; `table_structure` — **список таблиц** `{label, rows[]}`, одна таблица = один документ БС/ОФР, в одном json их может быть несколько); успех — `error = {"code": "", "message": ""}` (не `null`), ошибка — непустой `code` и пустой `table_structure`. §11 исправлен в `docs` | O-1, T3.2; OQ-77 | В Срезе 0 успех файла определяется как `error.code == ""` (design #57); разбор таблиц — Срез 3 |
| 5 | **Решено человеком 04.10.2026 (OQ-78):** исходно шлюз ожидал единственный файл `<output>/<job_id>/<job_id>.json`, старый код читал все файлы каталога `Output/<id>/`. Факт: ИС кладёт **все распознанные документы одновременно** в каталог `<job_id>`, по одному json на документ (`aep_3.json` + `aep_3_ofr.json`); шлюз читает **все** `*.json` каталога и склеивает в один ответ; готовность — по стабилизации набора (`SCAN_SETTLE_SECONDS`, 2 с); ошибка одного документа не отменяет остальные (`metrics.warnings`) | T8.5; OQ-78 | Реализуется доработкой PR #3 (tasks 7.x, design #54–#58); подходящее значение settle и поведение ИС на реальной шаре — проверить на стенде в Срезе 3 |
| 6 | **Решено человеком 04.10.2026 (OQ-79):** исходно временный файл `.<имя>.part` лежал в `Input` (R-5), и ИС без маски могла забрать недописанный файл. Решение: временный файл уходит в служебный каталог `SCAN_TMP_DIR` (по умолчанию `Tmp`) рядом с `Input`/`Output`, на той же шаре/ФС, затем `rename` в `Input` (атомарно) | R-5, T7/T8; OQ-79 | Реализуется доработкой PR #3 (tasks 7.x, design #59); `Tmp` должен лежать на той же шаре, что `Input`; поведение ИС на реальной шаре — проверить в Срезе 3 |
| 7 | Сценарий «NATS перезапущен -> подписка восстановлена за <= 20 с» не автоматизирован: общий NATS нельзя перезапускать в проверках | T11.7 | Ручная проверка на отдельном `nats-server` (поднять, подключить шлюз, убить, поднять снова) либо отдельный compose-профиль для тестов |
| 8 | Штатная остановка не публикует конверты для принятых (ack) заданий; D-2 допускает потерю при падении, при rolling deploy задание теряется до `reply_deadline` | T12.5, D-2 | Публиковать `UNKNOWN_ERROR` «сервис остановлен» в `stop()` для всех принятых заданий; Срез 3+ |
| 9 | `/healthcheck` всегда 200, даже если NATS постоянно недоступен (`nats: down`) | T12.10 | Платформе (readiness/liveness) читать поле `nats`/`hotfolder`, а не код ответа; либо отдельные `/ready` и `/live` |
@ -164,8 +164,9 @@ python -m src.scan_emulator` завершается кодом 2 с сообще
отдельным PR репозитория `docs`.
- [ ] Согласовать с владельцем tabular-parser семантику `PUBLISH_RETRIES` (пункт 1) и решить судьбу правила про
`SCHEMA_MISMATCH` + ack (пункт 2).
- [ ] Занести пункты 3-5 (образцы успешных ответов Content AI, формат `data_ocr`, имя файла ответа) в
`docs/context/07-open-questions.md`; пункты 6-8, 15 — в follow-up issue (`Follow-ups from #1`).
- [x] Пункты 3-6 (OQ-76/77/78/79: образцы успешных ответов, формат `data_ocr`, состав файлов ответа, временный файл)
решены человеком 04.10.2026 и внесены в `docs/context/07-open-questions.md`; пункты 7, 8, 15 — в follow-up issue
(`Follow-ups from #1`).
- [ ] Не архивировать change `slice-0-skeleton`, пока compose-фрагмент не применён в `docs` и не проверена команда
`docker compose --profile services up -d --wait scan-gateway scan-emulator` (I-2).
- [ ] Решить I-3 (пункт 17: replay с `attempt + 1` для задания из LRU процесса).

View File

@ -21,25 +21,38 @@
`Dockerfile` (`python:3.11-slim`; строка убрана из `.gitignore`), `Makefile` с общими целями + `venv`, `emulator`,
`.env.example`, `.env.test`; `Settings` читает `ENV_FILE`; `SERVICE_NAME = "scan-gateway"`.
- **Контракт**: вендоренные схемы `api/contracts/` + pydantic-модели `src/contract/`; тест идентичности с `../docs/contracts`.
- **Горячие папки** `src/gateway/`: протокол `HotFolder` (`put(name, data)`, `poll(uuid) -> bytes | None`) с двумя
реализациями — `LocalHotFolder` (каталог; dev и тесты) и `SmbHotFolder` (`smbclient` шаблона; прод). Имена подкаталогов
настраиваются (`SCAN_INPUT_DIR = "Input"`, `SCAN_OUTPUT_DIR = "Output"`); файл кладётся как `<job_id>_<original_name>`,
ответ ожидается в `<output>/<job_id>/<job_id>.json`.
- **Горячие папки** `src/gateway/`: протокол `HotFolder` (`put(name, data)`, `poll(job_id) -> list[AnswerFile] | None`,
`healthy()`) с двумя реализациями — `LocalHotFolder` (каталог; dev и тесты) и `SmbHotFolder` (`smbclient` шаблона;
прод). Имена подкаталогов настраиваются (`SCAN_INPUT_DIR = "Input"`, `SCAN_OUTPUT_DIR = "Output"`, `SCAN_TMP_DIR =
"Tmp"`); файл кладётся как `<job_id>_<original_name>`; временный файл атомарной записи (`.part`) лежит в служебном
каталоге `<корень>/<SCAN_TMP_DIR>/` рядом с `Input`/`Output` (на той же шаре/ФС) и переименовывается в `Input`, так что
в `Input` недописанных файлов не бывает (OQ-79). ИС кладёт **все** распознанные документы задания одновременно в
`<output>/<job_id>/` — по одному `*.json` на документ (OQ-78); `poll` возвращает все `*.json` каталога по возрастанию
имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров не менялся `SCAN_SETTLE_SECONDS` (2).
- **Воркер** `src/core/worker.py` (NATS без JetStream): подписка на `recognition.image.request` в queue group
`scan-gateway`; по получении — сразу ack `processing` в `recognition.result`; до `SCAN_MAX_INFLIGHT` (4) заданий
параллельно: S3 `uploads/…` → горячая папка → опрос каждые `SCAN_POLL_SECONDS` (5) до
`min(reply_deadline, now + SCAN_TIMEOUT_SECONDS=300)` → сырой ответ в S3 `raw/{job_id}.json` → нормализатор →
`min(reply_deadline, now + SCAN_TIMEOUT_SECONDS=300)` → склейка всех `*.json` ответа в один сырой ответ
`{"job_id", "files": [{"name", "response", "parse_error"}]}` в S3 `raw/{job_id}.json` → нормализатор →
`results/{file_id}/{job_id}/result.json` → конверт результата (`raw_s3_key` заполнен). Таймаут → `error.code = TIMEOUT`.
Process-local множество `job_id` в работе: повторный запрос получает ack и не создаёт второй файл в горячей папке.
- **Нормализатор как интерфейс** `src/normalizer/`: протокол `Normalizer(raw_json) -> documents | error`; в Срезе 0 —
`NullNormalizer`: ошибки Content AI (`error.code` из ответа: `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`,
`RECOGNITION_FAILED`, `UNKNOWN_ERROR`) пробрасываются как есть; успешный ответ → `NOT_IMPLEMENTED` (Срез 3 реализует §11).
- **Нормализатор как интерфейс** `src/normalizer/`: протокол `Normalizer(merged) -> documents | error`, вход — склеенный
ответ; в Срезе 0 — `NullNormalizer`. Успех файла ИС — `error.code == ""` (поле `error` не `null`: у реального успешного
ответа `error = {"code": "", "message": ""}`); ошибка — непустой `error.code` (`FILE_READ_ERROR`, `INVALID_FORMAT`,
`INVALID_DOCUMENT`, `RECOGNITION_FAILED`, `UNKNOWN_ERROR`) либо `parse_error` (файл не разобран как JSON). Если ошибочны
**все** файлы — результат `error` с кодом первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`),
каждый ошибочный файл — строка `<имя>: <код>: <сообщение>` в `metrics.warnings`. Есть хотя бы один успешный файл →
`NOT_IMPLEMENTED` (Срез 3 реализует §11), ошибочные файлы — в `metrics.warnings` так же.
- **Эмулятор ИС Сканирование** `src/scan_emulator/` (`python -m src.scan_emulator`): следит за `Input/`, через
`SCAN_EMULATOR_DELAY` (2 с) кладёт в `Output/<uuid>/<uuid>.json` содержимое `SCAN_FIXTURES_DIR/<имя без uuid и расширения>*.json`
(`aep_5.pdf` → `aep_5.json`, `aep_8.pdf` → `aep_8_form4.json`), для неизвестных имён — ответ Content AI с
`error.code = INVALID_DOCUMENT`. Запускается тем же образом в compose (`scan-emulator`).
`SCAN_EMULATOR_DELAY` (2 с) кладёт в `Output/<uuid>/` **набор** ответов: все `SCAN_FIXTURES_DIR/<name>.json` и
`<name>_*.json` (`<name>` — имя без uuid и расширения; не префикс `<name>*`) под именами `<uuid>.json` и
`<uuid>_<suffix>.json`, с `file_uid = <uuid>` (`aep_3.pdf` → `<uuid>.json` + `<uuid>_ofr.json`, `aep_6.pdf` → один
файл, `aep_8.pdf` → `<uuid>_form4.json`); набор появляется в `Output/<uuid>/` атомарно целиком (временный каталог +
переименование каталога). Для неизвестных имён — один ответ Content AI с `error.code = INVALID_DOCUMENT`. Запускается
тем же образом в compose (`scan-emulator`).
- **Ошибки транспорта** → результат: запрос не по схеме → `SCHEMA_MISMATCH`; S3 не читается → `FILE_READ_ERROR`;
горячая папка недоступна → `RECOGNITION_FAILED`; ответ ИС не JSON → `INVALID_FORMAT`; исключение → `UNKNOWN_ERROR`.
горячая папка недоступна → `RECOGNITION_FAILED`; ответ ИС не JSON (после готовности набора) → `parse_error` файла,
при ошибочности всех файлов — `INVALID_FORMAT`; исключение → `UNKNOWN_ERROR`.
- **Healthcheck** дополняется полями `nats` и `hotfolder` (`ok`/`down`).
- **Проверки качества** вместо CI: `make check` и `make check-full` по `../docs/dev/checks.md`.

View File

@ -33,42 +33,80 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO
### Requirement: Передача файла в ИС Сканирование через горячие папки
После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как
`<job_id>_<original_name>`; затем SHALL опрашивать исходящий каталог `<SCAN_OUTPUT_DIR>/<job_id>/<job_id>.json` каждые
`SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`.
`<job_id>_<original_name>`; временный файл записи SHALL лежать в служебном каталоге `<корень>/<SCAN_TMP_DIR>/`
(по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и переименовываться в `Input` атомарно, так
что во входящем каталоге MUST NOT появляться недописанные и временные файлы. Затем сервис SHALL опрашивать исходящий
каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`.
ИС кладёт в этот каталог все распознанные документы задания — по одному `*.json` на документ. Ответ SHALL считаться
готовым, когда в каталоге есть хотя бы один `*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS`
(по умолчанию 2); готовый набор SHALL включать **все** `*.json` каталога по возрастанию имени, остальные файлы
игнорируются.
#### Scenario: Файл положен во входящий каталог
- **WHEN** получен запрос для `aep_5.pdf` с `job_id = 3f1c…`
- **THEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`, байт в байт равный объекту S3
- **THEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`, байт в байт равный объекту S3; во время записи во входящем каталоге нет временных файлов, а `Tmp/` существует
#### Scenario: Ответ получен
- **WHEN** в `Output/3f1c…/3f1c….json` появляется ответ ИС до истечения таймаута
- **THEN** опрос прекращается не позднее чем через `SCAN_POLL_SECONDS`, ответ передаётся дальше по конвейеру
#### Scenario: Ответ из одного файла
- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS`
- **THEN** опрос прекращается не позднее чем через `SCAN_POLL_SECONDS` после этого, ответ из одного файла передаётся дальше по конвейеру
#### Scenario: Ответ из нескольких файлов
- **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS`
- **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется
#### Scenario: Набор ещё пишется
- **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад
- **THEN** ответ не считается готовым, опрос продолжается до дедлайна
### Requirement: Сырой ответ сохраняется в S3 до нормализации
Полученный ответ ИС SHALL быть записан как есть в `raw/{job_id}.json` того же бакета **до** вызова нормализатора;
ключ SHALL передаваться в конверте результата полем `raw_s3_key`; в БД объект не регистрируется (ядро его не создаёт).
Все файлы ответа ИС SHALL быть склеены в один объект `{"job_id": "<job_id>", "files": [{"name": "<имя файла>",
"response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}` (файлы по возрастанию имени) и записаны
в `raw/{job_id}.json` того же бакета **до** вызова нормализатора; файл, не разобранный как JSON, SHALL получить
`response = null` и заполненный `parse_error` и MUST NOT прерывать задание. Ключ SHALL передаваться в конверте результата
полем `raw_s3_key` (одна строка `raw/{job_id}.json`); в БД объект не регистрируется (ядро его не создаёт).
#### Scenario: Нормализатор упал
- **WHEN** ответ ИС получен, а нормализатор бросил исключение
- **THEN** объект `raw/{job_id}.json` существует в S3, конверт содержит `raw_s3_key` и `error.code = UNKNOWN_ERROR`
#### Scenario: Склеенный сырой ответ
- **WHEN** ИС положила `aep_3.json` и `aep_3_ofr.json` (имена с префиксом `job_id`)
- **THEN** `raw/{job_id}.json` содержит `job_id` и `files` из двух элементов по возрастанию имени, у каждого `response` — разобранный ответ, `parse_error = null`
#### Scenario: Файл не JSON
- **WHEN** один из файлов ответа не разбирается как JSON
- **THEN** в `files` для него `response = null`, `parse_error` непустой; остальные файлы разобраны, задание не падает
### Requirement: Результат по контракту
Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с
`service.name = "scan-gateway"`, `Nats-Msg-Id = result:<job_id>:<attempt>`; при `status ∈ {done, needs_check}` — файл
результата в `results/{file_id}/{job_id}/result.json` и `result_s3`; при неудаче публикации SHALL повторить её до трёх раз.
В Срезе 0 нормализатор SHALL возвращать: при `error ≠ null` в ответе ИС — `status = error` с кодом из ответа; иначе —
`error.code = NOT_IMPLEMENTED`.
В Срезе 0 нормализатор получает склеенный ответ и SHALL считать файл успешным, если `error.code == ""` (поле `error`
реального успешного ответа — `{"code": "", "message": ""}`, а не `null`; отсутствующий или `null` `error` ошибкой не
считается), и ошибочным, если `error.code` непуст либо `parse_error` не `null`; успех MUST NOT определяться проверкой
«`error` не `null`». Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у первого
ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`). Если есть хотя бы один успешный файл —
`status = error`, `error.code = NOT_IMPLEMENTED`. В обоих случаях `metrics.warnings` SHALL содержать по одной строке
`<имя>: <код>: <сообщение>` на каждый ошибочный файл.
#### Scenario: Успешный ответ ИС в Срезе 0
- **WHEN** ответ ИС содержит `data_ocr` без `error`
- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/<job_id>.json`
- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (`aep_6.json`)
- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/<job_id>.json`, `metrics.warnings` пуст или отсутствует
#### Scenario: Ошибка ИС
- **WHEN** ответ ИС содержит `error.code = INVALID_DOCUMENT`
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен
- **WHEN** единственный файл ответа содержит `error.code = INVALID_DOCUMENT`
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `<имя>: INVALID_DOCUMENT: <сообщение>`
#### Scenario: Ошибка одного из документов
- **WHEN** ответ состоит из `aep_3.json` (успех) и `aep_3_ofr.json` (`INVALID_DOCUMENT`)
- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `metrics.warnings` содержит ровно одну строку про `aep_3_ofr.json`
#### Scenario: Все документы ошибочны
- **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON)
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит две строки; файл не JSON в warnings несёт код `INVALID_FORMAT`
### Requirement: Таймаут ожидания ответа
Если ответ не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`;
Если готовый набор ответа (см. «Передача файла…») не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`;
повторная отправка файла в ИС MUST NOT выполняться.
#### Scenario: ИС молчит
@ -78,7 +116,8 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO
### Requirement: Ошибки транспорта превращаются в результат
Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 →
`SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не
удалась → `RECOGNITION_FAILED`; ответ ИС не разбирается как JSON → `INVALID_FORMAT`; иное исключение → `UNKNOWN_ERROR`.
удалась → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error` этого файла, а если ошибочны все
файлы — `INVALID_FORMAT`; иное исключение → `UNKNOWN_ERROR`.
Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться.
#### Scenario: Горячая папка недоступна

View File

@ -10,27 +10,42 @@
### Requirement: Эмулятор отвечает по протоколу горячих папок
Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; для нового файла
`<uuid>_<name>.<ext>` через `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) SHALL создать `<output>/<uuid>/<uuid>.json`;
после обработки входящий файл SHALL быть переименован в `<uuid>_<name>.<ext>.done`, чтобы не обрабатываться повторно.
`<uuid>_<name>.<ext>` через `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) SHALL создать каталог `<output>/<uuid>/` с
набором ответов (по одному файлу на документ); каталог SHALL появиться в `<output>` атомарно целиком: набор записывается
во временный каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `<uuid>_<name>.<ext>.done`, чтобы не обрабатываться повторно.
#### Scenario: Один файл
- **WHEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`
- **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `…pdf.done`
### Requirement: Ответ берётся из фикстур по имени
Эмулятор SHALL искать в `SCAN_FIXTURES_DIR` файл `<name>.json`, затем `<name>*.json` (первый по алфавиту), где `<name>` —
имя входящего файла без префикса `<uuid>_` и расширения; содержимое SHALL быть записано в ответ с `file_uid = <uuid>`.
#### Scenario: Набор появляется целиком
- **WHEN** эмулятор записывает набор из нескольких файлов
- **THEN** каталог `Output/3f1c…/` либо не существует, либо уже содержит весь набор; частичного набора в `Output` нет
### Requirement: Ответы берутся из фикстур по имени
Эмулятор SHALL собрать в `SCAN_FIXTURES_DIR` **все** файлы `<name>.json` и `<name>_*.json`, где `<name>` — имя входящего
файла без префикса `<uuid>_` и расширения (регистр расширения входящего файла не важен). Файл `<name>.json` SHALL быть
записан как `<uuid>.json`, файл `<name>_<suffix>.json` — как `<uuid>_<suffix>.json`; в каждом `file_uid = <uuid>`.
Фикстуры вида `<name><другие символы>.json` без разделителя `_` (например, `aep_30.json` для `aep_3`) MUST NOT попадать в набор.
#### Scenario: Точное имя
- **WHEN** входящий файл — `…_aep_5.pdf`, в фикстурах есть `aep_5.json`
- **THEN** ответ равен `aep_5.json` с заменённым `file_uid`
- **THEN** в `Output/<uuid>/` один файл `<uuid>.json`, равный `aep_5.json` с заменённым `file_uid`
#### Scenario: Префикс
#### Scenario: Суффикс
- **WHEN** входящий файл — `…_aep_8.pdf`, в фикстурах есть `aep_8_form4.json`
- **THEN** ответ равен `aep_8_form4.json` с заменённым `file_uid`
- **THEN** в `Output/<uuid>/` файл `<uuid>_form4.json`, равный `aep_8_form4.json` с заменённым `file_uid`
#### Scenario: Несколько файлов на одно задание
- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть `aep_3.json` и `aep_3_ofr.json`
- **THEN** в `Output/<uuid>/` два файла: `<uuid>.json` (из `aep_3.json`) и `<uuid>_ofr.json` (из `aep_3_ofr.json`), в обоих `file_uid = <uuid>`
#### Scenario: Чужой префикс
- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть также `aep_30.json`
- **THEN** `aep_30.json` в набор не попадает
### Requirement: Неизвестный файл — ошибка в формате Content AI
Если пара не найдена, эмулятор SHALL записать ответ `{"file_uid": "<uuid>", "error": {"code": "INVALID_DOCUMENT",
Если ни одного файла набора не найдено, эмулятор SHALL записать один ответ (`<uuid>.json`) `{"file_uid": "<uuid>", "error": {"code": "INVALID_DOCUMENT",
"message": "<имя>: нет эталонного ответа в фикстурах"}, "data_ocr": []}`.
#### Scenario: Нет пары

View File

@ -69,3 +69,36 @@
на ветке `feat/1-slice-0-skeleton`
- [x] 6.3 Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md`; `openspec validate
slice-0-skeleton --strict`; вывод `make check-full` — в описание PR
## 7. Доработка по реальным ответам ИС
Основание: решения человека 04.10.2026 (OQ-76/77/78/79), план `docs/superpowers/plans/2026-10-04-multi-file-response.md`,
design №54–62. Контракт v1.0 не меняется; нормализация в `statementDocument` — Срез 3.
- [x] 7.0 Привести change в соответствие с решениями человека: `proposal.md`, `design.md` (D-1, D-3–D-6, решения №54–62),
`specs/image-recognition-gateway/spec.md`, `specs/scan-emulator/spec.md`, `open-questions-proposal.md`
(OQ-76/77/78/79 решены), эта группа; проверка: `openspec validate slice-0-skeleton --strict` — valid; в спеках нет
«читает ровно `<job_id>.json`», `.part` в `Input`, `data_ocr` как списка и успеха по `error != null`
- [ ] 7.1 Горячие папки: `SCAN_TMP_DIR` (`Tmp`), `SCAN_SETTLE_SECONDS` (2); `HotFolder.poll(job_id)` возвращает `None` либо
список `AnswerFile(name, data)` — все `*.json` каталога ответа по имени после settle; `.part` создаётся в `<корень>/Tmp/`
(`LocalHotFolder`, `SmbHotFolder`), в `Input` — только итоговый файл; проверка: unit-тесты на `tmp_path` (нет `.part` в
`Input`; нет каталога → `None`; один json; два json по имени; дописываемый файл → `None` до стабилизации; посторонний файл
игнорируется; `SmbHotFolder` с подменённым `smbclient`), `make check` зелёный
- [ ] 7.2 Склейка, сырой файл, нормализатор, воркер: `merge_answers(job_id, files)`; `raw/{job_id}.json` = склеенный ответ;
`NullNormalizer` по правилам D-5 (успех = `error.code == ""`, per-file ошибки в `metrics.warnings`, `NOT_IMPLEMENTED`
при хотя бы одном успешном файле); проверка: unit-тесты `merge_answers` (порядок, невалидный json → `parse_error`),
нормализатор на реальных `aep_6.json`, `aep_3.json` (не ошибка), `aep_3_ofr.json`, `aep_5.json` (`INVALID_DOCUMENT`),
`aep_3.json` + `aep_3_ofr.json` → `NOT_IMPLEMENTED` с одной строкой warnings, воркер с фейками; `make check` зелёный
- [ ] 7.3 Эмулятор: набор файлов на задание — `<name>.json` и `<name>_*.json` в `Output/<uuid>/` как `<uuid>.json` /
`<uuid>_<suffix>.json` с `file_uid = <uuid>`, атомарная запись каталога; проверка: unit-тесты (маркер `fixtures`) —
`aep_3.pdf` → два файла, `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `<uuid>_form4.json`, `aep_30*` не
попадает в набор `aep_3`, неизвестное имя → `INVALID_DOCUMENT`; сквозной «put → эмулятор → poll (с settle) → склейка» на
`aep_3`; `make check` зелёный
- [ ] 7.4 Интеграционные тесты и документация: реальные NATS и MinIO — `aep_3.pdf` → `NOT_IMPLEMENTED` с warnings про
`aep_3_ofr.json` и двумя файлами в `raw/`, `aep_6.pdf` → `NOT_IMPLEMENTED` без warnings, `aep_5.PDF` → `INVALID_DOCUMENT`;
`README.md`, `CLAUDE.md`, `.env.example`, `.env.test` актуализированы (`SCAN_TMP_DIR`, `SCAN_SETTLE_SECONDS`, склеенный raw);
проверка: `make check-full` (при поднятом `make up`) — exit 0, вывод в отчёт
- [ ] 7.5 Проверка в реальном compose (без правок `../docs`): сервисы `scan-gateway` и `scan-emulator` собираются и стартуют,
сценарии `aep_3.pdf`, `aep_6.pdf`, `aep_5.PDF` проходят способом из `../docs/dev/README.md`, содержимое
`raw/<job_id>.json` зафиксировано; окружение остановлено (`down -v`); необходимые правки compose (`SCAN_TMP_DIR`,
`SCAN_SETTLE_SECONDS`) — точным фрагментом в `open-questions-proposal.md`; проверка: отчёт со снимками выводов