diff --git a/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md b/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md index 9e2822c..a6bf60a 100644 --- a/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md +++ b/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md @@ -844,6 +844,29 @@ Validation: - `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts` passed 33/33; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Discovery Turn Input Adapter + +The eighth implementation slice of Big Block 5 added the adapter between current-turn meaning/predecompose contracts and MCP discovery input: + +- `assistantMcpDiscoveryTurnInputAdapter.ts` +- `assistantMcpDiscoveryTurnInputAdapter.test.ts` + +This adapter still does not wire discovery into the hot answer path. + +It solves the next runtime integration seam: + +- maps `assistant_turn_meaning_v1` into `AssistantMcpDiscoveryTurnMeaningRef`; +- extracts counterparty, organization, and date scope from predecompose contracts; +- bootstraps lifecycle/activity-duration questions from raw user wording; +- treats unsupported-but-understood meaning as discovery-eligible instead of stale-replay fallback; +- avoids serializing structured entity candidates as `[object Object]`; +- keeps supported exact routes out of MCP discovery. + +Validation: + +- `npm test -- assistantMcpDiscoveryTurnInputAdapter.test.ts assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts` passed 37/37; +- `npm run build` passed. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js new file mode 100644 index 0000000..5e6ddaa --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -0,0 +1,215 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = void 0; +exports.buildAssistantMcpDiscoveryTurnInput = buildAssistantMcpDiscoveryTurnInput; +exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = "assistant_mcp_discovery_turn_input_v1"; +function toRecordObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} +function toNonEmptyString(value) { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} +function normalizeReasonCode(value) { + const normalized = value + .trim() + .replace(/[^\p{L}\p{N}_.:-]+/gu, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + return normalized.length > 0 ? normalized.slice(0, 120) : null; +} +function pushReason(target, value) { + const normalized = normalizeReasonCode(value); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} +function pushUnique(target, value) { + const text = toNonEmptyString(value); + if (text && !target.includes(text)) { + target.push(text); + } +} +function compactLower(value) { + return String(value ?? "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); +} +function candidateValue(value) { + const direct = toNonEmptyString(value); + if (direct && direct !== "[object Object]") { + return direct; + } + const record = toRecordObject(value); + if (!record) { + return null; + } + return (toNonEmptyString(record.value) ?? + toNonEmptyString(record.name) ?? + toNonEmptyString(record.ref) ?? + toNonEmptyString(record.text)); +} +function collectEntityCandidates(value) { + const result = []; + if (Array.isArray(value)) { + for (const item of value) { + pushUnique(result, candidateValue(item)); + } + return result; + } + pushUnique(result, candidateValue(value)); + return result; +} +function collectPredecomposeEntities(predecompose) { + const entities = toRecordObject(predecompose?.entities); + return { + counterparty: toNonEmptyString(entities?.counterparty), + organization: toNonEmptyString(entities?.organization) + }; +} +function collectDateScope(predecompose) { + const period = toRecordObject(predecompose?.period); + const asOfDate = toNonEmptyString(period?.as_of_date); + const periodFrom = toNonEmptyString(period?.period_from); + const periodTo = toNonEmptyString(period?.period_to); + if (asOfDate) { + return asOfDate; + } + const yearFrom = periodFrom?.match(/^(\d{4})-01-01$/); + const yearTo = periodTo?.match(/^(\d{4})-12-31$/); + if (yearFrom && yearTo && yearFrom[1] === yearTo[1]) { + return yearFrom[1]; + } + if (periodFrom && periodTo) { + return `${periodFrom}..${periodTo}`; + } + return periodFrom ?? periodTo ?? null; +} +function hasLifecycleSignal(text) { + return /(?:сколько\s+лет|как\s+давно|давно\s+ли|возраст|перв(?:ая|ый)\s+актив|когда\s+начал|когда\s+появ|lifecycle|activity\s+duration|business\s+age|how\s+long)/iu.test(text); +} +function semanticNeedFor(input) { + const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`); + if (input.lifecycleSignal || /(?:lifecycle|activity|duration|age)/iu.test(combined)) { + return "counterparty lifecycle evidence"; + } + if (/(?:turnover|revenue|payment|payout|value)/iu.test(combined)) { + return "counterparty value-flow evidence"; + } + if (/(?:document|documents|list_documents)/iu.test(combined)) { + return "document evidence"; + } + if (/(?:metadata|schema|catalog)/iu.test(combined)) { + return "1C metadata evidence"; + } + return null; +} +function shouldRunDiscovery(input) { + if (input.lifecycleSignal || input.unsupported) { + return true; + } + if (!input.explicitIntentCandidate && input.semanticDataNeed) { + return true; + } + return false; +} +function buildAssistantMcpDiscoveryTurnInput(input) { + const assistantTurnMeaning = toRecordObject(input.assistantTurnMeaning); + const predecomposeContract = toRecordObject(input.predecomposeContract); + const predecomposeEntities = collectPredecomposeEntities(predecomposeContract); + const reasonCodes = []; + const rawText = compactLower(`${input.userMessage ?? ""} ${input.effectiveMessage ?? ""}`); + const lifecycleSignal = hasLifecycleSignal(rawText); + const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); + const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); + const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); + const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); + const semanticDataNeed = semanticNeedFor({ + domain: rawDomain, + action: rawAction, + unsupported, + lifecycleSignal + }); + const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); + pushUnique(entityCandidates, predecomposeEntities.counterparty); + const turnMeaning = { + asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : rawDomain, + asked_action_family: lifecycleSignal ? "activity_duration" : rawAction, + explicit_entity_candidates: entityCandidates, + explicit_organization_scope: predecomposeEntities.organization, + explicit_date_scope: collectDateScope(predecomposeContract), + unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : null), + stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal) + }; + const cleanTurnMeaning = {}; + if (toNonEmptyString(turnMeaning.asked_domain_family)) { + cleanTurnMeaning.asked_domain_family = turnMeaning.asked_domain_family; + } + if (toNonEmptyString(turnMeaning.asked_action_family)) { + cleanTurnMeaning.asked_action_family = turnMeaning.asked_action_family; + } + if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { + cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; + } + if (toNonEmptyString(turnMeaning.explicit_organization_scope)) { + cleanTurnMeaning.explicit_organization_scope = turnMeaning.explicit_organization_scope; + } + if (toNonEmptyString(turnMeaning.explicit_date_scope)) { + cleanTurnMeaning.explicit_date_scope = turnMeaning.explicit_date_scope; + } + if (toNonEmptyString(turnMeaning.unsupported_but_understood_family)) { + cleanTurnMeaning.unsupported_but_understood_family = turnMeaning.unsupported_but_understood_family; + } + if (turnMeaning.stale_replay_forbidden) { + cleanTurnMeaning.stale_replay_forbidden = true; + } + const runDiscovery = shouldRunDiscovery({ + unsupported, + lifecycleSignal, + semanticDataNeed, + explicitIntentCandidate + }); + const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; + const sourceSignal = assistantTurnMeaning + ? "assistant_turn_meaning" + : predecomposeContract + ? "predecompose_contract" + : lifecycleSignal + ? "raw_text" + : "none"; + if (lifecycleSignal) { + pushReason(reasonCodes, "mcp_discovery_lifecycle_signal_detected"); + } + if (unsupported) { + pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); + } + if (predecomposeEntities.counterparty) { + pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose"); + } + if (entityCandidates.length > 0) { + pushReason(reasonCodes, "mcp_discovery_entity_scope_available"); + } + if (!runDiscovery) { + pushReason(reasonCodes, "mcp_discovery_not_applicable_for_supported_exact_turn"); + } + if (runDiscovery && !hasTurnMeaning) { + pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing"); + } + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryTurnInputAdapter", + adapter_status: !runDiscovery ? "not_applicable" : hasTurnMeaning ? "ready" : "needs_more_context", + should_run_discovery: runDiscovery, + semantic_data_need: runDiscovery ? semanticDataNeed : null, + turn_meaning_ref: runDiscovery && hasTurnMeaning ? cleanTurnMeaning : null, + source_signal: sourceSignal, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts new file mode 100644 index 0000000..b7a02d5 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -0,0 +1,278 @@ +import type { AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy"; + +export const ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = + "assistant_mcp_discovery_turn_input_v1" as const; + +export type AssistantMcpDiscoveryTurnInputStatus = "ready" | "needs_more_context" | "not_applicable"; +export type AssistantMcpDiscoveryTurnInputSource = + | "assistant_turn_meaning" + | "predecompose_contract" + | "raw_text" + | "none"; + +export interface BuildAssistantMcpDiscoveryTurnInputAdapterInput { + assistantTurnMeaning?: Record | null; + predecomposeContract?: Record | null; + userMessage?: string | null; + effectiveMessage?: string | null; +} + +export interface AssistantMcpDiscoveryTurnInputContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryTurnInputAdapter"; + adapter_status: AssistantMcpDiscoveryTurnInputStatus; + should_run_discovery: boolean; + semantic_data_need: string | null; + turn_meaning_ref: AssistantMcpDiscoveryTurnMeaningRef | null; + source_signal: AssistantMcpDiscoveryTurnInputSource; + reason_codes: string[]; +} + +function toRecordObject(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function toNonEmptyString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function normalizeReasonCode(value: string): string | null { + const normalized = value + .trim() + .replace(/[^\p{L}\p{N}_.:-]+/gu, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + return normalized.length > 0 ? normalized.slice(0, 120) : null; +} + +function pushReason(target: string[], value: string): void { + const normalized = normalizeReasonCode(value); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} + +function pushUnique(target: string[], value: unknown): void { + const text = toNonEmptyString(value); + if (text && !target.includes(text)) { + target.push(text); + } +} + +function compactLower(value: unknown): string { + return String(value ?? "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); +} + +function candidateValue(value: unknown): string | null { + const direct = toNonEmptyString(value); + if (direct && direct !== "[object Object]") { + return direct; + } + const record = toRecordObject(value); + if (!record) { + return null; + } + return ( + toNonEmptyString(record.value) ?? + toNonEmptyString(record.name) ?? + toNonEmptyString(record.ref) ?? + toNonEmptyString(record.text) + ); +} + +function collectEntityCandidates(value: unknown): string[] { + const result: string[] = []; + if (Array.isArray(value)) { + for (const item of value) { + pushUnique(result, candidateValue(item)); + } + return result; + } + pushUnique(result, candidateValue(value)); + return result; +} + +function collectPredecomposeEntities(predecompose: Record | null): { + counterparty: string | null; + organization: string | null; +} { + const entities = toRecordObject(predecompose?.entities); + return { + counterparty: toNonEmptyString(entities?.counterparty), + organization: toNonEmptyString(entities?.organization) + }; +} + +function collectDateScope(predecompose: Record | null): string | null { + const period = toRecordObject(predecompose?.period); + const asOfDate = toNonEmptyString(period?.as_of_date); + const periodFrom = toNonEmptyString(period?.period_from); + const periodTo = toNonEmptyString(period?.period_to); + if (asOfDate) { + return asOfDate; + } + const yearFrom = periodFrom?.match(/^(\d{4})-01-01$/); + const yearTo = periodTo?.match(/^(\d{4})-12-31$/); + if (yearFrom && yearTo && yearFrom[1] === yearTo[1]) { + return yearFrom[1]; + } + if (periodFrom && periodTo) { + return `${periodFrom}..${periodTo}`; + } + return periodFrom ?? periodTo ?? null; +} + +function hasLifecycleSignal(text: string): boolean { + return /(?:сколько\s+лет|как\s+давно|давно\s+ли|возраст|перв(?:ая|ый)\s+актив|когда\s+начал|когда\s+появ|lifecycle|activity\s+duration|business\s+age|how\s+long)/iu.test( + text + ); +} + +function semanticNeedFor(input: { + domain: string | null; + action: string | null; + unsupported: string | null; + lifecycleSignal: boolean; +}): string | null { + const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`); + if (input.lifecycleSignal || /(?:lifecycle|activity|duration|age)/iu.test(combined)) { + return "counterparty lifecycle evidence"; + } + if (/(?:turnover|revenue|payment|payout|value)/iu.test(combined)) { + return "counterparty value-flow evidence"; + } + if (/(?:document|documents|list_documents)/iu.test(combined)) { + return "document evidence"; + } + if (/(?:metadata|schema|catalog)/iu.test(combined)) { + return "1C metadata evidence"; + } + return null; +} + +function shouldRunDiscovery(input: { + unsupported: string | null; + lifecycleSignal: boolean; + semanticDataNeed: string | null; + explicitIntentCandidate: string | null; +}): boolean { + if (input.lifecycleSignal || input.unsupported) { + return true; + } + if (!input.explicitIntentCandidate && input.semanticDataNeed) { + return true; + } + return false; +} + +export function buildAssistantMcpDiscoveryTurnInput( + input: BuildAssistantMcpDiscoveryTurnInputAdapterInput +): AssistantMcpDiscoveryTurnInputContract { + const assistantTurnMeaning = toRecordObject(input.assistantTurnMeaning); + const predecomposeContract = toRecordObject(input.predecomposeContract); + const predecomposeEntities = collectPredecomposeEntities(predecomposeContract); + const reasonCodes: string[] = []; + const rawText = compactLower(`${input.userMessage ?? ""} ${input.effectiveMessage ?? ""}`); + const lifecycleSignal = hasLifecycleSignal(rawText); + + const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); + const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); + const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); + const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); + const semanticDataNeed = semanticNeedFor({ + domain: rawDomain, + action: rawAction, + unsupported, + lifecycleSignal + }); + const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); + pushUnique(entityCandidates, predecomposeEntities.counterparty); + + const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = { + asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : rawDomain, + asked_action_family: lifecycleSignal ? "activity_duration" : rawAction, + explicit_entity_candidates: entityCandidates, + explicit_organization_scope: predecomposeEntities.organization, + explicit_date_scope: collectDateScope(predecomposeContract), + unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : null), + stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal) + }; + + const cleanTurnMeaning: AssistantMcpDiscoveryTurnMeaningRef = {}; + if (toNonEmptyString(turnMeaning.asked_domain_family)) { + cleanTurnMeaning.asked_domain_family = turnMeaning.asked_domain_family; + } + if (toNonEmptyString(turnMeaning.asked_action_family)) { + cleanTurnMeaning.asked_action_family = turnMeaning.asked_action_family; + } + if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { + cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; + } + if (toNonEmptyString(turnMeaning.explicit_organization_scope)) { + cleanTurnMeaning.explicit_organization_scope = turnMeaning.explicit_organization_scope; + } + if (toNonEmptyString(turnMeaning.explicit_date_scope)) { + cleanTurnMeaning.explicit_date_scope = turnMeaning.explicit_date_scope; + } + if (toNonEmptyString(turnMeaning.unsupported_but_understood_family)) { + cleanTurnMeaning.unsupported_but_understood_family = turnMeaning.unsupported_but_understood_family; + } + if (turnMeaning.stale_replay_forbidden) { + cleanTurnMeaning.stale_replay_forbidden = true; + } + + const runDiscovery = shouldRunDiscovery({ + unsupported, + lifecycleSignal, + semanticDataNeed, + explicitIntentCandidate + }); + const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; + const sourceSignal: AssistantMcpDiscoveryTurnInputSource = assistantTurnMeaning + ? "assistant_turn_meaning" + : predecomposeContract + ? "predecompose_contract" + : lifecycleSignal + ? "raw_text" + : "none"; + + if (lifecycleSignal) { + pushReason(reasonCodes, "mcp_discovery_lifecycle_signal_detected"); + } + if (unsupported) { + pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); + } + if (predecomposeEntities.counterparty) { + pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose"); + } + if (entityCandidates.length > 0) { + pushReason(reasonCodes, "mcp_discovery_entity_scope_available"); + } + if (!runDiscovery) { + pushReason(reasonCodes, "mcp_discovery_not_applicable_for_supported_exact_turn"); + } + if (runDiscovery && !hasTurnMeaning) { + pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing"); + } + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryTurnInputAdapter", + adapter_status: !runDiscovery ? "not_applicable" : hasTurnMeaning ? "ready" : "needs_more_context", + should_run_discovery: runDiscovery, + semantic_data_need: runDiscovery ? semanticDataNeed : null, + turn_meaning_ref: runDiscovery && hasTurnMeaning ? cleanTurnMeaning : null, + source_signal: sourceSignal, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts new file mode 100644 index 0000000..d005736 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { buildAssistantMcpDiscoveryTurnInput } from "../src/services/assistantMcpDiscoveryTurnInputAdapter"; + +describe("assistant MCP discovery turn input adapter", () => { + it("maps unsupported assistant turn meaning into a discovery-ready value-flow input", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + assistantTurnMeaning: { + schema_version: "assistant_turn_meaning_v1", + asked_domain_family: "counterparty", + asked_action_family: "counterparty_value_or_turnover", + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true, + explicit_entity_candidates: [{ type: "counterparty", value: "SVK", source: "current_turn_loose_entity_tail" }] + }, + predecomposeContract: { + entities: { counterparty: "Группа СВК", organization: "Альтернатива" }, + period: { period_from: "2020-01-01", period_to: "2020-12-31" } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toEqual(["SVK", "Группа СВК"]); + expect(result.turn_meaning_ref?.explicit_organization_scope).toBe("Альтернатива"); + expect(result.turn_meaning_ref?.explicit_date_scope).toBe("2020"); + expect(result.turn_meaning_ref?.stale_replay_forbidden).toBe(true); + }); + + it("bootstraps lifecycle discovery from raw user wording and predecompose scope", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "Сколько лет мы работаем с Группа СВК?", + predecomposeContract: { + entities: { counterparty: "Группа СВК" }, + period: { period_from: null, period_to: null, as_of_date: null } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.source_signal).toBe("predecompose_contract"); + expect(result.semantic_data_need).toBe("counterparty lifecycle evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["Группа СВК"], + unsupported_but_understood_family: "counterparty_lifecycle", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_lifecycle_signal_detected"); + }); + + it("does not activate discovery for supported exact current-turn intent", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "list_documents", + explicit_intent_candidate: "list_documents_by_counterparty", + explicit_entity_candidates: [{ value: "SVK" }], + stale_replay_forbidden: false + } + }); + + expect(result.adapter_status).toBe("not_applicable"); + expect(result.should_run_discovery).toBe(false); + expect(result.turn_meaning_ref).toBeNull(); + expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); + + it("never serializes object candidates as [object Object]", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "counterparty_value_or_turnover", + unsupported_but_understood_family: "counterparty_value_or_turnover", + explicit_entity_candidates: [{ type: "counterparty", value: "SVK" }] + } + }); + + expect(result.turn_meaning_ref?.explicit_entity_candidates).toEqual(["SVK"]); + expect(result.turn_meaning_ref?.explicit_entity_candidates).not.toContain("[object Object]"); + }); +});