diff --git a/CLAUDE.md b/CLAUDE.md index f1cd339..c7025d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ §11 и §11.1: правило, которого там нет, не реализуется, а поднимается вопросом. Функции очистки (`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 символов не разбираются). Функции `ocr/*` — чистые: без I/O, без настроек, без логов. - Справочники — данные: `api/rules/*.yaml` (редакции форм `66n` / `fsbu4`, двойники, `label` → тип), правила не @@ -50,7 +50,7 @@ `_<суффикс>.json` (атомарно, через `Tmp`). Промах → набор `SCAN_EMULATOR_DEFAULT`, иначе ответ-ошибка `INVALID_DOCUMENT`. Имя входящего файла на выбор пары не влияет. - Описание сервиса, протокол горячих папок, нормализация, 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/`. ## Соглашения шаблона diff --git a/README.md b/README.md index a1f13b3..81ebbaf 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ change'а `slice-3a-ocr-normalizer`: поля 1.1–1.2 документы PDF- Состояние: Срез 3a. Сквозной путь работает, ответ ИС нормализуется в `statementDocument` v1.2 (раздел «Нормализация»); раскладка горячих папок — как в проверенном в контуре `armka_file_worker` (`.`). Известные ограничения — в конце файла. Правила для агента и соглашения репозитория — `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/`. ## Поток обработки @@ -167,7 +167,7 @@ ack (с `attempt` повторного запроса); файл в ИС вто колонки, строки, значения с `is_recognized`) → `__init__.py` (`OcrNormalizer.normalize`: обход файлов склейки, статус, `metrics`). -Что получается в документе (подробно — требование «Нормализация ответа ИС» спеки `openspec/changes/slice-3a-ocr-normalizer/`): +Что получается в документе (подробно — требование «Нормализация ответа ИС» спеки `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/`): - `document_type` — по `label` таблицы (`statement_labels.yaml`), если не сопоставился — по первой цифре кодов строк (`1…` → `BALANCE`, `2…` → `INCOME`), иначе `OTHER` (документ всё равно отдаётся, статус `needs_check`). @@ -466,7 +466,7 @@ make golden-update # пересобрать эталоны нормализ ## Известные ограничения Нормализатор (Срез 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 не заполнена», статус `needs_check`. Состав редакции — данные владельца (см. «Справочники»). diff --git a/openspec/changes/slice-3a-ocr-normalizer/.openspec.yaml b/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/.openspec.yaml similarity index 100% rename from openspec/changes/slice-3a-ocr-normalizer/.openspec.yaml rename to openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/.openspec.yaml diff --git a/openspec/changes/slice-3a-ocr-normalizer/design.md b/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md similarity index 100% rename from openspec/changes/slice-3a-ocr-normalizer/design.md rename to openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md diff --git a/openspec/changes/slice-3a-ocr-normalizer/proposal.md b/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/proposal.md similarity index 100% rename from openspec/changes/slice-3a-ocr-normalizer/proposal.md rename to openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/proposal.md diff --git a/openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md b/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md similarity index 100% rename from openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md rename to openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md diff --git a/openspec/changes/slice-3a-ocr-normalizer/specs/scan-emulator/spec.md b/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/specs/scan-emulator/spec.md similarity index 100% rename from openspec/changes/slice-3a-ocr-normalizer/specs/scan-emulator/spec.md rename to openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/specs/scan-emulator/spec.md diff --git a/openspec/changes/slice-3a-ocr-normalizer/tasks.md b/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/tasks.md similarity index 100% rename from openspec/changes/slice-3a-ocr-normalizer/tasks.md rename to openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/tasks.md diff --git a/openspec/specs/image-recognition-gateway/spec.md b/openspec/specs/image-recognition-gateway/spec.md index 0497b87..5fe145e 100644 --- a/openspec/specs/image-recognition-gateway/spec.md +++ b/openspec/specs/image-recognition-gateway/spec.md @@ -28,22 +28,33 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO #### Scenario: Повторный запрос - **WHEN** тот же запрос доставлен повторно во время ожидания ответа ИС -- **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `_aep_5.pdf` +- **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `.pdf` ### Requirement: Передача файла в ИС Сканирование через горячие папки После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как -`_`; временный файл записи SHALL лежать в служебном каталоге `<корень>//` -(по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и переименовываться в `Input` атомарно, так -что во входящем каталоге MUST NOT появляться недописанные и временные файлы. Затем сервис SHALL опрашивать исходящий -каталог `//` каждые `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. -ИС кладёт в этот каталог все распознанные документы задания — по одному `*.json` на документ. Ответ SHALL считаться -готовым, когда в каталоге есть хотя бы один `*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` -(по умолчанию 2); готовый набор SHALL включать **все** `*.json` каталога по возрастанию имени, остальные файлы -игнорируются. +`.`, где `` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL +завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Временный файл записи SHALL лежать в служебном +каталоге `<корень>//` (по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и +переименовываться в `Input` атомарно **без замены**: если `/.` уже существует, файл SHALL считаться +доставленным, временный файл MUST NOT его перезаписывать. Во входящем каталоге MUST NOT появляться недописанные и +временные файлы. Затем сервис SHALL опрашивать исходящий каталог `//` каждые +`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…_aep_5.pdf`, байт в байт равный объекту S3; во время записи во входящем каталоге нет временных файлов, а `Tmp/` существует +- **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` @@ -79,29 +90,34 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO ### Requirement: Результат по контракту Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с `service.name = "scan-gateway"`, `Nats-Msg-Id = result::`; при `status ∈ {done, needs_check}` — файл -результата в `results/{file_id}/{job_id}/result.json` и `result_s3`; при неудаче публикации SHALL повторить её до трёх раз. -В Срезе 0 нормализатор получает склеенный ответ и 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`. Если есть хотя бы один успешный -файл — `status = error`, `error.code = NOT_IMPLEMENTED`. В обоих случаях `metrics.warnings` SHALL содержать по одной -строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как получен (для -`parse_error` и не-объекта — `INVALID_FORMAT`); если ошибочных файлов нет, поле `warnings` SHALL быть опущено. +результата (`recognition-result.schema.json`, `schema_version = "1.0"` — `CONTRACT_VERSION` сервиса, решение №14; схема допускает любой MINOR) в `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** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/.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/.json`, `result_s3` указывает на файл результата с двумя документами (`BALANCE`, `INCOME`) #### Scenario: Ошибка ИС - **WHEN** единственный файл ответа `.json` содержит `error.code = INVALID_DOCUMENT` - **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `.json: INVALID_DOCUMENT: <сообщение>` #### Scenario: Ошибка одного из документов -- **WHEN** ответ состоит из `.json` (успех) и `_ofr.json` (`INVALID_DOCUMENT`) — как у фикстур `aep_3.json` и `aep_3_ofr.json` -- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `metrics.warnings` содержит ровно одну строку `_ofr.json: INVALID_DOCUMENT: <сообщение>` +- **WHEN** ответ состоит из `.json` (успех, `aep_3.json`) и `_ofr.json` (`INVALID_DOCUMENT`, `aep_3_ofr.json`) +- **THEN** конверт `status = needs_check`, документы собраны из `.json`, `metrics.warnings` содержит строку `_ofr.json: INVALID_DOCUMENT: <сообщение>` и предупреждения нормализации #### Scenario: Все документы ошибочны - **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON) @@ -134,8 +150,9 @@ JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST ### Requirement: Ошибки транспорта превращаются в результат Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не -удалась → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error` этого файла, а если ошибочны все -файлы — `INVALID_FORMAT`; иное исключение → `UNKNOWN_ERROR`. +удалась или у исходника нет расширения → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error` +этого файла, а если ошибочны все файлы — `INVALID_FORMAT`; ни одной таблицы не разобрано нормализатором → +`RECOGNITION_FAILED`; иное исключение → `UNKNOWN_ERROR`. Ошибки очистки горячей папки MUST NOT влиять на конверт. Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться. #### Scenario: Горячая папка недоступна @@ -166,3 +183,146 @@ SHALL отражать состояние. #### Scenario: NATS перезапущен - **WHEN** контейнер NATS перезапущен во время работы сервиса - **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok` + +### Requirement: Очистка горячей папки под флагом +При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от +исхода нормализации), сервис SHALL удалить всё содержимое `//` (json-файлы ответа и любые прочие) и сам каталог ``, входящий файл +`/.`, его вариант `/..done` и временный `/...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//` (если есть) не тронуты + +### 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:"`. +- Раскладка колонок — по строке с ячейкой «код…» (`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 :`, `изо :`) пропускается, на таблицу выдаётся одно предупреждение +`строк с нечисловым кодом пропущено: `; строка с пустым кодом и хотя бы одной разобранной суммой в колонках значений +пропускается с одним предупреждением на таблицу `строк без кода с суммами пропущено: ` (как в 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:: код 1150 повторяется: оставлена первая строка`, статус `needs_check` + +#### Scenario: Тип по кодам при зашумлённом label +- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700` +- **THEN** `document_type = BALANCE`, предупреждение «тип определён по кодам» + +#### Scenario: Таблица без столбца кода +- **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов из справочника +- **THEN** таблица пропущена, предупреждение `<имя json>: table:: нет шапки и столбца кода`; остальные таблицы файла разобраны + +### 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` колонок, при отсутствии обеих — по числу совпадений кодов с +предупреждением «редакция определена по кодам». Пустая выбранная редакция → предупреждение «редакция не +заполнена», имена строк — из OCR. Код, отсутствующий в выбранной непустой редакции, но присутствующий в другой → предупреждение +«код <код> из редакции » (для пустой редакции не выдаётся — достаточно «редакция не заполнена»; решение №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:: <текст>`, уровня +файла — `<имя 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` diff --git a/openspec/specs/scan-emulator/spec.md b/openspec/specs/scan-emulator/spec.md index 8c5b92b..c126855 100644 --- a/openspec/specs/scan-emulator/spec.md +++ b/openspec/specs/scan-emulator/spec.md @@ -8,49 +8,72 @@ ## Requirements ### Requirement: Эмулятор отвечает по протоколу горячих папок -Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; для нового файла -`_.` через `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) SHALL создать каталог `//` с -набором ответов (по одному файлу на документ); каталог SHALL появиться в `` атомарно целиком: набор записывается -во временный каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `_..done`, чтобы не обрабатываться повторно. +Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; файлы с суффиксами `.part`, +`.tmp`, `.done` и файлы, начинающиеся с `.`, SHALL игнорироваться. Для нового файла `.` через +`SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) эмулятор SHALL создать каталог `//` с набором ответов (по +одному файлу на документ); каталог SHALL появиться в `` атомарно целиком: набор записывается во временный +каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `..done`, +чтобы не обрабатываться повторно. #### Scenario: Один файл -- **WHEN** во входящем каталоге появляется `3f1c…_aep_5.pdf` -- **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `…pdf.done` +- **WHEN** во входящем каталоге появляется `3f1c….pdf` с содержимым `aep_5.pdf` +- **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `3f1c….pdf.done` #### Scenario: Набор появляется целиком - **WHEN** эмулятор записывает набор из нескольких файлов - **THEN** каталог `Output/3f1c…/` либо не существует, либо уже содержит весь набор; частичного набора в `Output` нет -### Requirement: Ответы берутся из фикстур по имени -Эмулятор SHALL собрать в `SCAN_FIXTURES_DIR` **все** файлы `.json` и `_*.json`, где `` — имя входящего -файла без префикса `_` и расширения (регистр расширения входящего файла не важен). Файл `.json` SHALL быть -записан как `.json`, файл `_.json` — как `_.json`; в каждом `file_uid = `. -Фикстуры вида `<другие символы>.json` без разделителя `_` (например, `aep_30.json` для `aep_3`) MUST NOT попадать в набор. +#### Scenario: Временные файлы шлюза игнорируются +- **WHEN** во входящем каталоге лежат `.3f1c….pdf.part` и `3f1c….pdf.done` +- **THEN** эмулятор не создаёт для них ответов + +### Requirement: Ответы берутся из фикстур по содержимому входящего файла +Эмулятор SHALL индексировать `SCAN_FIXTURES_DIR`: для каждого файла, не являющегося `*.json`, вычисляется sha256 → +основа имени `` (имя без расширения; регистр расширения не важен); индекс SHALL пересчитываться, когда меняется +список файлов каталога. Для входящего файла SHALL вычисляться sha256 содержимого; при совпадении эмулятор SHALL собрать +**все** файлы `.json` и `_*.json`: `.json` записывается как `.json`, `_.json` — как +`_.json`, в каждом `file_uid = `. Фикстуры вида `<другие символы>.json` без разделителя `_` +(например, `aep_30.json` для `aep_3`) MUST NOT попадать в набор. При промахе по хешу и заданном +`SCAN_EMULATOR_DEFAULT=` с существующим набором эмулятор SHALL отдать набор ``; лог SHALL сообщать, что +пара найдена по умолчанию. #### Scenario: Точное имя -- **WHEN** входящий файл — `…_aep_5.pdf`, в фикстурах есть `aep_5.json` +- **WHEN** входящий файл называется `.pdf`, а в фикстурах есть `aep_5.pdf` и `aep_5.json` +- **THEN** имя входящего файла не используется для поиска: пара определяется только по содержимому (сценарий «Пара по содержимому») + +#### Scenario: Пара по содержимому +- **WHEN** входящий файл байт в байт равен `aep_5.pdf`, в фикстурах есть `aep_5.json` - **THEN** в `Output//` один файл `.json`, равный `aep_5.json` с заменённым `file_uid` #### Scenario: Суффикс -- **WHEN** входящий файл — `…_aep_8.pdf`, в фикстурах есть `aep_8_form4.json` +- **WHEN** входящий файл равен `aep_8.pdf`, в фикстурах есть `aep_8_form4.json` - **THEN** в `Output//` файл `_form4.json`, равный `aep_8_form4.json` с заменённым `file_uid` #### Scenario: Несколько файлов на одно задание -- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть `aep_3.json` и `aep_3_ofr.json` -- **THEN** в `Output//` два файла: `.json` (из `aep_3.json`) и `_ofr.json` (из `aep_3_ofr.json`), в обоих `file_uid = ` +- **WHEN** входящий файл равен `aep_3.pdf`, в фикстурах есть `aep_3.json` и `aep_3_ofr.json` +- **THEN** в `Output//` два файла: `.json` и `_ofr.json`, в обоих `file_uid = ` #### Scenario: Чужой префикс -- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть также `aep_30.json` +- **WHEN** входящий файл равен `aep_3.pdf`, в фикстурах есть также `aep_30.json` - **THEN** `aep_30.json` в набор не попадает +#### Scenario: Промах с набором по умолчанию +- **WHEN** `SCAN_EMULATOR_DEFAULT=aep_6`, входящий файл — произвольный PDF, которого нет в фикстурах +- **THEN** в `Output//` набор `aep_6` (`.json` из `aep_6.json`), в логе отметка «по умолчанию» + +#### Scenario: Имя входящего файла не влияет +- **WHEN** входящий файл называется `.pdf`, а его содержимое равно `aep_6.pdf` +- **THEN** отдан набор `aep_6` + ### Requirement: Неизвестный файл — ошибка в формате Content AI -Если ни одного файла набора не найдено, эмулятор SHALL записать один ответ `.json` в формате реальных ответов-ошибок -ИС (`aep_5.json`, `aep_8_form4.json`): `file_uid = `, `error = {"code": "INVALID_DOCUMENT", "message": "<имя>: нет -эталонного ответа в фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`, -`date` — `{"form_name": "", "confidence": 0}`) и `table_structure: []`. +Если пара по содержимому не найдена и `SCAN_EMULATOR_DEFAULT` не задан (или его набор отсутствует), эмулятор SHALL +записать один ответ `.json` в формате реальных ответов-ошибок ИС (`aep_5.json`, `aep_8_form4.json`): +`file_uid = `, `error = {"code": "INVALID_DOCUMENT", "message": ": нет пары по содержимому в +фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`, `date` — `{"form_name": +"", "confidence": 0}`) и `table_structure: []`. #### Scenario: Нет пары -- **WHEN** входящий файл — `…_unknown.pdf` +- **WHEN** входящий файл — произвольный PDF, `SCAN_EMULATOR_DEFAULT` не задан - **THEN** ответ содержит `error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`; `scan-gateway` публикует конверт с тем же кодом ### Requirement: Эмулятор запускается из того же образа