openspec: архив change slice-3a-ocr-normalizer (2026-10-07), спеки image-recognition-gateway и scan-emulator

Ссылки README и CLAUDE.md переведены на архив.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 05:07:07 +04:00
parent 477816225b
commit 98f21297bb
10 changed files with 236 additions and 53 deletions

View File

@ -17,7 +17,7 @@
§11 и §11.1: правило, которого там нет, не реализуется, а поднимается вопросом. Функции очистки §11 и §11.1: правило, которого там нет, не реализуется, а поднимается вопросом. Функции очистки
(`src/normalizer/ocr/`) перенесены из старого ядра **дословно** (единственное исключение для `reference-old-arm`): (`src/normalizer/ocr/`) перенесены из старого ядра **дословно** (единственное исключение для `reference-old-arm`):
при правке сначала разберись, что делает источник; расхождения с ним — только решением, записанным в при правке сначала разберись, что делает источник; расхождения с ним — только решением, записанным в
`openspec/changes/slice-3a-ocr-normalizer/design.md` (таблица «Decisions made without discussion»: №12, №13, №20, №22, `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md` (таблица «Decisions made without discussion»: №12, №13, №20, №22,
№30 — `Money` не больше двух знаков после запятой, №31 — тексты дат длиннее 200 символов не разбираются). №30 — `Money` не больше двух знаков после запятой, №31 — тексты дат длиннее 200 символов не разбираются).
Функции `ocr/*` — чистые: без I/O, без настроек, без логов. Функции `ocr/*` — чистые: без I/O, без настроек, без логов.
- Справочники — данные: `api/rules/*.yaml` (редакции форм `66n` / `fsbu4`, двойники, `label` → тип), правила не - Справочники — данные: `api/rules/*.yaml` (редакции форм `66n` / `fsbu4`, двойники, `label` → тип), правила не
@ -50,7 +50,7 @@
`<uuid>_<суффикс>.json` (атомарно, через `Tmp`). Промах → набор `SCAN_EMULATOR_DEFAULT`, иначе ответ-ошибка `<uuid>_<суффикс>.json` (атомарно, через `Tmp`). Промах → набор `SCAN_EMULATOR_DEFAULT`, иначе ответ-ошибка
`INVALID_DOCUMENT`. Имя входящего файла на выбор пары не влияет. `INVALID_DOCUMENT`. Имя входящего файла на выбор пары не влияет.
- Описание сервиса, протокол горячих папок, нормализация, env, запуск и коды ошибок — `README.md`; требования и решения - Описание сервиса, протокол горячих папок, нормализация, env, запуск и коды ошибок — `README.md`; требования и решения
Среза 3a — `openspec/changes/slice-3a-ocr-normalizer/` (`design.md`, `specs/`), Среза 0 — Среза 3a — `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/` (`design.md`, `specs/`), Среза 0 —
`openspec/changes/archive/2026-10-04-slice-0-skeleton/`. `openspec/changes/archive/2026-10-04-slice-0-skeleton/`.
## Соглашения шаблона ## Соглашения шаблона

View File

@ -17,7 +17,7 @@ change'а `slice-3a-ocr-normalizer`: поля 1.1–1.2 документы PDF-
Состояние: Срез 3a. Сквозной путь работает, ответ ИС нормализуется в `statementDocument` v1.2 (раздел «Нормализация»); Состояние: Срез 3a. Сквозной путь работает, ответ ИС нормализуется в `statementDocument` v1.2 (раздел «Нормализация»);
раскладка горячих папок — как в проверенном в контуре `armka_file_worker` (`<job_id>.<ext>`). Известные ограничения — раскладка горячих папок — как в проверенном в контуре `armka_file_worker` (`<job_id>.<ext>`). Известные ограничения —
в конце файла. Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения — в конце файла. Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения —
`openspec/changes/slice-3a-ocr-normalizer/` (`design.md`: таблица «Decisions made without discussion») и архив `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/` (`design.md`: таблица «Decisions made without discussion») и архив
Среза 0 `openspec/changes/archive/2026-10-04-slice-0-skeleton/`. Среза 0 `openspec/changes/archive/2026-10-04-slice-0-skeleton/`.
## Поток обработки ## Поток обработки
@ -167,7 +167,7 @@ ack (с `attempt` повторного запроса); файл в ИС вто
колонки, строки, значения с `is_recognized`) → `__init__.py` (`OcrNormalizer.normalize`: обход файлов склейки, статус, колонки, строки, значения с `is_recognized`) → `__init__.py` (`OcrNormalizer.normalize`: обход файлов склейки, статус,
`metrics`). `metrics`).
Что получается в документе (подробно — требование «Нормализация ответа ИС» спеки `openspec/changes/slice-3a-ocr-normalizer/`): Что получается в документе (подробно — требование «Нормализация ответа ИС» спеки `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/`):
- `document_type` — по `label` таблицы (`statement_labels.yaml`), если не сопоставился — по первой цифре кодов строк - `document_type` — по `label` таблицы (`statement_labels.yaml`), если не сопоставился — по первой цифре кодов строк
(`1…` → `BALANCE`, `2…` → `INCOME`), иначе `OTHER` (документ всё равно отдаётся, статус `needs_check`). (`1…` → `BALANCE`, `2…` → `INCOME`), иначе `OTHER` (документ всё равно отдаётся, статус `needs_check`).
@ -466,7 +466,7 @@ make golden-update # пересобрать эталоны нормализ
## Известные ограничения ## Известные ограничения
Нормализатор (Срез 3a; решения — таблица «Decisions made without discussion» в Нормализатор (Срез 3a; решения — таблица «Decisions made without discussion» в
`openspec/changes/slice-3a-ocr-normalizer/design.md`): `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md`):
- Редакция `fsbu4` (отчётность с 2025 года) пока пуста: имена строк документов 2025 года берутся из OCR, предупреждение - Редакция `fsbu4` (отчётность с 2025 года) пока пуста: имена строк документов 2025 года берутся из OCR, предупреждение
«редакция fsbu4 не заполнена», статус `needs_check`. Состав редакции — данные владельца (см. «Справочники»). «редакция fsbu4 не заполнена», статус `needs_check`. Состав редакции — данные владельца (см. «Справочники»).

View File

@ -28,22 +28,33 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO
#### Scenario: Повторный запрос #### Scenario: Повторный запрос
- **WHEN** тот же запрос доставлен повторно во время ожидания ответа ИС - **WHEN** тот же запрос доставлен повторно во время ожидания ответа ИС
- **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `<job_id>_aep_5.pdf` - **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `<job_id>.pdf`
### Requirement: Передача файла в ИС Сканирование через горячие папки ### Requirement: Передача файла в ИС Сканирование через горячие папки
После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как
`<job_id>_<original_name>`; временный файл записи SHALL лежать в служебном каталоге `<корень>/<SCAN_TMP_DIR>/` `<job_id>.<ext>`, где `<ext>` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL
(по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и переименовываться в `Input` атомарно, так завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Временный файл записи SHALL лежать в служебном
что во входящем каталоге MUST NOT появляться недописанные и временные файлы. Затем сервис SHALL опрашивать исходящий каталоге `<корень>/<SCAN_TMP_DIR>/` (по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и
каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. переименовываться в `Input` атомарно **без замены**: если `<Input>/<job_id>.<ext>` уже существует, файл SHALL считаться
ИС кладёт в этот каталог все распознанные документы задания — по одному `*.json` на документ. Ответ SHALL считаться доставленным, временный файл MUST NOT его перезаписывать. Во входящем каталоге MUST NOT появляться недописанные и
готовым, когда в каталоге есть хотя бы один `*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` временные файлы. Затем сервис SHALL опрашивать исходящий каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые
(по умолчанию 2); готовый набор SHALL включать **все** `*.json` каталога по возрастанию имени, остальные файлы `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. ИС кладёт в этот каталог все распознанные
игнорируются. документы задания — по одному `*.json` на документ, набор появляется целиком и больше не меняется; пустой или
отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один `*.json`, а
набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2); готовый набор SHALL включать **все**
`*.json` каталога по возрастанию имени, остальные файлы игнорируются.
#### Scenario: Файл положен во входящий каталог #### Scenario: Файл положен во входящий каталог
- **WHEN** получен запрос для `aep_5.pdf` с `job_id = 3f1c…` - **WHEN** получен запрос для `aep_5.PDF` с `job_id = 3f1c…`
- **THEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`, байт в байт равный объекту S3; во время записи во входящем каталоге нет временных файлов, а `Tmp/` существует - **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: Ответ из одного файла #### Scenario: Ответ из одного файла
- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS` - **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS`
@ -79,29 +90,34 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO
### Requirement: Результат по контракту ### Requirement: Результат по контракту
Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с
`service.name = "scan-gateway"`, `Nats-Msg-Id = result:<job_id>:<attempt>`; при `status ∈ {done, needs_check}` — файл `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 повторить её до трёх раз. результата (`recognition-result.schema.json`, `schema_version = "1.0"` — `CONTRACT_VERSION` сервиса, решение №14; схема допускает любой MINOR) в `results/{file_id}/{job_id}/result.json` и `result_s3`;
В Срезе 0 нормализатор получает склеенный ответ и SHALL считать файл успешным, если `error.code == ""` (поле `error` при неудаче публикации SHALL повторить её до трёх раз. Нормализатор получает склеенный ответ и SHALL считать файл
реального успешного ответа — `{"code": "", "message": ""}`, а не `null`; отсутствующий или `null` `error` ошибкой не успешным, если `error.code == ""` (поле `error` реального успешного ответа — `{"code": "", "message": ""}`, а не `null`;
считается), и ошибочным, если `error.code` непуст, `parse_error` не `null` либо корень ответа файла не является объектом отсутствующий или `null` `error` ошибкой не считается), и ошибочным, если `error.code` непуст, `parse_error` не `null`
JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST NOT определяться проверкой «`error` не `null`». Если ошибочны либо корень ответа файла не является объектом JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST NOT определяться
**все** файлы — `status = error`, `error.code` и `error.message` берутся у первого ошибочного файла по имени (для проверкой «`error` не `null`». Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у
`parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не входит в перечень кодов контракта, первого ошибочного файла по имени (для `parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не
`error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`. Если есть хотя бы один успешный входит в перечень кодов контракта, `error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`.
файл — `status = error`, `error.code = NOT_IMPLEMENTED`. В обоих случаях `metrics.warnings` SHALL содержать по одной Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС»,
строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как получен (для а статус определяется по требованию «Статус результата нормализации». В обоих случаях `metrics.warnings` SHALL
`parse_error` и не-объекта — `INVALID_FORMAT`); если ошибочных файлов нет, поле `warnings` SHALL быть опущено. содержать по одной строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как
получен (для `parse_error` и не-объекта — `INVALID_FORMAT`); если предупреждений нет, поле `warnings` SHALL быть опущено.
#### Scenario: Успешный ответ ИС в Срезе 0 #### Scenario: Успешный ответ ИС в Срезе 0
- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.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` отсутствует - **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: Ошибка ИС #### Scenario: Ошибка ИС
- **WHEN** единственный файл ответа `<job_id>.json` содержит `error.code = INVALID_DOCUMENT` - **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: <сообщение>` - **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `<job_id>.json: INVALID_DOCUMENT: <сообщение>`
#### Scenario: Ошибка одного из документов #### Scenario: Ошибка одного из документов
- **WHEN** ответ состоит из `<job_id>.json` (успех) и `<job_id>_ofr.json` (`INVALID_DOCUMENT`) — как у фикстур `aep_3.json` и `aep_3_ofr.json` - **WHEN** ответ состоит из `<job_id>.json` (успех, `aep_3.json`) и `<job_id>_ofr.json` (`INVALID_DOCUMENT`, `aep_3_ofr.json`)
- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `metrics.warnings` содержит ровно одну строку `<job_id>_ofr.json: INVALID_DOCUMENT: <сообщение>` - **THEN** конверт `status = needs_check`, документы собраны из `<job_id>.json`, `metrics.warnings` содержит строку `<job_id>_ofr.json: INVALID_DOCUMENT: <сообщение>` и предупреждения нормализации
#### Scenario: Все документы ошибочны #### Scenario: Все документы ошибочны
- **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON) - **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON)
@ -134,8 +150,9 @@ JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST
### Requirement: Ошибки транспорта превращаются в результат ### Requirement: Ошибки транспорта превращаются в результат
Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 → Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 →
`SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не `SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не
удалась → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error` этого файла, а если ошибочны все удалась или у исходника нет расширения → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error`
файлы — `INVALID_FORMAT`; иное исключение → `UNKNOWN_ERROR`. этого файла, а если ошибочны все файлы — `INVALID_FORMAT`; ни одной таблицы не разобрано нормализатором →
`RECOGNITION_FAILED`; иное исключение → `UNKNOWN_ERROR`. Ошибки очистки горячей папки MUST NOT влиять на конверт.
Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться. Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться.
#### Scenario: Горячая папка недоступна #### Scenario: Горячая папка недоступна
@ -166,3 +183,146 @@ SHALL отражать состояние.
#### Scenario: NATS перезапущен #### Scenario: NATS перезапущен
- **WHEN** контейнер NATS перезапущен во время работы сервиса - **WHEN** контейнер NATS перезапущен во время работы сервиса
- **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok` - **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok`
### 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 удалять ничего, включая осиротевшие временные файлы (`.part`, ставший
целью успешной публикации, снимается самой записью — это завершение «rename без замены», а не очистка; ошибка его снятия
запись не прерывает, решение №26; запись собственного `.part` заново удаляет прежний остаток под тем же именем, решение №32). При
`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** все файлы остаются на месте, сервис ничего не удаляет и не пишет в лог о очистке (решение №27)
#### 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`); строка без кода и
строка, чей код после обрезки пробелов не является целым числом (`1150 :` — перенос `_classify_row`: считается повторным
заголовком), пропускаются. Повтор кода в документе: остаётся первая строка, предупреждение `код <код> повторяется:
оставлена первая строка`, документ `needs_check` (DBML: `financial_statement_line` уникален по `(meta_id, line_code)`;
так же поступает Excel-путь, §12 п. 5). `line_name` — из справочника редакции по коду; если кода
нет в справочниках — текст ячейки наименования. `line_code_confidence` — `confidence` ячейки кода, если очищенный текст
равен сырому и код есть в справочнике, иначе `min(confidence, 99)`; `line_name_confidence` — то же для словарного
имени, иначе `confidence` ячейки наименования.
- `values` — по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок —
недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила
число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`1,234`) не помещается в `Money` контракта — `amount = null`,
`string_value` — текст ячейки, `is_recognized = false`, решение №30); `string_value` — текст ячейки как
распознан (`\n`/`\t` → пробел, края обрезаны) **только если `amount = null`** и текст не прочерк (DBML: «как распознано,
если не число»), иначе `null`; `confidence` — ячейки (значения вне 0–100 и NaN приводятся к диапазону, №30); `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 присутствовать.
Строка с непустым нечисловым кодом (`1150 :`, `изо :`) пропускается, на таблицу выдаётся одно предупреждение
`строк с нечисловым кодом пропущено: <N>`; строка с пустым кодом и хотя бы одной разобранной суммой в колонках значений
пропускается с одним предупреждением на таблицу `строк без кода с суммами пропущено: <N>` (как в Excel-пути, OQ-141);
строки с пустым кодом без сумм (заголовки групп, «в том числе») — без предупреждения (решение №29).
Двойники букв и цифр (`fix_lookalikes`) SHALL применяться к тексту дат (дата отчёта и подписи колонок) только вплотную
к цифрам; `|` SHALL считаться разделителем клеток, если первая строка значения состоит из цифр, пробелов и знаков
пунктуации. Коды очищаются `clean_value`, суммы — `parse_amount`, ИНН — выделением цифр (как в проверенном на стенде
старом ядре, §11.1 п. 1; решение №22 `design.md`).
#### 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 = null` у всех трёх (суммы разобраны)
#### Scenario: Нечисловая ячейка значения
- **WHEN** ячейка значения содержит `не ****` с `confidence 5.6`
- **THEN** `amount = null`, `string_value = "не ****"`, `is_recognized = false`
#### Scenario: Повтор кода строки
- **WHEN** в таблице баланса две строки с кодом `1150`
- **THEN** в документе одна строка `1150` (первая по порядку), предупреждение `<имя json>: table:<i>: код 1150 повторяется: оставлена первая строка`, статус `needs_check`
#### 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>» (для пустой редакции не выдаётся — достаточно «редакция <id> не заполнена»; решение №23). `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`. Если среди файлов набора есть ошибочный (при хотя бы одном успешном), статус не может быть
`done` — `needs_check` (потерян документ набора; решение №28). Предупреждения уровня таблицы 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`

View File

@ -8,49 +8,72 @@
## Requirements ## Requirements
### Requirement: Эмулятор отвечает по протоколу горячих папок ### Requirement: Эмулятор отвечает по протоколу горячих папок
Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; для нового файла Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; файлы с суффиксами `.part`,
`<uuid>_<name>.<ext>` через `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) SHALL создать каталог `<output>/<uuid>/` с `.tmp`, `.done` и файлы, начинающиеся с `.`, SHALL игнорироваться. Для нового файла `<uuid>.<ext>` через
набором ответов (по одному файлу на документ); каталог SHALL появиться в `<output>` атомарно целиком: набор записывается `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) эмулятор SHALL создать каталог `<output>/<uuid>/` с набором ответов (по
во временный каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `<uuid>_<name>.<ext>.done`, чтобы не обрабатываться повторно. одному файлу на документ); каталог SHALL появиться в `<output>` атомарно целиком: набор записывается во временный
каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `<uuid>.<ext>.done`,
чтобы не обрабатываться повторно.
#### Scenario: Один файл #### Scenario: Один файл
- **WHEN** во входящем каталоге появляется `3f1c…_aep_5.pdf` - **WHEN** во входящем каталоге появляется `3f1c….pdf` с содержимым `aep_5.pdf`
- **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `…pdf.done` - **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `3f1c….pdf.done`
#### Scenario: Набор появляется целиком #### Scenario: Набор появляется целиком
- **WHEN** эмулятор записывает набор из нескольких файлов - **WHEN** эмулятор записывает набор из нескольких файлов
- **THEN** каталог `Output/3f1c…/` либо не существует, либо уже содержит весь набор; частичного набора в `Output` нет - **THEN** каталог `Output/3f1c…/` либо не существует, либо уже содержит весь набор; частичного набора в `Output` нет
### Requirement: Ответы берутся из фикстур по имени #### Scenario: Временные файлы шлюза игнорируются
Эмулятор SHALL собрать в `SCAN_FIXTURES_DIR` **все** файлы `<name>.json` и `<name>_*.json`, где `<name>` — имя входящего - **WHEN** во входящем каталоге лежат `.3f1c….pdf.part` и `3f1c….pdf.done`
файла без префикса `<uuid>_` и расширения (регистр расширения входящего файла не важен). Файл `<name>.json` SHALL быть - **THEN** эмулятор не создаёт для них ответов
записан как `<uuid>.json`, файл `<name>_<suffix>.json` — как `<uuid>_<suffix>.json`; в каждом `file_uid = <uuid>`.
Фикстуры вида `<name><другие символы>.json` без разделителя `_` (например, `aep_30.json` для `aep_3`) MUST NOT попадать в набор. ### 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: Точное имя #### Scenario: Точное имя
- **WHEN** входящий файл — `…_aep_5.pdf`, в фикстурах есть `aep_5.json` - **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` - **THEN** в `Output/<uuid>/` один файл `<uuid>.json`, равный `aep_5.json` с заменённым `file_uid`
#### Scenario: Суффикс #### Scenario: Суффикс
- **WHEN** входящий файл — `…_aep_8.pdf`, в фикстурах есть `aep_8_form4.json` - **WHEN** входящий файл равен `aep_8.pdf`, в фикстурах есть `aep_8_form4.json`
- **THEN** в `Output/<uuid>/` файл `<uuid>_form4.json`, равный `aep_8_form4.json` с заменённым `file_uid` - **THEN** в `Output/<uuid>/` файл `<uuid>_form4.json`, равный `aep_8_form4.json` с заменённым `file_uid`
#### Scenario: Несколько файлов на одно задание #### Scenario: Несколько файлов на одно задание
- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть `aep_3.json` и `aep_3_ofr.json` - **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>` - **THEN** в `Output/<uuid>/` два файла: `<uuid>.json` и `<uuid>_ofr.json`, в обоих `file_uid = <uuid>`
#### Scenario: Чужой префикс #### Scenario: Чужой префикс
- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть также `aep_30.json` - **WHEN** входящий файл равен `aep_3.pdf`, в фикстурах есть также `aep_30.json`
- **THEN** `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 ### Requirement: Неизвестный файл — ошибка в формате Content AI
Если ни одного файла набора не найдено, эмулятор SHALL записать один ответ `<uuid>.json` в формате реальных ответов-ошибок Если пара по содержимому не найдена и `SCAN_EMULATOR_DEFAULT` не задан (или его набор отсутствует), эмулятор SHALL
ИС (`aep_5.json`, `aep_8_form4.json`): `file_uid = <uuid>`, `error = {"code": "INVALID_DOCUMENT", "message": "<имя>: нет записать один ответ `<uuid>.json` в формате реальных ответов-ошибок ИС (`aep_5.json`, `aep_8_form4.json`):
эталонного ответа в фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`, `file_uid = <uuid>`, `error = {"code": "INVALID_DOCUMENT", "message": "<sha256[:12]>: нет пары по содержимому в
`date` — `{"form_name": "", "confidence": 0}`) и `table_structure: []`. фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`, `date` — `{"form_name":
"", "confidence": 0}`) и `table_structure: []`.
#### Scenario: Нет пары #### Scenario: Нет пары
- **WHEN** входящий файл — `…_unknown.pdf` - **WHEN** входящий файл — произвольный PDF, `SCAN_EMULATOR_DEFAULT` не задан
- **THEN** ответ содержит `error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`; `scan-gateway` публикует конверт с тем же кодом - **THEN** ответ содержит `error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`; `scan-gateway` публикует конверт с тем же кодом
### Requirement: Эмулятор запускается из того же образа ### Requirement: Эмулятор запускается из того же образа