Merge pull request 'AURORA-1734, 1735, 1864: pylib_ai_arm_statements 0.1.5' (#55) from feat/54-statements-015 into main

This commit is contained in:
aep 2026-11-12 13:32:01 +03:00
commit 68724fbf01
5 changed files with 61 additions and 20 deletions

View File

@ -8,18 +8,18 @@
старом АРМ), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый старом АРМ), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый
распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить
горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json` горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json`
(`statementDocument` v2.0 общим кодом `arm_statements`: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed` (`statementDocument` v2.0 общим кодом `arm_statements`: по документу на таблицу `table_structure[i]`, а при смене формы внутри таблицы — на каждую часть), опубликовать `files.tabular_parsed`
(по умолчанию; задаётся `OPENBAO__NATS__NATS_*_SUBJECT`) (`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`, (по умолчанию; задаётся `OPENBAO__NATS__NATS_*_SUBJECT`) (`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`,
старт — запись файла во «Входящие». **Нет доступа к БД.** старт — запись файла во «Входящие». **Нет доступа к БД.**
## Правила ## Правила
- `arm_statements` — библиотека `pylib_ai_arm_statements==0.1.1`, не публикуется в PyPI. - `arm_statements` — библиотека `pylib_ai_arm_statements` (версия — пин в `api/requirements.txt`), не публикуется в PyPI.
`make venv` и `make install` устанавливают её из `../pylib_ai_arm_statements` (`STATEMENTS_DIR`). `make venv` и `make install` устанавливают её из `../pylib_ai_arm_statements` (`STATEMENTS_DIR`).
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md` - Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md`
§11, §11.1 и §11.9: правило, которого там нет, не реализуется, а поднимается вопросом. Шаги 2–8, словари и статусы — §11, §11.1 и §11.9: правило, которого там нет, не реализуется, а поднимается вопросом. Шаги 2–8, словари и статусы —
в пакете `arm_statements`; в шлюзе остаются только: в пакете `arm_statements`; в шлюзе остаются только:
- адаптер шага 1 — `api/src/normalizer/ocr/adapter.py` (таблица ИС → кандидат, `ref` — номер строки ИС); - адаптер шага 1 — `api/src/normalizer/ocr/adapter.py` (таблица ИС → кандидат, `ref` — номер строки ИС);
- обход набора json — `api/src/normalizer/ocr/answer_set.py` (`normalize_candidate` на каждую таблицу, - обход набора json — `api/src/normalizer/ocr/answer_set.py` (`split_by_form` на каждую таблицу — ББ и ОФР одной таблицей ИС, AURORA-1734 — и `normalize_candidate` на каждую часть,
`envelope_status`, коды `JSON_FAILED` / `TABLE_PARSE_ERROR`; Р-32/Р-33); `envelope_status`, коды `JSON_FAILED` / `TABLE_PARSE_ERROR`; Р-32/Р-33);
- граница контракта — `to_contract` / `warning_to_contract` пакета, вызываются в `api/src/normalizer/ocr/__init__.py` - граница контракта — `to_contract` / `warning_to_contract` пакета, вызываются в `api/src/normalizer/ocr/__init__.py`
(`OcrNormalizer`); своего конвертера и своего уплощения шапки нет, шлюз только валидирует словарь моделями (`OcrNormalizer`); своего конвертера и своего уплощения шапки нет, шлюз только валидирует словарь моделями

View File

@ -158,8 +158,10 @@ ack (с `attempt` повторного запроса); файл в ИС вто
пакета (сетка ячеек по возрастанию `row_index`, `ref` ячейки — номер строки ИС, шапка `data_ocr`, заголовок формы). пакета (сетка ячеек по возрастанию `row_index`, `ref` ячейки — номер строки ИС, шапка `data_ocr`, заголовок формы).
Нарушенная структура таблицы (повтор `row_index` / `column_index`, нецелые индексы, `rows` не список) — исключение Нарушенная структура таблицы (повтор `row_index` / `column_index`, нецелые индексы, `rows` не список) — исключение
адаптера, то есть `TABLE_PARSE_ERROR` всей таблицы. адаптера, то есть `TABLE_PARSE_ERROR` всей таблицы.
- `ocr/answer_set.py` — обход набора json склейки: на каждую таблицу успешного файла зовётся `normalize_candidate` - `ocr/answer_set.py` — обход набора json склейки: кандидат каждой таблицы успешного файла режется `split_by_form`
пакета, исключение одной таблицы не роняет остальные; статус набора — `envelope_status` пакета. пакета (ББ и ОФР одной таблицей ИС — две части, AURORA-1734), на каждую часть зовётся `normalize_candidate`;
исключение одной таблицы или части не роняет остальные (`TABLE_PARSE_ERROR` с её `locator`); статус набора —
`envelope_status` пакета.
- `ocr/__init__.py` — `OcrNormalizer`: результат пакета переводится в модели контракта функциями `to_contract` / - `ocr/__init__.py` — `OcrNormalizer`: результат пакета переводится в модели контракта функциями `to_contract` /
`warning_to_contract` (граница контракта; шлюз только валидирует словарь своими pydantic-моделями), версия библиотеки `warning_to_contract` (граница контракта; шлюз только валидирует словарь своими pydantic-моделями), версия библиотеки
(`statements_version()`) идёт в `NormalizeOutcome` и в `service.statements_version` файла результата. (`statements_version()`) идёт в `NormalizeOutcome` и в `service.statements_version` файла результата.
@ -170,7 +172,9 @@ ack (с `attempt` повторного запроса); файл в ИС вто
удалены. удалены.
Документ на каждую таблицу успешного файла: `locator = "file:<имя json>;table:<i>"`, `header.form_name` — заголовок Документ на каждую таблицу успешного файла: `locator = "file:<имя json>;table:<i>"`, `header.form_name` — заголовок
таблицы ИС. Подробные правила шагов 2–8 и статусов — `05-domain-rules.md` §11.9, предупреждения документа — там же и таблицы ИС. Если внутри таблицы сменилась форма (сегмент со своей шапкой и кодами другой формы), следующие части —
отдельные документы с `locator = "file:<имя json>;table:<i>;form:<k>"`, тип — только по кодам (§11, «Смена формы
внутри кандидата»). Подробные правила шагов 2–8 и статусов — `05-domain-rules.md` §11.9, предупреждения документа — там же и
в схеме `statementDocument`. в схеме `statementDocument`.
### Статус результата ### Статус результата

View File

@ -34,7 +34,7 @@ multidict==6.7.1
nats-py==2.14.0 nats-py==2.14.0
nkeys==0.2.1 nkeys==0.2.1
propcache==0.5.2 propcache==0.5.2
pylib_ai_arm_statements==0.1.3 pylib_ai_arm_statements==0.1.5
pycparser==3.0 pycparser==3.0
pycryptodome==3.23.0 pycryptodome==3.23.0
pydantic==2.13.4 pydantic==2.13.4

View File

@ -10,6 +10,7 @@ from dataclasses import dataclass
from arm_statements import ( from arm_statements import (
EnvelopeStatus, EnvelopeStatus,
FormCandidate,
Rules, Rules,
StatementDocument, StatementDocument,
StatementWarning, StatementWarning,
@ -17,6 +18,7 @@ from arm_statements import (
envelope_status, envelope_status,
make_warning, make_warning,
normalize_candidate, normalize_candidate,
split_by_form,
) )
from src.contract import ErrorCode from src.contract import ErrorCode
@ -107,18 +109,22 @@ def _normalize_file(
header = header_cells(data_ocr) header = header_cells(data_ocr)
for i, table in enumerate(tables): for i, table in enumerate(tables):
try: try:
result = normalize_candidate(table_candidate(name, i, table, header, rules), rules) # ББ и ОФР одной таблицей ИС — части по смене формы (`split_by_form`, AURORA-1734)
parts: tuple[FormCandidate, ...] = split_by_form(table_candidate(name, i, table, header, rules), rules)
except Exception as exc: # noqa: BLE001 -- изоляция разбора одной таблицы (P5-9) except Exception as exc: # noqa: BLE001 -- изоляция разбора одной таблицы (P5-9)
text = f"{type(exc).__name__}: {str(exc)[:EXCEPTION_TEXT_LIMIT]}" warnings.append(_table_parse_error(exc, f"file:{name};table:{i}", rules))
warnings.append(
make_warning(
WarningCode.TABLE_PARSE_ERROR,
f"BACKEND_PARSING_ERROR: {text}",
rules,
locator=f"file:{name};table:{i}",
)
)
continue continue
if result.document is not None: for part in parts:
documents.append(result.document) try:
warnings.extend(result.file_warnings) result = normalize_candidate(part, rules)
except Exception as exc: # noqa: BLE001 -- изоляция разбора одной части таблицы (P5-9)
warnings.append(_table_parse_error(exc, part.locator, rules))
continue
if result.document is not None:
documents.append(result.document)
warnings.extend(result.file_warnings)
def _table_parse_error(exc: Exception, locator: str, rules: Rules) -> StatementWarning:
text = f"{type(exc).__name__}: {str(exc)[:EXCEPTION_TEXT_LIMIT]}"
return make_warning(WarningCode.TABLE_PARSE_ERROR, f"BACKEND_PARSING_ERROR: {text}", rules, locator=locator)

View File

@ -186,3 +186,34 @@ def test_bad_entry_and_parse_error_through_answer_set() -> None:
assert "Expecting value" in res.error.message assert "Expecting value" in res.error.message
[w] = res.file_warnings [w] = res.file_warnings
assert w.locator == "file:j.json" assert w.locator == "file:j.json"
def test_balance_and_income_in_one_table_split_by_form() -> None:
"""ББ и ОФР одной таблицей ИС (АК-ЮЛ, AURORA-1734): шапка ОФР внутри таблицы режет её на два документа."""
res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE + INCOME)))), RULES)
assert [(d.locator, d.document_type) for d in res.documents] == [
("file:j.json;table:0", DocumentType.BALANCE),
("file:j.json;table:0;form:1", DocumentType.INCOME),
]
balance, income = res.documents
assert {line.line_code for line in balance.lines} == {row[1] for row in BALANCE[1:]}
assert {line.line_code for line in income.lines} == {row[1] for row in INCOME[1:]}
assert [c.period_date for c in income.columns] == [date(2024, 12, 31)]
assert "TYPE_CONFLICT" not in {w.code for w in income.warnings}
def test_part_exception_keeps_other_part(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ошибка разбора части `;form:1` — `TABLE_PARSE_ERROR` с её locator, документ первой части остаётся."""
real = answer_set_module.normalize_candidate
def flaky(candidate: Any, rules: Any = None) -> Any:
if candidate.locator.endswith(";form:1"):
raise RuntimeError("сломалось")
return real(candidate, rules)
monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky)
res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE + INCOME)))), RULES)
assert [d.document_type for d in res.documents] == [DocumentType.BALANCE]
assert [(w.code, w.locator) for w in res.file_warnings] == [("TABLE_PARSE_ERROR", "file:j.json;table:0;form:1")]