diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 5bf2f99..b29eb76 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -1,8 +1,10 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.readAssistantMcpDiscoveryPilotScope = readAssistantMcpDiscoveryPilotScope; exports.formatIsoDateForReply = formatIsoDateForReply; exports.readAddressDebugFilters = readAddressDebugFilters; exports.readAddressDebugItem = readAddressDebugItem; +exports.readAddressDebugCounterparty = readAddressDebugCounterparty; exports.readAddressDebugOrganization = readAddressDebugOrganization; exports.readAddressDebugScopedDate = readAddressDebugScopedDate; exports.readAddressDebugTemporalScope = readAddressDebugTemporalScope; @@ -41,6 +43,48 @@ function toRecordObject(value) { } return value; } +function readAssistantMcpDiscoveryEntry(debug) { + 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) { + const entry = readAssistantMcpDiscoveryEntry(debug); + const turnInput = toRecordObject(entry?.turn_input); + return toRecordObject(turnInput?.turn_meaning_ref); +} +function readAssistantMcpDiscoveryBridge(debug) { + return toRecordObject(readAssistantMcpDiscoveryEntry(debug)?.bridge); +} +function readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString = fallbackToNonEmptyString) { + const bridge = readAssistantMcpDiscoveryBridge(debug); + const pilot = toRecordObject(bridge?.pilot); + return toNonEmptyString(pilot?.pilot_scope); +} +function formatDiscoveryDateScopeForReply(value) { + const text = fallbackToNonEmptyString(value); + if (!text) { + return null; + } + return formatIsoDateForReply(text) ?? text; +} +function hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString = fallbackToNonEmptyString) { + 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) { return (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(values); } @@ -65,17 +109,43 @@ function readAddressDebugItem(debug, toNonEmptyString = fallbackToNonEmptyString ? toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw) : null)); } +function readAddressDebugCounterparty(debug, toNonEmptyString = fallbackToNonEmptyString) { + 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; +} function readAddressDebugOrganization(debug, toNonEmptyString = fallbackToNonEmptyString) { 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)); } function readAddressDebugScopedDate(debug) { const extractedFilters = readAddressDebugFilters(debug); const rootFrameContext = toRecordObject(debug?.address_root_frame_context); 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)); } function readAddressDebugTemporalScope(debug, toNonEmptyString = fallbackToNonEmptyString) { const extractedFilters = readAddressDebugFilters(debug); @@ -110,6 +180,13 @@ function resolveAddressDebugAnchorContext(debug, toNonEmptyString = fallbackToNo anchorValue: counterparty }; } + const discoveryCounterparty = readAddressDebugCounterparty(debug, toNonEmptyString); + if (discoveryCounterparty) { + return { + anchorType: "counterparty", + anchorValue: discoveryCounterparty + }; + } const account = toNonEmptyString(extractedFilters?.account); if (account) { return { @@ -149,6 +226,7 @@ function resolveNavigationSessionContextState(addressNavigationState, toNonEmpty function resolveAddressDebugContextFacts(debug, toNonEmptyString = fallbackToNonEmptyString) { return { item: readAddressDebugItem(debug, toNonEmptyString), + counterparty: readAddressDebugCounterparty(debug, toNonEmptyString), organization: readAddressDebugOrganization(debug, toNonEmptyString), scopedDate: readAddressDebugScopedDate(debug) }; @@ -538,13 +616,12 @@ function isGroundedAddressDebug(debug, toNonEmptyString = fallbackToNonEmptyStri 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(debug, toNonEmptyString) { if (!isGroundedAddressDebug(debug, toNonEmptyString)) { diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js index d3aac0e..2dbda48 100644 --- a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -14,6 +14,62 @@ function toNonEmptyString(value) { const text = String(value).trim(); return text.length > 0 ? text : null; } +function toRecordObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} +function periodPartForRecap(scopedDate) { + if (!scopedDate) { + return ""; + } + return /^\d{2}\.\d{2}\.\d{4}$/.test(scopedDate) ? ` на ${scopedDate}` : ` за период ${scopedDate}`; +} +function buildDiscoveryRecapFactLine(input) { + if (!input.debug || !input.counterparty) { + return null; + } + const pilotScope = (0, assistantContinuityPolicy_1.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) { const values = [ input.rawUserMessage, @@ -60,7 +116,16 @@ function normalizeRecapIdentity(value) { function buildRecapFactLine(input) { const detectedIntent = String(input.debug?.detected_intent ?? ""); const scopedDate = (0, assistantContinuityPolicy_1.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") { @@ -87,6 +152,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(); } @@ -125,6 +193,7 @@ function collectRecentRecapFacts(input) { const fact = buildRecapFactLine({ debug: item.debug, item: debugItem, + counterparty: debugContext.counterparty, organization: debugOrganization }); if (!fact || seen.has(fact)) { @@ -141,6 +210,7 @@ function collectRecentRecapFacts(input) { function buildAddressMemoryRecapReply(input) { const contextFacts = (0, assistantContinuityPolicy_1.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({ @@ -166,6 +236,21 @@ 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}` : ""; @@ -179,7 +264,35 @@ function buildAddressMemoryRecapReply(input) { function buildSelectedObjectAnswerInspectionReply(input) { const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const itemLabel = contextFacts.item ?? "эта позиция"; + const counterpartyLabel = contextFacts.counterparty; const detectedIntent = String(input.addressDebug?.detected_intent ?? ""); + const pilotScope = (0, assistantContinuityPolicy_1.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 [ `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`, diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index d39e3a6..e6541a9 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3865,11 +3865,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; } } diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index e73d047..554ce24 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -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 | null { return value as Record; } +function readAssistantMcpDiscoveryEntry( + debug: Record | null +): Record | 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 | null +): Record | null { + const entry = readAssistantMcpDiscoveryEntry(debug); + const turnInput = toRecordObject(entry?.turn_input); + return toRecordObject(turnInput?.turn_meaning_ref); +} + +function readAssistantMcpDiscoveryBridge( + debug: Record | null +): Record | null { + return toRecordObject(readAssistantMcpDiscoveryEntry(debug)?.bridge); +} + +export function readAssistantMcpDiscoveryPilotScope( + debug: Record | 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 | 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 | 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 | 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 | null): string | null { @@ -156,7 +250,8 @@ export function readAddressDebugScopedDate(debug: Record | 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( diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts index 3ad39e3..bcac467 100644 --- a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +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 | 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 | 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 [ diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 3151578..7f94a5a 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -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; } } diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index 59e70c6..a937fb2 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -108,11 +108,45 @@ describe("assistantContinuityPolicy organization authority", () => { expect(facts).toEqual({ item: "Рабочая станция", + counterparty: null, organization: 'ООО "Альтернатива Плюс"', scopedDate: "31.03.2020" }); }); + it("reads counterparty, organization and period from grounded MCP discovery fallback", () => { + const facts = resolveAddressDebugContextFacts({ + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_active_organization: "ООО Альтернатива Плюс", + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + } + }); + + expect(facts).toEqual({ + item: null, + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + scopedDate: "2020" + }); + }); + it("resolves navigation session context through one shared helper", () => { const state = resolveNavigationSessionContextState({ session_context: { diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index 775f127..29311bc 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -366,6 +366,56 @@ describe("assistant living chat runtime adapter", () => { expect(executeLlmChat).not.toHaveBeenCalled(); }); + it("builds deterministic memory recap for prior grounded MCP discovery counterparty context", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + userMessage: "а ты помнишь, что мы выяснили по свк?", + modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" }, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + }, + pilot: { + pilot_scope: "counterparty_supplier_payout_query_movements_v1", + derived_value_flow: { + total_amount_human_ru: "43 763 351,53 руб." + } + } + } + } + } + } + ], + executeLlmChat + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toContain("Группа СВК"); + expect(output.chatText).toContain("43 763 351,53 руб."); + expect(output.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract"); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); + it("uses continuity-backed active organization for organization-fact boundary even when session scope is empty", async () => { const executeLlmChat = vi.fn(async () => "raw-llm"); const input = buildRuntimeInput({ @@ -434,4 +484,50 @@ describe("assistant living chat runtime adapter", () => { expect(output.debug?.living_chat_response_source).toBe("deterministic_answer_inspection_contract"); expect(executeLlmChat).not.toHaveBeenCalled(); }); + + it("builds deterministic answer inspection reply over grounded MCP discovery net answer", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + userMessage: "что ты имел в виду под нетто по свк?", + modeDecision: { mode: "chat", reason: "answer_inspection_followup_detected" }, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + }, + pilot: { + pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1" + } + } + } + } + } + ], + executeLlmChat + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toContain("Группа СВК"); + expect(output.chatText).toContain("Нетто"); + expect(output.debug?.living_chat_response_source).toBe("deterministic_answer_inspection_contract"); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts index 4db1898..1e5a258 100644 --- a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts @@ -133,6 +133,50 @@ describe("assistantMemoryRecapPolicy", () => { expect(signals.contextualMemoryRecapFollowupDetected).toBe(false); }); + it("detects contextual memory recap over prior grounded MCP discovery answer", () => { + const signals = policy.resolveRouteMemorySignals({ + rawUserMessage: "а ты помнишь, что мы уже выяснили по свк?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "", + dataScopeMetaQuery: false, + capabilityMetaQuery: false, + dataRetrievalSignal: false, + strongDataSignal: false, + aggregateBusinessAnalyticsSignal: false, + lastGroundedAddressDebug: null, + hasPriorAddressDebug: true, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + } + } + } + ] + }); + + expect(signals.contextualMemoryRecapFollowupDetected).toBe(true); + }); + it("builds deterministic recap summary from recent selected-object facts", () => { const context = resolveAssistantLivingChatMemoryContext({ modeDecisionReason: "memory_recap_followup_detected", @@ -278,4 +322,114 @@ describe("assistantMemoryRecapPolicy", () => { expect(reply).toContain("Рабочая станция"); expect(reply).toContain("Покупатель"); }); + + it("builds deterministic recap summary from grounded MCP discovery counterparty context", () => { + const sessionItems = [ + { + role: "assistant", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + }, + pilot: { + pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1", + derived_bidirectional_value_flow: { + net_amount_human_ru: "3 865 501,50 руб.", + incoming_customer_revenue: { + total_amount_human_ru: "47 628 853,03 руб." + }, + outgoing_supplier_payout: { + total_amount_human_ru: "43 763 351,53 руб." + } + } + } + } + } + } + } + ]; + const context = resolveAssistantLivingChatMemoryContext({ + modeDecisionReason: "memory_recap_followup_detected", + sessionItems + }); + + const reply = buildAddressMemoryRecapReply({ + organization: null, + addressDebug: context.lastMemoryAddressDebug, + sessionItems, + toNonEmptyString: (value: unknown) => { + const text = String(value ?? "").trim(); + return text.length > 0 ? text : null; + } + }); + + expect(context.contextualMemoryRecapFollowup).toBe(true); + expect(reply).toContain("Группа СВК"); + expect(reply).toContain("нетто"); + expect(reply).toContain("47 628 853,03 руб."); + expect(reply).toContain("43 763 351,53 руб."); + }); + + it("builds grounded answer inspection reply for MCP discovery net answer", () => { + const context = resolveAssistantLivingChatMemoryContext({ + modeDecisionReason: "answer_inspection_followup_detected", + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + }, + pilot: { + pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1" + } + } + } + } + } + ] + }); + + const reply = buildSelectedObjectAnswerInspectionReply({ + addressDebug: context.lastAnswerInspectionAddressDebug, + toNonEmptyString: (value: unknown) => { + const text = String(value ?? "").trim(); + return text.length > 0 ? text : null; + } + }); + + expect(context.contextualAnswerInspectionFollowup).toBe(true); + expect(reply).toContain("Группа СВК"); + expect(reply).toContain("Нетто"); + expect(reply).toContain("проверенному периоду"); + }); });