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:
parent
b1da9b8774
commit
86fa779c0c
@ -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; перед правкой проверить последний номер в таблице):
|
||||
|
||||
@ -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/`) |
|
||||
|
||||
@ -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`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user