351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import { runAssistantRoutePolicyRuntime } from "./assistantRoutePolicyRuntimeAdapter";
|
||
import {
|
||
runAssistantMcpDiscoveryRuntimeEntryPoint,
|
||
type RunAssistantMcpDiscoveryRuntimeEntryPointInput
|
||
} from "./assistantMcpDiscoveryRuntimeEntryPoint";
|
||
|
||
import { hasInventoryProfitabilityCue } from "./inventoryLifecycleCueHelpers";
|
||
|
||
export interface BuildAssistantAddressOrchestrationRuntimeInput {
|
||
userMessage: string;
|
||
sessionItems: unknown[];
|
||
sessionAddressNavigationState?: unknown;
|
||
sessionOrganizationScope?: {
|
||
knownOrganizations?: unknown;
|
||
selectedOrganization?: unknown;
|
||
activeOrganization?: unknown;
|
||
} | null;
|
||
llmProvider: unknown;
|
||
useMock: boolean;
|
||
featureAddressLlmPredecomposeV1: boolean;
|
||
runAddressLlmPreDecompose: () => Promise<Record<string, unknown>>;
|
||
buildAddressLlmPredecomposeContractV1: (input: {
|
||
sourceMessage: string;
|
||
canonicalMessage: string;
|
||
}) => unknown;
|
||
sanitizeAddressMessageForFallback: (userMessage: string) => string;
|
||
toNonEmptyString: (value: unknown) => string | null;
|
||
resolveAddressFollowupCarryoverContext: (
|
||
userMessage: string,
|
||
sessionItems: unknown[],
|
||
addressInputMessage: string,
|
||
addressPreDecompose: Record<string, unknown>,
|
||
sessionAddressNavigationState?: unknown
|
||
) => AssistantAddressCarryoverLike | null;
|
||
resolveAssistantOrchestrationDecision: (input: {
|
||
rawUserMessage: string;
|
||
effectiveAddressUserMessage: string;
|
||
followupContext: unknown;
|
||
llmPreDecomposeMeta: Record<string, unknown>;
|
||
sessionItems?: unknown[];
|
||
sessionOrganizationScope?: unknown;
|
||
useMock: boolean;
|
||
}) => Record<string, unknown>;
|
||
buildAddressDialogContinuationContractV2: (
|
||
userMessage: string,
|
||
addressInputMessage: string,
|
||
carryover: AssistantAddressCarryoverLike | null,
|
||
addressPreDecompose: Record<string, unknown>
|
||
) => unknown;
|
||
runMcpDiscoveryRuntimeEntryPoint?: (
|
||
input: RunAssistantMcpDiscoveryRuntimeEntryPointInput
|
||
) => Promise<Record<string, unknown>>;
|
||
}
|
||
|
||
export interface AssistantAddressCarryoverLike {
|
||
followupContext?: unknown;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
export interface BuildAssistantAddressOrchestrationRuntimeOutput {
|
||
addressPreDecompose: Record<string, unknown>;
|
||
addressInputMessage: string;
|
||
carryover: AssistantAddressCarryoverLike | null;
|
||
orchestrationDecision: Record<string, unknown>;
|
||
addressRuntimeMeta: Record<string, unknown>;
|
||
livingModeDecision: {
|
||
mode: unknown;
|
||
reason: unknown;
|
||
};
|
||
}
|
||
|
||
function toRecordObject(value: unknown): Record<string, unknown> | null {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||
return null;
|
||
}
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function hasSelectedObjectInventorySignal(text: string | null): boolean {
|
||
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ним|selected\s+object)/iu.test(
|
||
String(text ?? "")
|
||
);
|
||
}
|
||
|
||
function hasSelectedObjectInventoryActionCue(text: string | null): boolean {
|
||
const value = String(text ?? "");
|
||
return (
|
||
/(?:кому[\s\S]{0,80}(?:продал[аи]?|реализова[нлт][а-я]*|поставил[аи]?|поставлен[а-я]*|отгрузил[аи]?|отгружен[а-я]*)|кому\s+был\s+продан|куда[\s\S]{0,80}(?:продал[аи]?|реализова[нлт][а-я]*|поставил[аи]?|поставлен[а-я]*|отгрузил[аи]?|отгружен[а-я]*)|кто[\s\S]{0,40}купил|кто\s+это\s+поставил|кто\s+поставил|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+мы\s+купили|где\s+куплено|по\s+каким\s+документам|какими\s+документами|покажи\s+документы|документы[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|документы\s+закупки|buyer|sale\s+trace|supplier|vendor|purchase\s+documents|purchase[\s-]?to[\s-]?sale|old\s+purchase|aged\s+stock)/iu.test(
|
||
value
|
||
) || hasInventoryProfitabilityCue(value)
|
||
);
|
||
}
|
||
|
||
function hasShortInventoryPurchaseFollowupCue(text: string | null): boolean {
|
||
return /(?:^|[\s,.;:!?])(а\s+)?(?:купили\s+у\s+кого|у\s+кого\s+купили|поставщик|продавец|seller)(?:[\s,.;:!?]|$)/iu.test(
|
||
String(text ?? "")
|
||
);
|
||
}
|
||
|
||
function isInventorySelectedObjectOrRootIntent(intent: string | null): boolean {
|
||
return (
|
||
intent === "inventory_on_hand_as_of_date" ||
|
||
intent === "inventory_purchase_provenance_for_item" ||
|
||
intent === "inventory_purchase_documents_for_item" ||
|
||
intent === "inventory_sale_trace_for_item" ||
|
||
intent === "inventory_profitability_for_item" ||
|
||
intent === "inventory_purchase_to_sale_chain" ||
|
||
intent === "inventory_aging_by_purchase_date"
|
||
);
|
||
}
|
||
|
||
function isGenericCanonicalDriftIntent(intent: string | null): boolean {
|
||
return (
|
||
intent === "open_items_by_counterparty_or_contract" ||
|
||
intent === "customer_revenue_and_payments" ||
|
||
intent === "list_documents_by_counterparty" ||
|
||
intent === "list_documents_by_contract" ||
|
||
intent === "bank_operations_by_counterparty" ||
|
||
intent === "bank_operations_by_contract" ||
|
||
intent === "documents_forming_balance"
|
||
);
|
||
}
|
||
|
||
function hasSameDateFollowupSignal(text: string | null): boolean {
|
||
return /(?:эту\s+же\s+дат(?:у|е|ой)|ту\s+же\s+дат(?:у|е|ой)|same\s+date)/iu.test(String(text ?? ""));
|
||
}
|
||
|
||
function hasExplicitCurrentDateSignal(text: string | null): boolean {
|
||
return /(?:текущ(?:ую|ая|ий|ее|ей)\s+дат(?:у|а|е|ой)|сегодняшн(?:юю|ий|ей)\s+дат(?:у|а|е|ой)|today|current\s+date)/iu.test(
|
||
String(text ?? "")
|
||
);
|
||
}
|
||
|
||
function hasInventoryTemporalRootFollowupCue(text: string | null): boolean {
|
||
const value = String(text ?? "").trim().toLowerCase();
|
||
if (!value) {
|
||
return false;
|
||
}
|
||
const tokenCount = value.split(/\s+/).filter(Boolean).length;
|
||
const hasMonthYearCue =
|
||
/(?:январ(?:ь|е)|феврал(?:ь|е)|март(?:е)?|апрел(?:ь|е)|ма(?:й|е)|июн(?:ь|е)|июл(?:ь|е)|август(?:е)?|сентябр(?:ь|е)|октябр(?:ь|е)|ноябр(?:ь|е)|декабр(?:ь|е))(?:\s+\d{4})?/iu.test(
|
||
value
|
||
) || /\b(?:19|20)\d{2}\b/u.test(value);
|
||
if (tokenCount <= 3 && hasMonthYearCue) {
|
||
return true;
|
||
}
|
||
const hasInventoryLexeme = /(?:остат|склад|товар|позици|номенклатур)/iu.test(value);
|
||
return hasInventoryLexeme && (hasMonthYearCue || hasSameDateFollowupSignal(value));
|
||
}
|
||
|
||
function shouldPreferRawFollowupMessage(
|
||
userMessage: string,
|
||
addressInputMessage: string,
|
||
carryover: AssistantAddressCarryoverLike | null,
|
||
addressPreDecompose: Record<string, unknown>,
|
||
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
|
||
): boolean {
|
||
if (!carryover?.followupContext || typeof carryover.followupContext !== "object") {
|
||
return false;
|
||
}
|
||
|
||
const rawMessage = toNonEmptyString(userMessage);
|
||
const canonicalMessage = toNonEmptyString(addressInputMessage);
|
||
if (!rawMessage || !canonicalMessage || rawMessage === canonicalMessage) {
|
||
return false;
|
||
}
|
||
|
||
const predecomposeContract =
|
||
addressPreDecompose?.predecomposeContract && typeof addressPreDecompose.predecomposeContract === "object"
|
||
? (addressPreDecompose.predecomposeContract as Record<string, unknown>)
|
||
: null;
|
||
const mode = toNonEmptyString(predecomposeContract?.mode) ?? "unknown";
|
||
const intent = toNonEmptyString(predecomposeContract?.intent) ?? "unknown";
|
||
const followupContext =
|
||
carryover.followupContext && typeof carryover.followupContext === "object"
|
||
? (carryover.followupContext as Record<string, unknown>)
|
||
: null;
|
||
const previousIntent = toNonEmptyString(followupContext?.previous_intent);
|
||
const rootIntent = toNonEmptyString(followupContext?.root_intent);
|
||
const previousAnchorType = toNonEmptyString(followupContext?.previous_anchor_type);
|
||
const hasReferentialDocumentExclusionFollowupCue = /(?:\u043a\u0440\u043e\u043c\u0435|\u043f\u043e\u043c\u0438\u043c\u043e)\s+(?:\u044d\u0442\u043e\u0433\u043e|\u044d\u0442\u043e\u0439|\u044d\u0442\u043e\u0442|\u044d\u0442\u0443|\u044d\u0442\u0438\u0445)(?:\s+(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430|\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430))?/iu.test(
|
||
rawMessage
|
||
);
|
||
const hasInventoryItemCarryover =
|
||
previousAnchorType === "item" && isInventorySelectedObjectOrRootIntent(previousIntent);
|
||
const hasInventoryFrameCarryover =
|
||
isInventorySelectedObjectOrRootIntent(previousIntent) ||
|
||
isInventorySelectedObjectOrRootIntent(rootIntent);
|
||
const hasDocumentCarryover =
|
||
previousIntent === "list_documents_by_counterparty" || previousIntent === "list_documents_by_contract";
|
||
|
||
if (mode === "unsupported" && intent === "unknown") {
|
||
return true;
|
||
}
|
||
|
||
if (hasDocumentCarryover && hasReferentialDocumentExclusionFollowupCue) {
|
||
return true;
|
||
}
|
||
|
||
if (hasSameDateFollowupSignal(rawMessage) && hasExplicitCurrentDateSignal(canonicalMessage)) {
|
||
return true;
|
||
}
|
||
|
||
if (
|
||
hasInventoryFrameCarryover &&
|
||
hasInventoryTemporalRootFollowupCue(rawMessage) &&
|
||
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
return (
|
||
(hasSelectedObjectInventorySignal(rawMessage) || hasInventoryItemCarryover) &&
|
||
(hasSelectedObjectInventoryActionCue(rawMessage) || hasShortInventoryPurchaseFollowupCue(rawMessage)) &&
|
||
(isGenericCanonicalDriftIntent(intent) || intent === "unknown")
|
||
);
|
||
}
|
||
|
||
function fallbackAddressPreDecompose(
|
||
userMessage: string,
|
||
llmProvider: unknown,
|
||
buildAddressLlmPredecomposeContractV1: BuildAssistantAddressOrchestrationRuntimeInput["buildAddressLlmPredecomposeContractV1"],
|
||
sanitizeAddressMessageForFallback: BuildAssistantAddressOrchestrationRuntimeInput["sanitizeAddressMessageForFallback"]
|
||
): Record<string, unknown> {
|
||
const provider =
|
||
llmProvider === "local" ? "local" : llmProvider === "openai" ? "openai" : null;
|
||
return {
|
||
attempted: false,
|
||
applied: false,
|
||
provider,
|
||
traceId: null,
|
||
effectiveMessage: userMessage,
|
||
reason: "disabled_by_feature_flag",
|
||
llmCanonicalCandidateDetected: false,
|
||
predecomposeContract: buildAddressLlmPredecomposeContractV1({
|
||
sourceMessage: userMessage,
|
||
canonicalMessage: userMessage
|
||
}),
|
||
fallbackRuleHit: null,
|
||
sanitizedUserMessage: sanitizeAddressMessageForFallback(userMessage),
|
||
toolGateDecision: null,
|
||
toolGateReason: null
|
||
};
|
||
}
|
||
|
||
export async function buildAssistantAddressOrchestrationRuntime(
|
||
input: BuildAssistantAddressOrchestrationRuntimeInput
|
||
): Promise<BuildAssistantAddressOrchestrationRuntimeOutput> {
|
||
const initialAddressPreDecompose = input.featureAddressLlmPredecomposeV1
|
||
? await input.runAddressLlmPreDecompose()
|
||
: fallbackAddressPreDecompose(
|
||
input.userMessage,
|
||
input.llmProvider,
|
||
input.buildAddressLlmPredecomposeContractV1,
|
||
input.sanitizeAddressMessageForFallback
|
||
);
|
||
|
||
let addressPreDecompose = initialAddressPreDecompose;
|
||
let addressInputMessage =
|
||
input.toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? input.userMessage;
|
||
let carryover = input.resolveAddressFollowupCarryoverContext(
|
||
input.userMessage,
|
||
input.sessionItems,
|
||
addressInputMessage,
|
||
addressPreDecompose,
|
||
input.sessionAddressNavigationState
|
||
);
|
||
if (
|
||
shouldPreferRawFollowupMessage(
|
||
input.userMessage,
|
||
addressInputMessage,
|
||
carryover,
|
||
addressPreDecompose,
|
||
input.toNonEmptyString
|
||
)
|
||
) {
|
||
addressInputMessage = input.userMessage;
|
||
addressPreDecompose = {
|
||
...addressPreDecompose,
|
||
applied: false,
|
||
effectiveMessage: input.userMessage,
|
||
reason: "followup_raw_message_preferred_over_llm_rewrite",
|
||
predecomposeContract: input.buildAddressLlmPredecomposeContractV1({
|
||
sourceMessage: input.userMessage,
|
||
canonicalMessage: input.userMessage
|
||
})
|
||
};
|
||
carryover = input.resolveAddressFollowupCarryoverContext(
|
||
input.userMessage,
|
||
input.sessionItems,
|
||
addressInputMessage,
|
||
addressPreDecompose,
|
||
input.sessionAddressNavigationState
|
||
);
|
||
}
|
||
|
||
const followupContext = toRecordObject(carryover?.followupContext);
|
||
const routePolicyRuntime = runAssistantRoutePolicyRuntime({
|
||
rawUserMessage: input.userMessage,
|
||
effectiveAddressUserMessage: addressInputMessage,
|
||
followupContext,
|
||
llmPreDecomposeMeta: addressPreDecompose,
|
||
sessionItems: input.sessionItems,
|
||
sessionOrganizationScope: input.sessionOrganizationScope ?? null,
|
||
useMock: input.useMock,
|
||
resolveAssistantOrchestrationDecision: input.resolveAssistantOrchestrationDecision
|
||
});
|
||
const orchestrationDecision = routePolicyRuntime.orchestrationDecision;
|
||
const orchestrationContract = toRecordObject(orchestrationDecision.orchestrationContract);
|
||
const predecomposeContract = toRecordObject(addressPreDecompose.predecomposeContract);
|
||
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(
|
||
input.userMessage,
|
||
addressInputMessage,
|
||
carryover,
|
||
addressPreDecompose
|
||
);
|
||
const runDiscoveryEntryPoint = input.runMcpDiscoveryRuntimeEntryPoint ?? runAssistantMcpDiscoveryRuntimeEntryPoint;
|
||
let mcpDiscoveryRuntimeEntryPoint: Record<string, unknown> | null = null;
|
||
let mcpDiscoveryRuntimeEntryPointError: string | null = null;
|
||
try {
|
||
mcpDiscoveryRuntimeEntryPoint = (await runDiscoveryEntryPoint({
|
||
userMessage: input.userMessage,
|
||
effectiveMessage: addressInputMessage,
|
||
assistantTurnMeaning: toRecordObject(orchestrationContract?.assistant_turn_meaning),
|
||
predecomposeContract,
|
||
followupContext
|
||
})) as Record<string, unknown>;
|
||
} catch (error) {
|
||
mcpDiscoveryRuntimeEntryPointError = String(error instanceof Error ? error.message : error ?? "unknown_error").slice(0, 280);
|
||
}
|
||
const addressRuntimeMeta = {
|
||
...addressPreDecompose,
|
||
toolGateDecision: orchestrationDecision.toolGateDecision ?? null,
|
||
toolGateReason: orchestrationDecision.toolGateReason ?? null,
|
||
dialogContinuationContract,
|
||
orchestrationContract: orchestrationContract ?? null,
|
||
routePolicyContract: routePolicyRuntime.routePolicyContract,
|
||
mcpDiscoveryRuntimeEntryPoint,
|
||
mcpDiscoveryRuntimeEntryPointError
|
||
};
|
||
|
||
return {
|
||
addressPreDecompose,
|
||
addressInputMessage,
|
||
carryover,
|
||
orchestrationDecision,
|
||
addressRuntimeMeta,
|
||
livingModeDecision: routePolicyRuntime.livingModeDecision
|
||
};
|
||
}
|