diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index 670b8a9..c30c9c8 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -12,6 +12,37 @@ function hasPriorAssistantTurn(items) { function buildDeterministicSmalltalkLeadReply() { return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e."; } +function asRecord(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} +function firstMeaningEntityLabel(assistantTurnMeaning) { + const candidates = Array.isArray(assistantTurnMeaning?.explicit_entity_candidates) + ? assistantTurnMeaning?.explicit_entity_candidates + : []; + for (const candidate of candidates) { + const record = asRecord(candidate); + const value = typeof record?.value === "string" ? record.value.trim() : ""; + if (value.length > 0) { + return value; + } + } + return null; +} +function buildUnsupportedCurrentTurnMeaningBoundaryReply(input) { + const family = typeof input.assistantTurnMeaning?.unsupported_but_understood_family === "string" + ? input.assistantTurnMeaning.unsupported_but_understood_family + : null; + const entityLabel = firstMeaningEntityLabel(input.assistantTurnMeaning); + if (family === "counterparty_value_or_turnover") { + const entityPart = entityLabel ? ` \u043f\u043e \u00ab${entityLabel}\u00bb` : ""; + return [ + `\u042f \u043f\u043e\u043d\u044f\u043b \u0432\u043e\u043f\u0440\u043e\u0441: \u043d\u0443\u0436\u0435\u043d \u043e\u0431\u043e\u0440\u043e\u0442${entityPart}.`, + "\u0422\u043e\u0447\u043d\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u0434\u043b\u044f \u0442\u0430\u043a\u043e\u0433\u043e \u0440\u0430\u0441\u0447\u0451\u0442\u0430 \u0435\u0449\u0451 \u043d\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0451\u043d, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u044f \u043d\u0435 \u0431\u0443\u0434\u0443 \u043f\u043e\u0434\u0441\u0442\u0430\u0432\u043b\u044f\u0442\u044c \u043f\u0440\u043e\u0448\u043b\u044b\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0438\u043b\u0438 \u0441\u0442\u0430\u0440\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430.", + "\u041c\u043e\u0433\u0443 \u043f\u043e\u043a\u0430 \u043d\u0430\u0434\u0451\u0436\u043d\u043e \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b, \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u0438\u043b\u0438 \u0431\u0430\u043d\u043a\u043e\u0432\u0441\u043a\u0438\u0435 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u0438 \u043f\u043e \u044d\u0442\u043e\u043c\u0443 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0443." + ].join(" "); + } + return "\u042f \u043f\u043e\u043d\u044f\u043b \u0441\u043c\u044b\u0441\u043b \u043d\u043e\u0432\u043e\u0433\u043e \u0432\u043e\u043f\u0440\u043e\u0441\u0430, \u043d\u043e \u0442\u043e\u0447\u043d\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u0434\u043b\u044f \u043d\u0435\u0433\u043e \u0435\u0449\u0451 \u043d\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0451\u043d. \u041d\u0435 \u0431\u0443\u0434\u0443 \u043f\u0435\u0440\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0441\u0442\u0430\u0440\u044b\u0439 \u043e\u0442\u0432\u0435\u0442 \u043a\u0430\u043a \u0431\u0443\u0434\u0442\u043e \u044d\u0442\u043e \u0442\u043e \u0436\u0435 \u0441\u0430\u043c\u043e\u0435."; +} async function runAssistantLivingChatRuntime(input) { const userMessage = String(input.userMessage ?? ""); const organizationAuthority = (0, assistantContinuityPolicy_1.resolveAssistantOrganizationAuthority)({ @@ -43,6 +74,13 @@ async function runAssistantLivingChatRuntime(input) { let knownOrganizations = [...organizationAuthority.knownOrganizations]; let selectedOrganization = organizationAuthority.selectedOrganization; let activeOrganization = organizationAuthority.activeOrganization; + const addressRuntimeMeta = (input.addressRuntimeMeta && typeof input.addressRuntimeMeta === "object" + ? input.addressRuntimeMeta + : {}); + const orchestrationContract = asRecord(addressRuntimeMeta.orchestrationContract); + const assistantTurnMeaning = asRecord(orchestrationContract?.assistant_turn_meaning); + const unsupportedCurrentTurnMeaningBoundary = Boolean(input.modeDecision?.reason === "unsupported_current_turn_meaning_boundary" || + orchestrationContract?.unsupported_current_turn_meaning_boundary === true); const memoryRecapContext = (0, assistantMemoryRecapPolicy_1.resolveAssistantLivingChatMemoryContext)({ modeDecisionReason: input.modeDecision?.reason ?? null, sessionItems: input.sessionItems @@ -72,6 +110,12 @@ async function runAssistantLivingChatRuntime(input) { ? "deterministic_data_scope_contract_live" : "deterministic_data_scope_contract"; } + else if (unsupportedCurrentTurnMeaningBoundary) { + chatText = buildUnsupportedCurrentTurnMeaningBoundaryReply({ + assistantTurnMeaning + }); + livingChatSource = "deterministic_unsupported_current_turn_boundary"; + } else if ((selectedOrganization || activeOrganization) && input.hasOrganizationFactLookupSignal(userMessage)) { const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; chatText = input.buildAssistantOrganizationFactBoundaryReply(scopedOrganization); @@ -184,9 +228,6 @@ async function runAssistantLivingChatRuntime(input) { debug: null }; } - const addressRuntimeMeta = (input.addressRuntimeMeta && typeof input.addressRuntimeMeta === "object" - ? input.addressRuntimeMeta - : {}); const predecomposeContract = addressRuntimeMeta.predecomposeContract && typeof addressRuntimeMeta.predecomposeContract === "object" ? addressRuntimeMeta.predecomposeContract : null; diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 7206e2e..9ec70a4 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -78,6 +78,43 @@ function buildDeterministicSmalltalkLeadReply(): string { return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e."; } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + +function firstMeaningEntityLabel(assistantTurnMeaning: Record | null): string | null { + const candidates = Array.isArray(assistantTurnMeaning?.explicit_entity_candidates) + ? assistantTurnMeaning?.explicit_entity_candidates + : []; + for (const candidate of candidates) { + const record = asRecord(candidate); + const value = typeof record?.value === "string" ? record.value.trim() : ""; + if (value.length > 0) { + return value; + } + } + return null; +} + +function buildUnsupportedCurrentTurnMeaningBoundaryReply(input: { + assistantTurnMeaning: Record | null; +}): string { + const family = + typeof input.assistantTurnMeaning?.unsupported_but_understood_family === "string" + ? input.assistantTurnMeaning.unsupported_but_understood_family + : null; + const entityLabel = firstMeaningEntityLabel(input.assistantTurnMeaning); + if (family === "counterparty_value_or_turnover") { + const entityPart = entityLabel ? ` \u043f\u043e \u00ab${entityLabel}\u00bb` : ""; + return [ + `\u042f \u043f\u043e\u043d\u044f\u043b \u0432\u043e\u043f\u0440\u043e\u0441: \u043d\u0443\u0436\u0435\u043d \u043e\u0431\u043e\u0440\u043e\u0442${entityPart}.`, + "\u0422\u043e\u0447\u043d\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u0434\u043b\u044f \u0442\u0430\u043a\u043e\u0433\u043e \u0440\u0430\u0441\u0447\u0451\u0442\u0430 \u0435\u0449\u0451 \u043d\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0451\u043d, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u044f \u043d\u0435 \u0431\u0443\u0434\u0443 \u043f\u043e\u0434\u0441\u0442\u0430\u0432\u043b\u044f\u0442\u044c \u043f\u0440\u043e\u0448\u043b\u044b\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0438\u043b\u0438 \u0441\u0442\u0430\u0440\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430.", + "\u041c\u043e\u0433\u0443 \u043f\u043e\u043a\u0430 \u043d\u0430\u0434\u0451\u0436\u043d\u043e \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b, \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u0438\u043b\u0438 \u0431\u0430\u043d\u043a\u043e\u0432\u0441\u043a\u0438\u0435 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u0438 \u043f\u043e \u044d\u0442\u043e\u043c\u0443 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0443." + ].join(" "); + } + return "\u042f \u043f\u043e\u043d\u044f\u043b \u0441\u043c\u044b\u0441\u043b \u043d\u043e\u0432\u043e\u0433\u043e \u0432\u043e\u043f\u0440\u043e\u0441\u0430, \u043d\u043e \u0442\u043e\u0447\u043d\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u0434\u043b\u044f \u043d\u0435\u0433\u043e \u0435\u0449\u0451 \u043d\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0451\u043d. \u041d\u0435 \u0431\u0443\u0434\u0443 \u043f\u0435\u0440\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0441\u0442\u0430\u0440\u044b\u0439 \u043e\u0442\u0432\u0435\u0442 \u043a\u0430\u043a \u0431\u0443\u0434\u0442\u043e \u044d\u0442\u043e \u0442\u043e \u0436\u0435 \u0441\u0430\u043c\u043e\u0435."; +} + export async function runAssistantLivingChatRuntime( input: AssistantLivingChatRuntimeInput ): Promise { @@ -112,6 +149,15 @@ export async function runAssistantLivingChatRuntime( let knownOrganizations = [...organizationAuthority.knownOrganizations]; let selectedOrganization = organizationAuthority.selectedOrganization; let activeOrganization = organizationAuthority.activeOrganization; + const addressRuntimeMeta = (input.addressRuntimeMeta && typeof input.addressRuntimeMeta === "object" + ? input.addressRuntimeMeta + : {}) as Record; + const orchestrationContract = asRecord(addressRuntimeMeta.orchestrationContract); + const assistantTurnMeaning = asRecord(orchestrationContract?.assistant_turn_meaning); + const unsupportedCurrentTurnMeaningBoundary = Boolean( + input.modeDecision?.reason === "unsupported_current_turn_meaning_boundary" || + orchestrationContract?.unsupported_current_turn_meaning_boundary === true + ); const memoryRecapContext = resolveAssistantLivingChatMemoryContext({ modeDecisionReason: input.modeDecision?.reason ?? null, sessionItems: input.sessionItems @@ -142,6 +188,11 @@ export async function runAssistantLivingChatRuntime( dataScopeProbe?.status === "resolved" ? "deterministic_data_scope_contract_live" : "deterministic_data_scope_contract"; + } else if (unsupportedCurrentTurnMeaningBoundary) { + chatText = buildUnsupportedCurrentTurnMeaningBoundaryReply({ + assistantTurnMeaning + }); + livingChatSource = "deterministic_unsupported_current_turn_boundary"; } else if ((selectedOrganization || activeOrganization) && input.hasOrganizationFactLookupSignal(userMessage)) { const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; chatText = input.buildAssistantOrganizationFactBoundaryReply(scopedOrganization); @@ -254,9 +305,6 @@ export async function runAssistantLivingChatRuntime( }; } - const addressRuntimeMeta = (input.addressRuntimeMeta && typeof input.addressRuntimeMeta === "object" - ? input.addressRuntimeMeta - : {}) as Record; const predecomposeContract = addressRuntimeMeta.predecomposeContract && typeof addressRuntimeMeta.predecomposeContract === "object" ? (addressRuntimeMeta.predecomposeContract as Record) diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index 69d5a2e..9560a38 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -133,6 +133,42 @@ describe("assistant living chat runtime adapter", () => { expect(executeLlmChat).toHaveBeenCalledTimes(1); }); + it("builds deterministic boundary for unsupported current-turn business meaning", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + userMessage: + "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a", + modeDecision: { mode: "chat", reason: "unsupported_current_turn_meaning_boundary" }, + addressRuntimeMeta: { + toolGateReason: "unsupported_current_turn_meaning_boundary", + orchestrationContract: { + unsupported_current_turn_meaning_boundary: true, + assistant_turn_meaning: { + unsupported_but_understood_family: "counterparty_value_or_turnover", + explicit_entity_candidates: [ + { + type: "counterparty", + value: "\u0441\u0432\u043a" + } + ] + } + } + }, + executeLlmChat + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toContain("\u043d\u0443\u0436\u0435\u043d \u043e\u0431\u043e\u0440\u043e\u0442"); + expect(output.chatText).toContain("\u00ab\u0441\u0432\u043a\u00bb"); + expect(output.chatText).toContain("\u043d\u0435 \u0431\u0443\u0434\u0443 \u043f\u043e\u0434\u0441\u0442\u0430\u0432\u043b\u044f\u0442\u044c"); + expect(output.debug?.living_chat_response_source).toBe( + "deterministic_unsupported_current_turn_boundary" + ); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); + it("adds proactive organization offer on first smalltalk turn when multiple organizations are available", async () => { const resolveDataScopeProbe = vi.fn(async () => ({ status: "resolved",