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
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; перед правкой проверить последний номер в таблице):

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/` и подсветку |
| 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/`) |

View File

@ -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:<i>: код 1150 повторяется: оставлена первая строка`, статус `needs_check`
#### Scenario: Тип по кодам при зашумлённом label
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700`