diff --git a/api/contracts/metrics/quality-facts.schema.json b/api/contracts/metrics/quality-facts.schema.json index ff63bcf..c29cece 100644 --- a/api/contracts/metrics/quality-facts.schema.json +++ b/api/contracts/metrics/quality-facts.schema.json @@ -52,7 +52,7 @@ "string_value": { "type": ["string", "null"] }, "source_text": { "type": ["string", "null"] }, "confidence": { "type": ["number", "null"] }, - "is_recognized": { "type": "boolean" }, + "is_recognized": { "type": ["boolean", "null"] }, "needs_review": { "type": "boolean" } } } diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 43038db..81962a5 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -665,6 +665,27 @@ async def test_confirm_writes_outcome_fact_as_is_when_no_fixes( assert fact.payload["fixes"] == [] +@pytest.mark.fixtures +async def test_confirm_outcome_fact_with_analyst_added_cell_matches_contract( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """I-9: ячейка, созданная правкой аналитика (`is_recognized = NULL`), попадает в payload и проходит схему.""" + office = await _rostov_office(seeded) + income, _meta = await _income_form(seeded, fixtures_dir, office=office) + value = await _set_value_fix( + seeded, income, source_text="1", original_amount=Decimal("1.00"), confirmed_amount=Decimal("2.00") + ) + value.is_recognized = None + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) # валидирует payload по контракту + assert any(item["is_recognized"] is None for item in fact.payload["values"]) + + @pytest.mark.fixtures async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory diff --git a/api/tests/test_domain_quality_facts.py b/api/tests/test_domain_quality_facts.py index 7da30c3..e701198 100644 --- a/api/tests/test_domain_quality_facts.py +++ b/api/tests/test_domain_quality_facts.py @@ -14,6 +14,7 @@ from src.models.enums import ( ) from src.models.files import Document from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue +from tests.quality_payload_contract import assert_intake_payload_valid, assert_outcome_payload_valid ROW_COUNT = 4_200 @@ -163,3 +164,23 @@ def test_header_source_and_row_count_absent_when_kwargs_omitted(): assert "row_count" not in osv outcome_osv = build_outcome_payload(make_document(document_type=DocumentType.OSV), osv_warnings=[], fixes=[]) assert "row_count" not in outcome_osv + + +def test_payload_with_null_is_recognized_matches_contract(): + """I-9: ячейка без признака распознавания (правка аналитика, пустая ячейка источника) проходит схему контракта.""" + document = make_document(document_type=DocumentType.BALANCE) + line = FinancialStatementLine( + id=uuid.uuid4(), + source_ref="row:1", + row_index=1, + line_code="1100", + line_status=StatementLineStatus.ok, + needs_review=False, + ) + value = FinancialStatementValue( + line_id=line.id, column_id=uuid.uuid4(), amount=Decimal("10.00"), is_recognized=None, needs_review=False + ) + intake = build_intake_payload(document, lines=[line], values=[value], issues=[]) + assert intake["values"][0]["is_recognized"] is None + assert_intake_payload_valid(intake) + assert_outcome_payload_valid(build_outcome_payload(document, lines=[line], values=[value], issues=[], fixes=[])) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 5d709d7..b937cd3 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -229,7 +229,7 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана | | 06.11.2026 | Task 13: `web/src/api/types.ts` — `'QUALITY_METRICS'` дописан в конец union `PermissionObject` ОДНОЙ строкой (не многострочным форматом), итоговая строка ровно 140 символов — на пределе, но не сверх лимита ширины biome | сохраняет однострочный стиль, которым уже написана эта строка (и соседняя `PermissionAction`); многострочный вариант рассматривался как равноценная по типам альтернатива, выбор между ними непринципиален | нулевая — чисто форматирование, обе формы типобезопасно идентичны | | 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит | -| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл есть в `../docs/contracts/metrics/` (сиблинг-репозиторий arm-ka-docs, ветка feat/202, PR docs #203 — НЕ слита; порядок merge: docs PR #203 сливается раньше PR arm-core; позже файл вендорен — R-16), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | +| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл есть в `../docs/contracts/metrics/` (сиблинг-репозиторий arm-ka-docs, ветка feat/202, PR docs #203 — НЕ слита; порядок merge: docs PR #203 сливается раньше PR arm-core; позже файл вендорен — R-16), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда Перекрыто R-16 (07.11.2026): контракт вендорен, `test_contract` зелёный. | | 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 | | 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) | | 06.11.2026 | Task 10 (`quality_failure_fact`): `error_code` для отказов маппинга (`_fail`, `_register_failure`) — константа `MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED"` (`result_mapping.py`), не свободный текст из `message`/описания исключения | `QualityFailureFact.error_code` не имеет свободнотекстового столбца-соседа для деталей отказа маппинга (в отличие от `recognition_job.error_message`); деталь остаётся в `metrics.mapping_error`/логе, код — единая константа, отличимая от кодов обмена (`ERROR_NO_ACK`/`ERROR_INVALID_REQUEST`), что проверяет отдельный тест | если деталь отказа маппинга понадобится в матрице причин без обращения к логам — нужен отдельный столбец/payload-поле, не замена константы | @@ -265,3 +265,7 @@ PDF, точный вид выгрузки, разбиение на задачи. | 06.11.2026 | R-7 (Task 7, сведён I-2): несоответствие формы `payload.fixes`/`values` контракту `quality-facts.schema.json` было отложено и вынесено в финальное ревью | ни одна задача плана не владела приведением payload к контракту, контракт не был вендорен, тестов валидации не было | закрыто в I-2: R-16…R-21 (вендоринг, приведение payload, тест валидации) | | 07.11.2026 | R-22 (final review M-7): условие §14.4 «нарушенное соотношение не задето правками» НЕ проверяется: `checks_accepted = meta.checks_accepted_by is not None` (`documents._write_confirm_outcome_fact`), то есть любой акцепт нарушений при ручном подтверждении | на ячейку матрицы не влияет: при любой правке (`service`/`ours`) приоритет §6 п.2-3 выше «документа клиента», а без правок задетое правками соотношение невозможно; влияет только на поле `checks_accepted` в факте и на «документ клиента» у документа без правок — где условие выполняется тривиально. Точная проверка требует пересчёта соотношений до/после правок | если акцепт дан, а часть соотношений исправлена правками, поле `checks_accepted` у такого факта завышено; ячейка остаётся «сервис»/«наш разбор» | | 07.11.2026 | R-23 (final review I-7): дельты OpenSpec — ADDED `quality-metrics`, `quality-metrics-ui`; MODIFIED `primary-files` (загрузка, удаление), `recognition-job-exchange` (inbox, таймаут), `documents-api` (подтверждение), `osv-result-mapping` (цикл маппинга, автоподтверждение), `authn-authz` (`GET /me`), `reference-data-seed` (seed, 16 → 17 прав) | `openspec validate --strict` требует полного текста изменяемого требования, поэтому MODIFIED копируют текущее требование из `openspec/specs/` с добавлением; авторизационная спека не перечисляет права, поэтому право отражено сценарием `/me` | при архивации дельты заменят требования целиком — правки спек основной ветки, сделанные параллельно, потеряются (проверить конфликт перед `openspec archive`) | +| 05.11.2026 | R-1 (Task 3): имена экспорта `arm_statements` в плане (`fix_lookalikes`/`parse_amount`/`parse_code`/`parse_date`/`default_rules`) предварительные; исполнитель задачи 3 сверяет их с реальным `dir(arm_statements)` до написания `domain/quality_attribution.py` и фиксирует расхождения строкой в ledger | это собственная инструкция плана, в рулинг вынесена, чтобы попала в бриф задачи | если имена не совпали, а шаг пропущен — `quality_attribution.py` не импортируется, ловится красным тестом сразу, правка дешёвая | +| 06.11.2026 | R-2 (Task 11): регистрация сырого объекта в `recognition_exchange._apply_result` не зависит от `message.status`: срабатывает при `message.raw_s3_key is not None`, независимо от проверки `status is ERROR` для факта отказа (Task 10); оба хука живут в одной функции на разных условиях | план задачи 11 уже говорит «если `message.raw_s3_key is not None`» без условия по статусу; design §9: сырой ответ нужен эталонному прогону при любом исходе | если регистрация должна быть условной, упавшее задание получит лишнюю строку `s3_object` на полезный сырой ответ — дёшево и, скорее всего, правильно | +| 06.11.2026 | R-3 (Task 2): сигнатуры `build_intake_payload` / `build_outcome_payload` расширены необязательными `header_source: Mapping \| None` (кладётся в `header` для ББ/ОФР) и `osv_row_count: int \| None` (ключ `row_count` в ветке ОСВ) | исполнитель задачи 2 заметил, что бриф «Consumes» называет `FinancialStatementMeta`, а сигнатура в «Produces» параметра для него не давала; берётся существующее поле ORM `header_source`, новой формы не изобретали; параметры необязательные — прежние тесты не менялись | если реальному хуку задачи 5 нужна иная форма, сигнатуру правят позже — локальная и дешёвая правка | +| 07.11.2026 | R-24 (повторное ревью I-9): в схеме `quality-facts.schema.json` поле `intake-payload/values/items/is_recognized` — `["boolean", "null"]` (docs, пока PR #203 не слит; копия `api/contracts/metrics/` байт-в-байт). Остальные поля сверены скриптом по моделям: NULL допускают только `is_recognized` (правка аналитика `add_value`, пустая ячейка источника при приёме) и уже допускающие null поля | `FinancialStatementValue.is_recognized` — `bool \| None`; NULL означает «не из источника», а не «не распознана» (P3-54), поэтому тип поля менять не стали, а ослабили контракт; тесты: unit-валидация NULL-ячейки и `CONFIRMED` с ячейкой аналитика | потребитель фактов обязан обрабатывать `null` в `is_recognized`; ужесточение схемы позже потребует миграции данных |