openspec: спека Среза 3a — горячие папки как у armka_file_worker, нормализатор OCR → statementDocument v1.2 (#6)
proposal, design (D-1…D-9, решения без обсуждения №1–12), дельты спек image-recognition-gateway (папки, очистка под флагом, нормализация, справочники с редакциями, статусы) и scan-emulator (пара по sha256, набор по умолчанию). tasks.md заполняется планом. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
dffc18a2f1
commit
a15fe65d52
2
openspec/changes/slice-3a-ocr-normalizer/.openspec.yaml
Normal file
2
openspec/changes/slice-3a-ocr-normalizer/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-06
|
||||
151
openspec/changes/slice-3a-ocr-normalizer/design.md
Normal file
151
openspec/changes/slice-3a-ocr-normalizer/design.md
Normal file
@ -0,0 +1,151 @@
|
||||
# Design: Срез 3a — горячие папки как у armka_file_worker и нормализатор OCR
|
||||
|
||||
## Context
|
||||
|
||||
Срез 0 построил конвейер scan-gateway (ack → S3 → горячая папка → опрос → `raw/` → нормализатор → конверт) с
|
||||
`NullNormalizer`. Контракт v1.2 (Срез 2b) определил `statementDocument`: `header`, `columns[{column_index, label,
|
||||
period_date}]`, `lines[{line_code, line_code_confidence, line_name, line_name_confidence, values[{column_index, amount,
|
||||
string_value, confidence, is_recognized}]}]`, необязательный `checks`. Реальные ответы ИС (`../fixtures/ocr/aep_3.json`,
|
||||
`aep_6.json`) — `data_ocr {label, organization, inn, units, date, table_structure[{label, rows[{row_index, cells[{column_index,
|
||||
value, confidence}]}]}]}`; текст в ячейках зашумлён (двойники букв и цифр, `|` как границы клеток, переносы строк, скобки),
|
||||
в `aep_3.json` у таблицы нет строки-шапки. Старое ядро (`DKB_ARMKA`, `services/ofr_result_conversion/`) решает эти
|
||||
задачи кодом, проверенным на стенде ИС Сканирование; владелец 06.10.2026 разрешил его перенос (§11.1).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
Goals: PDF-путь отдаёт ядру тот же `statementDocument` v1.2, что Excel-путь; раскладка горячих папок совпадает с
|
||||
проверенным в контуре FileWorker; перенос проверенных функций очистки без их DB-обвязки; dev-стенд с эмулятором
|
||||
работает на реальных парах PDF+JSON.
|
||||
Non-goals: см. `proposal.md`.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D-1. Имена в горячих папках как у FileWorker, временный файл остаётся в `Tmp` (решение владельца)
|
||||
`put(job_id, ext, data)` пишет `<Tmp>/.<job_id>.<ext>.part`, затем rename без замены в `<Input>/<job_id>.<ext>`.
|
||||
Расширение — суффикс `original_name` запроса в нижнем регистре; без суффикса — `RECOGNITION_FAILED` (ИС различает
|
||||
форматы по расширению). Назначение уже существует (повторная публикация задания после потери ack) → файл считается
|
||||
доставленным, `.part` остаётся до очистки. Владелец отклонил `.tmp` рядом с назначением (как у FileWorker): ИС может
|
||||
подхватить недописанный файл; FileWorker полагается на игнор `*.tmp`, у нас такой гарантии нет. `poll` без изменений.
|
||||
|
||||
### D-2. Очистка — отдельный метод под флагом `SCAN_CLEANUP` (решение владельца)
|
||||
`cleanup(job_id, ext)` удаляет `Output/<job_id>/*.json`, каталог `Output/<job_id>`, `<Input>/<job_id>.<ext>`,
|
||||
`<Input>/<job_id>.<ext>.done` (след эмулятора) и `<Tmp>/.<job_id>.<ext>.part`. Вызывается воркером только после того,
|
||||
как набор ответа прочитан и склеен в `raw/` (любой исход разбора), и только при `SCAN_CLEANUP=true`. При `TIMEOUT`,
|
||||
ошибке чтения S3, ошибке записи в горячую папку — не вызывается. Любое удаление, включая `.part`, подчиняется флагу: на
|
||||
SMB-шаре контура права на удаление не подтверждены. Ошибки удаления — одна строка лога на задание, исход задания и
|
||||
`/healthcheck` не меняются. По умолчанию `false`; dev-compose — `true`.
|
||||
|
||||
### D-3. Эмулятор ищет пару по sha256 содержимого, при промахе — набор по умолчанию (решение владельца, вариант 3)
|
||||
Имя входящего файла больше не несёт имени фикстуры. Эмулятор при старте индексирует `SCAN_FIXTURES_DIR`: для каждого
|
||||
файла, не являющегося `*.json`, считается sha256 → основа имени `<name>` (без расширения); индекс пересчитывается,
|
||||
если изменился список файлов каталога. Для входящего `<uuid>.<ext>` считается sha256; совпадение → набор `<name>.json` +
|
||||
`<name>_*.json` по прежним правилам (`<uuid>.json`, `<uuid>_<suffix>.json`, `file_uid = <uuid>`); промах →
|
||||
`SCAN_EMULATOR_DEFAULT=<name>` (если задан и его набор существует) → иначе ответ-ошибка `INVALID_DOCUMENT` с текстом «нет
|
||||
пары по содержимому». Владелец дополнит пары PDF+JSON в fixtures (сейчас пары есть для `aep_3`, `aep_5`, `aep_6`, `aep_8`).
|
||||
|
||||
### D-4. Нормализатор — конвейер чистых функций, проверенные куски переносятся дословно (решение владельца, вариант B)
|
||||
`src/normalizer/ocr/`:
|
||||
- `text.py` — `fix_lookalikes` (замена двойников только вплотную к цифре, несколько проходов; `|` как разделитель, если
|
||||
первая строка состоит из цифр, пробелов, `|`, `•`, `·`, точек, двоеточий, запятых), `clean_value` (буквы, цифры, пробел,
|
||||
точка; `\n`/`\t` → пробел), `digits_only`, `normalize_header` (нижний регистр, не-буквенно-цифровое → пробел).
|
||||
- `dates.py` — `parse_date(text) -> date | None`: текстовые формы по приоритету, затем числовые; дословный перенос
|
||||
`date_utils._parse_date` и паттернов. Паттерны остаются в коде (регулярки с лямбдами).
|
||||
- `amounts.py` — `parse_amount(text) -> ParsedAmount(amount: Decimal | None, verified: bool)`; `is_blank` (прочерки).
|
||||
- `layout.py` — `ColumnLayout(label_idx, code_idx, value_idxs)`, `build_layout(cells)` по строке с ячейкой «код…»,
|
||||
`infer_layout(rows_cells, known_codes)` по данным; `cells` — `dict[int, Cell(value, confidence)]`.
|
||||
- `rows.py` — `parse_table_rows(rows, known_codes) -> list[ParsedRow] | None` (первая строка — шапка настоящая или
|
||||
синтетическая, повторная шапка перезаписывает раскладку), `classify_row` (`HEADER`/`TOTAL`/`BALANCE`/`ROW`),
|
||||
`table_type_by_codes`.
|
||||
- `header.py` — `parse_file_header(data_ocr) -> FileHeader` (организация, ИНН, единицы, дата отчёта, их `confidence` и
|
||||
признаки «распознано» по §11.1 п. 8).
|
||||
- `document.py` — `build_document(table, index, file_name, file_header, rules) -> BuiltDocument` (документ контракта +
|
||||
предупреждения + флаг «всё распознано»).
|
||||
- `__init__.py` — `OcrNormalizer(rules)`: обход `files` склейки по возрастанию имени, сбор документов, статус (D-6),
|
||||
`metrics`. Исключение при разборе одной таблицы → предупреждение `BACKEND_PARSING_ERROR`, остальные таблицы
|
||||
разбираются; исключение вне таблицы → как в Срезе 0 (воркер отвечает `UNKNOWN_ERROR`, `raw/` уже сохранён).
|
||||
Все функции без I/O и настроек; правила (D-7) передаются объектом. Перенос с именами без подчёркиваний, типами и
|
||||
docstring'ами на русском; алгоритмы и регулярки — как в источнике, расхождения фиксируются в тестах.
|
||||
|
||||
### D-5. `checks` в документе scan-gateway отсутствуют (решение владельца)
|
||||
Отсутствие `checks` по контракту означает «не проверялось». Соотношения §7 для обоих источников считает ядро в 3b;
|
||||
дублировать `statement_checks.yaml` из 2b в scan-gateway не нужно. Чтобы не конфликтовать: ядро не должно полагаться на
|
||||
`checks` как на единственный источник, это требование уйдёт в change 3b.
|
||||
|
||||
### D-6. Статусы и предупреждения
|
||||
Файлы ответа: правила Среза 0 без изменений (ошибочные файлы, `error` при всех ошибочных, строки `metrics.warnings`).
|
||||
Из успешных файлов собираются документы по одному на таблицу:
|
||||
- ни одной таблицы не разобрано (нигде нет столбца кода, `table_structure` пусты) → `status = error`,
|
||||
`error.code = RECOGNITION_FAILED`, `error.message = "ни одной таблицы не разобрано"`, причины по таблицам — в warnings;
|
||||
- есть документы → `done`, если у всех документов `inn_confidence` и `organization_name_confidence` равны 100,
|
||||
`report_date` и все `period_date` заполнены, все `line_code_confidence`/`line_name_confidence` равны 100, все значения
|
||||
`is_recognized`, тип совпал с `label`, редакция определена по дате и заполнена, и нет предупреждений уровня таблицы;
|
||||
иначе `needs_check`. Единицы (`units`) в критерий не входят: у поля нет `confidence` в контракте.
|
||||
Предупреждения уровня таблицы: `<имя json>: table:<i>: <текст>` — таблица пропущена (нет шапки и столбца кода), шапка
|
||||
синтезирована (колонки без дат), тип по кодам разошёлся с `label`, колонка без даты, редакция определена по кодам,
|
||||
редакция `fsbu4` не заполнена, код из другой редакции, `BACKEND_PARSING_ERROR: <класс>: <сообщение>`. Уровня файла:
|
||||
`<имя json>: ИНН не распознан`, `<имя json>: дата отчёта не разобрана`. Длина и число строк — как в Срезе 0 (`clean_text`).
|
||||
`metrics.rows` — сумма строк документов. Документ `OTHER` (тип не определён) включается с разобранным содержимым и даёт
|
||||
`needs_check`.
|
||||
|
||||
### D-7. Правила — данные в `api/rules/`, справочник кодов с редакциями (решение владельца)
|
||||
- `statement_lines.yaml`: `editions: [{id: 66n, valid_until: 2024-12-31, lines: {...}}, {id: fsbu4, valid_from:
|
||||
2025-01-01, lines: {}}]`. `66n` — из `codes_mapping` старого ядра с исправленными опечатками; `fsbu4` — пустая, данные
|
||||
положит владелец, код не меняется. Выбор редакции для документа: по `report_date` шапки → по самой поздней
|
||||
`period_date` колонок → по совпадениям кодов (с предупреждением). Пустая выбранная редакция → предупреждение
|
||||
«редакция fsbu4 не заполнена», имена из OCR, `needs_check`. Код не в выбранной редакции, но есть в другой →
|
||||
предупреждение, `needs_check`; код нигде → имя из ячейки OCR, `line_code_confidence` из OCR.
|
||||
- `ocr_lookalikes.yaml`: пары «символ → цифра» и список символов замены.
|
||||
- `statement_labels.yaml`: нормализованные подстроки `label` таблицы → `BALANCE` / `INCOME`.
|
||||
Загрузка один раз при старте (`SCAN_RULES_DIR`, по умолчанию `rules/` сервиса), pydantic-валидация; битый yaml — ошибка
|
||||
старта. Тест покрытия: коды 4–5 цифр, имена непустые, дубли отсутствуют, обе редакции валидируются одинаково.
|
||||
|
||||
### D-8. Тесты
|
||||
Юнит по каждому модулю `normalizer/ocr/`; golden на реальных фикстурах `../fixtures/ocr/*.json` с эталонами
|
||||
`../fixtures/ocr/expected_normalize/<name>.json` (формат — файл результата: `status`, `documents`, `warnings`;
|
||||
структурное сравнение, обновление — `make golden-update`); протокольные тесты нормализатора переводятся на
|
||||
`OcrNormalizer`; тесты горячих папок, эмулятора, воркера обновляются; `test_roundtrip.py` на `aep_6.pdf` до конверта с
|
||||
документами. Интеграция не запускается одновременно с интеграцией arm-core (OQ-83).
|
||||
|
||||
### D-9. Настройки и соседние репозитории
|
||||
`SCAN_CLEANUP=false`, `SCAN_RULES_DIR`, `SCAN_EMULATOR_DEFAULT=""`; `SCAN_TMP_DIR` сохраняется. Ветка — от main после
|
||||
мержа scan-gateway #5 (копия схемы 1.2). fixtures: пары PDF+JSON от владельца и `expected_normalize/` — отдельный PR.
|
||||
docs: bounded-PR (`03-architecture.md`, OQ-137, §11.1 п. 6 и 9, новые OQ).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Реальные сканы почти всегда дадут `needs_check` — это ожидаемо; `done` означает лишь отсутствие подсветок в 3b.
|
||||
- Пустая редакция `fsbu4` до заполнения: документы 2025 года идут с именами из OCR. Цена — больше ручной проверки, не
|
||||
потеря данных.
|
||||
- `|`-эвристика и двойники могут исказить редкие корректные значения; `string_value` хранит очищенный, а `raw/` — сырой
|
||||
текст, аналитик видит оба.
|
||||
- Rename без замены на SMB: `smbclient.rename` поднимает `FileExistsError`; при гонке с ИС, уже забравшей файл,
|
||||
назначения может не быть — тогда `.part` переименовывается повторно, это идемпотентно.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Не требуется: сервис без БД; изменение имён файлов видят только ИС и эмулятор; контракт не меняется. Dev-стенд: после
|
||||
мержа обновить `docs/dev/.env.example` и compose (`SCAN_CLEANUP=true`, `SCAN_EMULATOR_DEFAULT` по желанию).
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Права на удаление на SMB-шаре контура (новый OQ) — пока `SCAN_CLEANUP=false`.
|
||||
2. Нужна ли ядру редакция формы в конверте (кандидат в MINOR 1.3) — новый OQ.
|
||||
3. Словари §7 и §12 по редакциям (2b, 3b) — новый OQ, расширение OQ-12.
|
||||
4. Состав редакции `fsbu4` — данные владельца.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| № | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 2026-10-06 | Один `statementDocument` на каждую таблицу `table_structure[i]`; `locator = "file:<имя json>;table:<i>"` | §11: тип документа задаёт `label` таблицы; `locator` нужен ядру для привязки к `raw/` | смена формата `locator` |
|
||||
| 2 | 2026-10-06 | Расширение входящего файла — суффикс `original_name` в нижнем регистре; без суффикса `RECOGNITION_FAILED` | ИС различает форматы по расширению; FileWorker делает так же | потеря заданий без расширения — их не бывает (ядро проверяет сигнатуру) |
|
||||
| 3 | 2026-10-06 | Очистка не вызывается при `TIMEOUT` и транспортных ошибках | ИС может ещё писать ответ; FileWorker чистит только после результата | мусор в папке при таймаутах |
|
||||
| 4 | 2026-10-06 | Исключение при разборе одной таблицы — предупреждение, остальные таблицы разбираются; исключение вне таблиц — `UNKNOWN_ERROR` воркера | §11.1 п. 9, поведение старого ядра по файлам | скрытая ошибка в предупреждении вместо конверта `error` |
|
||||
| 5 | 2026-10-06 | Документ без строки-шапки отдаётся с синтетической шапкой и колонками без `period_date`, `needs_check` | рулинг §11.1 п. 3 | аналитик расставляет даты руками |
|
||||
| 6 | 2026-10-06 | Выбор редакции: `report_date` → поздняя `period_date` → совпадения кодов | дата отчёта надёжнее кодов; коды — последний резерв | неверная редакция при мусорной дате; тогда предупреждение по кодам |
|
||||
| 7 | 2026-10-06 | Паттерны дат остаются в коде, двойники и `label` — в yaml | регулярки с лямбдами в yaml нечитаемы; таблицы символов — данные | править даты — через код |
|
||||
| 8 | 2026-10-06 | `OTHER` включается в `documents`, а не отбрасывается | как `OTHER` у Excel-пути 2b; ядро решает | лишний документ на экране проверки |
|
||||
| 9 | 2026-10-06 | Эмулятор пересчитывает индекс хешей при изменении списка файлов каталога, а не на каждый файл | дёшево и достаточно для dev | изменённый, но не переименованный PDF не переиндексируется |
|
||||
| 10 | 2026-10-06 | `label` колонки — сырой текст шапки с `\n` → пробел; `period_date` из `parse_date` | ядро показывает текст как есть | — |
|
||||
| 12 | 2026-10-06 | `parse_amount` переносится дословно: скобка, распознанная как `}` (`(7744536}`), минус не даёт — сумма положительная с `is_recognized = false` | проверенный код; расширение множества скобок — отдельное правило §11.1 после проверки на стенде | знак потерян, аналитик видит `raw/` и подсветку |
|
||||
| 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в `raw/`) |
|
||||
62
openspec/changes/slice-3a-ocr-normalizer/proposal.md
Normal file
62
openspec/changes/slice-3a-ocr-normalizer/proposal.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Proposal: Срез 3a — горячие папки как у armka_file_worker и нормализатор OCR → `statementDocument` v1.2
|
||||
|
||||
**Срез:** 3a, первая половина Среза 3 «ББ/ОФР: scan-gateway, маппинг форм, логические проверки» (`../docs/context/09-delivery-plan.md`).
|
||||
**Репозиторий:** scan-gateway. **Issue:** RSHB/arm-scan-gateway#6.
|
||||
**Соседние:** fixtures — пары PDF+JSON и `ocr/expected_normalize/` (issue заводится при плане); docs — bounded-PR без change
|
||||
(`03-architecture.md`, OQ-137, §11.1 п. 6 и п. 9, новые OQ). Срез 3b (маппинг `statementDocument` в ядре, проверки §7,
|
||||
подтверждение, чек-лист) — отдельный change в arm-core.
|
||||
|
||||
## Why
|
||||
|
||||
Срез 0 оставил scan-gateway с `NullNormalizer`: любой успешный ответ ИС Сканирование превращается в `NOT_IMPLEMENTED`.
|
||||
Срез 2b даёт ядру `statementDocument` v1.2 из Excel; чтобы ядро в 3b маппило формы одним кодом для обоих источников,
|
||||
PDF-путь должен отдавать тот же документ. Второй повод — 06.10.2026 в контуре проверен PDFWorker старого АРМ
|
||||
(`RSHB/armka_file_worker`): его раскладка горячих папок ИС принята владельцем как эталон (OQ-137), а модуль очистки OCR
|
||||
(`ofr_result_conversion`) разрешено переносить (§11.1 `05-domain-rules.md`, единственное исключение для `reference-old-arm`).
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Горячие папки** (`src/gateway/`): входящий файл — `<Input>/<job_id>.<ext>` (расширение исходного имени в нижнем
|
||||
регистре) вместо `<job_id>_<original_name>`; временный `.part` по-прежнему в `<SCAN_TMP_DIR>` (решение владельца: в `Input`
|
||||
временных файлов не класть, ИС может их подхватить), rename **без замены** — существующее назначение считается
|
||||
доставленным. Новый метод `cleanup(job_id, ext)` под флагом `SCAN_CLEANUP` (по умолчанию `false`): удаляет
|
||||
`Output/<job_id>/*.json`, каталог, входящий файл (и его `.done`-вариант эмулятора) и осиротевший `.part`; любое
|
||||
удаление только при включённом флаге (на SMB может не быть прав). Ошибка удаления не меняет исход задания.
|
||||
- **Эмулятор** (`src/scan_emulator/`): входящий `<uuid>.<ext>`; пара фикстур ищется по **sha256 содержимого** среди
|
||||
не-JSON файлов `SCAN_FIXTURES_DIR` (`aep_6.pdf` → `aep_6.json` + `aep_6_*.json`); промах → набор
|
||||
`SCAN_EMULATOR_DEFAULT`, если задан, иначе ответ-ошибка `INVALID_DOCUMENT`. `.part`/`.tmp`/`.done` игнорируются.
|
||||
- **Нормализатор** (`src/normalizer/ocr/`): `OcrNormalizer` реализует протокол `Normalizer` Среза 0 — конвейер чистых
|
||||
функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document`; проверенные на стенде функции
|
||||
старого ядра (двойники, `|`, разбор дат, суммы, раскладка с шапкой и без) переносятся дословно по §11.1; выход —
|
||||
pydantic-модели контракта v1.2 (`StatementDocument`), по одному документу на таблицу `table_structure[i]`.
|
||||
`checks` не считаются (их считает ядро в 3b для обоих источников).
|
||||
- **Статусы**: ошибочные файлы — как в Срезе 0; из успешных собираются документы; ни одной таблицы →
|
||||
`error RECOGNITION_FAILED`; всё распознано → `done`, иначе `needs_check`; причины — в `metrics.warnings`.
|
||||
- **Правила как данные** (`api/rules/`): `statement_lines.yaml` с **редакциями** форм (`66n` до отчётности за 2024
|
||||
включительно — из `codes_mapping` старого ядра; `fsbu4` с 2025 — пустая, данные положит владелец), `ocr_lookalikes.yaml`,
|
||||
`statement_labels.yaml`. Загружаются и валидируются при старте.
|
||||
- **Настройки**: `SCAN_CLEANUP`, `SCAN_RULES_DIR`, `SCAN_EMULATOR_DEFAULT`; `SCAN_TMP_DIR` остаётся. Dev-compose:
|
||||
`SCAN_CLEANUP=true`.
|
||||
- **Тесты**: юнит по функциям, golden на `../fixtures/ocr/aep_3.json`, `aep_6.json` (эталоны `../fixtures/ocr/expected_normalize/`),
|
||||
обновлённые тесты горячих папок, эмулятора, воркера; roundtrip с эмулятором.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Маппинг `statementDocument` в `financial_statement_*`, проверки §7, подтверждение документа, чек-лист, UI — Срез 3b.
|
||||
- Изменение контракта (v1.2 заморожен): редакция формы в конверт не добавляется (OQ, кандидат в MINOR 1.3).
|
||||
- Расшифровки без кода (OQ-36), склейка одной формы из нескольких таблиц (OQ-77), `DISCLOSURE` из OCR.
|
||||
- Заполнение словаря редакции `fsbu4` и подбор пар PDF+JSON — владелец, отдельный PR в fixtures.
|
||||
- Перенос обвязки старого ядра (`json_to_db_rows`, запись в БД, `pdfworker_results.py`).
|
||||
|
||||
## Параграфы `05-domain-rules.md`
|
||||
|
||||
§11 (структура ответа ИС, маппинг таблица → документ), §11.1 п. 1–9 (очистка OCR-текста), §12 п. 4 (разбор дат —
|
||||
общий смысл с Excel-путём). Правила, которых в `05` нет и которые вводятся этим change'ом: редакции форм в справочнике
|
||||
кодов (ASSUMPTION(OQ-12): состав 2025 года не задан), `is_recognized` колонок шапки. Таблицы DBML не затрагиваются
|
||||
(сервис без БД).
|
||||
|
||||
## Открытые вопросы, затрагиваемые change'ом
|
||||
|
||||
OQ-137 (уточнить: `Tmp` остаётся, очистка под флагом), OQ-36 (строки без кода не отдаются), OQ-77 (одна форма двумя
|
||||
таблицами — два документа с предупреждением), OQ-12 (редакции форм и §7), новые: права на удаление на SMB-шаре;
|
||||
редакция формы в контракте; §7/§12 по редакциям для 2b и 3b.
|
||||
@ -0,0 +1,243 @@
|
||||
# Spec Delta: image-recognition-gateway
|
||||
|
||||
## Purpose
|
||||
|
||||
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает и подтверждает задание из NATS без JetStream,
|
||||
передаёт файл через горячие папки по раскладке проверенного в контуре PDFWorker (`RSHB/armka_file_worker`), ждёт ответ
|
||||
не дольше таймаута, сохраняет сырой ответ в S3, нормализует `data_ocr` в `statementDocument` контракта v1.2 по §11 и
|
||||
§11.1 `05-domain-rules.md` и отвечает ядру. Без БД и фронта.
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Подтверждение запроса до обработки
|
||||
Сервис SHALL быть подписан на `recognition.image.request` в queue group `scan-gateway`. Получив запрос, проходящий схему,
|
||||
сервис SHALL до чтения файла из S3 опубликовать в `recognition.result` конверт `status = processing`
|
||||
(`service.name = "scan-gateway"`, `Nats-Msg-Id = ack:<job_id>:<attempt>`). Повторный запрос с `job_id`, который уже
|
||||
обрабатывается или обработан этим процессом, SHALL получить повторный ack, и второй файл во входящий каталог MUST NOT записываться.
|
||||
|
||||
#### Scenario: Ack раньше файла
|
||||
- **WHEN** получен запрос для `aep_5.pdf`
|
||||
- **THEN** конверт `processing` опубликован раньше, чем файл появился во входящем каталоге
|
||||
|
||||
#### Scenario: Повторный запрос
|
||||
- **WHEN** тот же запрос доставлен повторно во время ожидания ответа ИС
|
||||
- **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `<job_id>.pdf`
|
||||
|
||||
### Requirement: Передача файла в ИС Сканирование через горячие папки
|
||||
После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как
|
||||
`<job_id>.<ext>`, где `<ext>` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL
|
||||
завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Временный файл записи SHALL лежать в служебном
|
||||
каталоге `<корень>/<SCAN_TMP_DIR>/` (по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и
|
||||
переименовываться в `Input` атомарно **без замены**: если `<Input>/<job_id>.<ext>` уже существует, файл SHALL считаться
|
||||
доставленным, временный файл MUST NOT его перезаписывать. Во входящем каталоге 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….pdf`, байт в байт равный объекту S3; во время записи во входящем каталоге нет временных файлов, а `Tmp/` существует
|
||||
|
||||
#### Scenario: Назначение уже существует
|
||||
- **WHEN** запрос для `aep_5.pdf` с `job_id = 3f1c…` обрабатывается, а `Input/3f1c….pdf` уже лежит (задание повторено ядром после потери ack в другом процессе)
|
||||
- **THEN** существующий файл не перезаписан, задание продолжается опросом `Output/3f1c…/`, конверт `RECOGNITION_FAILED` не публикуется
|
||||
|
||||
#### Scenario: Запрос без расширения
|
||||
- **WHEN** получен запрос с `original_name = "scan"` без точки
|
||||
- **THEN** опубликован конверт `error`, `error.code = RECOGNITION_FAILED`, сообщение о расширении; во входящем каталоге файла нет
|
||||
|
||||
#### Scenario: Ответ из одного файла
|
||||
- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS`
|
||||
- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `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: Результат по контракту
|
||||
Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с
|
||||
`service.name = "scan-gateway"`, `Nats-Msg-Id = result:<job_id>:<attempt>`; при `status ∈ {done, needs_check}` — файл
|
||||
результата (`recognition-result.schema.json`, `schema_version = "1.2"`) в `results/{file_id}/{job_id}/result.json` и `result_s3`;
|
||||
при неудаче публикации SHALL повторить её до трёх раз. Нормализатор получает склеенный ответ и SHALL считать файл
|
||||
успешным, если `error.code == ""` (поле `error` реального успешного ответа — `{"code": "", "message": ""}`, а не `null`;
|
||||
отсутствующий или `null` `error` ошибкой не считается), и ошибочным, если `error.code` непуст, `parse_error` не `null`
|
||||
либо корень ответа файла не является объектом JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST NOT определяться
|
||||
проверкой «`error` не `null`». Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у
|
||||
первого ошибочного файла по имени (для `parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не
|
||||
входит в перечень кодов контракта, `error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`.
|
||||
Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС»,
|
||||
а статус определяется по требованию «Статус результата нормализации». В обоих случаях `metrics.warnings` SHALL
|
||||
содержать по одной строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как
|
||||
получен (для `parse_error` и не-объекта — `INVALID_FORMAT`); если предупреждений нет, поле `warnings` SHALL быть опущено.
|
||||
|
||||
#### Scenario: Успешный ответ ИС в Срезе 0
|
||||
- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.json`)
|
||||
- **THEN** начиная со Среза 3a конверт больше не `NOT_IMPLEMENTED`: поведение — по сценарию «Успешный ответ ИС» ниже
|
||||
|
||||
#### Scenario: Успешный ответ ИС
|
||||
- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (`aep_6.json`)
|
||||
- **THEN** конверт `status = needs_check`, `raw_s3_key = raw/<job_id>.json`, `result_s3` указывает на файл результата с двумя документами (`BALANCE`, `INCOME`)
|
||||
|
||||
#### Scenario: Ошибка ИС
|
||||
- **WHEN** единственный файл ответа `<job_id>.json` содержит `error.code = INVALID_DOCUMENT`
|
||||
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `<job_id>.json: INVALID_DOCUMENT: <сообщение>`
|
||||
|
||||
#### Scenario: Ошибка одного из документов
|
||||
- **WHEN** ответ состоит из `<job_id>.json` (успех, `aep_3.json`) и `<job_id>_ofr.json` (`INVALID_DOCUMENT`, `aep_3_ofr.json`)
|
||||
- **THEN** конверт `status = needs_check`, документы собраны из `<job_id>.json`, `metrics.warnings` содержит строку `<job_id>_ofr.json: INVALID_DOCUMENT: <сообщение>` и предупреждения нормализации
|
||||
|
||||
#### Scenario: Все документы ошибочны
|
||||
- **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON)
|
||||
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит две строки; файл не JSON в warnings несёт код `INVALID_FORMAT`
|
||||
|
||||
#### Scenario: Все файлы не JSON
|
||||
- **WHEN** единственный файл ответа не разбирается как JSON
|
||||
- **THEN** конверт `status = error`, `error.code = INVALID_FORMAT`, `metrics.warnings` содержит одну строку `<job_id>.json: INVALID_FORMAT: <сообщение разбора>`
|
||||
|
||||
#### Scenario: Корень ответа не объект
|
||||
- **WHEN** файл ответа — корректный JSON, но корень — массив
|
||||
- **THEN** этот файл ошибочен с кодом `INVALID_FORMAT`; остальные файлы оцениваются по общим правилам
|
||||
|
||||
#### Scenario: Код ошибки ИС вне перечня контракта
|
||||
- **WHEN** единственный файл ответа содержит `error.code = SOME_NEW_CODE`
|
||||
- **THEN** конверт `status = error`, `error.code = UNKNOWN_ERROR`, `error.message` содержит `SOME_NEW_CODE`, строка в `metrics.warnings` — `<job_id>.json: SOME_NEW_CODE: <сообщение>`
|
||||
|
||||
### Requirement: Ошибки транспорта превращаются в результат
|
||||
Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 →
|
||||
`SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не
|
||||
удалась или у исходника нет расширения → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error`
|
||||
этого файла, а если ошибочны все файлы — `INVALID_FORMAT`; ни одной таблицы не разобрано нормализатором →
|
||||
`RECOGNITION_FAILED`; иное исключение → `UNKNOWN_ERROR`. Ошибки очистки горячей папки MUST NOT влиять на конверт.
|
||||
Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться.
|
||||
|
||||
#### Scenario: Горячая папка недоступна
|
||||
- **WHEN** каталог `SCAN_HOTFOLDER_PATH` не существует в момент обработки
|
||||
- **THEN** конверт `RECOGNITION_FAILED` с сообщением о недоступности горячей папки; `/healthcheck.hotfolder = down`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Очистка горячей папки под флагом
|
||||
При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от
|
||||
исхода нормализации), сервис SHALL удалить `<SCAN_OUTPUT_DIR>/<job_id>/*.json`, сам каталог `<job_id>`, входящий файл
|
||||
`<Input>/<job_id>.<ext>`, его вариант `<Input>/<job_id>.<ext>.done` и временный `<Tmp>/.<job_id>.<ext>.part`, если они
|
||||
существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая временные файлы. При
|
||||
`TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться. Ошибка удаления SHALL быть
|
||||
записана в лог одной строкой на задание и MUST NOT менять конверт, `metrics.warnings` и `/healthcheck`.
|
||||
|
||||
#### Scenario: Очистка включена
|
||||
- **WHEN** `SCAN_CLEANUP=true`, ответ на `3f1c….pdf` прочитан из `Output/3f1c…/`
|
||||
- **THEN** после публикации конверта в горячей папке нет `Output/3f1c…/`, `Input/3f1c….pdf`, `Input/3f1c….pdf.done` и `Tmp/.3f1c….pdf.part`
|
||||
|
||||
#### Scenario: Очистка выключена
|
||||
- **WHEN** `SCAN_CLEANUP=false`, ответ прочитан, а в `Tmp/` остался осиротевший `.part`
|
||||
- **THEN** все файлы остаются на месте, в логе одна строка с путём `.part`
|
||||
|
||||
#### Scenario: Нет прав на удаление
|
||||
- **WHEN** `SCAN_CLEANUP=true`, удаление `Output/3f1c…/3f1c….json` завершается `PermissionError`
|
||||
- **THEN** конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления; `/healthcheck.hotfolder = ok`
|
||||
|
||||
#### Scenario: Таймаут не чистит
|
||||
- **WHEN** `SCAN_CLEANUP=true`, ответ не появился до дедлайна
|
||||
- **THEN** опубликован `TIMEOUT`, входящий файл и каталог `Output/<job_id>/` (если есть) не тронуты
|
||||
|
||||
### Requirement: Нормализация ответа ИС в `statementDocument`
|
||||
Для каждого успешного файла ответа нормализатор SHALL разобрать `data_ocr.table_structure[i]` в отдельный
|
||||
`statementDocument` по §11 и §11.1 `05-domain-rules.md`:
|
||||
- `document_type` — по нормализованному `label.form_name` таблицы через `statement_labels.yaml`; если `label` не
|
||||
сопоставлен — по первой цифре кодов строк данных (`1` → `BALANCE`, `2` → `INCOME`); иначе `OTHER`. Если `label` и коды
|
||||
дают разный тип — тип по `label`, предупреждение; `locator = "file:<имя json>;table:<i>"`.
|
||||
- Раскладка колонок — по строке с ячейкой «код…» (`build_layout`); повторная шапка перезаписывает раскладку; без шапки —
|
||||
`infer_layout` по данным (столбец с наибольшим числом кодов из справочника, наименование — левее, значения — правее,
|
||||
где суммы не реже мусора или столбец пуст), шапка синтезируется, предупреждение. Ни шапки, ни столбца кода — таблица
|
||||
пропускается с предупреждением.
|
||||
- `columns` — колонки значений раскладки по возрастанию `column_index` исходной таблицы, перенумерованные с 0;
|
||||
`label` — текст ячейки шапки (`\n`/`\t` → пробел; у синтетической шапки — пустая строка), `period_date` — `parse_date`
|
||||
текста (квартал/полугодие/N месяцев/диапазон → последний день последнего месяца), `null`, если не разобрался.
|
||||
- `lines` — только строки данных с кодом: `line_code` — очищенный текст ячейки кода (`clean_value`); строка без кода и
|
||||
строка с нечисловым кодом (повторный заголовок) пропускаются. `line_name` — из справочника редакции по коду; если кода
|
||||
нет в справочниках — текст ячейки наименования. `line_code_confidence` — `confidence` ячейки кода, если очищенный текст
|
||||
равен сырому и код есть в справочнике, иначе `min(confidence, 99)`; `line_name_confidence` — то же для словарного
|
||||
имени, иначе `confidence` ячейки наименования.
|
||||
- `values` — по одному на колонку: `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы
|
||||
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`); `string_value` — `clean_value` сырого
|
||||
текста (`null`, если пусто); `confidence` — ячейки; `is_recognized = confidence == 100 и разбор не отбрасывал символов`;
|
||||
ячейки под колонкой нет → `amount = null`, `string_value = null`, `confidence = 0`, `is_recognized = false`.
|
||||
- `header` — `form_name` из `label` таблицы; `organization_name` и `organization_name_confidence` из `data_ocr.organization`
|
||||
(`\n` → пробел); `inn` — только цифры из `data_ocr.inn.form_name` (`null`, если цифр нет), `inn_confidence = confidence`,
|
||||
если сырой текст состоял только из цифр, иначе `min(confidence, 99)`; `units` — `clean_value(units.form_name)`;
|
||||
`report_date_text` — сырой текст `date.form_name`, `report_date` — `parse_date` после `fix_lookalikes`
|
||||
(`null`, если текст не дата); `okud`, `okpo`, `okopf`, `okfs`, `address` — `null` (ИС их не отдаёт).
|
||||
- `checks` MUST NOT присутствовать.
|
||||
Двойники букв и цифр (`fix_lookalikes`) SHALL применяться к тексту кодов, сумм, дат и ИНН только вплотную к цифрам;
|
||||
`|` SHALL считаться разделителем клеток, если первая строка значения состоит из цифр, пробелов и знаков пунктуации.
|
||||
|
||||
#### Scenario: Чистый скан с шапкой (`aep_6.json`)
|
||||
- **WHEN** нормализуется `aep_6.json` (две таблицы: «Бухгалтерский баланс» 122 строки со строкой-шапкой «Наименование показателя / Код / На 30 июня 2025 г / На 31 декабря 2024 г. / На 31 декабря 2023 г.» и «Отчет о финансовых результатах» 49 строк)
|
||||
- **THEN** два документа: `BALANCE` с `columns` `[{0, "На 30 июня 2025 г", 2025-06-30}, {1, "На 31 декабря 2024 г.", 2024-12-31}, {2, "На 31 декабря 2023 г.", 2023-12-31}]`, строка `1105` и `1110` присутствуют, строка «в том числе: …» без кода отсутствует; `INCOME` с колонками «За январь - июнь 2025 г.» → `2025-06-30` и «За январь - июнь 2024 г.» → `2024-06-30`, строка `2110` с `values[0].amount = 13070`; `header.units = "тыс. руб."`, `header.inn = null` (текст `+`), `header.report_date = null` (текст — название организации); статус `needs_check`
|
||||
|
||||
#### Scenario: Зашумлённый скан без шапки (`aep_3.json`)
|
||||
- **WHEN** нормализуется `aep_3.json` (таблица «Бухгалтерский баланс» без строки-шапки, коды в `column_index = 1` с мусором вида `изо :`, ИНН `3|1 21 2| о! О' 0 0; 3: 51`, дата `2|0|2|4|`)
|
||||
- **THEN** столбец кода выведен по данным, документ `BALANCE` с синтетической шапкой, `columns` без `period_date` и предупреждение «шапка синтезирована»; `header.inn = "3121200351"`, `inn_confidence < 100`; `header.report_date = 2024-12-31`; строка `1120` присутствует; статус `needs_check`
|
||||
|
||||
#### Scenario: Суммы в скобках и с пробелами (`aep_3.json`, ОФР)
|
||||
- **WHEN** ячейка значения содержит `(7744536}` с `confidence 88.9`, другая — `(6032409)` с `confidence 100`, третья — `13 070` с `confidence 100`
|
||||
- **THEN** `amount` соответственно `7744536` (закрывающая скобка не распознана, минус не выставлен, `is_recognized = false`), `-6032409` (`is_recognized = true`), `13070` (`is_recognized = true`); `string_value` — очищенный текст (`7744536`, `6032409`, `13 070`)
|
||||
|
||||
#### Scenario: Тип по кодам при зашумлённом label
|
||||
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700`
|
||||
- **THEN** `document_type = BALANCE`, предупреждение «тип определён по кодам»
|
||||
|
||||
#### Scenario: Таблица без столбца кода
|
||||
- **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов из справочника
|
||||
- **THEN** таблица пропущена, предупреждение `<имя json>: table:<i>: нет шапки и столбца кода`; остальные таблицы файла разобраны
|
||||
|
||||
### Requirement: Справочники нормализации — данные с редакциями форм
|
||||
Правила SHALL лежать в `api/rules/` (`SCAN_RULES_DIR`) и загружаться один раз при старте с pydantic-валидацией; битый
|
||||
файл SHALL прерывать старт сервиса. `statement_lines.yaml` SHALL содержать список `editions` с полями `id`,
|
||||
`valid_from`/`valid_until` (даты отчёта) и `lines` (код → наименование); редакция `66n` (до 2024-12-31) заполнена,
|
||||
`fsbu4` (с 2025-01-01) присутствует и может быть пустой. Редакция документа SHALL выбираться по `header.report_date`,
|
||||
при его отсутствии — по самой поздней `period_date` колонок, при отсутствии обеих — по числу совпадений кодов с
|
||||
предупреждением «редакция определена по кодам». Пустая выбранная редакция → предупреждение «редакция <id> не
|
||||
заполнена», имена строк — из OCR. Код, отсутствующий в выбранной редакции, но присутствующий в другой → предупреждение
|
||||
«код <код> из редакции <id>». `ocr_lookalikes.yaml` SHALL задавать таблицу двойников; `statement_labels.yaml` —
|
||||
нормализованные подстроки `label` → `BALANCE`/`INCOME`. Коды справочника SHALL быть строками из 4–5 цифр без дублей.
|
||||
|
||||
#### Scenario: Редакция по дате отчёта
|
||||
- **WHEN** `header.report_date = 2024-12-31`, строка с кодом `1150`
|
||||
- **THEN** `line_name = "Основные средства"` из редакции `66n`, предупреждений о редакции нет
|
||||
|
||||
#### Scenario: Редакция 2025 не заполнена
|
||||
- **WHEN** `report_date = null`, самая поздняя `period_date = 2025-06-30`, редакция `fsbu4` пуста
|
||||
- **THEN** имена строк взяты из ячеек OCR, предупреждение `редакция fsbu4 не заполнена`, статус `needs_check`
|
||||
|
||||
#### Scenario: Битый справочник
|
||||
- **WHEN** в `statement_lines.yaml` код `11A0`
|
||||
- **THEN** сервис не стартует, в логе ошибка валидации с именем файла и кодом
|
||||
|
||||
### Requirement: Статус результата нормализации
|
||||
Если из успешных файлов не собран ни один документ — `status = error`, `error.code = RECOGNITION_FAILED`,
|
||||
`error.message = "ни одной таблицы не разобрано"`, причины по таблицам — в `metrics.warnings`. Если документы есть —
|
||||
`status = done` только когда у всех документов `inn_confidence`, `organization_name_confidence` равны 100,
|
||||
`report_date` и все `period_date` заполнены, все `line_code_confidence` и `line_name_confidence` равны 100, все значения
|
||||
`is_recognized`, тип совпал с `label`, редакция определена по дате и заполнена, и нет предупреждений уровня таблицы;
|
||||
иначе `status = needs_check`. Предупреждения уровня таблицы SHALL иметь вид `<имя json>: table:<i>: <текст>`, уровня
|
||||
файла — `<имя json>: <текст>`; `metrics.rows` — сумма строк всех документов.
|
||||
|
||||
#### Scenario: Ни одной таблицы
|
||||
- **WHEN** единственный успешный файл содержит `table_structure: []`
|
||||
- **THEN** конверт `status = error`, `error.code = RECOGNITION_FAILED`, `raw_s3_key` заполнен
|
||||
|
||||
#### Scenario: Всё распознано
|
||||
- **WHEN** синтетический ответ, где все `confidence = 100`, тексты чистые, шапка есть, даты колонок и отчёта разобраны, `report_date = 2024-12-31`, коды из редакции `66n`
|
||||
- **THEN** `status = done`, поле `metrics.warnings` отсутствует
|
||||
|
||||
#### Scenario: Исключение в одной таблице
|
||||
- **WHEN** разбор `table_structure[1]` бросает исключение, `table_structure[0]` разбирается
|
||||
- **THEN** один документ, предупреждение `<имя json>: table:1: BACKEND_PARSING_ERROR: <класс>: <сообщение>`, статус `needs_check`
|
||||
@ -0,0 +1,82 @@
|
||||
# Spec Delta: scan-emulator
|
||||
|
||||
## Purpose
|
||||
|
||||
Эмулятор ИС Сканирование для разработки и тестов: воспроизводит протокол горячих папок (раскладка FileWorker:
|
||||
`<uuid>.<ext>` → `Output/<uuid>/*.json`) и формат ответов Content AI, отдавая реальные ответы из `../fixtures/ocr` по
|
||||
содержимому входящего файла, чтобы конвейер `scan-gateway` проверялся без доступа к банковской системе.
|
||||
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Ответы берутся из фикстур по имени`
|
||||
- TO: `### Requirement: Ответы берутся из фикстур по содержимому входящего файла`
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Эмулятор отвечает по протоколу горячих папок
|
||||
Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; файлы с суффиксами `.part`,
|
||||
`.tmp`, `.done` и файлы, начинающиеся с `.`, SHALL игнорироваться. Для нового файла `<uuid>.<ext>` через
|
||||
`SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) эмулятор SHALL создать каталог `<output>/<uuid>/` с набором ответов (по
|
||||
одному файлу на документ); каталог SHALL появиться в `<output>` атомарно целиком: набор записывается во временный
|
||||
каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `<uuid>.<ext>.done`,
|
||||
чтобы не обрабатываться повторно.
|
||||
|
||||
#### Scenario: Один файл
|
||||
- **WHEN** во входящем каталоге появляется `3f1c….pdf` с содержимым `aep_5.pdf`
|
||||
- **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `3f1c….pdf.done`
|
||||
|
||||
#### Scenario: Набор появляется целиком
|
||||
- **WHEN** эмулятор записывает набор из нескольких файлов
|
||||
- **THEN** каталог `Output/3f1c…/` либо не существует, либо уже содержит весь набор; частичного набора в `Output` нет
|
||||
|
||||
#### Scenario: Временные файлы шлюза игнорируются
|
||||
- **WHEN** во входящем каталоге лежат `.3f1c….pdf.part` и `3f1c….pdf.done`
|
||||
- **THEN** эмулятор не создаёт для них ответов
|
||||
|
||||
### Requirement: Ответы берутся из фикстур по содержимому входящего файла
|
||||
Эмулятор SHALL индексировать `SCAN_FIXTURES_DIR`: для каждого файла, не являющегося `*.json`, вычисляется sha256 →
|
||||
основа имени `<name>` (имя без расширения; регистр расширения не важен); индекс SHALL пересчитываться, когда меняется
|
||||
список файлов каталога. Для входящего файла SHALL вычисляться sha256 содержимого; при совпадении эмулятор SHALL собрать
|
||||
**все** файлы `<name>.json` и `<name>_*.json`: `<name>.json` записывается как `<uuid>.json`, `<name>_<suffix>.json` — как
|
||||
`<uuid>_<suffix>.json`, в каждом `file_uid = <uuid>`. Фикстуры вида `<name><другие символы>.json` без разделителя `_`
|
||||
(например, `aep_30.json` для `aep_3`) MUST NOT попадать в набор. При промахе по хешу и заданном
|
||||
`SCAN_EMULATOR_DEFAULT=<name>` с существующим набором эмулятор SHALL отдать набор `<name>`; лог SHALL сообщать, что
|
||||
пара найдена по умолчанию.
|
||||
|
||||
#### Scenario: Точное имя
|
||||
- **WHEN** входящий файл называется `<uuid>.pdf`, а в фикстурах есть `aep_5.pdf` и `aep_5.json`
|
||||
- **THEN** имя входящего файла не используется для поиска: пара определяется только по содержимому (сценарий «Пара по содержимому»)
|
||||
|
||||
#### Scenario: Пара по содержимому
|
||||
- **WHEN** входящий файл байт в байт равен `aep_5.pdf`, в фикстурах есть `aep_5.json`
|
||||
- **THEN** в `Output/<uuid>/` один файл `<uuid>.json`, равный `aep_5.json` с заменённым `file_uid`
|
||||
|
||||
#### Scenario: Суффикс
|
||||
- **WHEN** входящий файл равен `aep_8.pdf`, в фикстурах есть `aep_8_form4.json`
|
||||
- **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` и `<uuid>_ofr.json`, в обоих `file_uid = <uuid>`
|
||||
|
||||
#### Scenario: Чужой префикс
|
||||
- **WHEN** входящий файл равен `aep_3.pdf`, в фикстурах есть также `aep_30.json`
|
||||
- **THEN** `aep_30.json` в набор не попадает
|
||||
|
||||
#### Scenario: Промах с набором по умолчанию
|
||||
- **WHEN** `SCAN_EMULATOR_DEFAULT=aep_6`, входящий файл — произвольный PDF, которого нет в фикстурах
|
||||
- **THEN** в `Output/<uuid>/` набор `aep_6` (`<uuid>.json` из `aep_6.json`), в логе отметка «по умолчанию»
|
||||
|
||||
#### Scenario: Имя входящего файла не влияет
|
||||
- **WHEN** входящий файл называется `<uuid>.pdf`, а его содержимое равно `aep_6.pdf`
|
||||
- **THEN** отдан набор `aep_6`
|
||||
|
||||
### Requirement: Неизвестный файл — ошибка в формате Content AI
|
||||
Если пара по содержимому не найдена и `SCAN_EMULATOR_DEFAULT` не задан (или его набор отсутствует), эмулятор SHALL
|
||||
записать один ответ `<uuid>.json` в формате реальных ответов-ошибок ИС (`aep_5.json`, `aep_8_form4.json`):
|
||||
`file_uid = <uuid>`, `error = {"code": "INVALID_DOCUMENT", "message": "<sha256[:12]>: нет пары по содержимому в
|
||||
фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`, `date` — `{"form_name":
|
||||
"", "confidence": 0}`) и `table_structure: []`.
|
||||
|
||||
#### Scenario: Нет пары
|
||||
- **WHEN** входящий файл — произвольный PDF, `SCAN_EMULATOR_DEFAULT` не задан
|
||||
- **THEN** ответ содержит `error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`; `scan-gateway` публикует конверт с тем же кодом
|
||||
5
openspec/changes/slice-3a-ocr-normalizer/tasks.md
Normal file
5
openspec/changes/slice-3a-ocr-normalizer/tasks.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Tasks: Срез 3a — горячие папки и нормализатор OCR
|
||||
|
||||
Заполняется планом (`writing-plans`) после одобрения спеки владельцем. Репозиторий — scan-gateway; команды — из `api/`.
|
||||
|
||||
- [ ] 0.1 План реализации написан и одобрен; задачи перенесены сюда из плана
|
||||
Loading…
x
Reference in New Issue
Block a user