258 lines
9.9 KiB
TypeScript
258 lines
9.9 KiB
TypeScript
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<string, unknown> | null) => string;
|
|
buildAssistantProactiveOrganizationOfferReply: (scopeProbe?: Record<string, unknown> | 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 {
|
|
function buildAssistantDataScopeContractReply(scopeProbe: Record<string, unknown> | null = null): string {
|
|
const organizations = Array.isArray(scopeProbe?.organizations)
|
|
? scopeProbe.organizations
|
|
.map((item) => normalizeSelectedOrganization(item, deps.normalizeOrganizationScopeValue))
|
|
.filter((item) => item.length > 0)
|
|
.filter((item, index, array) => array.indexOf(item) === index)
|
|
: [];
|
|
|
|
if (organizations.length === 1) {
|
|
return [
|
|
`Сейчас доступна организация: ${organizations[0]}.`,
|
|
"Могу сразу показать по ней документы, операции, договоры или остатки."
|
|
].join(" ");
|
|
}
|
|
|
|
if (organizations.length > 1) {
|
|
const preview = organizations.slice(0, 10).join(", ");
|
|
return [
|
|
`Сейчас доступны организации (${organizations.length}): ${preview}.`,
|
|
"Скажите, по какой организации смотреть данные."
|
|
].join(" ");
|
|
}
|
|
|
|
if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
|
|
return [
|
|
"Сейчас не удалось определить список организаций из подключенной базы.",
|
|
`Техническая причина: ${scopeProbe.error}.`,
|
|
"Проверьте подключение, и я сразу назову доступный контур."
|
|
].join(" ");
|
|
}
|
|
|
|
return [
|
|
"Сейчас вижу только данные текущего подключенного контура.",
|
|
"Если в нем несколько организаций, скажите, по какой смотреть данные."
|
|
].join(" ");
|
|
}
|
|
|
|
function buildAssistantProactiveOrganizationOfferReply(scopeProbe: Record<string, unknown> | null = null): string {
|
|
const organizations = Array.isArray(scopeProbe?.organizations)
|
|
? scopeProbe.organizations
|
|
.map((item) => normalizeSelectedOrganization(item, deps.normalizeOrganizationScopeValue))
|
|
.filter((item) => item.length > 0)
|
|
.filter((item, index, array) => array.indexOf(item) === index)
|
|
: [];
|
|
|
|
if (organizations.length === 1) {
|
|
return [
|
|
`Если дальше пойдём в данные 1С, могу сразу держать в контуре ${organizations[0]}.`,
|
|
"Можно просто писать вопрос по документам, остаткам, НДС или контрагентам."
|
|
].join(" ");
|
|
}
|
|
|
|
if (organizations.length > 1) {
|
|
const preview = organizations.slice(0, 10).join(", ");
|
|
return [
|
|
`Если дальше пойдём в данные 1С, могу сразу зафиксировать организацию: ${preview}.`,
|
|
"Просто напишите название компании, и дальше буду держать её активной в этой сессии."
|
|
].join(" ");
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
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,
|
|
buildAssistantProactiveOrganizationOfferReply,
|
|
buildAssistantDataScopeSelectionReply,
|
|
buildAssistantOrganizationFactBoundaryReply,
|
|
buildAssistantOperationalBoundaryReply,
|
|
buildAssistantSafetyRefusalReply,
|
|
applyLivingChatScriptGuard,
|
|
applyLivingChatGroundingGuard
|
|
};
|
|
}
|