366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
// @ts-nocheck
|
||
|
||
import {
|
||
isGroundedAddressDebug,
|
||
resolveAddressDebugContextFacts,
|
||
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;
|
||
contextualAnswerInspectionFollowup: boolean;
|
||
lastGroundedInventoryAddressDebug: Record<string, unknown> | null;
|
||
lastMemoryAddressDebug: Record<string, unknown> | null;
|
||
lastAnswerInspectionAddressDebug: 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 contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
|
||
const organization = input.organization ?? contextFacts.organization;
|
||
const lastAsOfDate = contextFacts.scopedDate;
|
||
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 = resolveAddressDebugContextFacts(input.debug).scopedDate;
|
||
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 debugContext = resolveAddressDebugContextFacts(item.debug, input.toNonEmptyString);
|
||
const debugItem = debugContext.item;
|
||
const debugOrganization = debugContext.organization;
|
||
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 contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
|
||
const item = contextFacts.item;
|
||
const organization = input.organization ?? contextFacts.organization;
|
||
const scopedDate = contextFacts.scopedDate;
|
||
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 buildSelectedObjectAnswerInspectionReply(input: {
|
||
addressDebug: Record<string, unknown> | null;
|
||
toNonEmptyString: (value: unknown) => string | null;
|
||
}): string {
|
||
const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
|
||
const itemLabel = contextFacts.item ?? "эта позиция";
|
||
const detectedIntent = String(input.addressDebug?.detected_intent ?? "");
|
||
|
||
if (detectedIntent === "inventory_sale_trace_for_item") {
|
||
return [
|
||
`Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`,
|
||
"В предыдущем ответе я показывал документы выбытия по этой позиции. Покупатель в доступных данных отдельно не выделен, поэтому назвать контрагента-покупателя я там не мог.",
|
||
"Если хочешь, следующим шагом могу отдельно проверить, можно ли вытащить покупателя по связанным документам реализации."
|
||
].join(" ");
|
||
}
|
||
|
||
if (
|
||
detectedIntent === "inventory_purchase_provenance_for_item" ||
|
||
detectedIntent === "inventory_purchase_documents_for_item"
|
||
) {
|
||
return [
|
||
`Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция или номенклатура.`,
|
||
"В предыдущем ответе речь шла о закупке этой позиции: я перечислял поставщиков или закупочные документы по ней, а не называл саму позицию контрагентом."
|
||
].join(" ");
|
||
}
|
||
|
||
return [
|
||
`Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а выбранный объект разбора.`,
|
||
"Я сейчас уточняю именно смысл предыдущего grounded-ответа по этой позиции, а не запускаю новый адресный поиск."
|
||
].join(" ");
|
||
}
|
||
|
||
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 contextualAnswerInspectionFollowup =
|
||
String(input.modeDecisionReason ?? "") === "answer_inspection_followup_detected";
|
||
const continuity = resolveAssistantContinuitySnapshot({
|
||
sessionItems: input.sessionItems,
|
||
toNonEmptyString
|
||
});
|
||
return {
|
||
contextualInventoryHistoryCapabilityFollowup,
|
||
contextualMemoryRecapFollowup,
|
||
contextualAnswerInspectionFollowup,
|
||
lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup
|
||
? continuity.lastGroundedInventoryAddressDebug
|
||
: null,
|
||
lastMemoryAddressDebug: contextualMemoryRecapFollowup
|
||
? continuity.lastGroundedItemAddressDebug ?? continuity.lastGroundedAddressDebug
|
||
: null,
|
||
lastAnswerInspectionAddressDebug: contextualAnswerInspectionFollowup
|
||
? 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
|
||
};
|
||
}
|