diff --git a/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js b/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js new file mode 100644 index 0000000..61ba36f --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js @@ -0,0 +1,172 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAssistantBoundaryPolicy = createAssistantBoundaryPolicy; +function normalizeSelectedOrganization(value, normalizeOrganizationScopeValue) { + return normalizeOrganizationScopeValue(value) ?? String(value ?? "").trim(); +} +function containsCjkChars(text) { + const source = String(text ?? ""); + if (!source) { + return false; + } + return /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(source); +} +function containsLetterLikeChars(text) { + const source = String(text ?? ""); + if (!source) { + return false; + } + return /[A-Za-z\u0400-\u04FF]/u.test(source); +} +function createAssistantBoundaryPolicy(deps) { + const defaultChannel = String(deps.activeMcpChannel ?? "default"); + function buildAssistantDataScopeContractReply(scopeProbe = null) { + const channel = String(scopeProbe?.channel ?? defaultChannel); + const organizations = Array.isArray(scopeProbe?.organizations) + ? scopeProbe.organizations + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0) + : []; + if (organizations.length === 1) { + return [ + `Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`, + "Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки." + ].join(" "); + } + if (organizations.length > 1) { + const preview = organizations.slice(0, 10).join(", "); + return [ + `Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`, + "Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции." + ].join(" "); + } + if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) { + return [ + `Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`, + "Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контур." + ].join(" "); + } + return [ + `Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`, + "Словарь компаний не зашит в код: рабочий контур определяется live-подключением.", + "Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов." + ].join(" "); + } + function buildAssistantDataScopeSelectionReply(organization) { + const selected = normalizeSelectedOrganization(organization, deps.normalizeOrganizationScopeValue); + return [ + `Отлично, фиксирую рабочую организацию: ${selected}.`, + "Дальше буду держать этот контур как активный, пока вы не переключите организацию." + ].join(" "); + } + function buildAssistantOrganizationFactBoundaryReply(organization) { + const selected = normalizeSelectedOrganization(organization, deps.normalizeOrganizationScopeValue); + if (selected) { + return [ + `По организации ${selected} не буду называть дату/возраст без live-подтвержденного источника.`, + "Если нужно, запрошу факт из 1С и верну только подтвержденный ответ." + ].join(" "); + } + return [ + "Не буду называть дату/возраст организации без live-подтвержденного источника.", + "Сначала получу факт из 1С, потом дам точный ответ." + ].join(" "); + } + function buildAssistantOperationalBoundaryReply() { + return [ + "Понимаю, что ситуация срочная.", + "Я не могу сам настраивать 1С или менять базу/конфигурацию.", + "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа." + ].join(" "); + } + function buildAssistantSafetyRefusalReply() { + return [ + "Я не могу помогать с удалением базы или скрытием данных.", + "Если вам угрожает опасность, срочно звоните 112 и следуйте указаниям экстренных служб.", + "По 1С могу дать только безопасные диагностические рекомендации." + ].join(" "); + } + function applyLivingChatScriptGuard(chatText, userMessage) { + const source = String(chatText ?? "").trim(); + if (!source) { + return { + text: "", + applied: false, + reason: null + }; + } + if (!containsCjkChars(source) || containsCjkChars(userMessage)) { + return { + text: source, + applied: false, + reason: null + }; + } + const stripped = source + .replace(/[\u3400-\u9FFF\uF900-\uFAFF]+/gu, "") + .replace(/[,。、!?;:()【】]/gu, "") + .replace(/\s{2,}/g, " ") + .replace(/\s+([,.!?;:])/g, "$1") + .trim(); + if (stripped && containsLetterLikeChars(stripped)) { + return { + text: stripped, + applied: true, + reason: "unexpected_cjk_fragment_stripped" + }; + } + return { + text: "Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам.", + applied: true, + reason: "unexpected_cjk_fragment_fallback" + }; + } + function applyLivingChatGroundingGuard(input) { + const userMessage = String(input?.userMessage ?? ""); + const chatText = String(input?.chatText ?? "").trim(); + const organization = deps.toNonEmptyString(input?.organization); + if (!chatText) { + return { + text: chatText, + applied: false, + reason: null + }; + } + if (!deps.hasOrganizationFactLookupSignal(userMessage)) { + return { + text: chatText, + applied: false, + reason: null + }; + } + if (/(?:не\s+могу|не\s+вижу|после\s+проверки|live|подтвержден)/i.test(chatText)) { + return { + text: chatText, + applied: false, + reason: null + }; + } + const hasSpecificUnverifiedFact = /(?:\b\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})\b|\b(?:19|20)\d{2}\b|\b\d+\s+лет\b)/i.test(chatText); + if (!hasSpecificUnverifiedFact) { + return { + text: chatText, + applied: false, + reason: null + }; + } + return { + text: buildAssistantOrganizationFactBoundaryReply(organization), + applied: true, + reason: "organization_fact_without_live_source_blocked" + }; + } + return { + buildAssistantDataScopeContractReply, + buildAssistantDataScopeSelectionReply, + buildAssistantOrganizationFactBoundaryReply, + buildAssistantOperationalBoundaryReply, + buildAssistantSafetyRefusalReply, + applyLivingChatScriptGuard, + applyLivingChatGroundingGuard + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index f59c05d..b80582e 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -66,6 +66,7 @@ const assistantCanon_1 = __importStar(require("./assistantCanon")); const assistantAddressAttemptRuntimeAdapter_1 = __importStar(require("./assistantAddressAttemptRuntimeAdapter")); const assistantCoverageGrounding_1 = __importStar(require("./assistantCoverageGrounding")); const assistantDeepTurnAttemptRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnAttemptRuntimeAdapter")); +const assistantBoundaryPolicy_1 = __importStar(require("./assistantBoundaryPolicy")); const assistantOrganizationScopeRuntimeAdapter_1 = __importStar(require("./assistantOrganizationScopeRuntimeAdapter")); const assistantTurnAttemptRuntimeAdapter_1 = __importStar(require("./assistantTurnAttemptRuntimeAdapter")); const assistantTurnRuntimeDepsAdapter_1 = __importStar(require("./assistantTurnRuntimeDepsAdapter")); @@ -5926,6 +5927,33 @@ async function resolveAssistantDataScopeProbe() { }); return fallback; } +const assistantBoundaryPolicy = (0, assistantBoundaryPolicy_1.createAssistantBoundaryPolicy)({ + activeMcpChannel: config_1.ASSISTANT_MCP_CHANNEL, + normalizeOrganizationScopeValue, + toNonEmptyString, + hasOrganizationFactLookupSignal +}); +function buildAssistantDataScopeContractReplyFromPolicy(scopeProbe = null) { + return assistantBoundaryPolicy.buildAssistantDataScopeContractReply(scopeProbe); +} +function buildAssistantDataScopeSelectionReplyFromPolicy(organization) { + return assistantBoundaryPolicy.buildAssistantDataScopeSelectionReply(organization); +} +function buildAssistantOrganizationFactBoundaryReplyFromPolicy(organization) { + return assistantBoundaryPolicy.buildAssistantOrganizationFactBoundaryReply(organization); +} +function buildAssistantOperationalBoundaryReplyFromPolicy() { + return assistantBoundaryPolicy.buildAssistantOperationalBoundaryReply(); +} +function buildAssistantSafetyRefusalReplyFromPolicy() { + return assistantBoundaryPolicy.buildAssistantSafetyRefusalReply(); +} +function applyLivingChatScriptGuardFromPolicy(chatText, userMessage) { + return assistantBoundaryPolicy.applyLivingChatScriptGuard(chatText, userMessage); +} +function applyLivingChatGroundingGuardFromPolicy(input) { + return assistantBoundaryPolicy.applyLivingChatGroundingGuard(input); +} function buildAssistantDataScopeContractReply(scopeProbe = null) { const channel = String(scopeProbe?.channel ?? config_1.ASSISTANT_MCP_CHANNEL ?? "default"); const organizations = Array.isArray(scopeProbe?.organizations) ? scopeProbe.organizations : []; @@ -6207,13 +6235,13 @@ class AssistantService { shouldEmitOrganizationSelectionReply, hasAssistantCapabilityQuestionSignal, resolveDataScopeProbe: () => resolveAssistantDataScopeProbe(), - applyScriptGuard: (chatText, runtimeUserMessage) => applyLivingChatScriptGuard(chatText, runtimeUserMessage), - applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput), - buildAssistantSafetyRefusalReply, - buildAssistantDataScopeContractReply, - buildAssistantOrganizationFactBoundaryReply, - buildAssistantDataScopeSelectionReply, - buildAssistantOperationalBoundaryReply, + applyScriptGuard: applyLivingChatScriptGuardFromPolicy, + applyGroundingGuard: applyLivingChatGroundingGuardFromPolicy, + buildAssistantSafetyRefusalReply: buildAssistantSafetyRefusalReplyFromPolicy, + buildAssistantDataScopeContractReply: buildAssistantDataScopeContractReplyFromPolicy, + buildAssistantOrganizationFactBoundaryReply: buildAssistantOrganizationFactBoundaryReplyFromPolicy, + buildAssistantDataScopeSelectionReply: buildAssistantDataScopeSelectionReplyFromPolicy, + buildAssistantOperationalBoundaryReply: buildAssistantOperationalBoundaryReplyFromPolicy, buildAssistantCapabilityContractReply, loadAssistantCanonExcerpt: assistantCanon_1.loadAssistantCanonExcerpt, sanitizeOutgoingAssistantText, diff --git a/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts b/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts new file mode 100644 index 0000000..21b786d --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts @@ -0,0 +1,231 @@ +export interface AssistantBoundaryPolicyDeps { + activeMcpChannel?: string | null; + normalizeOrganizationScopeValue: (value: unknown) => string | null; + toNonEmptyString: (value: unknown) => string | null; + hasOrganizationFactLookupSignal: (message: string) => boolean; +} + +export interface AssistantBoundaryPolicyGuardResult { + text: string; + applied: boolean; + reason: string | null; +} + +export interface AssistantBoundaryPolicyGroundingGuardInput { + userMessage?: unknown; + chatText?: unknown; + organization?: unknown; +} + +export interface AssistantBoundaryPolicy { + buildAssistantDataScopeContractReply: (scopeProbe?: Record | null) => string; + buildAssistantDataScopeSelectionReply: (organization: unknown) => string; + buildAssistantOrganizationFactBoundaryReply: (organization: unknown) => string; + buildAssistantOperationalBoundaryReply: () => string; + buildAssistantSafetyRefusalReply: () => string; + applyLivingChatScriptGuard: (chatText: unknown, userMessage: unknown) => AssistantBoundaryPolicyGuardResult; + applyLivingChatGroundingGuard: ( + input: AssistantBoundaryPolicyGroundingGuardInput + ) => AssistantBoundaryPolicyGuardResult; +} + +function normalizeSelectedOrganization( + value: unknown, + normalizeOrganizationScopeValue: AssistantBoundaryPolicyDeps["normalizeOrganizationScopeValue"] +): string { + return normalizeOrganizationScopeValue(value) ?? String(value ?? "").trim(); +} + +function containsCjkChars(text: unknown): boolean { + const source = String(text ?? ""); + if (!source) { + return false; + } + return /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(source); +} + +function containsLetterLikeChars(text: unknown): boolean { + const source = String(text ?? ""); + if (!source) { + return false; + } + return /[A-Za-z\u0400-\u04FF]/u.test(source); +} + +export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps): AssistantBoundaryPolicy { + const defaultChannel = String(deps.activeMcpChannel ?? "default"); + + function buildAssistantDataScopeContractReply(scopeProbe: Record | null = null): string { + const channel = String(scopeProbe?.channel ?? defaultChannel); + const organizations = Array.isArray(scopeProbe?.organizations) + ? scopeProbe.organizations + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0) + : []; + + if (organizations.length === 1) { + return [ + `Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`, + "Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки." + ].join(" "); + } + + if (organizations.length > 1) { + const preview = organizations.slice(0, 10).join(", "); + return [ + `Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`, + "Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции." + ].join(" "); + } + + if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) { + return [ + `Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`, + "Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контур." + ].join(" "); + } + + return [ + `Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`, + "Словарь компаний не зашит в код: рабочий контур определяется live-подключением.", + "Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов." + ].join(" "); + } + + function buildAssistantDataScopeSelectionReply(organization: unknown): string { + const selected = normalizeSelectedOrganization(organization, deps.normalizeOrganizationScopeValue); + return [ + `Отлично, фиксирую рабочую организацию: ${selected}.`, + "Дальше буду держать этот контур как активный, пока вы не переключите организацию." + ].join(" "); + } + + function buildAssistantOrganizationFactBoundaryReply(organization: unknown): string { + const selected = normalizeSelectedOrganization(organization, deps.normalizeOrganizationScopeValue); + if (selected) { + return [ + `По организации ${selected} не буду называть дату/возраст без live-подтвержденного источника.`, + "Если нужно, запрошу факт из 1С и верну только подтвержденный ответ." + ].join(" "); + } + + return [ + "Не буду называть дату/возраст организации без live-подтвержденного источника.", + "Сначала получу факт из 1С, потом дам точный ответ." + ].join(" "); + } + + function buildAssistantOperationalBoundaryReply(): string { + return [ + "Понимаю, что ситуация срочная.", + "Я не могу сам настраивать 1С или менять базу/конфигурацию.", + "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа." + ].join(" "); + } + + function buildAssistantSafetyRefusalReply(): string { + return [ + "Я не могу помогать с удалением базы или скрытием данных.", + "Если вам угрожает опасность, срочно звоните 112 и следуйте указаниям экстренных служб.", + "По 1С могу дать только безопасные диагностические рекомендации." + ].join(" "); + } + + function applyLivingChatScriptGuard(chatText: unknown, userMessage: unknown): AssistantBoundaryPolicyGuardResult { + const source = String(chatText ?? "").trim(); + if (!source) { + return { + text: "", + applied: false, + reason: null + }; + } + + if (!containsCjkChars(source) || containsCjkChars(userMessage)) { + return { + text: source, + applied: false, + reason: null + }; + } + + const stripped = source + .replace(/[\u3400-\u9FFF\uF900-\uFAFF]+/gu, "") + .replace(/[,。、!?;:()【】]/gu, "") + .replace(/\s{2,}/g, " ") + .replace(/\s+([,.!?;:])/g, "$1") + .trim(); + + if (stripped && containsLetterLikeChars(stripped)) { + return { + text: stripped, + applied: true, + reason: "unexpected_cjk_fragment_stripped" + }; + } + + return { + text: "Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам.", + applied: true, + reason: "unexpected_cjk_fragment_fallback" + }; + } + + function applyLivingChatGroundingGuard( + input: AssistantBoundaryPolicyGroundingGuardInput + ): AssistantBoundaryPolicyGuardResult { + const userMessage = String(input?.userMessage ?? ""); + const chatText = String(input?.chatText ?? "").trim(); + const organization = deps.toNonEmptyString(input?.organization); + + if (!chatText) { + return { + text: chatText, + applied: false, + reason: null + }; + } + + if (!deps.hasOrganizationFactLookupSignal(userMessage)) { + return { + text: chatText, + applied: false, + reason: null + }; + } + + if (/(?:не\s+могу|не\s+вижу|после\s+проверки|live|подтвержден)/i.test(chatText)) { + return { + text: chatText, + applied: false, + reason: null + }; + } + + const hasSpecificUnverifiedFact = + /(?:\b\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})\b|\b(?:19|20)\d{2}\b|\b\d+\s+лет\b)/i.test(chatText); + if (!hasSpecificUnverifiedFact) { + return { + text: chatText, + applied: false, + reason: null + }; + } + + return { + text: buildAssistantOrganizationFactBoundaryReply(organization), + applied: true, + reason: "organization_fact_without_live_source_blocked" + }; + } + + return { + buildAssistantDataScopeContractReply, + buildAssistantDataScopeSelectionReply, + buildAssistantOrganizationFactBoundaryReply, + buildAssistantOperationalBoundaryReply, + buildAssistantSafetyRefusalReply, + applyLivingChatScriptGuard, + applyLivingChatGroundingGuard + }; +} diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index cf44726..a71839e 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -20,6 +20,7 @@ import * as assistantCanon_1 from "./assistantCanon"; import * as assistantAddressAttemptRuntimeAdapter_1 from "./assistantAddressAttemptRuntimeAdapter"; import * as assistantCoverageGrounding_1 from "./assistantCoverageGrounding"; import * as assistantDeepTurnAttemptRuntimeAdapter_1 from "./assistantDeepTurnAttemptRuntimeAdapter"; +import * as assistantBoundaryPolicy_1 from "./assistantBoundaryPolicy"; import * as assistantOrganizationScopeRuntimeAdapter_1 from "./assistantOrganizationScopeRuntimeAdapter"; import * as assistantOrganizationMatcher_1 from "./assistantOrganizationMatcher"; import * as assistantTurnAttemptRuntimeAdapter_1 from "./assistantTurnAttemptRuntimeAdapter"; @@ -5884,6 +5885,33 @@ async function resolveAssistantDataScopeProbe() { }); return fallback; } +const assistantBoundaryPolicy = (0, assistantBoundaryPolicy_1.createAssistantBoundaryPolicy)({ + activeMcpChannel: config_1.ASSISTANT_MCP_CHANNEL, + normalizeOrganizationScopeValue, + toNonEmptyString, + hasOrganizationFactLookupSignal +}); +function buildAssistantDataScopeContractReplyFromPolicy(scopeProbe = null) { + return assistantBoundaryPolicy.buildAssistantDataScopeContractReply(scopeProbe); +} +function buildAssistantDataScopeSelectionReplyFromPolicy(organization) { + return assistantBoundaryPolicy.buildAssistantDataScopeSelectionReply(organization); +} +function buildAssistantOrganizationFactBoundaryReplyFromPolicy(organization) { + return assistantBoundaryPolicy.buildAssistantOrganizationFactBoundaryReply(organization); +} +function buildAssistantOperationalBoundaryReplyFromPolicy() { + return assistantBoundaryPolicy.buildAssistantOperationalBoundaryReply(); +} +function buildAssistantSafetyRefusalReplyFromPolicy() { + return assistantBoundaryPolicy.buildAssistantSafetyRefusalReply(); +} +function applyLivingChatScriptGuardFromPolicy(chatText, userMessage) { + return assistantBoundaryPolicy.applyLivingChatScriptGuard(chatText, userMessage); +} +function applyLivingChatGroundingGuardFromPolicy(input) { + return assistantBoundaryPolicy.applyLivingChatGroundingGuard(input); +} function buildAssistantDataScopeContractReply(scopeProbe = null) { const channel = String(scopeProbe?.channel ?? config_1.ASSISTANT_MCP_CHANNEL ?? "default"); const organizations = Array.isArray(scopeProbe?.organizations) ? scopeProbe.organizations : []; @@ -6165,13 +6193,13 @@ export class AssistantService { shouldEmitOrganizationSelectionReply, hasAssistantCapabilityQuestionSignal, resolveDataScopeProbe: () => resolveAssistantDataScopeProbe(), - applyScriptGuard: (chatText, runtimeUserMessage) => applyLivingChatScriptGuard(chatText, runtimeUserMessage), - applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput), - buildAssistantSafetyRefusalReply, - buildAssistantDataScopeContractReply, - buildAssistantOrganizationFactBoundaryReply, - buildAssistantDataScopeSelectionReply, - buildAssistantOperationalBoundaryReply, + applyScriptGuard: applyLivingChatScriptGuardFromPolicy, + applyGroundingGuard: applyLivingChatGroundingGuardFromPolicy, + buildAssistantSafetyRefusalReply: buildAssistantSafetyRefusalReplyFromPolicy, + buildAssistantDataScopeContractReply: buildAssistantDataScopeContractReplyFromPolicy, + buildAssistantOrganizationFactBoundaryReply: buildAssistantOrganizationFactBoundaryReplyFromPolicy, + buildAssistantDataScopeSelectionReply: buildAssistantDataScopeSelectionReplyFromPolicy, + buildAssistantOperationalBoundaryReply: buildAssistantOperationalBoundaryReplyFromPolicy, buildAssistantCapabilityContractReply, loadAssistantCanonExcerpt: assistantCanon_1.loadAssistantCanonExcerpt, sanitizeOutgoingAssistantText, diff --git a/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts b/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts new file mode 100644 index 0000000..7eeb295 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { createAssistantBoundaryPolicy } from "../src/services/assistantBoundaryPolicy"; + +function createPolicy() { + return createAssistantBoundaryPolicy({ + activeMcpChannel: "default", + normalizeOrganizationScopeValue: (value: unknown) => { + if (value === null || value === undefined) { + return null; + } + const text = String(value ?? "").trim(); + return text.length > 0 ? text : null; + }, + toNonEmptyString: (value: unknown) => { + if (value === null || value === undefined) { + return null; + } + const text = String(value ?? "").trim(); + return text.length > 0 ? text : null; + }, + hasOrganizationFactLookupSignal: (message: string) => /возраст|дата регистрации/i.test(message) + }); +} + +describe("assistantBoundaryPolicy", () => { + it("builds deterministic data-scope reply for single organization", () => { + const policy = createPolicy(); + + const reply = policy.buildAssistantDataScopeContractReply({ + status: "resolved", + channel: "finance", + organizations: ["ООО Альтернатива Плюс"] + }); + + expect(reply).toContain("MCP-канале `finance`"); + expect(reply).toContain("ООО Альтернатива Плюс"); + expect(reply.toLowerCase()).toContain("read-only"); + }); + + it("strips unexpected CJK fragments from live chat reply", () => { + const policy = createPolicy(); + + const guarded = policy.applyLivingChatScriptGuard( + "Прошу прощения, но я не могу продолжать этот разговор. 随时关注。", + "че как" + ); + + expect(guarded.applied).toBe(true); + expect(guarded.reason).toBe("unexpected_cjk_fragment_stripped"); + expect(guarded.text).toContain("Прошу прощения"); + expect(/[\u3400-\u9FFF\uF900-\uFAFF]/u.test(guarded.text)).toBe(false); + }); + + it("blocks ungrounded organization fact answer with deterministic boundary reply", () => { + const policy = createPolicy(); + + const guarded = policy.applyLivingChatGroundingGuard({ + userMessage: "какой возраст у альтернативы?", + chatText: "Для ООО Альтернатива Плюс дата регистрации 01.07.2015, возраст 8 лет.", + organization: "ООО Альтернатива Плюс" + }); + + expect(guarded.applied).toBe(true); + expect(guarded.reason).toBe("organization_fact_without_live_source_blocked"); + expect(guarded.text.toLowerCase()).toContain("не буду называть"); + expect(guarded.text).not.toContain("01.07.2015"); + }); +});