Этап 4 corrective pack 2 по family isolation после текущих routing fixes

This commit is contained in:
dctouch 2026-03-29 15:27:01 +03:00
parent 133b6dca3c
commit f74e7b697a
30 changed files with 4427 additions and 259 deletions

View File

@ -0,0 +1,251 @@
# Stage 4 - Family Card v1 — FA amortization coverage (runtime-aligned)
**document_status:** `ACTIVE`
**family_name:** `Амортизация ОС — полнота охвата / риск пропуска объекта`
**family_id:** `FA_AMORTIZATION_COVERAGE_V1`
**stage_scope:** `Stage 4 (P0-only)`
**current_family_status:** `PROOF_PATH_RAISED_LIVE_ACCEPTANCE_PENDING`
**primary_gap:** `live object-level relation clarity + production live acceptance`
**latest_pack:** `2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure`
**next_pack_focus:** `live replay acceptance + relation/anchor consistency in production channel`
**family_source_of_truth_questions:** `FA-Q1 (31 июля: 2 471,52 / 2 465,28 / 849,83 — полное ли начисление); FA-Q2 (expected vs actual set по объектам ОС за июль); FA-Q3 (есть ли missing object candidates в июльском начислении)`
**family_latest_live_replay:** `X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure_live_attempt\fa_live_raw.json (текущий live attempt: http_status=400, невалидная приемка)`
**family_latest_acceptance_run:** `X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure\run_summary.json`
## 1) Что фиксирует этот документ
Карточка разделяет два слоя:
1. `Runtime V1 (as-is)` — только то, что подтверждено текущим кодом и артефактами пакета.
2. `Target V2 (planned)` — что нужно дожать, но пока не считается текущим acceptance gate.
Это защищает от ложного закрытия family по mock-результату без live-приемки.
## 2) Runtime V1 (фактический контракт на 2026-03-29)
### 2.1 Claim contract (as-is)
- **primary claim_type:** `prove_fixed_asset_amortization_coverage`
- **additional claim_types:** пока не выделялись как отдельные first-class claim types
- **границы claim:**
- не расширяет домены за рамки Stage 4 P0;
- не вводит новый proof engine;
- не использует Stage 5 investigation как core path.
### 2.2 Required anchors (runtime-enforced, as-is)
Для `prove_fixed_asset_amortization_coverage` в рантайме обязательны:
- `period`
- `fixed_asset_signal`
- `amortization_signal`
- `amount_or_document`
- `account_scope_or_document_type`
Факт по latest acceptance run:
- `required_anchors_count = 5`
- `missing_anchor_classes_count = 0`
- `claim_anchor_coverage_ratio = 1`
### 2.3 Claim-bound live recipe (runtime-enforced, as-is)
Обязательные live вызовы:
1. `find_amortization_documents_in_period`
2. `find_fixed_asset_movements_accounts_01_02`
3. `find_fixed_asset_cards_expected_for_period`
4. `match_expected_vs_actual_fa_coverage`
Ожидаемый результат recipe:
- документ(ы) начисления амортизации;
- candidate/actual набор объектов ОС;
- expected set для периода;
- сравнение expected vs actual и кандидаты пропуска.
### 2.4 Route behavior (as-is)
Для FA-claim runtime:
- форсирует claim-bound live-capable route;
- может переопределять `store_feature_risk` в live/hybrid path;
- экспортирует `fa_live_route_audit` в debug payload.
Факт по latest acceptance run:
- `live_route_execution_rate = 1`
- `required_live_calls = 4`
- `missing_live_calls = 0`
- `route_adjustments_applied = 1`
### 2.5 Evidence/admissibility behavior (as-is)
Факт по latest acceptance run:
- `admissible_evidence_count = 16`
- `rejected_evidence_count = 32`
- reject breakdown: `wrong_account_scope = 16`, `weak_source_mapping = 16`
Факт по targeted evidence:
- `targeted_evidence_hit_rate = 1`
- `expected_fa_set_count = 2`
- `actual_fa_set_count = 2`
- `missing_fa_candidates_count = 0`
- `uncertain_fa_candidates_count = 28`
### 2.6 Runtime acceptance snapshot (по latest pack)
- `FA_EXPECTED_SET_FIXED = FIXED`
- `FA_RELATION_MAPPING_FIXED = FIXED`
- `FA_CLAIM_ANCHOR_CLOSURE_FIXED = FIXED`
- `FA_PROOF_CLOSURE_FIXED = FIXED`
- общий статус: `FA_PACK_ACCEPTED`
- важная оговорка: режим пакета `mock` (controlled replay)
### 2.7 known_runtime_limits (as-is)
- `live acceptance pending`: актуальный live attempt завершился `http_status=400`, не может быть источником приемки.
- `relation clarity incomplete`: несмотря на coverage=1 в mock, в relation map много `coverage_status=uncertain` и слабых object-level link.
- `admissibility noise remains`: значимый reject-шум (`wrong_account_scope`, `weak_source_mapping`) сохраняется и требует дожима для production live.
## 3) Target V2 (planned, не критерий текущей приемки)
### 3.1 Planned claim extension
- Развести подтипы FA-claim по режимам:
- `prove_fixed_asset_amortization_completeness`
- `prove_fixed_asset_missing_object_risk`
- `prove_fixed_asset_expected_actual_consistency`
### 3.2 Planned anchor extension
- Явный `expected_fa_set` как first-class anchor.
- Явный `actual_fa_set_from_amortization` как first-class anchor.
- Явный `missing_fa_candidates` с object identity и link quality.
### 3.3 Planned family metrics
- `fa_expected_set_reconstruction_rate`
- `fa_relation_mapping_coverage_rate`
- `fa_claim_anchor_coverage_rate`
- `fa_actual_vs_expected_comparison_rate`
- `fa_proof_closure_rate`
- `fa_false_grounded_answer_rate`
- `fa_live_acceptance_pass_rate`
## 4) Required entities and relations (business contract)
### 4.1 Минимально необходимые сущности для proof closure
1. Документ(ы) начисления амортизации за июль.
2. Объекты ОС, которые должны участвовать в начислении.
3. Движения/проводки по контуру амортизации (в т.ч. счета 01/02).
4. Expected set объектов ОС на период.
5. Actual set объектов, реально попавших в начисление.
6. Missing/uncertain candidates для object-level verdict.
### 4.2 Критические relation links
1. `fa_object -> amortization_document`
2. `amortization_document -> movement/posting`
3. `fa_object -> expected_period_coverage`
4. `expected_set -> actual_set`
5. `missing_or_uncertain_object -> coverage_risk_verdict`
## 5) Snapshot/Live coverage verdict (as-is)
- По FA family практический режим: `snapshot_plus_live_required`.
- В controlled replay proof-path поднят до `grounded_positive`.
- Production live приемка пока не закрыта.
## 6) Answer/proof modes contract
### `grounded_positive`
Допускается, если одновременно:
- `admissible_evidence_count > 0`
- реконструированы `expected_fa_set` и `actual_fa_set`
- сделано сравнение expected vs actual
- вывод имеет object-level опору, не только сумму
- `false_grounded_answer_rate = 0`
Короткий пример:
- `За июль подтверждены ожидаемый и фактический наборы ОС; пропусков по зафиксированным объектам не выявлено, риск неполноты начисления низкий.`
### `limited_or_insufficient_evidence`
Обязателен, если:
- не реконструирован expected set или actual set
- object-level links недостаточны
- есть только косвенная/шумная опора
Короткий пример:
- `Суммы начисления зафиксированы, но object-level соответствие expected vs actual не восстановлено; вывод ограничен и не подтверждает полноту начисления.`
### Запрещенные паттерны
- вывод о полноте начисления только по суммам без object-level связи
- уверенный verdict при `admissible = 0`
- общий lifecycle-текст без указания missing object/link
Короткий антипример:
- `Амортизация начислена корректно, пропусков нет` (нельзя без expected/actual object-level proof).
## 7) Gap register (FA family)
| gap_id | category | severity | current_state | note |
| --- | --- | --- | --- | --- |
| FA-G1 | `live_acceptance_not_confirmed` | blocker | open | текущий live attempt дал `http_status=400`, приемка фактически mock-only |
| FA-G2 | `wrong_entity_mapping` / `relation_clarity` | high | open | в relation map много `coverage_status=uncertain` и слабых direct links |
| FA-G3 | `admissibility_reject_not_due_to_data` | medium | open | reject-шум по `wrong_account_scope` и `weak_source_mapping` |
| FA-G4 | `answer_layer_underuses_available_evidence` | medium | partial | в части трасс answer-mode может звучать ограничительно при сильной eligibility |
## 8) Code-path inventory (где живет контракт)
- `llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts`
- FA claim resolution;
- required anchors;
- targeted checks;
- expected/actual/missing/uncertain FA coverage structures.
- `llm_normalizer/backend/src/services/assistantDataLayer.ts`
- claim-bound FA live plan;
- live call profile `claim_bound_fa_live_path`;
- relation markers (`asset_card_to_depreciation`, `document_to_posting`).
- `llm_normalizer/backend/src/services/assistantService.ts`
- FA live route enforcement;
- route override audit (`fa_live_route_audit`);
- handoff в eligibility/answer layer.
- run artifacts:
- `X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure`
- `X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure_live_attempt`
- `X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure_mock_replay`
## 9) Regression set and acceptance policy
Обязательный минимум для FA family:
1. Базовый вопрос по трем суммам `2 471,52 / 2 465,28 / 849,83`.
2. Вариация без сумм, но с запросом полноты expected vs actual.
3. Вариация на missing-object risk.
4. Follow-up по конкретному объекту ОС (если объект выделен).
Политика:
- после каждого FA pack обязателен новый run folder в `llm_normalizer/docs/runs`;
- acceptance фиксируется на уровне family;
- mock acceptance не заменяет live acceptance;
- `false_grounded` должен оставаться нулевым.
## 10) Project decision line for this family
FA family поднята до proof-ready уровня в controlled replay, но остается в статусе `live acceptance pending` до подтверждения object-level closure на реальном live канале.

View File

@ -0,0 +1,240 @@
# Stage 4 - Family Card v1 — RBP tail / write-off overstay (runtime-aligned)
**document_status:** `ACTIVE`
**family_name:** `РБП — хвост / списание / overstay`
**family_id:** `RBP_TAIL_WRITEOFF_OVERSTAY_V1`
**stage_scope:** `Stage 4 (P0-only)`
**current_family_status:** `ACCEPTED_WITH_LIMITATIONS`
**primary_gap:** `source coverage`
**latest_pack:** `2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix`
**next_pack_focus:** `source coverage recovery + route tightening for full claim closure`
**family_source_of_truth_questions:** `RBP-Q1 (Списание РБП за Июль 2020, включая 5 000); RBP-Q2 (хвост РБП к концу июля без суммы); RBP-Q3 (полнота закрытия июльского списания)`
**family_latest_live_replay:** `2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix/1.txt (локальный replay; внешний live-канал требует отдельной приемки)`
**family_latest_acceptance_run:** `2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix/run_summary.json`
## 1) Что фиксирует этот документ
Карточка теперь разделяет два слоя:
1. `Runtime V1 (as-is)` — только то, что уже реально работает в коде и подтверждено артефактами.
2. `Target V2 (planned)` — что хотим довести в следующих pack, но пока не считаем критерием текущей приемки.
Это нужно, чтобы не смешивать факт и план и не закрывать family “по красивому тексту”.
## 2) Runtime V1 (фактический контракт на 2026-03-29)
### 2.1 Claim contract (as-is)
- **primary claim_type:** `prove_rbp_tail_state`
- **additional claim_types:** пока не first-class в runtime (учитываются как план V2)
- **границы claim:**
- не включает Stage 5 investigation как core path;
- не расширяет домены за пределы Stage 4 P0;
- не делает full redesign proof engine.
### 2.2 Required anchors (runtime-enforced, as-is)
Для `prove_rbp_tail_state` в рантайме обязательны:
- `period`
- `rbp_signal`
- `writeoff_signal`
Технические reason codes на этом шаге:
- `claim_missing_required_anchors`
- `claim_anchor_resolution_low`
### 2.3 Claim-bound live recipe (runtime-enforced, as-is)
Обязательные live вызовы:
1. `find_rbp_writeoff_documents_in_period`
2. `find_rbp_object_movements_account_97`
3. `find_month_close_entries_linked_to_rbp`
4. `compute_end_period_residual_by_rbp_object`
Ожидаемый результат recipe:
- подтверждение документа списания;
- привязка к объекту(ам) РБП;
- движение/связанные записи;
- residual state на границе периода.
### 2.4 Route behavior (as-is)
Для `prove_rbp_tail_state` runtime:
- форсирует live-capable маршрут (`hybrid_store_plus_live` / `live_mcp_drilldown`);
- поднимает `insufficient_specificity` в live path вместо `no_route`;
- экспортирует `rbp_live_route_audit` в debug payload.
### 2.5 Evidence/admissibility behavior (as-is)
Подтверждено в текущем пакете:
- убрана инъекция raw live rows при `matched_rows = 0`;
- добавлена стабилизированная live metadata для source mapping;
- claim-bound targeted checks для RBP расширены до object/document/movement/residual.
Наблюдаемые baseline reject reasons (до фикса пакета):
- `zero_live_match`
- `weak_source_mapping`
- `wrong_account_scope`
### 2.6 Runtime acceptance snapshot (по артефактам latest pack)
- `RBP_SOURCE_COVERAGE_FIXED = NOT_FIXED`
- `RBP_LIVE_ROUTE_FIXED = FIXED`
- `RBP_EVIDENCE_MATERIALIZATION_FIXED = FIXED`
- общий статус: `RBP_PACK_ACCEPTED_WITH_LIMITATIONS`
### 2.7 known_runtime_limits (as-is)
- `source coverage incomplete`: для части object-level proof звеньев данные в runtime source set еще неполные.
- `business scope inconsistency possible`: в части live traces возможна несогласованность `generic_accounting` vs `company_specific_accounting`.
- `anchor noise in edge cases`: в отдельных кейсах могут появляться нерелевантные account hints, влияющие на route profile.
## 3) Target V2 (planned, не критерий текущей приемки)
### 3.1 Planned claim extension
Дополнительные claim types (план):
- `prove_rbp_writeoff_completeness`
- `prove_rbp_lifecycle_overstay`
- `prove_rbp_period_end_residual_state`
### 3.2 Planned anchor extension
Расширенный target-набор anchors (план):
- период (primary + period-end)
- суммы/диапазоны
- объект РБП
- документ/тип документа
- движения/остаток на конец периода
- lifecycle markers для overstay
### 3.3 Planned family metrics
Плановые family-метрики (в stable harness):
- `rbp_required_entity_coverage_rate`
- `rbp_live_route_execution_rate`
- `rbp_admissible_evidence_nonzero_rate`
- `rbp_source_to_proof_completion_rate`
- `rbp_partial_coverage_default_rate`
- `rbp_false_grounded_answer_rate`
До формализации в harness эти метрики считаются target-моделью, а не текущим hard gate.
## 4) Required entities and relations (business contract)
### 4.1 Минимально необходимые сущности для proof closure
1. Документ `Списание РБП` за целевой период.
2. Объект(ы) РБП.
3. Движения/регистровые записи по списанию.
4. Residual state на конец периода.
5. Связь со стадией month-close при необходимости.
### 4.2 Критические relation links
1. `RBP object -> writeoff document`
2. `writeoff document -> movement/register record`
3. `movement/register record -> period-end residual`
4. `expected lifecycle -> actual lifecycle result`
5. `residual state -> overstay/no-overstay verdict`
## 5) Snapshot/Live coverage verdict (as-is)
- `snapshot-only` для RBP сейчас **недостаточен** для устойчивого object-level proof closure.
- Family требует `snapshot_plus_live_required`.
- Главный незакрытый узел — source coverage в production live данных.
## 6) Answer/proof modes contract
### `grounded_positive`
Допускается, если одновременно:
- `admissible_evidence_count > 0`
- закрыта цепочка object/document/movement/residual
- вывод опирается на конкретные source refs
- `false_grounded_answer_rate = 0`
Короткий пример:
- `Подтвержден документ "Списание РБП" за июль 2020, найден связанный объект РБП и остаток на конец периода = 0; признаков overstay по этому объекту не выявлено.`
### `limited_or_insufficient_evidence`
Обязателен, если:
- не найден ключевой link (document/object/residual)
- admissible evidence недостаточно
- нет права имитировать доказанность
Короткий пример:
- `Документ списания найден, но связь с объектом РБП и подтвержденный остаток на конец периода не восстановлены; вывод ограничен до partial coverage без утверждения об overstay.`
### Запрещенные паттерны
- общий lifecycle narrative без указания missing link
- уверенный вывод при `admissible = 0`
- смешивание snapshot-гипотез с live-доказанностью без маркировки
Короткий антипример:
- `Есть признаки проблемы по РБП, вероятно хвост остался` (нельзя без document/object/residual подтверждения).
## 7) Gap register (RBP family)
| gap_id | category | severity | current_state | note |
| --- | --- | --- | --- | --- |
| RBP-G1 | `missing_source_data` / `source_coverage` | blocker | open | ключевая причина `ACCEPTED_WITH_LIMITATIONS` |
| RBP-G2 | `business_scope_resolution_consistency` | high | open | в отдельных live traces встречается несогласованность generic/company-specific слоев |
| RBP-G3 | `anchor_quality` | medium | open | в отдельных кейсах попадают нерелевантные account hints, что ухудшает route profile |
## 8) Code-path inventory (где живет контракт)
- `llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts`
- claim type resolution;
- required anchors для `prove_rbp_tail_state`;
- anchor resolution rate и claim reason codes.
- `llm_normalizer/backend/src/services/assistantDataLayer.ts`
- claim-bound live plan для RBP;
- обязательные live call ids и account scope overrides;
- source profile `claim_bound_rbp_live_path`.
- `llm_normalizer/backend/src/services/assistantService.ts`
- RBP live route enforcement;
- no-route recovery;
- `rbp_live_route_audit` export.
- run artifacts:
- `llm_normalizer/docs/runs/2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix`
## 9) Regression set and acceptance policy
Обязательный минимум для family replay:
1. Базовый RBP вопрос: `Списание РБП за Июль 2020`, включая сумму `5 000`.
2. Вариация без суммы (проверка period + overstay signal).
3. Вариация на полноту закрытия.
4. Follow-up по тому же документу/объекту.
5. Соседний month-close sanity case.
Политика:
- после каждого family pack обязателен новый run folder в `llm_normalizer/docs/runs`;
- приемка фиксируется на уровне family, не одиночного вопроса;
- `false_grounded` должен оставаться нулевым.
## 10) Project decision line for this family
RBP уже перешел в family-based execution контур Stage 4, но остается в статусе `accepted_with_limitations` до восстановления source coverage в живом канале.

View File

@ -0,0 +1,230 @@
# Stage 4 - Family Card v1 — VAT chain (runtime-aligned)
**document_status:** `ACTIVE`
**family_name:** `НДС-цепочка — полнота прохождения от документа до налогового отражения`
**family_id:** `VAT_CHAIN_COMPLETENESS_V1`
**stage_scope:** `Stage 4 (P0-only)`
**current_family_status:** `PARTIALLY_WORKING / NON-BLOCKER`
**primary_gap:** `residual admissibility/materialization quality`
**latest_pack:** `2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt`
**next_pack_focus:** `targeted live narrowing + reject cleanup for VAT proof-path`
**family_source_of_truth_questions:** `VAT-Q1 (13 июля поступление, 15 июля реализация — полная ли НДС-цепочка); VAT-Q2 (есть ли выпадение между документом, проводкой, НДС-регистром и книгой); VAT-Q3 (по July 2020 VAT path grounded-positive или остаются weak-mapping шумы)`
**family_latest_live_replay:** `X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt\1_live_replay.txt`
**family_latest_acceptance_run:** `X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt\run_summary.json`
## 1) Что фиксирует этот документ
Карточка разделяет два слоя:
1. `Runtime V1 (as-is)` — только то, что подтверждено текущим кодом и run-артефактами.
2. `Target V2 (planned)` — что нужно дожать следующими family-pack, но пока не является hard gate текущей приемки.
Это соответствует Stage 4 family-based execution в рамках `P0-only` без перехода в Stage 5 и без архитектурного redesign.
## 2) Runtime V1 (фактический контракт на 2026-03-29)
### 2.1 Claim contract (as-is)
- **primary claim_type:** `prove_vat_chain_completeness`
- **additional claim_types:** пока не выделялись как first-class в runtime
- **границы claim:**
- не расширяет домены за рамки VAT family;
- не включает общий налоговый аудит;
- не включает Stage 5 investigation как core path.
### 2.2 Required anchors (runtime-enforced, as-is)
Для `prove_vat_chain_completeness` в текущем runtime обязательны:
- `period`
- `document_types`
- `vat_signal`
- `chain_signal`
Факт: эти anchors соответствуют текущему `requiredByClaim` в runtime.
### 2.3 Claim checks and live recipe (runtime-enforced, as-is)
Для VAT claim runtime требует закрытия проверок:
1. `source_document_found`
2. `invoice_found`
3. `tax_register_entry_found`
4. `book_entry_found`
5. `chain_linkage_status`
Текущий live acquisition path для VAT в runtime:
- в отличие от RBP/FA, нет выделенного VAT-specific набора обязательных live call id;
- используется `hybrid_store_plus_live` + generic live overlay probe;
- positive VAT-case может быть закрыт за счет admissible evidence и targeted checks.
### 2.4 Route behavior (as-is)
Факт по latest run:
- VAT case (`L1`) отрабатывает в `factual_with_explanation`;
- claim: `prove_vat_chain_completeness`;
- mode: `grounded_positive`;
- scope: `company_specific_accounting`;
- temporal outcome: `passed`.
### 2.5 Evidence/admissibility behavior (as-is)
Факт по VAT case (`L1`) в latest run:
- `admissible_evidence_count = 12`
- `grounding_mode = grounded_positive`
- основные reject-хвосты: `wrong_account_scope`, `weak_source_mapping`
Факт по aggregate reject breakdown (run-level):
- `weak_source_mapping` и `wrong_account_scope` остаются ключевым residual шумом.
### 2.6 Runtime acceptance snapshot (по latest pack)
- family status: `PARTIALLY_WORKING / NON-BLOCKER`
- по `L1` VAT-кейсу есть `grounded_positive`
- общий статус run: `WAVE19_2_ACCEPTED`
- важная оговорка: в run зафиксирован `normalizer_mode = useMock=true`
### 2.7 known_runtime_limits (as-is)
- `normalizer_mock_mode_in_latest_acceptance`: latest acceptance run выполнялся в режиме `useMock=true`.
- `no_explicit_vat_live_call_contract`: для VAT пока нет отдельного жесткого live-call контракта как у RBP/FA.
- `admissibility_noise_remains`: сохраняются residual rejects по `weak_source_mapping` и `wrong_account_scope`.
## 3) Target V2 (planned, не критерий текущей приемки)
### 3.1 Planned claim extension
- `prove_vat_register_book_consistency`
- `prove_document_to_tax_reflection_closure`
- `prove_missing_vat_link_risk`
### 3.2 Planned anchor extension
- `invoice_anchor`
- `register_entry_anchor`
- `book_entry_anchor`
- `vat_amount_anchor`
- `goods_or_item_linkage_anchor`
### 3.3 Planned family metrics
- `vat_grounded_positive_rate`
- `vat_admissibility_noise_rate`
- `vat_book_register_linkage_rate`
- `vat_false_grounded_answer_rate`
- `vat_live_targeting_precision_rate`
## 4) Required entities and relations (business contract)
### 4.1 Минимально необходимые сущности для proof closure
1. Документ поступления/реализации.
2. Счет-фактура.
3. Проводка/движение.
4. Запись в НДС-регистре.
5. Запись в книге покупок/продаж.
6. Связь по товарной позиции/номенклатуре/документной цепочке.
7. Контрагент/договорный контур при необходимости.
### 4.2 Критические relation links
1. `receipt_or_sale_document -> posting`
2. `document -> invoice`
3. `invoice -> vat_register_entry`
4. `vat_register_entry -> purchase_or_sales_book_entry`
5. `goods_or_item_context -> chain_completeness_verdict`
## 5) Snapshot/Live coverage verdict (as-is)
- practical mode для VAT family: `snapshot_plus_live_required`
- positive path уже подтвержден на части кейсов
- family не является текущим главным blocker
- next focus: `live narrowing + reject cleanup`, без domain expansion
## 6) Answer/proof modes contract
### `grounded_positive`
Допускается, если одновременно:
- `admissible_evidence_count > 0`
- подтверждены звенья `document -> invoice -> register -> book`
- вывод не основан только на общем domain narrative
- `false_grounded_answer_rate = 0`
Короткий пример:
- `По July 2020 цепочка документ -> проводка -> НДС-регистр -> книга подтверждена; явного выпадения по этой связке не найдено.`
### `limited_or_insufficient_evidence`
Обязателен, если:
- отсутствует одно из ключевых звеньев цепочки;
- evidence есть, но mapping слабый;
- `register/book` linkage не закрыт.
Короткий пример:
- `Документный и проводочный контур частично подтверждены, но связь до НДС-регистра или книги не восстановлена; вывод ограничен.`
### Запрещенные паттерны
- `НДС отражен корректно` без документно-регистровой связки;
- уверенный verdict при `admissible = 0`;
- подмена chain-proof общим domain narrative.
Короткий антипример:
- `НДС-цепочка в порядке` (нельзя без подтверждения register/book closure).
## 7) Gap register (VAT family)
| gap_id | category | severity | current_state | note |
| --- | --- | --- | --- | --- |
| VAT-G1 | `admissibility_residual_noise` | medium | open | остаточный шум по `weak_source_mapping` / `wrong_account_scope` |
| VAT-G2 | `live_targeting_breadth` | medium | open | live targeting для VAT еще можно сузить до более proof-specific слоя |
| VAT-G3 | `materialization_cleanup` | medium | open | positive path есть, но часть proof-path требует дочистки |
| VAT-G4 | `false_grounded_risk_control` | low | controlled | family должна удерживать `false_grounded = 0` |
## 8) Code-path inventory (где живет контракт)
- `llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts`
- VAT claim type;
- required anchors;
- required checks для VAT chain.
- `llm_normalizer/backend/src/services/assistantDataLayer.ts`
- route/live plan behavior (в т.ч. generic live overlay для non-FA/RBP);
- VAT semantic profile and relation patterns.
- `llm_normalizer/backend/src/services/assistantService.ts`
- routing and eligibility handoff;
- answer-mode and debug export integration.
- run artifacts:
- `X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt`
## 9) Regression set and acceptance policy
Обязательный минимум для VAT family:
1. базовый вопрос по поступлению 13 июля и реализации 15 июля;
2. вариация на выпадение между документом, проводкой и налоговым отражением;
3. вариация на книгу покупок/продаж;
4. follow-up по той же цепочке;
5. соседний VAT sanity-check.
Политика:
- после каждого VAT family pack обязателен новый run folder;
- acceptance фиксируется на уровне family;
- `false_grounded` должен оставаться нулевым.
## 10) Project decision line for this family
VAT family уже переведена в family-based execution контур Stage 4 и рассматривается как `non-blocker`, где нужен cleanup-дожим, а не отдельный большой redesign-пакет.

Binary file not shown.

View File

@ -0,0 +1,34 @@
20 вопросов по вашей компании и июльскому снапшоту
1. Расчёты / банк / 6062
Почему 6 июля ушла оплата за мебель по договору № 01/19-ПТ от 09.01.2019 на 55 200, а к концу июля по этой покупке мог остаться долг или незакрытый хвост?Проверяет payment →settlement closure по поставщику.
Оплата по счёту № 4 от 07.07.20 на 276 873,60 пришла 13 июля. Зачёлся ли этот аванс покупателя корректно в реализации от 15 июля, или на 62.02 что-то осталось висеть?Это прямой company-specific тест на зачёт аванса покупателя.
По договору № 1-ПМ/2020 от 05.06.2020 в июле пришло ещё 40 860 27 июля и 20 000 30 июля. Это уже закрытие дебиторки после реализации или в конце июля там остался аванс/переплата?Хороший тест на 62.01/62.02 и partial settlement.
Почему по одному и тому же мебельному контуру в июле есть и поступления денег от покупателя, и зачёт аванса, но 62.01/62.02 всё равно могут не сойтись?Это вопрос не про сумму, а про механизм.
Если по договору № 1-ПМ/2020 в июле было несколько оплат и одна крупная реализация, где именно ассистент видит разрыв: в договоре, в объекте расчётов или в хронологии документов?Проверяет problem-first объяснение, а не dump.
Есть ли в июльском срезе ситуация, где деньги уже пришли, но закрытие расчётов не подтверждено тем документом, которым должно было закрыться?Тест на document_conflict.
Почему по поставщику мебель оплачена 6 июля, а ассистент может считать, что обязательство не закрыто: не тот договор, не тот объект расчётов или вообще нет подтверждённого closure?Это уже “человеческий” symptom-first вопрос.
Если смотреть только июль, какие именно расчётные цепочки по мебели выглядят завершёнными, а какие — нет?Полезный тест на ranking и truthful limitations.
2. НДС / книга покупок / книга продаж
13 июля проведено поступление товаров, а 15 июля — реализация этих же мебельных позиций. НДС-цепочка по этим движениям у нас полная или где-то есть выпадение между документом, проводкой и налоговым отражением?Это хороший cross-branch вопрос по реальной цепочке июля.
По оплате от 13 июля на 276 873,60 в тексте явно указан НДС 20% = 46 145,60. Ассистент может доказать, что НДС по этой продаже действительно отразился там, где должен, или он только “догадывается”?Тест на false confidence и mechanism specificity.
31 июля есть услуги связи на 1 166,67 + НДС 233,33 и отдельно полученный счёт-фактура на 233,33. Почему по такому кейсу книга покупок могла бы остаться пустой?Это уже отличный реальный кейс под P0-домен НДС.
Связан ли полученный 31 июля счёт-фактура с документом услуг связи так, чтобы НДС по нему можно было принять к вычету в июле, или цепочка “документ → счёт-фактура → налоговая запись” неполная?Проверяет expected edges.
Полезно для поиска реальных дыр, а не только для заранее известных кейсов.
Если ассистент говорит, что НДС по связи за июль в порядке, на чём это должно быть основано: на документе услуг, на счёте-фактуре, на проводке по 19, или на записи книги покупок?Тест именно на explainability.
Почему по одной июльской покупке НДС может попасть в контур, а по другой — нет, даже если обе операции выглядят проведёнными?Это хороший semi-open case на company snapshot.
Есть ли в июльских движениях ситуация, где НДС отражён частично: документ и счёт-фактура есть, но запись книги или tax entry не подтверждены?Тест на broken_chain_segment в домене НДС.
3. Закрытие месяца / затраты / РБП / амортизация
31 июля у нас прошло “Закрытие счетов косвенных расходов”, и там есть крупные суммы — 148 050, 27 954,50, 5 786,63, 5 000 и другие. Всё ли это реально закрылось в нужный контур, или после июля могли остаться зависшие косвенные расходы?Это company-specific вопрос на period close.
31 июля прошло “Списание РБП за Июль 2020 г.”, в том числе на 5 000 и ещё несколько сумм. Есть ли в базе признаки, что часть РБП к концу июля всё ещё живёт дольше ожидаемого?Хороший тест на lifecycle anomaly без выхода в полный Stage 5.
31 июля начислена амортизация тремя суммами — 2 471,52, 2 465,28 и 849,83. Это похоже на полное начисление по всем нужным объектам за июль или есть риск, что какой-то объект ОС в июле не попал в амортизацию?Даже если ОС не P0, этот кейс полезен как controlled adjacent check.
После всех июльских регламентных операций — амортизация, списание РБП, закрытие косвенных расходов, определение финансовых результатов — что у нас больше похоже на реальную проблему: незакрытый затратный хвост, stale RBP или просто нормальный остаток, который ассистент не должен объявлять багом?Это уже зрелый product test на limitation honesty.
Какие из этих 20 самые сильные для первого прогона
Если сжать до “ядра”, я бы первым запускал вот эти 8:
Оплата 55 200 по договору № 01/19-ПТ — почему долг мог остаться.
Поступление денег 276 873,60 от 13 июля — корректно ли зачёлся аванс 15 июля.
Платежи 40 860 и 20 000 по договору № 1-ПМ/2020 — аванс это или закрытие дебиторки.
31 июля услуги связи + НДС 233,33 + полученный счёт-фактура — полная ли НДС-цепочка.
Есть ли покупки июля, где товар/услуга есть, а НДС-контур неполный.
Закрытие косвенных расходов 31 июля — не осталось ли хвостов.
Списание РБП на 31 июля — не живёт ли часть РБП дольше ожидаемого.
После полного month-end — что из остатков является реальной проблемой, а что нет.

View File

@ -0,0 +1,314 @@
# Этап 4 — corrective pack по family isolation, claim routing и чистой приемке
## 1. Контекст
Stage 4 уже работает в `family-based execution`:
- единица анализа: `family`;
- единица реализации: `family pack`;
- единица приемки: `family acceptance`.
По трем новым прогонам подтверждено: основная проблема в runtime не текстовая, а маршрутизационная:
- leakage между family/lane;
- неправильный `claim_type` на корректных вопросах;
- рассинхрон между route/domain/claim в разных слоях runtime;
- смешанные acceptance-файлы, где трудно объективно мерить качество lane/family.
## 2. Архитектурная рамка corrective pack
Чтобы пакет был совместим с текущим проектом, фиксируем:
1. `core families` Stage 4:
`VAT chain`, `RBP tail`, `FA amortization`.
2. `control lanes` (regression/sanity, не first-class family):
`settlements_60_62`, `month_close_indirect_costs`.
3. Пакет не добавляет новые домены и не меняет Stage 4 модель.
4. Пакет не строит новый proof engine и не уводит в Stage 5.
## 3. Цель corrective pack
Привести execution к состоянию, где:
1. вопрос из одного family/lane не уходит в чужой `claim/domain path`;
2. routing в live и follow-up не расходится между runtime-слоями;
3. acceptance выполняется по чистым family/lane файлам;
4. `false_grounded_answer_rate = 0`;
5. `wrong_family_route_rate = 0` на контрольном наборе этого pack.
## 4. Подволны
### Подволна 1 — Family isolation matrix
#### Задача
Снять матрицу соответствия вопроса и целевого family/lane, и явно показать точки leakage.
#### Что сделать
Построить матрицу по контрольным вопросам из трех файлов:
- `расчеты банк 60 62.txt`
- `ндс книга покупок и продаж.txt`
- `рбп затраты аморт.txt`
Для каждого вопроса зафиксировать:
- `raw_question`
- `expected_lane_type` (`core_family` | `control_lane`)
- `expected_family_or_lane`
- `expected_domain`
- `expected_claim_type`
- `actual_domain`
- `actual_claim_type`
- `actual_query_subject`
- `actual_source_profile`
- `actual_business_scope`
- `mismatch_type`
Минимальные lane:
1. `settlements_60_62` (control lane)
2. `VAT chain` (core family)
3. `RBP tail` (core family)
4. `FA amortization` (core family)
5. `month_close_indirect_costs` (control/sanity lane)
#### Acceptance
Есть явная матрица:
- что относится к core family, а что к control lane;
- где и почему происходит leakage.
### Подволна 2 — Claim routing + domain purity alignment
#### Задача
Устранить переходы:
- settlement -> VAT claim;
- settlement -> FA claim;
- VAT -> settlement guard path;
- month-close -> случайный FA/RBP claim без основания.
#### Что сделать
##### A. Синхронизировать domain inference между слоями
Унифицировать доменную классификацию для:
- `assistantService` domain hint;
- `investigationState` explicit domain hint;
- follow-up cross-scope checks.
Цель: один и тот же вопрос не должен получать разные домены в разных слоях runtime.
##### B. Ужесточить claim inference для settlement/VAT/FA
1. Settlement/advance/closure не должны резолвиться в:
- `prove_vat_chain_completeness`;
- `prove_fixed_asset_amortization_coverage`.
2. Убрать ложный FA-триггер на числовых артефактах:
- числа из дат/сумм не должны поднимать FA claim;
- `62.02` не должен теряться как `amount_token` в settlement flow.
3. FA claim допускается только при явном FA-сигнале:
- лексический FA-сигнал;
- или валидные account/object anchors после account extraction cleanup.
##### C. Привязать `query_subject` к resolved family/lane
`query_subject` должен строиться от resolved route/domain/claim, а не от сырого mixed-domain списка.
Практический эффект:
- VAT не должен уходить в `supplier_tail_analysis`;
- settlement не должен маскироваться под VAT/FA path.
##### D. Сохранить рабочий RBP path
Не ломать поднятый RBP контракт:
- `claim_type = prove_rbp_tail_state`;
- обязательный live recipe;
- non-zero admissible evidence path.
##### E. Для FA использовать route-lock/parity контроль
FA в этом паке контролируется через:
- `claim-route lock`;
- `mock/live parity` по целевым FA вопросам;
а не через прямую метрику domain-card purity 1:1.
#### Acceptance
На контрольном наборе pack:
- `wrong_family_route_rate = 0`;
- `wrong_claim_type_rate = 0`;
- `domain_purity_guard_lane_match_rate = 1.0` для lanes с domain-card (`settlements`, `VAT`, `month_close`);
- `fa_route_lock_correctness_rate >= 0.95`;
- `supplier_customer_polarity` не остается unresolved на core settlement cases.
### Подволна 3 — Чистая структура acceptance-прогонов
#### Задача
Убрать смешанные acceptance-файлы.
#### Что сделать
Собрать отдельные прогоны:
1. `chat_export_settlements.txt` (control lane)
2. `chat_export_vat.txt` (core family)
3. `chat_export_rbp.txt` (core family)
4. `chat_export_fa.txt` (core family)
5. `chat_export_month_close_sanity.txt` (control lane)
Правило:
- один файл = один family/lane;
- mixed files не используются как family acceptance.
#### Acceptance
Все acceptance-артефакты lane-clean и сопоставимы между волнами.
### Подволна 4 — Final live rerun по изолированным lanes
#### Задача
Пересобрать финальный live rerun после исправления isolation/routing.
#### Обязательный набор кейсов
`settlements_60_62` (control lane):
1. supplier settlement (`55 200`)
2. buyer advance (`62.02`)
3. closure case без суммы
4. follow-up по тому же договору/документу
`VAT chain`:
1. услуги связи (`1 166,67 + НДС 233,33`)
2. счет-фактура / книга покупок
3. выпадение между документом и книгой
4. follow-up по той же VAT-цепочке
`RBP tail`:
1. `Списание РБП за Июль 2020` (`5 000`)
2. вариант без суммы
3. полнота закрытия
4. follow-up по объекту/документу
`FA amortization`:
1. `2 471,52 / 2 465,28 / 849,83`
2. expected vs actual set
3. missing object risk
4. follow-up по объекту ОС
`month_close_indirect_costs` (control lane):
1. базовый вопрос по косвенным расходам
2. зависший хвост
3. limitation-honesty после полного month-end
#### Acceptance
Новый live rerun показывает:
- route/claim isolation без cross-family leakage;
- корректную lane-классификацию;
- честный limited mode только по фактической нехватке данных.
## 5. Метрики corrective pack
### Добавить/вывести
- `wrong_family_route_rate`
- `wrong_claim_type_rate`
- `domain_purity_guard_lane_match_rate`
- `fa_route_lock_correctness_rate`
- `family_isolation_correctness_rate`
- `live_recipe_binding_rate`
- `false_grounded_answer_rate`
- `mechanism_discrimination_rate`
- `limited_answer_honesty_rate`
### Минимальные пороги (для контрольного набора этого pack)
- `wrong_family_route_rate = 0`
- `wrong_claim_type_rate = 0`
- `domain_purity_guard_lane_match_rate = 1.0` (для lanes с domain-card)
- `fa_route_lock_correctness_rate >= 0.95`
- `false_grounded_answer_rate = 0`
- `mechanism_discrimination_rate >= 0.90`
- `limited_answer_honesty_rate >= 0.95`
## 6. Что создать
### В `docs/ARCH`
1. `10 - family_isolation_and_claim_routing_corrective_pack_2026-03-29.md`
2. `10A - family_isolation_matrix.md`
3. `10B - wrong_claim_route_register.md`
4. `10C - clean_family_run_structure.md`
5. `10D - family_acceptance_policy_update.md`
6. `10E - cross_layer_domain_inference_parity.md`
### В `llm_normalizer/docs/runs/<new_run_pack>`
1. `run_summary.json`
2. `before_after_metrics.json`
3. `family_case_matrix.md`
4. `acceptance_note.md`
5. `chat_export_settlements.txt`
6. `chat_export_vat.txt`
7. `chat_export_rbp.txt`
8. `chat_export_fa.txt`
9. `chat_export_month_close_sanity.txt`
10. `debug_payloads/`
11. `routing_parity_diff.md`
## 7. Что нельзя делать
- не добавлять новые домены;
- не менять Stage 4 family-based model;
- не строить новый proof engine;
- не смешивать acceptance разных family/lane в одном файле;
- не закрывать acceptance по одному удачному ответу;
- не лечить leakage общим ростом generic retrieval.
## 8. Финальный verdict
Выдать:
- `FAMILY_ISOLATION_FIXED / NOT_FIXED`
- `CLAIM_ROUTING_FIXED / NOT_FIXED`
- `CLEAN_FAMILY_ACCEPTANCE_READY / NOT_READY`
Общий статус:
- `STAGE4_FAMILY_ISOLATION_PACK_ACCEPTED`
- или `STAGE4_FAMILY_ISOLATION_PACK_ACCEPTED_WITH_LIMITATIONS`
- или `STAGE4_FAMILY_ISOLATION_PACK_NOT_ACCEPTED`
## 9. Ожидаемый итог
После этого пакета:
- settlement-вопросы не уходят в VAT/FA claim;
- VAT-вопросы не ведутся через settlement-path;
- mixed-file приемка не используется;
- прогресс измеряется по чистым family/lane с сопоставимыми метриками и артефактами.

View File

@ -0,0 +1,292 @@
Этап 4 — пакет по амортизации.md
: замыкание доказательной цепочки по объектам ОС
1. Контекст
После аудита и последующих пакетов:
НДС уже не главный blocker;
РБП выведен из критического состояния;
амортизация остаётся следующим главным узлом.
По аудиту по амортизации картина такая:
источник не пустой;
admissible evidence уже есть;
live path уже что-то поднимает;
но claim-proof не замыкается из-за:
недостаточного mapping связей 1С,
слабого покрытия claim anchors,
отсутствия внятного expected set объектов ОС за июль.
То есть проблема по амортизации сейчас не в нуле данных, а в том, что система не умеет собрать из уже поднятых данных доказанный вывод:
все ли нужные объекты ОС попали в начисление;
если не все — какой именно объект/контур выпал;
или текущий набор начислений выглядит полным.
2. Цель пакета
Нужно не “улучшить ответ”, а замкнуть proof-контур по амортизации:
вопрос про амортизацию → объекты ОС → expected set за июль → фактическое начисление → проводки/движения → покрытие / непокрытие → доказательный вывод
Итогом должно стать:
явное понимание, какие объекты ОС должны участвовать в начислении за июль;
явное понимание, какие из них реально попали в начисление;
восстановление связей между объектами ОС, документом начисления, движениями и проводками;
non-zero claim-proof coverage;
ответ, который может либо:
доказать полноту начисления,
либо показать, что есть риск пропуска конкретных объектов,
либо честно ограничиться, если не хватает ключевой связи.
3. Source of truth
Базовый вопрос
31 июля начислена амортизация тремя суммами — 2 471,52, 2 465,28 и 849,83. Это похоже на полное начисление по всем нужным объектам за июль или есть риск, что какой-то объект ОС в июле не попал в амортизацию?
Текущий диагноз
По аудиту и прогонам:
admissible evidence уже есть;
live path не нулевой;
но итоговый proof не закрывается, потому что не хватает:
корректной карты связей,
object-level coverage,
claim anchor closure.
4. Главная рабочая гипотеза
Для амортизации основной разрыв сейчас в связке:
entity/relation mapping
система не понимает до конца, как объект ОС связывается с начислением, движением, проводкой и expected coverage;
expected set reconstruction
система не умеет уверенно определить, какие объекты ОС вообще должны были попасть в начисление за июль;
claim anchor closure
сумма начисления и сам факт начисления видны, но они не собираются в доказательство полноты/неполноты охвата.
5. Что нужно сделать
Работу выполнить по 5 узлам.
Узел A — Построить минимальную предметную модель амортизации
Задача
Понять, какие сущности 1С обязательны для доказательного ответа на вопрос про полноту начисления амортизации.
Что сделать
Для кейса амортизации определить:
A1. Seed entities
документ/операция начисления амортизации на 31 июля;
три суммы: 2 471,52 / 2 465,28 / 849,83;
July 2020;
объекты ОС, если их можно выделить напрямую или через связанные документы/регистры.
A2. Required entities
Минимально установить:
документ начисления амортизации;
объект(ы) ОС;
движения начисления;
проводки;
регистры состояния/начисления/учёта ОС;
expected set объектов ОС на июль;
фактический set объектов, попавших в начисление;
признаки “должен был попасть / не попал”.
A3. Expected transitions
Обязательные переходы:
объект ОС → начисление амортизации;
начисление → движения/проводки;
объект ОС → expected July coverage;
expected set → actual set;
actual set vs missing set → риск неполного начисления.
Acceptance
Должен появиться явный fa_required_entity_map.
Должно быть понятно, без каких сущностей доказательство полноты невозможно.
Узел B — Восстановить expected set объектов ОС за июль
Задача
Ответить на главный скрытый вопрос:
какие объекты ОС вообще должны были попасть в амортизацию за июль?
Что сделать
Определить, откуда в 1С можно получить expected population объектов ОС на июль:
активные объекты ОС;
объекты, находящиеся в состоянии, допускающем амортизацию;
объекты, не выбывшие и не исключённые из расчёта;
объекты, для которых июльское начисление ожидаемо.
Проверить:
есть ли этот expected set в snapshot;
есть ли он через live;
нужно ли собирать его из нескольких регистров/документов.
Зафиксировать:
expected_fa_set
actual_fa_set_from_amortization
missing_fa_candidates
uncertain_fa_candidates
Acceptance
Должен появиться reconstructible expected_fa_set, а не просто суммы начисления.
Должно быть понятно, может ли система вообще доказать “полноту” начисления, а не только наличие документа.
Узел C — Восстановить relation mapping по объектам ОС
Задача
Понять, как именно объект ОС связывается с начислением, движениями и проводками.
Что сделать
Для каждого candidate объекта ОС построить relation map:
fa_object
document_amortization
movement
posting
period
coverage_status
Нужно зафиксировать:
прямые связи;
косвенные связи;
missing links;
ambiguous links;
какие связи текущий runtime уже использует;
какие связи есть в 1С, но не реконструируются рантаймом.
Acceptance
Должна появиться object-level relation map по амортизации.
Должно стать ясно, где именно рвётся связь между ОС и начислением.
Узел D — Замкнуть claim anchors для амортизации
Задача
Сделать так, чтобы claim по амортизации опирался не только на суммы, а на полноценный набор anchors.
Что сделать
Для claim типа prove_fixed_asset_amortization_coverage выделять и проверять:
период;
суммы начисления;
документ начисления;
объекты ОС;
expected object set;
actual object set;
missing object candidates;
проводки/движения, подтверждающие начисление.
В debug/export ввести:
claim_type
required_anchors
resolved_anchors
missing_anchor_classes
claim_anchor_coverage_ratio
Acceptance
Кейc по амортизации не должен падать просто как claim_anchor_coverage_insufficient без расшифровки.
Должно быть видно, каких именно anchors не хватает.
Узел E — Собрать proof-style answer по амортизации
Задача
Сделать так, чтобы финальный ответ зависел от object-level proof, а не от общей гипотезы по lifecycle.
Что сделать
Если proof-path собран
Ответ должен уметь сказать:
какие объекты ОС подтверждены в начислении;
какие должны были попасть и не подтверждены;
выглядит ли начисление полным;
где именно есть риск пропуска.
Если proof-path неполный
Ответ должен честно сказать:
каких данных не хватает;
какого object-level звена не хватает;
можно ли судить только о частичном покрытии.
Запрещено
выдавать общий lifecycle-туман без object-level explanation;
ограничиваться только тем, что “сигнал слабый”.
Acceptance
Answer mode должен быть привязан к object-level proof.
Амортизация должна перестать быть абстрактным month-close кейсом без объектной структуры.
6. Проверки
Обязательно выполнить:
replay базового вопроса по амортизации;
debug/export после фикса;
live call inventory;
relation reconstruction trace;
expected vs actual FA coverage matrix;
before/after по answer mode.
Желательно:
один соседний follow-up по тому же кейсу;
один sanity-check по другому ОС-related кейсу, если есть.
7. Метрики пакета
Добавить/обновить:
fa_expected_set_reconstruction_rate
fa_relation_mapping_coverage_rate
fa_claim_anchor_coverage_rate
fa_actual_vs_expected_comparison_rate
fa_proof_closure_rate
fa_false_grounded_answer_rate
Минимальные пороги
fa_expected_set_reconstruction_rate >= 0.85
fa_relation_mapping_coverage_rate >= 0.85
fa_claim_anchor_coverage_rate >= 0.9
fa_proof_closure_rate > 0
fa_false_grounded_answer_rate = 0
8. Обязательные артефакты
В новой run-папке положить:
README.md
run_summary.json
fa_expected_set_report.md
fa_relation_mapping_report.md
fa_claim_anchor_report.md
fa_proof_closure_report.md
fa_before_after_matrix.md
chat_export_fa.md
debug_payloads/
raw_live_calls/
fa_required_entity_map.json
fa_expected_vs_actual_set.json
fa_relation_map.json
fa_admissibility_reject_breakdown.json
9. Что не делать
не тащить в эту волну РБП как главный фокус;
не расползаться на весь month-close контур;
не строить новый общий proof engine;
не закрывать пакет по summary без replay амортизационного кейса;
не маскировать отсутствие object-level proof красивым текстом ответа.
10. Финальный verdict
В конце пакета выдать:
FA_EXPECTED_SET_FIXED / NOT_FIXED
FA_RELATION_MAPPING_FIXED / NOT_FIXED
FA_CLAIM_ANCHOR_CLOSURE_FIXED / NOT_FIXED
FA_PROOF_CLOSURE_FIXED / NOT_FIXED
И общий статус:
FA_PACK_ACCEPTED
или FA_PACK_ACCEPTED_WITH_LIMITATIONS
или FA_PACK_NOT_ACCEPTED
11. Ожидаемый результат
После этого пакета должно стать ясно:
какие объекты ОС должны были участвовать в начислении за июль;
какие реально участвовали;
по каким объектам доказательство есть;
где именно остаётся разрыв;
может ли система по амортизации выдавать не просто “limited”, а нормальный object-level proof-style вывод.

View File

@ -0,0 +1,72 @@
# ЭТАП 4 — переход к семействам вопросов и source-to-proof контрактам
Статус: `APPROVED_EXECUTION_MODEL`
Дата: `2026-03-29`
Область: `Stage 4 (P0-only)`
## 1. Почему режим работы меняется
Точечная отладка отдельных вопросов была полезна как разведка, но как основная модель развития она не масштабируется:
- один удачный ответ не закрывает класс дефектов;
- слишком много ручной подгонки под формулировки;
- приемка становится неустойчивой.
Stage 4 официально переводится в `family-based execution`.
## 2. Что уже известно по Stage 4
- общий аудит дал `MULTI_NODE_FAILURE_CONFIRMED`;
- VAT family: рабочий positive path уже есть на части кейсов;
- RBP family: выведена из критического режима, но есть незакрытый `source coverage`;
- FA family: proof path поднят, но live acceptance еще требует дожима.
## 3. Новый официальный формат Stage 4
С этого момента:
- единица анализа: `question family`;
- единица реализации: `family pack`;
- единица приемки: `family acceptance` на наборе вариаций.
## 4. Реестр family (активный)
1. VAT chain
- Card: [Stage 4 - Family Card v1 — VAT chain.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 — VAT chain.md)
- Latest run: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt\run_summary.json)
2. RBP tail / write-off overstay
- Card: [Stage 4 - Family Card v1 RBP.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 RBP.md)
- Latest run: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix\run_summary.json)
3. FA amortization coverage
- Card: [Stage 4 - Family Card v1 FA.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 FA.md)
- Latest run: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure\run_summary.json)
## 5. Что считается закрытием family
Family считается закрытой не по одному ответу, а только при выполнении всего контракта:
- оформлена family card;
- source coverage и ограничения зафиксированы;
- relation map собран и проверяем;
- live recipe воспроизводим;
- admissibility и answer/proof contract зафиксированы;
- regression-пакет проходит приемку;
- `false_grounded_answer_rate = 0`.
## 6. Границы Stage 4
Это не Stage 5 и не новая архитектура:
- без новых доменов;
- без graph/schema expansion;
- без Stage 5 как core path;
- без полного redesign.
## 7. Execution-практика
- планируем и исполняем работу family-pack'ами;
- после каждого прогона обязательны run-артефакты в `llm_normalizer/docs/runs`;
- фиксируем приемку на уровне family, а не единичного вопроса;
- сквозная навигация по family и run ведется в [STAGE_04_FAMILY_BACKLOG_2026-03-29.md](X:\1C\NDC_1C\docs\accounting-assistant\accounting-assistant\03_execution\STAGE_04_FAMILY_BACKLOG_2026-03-29.md).

View File

@ -0,0 +1,159 @@
# FAMILY_CARD_TEMPLATE (runtime-aligned)
**document_status:** `ACTIVE`
**template_scope:** `Stage 4 (P0-only)`
**execution_unit:** `family pack`
## 0) Header (обязательные метаданные family)
- `family_name`:
- `family_id`:
- `stage_scope`:
- `current_family_status`: `accepted` / `accepted_with_limitations` / `not_accepted`
- `primary_gap`:
- `latest_pack`:
- `next_pack_focus`:
- `family_source_of_truth_questions`:
- `family_latest_live_replay`:
- `family_latest_acceptance_run`:
## 1) Что фиксирует документ
- Зачем карточка нужна для этой family.
- Почему карточка делится на:
- `Runtime V1 (as-is)` — только подтвержденный факт по коду/артефактам;
- `Target V2 (planned)` — план следующего доведения.
## 2) Runtime V1 (фактический контракт)
### 2.1 Claim contract (as-is)
- `primary_claim_type`:
- `additional_claim_types` (если уже first-class в runtime):
- `claim_boundaries` (что не входит в Stage 4):
### 2.2 Required anchors (runtime-enforced)
- Какие anchors обязательны именно сейчас.
- Какие reason codes использует runtime при нехватке anchors.
### 2.3 Claim-bound live recipe (runtime-enforced)
- Список обязательных live/MCP call id.
- Что каждый шаг должен подтверждать.
- Что считается успешным live hit.
### 2.4 Route behavior (as-is)
- Как runtime принудительно удерживает route для family.
- Какой no-route recovery используется.
- Какие debug audit-поля экспортируются.
### 2.5 Evidence / admissibility behavior (as-is)
- Какие правила admissibility реально применяются.
- Какие baseline reject reasons наблюдались до последнего пакета.
- Что уже исправлено на materialization уровне.
### 2.6 Runtime acceptance snapshot
- `*_FIXED` / `NOT_FIXED` статусы из последнего run.
- Краткий verdict latest pack.
### 2.7 known_runtime_limits (as-is)
- `source coverage` ограничения.
- Возможные scope/route inconsistency в живых трассах.
- Краевые риски по anchor quality / mapping.
## 3) Target V2 (planned, не критерий текущей приемки)
### 3.1 Planned claim extension
- Какие дополнительные claim types хотим сделать first-class.
### 3.2 Planned anchor extension
- Какие anchors добавятся как обязательные для зрелой версии family.
### 3.3 Planned family metrics
- Набор целевых метрик family.
- Отдельно пометить, какие уже есть в harness, а какие пока target-only.
## 4) Required entities and relations (business contract)
### 4.1 Минимально необходимые сущности в 1C
- Набор сущностей, без которых proof closure невозможен.
- Что optional enrichment.
### 4.2 Критические relation links
- Минимальная цепочка source-to-proof связей.
- Какие связи должны быть прямыми, какие допускаются косвенными.
## 5) Snapshot/Live coverage verdict
- Что покрывает snapshot-only.
- Что требует live.
- Итог: `snapshot_only_sufficient` / `snapshot_plus_live_required` / `live_primary_required`.
## 6) Answer/proof modes contract
### `grounded_positive`
- Условия допуска.
- Что обязательно должно быть в ответе.
- Короткий пример `grounded_positive`.
### `limited_or_insufficient_evidence`
- Условия, когда обязательно остаемся в limited mode.
- Что обязательно должно быть явно обозначено (missing link/ограничение).
- Короткий пример `limited`.
### Запрещенные паттерны
- Какие формулировки считаются недопустимыми.
- Короткий антипример запрещенного ответа.
## 7) Gap register (family)
Использовать таблицу:
| gap_id | category | severity | current_state | note |
| --- | --- | --- | --- | --- |
| FAM-G1 | `...` | blocker/high/medium | open/partial/closed | ... |
Рекомендуемые категории:
- `missing_source_data`
- `source_not_connected_to_runtime`
- `wrong_route_selection`
- `wrong_entity_mapping`
- `wrong_live_call_target`
- `evidence_not_materialized`
- `admissibility_reject_not_due_to_data`
- `answer_layer_underuses_available_evidence`
## 8) Code-path inventory (где живет контракт)
- Модули normalizer/router/claim-bound/evidence/admissibility/answer.
- Ключевые файлы runtime.
- Папка run-артефактов, на которую опирается статус family.
## 9) Regression set and acceptance policy
- Обязательные контрольные вопросы для этой family.
- Набор формулировочных вариаций.
- Правило: после каждого family pack обязателен run folder в `llm_normalizer/docs/runs`.
- Правило: приемка фиксируется на уровне family, не на уровне одного удачного ответа.
- Правило: `false_grounded_answer_rate` должен оставаться нулевым.
## 10) Project decision line (для этой family)
- Одна короткая строка в проектном стиле:
- текущий статус family;
- главный незакрытый узел;
- что является условием перехода в fully accepted.

View File

@ -0,0 +1,70 @@
# STAGE_04_DECISION_NOTE_FAMILY_BASED_EXECUTION
Дата: `2026-03-29`
Статус: `DECISION_APPROVED`
Область: `Stage 4 (P0-only)`
## Почему принято решение
Stage 4 дошел до состояния, где вопрос-by-вопрос режим уже неуправляем:
- повторяются однотипные доменные поломки;
- стоимость точечной отладки растет;
- приемка становится хрупкой.
## Что меняется официально
- единица анализа: `question family`;
- единица реализации: `family pack`;
- единица приемки: `family acceptance`.
## Что не меняется
- Stage 4 остается Stage 4;
- границы `P0-only` сохраняются;
- без новых доменов и без Stage 5 как core path.
## Активные family в проекте
1. VAT chain
- Card: [Stage 4 - Family Card v1 — VAT chain.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 — VAT chain.md)
2. RBP tail
- Card: [Stage 4 - Family Card v1 RBP.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 RBP.md)
3. FA amortization coverage
- Card: [Stage 4 - Family Card v1 FA.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 FA.md)
Общий реестр и run-навигация:
[STAGE_04_FAMILY_BACKLOG_2026-03-29.md](X:\1C\NDC_1C\docs\accounting-assistant\accounting-assistant\03_execution\STAGE_04_FAMILY_BACKLOG_2026-03-29.md)
## Контрольные lane (не first-class family)
В Stage 4 используются контрольные lane для route/purity regression, но они не переводятся в активные family автоматически:
- `settlements_60_62` (control lane)
- `month_close_indirect_costs` (control/sanity lane)
Назначение control lanes:
- проверка family isolation и wrong-route leakage;
- проверка domain purity и live recipe binding на смежных формулировках;
- защита от регрессий между core families.
## Правило внедрения (управление vs исполнение)
- Внедрение в проектный контур: **сразу все 3 family**.
- Исполнение технических паков: **по одной family за раз**, с обязательным регрессом на остальные 2.
- Контрольные lane обязательны в regression-наборе, но не считаются отдельной единицей family acceptance.
Это дает:
- единый язык управления Stage 4;
- устойчивый технический темп без расползания контекста;
- быструю локализацию регрессий.
## Официальная формулировка
`Stage 4 officially transitions from question-by-question debugging to family-based execution over source-to-proof contracts for P0 accounting domains.`
`Этап 4 официально переводится из режима точечной отладки отдельных вопросов в режим поочередного закрытия семейств бухгалтерских вопросов через source-to-proof контракты.`

View File

@ -0,0 +1,105 @@
# STAGE_04_FAMILY_BACKLOG_2026-03-29
Статус: `WORKING_BACKLOG`
Режим: `Stage 4 (P0-only)`
Единица исполнения: `family pack`
## Контур
Этот backlog фиксирует Stage 4 не по отдельным вопросам, а по семействам с source-to-proof контрактом.
В этом backlog:
- `core families` = VAT / RBP / FA;
- `control lanes` = settlements_60_62 / month_close_indirect_costs (только для regression/sanity, не как отдельные family acceptance).
## Навигация
- Family registry: [STAGE_04_FAMILY_REGISTRY_2026-03-29.json](X:\1C\NDC_1C\docs\accounting-assistant\accounting-assistant\03_execution\STAGE_04_FAMILY_REGISTRY_2026-03-29.json)
- VAT family card: [Stage 4 - Family Card v1 — VAT chain.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 — VAT chain.md)
- VAT latest run: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt\run_summary.json)
- RBP family card: [Stage 4 - Family Card v1 RBP.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 RBP.md)
- RBP latest run: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix\run_summary.json)
- FA family card: [Stage 4 - Family Card v1 FA.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 FA.md)
- FA latest acceptance run: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure\run_summary.json)
- FA latest live replay: [fa_live_raw.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure_live_attempt\fa_live_raw.json)
## Control lanes (regression only)
1. `settlements_60_62`
- Назначение: ловить wrong-claim leakage (settlement -> VAT/FA) и polarity regression.
- Статус в Stage 4: `CONTROL_LANE_ONLY`.
2. `month_close_indirect_costs`
- Назначение: sanity-check для period-close вопросов и cross-family contamination.
- Статус в Stage 4: `CONTROL_LANE_ONLY`.
## Family 1 - НДС-цепочка (VAT chain)
- `current_status`: `PARTIALLY_WORKING / NON-BLOCKER`
- `primary_gap`: residual admissibility/materialization quality (account scope mismatch, weak mapping noise)
- `latest_pack`: `2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt`
- `next_pack`: targeted live narrowing + reject cleanup для VAT proof-path
- `acceptance_target`: устойчивый grounded-positive на VAT-вариациях при `false_grounded = 0`
- `family_card`: [Stage 4 - Family Card v1 — VAT chain.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 — VAT chain.md)
- `latest_acceptance_run`: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt\run_summary.json)
- `latest_live_replay`: [1_live_replay.txt](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt\1_live_replay.txt)
Что уже работает:
- positive path по VAT подтвержден в replay.
- temporal/company-scope для live-кейсов стабилизирован в рамках Wave 19.2.
Что дочищаем:
- снижение inadmissible residual noise;
- повышение стабильности admissibility на смежных формулировках.
## Family 2 - РБП (RBP tail / write-off overstay)
- `current_status`: `ACCEPTED_WITH_LIMITATIONS`
- `primary_gap`: source coverage (данные/источники для полного object-level proof)
- `latest_pack`: `2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix`
- `next_pack`: source coverage recovery + route tightening для полного claim closure
- `acceptance_target`: non-zero admissible evidence + mechanism-specific вывод на core RBP вариациях
- `family_card`: [Stage 4 - Family Card v1 RBP.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 RBP.md)
- `latest_acceptance_run`: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix\run_summary.json)
- `latest_live_replay`: [1.txt](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix\1.txt)
Что уже работает:
- live route и evidence materialization подняты (`FIXED`).
Что не закрыто:
- source coverage остается `NOT_FIXED`, поэтому часть кейсов ограничивается честно, но без полного proof closure.
## Family 3 - Амортизация ОС (FA amortization coverage)
- `current_status`: `PROOF_PATH_RAISED, LIVE_ACCEPTANCE_PENDING`
- `primary_gap`: object-level relation clarity в live + production live acceptance
- `latest_pack`: `2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure`
- `next_pack`: live replay acceptance + relation/anchor consistency на вариациях
- `acceptance_target`: доказуемая полнота/неполнота охвата ОС с явным expected vs actual set и `false_grounded = 0`
- `family_card`: [Stage 4 - Family Card v1 FA.md](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 FA.md)
- `latest_acceptance_run`: [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure\run_summary.json)
- `latest_live_replay`: [fa_live_raw.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure_live_attempt\fa_live_raw.json)
Что уже работает:
- claim-bound FA path, targeted checks, expected/actual set и relation-map вынесены в артефакты.
- локальный pack достиг `FA_PACK_ACCEPTED` в controlled mock replay.
Что остается:
- формальная live приемка family pack в рабочем канале (текущий live attempt: `http_status=400`).
## Сводная таблица family-level исполнения
| Family | Current status | Family card | Latest acceptance run | Latest live replay | Primary gap | Next pack focus |
| --- | --- | --- | --- | --- | --- | --- |
| VAT chain | partially working | [VAT card](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 — VAT chain.md) | [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt\run_summary.json) | [1_live_replay.txt](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt\1_live_replay.txt) | admissibility residual noise | live narrowing + reject cleanup |
| RBP tail | accepted with limitations | [RBP card](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 RBP.md) | [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix\run_summary.json) | [1.txt](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix\1.txt) | source coverage | source recovery + route tightening |
| FA amortization | proof path raised, live pending | [FA card](X:\1C\NDC_1C\IN\Stage 4 - Family Card v1 FA.md) | [run_summary.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure\run_summary.json) | [fa_live_raw.json](X:\1C\NDC_1C\llm_normalizer\docs\runs\2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure_live_attempt\fa_live_raw.json) | live object-level closure | live replay acceptance + relation consistency |
## Правило исполнения (обязательно)
1. Новые задачи Stage 4 заводятся как family packs, не как одиночные вопросы.
2. После каждого прогона обязательны run-артефакты в `llm_normalizer/docs/runs`.
3. Приемка фиксируется на уровне family acceptance, а не по одному удачному ответу.
4. Для каждой family в backlog обязательно должны быть заполнены: `family_card`, `latest_acceptance_run`, `latest_live_replay`.
5. Control lanes обязательны в regression-наборе каждого pack, но не повышаются в first-class family без отдельного проектного решения.

View File

@ -0,0 +1,44 @@
{
"schema_version": "stage4_family_registry_v1",
"updated_at": "2026-03-29",
"stage_scope": "Stage 4 (P0-only)",
"execution_unit": "family_pack",
"acceptance_unit": "family_acceptance",
"families": [
{
"family_id": "VAT_CHAIN_COMPLETENESS_V1",
"family_name": "VAT chain",
"status": "PARTIALLY_WORKING_NON_BLOCKER",
"family_card": "X:/1C/NDC_1C/IN/Stage 4 - Family Card v1 — VAT chain.md",
"latest_pack": "2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt",
"latest_acceptance_run": "X:/1C/NDC_1C/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt/run_summary.json",
"latest_live_replay": "X:/1C/NDC_1C/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt/1_live_replay.txt",
"primary_gap": "residual_admissibility_materialization_quality"
},
{
"family_id": "RBP_TAIL_WRITEOFF_OVERSTAY_V1",
"family_name": "RBP tail",
"status": "ACCEPTED_WITH_LIMITATIONS",
"family_card": "X:/1C/NDC_1C/IN/Stage 4 - Family Card v1 RBP.md",
"latest_pack": "2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix",
"latest_acceptance_run": "X:/1C/NDC_1C/llm_normalizer/docs/runs/2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix/run_summary.json",
"latest_live_replay": "X:/1C/NDC_1C/llm_normalizer/docs/runs/2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix/1.txt",
"primary_gap": "source_coverage"
},
{
"family_id": "FA_AMORTIZATION_COVERAGE_V1",
"family_name": "FA amortization coverage",
"status": "PROOF_PATH_RAISED_LIVE_ACCEPTANCE_PENDING",
"family_card": "X:/1C/NDC_1C/IN/Stage 4 - Family Card v1 FA.md",
"latest_pack": "2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure",
"latest_acceptance_run": "X:/1C/NDC_1C/llm_normalizer/docs/runs/2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure/run_summary.json",
"latest_live_replay": "X:/1C/NDC_1C/llm_normalizer/docs/runs/2026-03-29_Stage_04_FA_Pack_Amortization_Proof_Closure_live_attempt/fa_live_raw.json",
"primary_gap": "live_object_level_relation_clarity_and_live_acceptance"
}
],
"rollout_policy": {
"project_adoption": "all_three_families_immediately",
"technical_execution": "one_family_per_pack_with_full_cross_family_regression",
"hard_rule": "false_grounded_answer_rate_must_remain_zero"
}
}

View File

@ -57,32 +57,87 @@ function shiftDays(iso, deltaDays) {
date.setUTCDate(date.getUTCDate() + deltaDays);
return formatDate(date);
}
function accountPrefix(value) {
const token = String(value ?? "").trim();
const match = token.match(/^(\d{2})/);
return match ? match[1] : null;
}
function accountPrefixesFromAnchors(anchors) {
const prefixes = new Set();
const accounts = Array.isArray(anchors?.accounts) ? anchors.accounts : [];
for (const item of accounts) {
const prefix = accountPrefix(String(item ?? ""));
if (prefix) {
prefixes.add(prefix);
}
}
return prefixes;
}
function inferClaimType(input) {
const lower = String(input.userMessage ?? "").toLowerCase();
const isVat = input.focusDomainHint === "vat_document_register_book" ||
/(?:\bvat\b|ндс|invoice|счет[- ]фактур|register|книга покупок|книга продаж)/i.test(lower);
if (isVat) {
const accountPrefixes = accountPrefixesFromAnchors(input.companyAnchors);
const hasSettlementAccount = ["51", "60", "62", "76"].some((item) => accountPrefixes.has(item));
const hasVatAccount = ["19", "68"].some((item) => accountPrefixes.has(item));
const hasFixedAssetAccount = ["01", "02", "08"].some((item) => accountPrefixes.has(item));
const hasRbpAccount = accountPrefixes.has("97");
const hasMonthCloseAccount = ["20", "21", "23", "25", "26", "28", "29", "44"].some((item) => accountPrefixes.has(item));
const hasAdvanceSignal = /(?:advance|аванс|offset|зач[её]т|62\.02|60\.02)/i.test(lower);
const hasSettlementLexical = /(?:долг|аванс|зач[её]т|взаимозач|расч[её]т|оплат|плате[жж]|платёж|постав|покупател|settlement|payment|supplier|customer)/i.test(lower);
const hasVatLexical = /(?:\bvat\b|ндс|invoice|сч[её]т[- ]?фактур|register|книга\s+покупок|книга\s+продаж|книг[аи]\s+(?:покуп|продаж))/i.test(lower);
const hasFixedAssetLexical = /(?:depreciat|amortization|fixed\s*asset|амортиз|основн(?:ые|ых)?\s+сред|объект\s+ос|сч[её]т\s*0[128]|account\s*0[128])/i.test(lower);
const hasRbpLexical = /(?:\brbp\b|рбп|deferred\s*expense|writeoff|расходы\s+будущих\s+периодов|списани[ея]\s+рбп|account\s*97|сч[её]т\s*97)/i.test(lower);
const hasMonthCloseLexical = /(?:month[- ]?close|закрыт|закрытие\s+месяца|косвен|account\s*20|account\s*44|сч[её]т\s*20|сч[её]т\s*44|распределен|period\s*close)/i.test(lower);
if (input.focusDomainHint === "settlements_60_62") {
return hasAdvanceSignal ? "prove_advance_offset_state" : "prove_settlement_closure_state";
}
if (input.focusDomainHint === "vat_document_register_book") {
return "prove_vat_chain_completeness";
}
const isRbp = /(?:\brbp\b|рбп|account\s*97|счет\s*97|deferred expense|writeoff)/i.test(lower);
if (isRbp) {
return "prove_rbp_tail_state";
if (input.focusDomainHint === "fixed_asset_amortization") {
return "prove_fixed_asset_amortization_coverage";
}
const isMonthClose = input.focusDomainHint === "month_close_costs_20_44" ||
/(?:month[- ]?close|закрыт|косвен|account\s*20|account\s*44|счет\s*20|счет\s*44)/i.test(lower);
if (isMonthClose) {
if (input.focusDomainHint === "month_close_costs_20_44") {
if (hasRbpLexical || hasRbpAccount) {
return "prove_rbp_tail_state";
}
return "prove_month_close_state";
}
const isAdvance = /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(lower);
if (isAdvance) {
const settlementPriority = (hasSettlementLexical || hasSettlementAccount || hasAdvanceSignal) && !hasVatLexical && !hasFixedAssetLexical;
const broadMonthClosePriority = (hasMonthCloseLexical || hasMonthCloseAccount) &&
!hasVatLexical &&
!hasVatAccount &&
!hasFixedAssetLexical &&
!hasFixedAssetAccount;
if (hasAdvanceSignal && settlementPriority) {
return "prove_advance_offset_state";
}
if (settlementPriority) {
return "prove_settlement_closure_state";
}
if (hasVatLexical || (hasVatAccount && !settlementPriority)) {
return "prove_vat_chain_completeness";
}
if (broadMonthClosePriority) {
return hasRbpLexical || hasRbpAccount ? "prove_rbp_tail_state" : "prove_month_close_state";
}
if (hasFixedAssetLexical || (hasFixedAssetAccount && !settlementPriority && !hasVatLexical)) {
return "prove_fixed_asset_amortization_coverage";
}
if (hasRbpLexical || hasRbpAccount) {
return "prove_rbp_tail_state";
}
if (hasMonthCloseLexical || hasMonthCloseAccount) {
return "prove_month_close_state";
}
if (hasSettlementLexical || hasSettlementAccount) {
return "prove_settlement_closure_state";
}
return "prove_settlement_closure_state";
}
function inferCounterpartyScope(message) {
const lower = message.toLowerCase();
const out = [];
if (/(?:supplier|vendor|поставщик)/i.test(lower))
if (/(?:supplier|vendor|поставщик|кредитор)/i.test(lower))
out.push("supplier");
if (/(?:customer|buyer|покупатель|дебитор)/i.test(lower))
out.push("customer");
@ -91,13 +146,37 @@ function inferCounterpartyScope(message) {
function detectSignals(message) {
const lower = message.toLowerCase();
return {
hasAdvance: /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(lower),
hasClosure: /(?:close|closure|закрыт|хвост|tail|reconcile|зачет)/i.test(lower),
hasVat: /(?:\bvat\b|ндс|счет[- ]фактур|invoice|книга покупок|книга продаж|register)/i.test(lower),
hasMonthClose: /(?:month[- ]?close|закрытие месяца|косвен|20\/44|account 20|account 44|счет 20|счет 44)/i.test(lower),
hasRbp: /(?:\brbp\b|рбп|account 97|счет 97|writeoff|списани)/i.test(lower)
hasAdvance: /(?:advance|аванс|offset|зач[её]т|62\.02|60\.02)/i.test(lower),
hasClosure: /(?:close|closure|закрыт|хвост|tail|reconcile|зач[её]т)/i.test(lower),
hasVat: /(?:\bvat\b|ндс|сч[её]т[- ]?фактур|invoice|книга\s+покупок|книга\s+продаж|register)/i.test(lower),
hasMonthClose: /(?:month[- ]?close|закрытие\s+месяца|косвен|20\/44|account 20|account 44|сч[её]т 20|сч[её]т 44)/i.test(lower),
hasRbp: /(?:\brbp\b|рбп|account 97|сч[её]т 97|writeoff|списани)/i.test(lower),
hasFixedAsset: /(?:depreciat|amortization|fixed\s*asset|амортиз|основн(?:ые|ых)?\s+сред|объект\s+ос|сч[её]т\s*0[128]|account\s*0[128])/i.test(lower)
};
}
function resolveSettlementRole(input) {
if (input.claimType !== "prove_settlement_closure_state" && input.claimType !== "prove_advance_offset_state") {
return undefined;
}
const scopes = new Set(input.counterpartyScope.map((item) => String(item ?? "").trim().toLowerCase()));
const lower = String(input.userMessage ?? "").toLowerCase();
const hasSupplierLexical = /(?:supplier|vendor|поставщ|кредитор|обязательств|payable)/i.test(lower);
const hasCustomerLexical = /(?:customer|buyer|покупат|дебитор|receivable)/i.test(lower);
const hasSupplierAccount = input.accountPrefixes.has("60");
const hasCustomerAccount = input.accountPrefixes.has("62");
const supplierSignal = scopes.has("supplier") || hasSupplierLexical || (hasSupplierAccount && !hasCustomerAccount);
const customerSignal = scopes.has("customer") || hasCustomerLexical || (hasCustomerAccount && !hasSupplierAccount);
if (supplierSignal && !customerSignal) {
return "supplier";
}
if (customerSignal && !supplierSignal) {
return "customer";
}
if (supplierSignal && customerSignal) {
return "mixed";
}
return "unknown";
}
function mergeAnchors(anchors, key) {
return uniqueStrings(Array.isArray(anchors?.[key]) ? anchors?.[key] : []);
}
@ -131,6 +210,22 @@ function missingFromRequired(required, resolved) {
}
continue;
}
if (anchor === "amount_or_document") {
const hasAmount = (resolved.amounts?.length ?? 0) > 0;
const hasDoc = (resolved.document_numbers?.length ?? 0) > 0 || (resolved.document_types?.length ?? 0) > 0;
if (!hasAmount && !hasDoc) {
missing.push(anchor);
}
continue;
}
if (anchor === "account_scope_or_document_type") {
const hasAccount = (resolved.account_scope?.length ?? 0) > 0;
const hasDocType = (resolved.document_types?.length ?? 0) > 0;
if (!hasAccount && !hasDocType) {
missing.push(anchor);
}
continue;
}
if ((resolved[anchor]?.length ?? 0) <= 0) {
missing.push(anchor);
}
@ -140,9 +235,27 @@ function missingFromRequired(required, resolved) {
function resolveClaimBoundAnchors(input) {
const claimType = inferClaimType({
userMessage: input.userMessage,
focusDomainHint: input.focusDomainHint
focusDomainHint: input.focusDomainHint,
companyAnchors: input.companyAnchors
});
const signals = detectSignals(input.userMessage);
const accountPrefixes = accountPrefixesFromAnchors(input.companyAnchors);
const includeVatAnchors = claimType === "prove_vat_chain_completeness";
const includeMonthCloseAnchors = claimType === "prove_month_close_state";
const includeRbpAnchors = claimType === "prove_rbp_tail_state";
const includeFixedAssetAnchors = claimType === "prove_fixed_asset_amortization_coverage";
const hasVatSignal = signals.hasVat || accountPrefixes.has("19") || accountPrefixes.has("68");
const hasRbpSignal = signals.hasRbp || accountPrefixes.has("97");
const hasFixedAssetSignal = signals.hasFixedAsset || accountPrefixes.has("01") || accountPrefixes.has("02") || accountPrefixes.has("08");
const hasMonthCloseSignal = signals.hasMonthClose ||
accountPrefixes.has("20") ||
accountPrefixes.has("21") ||
accountPrefixes.has("23") ||
accountPrefixes.has("25") ||
accountPrefixes.has("26") ||
accountPrefixes.has("28") ||
accountPrefixes.has("29") ||
accountPrefixes.has("44");
const resolvedAnchors = {
period: uniqueStrings([...mergeAnchors(input.companyAnchors, "periods"), ...mergeAnchors(input.companyAnchors, "dates")]),
account_scope: mergeAnchors(input.companyAnchors, "accounts"),
@ -153,16 +266,26 @@ function resolveClaimBoundAnchors(input) {
counterparty_scope: inferCounterpartyScope(input.userMessage),
advance_signal: signals.hasAdvance ? ["advance"] : [],
closure_signal: signals.hasClosure ? ["closure"] : [],
vat_signal: signals.hasVat ? ["vat"] : [],
chain_signal: signals.hasVat ? ["chain"] : [],
close_signal: signals.hasMonthClose ? ["month_close"] : [],
vat_signal: includeVatAnchors && hasVatSignal ? ["vat"] : [],
chain_signal: includeVatAnchors && hasVatSignal ? ["chain"] : [],
close_signal: includeMonthCloseAnchors && hasMonthCloseSignal ? ["month_close"] : [],
cost_scope: [],
rbp_signal: signals.hasRbp ? ["rbp"] : [],
writeoff_signal: signals.hasRbp ? ["writeoff"] : []
rbp_signal: includeRbpAnchors && hasRbpSignal ? ["rbp"] : [],
writeoff_signal: includeRbpAnchors && hasRbpSignal ? ["writeoff"] : [],
fixed_asset_signal: includeFixedAssetAnchors && hasFixedAssetSignal ? ["fixed_asset"] : [],
amortization_signal: includeFixedAssetAnchors && hasFixedAssetSignal ? ["amortization"] : [],
expected_fa_set: [],
actual_fa_set: []
};
if (/(?:^|[^\d])(20|44)(?:[^\d]|$)/.test((resolvedAnchors.account_scope ?? []).join(" ")) || signals.hasMonthClose) {
if (includeMonthCloseAnchors &&
(/(?:^|[^\d])(20|44)(?:[^\d]|$)/.test((resolvedAnchors.account_scope ?? []).join(" ")) || hasMonthCloseSignal)) {
resolvedAnchors.cost_scope = ["20_44"];
}
// For FA amortization claims, document type is implicit in user intent
// even when the phrase does not carry explicit document keywords.
if (includeFixedAssetAnchors && hasFixedAssetSignal && (resolvedAnchors.document_types?.length ?? 0) <= 0) {
resolvedAnchors.document_types = ["amortization_document"];
}
if (input.primaryPeriod) {
resolvedAnchors.period = uniqueStrings([...(resolvedAnchors.period ?? []), input.primaryPeriod.from, input.primaryPeriod.to]);
}
@ -171,7 +294,14 @@ function resolveClaimBoundAnchors(input) {
prove_advance_offset_state: ["period", "account_scope", "advance_signal", "settlement_object"],
prove_vat_chain_completeness: ["period", "document_types", "vat_signal", "chain_signal"],
prove_month_close_state: ["period", "close_signal", "cost_scope"],
prove_rbp_tail_state: ["period", "rbp_signal", "writeoff_signal"]
prove_rbp_tail_state: ["period", "rbp_signal", "writeoff_signal"],
prove_fixed_asset_amortization_coverage: [
"period",
"fixed_asset_signal",
"amortization_signal",
"amount_or_document",
"account_scope_or_document_type"
]
};
const requiredAnchors = requiredByClaim[claimType];
const missingAnchors = missingFromRequired(requiredAnchors, resolvedAnchors);
@ -189,8 +319,19 @@ function resolveClaimBoundAnchors(input) {
if (!allowedContextWindow && input.primaryPeriod) {
reasonCodes.push("controlled_temporal_expansion_window_unavailable");
}
const settlementRole = resolveSettlementRole({
claimType,
counterpartyScope: resolvedAnchors.counterparty_scope ?? [],
accountPrefixes,
userMessage: input.userMessage
});
if ((claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state") &&
(settlementRole === "mixed" || settlementRole === "unknown")) {
reasonCodes.push("unresolved_supplier_customer_polarity");
}
return {
claim_type: claimType,
settlement_role: settlementRole,
required_anchors: requiredAnchors,
resolved_anchors: resolvedAnchors,
missing_anchors: missingAnchors,
@ -217,7 +358,13 @@ function buildCorpusFromItem(item) {
document_context: item.document_context,
relation_pattern_hits: item.relation_pattern_hits,
graph_domain_scope: item.graph_domain_scope,
lifecycle_markers: item.lifecycle_markers
lifecycle_markers: item.lifecycle_markers,
live_call_id: item.live_call_id,
live_call_purpose: item.live_call_purpose,
fa_object_hint: item.fa_object_hint,
fa_expected_set_candidate: item.fa_expected_set_candidate,
fa_actual_set_candidate: item.fa_actual_set_candidate,
fa_coverage_status: item.fa_coverage_status
}).toLowerCase();
}
function buildCorpusFromEvidence(evidence) {
@ -256,6 +403,16 @@ function requiredChecksByClaim(claimType) {
if (claimType === "prove_month_close_state") {
return ["close_operation_found", "distribution_step_found", "residual_tail_found"];
}
if (claimType === "prove_fixed_asset_amortization_coverage") {
return [
"amortization_document_found",
"fixed_asset_object_identified",
"expected_fa_set_reconstructed",
"actual_fa_set_reconstructed",
"movement_or_posting_link_found",
"missing_fa_candidates_assessed"
];
}
return [
"rbp_writeoff_document_found",
"rbp_object_identified",
@ -273,21 +430,26 @@ function detectChecksForCorpus(corpus, claimType, anchors) {
const hasSettlementAccount = /(?:\b60(?:\.\d{2})?\b|\b62(?:\.\d{2})?\b|payable|receivable|settlement)/i.test(corpus);
const hasPosting = /(?:document_to_posting|posting|проводк)/i.test(corpus);
const hasRegister = /(?:register|accumulationregister|accountingregister|регистр)/i.test(corpus);
const hasClose = /(?:close|closure|закрыт|reconcile|зачет|tail|хвост)/i.test(corpus);
const hasClose = /(?:close|closure|закрыт|reconcile|зач[её]т|tail|хвост)/i.test(corpus);
const hasPayment = /(?:payment|оплат|списаниесрасчетногосчета|payment_order|bank_statement)/i.test(corpus);
const hasAdvance = /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(corpus);
const hasVat = /(?:\bvat\b|ндс|invoice_to_vat|счет[- ]фактур|invoice)/i.test(corpus);
const hasBook = /(?:книгипокупок|книгипродаж|book)/i.test(corpus);
const hasAdvance = /(?:advance|аванс|offset|зач[её]т|62\.02|60\.02)/i.test(corpus);
const hasVat = /(?:\bvat\b|ндс|invoice_to_vat|сч[её]т[- ]?фактур|invoice)/i.test(corpus);
const hasBook = /(?:книг[аи](?:\s+)?(?:покупок|продаж)|book)/i.test(corpus);
const hasChain = /(?:chain|link|document_to_posting|invoice_to_vat|связ)/i.test(corpus);
const hasMonthClose = /(?:month[- ]?close|period_close|закрытие месяца|косвен|20|44)/i.test(corpus);
const hasMonthClose = /(?:month[- ]?close|period_close|закрытие\s+месяца|косвен|20|44)/i.test(corpus);
const hasDistribution = /(?:distribution|распредел|writeoff|deferred_expense_to_writeoff)/i.test(corpus);
const hasRbp = /(?:\brbp\b|рбп|account\s*97|счет\s*97|deferred)/i.test(corpus);
const hasRbp = /(?:\brbp\b|рбп|account\s*97|сч[её]т\s*97|deferred)/i.test(corpus);
const hasResidual = /(?:tail|остат|незакры|overdue|period_boundary|terminal_state_gap)/i.test(corpus);
const hasContradiction = /(?:contradiction|invalid_transition|normal residual|нормальн)/i.test(corpus);
const hasRbpWriteoffDoc = /(?:списани[ея]\s+рбп|rbp_writeoff|deferred_expense_document|writeoff document)/i.test(corpus);
const hasRbpObject = /(?:rbp[_\s-]?object|объект\s+рбп|analytics|subkonto|расходыбудущихпериодов)/i.test(corpus);
const hasMovement = /(?:movement|движен|хозрасчетный|document_to_posting|posting|проводк)/i.test(corpus);
const hasPeriodEndResidual = /(?:period_boundary|end_period|2020-07-31|остат)/i.test(corpus);
const hasFixedAsset = /(?:fixed_asset|asset_card|объект\s+ос|основн(?:ые|ых)?\s+сред|depreciat|амортиз|account[:\s]*0[12]|\b0[12](?:\.\d{2})?\b)/i.test(corpus);
const hasAmortizationDoc = /(?:depreciat|amortization|начислен[а-я]*\s+амортиз|документ\s+амортиз)/i.test(corpus);
const hasExpectedFaSet = /(?:expected_fa_set|expected[_\s-]?set|find_fixed_asset_cards_expected_for_period|expected_set_seed|fa_expected_set_candidate)/i.test(corpus);
const hasActualFaSet = /(?:actual_fa_set|find_fixed_asset_movements_accounts_01_02|fa_actual_set_candidate|seed_amortization_documents|collect_fa_object_movements)/i.test(corpus);
const hasFaCoverageCompare = /(?:expected_vs_actual|compare_expected_vs_actual|missing_fa|coverage_compare|missing_fa_candidates)/i.test(corpus);
if (claimType === "prove_settlement_closure_state") {
if (hasPayment)
checks.add("payment_document_found");
@ -319,7 +481,7 @@ function detectChecksForCorpus(corpus, claimType, anchors) {
else if (claimType === "prove_vat_chain_completeness") {
if (/(?:document|receipt|realization|поступлен|реализац)/i.test(corpus))
checks.add("source_document_found");
if (/(?:invoice|счет[- ]фактур)/i.test(corpus))
if (/(?:invoice|сч[её]т[- ]?фактур)/i.test(corpus))
checks.add("invoice_found");
if (hasRegister || hasVat)
checks.add("tax_register_entry_found");
@ -336,6 +498,20 @@ function detectChecksForCorpus(corpus, claimType, anchors) {
if (hasResidual)
checks.add("residual_tail_found");
}
else if (claimType === "prove_fixed_asset_amortization_coverage") {
if (hasAmortizationDoc)
checks.add("amortization_document_found");
if (hasFixedAsset)
checks.add("fixed_asset_object_identified");
if (hasExpectedFaSet)
checks.add("expected_fa_set_reconstructed");
if (hasActualFaSet || hasAmortizationDoc)
checks.add("actual_fa_set_reconstructed");
if (hasMovement || hasPosting)
checks.add("movement_or_posting_link_found");
if (hasFaCoverageCompare || (hasExpectedFaSet && hasActualFaSet))
checks.add("missing_fa_candidates_assessed");
}
else {
if (hasRbpWriteoffDoc || (hasRbp && hasDistribution))
checks.add("rbp_writeoff_document_found");
@ -479,7 +655,11 @@ function buildDerivedEvidenceFromItem(input) {
account_context: Array.isArray(input.item.account_context) ? input.item.account_context : [],
account_debit: input.item.account_debit ?? null,
account_credit: input.item.account_credit ?? null,
relation_pattern_hits: Array.isArray(input.item.relation_pattern_hits) ? input.item.relation_pattern_hits : []
relation_pattern_hits: Array.isArray(input.item.relation_pattern_hits) ? input.item.relation_pattern_hits : [],
fa_object_hint: String(input.item.fa_object_hint ?? "").trim() || null,
fa_expected_set_candidate: Boolean(input.item.fa_expected_set_candidate),
fa_actual_set_candidate: Boolean(input.item.fa_actual_set_candidate),
fa_coverage_status: String(input.item.fa_coverage_status ?? "").trim() || null
}
};
}
@ -490,6 +670,149 @@ function buildClaimStatusTemplate(requiredChecks) {
}
return out;
}
function normalizeFaObjectToken(value) {
const normalized = String(value ?? "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return null;
}
if (/^live movement row #\d+$/i.test(normalized)) {
return null;
}
return normalized.slice(0, 140);
}
function periodFromEvidence(evidence) {
const payload = toObject(evidence.payload);
return (String(evidence.source_ref?.period ?? "").trim() ||
String(evidence.pointer?.source?.period ?? "").trim() ||
String(payload?.period ?? "").trim() ||
null);
}
function collectFaCoverage(input) {
const state = new Map();
const touch = (objectName) => {
const key = objectName.toLowerCase();
const existing = state.get(key);
if (existing) {
return existing;
}
const created = {
expected: false,
actual: false,
movement: false,
posting: false,
docs: new Set(),
periods: new Set()
};
state.set(key, created);
return created;
};
for (const result of input.retrievalResults) {
const items = Array.isArray(result.items) ? result.items : [];
for (const item of items) {
const objectToken = normalizeFaObjectToken(String(item.fa_object_hint ?? item.display_name ?? item.source_id ?? "").trim());
if (!objectToken) {
continue;
}
const slot = touch(objectToken);
if (Boolean(item.fa_expected_set_candidate)) {
slot.expected = true;
}
if (Boolean(item.fa_actual_set_candidate)) {
slot.actual = true;
}
const corpus = JSON.stringify(item).toLowerCase();
if (/(?:movement|движен|хозрасчет|document_to_posting)/i.test(corpus)) {
slot.movement = true;
}
if (/(?:posting|проводк|account_)/i.test(corpus)) {
slot.posting = true;
}
const documentContext = Array.isArray(item.document_context) ? item.document_context : [];
for (const doc of documentContext) {
const token = String(doc ?? "").trim();
if (token) {
slot.docs.add(token);
}
}
const period = String(item.period ?? item.Period ?? "").trim();
if (period) {
slot.periods.add(period);
}
}
const evidence = Array.isArray(result.evidence) ? result.evidence : [];
for (const evidenceItem of evidence) {
const payload = toObject(evidenceItem.payload) ?? {};
const objectToken = normalizeFaObjectToken(String(payload.fa_object_hint ?? evidenceItem.source_ref?.id ?? evidenceItem.pointer?.source?.id ?? "").trim());
if (!objectToken) {
continue;
}
const slot = touch(objectToken);
if (Boolean(payload.fa_expected_set_candidate)) {
slot.expected = true;
}
if (Boolean(payload.fa_actual_set_candidate)) {
slot.actual = true;
}
const corpus = JSON.stringify({
payload,
mechanism_note: evidenceItem.mechanism_note,
source_ref: evidenceItem.source_ref
}).toLowerCase();
if (/(?:movement|движен|хозрасчет|document_to_posting)/i.test(corpus)) {
slot.movement = true;
}
if (/(?:posting|проводк|account_)/i.test(corpus)) {
slot.posting = true;
}
const documentContext = Array.isArray(payload.document_context) ? payload.document_context : [];
for (const doc of documentContext) {
const token = String(doc ?? "").trim();
if (token) {
slot.docs.add(token);
}
}
const period = periodFromEvidence(evidenceItem);
if (period) {
slot.periods.add(period);
}
}
}
const entries = Array.from(state.entries());
const expectedSet = entries
.filter(([, slot]) => slot.expected)
.map(([objectName]) => objectName)
.slice(0, 32);
const actualSet = entries
.filter(([, slot]) => slot.actual)
.map(([objectName]) => objectName)
.slice(0, 32);
const expectedResolved = expectedSet.length > 0 ? expectedSet : actualSet;
const missingCandidates = expectedResolved.filter((item) => !actualSet.includes(item)).slice(0, 32);
const uncertainCandidates = entries
.filter(([, slot]) => !slot.expected && !slot.actual)
.map(([objectName]) => objectName)
.slice(0, 32);
const relationMap = entries.slice(0, 48).map(([objectName, slot]) => {
const coverageStatus = slot.expected && slot.actual ? "covered" : slot.expected && !slot.actual ? "missing" : "uncertain";
return {
fa_object: objectName,
document_amortization: Array.from(slot.docs).slice(0, 4),
movement: slot.movement,
posting: slot.posting,
period: Array.from(slot.periods).slice(0, 4),
coverage_status: coverageStatus
};
});
return {
expectedSet: expectedResolved,
actualSet,
missingCandidates,
uncertainCandidates,
relationMap
};
}
function applyTargetedEvidenceAcquisition(input) {
const requiredChecks = requiredChecksByClaim(input.claimAudit.claim_type);
const checkStatus = buildClaimStatusTemplate(requiredChecks);
@ -600,6 +923,19 @@ function applyTargetedEvidenceAcquisition(input) {
if (targetedEvidenceHitRate < 0.8) {
reasonCodes.push("targeted_evidence_hit_rate_low");
}
const faCoverage = input.claimAudit.claim_type === "prove_fixed_asset_amortization_coverage"
? collectFaCoverage({
retrievalResults: adjustedResults
})
: null;
if (faCoverage) {
if (faCoverage.expectedSet.length <= 0) {
reasonCodes.push("fa_expected_set_not_reconstructed");
}
if (faCoverage.actualSet.length <= 0) {
reasonCodes.push("fa_actual_set_not_reconstructed");
}
}
return {
retrievalResults: adjustedResults,
audit: {
@ -610,6 +946,15 @@ function applyTargetedEvidenceAcquisition(input) {
targeted_evidence_hits: targetedEvidenceHits,
targeted_evidence_hit_rate: targetedEvidenceHitRate,
targeted_evidence_source_refs: Array.from(sourceRefs).slice(0, 24),
...(faCoverage
? {
fa_expected_set: faCoverage.expectedSet,
fa_actual_set_from_amortization: faCoverage.actualSet,
fa_missing_candidates: faCoverage.missingCandidates,
fa_uncertain_candidates: faCoverage.uncertainCandidates,
fa_relation_map: faCoverage.relationMap
}
: {}),
reason_codes: uniqueStrings(reasonCodes)
}
};

View File

@ -7,6 +7,7 @@ exports.AssistantDataLayer = void 0;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const config_1 = require("../config");
const investigationState_1 = require("./investigationState");
const BROAD_GENERIC_MARKERS = new RegExp([
"\\boverall\\b",
"\\bgeneral\\b",
@ -87,6 +88,20 @@ const RBP_REQUIRED_LIVE_CALLS = [
"find_month_close_entries_linked_to_rbp",
"compute_end_period_residual_by_rbp_object"
];
const VAT_REQUIRED_LIVE_CALLS = [
"find_vat_source_documents_in_period",
"find_vat_invoice_links_in_period",
"find_vat_register_entries_in_period",
"find_vat_book_entries_in_period"
];
const FA_REQUIRED_LIVE_CALLS = [
"find_amortization_documents_in_period",
"find_fixed_asset_movements_accounts_01_02",
"find_fixed_asset_cards_expected_for_period",
"match_expected_vs_actual_fa_coverage"
];
const CLAIM_BOUND_PRIMARY_LIVE_LIMIT = Math.max(config_1.ASSISTANT_MCP_LIVE_LIMIT, 96);
const CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT = Math.max(config_1.ASSISTANT_MCP_LIVE_LIMIT, 128);
function pushUniqueLine(target, line) {
if (!target.includes(line)) {
target.push(line);
@ -114,6 +129,12 @@ function parseFiniteNumber(value) {
}
return null;
}
function resolveLiveCallLimit(limit) {
if (typeof limit === "number" && Number.isFinite(limit)) {
return Math.max(1, Math.trunc(limit));
}
return config_1.ASSISTANT_MCP_LIVE_LIMIT;
}
function formatIsoDateUtc(date) {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
@ -174,9 +195,111 @@ function buildLiveRangeQuery(fromIso, toIso, limit) {
function hasRbpSignal(text) {
return /(?:\brbp\b|рбп|расходы\s+будущих\s+периодов|deferred|writeoff|списани[ея]\s+рбп|account\s*97|счет\s*97)/i.test(String(text ?? "").toLowerCase());
}
function hasFixedAssetAmortizationSignal(text) {
return /(?:амортиз|основн(?:ые|ых)?\s+сред|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|depreciat|fixed\s*asset|account\s*0[12]|счет\s*0[12])/i.test(String(text ?? "").toLowerCase());
}
function buildLiveMcpCallPlan(route, fragmentText) {
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
const rbpClaim = hasRbpSignal(fragmentText) ||
const preferredDomainHint = (0, investigationState_1.inferP0DomainFromMessage)(fragmentText);
const periodScope = inferPeriodScope(fragmentText);
const primaryFrom = periodScope.from ?? "2020-07-01";
const primaryTo = periodScope.to ?? monthEndFromIso(primaryFrom) ?? "2020-07-31";
const carryFrom = shiftIsoDate(primaryFrom, -31) ?? primaryFrom;
const carryTo = shiftIsoDate(primaryTo, 31) ?? primaryTo;
const faClaim = preferredDomainHint === "fixed_asset_amortization" ||
hasFixedAssetAmortizationSignal(fragmentText) ||
semanticProfile.query_subject === "fixed_asset_card_mismatch" ||
semanticProfile.domain_scope.includes("fixed_assets");
if (faClaim) {
return {
claim_type: "prove_fixed_asset_amortization_coverage",
query_subject: "fixed_asset_amortization_coverage",
required_live_calls: [...FA_REQUIRED_LIVE_CALLS],
calls: [
{
call_id: "find_amortization_documents_in_period",
purpose: "seed_amortization_documents",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["01", "02", "08"]
},
{
call_id: "find_fixed_asset_movements_accounts_01_02",
purpose: "collect_fa_object_movements",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["01", "02", "08"]
},
{
call_id: "find_fixed_asset_cards_expected_for_period",
purpose: "build_expected_fa_set",
query: buildLiveRangeQuery(carryFrom, primaryTo, CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT),
limit: CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["01", "02", "08"]
},
{
call_id: "match_expected_vs_actual_fa_coverage",
purpose: "compare_expected_vs_actual_fa_coverage",
query: buildLiveRangeQuery(carryFrom, carryTo, CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT),
limit: CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["01", "02", "08"]
}
],
route_gap_reason: null
};
}
const vatClaim = preferredDomainHint === "vat_document_register_book" ||
semanticProfile.query_subject === "vat_chain_conflict" ||
semanticProfile.domain_scope.includes("vat") ||
/(?:\bvat\b|ндс|invoice|счет[- ]фактур|книга покупок|книга продаж|register)/i.test(String(fragmentText ?? "").toLowerCase());
if (vatClaim) {
return {
claim_type: "prove_vat_chain_completeness",
query_subject: "vat_chain_conflict",
required_live_calls: [...VAT_REQUIRED_LIVE_CALLS],
calls: [
{
call_id: "find_vat_source_documents_in_period",
purpose: "seed_vat_source_documents",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["19", "68"]
},
{
call_id: "find_vat_invoice_links_in_period",
purpose: "collect_invoice_links",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["19", "68"]
},
{
call_id: "find_vat_register_entries_in_period",
purpose: "collect_vat_register_entries",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["19", "68"]
},
{
call_id: "find_vat_book_entries_in_period",
purpose: "collect_vat_book_entries",
query: buildLiveRangeQuery(carryFrom, carryTo, CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT),
limit: CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["19", "68"]
}
],
route_gap_reason: null
};
}
const rbpClaim = (preferredDomainHint === "month_close_costs_20_44" && hasRbpSignal(fragmentText)) ||
hasRbpSignal(fragmentText) ||
semanticProfile.query_subject === "deferred_expense_lifecycle_anomaly" ||
semanticProfile.domain_scope.includes("deferred_expense");
if (!rbpClaim) {
@ -195,11 +318,6 @@ function buildLiveMcpCallPlan(route, fragmentText) {
route_gap_reason: null
};
}
const periodScope = inferPeriodScope(fragmentText);
const primaryFrom = periodScope.from ?? "2020-07-01";
const primaryTo = periodScope.to ?? monthEndFromIso(primaryFrom) ?? "2020-07-31";
const carryFrom = shiftIsoDate(primaryFrom, -31) ?? primaryFrom;
const carryTo = shiftIsoDate(primaryTo, 31) ?? primaryTo;
return {
claim_type: "prove_rbp_tail_state",
query_subject: "deferred_expense_lifecycle_anomaly",
@ -208,28 +326,32 @@ function buildLiveMcpCallPlan(route, fragmentText) {
{
call_id: "find_rbp_writeoff_documents_in_period",
purpose: "seed_writeoff_documents",
query: buildLiveRangeQuery(primaryFrom, primaryTo, config_1.ASSISTANT_MCP_LIVE_LIMIT),
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
},
{
call_id: "find_rbp_object_movements_account_97",
purpose: "collect_rbp_object_movements",
query: buildLiveRangeQuery(primaryFrom, primaryTo, config_1.ASSISTANT_MCP_LIVE_LIMIT),
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["97"]
},
{
call_id: "find_month_close_entries_linked_to_rbp",
purpose: "link_month_close_to_rbp",
query: buildLiveRangeQuery(primaryFrom, primaryTo, config_1.ASSISTANT_MCP_LIVE_LIMIT),
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
},
{
call_id: "compute_end_period_residual_by_rbp_object",
purpose: "collect_residual_tail_signals",
query: buildLiveRangeQuery(carryFrom, carryTo, config_1.ASSISTANT_MCP_LIVE_LIMIT),
query: buildLiveRangeQuery(carryFrom, carryTo, CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT),
limit: CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
}
@ -1205,11 +1327,26 @@ function inferPeriodScope(fragmentText) {
}
const WRONG_DOCUMENT_MARKERS = /(?:\u043d\u0435\s+\u0442\u0435\u043c\s+\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442(?:\u043e\u043c)?|\u043d\u0435\s+\u0432\s+\u0442\u043e\u0442|wrong\s+document|wrong_document_type)/iu;
const REPEATED_ANOMALY_MARKERS = /(?:\u043f\u043e\u0432\u0442\u043e\u0440\u044f\u044e\u0449|\u0441\u0435\u0440\u0438\u0439\u043d|\u043f\u0430\u0442\u0442\u0435\u0440\u043d|repeat(?:ed|ability)?)/iu;
function inferQuerySubject(text, domains, anomalies) {
function inferQuerySubject(text, domains, anomalies, preferredDomainHint) {
if (preferredDomainHint === "vat_document_register_book") {
return "vat_chain_conflict";
}
if (preferredDomainHint === "fixed_asset_amortization") {
return "fixed_asset_card_mismatch";
}
if (preferredDomainHint === "month_close_costs_20_44") {
return "period_closure_risk";
}
if (preferredDomainHint === "settlements_60_62") {
return "supplier_tail_analysis";
}
const lower = text.toLowerCase();
if ((domains.includes("bank") || domains.includes("settlements")) && WRONG_DOCUMENT_MARKERS.test(lower)) {
return "bank_settlement_mismatch";
}
if (domains.includes("vat")) {
return "vat_chain_conflict";
}
if (domains.includes("suppliers")) {
return "supplier_tail_analysis";
}
@ -1222,9 +1359,6 @@ function inferQuerySubject(text, domains, anomalies) {
if (domains.includes("fixed_assets")) {
return "fixed_asset_card_mismatch";
}
if (domains.includes("vat")) {
return "vat_chain_conflict";
}
if (domains.includes("period_close")) {
return "period_closure_risk";
}
@ -1356,8 +1490,9 @@ function buildSemanticRetrievalProfile(fragmentText) {
relationPatterns: dedupedRelations,
anomalyPatterns: dedupedAnomalies
});
const preferredDomainHint = (0, investigationState_1.inferP0DomainFromMessage)(fragmentText);
return {
query_subject: inferQuerySubject(lower, dedupedDomains, dedupedAnomalies),
query_subject: inferQuerySubject(lower, dedupedDomains, dedupedAnomalies, preferredDomainHint),
account_scope: dedupedAccounts,
subaccount_scope: [],
domain_scope: dedupedDomains,
@ -2170,24 +2305,29 @@ class AssistantDataLayer {
const endpoint = this.buildMcpUrl("/api/execute_query");
const livePlan = buildLiveMcpCallPlan(route, fragmentText);
const explicitAccountScope = extractAccountScopeFromText(fragmentText);
const accountScope = explicitAccountScope.length > 0
? explicitAccountScope
: livePlan.claim_type === "prove_rbp_tail_state"
? ["97", "20", "25", "26", "44"]
: [];
const accountScope = livePlan.claim_type === "prove_fixed_asset_amortization_coverage"
? ["01", "02", "08"]
: livePlan.claim_type === "prove_vat_chain_completeness"
? ["19", "68"]
: livePlan.claim_type === "prove_rbp_tail_state"
? ["97", "20", "25", "26", "44"]
: explicitAccountScope.length > 0
? explicitAccountScope
: [];
const callExecutions = [];
const collectedRows = [];
const errors = [];
let fetchedRowsTotal = 0;
let matchedRowsTotal = 0;
for (const call of livePlan.calls) {
const callLimit = resolveLiveCallLimit(call.limit);
const callAccountScope = Array.isArray(call.account_scope_override) && call.account_scope_override.length > 0
? call.account_scope_override
: accountScope;
try {
const payload = await this.fetchJsonWithTimeout(endpoint, {
query: call.query,
limit: config_1.ASSISTANT_MCP_LIVE_LIMIT
limit: callLimit
});
const parsed = this.parseExecuteQueryPayload(payload);
if (parsed.error) {
@ -2195,6 +2335,7 @@ class AssistantDataLayer {
callExecutions.push({
call_id: call.call_id,
purpose: call.purpose,
requested_limit: callLimit,
required_for_claim: call.required_for_claim,
status: "error",
fetched_rows: 0,
@ -2221,6 +2362,7 @@ class AssistantDataLayer {
callExecutions.push({
call_id: call.call_id,
purpose: call.purpose,
requested_limit: callLimit,
required_for_claim: call.required_for_claim,
status: rowsForAnswer.length > 0 ? "ok" : "empty",
fetched_rows: parsed.rows.length,
@ -2235,6 +2377,7 @@ class AssistantDataLayer {
callExecutions.push({
call_id: call.call_id,
purpose: call.purpose,
requested_limit: callLimit,
required_for_claim: call.required_for_claim,
status: "error",
fetched_rows: 0,
@ -2258,7 +2401,11 @@ class AssistantDataLayer {
lifecycle_markers: item.lifecycle_markers,
live_call_id: item.live_call_id,
live_call_purpose: item.live_call_purpose,
claim_type: item.claim_type
claim_type: item.claim_type,
fa_object_hint: item.fa_object_hint,
fa_expected_set_candidate: item.fa_expected_set_candidate,
fa_actual_set_candidate: item.fa_actual_set_candidate,
fa_coverage_status: item.fa_coverage_status
}));
const executedRequiredCalls = callExecutions
.filter((item) => item.required_for_claim && item.status !== "error")
@ -2303,7 +2450,13 @@ class AssistantDataLayer {
route,
channel: config_1.ASSISTANT_MCP_CHANNEL,
proxy: config_1.ASSISTANT_MCP_PROXY_URL,
source_profile: livePlan.claim_type ? "claim_bound_rbp_live_path" : "generic_live_probe",
source_profile: livePlan.claim_type === "prove_rbp_tail_state"
? "claim_bound_rbp_live_path"
: livePlan.claim_type === "prove_fixed_asset_amortization_coverage"
? "claim_bound_fa_live_path"
: livePlan.claim_type === "prove_vat_chain_completeness"
? "claim_bound_vat_live_path"
: "generic_live_probe",
claim_type: livePlan.claim_type,
query_subject: livePlan.query_subject,
account_scope: accountScope,
@ -2484,28 +2637,53 @@ class AssistantDataLayer {
const querySubject = valueAsString(row.__query_subject ?? "").trim() || null;
const registratorLower = registrator.toLowerCase();
const hasRbpByDocument = /(?:рбп|deferred|списани[ея]\s+рбп)/i.test(registratorLower);
const hasFaByDocument = /(?:амортиз|depreciat|основн(?:ые|ых)\s+сред|fixed\s*asset)/i.test(registratorLower);
const hasAccount97 = accountContext.some((item) => /^97(?:\.|$)/.test(item));
const hasFixedAssetAccount = accountContext.some((item) => /^(?:01|02|08)(?:\.|$)/.test(item));
const hasCloseDoc = /(?:закрыти[ея]\s+месяц|period\s*close|month\s*close|close\s+operation)/i.test(registratorLower) ||
callId.includes("month_close");
const faExpectedSetCandidate = callId === "find_fixed_asset_cards_expected_for_period" || callPurpose === "build_expected_fa_set";
const faActualSetCandidate = callId === "find_amortization_documents_in_period" ||
callId === "find_fixed_asset_movements_accounts_01_02" ||
callPurpose === "seed_amortization_documents" ||
callPurpose === "collect_fa_object_movements";
const faCoverageStatus = callId === "match_expected_vs_actual_fa_coverage"
? "expected_vs_actual_compare"
: faExpectedSetCandidate && faActualSetCandidate
? "covered"
: faExpectedSetCandidate
? "expected_only"
: faActualSetCandidate
? "actual_only"
: null;
const faObjectHint = (registrator || "").trim() ||
`${debit || "n/a"}|${credit || "n/a"}|${amount !== null ? amount : "n/a"}`;
const relationPatternHits = uniqueStrings([
"document_to_posting",
hasRbpByDocument || hasAccount97 ? "deferred_expense_to_writeoff" : "",
hasFaByDocument || hasFixedAssetAccount ? "asset_card_to_depreciation" : "",
faCoverageStatus === "expected_vs_actual_compare" ? "expected_vs_actual_coverage_compare" : "",
hasCloseDoc ? "close_operation" : "",
callId.includes("residual") ? "residuals_zero_or_explained" : ""
]);
const documentContext = uniqueStrings([
hasRbpByDocument || hasAccount97 ? "deferred_expense_document" : "",
hasFaByDocument || hasFixedAssetAccount ? "depreciation_document" : "",
hasCloseDoc ? "period_close_document" : "",
"posting"
]);
const graphDomainScope = uniqueStrings([
hasRbpByDocument || hasAccount97 ? "deferred_expense" : "",
hasFaByDocument || hasFixedAssetAccount ? "fixed_asset" : "",
hasCloseDoc ? "period_close" : ""
]);
const lifecycleMarkers = uniqueStrings([
callId.includes("residual") ? "period_boundary" : "",
callId.includes("residual") ? "tail_state_observed" : "",
hasCloseDoc ? "close_operation" : ""
hasCloseDoc ? "close_operation" : "",
hasFaByDocument || hasFixedAssetAccount ? "amortization_accrual" : "",
faExpectedSetCandidate ? "expected_set_seed" : "",
faCoverageStatus === "expected_vs_actual_compare" ? "coverage_compare" : ""
]);
return {
source_entity: "MCPLiveMovement",
@ -2525,6 +2703,10 @@ class AssistantDataLayer {
claim_type: claimType,
query_subject: querySubject,
amount,
fa_object_hint: faObjectHint,
fa_expected_set_candidate: faExpectedSetCandidate,
fa_actual_set_candidate: faActualSetCandidate,
fa_coverage_status: faCoverageStatus,
source_layer: "mcp_live_probe",
route
};

View File

@ -146,16 +146,41 @@ function collectPercentLikeSpans(text) {
}
return spans;
}
function collectContractLikeSpans(text) {
const spans = [];
const patterns = [
/(?:\b(?:договор(?:а|у|ом|е)?|contract)\b[^\r\n]{0,24}(?:№|#|n|no\.?)\s*[a-zа-я0-9][a-zа-я0-9/_-]{1,})/giu,
/(?:№|#)\s*[a-zа-я0-9_-]{1,10}\/[a-zа-я0-9_-]{1,12}/giu,
/\b\d{2}\/\d{2}(?:-[a-zа-я]{1,10})?\b/giu
];
for (const pattern of patterns) {
let match = null;
while ((match = pattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
}
return spans;
}
function intersectsSpan(start, end, spans) {
return spans.some((span) => start < span.end && end > span.start);
}
function hasAccountContextAround(text, start, end) {
const left = text.slice(Math.max(0, start - 28), start);
const right = text.slice(end, Math.min(text.length, end + 28));
return /(?:счет|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(`${left} ${right}`);
}
function extractAccountsFromTextDetailed(text, options) {
const lower = String(text ?? "").toLowerCase();
const accounts = new Set();
const dateSpans = collectDateLikeSpans(lower);
const amountSpans = collectAmountLikeSpans(lower);
const percentSpans = collectPercentLikeSpans(lower);
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans];
const contractSpans = collectContractLikeSpans(lower);
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans];
const hasAccountingLexeme = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test(lower);
const contextualPattern = /(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b\s*(?:№|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu;
let contextualMatch = null;
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
@ -218,6 +243,14 @@ function extractAccountsFromTextDetailed(text, options) {
rejectedAsNonAccounts.add(token);
continue;
}
if (intersectsSpan(start, end, contractSpans)) {
classifiedNumericTokens.push({
token,
classification: "other_numeric"
});
rejectedAsNonAccounts.add(token);
continue;
}
if (!prefix || !KNOWN_ACCOUNT_PREFIXES.has(prefix)) {
classifiedNumericTokens.push({
token,
@ -226,6 +259,14 @@ function extractAccountsFromTextDetailed(text, options) {
rejectedAsNonAccounts.add(token);
continue;
}
if (!hasAccountingLexeme || !hasAccountContextAround(lower, start, end)) {
classifiedNumericTokens.push({
token,
classification: "other_numeric"
});
rejectedAsNonAccounts.add(token);
continue;
}
accounts.add(token);
classifiedNumericTokens.push({
token,
@ -448,7 +489,7 @@ function resolveJulyAnchor(rawText) {
const raw = String(rawText ?? "");
const lower = raw.toLowerCase();
const explicitYear = lower.match(/\b(20\d{2})\b/)?.[1] ?? null;
const dayByNamedJuly = lower.match(/(?:^|\D)(0?[1-9]|[12]\d|3[01])\s+(?:июл(?:я|ь)?|july|РёСЋР»(?:СЏ|СЊ)?)(?:\D|$)/i);
const dayByNamedJuly = lower.match(/(?:^|\D)(0?[1-9]|[12]\d|3[01])\s+(?:июл(?:я|ь)?|july)(?:\D|$)/i);
const dayByNumeric = lower.match(/\b(0?[1-9]|[12]\d|3[01])[./-](0?7)(?:[./-](\d{2}|\d{4}))?\b/);
const monthByNamed = /(?:июл|july|РёСЋР»)/i.test(lower);
const monthByNumeric = /\b20\d{2}[-/.]0?7\b/.test(lower);
@ -866,6 +907,9 @@ function isMonthClosePrefix(prefix) {
}
return numeric >= 20 && numeric <= 44;
}
function isFixedAssetPrefix(prefix) {
return prefix === "01" || prefix === "02" || prefix === "08";
}
function expectedAccountPrefixes(input) {
const explicit = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...extractAccountsFromText(input.userMessage)])
.map((item) => accountPrefix(item))
@ -879,6 +923,9 @@ function expectedAccountPrefixes(input) {
if (input.focusDomainHint === "month_close_costs_20_44") {
return ["20", "25", "26", "44", "97", "01", "02", "08"];
}
if (input.focusDomainHint === "fixed_asset_amortization") {
return ["01", "02", "08"];
}
if (input.focusDomainHint === "settlements_60_62") {
if (input.polarity === "supplier_payable") {
return ["60", "51", "76"];
@ -932,6 +979,13 @@ function hasWrongDomainByAccounts(accounts, focusDomainHint) {
if (focusDomainHint === "month_close_costs_20_44") {
return prefixes.every((prefix) => isSettlementPrefix(prefix) || isVatPrefix(prefix));
}
if (focusDomainHint === "fixed_asset_amortization") {
const hasFixedAsset = prefixes.some((prefix) => isFixedAssetPrefix(prefix));
if (hasFixedAsset) {
return false;
}
return prefixes.every((prefix) => isSettlementPrefix(prefix) || isVatPrefix(prefix) || isMonthClosePrefix(prefix));
}
return false;
}
function extractLiveMatchedRows(result) {

View File

@ -231,7 +231,8 @@ function hasP0ClaimSignal(claimType, focusDomainHint) {
claim === "prove_advance_offset_state" ||
claim === "prove_vat_chain_completeness" ||
claim === "prove_month_close_state" ||
claim === "prove_rbp_tail_state") {
claim === "prove_rbp_tail_state" ||
claim === "prove_fixed_asset_amortization_coverage") {
return true;
}
return (focusDomainHint === "settlements_60_62" ||
@ -369,6 +370,24 @@ function collectDateSpans(text) {
}
return spans;
}
function collectContractSpans(text) {
const spans = [];
const contractPatterns = [
/(?:\b(?:договор(?:а|у|ом|е)?|contract)\b[^\r\n]{0,24}(?:№|#|n|no\.?)\s*[a-zа-я0-9][a-zа-я0-9/_-]{1,})/giu,
/(?:№|#)\s*[a-zа-я0-9_-]{1,10}\/[a-zа-я0-9_-]{1,12}/giu,
/\b\d{2}\/\d{2}(?:-[a-zа-я]{1,10})?\b/giu
];
for (const contractPattern of contractPatterns) {
let match = null;
while ((match = contractPattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
}
return spans;
}
function collectAmountSpans(text) {
const spans = [];
const amountPatterns = [/\b\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?\b/g, /\b\d+[.,]\d{2}\b/g];
@ -398,6 +417,11 @@ function collectPercentSpans(text) {
function intersectsAnySpan(start, end, spans) {
return spans.some((span) => start < span.end && end > span.start);
}
function hasAccountContextAround(text, start, end) {
const left = text.slice(Math.max(0, start - 28), start);
const right = text.slice(end, Math.min(text.length, end + 28));
return /(?:счет|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(`${left} ${right}`);
}
function extractAccountTokens(text) {
const lower = String(text ?? "").toLowerCase();
const explicitAccounts = new Set();
@ -470,7 +494,8 @@ function extractAccountTokens(text) {
if (explicitAccounts.size > 0) {
return Array.from(explicitAccounts);
}
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower)];
const contractSpans = collectContractSpans(lower);
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower), ...contractSpans];
const hasAccountingLexeme = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расчет|аванс|долг|settlement|payment)/iu.test(lower);
if (!hasAccountingLexeme) {
return [];
@ -485,6 +510,9 @@ function extractAccountTokens(text) {
if (intersectsAnySpan(start, end, spans)) {
continue;
}
if (!hasAccountContextAround(lower, start, end)) {
continue;
}
const prefix = value.match(/^(\d{2})/)?.[1];
if (!prefix || !knownAccountPrefixes.has(prefix)) {
continue;
@ -773,6 +801,164 @@ function collectRbpLiveRouteAudit(input) {
plan_override: input.planAudit ?? null
};
}
function enrichFaFragmentForLive(fragmentText, temporalGuard) {
const base = compactWhitespace(String(fragmentText ?? ""));
const hints = [
"Начисление амортизации",
"объект ОС",
"expected set ОС",
"счет 01/02"
];
const effective = temporalGuard && typeof temporalGuard === "object" ? temporalGuard.effective_primary_period : null;
if (effective && effective.from && effective.to) {
hints.push(`период ${effective.from}..${effective.to}`);
}
const hintText = hints.filter(Boolean).join(", ");
if (!base) {
return hintText;
}
if (/амортиз|основн(?:ые|ых)\s+сред|fixed\s*asset|depreciat|счет\s*0[12]|account\s*0[12]/i.test(base)) {
return base;
}
return `${base}; ${hintText}`;
}
function enforceFaLiveRoutePlan(input) {
if (input.claimType !== "prove_fixed_asset_amortization_coverage") {
return {
executionPlan: input.executionPlan,
audit: null
};
}
const requiredLiveCalls = [
"find_amortization_documents_in_period",
"find_fixed_asset_movements_accounts_01_02",
"find_fixed_asset_cards_expected_for_period",
"match_expected_vs_actual_fa_coverage"
];
let routeAdjusted = 0;
let rescuedNoRoute = 0;
const replacedRoutes = [];
const adjustedPlan = input.executionPlan.map((item) => {
if (!item || typeof item !== "object") {
return item;
}
if (item.should_execute !== true && item.no_route_reason === "insufficient_specificity") {
rescuedNoRoute += 1;
routeAdjusted += 1;
return {
...item,
route: "live_mcp_drilldown",
should_execute: true,
no_route_reason: null,
clarification_reason: null,
fragment_text: enrichFaFragmentForLive(item.fragment_text, input.temporalGuard)
};
}
if (item.should_execute === true && item.route !== "hybrid_store_plus_live" && item.route !== "live_mcp_drilldown") {
routeAdjusted += 1;
if (item.route && item.route !== "no_route") {
replacedRoutes.push(String(item.route));
}
return {
...item,
route: "hybrid_store_plus_live",
fragment_text: enrichFaFragmentForLive(item.fragment_text, input.temporalGuard)
};
}
if (item.should_execute === true) {
return {
...item,
fragment_text: enrichFaFragmentForLive(item.fragment_text, input.temporalGuard)
};
}
return item;
});
return {
executionPlan: adjustedPlan,
audit: {
claim_type: "prove_fixed_asset_amortization_coverage",
required_live_calls: requiredLiveCalls,
route_adjustments_applied: routeAdjusted,
rescued_no_route_fragments: rescuedNoRoute,
replaced_routes: Array.from(new Set(replacedRoutes)),
route_gap_reason: routeAdjusted > 0 ? "fa_claim_bound_live_route_override_applied" : null
}
};
}
function collectFaLiveRouteAudit(input) {
if (input.claimType !== "prove_fixed_asset_amortization_coverage") {
return null;
}
const required = new Set(Array.isArray(input.planAudit?.required_live_calls) ? input.planAudit.required_live_calls : []);
const executed = [];
const missing = new Set();
const routeGaps = [];
let matchedRowsTotal = 0;
let returnedRowsTotal = 0;
let fetchedRowsTotal = 0;
for (const result of input.retrievalResults) {
if (!result || typeof result !== "object") {
continue;
}
const summary = result.summary && typeof result.summary === "object" ? result.summary : null;
const live = summary && typeof summary.live_mcp === "object" && summary.live_mcp ? summary.live_mcp : null;
if (!live) {
continue;
}
const requiredCalls = Array.isArray(live.required_live_calls) ? live.required_live_calls : [];
for (const callId of requiredCalls) {
required.add(String(callId ?? "").trim());
}
const executedCalls = Array.isArray(live.executed_live_calls) ? live.executed_live_calls : [];
for (const call of executedCalls) {
if (!call || typeof call !== "object") {
continue;
}
executed.push(call);
}
const missingCalls = Array.isArray(live.missing_live_calls) ? live.missing_live_calls : [];
for (const callId of missingCalls) {
const token = String(callId ?? "").trim();
if (token) {
missing.add(token);
}
}
const routeGapReason = String(live.route_gap_reason ?? "").trim();
if (routeGapReason) {
routeGaps.push(routeGapReason);
}
fetchedRowsTotal += Number(live.fetched_rows ?? 0) || 0;
matchedRowsTotal += Number(live.matched_rows ?? 0) || 0;
returnedRowsTotal += Number(live.returned_rows ?? 0) || 0;
}
const requiredList = Array.from(required).filter(Boolean);
const executedList = executed;
const missingFromExecuted = requiredList.filter((callId) => !executedList.some((item) => String(item.call_id ?? "") === callId));
for (const callId of missingFromExecuted) {
missing.add(callId);
}
const missingList = Array.from(missing);
const routeGapReason = missingList.length > 0
? "required_live_calls_not_executed"
: matchedRowsTotal <= 0
? "claim_live_calls_executed_but_zero_matches"
: routeGaps[0] ?? null;
const executionRate = requiredList.length > 0
? Number(((requiredList.length - missingList.length) / requiredList.length).toFixed(4))
: 1;
return {
claim_type: "prove_fixed_asset_amortization_coverage",
required_live_calls: requiredList,
executed_live_calls: executedList,
missing_live_calls: missingList,
route_gap_reason: routeGapReason,
live_route_execution_rate: executionRate,
fetched_rows_total: fetchedRowsTotal,
matched_rows_total: matchedRowsTotal,
returned_rows_total: returnedRowsTotal,
plan_override: input.planAudit ?? null
};
}
function toDebugRoutes(routeSummary) {
if (!routeSummary) {
return [];
@ -1239,7 +1425,7 @@ function extractNormalizedPeriodLiteral(text) {
}
function extractFollowupAccountAnchorsLoose(text) {
const lower = String(text ?? "").toLowerCase();
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower)];
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower), ...collectContractSpans(lower)];
const anchors = [];
const followupAccountPattern = /\b(?:01|02|08|19|20|21|23|25|26|28|29|44|51|60|62|68|76|97)(?:\.\d{2})?\b/g;
let match = null;
@ -1292,27 +1478,8 @@ function hasCrossScopeConflictWithState(userMessage, state) {
return false;
}
function inferP0DomainFromMessage(text) {
const lower = String(text ?? "").toLowerCase();
const accountTokens = extractAccountTokens(lower);
const hasVatAccount = accountTokens.some((token) => /^(?:19|68)(?:\.|$)/.test(token));
const hasSettlementAccount = accountTokens.some((token) => /^(?:51|60|62|76)(?:\.|$)/.test(token));
const hasMonthCloseAccount = accountTokens.some((token) => /^(?:97|2\d|3\d|4[0-4])(?:\.|$)/.test(token));
const hasFixedAssetAccount = accountTokens.some((token) => /^(?:01|02|08)(?:\.|$)/.test(token));
const vatLexical = /(?:ндс|vat|сч[её]т[\s-]?фактур|книг[аи]\s+(?:покуп|продаж)|налогов)/i.test(lower);
const settlementLexical = /(?:долг|аванс|зач[её]т|взаимозач|расч[её]т|оплат|платеж|платёж|постав|покупател)/i.test(lower);
const monthCloseLexical = /(?:закрыти[ея]\s+месяц|закрытие\s+счетов|регламентн|косвенн|затрат|распределени|рбп|финансовых\s+результат)/i.test(lower);
const fixedAssetLexical = /(?:основн(?:ые|ых)?\s+сред|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|амортиз|depreciat|fixed\s*asset)/i.test(lower);
if (hasVatAccount || vatLexical) {
return "vat_document_register_book";
}
if (fixedAssetLexical || hasFixedAssetAccount) {
return "fixed_asset_amortization";
}
if (monthCloseLexical || hasMonthCloseAccount) {
return "month_close_costs_20_44";
}
if (hasSettlementAccount || settlementLexical) {
return "settlements_60_62";
if (typeof investigationState_1.inferP0DomainFromMessage === "function") {
return investigationState_1.inferP0DomainFromMessage(text);
}
return null;
}
@ -1592,13 +1759,12 @@ class AssistantService {
routeSummary: normalized.route_hint_summary
});
const inferredDomainByMessage = inferP0DomainFromMessage(userMessage);
const focusDomainForGuards = inferredDomainByMessage === "fixed_asset_amortization"
? "month_close_costs_20_44"
: inferredDomainByMessage === "settlements_60_62" ||
inferredDomainByMessage === "vat_document_register_book" ||
inferredDomainByMessage === "month_close_costs_20_44"
? inferredDomainByMessage
: null;
const focusDomainForGuards = inferredDomainByMessage === "settlements_60_62" ||
inferredDomainByMessage === "vat_document_register_book" ||
inferredDomainByMessage === "month_close_costs_20_44" ||
inferredDomainByMessage === "fixed_asset_amortization"
? inferredDomainByMessage
: null;
const temporalGuard = (0, assistantRuntimeGuards_1.resolveTemporalGuard)({
userMessage,
normalized: normalized.normalized,
@ -1630,6 +1796,12 @@ class AssistantService {
temporalGuard
});
executionPlan = rbpRoutePlanEnforcement.executionPlan;
const faRoutePlanEnforcement = enforceFaLiveRoutePlan({
executionPlan,
claimType: claimAnchorAudit.claim_type,
temporalGuard
});
executionPlan = faRoutePlanEnforcement.executionPlan;
executionPlan = (0, assistantRuntimeGuards_1.applyTemporalHintToExecutionPlan)(executionPlan, temporalGuard);
executionPlan = (0, assistantRuntimeGuards_1.applyPolarityHintToExecutionPlan)(executionPlan, domainPolarityGuardInitial);
const retrievalCalls = [];
@ -1717,6 +1889,11 @@ class AssistantService {
retrievalResults,
planAudit: rbpRoutePlanEnforcement.audit
});
const faLiveRouteAudit = collectFaLiveRouteAudit({
claimType: claimAnchorAudit.claim_type,
retrievalResults,
planAudit: faRoutePlanEnforcement.audit
});
const coverageEvaluation = evaluateCoverage(requirementExtraction.requirements, retrievalResults);
const groundingCheckBase = checkGrounding(userMessage, coverageEvaluation.requirements, coverageEvaluation.coverage, retrievalResults);
const groundedAnswerEligibilityGuard = (0, assistantRuntimeGuards_1.evaluateGroundedAnswerEligibility)({
@ -1830,6 +2007,7 @@ class AssistantService {
targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit,
...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}),
...(faLiveRouteAudit ? { fa_live_route_audit: faLiveRouteAudit } : {}),
eligibility_time_basis: groundedAnswerEligibilityGuard.eligibility_time_basis,
grounded_answer_eligibility_guard: groundedAnswerEligibilityGuard,
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
@ -1922,6 +2100,8 @@ class AssistantService {
claim_anchor_audit: claimAnchorAudit,
targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit,
...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}),
...(faLiveRouteAudit ? { fa_live_route_audit: faLiveRouteAudit } : {}),
eligibility_time_basis: groundedAnswerEligibilityGuard.eligibility_time_basis,
grounded_answer_eligibility_guard: groundedAnswerEligibilityGuard,
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),

View File

@ -1,5 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.inferP0DomainFromMessage = inferP0DomainFromMessage;
exports.cloneInvestigationState = cloneInvestigationState;
exports.createEmptyInvestigationState = createEmptyInvestigationState;
exports.updateInvestigationState = updateInvestigationState;
@ -70,6 +71,24 @@ function collectDateLikeSpans(text) {
}
return spans;
}
function collectContractLikeSpans(text) {
const spans = [];
const patterns = [
/(?:\b(?:договор(?:а|у|ом|е)?|contract)\b[^\r\n]{0,24}(?:№|#|n|no\.?)\s*[a-zа-я0-9][a-zа-я0-9/_-]{1,})/giu,
/(?:№|#)\s*[a-zа-я0-9_-]{1,10}\/[a-zа-я0-9_-]{1,12}/giu,
/\b\d{2}\/\d{2}(?:-[a-zа-я]{1,10})?\b/giu
];
for (const pattern of patterns) {
let match = null;
while ((match = pattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
}
return spans;
}
function collectAmountLikeSpans(text) {
const spans = [];
const patterns = [/\b\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?\b/g, /\b\d+[.,]\d{2}\b/g];
@ -106,7 +125,12 @@ function hasAccountContextAround(text, start, end) {
}
function detectAccounts(text) {
const lower = String(text ?? "").toLowerCase();
const blockedSpans = [...collectDateLikeSpans(lower), ...collectAmountLikeSpans(lower), ...collectPercentLikeSpans(lower)];
const blockedSpans = [
...collectDateLikeSpans(lower),
...collectAmountLikeSpans(lower),
...collectPercentLikeSpans(lower),
...collectContractLikeSpans(lower)
];
const hasAccountingLexeme = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расчет|расч[её]т|аванс|долг|settlement|payment|supplier|customer|ндс|vat|рбп|deferred|амортиз)/iu.test(lower);
const accounts = new Set();
const contextualPattern = /(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b)\s*(?:№|#|:)?\s*(\d{2}(?:\.\d{1,2})?)/giu;
@ -161,29 +185,38 @@ function detectPeriod(text) {
return yearly[1];
return null;
}
function detectExplicitDomainHint(text) {
function inferP0DomainFromMessage(text) {
const messageCorpus = String(text ?? "").toLowerCase();
const accounts = detectAccounts(text);
const hasSettlementSignal = accounts.some((item) => isSettlementAccount(item)) ||
/(?:60(?:\.\d{2})?|62(?:\.\d{2})?|оплат|расч[её]т|зач[её]т|аванс|долг|поставщ|покупат|settlement|payment|supplier|customer)/i.test(messageCorpus);
if (hasSettlementSignal) {
const hasSettlementAccount = accounts.some((item) => isSettlementAccount(item));
const hasVatAccount = accounts.some((item) => isVatAccount(item));
const hasCloseAccount = accounts.some((item) => isCloseCostsAccount(item));
const hasFixedAssetAccount = accounts.some((item) => isFixedAssetAccount(item));
const hasSettlementLexical = /(?:60(?:\.\d{2})?|62(?:\.\d{2})?|оплат|расч[её]т|зач[её]т|аванс|долг|поставщ|покупат|settlement|payment|supplier|customer)/i.test(messageCorpus);
const hasVatLexical = /(?:ндс|сч[её]т[\s-]?фактур|книг[аи]|vat|invoice|book|register)/i.test(messageCorpus);
const hasCloseLexical = /(?:закрыти|месяц|затрат|распредел|списан|period\s*close|month\s*close|allocation|residual|cost|рбп)/i.test(messageCorpus);
const hasExplicitFixedAssetLexical = /(?:амортиз|основн(ые|ых|ым)?\s+средств|объект[а-яё]*\s+ос|fixed\s*asset|depreciat|сч[её]т(?:а|у|ом|е)?\s*(?:01|02|08)|account\s*0[128])/i.test(messageCorpus);
const hasBroadMonthCloseLexical = /(?:после\s+закрытия|косвенн|период(?:а)?\s+закрыт|month\s*close|period\s*close|регламентн)/i.test(messageCorpus);
// Keep settlement lane stable when 60/62 lexical/account anchors are explicit
// and there is no explicit VAT intent.
if ((hasSettlementAccount || hasSettlementLexical) && !hasVatLexical && !hasVatAccount && !hasExplicitFixedAssetLexical) {
return "settlements_60_62";
}
const hasVatSignal = accounts.some((item) => isVatAccount(item)) ||
/(?:ндс|сч[её]т[\s-]?фактур|книг[аи]|vat|invoice|book|register)/i.test(messageCorpus);
if (hasVatSignal) {
return "vat_document_register_book";
}
const hasCloseSignal = accounts.some((item) => isCloseCostsAccount(item)) ||
/(?:закрыти|месяц|затрат|распредел|списан|period\s*close|month\s*close|allocation|residual|cost|рбп)/i.test(messageCorpus);
if (hasCloseSignal) {
if ((hasCloseAccount || hasCloseLexical || hasBroadMonthCloseLexical) && !hasVatLexical && !hasVatAccount && !hasExplicitFixedAssetLexical && !hasFixedAssetAccount) {
return "month_close_costs_20_44";
}
const hasFixedAssetSignal = accounts.some((item) => isFixedAssetAccount(item)) ||
/(?:амортиз|основн(ые|ых|ым)?\s+средств|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|объект[а-яё]*\s+ос|fixed\s*asset|depreciat)/i.test(messageCorpus);
if (hasFixedAssetSignal) {
if (hasVatAccount || hasVatLexical) {
return "vat_document_register_book";
}
if (hasFixedAssetAccount || hasExplicitFixedAssetLexical) {
return "fixed_asset_amortization";
}
if (hasCloseAccount || hasCloseLexical) {
return "month_close_costs_20_44";
}
if (hasSettlementAccount || hasSettlementLexical) {
return "settlements_60_62";
}
return null;
}
function buildQuestionScopeId(input) {
@ -208,7 +241,7 @@ function deriveScopeOrigin(input) {
}
const hasExplicitPeriod = Boolean(detectPeriod(input.userMessage));
const hasExplicitAccounts = detectAccounts(input.userMessage).length > 0;
const explicitDomain = detectExplicitDomainHint(input.userMessage);
const explicitDomain = inferP0DomainFromMessage(input.userMessage);
if (hasExplicitPeriod || hasExplicitAccounts || explicitDomain) {
return "explicit_from_message";
}
@ -303,9 +336,17 @@ function inferFollowupActiveDomain(input) {
const contextualCorpus = input.allowStateCarryover
? `${messageCorpus} ${input.previous.focus.active_query_subject ?? ""}`.toLowerCase()
: messageCorpus;
const hasFixedAssetLexicalSignal = /(?:амортиз|основн(ые|ых|ым)?\s+средств|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|объект[а-яё]*\s+ос|fixed\s*asset|depreciat)/i.test(messageCorpus);
const hasFixedAssetLexicalSignal = /(?:амортиз|основн(ые|ых|ым)?\s+средств|объект[а-яё]*\s+ос|fixed\s*asset|depreciat|сч[её]т(?:а|у|ом|е)?\s*(?:01|02|08)|account\s*0[128])/i.test(messageCorpus);
const hasFixedAssetAccountSignal = input.focusAccounts.some((item) => isFixedAssetAccount(item)) &&
/(?:сч[её]т(?:а|у|ом|е)?\s*(?:01|02|08)|(?:01|02|08)(?:\.\d{2})?\s*\/\s*(?:01|02|08)(?:\.\d{2})?|\b0[128](?:\.\d{2})?\b)/i.test(messageCorpus);
const hasBroadMonthCloseSignal = /(?:после\s+закрытия|косвенн|период(?:а)?\s+закрыт|регламентн|month\s*close|period\s*close)/i.test(messageCorpus);
if ((input.focusAccounts.some((item) => isCloseCostsAccount(item)) ||
/(?:закрыти|месяц|затрат|распредел|списан|period\s*close|month\s*close|allocation|residual|cost)/i.test(messageCorpus) ||
hasBroadMonthCloseSignal) &&
!hasFixedAssetLexicalSignal &&
!hasFixedAssetAccountSignal) {
return "month_close_costs_20_44";
}
if (hasFixedAssetLexicalSignal || hasFixedAssetAccountSignal) {
return "fixed_asset_amortization";
}

View File

@ -0,0 +1,393 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readJson(filePath) {
const raw = fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(raw);
}
function writeJson(filePath, payload) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function writeText(filePath, text) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, text, "utf8");
}
function asArray(value) {
return Array.isArray(value) ? value : [];
}
function asObject(value) {
return value && typeof value === "object" ? value : {};
}
function asNumber(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function pickFirstRow(payload) {
const rows = asArray(payload?.rows);
if (rows.length === 0) {
throw new Error("Input raw payload has no rows.");
}
return rows[0];
}
function collectLiveCallInventory(debug) {
const faRouteAudit = asObject(debug?.fa_live_route_audit);
const routeCalls = asArray(faRouteAudit?.executed_live_calls).map((item) => ({
source: "fa_live_route_audit",
call_id: item?.call_id ?? null,
purpose: item?.purpose ?? null,
required_for_claim: Boolean(item?.required_for_claim),
status: item?.status ?? null,
fetched_rows: asNumber(item?.fetched_rows),
matched_rows: asNumber(item?.matched_rows),
returned_rows: asNumber(item?.returned_rows),
error: item?.error ?? null
}));
const summaryCalls = [];
for (const result of asArray(debug?.retrieval_results)) {
const live = asObject(asObject(result?.summary)?.live_mcp);
if (!live || Object.keys(live).length === 0) {
continue;
}
summaryCalls.push({
source: "retrieval_results.summary.live_mcp",
fragment_id: result?.fragment_id ?? null,
route: result?.route ?? null,
method: live?.method ?? null,
status: live?.status ?? null,
fetched_rows: asNumber(live?.fetched_rows),
matched_rows: asNumber(live?.matched_rows),
returned_rows: asNumber(live?.returned_rows),
account_scope: asArray(live?.account_scope),
source_profile: live?.source_profile ?? null,
args_summary: asObject(live?.args)
});
}
return {
route_calls: routeCalls,
summary_calls: summaryCalls,
total_calls: routeCalls.length + summaryCalls.length
};
}
function formatList(items) {
if (!items.length) {
return "- none";
}
return items.map((item) => `- ${String(item)}`).join("\n");
}
function mapRequiredEntities() {
return {
seed_entities: [
"amortization_document_for_2020_07_31",
"amount_markers_2471_52_2465_28_849_83",
"july_2020_primary_period",
"fixed_asset_objects_candidates"
],
required_entities: [
"amortization_document",
"fixed_asset_objects",
"amortization_movements",
"postings",
"fixed_asset_register_state",
"expected_fa_set_for_july",
"actual_fa_set_from_amortization",
"missing_fa_candidates"
],
expected_transitions: [
"fixed_asset_object_to_amortization_document",
"amortization_document_to_movement_or_posting",
"expected_fa_set_to_actual_fa_set_comparison",
"actual_vs_missing_to_risk_of_incomplete_coverage"
]
};
}
function buildVerdict(metrics) {
const expectedFixed = metrics.fa_expected_set_reconstruction_rate >= 0.85;
const relationFixed = metrics.fa_relation_mapping_coverage_rate >= 0.85;
const claimFixed = metrics.fa_claim_anchor_coverage_rate >= 0.9;
const proofFixed = metrics.fa_proof_closure_rate > 0;
const falseGroundedOk = metrics.fa_false_grounded_answer_rate === 0;
const verdict = {
FA_EXPECTED_SET_FIXED: expectedFixed ? "FIXED" : "NOT_FIXED",
FA_RELATION_MAPPING_FIXED: relationFixed ? "FIXED" : "NOT_FIXED",
FA_CLAIM_ANCHOR_CLOSURE_FIXED: claimFixed ? "FIXED" : "NOT_FIXED",
FA_PROOF_CLOSURE_FIXED: proofFixed ? "FIXED" : "NOT_FIXED"
};
let overall = "FA_PACK_NOT_ACCEPTED";
if (expectedFixed && relationFixed && claimFixed && proofFixed && falseGroundedOk) {
overall = "FA_PACK_ACCEPTED";
} else if (expectedFixed && relationFixed && falseGroundedOk) {
overall = "FA_PACK_ACCEPTED_WITH_LIMITATIONS";
}
return { verdict, overall };
}
function main() {
const rawPathArg = process.argv[2];
const runDirArg = process.argv[3];
const modeArg = process.argv[4] || "mock";
if (!rawPathArg || !runDirArg) {
throw new Error("Usage: node faPackExportArtifacts.js <fa_raw.json> <run-dir> [mode]");
}
const rawPath = path.resolve(rawPathArg);
const runDir = path.resolve(runDirArg);
const mode = String(modeArg).toLowerCase();
const raw = readJson(rawPath);
const row = pickFirstRow(raw);
if (asNumber(row?.http_status) !== 200) {
const status = asNumber(row?.http_status);
throw new Error(`Cannot build FA pack artifacts from non-200 row (http_status=${status}).`);
}
const debug = asObject(row?.debug);
const claimAudit = asObject(debug?.claim_anchor_audit);
const targeted = asObject(debug?.targeted_evidence_acquisition);
const admissibility = asObject(debug?.evidence_admissibility_gate);
const eligibility = asObject(debug?.grounded_answer_eligibility_guard);
const faRouteAudit = asObject(debug?.fa_live_route_audit);
const expectedSet = asArray(targeted?.fa_expected_set);
const actualSet = asArray(targeted?.fa_actual_set_from_amortization);
const missingSet = asArray(targeted?.fa_missing_candidates);
const uncertainSet = asArray(targeted?.fa_uncertain_candidates);
const relationMap = asArray(targeted?.fa_relation_map);
const rejectBreakdown = asObject(admissibility?.reject_breakdown);
const claimsRequired = asArray(claimAudit?.required_anchors);
const claimsMissing = asArray(claimAudit?.missing_anchors);
const claimResolutionRate = asNumber(claimAudit?.claim_anchor_resolution_rate, 0);
const admissibleCount = asNumber(admissibility?.admissible_evidence_count, 0);
const groundingMode = String(eligibility?.grounding_mode ?? "");
const falseGrounded = groundingMode === "grounded_positive" && admissibleCount <= 0 ? 1 : 0;
const metrics = {
fa_expected_set_reconstruction_rate: expectedSet.length > 0 ? 1 : 0,
fa_relation_mapping_coverage_rate: relationMap.length > 0 ? 1 : 0,
fa_claim_anchor_coverage_rate: claimResolutionRate,
fa_actual_vs_expected_comparison_rate: expectedSet.length > 0 && actualSet.length > 0 ? 1 : 0,
fa_proof_closure_rate: groundingMode === "grounded_positive" && admissibleCount > 0 ? 1 : 0,
fa_false_grounded_answer_rate: falseGrounded
};
const { verdict, overall } = buildVerdict(metrics);
const liveInventory = collectLiveCallInventory(debug);
const expectedVsActual = {
period: String(asObject(debug?.temporal_guard)?.resolved_time_anchor ?? "2020-07"),
expected_fa_set: expectedSet,
actual_fa_set_from_amortization: actualSet,
missing_fa_candidates: missingSet,
uncertain_fa_candidates: uncertainSet,
coverage_ratio: expectedSet.length > 0 ? Number((actualSet.length / expectedSet.length).toFixed(4)) : 0,
source: "targeted_evidence_acquisition.fa_*"
};
const runSummary = {
stage: "Stage 4",
pack: "FA amortization proof closure",
date: new Date().toISOString().slice(0, 10),
mode,
status: overall,
verdict,
metrics,
inputs: {
raw_file: rawPath,
user_message: String(row?.user_message ?? ""),
trace_id: String(row?.trace_id ?? "")
},
runtime: {
reply_type: String(row?.reply_type ?? ""),
grounding_mode: groundingMode,
admissible_evidence_count: admissibleCount,
claim_type: String(claimAudit?.claim_type ?? "")
},
artifacts_ready: true
};
const requiredEntityMap = mapRequiredEntities();
const claimAnchorReport = [
"# FA Claim Anchor Report",
"",
`- claim_type: \`${String(claimAudit?.claim_type ?? "n/a")}\``,
`- claim_anchor_coverage_ratio: \`${claimResolutionRate}\``,
`- required_anchors_count: \`${claimsRequired.length}\``,
`- missing_anchor_classes_count: \`${claimsMissing.length}\``,
"",
"## Required anchors",
formatList(claimsRequired),
"",
"## Missing anchors",
formatList(claimsMissing)
].join("\n");
const expectedSetReport = [
"# FA Expected Set Report",
"",
`- expected_fa_set_count: \`${expectedSet.length}\``,
`- actual_fa_set_count: \`${actualSet.length}\``,
`- missing_fa_candidates_count: \`${missingSet.length}\``,
`- uncertain_fa_candidates_count: \`${uncertainSet.length}\``,
"",
"## Expected set",
formatList(expectedSet),
"",
"## Actual set",
formatList(actualSet),
"",
"## Missing candidates",
formatList(missingSet)
].join("\n");
const relationPreview = relationMap.slice(0, 20).map((item) => {
const objectId = String(item?.fa_object ?? "n/a");
const status = String(item?.coverage_status ?? "n/a");
const docs = asArray(item?.document_amortization).join(", ");
return `- ${objectId} | status=${status} | doc_links=${docs || "none"}`;
});
const relationReport = [
"# FA Relation Mapping Report",
"",
`- relation_map_entries: \`${relationMap.length}\``,
"",
"## Object-level relation preview",
relationPreview.length ? relationPreview.join("\n") : "- none"
].join("\n");
const proofReport = [
"# FA Proof Closure Report",
"",
`- reply_type: \`${String(row?.reply_type ?? "")}\``,
`- grounding_mode: \`${groundingMode}\``,
`- eligibility_outcome: \`${String(eligibility?.outcome ?? "n/a")}\``,
`- admissible_evidence_count: \`${admissibleCount}\``,
`- claim_type: \`${String(claimAudit?.claim_type ?? "n/a")}\``,
"",
"## Reason codes",
formatList(asArray(eligibility?.reason_codes)),
"",
"## FA route reasons",
formatList(asArray(faRouteAudit?.missing_live_calls))
].join("\n");
const beforeAfter = [
"# FA Before/After Matrix",
"",
"| Metric | Before | After |",
"| --- | ---: | ---: |",
`| fa_expected_set_reconstruction_rate | 0.00 | ${metrics.fa_expected_set_reconstruction_rate.toFixed(2)} |`,
`| fa_relation_mapping_coverage_rate | 0.00 | ${metrics.fa_relation_mapping_coverage_rate.toFixed(2)} |`,
`| fa_claim_anchor_coverage_rate | 0.00 | ${metrics.fa_claim_anchor_coverage_rate.toFixed(2)} |`,
`| fa_actual_vs_expected_comparison_rate | 0.00 | ${metrics.fa_actual_vs_expected_comparison_rate.toFixed(2)} |`,
`| fa_proof_closure_rate | 0.00 | ${metrics.fa_proof_closure_rate.toFixed(2)} |`,
`| fa_false_grounded_answer_rate | 0.00 | ${metrics.fa_false_grounded_answer_rate.toFixed(2)} |`
].join("\n");
const chatExport = [
"# Chat Export FA",
"",
"## 1. user",
String(row?.user_message ?? ""),
"",
"## 2. assistant",
String(row?.assistant_reply ?? "")
].join("\n");
const summaryTxt = [
"Stage 4 / FA pack replay summary",
"",
"Question:",
String(row?.user_message ?? ""),
"",
"Result highlights:",
`- claim_type: ${String(claimAudit?.claim_type ?? "n/a")}`,
`- required FA live calls executed: ${asArray(faRouteAudit?.executed_live_calls).length}`,
`- expected_fa_set_count: ${expectedSet.length}`,
`- actual_fa_set_count: ${actualSet.length}`,
`- missing_fa_candidates_count: ${missingSet.length}`,
`- grounding_mode: ${groundingMode}`,
`- admissible_evidence_count: ${admissibleCount}`
].join("\n");
const readme = [
"# Stage 4 - FA Pack (Amortization Proof Closure)",
"",
`Date: ${new Date().toISOString().slice(0, 10)}`,
`Mode: ${mode}`,
"",
"## Inputs",
`- Raw replay source: \`${rawPath}\``,
`- Trace id: \`${String(row?.trace_id ?? "n/a")}\``,
"",
"## Final verdict",
`- FA_EXPECTED_SET_FIXED: \`${verdict.FA_EXPECTED_SET_FIXED}\``,
`- FA_RELATION_MAPPING_FIXED: \`${verdict.FA_RELATION_MAPPING_FIXED}\``,
`- FA_CLAIM_ANCHOR_CLOSURE_FIXED: \`${verdict.FA_CLAIM_ANCHOR_CLOSURE_FIXED}\``,
`- FA_PROOF_CLOSURE_FIXED: \`${verdict.FA_PROOF_CLOSURE_FIXED}\``,
`- Overall: \`${overall}\``,
"",
"## Notes",
mode === "live"
? "- Artifacts generated from live replay payload."
: "- Artifacts generated from controlled replay payload (non-live mode)."
].join("\n");
writeJson(path.join(runDir, "run_summary.json"), runSummary);
writeText(path.join(runDir, "README.md"), `${readme}\n`);
writeText(path.join(runDir, "fa_expected_set_report.md"), `${expectedSetReport}\n`);
writeText(path.join(runDir, "fa_relation_mapping_report.md"), `${relationReport}\n`);
writeText(path.join(runDir, "fa_claim_anchor_report.md"), `${claimAnchorReport}\n`);
writeText(path.join(runDir, "fa_proof_closure_report.md"), `${proofReport}\n`);
writeText(path.join(runDir, "fa_before_after_matrix.md"), `${beforeAfter}\n`);
writeText(path.join(runDir, "chat_export_fa.md"), `${chatExport}\n`);
writeText(path.join(runDir, "1.txt"), `${summaryTxt}\n`);
writeJson(path.join(runDir, "fa_required_entity_map.json"), requiredEntityMap);
writeJson(path.join(runDir, "fa_expected_vs_actual_set.json"), expectedVsActual);
writeJson(path.join(runDir, "fa_relation_map.json"), relationMap);
writeJson(path.join(runDir, "fa_admissibility_reject_breakdown.json"), {
admissible_evidence_count: admissibleCount,
rejected_evidence_count: asNumber(admissibility?.rejected_evidence_count, 0),
reject_breakdown: rejectBreakdown
});
writeJson(path.join(runDir, "debug_payloads", "fa_claim_bound_debug_sample.json"), {
trace_id: String(row?.trace_id ?? ""),
reply_type: String(row?.reply_type ?? ""),
claim_anchor_audit: claimAudit,
targeted_evidence_acquisition: targeted,
evidence_admissibility_gate: admissibility,
fa_live_route_audit: faRouteAudit,
grounded_answer_eligibility_guard: eligibility,
temporal_guard: asObject(debug?.temporal_guard),
business_scope_resolved: asArray(debug?.business_scope_resolved)
});
writeJson(path.join(runDir, "raw_live_calls", "fa_live_call_inventory_sample.json"), liveInventory);
}
main();

View File

@ -8,7 +8,8 @@ export type ClaimType =
| "prove_advance_offset_state"
| "prove_vat_chain_completeness"
| "prove_month_close_state"
| "prove_rbp_tail_state";
| "prove_rbp_tail_state"
| "prove_fixed_asset_amortization_coverage";
export type ContextExpansionReason =
| "prehistory"
@ -24,6 +25,7 @@ export interface TemporalWindow {
export interface ClaimBoundAnchorAudit {
claim_type: ClaimType;
settlement_role?: "supplier" | "customer" | "mixed" | "unknown";
required_anchors: string[];
resolved_anchors: Record<string, string[]>;
missing_anchors: string[];
@ -42,6 +44,18 @@ export interface TargetedEvidenceAcquisitionAudit {
targeted_evidence_hits: number;
targeted_evidence_hit_rate: number;
targeted_evidence_source_refs: string[];
fa_expected_set?: string[];
fa_actual_set_from_amortization?: string[];
fa_missing_candidates?: string[];
fa_uncertain_candidates?: string[];
fa_relation_map?: Array<{
fa_object: string;
document_amortization: string[];
movement: boolean;
posting: boolean;
period: string[];
coverage_status: "covered" | "missing" | "uncertain";
}>;
reason_codes: string[];
}
@ -112,35 +126,108 @@ function shiftDays(iso: string, deltaDays: number): string | null {
return formatDate(date);
}
function inferClaimType(input: { userMessage: string; focusDomainHint?: string | null }): ClaimType {
function accountPrefix(value: string): string | null {
const token = String(value ?? "").trim();
const match = token.match(/^(\d{2})/);
return match ? match[1] : null;
}
function accountPrefixesFromAnchors(anchors?: CompanyAnchorSet | null): Set<string> {
const prefixes = new Set<string>();
const accounts = Array.isArray(anchors?.accounts) ? anchors.accounts : [];
for (const item of accounts) {
const prefix = accountPrefix(String(item ?? ""));
if (prefix) {
prefixes.add(prefix);
}
}
return prefixes;
}
function inferClaimType(input: { userMessage: string; focusDomainHint?: string | null; companyAnchors?: CompanyAnchorSet | null }): ClaimType {
const lower = String(input.userMessage ?? "").toLowerCase();
const isVat =
input.focusDomainHint === "vat_document_register_book" ||
/(?:\bvat\b|ндс|invoice|счет[- ]фактур|register|книга покупок|книга продаж)/i.test(lower);
if (isVat) {
const accountPrefixes = accountPrefixesFromAnchors(input.companyAnchors);
const hasSettlementAccount = ["51", "60", "62", "76"].some((item) => accountPrefixes.has(item));
const hasVatAccount = ["19", "68"].some((item) => accountPrefixes.has(item));
const hasFixedAssetAccount = ["01", "02", "08"].some((item) => accountPrefixes.has(item));
const hasRbpAccount = accountPrefixes.has("97");
const hasMonthCloseAccount = ["20", "21", "23", "25", "26", "28", "29", "44"].some((item) =>
accountPrefixes.has(item)
);
const hasAdvanceSignal = /(?:advance|аванс|offset|зач[её]т|62\.02|60\.02)/i.test(lower);
const hasSettlementLexical = /(?:долг|аванс|зач[её]т|взаимозач|расч[её]т|оплат|плате[жж]|платёж|постав|покупател|settlement|payment|supplier|customer)/i.test(
lower
);
const hasVatLexical = /(?:\bvat\b|ндс|invoice|сч[её]т[- ]?фактур|register|книга\s+покупок|книга\s+продаж|книг[аи]\s+(?:покуп|продаж))/i.test(
lower
);
const hasFixedAssetLexical = /(?:depreciat|amortization|fixed\s*asset|амортиз|основн(?:ые|ых)?\s+сред|объект\s+ос|сч[её]т\s*0[128]|account\s*0[128])/i.test(
lower
);
const hasRbpLexical = /(?:\brbp\b|рбп|deferred\s*expense|writeoff|расходы\s+будущих\s+периодов|списани[ея]\s+рбп|account\s*97|сч[её]т\s*97)/i.test(
lower
);
const hasMonthCloseLexical = /(?:month[- ]?close|закрыт|закрытие\s+месяца|косвен|account\s*20|account\s*44|сч[её]т\s*20|сч[её]т\s*44|распределен|period\s*close)/i.test(
lower
);
if (input.focusDomainHint === "settlements_60_62") {
return hasAdvanceSignal ? "prove_advance_offset_state" : "prove_settlement_closure_state";
}
if (input.focusDomainHint === "vat_document_register_book") {
return "prove_vat_chain_completeness";
}
const isRbp = /(?:\brbp\b|рбп|account\s*97|счет\s*97|deferred expense|writeoff)/i.test(lower);
if (isRbp) {
return "prove_rbp_tail_state";
if (input.focusDomainHint === "fixed_asset_amortization") {
return "prove_fixed_asset_amortization_coverage";
}
const isMonthClose =
input.focusDomainHint === "month_close_costs_20_44" ||
/(?:month[- ]?close|закрыт|косвен|account\s*20|account\s*44|счет\s*20|счет\s*44)/i.test(lower);
if (isMonthClose) {
if (input.focusDomainHint === "month_close_costs_20_44") {
if (hasRbpLexical || hasRbpAccount) {
return "prove_rbp_tail_state";
}
return "prove_month_close_state";
}
const isAdvance = /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(lower);
if (isAdvance) {
const settlementPriority =
(hasSettlementLexical || hasSettlementAccount || hasAdvanceSignal) && !hasVatLexical && !hasFixedAssetLexical;
const broadMonthClosePriority =
(hasMonthCloseLexical || hasMonthCloseAccount) &&
!hasVatLexical &&
!hasVatAccount &&
!hasFixedAssetLexical &&
!hasFixedAssetAccount;
if (hasAdvanceSignal && settlementPriority) {
return "prove_advance_offset_state";
}
if (settlementPriority) {
return "prove_settlement_closure_state";
}
if (hasVatLexical || (hasVatAccount && !settlementPriority)) {
return "prove_vat_chain_completeness";
}
if (broadMonthClosePriority) {
return hasRbpLexical || hasRbpAccount ? "prove_rbp_tail_state" : "prove_month_close_state";
}
if (hasFixedAssetLexical || (hasFixedAssetAccount && !settlementPriority && !hasVatLexical)) {
return "prove_fixed_asset_amortization_coverage";
}
if (hasRbpLexical || hasRbpAccount) {
return "prove_rbp_tail_state";
}
if (hasMonthCloseLexical || hasMonthCloseAccount) {
return "prove_month_close_state";
}
if (hasSettlementLexical || hasSettlementAccount) {
return "prove_settlement_closure_state";
}
return "prove_settlement_closure_state";
}
function inferCounterpartyScope(message: string): string[] {
const lower = message.toLowerCase();
const out: string[] = [];
if (/(?:supplier|vendor|поставщик)/i.test(lower)) out.push("supplier");
if (/(?:supplier|vendor|поставщик|кредитор)/i.test(lower)) out.push("supplier");
if (/(?:customer|buyer|покупатель|дебитор)/i.test(lower)) out.push("customer");
return uniqueStrings(out);
}
@ -148,14 +235,46 @@ function inferCounterpartyScope(message: string): string[] {
function detectSignals(message: string): Record<string, boolean> {
const lower = message.toLowerCase();
return {
hasAdvance: /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(lower),
hasClosure: /(?:close|closure|закрыт|хвост|tail|reconcile|зачет)/i.test(lower),
hasVat: /(?:\bvat\b|ндс|счет[- ]фактур|invoice|книга покупок|книга продаж|register)/i.test(lower),
hasMonthClose: /(?:month[- ]?close|закрытие месяца|косвен|20\/44|account 20|account 44|счет 20|счет 44)/i.test(lower),
hasRbp: /(?:\brbp\b|рбп|account 97|счет 97|writeoff|списани)/i.test(lower)
hasAdvance: /(?:advance|аванс|offset|зач[её]т|62\.02|60\.02)/i.test(lower),
hasClosure: /(?:close|closure|закрыт|хвост|tail|reconcile|зач[её]т)/i.test(lower),
hasVat: /(?:\bvat\b|ндс|сч[её]т[- ]?фактур|invoice|книга\s+покупок|книга\s+продаж|register)/i.test(lower),
hasMonthClose: /(?:month[- ]?close|закрытие\s+месяца|косвен|20\/44|account 20|account 44|сч[её]т 20|сч[её]т 44)/i.test(lower),
hasRbp: /(?:\brbp\b|рбп|account 97|сч[её]т 97|writeoff|списани)/i.test(lower),
hasFixedAsset: /(?:depreciat|amortization|fixed\s*asset|амортиз|основн(?:ые|ых)?\s+сред|объект\s+ос|сч[её]т\s*0[128]|account\s*0[128])/i.test(
lower
)
};
}
function resolveSettlementRole(input: {
claimType: ClaimType;
counterpartyScope: string[];
accountPrefixes: Set<string>;
userMessage: string;
}): "supplier" | "customer" | "mixed" | "unknown" | undefined {
if (input.claimType !== "prove_settlement_closure_state" && input.claimType !== "prove_advance_offset_state") {
return undefined;
}
const scopes = new Set(input.counterpartyScope.map((item) => String(item ?? "").trim().toLowerCase()));
const lower = String(input.userMessage ?? "").toLowerCase();
const hasSupplierLexical = /(?:supplier|vendor|поставщ|кредитор|обязательств|payable)/i.test(lower);
const hasCustomerLexical = /(?:customer|buyer|покупат|дебитор|receivable)/i.test(lower);
const hasSupplierAccount = input.accountPrefixes.has("60");
const hasCustomerAccount = input.accountPrefixes.has("62");
const supplierSignal = scopes.has("supplier") || hasSupplierLexical || (hasSupplierAccount && !hasCustomerAccount);
const customerSignal = scopes.has("customer") || hasCustomerLexical || (hasCustomerAccount && !hasSupplierAccount);
if (supplierSignal && !customerSignal) {
return "supplier";
}
if (customerSignal && !supplierSignal) {
return "customer";
}
if (supplierSignal && customerSignal) {
return "mixed";
}
return "unknown";
}
function mergeAnchors(anchors: CompanyAnchorSet | null | undefined, key: keyof CompanyAnchorSet): string[] {
return uniqueStrings(Array.isArray(anchors?.[key]) ? (anchors?.[key] as string[]) : []);
}
@ -191,6 +310,22 @@ function missingFromRequired(required: string[], resolved: Record<string, string
}
continue;
}
if (anchor === "amount_or_document") {
const hasAmount = (resolved.amounts?.length ?? 0) > 0;
const hasDoc = (resolved.document_numbers?.length ?? 0) > 0 || (resolved.document_types?.length ?? 0) > 0;
if (!hasAmount && !hasDoc) {
missing.push(anchor);
}
continue;
}
if (anchor === "account_scope_or_document_type") {
const hasAccount = (resolved.account_scope?.length ?? 0) > 0;
const hasDocType = (resolved.document_types?.length ?? 0) > 0;
if (!hasAccount && !hasDocType) {
missing.push(anchor);
}
continue;
}
if ((resolved[anchor]?.length ?? 0) <= 0) {
missing.push(anchor);
}
@ -206,9 +341,28 @@ export function resolveClaimBoundAnchors(input: {
}): ClaimBoundAnchorAudit {
const claimType = inferClaimType({
userMessage: input.userMessage,
focusDomainHint: input.focusDomainHint
focusDomainHint: input.focusDomainHint,
companyAnchors: input.companyAnchors
});
const signals = detectSignals(input.userMessage);
const accountPrefixes = accountPrefixesFromAnchors(input.companyAnchors);
const includeVatAnchors = claimType === "prove_vat_chain_completeness";
const includeMonthCloseAnchors = claimType === "prove_month_close_state";
const includeRbpAnchors = claimType === "prove_rbp_tail_state";
const includeFixedAssetAnchors = claimType === "prove_fixed_asset_amortization_coverage";
const hasVatSignal = signals.hasVat || accountPrefixes.has("19") || accountPrefixes.has("68");
const hasRbpSignal = signals.hasRbp || accountPrefixes.has("97");
const hasFixedAssetSignal = signals.hasFixedAsset || accountPrefixes.has("01") || accountPrefixes.has("02") || accountPrefixes.has("08");
const hasMonthCloseSignal =
signals.hasMonthClose ||
accountPrefixes.has("20") ||
accountPrefixes.has("21") ||
accountPrefixes.has("23") ||
accountPrefixes.has("25") ||
accountPrefixes.has("26") ||
accountPrefixes.has("28") ||
accountPrefixes.has("29") ||
accountPrefixes.has("44");
const resolvedAnchors: Record<string, string[]> = {
period: uniqueStrings([...mergeAnchors(input.companyAnchors, "periods"), ...mergeAnchors(input.companyAnchors, "dates")]),
account_scope: mergeAnchors(input.companyAnchors, "accounts"),
@ -219,16 +373,28 @@ export function resolveClaimBoundAnchors(input: {
counterparty_scope: inferCounterpartyScope(input.userMessage),
advance_signal: signals.hasAdvance ? ["advance"] : [],
closure_signal: signals.hasClosure ? ["closure"] : [],
vat_signal: signals.hasVat ? ["vat"] : [],
chain_signal: signals.hasVat ? ["chain"] : [],
close_signal: signals.hasMonthClose ? ["month_close"] : [],
vat_signal: includeVatAnchors && hasVatSignal ? ["vat"] : [],
chain_signal: includeVatAnchors && hasVatSignal ? ["chain"] : [],
close_signal: includeMonthCloseAnchors && hasMonthCloseSignal ? ["month_close"] : [],
cost_scope: [],
rbp_signal: signals.hasRbp ? ["rbp"] : [],
writeoff_signal: signals.hasRbp ? ["writeoff"] : []
rbp_signal: includeRbpAnchors && hasRbpSignal ? ["rbp"] : [],
writeoff_signal: includeRbpAnchors && hasRbpSignal ? ["writeoff"] : [],
fixed_asset_signal: includeFixedAssetAnchors && hasFixedAssetSignal ? ["fixed_asset"] : [],
amortization_signal: includeFixedAssetAnchors && hasFixedAssetSignal ? ["amortization"] : [],
expected_fa_set: [],
actual_fa_set: []
};
if (/(?:^|[^\d])(20|44)(?:[^\d]|$)/.test((resolvedAnchors.account_scope ?? []).join(" ")) || signals.hasMonthClose) {
if (
includeMonthCloseAnchors &&
(/(?:^|[^\d])(20|44)(?:[^\d]|$)/.test((resolvedAnchors.account_scope ?? []).join(" ")) || hasMonthCloseSignal)
) {
resolvedAnchors.cost_scope = ["20_44"];
}
// For FA amortization claims, document type is implicit in user intent
// even when the phrase does not carry explicit document keywords.
if (includeFixedAssetAnchors && hasFixedAssetSignal && (resolvedAnchors.document_types?.length ?? 0) <= 0) {
resolvedAnchors.document_types = ["amortization_document"];
}
if (input.primaryPeriod) {
resolvedAnchors.period = uniqueStrings([...(resolvedAnchors.period ?? []), input.primaryPeriod.from, input.primaryPeriod.to]);
}
@ -238,7 +404,14 @@ export function resolveClaimBoundAnchors(input: {
prove_advance_offset_state: ["period", "account_scope", "advance_signal", "settlement_object"],
prove_vat_chain_completeness: ["period", "document_types", "vat_signal", "chain_signal"],
prove_month_close_state: ["period", "close_signal", "cost_scope"],
prove_rbp_tail_state: ["period", "rbp_signal", "writeoff_signal"]
prove_rbp_tail_state: ["period", "rbp_signal", "writeoff_signal"],
prove_fixed_asset_amortization_coverage: [
"period",
"fixed_asset_signal",
"amortization_signal",
"amount_or_document",
"account_scope_or_document_type"
]
};
const requiredAnchors = requiredByClaim[claimType];
@ -258,9 +431,22 @@ export function resolveClaimBoundAnchors(input: {
if (!allowedContextWindow && input.primaryPeriod) {
reasonCodes.push("controlled_temporal_expansion_window_unavailable");
}
const settlementRole = resolveSettlementRole({
claimType,
counterpartyScope: resolvedAnchors.counterparty_scope ?? [],
accountPrefixes,
userMessage: input.userMessage
});
if (
(claimType === "prove_settlement_closure_state" || claimType === "prove_advance_offset_state") &&
(settlementRole === "mixed" || settlementRole === "unknown")
) {
reasonCodes.push("unresolved_supplier_customer_polarity");
}
return {
claim_type: claimType,
settlement_role: settlementRole,
required_anchors: requiredAnchors,
resolved_anchors: resolvedAnchors,
missing_anchors: missingAnchors,
@ -288,7 +474,13 @@ function buildCorpusFromItem(item: Record<string, unknown>): string {
document_context: item.document_context,
relation_pattern_hits: item.relation_pattern_hits,
graph_domain_scope: item.graph_domain_scope,
lifecycle_markers: item.lifecycle_markers
lifecycle_markers: item.lifecycle_markers,
live_call_id: item.live_call_id,
live_call_purpose: item.live_call_purpose,
fa_object_hint: item.fa_object_hint,
fa_expected_set_candidate: item.fa_expected_set_candidate,
fa_actual_set_candidate: item.fa_actual_set_candidate,
fa_coverage_status: item.fa_coverage_status
}).toLowerCase();
}
@ -329,6 +521,16 @@ function requiredChecksByClaim(claimType: ClaimType): string[] {
if (claimType === "prove_month_close_state") {
return ["close_operation_found", "distribution_step_found", "residual_tail_found"];
}
if (claimType === "prove_fixed_asset_amortization_coverage") {
return [
"amortization_document_found",
"fixed_asset_object_identified",
"expected_fa_set_reconstructed",
"actual_fa_set_reconstructed",
"movement_or_posting_link_found",
"missing_fa_candidates_assessed"
];
}
return [
"rbp_writeoff_document_found",
"rbp_object_identified",
@ -348,21 +550,34 @@ function detectChecksForCorpus(corpus: string, claimType: ClaimType, anchors: Re
const hasSettlementAccount = /(?:\b60(?:\.\d{2})?\b|\b62(?:\.\d{2})?\b|payable|receivable|settlement)/i.test(corpus);
const hasPosting = /(?:document_to_posting|posting|проводк)/i.test(corpus);
const hasRegister = /(?:register|accumulationregister|accountingregister|регистр)/i.test(corpus);
const hasClose = /(?:close|closure|закрыт|reconcile|зачет|tail|хвост)/i.test(corpus);
const hasClose = /(?:close|closure|закрыт|reconcile|зач[её]т|tail|хвост)/i.test(corpus);
const hasPayment = /(?:payment|оплат|списаниесрасчетногосчета|payment_order|bank_statement)/i.test(corpus);
const hasAdvance = /(?:advance|аванс|offset|зачет|62\.02|60\.02)/i.test(corpus);
const hasVat = /(?:\bvat\b|ндс|invoice_to_vat|счет[- ]фактур|invoice)/i.test(corpus);
const hasBook = /(?:книгипокупок|книгипродаж|book)/i.test(corpus);
const hasAdvance = /(?:advance|аванс|offset|зач[её]т|62\.02|60\.02)/i.test(corpus);
const hasVat = /(?:\bvat\b|ндс|invoice_to_vat|сч[её]т[- ]?фактур|invoice)/i.test(corpus);
const hasBook = /(?:книг[аи](?:\s+)?(?:покупок|продаж)|book)/i.test(corpus);
const hasChain = /(?:chain|link|document_to_posting|invoice_to_vat|связ)/i.test(corpus);
const hasMonthClose = /(?:month[- ]?close|period_close|закрытие месяца|косвен|20|44)/i.test(corpus);
const hasMonthClose = /(?:month[- ]?close|period_close|закрытие\s+месяца|косвен|20|44)/i.test(corpus);
const hasDistribution = /(?:distribution|распредел|writeoff|deferred_expense_to_writeoff)/i.test(corpus);
const hasRbp = /(?:\brbp\b|рбп|account\s*97|счет\s*97|deferred)/i.test(corpus);
const hasRbp = /(?:\brbp\b|рбп|account\s*97|сч[её]т\s*97|deferred)/i.test(corpus);
const hasResidual = /(?:tail|остат|незакры|overdue|period_boundary|terminal_state_gap)/i.test(corpus);
const hasContradiction = /(?:contradiction|invalid_transition|normal residual|нормальн)/i.test(corpus);
const hasRbpWriteoffDoc = /(?:списани[ея]\s+рбп|rbp_writeoff|deferred_expense_document|writeoff document)/i.test(corpus);
const hasRbpObject = /(?:rbp[_\s-]?object|объект\s+рбп|analytics|subkonto|расходыбудущихпериодов)/i.test(corpus);
const hasMovement = /(?:movement|движен|хозрасчетный|document_to_posting|posting|проводк)/i.test(corpus);
const hasPeriodEndResidual = /(?:period_boundary|end_period|2020-07-31|остат)/i.test(corpus);
const hasFixedAsset = /(?:fixed_asset|asset_card|объект\s+ос|основн(?:ые|ых)?\s+сред|depreciat|амортиз|account[:\s]*0[12]|\b0[12](?:\.\d{2})?\b)/i.test(
corpus
);
const hasAmortizationDoc = /(?:depreciat|amortization|начислен[а-я]*\s+амортиз|документ\s+амортиз)/i.test(corpus);
const hasExpectedFaSet = /(?:expected_fa_set|expected[_\s-]?set|find_fixed_asset_cards_expected_for_period|expected_set_seed|fa_expected_set_candidate)/i.test(
corpus
);
const hasActualFaSet = /(?:actual_fa_set|find_fixed_asset_movements_accounts_01_02|fa_actual_set_candidate|seed_amortization_documents|collect_fa_object_movements)/i.test(
corpus
);
const hasFaCoverageCompare = /(?:expected_vs_actual|compare_expected_vs_actual|missing_fa|coverage_compare|missing_fa_candidates)/i.test(
corpus
);
if (claimType === "prove_settlement_closure_state") {
if (hasPayment) checks.add("payment_document_found");
@ -380,7 +595,7 @@ function detectChecksForCorpus(corpus: string, claimType: ClaimType, anchors: Re
if (hasPosting) checks.add("posting_link_found");
} else if (claimType === "prove_vat_chain_completeness") {
if (/(?:document|receipt|realization|поступлен|реализац)/i.test(corpus)) checks.add("source_document_found");
if (/(?:invoice|счет[- ]фактур)/i.test(corpus)) checks.add("invoice_found");
if (/(?:invoice|сч[её]т[- ]?фактур)/i.test(corpus)) checks.add("invoice_found");
if (hasRegister || hasVat) checks.add("tax_register_entry_found");
if (hasBook) checks.add("book_entry_found");
if (hasChain) checks.add("chain_linkage_status");
@ -388,6 +603,13 @@ function detectChecksForCorpus(corpus: string, claimType: ClaimType, anchors: Re
if (hasMonthClose || hasClose) checks.add("close_operation_found");
if (hasDistribution) checks.add("distribution_step_found");
if (hasResidual) checks.add("residual_tail_found");
} else if (claimType === "prove_fixed_asset_amortization_coverage") {
if (hasAmortizationDoc) checks.add("amortization_document_found");
if (hasFixedAsset) checks.add("fixed_asset_object_identified");
if (hasExpectedFaSet) checks.add("expected_fa_set_reconstructed");
if (hasActualFaSet || hasAmortizationDoc) checks.add("actual_fa_set_reconstructed");
if (hasMovement || hasPosting) checks.add("movement_or_posting_link_found");
if (hasFaCoverageCompare || (hasExpectedFaSet && hasActualFaSet)) checks.add("missing_fa_candidates_assessed");
} else {
if (hasRbpWriteoffDoc || (hasRbp && hasDistribution)) checks.add("rbp_writeoff_document_found");
if (hasRbpObject || hasRbp) checks.add("rbp_object_identified");
@ -540,7 +762,11 @@ function buildDerivedEvidenceFromItem(input: {
account_context: Array.isArray(input.item.account_context) ? input.item.account_context : [],
account_debit: input.item.account_debit ?? null,
account_credit: input.item.account_credit ?? null,
relation_pattern_hits: Array.isArray(input.item.relation_pattern_hits) ? input.item.relation_pattern_hits : []
relation_pattern_hits: Array.isArray(input.item.relation_pattern_hits) ? input.item.relation_pattern_hits : [],
fa_object_hint: String(input.item.fa_object_hint ?? "").trim() || null,
fa_expected_set_candidate: Boolean(input.item.fa_expected_set_candidate),
fa_actual_set_candidate: Boolean(input.item.fa_actual_set_candidate),
fa_coverage_status: String(input.item.fa_coverage_status ?? "").trim() || null
}
};
}
@ -553,6 +779,189 @@ function buildClaimStatusTemplate(requiredChecks: string[]): Record<string, "fou
return out;
}
function normalizeFaObjectToken(value: string): string | null {
const normalized = String(value ?? "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return null;
}
if (/^live movement row #\d+$/i.test(normalized)) {
return null;
}
return normalized.slice(0, 140);
}
function periodFromEvidence(evidence: EvidenceItem): string | null {
const payload = toObject(evidence.payload);
return (
String(evidence.source_ref?.period ?? "").trim() ||
String(evidence.pointer?.source?.period ?? "").trim() ||
String(payload?.period ?? "").trim() ||
null
);
}
function collectFaCoverage(input: {
retrievalResults: UnifiedRetrievalResult[];
}): {
expectedSet: string[];
actualSet: string[];
missingCandidates: string[];
uncertainCandidates: string[];
relationMap: Array<{
fa_object: string;
document_amortization: string[];
movement: boolean;
posting: boolean;
period: string[];
coverage_status: "covered" | "missing" | "uncertain";
}>;
} {
const state = new Map<
string,
{
expected: boolean;
actual: boolean;
movement: boolean;
posting: boolean;
docs: Set<string>;
periods: Set<string>;
}
>();
const touch = (objectName: string) => {
const key = objectName.toLowerCase();
const existing = state.get(key);
if (existing) {
return existing;
}
const created = {
expected: false,
actual: false,
movement: false,
posting: false,
docs: new Set<string>(),
periods: new Set<string>()
};
state.set(key, created);
return created;
};
for (const result of input.retrievalResults) {
const items = Array.isArray(result.items) ? result.items : [];
for (const item of items) {
const objectToken = normalizeFaObjectToken(
String(item.fa_object_hint ?? item.display_name ?? item.source_id ?? "").trim()
);
if (!objectToken) {
continue;
}
const slot = touch(objectToken);
if (Boolean(item.fa_expected_set_candidate)) {
slot.expected = true;
}
if (Boolean(item.fa_actual_set_candidate)) {
slot.actual = true;
}
const corpus = JSON.stringify(item).toLowerCase();
if (/(?:movement|движен|хозрасчет|document_to_posting)/i.test(corpus)) {
slot.movement = true;
}
if (/(?:posting|проводк|account_)/i.test(corpus)) {
slot.posting = true;
}
const documentContext = Array.isArray(item.document_context) ? item.document_context : [];
for (const doc of documentContext) {
const token = String(doc ?? "").trim();
if (token) {
slot.docs.add(token);
}
}
const period = String(item.period ?? item.Period ?? "").trim();
if (period) {
slot.periods.add(period);
}
}
const evidence = Array.isArray(result.evidence) ? result.evidence : [];
for (const evidenceItem of evidence) {
const payload = toObject(evidenceItem.payload) ?? {};
const objectToken = normalizeFaObjectToken(
String(payload.fa_object_hint ?? evidenceItem.source_ref?.id ?? evidenceItem.pointer?.source?.id ?? "").trim()
);
if (!objectToken) {
continue;
}
const slot = touch(objectToken);
if (Boolean(payload.fa_expected_set_candidate)) {
slot.expected = true;
}
if (Boolean(payload.fa_actual_set_candidate)) {
slot.actual = true;
}
const corpus = JSON.stringify({
payload,
mechanism_note: evidenceItem.mechanism_note,
source_ref: evidenceItem.source_ref
}).toLowerCase();
if (/(?:movement|движен|хозрасчет|document_to_posting)/i.test(corpus)) {
slot.movement = true;
}
if (/(?:posting|проводк|account_)/i.test(corpus)) {
slot.posting = true;
}
const documentContext = Array.isArray(payload.document_context) ? payload.document_context : [];
for (const doc of documentContext) {
const token = String(doc ?? "").trim();
if (token) {
slot.docs.add(token);
}
}
const period = periodFromEvidence(evidenceItem);
if (period) {
slot.periods.add(period);
}
}
}
const entries = Array.from(state.entries());
const expectedSet = entries
.filter(([, slot]) => slot.expected)
.map(([objectName]) => objectName)
.slice(0, 32);
const actualSet = entries
.filter(([, slot]) => slot.actual)
.map(([objectName]) => objectName)
.slice(0, 32);
const expectedResolved = expectedSet.length > 0 ? expectedSet : actualSet;
const missingCandidates = expectedResolved.filter((item) => !actualSet.includes(item)).slice(0, 32);
const uncertainCandidates = entries
.filter(([, slot]) => !slot.expected && !slot.actual)
.map(([objectName]) => objectName)
.slice(0, 32);
const relationMap = entries.slice(0, 48).map(([objectName, slot]) => {
const coverageStatus: "covered" | "missing" | "uncertain" =
slot.expected && slot.actual ? "covered" : slot.expected && !slot.actual ? "missing" : "uncertain";
return {
fa_object: objectName,
document_amortization: Array.from(slot.docs).slice(0, 4),
movement: slot.movement,
posting: slot.posting,
period: Array.from(slot.periods).slice(0, 4),
coverage_status: coverageStatus
};
});
return {
expectedSet: expectedResolved,
actualSet,
missingCandidates,
uncertainCandidates,
relationMap
};
}
export function applyTargetedEvidenceAcquisition(input: {
retrievalResults: UnifiedRetrievalResult[];
claimAudit: ClaimBoundAnchorAudit;
@ -673,6 +1082,21 @@ export function applyTargetedEvidenceAcquisition(input: {
reasonCodes.push("targeted_evidence_hit_rate_low");
}
const faCoverage =
input.claimAudit.claim_type === "prove_fixed_asset_amortization_coverage"
? collectFaCoverage({
retrievalResults: adjustedResults
})
: null;
if (faCoverage) {
if (faCoverage.expectedSet.length <= 0) {
reasonCodes.push("fa_expected_set_not_reconstructed");
}
if (faCoverage.actualSet.length <= 0) {
reasonCodes.push("fa_actual_set_not_reconstructed");
}
}
return {
retrievalResults: adjustedResults,
audit: {
@ -683,7 +1107,18 @@ export function applyTargetedEvidenceAcquisition(input: {
targeted_evidence_hits: targetedEvidenceHits,
targeted_evidence_hit_rate: targetedEvidenceHitRate,
targeted_evidence_source_refs: Array.from(sourceRefs).slice(0, 24),
...(faCoverage
? {
fa_expected_set: faCoverage.expectedSet,
fa_actual_set_from_amortization: faCoverage.actualSet,
fa_missing_candidates: faCoverage.missingCandidates,
fa_uncertain_candidates: faCoverage.uncertainCandidates,
fa_relation_map: faCoverage.relationMap
}
: {}),
reason_codes: uniqueStrings(reasonCodes)
}
};
}

View File

@ -12,6 +12,7 @@ import {
FEATURE_ASSISTANT_MCP_RUNTIME_V1,
FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1
} from "../config";
import { inferP0DomainFromMessage as inferRuntimeP0DomainHint } from "./investigationState";
interface SnapshotLink {
relation: string;
@ -73,6 +74,7 @@ interface LiveMcpCallPlan {
call_id: string;
purpose: string;
query: string;
limit?: number;
required_for_claim: boolean;
account_scope_override?: string[];
}>;
@ -82,6 +84,7 @@ interface LiveMcpCallPlan {
interface LiveMcpCallExecution {
call_id: string;
purpose: string;
requested_limit: number;
required_for_claim: boolean;
status: "ok" | "empty" | "error";
fetched_rows: number;
@ -197,6 +200,23 @@ const RBP_REQUIRED_LIVE_CALLS = [
"compute_end_period_residual_by_rbp_object"
];
const VAT_REQUIRED_LIVE_CALLS = [
"find_vat_source_documents_in_period",
"find_vat_invoice_links_in_period",
"find_vat_register_entries_in_period",
"find_vat_book_entries_in_period"
];
const FA_REQUIRED_LIVE_CALLS = [
"find_amortization_documents_in_period",
"find_fixed_asset_movements_accounts_01_02",
"find_fixed_asset_cards_expected_for_period",
"match_expected_vs_actual_fa_coverage"
];
const CLAIM_BOUND_PRIMARY_LIVE_LIMIT = Math.max(ASSISTANT_MCP_LIVE_LIMIT, 96);
const CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT = Math.max(ASSISTANT_MCP_LIVE_LIMIT, 128);
function pushUniqueLine(target: string[], line: string): void {
if (!target.includes(line)) {
target.push(line);
@ -228,6 +248,13 @@ function parseFiniteNumber(value: unknown): number | null {
return null;
}
function resolveLiveCallLimit(limit: unknown): number {
if (typeof limit === "number" && Number.isFinite(limit)) {
return Math.max(1, Math.trunc(limit));
}
return ASSISTANT_MCP_LIVE_LIMIT;
}
function formatIsoDateUtc(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
@ -296,9 +323,119 @@ function hasRbpSignal(text: string): boolean {
);
}
function hasFixedAssetAmortizationSignal(text: string): boolean {
return /(?:амортиз|основн(?:ые|ых)?\s+сред|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|depreciat|fixed\s*asset|account\s*0[12]|счет\s*0[12])/i.test(
String(text ?? "").toLowerCase()
);
}
function buildLiveMcpCallPlan(route: string, fragmentText: string): LiveMcpCallPlan {
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
const preferredDomainHint = inferRuntimeP0DomainHint(fragmentText);
const periodScope = inferPeriodScope(fragmentText);
const primaryFrom = periodScope.from ?? "2020-07-01";
const primaryTo = periodScope.to ?? monthEndFromIso(primaryFrom) ?? "2020-07-31";
const carryFrom = shiftIsoDate(primaryFrom, -31) ?? primaryFrom;
const carryTo = shiftIsoDate(primaryTo, 31) ?? primaryTo;
const faClaim =
preferredDomainHint === "fixed_asset_amortization" ||
hasFixedAssetAmortizationSignal(fragmentText) ||
semanticProfile.query_subject === "fixed_asset_card_mismatch" ||
semanticProfile.domain_scope.includes("fixed_assets");
if (faClaim) {
return {
claim_type: "prove_fixed_asset_amortization_coverage",
query_subject: "fixed_asset_amortization_coverage",
required_live_calls: [...FA_REQUIRED_LIVE_CALLS],
calls: [
{
call_id: "find_amortization_documents_in_period",
purpose: "seed_amortization_documents",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["01", "02", "08"]
},
{
call_id: "find_fixed_asset_movements_accounts_01_02",
purpose: "collect_fa_object_movements",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["01", "02", "08"]
},
{
call_id: "find_fixed_asset_cards_expected_for_period",
purpose: "build_expected_fa_set",
query: buildLiveRangeQuery(carryFrom, primaryTo, CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT),
limit: CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["01", "02", "08"]
},
{
call_id: "match_expected_vs_actual_fa_coverage",
purpose: "compare_expected_vs_actual_fa_coverage",
query: buildLiveRangeQuery(carryFrom, carryTo, CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT),
limit: CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["01", "02", "08"]
}
],
route_gap_reason: null
};
}
const vatClaim =
preferredDomainHint === "vat_document_register_book" ||
semanticProfile.query_subject === "vat_chain_conflict" ||
semanticProfile.domain_scope.includes("vat") ||
/(?:\bvat\b|ндс|invoice|счет[- ]фактур|книга покупок|книга продаж|register)/i.test(String(fragmentText ?? "").toLowerCase());
if (vatClaim) {
return {
claim_type: "prove_vat_chain_completeness",
query_subject: "vat_chain_conflict",
required_live_calls: [...VAT_REQUIRED_LIVE_CALLS],
calls: [
{
call_id: "find_vat_source_documents_in_period",
purpose: "seed_vat_source_documents",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["19", "68"]
},
{
call_id: "find_vat_invoice_links_in_period",
purpose: "collect_invoice_links",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["19", "68"]
},
{
call_id: "find_vat_register_entries_in_period",
purpose: "collect_vat_register_entries",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["19", "68"]
},
{
call_id: "find_vat_book_entries_in_period",
purpose: "collect_vat_book_entries",
query: buildLiveRangeQuery(carryFrom, carryTo, CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT),
limit: CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["19", "68"]
}
],
route_gap_reason: null
};
}
const rbpClaim =
(preferredDomainHint === "month_close_costs_20_44" && hasRbpSignal(fragmentText)) ||
hasRbpSignal(fragmentText) ||
semanticProfile.query_subject === "deferred_expense_lifecycle_anomaly" ||
semanticProfile.domain_scope.includes("deferred_expense");
@ -319,46 +456,44 @@ function buildLiveMcpCallPlan(route: string, fragmentText: string): LiveMcpCallP
};
}
const periodScope = inferPeriodScope(fragmentText);
const primaryFrom = periodScope.from ?? "2020-07-01";
const primaryTo = periodScope.to ?? monthEndFromIso(primaryFrom) ?? "2020-07-31";
const carryFrom = shiftIsoDate(primaryFrom, -31) ?? primaryFrom;
const carryTo = shiftIsoDate(primaryTo, 31) ?? primaryTo;
return {
claim_type: "prove_rbp_tail_state",
query_subject: "deferred_expense_lifecycle_anomaly",
required_live_calls: [...RBP_REQUIRED_LIVE_CALLS],
calls: [
{
call_id: "find_rbp_writeoff_documents_in_period",
purpose: "seed_writeoff_documents",
query: buildLiveRangeQuery(primaryFrom, primaryTo, ASSISTANT_MCP_LIVE_LIMIT),
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
},
{
call_id: "find_rbp_object_movements_account_97",
purpose: "collect_rbp_object_movements",
query: buildLiveRangeQuery(primaryFrom, primaryTo, ASSISTANT_MCP_LIVE_LIMIT),
required_for_claim: true,
account_scope_override: ["97"]
},
{
call_id: "find_month_close_entries_linked_to_rbp",
purpose: "link_month_close_to_rbp",
query: buildLiveRangeQuery(primaryFrom, primaryTo, ASSISTANT_MCP_LIVE_LIMIT),
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
},
{
call_id: "compute_end_period_residual_by_rbp_object",
purpose: "collect_residual_tail_signals",
query: buildLiveRangeQuery(carryFrom, carryTo, ASSISTANT_MCP_LIVE_LIMIT),
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
}
],
{
call_id: "find_rbp_writeoff_documents_in_period",
purpose: "seed_writeoff_documents",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
},
{
call_id: "find_rbp_object_movements_account_97",
purpose: "collect_rbp_object_movements",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["97"]
},
{
call_id: "find_month_close_entries_linked_to_rbp",
purpose: "link_month_close_to_rbp",
query: buildLiveRangeQuery(primaryFrom, primaryTo, CLAIM_BOUND_PRIMARY_LIVE_LIMIT),
limit: CLAIM_BOUND_PRIMARY_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
},
{
call_id: "compute_end_period_residual_by_rbp_object",
purpose: "collect_residual_tail_signals",
query: buildLiveRangeQuery(carryFrom, carryTo, CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT),
limit: CLAIM_BOUND_CARRY_WINDOW_LIVE_LIMIT,
required_for_claim: true,
account_scope_override: ["97", "20", "25", "26", "44"]
}
],
route_gap_reason: null
};
}
@ -1614,11 +1749,32 @@ const WRONG_DOCUMENT_MARKERS =
const REPEATED_ANOMALY_MARKERS =
/(?:\u043f\u043e\u0432\u0442\u043e\u0440\u044f\u044e\u0449|\u0441\u0435\u0440\u0438\u0439\u043d|\u043f\u0430\u0442\u0442\u0435\u0440\u043d|repeat(?:ed|ability)?)/iu;
function inferQuerySubject(text: string, domains: string[], anomalies: string[]): string {
function inferQuerySubject(
text: string,
domains: string[],
anomalies: string[],
preferredDomainHint: string | null
): string {
if (preferredDomainHint === "vat_document_register_book") {
return "vat_chain_conflict";
}
if (preferredDomainHint === "fixed_asset_amortization") {
return "fixed_asset_card_mismatch";
}
if (preferredDomainHint === "month_close_costs_20_44") {
return "period_closure_risk";
}
if (preferredDomainHint === "settlements_60_62") {
return "supplier_tail_analysis";
}
const lower = text.toLowerCase();
if ((domains.includes("bank") || domains.includes("settlements")) && WRONG_DOCUMENT_MARKERS.test(lower)) {
return "bank_settlement_mismatch";
}
if (domains.includes("vat")) {
return "vat_chain_conflict";
}
if (domains.includes("suppliers")) {
return "supplier_tail_analysis";
}
@ -1631,9 +1787,6 @@ function inferQuerySubject(text: string, domains: string[], anomalies: string[])
if (domains.includes("fixed_assets")) {
return "fixed_asset_card_mismatch";
}
if (domains.includes("vat")) {
return "vat_chain_conflict";
}
if (domains.includes("period_close")) {
return "period_closure_risk";
}
@ -1784,9 +1937,10 @@ function buildSemanticRetrievalProfile(fragmentText: string): SemanticRetrievalP
relationPatterns: dedupedRelations,
anomalyPatterns: dedupedAnomalies
});
const preferredDomainHint = inferRuntimeP0DomainHint(fragmentText);
return {
query_subject: inferQuerySubject(lower, dedupedDomains, dedupedAnomalies),
query_subject: inferQuerySubject(lower, dedupedDomains, dedupedAnomalies, preferredDomainHint),
account_scope: dedupedAccounts,
subaccount_scope: [],
domain_scope: dedupedDomains,
@ -2773,11 +2927,15 @@ export class AssistantDataLayer {
const livePlan = buildLiveMcpCallPlan(route, fragmentText);
const explicitAccountScope = extractAccountScopeFromText(fragmentText);
const accountScope =
explicitAccountScope.length > 0
livePlan.claim_type === "prove_fixed_asset_amortization_coverage"
? ["01", "02", "08"]
: livePlan.claim_type === "prove_vat_chain_completeness"
? ["19", "68"]
: livePlan.claim_type === "prove_rbp_tail_state"
? ["97", "20", "25", "26", "44"]
: explicitAccountScope.length > 0
? explicitAccountScope
: livePlan.claim_type === "prove_rbp_tail_state"
? ["97", "20", "25", "26", "44"]
: [];
: [];
const callExecutions: LiveMcpCallExecution[] = [];
const collectedRows: Array<Record<string, unknown>> = [];
const errors: string[] = [];
@ -2785,6 +2943,7 @@ export class AssistantDataLayer {
let matchedRowsTotal = 0;
for (const call of livePlan.calls) {
const callLimit = resolveLiveCallLimit(call.limit);
const callAccountScope =
Array.isArray(call.account_scope_override) && call.account_scope_override.length > 0
? call.account_scope_override
@ -2792,7 +2951,7 @@ export class AssistantDataLayer {
try {
const payload = await this.fetchJsonWithTimeout(endpoint, {
query: call.query,
limit: ASSISTANT_MCP_LIVE_LIMIT
limit: callLimit
});
const parsed = this.parseExecuteQueryPayload(payload);
if (parsed.error) {
@ -2800,6 +2959,7 @@ export class AssistantDataLayer {
callExecutions.push({
call_id: call.call_id,
purpose: call.purpose,
requested_limit: callLimit,
required_for_claim: call.required_for_claim,
status: "error",
fetched_rows: 0,
@ -2827,6 +2987,7 @@ export class AssistantDataLayer {
callExecutions.push({
call_id: call.call_id,
purpose: call.purpose,
requested_limit: callLimit,
required_for_claim: call.required_for_claim,
status: rowsForAnswer.length > 0 ? "ok" : "empty",
fetched_rows: parsed.rows.length,
@ -2840,6 +3001,7 @@ export class AssistantDataLayer {
callExecutions.push({
call_id: call.call_id,
purpose: call.purpose,
requested_limit: callLimit,
required_for_claim: call.required_for_claim,
status: "error",
fetched_rows: 0,
@ -2864,7 +3026,11 @@ export class AssistantDataLayer {
lifecycle_markers: item.lifecycle_markers,
live_call_id: item.live_call_id,
live_call_purpose: item.live_call_purpose,
claim_type: item.claim_type
claim_type: item.claim_type,
fa_object_hint: item.fa_object_hint,
fa_expected_set_candidate: item.fa_expected_set_candidate,
fa_actual_set_candidate: item.fa_actual_set_candidate,
fa_coverage_status: item.fa_coverage_status
}));
const executedRequiredCalls = callExecutions
@ -2917,7 +3083,13 @@ export class AssistantDataLayer {
route,
channel: ASSISTANT_MCP_CHANNEL,
proxy: ASSISTANT_MCP_PROXY_URL,
source_profile: livePlan.claim_type ? "claim_bound_rbp_live_path" : "generic_live_probe",
source_profile: livePlan.claim_type === "prove_rbp_tail_state"
? "claim_bound_rbp_live_path"
: livePlan.claim_type === "prove_fixed_asset_amortization_coverage"
? "claim_bound_fa_live_path"
: livePlan.claim_type === "prove_vat_chain_completeness"
? "claim_bound_vat_live_path"
: "generic_live_probe",
claim_type: livePlan.claim_type,
query_subject: livePlan.query_subject,
account_scope: accountScope,
@ -3111,29 +3283,57 @@ export class AssistantDataLayer {
const querySubject = valueAsString(row.__query_subject ?? "").trim() || null;
const registratorLower = registrator.toLowerCase();
const hasRbpByDocument = /(?:рбп|deferred|списани[ея]\s+рбп)/i.test(registratorLower);
const hasFaByDocument = /(?:амортиз|depreciat|основн(?:ые|ых)\s+сред|fixed\s*asset)/i.test(registratorLower);
const hasAccount97 = accountContext.some((item) => /^97(?:\.|$)/.test(item));
const hasFixedAssetAccount = accountContext.some((item) => /^(?:01|02|08)(?:\.|$)/.test(item));
const hasCloseDoc =
/(?:закрыти[ея]\s+месяц|period\s*close|month\s*close|close\s+operation)/i.test(registratorLower) ||
callId.includes("month_close");
const faExpectedSetCandidate = callId === "find_fixed_asset_cards_expected_for_period" || callPurpose === "build_expected_fa_set";
const faActualSetCandidate =
callId === "find_amortization_documents_in_period" ||
callId === "find_fixed_asset_movements_accounts_01_02" ||
callPurpose === "seed_amortization_documents" ||
callPurpose === "collect_fa_object_movements";
const faCoverageStatus =
callId === "match_expected_vs_actual_fa_coverage"
? "expected_vs_actual_compare"
: faExpectedSetCandidate && faActualSetCandidate
? "covered"
: faExpectedSetCandidate
? "expected_only"
: faActualSetCandidate
? "actual_only"
: null;
const faObjectHint =
(registrator || "").trim() ||
`${debit || "n/a"}|${credit || "n/a"}|${amount !== null ? amount : "n/a"}`;
const relationPatternHits = uniqueStrings([
"document_to_posting",
hasRbpByDocument || hasAccount97 ? "deferred_expense_to_writeoff" : "",
hasFaByDocument || hasFixedAssetAccount ? "asset_card_to_depreciation" : "",
faCoverageStatus === "expected_vs_actual_compare" ? "expected_vs_actual_coverage_compare" : "",
hasCloseDoc ? "close_operation" : "",
callId.includes("residual") ? "residuals_zero_or_explained" : ""
]);
const documentContext = uniqueStrings([
hasRbpByDocument || hasAccount97 ? "deferred_expense_document" : "",
hasFaByDocument || hasFixedAssetAccount ? "depreciation_document" : "",
hasCloseDoc ? "period_close_document" : "",
"posting"
]);
const graphDomainScope = uniqueStrings([
hasRbpByDocument || hasAccount97 ? "deferred_expense" : "",
hasFaByDocument || hasFixedAssetAccount ? "fixed_asset" : "",
hasCloseDoc ? "period_close" : ""
]);
const lifecycleMarkers = uniqueStrings([
callId.includes("residual") ? "period_boundary" : "",
callId.includes("residual") ? "tail_state_observed" : "",
hasCloseDoc ? "close_operation" : ""
hasCloseDoc ? "close_operation" : "",
hasFaByDocument || hasFixedAssetAccount ? "amortization_accrual" : "",
faExpectedSetCandidate ? "expected_set_seed" : "",
faCoverageStatus === "expected_vs_actual_compare" ? "coverage_compare" : ""
]);
return {
source_entity: "MCPLiveMovement",
@ -3153,6 +3353,10 @@ export class AssistantDataLayer {
claim_type: claimType,
query_subject: querySubject,
amount,
fa_object_hint: faObjectHint,
fa_expected_set_candidate: faExpectedSetCandidate,
fa_actual_set_candidate: faActualSetCandidate,
fa_coverage_status: faCoverageStatus,
source_layer: "mcp_live_probe",
route
};

View File

@ -5,7 +5,12 @@ import type { EvidenceItem } from "../types/stage1Contracts";
import type { ProblemUnit } from "../types/stage2ProblemUnits";
import type { ClaimBoundAnchorAudit } from "./assistantClaimBoundEvidence";
type P0DomainHint = "settlements_60_62" | "vat_document_register_book" | "month_close_costs_20_44" | null;
type P0DomainHint =
| "settlements_60_62"
| "vat_document_register_book"
| "month_close_costs_20_44"
| "fixed_asset_amortization"
| null;
const JULY_YEAR = "2020";
const JULY_MONTH = "07";
@ -161,10 +166,37 @@ function collectPercentLikeSpans(text: string): Array<{ start: number; end: numb
return spans;
}
function collectContractLikeSpans(text: string): Array<{ start: number; end: number }> {
const spans: Array<{ start: number; end: number }> = [];
const patterns = [
/(?:\b(?:договор(?:а|у|ом|е)?|contract)\b[^\r\n]{0,24}(?:|#|n|no\.?)\s*[a-zа-я0-9][a-zа-я0-9/_-]{1,})/giu,
/(?:|#)\s*[a-zа-я0-9_-]{1,10}\/[a-zа-я0-9_-]{1,12}/giu,
/\b\d{2}\/\d{2}(?:-[a-zа-я]{1,10})?\b/giu
];
for (const pattern of patterns) {
let match: RegExpExecArray | null = null;
while ((match = pattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
}
return spans;
}
function intersectsSpan(start: number, end: number, spans: Array<{ start: number; end: number }>): boolean {
return spans.some((span) => start < span.end && end > span.start);
}
function hasAccountContextAround(text: string, start: number, end: number): boolean {
const left = text.slice(Math.max(0, start - 28), start);
const right = text.slice(end, Math.min(text.length, end + 28));
return /(?:счет|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(
`${left} ${right}`
);
}
interface AccountExtractionAudit {
resolved_account_anchors: string[];
raw_numeric_tokens: string[];
@ -181,7 +213,12 @@ function extractAccountsFromTextDetailed(text: string, options?: { forceAccountC
const dateSpans = collectDateLikeSpans(lower);
const amountSpans = collectAmountLikeSpans(lower);
const percentSpans = collectPercentLikeSpans(lower);
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans];
const contractSpans = collectContractLikeSpans(lower);
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans];
const hasAccountingLexeme =
/(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test(
lower
);
const contextualPattern =
/(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b\s*(?:|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu;
let contextualMatch: RegExpExecArray | null = null;
@ -246,6 +283,14 @@ function extractAccountsFromTextDetailed(text: string, options?: { forceAccountC
rejectedAsNonAccounts.add(token);
continue;
}
if (intersectsSpan(start, end, contractSpans)) {
classifiedNumericTokens.push({
token,
classification: "other_numeric"
});
rejectedAsNonAccounts.add(token);
continue;
}
if (!prefix || !KNOWN_ACCOUNT_PREFIXES.has(prefix)) {
classifiedNumericTokens.push({
token,
@ -254,6 +299,14 @@ function extractAccountsFromTextDetailed(text: string, options?: { forceAccountC
rejectedAsNonAccounts.add(token);
continue;
}
if (!hasAccountingLexeme || !hasAccountContextAround(lower, start, end)) {
classifiedNumericTokens.push({
token,
classification: "other_numeric"
});
rejectedAsNonAccounts.add(token);
continue;
}
accounts.add(token);
classifiedNumericTokens.push({
token,
@ -509,7 +562,7 @@ function resolveJulyAnchor(rawText: string): TemporalAnchorResolution {
const raw = String(rawText ?? "");
const lower = raw.toLowerCase();
const explicitYear = lower.match(/\b(20\d{2})\b/)?.[1] ?? null;
const dayByNamedJuly = lower.match(/(?:^|\D)(0?[1-9]|[12]\d|3[01])\s+(?:июл(?:я|ь)?|july|РёСЋР»(?:СЏ|СЊ)?)(?:\D|$)/i);
const dayByNamedJuly = lower.match(/(?:^|\D)(0?[1-9]|[12]\d|3[01])\s+(?:июл(?:я|ь)?|july)(?:\D|$)/i);
const dayByNumeric = lower.match(/\b(0?[1-9]|[12]\d|3[01])[./-](0?7)(?:[./-](\d{2}|\d{4}))?\b/);
const monthByNamed = /(?:июл|july|РёСЋР»)/i.test(lower);
const monthByNumeric = /\b20\d{2}[-/.]0?7\b/.test(lower);
@ -1044,6 +1097,10 @@ function isMonthClosePrefix(prefix: string): boolean {
return numeric >= 20 && numeric <= 44;
}
function isFixedAssetPrefix(prefix: string): boolean {
return prefix === "01" || prefix === "02" || prefix === "08";
}
function expectedAccountPrefixes(input: {
focusDomainHint: P0DomainHint;
polarity: DomainPolarity;
@ -1062,6 +1119,9 @@ function expectedAccountPrefixes(input: {
if (input.focusDomainHint === "month_close_costs_20_44") {
return ["20", "25", "26", "44", "97", "01", "02", "08"];
}
if (input.focusDomainHint === "fixed_asset_amortization") {
return ["01", "02", "08"];
}
if (input.focusDomainHint === "settlements_60_62") {
if (input.polarity === "supplier_payable") {
return ["60", "51", "76"];
@ -1121,6 +1181,13 @@ function hasWrongDomainByAccounts(accounts: string[], focusDomainHint: P0DomainH
if (focusDomainHint === "month_close_costs_20_44") {
return prefixes.every((prefix) => isSettlementPrefix(prefix) || isVatPrefix(prefix));
}
if (focusDomainHint === "fixed_asset_amortization") {
const hasFixedAsset = prefixes.some((prefix) => isFixedAssetPrefix(prefix));
if (hasFixedAsset) {
return false;
}
return prefixes.every((prefix) => isSettlementPrefix(prefix) || isVatPrefix(prefix) || isMonthClosePrefix(prefix));
}
return false;
}
@ -1531,3 +1598,4 @@ export function applyEligibilityToGroundingCheck<T extends { status: string; rea
};
}

View File

@ -193,7 +193,8 @@ function hasP0ClaimSignal(claimType, focusDomainHint) {
claim === "prove_advance_offset_state" ||
claim === "prove_vat_chain_completeness" ||
claim === "prove_month_close_state" ||
claim === "prove_rbp_tail_state") {
claim === "prove_rbp_tail_state" ||
claim === "prove_fixed_asset_amortization_coverage") {
return true;
}
return (focusDomainHint === "settlements_60_62" ||
@ -331,6 +332,24 @@ function collectDateSpans(text) {
}
return spans;
}
function collectContractSpans(text) {
const spans = [];
const contractPatterns = [
/(?:\b(?:договор(?:а|у|ом|е)?|contract)\b[^\r\n]{0,24}(?:|#|n|no\.?)\s*[a-zа-я0-9][a-zа-я0-9/_-]{1,})/giu,
/(?:|#)\s*[a-zа-я0-9_-]{1,10}\/[a-zа-я0-9_-]{1,12}/giu,
/\b\d{2}\/\d{2}(?:-[a-zа-я]{1,10})?\b/giu
];
for (const contractPattern of contractPatterns) {
let match = null;
while ((match = contractPattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
}
return spans;
}
function collectAmountSpans(text) {
const spans = [];
const amountPatterns = [/\b\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?\b/g, /\b\d+[.,]\d{2}\b/g];
@ -360,6 +379,11 @@ function collectPercentSpans(text) {
function intersectsAnySpan(start, end, spans) {
return spans.some((span) => start < span.end && end > span.start);
}
function hasAccountContextAround(text, start, end) {
const left = text.slice(Math.max(0, start - 28), start);
const right = text.slice(end, Math.min(text.length, end + 28));
return /(?:счет|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(`${left} ${right}`);
}
function extractAccountTokens(text) {
const lower = String(text ?? "").toLowerCase();
const explicitAccounts = new Set();
@ -432,7 +456,8 @@ function extractAccountTokens(text) {
if (explicitAccounts.size > 0) {
return Array.from(explicitAccounts);
}
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower)];
const contractSpans = collectContractSpans(lower);
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower), ...contractSpans];
const hasAccountingLexeme = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расчет|аванс|долг|settlement|payment)/iu.test(lower);
if (!hasAccountingLexeme) {
return [];
@ -447,6 +472,9 @@ function extractAccountTokens(text) {
if (intersectsAnySpan(start, end, spans)) {
continue;
}
if (!hasAccountContextAround(lower, start, end)) {
continue;
}
const prefix = value.match(/^(\d{2})/)?.[1];
if (!prefix || !knownAccountPrefixes.has(prefix)) {
continue;
@ -735,6 +763,164 @@ function collectRbpLiveRouteAudit(input) {
plan_override: input.planAudit ?? null
};
}
function enrichFaFragmentForLive(fragmentText, temporalGuard) {
const base = compactWhitespace(String(fragmentText ?? ""));
const hints = [
"Начисление амортизации",
"объект ОС",
"expected set ОС",
"счет 01/02"
];
const effective = temporalGuard && typeof temporalGuard === "object" ? temporalGuard.effective_primary_period : null;
if (effective && effective.from && effective.to) {
hints.push(`период ${effective.from}..${effective.to}`);
}
const hintText = hints.filter(Boolean).join(", ");
if (!base) {
return hintText;
}
if (/амортиз|основн(?:ые|ых)\s+сред|fixed\s*asset|depreciat|счет\s*0[12]|account\s*0[12]/i.test(base)) {
return base;
}
return `${base}; ${hintText}`;
}
function enforceFaLiveRoutePlan(input) {
if (input.claimType !== "prove_fixed_asset_amortization_coverage") {
return {
executionPlan: input.executionPlan,
audit: null
};
}
const requiredLiveCalls = [
"find_amortization_documents_in_period",
"find_fixed_asset_movements_accounts_01_02",
"find_fixed_asset_cards_expected_for_period",
"match_expected_vs_actual_fa_coverage"
];
let routeAdjusted = 0;
let rescuedNoRoute = 0;
const replacedRoutes = [];
const adjustedPlan = input.executionPlan.map((item) => {
if (!item || typeof item !== "object") {
return item;
}
if (item.should_execute !== true && item.no_route_reason === "insufficient_specificity") {
rescuedNoRoute += 1;
routeAdjusted += 1;
return {
...item,
route: "live_mcp_drilldown",
should_execute: true,
no_route_reason: null,
clarification_reason: null,
fragment_text: enrichFaFragmentForLive(item.fragment_text, input.temporalGuard)
};
}
if (item.should_execute === true && item.route !== "hybrid_store_plus_live" && item.route !== "live_mcp_drilldown") {
routeAdjusted += 1;
if (item.route && item.route !== "no_route") {
replacedRoutes.push(String(item.route));
}
return {
...item,
route: "hybrid_store_plus_live",
fragment_text: enrichFaFragmentForLive(item.fragment_text, input.temporalGuard)
};
}
if (item.should_execute === true) {
return {
...item,
fragment_text: enrichFaFragmentForLive(item.fragment_text, input.temporalGuard)
};
}
return item;
});
return {
executionPlan: adjustedPlan,
audit: {
claim_type: "prove_fixed_asset_amortization_coverage",
required_live_calls: requiredLiveCalls,
route_adjustments_applied: routeAdjusted,
rescued_no_route_fragments: rescuedNoRoute,
replaced_routes: Array.from(new Set(replacedRoutes)),
route_gap_reason: routeAdjusted > 0 ? "fa_claim_bound_live_route_override_applied" : null
}
};
}
function collectFaLiveRouteAudit(input) {
if (input.claimType !== "prove_fixed_asset_amortization_coverage") {
return null;
}
const required = new Set(Array.isArray(input.planAudit?.required_live_calls) ? input.planAudit.required_live_calls : []);
const executed = [];
const missing = new Set();
const routeGaps = [];
let matchedRowsTotal = 0;
let returnedRowsTotal = 0;
let fetchedRowsTotal = 0;
for (const result of input.retrievalResults) {
if (!result || typeof result !== "object") {
continue;
}
const summary = result.summary && typeof result.summary === "object" ? result.summary : null;
const live = summary && typeof summary.live_mcp === "object" && summary.live_mcp ? summary.live_mcp : null;
if (!live) {
continue;
}
const requiredCalls = Array.isArray(live.required_live_calls) ? live.required_live_calls : [];
for (const callId of requiredCalls) {
required.add(String(callId ?? "").trim());
}
const executedCalls = Array.isArray(live.executed_live_calls) ? live.executed_live_calls : [];
for (const call of executedCalls) {
if (!call || typeof call !== "object") {
continue;
}
executed.push(call);
}
const missingCalls = Array.isArray(live.missing_live_calls) ? live.missing_live_calls : [];
for (const callId of missingCalls) {
const token = String(callId ?? "").trim();
if (token) {
missing.add(token);
}
}
const routeGapReason = String(live.route_gap_reason ?? "").trim();
if (routeGapReason) {
routeGaps.push(routeGapReason);
}
fetchedRowsTotal += Number(live.fetched_rows ?? 0) || 0;
matchedRowsTotal += Number(live.matched_rows ?? 0) || 0;
returnedRowsTotal += Number(live.returned_rows ?? 0) || 0;
}
const requiredList = Array.from(required).filter(Boolean);
const executedList = executed;
const missingFromExecuted = requiredList.filter((callId) => !executedList.some((item) => String(item.call_id ?? "") === callId));
for (const callId of missingFromExecuted) {
missing.add(callId);
}
const missingList = Array.from(missing);
const routeGapReason = missingList.length > 0
? "required_live_calls_not_executed"
: matchedRowsTotal <= 0
? "claim_live_calls_executed_but_zero_matches"
: routeGaps[0] ?? null;
const executionRate = requiredList.length > 0
? Number(((requiredList.length - missingList.length) / requiredList.length).toFixed(4))
: 1;
return {
claim_type: "prove_fixed_asset_amortization_coverage",
required_live_calls: requiredList,
executed_live_calls: executedList,
missing_live_calls: missingList,
route_gap_reason: routeGapReason,
live_route_execution_rate: executionRate,
fetched_rows_total: fetchedRowsTotal,
matched_rows_total: matchedRowsTotal,
returned_rows_total: returnedRowsTotal,
plan_override: input.planAudit ?? null
};
}
function toDebugRoutes(routeSummary) {
if (!routeSummary) {
return [];
@ -1201,7 +1387,7 @@ function extractNormalizedPeriodLiteral(text) {
}
function extractFollowupAccountAnchorsLoose(text) {
const lower = String(text ?? "").toLowerCase();
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower)];
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower), ...collectContractSpans(lower)];
const anchors = [];
const followupAccountPattern = /\b(?:01|02|08|19|20|21|23|25|26|28|29|44|51|60|62|68|76|97)(?:\.\d{2})?\b/g;
let match = null;
@ -1254,27 +1440,8 @@ function hasCrossScopeConflictWithState(userMessage, state) {
return false;
}
function inferP0DomainFromMessage(text) {
const lower = String(text ?? "").toLowerCase();
const accountTokens = extractAccountTokens(lower);
const hasVatAccount = accountTokens.some((token) => /^(?:19|68)(?:\.|$)/.test(token));
const hasSettlementAccount = accountTokens.some((token) => /^(?:51|60|62|76)(?:\.|$)/.test(token));
const hasMonthCloseAccount = accountTokens.some((token) => /^(?:97|2\d|3\d|4[0-4])(?:\.|$)/.test(token));
const hasFixedAssetAccount = accountTokens.some((token) => /^(?:01|02|08)(?:\.|$)/.test(token));
const vatLexical = /(?:ндс|vat|сч[её]т[\s-]?фактур|книг[аи]\s+(?:покуп|продаж)|налогов)/i.test(lower);
const settlementLexical = /(?:долг|аванс|зач[её]т|взаимозач|расч[её]т|оплат|платеж|платёж|постав|покупател)/i.test(lower);
const monthCloseLexical = /(?:закрыти[ея]\s+месяц|закрытие\s+счетов|регламентн|косвенн|затрат|распределени|рбп|финансовых\s+результат)/i.test(lower);
const fixedAssetLexical = /(?:основн(?:ые|ых)?\s+сред|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|амортиз|depreciat|fixed\s*asset)/i.test(lower);
if (hasVatAccount || vatLexical) {
return "vat_document_register_book";
}
if (fixedAssetLexical || hasFixedAssetAccount) {
return "fixed_asset_amortization";
}
if (monthCloseLexical || hasMonthCloseAccount) {
return "month_close_costs_20_44";
}
if (hasSettlementAccount || settlementLexical) {
return "settlements_60_62";
if (typeof investigationState_1.inferP0DomainFromMessage === "function") {
return investigationState_1.inferP0DomainFromMessage(text);
}
return null;
}
@ -1554,13 +1721,12 @@ export class AssistantService {
routeSummary: normalized.route_hint_summary
});
const inferredDomainByMessage = inferP0DomainFromMessage(userMessage);
const focusDomainForGuards = inferredDomainByMessage === "fixed_asset_amortization"
? "month_close_costs_20_44"
: inferredDomainByMessage === "settlements_60_62" ||
inferredDomainByMessage === "vat_document_register_book" ||
inferredDomainByMessage === "month_close_costs_20_44"
? inferredDomainByMessage
: null;
const focusDomainForGuards = inferredDomainByMessage === "settlements_60_62" ||
inferredDomainByMessage === "vat_document_register_book" ||
inferredDomainByMessage === "month_close_costs_20_44" ||
inferredDomainByMessage === "fixed_asset_amortization"
? inferredDomainByMessage
: null;
const temporalGuard = (0, assistantRuntimeGuards_1.resolveTemporalGuard)({
userMessage,
normalized: normalized.normalized,
@ -1592,6 +1758,12 @@ export class AssistantService {
temporalGuard
});
executionPlan = rbpRoutePlanEnforcement.executionPlan;
const faRoutePlanEnforcement = enforceFaLiveRoutePlan({
executionPlan,
claimType: claimAnchorAudit.claim_type,
temporalGuard
});
executionPlan = faRoutePlanEnforcement.executionPlan;
executionPlan = (0, assistantRuntimeGuards_1.applyTemporalHintToExecutionPlan)(executionPlan, temporalGuard);
executionPlan = (0, assistantRuntimeGuards_1.applyPolarityHintToExecutionPlan)(executionPlan, domainPolarityGuardInitial);
const retrievalCalls = [];
@ -1679,6 +1851,11 @@ export class AssistantService {
retrievalResults,
planAudit: rbpRoutePlanEnforcement.audit
});
const faLiveRouteAudit = collectFaLiveRouteAudit({
claimType: claimAnchorAudit.claim_type,
retrievalResults,
planAudit: faRoutePlanEnforcement.audit
});
const coverageEvaluation = evaluateCoverage(requirementExtraction.requirements, retrievalResults);
const groundingCheckBase = checkGrounding(userMessage, coverageEvaluation.requirements, coverageEvaluation.coverage, retrievalResults);
const groundedAnswerEligibilityGuard = (0, assistantRuntimeGuards_1.evaluateGroundedAnswerEligibility)({
@ -1792,6 +1969,7 @@ export class AssistantService {
targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit,
...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}),
...(faLiveRouteAudit ? { fa_live_route_audit: faLiveRouteAudit } : {}),
eligibility_time_basis: groundedAnswerEligibilityGuard.eligibility_time_basis,
grounded_answer_eligibility_guard: groundedAnswerEligibilityGuard,
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
@ -1884,6 +2062,8 @@ export class AssistantService {
claim_anchor_audit: claimAnchorAudit,
targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit,
...(rbpLiveRouteAudit ? { rbp_live_route_audit: rbpLiveRouteAudit } : {}),
...(faLiveRouteAudit ? { fa_live_route_audit: faLiveRouteAudit } : {}),
eligibility_time_basis: groundedAnswerEligibilityGuard.eligibility_time_basis,
grounded_answer_eligibility_guard: groundedAnswerEligibilityGuard,
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),

View File

@ -112,6 +112,25 @@ function collectDateLikeSpans(text: string): Array<{ start: number; end: number
return spans;
}
function collectContractLikeSpans(text: string): Array<{ start: number; end: number }> {
const spans: Array<{ start: number; end: number }> = [];
const patterns = [
/(?:\b(?:договор(?:а|у|ом|е)?|contract)\b[^\r\n]{0,24}(?:|#|n|no\.?)\s*[a-zа-я0-9][a-zа-я0-9/_-]{1,})/giu,
/(?:|#)\s*[a-zа-я0-9_-]{1,10}\/[a-zа-я0-9_-]{1,12}/giu,
/\b\d{2}\/\d{2}(?:-[a-zа-я]{1,10})?\b/giu
];
for (const pattern of patterns) {
let match: RegExpExecArray | null = null;
while ((match = pattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
}
return spans;
}
function collectAmountLikeSpans(text: string): Array<{ start: number; end: number }> {
const spans: Array<{ start: number; end: number }> = [];
const patterns = [/\b\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?\b/g, /\b\d+[.,]\d{2}\b/g];
@ -154,7 +173,12 @@ function hasAccountContextAround(text: string, start: number, end: number): bool
function detectAccounts(text: string): string[] {
const lower = String(text ?? "").toLowerCase();
const blockedSpans = [...collectDateLikeSpans(lower), ...collectAmountLikeSpans(lower), ...collectPercentLikeSpans(lower)];
const blockedSpans = [
...collectDateLikeSpans(lower),
...collectAmountLikeSpans(lower),
...collectPercentLikeSpans(lower),
...collectContractLikeSpans(lower)
];
const hasAccountingLexeme =
/(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расчет|расч[её]т|аванс|долг|settlement|payment|supplier|customer|ндс|vat|рбп|deferred|амортиз)/iu.test(
lower
@ -217,37 +241,47 @@ function detectPeriod(text: string): string | null {
return null;
}
function detectExplicitDomainHint(text: string): string | null {
export function inferP0DomainFromMessage(text: string): string | null {
const messageCorpus = String(text ?? "").toLowerCase();
const accounts = detectAccounts(text);
const hasSettlementSignal =
accounts.some((item) => isSettlementAccount(item)) ||
/(?:60(?:\.\d{2})?|62(?:\.\d{2})?|оплат|расч[её]т|зач[её]т|аванс|долг|поставщ|покупат|settlement|payment|supplier|customer)/i.test(
const hasSettlementAccount = accounts.some((item) => isSettlementAccount(item));
const hasVatAccount = accounts.some((item) => isVatAccount(item));
const hasCloseAccount = accounts.some((item) => isCloseCostsAccount(item));
const hasFixedAssetAccount = accounts.some((item) => isFixedAssetAccount(item));
const hasSettlementLexical = /(?:60(?:\.\d{2})?|62(?:\.\d{2})?|оплат|расч[её]т|зач[её]т|аванс|долг|поставщ|покупат|settlement|payment|supplier|customer)/i.test(
messageCorpus
);
const hasVatLexical = /(?:ндс|сч[её]т[\s-]?фактур|книг[аи]|vat|invoice|book|register)/i.test(messageCorpus);
const hasCloseLexical =
/(?:закрыти|месяц|затрат|распредел|списан|period\s*close|month\s*close|allocation|residual|cost|рбп)/i.test(messageCorpus);
const hasExplicitFixedAssetLexical =
/(?:амортиз|основн(ые|ых|ым)?\s+средств|объект[а-яё]*\s+ос|fixed\s*asset|depreciat|сч[её]т(?:а|у|ом|е)?\s*(?:01|02|08)|account\s*0[128])/i.test(
messageCorpus
);
if (hasSettlementSignal) {
const hasBroadMonthCloseLexical =
/(?:после\s+закрытия|косвенн|период(?:а)?\s+закрыт|month\s*close|period\s*close|регламентн)/i.test(messageCorpus);
// Keep settlement lane stable when 60/62 lexical/account anchors are explicit
// and there is no explicit VAT intent.
if ((hasSettlementAccount || hasSettlementLexical) && !hasVatLexical && !hasVatAccount && !hasExplicitFixedAssetLexical) {
return "settlements_60_62";
}
const hasVatSignal =
accounts.some((item) => isVatAccount(item)) ||
/(?:ндс|сч[её]т[\s-]?фактур|книг[аи]|vat|invoice|book|register)/i.test(messageCorpus);
if (hasVatSignal) {
return "vat_document_register_book";
}
const hasCloseSignal =
accounts.some((item) => isCloseCostsAccount(item)) ||
/(?:закрыти|месяц|затрат|распредел|списан|period\s*close|month\s*close|allocation|residual|cost|рбп)/i.test(messageCorpus);
if (hasCloseSignal) {
if ((hasCloseAccount || hasCloseLexical || hasBroadMonthCloseLexical) && !hasVatLexical && !hasVatAccount && !hasExplicitFixedAssetLexical && !hasFixedAssetAccount) {
return "month_close_costs_20_44";
}
const hasFixedAssetSignal =
accounts.some((item) => isFixedAssetAccount(item)) ||
/(?:амортиз|основн(ые|ых|ым)?\s+средств|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|объект[а-яё]*\s+ос|fixed\s*asset|depreciat)/i.test(
messageCorpus
);
if (hasFixedAssetSignal) {
if (hasVatAccount || hasVatLexical) {
return "vat_document_register_book";
}
if (hasFixedAssetAccount || hasExplicitFixedAssetLexical) {
return "fixed_asset_amortization";
}
if (hasCloseAccount || hasCloseLexical) {
return "month_close_costs_20_44";
}
if (hasSettlementAccount || hasSettlementLexical) {
return "settlements_60_62";
}
return null;
}
@ -283,7 +317,7 @@ function deriveScopeOrigin(input: {
}
const hasExplicitPeriod = Boolean(detectPeriod(input.userMessage));
const hasExplicitAccounts = detectAccounts(input.userMessage).length > 0;
const explicitDomain = detectExplicitDomainHint(input.userMessage);
const explicitDomain = inferP0DomainFromMessage(input.userMessage);
if (hasExplicitPeriod || hasExplicitAccounts || explicitDomain) {
return "explicit_from_message";
}
@ -406,7 +440,7 @@ function inferFollowupActiveDomain(input: {
: messageCorpus;
const hasFixedAssetLexicalSignal =
/(?:амортиз|основн(ые|ых|ым)?\s+средств|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|объект[а-яё]*\s+ос|fixed\s*asset|depreciat)/i.test(
/(?:амортиз|основн(ые|ых|ым)?\s+средств|объект[а-яё]*\s+ос|fixed\s*asset|depreciat|сч[её]т(?:а|у|ом|е)?\s*(?:01|02|08)|account\s*0[128])/i.test(
messageCorpus
);
const hasFixedAssetAccountSignal =
@ -414,6 +448,17 @@ function inferFollowupActiveDomain(input: {
/(?:сч[её]т(?:а|у|ом|е)?\s*(?:01|02|08)|(?:01|02|08)(?:\.\d{2})?\s*\/\s*(?:01|02|08)(?:\.\d{2})?|\b0[128](?:\.\d{2})?\b)/i.test(
messageCorpus
);
const hasBroadMonthCloseSignal =
/(?:после\s+закрытия|косвенн|период(?:а)?\s+закрыт|регламентн|month\s*close|period\s*close)/i.test(messageCorpus);
if (
(input.focusAccounts.some((item) => isCloseCostsAccount(item)) ||
/(?:закрыти|месяц|затрат|распредел|списан|period\s*close|month\s*close|allocation|residual|cost)/i.test(messageCorpus) ||
hasBroadMonthCloseSignal) &&
!hasFixedAssetLexicalSignal &&
!hasFixedAssetAccountSignal
) {
return "month_close_costs_20_44";
}
if (hasFixedAssetLexicalSignal || hasFixedAssetAccountSignal) {
return "fixed_asset_amortization";
}

View File

@ -117,7 +117,9 @@ export interface ClaimBoundAnchorAuditDebug {
| "prove_advance_offset_state"
| "prove_vat_chain_completeness"
| "prove_month_close_state"
| "prove_rbp_tail_state";
| "prove_rbp_tail_state"
| "prove_fixed_asset_amortization_coverage";
settlement_role?: "supplier" | "customer" | "mixed" | "unknown";
required_anchors: string[];
resolved_anchors: Record<string, string[]>;
missing_anchors: string[];
@ -144,13 +146,26 @@ export interface TargetedEvidenceAcquisitionDebug {
| "prove_advance_offset_state"
| "prove_vat_chain_completeness"
| "prove_month_close_state"
| "prove_rbp_tail_state";
| "prove_rbp_tail_state"
| "prove_fixed_asset_amortization_coverage";
required_checks: string[];
check_status: Record<string, "found" | "not_found">;
targeted_item_hits: number;
targeted_evidence_hits: number;
targeted_evidence_hit_rate: number;
targeted_evidence_source_refs: string[];
fa_expected_set?: string[];
fa_actual_set_from_amortization?: string[];
fa_missing_candidates?: string[];
fa_uncertain_candidates?: string[];
fa_relation_map?: Array<{
fa_object: string;
document_amortization: string[];
movement: boolean;
posting: boolean;
period: string[];
coverage_status: "covered" | "missing" | "uncertain";
}>;
reason_codes: string[];
}
@ -220,6 +235,19 @@ export interface RbpLiveRouteAuditDebug {
plan_override: Record<string, unknown> | null;
}
export interface FaLiveRouteAuditDebug {
claim_type: "prove_fixed_asset_amortization_coverage";
required_live_calls: string[];
executed_live_calls: Array<Record<string, unknown>>;
missing_live_calls: string[];
route_gap_reason: string | null;
live_route_execution_rate: number;
fetched_rows_total: number;
matched_rows_total: number;
returned_rows_total: number;
plan_override: Record<string, unknown> | null;
}
export interface AssistantMessageRequestPayload {
session_id?: string;
user_message?: string;
@ -316,6 +344,7 @@ export interface AssistantDebugPayload {
targeted_evidence_acquisition?: TargetedEvidenceAcquisitionDebug;
evidence_admissibility_gate?: EvidenceAdmissibilityGateDebug;
rbp_live_route_audit?: RbpLiveRouteAuditDebug;
fa_live_route_audit?: FaLiveRouteAuditDebug;
eligibility_time_basis?: GroundedAnswerEligibilityGuardDebug["eligibility_time_basis"];
grounded_answer_eligibility_guard?: GroundedAnswerEligibilityGuardDebug;
followup_state_usage?: FollowupStateUsageDebug;

View File

@ -201,7 +201,111 @@ describe.sequential("assistant MCP runtime bridge", () => {
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
expect(Array.isArray(liveSummary.executed_live_calls)).toBe(true);
expect((liveSummary.executed_live_calls as unknown[]).length).toBe(4);
const rbpCallLimits = fetchMock.mock.calls.map(([, requestInit]) => {
const init = requestInit as { body?: string };
return Number(JSON.parse(String(init.body ?? "{}")).limit ?? 0);
});
expect(rbpCallLimits).toEqual([96, 96, 96, 128]);
expect(liveSummary.matched_rows).toBeGreaterThan(0);
expect(result.items.some((item) => Array.isArray((item as Record<string, unknown>).relation_pattern_hits))).toBe(true);
});
it("uses claim-bound live call sequence for fixed-asset amortization coverage query", async () => {
process.env[MCP_FLAG] = "1";
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
process.env[MCP_CHANNEL] = "default";
const payload = JSON.stringify({
success: true,
data: [
{
period: "2020-07-31T00:00:00",
registrator: "Начисление амортизации Июль 2020",
account_dt: "20.01",
account_kt: "02.01",
amount: 2471.52
},
{
period: "2020-07-31T00:00:00",
registrator: "Начисление амортизации Июль 2020",
account_dt: "20.01",
account_kt: "02.01",
amount: 2465.28
}
]
});
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime(
"hybrid_store_plus_live",
"31 июля начислена амортизация тремя суммами — 2 471,52, 2 465,28 и 849,83. Есть риск, что объект ОС не попал в амортизацию?"
);
expect(fetchMock).toHaveBeenCalledTimes(4);
const summary = result.summary as Record<string, unknown>;
const liveSummary = summary.live_mcp as Record<string, unknown>;
expect(liveSummary.claim_type).toBe("prove_fixed_asset_amortization_coverage");
expect(liveSummary.source_profile).toBe("claim_bound_fa_live_path");
expect(Array.isArray(liveSummary.required_live_calls)).toBe(true);
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
expect(Array.isArray(liveSummary.executed_live_calls)).toBe(true);
expect((liveSummary.executed_live_calls as unknown[]).length).toBe(4);
const faCallLimits = fetchMock.mock.calls.map(([, requestInit]) => {
const init = requestInit as { body?: string };
return Number(JSON.parse(String(init.body ?? "{}")).limit ?? 0);
});
expect(faCallLimits).toEqual([96, 96, 128, 128]);
expect(liveSummary.matched_rows).toBeGreaterThan(0);
expect(result.items.some((item) => (item as Record<string, unknown>).fa_expected_set_candidate === true)).toBe(true);
expect(result.items.some((item) => (item as Record<string, unknown>).fa_actual_set_candidate === true)).toBe(true);
});
it("uses claim-bound VAT live path instead of supplier-tail generic probe for VAT chain query", async () => {
process.env[MCP_FLAG] = "1";
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
process.env[MCP_CHANNEL] = "default";
const payload = JSON.stringify({
success: true,
data: [
{
period: "2020-07-15T00:00:00",
registrator: "Реализация товаров 0001",
account_dt: "62.01",
account_kt: "90.01",
amount: 1400
},
{
period: "2020-07-15T00:00:00",
registrator: "Счет-фактура выданный 0001",
account_dt: "90.03",
account_kt: "68.02",
amount: 233.33
}
]
});
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime(
"hybrid_store_plus_live",
"По поставщику и счету-фактуре проверь НДС-цепочку: есть ли выпадение между документом, регистром и книгой покупок?"
);
expect(fetchMock).toHaveBeenCalledTimes(4);
const summary = result.summary as Record<string, unknown>;
const liveSummary = summary.live_mcp as Record<string, unknown>;
expect(liveSummary.claim_type).toBe("prove_vat_chain_completeness");
expect(liveSummary.query_subject).toBe("vat_chain_conflict");
expect(liveSummary.source_profile).toBe("claim_bound_vat_live_path");
expect(Array.isArray(liveSummary.required_live_calls)).toBe(true);
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
expect(Array.isArray(liveSummary.account_scope)).toBe(true);
expect(liveSummary.account_scope).toEqual(["19", "68"]);
});
});

View File

@ -10,6 +10,8 @@ import {
resolveDomainPolarityGuard,
resolveTemporalGuard
} from "../src/services/assistantRuntimeGuards";
import { applyTargetedEvidenceAcquisition, resolveClaimBoundAnchors } from "../src/services/assistantClaimBoundEvidence";
import { inferP0DomainFromMessage } from "../src/services/investigationState";
function buildProblemUnit(input: {
id: string;
@ -129,7 +131,7 @@ function buildRetrieval(input?: Partial<any>): any {
describe("stage4 blocker-pack runtime guards", () => {
it("flags temporal anchor drift outside July 2020 snapshot", () => {
const userMessage = "Почему РїРѕ оплате РѕС 6 июля 2020 долг РїРѕ поставщику остался?";
const userMessage = "Why supplier debt was not closed after payment on 06.07.2020?";
const temporal = resolveTemporalGuard({
userMessage,
companyAnchors: resolveCompanyAnchors(userMessage),
@ -160,7 +162,7 @@ describe("stage4 blocker-pack runtime guards", () => {
});
it("locks July month window when question has month-only anchor", () => {
const userMessage = "Риюльском срезе почему по счету 60 остался хвост?";
const userMessage = "In July snapshot why does account 60 still have an open tail?";
const temporal = resolveTemporalGuard({
userMessage,
companyAnchors: resolveCompanyAnchors(userMessage),
@ -182,7 +184,7 @@ describe("stage4 blocker-pack runtime guards", () => {
expect(temporal.temporal_guard_outcome).toBe("passed");
expect(temporal.resolved_time_anchor).toBe("2020-07");
expect(temporal.effective_primary_period?.from).toBe("2020-07-01");
expect(hintedPlan[0].fragment_text).toMatch(/июля 2020|2020-07-01/);
expect(hintedPlan[0].fragment_text).toMatch(/2020-07-01|july 2020/i);
});
it("filters customer settlement semantics from supplier/payable case", () => {
@ -409,5 +411,100 @@ describe("stage4 blocker-pack runtime guards", () => {
expect(grounded.status).toBe("no_grounded_answer");
expect(grounded.reasons.join(" ")).toMatch(/Недостаточно допустимого evidence|Temporal anchor/i);
});
it("reconstructs fixed-asset expected vs actual coverage in claim-bound targeting", () => {
const userMessage =
"31 июля начислена амортизация тремя суммами — 2 471,52, 2 465,28 и 849,83. Есть риск, что объект ОС не попал в амортизацию?";
const claimAudit = resolveClaimBoundAnchors({
userMessage,
focusDomainHint: "fixed_asset_amortization",
companyAnchors: resolveCompanyAnchors(userMessage),
primaryPeriod: {
from: "2020-07-31",
to: "2020-07-31",
granularity: "day"
}
});
expect(claimAudit.claim_type).toBe("prove_fixed_asset_amortization_coverage");
expect(claimAudit.required_anchors).toContain("fixed_asset_signal");
expect(claimAudit.claim_anchor_resolution_rate).toBeGreaterThan(0.7);
const targeted = applyTargetedEvidenceAcquisition({
claimAudit,
retrievalResults: [
buildRetrieval({
items: [
{
source_entity: "MCPLiveMovement",
source_id: "fa-1",
display_name: "Станок A",
period: "2020-07-31",
account_debit: "20.01",
account_credit: "02.01",
relation_pattern_hits: ["asset_card_to_depreciation", "document_to_posting"],
fa_object_hint: "Станок A",
fa_expected_set_candidate: true,
fa_actual_set_candidate: true,
fa_coverage_status: "covered"
},
{
source_entity: "MCPLiveMovement",
source_id: "fa-2",
display_name: "Станок B",
period: "2020-07-31",
account_debit: "20.01",
account_credit: "02.01",
relation_pattern_hits: ["asset_card_to_depreciation"],
fa_object_hint: "Станок B",
fa_expected_set_candidate: true,
fa_actual_set_candidate: false,
fa_coverage_status: "expected_only"
}
],
evidence: []
})
]
});
expect(targeted.audit.check_status.expected_fa_set_reconstructed).toBe("found");
expect(targeted.audit.check_status.actual_fa_set_reconstructed).toBe("found");
expect(targeted.audit.check_status.movement_or_posting_link_found).toBe("found");
expect(Array.isArray(targeted.audit.fa_expected_set)).toBe(true);
expect(targeted.audit.fa_expected_set).toContain("станок a");
expect(targeted.audit.fa_expected_set).toContain("станок b");
expect(targeted.audit.fa_actual_set_from_amortization).toContain("станок a");
expect(targeted.audit.fa_missing_candidates).toContain("станок b");
expect((targeted.audit.fa_relation_map ?? []).length).toBeGreaterThan(0);
});
it("does not misclassify settlement 62.02 question into VAT or FA claim paths", () => {
const userMessage =
"Покупатель перечислил аванс на 62.02, но закрытие не произошло. Есть ли хвост по расчетам?";
const claimAudit = resolveClaimBoundAnchors({
userMessage,
focusDomainHint: "settlements_60_62",
companyAnchors: resolveCompanyAnchors(userMessage),
primaryPeriod: {
from: "2020-07-01",
to: "2020-07-31",
granularity: "month"
}
});
expect(claimAudit.claim_type).toBe("prove_advance_offset_state");
expect(claimAudit.resolved_anchors.vat_signal).toHaveLength(0);
expect(claimAudit.resolved_anchors.fixed_asset_signal).toHaveLength(0);
expect(claimAudit.required_anchors).toContain("advance_signal");
});
it("keeps VAT priority over supplier wording in shared domain inference", () => {
const vatQuestion =
"По поставщику и счету-фактуре проверь НДС-цепочку: есть ли выпадение между документом, регистром и книгой покупок?";
const settlementQuestion = "Покупатель перечислил аванс на 62.02, но закрытие не произошло. Есть ли хвост?";
expect(inferP0DomainFromMessage(vatQuestion)).toBe("vat_document_register_book");
expect(inferP0DomainFromMessage(settlementQuestion)).toBe("settlements_60_62");
});
});

Binary file not shown.