chore(arm_statements): копия arm-statements@25b2bb8 (#50)
Копия api/arm_statements: 3390f92..25b2bb8 (1 коммитов), скрипт docs/scripts/sync-statements.sh. Коммиты arm-statements: - 72e2e72 fix(#8): шаг 2 — нумерация со сбоем OCR под заголовком, повтор шапки со склеенной нумерацией (AURORA-1517) statements-check scan-gateway: 0 — копия синхронна с arm-statements@origin/main (25b2bb8) Co-Authored-By: sync-statements.sh <noreply@aepif.ru>
This commit is contained in:
parent
b8447581ae
commit
55dbf8d16d
@ -1 +1 @@
|
|||||||
3390f92daddbfd4919d7f799ad2959cc35bfbd92
|
25b2bb81195bb1e99f73b90e336cd175ae8b344c
|
||||||
|
|||||||
@ -17,6 +17,7 @@ _MIN_NUMBERING_CELLS = 3
|
|||||||
_SHORT_NOTE = 3 # «Пояснения» без подписи: значения не длиннее 3 символов
|
_SHORT_NOTE = 3 # «Пояснения» без подписи: значения не длиннее 3 символов
|
||||||
_SKIP_STEP = 2 # скачок через одну пропущенную цифру нумерации
|
_SKIP_STEP = 2 # скачок через одну пропущенную цифру нумерации
|
||||||
_NUMBER = re.compile(r"[0-9]+")
|
_NUMBER = re.compile(r"[0-9]+")
|
||||||
|
_NUMBERING_TAIL = re.compile(r"\s+[0-9]{1,2}$")
|
||||||
_FULL_CONFIDENCE = 100.0
|
_FULL_CONFIDENCE = 100.0
|
||||||
|
|
||||||
|
|
||||||
@ -24,6 +25,11 @@ def _text(cell: Cell | None) -> str:
|
|||||||
return "" if cell is None else cell.text.strip()
|
return "" if cell is None else cell.text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def caption_text(text: str) -> str:
|
||||||
|
"""Подпись шапки для сравнения: `normalize_caption` без номера столбца, склеенного снизу («Коды\\n2» → «коды»)."""
|
||||||
|
return _NUMBERING_TAIL.sub("", normalize_caption(text))
|
||||||
|
|
||||||
|
|
||||||
def cell_at(rows: Grid, row: int, col: int) -> Cell | None:
|
def cell_at(rows: Grid, row: int, col: int) -> Cell | None:
|
||||||
"""Ячейка сетки; вне строки или сетки — `None`."""
|
"""Ячейка сетки; вне строки или сетки — `None`."""
|
||||||
if row < 0 or row >= len(rows) or col < 0:
|
if row < 0 or row >= len(rows) or col < 0:
|
||||||
@ -138,17 +144,27 @@ def first_code_row(rows: Grid, first: int, last: int, code_col: int, rules: Rule
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_numbering_row(cells: Sequence[Cell | None], rules: Rules) -> bool:
|
def is_numbering_row(cells: Sequence[Cell | None], rules: Rules, *, noisy: bool = False) -> bool:
|
||||||
"""≥ 3 непустых ячеек-целых, слева направо +1, начало 1 или 2, не больше одного пропуска."""
|
"""≥ 3 непустых ячеек-целых, слева направо +1, начало 1 или 2, не больше одного пропуска.
|
||||||
|
|
||||||
|
`noisy` — строка сразу под шапкой (AURORA-1517): ячейка со сбоем OCR даёт своё единственное целое («• ■ 1» → 1,
|
||||||
|
«3гола» → 3), ячейка без цифр («/», «V») — пропуск, два целых в ячейке — не нумерация.
|
||||||
|
"""
|
||||||
numbers: list[int] = []
|
numbers: list[int] = []
|
||||||
for cell in cells:
|
for cell in cells:
|
||||||
text = _text(cell)
|
text = _text(cell)
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
fixed = fix_lookalikes(text, rules.lookalikes, anywhere=True).strip()
|
fixed = fix_lookalikes(text, rules.lookalikes, anywhere=True).strip()
|
||||||
if _NUMBER.fullmatch(fixed) is None:
|
if _NUMBER.fullmatch(fixed) is not None:
|
||||||
|
numbers.append(int(fixed))
|
||||||
|
continue
|
||||||
|
if not noisy:
|
||||||
return False
|
return False
|
||||||
numbers.append(int(fixed))
|
found = _NUMBER.findall(text)
|
||||||
|
if len(found) > 1:
|
||||||
|
return False
|
||||||
|
numbers.extend(int(number) for number in found)
|
||||||
if len(numbers) < _MIN_NUMBERING_CELLS or numbers[0] not in (1, 2):
|
if len(numbers) < _MIN_NUMBERING_CELLS or numbers[0] not in (1, 2):
|
||||||
return False
|
return False
|
||||||
skips = 0
|
skips = 0
|
||||||
@ -212,7 +228,7 @@ def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rul
|
|||||||
"""Строка заголовка (одна или две, склеенные); не найдена — `()`."""
|
"""Строка заголовка (одна или две, склеенные); не найдена — `()`."""
|
||||||
candidates = [r for r in range(first, first_code) if not is_numbering_row(rows[r], rules)]
|
candidates = [r for r in range(first, first_code) if not is_numbering_row(rows[r], rules)]
|
||||||
captioned = [
|
captioned = [
|
||||||
r for r in candidates if rules.headers.code_caption.search(normalize_caption(_text(cell_at(rows, r, code_col))))
|
r for r in candidates if rules.headers.code_caption.search(caption_text(_text(cell_at(rows, r, code_col))))
|
||||||
]
|
]
|
||||||
header: int | None = captioned[-1] if captioned else None
|
header: int | None = captioned[-1] if captioned else None
|
||||||
if header is None:
|
if header is None:
|
||||||
@ -229,15 +245,17 @@ def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rul
|
|||||||
|
|
||||||
|
|
||||||
def segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole]:
|
def segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole]:
|
||||||
"""Роли строк `first..last`: выше заголовка — TITLE (только при найденном заголовке)."""
|
"""Роли строк `first..last`: выше заголовка — TITLE (только при найденном заголовке); строка сразу под заголовком —
|
||||||
|
нумерация и при сбое OCR (`is_numbering_row(noisy=True)`)."""
|
||||||
roles: list[RowRole] = []
|
roles: list[RowRole] = []
|
||||||
top = min(header_rows) if header_rows else None
|
top = min(header_rows) if header_rows else None
|
||||||
|
under = max(header_rows) + 1 if header_rows else None
|
||||||
for row in range(first, last + 1):
|
for row in range(first, last + 1):
|
||||||
if row in header_rows:
|
if row in header_rows:
|
||||||
roles.append(RowRole.HEADER)
|
roles.append(RowRole.HEADER)
|
||||||
elif top is not None and row < top:
|
elif top is not None and row < top:
|
||||||
roles.append(RowRole.TITLE)
|
roles.append(RowRole.TITLE)
|
||||||
elif is_numbering_row(rows[row], rules):
|
elif is_numbering_row(rows[row], rules, noisy=row == under):
|
||||||
roles.append(RowRole.NUMBERING)
|
roles.append(RowRole.NUMBERING)
|
||||||
else:
|
else:
|
||||||
roles.append(RowRole.DATA)
|
roles.append(RowRole.DATA)
|
||||||
@ -251,7 +269,7 @@ def _caption_column(
|
|||||||
for col, cell in enumerate(rows[row]):
|
for col, cell in enumerate(rows[row]):
|
||||||
if col == code_col or (left_of_code and col > code_col):
|
if col == code_col or (left_of_code and col > code_col):
|
||||||
continue
|
continue
|
||||||
if pattern.search(normalize_caption(_text(cell))):
|
if pattern.search(caption_text(_text(cell))):
|
||||||
return col
|
return col
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from arm_statements.columns import (
|
|||||||
from arm_statements.issues import WarningCode, make_warning
|
from arm_statements.issues import WarningCode, make_warning
|
||||||
from arm_statements.layout import (
|
from arm_statements.layout import (
|
||||||
Grid,
|
Grid,
|
||||||
|
caption_text,
|
||||||
cell_at,
|
cell_at,
|
||||||
find_code_column,
|
find_code_column,
|
||||||
find_header_rows,
|
find_header_rows,
|
||||||
@ -35,7 +36,6 @@ from arm_statements.model import (
|
|||||||
StatementHeader,
|
StatementHeader,
|
||||||
StatementWarning,
|
StatementWarning,
|
||||||
)
|
)
|
||||||
from arm_statements.text import normalize_caption
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@ -100,7 +100,7 @@ def _chunks(rows: Grid, breaks: Sequence[int]) -> list[tuple[int, int]]:
|
|||||||
|
|
||||||
|
|
||||||
def _caption_cells(rows: Grid, header_rows: Sequence[int]) -> frozenset[str]:
|
def _caption_cells(rows: Grid, header_rows: Sequence[int]) -> frozenset[str]:
|
||||||
cells = (normalize_caption(_text(rows, r, c)) for r in header_rows for c in range(len(rows[r])))
|
cells = (caption_text(_text(rows, r, c)) for r in header_rows for c in range(len(rows[r])))
|
||||||
return frozenset(c for c in cells if c)
|
return frozenset(c for c in cells if c)
|
||||||
|
|
||||||
|
|
||||||
@ -110,7 +110,7 @@ def _is_repeat_header(rows: Grid, row: int, code_col: int | None, reference: fro
|
|||||||
return False
|
return False
|
||||||
if code_col is not None and is_code_cell(_text(rows, row, code_col), rules):
|
if code_col is not None and is_code_cell(_text(rows, row, code_col), rules):
|
||||||
return False
|
return False
|
||||||
normalized = [normalize_caption(c.text) for c in rows[row] if c is not None and c.text.strip()]
|
normalized = [caption_text(c.text) for c in rows[row] if c is not None and c.text.strip()]
|
||||||
if any(rules.headers.code_caption.search(text) for text in normalized):
|
if any(rules.headers.code_caption.search(text) for text in normalized):
|
||||||
return True
|
return True
|
||||||
return sum(1 for text in normalized if text and text in reference) >= _MIN_SAME_CELLS
|
return sum(1 for text in normalized if text and text in reference) >= _MIN_SAME_CELLS
|
||||||
|
|||||||
@ -215,3 +215,45 @@ def test_headerless_continuation_sheet_with_outside_numbers(rules: Rules) -> Non
|
|||||||
assert layout.warnings[0].locator == "Лист1!F2\nЛист2!F1"
|
assert layout.warnings[0].locator == "Лист1!F2\nЛист2!F1"
|
||||||
assert layout.segments[1].value_cols == ((3, 3), (4, 4))
|
assert layout.segments[1].value_cols == ((3, 3), (4, 4))
|
||||||
assert layout.segments[1].extra_cols == ()
|
assert layout.segments[1].extra_cols == ()
|
||||||
|
|
||||||
|
|
||||||
|
BALANCE_CAPTIONS = ["Наименование показателя", "Коды", "На 30 июня\n2026 года", "На 31 декабря\n2025 года"]
|
||||||
|
ASSETS = [["Запасы", "1210", "5", "4"], ["Дебиторская задолженность", "1230", "3", "2"], ["Итого", "1200", "8", "6"]]
|
||||||
|
LIABILITIES = [["Заемные средства", "1510", "3", "2"], ["Кредиторская задолженность", "1520", "5", "4"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbering_with_ocr_noise_right_under_header(rules: Rules) -> None:
|
||||||
|
"""AURORA-1517: нумерация под шапкой со сбоем OCR («/» вместо 1, мусор, протёкшее «года», «V» вместо 4)."""
|
||||||
|
for numbering in (
|
||||||
|
["/", "2", "3", "4"],
|
||||||
|
["• ■ 1", "2", "3", "4"],
|
||||||
|
["1", "2", "3гола", "гола\n4"],
|
||||||
|
["1", "2", "3", "V"],
|
||||||
|
):
|
||||||
|
layout = find_layout(ocr([BALANCE_CAPTIONS, numbering, *ASSETS]), parse_header(hdr(), rules), rules)
|
||||||
|
assert layout.roles[1] is RowRole.NUMBERING, numbering
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbering_with_ocr_noise_under_repeat_header(rules: Rules) -> None:
|
||||||
|
"""AURORA-1517, «Светлый путь»: «• ■ 1 2 3 4 5» под повторной шапкой второй страницы."""
|
||||||
|
rows = [BALANCE_CAPTIONS, ["1", "2", "3", "4"], *ASSETS, BALANCE_CAPTIONS, ["• ■ 1", "2", "3", "4"], *LIABILITIES]
|
||||||
|
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
|
||||||
|
assert [s.first_row for s in layout.segments] == [0, 5]
|
||||||
|
assert layout.roles[6] is RowRole.NUMBERING and layout.roles[7] is RowRole.DATA
|
||||||
|
|
||||||
|
|
||||||
|
def test_noisy_numbering_elsewhere_stays_data(rules: Rules) -> None:
|
||||||
|
"""Мягкая проверка — только сразу под шапкой; строка с кодом формы — не нумерация."""
|
||||||
|
rows = [BALANCE_CAPTIONS, ["Запасы 1", "1210", "2", "3"], *ASSETS, ["/", "2", "3", "4"], *LIABILITIES]
|
||||||
|
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
|
||||||
|
assert layout.roles[1] is RowRole.DATA and layout.roles[5] is RowRole.DATA
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeat_header_glued_with_numbering(rules: Rules) -> None:
|
||||||
|
"""AURORA-1517, «Будамшинское»: повторная шапка, в каждой подписи которой снизу номер столбца."""
|
||||||
|
glued = ["Наименование показателя\n1", "Коды\n2", "На 30 июня\n2026 года\n3", "На 31 декабря\n2025 года\n4"]
|
||||||
|
rows = [BALANCE_CAPTIONS, ["1", "2", "3", "4"], *ASSETS, glued, *LIABILITIES]
|
||||||
|
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
|
||||||
|
assert [s.first_row for s in layout.segments] == [0, 5]
|
||||||
|
assert layout.roles[5] is RowRole.HEADER and layout.roles[6] is RowRole.DATA
|
||||||
|
assert layout.segments[1].value_cols == ((2, 2), (3, 3))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user