From bef8441ca84f0d504561ffccba3e0e8c8bb03401 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 02:42:49 +0400 Subject: [PATCH] =?UTF-8?q?docs(scan-gateway):=20README/CLAUDE.md=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=A1=D1=80=D0=B5=D0=B7=203a,=20roundtrip=20?= =?UTF-8?q?=D1=81=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8,=20tasks.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 87 +++-- README.md | 293 +++++++++++++--- api/tests/integration/test_roundtrip.py | 319 +++++++++++++----- .../changes/slice-3a-ocr-normalizer/tasks.md | 32 +- 4 files changed, 573 insertions(+), 158 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 40575a3..8b9673a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,31 +3,54 @@ Общий контекст системы — `../CLAUDE.md` и `../docs/`. Этот файл — только про этот сервис. ## Что это -Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: переименовать файл `UUID_имя`, положить в «Входящие» -(через `SCAN_TMP_DIR`, по умолчанию `Tmp`: `.part` пишется туда, на той же ФС/шаре, и переименовывается), забрать **все** -`Исходящие/UUID/*.json` (ИС кладёт по одному json на каждый распознанный документ), склеить и сохранить сырой ответ в S3 -`raw/{job_id}.json` (в БД не регистрируется), -нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument`), опубликовать -`recognition.result`. Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`, старт — -запись файла во «Входящие». **Нет доступа к БД.** +Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `.` +(расширение `original_name` в нижнем регистре; `.part` пишется в `SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же ФС/шаре и +публикуется **без замены**), забрать **все** `Исходящие//*.json` (ИС кладёт по одному json на каждый +распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить +горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json` +(`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `recognition.result` +(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`, +старт — запись файла во «Входящие». **Нет доступа к БД.** ## Правила -- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14): confidence → 0–100, - поля организации/даты — по тому, что реально приходит. +- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md` + §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). + Функции `ocr/*` — чистые: без I/O, без настроек, без логов. +- Справочники — данные: `api/rules/*.yaml` (редакции форм `66n` / `fsbu4`, двойники, `label` → тип), правила не + зашиваются в код. `fsbu4` пока пуста — данные кладёт владелец, код при этом не меняется. +- Статус: `done` только когда всё распознано (`confidence` 100, даты разобраны, редакция определена по дате и заполнена, + нет предупреждений); иначе `needs_check`; ни одной таблицы → `error RECOGNITION_FAILED`. Предупреждения — строки + `metrics.warnings` (`<имя json>: table:: <текст>`); при изменении их текстов обнови README (раздел «Предупреждения») + и golden. +- Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть + в fixtures и объяснять; эталоны меняются отдельным PR fixtures. +- Горячие папки (раскладка FileWorker): имя `.`, `.part` только в `Tmp`, запись без замены (`os.link` / + `smbclient.rename`; существующее назначение = «доставлено»). `.part`, ставший целью публикации, снимается самой записью + независимо от флага (№26); любое другое удаление (ответ, входящий файл, `.done`, осиротевшие `.part`) — только при + `SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка + удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`. - Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2; отсчёт от первого опроса, увидевшего окончательный набор). Не ждать «один файл `.json`». - Склеенный `raw/{job_id}.json`: `{"job_id", "files": [{"name", "response", "parse_error"}, ...]}`, все `*.json` каталога по возрастанию имени; нормализатор получает ровно эту структуру. - Успех файла ответа — `error.code == ""` (`error` при этом не `null`!), ошибка — непустой `error.code`, - `parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна. Ошибочные файлы — в `metrics.warnings` - (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого, есть успешный → `NOT_IMPLEMENTED` (Срез 0). -- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы. Таблицы не - интерпретируются в Срезе 0 — разбор в Срезе 3. -- Dev-режим: эмулятор ИС Сканирование: по входящему `_.` кладёт в `Output//` набор фикстур - `.json` + `_*.json` как `.json` / `_<суффикс>.json` (атомарно, через `Tmp`): `make emulator` - (то же, что `python -m src.scan_emulator` из `api/`); в контейнере — тот же образ с `command: python -m src.scan_emulator`. -- Описание сервиса, протокол горячих папок, env, запуск и коды ошибок — `README.md`; требования и решения Среза 0 — - `openspec/changes/slice-0-skeleton/` (`design.md`). + `parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна. + Ошибочные файлы — в `metrics.warnings` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть + успешный — документы из успешных файлов. `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только + для тестов воркера). +- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы (запасной + путь — коды строк). `checks` в документах не передаются — их считает ядро (Срез 3b). +- Dev-режим: эмулятор ИС Сканирование (`make emulator`, то же, что `python -m src.scan_emulator` из `api/`; в контейнере — + тот же образ с `command: python -m src.scan_emulator`): по входящему `.` считает sha256 содержимого, находит + пару в `SCAN_FIXTURES_DIR` (PDF + `<имя>.json` + `<имя>_*.json`) и кладёт в `Output//` набор как `.json` / + `_<суффикс>.json` (атомарно, через `Tmp`). Промах → набор `SCAN_EMULATOR_DEFAULT`, иначе ответ-ошибка + `INVALID_DOCUMENT`. Имя входящего файла на выбор пары не влияет. +- Описание сервиса, протокол горячих папок, нормализация, env, запуск и коды ошибок — `README.md`; требования и решения + Среза 3a — `openspec/changes/slice-3a-ocr-normalizer/` (`design.md`, `specs/`), Среза 0 — + `openspec/changes/archive/2026-10-04-slice-0-skeleton/`. ## Соглашения шаблона Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения — @@ -47,18 +70,28 @@ SMB-сессия, S3, NATS, воркер; `GET /healthcheck`; gzip/proxy middlew `raisa_fastapi_protected_api`; шаблонные `core/nats_callback.py` и `README.md` заменены своими. **Добавлено:** -- `api/src/gateway/` — горячие папки: протокол `HotFolder` (`hotfolder.py`: `LocalHotFolder`, `SettleTracker`, `inbox_name`), - `smb.py` (`SmbHotFolder`, импортируется лениво), фабрика `create_hotfolder`; `merge.py` — склейка файлов ответа в `raw/`; референс поллинга — `../reference-old-arm/api/src/core/PDFWorker`. -- `api/src/core/` — `worker.py` (задание: проверка → ack → S3 → горячая папка → опрос → `raw/` → нормализатор → - результат; ошибки по таблице D-7), `nats.py` (core NATS без JetStream, queue group `scan-gateway`, публикация с +- `api/src/gateway/` — горячие папки: протокол `HotFolder` (`hotfolder.py`: `LocalHotFolder`, `SettleTracker`, `inbox_name`; + методы `put`, `poll`, `cleanup`, `healthy`), `smb.py` (`SmbHotFolder`, импортируется лениво), фабрика `create_hotfolder`; + `merge.py` — склейка файлов ответа в `raw/`; референс поллинга — `../reference-old-arm/api/src/core/PDFWorker`. +- `api/src/core/` — `worker.py` (задание: проверка → ack → S3 → горячая папка → опрос → `raw/` → очистка (под флагом) → + нормализатор → результат; ошибки по таблице D-7), `nats.py` (core NATS без JetStream, queue group `scan-gateway`, публикация с повторами), `clients.py` (`S3Storage`), `health.py` (`nats`/`hotfolder` в `/healthcheck`). -- `api/src/normalizer/` — `Normalizer`, `NullNormalizer` (Срез 0: разбор по файлам склейки — все ошибочны → код - первого, есть успешный → `NOT_IMPLEMENTED`, ошибочные файлы → `metrics.warnings`); настоящая нормализация — Срез 3. +- `api/src/normalizer/` — протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (заглушка Среза 0, остаётся для тестов + воркера) и `OcrNormalizer` (Срез 3a, подключён в `create_app`). +- `api/src/normalizer/ocr/` (Срез 3a) — конвейер чистых функций: `text.py` → `dates.py` → `amounts.py` → `layout.py` → + `rows.py` → `header.py` → `document.py`; `__init__.py` — `OcrNormalizer` (обход файлов склейки, статус, `metrics`). +- `api/src/normalizer/rules.py` — `load_rules(SCAN_RULES_DIR)`: pydantic-модели справочников, `OcrRules` (выбор редакции + по дате / по кодам); `api/rules/` — `statement_lines.yaml` (редакции `66n`, `fsbu4`), `ocr_lookalikes.yaml`, + `statement_labels.yaml`. - `api/src/contract/` + `api/contracts/` — pydantic-модели и байт-в-байт копии схем `../docs/contracts`; идентичность проверяет `api/tests/unit/test_contract.py` (входит в `make check`). - `api/src/scan_emulator/` — эмулятор ИС Сканирование (`SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_DELAY`, - `SCAN_EMULATOR_POLL_SECONDS`); в compose контейнера пока нет — `make emulator`. + `SCAN_EMULATOR_POLL_SECONDS`, `SCAN_EMULATOR_DEFAULT`; пара по sha256 содержимого); в compose контейнера пока нет — + `make emulator`. - `Makefile` (`venv`, `lint`, `test`, `test-integration`, `check`, `docker-build`, `check-full`, `up`, - `emulator`), `api/pyproject.toml` (ruff, mypy), `Dockerfile` + `.dockerignore` (один образ: шлюз и эмулятор), + `emulator`, `golden-update`), `api/pyproject.toml` (ruff, mypy), `Dockerfile` + `.dockerignore` (один образ: шлюз и эмулятор), `api/.env.example`, `api/.env.test`. -- Тесты: `api/tests/unit/`, `api/tests/integration/` (маркеры `integration`, `fixtures`); перед PR — `make check-full`. +- Тесты: `api/tests/unit/` (в том числе `unit/normalizer/` — юниты по модулям `ocr/` и golden), `api/tests/integration/` + (маркеры `integration`, `fixtures`; `test_roundtrip.py` — сквозной сценарий с эмулятором и `SCAN_CLEANUP`); + `api/tests/golden_update.py` — сбор склейки реального ответа и запись эталонов в `../fixtures/ocr/expected_normalize/` + (`make golden-update`); перед PR — `make check-full`. diff --git a/README.md b/README.md index 440587f..83bc0fa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений (PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его -и публикует результат в `recognition.result`. Контракт обмена — `../docs/contracts/` (v1.0), архитектура — +и публикует результат в `recognition.result`. Контракт обмена — `../docs/contracts/` (v1.2: `statementDocument`; +`schema_version` в конвертах и файле результата сервис по-прежнему пишет `1.0` — `CONTRACT_VERSION`, решение №14 +change'а `slice-3a-ocr-normalizer`: поля 1.1–1.2 документы PDF-пути не используют), архитектура — `../docs/context/03-architecture.md`. Границы сервиса (DECISION-4): @@ -12,8 +14,11 @@ - сырой ответ ИС лежит в S3 (`raw/{job_id}.json`) и в БД не регистрируется; - саму ИС Сканирование мы не пишем: для разработки есть эмулятор (`api/src/scan_emulator/`). -Состояние: Срез 0 (скелет). Сквозной путь работает, нормализатор — заглушка (см. «Ограничения Среза 0»). -Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения — `openspec/changes/slice-0-skeleton/`. +Состояние: Срез 3a. Сквозной путь работает, ответ ИС нормализуется в `statementDocument` v1.2 (раздел «Нормализация»); +раскладка горячих папок — как в проверенном в контуре `armka_file_worker` (`.`). Известные ограничения — +в конце файла. Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения — +`openspec/changes/slice-3a-ocr-normalizer/` (`design.md`: таблица «Decisions made without discussion») и архив +Среза 0 `openspec/changes/archive/2026-10-04-slice-0-skeleton/`. ## Поток обработки @@ -23,12 +28,13 @@ recognition.image.request (NATS, queue group scan-gateway) 2. ack: recognition.result, status=processing (до чтения S3 и до ожидания слота) 3. слот (SCAN_MAX_INFLIGHT одновременных заданий) 4. файл из S3 (s3.bucket/s3.key) - 5. горячая папка: /_ (через /.<имя>.part и rename) + 5. горячая папка: /. (через /...part, без замены) 6. опрос // каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда не менялся SCAN_SETTLE_SECONDS 7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации) - 8. нормализатор ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json - 9. recognition.result: итоговый конверт (done | needs_check | error), в нём raw_s3_key + 8. очистка горячей папки (только при SCAN_CLEANUP=true) + 9. нормализатор (OcrNormalizer) ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json + 10. recognition.result: итоговый конверт (done | needs_check | error), в нём raw_s3_key ``` Каждое задание заканчивается опубликованным конвертом: сбой превращается в `error` с кодом (таблица ниже), а не в @@ -40,26 +46,34 @@ ack (с `attempt` повторного запроса); файл в ИС вто ## Протокол горячих папок -Реализации интерфейса `HotFolder` (`api/src/gateway/hotfolder.py`: `put`, `poll`, `healthy`): +Реализации интерфейса `HotFolder` (`api/src/gateway/hotfolder.py`: `put`, `poll`, `cleanup`, `healthy`): | Режим | Реализация | Для чего | |---|---|---| | `local` (по умолчанию) | `LocalHotFolder`, обычный каталог `SCAN_HOTFOLDER_PATH` | разработка, тесты, эмулятор | | `smb` | `SmbHotFolder` (`api/src/gateway/smb.py`, `smbprotocol`), шара из `OPENBAO__SMB__*` | платформа | -- Входящий файл: `/_`. `original_name` приходит из запроса и не доверяется: берётся - последний компонент пути, символы, недопустимые в Windows/SMB (`: < > " | ? *` и управляющие), заменяются на `_`, - завершающие точки и пробелы отбрасываются; кириллица и пробелы сохраняются; длинное имя укорачивается так, чтобы - временный `.<имя>.part` умещался в 240 байт UTF-8 (расширение сохраняется). Если имени не осталось — - `RECOGNITION_FAILED`. -- Запись атомарна: сначала временный `.<имя>.part` в служебном каталоге `` (по умолчанию `Tmp`), затем - переименование в `Input`. `Tmp` лежит рядом с `Input` и `Output`, на той же ФС/шаре, чтобы `rename` оставался - атомарным, а ИС не забрала недописанный файл из `Input`. Каталоги `Input` и `Tmp` создаются при необходимости (без - родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`. +Раскладка совпадает с проверенной в контуре (`RSHB/armka_file_worker`, OQ-137): `Input/.` → +`Output//*.json`. + +- Входящий файл: `/.`. `` — суффикс после последней точки последнего компонента + `original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII + (`aep_5.PDF` → `.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не + попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку. +- Запись атомарна и идёт **без замены**: сначала временный файл `...part` в служебном каталоге + `` (по умолчанию `Tmp`), затем он публикуется в `Input` под настоящим именем. `Tmp` лежит рядом с `Input` + и `Output`, на той же ФС/шаре: публикация атомарна, а ИС не может забрать недописанный файл (временных файлов в + `Input` нет — решение владельца 06.10.2026, у FileWorker `.tmp` лежит рядом с назначением). В режиме `local` публикация — + жёсткая ссылка `.part` → `Input/<имя>` (`FileExistsError`, если назначение есть) и удаление `.part`; на файловой + системе без жёстких ссылок (`EPERM`/`EXDEV`/`ENOTSUP`) — `os.replace`, если назначения ещё нет (решение №17). В + режиме `smb` — `smbclient.rename` (поднимает `FileExistsError`, если назначение есть). Если `Input/.` + уже существует (повторная публикация задания после потери ack в другом процессе), файл считается доставленным: + назначение не перезаписывается, задание продолжается опросом `Output//`. Каталоги `Input` и `Tmp` + создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`. - Ответ ИС — каталог `//`: ИС кладёт туда **все** распознанные документы задания одновременно, по одному `*.json` на файл (`Output//*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть - несколько таблиц разных форм (баланс, ОФР); таблицы в Срезе 0 не интерпретируются (Срез 3). + несколько таблиц разных форм (баланс, ОФР); нормализатор делает из каждой таблицы отдельный документ. - Готовность набора — по стабилизации (settle): в каталоге есть хотя бы один `*.json`, а имена и размеры `*.json` не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2 с). Отсчёт идёт от первого опроса, который увидел окончательный набор (а не от времени изменения файлов), поэтому ответ приходит не раньше чем через `SCAN_SETTLE_SECONDS` после @@ -70,12 +84,44 @@ ack (с `attempt` повторного запроса); файл в ИС вто Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется. - Повторной отправки в ИС нет (OQ-15): после `TIMEOUT` входящий файл остаётся в каталоге, поздний ответ ИС теряется (сырой файл остаётся на шаре). -- Эмулятор ИС в `local`-режиме: для файла `_<имя>` ждёт `SCAN_EMULATOR_DELAY` и кладёт в `Output//` весь - набор фикстур: `<имя>.json` -> `.json` и `<имя>_<суффикс>.json` -> `_<суффикс>.json` (`<имя>` — имя - входящего файла без расширения; именно `<имя>_*`, а не префикс `<имя>*`: `aep_3` не забирает `aep_30*`; в каждом - файле `file_uid` = ``). Набор собирается во временном каталоге `Tmp` и появляется в `Output//` - атомарно целиком (rename каталога). Затем входящий файл переименовывается в `….done`. Нет фикстуры — один - ответ-ошибка `INVALID_DOCUMENT`. Dot-файлы и `.done` игнорируются. + +### Очистка горячей папки (`SCAN_CLEANUP`) + +Очистка включается `SCAN_CLEANUP=true`; **по умолчанию выключена**: на SMB-шаре контура права на удаление не +подтверждены (открытый вопрос Среза 3a). Для dev-стенда (compose, эмулятор) флаг стоит включать. + +- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в + `raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС). +- **Что удаляется** (всё, что есть): `Output//*.json` и сам каталог `Output//`, `Input/.`, + `Input/..done` (след эмулятора), `Tmp/...part`. Отсутствующий файл не ошибка. +- **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во + входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются. +- **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт, + `metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа. +- **`.part` и флаг (решение №26).** `.part`, ставший целью успешной публикации, снимается **самой записью**, независимо + от `SCAN_CLEANUP` (в `local` — удаление после жёсткой ссылки, ошибка удаления подавляется; в `smb` `.part` + переименовывается во входящий и отдельного удаления не требует): иначе при выключенной очистке на каждое задание + оставалась бы лишняя ссылка, а ошибка удаления на шаре с неподтверждёнными правами превращала бы доставленный файл в + `RECOGNITION_FAILED`. **Флаг управляет остатками:** осиротевшие `.part` (оборванная запись), `.part` при уже + существующем назначении, каталог ответа, входящий файл и `.done`. При `SCAN_CLEANUP=false` сервис не удаляет ничего из + этого и не пишет в лог об осиротевших `.part` (решение №27): они накапливаются в `Tmp` и убираются вручную или + включением флага. +- Права на SMB: для очистки учётной записи шары нужны права на удаление в `Input`, `Output` (включая каталог + ``) и `Tmp`; для записи — создание файлов в `Tmp` и `Input` и переименование. Пока права на удаление не + подтверждены, оставляйте `SCAN_CLEANUP=false`. + +### Эмулятор ИС в режиме `local` + +Для файла `.` эмулятор ждёт `SCAN_EMULATOR_DELAY` и кладёт в `Output//` набор эталонных ответов. Пара +ищется **по sha256 содержимого** входящего файла, а не по имени: эмулятор при старте индексирует `SCAN_FIXTURES_DIR` +(для каждого файла, не являющегося `*.json`, — sha256 → основа имени без расширения, `aep_6.pdf` → `aep_6`; индекс +пересчитывается, когда меняется список файлов каталога). При совпадении хеша в наборе все `<основа>.json` и +`<основа>_<суффикс>.json` → `.json` и `_<суффикс>.json` (именно `<основа>_*`, а не префикс `<основа>*`: +`aep_3` не забирает `aep_30*`; в каждом файле `file_uid` = ``). **Промах по хешу:** набор `SCAN_EMULATOR_DEFAULT` +(основа имени, например `aep_6`), если он задан и такой набор есть, в логе отметка «по умолчанию»; иначе один +ответ-ошибка `INVALID_DOCUMENT` с текстом `<первые 12 символов sha256>: нет пары по содержимому в фикстурах`. Набор +собирается во временном каталоге `Tmp` и появляется в `Output//` атомарно целиком (rename каталога). Затем +входящий файл переименовывается в `….done`. Файлы с точки, `*.part`, `*.tmp`, `*.done` игнорируются. ## Склеенный ответ ИС (`raw/{job_id}.json`) @@ -99,6 +145,114 @@ ack (с `attempt` повторного запроса); файл в ИС вто `error.code == ""`** (`error` при этом не `null`: `{"code": "", "message": ""}`), поэтому проверять `error != null` нельзя. Ошибка — непустой `error.code` (`INVALID_DOCUMENT`, ...). +## Нормализация (`statementDocument` v1.2) + +`OcrNormalizer` (`api/src/normalizer/ocr/`) превращает склеенный ответ ИС в документы контракта v1.2 по `§11` и `§11.1` +`../docs/context/05-domain-rules.md`: из каждой таблицы `data_ocr.table_structure[i]` каждого успешного файла получается +один `statementDocument` (`locator = "file:<имя json>;table:"`). Проверенные на стенде функции очистки старого ядра +перенесены дословно (единственное исключение для `reference-old-arm`, решение владельца 06.10.2026), DB-обвязка не +переносится. Нормализатор — чистые функции без I/O и настроек; `checks` в документах не передаются (их считает ядро). + +Конвейер (`src/normalizer/ocr/`): `text.py` (двойники букв и цифр, `|` как разделитель клеток, `clean_value`) → +`dates.py` (`parse_date`: даты и периоды вида «на 30 июня 2025 г», «за январь - июнь», «1 квартал», «9 месяцев» → последний день периода) → +`amounts.py` (`parse_amount`: запятая → точка, скобки → минус, прочерк → `null`) → `layout.py` и `rows.py` (раскладка +колонок по шапке с ячейкой «Код» или, если шапки нет, по данным; классы строк; повторная шапка перезаписывает +раскладку) → `header.py` (организация, ИНН, единицы, дата отчёта из `data_ocr`) → `document.py` (тип документа, +колонки, строки, значения с `is_recognized`) → `__init__.py` (`OcrNormalizer.normalize`: обход файлов склейки, статус, +`metrics`). + +Что получается в документе (подробно — требование «Нормализация ответа ИС» спеки `openspec/changes/slice-3a-ocr-normalizer/`): + +- `document_type` — по `label` таблицы (`statement_labels.yaml`), если не сопоставился — по первой цифре кодов строк + (`1…` → `BALANCE`, `2…` → `INCOME`), иначе `OTHER` (документ всё равно отдаётся, статус `needs_check`). +- `columns` — колонки значений первой шапки, перенумерованные с 0; `period_date` — из подписи колонки. +- `lines` — только строки данных с целым кодом. Повтор кода: остаётся первая строка (в БД `line_code` уникален в + документе), остальные теряются в документе, но остаются в `raw/`. +- `values` — по одному на колонку документа; `amount` — число (в файле результата деньги строкой) или `null`; + `string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не + отбрасывал символов`. +- `header.report_date` — после замены двойников; `inn` — только цифры. + +### Справочники `api/rules/` + +Данные, а не код; загружаются один раз при старте из `SCAN_RULES_DIR` (по умолчанию `rules`, путь от `api/`) с +pydantic-валидацией; битый файл (код не из 4–5 цифр, пустое наименование, дубль `id`) — сервис не стартует, в ошибке +имя файла. + +| Файл | Содержимое | +|---|---| +| `statement_lines.yaml` | `editions`: список редакций форм `{id, valid_from?, valid_until?, lines: {код: наименование}}` | +| `ocr_lookalikes.yaml` | `digits`: символ, который OCR ставит вместо цифры → цифра (применяется только к датам, вплотную к цифрам) | +| `statement_labels.yaml` | `labels`: нормализованная (нижний регистр, не-буквенно-цифровое → пробел) подстрока `label` таблицы → `BALANCE` / `INCOME` | + +Редакции форм: `66n` (Приказ Минфина № 66н, отчётность по 2024 год включительно, `valid_until: 2024-12-31`; заполнена из +`codes_mapping` старого ядра) и `fsbu4` (ФСБУ 4/2023, отчётность с 2025 года, `valid_from: 2025-01-01`; **пока +пуста** — данные положит владелец). Редакция документа выбирается по `header.report_date`, если его нет — по самой +поздней `period_date` колонок, если нет и её — по числу совпадений кодов (с предупреждением). Имя строки берётся из +словаря выбранной редакции по коду; если кода в нём нет — из ячейки наименования OCR. Пустая выбранная редакция — +предупреждение «редакция fsbu4 не заполнена», имена из OCR, `needs_check`. + +Как заполнить `fsbu4`: в `api/rules/statement_lines.yaml` заменить `lines: {}` у редакции `id: fsbu4` на таблицу +`"<код>": "<наименование>"` (коды — строки из 4–5 цифр в кавычках, без дублей; наименования непустые; источник — +Приложения 3–4 ФСБУ 4/2023, черновой перечень кодов — шаблоны `fixtures/statements-xlsx/templates/`). Код нормализатора не +меняется. Затем: `cd api && .venv/bin/pytest tests/unit/normalizer/test_rules.py` (валидность данных), +`make golden-update` и `git -C ../fixtures diff` (эталоны документов 2025 года изменятся: имена строк и предупреждения — +объяснить в PR fixtures), перезапуск сервиса (справочники читаются при старте). + +### Статус результата + +- Все файлы набора ошибочны — `error` с кодом первого ошибочного файла по имени (см. «Коды ошибок»). +- Из успешных файлов не собран ни один документ (нет ни одной таблицы с шапкой или столбцом кода, `table_structure` + пуст или это не объект `data_ocr`) — `error`, `RECOGNITION_FAILED`, сообщение «ни одной таблицы не разобрано»; + причины по таблицам — в `metrics.warnings`; `raw_s3_key` заполнен. +- Документы есть — `done`, если всё распознано: у всех документов `inn_confidence` и `organization_name_confidence` + равны 100; дата отчёта разобрана и распознана; все подписи колонок разобраны в даты; все `line_code_confidence` и + `line_name_confidence` равны 100; все значения `is_recognized`; тип по `label` совпал с типом по кодам; редакция + определена по дате отчёта или дате колонки и заполнена; нет предупреждений уровня таблицы и файла нормализации. Иначе `needs_check`. + Единицы (`units`) в критерий не входят (в контракте у поля нет `confidence`). Реальные сканы почти всегда дают + `needs_check`: `done` означает лишь «нечего подсвечивать аналитику». +- Ошибочный файл набора при наличии успешного (например, `_ofr.json` с `INVALID_DOCUMENT`) статус сам по себе не + понижает: он попадает в `metrics.warnings` строкой `<имя>: <код ИС>: <сообщение>`. +- `metrics.rows` — число строк во всех документах. Файл результата `results/{file_id}/{job_id}/result.json` пишется для + `done` и `needs_check`; предупреждения (`metrics.warnings`) лежат и в конверте, и в файле результата. + +### Предупреждения (`metrics.warnings`) + +Одна строка на предупреждение; уровня таблицы — `<имя json>: table:: <текст>`, уровня файла — `<имя json>: <текст>`, +ошибочного файла — `<имя json>: <код ИС>: <сообщение>` (код `INVALID_FORMAT` для файла, не разобранного как JSON, и +для корня-не-объекта). Строка обрезается до 1000 символов, строк не больше 100: лишние сводятся в последнюю +(`ещё предупреждений: ; все ошибки файлов — в raw/.json`). Тексты (из `src/normalizer/ocr/`): + +| Уровень | Текст | +|---|---| +| файл | `ИНН не распознан` (ИНН нет или `confidence` ≠ 100) | +| файл | `дата отчёта не разобрана` | +| файл | `data_ocr отсутствует или не объект` | +| таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются) | +| таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) | +| таблица | `колонка без даты: <подпись колонки>` | +| таблица | `нет колонок значений` | +| таблица | `повторная шапка изменила число колонок значений` (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет») | +| таблица | `тип по кодам (<тип>) не совпал с label (