AURORA-1735: редкий столбец с неразобранной подписью между периодами — числа вне колонок (#5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-07 08:58:29 +04:00
parent d0467b81d5
commit 32c1b05d38
2 changed files with 128 additions and 8 deletions

View File

@ -15,6 +15,10 @@ from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, Stat
_FULL_CONFIDENCE = 100.0 _FULL_CONFIDENCE = 100.0
_MAX_REFS = 10 _MAX_REFS = 10
_SPARSE_MAX_NUMBERS = 1 # редкий столбец: число не более чем в одной строке с кодом (AURORA-1735)
_PERIOD_MIN_NUMBERS = 3 # … при числах не менее чем в трёх строках у каждой колонки периода
_RIGHT_TEXT = "числа правее таблицы формы не учитываются"
_BESIDE_TEXT = "числа вне колонок формы не учитываются"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -59,6 +63,15 @@ def has_numbers( # noqa: PLR0913, PLR0917 - сигнатура задана п
return any(_is_number(cell_at(rows, r, col), source, rules) for r in _code_rows(rows, data_rows, code_col, rules)) return any(_is_number(cell_at(rows, r, col), source, rules) for r in _code_rows(rows, data_rows, code_col, rules))
def _number_count( # noqa: PLR0913, PLR0917 - как has_numbers
rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules
) -> int:
"""Сколько строк с кодом содержат в столбце число."""
return sum(
1 for r in _code_rows(rows, data_rows, code_col, rules) if _is_number(cell_at(rows, r, col), source, rules)
)
def _width(rows: Grid, row_indexes: Sequence[int]) -> int: def _width(rows: Grid, row_indexes: Sequence[int]) -> int:
return max((len(rows[r]) for r in row_indexes if 0 <= r < len(rows)), default=0) return max((len(rows[r]) for r in row_indexes if 0 <= r < len(rows)), default=0)
@ -97,7 +110,9 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана
warnings: list[StatementWarning] = [] warnings: list[StatementWarning] = []
columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows])) columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows]))
last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules) last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules)
dated = _dated_counts(rows, header_rows, data_rows, columns, code_col, source, rules)
outside: list[int] = [] outside: list[int] = []
beside: list[int] = []
for col in columns: for col in columns:
numbers = has_numbers(rows, data_rows, col, code_col, source, rules) numbers = has_numbers(rows, data_rows, col, code_col, source, rules)
if not header_rows: if not header_rows:
@ -113,16 +128,63 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана
drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date)) drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date))
elif numbers and cell is None and last_dated is not None and col > last_dated: elif numbers and cell is None and last_dated is not None and col > last_dated:
outside.append(col) # числа правее последней колонки с подписью периода — не колонка сумм outside.append(col) # числа правее последней колонки с подписью периода — не колонка сумм
elif numbers and cell is not None and _is_sparse(col, dated, rows, data_rows, code_col, source, rules):
beside.append(col) # обрывок подписи периода с посторонним числом — не колонка сумм (AURORA-1735)
elif numbers: elif numbers:
label, conf = (cell.text, cell.confidence) if cell is not None else ("", None) label, conf = (cell.text, cell.confidence) if cell is not None else ("", None)
message = f"у столбца {col} с числами нет разобранной подписи периода" message = f"у столбца {col} с числами нет разобранной подписи периода"
warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col)) warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col))
drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None)) drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None))
if outside: if outside or beside:
warnings.append(_outside_table_warning(rows, data_rows, outside, code_col, source, rules)) warnings.append(
_outside_table_warning(rows, data_rows, [*beside, *outside], code_col, source, rules, beside=bool(beside))
)
return drafts, warnings return drafts, warnings
def _dated_counts( # noqa: PLR0913, PLR0917 - внутренняя сборка
rows: Grid,
header_rows: tuple[int, ...],
data_rows: Sequence[int],
columns: range,
code_col: int,
source: Source,
rules: Rules,
) -> dict[int, int]:
"""Столбцы сегмента с подписью периода и числами → число строк с кодом, где есть число."""
counts: dict[int, int] = {}
if not header_rows:
return counts
for col in columns:
cell = _label(rows, header_rows, col)
if cell is None:
continue
parsed = parse_period_label(cell, rules)
if parsed.excluded or parsed.kind is PeriodKind.NONE:
continue
count = _number_count(rows, data_rows, col, code_col, source, rules)
if count:
counts[col] = count
return counts
def _is_sparse( # noqa: PLR0913, PLR0917 - внутренняя проверка
col: int,
dated: dict[int, int],
rows: Grid,
data_rows: Sequence[int],
code_col: int,
source: Source,
rules: Rules,
) -> bool:
"""Редкий столбец: колонки периода левее и правее, у него ≤ 1 числа, у каждой колонки периода ≥ 3."""
if not any(c < col for c in dated) or not any(c > col for c in dated):
return False
if any(count < _PERIOD_MIN_NUMBERS for count in dated.values()):
return False
return _number_count(rows, data_rows, col, code_col, source, rules) <= _SPARSE_MAX_NUMBERS
def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы
rows: Grid, rows: Grid,
header_rows: tuple[int, ...], header_rows: tuple[int, ...],
@ -149,7 +211,14 @@ def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сбо
def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения
rows: Grid, data_rows: Sequence[int], outside: Sequence[int], code_col: int, source: Source, rules: Rules rows: Grid,
data_rows: Sequence[int],
outside: Sequence[int],
code_col: int,
source: Source,
rules: Rules,
*,
beside: bool = False,
) -> StatementWarning: ) -> StatementWarning:
refs = [ refs = [
cell.ref cell.ref
@ -157,13 +226,15 @@ def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя с
for col in outside for col in outside
if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules) if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules)
] ]
return outside_table_warning(refs, rules) return outside_table_warning(refs, rules, beside=beside)
def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning: def outside_table_warning(refs: Sequence[str], rules: Rules, *, beside: bool = False) -> StatementWarning:
"""`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы (`locator` — все, через перевод строки).""" """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек вне колонок формы (`locator` — все, через перевод строки).
`beside` — среди адресов есть редкий столбец между колонками периодов (AURORA-1735): текст «вне колонок»."""
shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "") shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "")
message = f"числа правее таблицы формы не учитываются: {shown}" message = f"{_BESIDE_TEXT if beside else _RIGHT_TEXT}: {shown}"
return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs)) return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs))
@ -171,12 +242,14 @@ def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> l
"""Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов).""" """Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов)."""
refs: dict[str, None] = {} refs: dict[str, None] = {}
rest: list[StatementWarning] = [] rest: list[StatementWarning] = []
beside = False
for warning in warnings: for warning in warnings:
if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE: if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE:
refs.update(dict.fromkeys((warning.locator or "").split("\n"))) refs.update(dict.fromkeys((warning.locator or "").split("\n")))
beside = beside or warning.message.startswith(_BESIDE_TEXT)
else: else:
rest.append(warning) rest.append(warning)
return [*rest, outside_table_warning(list(refs), rules)] if refs else rest return [*rest, outside_table_warning(list(refs), rules, beside=beside)] if refs else rest
def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка

View File

@ -210,3 +210,50 @@ def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules:
assert [c.column_index for c in doc.columns] == [2] assert [c.column_index for c in doc.columns] == [2]
assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)]
assert all(len(line.values) == 1 for line in doc.lines) assert all(len(line.values) == 1 for line in doc.lines)
# --- редкий столбец с неразобранной подписью между периодами (AURORA-1735, решение владельца 06.11.2026) ---
SPARSE_COL = 3 # столбец «31» раскладки Калтана
KALTAN_HEAD = ["Наименование показателя", "Коды", "За 1 полугодие 2026 года", "31", "За I полугодие 2025 года"]
def kaltan_rows(caption: str = "31", second: str | None = None) -> list[list[str | None]]:
head = [*KALTAN_HEAD]
head[3] = caption
return [
head,
["Выручка", "2110", "9671", None, "15405"],
["Себестоимость продаж", "2120", "-7084", second, "-10446"],
["Валовая прибыль (убыток)", "2100", "2587", None, "4959"],
["Прочие расходы", "2350", "-486", "——-", "-348"],
["Чистая прибыль (убыток)", "2400", "1935", "$301 .", "4182"],
]
def test_sparse_column_between_periods_is_outside(rules: Rules) -> None:
doc = normalize_candidate(ocr(kaltan_rows()), rules).document
assert doc is not None
assert [c.column_index for c in doc.columns] == [2, 4]
assert all(v.column_index != SPARSE_COL for line in doc.lines for v in line.values)
(warning,) = [w for w in doc.warnings if w.code == "NUMBERS_OUTSIDE_TABLE"]
assert warning.severity is Severity.INFO
assert warning.message == "числа вне колонок формы не учитываются: row:5"
assert "COLUMN_WITHOUT_DATE" not in [w.code for w in doc.warnings]
def test_unparsed_caption_with_two_numbers_stays_column(rules: Rules) -> None:
doc = normalize_candidate(ocr(kaltan_rows("Прим.", second="12")), rules).document
assert doc is not None
assert [c.column_index for c in doc.columns] == [2, 3, 4]
assert [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"] == [3]
assert "NUMBERS_OUTSIDE_TABLE" not in [w.code for w in doc.warnings]
def test_sparse_column_without_period_to_the_right_stays(rules: Rules) -> None:
rows = kaltan_rows("За I п\n20:")
rows[0][4] = "юлугодие\n25 года"
doc = normalize_candidate(ocr(rows), rules).document
assert doc is not None
assert [c.column_index for c in doc.columns] == [2, 3, 4]
assert SPARSE_COL in [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"]