350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
// @ts-nocheck
|
|
|
|
import {
|
|
formatIsoDateForReply,
|
|
isGroundedAddressDebug,
|
|
readAddressDebugFilters,
|
|
readAddressDebugItem,
|
|
readAddressDebugOrganization,
|
|
readAddressDebugScopedDate,
|
|
resolveAssistantContinuitySnapshot
|
|
} from "./assistantContinuityPolicy";
|
|
|
|
export interface ResolveAssistantRouteMemorySignalsInput {
|
|
rawUserMessage?: unknown;
|
|
repairedRawUserMessage?: unknown;
|
|
effectiveAddressUserMessage?: unknown;
|
|
repairedEffectiveAddressUserMessage?: unknown;
|
|
dataScopeMetaQuery?: boolean;
|
|
capabilityMetaQuery?: boolean;
|
|
dataRetrievalSignal?: boolean;
|
|
strongDataSignal?: boolean;
|
|
aggregateBusinessAnalyticsSignal?: boolean;
|
|
lastGroundedAddressDebug?: unknown;
|
|
hasPriorAddressDebug?: boolean;
|
|
sessionItems?: unknown[];
|
|
}
|
|
|
|
export interface AssistantRouteMemorySignals {
|
|
contextualHistoricalCapabilityFollowupDetected: boolean;
|
|
contextualMemoryRecapFollowupDetected: boolean;
|
|
}
|
|
|
|
export interface ResolveAssistantLivingChatMemoryContextInput {
|
|
modeDecisionReason?: unknown;
|
|
sessionItems?: unknown[];
|
|
}
|
|
|
|
export interface AssistantLivingChatMemoryContext {
|
|
contextualInventoryHistoryCapabilityFollowup: boolean;
|
|
contextualMemoryRecapFollowup: boolean;
|
|
lastGroundedInventoryAddressDebug: Record<string, unknown> | null;
|
|
lastMemoryAddressDebug: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface AssistantMemoryRecapPolicyDeps {
|
|
hasHistoricalCapabilityFollowupSignal: (text: unknown) => boolean;
|
|
hasConversationMemoryRecallFollowupSignal: (text: unknown) => boolean;
|
|
isGroundedInventoryContextDebug: (debug: unknown) => boolean;
|
|
}
|
|
|
|
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 collectMessageSamples(input: ResolveAssistantRouteMemorySignalsInput): string[] {
|
|
const values = [
|
|
input.rawUserMessage,
|
|
input.repairedRawUserMessage,
|
|
input.effectiveAddressUserMessage,
|
|
input.repairedEffectiveAddressUserMessage
|
|
];
|
|
return Array.from(
|
|
new Set(
|
|
values
|
|
.map((item) => String(item ?? "").trim())
|
|
.filter((item) => item.length > 0)
|
|
)
|
|
);
|
|
}
|
|
|
|
function hasSignalAcrossSamples(
|
|
samples: string[],
|
|
detector: (text: unknown) => boolean
|
|
): boolean {
|
|
return samples.some((sample) => detector(sample));
|
|
}
|
|
|
|
function hasExplicitRecapPromptSignal(samples: string[]): boolean {
|
|
return samples.some((sample) =>
|
|
/(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы)/iu.test(
|
|
sample
|
|
)
|
|
);
|
|
}
|
|
|
|
export function buildInventoryHistoryCapabilityFollowupReply(input: {
|
|
organization: string | null;
|
|
addressDebug: Record<string, unknown> | null;
|
|
toNonEmptyString: (value: unknown) => string | null;
|
|
}): string {
|
|
const rootFrameContext =
|
|
input.addressDebug?.address_root_frame_context &&
|
|
typeof input.addressDebug.address_root_frame_context === "object"
|
|
? (input.addressDebug.address_root_frame_context as Record<string, unknown>)
|
|
: null;
|
|
const extractedFilters = readAddressDebugFilters(input.addressDebug);
|
|
const organization =
|
|
input.organization ??
|
|
input.toNonEmptyString(rootFrameContext?.organization) ??
|
|
input.toNonEmptyString(extractedFilters?.organization);
|
|
const lastAsOfDate =
|
|
formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
|
formatIsoDateForReply(extractedFilters?.as_of_date);
|
|
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
|
const referenceLine = lastAsOfDate
|
|
? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.`
|
|
: `Да, могу показать исторические данные${organizationPart} в этом же складском контуре.`;
|
|
return [
|
|
referenceLine,
|
|
`Могу показать исторические остатки${organizationPart} за нужный месяц, дату или год.`,
|
|
"Например:",
|
|
"- `на март 2020`",
|
|
"- `на июнь 2016`",
|
|
"- `за 2017 год`",
|
|
"- `сравни июнь 2016 с текущим срезом`",
|
|
"Если хочешь, сразу покажу нужный исторический период."
|
|
].join("\n");
|
|
}
|
|
|
|
function normalizeRecapIdentity(value: unknown): string {
|
|
return String(value ?? "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[«»"'`]/g, "")
|
|
.replace(/\s+/g, " ");
|
|
}
|
|
|
|
function buildRecapFactLine(input: {
|
|
debug: Record<string, unknown> | null;
|
|
item: string | null;
|
|
organization: string | null;
|
|
}): string | null {
|
|
const detectedIntent = String(input.debug?.detected_intent ?? "");
|
|
const scopedDate = readAddressDebugScopedDate(input.debug);
|
|
const itemPart = input.item ? `по позиции «${input.item}»` : null;
|
|
const organizationPart = input.organization ? `по компании «${input.organization}»` : null;
|
|
const datePart = scopedDate ? ` на ${scopedDate}` : "";
|
|
if (detectedIntent === "inventory_on_hand_as_of_date") {
|
|
return `смотрели остатки${organizationPart ? ` ${organizationPart}` : ""}${datePart}`.trim();
|
|
}
|
|
if (detectedIntent === "inventory_purchase_provenance_for_item" && itemPart) {
|
|
return `разобрали, кто поставлял ${itemPart}${datePart}`.trim();
|
|
}
|
|
if (detectedIntent === "inventory_purchase_documents_for_item" && itemPart) {
|
|
return `подняли документы закупки ${itemPart}${datePart}`.trim();
|
|
}
|
|
if (detectedIntent === "inventory_sale_trace_for_item" && itemPart) {
|
|
return `разобрали, кому продавали ${itemPart}${datePart}`.trim();
|
|
}
|
|
if (detectedIntent === "inventory_purchase_to_sale_chain" && itemPart) {
|
|
return `проследили цепочку от закупки до продажи ${itemPart}${datePart}`.trim();
|
|
}
|
|
if (detectedIntent === "inventory_profitability_for_item" && itemPart) {
|
|
return `смотрели рентабельность ${itemPart}${datePart}`.trim();
|
|
}
|
|
if (detectedIntent === "inventory_aging_by_purchase_date" && itemPart) {
|
|
return `смотрели возраст остатков ${itemPart}${datePart}`.trim();
|
|
}
|
|
if (detectedIntent === "counterparty_activity_lifecycle" && organizationPart) {
|
|
return `смотрели активность в базе 1С ${organizationPart}`.trim();
|
|
}
|
|
if (detectedIntent === "list_documents_by_counterparty" && organizationPart) {
|
|
return `поднимали документы ${organizationPart}${datePart}`.trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectRecentRecapFacts(input: {
|
|
sessionItems?: unknown[];
|
|
item: string | null;
|
|
organization: string | null;
|
|
toNonEmptyString: (value: unknown) => string | null;
|
|
}): string[] {
|
|
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
|
|
if (sessionItems.length === 0) {
|
|
return [];
|
|
}
|
|
const currentItemKey = normalizeRecapIdentity(input.item);
|
|
const currentOrganizationKey = normalizeRecapIdentity(input.organization);
|
|
const facts: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
|
|
const item = sessionItems[index] as { role?: string; debug?: Record<string, unknown> } | null;
|
|
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
|
continue;
|
|
}
|
|
if (!isGroundedAddressDebug(item.debug, input.toNonEmptyString)) {
|
|
continue;
|
|
}
|
|
const debugItem = readAddressDebugItem(item.debug, input.toNonEmptyString);
|
|
const debugOrganization = readAddressDebugOrganization(item.debug, input.toNonEmptyString);
|
|
const itemMatches = currentItemKey ? normalizeRecapIdentity(debugItem) === currentItemKey : false;
|
|
const organizationMatches = currentOrganizationKey
|
|
? normalizeRecapIdentity(debugOrganization) === currentOrganizationKey
|
|
: false;
|
|
if (currentItemKey && !itemMatches) {
|
|
continue;
|
|
}
|
|
if (!currentItemKey && currentOrganizationKey && !organizationMatches) {
|
|
continue;
|
|
}
|
|
const fact = buildRecapFactLine({
|
|
debug: item.debug,
|
|
item: debugItem,
|
|
organization: debugOrganization
|
|
});
|
|
if (!fact || seen.has(fact)) {
|
|
continue;
|
|
}
|
|
seen.add(fact);
|
|
facts.push(fact);
|
|
if (facts.length >= 3) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return facts.reverse();
|
|
}
|
|
|
|
export function buildAddressMemoryRecapReply(input: {
|
|
organization: string | null;
|
|
addressDebug: Record<string, unknown> | null;
|
|
sessionItems?: unknown[];
|
|
toNonEmptyString: (value: unknown) => string | null;
|
|
}): string {
|
|
const extractedFilters =
|
|
input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object"
|
|
? (input.addressDebug.extracted_filters as Record<string, unknown>)
|
|
: null;
|
|
const rootFrameContext =
|
|
input.addressDebug?.address_root_frame_context &&
|
|
typeof input.addressDebug.address_root_frame_context === "object"
|
|
? (input.addressDebug.address_root_frame_context as Record<string, unknown>)
|
|
: null;
|
|
const item = readAddressDebugItem(input.addressDebug, input.toNonEmptyString);
|
|
const organization =
|
|
input.organization ??
|
|
input.toNonEmptyString(extractedFilters?.organization) ??
|
|
input.toNonEmptyString(rootFrameContext?.organization);
|
|
const scopedDate = readAddressDebugScopedDate(input.addressDebug);
|
|
const recapFacts = collectRecentRecapFacts({
|
|
sessionItems: input.sessionItems,
|
|
item,
|
|
organization,
|
|
toNonEmptyString: input.toNonEmptyString
|
|
});
|
|
|
|
if (item) {
|
|
if (recapFacts.length > 0) {
|
|
const datePart = scopedDate ? ` в срезе на ${scopedDate}` : "";
|
|
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
|
return [
|
|
`Да, помню. По позиции «${item}»${organizationPart}${datePart} мы уже выяснили:`,
|
|
...recapFacts.map((fact) => `- ${fact}.`),
|
|
"Могу сразу продолжить по ней: поставщик, закупка, документы или продажа."
|
|
].join("\n");
|
|
}
|
|
const datePart = scopedDate ? ` в срезе на ${scopedDate}` : "";
|
|
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
|
return [
|
|
`Да, помню. Мы обсуждали позицию «${item}»${organizationPart}${datePart}.`,
|
|
"Могу продолжить по ней без переписывания сущности: кто поставил, когда купили, по каким документам или кому продали."
|
|
].join(" ");
|
|
}
|
|
|
|
if (organization || scopedDate) {
|
|
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
|
const datePart = scopedDate ? ` на ${scopedDate}` : "";
|
|
return [
|
|
`Да, помню. Мы уже смотрели адресный контур${organizationPart}${datePart}.`,
|
|
"Могу кратко напомнить контекст или сразу продолжить следующий шаг по этому же сценарию."
|
|
].join(" ");
|
|
}
|
|
|
|
return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг.";
|
|
}
|
|
|
|
export function resolveAssistantLivingChatMemoryContext(
|
|
input: ResolveAssistantLivingChatMemoryContextInput
|
|
): AssistantLivingChatMemoryContext {
|
|
const contextualInventoryHistoryCapabilityFollowup =
|
|
String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected";
|
|
const contextualMemoryRecapFollowup =
|
|
String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected";
|
|
const continuity = resolveAssistantContinuitySnapshot({
|
|
sessionItems: input.sessionItems,
|
|
toNonEmptyString
|
|
});
|
|
return {
|
|
contextualInventoryHistoryCapabilityFollowup,
|
|
contextualMemoryRecapFollowup,
|
|
lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup
|
|
? continuity.lastGroundedInventoryAddressDebug
|
|
: null,
|
|
lastMemoryAddressDebug: contextualMemoryRecapFollowup
|
|
? continuity.lastGroundedItemAddressDebug ?? continuity.lastGroundedAddressDebug
|
|
: null
|
|
};
|
|
}
|
|
|
|
export function createAssistantMemoryRecapPolicy(
|
|
deps: AssistantMemoryRecapPolicyDeps
|
|
) {
|
|
function resolveRouteMemorySignals(
|
|
input: ResolveAssistantRouteMemorySignalsInput
|
|
): AssistantRouteMemorySignals {
|
|
const samples = collectMessageSamples(input);
|
|
const continuity = resolveAssistantContinuitySnapshot({
|
|
sessionItems: input.sessionItems,
|
|
toNonEmptyString
|
|
});
|
|
const groundedInventoryContext = continuity.lastGroundedInventoryAddressDebug ?? input.lastGroundedAddressDebug;
|
|
const historicalCapabilitySignal = hasSignalAcrossSamples(
|
|
samples,
|
|
deps.hasHistoricalCapabilityFollowupSignal
|
|
);
|
|
const memoryRecapSignal = hasSignalAcrossSamples(
|
|
samples,
|
|
deps.hasConversationMemoryRecallFollowupSignal
|
|
);
|
|
const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples);
|
|
return {
|
|
contextualHistoricalCapabilityFollowupDetected: Boolean(
|
|
input.capabilityMetaQuery &&
|
|
!input.dataScopeMetaQuery &&
|
|
!input.dataRetrievalSignal &&
|
|
historicalCapabilitySignal &&
|
|
deps.isGroundedInventoryContextDebug(groundedInventoryContext)
|
|
),
|
|
contextualMemoryRecapFollowupDetected: Boolean(
|
|
!input.dataScopeMetaQuery &&
|
|
!input.capabilityMetaQuery &&
|
|
!input.aggregateBusinessAnalyticsSignal &&
|
|
memoryRecapSignal &&
|
|
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
|
continuity.hasGroundedAddressContext
|
|
)
|
|
};
|
|
}
|
|
|
|
return {
|
|
resolveRouteMemorySignals
|
|
};
|
|
}
|