diff --git a/IN/Stage 4 - Family Card v1 FA.md b/IN/Stage 4 - Family Card v1 FA.md new file mode 100644 index 0000000..8169abf --- /dev/null +++ b/IN/Stage 4 - Family Card v1 FA.md @@ -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 канале. diff --git a/IN/Stage 4 - Family Card v1 RBP.md b/IN/Stage 4 - Family Card v1 RBP.md new file mode 100644 index 0000000..9155f75 --- /dev/null +++ b/IN/Stage 4 - Family Card v1 RBP.md @@ -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 в живом канале. diff --git a/IN/Stage 4 - Family Card v1 — VAT chain.md b/IN/Stage 4 - Family Card v1 — VAT chain.md new file mode 100644 index 0000000..20b8fc0 --- /dev/null +++ b/IN/Stage 4 - Family Card v1 — VAT chain.md @@ -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-пакет. diff --git a/IN/ВОПРОСЫ 2020 07.docx b/IN/ВОПРОСЫ 2020 07.docx new file mode 100644 index 0000000..fc13469 Binary files /dev/null and b/IN/ВОПРОСЫ 2020 07.docx differ diff --git a/IN/ВОПРОСЫ_2020_07_extracted.txt b/IN/ВОПРОСЫ_2020_07_extracted.txt new file mode 100644 index 0000000..7e3a5b5 --- /dev/null +++ b/IN/ВОПРОСЫ_2020_07_extracted.txt @@ -0,0 +1,34 @@ +20 вопросов по вашей компании и июльскому снапшоту +1. Расчёты / банк / 60–62 +Почему 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 — что из остатков является реальной проблемой, а что нет. diff --git a/IN/Этап 4 — corrective pack по family isolation.md b/IN/Этап 4 — corrective pack по family isolation.md new file mode 100644 index 0000000..a9baae6 --- /dev/null +++ b/IN/Этап 4 — corrective pack по family isolation.md @@ -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/` + +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 с сопоставимыми метриками и артефактами. diff --git a/IN/Этап 4 — пакет по амортизации.md b/IN/Этап 4 — пакет по амортизации.md new file mode 100644 index 0000000..0e27a73 --- /dev/null +++ b/IN/Этап 4 — пакет по амортизации.md @@ -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 вывод. \ No newline at end of file diff --git a/docs/accounting-assistant/accounting-assistant/02_stages/ЭТАП_4_переход_к_семействам_вопросов_и_source_to_proof_контрактам.md b/docs/accounting-assistant/accounting-assistant/02_stages/ЭТАП_4_переход_к_семействам_вопросов_и_source_to_proof_контрактам.md new file mode 100644 index 0000000..d5ca40a --- /dev/null +++ b/docs/accounting-assistant/accounting-assistant/02_stages/ЭТАП_4_переход_к_семействам_вопросов_и_source_to_proof_контрактам.md @@ -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). diff --git a/docs/accounting-assistant/accounting-assistant/03_execution/FAMILY_CARD_TEMPLATE.md b/docs/accounting-assistant/accounting-assistant/03_execution/FAMILY_CARD_TEMPLATE.md new file mode 100644 index 0000000..6a9c6b4 --- /dev/null +++ b/docs/accounting-assistant/accounting-assistant/03_execution/FAMILY_CARD_TEMPLATE.md @@ -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. diff --git a/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_DECISION_NOTE_FAMILY_BASED_EXECUTION.md b/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_DECISION_NOTE_FAMILY_BASED_EXECUTION.md new file mode 100644 index 0000000..8f9c7d5 --- /dev/null +++ b/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_DECISION_NOTE_FAMILY_BASED_EXECUTION.md @@ -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 контракты.` diff --git a/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_FAMILY_BACKLOG_2026-03-29.md b/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_FAMILY_BACKLOG_2026-03-29.md new file mode 100644 index 0000000..fe4e386 --- /dev/null +++ b/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_FAMILY_BACKLOG_2026-03-29.md @@ -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 без отдельного проектного решения. diff --git a/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_FAMILY_REGISTRY_2026-03-29.json b/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_FAMILY_REGISTRY_2026-03-29.json new file mode 100644 index 0000000..426bda7 --- /dev/null +++ b/docs/accounting-assistant/accounting-assistant/03_execution/STAGE_04_FAMILY_REGISTRY_2026-03-29.json @@ -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" + } +} diff --git a/llm_normalizer/backend/dist/services/assistantClaimBoundEvidence.js b/llm_normalizer/backend/dist/services/assistantClaimBoundEvidence.js index 3c3908e..b77f772 100644 --- a/llm_normalizer/backend/dist/services/assistantClaimBoundEvidence.js +++ b/llm_normalizer/backend/dist/services/assistantClaimBoundEvidence.js @@ -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) } }; diff --git a/llm_normalizer/backend/dist/services/assistantDataLayer.js b/llm_normalizer/backend/dist/services/assistantDataLayer.js index 58f96a1..0e3f99f 100644 --- a/llm_normalizer/backend/dist/services/assistantDataLayer.js +++ b/llm_normalizer/backend/dist/services/assistantDataLayer.js @@ -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 }; diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js index d7e8388..fb00fba 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js @@ -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) { diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index d572eaf..11dbe71 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -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 } : {}), diff --git a/llm_normalizer/backend/dist/services/investigationState.js b/llm_normalizer/backend/dist/services/investigationState.js index 902bb1e..aaad94a 100644 --- a/llm_normalizer/backend/dist/services/investigationState.js +++ b/llm_normalizer/backend/dist/services/investigationState.js @@ -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"; } diff --git a/llm_normalizer/backend/scripts/faPackExportArtifacts.js b/llm_normalizer/backend/scripts/faPackExportArtifacts.js new file mode 100644 index 0000000..4e4b6ae --- /dev/null +++ b/llm_normalizer/backend/scripts/faPackExportArtifacts.js @@ -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 [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(); + diff --git a/llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts b/llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts index 9dd3deb..6674a0a 100644 --- a/llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts +++ b/llm_normalizer/backend/src/services/assistantClaimBoundEvidence.ts @@ -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; 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 { + 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: { 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 { 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; + 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 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 = { 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 { 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; +} { + const state = new Map< + string, + { + expected: boolean; + actual: boolean; + movement: boolean; + posting: boolean; + docs: Set; + periods: Set; + } + >(); + + 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(), + 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: "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) } }; } + + diff --git a/llm_normalizer/backend/src/services/assistantDataLayer.ts b/llm_normalizer/backend/src/services/assistantDataLayer.ts index 53869c3..3ccbcf5 100644 --- a/llm_normalizer/backend/src/services/assistantDataLayer.ts +++ b/llm_normalizer/backend/src/services/assistantDataLayer.ts @@ -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> = []; 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 }; diff --git a/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts b/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts index 8873bec..2ae7aea 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts @@ -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 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 } : {}), diff --git a/llm_normalizer/backend/src/services/investigationState.ts b/llm_normalizer/backend/src/services/investigationState.ts index 17d9eba..da49808 100644 --- a/llm_normalizer/backend/src/services/investigationState.ts +++ b/llm_normalizer/backend/src/services/investigationState.ts @@ -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"; } diff --git a/llm_normalizer/backend/src/types/assistant.ts b/llm_normalizer/backend/src/types/assistant.ts index bfc5d9e..d049685 100644 --- a/llm_normalizer/backend/src/types/assistant.ts +++ b/llm_normalizer/backend/src/types/assistant.ts @@ -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; 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; 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 | null; } +export interface FaLiveRouteAuditDebug { + claim_type: "prove_fixed_asset_amortization_coverage"; + required_live_calls: string[]; + executed_live_calls: Array>; + 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 | 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; diff --git a/llm_normalizer/backend/tests/assistantMcpRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpRuntimeBridge.test.ts index b362ba8..43d1124 100644 --- a/llm_normalizer/backend/tests/assistantMcpRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpRuntimeBridge.test.ts @@ -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).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; + const liveSummary = summary.live_mcp as Record; + 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).fa_expected_set_candidate === true)).toBe(true); + expect(result.items.some((item) => (item as Record).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; + const liveSummary = summary.live_mcp as Record; + 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"]); + }); }); diff --git a/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts b/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts index 5c922f0..e971d2f 100644 --- a/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts +++ b/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts @@ -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 { 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"); + }); }); diff --git a/llm_normalizer/docs/runs/2.zip b/llm_normalizer/docs/runs/2.zip new file mode 100644 index 0000000..7cac080 Binary files /dev/null and b/llm_normalizer/docs/runs/2.zip differ diff --git a/llm_normalizer/docs/runs/2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix.zip b/llm_normalizer/docs/runs/2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix.zip deleted file mode 100644 index e769fc1..0000000 Binary files a/llm_normalizer/docs/runs/2026-03-29_Stage_04_RBP_Pack_Live_Source_To_Proof_Fix.zip and /dev/null differ diff --git a/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_1_Live_Alignment_Fix_Claim_Bound_Runtime.zip b/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_1_Live_Alignment_Fix_Claim_Bound_Runtime.zip deleted file mode 100644 index 034ed15..0000000 Binary files a/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_1_Live_Alignment_Fix_Claim_Bound_Runtime.zip and /dev/null differ diff --git a/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt.zip b/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt.zip deleted file mode 100644 index 88e1f30..0000000 Binary files a/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_2_Live_Runtime_Fix_Replay_1txt.zip and /dev/null differ