# arm-statements Общий код нормализации форм ББ/ОФР (пакет `arm_statements`) для tabular-parser, scan-gateway и arm-core. Раскладывается копией в `api/arm_statements/` хостов скриптом `sync-statements` из arm-ka-docs. Спека: arm-ka-docs, `openspec/changes/unified-statement-normalization/`. Копия раскладывается `make -C docs sync-statements`; прямые правки копии в хостах запрещены. ## Как проверить ```bash make venv && make check ``` ## Публичный API Импорт — `from arm_statements import …`. Всё, что не перечислено, — внутреннее (может меняться без координации с хостами). Все dataclass-ы — `frozen=True, slots=True`; последовательности — `tuple`. Раздел сведён с запросами планов 3, 4, 5 (18.10.2026, P1-21…P1-28): хосты не повторяют правил пакета у себя — ни столбца кодов, ни нормализации меток, ни перевода в контракт. ### Перечисления (`StrEnum`, значение — строка контракта) | Имя | Значения | |---|---| | `Source` | `OCR = "ocr"`, `EXCEL = "excel"` | | `DocumentType` | `BALANCE`, `INCOME`, `DISCLOSURE` | | `FormVariant` | `FULL = "full"`, `SIMPLIFIED = "simplified"` | | `Edition` | `RAS_66N = "66n"`, `FSBU4 = "fsbu4"` | | `PeriodKind` | `ABSOLUTE = "absolute"`, `CURRENT = "relative:current"`, `PREV_YEAR_END = "relative:prev_year_end"`, `PREV_PREV_YEAR_END = "relative:prev_prev_year_end"`, `PREV_PERIOD = "relative:prev_period"`, `NONE = "none"` | | `LineStatus` | `OK = "ok"`, `UNREADABLE = "unreadable"`, `MISSING = "missing"`, `DETAIL = "detail"` | | `Severity` | `ERROR = "error"`, `INFO = "info"` | | `DocStatus` | `DONE = "done"`, `NEEDS_CHECK = "needs_check"` | | `EnvelopeStatus` | `DONE = "done"`, `NEEDS_CHECK = "needs_check"`, `ERROR = "error"` | | `SignForm` | `PARENTHESES = "parentheses"`, `MINUS = "minus"`, `UNSIGNED = "unsigned"` | | `WarningCode` | по члену на код словаря `statement_warnings.yaml`, имя = значение: `JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`, `CODE_COLUMN_NOT_FOUND`, `SEGMENT_WITHOUT_CODES`, `SHEET_NOT_JOINED`, `CONTINUATION_TYPE_CONFLICT`, `SEGMENT_COLUMNS_MISMATCH`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`, `HEADER_ROW_NOT_FOUND`, `TYPE_CONFLICT`, `NO_ORGANIZATION_NO_INN`, `PERIOD_UNKNOWN`, `REPORT_DATE_UNCERTAIN`, `XLSB_SCALE_SUSPECT`, `INN_NOT_FOUND`, `INN_UNCERTAIN`, `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN`, `REPORT_DATE_UNPARSED`, `UNITS_NOT_FOUND`, `FILE_TYPE_MISMATCH`, `COLUMN_SKIPPED`, `FRACTIONAL_IN_THOUSANDS`, `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED`, `VALUE_OUT_OF_RANGE`, `SHEET_OTHER`, `ROW_SPLIT`, `XLS_NO_CODEPAGE`. Коды `OSV_*` пути ОСВ сюда не входят (их создаёт tabular-parser, спека «Шаг 8 — предупреждения») | ### Вход шага 1 (строит адаптер хоста) ```python CellValue = Decimal | date | bool | None Cell(text: str, value: CellValue, confidence: float, ref: str) # text — как показывает источник; value — у OCR всегда None, у Excel — по числовому формату (Decimal), дата или bool; # confidence 0–100 (Excel — 100); ref — OCR "row:", Excel "<лист>!" по правилу Excel. # ref ячеек шапки (HeaderCells) и evidence пакет только читает: в выход шагов 2–8 он не переносится (у HeaderField # поля ref нет), формат — на усмотрение адаптера (scan-gateway — "data_ocr.<поле ИС>", P5-5) Evidence(okud: Cell | None = None, title: Cell | None = None) # title.text без изменений становится StatementHeader.form_name (пустой после strip — None) HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None) HEADER_FIELDS: tuple[str, ...] # имена полей HeaderCells по порядку StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (), column_index: int | None = None, field: str | None = None, locator: str | None = None) FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells, rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (), warnings: tuple[StatementWarning, ...] = ()) # rows: позиция ячейки в строке = номер столбца сетки (OCR — column_index ИС, Excel — столбец листа с 0); # locator: "file:<имя json>;table:" | "sheet:[,…]"; warnings — предупреждения адаптера документу # (XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED …) ``` ### Выход шагов 2–8 ```python HeaderField(text: str | None, confidence: float | None, value: str | None) DateField(text: str | None, confidence: float | None, value: date | None) StatementHeader(organization_name: HeaderField, inn: HeaderField, report_date: DateField, units: HeaderField, okud: HeaderField, okpo: HeaderField, okopf: HeaderField, okfs: HeaderField, okei: HeaderField, address: HeaderField, form_name: str | None = None, needs_review_fields: tuple[str, ...] = ()) # контракт: <поле> = value, <поле>_text = text, <поле>_confidence = confidence; form_name — header.form_name # (заголовок формы как в источнике, из evidence.title кандидата; не теряется при normalize) StatementColumn(column_index: int, label: str, label_confidence: float | None, period_date: date | None, period_kind: PeriodKind) StatementValue(column_index: int, amount: Decimal | None, string_value: str | None, text: str | None, confidence: float, is_recognized: bool, needs_review: bool = False) StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str | None, line_code_text: str | None, line_code_confidence: float | None, line_name: str | None, line_name_confidence: float | None, parent_ref: str | None, needs_review: bool, values: tuple[StatementValue, ...]) # values — по всем columns документа, в их порядке FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...], expected: Decimal | None, actual: Decimal | None) # сервис: column_key = str(column_index); ядро: column_key — StoredColumn.key. # expected is None ⇔ цель пуста, actual is None ⇔ пусты все члены (контракт: money, null — «цели нет»); такое # посчитанное соотношение — всегда нарушение (спека «Шаг 7»), P1-21 StatementChecks(relations_failed: tuple[FailedRelation, ...], soft_relations_failed: tuple[FailedRelation, ...]) StatementDocument(document_type: DocumentType, form_variant: FormVariant | None, locator: str, header: StatementHeader, columns: tuple[StatementColumn, ...], lines: tuple[StatementLine, ...], checks: StatementChecks | None, status: DocStatus, warnings: tuple[StatementWarning, ...]) # checks is None ⇔ lines == (); form_variant is None ⇔ DISCLOSURE (у BALANCE/INCOME без строк — FULL, P1-19); # status == document_status(status_input(document)) всегда CandidateResult(document: StatementDocument | None, file_warnings: tuple[StatementWarning, ...]) NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tuple[StatementWarning, ...]) ``` ### Функции конвейера и разборов ```python normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult # шаги 2–8 одного кандидата; документа нет — file_warnings (TABLE_LOST | TABLE_SKIPPED | SHEET_OTHER); # при этом candidate.warnings адаптера пакет не переносит — хост обрабатывает их сам normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult # по порядку; у кандидата без документа candidate.warnings не попадают в результат (см. выше); # исключение кандидата не перехватывается — хост, которому нужна изоляция кандидатов # (TABLE_PARSE_ERROR), зовёт normalize_candidate в цикле recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader # form_name = None (ставит конвейер) type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None is_continuation(text: str | None, rules: Rules) -> bool # маркер «Форма 0710001 с.2» (любой ОКУД) continuation_type(text: str | None, rules: Rules) -> DocumentType | None # тип маркера; не маркер или ОКУД не из словаря — None sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int, form_amount_columns: int, rules: Rules) -> bool # лист без маркера и шапки (адаптер Excel плана 4) code_column(rows: Sequence[Sequence[Cell | None]], rules: Rules, *, relaxed: DocumentType | None = None) -> int | None # столбец кодов сетки — правило (а)/(б) шага 2 (с защитой от блока «Коды»); relaxed=BALANCE|INCOME — ослабленный # поиск в отрезке этой формы, минимум rules.code_ranges.min_codes_continuation; relaxed=DISCLOSURE — ValueError amount_columns(rows: Sequence[Sequence[Cell | None]], code_col: int, rules: Rules, *, source: Source = Source.EXCEL) -> tuple[int, ...] # столбцы правее code_col, где в строках с кодом есть число — колонки сегмента без заголовка; «число колонок сумм» # для sheet_joins — len(amount_columns(...)) parse_code(text: str | None, rules: Rules) -> CodeParse # CodeParse(code: str | None, fixed: bool) parse_amount(text: str | None, source: Source, rules: Rules) -> AmountParse # AmountParse(amount: Decimal | None, string_value: str | None, is_recognized: bool, sign_form: SignForm | None) parse_date(text: str | None, rules: Rules) -> date | None resolve_relative(kind: PeriodKind, effective: date | None) -> date | None # дата относительной колонки от эффективной даты (ядро — после правки даты отчёта); effective is None → None; # ABSOLUTE и NONE → None, не исключение (P1-24) choose_edition(document_type: DocumentType, codes: Sequence[tuple[str, bool]], effective: date | None, rules: Rules, *, variant: FormVariant | None = None) -> Edition | None # редакция формы без полного пересчёта (ядро — при приёме результата 2.0): codes — (код, уверенный) строк ok по # порядку строк, уверенный ⇔ confidence == 100 или код зафиксирован аналитиком; variant None → по кодам; # лучшая подпоследовательность перечня (больше уверенных, затем длиннее), ничья — по дате, ничья без даты — None; # DISCLOSURE и пустые codes — по дате. recompute отдаёт ту же редакцию (P1-25) fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str normalize_caption(text: str | None) -> str # подписи колонок: нижний регистр, ё→е, всё кроме букв, цифр и % → пробел normalize_label(text: str | None) -> str # метки шапки и заголовки: = normalize_text tabular-parser (P1-22) make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str] = (), column_index: int | None = None, field: str | None = None, locator: str | None = None) -> StatementWarning # code — WarningCode (или строка того же значения); уровень — из словаря statement_warnings.yaml; неизвестный код — # KeyError (предупреждение с выдуманным кодом не создаётся). Хосты создают JSON_FAILED, TABLE_PARSE_ERROR, # FILE_TYPE_MISMATCH, XLS_NO_CODEPAGE, XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, # SHEET_NOT_JOINED, SHEET_OTHER только через неё to_jsonable(obj: object) -> Any # dataclass пакета → json-совместимое значение (golden) candidate_from_dict(data: Mapping[str, Any]) -> FormCandidate # обратная для кандидата statements_version(path: Path | None = None) -> str | None # содержимое api/arm_statements/VERSION копии load_rules(directory: Path | None = None) -> Rules # RulesError при ошибке словаря default_rules() -> Rules # кэш словарей пакета ``` `Rules` и `RulesError` входят в `__all__`: это тип результата `load_rules` / `default_rules` и ошибка словаря (хостам нужны для аннотаций и обработки `RulesError`); атрибуты `Rules`, доступные хостам, перечислены в разделе «Словари (`Rules`)» ниже. `CodeParse` и `AmountParse` в `__all__` не входят (возвращаются функциями; при необходимости аннотаций — `arm_statements.codes.CodeParse`, `arm_statements.amounts.AmountParse`). ### Форма контракта v2.0 (сервисы пишут, ядро читает) ```python to_contract(document: StatementDocument) -> dict[str, Any] # statementDocument схемы v2.0: шапка плоская (form_name, <поле>, <поле>_text, <поле>_confidence для всех # HEADER_FIELDS, needs_review_fields), деньги — строки format(x, "f") или null, у нарушений нет column_key, # checks нет ⇔ строк нет, предупреждения — warning_to_contract без locator. Хост валидирует dict своей моделью warning_to_contract(warning: StatementWarning, *, file_level: bool = False) -> dict[str, Any] # {code, severity, message} + непустые line_refs / column_index / field; locator — только file_level=True # (metrics.warnings); null у необязательных ключей не пишется — схема его не допускает status_input_from_contract(document: Mapping[str, Any]) -> StatusInput # вход функции статуса по statementDocument контракта (ядро: страховка document.status результата 2.0); # status_input_from_contract(to_contract(d)) == status_input(d) ``` ### Статус ```python StatusLine(line_status: LineStatus, needs_review: bool) StatusValue(line_status: LineStatus, needs_review: bool) # line_status — статус строки значения StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool, needs_review_fields: tuple[str, ...], lines: tuple[StatusLine, ...], values: tuple[StatusValue, ...], hard_failures: int, accepted: bool, warning_severities: tuple[Severity, ...]) # needs_review на входе уже учитывает правки и подтверждения; значения строк DETAIL функция игнорирует status_input(document: StatementDocument) -> StatusInput # layout_found = строки есть; period_known = дата отчёта или колонка absolute с датой; hard_failures = # len(checks.relations_failed); accepted = False — сервис и ядро собирают вход одним правилом document_status(data: StatusInput) -> DocStatus envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus # ERROR при отсутствии документов; код ошибки (RECOGNITION_FAILED / INVALID_DOCUMENT) выбирает хост ``` ### Пересчёт по сохранённым данным (ядро) ```python StoredColumn(key: str, column_index: int | None, period_date: date | None, period_kind: PeriodKind) StoredValue(column_key: str, amount: Decimal | None, text: str | None, confidence: float | None, is_recognized: bool, value_fixed: bool) # text — source_text (у значения правки None); value_fixed — is_modified или is_confirmed StoredLine(key: str, row_index: int | None, line_status: LineStatus, line_code: str | None, line_code_confidence: float | None, line_name: str | None, code_fixed: bool, parent_key: str | None, values: tuple[StoredValue, ...]) # row_index None — строка правки (в §7 и варианте — пока есть сумма; в структуре и родителях detail — нет); # code_fixed — line_code_modified или is_confirmed строки StoredWarning(code: str, severity: Severity) StoredForm(document_type: DocumentType, effective_date: date | None, has_organization_or_inn: bool, period_known: bool, needs_review_fields: tuple[str, ...], accepted: bool, warnings: tuple[StoredWarning, ...], columns: tuple[StoredColumn, ...], lines: tuple[StoredLine, ...]) RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, checks: StatementChecks, line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool], detail_parents: Mapping[str, str | None], status: DocStatus) # value_needs_review: ключ (StoredLine.key, StoredValue.column_key); detail_parents — только строки DETAIL; # checks.*.expected / actual — Decimal | None, как у сервиса ``` ### Словари (`Rules`) — что нужно хостам Хосты читают словари только через эти атрибуты; прочие поля `Rules` — внутренние. ```python Rules.code_orders.sequence(edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...] # перечень кодов бланка с законными повторами (ядру — порядок и состав строк формы); DISCLOSURE → (); # variant — FormVariant, не None: у формы без варианта хост передаёт FormVariant.FULL (как набор §7) Rules.code_orders.editions -> tuple[Edition, ...] # порядок редакций перечня: (Edition.RAS_66N, Edition.FSBU4) Rules.checks.tolerance -> Decimal # Decimal("1") — допуск ±1 (ядру — межформенная проверка) Rules.code_ranges.min_codes -> int # 3 — «три и более кодов» (столбец кодов, sheet_joins) Rules.code_ranges.min_codes_continuation -> int # 1 — минимум кодов слабого продолжения (code_column relaxed, sheet_joins) Rules.headers.labels -> Mapping[str, tuple[re.Pattern[str], ...]] # ключи ровно: organization, inn, okpo, okopf, okfs, okopf_okfs, okei, units, address, date (LABEL_KEYS) Rules.headers.form_titles / okud -> Mapping[DocumentType, tuple[re.Pattern[str], ...]] Rules.headers.okud_code -> re.Pattern[str] Rules.headers.continuation / auditor / legal_form -> tuple[re.Pattern[str], ...] Rules.headers.code_caption / name_caption / explanations_caption -> re.Pattern[str] Rules.headers.column_exclusions -> tuple[re.Pattern[str], ...] Rules.periods.relative -> tuple[tuple[PeriodKind, re.Pattern[str]], ...] Rules.lookalikes -> Mapping[str, str] # буквы-двойники Rules.warnings -> Mapping[str, WarningRule] # WarningRule(code, severity, scope: "document" | "file") ``` Какой текст подаётся шаблонам (`re.search`), — правило словаря, хост его не выбирает (P1-22): | Нормализация | Ключи | |---|---| | `normalize_label(text)` — пунктуация сохраняется | `headers.form_titles`, `okud`, `okud_code`, `continuation`, `labels.*`, `auditor`, `legal_form` | | `normalize_caption(text)` — пунктуация → пробел | `headers.code_caption`, `name_caption`, `explanations_caption`, `column_exclusions`, `units.spellings`, `periods.relative` | ## Как менять словари Словари пакета — данные в `arm_statements/rules/*.yaml`, правка идёт PR сюда, затем `make -C docs sync-statements` в хостах («сначала пакет»). - `statement_code_orders.yaml` — **байтовая копия** `docs/contracts/statement-code-orders.yaml` (arm-ka-docs); руками не правится. Перечень кодов меняется только PR в docs, затем копия переносится сюда без изменений. - Новый код предупреждения — три правки: строка в `rules/statement_warnings.yaml` (уровень и область), член `WarningCode` в `arm_statements/issues.py` (загрузчик требует равенства множеств), строка таблицы §11.9 `05-domain-rules.md` в docs и таблица спеки «Шаг 8 — предупреждения». - Новый синоним метки шапки, заголовка, подписи колонки — в соответствующий `statement_*.yaml` под нормализацию своего ключа (таблица «ключ словаря → нормализация» в разделе «Публичный API»). - Новое соотношение §7 — строка в `statement_checks.yaml`; прочая логика не меняется. - После правки словаря: `make check`; golden-эталоны, которые изменились осознанно, пересобираются `make golden-update`. ## golden-update Эталоны `arm_statements/tests/golden/*.expected.json` — `to_jsonable` результата `normalize_candidate` для пяти обезличенных входов (`*.candidate.json`), закрепляющих сочетания, которые сценарии спеки описывают по одному. Без переменной тест сравнивает с эталоном. Пересборка: ```bash make golden-update # GOLDEN_UPDATE=1 pytest arm_statements/tests/test_golden.py ``` Тест переписывает эталоны и падает с напоминанием. Дальше: просмотреть `git diff` эталонов глазами против Review Focus плана 1 (каждое изменение — осознанное), запустить `make check` без переменной и закоммитить эталоны вместе с кодом. Реальные названия организаций, ИНН и адреса во входах запрещены — только обезличенные.