arm-scan-gateway/docs/superpowers/plans/2026-10-04-multi-file-response.md
Andrey Epifancev b127c02422 Срез 0 [7.5]: финальная волна, документы — T5.1, T5.3, T6.1, T6.3, T6.6, M-3
- 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>
2026-10-04 16:01:13 +04:00

57 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.** ИС Сканирование кладёт **все распознанные документы одновременно** в каталог `<output>/<job_id>/` — по одному 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`, новый). Файл во входящем каталоге — `<job_id>_<original_name>`.
- Сырой ответ — в 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`.
- Эмулятор: по входящему `<uuid>_<name>.<ext>` кладёт в `Output/<uuid>/` **все** фикстуры `<name>.json` и `<name>_*.json` из `SCAN_FIXTURES_DIR` (не префикс `<name>*`: `aep_3` не должен забирать `aep_30*`), имена в каталоге ответа — `<uuid>.json` для `<name>.json` и `<uuid>_<suffix>.json` для `<name>_<suffix>.json`, подстановка `file_uid = <uuid>`; пары нет — один ответ `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 <noreply@anthropic.com>`.
## Задачи
### 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 нет упоминаний «читает ровно `<job_id>.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` каталога `<output>/<job_id>/`, с проверкой готовности по settle (набор имён и размеров не менялся `SCAN_SETTLE_SECONDS`); `LocalHotFolder` и `SmbHotFolder` — временный файл записи (`.part`) создаётся в `<root>/<SCAN_TMP_DIR>/` (каталог создаётся, если нет; на той же ФС/шаре) и переименовывается в `Input/<name>` (атомарно); `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/`: по входящему `<uuid>_<name>.<ext>` собрать из `SCAN_FIXTURES_DIR` все `<name>.json` и `<name>_*.json` (точное правило из Global Constraints), записать в `Output/<uuid>/` под именами `<uuid>.json` / `<uuid>_<suffix>.json` с `file_uid = <uuid>`; пары нет — прежний `INVALID_DOCUMENT`; `aep_3` не забирает `aep_30*`. Тесты (маркер `fixtures`, `tmp_path`): `aep_3.pdf` → два файла (`<uuid>.json` успех, `<uuid>_ofr.json` ошибка), `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `aep_8_form4.json` как раньше (правило `<name>_*.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/<job_id>.json`) в отчёт. В конце окружение остановить (`down -v` — заменено R-27, design #77: `rm -sfv` собственных сервисов). Если `../docs/dev/docker-compose.yml` требует правки (например, переменных `SCAN_TMP_DIR`/`SCAN_SETTLE_SECONDS`) — не править, а записать точный фрагмент в `open-questions-proposal.md`.