# scan-gateway — доработка по реальным ответам ИС Сканирование (Срез 0, правки в PR #3) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Branch:** `feat/1-slice-0-skeleton` (worktree `.wt/slice-0/scan-gateway`), поверх уже выполненного плана `2026-10-03-slice-0-skeleton.md`. PR #3 открыт, правки идут в ту же ветку. **Spec:** `openspec/changes/slice-0-skeleton/` (specs `image-recognition-gateway`, `scan-emulator`, `design.md`). Спека — единственный источник истины; задача 1 приводит её в соответствие с решениями человека 04.10.2026, остальные задачи реализуют обновлённую спеку. ## Решения человека (04.10.2026) — основание правок 1. **OQ-78.** ИС Сканирование кладёт **все распознанные документы одновременно** в каталог `//` — по одному json на документ. Шлюз читает **все** json каталога и склеивает в один ответ (как в старом коде `reference-old-arm/api/src/core/PDFWorker/PDFTempPoller.py`, но с понятной структурой). 2. **OQ-79.** Временный файл атомарной записи уходит из `Input` в служебный каталог (`SCAN_TMP_DIR`, по умолчанию `Tmp`, рядом с `Input`/`Output`, на той же шаре/ФС, чтобы `rename` оставался атомарным). 3. **OQ-77 / OQ-76.** Образцы реальных ответов добавлены в `../fixtures/ocr`: `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` — ответы-ошибки. ## Факты о формате реального ответа (проверены по `../fixtures/ocr/aep_3.json`, `aep_3_ofr.json`, `aep_6.json`) - Корень: `{"file_uid": str, "error": {"code": str, "message": str}, "data_ocr": {...}}`. **`data_ocr` — объект, не список** (`label`, `organization`, `inn`, `units`, `date` — каждый `{"form_name": str, "confidence": number 0..100}`; `table_structure` — **список таблиц** `{"label": {"form_name": "Бухгалтерский баланс" | "Отчет о финансовых результатах", "confidence"}, "rows": [{"row_index": int, "cells": [{"column_index": int, "value": str, "confidence": number}]}]}`). - **Успех = `error.code == ""`** (а `error` не `null`!): `{"code": "", "message": ""}`. Ошибка: `code` непустой (`INVALID_DOCUMENT`, …), `data_ocr` с пустыми полями и `table_structure: []`. Проверка «`error` не null» для реального ответа **неверна**. - В одном json может быть несколько таблиц разных форм (в `aep_3.json` и `aep_6.json` — баланс и ОФР), у каждой свой `label`; тип документа задаёт `label`. Таблицы не интерпретируются в Срезе 0 (Срез 3). - `confidence` — числа 0–100 (OQ-13 закрыт для scan-gateway); поля организации/ИНН/даты — шумный OCR-текст в `form_name` (в `aep_6.json` `date.form_name` — мусор, даты периодов — в строке-шапке таблицы `row_index 0`). Разбор — Срез 3, здесь не интерпретируется. ## Global Constraints - Сервис без БД и фронта (DECISION-4). Раскладка шаблона, команды из `api/`. Python 3.11 (`uv venv --python 3.11`), NATS без JetStream, один бакет S3. CI нет — `make check` / `make check-full`. - Контракт v1.0 в `../docs/contracts/` **не менять** (файлы схем/примеров не трогать; `raw_s3_key` — по-прежнему одна строка `raw/{job_id}.json`). Нормализация в `statementDocument` — Срез 3, не делать: `NullNormalizer` остаётся заглушкой. - Горячие папки: протокол `HotFolder` (расширить), режимы `local` / `smb`; каталоги `SCAN_INPUT_DIR` (`Input`), `SCAN_OUTPUT_DIR` (`Output`), `SCAN_TMP_DIR` (`Tmp`, новый). Файл во входящем каталоге — `_`. - Сырой ответ — в S3 `raw/{job_id}.json` **до** нормализации; в БД не регистрируется. Новая структура склеенного ответа — `{"job_id": str, "files": [{"name": "<имя файла>", "response": <разобранный json> | null, "parse_error": str | null}, ...]}`, файлы по возрастанию имени; в `files` — все `*.json` каталога ответа, другие файлы игнорируются. - Готовность: каталог ответа считается готовым, когда в нём есть хотя бы один `*.json` и набор `*.json` с их размерами не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2) — защита от недописанного набора; таймаут задания по-прежнему `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. - Результат (Срез 0, `NullNormalizer`, вход — склеенный ответ): файл с `error.code ≠ ""` или `parse_error ≠ null` — «ошибочный». **Все** файлы ошибочные → `status = error`, `error.code` — код первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`), `error.message` — его сообщение, `metrics.warnings` — по строке на каждый ошибочный файл (`<имя>: <код>: <сообщение>`). Есть хотя бы один успешный файл → `status = error`, `error.code = NOT_IMPLEMENTED` (нормализация — Срез 3), ошибочные файлы — в `metrics.warnings` так же. Успех никогда не принимается за ошибку по `error != null`. - Эмулятор: по входящему `_.` кладёт в `Output//` **все** фикстуры `.json` и `_*.json` из `SCAN_FIXTURES_DIR` (не префикс `*`: `aep_3` не должен забирать `aep_30*`), имена в каталоге ответа — `.json` для `.json` и `_.json` для `_.json`, подстановка `file_uid = `; пары нет — один ответ `INVALID_DOCUMENT` как раньше; запись набора — атомарно целиком (сначала во временный каталог эмулятора, затем rename каталога) либо с паузой между файлами — на усмотрение, но поведение должно быть покрыто тестом «готовность по settle». - Линтеры: ruff (120, E,F,I,UP,B,PL) + mypy; pytest-asyncio auto; маркеры `integration`/`fixtures`. Язык комментариев/коммитов — русский, идентификаторы — английские; коммит заканчивается строкой `Co-Authored-By: Claude Sonnet 5.5 `. ## Задачи ### Task 1: [R1] Обновить change под решения человека - [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 нет упоминаний «читает ровно `.json`» и `.part` в `Input`. ### Task 2: [R2] Горячие папки: чтение всех файлов, готовность, служебный каталог - [x] `src/gateway/` и настройки: `SCAN_TMP_DIR` (`Tmp`), `SCAN_SETTLE_SECONDS` (2); протокол `HotFolder.poll(job_id)` возвращает `None` (ответа нет/ещё не готов) либо список `AnswerFile(name: str, data: bytes)` в порядке возрастания имени — только `*.json` каталога `//`, с проверкой готовности по settle (набор имён и размеров не менялся `SCAN_SETTLE_SECONDS`); `LocalHotFolder` и `SmbHotFolder` — временный файл записи (`.part`) создаётся в `//` (каталог создаётся, если нет; на той же ФС/шаре) и переименовывается в `Input/` (атомарно); `healthy()` без изменений. Тесты: `tmp_path` — во время записи в `Input` нет `.part`, после put там только итоговый файл; poll: каталога нет → `None`; один json → после settle список из одного; два json → оба, по имени; файл дописывается (размер растёт) → `None` до стабилизации; посторонний не-json файл игнорируется; `SmbHotFolder` — с подменённым `smbclient`. Проверка: `make check` зелёный. ### Task 3: [R3] Склейка ответа, сырой файл, нормализатор, воркер - [x] `src/gateway/` (или `src/normalizer/`): функция склейки `merge_answers(job_id, files) -> MergedResponse` по структуре из Global Constraints (разбор каждого файла `json.loads`, невалидный json → `parse_error`); воркер: опрос `poll` → `raw/{job_id}.json` в S3 = склеенный ответ (до нормализации; `raw_s3_key` в конверте) → `Normalizer.normalize(merged)`; `NullNormalizer` по правилам Global Constraints (успех = `error.code == ""`; **регрессионный тест на реальных `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 про `aep_3_ofr.json`; все файлы ошибочные → код первого); `metrics.warnings` в конверте результата. Тесты: unit на `merge_answers` (порядок, невалидный json), нормализатор на реальных фикстурах (маркер `fixtures`), воркер с фейками (склеенный raw в S3 содержит все файлы; ошибка одного файла не роняет задание; `parse_error` → warnings). Проверка: `make check` зелёный. ### Task 4: [R4] Эмулятор: набор файлов на задание - [x] `src/scan_emulator/`: по входящему `_.` собрать из `SCAN_FIXTURES_DIR` все `.json` и `_*.json` (точное правило из Global Constraints), записать в `Output//` под именами `.json` / `_.json` с `file_uid = `; пары нет — прежний `INVALID_DOCUMENT`; `aep_3` не забирает `aep_30*`. Тесты (маркер `fixtures`, `tmp_path`): `aep_3.pdf` → два файла (`.json` успех, `_ofr.json` ошибка), `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `aep_8_form4.json` как раньше (правило `_*.json` покрывает `aep_8_form4`), неизвестное имя → `INVALID_DOCUMENT`; сквозной тест «put → эмулятор → poll (с settle) → merge» на `aep_3`. Проверка: `make check` зелёный. ### Task 5: [R5] Интеграционные тесты и документация - [x] Интеграционный тест (`tests/integration`, маркер `integration`): реальные NATS и MinIO, эмулятор и шлюз в процессе теста: `../fixtures/ocr/aep_3.pdf` в `uploads/…` → ack `processing`, затем конверт `error NOT_IMPLEMENTED` с `metrics.warnings` про `aep_3_ofr.json`, `raw/{job_id}.json` в MinIO содержит два файла; `aep_6.pdf` → `NOT_IMPLEMENTED` без warnings; `aep_5.PDF` → `INVALID_DOCUMENT`. Актуализировать `README.md` и `CLAUDE.md` (раскладка каталога ответа, `SCAN_TMP_DIR`, `SCAN_SETTLE_SECONDS`, склеенный raw, успех = `error.code == ""`), `.env.example`/`.env.test`. Проверка: `make check-full` (с поднятым compose-проектом `arm-ka`: `make up`) — exit 0; вывод в отчёт. ### Task 6: [R6] Проверка в реальном compose - [x] Без правок `../docs`: `docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build` нужных сервисов (`scan-gateway`, `scan-emulator` + зависимости; `--wait` по именам сервисов, не голый), затем сценарии через реальный compose: положить `aep_3.pdf`, `aep_6.pdf`, `aep_5.PDF` из `../fixtures/ocr` в MinIO и вставить задания способом из `../docs/dev/README.md` («Проверка окружения»); зафиксировать выводы (в т.ч. содержимое `raw/.json`) в отчёт. В конце окружение остановить (`down -v` — заменено R-27, design #77: `rm -sfv` собственных сервисов). Если `../docs/dev/docker-compose.yml` требует правки (например, переменных `SCAN_TMP_DIR`/`SCAN_SETTLE_SECONDS`) — не править, а записать точный фрагмент в `open-questions-proposal.md`.