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; прямые правки копии в хостах запрещены.
Как проверить
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, NUMBERS_OUTSIDE_TABLE, XLS_NO_CODEPAGE. Коды OSV_* пути ОСВ сюда не входят (их создаёт tabular-parser, спека «Шаг 8 — предупреждения») |
Вход шага 1 (строит адаптер хоста)
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:<row_index ИС>", Excel "<лист>!<A1>" по правилу 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:<i>" | "sheet:<A>[,<B>…]"; warnings — предупреждения адаптера документу
# (XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED …)
Выход шагов 2–8
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, ...])
Функции конвейера и разборов
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 (сервисы пишут, ядро читает)
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)
Статус
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) выбирает хост
Пересчёт по сохранённым данным (ядро)
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 — внутренние.
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.905-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), закрепляющих сочетания, которые сценарии спеки описывают по одному.
Без переменной тест сравнивает с эталоном. Пересборка:
make golden-update # GOLDEN_UPDATE=1 pytest arm_statements/tests/test_golden.py
Тест переписывает эталоны и падает с напоминанием. Дальше: просмотреть git diff эталонов глазами против Review Focus
плана 1 (каждое изменение — осознанное), запустить make check без переменной и закоммитить эталоны вместе с кодом.
Реальные названия организаций, ИНН и адреса во входах запрещены — только обезличенные.