diff --git a/docs/orchestration/address_truth_harness_phase26_entity_followup_chain.json b/docs/orchestration/address_truth_harness_phase26_entity_followup_chain.json new file mode 100644 index 0000000..e2e33a4 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase26_entity_followup_chain.json @@ -0,0 +1,99 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase26_entity_followup_chain", + "domain": "address_phase26_entity_followup_chain", + "title": "Phase 26 resolved-entity follow-up chain replay", + "description": "Targeted AGENT replay for the next Big Block C slice where an MCP-grounded counterparty must become a reusable dialog anchor for downstream document and movement evidence requests without forcing the user to repeat the resolved 1C name.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_resolve_counterparty_alias", + "title": "Entity resolution grounds the checked 1C counterparty from a loose alias", + "question": "найди в 1С контрагента СВК", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)свк", + "(?i)контрагент" + ], + "required_answer_patterns_any": [ + "(?i)группа\\s+свк", + "(?i)каталог", + "(?i)найден", + "(?i)наиболее вероят" + ], + "forbidden_answer_patterns": [ + "(?i)получили", + "(?i)заплатили", + "(?i)нетто", + "(?i)оборот", + "(?i)выручк", + "(?i)сумм(а|ы)" + ], + "criticality": "critical", + "semantic_tags": [ + "entity_resolution", + "alias_grounding", + "followup_anchor" + ] + }, + { + "step_id": "step_02_documents_by_resolved_entity_followup", + "title": "Short document follow-up reuses the resolved counterparty anchor", + "question": "по нему документы за 2020 год", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)документ|счет|накладн|акт", + "(?i)2020" + ], + "required_answer_patterns_any": [ + "(?i)группа\\s+свк", + "(?i)свк" + ], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните, какого контрагента", + "(?i)по какому контрагенту" + ], + "criticality": "critical", + "semantic_tags": [ + "entity_resolution", + "document_evidence", + "followup_reuse" + ] + }, + { + "step_id": "step_03_movements_by_resolved_entity_followup", + "title": "Short movement follow-up keeps the same grounded counterparty anchor", + "question": "а теперь по нему движения за 2020 год", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)движени|платеж|операц|проводк", + "(?i)2020" + ], + "required_answer_patterns_any": [ + "(?i)группа\\s+свк", + "(?i)свк" + ], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните, какого контрагента", + "(?i)по какому контрагенту" + ], + "criticality": "critical", + "semantic_tags": [ + "entity_resolution", + "movement_evidence", + "followup_reuse" + ] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase27_entity_value_followup_chain.json b/docs/orchestration/address_truth_harness_phase27_entity_value_followup_chain.json new file mode 100644 index 0000000..bd1f4a1 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase27_entity_value_followup_chain.json @@ -0,0 +1,101 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase27_entity_value_followup_chain", + "domain": "address_phase27_entity_value_followup_chain", + "title": "Phase 27 resolved-entity value-flow follow-up replay", + "description": "Targeted AGENT replay for the next Big Block C slice where an MCP-grounded counterparty must become a reusable dialog anchor for downstream value-flow and net-flow questions without forcing the user to restate the resolved 1C name.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_resolve_counterparty_alias", + "title": "Entity resolution grounds the checked 1C counterparty from a loose alias", + "question": "найди в 1С контрагента СВК", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)свк", + "(?i)контрагент" + ], + "required_answer_patterns_any": [ + "(?i)группа\\s+свк", + "(?i)каталог", + "(?i)найден", + "(?i)наиболее вероят" + ], + "forbidden_answer_patterns": [ + "(?i)получили", + "(?i)заплатили", + "(?i)нетто", + "(?i)оборот", + "(?i)выручк", + "(?i)сумм(а|ы)" + ], + "criticality": "critical", + "semantic_tags": [ + "entity_resolution", + "alias_grounding", + "followup_anchor" + ] + }, + { + "step_id": "step_02_value_flow_by_resolved_entity_followup", + "title": "Short turnover follow-up reuses the resolved counterparty anchor", + "question": "сколько получили по нему за 2020 год", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)2020", + "(?i)получил|входящ|поступ", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)группа\\s+свк", + "(?i)свк" + ], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните, какого контрагента", + "(?i)по какому контрагенту" + ], + "criticality": "critical", + "semantic_tags": [ + "entity_resolution", + "counterparty_value_flow", + "followup_reuse" + ] + }, + { + "step_id": "step_03_net_flow_by_resolved_entity_followup", + "title": "Short net-flow follow-up keeps the same grounded counterparty anchor", + "question": "а какое нетто по нему за 2020 год", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)2020", + "(?i)нетто|сальдо|разниц", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)группа\\s+свк", + "(?i)свк" + ], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните, какого контрагента", + "(?i)по какому контрагенту" + ], + "criticality": "critical", + "semantic_tags": [ + "entity_resolution", + "counterparty_net_value_flow", + "followup_reuse" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 431aa5d..15b1316 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -1,5 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.readAssistantMcpDiscoveryEntityCandidates = readAssistantMcpDiscoveryEntityCandidates; exports.readAssistantMcpDiscoveryPilotScope = readAssistantMcpDiscoveryPilotScope; exports.readAssistantMcpDiscoveryMetadataRouteFamily = readAssistantMcpDiscoveryMetadataRouteFamily; exports.readAssistantMcpDiscoveryMetadataSelectedEntitySet = readAssistantMcpDiscoveryMetadataSelectedEntitySet; @@ -91,6 +92,39 @@ function readAssistantMcpDiscoveryDerivedMetadataSurface(debug) { const pilot = toRecordObject(bridge?.pilot); return toRecordObject(pilot?.derived_metadata_surface); } +function readAssistantMcpDiscoveryDerivedEntityResolution(debug) { + const bridge = readAssistantMcpDiscoveryBridge(debug); + const pilot = toRecordObject(bridge?.pilot); + return toRecordObject(pilot?.derived_entity_resolution); +} +function collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString = fallbackToNonEmptyString) { + const result = []; + const resolution = readAssistantMcpDiscoveryDerivedEntityResolution(debug); + const pushCandidate = (value) => { + const text = toNonEmptyString(value); + if (text && !result.includes(text)) { + result.push(text); + } + }; + pushCandidate(resolution?.resolved_entity); + pushCandidate(resolution?.requested_entity); + if (Array.isArray(resolution?.ambiguity_candidates)) { + for (const candidate of resolution.ambiguity_candidates) { + pushCandidate(candidate); + } + } + const discoveryMeaning = readAssistantMcpDiscoveryTurnMeaning(debug); + const explicitEntities = Array.isArray(discoveryMeaning?.explicit_entity_candidates) + ? discoveryMeaning.explicit_entity_candidates + : []; + for (const entity of explicitEntities) { + pushCandidate(candidateValue(entity)); + } + return result; +} +function readAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString = fallbackToNonEmptyString) { + return collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString); +} function readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString = fallbackToNonEmptyString) { const bridge = readAssistantMcpDiscoveryBridge(debug); const pilot = toRecordObject(bridge?.pilot); @@ -250,12 +284,9 @@ function readAddressDebugCounterparty(debug, toNonEmptyString = fallbackToNonEmp 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 = candidateValue(entity); + const discoveryEntities = collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString); + for (const entity of discoveryEntities) { + const text = toNonEmptyString(entity); if (text) { return text; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 07d13c0..23c7b02 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -113,6 +113,21 @@ function firstEntityCandidate(pilot) { } return null; } +function explicitDateScope(pilot) { + const value = pilot.evidence.query_plan.turn_meaning_ref?.explicit_date_scope; + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} +function documentOrMovementScopeRu(pilot) { + const entity = firstEntityCandidate(pilot); + const period = explicitDateScope(pilot); + const entityPart = entity ? ` по контрагенту ${entity}` : ""; + const periodPart = period ? ` за ${period}` : " в проверенном окне"; + return `${entityPart}${periodPart}`; +} function isMovementLaneClarification(pilot) { return (isMovementPilot(pilot) || pilot.reason_codes.includes("planner_selected_movement_recipe") || @@ -190,7 +205,7 @@ function headlineFor(mode, pilot) { return "По текущему каталожному поиску 1С точный контрагент пока не подтвержден."; } if (isMovementPilot(pilot) && mode === "confirmed_with_bounded_inference") { - return "РџРѕ данным 1РЎ найдены строки движений; ответ ограничен проверенным периодом Рё найденными строками."; + return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`; } if (pilot.derived_metadata_surface && mode === "confirmed_with_bounded_inference") { if (pilot.derived_metadata_surface.ambiguity_detected) { @@ -208,7 +223,7 @@ function headlineFor(mode, pilot) { if (pilot.derived_value_flow.value_flow_direction === "outgoing_supplier_payout") { return "По данным 1С найдены строки исходящих платежей/списаний; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; } - return "По данным 1С найдены строки денежных движений; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; + return "По данным 1С найдены строки входящих денежных поступлений; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; } if (pilot.derived_bidirectional_value_flow && mode === "confirmed_with_bounded_inference") { return "По данным 1С найдены строки входящих и исходящих денежных движений; нетто можно называть только как расчет по найденным строкам и проверенному периоду."; @@ -217,14 +232,23 @@ function headlineFor(mode, pilot) { if (pilot.derived_value_flow.value_flow_direction === "outgoing_supplier_payout") { return "По данным 1С найдены строки исходящих платежей/списаний; сумму можно называть только в рамках проверенного периода и найденных строк."; } - return "По данным 1С найдены строки денежных движений; сумму можно называть только в рамках проверенного периода и найденных строк."; + return "По данным 1С найдены строки входящих денежных поступлений; сумму можно называть только в рамках проверенного периода и найденных строк."; } if (isDocumentPilot(pilot) && mode === "confirmed_with_bounded_inference") { - return "По данным 1С найдены строки документов; ответ ограничен проверенным периодом и найденными строками."; + return `По документам${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`; + } + if (isMovementPilot(pilot) && mode === "confirmed_with_bounded_inference") { + return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`; } if (mode === "confirmed_with_bounded_inference") { return "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод из этих строк."; } + if (isDocumentPilot(pilot) && mode === "bounded_inference_only") { + return `По документам${documentOrMovementScopeRu(pilot)} полный срез не подтвержден; пока есть только ограниченная граница проверенного окна 1С.`; + } + if (isMovementPilot(pilot) && mode === "bounded_inference_only") { + return `По движениям${documentOrMovementScopeRu(pilot)} полный срез не подтвержден; пока есть только ограниченная граница проверенного окна 1С.`; + } if (mode === "bounded_inference_only") { return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С."; } @@ -436,13 +460,15 @@ function derivedValueFlowConfirmedLine(pilot) { } const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : ""; const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне"; - const movementLabel = flow.value_flow_direction === "outgoing_supplier_payout" ? "исходящих платежей/списаний" : "денежных движений"; + const movementLabel = flow.value_flow_direction === "outgoing_supplier_payout" + ? "исходящих платежей/списаний" + : "входящих денежных поступлений"; const totalLabel = flow.value_flow_direction === "outgoing_supplier_payout" ? "сумма исходящих платежей/списаний составляет" - : "сумма составляет"; + : "сумма входящих денежных поступлений составляет"; const caveat = flow.value_flow_direction === "outgoing_supplier_payout" ? "Это расчет по найденным строкам 1С, а не подтверждение полного объема платежей вне проверенного окна." - : "Это расчет по найденным строкам 1С, а не подтверждение полного оборота вне проверенного окна."; + : "Это расчет по найденным строкам 1С, а не подтверждение полного объема поступлений вне проверенного окна."; const dates = flow.first_movement_date && flow.latest_movement_date ? ` Первая найденная дата движения: ${flow.first_movement_date}; последняя: ${flow.latest_movement_date}.` : ""; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index 8b28e42..a5a95dd 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -1267,25 +1267,26 @@ function buildLifecycleConfirmedFacts(result, counterparty) { : "1C activity rows were found for the requested counterparty scope" ]; } -function buildDocumentConfirmedFacts(result, counterparty) { - if (result.error || result.matched_rows <= 0) { - return []; - } - return [ - counterparty - ? `1C document rows were found for counterparty ${counterparty}` - : "1C document rows were found for the requested scope" - ]; +function checkedCounterpartySuffixRu(counterparty) { + return counterparty ? ` по контрагенту ${counterparty}` : ""; } -function buildMovementConfirmedFacts(result, counterparty) { +function checkedPeriodSuffixRu(periodScope) { + return periodScope ? ` за ${periodScope}` : " в проверенном окне"; +} +function uncheckedPeriodBoundaryRu(periodScope) { + return periodScope ? ` вне периода ${periodScope}` : " без явно проверенного периода"; +} +function buildDocumentConfirmedFacts(result, counterparty, periodScope) { if (result.error || result.matched_rows <= 0) { return []; } - return [ - counterparty - ? `1C movement rows were found for counterparty ${counterparty}` - : "1C movement rows were found for the requested scope" - ]; + return [`В 1С найдены строки документов${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)}.`]; +} +function buildMovementConfirmedFacts(result, counterparty, periodScope) { + if (result.error || result.matched_rows <= 0) { + return []; + } + return [`В 1С найдены строки движений${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)}.`]; } function buildValueFlowConfirmedFacts(result, counterparty, direction) { if (result.error || result.matched_rows <= 0) { @@ -1325,17 +1326,31 @@ function buildLifecycleInferredFacts(result) { } return ["Business activity duration may be inferred from first and latest confirmed 1C activity rows"]; } -function buildDocumentInferredFacts(result) { +function buildDocumentInferredFacts(result, counterparty, periodScope) { if (result.error || result.fetched_rows <= 0) { return []; } - return ["Counterparty document evidence is limited to confirmed 1C document rows in the checked scope"]; + if (result.matched_rows <= 0) { + return [ + `По документам${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)} удалось проверить только ограниченный срез 1С; подтвержденных строк документов этим поиском не найдено.` + ]; + } + return [ + `Срез документов${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)} ограничен только подтвержденными строками документов, найденными этим поиском.` + ]; } -function buildMovementInferredFacts(result) { +function buildMovementInferredFacts(result, counterparty, periodScope) { if (result.error || result.fetched_rows <= 0) { return []; } - return ["Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope"]; + if (result.matched_rows <= 0) { + return [ + `По движениям${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)} удалось проверить только ограниченный срез 1С; подтвержденных строк движений этим поиском не найдено.` + ]; + } + return [ + `Срез движений${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)} ограничен только подтвержденными строками движений, найденными этим поиском.` + ]; } function buildValueFlowInferredFacts(derived) { if (!derived) { @@ -1372,18 +1387,14 @@ function buildBidirectionalValueFlowInferredFacts(derived) { function buildLifecycleUnknownFacts() { return ["Legal registration date is not proven by this MCP discovery pilot"]; } -function buildDocumentUnknownFacts(periodScope) { +function buildDocumentUnknownFacts(periodScope, counterparty) { return [ - periodScope - ? "Full document history outside the checked period is not proven by this MCP discovery pilot" - : "Full document history is not proven without an explicit checked period" + `Полный исторический срез документов${checkedCounterpartySuffixRu(counterparty)}${uncheckedPeriodBoundaryRu(periodScope)} этим поиском не подтвержден.` ]; } -function buildMovementUnknownFacts(periodScope) { +function buildMovementUnknownFacts(periodScope, counterparty) { return [ - periodScope - ? "Full movement history outside the checked period is not proven by this MCP discovery pilot" - : "Full movement history is not proven without an explicit checked period" + `Полный исторический срез движений${checkedCounterpartySuffixRu(counterparty)}${uncheckedPeriodBoundaryRu(periodScope)} этим поиском не подтвержден.` ]; } function buildValueFlowUnknownFacts(periodScope, direction, derived) { @@ -1749,9 +1760,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ plan: planner.discovery_plan, probeResults, - confirmedFacts: queryResult ? buildDocumentConfirmedFacts(queryResult, counterparty) : [], - inferredFacts: queryResult ? buildDocumentInferredFacts(queryResult) : [], - unknownFacts: buildDocumentUnknownFacts(dateScope), + confirmedFacts: queryResult ? buildDocumentConfirmedFacts(queryResult, counterparty, dateScope) : [], + inferredFacts: queryResult ? buildDocumentInferredFacts(queryResult, counterparty, dateScope) : [], + unknownFacts: buildDocumentUnknownFacts(dateScope, counterparty), sourceRowsSummary, queryLimitations, recommendedNextProbe: "explain_evidence_basis" @@ -1831,9 +1842,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ plan: planner.discovery_plan, probeResults, - confirmedFacts: queryResult ? buildMovementConfirmedFacts(queryResult, counterparty) : [], - inferredFacts: queryResult ? buildMovementInferredFacts(queryResult) : [], - unknownFacts: buildMovementUnknownFacts(dateScope), + confirmedFacts: queryResult ? buildMovementConfirmedFacts(queryResult, counterparty, dateScope) : [], + inferredFacts: queryResult ? buildMovementInferredFacts(queryResult, counterparty, dateScope) : [], + unknownFacts: buildMovementUnknownFacts(dateScope, counterparty), sourceRowsSummary, queryLimitations, recommendedNextProbe: "explain_evidence_basis" diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 903104f..3022dac 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -70,10 +70,10 @@ function localizeLine(value) { } const valueFlowMatch = value.match(/^1C value-flow rows were found for counterparty\s+(.+)$/i); if (valueFlowMatch) { - return `В 1С найдены строки денежных движений по контрагенту ${valueFlowMatch[1]}.`; + return `В 1С найдены строки входящих денежных поступлений по контрагенту ${valueFlowMatch[1]}.`; } if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) { - return "В 1С найдены строки денежных движений по запрошенному контрагентскому контуру."; + return "В 1С найдены строки входящих денежных поступлений по запрошенному контрагентскому контуру."; } const documentRowsMatch = value.match(/^1C document rows were found for counterparty\s+(.+)$/i); if (documentRowsMatch) { @@ -118,10 +118,10 @@ function localizeLine(value) { return "Срез движений ограничен только подтвержденными строками движений РІ проверенном РѕРєРЅРµ."; } if (/^Counterparty value-flow total was calculated from confirmed 1C movement rows$/i.test(value)) { - return "Сумма рассчитана только по подтвержденным строкам денежных движений в 1С."; + return "Сумма входящих поступлений рассчитана только по подтвержденным строкам поступлений в 1С."; } if (/^Counterparty monthly value-flow breakdown was grouped by month over confirmed 1C movement rows$/i.test(value)) { - return "Помесячная раскладка денежного потока сгруппирована только по подтвержденным строкам движений 1С."; + return "Помесячная раскладка входящих поступлений построена только по подтвержденным строкам поступлений в 1С."; } if (/^Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows$/i.test(value)) { return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С."; @@ -186,10 +186,10 @@ function localizeLine(value) { return "Полное покрытие запрошенного периода по двустороннему денежному потоку не подтверждено: хотя бы одна сторона проверки достигла лимита найденных строк."; } if (/^Full turnover outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { - return "Полный оборот вне проверенного периода этим поиском не подтвержден."; + return "Полный объем входящих поступлений вне проверенного периода этим поиском не подтвержден."; } if (/^Full all-time turnover is not proven without an explicit checked period$/i.test(value)) { - return "Полный оборот за все время без явно проверенного периода не подтвержден."; + return "Полный объем входящих поступлений за все время без явно проверенного периода не подтвержден."; } if (/^Full document history outside the checked period 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 95241f7..7a228e4 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -36,6 +36,19 @@ function pushUnique(target, value) { target.push(text); } } +function isReferentialEntityPlaceholder(value) { + return /^(?:\u043d\u0435\u043c\u0443|\u043d\u0435\u0439|\u043d\u0438\u043c|\u043d\u0438\u043c\u0438|\u0435\u0433\u043e|\u0435\u0435|\u0435\u0451|\u0438\u0445|\u044d\u0442\u043e\u043c\u0443|\u044d\u0442\u043e\u0439|\u044d\u0442\u0438\u043c|\u044d\u0442\u0438\u043c\u0438|\u044d\u0442\u043e\u043c)$/iu.test(value.trim()); +} +function pushScopedEntityCandidate(target, value, groundedFollowupEntity) { + const text = candidateValue(value); + if (!text) { + return; + } + if (groundedFollowupEntity && isReferentialEntityPlaceholder(text)) { + return; + } + pushUnique(target, text); +} function canonicalizeEntityResolutionCandidate(value) { return normalizeEntityResolutionCandidate(value) .replace(/^(?:\u0441\s+\u043d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435\u043c\s+)/iu, "") @@ -143,6 +156,13 @@ function mapPilotScopeToFollowupMeaning(pilotScope) { unsupported: "movement_evidence" }; } + if (pilotScope === "counterparty_document_evidence_query_documents_v1") { + return { + domain: "documents", + action: "list_documents", + unsupported: "document_evidence" + }; + } if (pilotScope === "counterparty_supplier_payout_query_movements_v1") { return { domain: "counterparty_value", @@ -225,7 +245,8 @@ function collectFollowupDiscoverySeed(followupContext) { toNonEmptyString(rootFilters?.counterparty) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" ? toNonEmptyString(followupContext?.previous_anchor_value) - : null); + : null) ?? + (discoveryEntities[0] ?? null); const organization = toNonEmptyString(previousFilters?.organization) ?? toNonEmptyString(rootFilters?.organization) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "organization" @@ -264,7 +285,7 @@ function hasLifecycleSignal(text) { return /(?:сколько\s+лет|как\s+давно|давно\s+ли|возраст|перв(?:ая|ый)\s+актив|когда\s+начал|когда\s+появ|lifecycle|activity\s+duration|business\s+age|how\s+long)/iu.test(text); } function hasValueFlowSignal(text) { - return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow)/iu.test(text); + return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|входящ|получ(?:ил|ено|ен)|поступил|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow)/iu.test(text); } function hasPayoutSignal(text) { return /(?:\bмы\s+(?:за)?плат|(?:за)?платил|оплатил|перечисл|списан|расход|поставщик|исходящ|supplier|payout|outflow|paid\s+to|payment\s+to)/iu.test(text); @@ -291,6 +312,12 @@ function hasDocumentEvidenceFollowupSignal(text) { function hasMovementEvidenceFollowupSignal(text) { return /(?:\u043f\u043e\s+\u0434\u0432\u0438\u0436\u0435\u043d(?:\u0438\u044f\u043c|\u0438\u044f)?|\u0434\u0430\u0432\u0430\u0439\s+\u0434\u0432\u0438\u0436\u0435\u043d(?:\u0438\u044f|\u0438\u0435)|\u0438\u0449\u0438\s+\u0434\u0432\u0438\u0436\u0435\u043d(?:\u0438\u044f|\u0438\u0435)|\u043f\u043e\u043a\u0430\u0436\u0438\s+\u0434\u0432\u0438\u0436\u0435\u043d(?:\u0438\u044f|\u0438\u0435)|\u0431\u0430\u043d\u043a\u043e\u0432\u0441\u043a(?:\u0438\u0435|\u0438\u0439)\s+\u0434\u0432\u0438\u0436\u0435\u043d(?:\u0438\u044f|\u0438\u0435)|(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043a\u0430\u043a\u0438\u0435|\u0441\u043f\u0438\u0441\u043e\u043a|\u0434\u0430\u0439|\u0438\u0449\u0438)\s+(?:\u043f\u043b\u0430\u0442[еe]\u0436(?:\u0438|\u0438)?|\u043e\u043f\u0435\u0440\u0430\u0446(?:\u0438\u0438|\u0438\u044e)|\u043f\u0440\u043e\u0432\u043e\u0434\u043a(?:\u0438|\u0430)|\u0441\u043f\u0438\u0441\u0430\u043d(?:\u0438\u044f|\u0438\u0435)|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d(?:\u0438\u044f|\u0438\u0435)|payment(?:s)?|transaction(?:s)?|operation(?:s)?|posting(?:s)?|bank\s+operation(?:s)?)|movement(?:s)?\s+(?:then|next)?|(?:then|next)\s+movements?|go\s+to\s+movements?)/iu.test(text); } +function hasPronounDocumentEvidenceFollowupSignal(text) { + return /(?:\u043f\u043e\s+(?:\u043d\u0435\u043c\u0443|\u043d\u0435\u0439|\u044d\u0442\u043e\u043c\u0443|\u044d\u0442\u043e\u0439)\s+(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442(?:\u0430\u043c|\u044b)?|\u0441\u0447(?:[Рµe]С‚|\u0435\u0442)[-\u2011 ]?\u0444\u0430\u043a\u0442\u0443\u0440(?:\u044b|\u0430)?|\u043d\u0430\u043a\u043b\u0430\u0434\u043d(?:\u044b\u0435|\u0430\u044f)?|\u0430\u043a\u0442(?:\u044b)?))/iu.test(text); +} +function hasPronounMovementEvidenceFollowupSignal(text) { + return /(?:\u043f\u043e\s+(?:\u043d\u0435\u043c\u0443|\u043d\u0435\u0439|\u044d\u0442\u043e\u043c\u0443|\u044d\u0442\u043e\u0439)\s+(?:\u0434\u0432\u0438\u0436\u0435\u043d(?:\u0438\u044f\u043c|\u0438\u044f)?|\u043f\u043b\u0430\u0442[Рµe]\u0436(?:\u0430\u043c|\u0438)?|\u043e\u043f\u0435\u0440\u0430\u0446(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e)|\u043f\u0440\u043e\u0432\u043e\u0434\u043a(?:\u0430\u043c|\u0438)|\u0441\u043f\u0438\u0441\u0430\u043d(?:\u0438\u044f\u043c|\u0438\u0435)|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d(?:\u0438\u044f\u043c|\u0438\u0435)))/iu.test(text); +} function hasMetadataDownstreamContinuationSignal(text) { return /(?:\u0434\u0430\u0432\u0430\u0439\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0438\u0434(?:\u0435|\u0451)\u043c\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u043f\u043e\u0448\u043b(?:\u0438|\u0451\u043c)\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0439|\u0438\u0449\u0438\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0438\u0449\u0438\s+\u0434\u0430\u043d\u043d\u044b\u0435|\u043f\u043e\u043a\u0430\u0436\u0438\s+\u0434\u0430\u043d\u043d\u044b\u0435|\u043f\u043e\u043a\u0430\u0436\u0438\s+\u0441\u0442\u0440\u043e\u043a\u0438|\u0433\u043b\u0443\u0431\u0436\u0435|\u0447\u0442\u043e\s+\u0434\u0430\u043b\u044c\u0448\u0435|continue|go\s+ahead|go\s+deeper|look\s+deeper|drill\s+down|show\s+(?:data|rows))/iu.test(text); } @@ -395,6 +422,9 @@ function semanticNeedFor(input) { return null; } function shouldRunDiscovery(input) { + if (input.forceDiscoveryOverExplicitIntent && input.semanticDataNeed) { + return true; + } if (input.lifecycleSignal || input.unsupported) { return true; } @@ -439,8 +469,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null; const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null; const entityResolutionSignal = rawEntityResolutionSignal || Boolean(rawEntityCandidate); - const metadataDocumentHintSignal = hasDocumentEvidenceFollowupSignal(rawText); - const metadataMovementHintSignal = hasMovementEvidenceFollowupSignal(rawText); + const metadataDocumentHintSignal = hasDocumentEvidenceFollowupSignal(rawText) || hasPronounDocumentEvidenceFollowupSignal(rawText); + const metadataMovementHintSignal = hasMovementEvidenceFollowupSignal(rawText) || hasPronounMovementEvidenceFollowupSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis); @@ -485,6 +515,37 @@ function buildAssistantMcpDiscoveryTurnInput(input) { followupSeed.counterparty && !rawLifecycleSignal && metadataMovementHintSignal); + const entityResolutionGroundedDocumentFollowupApplicable = Boolean(followupSeed.pilotScope === "entity_resolution_search_v1" && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataDocumentHintSignal); + const entityResolutionGroundedMovementFollowupApplicable = Boolean(followupSeed.pilotScope === "entity_resolution_search_v1" && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataMovementHintSignal); + const groundedValueFlowFollowupApplicable = Boolean(rawValueFlowSignal && + !rawLifecycleSignal && + !rawMetadataSignal && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + (followupSeed.pilotScope === "entity_resolution_search_v1" || + followupSeed.pilotScope === "counterparty_document_evidence_query_documents_v1" || + followupSeed.pilotScope === "counterparty_movement_evidence_query_movements_v1")); + const documentEvidenceGroundedMovementFollowupApplicable = Boolean(followupSeed.pilotScope === "counterparty_document_evidence_query_documents_v1" && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataMovementHintSignal); + const movementEvidenceGroundedDocumentFollowupApplicable = Boolean(followupSeed.pilotScope === "counterparty_movement_evidence_query_movements_v1" && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataDocumentHintSignal); const metadataGroundedLaneContinuationApplicable = Boolean(followupSeed.pilotScope === "metadata_inspection_v1" && (followupSeed.metadataRouteFamily === "document_evidence" || followupSeed.metadataRouteFamily === "movement_evidence") && @@ -529,10 +590,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) { hasMetadataDownstreamContinuationSignal(rawText)); const metadataGroundedDocumentLaneApplicable = metadataGroundedDocumentFollowupApplicable || metadataAmbiguityResolvedDocumentFollowupApplicable || + entityResolutionGroundedDocumentFollowupApplicable || + movementEvidenceGroundedDocumentFollowupApplicable || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "document_evidence") || metadataAmbiguityCollapsedDocumentLaneContinuationApplicable; const metadataGroundedMovementLaneApplicable = metadataGroundedMovementFollowupApplicable || metadataAmbiguityResolvedMovementFollowupApplicable || + entityResolutionGroundedMovementFollowupApplicable || + documentEvidenceGroundedMovementFollowupApplicable || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "movement_evidence") || metadataAmbiguityCollapsedMovementLaneContinuationApplicable; const effectiveMetadataFollowupSeedApplicable = metadataFollowupSeedApplicable && @@ -586,7 +651,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) { metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, entityResolutionSignal }); - const entityCandidates = entityResolutionSignal ? [] : collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); + const groundedFollowupEntity = followupSeed.counterparty ?? followupSeed.discoveryEntity; + const entityCandidates = entityResolutionSignal ? [] : []; if (entityResolutionSignal) { pushNormalizedEntityResolutionCandidate(entityCandidates, rawEntityCandidate); for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { @@ -596,11 +662,20 @@ function buildAssistantMcpDiscoveryTurnInput(input) { pushNormalizedEntityResolutionCandidate(entityCandidates, followupSeed.counterparty); } else { - pushUnique(entityCandidates, predecomposeEntities.counterparty); - pushUnique(entityCandidates, followupSeed.counterparty); - pushUnique(entityCandidates, rawEntityCandidate); + if (groundedFollowupEntity) { + pushUnique(entityCandidates, groundedFollowupEntity); + } + for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { + pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity); + } + pushScopedEntityCandidate(entityCandidates, predecomposeEntities.counterparty, groundedFollowupEntity); + if (!groundedFollowupEntity) { + pushScopedEntityCandidate(entityCandidates, followupSeed.counterparty, null); + pushScopedEntityCandidate(entityCandidates, followupSeed.discoveryEntity, null); + } + pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity); } - if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !followupSeed.counterparty) { + if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !groundedFollowupEntity) { pushUnique(entityCandidates, followupSeed.discoveryEntity); pushUnique(entityCandidates, rawMetadataScopeHint); } @@ -724,7 +799,12 @@ function buildAssistantMcpDiscoveryTurnInput(input) { effectiveMetadataFollowupSeedApplicable || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || - metadataGroundedDocumentLaneApplicable + metadataGroundedDocumentLaneApplicable || + groundedValueFlowFollowupApplicable, + forceDiscoveryOverExplicitIntent: metadataAmbiguityLaneClarificationApplicable || + metadataGroundedMovementLaneApplicable || + metadataGroundedDocumentLaneApplicable || + groundedValueFlowFollowupApplicable }); const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal = assistantTurnMeaning @@ -791,6 +871,21 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (metadataAmbiguityResolvedMovementFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_ambiguity_resolved_to_movement_lane"); } + if (entityResolutionGroundedDocumentFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_document_followup"); + } + if (entityResolutionGroundedMovementFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_movement_followup"); + } + if (groundedValueFlowFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_grounded_value_flow_followup"); + } + if (documentEvidenceGroundedMovementFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_document_evidence_grounded_movement_followup"); + } + if (movementEvidenceGroundedDocumentFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_movement_evidence_grounded_document_followup"); + } if (metadataGroundedLaneContinuationApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_grounded_lane_continuation"); } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 12bb20d..5997045 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -467,6 +467,7 @@ function createAssistantTransitionPolicy(deps) { const sourceDiscoveryMetadataSelectedEntitySet = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataSelectedEntitySet)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryMetadataAmbiguityDetected = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataAmbiguityDetected)(carryoverSourceDebug); const sourceDiscoveryMetadataAmbiguityEntitySets = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataAmbiguityEntitySets)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryEntityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityCandidates)(carryoverSourceDebug, deps.toNonEmptyString); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent; @@ -697,6 +698,7 @@ function createAssistantTransitionPolicy(deps) { previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_value: previousAnchor, previous_discovery_pilot_scope: sourceDiscoveryPilotScope ?? undefined, + previous_discovery_entity_candidates: sourceDiscoveryEntityCandidates.length > 0 ? sourceDiscoveryEntityCandidates : undefined, previous_discovery_metadata_route_family: sourceDiscoveryMetadataRouteFamily ?? undefined, previous_discovery_metadata_selected_entity_set: sourceDiscoveryMetadataSelectedEntitySet ?? undefined, previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined, diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 39b7c5c..1643679 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -174,6 +174,53 @@ function readAssistantMcpDiscoveryDerivedMetadataSurface( return toRecordObject(pilot?.derived_metadata_surface); } +function readAssistantMcpDiscoveryDerivedEntityResolution( + debug: Record | null +): Record | null { + const bridge = readAssistantMcpDiscoveryBridge(debug); + const pilot = toRecordObject(bridge?.pilot); + return toRecordObject(pilot?.derived_entity_resolution); +} + +function collectAssistantMcpDiscoveryEntityCandidates( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string[] { + const result: string[] = []; + const resolution = readAssistantMcpDiscoveryDerivedEntityResolution(debug); + const pushCandidate = (value: unknown): void => { + const text = toNonEmptyString(value); + if (text && !result.includes(text)) { + result.push(text); + } + }; + + pushCandidate(resolution?.resolved_entity); + pushCandidate(resolution?.requested_entity); + if (Array.isArray(resolution?.ambiguity_candidates)) { + for (const candidate of resolution.ambiguity_candidates) { + pushCandidate(candidate); + } + } + + const discoveryMeaning = readAssistantMcpDiscoveryTurnMeaning(debug); + const explicitEntities = Array.isArray(discoveryMeaning?.explicit_entity_candidates) + ? discoveryMeaning.explicit_entity_candidates + : []; + for (const entity of explicitEntities) { + pushCandidate(candidateValue(entity)); + } + + return result; +} + +export function readAssistantMcpDiscoveryEntityCandidates( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string[] { + return collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString); +} + export function readAssistantMcpDiscoveryPilotScope( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString @@ -386,12 +433,9 @@ export function readAddressDebugCounterparty( 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 = candidateValue(entity); + const discoveryEntities = collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString); + for (const entity of discoveryEntities) { + const text = toNonEmptyString(entity); if (text) { return text; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 84c4df2..4d80c98 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -158,6 +158,23 @@ function firstEntityCandidate(pilot: AssistantMcpDiscoveryPilotExecutionContract return null; } +function explicitDateScope(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { + const value = pilot.evidence.query_plan.turn_meaning_ref?.explicit_date_scope; + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function documentOrMovementScopeRu(pilot: AssistantMcpDiscoveryPilotExecutionContract): string { + const entity = firstEntityCandidate(pilot); + const period = explicitDateScope(pilot); + const entityPart = entity ? ` по контрагенту ${entity}` : ""; + const periodPart = period ? ` за ${period}` : " в проверенном окне"; + return `${entityPart}${periodPart}`; +} + function isMovementLaneClarification(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { return ( isMovementPilot(pilot) || @@ -258,7 +275,7 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD return "По текущему каталожному поиску 1С точный контрагент пока не подтвержден."; } if (isMovementPilot(pilot) && mode === "confirmed_with_bounded_inference") { - return "РџРѕ данным 1РЎ найдены строки движений; ответ ограничен проверенным периодом Рё найденными строками."; + return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`; } if (pilot.derived_metadata_surface && mode === "confirmed_with_bounded_inference") { if (pilot.derived_metadata_surface.ambiguity_detected) { @@ -276,7 +293,7 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (pilot.derived_value_flow.value_flow_direction === "outgoing_supplier_payout") { return "По данным 1С найдены строки исходящих платежей/списаний; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; } - return "По данным 1С найдены строки денежных движений; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; + return "По данным 1С найдены строки входящих денежных поступлений; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; } if (pilot.derived_bidirectional_value_flow && mode === "confirmed_with_bounded_inference") { return "По данным 1С найдены строки входящих и исходящих денежных движений; нетто можно называть только как расчет по найденным строкам и проверенному периоду."; @@ -285,14 +302,23 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (pilot.derived_value_flow.value_flow_direction === "outgoing_supplier_payout") { return "По данным 1С найдены строки исходящих платежей/списаний; сумму можно называть только в рамках проверенного периода и найденных строк."; } - return "По данным 1С найдены строки денежных движений; сумму можно называть только в рамках проверенного периода и найденных строк."; + return "По данным 1С найдены строки входящих денежных поступлений; сумму можно называть только в рамках проверенного периода и найденных строк."; } if (isDocumentPilot(pilot) && mode === "confirmed_with_bounded_inference") { - return "По данным 1С найдены строки документов; ответ ограничен проверенным периодом и найденными строками."; + return `По документам${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`; + } + if (isMovementPilot(pilot) && mode === "confirmed_with_bounded_inference") { + return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`; } if (mode === "confirmed_with_bounded_inference") { return "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод из этих строк."; } + if (isDocumentPilot(pilot) && mode === "bounded_inference_only") { + return `По документам${documentOrMovementScopeRu(pilot)} полный срез не подтвержден; пока есть только ограниченная граница проверенного окна 1С.`; + } + if (isMovementPilot(pilot) && mode === "bounded_inference_only") { + return `По движениям${documentOrMovementScopeRu(pilot)} полный срез не подтвержден; пока есть только ограниченная граница проверенного окна 1С.`; + } if (mode === "bounded_inference_only") { return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С."; } @@ -523,15 +549,17 @@ function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutio const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : ""; const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне"; const movementLabel = - flow.value_flow_direction === "outgoing_supplier_payout" ? "исходящих платежей/списаний" : "денежных движений"; + flow.value_flow_direction === "outgoing_supplier_payout" + ? "исходящих платежей/списаний" + : "входящих денежных поступлений"; const totalLabel = flow.value_flow_direction === "outgoing_supplier_payout" ? "сумма исходящих платежей/списаний составляет" - : "сумма составляет"; + : "сумма входящих денежных поступлений составляет"; const caveat = flow.value_flow_direction === "outgoing_supplier_payout" ? "Это расчет по найденным строкам 1С, а не подтверждение полного объема платежей вне проверенного окна." - : "Это расчет по найденным строкам 1С, а не подтверждение полного оборота вне проверенного окна."; + : "Это расчет по найденным строкам 1С, а не подтверждение полного объема поступлений вне проверенного окна."; const dates = flow.first_movement_date && flow.latest_movement_date ? ` Первая найденная дата движения: ${flow.first_movement_date}; последняя: ${flow.latest_movement_date}.` diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index 6e9d9a7..07cf8a5 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -1694,26 +1694,38 @@ function buildLifecycleConfirmedFacts(result: AddressMcpQueryExecutorResult, cou ]; } -function buildDocumentConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { - if (result.error || result.matched_rows <= 0) { - return []; - } - return [ - counterparty - ? `1C document rows were found for counterparty ${counterparty}` - : "1C document rows were found for the requested scope" - ]; +function checkedCounterpartySuffixRu(counterparty: string | null): string { + return counterparty ? ` по контрагенту ${counterparty}` : ""; } -function buildMovementConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { +function checkedPeriodSuffixRu(periodScope: string | null): string { + return periodScope ? ` за ${periodScope}` : " в проверенном окне"; +} + +function uncheckedPeriodBoundaryRu(periodScope: string | null): string { + return periodScope ? ` вне периода ${periodScope}` : " без явно проверенного периода"; +} + +function buildDocumentConfirmedFacts( + result: AddressMcpQueryExecutorResult, + counterparty: string | null, + periodScope: string | null +): string[] { if (result.error || result.matched_rows <= 0) { return []; } - return [ - counterparty - ? `1C movement rows were found for counterparty ${counterparty}` - : "1C movement rows were found for the requested scope" - ]; + return [`В 1С найдены строки документов${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)}.`]; +} + +function buildMovementConfirmedFacts( + result: AddressMcpQueryExecutorResult, + counterparty: string | null, + periodScope: string | null +): string[] { + if (result.error || result.matched_rows <= 0) { + return []; + } + return [`В 1С найдены строки движений${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)}.`]; } function buildValueFlowConfirmedFacts( @@ -1763,18 +1775,40 @@ function buildLifecycleInferredFacts(result: AddressMcpQueryExecutorResult): str return ["Business activity duration may be inferred from first and latest confirmed 1C activity rows"]; } -function buildDocumentInferredFacts(result: AddressMcpQueryExecutorResult): string[] { +function buildDocumentInferredFacts( + result: AddressMcpQueryExecutorResult, + counterparty: string | null, + periodScope: string | null +): string[] { if (result.error || result.fetched_rows <= 0) { return []; } - return ["Counterparty document evidence is limited to confirmed 1C document rows in the checked scope"]; + if (result.matched_rows <= 0) { + return [ + `По документам${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)} удалось проверить только ограниченный срез 1С; подтвержденных строк документов этим поиском не найдено.` + ]; + } + return [ + `Срез документов${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)} ограничен только подтвержденными строками документов, найденными этим поиском.` + ]; } -function buildMovementInferredFacts(result: AddressMcpQueryExecutorResult): string[] { +function buildMovementInferredFacts( + result: AddressMcpQueryExecutorResult, + counterparty: string | null, + periodScope: string | null +): string[] { if (result.error || result.fetched_rows <= 0) { return []; } - return ["Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope"]; + if (result.matched_rows <= 0) { + return [ + `По движениям${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)} удалось проверить только ограниченный срез 1С; подтвержденных строк движений этим поиском не найдено.` + ]; + } + return [ + `Срез движений${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)} ограничен только подтвержденными строками движений, найденными этим поиском.` + ]; } function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueFlow | null): string[] { @@ -1820,19 +1854,15 @@ function buildLifecycleUnknownFacts(): string[] { return ["Legal registration date is not proven by this MCP discovery pilot"]; } -function buildDocumentUnknownFacts(periodScope: string | null): string[] { +function buildDocumentUnknownFacts(periodScope: string | null, counterparty: string | null): string[] { return [ - periodScope - ? "Full document history outside the checked period is not proven by this MCP discovery pilot" - : "Full document history is not proven without an explicit checked period" + `Полный исторический срез документов${checkedCounterpartySuffixRu(counterparty)}${uncheckedPeriodBoundaryRu(periodScope)} этим поиском не подтвержден.` ]; } -function buildMovementUnknownFacts(periodScope: string | null): string[] { +function buildMovementUnknownFacts(periodScope: string | null, counterparty: string | null): string[] { return [ - periodScope - ? "Full movement history outside the checked period is not proven by this MCP discovery pilot" - : "Full movement history is not proven without an explicit checked period" + `Полный исторический срез движений${checkedCounterpartySuffixRu(counterparty)}${uncheckedPeriodBoundaryRu(periodScope)} этим поиском не подтвержден.` ]; } @@ -2245,9 +2275,9 @@ export async function executeAssistantMcpDiscoveryPilot( const evidence = resolveAssistantMcpDiscoveryEvidence({ plan: planner.discovery_plan, probeResults, - confirmedFacts: queryResult ? buildDocumentConfirmedFacts(queryResult, counterparty) : [], - inferredFacts: queryResult ? buildDocumentInferredFacts(queryResult) : [], - unknownFacts: buildDocumentUnknownFacts(dateScope), + confirmedFacts: queryResult ? buildDocumentConfirmedFacts(queryResult, counterparty, dateScope) : [], + inferredFacts: queryResult ? buildDocumentInferredFacts(queryResult, counterparty, dateScope) : [], + unknownFacts: buildDocumentUnknownFacts(dateScope, counterparty), sourceRowsSummary, queryLimitations, recommendedNextProbe: "explain_evidence_basis" @@ -2330,9 +2360,9 @@ export async function executeAssistantMcpDiscoveryPilot( const evidence = resolveAssistantMcpDiscoveryEvidence({ plan: planner.discovery_plan, probeResults, - confirmedFacts: queryResult ? buildMovementConfirmedFacts(queryResult, counterparty) : [], - inferredFacts: queryResult ? buildMovementInferredFacts(queryResult) : [], - unknownFacts: buildMovementUnknownFacts(dateScope), + confirmedFacts: queryResult ? buildMovementConfirmedFacts(queryResult, counterparty, dateScope) : [], + inferredFacts: queryResult ? buildMovementInferredFacts(queryResult, counterparty, dateScope) : [], + unknownFacts: buildMovementUnknownFacts(dateScope, counterparty), sourceRowsSummary, queryLimitations, recommendedNextProbe: "explain_evidence_basis" diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 0411d87..b9af0a1 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -100,10 +100,10 @@ function localizeLine(value: string): string { } const valueFlowMatch = value.match(/^1C value-flow rows were found for counterparty\s+(.+)$/i); if (valueFlowMatch) { - return `В 1С найдены строки денежных движений по контрагенту ${valueFlowMatch[1]}.`; + return `В 1С найдены строки входящих денежных поступлений по контрагенту ${valueFlowMatch[1]}.`; } if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) { - return "В 1С найдены строки денежных движений по запрошенному контрагентскому контуру."; + return "В 1С найдены строки входящих денежных поступлений по запрошенному контрагентскому контуру."; } const documentRowsMatch = value.match(/^1C document rows were found for counterparty\s+(.+)$/i); if (documentRowsMatch) { @@ -152,10 +152,10 @@ function localizeLine(value: string): string { return "Срез движений ограничен только подтвержденными строками движений РІ проверенном РѕРєРЅРµ."; } if (/^Counterparty value-flow total was calculated from confirmed 1C movement rows$/i.test(value)) { - return "Сумма рассчитана только по подтвержденным строкам денежных движений в 1С."; + return "Сумма входящих поступлений рассчитана только по подтвержденным строкам поступлений в 1С."; } if (/^Counterparty monthly value-flow breakdown was grouped by month over confirmed 1C movement rows$/i.test(value)) { - return "Помесячная раскладка денежного потока сгруппирована только по подтвержденным строкам движений 1С."; + return "Помесячная раскладка входящих поступлений построена только по подтвержденным строкам поступлений в 1С."; } if (/^Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows$/i.test(value)) { return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С."; @@ -225,10 +225,10 @@ function localizeLine(value: string): string { return "Полное покрытие запрошенного периода по двустороннему денежному потоку не подтверждено: хотя бы одна сторона проверки достигла лимита найденных строк."; } if (/^Full turnover outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { - return "Полный оборот вне проверенного периода этим поиском не подтвержден."; + return "Полный объем входящих поступлений вне проверенного периода этим поиском не подтвержден."; } if (/^Full all-time turnover is not proven without an explicit checked period$/i.test(value)) { - return "Полный оборот за все время без явно проверенного периода не подтвержден."; + return "Полный объем входящих поступлений за все время без явно проверенного периода не подтвержден."; } if (/^Full document history outside the checked period 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 ed1aced..4eb6d70 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -68,6 +68,27 @@ function pushUnique(target: string[], value: unknown): void { } } +function isReferentialEntityPlaceholder(value: string): boolean { + return /^(?:\u043d\u0435\u043c\u0443|\u043d\u0435\u0439|\u043d\u0438\u043c|\u043d\u0438\u043c\u0438|\u0435\u0433\u043e|\u0435\u0435|\u0435\u0451|\u0438\u0445|\u044d\u0442\u043e\u043c\u0443|\u044d\u0442\u043e\u0439|\u044d\u0442\u0438\u043c|\u044d\u0442\u0438\u043c\u0438|\u044d\u0442\u043e\u043c)$/iu.test( + value.trim() + ); +} + +function pushScopedEntityCandidate( + target: string[], + value: unknown, + groundedFollowupEntity: string | null +): void { + const text = candidateValue(value); + if (!text) { + return; + } + if (groundedFollowupEntity && isReferentialEntityPlaceholder(text)) { + return; + } + pushUnique(target, text); +} + function canonicalizeEntityResolutionCandidate(value: string): string { return normalizeEntityResolutionCandidate(value) .replace(/^(?:\u0441\s+\u043d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435\u043c\s+)/iu, "") @@ -194,6 +215,13 @@ function mapPilotScopeToFollowupMeaning( unsupported: "movement_evidence" }; } + if (pilotScope === "counterparty_document_evidence_query_documents_v1") { + return { + domain: "documents", + action: "list_documents", + unsupported: "document_evidence" + }; + } if (pilotScope === "counterparty_supplier_payout_query_movements_v1") { return { domain: "counterparty_value", @@ -300,7 +328,8 @@ function collectFollowupDiscoverySeed(followupContext: Record | toNonEmptyString(rootFilters?.counterparty) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" ? toNonEmptyString(followupContext?.previous_anchor_value) - : null); + : null) ?? + (discoveryEntities[0] ?? null); const organization = toNonEmptyString(previousFilters?.organization) ?? toNonEmptyString(rootFilters?.organization) ?? @@ -351,7 +380,7 @@ function hasLifecycleSignal(text: string): boolean { } function hasValueFlowSignal(text: string): boolean { - return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow)/iu.test( + return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|входящ|получ(?:ил|ено|ен)|поступил|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow)/iu.test( text ); } @@ -410,6 +439,18 @@ function hasMovementEvidenceFollowupSignal(text: string): boolean { ); } +function hasPronounDocumentEvidenceFollowupSignal(text: string): boolean { + return /(?:\u043f\u043e\s+(?:\u043d\u0435\u043c\u0443|\u043d\u0435\u0439|\u044d\u0442\u043e\u043c\u0443|\u044d\u0442\u043e\u0439)\s+(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442(?:\u0430\u043c|\u044b)?|\u0441\u0447(?:[Рµe]С‚|\u0435\u0442)[-\u2011 ]?\u0444\u0430\u043a\u0442\u0443\u0440(?:\u044b|\u0430)?|\u043d\u0430\u043a\u043b\u0430\u0434\u043d(?:\u044b\u0435|\u0430\u044f)?|\u0430\u043a\u0442(?:\u044b)?))/iu.test( + text + ); +} + +function hasPronounMovementEvidenceFollowupSignal(text: string): boolean { + return /(?:\u043f\u043e\s+(?:\u043d\u0435\u043c\u0443|\u043d\u0435\u0439|\u044d\u0442\u043e\u043c\u0443|\u044d\u0442\u043e\u0439)\s+(?:\u0434\u0432\u0438\u0436\u0435\u043d(?:\u0438\u044f\u043c|\u0438\u044f)?|\u043f\u043b\u0430\u0442[Рµe]\u0436(?:\u0430\u043c|\u0438)?|\u043e\u043f\u0435\u0440\u0430\u0446(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e)|\u043f\u0440\u043e\u0432\u043e\u0434\u043a(?:\u0430\u043c|\u0438)|\u0441\u043f\u0438\u0441\u0430\u043d(?:\u0438\u044f\u043c|\u0438\u0435)|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d(?:\u0438\u044f\u043c|\u0438\u0435)))/iu.test( + text + ); +} + function hasMetadataDownstreamContinuationSignal(text: string): boolean { return /(?:\u0434\u0430\u0432\u0430\u0439\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0438\u0434(?:\u0435|\u0451)\u043c\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u043f\u043e\u0448\u043b(?:\u0438|\u0451\u043c)\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0439|\u0438\u0449\u0438\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0438\u0449\u0438\s+\u0434\u0430\u043d\u043d\u044b\u0435|\u043f\u043e\u043a\u0430\u0436\u0438\s+\u0434\u0430\u043d\u043d\u044b\u0435|\u043f\u043e\u043a\u0430\u0436\u0438\s+\u0441\u0442\u0440\u043e\u043a\u0438|\u0433\u043b\u0443\u0431\u0436\u0435|\u0447\u0442\u043e\s+\u0434\u0430\u043b\u044c\u0448\u0435|continue|go\s+ahead|go\s+deeper|look\s+deeper|drill\s+down|show\s+(?:data|rows))/iu.test( text @@ -548,7 +589,11 @@ function shouldRunDiscovery(input: { semanticDataNeed: string | null; explicitIntentCandidate: string | null; followupDiscoverySeedApplicable: boolean; + forceDiscoveryOverExplicitIntent: boolean; }): boolean { + if (input.forceDiscoveryOverExplicitIntent && input.semanticDataNeed) { + return true; + } if (input.lifecycleSignal || input.unsupported) { return true; } @@ -598,8 +643,10 @@ export function buildAssistantMcpDiscoveryTurnInput( const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null; const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null; const entityResolutionSignal = rawEntityResolutionSignal || Boolean(rawEntityCandidate); - const metadataDocumentHintSignal = hasDocumentEvidenceFollowupSignal(rawText); - const metadataMovementHintSignal = hasMovementEvidenceFollowupSignal(rawText); + const metadataDocumentHintSignal = + hasDocumentEvidenceFollowupSignal(rawText) || hasPronounDocumentEvidenceFollowupSignal(rawText); + const metadataMovementHintSignal = + hasMovementEvidenceFollowupSignal(rawText) || hasPronounMovementEvidenceFollowupSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); @@ -657,6 +704,47 @@ export function buildAssistantMcpDiscoveryTurnInput( !rawLifecycleSignal && metadataMovementHintSignal ); + const entityResolutionGroundedDocumentFollowupApplicable = Boolean( + followupSeed.pilotScope === "entity_resolution_search_v1" && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataDocumentHintSignal + ); + const entityResolutionGroundedMovementFollowupApplicable = Boolean( + followupSeed.pilotScope === "entity_resolution_search_v1" && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataMovementHintSignal + ); + const groundedValueFlowFollowupApplicable = Boolean( + rawValueFlowSignal && + !rawLifecycleSignal && + !rawMetadataSignal && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + (followupSeed.pilotScope === "entity_resolution_search_v1" || + followupSeed.pilotScope === "counterparty_document_evidence_query_documents_v1" || + followupSeed.pilotScope === "counterparty_movement_evidence_query_movements_v1") + ); + const documentEvidenceGroundedMovementFollowupApplicable = Boolean( + followupSeed.pilotScope === "counterparty_document_evidence_query_documents_v1" && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataMovementHintSignal + ); + const movementEvidenceGroundedDocumentFollowupApplicable = Boolean( + followupSeed.pilotScope === "counterparty_movement_evidence_query_movements_v1" && + (followupSeed.counterparty || followupSeed.discoveryEntity) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataDocumentHintSignal + ); const metadataGroundedLaneContinuationApplicable = Boolean( followupSeed.pilotScope === "metadata_inspection_v1" && (followupSeed.metadataRouteFamily === "document_evidence" || @@ -710,11 +798,15 @@ export function buildAssistantMcpDiscoveryTurnInput( const metadataGroundedDocumentLaneApplicable = metadataGroundedDocumentFollowupApplicable || metadataAmbiguityResolvedDocumentFollowupApplicable || + entityResolutionGroundedDocumentFollowupApplicable || + movementEvidenceGroundedDocumentFollowupApplicable || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "document_evidence") || metadataAmbiguityCollapsedDocumentLaneContinuationApplicable; const metadataGroundedMovementLaneApplicable = metadataGroundedMovementFollowupApplicable || metadataAmbiguityResolvedMovementFollowupApplicable || + entityResolutionGroundedMovementFollowupApplicable || + documentEvidenceGroundedMovementFollowupApplicable || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "movement_evidence") || metadataAmbiguityCollapsedMovementLaneContinuationApplicable; const effectiveMetadataFollowupSeedApplicable = @@ -773,7 +865,8 @@ export function buildAssistantMcpDiscoveryTurnInput( metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, entityResolutionSignal }); - const entityCandidates = entityResolutionSignal ? [] : collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); + const groundedFollowupEntity = followupSeed.counterparty ?? followupSeed.discoveryEntity; + const entityCandidates = entityResolutionSignal ? [] : []; if (entityResolutionSignal) { pushNormalizedEntityResolutionCandidate(entityCandidates, rawEntityCandidate); for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { @@ -782,11 +875,20 @@ export function buildAssistantMcpDiscoveryTurnInput( pushNormalizedEntityResolutionCandidate(entityCandidates, predecomposeEntities.counterparty); pushNormalizedEntityResolutionCandidate(entityCandidates, followupSeed.counterparty); } else { - pushUnique(entityCandidates, predecomposeEntities.counterparty); - pushUnique(entityCandidates, followupSeed.counterparty); - pushUnique(entityCandidates, rawEntityCandidate); + if (groundedFollowupEntity) { + pushUnique(entityCandidates, groundedFollowupEntity); + } + for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { + pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity); + } + pushScopedEntityCandidate(entityCandidates, predecomposeEntities.counterparty, groundedFollowupEntity); + if (!groundedFollowupEntity) { + pushScopedEntityCandidate(entityCandidates, followupSeed.counterparty, null); + pushScopedEntityCandidate(entityCandidates, followupSeed.discoveryEntity, null); + } + pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity); } - if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !followupSeed.counterparty) { + if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !groundedFollowupEntity) { pushUnique(entityCandidates, followupSeed.discoveryEntity); pushUnique(entityCandidates, rawMetadataScopeHint); } @@ -920,7 +1022,13 @@ export function buildAssistantMcpDiscoveryTurnInput( effectiveMetadataFollowupSeedApplicable || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || - metadataGroundedDocumentLaneApplicable + metadataGroundedDocumentLaneApplicable || + groundedValueFlowFollowupApplicable, + forceDiscoveryOverExplicitIntent: + metadataAmbiguityLaneClarificationApplicable || + metadataGroundedMovementLaneApplicable || + metadataGroundedDocumentLaneApplicable || + groundedValueFlowFollowupApplicable }); const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal: AssistantMcpDiscoveryTurnInputSource = assistantTurnMeaning @@ -988,6 +1096,21 @@ export function buildAssistantMcpDiscoveryTurnInput( if (metadataAmbiguityResolvedMovementFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_ambiguity_resolved_to_movement_lane"); } + if (entityResolutionGroundedDocumentFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_document_followup"); + } + if (entityResolutionGroundedMovementFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_movement_followup"); + } + if (groundedValueFlowFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_grounded_value_flow_followup"); + } + if (documentEvidenceGroundedMovementFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_document_evidence_grounded_movement_followup"); + } + if (movementEvidenceGroundedDocumentFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_movement_evidence_grounded_document_followup"); + } if (metadataGroundedLaneContinuationApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_grounded_lane_continuation"); } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index b3f4fad..86a9f1f 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -12,6 +12,7 @@ import { readAddressDebugItem, readAssistantMcpDiscoveryMetadataAmbiguityDetected, readAssistantMcpDiscoveryMetadataAmbiguityEntitySets, + readAssistantMcpDiscoveryEntityCandidates, readAssistantMcpDiscoveryMetadataRouteFamily, readAssistantMcpDiscoveryMetadataSelectedEntitySet, readAddressDebugTemporalScope, @@ -626,6 +627,10 @@ export function createAssistantTransitionPolicy(deps) { carryoverSourceDebug, deps.toNonEmptyString ); + const sourceDiscoveryEntityCandidates = readAssistantMcpDiscoveryEntityCandidates( + carryoverSourceDebug, + deps.toNonEmptyString + ); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; @@ -958,6 +963,8 @@ export function createAssistantTransitionPolicy(deps) { previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_value: previousAnchor, previous_discovery_pilot_scope: sourceDiscoveryPilotScope ?? undefined, + previous_discovery_entity_candidates: + sourceDiscoveryEntityCandidates.length > 0 ? sourceDiscoveryEntityCandidates : undefined, previous_discovery_metadata_route_family: sourceDiscoveryMetadataRouteFamily ?? undefined, previous_discovery_metadata_selected_entity_set: sourceDiscoveryMetadataSelectedEntitySet ?? undefined, previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined, diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index 9ce808e..deb5a4d 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -7,6 +7,7 @@ import { applyTemporalCarryoverFilters, buildRootScopedCarryoverFilters, hydrateInventoryRootFrameState, + readAddressDebugCounterparty, readAddressDebugIntent, readAddressDebugTemporalScope, resolveNavigationSessionContextState, @@ -191,6 +192,49 @@ describe("assistantContinuityPolicy organization authority", () => { }); }); + it("prefers the resolved entity from grounded entity-resolution discovery for counterparty carryover", () => { + const 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: { + asked_domain_family: "entity_resolution", + asked_action_family: "search_business_entity", + explicit_entity_candidates: ["СВК"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + pilot: { + pilot_scope: "entity_resolution_search_v1", + derived_entity_resolution: { + requested_entity: "СВК", + resolution_status: "resolved", + resolved_entity: "Группа СВК", + ambiguity_candidates: [] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + } + }; + + expect(readAddressDebugCounterparty(debug)).toBe("Группа СВК"); + expect(resolveAddressDebugCarryoverFilters(debug)).toEqual({ + counterparty: "Группа СВК" + }); + expect(resolveAddressDebugAnchorContext(debug)).toEqual({ + anchorType: "counterparty", + anchorValue: "Группа СВК" + }); + }); + it("resolves navigation session context through one shared helper", () => { const state = resolveNavigationSessionContextState({ session_context: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index a13811d..7faaf0c 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -31,6 +31,24 @@ function buildSequentialDeps(results: Array<{ rows: Array>; + raw_rows?: Array>; + error?: string | null; +}) { + return { + executeAddressMcpQuery: vi.fn(async () => ({ + fetched_rows: result.fetched_rows, + matched_rows: result.matched_rows, + rows: result.rows, + raw_rows: result.raw_rows ?? result.rows, + error: result.error ?? null + })) + }; +} + function buildMetadataDeps(rows: Array>, error: string | null = null) { return { executeAddressMcpMetadata: vi.fn(async () => ({ @@ -108,11 +126,15 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); expect(draft.headline).toContain("документ"); - expect(draft.confirmed_lines).toContain("1C document rows were found for counterparty SVK"); + expect(draft.headline).toContain("2020"); + expect(draft.headline).toContain("SVK"); + expect(draft.confirmed_lines).toContain("В 1С найдены строки документов по контрагенту SVK за 2020."); expect(draft.inference_lines).toContain( - "Counterparty document evidence is limited to confirmed 1C document rows in the checked scope" + "Срез документов по контрагенту SVK за 2020 ограничен только подтвержденными строками документов, найденными этим поиском." + ); + expect(draft.unknown_lines).toContain( + "Полный исторический срез документов по контрагенту SVK вне периода 2020 этим поиском не подтвержден." ); - expect(draft.unknown_lines).toContain("Full document history outside the checked period is not proven by this MCP discovery pilot"); expect(draft.must_not_claim).toContain("Do not claim full document history outside the checked period."); }); @@ -134,16 +156,54 @@ describe("assistant MCP discovery answer adapter", () => { const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); - expect(draft.headline).toContain("движений"); - expect(draft.confirmed_lines).toContain("1C movement rows were found for counterparty SVK"); + expect(draft.headline).toContain("движени"); + expect(draft.headline).toContain("2020"); + expect(draft.headline).toContain("SVK"); + expect(draft.confirmed_lines).toContain("В 1С найдены строки движений по контрагенту SVK за 2020."); expect(draft.inference_lines).toContain( - "Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope" + "Срез движений по контрагенту SVK за 2020 ограничен только подтвержденными строками движений, найденными этим поиском." + ); + expect(draft.unknown_lines).toContain( + "Полный исторический срез движений по контрагенту SVK вне периода 2020 этим поиском не подтвержден." ); - expect(draft.unknown_lines).toContain("Full movement history outside the checked period is not proven by this MCP discovery pilot"); expect(draft.must_not_claim).toContain("Do not claim full movement history outside the checked period."); expect(draft.must_not_claim).toContain("Do not present the confirmed movement rows as a complete movement universe."); }); + it("keeps bounded-only movement answers tied to the resolved entity and checked period", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "movement_evidence" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildCustomQueryDeps({ + fetched_rows: 100, + matched_rows: 0, + rows: [], + raw_rows: [{ Period: "2020-06-30T00:00:00", Counterparty: "Группа СВК", Registrar: "Move1" }] + }) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.answer_mode).toBe("bounded_inference_only"); + expect(draft.headline).toContain("движени"); + expect(draft.headline).toContain("Группа СВК"); + expect(draft.headline).toContain("2020"); + expect(draft.inference_lines).toContain( + "По движениям по контрагенту Группа СВК за 2020 удалось проверить только ограниченный срез 1С; подтвержденных строк движений этим поиском не найдено." + ); + expect(draft.unknown_lines).toContain( + "Полный исторический срез движений по контрагенту Группа СВК вне периода 2020 этим поиском не подтвержден." + ); + }); + it("asks for clarification when discovery did not execute due to missing scope", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { @@ -372,8 +432,9 @@ describe("assistant MCP discovery answer adapter", () => { const confirmedText = draft.confirmed_lines.join("\n"); expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); - expect(draft.headline).toContain("денежных движений"); + expect(draft.headline).toContain("входящих денежных поступлений"); expect(confirmedText).toContain("3 750,50 руб."); + expect(confirmedText).toContain("входящих денежных поступлений"); expect(confirmedText).toContain("2020-01-15"); expect(confirmedText).toContain("2020-02-20"); expect(draft.unknown_lines).toContain("Full turnover outside the checked period is not proven by this MCP discovery pilot"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index e6311fb..ee9833e 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -148,12 +148,12 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.pilot_status).toBe("executed"); expect(result.pilot_scope).toBe("counterparty_document_evidence_query_documents_v1"); expect(result.executed_primitives).toEqual(["query_documents"]); - expect(result.evidence.confirmed_facts).toContain("1C document rows were found for counterparty SVK"); + expect(result.evidence.confirmed_facts).toContain("В 1С найдены строки документов по контрагенту SVK за 2020."); expect(result.evidence.inferred_facts).toContain( - "Counterparty document evidence is limited to confirmed 1C document rows in the checked scope" + "Срез документов по контрагенту SVK за 2020 ограничен только подтвержденными строками документов, найденными этим поиском." ); expect(result.evidence.unknown_facts).toContain( - "Full document history outside the checked period is not proven by this MCP discovery pilot" + "Полный исторический срез документов по контрагенту SVK вне периода 2020 этим поиском не подтвержден." ); expect(result.source_rows_summary).toBe("2 MCP document rows fetched, 2 matched document scope"); }); @@ -180,12 +180,12 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.executed_primitives).toEqual(["query_movements"]); expect(result.derived_value_flow).toBeNull(); expect(result.derived_bidirectional_value_flow).toBeNull(); - expect(result.evidence.confirmed_facts).toContain("1C movement rows were found for counterparty SVK"); + expect(result.evidence.confirmed_facts).toContain("В 1С найдены строки движений по контрагенту SVK за 2020."); expect(result.evidence.inferred_facts).toContain( - "Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope" + "Срез движений по контрагенту SVK за 2020 ограничен только подтвержденными строками движений, найденными этим поиском." ); expect(result.evidence.unknown_facts).toContain( - "Full movement history outside the checked period is not proven by this MCP discovery pilot" + "Полный исторический срез движений по контрагенту SVK вне периода 2020 этим поиском не подтвержден." ); expect(result.source_rows_summary).toBe("2 MCP movement rows fetched, 2 matched movement scope"); @@ -762,6 +762,56 @@ describe("assistant MCP discovery pilot executor", () => { expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(1); }); + it("keeps document and movement evidence scoped to the resolved entity and checked period", async () => { + const documentPlanner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "documents", + asked_action_family: "list_documents", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "document_evidence" + } + }); + const documentResult = await executeAssistantMcpDiscoveryPilot( + documentPlanner, + buildDeps([{ Period: "2020-01-15T00:00:00", Counterparty: "Группа СВК", Registrar: "Doc1" }]) + ); + + expect(documentResult.evidence.confirmed_facts).toContain( + "В 1С найдены строки документов по контрагенту Группа СВК за 2020." + ); + expect(documentResult.evidence.inferred_facts).toContain( + "Срез документов по контрагенту Группа СВК за 2020 ограничен только подтвержденными строками документов, найденными этим поиском." + ); + expect(documentResult.evidence.unknown_facts).toContain( + "Полный исторический срез документов по контрагенту Группа СВК вне периода 2020 этим поиском не подтвержден." + ); + + const movementPlanner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "movement_evidence" + } + }); + const movementResult = await executeAssistantMcpDiscoveryPilot( + movementPlanner, + buildDeps([{ Period: "2020-01-15T00:00:00", Counterparty: "Группа СВК", Registrar: "Move1" }]) + ); + + expect(movementResult.evidence.confirmed_facts).toContain( + "В 1С найдены строки движений по контрагенту Группа СВК за 2020." + ); + expect(movementResult.evidence.inferred_facts).toContain( + "Срез движений по контрагенту Группа СВК за 2020 ограничен только подтвержденными строками движений, найденными этим поиском." + ); + expect(movementResult.evidence.unknown_facts).toContain( + "Полный исторический срез движений по контрагенту Группа СВК вне периода 2020 этим поиском не подтвержден." + ); + }); + it("records MCP errors as limitations without converting them into facts", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index ae7a5a6..73c25a2 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -54,10 +54,10 @@ describe("assistant MCP discovery response candidate", () => { requires_user_clarification: false, answer_draft: { answer_mode: "confirmed_with_bounded_inference", - headline: "По данным 1С найдены строки денежных движений; сумму можно называть только в рамках проверенного периода и найденных строк.", + headline: "По данным 1С найдены строки входящих денежных поступлений; сумму можно называть только в рамках проверенного периода и найденных строк.", confirmed_lines: [ "1C value-flow rows were found for counterparty SVK", - "По найденным строкам денежных движений в 1С по контрагенту SVK за период 2020 сумма составляет 3 750 руб." + "По найденным строкам входящих денежных поступлений в 1С по контрагенту SVK за период 2020 сумма входящих денежных поступлений составляет 3 750 руб." ], inference_lines: ["Counterparty value-flow total was calculated from confirmed 1C movement rows"], unknown_lines: ["Full turnover outside the checked period is not proven by this MCP discovery pilot"], @@ -69,9 +69,9 @@ describe("assistant MCP discovery response candidate", () => { ); expect(candidate.candidate_status).toBe("ready_for_guarded_use"); - expect(candidate.reply_text).toContain("В 1С найдены строки денежных движений по контрагенту SVK."); + expect(candidate.reply_text).toContain("В 1С найдены строки входящих денежных поступлений по контрагенту SVK."); expect(candidate.reply_text).toContain("3 750 руб."); - expect(candidate.reply_text).toContain("Полный оборот вне проверенного периода этим поиском не подтвержден."); + expect(candidate.reply_text).toContain("Полный объем входящих поступлений вне проверенного периода этим поиском не подтвержден."); expect(candidate.reply_text).not.toContain("pilot_"); expect(candidate.reply_text).not.toContain("query_movements"); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 86a76ad..9bc9376 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -209,6 +209,145 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_entity_scope_from_raw_entity_search"); }); + it("seeds document evidence follow-up from prior entity-resolution grounding", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "по нему документы за 2020 год", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_anchor_type: "counterparty", + previous_anchor_value: "Группа СВК", + previous_discovery_entity_candidates: ["Группа СВК", "СВК"] + } + }); + + 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("document evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "documents", + asked_action_family: "list_documents", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "document_evidence", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_grounded_document_followup"); + expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_followup_context"); + }); + + it("seeds movement evidence follow-up from prior entity-resolution grounding", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а теперь по нему движения за 2020 год", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_candidates: ["Группа СВК", "СВК"] + } + }); + + 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("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_grounded_movement_followup"); + expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_followup_context"); + }); + + it("overrides a wrong exact document intent when a grounded document follow-up asks to switch into movements", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а теперь по нему движения за 2020 год", + assistantTurnMeaning: { + asked_domain_family: "inventory", + asked_action_family: "purchase_documents", + explicit_intent_candidate: "inventory_purchase_documents_for_item", + explicit_entity_candidates: [{ value: "нему" }] + }, + predecomposeContract: { + entities: { + counterparty: "нему" + }, + period: { + period_from: "2020-01-01", + period_to: "2020-12-31" + } + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_document_evidence_query_documents_v1", + previous_filters: { + counterparty: "Группа СВК", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_discovery_entity_candidates: ["Группа СВК", "СВК"] + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.source_signal).toBe("assistant_turn_meaning"); + expect(result.semantic_data_need).toBe("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_document_evidence_grounded_movement_followup"); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); + + it("overrides a supported exact turnover intent when a grounded entity follow-up asks about incoming money", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "сколько получили по нему за 2020 год", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "turnover", + explicit_intent_candidate: "customer_revenue_and_payments", + explicit_entity_candidates: [{ value: "нему" }] + }, + predecomposeContract: { + entities: { + counterparty: "нему" + }, + period: { + period_from: "2020-01-01", + period_to: "2020-12-31" + } + }, + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_filters: { + counterparty: "Группа СВК" + }, + previous_discovery_entity_candidates: ["Группа СВК", "СВК"] + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_grounded_value_flow_followup"); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); + it("seeds short monthly follow-up from prior bidirectional discovery context", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "а по месяцам?", diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 1f87937..b2390b1 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1114,6 +1114,65 @@ describe("assistantTransitionPolicy", () => { period_to: "2020-12-31" }); }); + it("carries resolved entity candidates from grounded entity-resolution discovery into followup context", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => null, + hasAddressFollowupContextSignal: () => true + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "по нему документы за 2020 год", + [ + { + role: "assistant", + text: "В текущем каталожном срезе 1С по запросу \"СВК\" найден контрагент \"Группа СВК\".", + 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: { + asked_domain_family: "entity_resolution", + asked_action_family: "search_business_entity", + explicit_entity_candidates: ["СВК"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + pilot: { + pilot_scope: "entity_resolution_search_v1", + derived_entity_resolution: { + requested_entity: "СВК", + resolution_status: "resolved", + resolved_entity: "Группа СВК", + ambiguity_candidates: [] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + } + } + } + ], + null, + null, + null + ); + + expect(carryover?.followupContext?.previous_discovery_pilot_scope).toBe("entity_resolution_search_v1"); + expect(carryover?.followupContext?.previous_discovery_entity_candidates).toEqual(["Группа СВК", "СВК"]); + expect(carryover?.followupContext?.previous_anchor_type).toBe("counterparty"); + expect(carryover?.followupContext?.previous_anchor_value).toBe("Группа СВК"); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + counterparty: "Группа СВК" + }); + }); + it("carries grounded metadata downstream route hints into followup context", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => null,