openspec/plan: Срез 3a приведён к DBML и 2b — повтор кода (№15), string_value только для нечисла (№16)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 23:27:42 +04:00
parent b1da9b8774
commit 86fa779c0c
3 changed files with 61 additions and 21 deletions

View File

@ -1729,7 +1729,7 @@ class TestCleanTable:
assert first.line_name == "Основные средства" and first.line_code_confidence == 100 and first.line_name_confidence == 100 assert first.line_name == "Основные средства" and first.line_code_confidence == 100 and first.line_name_confidence == 100
values = first.values values = first.values
assert [v.column_index for v in values] == [0, 1] 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 assert values[1].amount == Decimal("-56") and values[1].is_recognized is True
blank = doc.lines[1].values[1] blank = doc.lines[1].values[1]
assert blank.amount is None and blank.string_value is None and blank.is_recognized is True 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: def test_unknown_code_uses_ocr_name_and_caps_confidence(self) -> None:
table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0],
_row(1, (0, "Расшифровка", 100), (1, "11501", 100), (2, "1", 100), (3, "2", 100)), _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))) _row(2, (0, "ОС", 100), (1, "1150", 90), (2, "1", 100), (3, "2", 100)))
doc = _build(table).document 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 == "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_code == "1150" and doc.lines[1].line_code_confidence == 90 # уже < 100, не поднимается
assert doc.lines[1].line_name == "Основные средства" assert doc.lines[1].line_name == "Основные средства"
assert built.fully_recognized is False
def test_noisy_code_text_caps_confidence_to_99(self) -> None: def test_non_integer_code_row_is_dropped(self) -> None:
table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100))) """Перенос `_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) 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: def test_missing_cells_keep_column_count(self) -> None:
"""Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок.""" """Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок."""
@ -1765,7 +1780,7 @@ class TestNoise:
def test_unverified_amount_is_not_recognized(self) -> None: 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))) 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 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" 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: def test_synthetic_header_columns_without_dates(self) -> None:
@ -1832,7 +1847,7 @@ from decimal import Decimal
from typing import Literal from typing import Literal
from src.contract.result import StatementColumn, StatementDocument, StatementHeader, StatementLine, StatementValue 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.dates import parse_date
from src.normalizer.ocr.header import FileHeader from src.normalizer.ocr.header import FileHeader
from src.normalizer.ocr.layout import Cell, cell_text 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 build.fully_recognized = False
return StatementValue(column_index=column_index, amount=None, string_value=None, confidence=0, is_recognized=False) return StatementValue(column_index=column_index, amount=None, string_value=None, confidence=0, is_recognized=False)
parsed = parse_amount(cell.value) 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 recognized = cell.confidence == FULL_CONFIDENCE and parsed.verified
if not recognized: if not recognized:
build.fully_recognized = False build.fully_recognized = False
return StatementValue( return StatementValue(
column_index=column_index, column_index=column_index,
amount=parsed.amount, amount=parsed.amount,
string_value=cleaned or None, string_value=string_value,
confidence=cell.confidence, confidence=cell.confidence,
is_recognized=recognized, 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] 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] codes = [clean_value(cell_text(row.cells, row.layout.code_idx)) for row in data_rows]
edition = _choose_edition(header, columns, codes, rules, build) edition = _choose_edition(header, columns, codes, rules, build)
lines = [ lines: list[StatementLine] = []
_line(row.cells, row.layout.code_idx, row.layout.label_idx, list(row.layout.value_idxs) or value_idxs, seen: set[str] = set()
edition, rules, build) for row in data_rows:
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) statement_header = _statement_header(label, header, build)
document = StatementDocument( document = StatementDocument(
document_type=document_type, 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. - [ ] **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` оставляет `-`, Ожидаемые тонкости: `test_lines_and_values` — пустое значение `-` даёт `parse_amount("-")`: `ALLOWED_RE` оставляет `-`,
`Decimal("-")` → `InvalidOperation` → `amount None`, `verified` = (`"-" == "-"`) `True` → `is_recognized True`, `string_value` `Decimal("-")` → `InvalidOperation` → `amount None`, `verified` = (`"-" == "-"`) `True` → `is_recognized True`, `string_value`
= `clean_value("-")` = `""` → `None`. Совпадает с тестом. — прочерк (`is_blank`) → `None`. Совпадает с тестом.
- [ ] **Step 5: Commit** - [ ] **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` 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 — «исключение при разборе одной таблицы → (а не `rules/statement_lines.yaml` из §12: в tabular-parser такого файла нет)»; п. 9 — «исключение при разборе одной таблицы →
предупреждение `BACKEND_PARSING_ERROR` уровня таблицы, остальные таблицы разбираются; ни одной таблицы → `error RECOGNITION_FAILED`». предупреждение `BACKEND_PARSING_ERROR` уровня таблицы, остальные таблицы разбираются; ни одной таблицы → `error RECOGNITION_FAILED`».
п. 7 — «`string_value` — текст ячейки как распознан, только если сумма не разобрана (DBML), иначе `null`»;
п. 4 — «повтор кода — остаётся первая строка, предупреждение, `needs_check` (как §12 п. 5)».
Добавить п. 10 «Редакции форм»: выбор по дате отчёта → поздней `period_date` → совпадениям кодов; пустая редакция → имена из OCR. Добавить п. 10 «Редакции форм»: выбор по дате отчёта → поздней `period_date` → совпадениям кодов; пустая редакция → имена из OCR.
3. `07-open-questions.md`: OQ-137 — дописать «уточнение 06.10.2026: `Tmp` остаётся, очистка под `SCAN_CLEANUP`»; новые строки 3. `07-open-questions.md`: OQ-137 — дописать «уточнение 06.10.2026: `Tmp` остаётся, очистка под `SCAN_CLEANUP`»; новые строки
OQ-144…OQ-147 (на 06.10.2026 последний — OQ-143 из 2b; перед правкой проверить последний номер в таблице): OQ-144…OQ-147 (на 06.10.2026 последний — OQ-143 из 2b; перед правкой проверить последний номер в таблице):

View File

@ -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/` и подсветку | | 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` | расхождение с поведением старого ядра на стенде: там такие суммы подсвечивались, у нас — нет | | 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` | | 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/`) | | 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в `raw/`) |

View File

@ -161,13 +161,17 @@
`label` — текст ячейки шапки (`\n`/`\t` → пробел; у синтетической шапки — пустая строка), `period_date` — `parse_date` `label` — текст ячейки шапки (`\n`/`\t` → пробел; у синтетической шапки — пустая строка), `period_date` — `parse_date`
текста (квартал/полугодие/N месяцев/диапазон → последний день последнего месяца), `null`, если не разобрался. текста (квартал/полугодие/N месяцев/диапазон → последний день последнего месяца), `null`, если не разобрался.
- `lines` — только строки данных с кодом: `line_code` — очищенный текст ячейки кода (`clean_value`); строка без кода и - `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` ячейки кода, если очищенный текст нет в справочниках — текст ячейки наименования. `line_code_confidence` — `confidence` ячейки кода, если очищенный текст
равен сырому и код есть в справочнике, иначе `min(confidence, 99)`; `line_name_confidence` — то же для словарного равен сырому и код есть в справочнике, иначе `min(confidence, 99)`; `line_name_confidence` — то же для словарного
имени, иначе `confidence` ячейки наименования. имени, иначе `confidence` ячейки наименования.
- `values` — по одному на колонку: `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы - `values` — по одному на колонку: `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`); `string_value` — `clean_value` сырого отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`); `string_value` — текст ячейки как
текста (`null`, если пусто); `confidence` — ячейки; `is_recognized = confidence == 100 и разбор не отбрасывал символов`; распознан (`\n`/`\t` → пробел, края обрезаны) **только если `amount = null`** и текст не прочерк (DBML: «как распознано,
если не число»), иначе `null`; `confidence` — ячейки; `is_recognized = confidence == 100 и разбор не отбрасывал символов`;
ячейки под колонкой нет → `amount = null`, `string_value = null`, `confidence = 0`, `is_recognized = false`. ячейки под колонкой нет → `amount = null`, `string_value = null`, `confidence = 0`, `is_recognized = false`.
- `header` — `form_name` из `label` таблицы; `organization_name` и `organization_name_confidence` из `data_ocr.organization` - `header` — `form_name` из `label` таблицы; `organization_name` и `organization_name_confidence` из `data_ocr.organization`
(`\n` → пробел); `inn` — только цифры из `data_ocr.inn.form_name` (`null`, если цифр нет), `inn_confidence = confidence`, (`\n` → пробел); `inn` — только цифры из `data_ocr.inn.form_name` (`null`, если цифр нет), `inn_confidence = confidence`,
@ -188,7 +192,15 @@
#### Scenario: Суммы в скобках и с пробелами (`aep_3.json`, ОФР) #### Scenario: Суммы в скобках и с пробелами (`aep_3.json`, ОФР)
- **WHEN** ячейка значения содержит `(7744536}` с `confidence 88.9`, другая — `(6032409)` с `confidence 100`, третья — `13 070` с `confidence 100` - **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:<i>: код 1150 повторяется: оставлена первая строка`, статус `needs_check`
#### Scenario: Тип по кодам при зашумлённом label #### Scenario: Тип по кодам при зашумлённом label
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700` - **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700`