From 429bd3d8ece6f9ba9a5983672615f2199701b8ab Mon Sep 17 00:00:00 2001 From: dctouch Date: Tue, 21 Apr 2026 18:43:05 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20contin?= =?UTF-8?q?uity=20=D0=B8=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20exact-=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B=20=D0=BE?= =?UTF-8?q?=D1=82=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ness_phase20_continuity_stabilization.json | 138 ++++++++++++++++++ .../assistantMcpDiscoveryResponsePolicy.js | 65 +++++++++ .../services/assistantTurnMeaningPolicy.js | 52 +++++-- .../assistantMcpDiscoveryResponsePolicy.ts | 79 ++++++++++ .../services/assistantTurnMeaningPolicy.ts | 30 +++- ...ssistantMcpDiscoveryResponsePolicy.test.ts | 96 +++++++++++- .../tests/assistantTransitionPolicy.test.ts | 119 +++++++++++++++ .../tests/assistantTurnMeaningPolicy.test.ts | 42 +++++- 8 files changed, 603 insertions(+), 18 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase20_continuity_stabilization.json diff --git a/docs/orchestration/address_truth_harness_phase20_continuity_stabilization.json b/docs/orchestration/address_truth_harness_phase20_continuity_stabilization.json new file mode 100644 index 0000000..efcccea --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase20_continuity_stabilization.json @@ -0,0 +1,138 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase20_continuity_stabilization", + "domain": "address_phase20_continuity_stabilization", + "title": "Phase 20 continuity stabilization replay", + "description": "Targeted AGENT replay for the continuity stabilization slice after assistant-stage1--I0x_DLqDb. The scenario validates that temporal tail words no longer turn into pseudo-counterparties, exact debt snapshots are not overwritten by discovery, and VAT/date follow-ups keep the previous period instead of drifting into unrelated carryover.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_top_client_all_time", + "title": "All-time top client question stays in ranking semantics and does not invent counterparty time", + "question": "кто у нас самый доходный клиент за все время?", + "required_answer_patterns_any": [ + "(?i)клиент|контрагент", + "(?i)доходн|выручк|заработ" + ], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент.*время", + "(?i)контрагент с названием\\s+\"?время\"?" + ], + "criticality": "critical", + "semantic_tags": [ + "value_flow_ranking", + "temporal_tail_not_entity" + ] + }, + { + "step_id": "step_02_top_year_all_time", + "title": "Top year question stays in yearly ranking semantics and does not invent pseudo-entity year", + "question": "какой у нас самый доходный год?", + "required_answer_patterns_any": [ + "(?i)20\\d{2}|19\\d{2}", + "(?i)доходн|выручк|заработ" + ], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент.*год", + "(?i)контрагент с названием\\s+\"?год\"?" + ], + "criticality": "critical", + "semantic_tags": [ + "value_flow_ranking", + "year_tail_not_entity" + ] + }, + { + "step_id": "step_03_receivables_as_of_may_2017", + "title": "Receivables snapshot for May 2017 answers directly instead of being overwritten by discovery", + "question": "кто нам должен денег на май 2017?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "required_direct_answer_patterns_any": [ + "(?i)долж", + "(?i)дебитор", + "(?i)задолж" + ], + "forbidden_direct_answer_patterns": [ + "(?i)partial_coverage", + "(?i)не удалось ответить", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": [ + "receivables_snapshot", + "exact_not_overwritten" + ] + }, + { + "step_id": "step_04_vat_for_same_period", + "title": "VAT payable follow-up keeps the carried May 2017 period", + "question": "а какой ндс мы должны примерно заплатить за этот период?", + "required_answer_patterns_all": [ + "(?i)ндс", + "(?i)май|2017", + "(?i)заплат|к уплате|уплат" + ], + "forbidden_answer_patterns": [ + "(?i)текущ(ий|его) период", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": [ + "vat_followup", + "period_carryover" + ] + }, + { + "step_id": "step_05_payables_today", + "title": "Today payables snapshot answers directly and keeps debt semantics", + "question": "мы должны комуто денег на сегодня?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "required_direct_answer_patterns_any": [ + "(?i)должны", + "(?i)кредитор", + "(?i)задолж", + "(?i)долг к оплате|к оплате" + ], + "forbidden_direct_answer_patterns": [ + "(?i)partial_coverage", + "(?i)не удалось ответить", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": [ + "payables_snapshot", + "exact_not_overwritten" + ] + }, + { + "step_id": "step_06_receivables_pronoun_followup", + "title": "Short follow-up a nam resolves to mirrored receivables instead of fake counterparty time", + "question": "а нам?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "required_direct_answer_patterns_any": [ + "(?i)нам должны", + "(?i)дебитор", + "(?i)задолж" + ], + "forbidden_direct_answer_patterns": [ + "(?i)не найден контрагент.*время", + "(?i)контрагент с названием\\s+\"?время\"?", + "(?i)partial_coverage" + ], + "criticality": "critical", + "semantic_tags": [ + "pronoun_followup", + "garbage_anchor_forbidden" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index a002078..df26456 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -89,6 +89,56 @@ function isDiscoveryReadyAddressCandidate(input, entryPoint) { turnInput?.should_run_discovery === true && (source === "address_lane" || source === "address_exact" || source === "address_query_runtime_v1")); } +function hasAlignedFactualAddressReply(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (toNonEmptyString(input.currentReplyType) !== "factual") { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const turnInput = toRecordObject(entryPoint?.turn_input); + const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family); + if (detectedIntent === "counterparty_activity_lifecycle") { + return askedDomain === "counterparty_lifecycle" || askedAction === "activity_duration"; + } + if (detectedIntent === "supplier_payouts_profile") { + return askedDomain === "counterparty_value" && askedAction === "payout"; + } + if (detectedIntent === "customer_revenue_and_payments") { + return askedDomain === "counterparty_value" && askedAction === "turnover"; + } + return false; +} +function hasMatchedFactualAddressContinuationTarget(input) { + if (toNonEmptyString(input.currentReplyType) !== "factual") { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const dialogContinuationContract = toRecordObject(input.addressRuntimeMeta?.dialogContinuationContract); + const targetIntent = toNonEmptyString(dialogContinuationContract?.target_intent); + return Boolean(detectedIntent && targetIntent && detectedIntent === targetIntent); +} +function hasFullConfirmedFactualAddressReply(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (toNonEmptyString(input.currentReplyType) !== "factual") { + return false; + } + const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); + if (truthGateStatus === "full_confirmed") { + return true; + } + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); + const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); + const coverageStatus = toNonEmptyString(truthGate?.coverage_status); + const groundingStatus = toNonEmptyString(truthGate?.grounding_status); + return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded"); +} function applyAssistantMcpDiscoveryResponsePolicy(input) { const currentReply = String(input.currentReply ?? ""); const currentReplySource = toNonEmptyString(input.currentReplySource) ?? toNonEmptyString(input.livingChatSource) ?? "unknown"; @@ -99,6 +149,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const discoveryReadyChatCandidate = isDiscoveryReadyChatCandidate(input, entryPoint); const discoveryReadyDeepCandidate = isDiscoveryReadyDeepCandidate(input, entryPoint); const discoveryReadyAddressCandidate = isDiscoveryReadyAddressCandidate(input, entryPoint); + const alignedFactualAddressReply = hasAlignedFactualAddressReply(input, entryPoint); + const matchedFactualAddressContinuationTarget = hasMatchedFactualAddressContinuationTarget(input); + const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); } @@ -114,6 +167,15 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (!discoveryReadyAddressCandidate) { pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_address_candidate"); } + if (alignedFactualAddressReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_aligned_factual_address_reply"); + } + if (matchedFactualAddressContinuationTarget) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_factual_address_continuation_target"); + } + if (fullConfirmedFactualAddressReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); + } if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); } @@ -128,6 +190,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { } const canApply = Boolean(entryPoint) && (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate || discoveryReadyAddressCandidate) && + !alignedFactualAddressReply && + !matchedFactualAddressContinuationTarget && + !fullConfirmedFactualAddressReply && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && Boolean(toNonEmptyString(candidate.reply_text)) && diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index c3a538b..08ee035 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -7,7 +7,10 @@ const SUPPORTED_ADDRESS_INTENTS = new Set([ "payables_confirmed_as_of_date", "list_documents_by_counterparty", "customer_revenue_and_payments", - "inventory_on_hand_as_of_date" + "inventory_on_hand_as_of_date", + "vat_liability_confirmed_for_tax_period", + "vat_payable_confirmed_as_of_date", + "vat_payable_forecast" ]); function fallbackCompactWhitespace(value) { return String(value ?? "").replace(/\s+/g, " ").trim(); @@ -85,8 +88,23 @@ function detectCounterpartyTurnoverFamily(text) { "\u0434\u043e\u0445\u043e\u0434", "\u0431\u044b\u043b", "\u0431\u044b\u043b\u0430", + "\u0432\u0440\u0435\u043c\u044f", + "\u0432\u0440\u0435\u043c\u0435\u043d\u0438", + "\u0433\u043e\u0434", + "\u0433\u043e\u0434\u0430", + "\u043f\u0435\u0440\u0438\u043e\u0434", + "\u043f\u0435\u0440\u0438\u043e\u0434\u0430", + "\u043c\u0435\u0441\u044f\u0446", + "\u043c\u0435\u0441\u044f\u0446\u0430", + "\u043a\u0432\u0430\u0440\u0442\u0430\u043b", + "\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430", "turnover", - "revenue" + "revenue", + "time", + "year", + "period", + "month", + "quarter" ]); const entity = rawEntity && !ignored.has(rawEntity) ? rawEntity : null; return { @@ -136,22 +154,30 @@ function createAssistantTurnMeaningPolicy(deps = {}) { ? "receivables" : explicitIntentCandidate?.startsWith("payables_") ? "payables" - : explicitIntentCandidate?.startsWith("inventory_") - ? "inventory" - : explicitIntentCandidate?.includes("counterparty") - ? "counterparty" - : counterpartyTurnover?.family + : explicitIntentCandidate?.startsWith("vat_") + ? "vat" + : explicitIntentCandidate?.startsWith("inventory_") + ? "inventory" + : explicitIntentCandidate?.includes("counterparty") ? "counterparty" - : null; + : counterpartyTurnover?.family + ? "counterparty" + : null; const askedActionFamily = explicitIntentCandidate === "receivables_confirmed_as_of_date" || explicitIntentCandidate === "payables_confirmed_as_of_date" || explicitIntentCandidate === "inventory_on_hand_as_of_date" ? "confirmed_snapshot" - : explicitIntentCandidate === "list_documents_by_counterparty" - ? "list_documents" - : counterpartyTurnover?.family - ? "counterparty_value_or_turnover" - : null; + : explicitIntentCandidate === "vat_liability_confirmed_for_tax_period" + ? "confirmed_tax_period" + : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" + ? "confirmed_snapshot" + : explicitIntentCandidate === "vat_payable_forecast" + ? "forecast" + : explicitIntentCandidate === "list_documents_by_counterparty" + ? "list_documents" + : counterpartyTurnover?.family + ? "counterparty_value_or_turnover" + : null; const staleReplayForbidden = Boolean(unsupportedFamily || (counterpartyTurnover?.entity && !explicitIntentCandidate)); return { schema_version: "assistant_turn_meaning_v1", diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index d207ad5..e1410b6 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -13,6 +13,7 @@ export type AssistantMcpDiscoveryResponsePolicyDecision = "apply_candidate" | "k export interface ApplyAssistantMcpDiscoveryResponsePolicyInput { currentReply: string; currentReplySource?: string | null; + currentReplyType?: string | null; livingChatSource?: string | null; modeDecisionReason?: string | null; addressRuntimeMeta?: Record | null; @@ -151,6 +152,69 @@ function isDiscoveryReadyAddressCandidate( ); } +function hasAlignedFactualAddressReply( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (toNonEmptyString(input.currentReplyType) !== "factual") { + return false; + } + + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const turnInput = toRecordObject(entryPoint?.turn_input); + const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family); + + if (detectedIntent === "counterparty_activity_lifecycle") { + return askedDomain === "counterparty_lifecycle" || askedAction === "activity_duration"; + } + if (detectedIntent === "supplier_payouts_profile") { + return askedDomain === "counterparty_value" && askedAction === "payout"; + } + if (detectedIntent === "customer_revenue_and_payments") { + return askedDomain === "counterparty_value" && askedAction === "turnover"; + } + return false; +} + +function hasMatchedFactualAddressContinuationTarget( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput +): boolean { + if (toNonEmptyString(input.currentReplyType) !== "factual") { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const dialogContinuationContract = toRecordObject(input.addressRuntimeMeta?.dialogContinuationContract); + const targetIntent = toNonEmptyString(dialogContinuationContract?.target_intent); + return Boolean(detectedIntent && targetIntent && detectedIntent === targetIntent); +} + +function hasFullConfirmedFactualAddressReply( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (toNonEmptyString(input.currentReplyType) !== "factual") { + return false; + } + const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); + if (truthGateStatus === "full_confirmed") { + return true; + } + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); + const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); + const coverageStatus = toNonEmptyString(truthGate?.coverage_status); + const groundingStatus = toNonEmptyString(truthGate?.grounding_status); + return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded"); +} + export function applyAssistantMcpDiscoveryResponsePolicy( input: ApplyAssistantMcpDiscoveryResponsePolicyInput ): AssistantMcpDiscoveryResponsePolicyResult { @@ -164,6 +228,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const discoveryReadyChatCandidate = isDiscoveryReadyChatCandidate(input, entryPoint); const discoveryReadyDeepCandidate = isDiscoveryReadyDeepCandidate(input, entryPoint); const discoveryReadyAddressCandidate = isDiscoveryReadyAddressCandidate(input, entryPoint); + const alignedFactualAddressReply = hasAlignedFactualAddressReply(input, entryPoint); + const matchedFactualAddressContinuationTarget = hasMatchedFactualAddressContinuationTarget(input); + const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); @@ -180,6 +247,15 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (!discoveryReadyAddressCandidate) { pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_address_candidate"); } + if (alignedFactualAddressReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_aligned_factual_address_reply"); + } + if (matchedFactualAddressContinuationTarget) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_factual_address_continuation_target"); + } + if (fullConfirmedFactualAddressReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); + } if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); } @@ -196,6 +272,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const canApply = Boolean(entryPoint) && (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate || discoveryReadyAddressCandidate) && + !alignedFactualAddressReply && + !matchedFactualAddressContinuationTarget && + !fullConfirmedFactualAddressReply && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && Boolean(toNonEmptyString(candidate.reply_text)) && diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index 02534eb..6e317b6 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -5,7 +5,10 @@ const SUPPORTED_ADDRESS_INTENTS = new Set([ "payables_confirmed_as_of_date", "list_documents_by_counterparty", "customer_revenue_and_payments", - "inventory_on_hand_as_of_date" + "inventory_on_hand_as_of_date", + "vat_liability_confirmed_for_tax_period", + "vat_payable_confirmed_as_of_date", + "vat_payable_forecast" ]); function fallbackCompactWhitespace(value) { @@ -89,8 +92,23 @@ function detectCounterpartyTurnoverFamily(text) { "\u0434\u043e\u0445\u043e\u0434", "\u0431\u044b\u043b", "\u0431\u044b\u043b\u0430", + "\u0432\u0440\u0435\u043c\u044f", + "\u0432\u0440\u0435\u043c\u0435\u043d\u0438", + "\u0433\u043e\u0434", + "\u0433\u043e\u0434\u0430", + "\u043f\u0435\u0440\u0438\u043e\u0434", + "\u043f\u0435\u0440\u0438\u043e\u0434\u0430", + "\u043c\u0435\u0441\u044f\u0446", + "\u043c\u0435\u0441\u044f\u0446\u0430", + "\u043a\u0432\u0430\u0440\u0442\u0430\u043b", + "\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430", "turnover", - "revenue" + "revenue", + "time", + "year", + "period", + "month", + "quarter" ]); const entity = rawEntity && !ignored.has(rawEntity) ? rawEntity : null; return { @@ -146,6 +164,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { ? "receivables" : explicitIntentCandidate?.startsWith("payables_") ? "payables" + : explicitIntentCandidate?.startsWith("vat_") + ? "vat" : explicitIntentCandidate?.startsWith("inventory_") ? "inventory" : explicitIntentCandidate?.includes("counterparty") @@ -158,6 +178,12 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { explicitIntentCandidate === "payables_confirmed_as_of_date" || explicitIntentCandidate === "inventory_on_hand_as_of_date" ? "confirmed_snapshot" + : explicitIntentCandidate === "vat_liability_confirmed_for_tax_period" + ? "confirmed_tax_period" + : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" + ? "confirmed_snapshot" + : explicitIntentCandidate === "vat_payable_forecast" + ? "forecast" : explicitIntentCandidate === "list_documents_by_counterparty" ? "list_documents" : counterpartyTurnover?.family diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index 6b8565c..8dfecd3 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -113,11 +113,17 @@ describe("assistant MCP discovery response policy", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "stale exact route answer", currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", addressRuntimeMeta: { + detected_intent: "list_documents_by_counterparty", assistant_mcp_discovery_entry_point_v1: entryPoint({ turn_input: { adapter_status: "ready", - should_run_discovery: true + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout" + } } }) } @@ -129,6 +135,94 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate"); }); + it("keeps aligned factual address lane answers when the exact lane already matched the same semantic intent", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "ИП Калинин Н.М. | сумма: 216600 | операций: 2", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "customer_revenue_and_payments", + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover" + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toBe("ИП Калинин Н.М. | сумма: 216600 | операций: 2"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_aligned_factual_address_reply"); + }); + + it("keeps factual address follow-up replies when they already match the continuation target intent", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "ИП Калинин Н.М. | сумма: 216600 | операций: 2", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "customer_revenue_and_payments", + dialogContinuationContract: { + target_intent: "customer_revenue_and_payments" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration" + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_factual_address_continuation_target"); + }); + + it("keeps full-confirmed factual address replies even when discovery has a guarded candidate", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "ООО Ромашка | сумма: 128000 | операций: 3", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "receivables_confirmed_as_of_date", + truth_gate_contract_status: "full_confirmed", + assistant_truth_answer_policy_v1: { + truth_gate: { + coverage_status: "full", + grounding_status: "grounded", + source_truth_gate_status: "full_confirmed" + } + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover" + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toBe("ООО Ромашка | сумма: 128000 | операций: 3"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); + }); + it("keeps address lane answers when discovery was not requested for the current turn", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "supported exact route answer", diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index fff2a89..b7405d5 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1013,4 +1013,123 @@ describe("assistantTransitionPolicy", () => { expect(carryover).toBeNull(); }); + + it("reuses grounded MCP discovery payout context for a short year-switch follow-up", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => null, + hasAddressFollowupContextSignal: () => true + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "а теперь за 2021?", + [ + { + role: "assistant", + text: "Подтверждены исходящие платежи по Группа СВК за 2020 год.", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_active_organization: "ООО Альтернатива Плюс", + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + asked_action_family: "payout", + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + pilot: { + pilot_scope: "counterparty_supplier_payout_query_movements_v1" + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + } + } + } + ], + null, + null, + null + ); + + expect(carryover?.followupSelectionMode).toBe("carry_previous_intent"); + expect(carryover?.followupContext?.previous_intent).toBe("supplier_payouts_profile"); + expect(carryover?.followupContext?.target_intent).toBe("supplier_payouts_profile"); + expect(carryover?.followupContext?.previous_discovery_pilot_scope).toBe( + "counterparty_supplier_payout_query_movements_v1" + ); + expect(carryover?.followupContext?.previous_anchor_type).toBe("counterparty"); + expect(carryover?.followupContext?.previous_anchor_value).toBe("Группа СВК"); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + }); + }); + it("switches to VAT tax-period intent while preserving carried period filters", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Подтвержденная дебиторская задолженность РЅР° 31.05.2017 собрана.", + debug: { + detected_intent: "receivables_confirmed_as_of_date", + extracted_filters: { + organization: 'РћРћРћ "Альтернатива Плюс"', + as_of_date: "2017-05-31", + period_from: "2017-05-01", + period_to: "2017-05-31" + }, + anchor_type: "organization", + anchor_value_resolved: 'РћРћРћ "Альтернатива Плюс"' + } + }), + hasAddressFollowupContextSignal: () => true, + hasReferentialPointer: (value: unknown) => /этот период/i.test(String(value ?? "")), + resolveAddressIntent: () => ({ intent: "unknown" }), + resolveAddressIntentFamily: (intent: unknown) => { + if (String(intent ?? "").startsWith("receivables_")) return "receivables"; + if (String(intent ?? "").startsWith("vat_")) return "vat"; + return null; + }, + resolveAssistantTurnMeaning: () => ({ + schema_version: "assistant_turn_meaning_v1", + asked_domain_family: "vat", + asked_action_family: "confirmed_tax_period", + explicit_intent_candidate: "vat_liability_confirmed_for_tax_period", + explicit_entity_candidates: [], + intent_override_strength: "explicit_current_turn_intent", + stale_replay_forbidden: false + }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "а какой ндс мы должны примерно заплатить за этот период?", + [], + "Какой НДС должен быть уплачен за текущий период?", + { + predecomposeContract: { + intent: "unknown" + } + }, + null + ); + + expect(carryover?.followupSelectionMode).toBe("carry_previous_intent"); + expect(carryover?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date"); + expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period"); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + organization: 'РћРћРћ "Альтернатива Плюс"', + as_of_date: "2017-05-31", + period_from: "2017-05-01", + period_to: "2017-05-31" + }); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index 938120f..ce56c5b 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createAssistantTurnMeaningPolicy } from "../src/services/assistantTurnMeaningPolicy"; import { resolveAddressIntent } from "../src/services/addressIntentResolver"; -function buildPolicy() { +function buildPolicy(overrides: Record = {}) { return createAssistantTurnMeaningPolicy({ compactWhitespace: (value: string) => String(value ?? "").replace(/\s+/g, " ").trim(), repairAddressMojibake: (value: string) => value, @@ -13,7 +13,8 @@ function buildPolicy() { } const text = String(value).trim(); return text.length > 0 ? text : null; - } + }, + ...overrides }); } @@ -55,4 +56,41 @@ describe("assistantTurnMeaningPolicy", () => { } ]); }); + + it("ignores temporal tail words in all-time revenue ranking questions", () => { + const policy = buildPolicy({ + resolveAddressIntent: (text: string) => + text.includes("\u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439") + ? { intent: "customer_revenue_and_payments", confidence: "high" } + : resolveAddressIntent(text) + }); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + "\u043a\u0442\u043e \u0443 \u043d\u0430\u0441 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u043a\u043b\u0438\u0435\u043d\u0442 \u0437\u0430 \u0432\u0441\u0435 \u0432\u0440\u0435\u043c\u044f" + }); + + expect(meaning.explicit_intent_candidate).toBe("customer_revenue_and_payments"); + expect(meaning.explicit_entity_candidates).toEqual([]); + expect(meaning.stale_replay_forbidden).toBe(false); + }); + + it("treats VAT period questions as supported current-turn intent", () => { + const policy = buildPolicy({ + resolveAddressIntent: (text: string) => + text.includes("\u043d\u0434\u0441") + ? { intent: "vat_liability_confirmed_for_tax_period", confidence: "high" } + : resolveAddressIntent(text) + }); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + "\u0430 \u043a\u0430\u043a\u043e\u0439 \u043d\u0434\u0441 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0437\u0430 \u044d\u0442\u043e\u0442 \u043f\u0435\u0440\u0438\u043e\u0434" + }); + + expect(meaning.explicit_intent_candidate).toBe("vat_liability_confirmed_for_tax_period"); + expect(meaning.asked_domain_family).toBe("vat"); + expect(meaning.asked_action_family).toBe("confirmed_tax_period"); + expect(meaning.stale_replay_forbidden).toBe(false); + }); });