From 1f308ddfe3df27ba0de471bd7813c55bfe684796 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 16:56:58 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(normalizer):=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=8F=D1=82=D0=B0=D1=8F-=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=D1=82=D1=8B=D1=81=D1=8F=D1=87?= =?UTF-8?q?=20=D0=B2=20=D1=81=D1=83=D0=BC=D0=BC=D0=B0=D1=85=20OCR=20(AUROR?= =?UTF-8?q?A-1405,=20#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «4,200» в PDF — это 4200: запятые вида [+-]?[1-9]\d{0,2}(,\d{3})+ (полное совпадение, с учётом скобок) удаляются до замены запятой на точку, verified сохраняется. Решение владельца 15.10.2026. Co-Authored-By: Claude Opus 5.5 --- api/src/normalizer/ocr/amounts.py | 22 +++++++++++++++++----- api/src/normalizer/ocr/document.py | 5 +++-- api/tests/unit/normalizer/test_amounts.py | 21 ++++++++++++++++----- api/tests/unit/normalizer/test_document.py | 20 +++++++++++++++++--- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/api/src/normalizer/ocr/amounts.py b/api/src/normalizer/ocr/amounts.py index 2158a96..ae36cab 100644 --- a/api/src/normalizer/ocr/amounts.py +++ b/api/src/normalizer/ocr/amounts.py @@ -1,14 +1,18 @@ """Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием — порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`). -Что делает разбор: запятая → точка, пробелы долой; если есть круглые скобки — берётся содержимое первой пары, и сумма +Что делает разбор: если текст целиком (после strip, без пробелов, с учётом скобок — берётся содержимое первой пары — и +знака) имеет вид `[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, +`(4,200)` → `-4200`, `1,234,567` → `1234567`); это не отбрасывание символов, `verified` не страдает. Правило тысяч — +решение владельца 15.10.2026 (AURORA-1405, scan-gateway#35), см. docs/context/05-domain-rules.md §11.1 п. 7. +Затем запятая → точка, пробелы долой; если есть круглые скобки — берётся содержимое первой пары, и сумма отрицательная; всё, кроме цифр, `+`, `-` и `.`, отбрасывается; если точек больше одной, удаляются ВСЕ точки (`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`). `verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого (`()`, `( )`, `(.)`) дают `(None, verified = True)`, в источнике — `False`. -Количество знаков после запятой здесь не проверяется: `1,234` → `1.234`; ограничение `Money` (не больше двух знаков) — -в `document._value` (решение №30).""" +Количество знаков после запятой здесь не проверяется: `0,125` → `0.125`, `12,3456` → `12.3456`; +ограничение `Money` (не больше двух знаков) — в `document._value` (решение №30).""" import re from dataclasses import dataclass @@ -19,6 +23,8 @@ __all__ = ["ParsedAmount", "is_blank", "parse_amount"] _BLANK_CHARS_RE = re.compile(r"[\s\-–—]") _ALLOWED_RE = re.compile(r"[^\d+\-.]") _BRACKET_RE = re.compile(r"\(([^()]*)\)") +# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; проверяется на всей строке +_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+") @dataclass(frozen=True) @@ -30,14 +36,20 @@ class ParsedAmount: def parse_amount(raw: str) -> ParsedAmount: """Парсит сумму из распознанного текста OCR-ячейки. - Заменяет запятую на точку, удаляет пробелы, извлекает содержимое круглых скобок + Если содержимое (без пробелов, с учётом скобок) целиком вида `[+-]?[1-9]\\d{0,2}(,\\d{3})+` — удаляет запятые + (разделители тысяч: `4,200` → 4200) без потери verified. Затем заменяет запятую на точку, удаляет пробелы, + извлекает содержимое круглых скобок (означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456). verified=True только если в исходной строке не пришлось отбрасывать символы (после удаления скобок). """ - s = raw.strip().replace(",", ".").replace(" ", "") + s = raw.strip().replace(" ", "") + inner = _BRACKET_RE.search(s) + if _THOUSANDS_RE.fullmatch(inner.group(1) if inner else s): + s = s.replace(",", "") + s = s.replace(",", ".") stripped_raw = s if not stripped_raw: return ParsedAmount(None, stripped_raw == raw) diff --git a/api/src/normalizer/ocr/document.py b/api/src/normalizer/ocr/document.py index 45fa8a8..1cc9cc1 100644 --- a/api/src/normalizer/ocr/document.py +++ b/api/src/normalizer/ocr/document.py @@ -142,8 +142,9 @@ def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValu amount = parsed.amount verified = parsed.verified if amount is not None and _decimal_places(amount) > MONEY_DECIMAL_PLACES: - # решение №30: `Money` контракта — не больше двух знаков; «1,234» (пробел тысяч как запятая) — артефакт OCR, - # сумма не принимается, текст остаётся в string_value, иначе ValidationError ронял бы всю таблицу + # решение №30: `Money` контракта — не больше двух знаков; «1,234» теперь = 1234 (правило тысяч в parse_amount, + # AURORA-1405), а «0,125», «12.3456» и т. п. — артефакт OCR: сумма не принимается, текст остаётся в + # string_value, иначе ValidationError ронял бы всю таблицу amount, verified = None, False # DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана string_value = text if amount is None and text and not is_blank(text) else None diff --git a/api/tests/unit/normalizer/test_amounts.py b/api/tests/unit/normalizer/test_amounts.py index 5c6474c..e277fbc 100644 --- a/api/tests/unit/normalizer/test_amounts.py +++ b/api/tests/unit/normalizer/test_amounts.py @@ -24,11 +24,22 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount # несколько точек: удаляются ВСЕ точки, десятичная часть теряется (`1.234,56` — не 1234.56), всегда не verified ("1.234,56", Decimal("123456"), False), ("1,234.56", Decimal("123456"), False), - ( - "1,234", - Decimal("1.234"), - True, - ), # знаков после запятой больше двух — `Money` отсекает `document._value` (№30) + # запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): удаляется до замены запятой на точку, verified=True + ("1,234", Decimal("1234"), True), + ("4,200", Decimal("4200"), True), + ("(4,200)", Decimal("-4200"), True), + ("1,234,567", Decimal("1234567"), True), + ("-4,200", Decimal("-4200"), True), + ("+4,200", Decimal("4200"), True), + ("(-4,200)", Decimal("-4200"), True), + ("4,2", Decimal("4.2"), True), + ("12,34", Decimal("12.34"), True), + ("1,23", Decimal("1.23"), True), + # не тысячи: больше двух знаков после запятой остаётся десятичным — `Money` отсекает `document._value` (№30) + ("0,125", Decimal("0.125"), True), + ("12,3456", Decimal("12.3456"), True), + ("4,200,", Decimal("4200"), False), # хвостовая запятая: правило не применяется (две точки удалены, как раньше) + ("(4,200}", Decimal("4.200"), False), # нет пары скобок: правило не применяется, минус не ставится # №13: скобки без содержимого — не сумма, но и не «неразобранный текст» ("()", None, True), ("( )", None, True), diff --git a/api/tests/unit/normalizer/test_document.py b/api/tests/unit/normalizer/test_document.py index b6df5ed..d334701 100644 --- a/api/tests/unit/normalizer/test_document.py +++ b/api/tests/unit/normalizer/test_document.py @@ -450,7 +450,7 @@ class TestKnownCodeConfidence: class TestMoneyPrecision: """Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу.""" - @pytest.mark.parametrize("text", ["1,234", "0,125", "12.3456", "(1,234)"]) + @pytest.mark.parametrize("text", ["12,3456", "0,125", "12.3456", "(0,125)"]) def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None: table = _table( "Бухгалтерский баланс", @@ -467,17 +467,31 @@ class TestMoneyPrecision: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1,234", 100), (3, "56,78", 100)), + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "0,125", 100), (3, "56,78", 100)), _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)), ) built = _build(table) first, second = built.document.lines - assert (first.values[0].amount, first.values[0].string_value) == (None, "1,234") + assert (first.values[0].amount, first.values[0].string_value) == (None, "0,125") assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True) assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True) assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True) built.document.to_wire() # контрактная модель сериализуется без ValidationError + @pytest.mark.parametrize( + ("text", "amount"), + [("4,200", Decimal("4200")), ("(4,200)", Decimal("-4200")), ("1,234,567", Decimal("1234567"))], + ) + def test_thousands_comma_gives_amount(self, text: str, amount: Decimal) -> None: + """AURORA-1405, scan-gateway#35: «4,200» в PDF — это 4200, а не 4.200 (отсекаемое №30).""" + table = _table( + "Бухгалтерский баланс", + CLEAN_BALANCE["rows"][0], + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), + ) + value = _build(table).document.lines[0].values[0] + assert value.amount == amount and value.is_recognized is True and value.string_value is None + @pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"]) def test_up_to_two_decimals_is_kept(self, text: str) -> None: table = _table( From 9dd568ce443334df63666eb868fad7b782e86109 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 16:59:32 +0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(normalizer):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D0=BE=20=D1=82=D1=8B=D1=81=D1=8F=D1=87=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D0=B1=D0=BA=D0=B0=D1=85=20=D0=BD=D0=B0=20=D0=B2?= =?UTF-8?q?=D1=81=D1=8E=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83,=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=BA=D0=B0=20=D0=B8=20README=20(AURORA-1405,=20#3?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: «(4,200) руб», «1,234(5,678)» и т. п. разбираются как прежде; I-2/I-3: спека и README; M-1..M-3: docstring, комментарий регулярки, тесты крайних случаев. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- api/src/normalizer/ocr/amounts.py | 21 ++++++++++--------- api/tests/unit/normalizer/test_amounts.py | 10 +++++++++ .../specs/image-recognition-gateway/spec.md | 4 ++-- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9e9ff3d..3988913 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ ack (с `attempt` повторного запроса); файл в ИС вто `string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не отбрасывал символов`. - **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками - (`1,234`, `0,125`: пробел тысяч, распознанный как запятая) не принимается: `amount = null`, `string_value` — текст + (`0,125`, `12,3456`; запятая-разделитель тысяч `4,200`/`1,234` — это 4200/1234, AURORA-1405, #35) не принимается: `amount = null`, `string_value` — текст ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Сумма вида `1.234,56` (несколько точек) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) — с `is_recognized = false`. diff --git a/api/src/normalizer/ocr/amounts.py b/api/src/normalizer/ocr/amounts.py index ae36cab..55d1ab5 100644 --- a/api/src/normalizer/ocr/amounts.py +++ b/api/src/normalizer/ocr/amounts.py @@ -1,11 +1,11 @@ """Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием — порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`). -Что делает разбор: если текст целиком (после strip, без пробелов, с учётом скобок — берётся содержимое первой пары — и -знака) имеет вид `[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, -`(4,200)` → `-4200`, `1,234,567` → `1234567`); это не отбрасывание символов, `verified` не страдает. Правило тысяч — -решение владельца 15.10.2026 (AURORA-1405, scan-gateway#35), см. docs/context/05-domain-rules.md §11.1 п. 7. -Затем запятая → точка, пробелы долой; если есть круглые скобки — берётся содержимое первой пары, и сумма +Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид +`[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`, +`1,234,567` → `1234567`); это не отбрасывание символов, `verified` не страдает. Правило тысяч — решение владельца +15.10.2026 (AURORA-1405, scan-gateway#35), см. docs/context/05-domain-rules.md §11.1 п. 7. Затем запятая → точка; +если есть круглые скобки — берётся содержимое первой пары, и сумма отрицательная; всё, кроме цифр, `+`, `-` и `.`, отбрасывается; если точек больше одной, удаляются ВСЕ точки (`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`). `verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого @@ -23,7 +23,8 @@ __all__ = ["ParsedAmount", "is_blank", "parse_amount"] _BLANK_CHARS_RE = re.compile(r"[\s\-–—]") _ALLOWED_RE = re.compile(r"[^\d+\-.]") _BRACKET_RE = re.compile(r"\(([^()]*)\)") -# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; проверяется на всей строке +# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; только если строка целиком +# (или содержимое пары скобок, занимающей всю строку) совпала; «(4,200) руб», «1,234(5,678)» — прежний разбор _THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+") @@ -36,9 +37,9 @@ class ParsedAmount: def parse_amount(raw: str) -> ParsedAmount: """Парсит сумму из распознанного текста OCR-ячейки. - Если содержимое (без пробелов, с учётом скобок) целиком вида `[+-]?[1-9]\\d{0,2}(,\\d{3})+` — удаляет запятые - (разделители тысяч: `4,200` → 4200) без потери verified. Затем заменяет запятую на точку, удаляет пробелы, - извлекает содержимое круглых скобок + Убирает пробелы; если строка целиком (или пара скобок на всю строку — её содержимое) вида + `[+-]?[1-9]\\d{0,2}(,\\d{3})+` — удаляет запятые (разделители тысяч: `4,200` → 4200) без потери verified. + Затем заменяет запятую на точку, извлекает содержимое круглых скобок (означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456). @@ -47,7 +48,7 @@ def parse_amount(raw: str) -> ParsedAmount: """ s = raw.strip().replace(" ", "") inner = _BRACKET_RE.search(s) - if _THOUSANDS_RE.fullmatch(inner.group(1) if inner else s): + if _THOUSANDS_RE.fullmatch(inner.group(1) if inner and inner.group(0) == s else s): s = s.replace(",", "") s = s.replace(",", ".") stripped_raw = s diff --git a/api/tests/unit/normalizer/test_amounts.py b/api/tests/unit/normalizer/test_amounts.py index e277fbc..a41cb50 100644 --- a/api/tests/unit/normalizer/test_amounts.py +++ b/api/tests/unit/normalizer/test_amounts.py @@ -40,6 +40,16 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount ("12,3456", Decimal("12.3456"), True), ("4,200,", Decimal("4200"), False), # хвостовая запятая: правило не применяется (две точки удалены, как раньше) ("(4,200}", Decimal("4.200"), False), # нет пары скобок: правило не применяется, минус не ставится + # скобки не на всю строку — правило тысяч не применяется, прежний разбор (запятая → точка) + ("(4,200) руб", Decimal("-4.200"), False), + ("abc(4,200)", Decimal("-4.200"), False), + ("(4,200)x", Decimal("-4.200"), False), + ("(4,200)(1,000)", Decimal("-4.200"), False), + ("1,234(5,678)", Decimal("-5.678"), False), + ("((4,200))", Decimal("-4.200"), True), + ("01,234", Decimal("1.234"), True), # ведущий ноль — не тысячи + ("4,2000", Decimal("4.2000"), True), + ("1, 234", Decimal("1234"), True), # пробелы убираются первыми # №13: скобки без содержимого — не сумма, но и не «неразобранный текст» ("()", None, True), ("( )", None, True), diff --git a/openspec/specs/image-recognition-gateway/spec.md b/openspec/specs/image-recognition-gateway/spec.md index 6307ce5..e564e90 100644 --- a/openspec/specs/image-recognition-gateway/spec.md +++ b/openspec/specs/image-recognition-gateway/spec.md @@ -238,8 +238,8 @@ SHALL отражать состояние. имени, иначе `confidence` ячейки наименования. - `values` — по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок — недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила - число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы - отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`1,234`) не помещается в `Money` контракта — `amount = null`, + число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая-разделитель тысяч (`4,200`, `1,234` → 4200, 1234; AURORA-1405, #35) удаляется, запятая → точка, скобки → минус, пробелы и прочие символы + отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`0,125`) не помещается в `Money` контракта — `amount = null`, `string_value` — текст ячейки, `is_recognized = false`, решение №30); `string_value` — текст ячейки как распознан (`\n`/`\t` → пробел, края обрезаны) **только если `amount = null`** и текст не прочерк (DBML: «как распознано, если не число»), иначе `null`; `confidence` — ячейки (значения вне 0–100 и NaN приводятся к диапазону, №30); `is_recognized = confidence == 100 и разбор не отбрасывал символов`;