# CLAUDE.md — scan-gateway Общий контекст системы — `../CLAUDE.md` и `../docs/`. Этот файл — только про этот сервис. ## Что это Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `.` (расширение `original_name` в нижнем регистре; пишется **сразу** открытием `wb`, существующий файл перезаписывается — как в старом АРМ), забрать **все** `Исходящие//*.json` (ИС кладёт по одному json на каждый распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed` (по умолчанию; задаётся `OPENBAO__NATS__NATS_*_SUBJECT`) (`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`, старт — запись файла во «Входящие». **Нет доступа к БД.** ## Правила - Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md` §11 и §11.1: правило, которого там нет, не реализуется, а поднимается вопросом. Функции очистки (`src/normalizer/ocr/`) перенесены из старого ядра **дословно** (единственное исключение для `reference-old-arm`): при правке сначала разберись, что делает источник; расхождения с ним — только решением, записанным в `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` → тип), правила не зашиваются в код. `fsbu4` пока пуста — данные кладёт владелец, код при этом не меняется. - Статус: `done` только когда всё распознано (`confidence` 100, даты разобраны, редакция определена по дате и заполнена, нет предупреждений) и ни один файл набора не ошибочен (№28); иначе `needs_check`; ни одной таблицы → `error RECOGNITION_FAILED`. Предупреждения — строки `metrics.warnings` (`<имя json>: table:: <текст>`); при изменении их текстов обнови README (раздел «Предупреждения») и golden. - Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть в fixtures и объяснять; эталоны меняются отдельным PR fixtures. - Горячие папки (раскладка FileWorker): имя `.`, запись сразу в `Input` открытием `wb` с перезаписью, без временного файла и `Tmp` — как в старом АРМ (change `hotfolder-direct-write`, ASSUMPTION(OQ-79)); `Tmp` — только у эмулятора. Любое удаление (ответ, входящий файл, `.done`, недописанный при ошибке записи входящий файл) — только при `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` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть успешный — документы из успешных файлов, статус не выше `needs_check` (№28). `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только для тестов воркера). - В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы; если все коды строк одной группы (`1xxx`/`2xxx`) и противоречат `label` — тип по кодам с предупреждением (#30); `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/archive/2026-10-07-slice-3a-ocr-normalizer/` (`design.md`, `specs/`), Среза 0 — `openspec/changes/archive/2026-10-04-slice-0-skeleton/`. ## Соглашения шаблона Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения — `../docs/context/03-architecture.md`, «Соглашения шаблона команды». Репозиторий приведён к сервису **без фронта и без БД**, но с SMB. Команды `make` — из корня репозитория (рецепты сами заходят в `api/`), `pytest` — из `api/`. **Оставлено от шаблона:** `api/run_server.py` (точка входа платформы, `SERVER__HOST/PORT`), `api/src/app.py` (lifespan: SMB-сессия, S3, NATS, воркер; `GET /healthcheck`; gzip/proxy middleware), `core/config.py` (pydantic-settings, `OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`, `DEBUG`), `core/exception_handlers.py` (подключается в `create_app`), `api/pytest.ini`, `api/requirements*.txt` (включая `smbprotocol`), `raisa_config.yml`, `setup/setup.sh` + `setup/pip.conf` (без npm-шага). Заглушки шаблона без кода сервиса: `src/api/` (пустой `router`, пустые `dependencies/` и `endpoints/`), `src/schemas/` и `src/services/` (пустые пакеты), `common/exceptions.py` (`NotFoundError`, `CouldntCreateError` нигде не используются), `core/logger.py` (`setup_logger` нигде не вызывается). **Вырезано (Срез 0):** `web/`, `setup/.npmrc`; из `app.py` — mount SPA, CORS, `raisa_fastapi_protected_api`; `db/`, `models/`, `repositories/`, `alembic/`; `OPENBAO.DATABASE`; из `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `raisa_fastapi_protected_api`; шаблонные `core/nats_callback.py` и `README.md` заменены своими. **Добавлено:** - `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`, `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`, `SCAN_EMULATOR_DEFAULT`; пара по sha256 содержимого); в compose контейнера пока нет — `make emulator`. - `Makefile` (`venv`, `lint`, `test`, `test-integration`, `check`, `docker-build`, `check-full`, `up`, `emulator`, `golden-update`), `api/pyproject.toml` (ruff, mypy), `Dockerfile` + `.dockerignore` (один образ: шлюз и эмулятор), `api/.env.example`, `api/.env.test`. - Тесты: `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`.