Files
NODEDC_1C/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts
T

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
};
}