ARCH: восстановить память discovery-ответов в living chat

This commit is contained in:
2026-04-21 08:56:38 +03:00
parent cd3315e06d
commit b542b65b81
9 changed files with 719 additions and 24 deletions
@@ -29,6 +29,7 @@ export interface AssistantContinuitySnapshot {
export interface AssistantAddressDebugContextFacts {
item: string | null;
counterparty: string | null;
organization: string | null;
scopedDate: string | null;
}
@@ -107,6 +108,68 @@ function toRecordObject(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function readAssistantMcpDiscoveryEntry(
debug: Record<string, unknown> | null
): Record<string, unknown> | null {
const entry = toRecordObject(debug?.assistant_mcp_discovery_entry_point_v1);
return fallbackToNonEmptyString(entry?.schema_version) === "assistant_mcp_discovery_runtime_entry_point_v1"
? entry
: null;
}
function readAssistantMcpDiscoveryTurnMeaning(
debug: Record<string, unknown> | null
): Record<string, unknown> | null {
const entry = readAssistantMcpDiscoveryEntry(debug);
const turnInput = toRecordObject(entry?.turn_input);
return toRecordObject(turnInput?.turn_meaning_ref);
}
function readAssistantMcpDiscoveryBridge(
debug: Record<string, unknown> | null
): Record<string, unknown> | null {
return toRecordObject(readAssistantMcpDiscoveryEntry(debug)?.bridge);
}
export function readAssistantMcpDiscoveryPilotScope(
debug: Record<string, unknown> | null,
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
): string | null {
const bridge = readAssistantMcpDiscoveryBridge(debug);
const pilot = toRecordObject(bridge?.pilot);
return toNonEmptyString(pilot?.pilot_scope);
}
function formatDiscoveryDateScopeForReply(value: unknown): string | null {
const text = fallbackToNonEmptyString(value);
if (!text) {
return null;
}
return formatIsoDateForReply(text) ?? text;
}
function hasGroundedDiscoveryBusinessAnswer(
debug: Record<string, unknown> | null,
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
): boolean {
if (!debug || debug.mcp_discovery_response_applied !== true) {
return false;
}
const entry = readAssistantMcpDiscoveryEntry(debug);
const bridge = readAssistantMcpDiscoveryBridge(debug);
const bridgeStatus = toNonEmptyString(bridge?.bridge_status);
const answerDraft = toRecordObject(bridge?.answer_draft);
const answerMode = toNonEmptyString(answerDraft?.answer_mode);
return Boolean(
entry &&
toNonEmptyString(entry.entry_status) === "bridge_executed" &&
bridgeStatus === "answer_draft_ready" &&
(bridge?.business_fact_answer_allowed === true ||
answerMode === "confirmed_with_bounded_inference" ||
answerMode === "bounded_inference_only")
);
}
function mergeKnownOrganizationsDefault(values: unknown[]): string[] {
return mergeKnownOrganizationsFromMatcher(values);
}
@@ -141,13 +204,44 @@ export function readAddressDebugItem(
);
}
export function readAddressDebugCounterparty(
debug: Record<string, unknown> | null,
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
): string | null {
const extractedFilters = readAddressDebugFilters(debug);
if (toNonEmptyString(extractedFilters?.counterparty)) {
return toNonEmptyString(extractedFilters?.counterparty);
}
if (String(debug?.anchor_type ?? "") === "counterparty") {
return toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw);
}
const discoveryMeaning = readAssistantMcpDiscoveryTurnMeaning(debug);
const explicitEntities = Array.isArray(discoveryMeaning?.explicit_entity_candidates)
? discoveryMeaning?.explicit_entity_candidates
: [];
for (const entity of explicitEntities) {
const text = toNonEmptyString(entity);
if (text) {
return text;
}
}
return null;
}
export function readAddressDebugOrganization(
debug: Record<string, unknown> | null,
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
): string | null {
const extractedFilters = readAddressDebugFilters(debug);
const rootFrameContext = toRecordObject(debug?.address_root_frame_context);
return toNonEmptyString(extractedFilters?.organization) ?? toNonEmptyString(rootFrameContext?.organization);
const discoveryMeaning = readAssistantMcpDiscoveryTurnMeaning(debug);
return (
toNonEmptyString(extractedFilters?.organization) ??
toNonEmptyString(rootFrameContext?.organization) ??
toNonEmptyString(discoveryMeaning?.explicit_organization_scope) ??
toNonEmptyString(debug?.assistant_active_organization) ??
toNonEmptyString(debug?.living_chat_selected_organization)
);
}
export function readAddressDebugScopedDate(debug: Record<string, unknown> | null): string | null {
@@ -156,7 +250,8 @@ export function readAddressDebugScopedDate(debug: Record<string, unknown> | null
return (
formatIsoDateForReply(extractedFilters?.as_of_date) ??
formatIsoDateForReply(rootFrameContext?.as_of_date) ??
formatIsoDateForReply(extractedFilters?.period_to)
formatIsoDateForReply(extractedFilters?.period_to) ??
formatDiscoveryDateScopeForReply(readAssistantMcpDiscoveryTurnMeaning(debug)?.explicit_date_scope)
);
}
@@ -202,6 +297,13 @@ export function resolveAddressDebugAnchorContext(
anchorValue: counterparty
};
}
const discoveryCounterparty = readAddressDebugCounterparty(debug, toNonEmptyString);
if (discoveryCounterparty) {
return {
anchorType: "counterparty",
anchorValue: discoveryCounterparty
};
}
const account = toNonEmptyString(extractedFilters?.account);
if (account) {
return {
@@ -250,6 +352,7 @@ export function resolveAddressDebugContextFacts(
): AssistantAddressDebugContextFacts {
return {
item: readAddressDebugItem(debug, toNonEmptyString),
counterparty: readAddressDebugCounterparty(debug, toNonEmptyString),
organization: readAddressDebugOrganization(debug, toNonEmptyString),
scopedDate: readAddressDebugScopedDate(debug)
};
@@ -897,13 +1000,12 @@ export function isGroundedAddressDebug(
if (!debug || typeof debug !== "object") {
return false;
}
const executionLane = toNonEmptyString(debug.execution_lane);
if (executionLane !== "address_query") {
return false;
}
const answerGroundingCheck = toRecordObject(debug.answer_grounding_check);
const groundingStatus = toNonEmptyString(answerGroundingCheck?.status);
return groundingStatus === "grounded";
if (groundingStatus === "grounded" && toNonEmptyString(debug.execution_lane) === "address_query") {
return true;
}
return hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString);
}
function isGroundedInventoryContextDebug(
@@ -2,6 +2,7 @@
import {
isGroundedAddressDebug,
readAssistantMcpDiscoveryPilotScope,
resolveAddressDebugContextFacts,
resolveAssistantContinuitySnapshot
} from "./assistantContinuityPolicy";
@@ -54,6 +55,69 @@ function toNonEmptyString(value: unknown): string | null {
return text.length > 0 ? text : null;
}
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 periodPartForRecap(scopedDate: string | null): string {
if (!scopedDate) {
return "";
}
return /^\d{2}\.\d{2}\.\d{4}$/.test(scopedDate) ? ` на ${scopedDate}` : ` за период ${scopedDate}`;
}
function buildDiscoveryRecapFactLine(input: {
debug: Record<string, unknown> | null;
counterparty: string | null;
scopedDate: string | null;
}): string | null {
if (!input.debug || !input.counterparty) {
return null;
}
const pilotScope = readAssistantMcpDiscoveryPilotScope(input.debug, toNonEmptyString);
const discoveryEntry = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
const bridge = toRecordObject(discoveryEntry?.bridge);
const pilot = toRecordObject(bridge?.pilot);
const periodPart = periodPartForRecap(input.scopedDate);
if (pilotScope === "counterparty_lifecycle_query_documents_v1") {
const activityPeriod = toRecordObject(pilot?.derived_activity_period);
const duration = toNonEmptyString(activityPeriod?.duration_human_ru);
return duration
? `смотрели подтвержденную активность по контрагенту «${input.counterparty}»${periodPart} и оценили период взаимодействия примерно как ${duration}`
: `смотрели подтвержденную активность по контрагенту «${input.counterparty}»${periodPart}`;
}
if (pilotScope === "counterparty_supplier_payout_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_value_flow);
const amount = toNonEmptyString(flow?.total_amount_human_ru);
return amount
? `считали исходящие платежи/списания по контрагенту «${input.counterparty}»${periodPart}: ${amount}`
: `считали исходящие платежи/списания по контрагенту «${input.counterparty}»${periodPart}`;
}
if (pilotScope === "counterparty_value_flow_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_value_flow);
const amount = toNonEmptyString(flow?.total_amount_human_ru);
return amount
? `смотрели денежный поток по контрагенту «${input.counterparty}»${periodPart}: ${amount}`
: `смотрели денежный поток по контрагенту «${input.counterparty}»${periodPart}`;
}
if (pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") {
const flow = toRecordObject(pilot?.derived_bidirectional_value_flow);
const incoming = toRecordObject(flow?.incoming_customer_revenue);
const outgoing = toRecordObject(flow?.outgoing_supplier_payout);
const incomingAmount = toNonEmptyString(incoming?.total_amount_human_ru);
const outgoingAmount = toNonEmptyString(outgoing?.total_amount_human_ru);
const netAmount = toNonEmptyString(flow?.net_amount_human_ru);
if (incomingAmount && outgoingAmount && netAmount) {
return `считали нетто по деньгам с контрагентом «${input.counterparty}»${periodPart}: получили ${incomingAmount}, заплатили ${outgoingAmount}, расчетное нетто ${netAmount}`;
}
return `считали нетто по деньгам с контрагентом «${input.counterparty}»${periodPart}`;
}
return null;
}
function collectMessageSamples(input: ResolveAssistantRouteMemorySignalsInput): string[] {
const values = [
input.rawUserMessage,
@@ -120,11 +184,21 @@ function normalizeRecapIdentity(value: unknown): string {
function buildRecapFactLine(input: {
debug: Record<string, unknown> | null;
item: string | null;
counterparty: string | null;
organization: string | null;
}): string | null {
const detectedIntent = String(input.debug?.detected_intent ?? "");
const scopedDate = resolveAddressDebugContextFacts(input.debug).scopedDate;
const discoveryFact = buildDiscoveryRecapFactLine({
debug: input.debug,
counterparty: input.counterparty,
scopedDate
});
if (discoveryFact) {
return discoveryFact;
}
const itemPart = input.item ? `по позиции «${input.item}»` : null;
const counterpartyPart = input.counterparty ? `по контрагенту «${input.counterparty}»` : null;
const organizationPart = input.organization ? `по компании «${input.organization}»` : null;
const datePart = scopedDate ? ` на ${scopedDate}` : "";
if (detectedIntent === "inventory_on_hand_as_of_date") {
@@ -151,6 +225,9 @@ function buildRecapFactLine(input: {
if (detectedIntent === "counterparty_activity_lifecycle" && organizationPart) {
return `смотрели активность в базе 1С ${organizationPart}`.trim();
}
if (detectedIntent === "list_documents_by_counterparty" && counterpartyPart) {
return `поднимали документы ${counterpartyPart}${datePart}`.trim();
}
if (detectedIntent === "list_documents_by_counterparty" && organizationPart) {
return `поднимали документы ${organizationPart}${datePart}`.trim();
}
@@ -196,6 +273,7 @@ function collectRecentRecapFacts(input: {
const fact = buildRecapFactLine({
debug: item.debug,
item: debugItem,
counterparty: debugContext.counterparty,
organization: debugOrganization
});
if (!fact || seen.has(fact)) {
@@ -219,6 +297,7 @@ export function buildAddressMemoryRecapReply(input: {
}): string {
const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
const item = contextFacts.item;
const counterparty = contextFacts.counterparty;
const organization = input.organization ?? contextFacts.organization;
const scopedDate = contextFacts.scopedDate;
const recapFacts = collectRecentRecapFacts({
@@ -246,6 +325,22 @@ export function buildAddressMemoryRecapReply(input: {
].join(" ");
}
if (counterparty) {
const organizationPart = organization ? ` по компании «${organization}»` : "";
const periodPart = periodPartForRecap(scopedDate);
if (recapFacts.length > 0) {
return [
`Да, помню. По контрагенту «${counterparty}»${organizationPart}${periodPart} мы уже выяснили:`,
...recapFacts.map((fact) => `- ${fact}.`),
"Могу сразу продолжить по нему: поступления, платежи, нетто, помесячную раскладку или границы подтверждения."
].join("\n");
}
return [
`Да, помню. Мы уже смотрели контур по контрагенту «${counterparty}»${organizationPart}${periodPart}.`,
"Могу продолжить по нему без переписывания контекста: поступления, платежи, нетто, документы или пояснение границ ответа."
].join(" ");
}
if (organization || scopedDate) {
const organizationPart = organization ? ` по компании «${organization}»` : "";
const datePart = scopedDate ? ` на ${scopedDate}` : "";
@@ -264,7 +359,39 @@ export function buildSelectedObjectAnswerInspectionReply(input: {
}): string {
const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
const itemLabel = contextFacts.item ?? "эта позиция";
const counterpartyLabel = contextFacts.counterparty;
const detectedIntent = String(input.addressDebug?.detected_intent ?? "");
const pilotScope = readAssistantMcpDiscoveryPilotScope(input.addressDebug, input.toNonEmptyString);
const periodPart = periodPartForRecap(contextFacts.scopedDate);
if (counterpartyLabel && pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") {
return [
`Да, в предыдущем ответе речь шла о двустороннем денежном потоке с контрагентом «${counterpartyLabel}»${periodPart}.`,
"Нетто там означало разницу между тем, что получили, и тем, что заплатили по найденным строкам 1С.",
"Это расчет по проверенному периоду и подтвержденным строкам, а не заявление про весь оборот вне этого окна."
].join(" ");
}
if (counterpartyLabel && pilotScope === "counterparty_supplier_payout_query_movements_v1") {
return [
`Да, в предыдущем ответе речь шла об исходящих платежах/списаниях по контрагенту «${counterpartyLabel}»${periodPart}.`,
"Это сумма по найденным строкам 1С за проверенный период, а не обещание, что за пределами этого окна больше движений не было."
].join(" ");
}
if (counterpartyLabel && pilotScope === "counterparty_value_flow_query_movements_v1") {
return [
`Да, в предыдущем ответе речь шла о денежном потоке по контрагенту «${counterpartyLabel}»${periodPart}.`,
"Это расчет по найденным движениям 1С за проверенный период, а не безусловный итог по всем временам."
].join(" ");
}
if (counterpartyLabel && pilotScope === "counterparty_lifecycle_query_documents_v1") {
return [
`Да, в предыдущем ответе речь шла об активности контрагента «${counterpartyLabel}»${periodPart}.`,
"Это оценка по подтвержденным строкам 1С, а не юридически подтвержденная дата регистрации."
].join(" ");
}
if (detectedIntent === "inventory_sale_trace_for_item") {
return [
@@ -3822,11 +3822,7 @@ function findLastGroundedAddressAnswerDebug(items) {
continue;
}
const debug = item.debug;
if (debug.execution_lane !== "address_query") {
continue;
}
const groundingStatus = toNonEmptyString(debug.answer_grounding_check?.status);
if (groundingStatus === "grounded") {
if ((0, assistantContinuityPolicy_1.isGroundedAddressDebug)(debug, toNonEmptyString)) {
return debug;
}
}