diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index d93a106..c79d387 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -112,6 +112,29 @@ function localizeLine(value) { if (/^Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows$/i.test(value)) { return "Помесячная нетто-раскладка сгруппирована только по подтвержденным входящим и исходящим строкам 1С."; } + const metadataSurfaceMatch = value.match(/^Confirmed 1C metadata surface(?: for scope "([^"]+)")?: (\d+) rows and (\d+) matching objects$/i); + if (metadataSurfaceMatch) { + const scopePart = metadataSurfaceMatch[1] ? ` по области "${metadataSurfaceMatch[1]}"` : ""; + return `В 1С подтверждена metadata-поверхность${scopePart}: ${metadataSurfaceMatch[2]} строк metadata-ответа и ${metadataSurfaceMatch[3]} совпавших объекта(ов).`; + } + const metadataObjectSetsMatch = value.match(/^Available metadata object sets: (.+)$/i); + if (metadataObjectSetsMatch) { + return `Доступные типы metadata-объектов: ${metadataObjectSetsMatch[1]}.`; + } + const metadataFieldsMatch = value.match(/^Available metadata fields\/sections: (.+)$/i); + if (metadataFieldsMatch) { + return `Доступные metadata-поля/секции: ${metadataFieldsMatch[1]}.`; + } + if (/^Detailed metadata fields were not returned by this MCP metadata probe$/i.test(value)) { + return "Эта MCP-проверка metadata не вернула детальный список полей."; + } + const noMatchingMetadataScopeMatch = value.match(/^No matching 1C metadata objects were confirmed for scope "([^"]+)"$/i); + if (noMatchingMetadataScopeMatch) { + return `В 1С не подтверждены metadata-объекты по области "${noMatchingMetadataScopeMatch[1]}".`; + } + if (/^No matching 1C metadata objects were confirmed by this MCP metadata probe$/i.test(value)) { + return "В 1С эта MCP-проверка не подтвердила подходящих metadata-объектов."; + } if (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) { return "Юридическая дата регистрации этим поиском не подтверждена."; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index c009258..5585908 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -141,6 +141,13 @@ function mapPilotScopeToFollowupMeaning(pilotScope) { unsupported: "counterparty_bidirectional_value_flow_or_netting" }; } + if (pilotScope === "metadata_inspection_v1") { + return { + domain: "metadata", + action: "inspect_catalog", + unsupported: "1c_metadata_surface" + }; + } return { domain: null, action: null, @@ -183,6 +190,7 @@ function collectFollowupDiscoverySeed(followupContext) { const mapped = mapPilotScopeToFollowupMeaning(pilotScope).domain !== null ? mapPilotScopeToFollowupMeaning(pilotScope) : mapAddressIntentToFollowupMeaning(previousIntent); + const discoveryEntities = collectEntityCandidates(followupContext?.previous_discovery_entity_candidates); const counterparty = toNonEmptyString(previousFilters?.counterparty) ?? toNonEmptyString(rootFilters?.counterparty) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" @@ -201,6 +209,7 @@ function collectFollowupDiscoverySeed(followupContext) { action: mapped.action, unsupported: mapped.unsupported, counterparty, + discoveryEntity: discoveryEntities[0] ?? null, organization, dateScope }; @@ -227,6 +236,9 @@ function hasMetadataSignal(text) { return (/(?:\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b|\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438|\u043f\u043e\u043b(?:\u0435|\u044f)|registers?|documents?|catalogs?|fields?)/iu.test(text) && /(?:\u0435\u0441\u0442\u044c|\u0434\u043e\u0441\u0442\u0443\u043f\u043d|\u0432\s+1\u0441|available|exist)/iu.test(text)); } +function hasMetadataObjectHint(text) { + return /(?:\u0440\u0435\u0433\u0438\u0441\u0442\u0440(?:\u044b)?|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442(?:\u044b)?|\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a(?:\u0438)?|\u043f\u043e\u043b(?:\u0435|\u044f)|registers?|documents?|catalogs?|fields?)/iu.test(text); +} function metadataActionFromRawText(text) { if (/(?:\u043f\u043e\u043b(?:\u0435|\u044f)|field)/iu.test(text)) { return "inspect_fields"; @@ -322,9 +334,13 @@ function buildAssistantMcpDiscoveryTurnInput(input) { !rawLifecycleSignal && !rawValueFlowSignal && (monthlyAggregationSignal || explicitDateScopeLiteralDetected || predecomposeDateScope)); - const seededDomain = followupDiscoverySeedApplicable ? followupSeed.domain : null; - const seededAction = followupDiscoverySeedApplicable ? followupSeed.action : null; - const seededUnsupported = followupDiscoverySeedApplicable ? followupSeed.unsupported : null; + const metadataFollowupSeedApplicable = Boolean(followupSeed.domain === "metadata" && + !rawLifecycleSignal && + !rawValueFlowSignal && + hasMetadataObjectHint(rawText)); + const seededDomain = followupDiscoverySeedApplicable || metadataFollowupSeedApplicable ? followupSeed.domain : null; + const seededAction = followupDiscoverySeedApplicable || metadataFollowupSeedApplicable ? followupSeed.action : null; + const seededUnsupported = followupDiscoverySeedApplicable || metadataFollowupSeedApplicable ? followupSeed.unsupported : null; const lifecycleSignal = rawLifecycleSignal || seededDomain === "counterparty_lifecycle"; const bidirectionalValueFlowSignal = !lifecycleSignal && (rawBidirectionalValueFlowSignal || seededAction === "net_value_flow"); @@ -338,11 +354,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) { unsupported: unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, - metadataSignal: rawMetadataSignal + metadataSignal: rawMetadataSignal || metadataFollowupSeedApplicable }); const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); pushUnique(entityCandidates, predecomposeEntities.counterparty); pushUnique(entityCandidates, followupSeed.counterparty); + if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !followupSeed.counterparty) { + pushUnique(entityCandidates, followupSeed.discoveryEntity); + } if (valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty) { pushUnique(entityCandidates, predecomposeEntities.organization); pushUnique(entityCandidates, followupSeed.organization); @@ -356,7 +375,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" - : rawMetadataSignal + : rawMetadataSignal || metadataFollowupSeedApplicable ? "metadata" : rawDomain ?? seededDomain, asked_action_family: lifecycleSignal @@ -367,8 +386,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) { : payoutSignal ? "payout" : rawAction ?? seededAction ?? "turnover" - : rawMetadataSignal - ? metadataActionFromRawText(rawText) + : rawMetadataSignal || metadataFollowupSeedApplicable + ? metadataActionFromRawText(rawText) ?? seededAction : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, explicit_entity_candidates: entityCandidates, @@ -383,7 +402,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { : payoutSignal ? "counterparty_payouts_or_outflow" : seededUnsupported ?? "counterparty_value_or_turnover" - : rawMetadataSignal + : rawMetadataSignal || metadataFollowupSeedApplicable ? "1c_metadata_surface" : followupDiscoverySeedApplicable ? seededUnsupported @@ -393,6 +412,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { lifecycleSignal || valueFlowSignal || rawMetadataSignal || + metadataFollowupSeedApplicable || followupDiscoverySeedApplicable) }; const cleanTurnMeaning = {}; @@ -424,15 +444,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { unsupported: unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, - metadataSignal: rawMetadataSignal, + metadataSignal: rawMetadataSignal || metadataFollowupSeedApplicable, semanticDataNeed, explicitIntentCandidate, - followupDiscoverySeedApplicable + followupDiscoverySeedApplicable: followupDiscoverySeedApplicable || metadataFollowupSeedApplicable }); const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal = assistantTurnMeaning ? "assistant_turn_meaning" - : followupDiscoverySeedApplicable + : followupDiscoverySeedApplicable || metadataFollowupSeedApplicable ? "followup_context" : predecomposeContract ? "predecompose_contract" @@ -440,7 +460,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "raw_text" : valueFlowSignal ? "raw_text" - : rawMetadataSignal + : rawMetadataSignal || metadataFollowupSeedApplicable ? "raw_text" : "none"; if (lifecycleSignal) { @@ -464,6 +484,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (followupDiscoverySeedApplicable) { pushReason(reasonCodes, "mcp_discovery_seeded_from_followup_context"); } + if (metadataFollowupSeedApplicable) { + pushReason(reasonCodes, "mcp_discovery_metadata_seeded_from_followup_context"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js index 74a89fd..23ad8c2 100644 --- a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -34,8 +34,30 @@ function periodPartForRecap(scopedDate) { } return /^\d{2}\.\d{2}\.\d{4}$/.test(scopedDate) ? ` на ${scopedDate}` : ` за период ${scopedDate}`; } +function readDiscoveryMetadataScope(debug) { + const discoveryEntry = toRecordObject(debug?.assistant_mcp_discovery_entry_point_v1); + const bridge = toRecordObject(discoveryEntry?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const surface = toRecordObject(pilot?.derived_metadata_surface); + const surfaceScope = toNonEmptyString(surface?.metadata_scope); + if (surfaceScope) { + return surfaceScope; + } + const turnInput = toRecordObject(discoveryEntry?.turn_input); + const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref); + const entityCandidates = Array.isArray(turnMeaningRef?.explicit_entity_candidates) + ? turnMeaningRef.explicit_entity_candidates + : []; + for (const candidate of entityCandidates) { + const text = toNonEmptyString(candidate); + if (text) { + return text; + } + } + return null; +} function buildDiscoveryRecapFactLine(input) { - if (!input.debug || !input.counterparty) { + if (!input.debug) { return null; } const pilotScope = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryPilotScope)(input.debug, toNonEmptyString); @@ -43,6 +65,34 @@ function buildDiscoveryRecapFactLine(input) { const bridge = toRecordObject(discoveryEntry?.bridge); const pilot = toRecordObject(bridge?.pilot); const periodPart = periodPartForRecap(input.scopedDate); + if (pilotScope === "metadata_inspection_v1") { + const metadataScope = readDiscoveryMetadataScope(input.debug); + const surface = toRecordObject(pilot?.derived_metadata_surface); + const entitySets = Array.isArray(surface?.available_entity_sets) + ? surface.available_entity_sets + .map((item) => toNonEmptyString(item)) + .filter((item) => Boolean(item)) + : []; + const fields = Array.isArray(surface?.available_fields) + ? surface.available_fields + .map((item) => toNonEmptyString(item)) + .filter((item) => Boolean(item)) + : []; + const objects = Array.isArray(surface?.matched_objects) + ? surface.matched_objects + .map((item) => toNonEmptyString(item)) + .filter((item) => Boolean(item)) + : []; + const rows = Number(surface?.matched_rows ?? 0); + const scopePart = metadataScope ? ` по области «${metadataScope}»` : ""; + const objectsPart = objects.length > 0 ? `, нашли объекты ${objects.slice(0, 4).join(", ")}` : ""; + const entitySetsPart = entitySets.length > 0 ? `, видны типы ${entitySets.slice(0, 4).join(", ")}` : ""; + const fieldsPart = fields.length > 0 ? `, доступны поля/секции ${fields.slice(0, 5).join(", ")}` : ""; + return `смотрели metadata-поверхность 1С${scopePart}${periodPart}: ${rows} подтвержденных строк${objectsPart}${entitySetsPart}${fieldsPart}`.trim(); + } + if (!input.counterparty) { + return null; + } if (pilotScope === "counterparty_lifecycle_query_documents_v1") { const activityPeriod = toRecordObject(pilot?.derived_activity_period); const duration = toNonEmptyString(activityPeriod?.duration_human_ru); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 7791925..68fc41f 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -146,6 +146,31 @@ function localizeLine(value: string): string { if (/^Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows$/i.test(value)) { return "Помесячная нетто-раскладка сгруппирована только по подтвержденным входящим и исходящим строкам 1С."; } + const metadataSurfaceMatch = value.match( + /^Confirmed 1C metadata surface(?: for scope "([^"]+)")?: (\d+) rows and (\d+) matching objects$/i + ); + if (metadataSurfaceMatch) { + const scopePart = metadataSurfaceMatch[1] ? ` по области "${metadataSurfaceMatch[1]}"` : ""; + return `В 1С подтверждена metadata-поверхность${scopePart}: ${metadataSurfaceMatch[2]} строк metadata-ответа и ${metadataSurfaceMatch[3]} совпавших объекта(ов).`; + } + const metadataObjectSetsMatch = value.match(/^Available metadata object sets: (.+)$/i); + if (metadataObjectSetsMatch) { + return `Доступные типы metadata-объектов: ${metadataObjectSetsMatch[1]}.`; + } + const metadataFieldsMatch = value.match(/^Available metadata fields\/sections: (.+)$/i); + if (metadataFieldsMatch) { + return `Доступные metadata-поля/секции: ${metadataFieldsMatch[1]}.`; + } + if (/^Detailed metadata fields were not returned by this MCP metadata probe$/i.test(value)) { + return "Эта MCP-проверка metadata не вернула детальный список полей."; + } + const noMatchingMetadataScopeMatch = value.match(/^No matching 1C metadata objects were confirmed for scope "([^"]+)"$/i); + if (noMatchingMetadataScopeMatch) { + return `В 1С не подтверждены metadata-объекты по области "${noMatchingMetadataScopeMatch[1]}".`; + } + if (/^No matching 1C metadata objects were confirmed by this MCP metadata probe$/i.test(value)) { + return "В 1С эта MCP-проверка не подтвердила подходящих metadata-объектов."; + } if (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) { return "Юридическая дата регистрации этим поиском не подтверждена."; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index e4ce9e8..c8f47a4 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -190,6 +190,13 @@ function mapPilotScopeToFollowupMeaning( unsupported: "counterparty_bidirectional_value_flow_or_netting" }; } + if (pilotScope === "metadata_inspection_v1") { + return { + domain: "metadata", + action: "inspect_catalog", + unsupported: "1c_metadata_surface" + }; + } return { domain: null, action: null, @@ -238,6 +245,7 @@ function collectFollowupDiscoverySeed(followupContext: Record | action: string | null; unsupported: string | null; counterparty: string | null; + discoveryEntity: string | null; organization: string | null; dateScope: string | null; } { @@ -250,6 +258,7 @@ function collectFollowupDiscoverySeed(followupContext: Record | mapPilotScopeToFollowupMeaning(pilotScope).domain !== null ? mapPilotScopeToFollowupMeaning(pilotScope) : mapAddressIntentToFollowupMeaning(previousIntent); + const discoveryEntities = collectEntityCandidates(followupContext?.previous_discovery_entity_candidates); const counterparty = toNonEmptyString(previousFilters?.counterparty) ?? toNonEmptyString(rootFilters?.counterparty) ?? @@ -271,6 +280,7 @@ function collectFollowupDiscoverySeed(followupContext: Record | action: mapped.action, unsupported: mapped.unsupported, counterparty, + discoveryEntity: discoveryEntities[0] ?? null, organization, dateScope }; @@ -322,6 +332,12 @@ function hasMetadataSignal(text: string): boolean { ); } +function hasMetadataObjectHint(text: string): boolean { + return /(?:\u0440\u0435\u0433\u0438\u0441\u0442\u0440(?:\u044b)?|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442(?:\u044b)?|\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a(?:\u0438)?|\u043f\u043e\u043b(?:\u0435|\u044f)|registers?|documents?|catalogs?|fields?)/iu.test( + text + ); +} + function metadataActionFromRawText(text: string): string { if (/(?:\u043f\u043e\u043b(?:\u0435|\u044f)|field)/iu.test(text)) { return "inspect_fields"; @@ -443,9 +459,15 @@ export function buildAssistantMcpDiscoveryTurnInput( !rawValueFlowSignal && (monthlyAggregationSignal || explicitDateScopeLiteralDetected || predecomposeDateScope) ); - const seededDomain = followupDiscoverySeedApplicable ? followupSeed.domain : null; - const seededAction = followupDiscoverySeedApplicable ? followupSeed.action : null; - const seededUnsupported = followupDiscoverySeedApplicable ? followupSeed.unsupported : null; + const metadataFollowupSeedApplicable = Boolean( + followupSeed.domain === "metadata" && + !rawLifecycleSignal && + !rawValueFlowSignal && + hasMetadataObjectHint(rawText) + ); + const seededDomain = followupDiscoverySeedApplicable || metadataFollowupSeedApplicable ? followupSeed.domain : null; + const seededAction = followupDiscoverySeedApplicable || metadataFollowupSeedApplicable ? followupSeed.action : null; + const seededUnsupported = followupDiscoverySeedApplicable || metadataFollowupSeedApplicable ? followupSeed.unsupported : null; const lifecycleSignal = rawLifecycleSignal || seededDomain === "counterparty_lifecycle"; const bidirectionalValueFlowSignal = @@ -463,11 +485,14 @@ export function buildAssistantMcpDiscoveryTurnInput( unsupported: unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, - metadataSignal: rawMetadataSignal + metadataSignal: rawMetadataSignal || metadataFollowupSeedApplicable }); const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); pushUnique(entityCandidates, predecomposeEntities.counterparty); pushUnique(entityCandidates, followupSeed.counterparty); + if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !followupSeed.counterparty) { + pushUnique(entityCandidates, followupSeed.discoveryEntity); + } if (valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty) { pushUnique(entityCandidates, predecomposeEntities.organization); pushUnique(entityCandidates, followupSeed.organization); @@ -484,7 +509,7 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" - : rawMetadataSignal + : rawMetadataSignal || metadataFollowupSeedApplicable ? "metadata" : rawDomain ?? seededDomain, asked_action_family: lifecycleSignal @@ -495,8 +520,8 @@ export function buildAssistantMcpDiscoveryTurnInput( : payoutSignal ? "payout" : rawAction ?? seededAction ?? "turnover" - : rawMetadataSignal - ? metadataActionFromRawText(rawText) + : rawMetadataSignal || metadataFollowupSeedApplicable + ? metadataActionFromRawText(rawText) ?? seededAction : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, explicit_entity_candidates: entityCandidates, @@ -512,7 +537,7 @@ export function buildAssistantMcpDiscoveryTurnInput( : payoutSignal ? "counterparty_payouts_or_outflow" : seededUnsupported ?? "counterparty_value_or_turnover" - : rawMetadataSignal + : rawMetadataSignal || metadataFollowupSeedApplicable ? "1c_metadata_surface" : followupDiscoverySeedApplicable ? seededUnsupported @@ -523,6 +548,7 @@ export function buildAssistantMcpDiscoveryTurnInput( lifecycleSignal || valueFlowSignal || rawMetadataSignal || + metadataFollowupSeedApplicable || followupDiscoverySeedApplicable ) }; @@ -557,15 +583,15 @@ export function buildAssistantMcpDiscoveryTurnInput( unsupported: unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, - metadataSignal: rawMetadataSignal, + metadataSignal: rawMetadataSignal || metadataFollowupSeedApplicable, semanticDataNeed, explicitIntentCandidate, - followupDiscoverySeedApplicable + followupDiscoverySeedApplicable: followupDiscoverySeedApplicable || metadataFollowupSeedApplicable }); const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal: AssistantMcpDiscoveryTurnInputSource = assistantTurnMeaning ? "assistant_turn_meaning" - : followupDiscoverySeedApplicable + : followupDiscoverySeedApplicable || metadataFollowupSeedApplicable ? "followup_context" : predecomposeContract ? "predecompose_contract" @@ -573,7 +599,7 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "raw_text" : valueFlowSignal ? "raw_text" - : rawMetadataSignal + : rawMetadataSignal || metadataFollowupSeedApplicable ? "raw_text" : "none"; @@ -598,6 +624,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (followupDiscoverySeedApplicable) { pushReason(reasonCodes, "mcp_discovery_seeded_from_followup_context"); } + if (metadataFollowupSeedApplicable) { + pushReason(reasonCodes, "mcp_discovery_metadata_seeded_from_followup_context"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts index d304cca..4b99efa 100644 --- a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -77,12 +77,35 @@ function periodPartForRecap(scopedDate: string | null): string { return /^\d{2}\.\d{2}\.\d{4}$/.test(scopedDate) ? ` на ${scopedDate}` : ` за период ${scopedDate}`; } +function readDiscoveryMetadataScope(debug: Record | null): string | null { + const discoveryEntry = toRecordObject(debug?.assistant_mcp_discovery_entry_point_v1); + const bridge = toRecordObject(discoveryEntry?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const surface = toRecordObject(pilot?.derived_metadata_surface); + const surfaceScope = toNonEmptyString(surface?.metadata_scope); + if (surfaceScope) { + return surfaceScope; + } + const turnInput = toRecordObject(discoveryEntry?.turn_input); + const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref); + const entityCandidates = Array.isArray(turnMeaningRef?.explicit_entity_candidates) + ? turnMeaningRef.explicit_entity_candidates + : []; + for (const candidate of entityCandidates) { + const text = toNonEmptyString(candidate); + if (text) { + return text; + } + } + return null; +} + function buildDiscoveryRecapFactLine(input: { debug: Record | null; counterparty: string | null; scopedDate: string | null; }): string | null { - if (!input.debug || !input.counterparty) { + if (!input.debug) { return null; } const pilotScope = readAssistantMcpDiscoveryPilotScope(input.debug, toNonEmptyString); @@ -90,6 +113,36 @@ function buildDiscoveryRecapFactLine(input: { const bridge = toRecordObject(discoveryEntry?.bridge); const pilot = toRecordObject(bridge?.pilot); const periodPart = periodPartForRecap(input.scopedDate); + if (pilotScope === "metadata_inspection_v1") { + const metadataScope = readDiscoveryMetadataScope(input.debug); + const surface = toRecordObject(pilot?.derived_metadata_surface); + const entitySets = Array.isArray(surface?.available_entity_sets) + ? surface.available_entity_sets + .map((item) => toNonEmptyString(item)) + .filter((item): item is string => Boolean(item)) + : []; + const fields = Array.isArray(surface?.available_fields) + ? surface.available_fields + .map((item) => toNonEmptyString(item)) + .filter((item): item is string => Boolean(item)) + : []; + const objects = Array.isArray(surface?.matched_objects) + ? surface.matched_objects + .map((item) => toNonEmptyString(item)) + .filter((item): item is string => Boolean(item)) + : []; + const rows = Number(surface?.matched_rows ?? 0); + const scopePart = metadataScope ? ` по области «${metadataScope}»` : ""; + const objectsPart = objects.length > 0 ? `, нашли объекты ${objects.slice(0, 4).join(", ")}` : ""; + const entitySetsPart = + entitySets.length > 0 ? `, видны типы ${entitySets.slice(0, 4).join(", ")}` : ""; + const fieldsPart = + fields.length > 0 ? `, доступны поля/секции ${fields.slice(0, 5).join(", ")}` : ""; + return `смотрели metadata-поверхность 1С${scopePart}${periodPart}: ${rows} подтвержденных строк${objectsPart}${entitySetsPart}${fieldsPart}`.trim(); + } + if (!input.counterparty) { + return null; + } if (pilotScope === "counterparty_lifecycle_query_documents_v1") { const activityPeriod = toRecordObject(pilot?.derived_activity_period); const duration = toNonEmptyString(activityPeriod?.duration_human_ru); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 35da20a..1ca71db 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -209,6 +209,39 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("broad probe hit the row limit"); }); + it("localizes metadata evidence without leaking raw MCP wording", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "По данным 1С найдена подтвержденная metadata-поверхность.", + confirmed_lines: [ + 'Confirmed 1C metadata surface for scope "НДС": 7 rows and 3 matching objects', + "Available metadata object sets: accumulation_register, document", + "Available metadata fields/sections: amount, vat_rate, organization" + ], + inference_lines: [], + unknown_lines: ['No matching 1C metadata objects were confirmed for scope "Прибыль"'], + limitation_lines: ["Detailed metadata fields were not returned by this MCP metadata probe"], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain('В 1С подтверждена metadata-поверхность по области "НДС"'); + expect(candidate.reply_text).toContain("Доступные типы metadata-объектов"); + expect(candidate.reply_text).toContain("Доступные metadata-поля/секции"); + expect(candidate.reply_text).toContain('В 1С не подтверждены metadata-объекты по области "Прибыль"'); + expect(candidate.reply_text).toContain("Эта MCP-проверка metadata не вернула детальный список полей"); + expect(candidate.reply_text).not.toContain("Confirmed 1C metadata surface"); + }); + it("returns not applicable when discovery was skipped for an exact supported route", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate({ schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index d868de4..6d91a8e 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -203,6 +203,34 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_date_scope_from_followup_context"); }); + it("seeds short metadata follow-up from prior metadata discovery context", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а по регистрам?", + followupContext: { + previous_discovery_pilot_scope: "metadata_inspection_v1", + previous_filters: { + counterparty: "НДС" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "НДС" + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.source_signal).toBe("followup_context"); + expect(result.semantic_data_need).toBe("1C metadata evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "metadata", + asked_action_family: "inspect_registers", + explicit_entity_candidates: ["НДС"], + unsupported_but_understood_family: "1c_metadata_surface", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_metadata_seeded_from_followup_context"); + expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_followup_context"); + }); + it("switches the checked year on a short payout follow-up while keeping prior discovery counterparty", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "а теперь за 2021?", diff --git a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts index d2c4a74..f9814fb 100644 --- a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts @@ -386,6 +386,64 @@ describe("assistantMemoryRecapPolicy", () => { expect(reply).toContain("43 763 351,53 руб."); }); + it("builds deterministic recap summary from grounded MCP metadata discovery 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: ["НДС"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + }, + pilot: { + pilot_scope: "metadata_inspection_v1", + derived_metadata_surface: { + metadata_scope: "НДС", + matched_rows: 7, + matched_objects: ["РегистрНакопления.НДСПокупок"], + available_entity_sets: ["accumulation_register", "document"], + available_fields: ["amount", "vat_rate", "organization"] + } + } + } + } + } + } + ]; + 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("metadata-поверхность 1С"); + expect(reply).toContain("amount"); + expect(reply).toContain("accumulation_register"); + }); + it("builds deterministic broad business evaluation summary from recent grounded organization facts", () => { const sessionItems = [ {