28 KiB
28 KiB
Context
Сквозной дизайн — change docs unified-statement-normalization (design.md, specs/arm-core/spec.md, план
plans/2026-10-18-plan-3-arm-core.md). Здесь — решения уровня ядра для части Б; решения части А — в
openspec/changes/unified-normalization-core-a/design.md (D-1…, A1-1…A5-6, P3-1…P3-24). Схема данных —
../docs/model/data-model-v5.19.dbml; код части А уже в main.
Goals / Non-Goals
Goals и Non-goals — см. proposal.md.
Decisions
- D-1. Единственная точка входа в пакет.
import arm_statements— толькоapi/src/services/statements_engine.py(протоколStatementsEngineи его реализация) иapi/src/domain/stored_form.py; остальной код ядра работает с протоколом. - D-2. §7 хранится только как
document_issue.GET …/statementиPOST …/confirmчитают нарушения из записей; временный расчёт на лету (P3-14 части А) удаляется в B6. - D-3. Архивация вместе с частью А (R-7 части А).
unified-normalization-core-bархивируется только вместе сunified-normalization-core-aи только после выкладки обеих частей; сначала архивируется А, затем Б (дельты Б MODIFIED опираются на требования, которые А добавляет). - D-4. Дельта «копия словаря §7 ядра» оформлена как MODIFIED. Требования с таким именем в
openspec/specs/нет: упоминание копии живёт в «Соотношения §7 по колонкам»; поэтому удаление выражено правкой этого требования (и пунктом «Словари ядра» вdocuments-api), а неREMOVED.
Decisions made without discussion
Решения, принятые без обсуждения с человеком на этапе плана и исполнения части Б (маркеры P3-n — из плана 3,
R-n — рулинги контроллера, Р-n — решения владельца). Таблица дополняется задачами B1–B6.
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-18 | R-1 (Р-40). CODE_NOT_IN_DICTIONARY / CODE_NOT_IN_EDITION пишутся только для кодов из ровно четырёх цифр; пятизначные подстроки Excel не проверяются. P3-7 плана в этой части заменён (запись на код документа сохраняется, пятизначные коды — нет) |
решение владельца 18.10.2026 (Р-40) перед исполнением Б; закрывает O-3 (поток info у пятизначных) |
пропущенный info у нестандартного пятизначного кода; восстановление — снять условие длины кода в маппере 2.0 и пересчёте |
| 2026-10-18 | R-2 (Р-34). Подтверждение кода строки — флаг is_confirmed; line_code_confidence и line_code_modified не меняются (P3-16 части А) |
решение владельца 18.10.2026; в пересчёте Б правило is_confirmed ⇒ needs_review строки = false берётся из P3-16 |
нет — поведение уже реализовано в части А |
| 2026-10-18 | R-3. Удаление копии словаря §7 в дельте оформлено как MODIFIED «Соотношения §7 по колонкам», а не REMOVED (см. D-4); «Ответ GET …/statement» и порядок 409 оформлены новыми ADDED-требованиями, так как в openspec/specs/ и дельтах части А одноимённых нет |
openspec archive требует, чтобы имя REMOVED/MODIFIED существовало; иначе архив упадёт |
при архивации — правка имён заголовков дельт |
| 2026-10-18 | P3-25 (B1). Тип формы во входе пересчёта — document.document_type (BALANCE / INCOME / DISCLOSURE), а не meta.statement_type (только balance / income); план писал «тип формы» без источника |
DISCLOSURE не представим в statement_type; тип документа — единственный источник для всех трёх типов |
форма DISCLOSURE собралась бы с ошибкой KeyError / потерей типа; правка — одна строка в build_stored_form |
| 2026-10-18 | P3-26 (B1, O-3 часть А). Копия statement-code-orders.yaml в api/contracts/ не заводится: перечень кодов читает пакет из своей байтовой копии api/arm_statements/rules/statement_code_orders.yaml (loader._ORDERS), ядро файла не открывает; идентичность копии пакета с docs/contracts/statement-code-orders.yaml проверяет sync-statements --check; тест идентичности контракта сверяет только *.json. Сейчас копия пакета побайтно равна файлу docs |
второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом | нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в contract_identity.py |
| 2026-10-18 | P3-27 (B1). edition_codes берёт строки ok результата 2.0, сортирует по row_index и помечает код уверенным при line_code_confidence == 100 (ровно), None — неуверенный |
как в разделе «Что ядро вызывает» и в recompute пакета (код правленый/подтверждённый там — code_fixed) |
редакция приёма разойдётся с редакцией пересчёта; сверку делает test_edition_codes_and_engine_edition |
| 2026-10-18 | P3-28 (B2). StatementService получает StatementsEngine через зависимость get_statements_engine (тесты подменяют её FakeEngine); FailedRelation, StatementChecks, PeriodKind реэкспортируются из services/statements_engine.py, чтобы import arm_statements оставался только в двух файлах |
единственная точка импорта пакета (common.md); подмена без патчей | нет |
| 2026-10-18 | P3-29 (B2). Статус после правки — form_status_v2: service_done = (result.status == DONE), file_error_warning — metrics.warnings задания, names_ok — нет NAME_MISMATCH пересчёта, duplicate_in_file — duplicate_period_forms по формам файла (file_form_periods); statement_status D-6 с пути правки убран |
один источник статуса с приёмом 2.0 (Р-16, Р-18, Р-19) |
форма с повтором периода в файле после правки остаётся NEEDS_CHECK — как при приёме |
| 2026-10-18 | P3-30 (B2). Пустая цель при непустых членах — нарушение с expected = NULL (P1-21); тесты части А «очищенная 1700 не цель» и «цель потерянной строки не проверяется» переписаны под это; потерявшая строку 1700 форма блокирует подтверждение до правки или акцепта |
следствие P1-21 пакета; спека («посчитанное соотношение») | форма, потерявшая итоговую строку, теперь требует действия аналитика — если владелец против, правится правило пакета (план 1), не ядро |
| 2026-10-18 | P3-31 (B2). NOTHING_TO_ACCEPT и CHECKS_NOT_PASSED читают записи relation; акцептованная форма не считается нарушенной (statement_hard_failures пуст при checks_accepted_at), поэтому повторный акцепт — 409 NOTHING_TO_ACCEPT, как в части А |
сохранение поведения А; буква брифа («нет записей relation») допускает оба прочтения |
повторный акцепт с новым комментарием станет возможен, если снять условие |
| 2026-10-18 | P3-32 (B2). «Эффективная дата» для relative:* колонок при правке header.report_date — новое значение meta.report_date (в том числе NULL ⇒ period_date = NULL), не document.reporting_date; изменение дат колонок идёт в аудит ключом column:<id> без признака правки данных |
период документа сам выводится из даты отчёта и дат колонок (§9.5 п. 2), поэтому циклическая зависимость недопустима | колонка при пустой дате отчёта теряет дату; аналитик ставит её вручную |
| 2026-10-18 | P3-33 (B2). Запись relation / soft несёт и column_id (колонка найдена по column_key: str(column_id), затем str(column_index)), и column_index нарушения; ответ берёт индекс из колонки, иначе из записи, иначе -1 |
ключ колонки B4 для 2.0 — индекс |
нет |
| 2026-10-18 | P3-34 (B2). NAME_MISMATCH пересчитывается для всех кодов (R-1 ограничивает только CODE_NOT_IN_*); CODE_NOT_IN_* не пишутся, если пакет не определил редакцию (а CODE_NOT_IN_EDITION — и вариант); словарь имён берётся по редакции пакета, не по дате |
R-1 и спека (редакция документа — edition пакета) |
пятизначная подстрока с уверенностью < 100 даёт NAME_MISMATCH (кода нет в словаре) — если поток окажется шумным, ограничить name_mismatches четырёхзначными |
| 2026-10-18 | P3-35 (B2). Временный код A12/A13 удалён: тело StatementService.recompute, settle_edited_values, has_review_values, has_source_lines, _value_needs_review, nearest_ok_parents и _recompute_parents; parent_row_index detail пишет только пересчёт пакета |
задание B2; T13.3 (флаги detail больше не затираются навсегда) | нет |
| 2026-10-18 | P3-36 (B3). checks_on_intake_1x живёт в statement_recompute.py; движок передаётся в _insert_statements необязательным параметром (по умолчанию default_engine()), map_file сигнатуру не меняет. Приём 1.x пишет meta.form_variant и записи relation / soft (core), но не CODE_NOT_IN_* / NAME_MISMATCH (P3-7) и не трогает needs_review (A9); условие D-6 «проверки сошлись» — not result.checks.relations_failed (нарушение с expected = NULL — жёсткое) |
задание B3, спека «Пересчёт при приёме»; расхождение с прежним run_checks (его failed) возможно только на формах с пустой целью, где пакет строже |
форма 1.x с пустой целью получит NEEDS_CHECK вместо RECOGNIZED |
| 2026-10-18 | P3-37 (fix1, T2.1). Аудит lines[] довносит смену parent_row_index после пересчёта: у правленных строк значение заменяется итоговым, у остальных появляется запись line:<id> с одним ключом parent_row_index ({"parent_row_index": старый} → {…: новый}) |
родителей пишет recompute_form уже после снимка строк правки; полный снимок для не правленных строк не нужен |
в событии RECOGNIZED_DATA_EDIT у не правленных строк неполный набор ключей |
| 2026-10-18 | P3-38 (fix1, T1.1/T1.2/T2.4/T2.6/T2.8). Паритет редакции приёма и пересчёта для строки с line_code_confidence < 100 и code_fixed подтверждён тестом (расхождения нет, код не менялся); edition_codes пропускает строку ok без кода; column_index нарушения — общим помощником issue_column_index (запись, иначе колонка по column_id) в ответе и в detail отказа; _unconfirmed_needs_review и присвоения needs_review в _apply_item удалены (флаг пишет только пересчёт, он всегда идёт после правки строк); неизвестный relative:* в _resolve_relative_columns пропускается без ошибки |
замечания ревью B1+B2 | колонка с неизвестным relative:* не следует за датой отчёта |
| 2026-10-18 | P3-39 (B4). Проверки ядра info (CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, STATEMENTS_VERSION_MISMATCH, RESULT_STATUS_INCONSISTENT, code_issues, name_issues, version_issue, name_check_lines) вынесены из services/statement_recompute.py в domain/statement_issues.py; единая функция core_info_issues(lines, *, form, edition: str | None, order: Collection[str] | None, catalog) -> CoreInfo(drafts, mismatched) используется приёмом 2.0 и recompute_form (поведение пересчёта не изменилось, P3-34). Домен пакет не импортирует: редакцию (engine.edition) и перечень кодов (engine.code_order) считает сервис; statement_recompute реэкспортирует прежние имена. Вместо сигнатуры плана core_info_issues(*, lines, edition, form_variant, catalog, engine) |
домен не должен знать StatementsEngine/arm_statements (D-1); одно правило для приёма и пересчёта |
нет; импортёры прежних имён из statement_recompute работают |
| 2026-10-18 | P3-40 (B4). Приём форм 2.0 — отдельный сервис services/statement_intake_v2.py (map_statement_docs_v2, insert_forms_v2); map_file выбирает маппер по типу результата через _plan_forms / _insert_forms (иначе функция превышала лимит ruff по операторам) и получил необязательный параметр engine. _load_result и тип в process_once остаются под 1.x до B5 |
размер result_mapping.py и лимиты линтера; граница B4/B5 из брифов |
нет |
| 2026-10-18 | P3-41 (B4). DISCLOSURE в 2.0 — по-прежнему один документ на файл со статусом RECOGNIZED (D-3), file_ordinal первого; предупреждения всех объединённых документов результата пишутся в его document_issue источником service. Статус сервиса и предупреждения файла уровня error на него не влияют |
спека условие RECOGNIZED относится к формам ББ/ОФР; у DISCLOSURE нет данных |
DISCLOSURE с needs_check сервиса автоподтверждается (данных нет — подтверждать нечего) |
| 2026-10-18 | P3-42 (B4). STATEMENTS_VERSION_MISMATCH пишется и при приёме 2.0 (version_issue(job.statements_version, engine.version)), а не только при пересчёте после правки |
иначе запись появлялась бы только после первой правки; один набор записей core у приёма и пересчёта |
лишний info на формах, принятых копией иной версии |
| 2026-10-18 | P3-43 (B4). Результат с повтором row_index или ref у строк: вторая и далее строка пропускается с MAPPING_WARNING; detail с parent_ref, которого нет, хранится с parent_row_index = null и тем же предупреждением |
уникальность (meta_id, source_ref) и ключа строки при вставке иначе роняет приём (файл ERROR после попыток); схема уникальности ref/row_index не проверяет |
потерянная строка дубля видна только в предупреждении |
| 2026-10-18 | P3-44 (B4). Запись relation / soft из checks результата несёт column_id (колонка с таким column_index, если есть) и всегда column_index (как P3-33); запись warning с column_index — либо column_id (колонка есть), либо column_index (колонки нет) |
спека «Пропущенная колонка» и ответ API, читающий индекс из записи | нет |
| 2026-10-18 | P3-45 (B4). is_current колонок 2.0 — как у 1.x (колонка с самой поздней датой, при равенстве меньший column_index; нет дат — NULL), в том числе у колонок relative:* с датой; значение, которого нет в результате, пишется с is_recognized = NULL, остальными полями пустыми и needs_review = false |
§9.5 п. 2; спека «Хранение результата формы v2.0» | колонка relative:* без даты не станет текущей |
| 2026-10-18 | P3-46 (B5). _load_result выбирает модель по MAJOR через contract.versions.parse_result; неизвестный MAJOR, неверный формат версии и любое нарушение схемы — MappingFatal с итогом проверки схемы (результат не соответствует схеме recognition-result: ошибок N, первая: …), файл ERROR, без повтора |
спека «Приём файла результата»; D-2 | файл с MAJOR 3 не мапится, а не обрабатывается молча как 1.x |
| 2026-10-18 | P3-47 (B5, T5.2 части А). Нечитаемый JSON (JSONDecodeError, не UTF-8) в _load_result — тоже MappingFatal с текстом «результат не соответствует схеме recognition-result: файл не является JSON» (текст исключения парсера пользователю не показывается; M-1); не-объект ([], null) отсекается моделью как нарушение схемы |
иначе ValueError из parse_result уходил бы в повторяемые ошибки и файл ERROR получал бы только после MAPPING_MAX_ATTEMPTS попыток |
ошибка JSON не отличается в тексте от прочих ошибок схемы |
| 2026-10-18 | P3-48 (B5). Статус ОСВ 2.0 — osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings); «предупреждения маппинга листа» = повтор счёта + сироты + «нарастающая по данным Q1» (вход document_status 1.x тот же); у 1.x статус как прежде (§9.3, статус задания). Предупреждение уровня error ищется в metrics.warnings результата (file_warnings), а не в recognition_job.metrics |
Р-28; единый источник с формами | ОСВ с error в метриках файла — NEEDS_CHECK |
| 2026-10-18 | P3-49 (B5). osvDocument.warnings пишутся в document_issue как warning / service с severity из предупреждения (по контракту info, error не отбрасывается); line_refs у ОСВ не сохраняются (строк формы нет), column_index и field — как пришли. Записи сервиса идут раньше записей маппинга ядра. Дубль листа (повтор счёта) своих предупреждений/статуса не вносит — документом остаётся первый лист |
у ОСВ нет line_ids/колонок формы; повтор — D-7 |
error-предупреждение листа ОСВ в issues не влияет на статус само по себе (статус — по status листа) |
| 2026-10-18 | P3-50 (B6). FailedCheck.codes в ядре остаётся (вопреки плану, который удалял поле): оно нужно межформенной проверке 1370↔2400 (codes = ("1370", "2400")) для codes / line_ids в checks.cross_form ответа; нарушения §7 по колонкам читаются из document_issue, где коды хранятся сами |
межформенную проверку пакет не считает (она по двум документам), без codes подсветка строк в ответе пропала бы |
у cross_form не было бы codes/line_ids, фронт не подсвечивал бы строки 1370 и 2400 |
| 2026-10-18 | P3-51 (B6). checks_report уже брал допуск у engine.tolerance() (с B2), поэтому правки сервиса в B6 нет; константа допуска ядра удалена вместе с load_check_rules. Состав и порядок редакций справочника имён statement_lines.yaml не переписывается на engine.editions() в коде: согласованность проверяет тест test_name_dictionary_codes_in_code_orders (редакции справочника = редакции пакета в том же порядке; каждый код справочника есть в перечне редакции пакета, любой вариант формы) |
справочник остаётся данными имён и состава экрана; перечни кодов — единственный источник в пакете, расхождение ловит тест, а не молчаливая подмена в рантайме | при добавлении редакции в пакет без справочника имён тест красный; в рантайме новая редакция дала бы форму без шаблонных строк |
| 2026-10-18 | P3-52 (B6). edition_for (граница 66n/fsbu4 по дате для шаблона экрана) в domain/statement_mapping.py не тронут: переход на engine.edition(...) требует кодов формы и варианта и относится к ответу формы (B7/доработка), а не к удалению копии словаря |
не расширять B6 за рамки брифа | ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено |
| 2026-10-18 | P3-60 (fix-волна, I-5). В ответах нарушение §7 может иметь expected / actual = null (пустая цель при непустых членах, P1-21). Индекс колонки при её отсутствии различается: checks.failed[] ответа GET …/statement отдаёт -1 (FailedCheckOut.column_index: int, межформенная cross_form всегда -1), а detail.failures[] отказа 409 CHECKS_NOT_PASSED — null (issue_column_index). Спека описывает поведение как есть; код не правился. Вопрос на выравнивание (-1 или null) — план 6 / фронт (arm-web) |
фиксация фактического контракта до решения владельца; тип поля у фронта (arm-web) нужно сверить | фронт, ожидающий число, получит null в detail.failures[].column_index (только в 409, когда у записи нет ни column_id, ни column_index) |
| 2026-10-18 | P3-61 (fix-волна, C-1, M-1). Дельты B переписаны под openspec archive: MODIFIED «Пересчёт после правки», «Словари ядра и достройка шаблонных строк», «Формы результата 1.x — §7 при приёме» теперь несут полный текст требования части А со всеми сценариями плюс уточнения Б (иначе архивация падает: «scenario(s) not present in the modified block»); добавлен MODIFIED «Хранение результата формы v2.0 (DBML v5.19)» и «Форма ББ/ОФР для экрана проверки» (сценарий «Строка, потерянная источником» приведён к P3-30). ADDED «Маппер результата 2.0 для форм» заменён MODIFIED «Хранение…» (устранено противоречие «первое остаётся» vs «без повторной дедупликации») и ADDED «Проверки ядра при приёме результата 2.0»; ADDED «CHECKS_NOT_PASSED из document_issue» и «Блок checks…» оставлены только с тем, чего нет в А (форма элемента нарушения, null), дубль порядка 409 убран. Проверено архивацией А, затем Б на копии openspec/. Дополняет R-3 и D-4 |
архивация обязана проходить; дубли и противоречия между А и Б вводят в заблуждение | если архивация А и Б падает — править заголовки и сценарии дельт Б; при будущей правке требований А в самой А тексты MODIFIED Б нужно обновить синхронно |