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

16 KiB
Raw Blame History

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 под решения человека

  • В 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] Горячие папки: чтение всех файлов, готовность, служебный каталог

  • 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] Склейка ответа, сырой файл, нормализатор, воркер

  • 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] Эмулятор: набор файлов на задание

  • 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] Интеграционные тесты и документация

  • Интеграционный тест (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

  • Без правок ../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.