Ссылки README и CLAUDE.md ведут в архив. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
38 KiB
Markdown
336 lines
38 KiB
Markdown
# image-recognition-gateway Specification
|
||
|
||
## Purpose
|
||
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает и подтверждает задание из NATS без JetStream,
|
||
передаёт файл через горячие папки, ждёт ответ не дольше таймаута, сохраняет сырой ответ в S3 и отвечает ядру по
|
||
контракту v1.0. Без БД и фронта.
|
||
|
||
## Requirements
|
||
|
||
### Requirement: Сервис не зависит от БД и фронта
|
||
Репозиторий MUST NOT содержать `web/`, `alembic/`, `src/db/`, `src/models/`, `src/repositories/`; зависимости `SQLAlchemy`,
|
||
`asyncpg`, `alembic`, `raisa_fastapi_protected_api` MUST NOT присутствовать; `smbprotocol` SHALL остаться. Для старта
|
||
SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFOLDER_MODE=local`, `SCAN_HOTFOLDER_PATH`.
|
||
|
||
#### Scenario: Старт в режиме локальных папок
|
||
- **WHEN** сервис запущен с `SCAN_HOTFOLDER_MODE=local` и существующим каталогом `SCAN_HOTFOLDER_PATH`
|
||
- **THEN** `GET /healthcheck` → 200 с полями `uptime`, `version`, `nats`, `hotfolder = ok`; `OPENBAO__SMB__*` не требуются
|
||
|
||
### Requirement: Подтверждение запроса до обработки
|
||
Сервис SHALL быть подписан на `files.img_uploaded` в queue group `scan-gateway`. Получив запрос, проходящий схему,
|
||
сервис SHALL до чтения файла из S3 опубликовать в `files.tabular_parsed` конверт `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 записываться сразу во входящий каталог
|
||
открытием на запись с усечением, как в старом АРМ (решение владельца 09.10.2026): временных файлов, служебного каталога
|
||
и переименования нет; если `<Input>/<job_id>.<ext>` уже существует, он SHALL быть перезаписан содержимым объекта S3.
|
||
Затем сервис SHALL опрашивать исходящий каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые `SCAN_POLL_SECONDS` до
|
||
`min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)` (`SCAN_TIMEOUT_SECONDS` по умолчанию 1200). ИС кладёт в этот каталог
|
||
все распознанные документы задания — по одному `*.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** `Input/3f1c….pdf` перезаписан и байт в байт равен объекту S3, задание продолжается опросом `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** ответ не считается готовым, опрос продолжается до дедлайна
|
||
|
||
#### Scenario: Ошибка посреди записи
|
||
- **WHEN** запись `Input/3f1c….pdf` прервалась ошибкой ввода-вывода
|
||
- **THEN** опубликован конверт `RECOGNITION_FAILED`; недописанный файл удалён только при `SCAN_CLEANUP=true`, при `false` остаётся на месте
|
||
|
||
### Requirement: Сырой ответ сохраняется в S3 до нормализации
|
||
Все файлы ответа ИС SHALL быть склеены в один объект `{"job_id": "<job_id>", "files": [{"name": "<имя файла>",
|
||
"response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}` (файлы по возрастанию имени) и записаны
|
||
в `raw/{job_id}.json` того же бакета **до** вызова нормализатора; файл, не разобранный как JSON, SHALL получить
|
||
`response = null` и заполненный `parse_error` и MUST NOT прерывать задание. Ключ SHALL передаваться в конверте результата
|
||
полем `raw_s3_key` (одна строка `raw/{job_id}.json`); в БД объект не регистрируется (ядро его не создаёт).
|
||
|
||
#### Scenario: Нормализатор упал
|
||
- **WHEN** ответ ИС получен, а нормализатор бросил исключение
|
||
- **THEN** объект `raw/{job_id}.json` существует в S3, конверт содержит `raw_s3_key` и `error.code = UNKNOWN_ERROR`
|
||
|
||
#### Scenario: Склеенный сырой ответ
|
||
- **WHEN** ИС положила в `Output/<job_id>/` файлы `<job_id>.json` и `<job_id>_ofr.json`
|
||
- **THEN** `raw/{job_id}.json` содержит `job_id` и `files` из двух элементов (`<job_id>.json`, затем `<job_id>_ofr.json`), у каждого `response` — разобранный ответ, `parse_error = null`
|
||
|
||
#### Scenario: Файл не JSON
|
||
- **WHEN** один из файлов ответа не разбирается как JSON
|
||
- **THEN** в `files` для него `response = null`, `parse_error` непустой; остальные файлы разобраны, задание не падает
|
||
|
||
### Requirement: Результат по контракту
|
||
Сервис SHALL публиковать в `files.tabular_parsed` конверт по `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 = "2.0"` — `CONTRACT_VERSION` сервиса; запрос `recognition-request` остаётся `1.x`, запрос `2.0` → `SCHEMA_MISMATCH`) в `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`». Конверт (включая ack `processing`) и файл результата несут `schema_version = "2.0"`, `result_schema_version = "2.0"`; `service.statements_version` (содержимое `api/arm_statements/VERSION`) SHALL быть в файле результата, в конверте `service` — `{name, version}`. Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у
|
||
первого ошибочного файла по имени (для `parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не
|
||
входит в перечень кодов контракта, `error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`.
|
||
Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС в `statementDocument`», а статус определяется по требованию «Статус результата нормализации». `metrics.warnings` SHALL быть списком структур предупреждений (`code`, `severity`, `message`, `locator`, …) по схеме v2.0 — одним и тем же в конверте и в файле результата (предел 100 структур, `message` до 1000 символов; при переполнении остаются все `error`, затем `info`). Каждый ошибочный json набора SHALL давать предупреждение `JSON_FAILED` (`error`, `locator = file:<имя json>`, `message = "<код ИС>: <сообщение ИС>"`; для `parse_error` и не-объекта — код `INVALID_FORMAT`), в том числе когда ошибочны все json; исключение при разборе таблицы — `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:<i>`, `message = "BACKEND_PARSING_ERROR: <тип>: <текст>"`). Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` SHALL давать `JSON_FAILED`. Если предупреждений нет, поле `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` содержит одну структуру `JSON_FAILED` (`error`, `locator = file:<job_id>.json`, `message = 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` содержит `JSON_FAILED` (`error`, `locator = file:<job_id>_ofr.json`, `message = INVALID_DOCUMENT: <сообщение>`) и предупреждения пакета; ядро не автоподтверждает документы файла
|
||
|
||
#### Scenario: Все документы ошибочны
|
||
- **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON)
|
||
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит два `JSON_FAILED` (по одному на json); у файла не JSON сообщение начинается с `INVALID_FORMAT`
|
||
|
||
#### Scenario: Все файлы не JSON
|
||
- **WHEN** единственный файл ответа не разбирается как JSON
|
||
- **THEN** конверт `status = error`, `error.code = INVALID_FORMAT`, `metrics.warnings` содержит один `JSON_FAILED` с `message = 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`, `JSON_FAILED` в `metrics.warnings` с `message = SOME_NEW_CODE: <сообщение>`
|
||
|
||
#### Scenario: Все json набора ошибочны
|
||
- **WHEN** оба json набора ошибочны (первый по имени — `INVALID_DOCUMENT`)
|
||
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код и сообщение первого ошибочного json), `metrics.warnings` содержит `JSON_FAILED` каждого json
|
||
|
||
#### Scenario: Успешный json без таблиц
|
||
- **WHEN** успешный json не содержит `data_ocr.table_structure` или `table_structure: []`
|
||
- **THEN** этот json даёт `JSON_FAILED`; если успешных json с таблицами нет — конверт `status = error`
|
||
|
||
### Requirement: Таймаут ожидания ответа
|
||
Если готовый набор ответа (см. «Передача файла…») не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`;
|
||
повторная отправка файла в ИС MUST NOT выполняться.
|
||
|
||
#### Scenario: ИС молчит
|
||
- **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется
|
||
- **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге
|
||
|
||
#### Scenario: Набор не успел настояться до дедлайна
|
||
- **WHEN** в `Output/<job_id>/` есть `*.json`, но набор имён или размеров менялся позже, чем `SCAN_SETTLE_SECONDS` до дедлайна
|
||
- **THEN** ответ не считается готовым, опубликован конверт `TIMEOUT`, недостающие файлы не ожидаются
|
||
|
||
### 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`
|
||
|
||
### Requirement: Параллельная обработка ограничена
|
||
Сервис SHALL обрабатывать не более `SCAN_MAX_INFLIGHT` (по умолчанию 4) заданий одновременно; задания сверх лимита
|
||
SHALL ждать слот во внутренней очереди, их ack SHALL быть опубликован сразу при получении.
|
||
|
||
#### Scenario: Пять заданий подряд
|
||
- **WHEN** в очереди пять запросов при `SCAN_MAX_INFLIGHT = 4`
|
||
- **THEN** пять ack'ов опубликованы сразу, четыре файла появляются во входящем каталоге, пятый — после завершения любого из четырёх
|
||
|
||
### Requirement: Копия контракта идентична источнику
|
||
Каждый файл `api/contracts/**/*.json` SHALL быть байт в байт равен своей паре в `../docs/contracts/` (или в `contracts`
|
||
каталога из `ARM_KA_DOCS`) по правилу спеки docs `recognition-contract`, требование «Копия контракта в сервисе
|
||
идентична источнику»: схема с `$id` `…/v<M>` — с `<имя>.v<M>.schema.json`, если такой файл есть в docs, иначе с
|
||
основным; пример — с `examples/v<M>/<имя>.json`, если он есть, иначе с `examples/<имя>.json`; файлы docs другого
|
||
`MAJOR` не сверяются. pydantic-модели SHALL валидировать все примеры копии `api/contracts/examples/*.json` и порождать
|
||
JSON, проходящий схему копии. Проверка входит в `make check`.
|
||
|
||
#### Scenario: `make check`
|
||
- **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`)
|
||
- **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением
|
||
|
||
#### Scenario: Окно перехода на контракт 2.0
|
||
- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, рядом `recognition-result.v1.schema.json` и
|
||
`examples/v1/`, а копия сервиса — v1.2
|
||
- **THEN** копия сверяется с файлами `.v1` и `examples/v1/` (запрос — с основным файлом), тест проходит
|
||
|
||
#### Scenario: Копия устарела
|
||
- **WHEN** в docs изменён файл, с которым сверяется копия, а копия — нет
|
||
- **THEN** тест идентичности падает, сообщение называет файл
|
||
|
||
### Requirement: Соединение с NATS без JetStream
|
||
При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT из `OPENBAO__NATS__NATS_SEED`/`NATS_JWT` (значения — секреты); MUST NOT требовать
|
||
stream'ов и consumer'ов JetStream; при разрыве SHALL переподключаться и восстанавливать подписку; `/healthcheck.nats`
|
||
SHALL отражать состояние.
|
||
|
||
#### Scenario: NATS перезапущен
|
||
- **WHEN** контейнер NATS перезапущен во время работы сервиса
|
||
- **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`,
|
||
если они существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая недописанный при
|
||
ошибке записи входящий файл. При `TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться
|
||
(при ошибке записи и `SCAN_CLEANUP=true` снимается только недописанный входящий файл). Ошибка удаления 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`
|
||
|
||
#### Scenario: Очистка выключена
|
||
- **WHEN** `SCAN_CLEANUP=false`, ответ прочитан
|
||
- **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 оставить у себя обмен с ИС Сканирование (горячие папки, опрос, срок, склейку набора json в `raw/{job_id}.json` до нормализации, очистку папки) и адаптер «таблица `table_structure[i]` json → `FormCandidate`»; шаги 2–8 SHALL выполняться копией пакета `arm_statements` (`api/arm_statements/`, правке не подлежит). Адаптер SHALL: создавать кандидата на каждую таблицу каждого успешного json; копировать шапку json (`organization`, `inn`, `units`, `date`) в шапку каждой своей таблицы как `Cell` (`text = form_name`, `confidence` ИС); передавать `label.form_name` таблицы как заголовок в `evidence` (тип — общим правилом шага 1 по словарю заголовков пакета, `type_hint(okud_text, title_text, rules)`, у OCR `okud_text = None`); строить плотную сетку по `row_index` и `column_index` ИС (пустые позиции — `null`), `Cell.ref = row:<row_index>`; `locator = file:<имя json>;table:<i>`. `value` у OCR-ячеек всегда `null`, `source = OCR`. Нарушенная структура таблицы ИС SHALL давать исключение адаптера и `TABLE_PARSE_ERROR` всей таблицы, остальные таблицы разбираются. Статус конверта SHALL определяться только `envelope_status` пакета; перевод документов и предупреждений в контракт — только `to_contract` / `warning_to_contract` пакета.
|
||
|
||
#### Scenario: Один json — ББ и ОФР
|
||
- **WHEN** json `aep_3.json` содержит две таблицы — «Бухгалтерский баланс» и «Отчет о финансовых результатах» — под общей шапкой
|
||
- **THEN** два кандидата с `locator` `file:aep_3.json;table:0` и `…;table:1`, у каждого копия шапки json
|
||
|
||
#### Scenario: Таблица упала при разборе
|
||
- **WHEN** разбор `table:1` json `aep_3.json` бросил исключение, `table:0` разобрана
|
||
- **THEN** документ `BALANCE` из `table:0`; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с `locator = "file:aep_3.json;table:1"`; конверт `needs_check`
|
||
|
||
#### Scenario: Таблица ИС только с текстом
|
||
- **WHEN** в json есть таблица без столбца кодов и без распознанного типа, в ячейках только текст подписей
|
||
- **THEN** документа нет, в `metrics.warnings` — `TABLE_SKIPPED` уровня `info` с `locator` таблицы; конверт из-за неё не `needs_check`
|
||
|
||
#### Scenario: Чистый скан с шапкой (`aep_6.json`)
|
||
- **WHEN** нормализуется `aep_6.json` (две таблицы: «Бухгалтерский баланс» и «Отчет о финансовых результатах»)
|
||
- **THEN** два кандидата, из них два документа `BALANCE` и `INCOME` по шагам 2–8 пакета; поля, колонки и значения — по golden `fixtures/ocr/expected_normalize/`; `header.units = "тыс. руб."`; статус определяет `envelope_status` пакета
|
||
|
||
#### Scenario: Зашумлённый скан без шапки (`aep_3.json`)
|
||
- **WHEN** нормализуется `aep_3.json` (таблица «Бухгалтерский баланс» без строки-шапки, коды с мусором)
|
||
- **THEN** разбор столбца кода, синтез шапки, предупреждения и статус документа — правилами пакета (шаги 2–8), шлюз их не дублирует; `header.inn` и `report_date` — по правилам очистки пакета; строки без кода сохраняются со статусом строки
|
||
|
||
#### Scenario: Суммы в скобках и с пробелами (`aep_3.json`, ОФР)
|
||
- **WHEN** ячейка значения содержит `(7744536}` с `confidence 88.9`, другая — `(6032409)`, третья — `13 070`
|
||
- **THEN** разбор сумм и `is_recognized` — общие правила пакета (шаг 4); шлюз передаёт текст ячейки как есть (`value = None`), без собственного разбора
|
||
|
||
#### Scenario: Нечисловая ячейка значения
|
||
- **WHEN** ячейка значения содержит `не ****` с `confidence 5.6`
|
||
- **THEN** `amount = null`, `string_value = "не ****"`, `is_recognized = false` (правило шага 4 пакета)
|
||
|
||
#### Scenario: Повтор кода строки
|
||
- **WHEN** в таблице баланса две строки с кодом `1150`
|
||
- **THEN** поведение определяет шаг пакета для повтора кода; шлюз ничего не отбрасывает до шагов 2–8, предупреждение приходит от пакета структурой `metrics.warnings`
|
||
|
||
#### Scenario: Тип по кодам при зашумлённом label
|
||
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен словарём заголовков), коды строк `1110…1700`
|
||
- **THEN** тип определяется общим правилом шага 1 пакета (по кодам), предупреждение — структурой пакета
|
||
|
||
#### Scenario: Label противоречит единодушным кодам
|
||
- **WHEN** `label.form_name` таблицы — `Бухгалтерский баланс`, все коды строк данных `2110…2910`
|
||
- **THEN** тип и предупреждение о расхождении — по правилам пакета; `header.form_name` — текст `label` как распознан; статус `needs_check`
|
||
|
||
#### Scenario: Label противоречит смешанным кодам
|
||
- **WHEN** `label.form_name` таблицы — `Отчет о финансовых результатах`, коды строк данных `1150`, `1100` и `2110`
|
||
- **THEN** тип и предупреждение о расхождении — по правилам пакета; статус `needs_check`
|
||
|
||
#### Scenario: Таблица без столбца кода
|
||
- **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов
|
||
- **THEN** документа нет, в `metrics.warnings` — `TABLE_SKIPPED` с `locator` таблицы; остальные таблицы файла разобраны
|
||
|
||
### Requirement: Статус результата нормализации
|
||
Статус конверта SHALL определяться функцией `envelope_status` пакета `arm_statements` по документам и предупреждениям. Если из успешных файлов не собран ни один документ — `status = error`, `error.code = RECOGNITION_FAILED`, `error.message = "ни одной таблицы не разобрано"`, причины по таблицам — в `metrics.warnings` (`TABLE_PARSE_ERROR`, `TABLE_SKIPPED`, `TABLE_LOST`); если все json ошибочны — код и сообщение первого ошибочного json. Если документы есть — `done` или `needs_check` по `envelope_status`; потерянный json набора (`JSON_FAILED`) или таблица (`TABLE_PARSE_ERROR`, `TABLE_LOST`) исключают `done`. `metrics.rows` — число строк `lines` всех документов, включая строки без кода.
|
||
|
||
#### Scenario: Ни одной таблицы
|
||
- **WHEN** единственный успешный файл содержит `table_structure: []`
|
||
- **THEN** json даёт `JSON_FAILED`, конверт `status = error`, `raw_s3_key` заполнен
|
||
|
||
#### Scenario: Все таблицы упали при разборе
|
||
- **WHEN** в успешном json разбор каждой таблицы бросил исключение
|
||
- **THEN** конверт `status = error`, `error.code = RECOGNITION_FAILED`, `error.message = "ни одной таблицы не разобрано"`, `metrics.warnings` содержит `TABLE_PARSE_ERROR` каждой таблицы
|
||
|
||
#### Scenario: Потерянный документ набора
|
||
- **WHEN** `<job_id>.json` успешен, `<job_id>_ofr.json` ошибочен
|
||
- **THEN** статус не `done`, а `needs_check`
|
||
|
||
#### Scenario: Всё распознано
|
||
- **WHEN** синтетический ответ, где все ячейки распознаны уверенно, шапка и даты разобраны, предупреждений нет
|
||
- **THEN** `status = done`, поле `metrics.warnings` отсутствует
|
||
|
||
#### Scenario: Исключение в одной таблице
|
||
- **WHEN** разбор `table_structure[1]` бросает исключение, `table_structure[0]` разбирается
|
||
- **THEN** один документ, `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:1`, `message = BACKEND_PARSING_ERROR: <класс>: <сообщение>`), статус `needs_check`
|
||
|
||
### Requirement: Subject'ы и учётные данные NATS из настроек окружения
|
||
Сервис SHALL брать subject'ы обмена из `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT`, `NATS_IMAGE_REQUEST_SUBJECT`,
|
||
`NATS_RESULT_SUBJECT` (умолчания `files.tabular_uploaded`, `files.img_uploaded`, `files.tabular_parsed`): подписан на `image_request` (queue group `scan-gateway`), публикует ack и результат в `result`.
|
||
При старте SHALL проверяться все три имени (непустое, без пробельных символов, без `*` и `>`, без пустых токенов,
|
||
попарно различны); при нарушении приложение MUST NOT стартовать. Значения `NATS_JWT` и `NATS_SEED` MUST NOT попадать в
|
||
логи, `repr` настроек и тексты ошибок валидации. Используемые subject'ы SHALL писаться в лог при старте и отдаваться в
|
||
`/healthcheck` объектом `subjects` с ключами `image_request`, `result` при любом значении `nats` (`ok | down | off`).
|
||
|
||
#### Scenario: Умолчания
|
||
- **WHEN** приложение стартует без ключей `OPENBAO__NATS__NATS_*_SUBJECT`
|
||
- **THEN** `/healthcheck.subjects` содержит имена `files.*` по умолчанию, обмен идёт по ним
|
||
|
||
#### Scenario: Subject'ы окружения
|
||
- **WHEN** заданы `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT=test.files.tabular_uploaded`,
|
||
`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=test.files.img_uploaded`,
|
||
`OPENBAO__NATS__NATS_RESULT_SUBJECT=test.files.tabular_parsed`
|
||
- **THEN** подписки и публикации идут только по этим subject'ам, `/healthcheck.subjects` их показывает
|
||
|
||
#### Scenario: Недопустимый subject
|
||
- **WHEN** `OPENBAO__NATS__NATS_RESULT_SUBJECT` совпадает с subject'ом запроса или содержит `>`
|
||
- **THEN** приложение не стартует, ошибка называет ключ
|
||
|
||
#### Scenario: JWT не раскрывается
|
||
- **WHEN** заданы `NATS_JWT` и `NATS_SEED`, а валидация настроек падает на другом поле
|
||
- **THEN** текст ошибки и лог не содержат значений JWT и seed; `repr` настроек показывает их скрытыми
|
||
|
||
#### Scenario: Тесты на своих subject'ах
|
||
- **WHEN** тесты интеграционные (`tests/integration/`) запускаются при работающих контейнерах стенда на том же NATS
|
||
- **THEN** тесты используют уникальные subject'ы из настроек и не получают и не забирают сообщения стенда
|