diff --git a/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md b/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md index 83bfdc5..8bf41b3 100644 --- a/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md +++ b/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md @@ -1729,7 +1729,7 @@ class TestCleanTable: assert first.line_name == "Основные средства" and first.line_code_confidence == 100 and first.line_name_confidence == 100 values = first.values assert [v.column_index for v in values] == [0, 1] - assert values[0].amount == Decimal("1234") and values[0].string_value == "1 234" and values[0].is_recognized is True + assert values[0].amount == Decimal("1234") and values[0].string_value is None and values[0].is_recognized is True assert values[1].amount == Decimal("-56") and values[1].is_recognized is True blank = doc.lines[1].values[1] assert blank.amount is None and blank.string_value is None and blank.is_recognized is True @@ -1743,17 +1743,32 @@ class TestNoise: def test_unknown_code_uses_ocr_name_and_caps_confidence(self) -> None: table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "Расшифровка", 100), (1, "11501", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "ОС", 100), (1, "1150 :", 90), (2, "1", 100), (3, "2", 100))) - doc = _build(table).document + _row(2, (0, "ОС", 100), (1, "1150", 90), (2, "1", 100), (3, "2", 100))) + built = _build(table) + doc = built.document + # кода нет в справочнике — имя из OCR, уверенность кода не выше 99 (spec: «иначе min(confidence, 99)») assert doc.lines[0].line_code == "11501" and doc.lines[0].line_name == "Расшифровка" - assert doc.lines[0].line_code_confidence == 100 and doc.lines[0].line_name_confidence == 100 + assert doc.lines[0].line_code_confidence == 99 and doc.lines[0].line_name_confidence == 100 assert doc.lines[1].line_code == "1150" and doc.lines[1].line_code_confidence == 90 # уже < 100, не поднимается assert doc.lines[1].line_name == "Основные средства" + assert built.fully_recognized is False - def test_noisy_code_text_caps_confidence_to_99(self) -> None: - table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100))) + def test_non_integer_code_row_is_dropped(self) -> None: + """Перенос `_classify_row`: «1150 :» не целое число → строка считается повторным заголовком и пропускается.""" + table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], + _row(1, (0, "НМА", 100), (1, "1110", 100), (2, "1", 100), (3, "2", 100)), + _row(2, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100))) + assert [line.line_code for line in _build(table).document.lines] == ["1110"] + + def test_duplicate_code_keeps_first(self) -> None: + """Решение №15: DBML уникален по (meta_id, line_code) — повтор кода даёт одну строку и предупреждение.""" + table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], + _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), + _row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100), (3, "8", 100))) built = _build(table) - assert built.document.lines[0].line_code_confidence == 99 and built.fully_recognized is False + assert [line.line_code for line in built.document.lines] == ["1150"] + assert built.document.lines[0].values[0].amount == Decimal("1") + assert "код 1150 повторяется: оставлена первая строка" in built.warnings and built.fully_recognized is False def test_missing_cells_keep_column_count(self) -> None: """Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок.""" @@ -1765,7 +1780,7 @@ class TestNoise: def test_unverified_amount_is_not_recognized(self) -> None: table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "(7744536}", 100), (3, "x", 100))) values = _build(table).document.lines[0].values - assert values[0].amount == Decimal("7744536") and values[0].is_recognized is False and values[0].string_value == "7744536" + assert values[0].amount == Decimal("7744536") and values[0].is_recognized is False and values[0].string_value is None assert values[1].amount is None and values[1].is_recognized is False and values[1].string_value == "x" def test_synthetic_header_columns_without_dates(self) -> None: @@ -1832,7 +1847,7 @@ from decimal import Decimal from typing import Literal from src.contract.result import StatementColumn, StatementDocument, StatementHeader, StatementLine, StatementValue -from src.normalizer.ocr.amounts import parse_amount +from src.normalizer.ocr.amounts import is_blank, parse_amount from src.normalizer.ocr.dates import parse_date from src.normalizer.ocr.header import FileHeader from src.normalizer.ocr.layout import Cell, cell_text @@ -1937,14 +1952,16 @@ def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValu build.fully_recognized = False return StatementValue(column_index=column_index, amount=None, string_value=None, confidence=0, is_recognized=False) parsed = parse_amount(cell.value) - cleaned = clean_value(cell.value) + text = collapse_whitespace(cell.value) + # DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана + string_value = text if parsed.amount is None and text and not is_blank(text) else None recognized = cell.confidence == FULL_CONFIDENCE and parsed.verified if not recognized: build.fully_recognized = False return StatementValue( column_index=column_index, amount=parsed.amount, - string_value=cleaned or None, + string_value=string_value, confidence=cell.confidence, is_recognized=recognized, ) @@ -2013,11 +2030,18 @@ def build_document( data_rows = [row for row in parsed.rows[1:] if row.kind is not RowKind.HEADER] codes = [clean_value(cell_text(row.cells, row.layout.code_idx)) for row in data_rows] edition = _choose_edition(header, columns, codes, rules, build) - lines = [ - _line(row.cells, row.layout.code_idx, row.layout.label_idx, list(row.layout.value_idxs) or value_idxs, - edition, rules, build) - for row in data_rows - ] + lines: list[StatementLine] = [] + seen: set[str] = set() + for row in data_rows: + code = clean_value(cell_text(row.cells, row.layout.code_idx)) + if code in seen: # решение №15: DBML уникален по (meta_id, line_code), как §12 п. 5 у Excel-пути + build.warn(f"код {code} повторяется: оставлена первая строка") + continue + seen.add(code) + lines.append( + _line(row.cells, row.layout.code_idx, row.layout.label_idx, list(row.layout.value_idxs) or value_idxs, + edition, rules, build) + ) statement_header = _statement_header(label, header, build) document = StatementDocument( document_type=document_type, @@ -2037,7 +2061,7 @@ def build_document( - [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_document.py -q && cd .. && make lint` → PASS. Ожидаемые тонкости: `test_lines_and_values` — пустое значение `-` даёт `parse_amount("-")`: `ALLOWED_RE` оставляет `-`, `Decimal("-")` → `InvalidOperation` → `amount None`, `verified` = (`"-" == "-"`) `True` → `is_recognized True`, `string_value` -= `clean_value("-")` = `""` → `None`. Совпадает с тестом. +— прочерк (`is_blank`) → `None`. Совпадает с тестом. - [ ] **Step 5: Commit** @@ -3325,6 +3349,8 @@ touch /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/docs/.bounded 2. `05-domain-rules.md` §11.1: п. 6 — «справочник — `api/rules/statement_lines.yaml` scan-gateway с редакциями `66n`/`fsbu4` (а не `rules/statement_lines.yaml` из §12: в tabular-parser такого файла нет)»; п. 9 — «исключение при разборе одной таблицы → предупреждение `BACKEND_PARSING_ERROR` уровня таблицы, остальные таблицы разбираются; ни одной таблицы → `error RECOGNITION_FAILED`». + п. 7 — «`string_value` — текст ячейки как распознан, только если сумма не разобрана (DBML), иначе `null`»; + п. 4 — «повтор кода — остаётся первая строка, предупреждение, `needs_check` (как §12 п. 5)». Добавить п. 10 «Редакции форм»: выбор по дате отчёта → поздней `period_date` → совпадениям кодов; пустая редакция → имена из OCR. 3. `07-open-questions.md`: OQ-137 — дописать «уточнение 06.10.2026: `Tmp` остаётся, очистка под `SCAN_CLEANUP`»; новые строки OQ-144…OQ-147 (на 06.10.2026 последний — OQ-143 из 2b; перед правкой проверить последний номер в таблице): diff --git a/openspec/changes/slice-3a-ocr-normalizer/design.md b/openspec/changes/slice-3a-ocr-normalizer/design.md index 4d48320..518e91a 100644 --- a/openspec/changes/slice-3a-ocr-normalizer/design.md +++ b/openspec/changes/slice-3a-ocr-normalizer/design.md @@ -153,4 +153,6 @@ docs: bounded-PR (`03-architecture.md`, OQ-137, §11.1 п. 6 и 9, новые OQ | 12 | 2026-10-06 | `parse_amount` переносится дословно: скобка, распознанная как `}` (`(7744536}`), минус не даёт — сумма положительная с `is_recognized = false` | проверенный код; расширение множества скобок — отдельное правило §11.1 после проверки на стенде | знак потерян, аналитик видит `raw/` и подсветку | | 13 | 2026-10-06 | В переносе `parse_amount` признак `verified` считается до постановки знака минус (в источнике — после, и любая сумма в скобках получала `verified = False`) | иначе каждая отрицательная сумма формы давала `is_recognized = false` и `needs_check` без причины; единственное отличие от источника, зафиксировано тестом `(6032409) → -6032409, verified` | расхождение с поведением старого ядра на стенде: там такие суммы подсвечивались, у нас — нет | | 14 | 2026-10-06 | `CONTRACT_VERSION` scan-gateway остаётся `1.0`, хотя копия схемы — v1.2 | документы 3a не используют полей 1.1–1.2 (`DISCLOSURE`, `checks`, границы периода ОСВ); потребитель принимает любой MINOR; меньше изменений в тестах конверта | если ядро начнёт различать MINOR производителя — одна константа и проверка в `worker_helpers` | +| 15 | 2026-10-06 | Повтор кода строки в таблице OCR: остаётся первая строка, предупреждение, `needs_check` | DBML: уникальный индекс `financial_statement_line (meta_id, line_code)` — без этого маппинг 3b падает; Excel-путь 2b делает так же (§12 п. 5); старое ядро дубли не убирало | вторая строка с тем же кодом теряется в документе (остаётся в `raw/`) | +| 16 | 2026-10-06 | `string_value` заполняется только при `amount = null` и непустом не-прочерке, текстом как распознан (`\n`/`\t` → пробел); у разобранных сумм — `null` | DBML `financial_statement_value.string_value`: «как распознано, если не число»; так же в 2b; первичная редакция спеки и старое ядро писали очищенный текст всегда | аналитик не видит сырой текст суммы на экране (он в `raw/`) | | 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в `raw/`) | diff --git a/openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md b/openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md index ad5f4ee..d4590df 100644 --- a/openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md +++ b/openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md @@ -161,13 +161,17 @@ `label` — текст ячейки шапки (`\n`/`\t` → пробел; у синтетической шапки — пустая строка), `period_date` — `parse_date` текста (квартал/полугодие/N месяцев/диапазон → последний день последнего месяца), `null`, если не разобрался. - `lines` — только строки данных с кодом: `line_code` — очищенный текст ячейки кода (`clean_value`); строка без кода и - строка с нечисловым кодом (повторный заголовок) пропускаются. `line_name` — из справочника редакции по коду; если кода + строка, чей код после обрезки пробелов не является целым числом (`1150 :` — перенос `_classify_row`: считается повторным + заголовком), пропускаются. Повтор кода в документе: остаётся первая строка, предупреждение `код <код> повторяется: + оставлена первая строка`, документ `needs_check` (DBML: `financial_statement_line` уникален по `(meta_id, line_code)`; + так же поступает Excel-путь, §12 п. 5). `line_name` — из справочника редакции по коду; если кода нет в справочниках — текст ячейки наименования. `line_code_confidence` — `confidence` ячейки кода, если очищенный текст равен сырому и код есть в справочнике, иначе `min(confidence, 99)`; `line_name_confidence` — то же для словарного имени, иначе `confidence` ячейки наименования. - `values` — по одному на колонку: `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы - отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`); `string_value` — `clean_value` сырого - текста (`null`, если пусто); `confidence` — ячейки; `is_recognized = confidence == 100 и разбор не отбрасывал символов`; + отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`); `string_value` — текст ячейки как + распознан (`\n`/`\t` → пробел, края обрезаны) **только если `amount = null`** и текст не прочерк (DBML: «как распознано, + если не число»), иначе `null`; `confidence` — ячейки; `is_recognized = confidence == 100 и разбор не отбрасывал символов`; ячейки под колонкой нет → `amount = null`, `string_value = null`, `confidence = 0`, `is_recognized = false`. - `header` — `form_name` из `label` таблицы; `organization_name` и `organization_name_confidence` из `data_ocr.organization` (`\n` → пробел); `inn` — только цифры из `data_ocr.inn.form_name` (`null`, если цифр нет), `inn_confidence = confidence`, @@ -188,7 +192,15 @@ #### Scenario: Суммы в скобках и с пробелами (`aep_3.json`, ОФР) - **WHEN** ячейка значения содержит `(7744536}` с `confidence 88.9`, другая — `(6032409)` с `confidence 100`, третья — `13 070` с `confidence 100` -- **THEN** `amount` соответственно `7744536` (закрывающая скобка не распознана, минус не выставлен, `is_recognized = false`), `-6032409` (`is_recognized = true`), `13070` (`is_recognized = true`); `string_value` — очищенный текст (`7744536`, `6032409`, `13 070`) +- **THEN** `amount` соответственно `7744536` (закрывающая скобка не распознана, минус не выставлен, `is_recognized = false`), `-6032409` (`is_recognized = true`), `13070` (`is_recognized = true`); `string_value = null` у всех трёх (суммы разобраны) + +#### Scenario: Нечисловая ячейка значения +- **WHEN** ячейка значения содержит `не ****` с `confidence 5.6` +- **THEN** `amount = null`, `string_value = "не ****"`, `is_recognized = false` + +#### Scenario: Повтор кода строки +- **WHEN** в таблице баланса две строки с кодом `1150` +- **THEN** в документе одна строка `1150` (первая по порядку), предупреждение `<имя json>: table:: код 1150 повторяется: оставлена первая строка`, статус `needs_check` #### Scenario: Тип по кодам при зашумлённом label - **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700`