Semantic Gate: закрепить контрагентский value-flow и денежный разбор

This commit is contained in:
2026-05-05 20:19:47 +03:00
parent 9be3fb29b7
commit ba23b056b8
19 changed files with 614 additions and 48 deletions
@@ -1077,6 +1077,9 @@ function buildWhereClause(filters, fieldPath, extraConditions = []) {
}
return "";
}
function buildBankDocumentWhereClause(filters, dateFieldPath, counterpartyFieldPath) {
return buildWhereClause(filters, dateFieldPath, [buildCounterpartyReferenceCondition(filters, [counterpartyFieldPath])].filter((item) => Boolean(item)));
}
function buildManagementWhereClause(filters, fieldPath) {
return buildWhereClause(filters, fieldPath);
}
@@ -1398,8 +1401,8 @@ function buildAddressRecipePlan(recipe, filters) {
const query = recipe.query_template === "bank_docs"
? BANK_DOCS_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replace("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
.replace("__WHERE_OUT__", buildBankDocumentWhereClause(filters, "БанкСписание.Дата", "БанкСписание.Контрагент"))
.replace("__WHERE_IN__", buildBankDocumentWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.Контрагент"))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "period_profile"
? PERIOD_COVERAGE_PROFILE_QUERY_TEMPLATE.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
@@ -1407,10 +1410,10 @@ function buildAddressRecipePlan(recipe, filters) {
? DOCUMENT_TYPE_AND_SECTION_PROFILE_QUERY_TEMPLATE.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
: recipe.query_template === "counterparty_roles_profile"
? COUNTERPARTY_POPULATION_AND_ROLES_QUERY_TEMPLATE
.replaceAll("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replaceAll("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
.replaceAll("__WHERE_OUT__", buildBankDocumentWhereClause(filters, "БанкСписание.Дата", "БанкСписание.Контрагент"))
.replaceAll("__WHERE_IN__", buildBankDocumentWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.Контрагент"))
: recipe.query_template === "counterparty_lifecycle_profile"
? COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE.replaceAll("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
? COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE.replaceAll("__WHERE_IN__", buildBankDocumentWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.Контрагент"))
: recipe.query_template === "contract_usage_profile"
? CONTRACT_USAGE_OVERVIEW_QUERY_TEMPLATE
.replaceAll("__WHERE_OUT_USED__", buildUsedContractWhereClause(filters, "БанкСписание.Дата", "БанкСписание.ДоговорКонтрагента"))
@@ -1418,12 +1421,12 @@ function buildAddressRecipePlan(recipe, filters) {
: recipe.query_template === "customer_revenue_profile"
? CUSTOMER_REVENUE_PROFILE_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
.replaceAll("__WHERE_IN__", buildBankDocumentWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.Контрагент"))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "supplier_payout_profile"
? SUPPLIER_PAYOUT_PROFILE_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replaceAll("__WHERE_OUT__", buildBankDocumentWhereClause(filters, "БанкСписание.Дата", "БанкСписание.Контрагент"))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "contract_value_profile"
? CONTRACT_VALUE_PROFILE_QUERY_TEMPLATE
@@ -10,6 +10,42 @@ function toRecordObject(value) {
}
return value;
}
function sessionOrganizationName(sessionOrganizationScope, toNonEmptyString) {
const scope = toRecordObject(sessionOrganizationScope);
return toNonEmptyString(scope?.selectedOrganization) ?? toNonEmptyString(scope?.activeOrganization);
}
function predecomposeOrganizationName(predecomposeContract, toNonEmptyString) {
const entities = toRecordObject(predecomposeContract?.entities);
return (toNonEmptyString(entities?.organization) ??
toNonEmptyString(predecomposeContract?.organization));
}
function mergeOrganizationIntoDiscoveryFollowupContext(followupContext, organization) {
if (!organization) {
return followupContext;
}
const base = followupContext ? { ...followupContext } : {};
const previousFilters = toRecordObject(base.previous_filters)
? { ...base.previous_filters }
: {};
if (!previousFilters.organization) {
previousFilters.organization = organization;
}
base.previous_filters = previousFilters;
const rootFilters = toRecordObject(base.root_filters)
? { ...base.root_filters }
: {};
if (!rootFilters.organization) {
rootFilters.organization = organization;
}
base.root_filters = rootFilters;
if (!base.previous_anchor_type) {
base.previous_anchor_type = "organization";
}
if (!base.previous_anchor_value) {
base.previous_anchor_value = organization;
}
return base;
}
function hasSelectedObjectInventorySignal(text) {
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ним|selected\s+object)/iu.test(String(text ?? ""));
}
@@ -155,6 +191,10 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
const orchestrationDecision = routePolicyRuntime.orchestrationDecision;
const orchestrationContract = toRecordObject(orchestrationDecision.orchestrationContract);
const predecomposeContract = toRecordObject(addressPreDecompose.predecomposeContract);
const explicitPredecomposeOrganization = predecomposeOrganizationName(predecomposeContract, input.toNonEmptyString);
const discoveryFollowupContext = mergeOrganizationIntoDiscoveryFollowupContext(followupContext, explicitPredecomposeOrganization
? null
: sessionOrganizationName(input.sessionOrganizationScope ?? null, input.toNonEmptyString));
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose);
const runDiscoveryEntryPoint = input.runMcpDiscoveryRuntimeEntryPoint ?? assistantMcpDiscoveryRuntimeEntryPoint_1.runAssistantMcpDiscoveryRuntimeEntryPoint;
let mcpDiscoveryRuntimeEntryPoint = null;
@@ -165,7 +205,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
effectiveMessage: addressInputMessage,
assistantTurnMeaning: toRecordObject(orchestrationContract?.assistant_turn_meaning),
predecomposeContract,
followupContext
followupContext: discoveryFollowupContext
}));
}
catch (error) {
@@ -117,6 +117,10 @@ function isValueFlowPilot(pilot) {
pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1" ||
pilot.pilot_scope === "counterparty_bidirectional_value_flow_query_movements_v1");
}
function isSingleDirectionValueFlowPilot(pilot) {
return (pilot.pilot_scope === "counterparty_value_flow_query_movements_v1" ||
pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1");
}
function isBusinessOverviewPilot(pilot) {
return pilot.pilot_scope === "business_overview_route_template_v1";
}
@@ -191,6 +195,52 @@ function explicitOrganizationScope(pilot) {
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function hasExecutedZeroValueFlowRows(pilot) {
const summary = pilot.source_rows_summary ?? "";
return (pilot.mcp_execution_performed &&
isSingleDirectionValueFlowPilot(pilot) &&
!pilot.derived_value_flow &&
(/0\s+MCP\s+value-flow\s+rows\s+fetched/i.test(summary) ||
/\b0\s+matched\s+value-flow\s+scope\b/i.test(summary)));
}
function valueFlowDirectionLabelRu(pilot) {
return pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1"
? "исходящих платежей/списаний"
: "входящих денежных поступлений";
}
function valueFlowZeroResultConfirmedLine(pilot) {
if (!hasExecutedZeroValueFlowRows(pilot)) {
return null;
}
const counterparty = firstEntityCandidate(pilot);
if (!counterparty) {
return null;
}
const organization = explicitOrganizationScope(pilot);
const period = explicitDateScope(pilot);
const organizationPart = organization ? ` по организации ${organization}` : "";
const periodPart = period ? ` за период ${period}` : " в проверенном окне";
return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart} ${valueFlowDirectionLabelRu(pilot)} не найдено.`;
}
function valueFlowZeroResultUnknownLine(pilot) {
if (!hasExecutedZeroValueFlowRows(pilot)) {
return null;
}
const counterparty = firstEntityCandidate(pilot);
if (!counterparty) {
return null;
}
const period = explicitDateScope(pilot);
const periodPart = period ? ` вне периода ${period}` : " вне проверенного окна";
return `Это не доказывает отсутствие операций с контрагентом ${counterparty}${periodPart} или вне доступного банковского контура.`;
}
function valueFlowZeroResultHeadline(pilot) {
const confirmedLine = valueFlowZeroResultConfirmedLine(pilot);
if (!confirmedLine) {
return null;
}
return confirmedLine;
}
function hasAllTimeScope(pilot) {
return (dryRunHasAxis(pilot, "all_time_scope") ||
pilot.reason_codes.includes("mcp_discovery_all_time_scope_signal_detected") ||
@@ -497,6 +547,10 @@ function headlineFor(mode, pilot) {
}
return "По данным 1С найдены строки входящих денежных поступлений; сумму можно называть только в рамках проверенного периода и найденных строк.";
}
const zeroValueFlowHeadline = valueFlowZeroResultHeadline(pilot);
if (mode === "checked_sources_only" && zeroValueFlowHeadline) {
return zeroValueFlowHeadline;
}
if (isDocumentPilot(pilot) && mode === "confirmed_with_bounded_inference") {
return `По документам${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`;
}
@@ -1275,6 +1329,10 @@ function businessOverviewUnknownLines(pilot) {
}
return userFacingUnknowns(pilot.evidence.unknown_facts);
}
function appendValueFlowZeroResultUnknown(lines, pilot) {
const zeroLine = valueFlowZeroResultUnknownLine(pilot);
return zeroLine ? uniqueStrings([zeroLine, ...lines]) : lines;
}
function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
const mode = modeFor(pilot);
const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes];
@@ -1364,18 +1422,20 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
? [derivedValueLine]
: derivedValueLine
? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines]
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
? [derivedMetadataLine]
: pilot.evidence.confirmed_facts;
: valueFlowZeroResultConfirmedLine(pilot)
? [valueFlowZeroResultConfirmedLine(pilot)]
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
? [derivedMetadataLine]
: pilot.evidence.confirmed_facts;
const unknownLines = pilot.derived_business_overview
? businessOverviewUnknownLines(pilot)
: pilot.derived_metadata_surface
? pilot.derived_metadata_surface.available_fields.length > 0
? userFacingUnknowns(pilot.evidence.unknown_facts)
: ["Детальный список полей этих объектов этим шагом не получен."]
: rankedValueFlowUnknownLines(pilot);
: appendValueFlowZeroResultUnknown(rankedValueFlowUnknownLines(pilot), pilot);
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryAnswerAdapter",
@@ -171,7 +171,9 @@ function pushScopedEntityCandidate(target, value, groundedFollowupEntity) {
if (!text) {
return;
}
if ((groundedFollowupEntity && isReferentialEntityPlaceholder(text)) || isValueFlowPredicateEntityCandidate(text)) {
if (isInvalidEntityCandidate(text) ||
(groundedFollowupEntity && isReferentialEntityPlaceholder(text)) ||
isValueFlowPredicateEntityCandidate(text)) {
return;
}
pushUnique(target, text);
@@ -652,7 +654,13 @@ function hasBusinessOverviewContinuationSignal(text) {
const hasAnalystContinuationCue = /(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|налогов\p{L}*\s+вывод)/iu.test(normalized);
const hasTaxContinuationCue = /(?:ндс|vat)[\s\S]{0,120}(?:позици|период|основан|не\s+хватает|налогов\p{L}*\s+вывод)|(?:позици|налогов\p{L}*\s+вывод)[\s\S]{0,80}(?:ндс|vat)/iu.test(normalized);
const hasFinalSummaryCue = /(?:\u0447\u0442\u043e\s+\u043c\u044b\s+\u0437\u043d\u0430\u0435\u043c|\u0447\u0442\u043e\s+\u043f\u043e\u043d\u044f\u0442\u043d\u043e|\u0447\u0442\u043e\s+\u043f\u0440\u043e\u0432\u0435\u0440\w*\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0441\u043b\u0435\u0434\u0443\u044e\u0449\w*\s+\u0448\u0430\u0433|\u0438\u0442\u043e\u0433\w*\s+\u0432\u044b\u0432\u043e\u0434|\u043a\u0430\u043a\u043e\u0439\s+\u0432\u044b\u0432\u043e\u0434|\u0447\u0442\u043e\s+\u0441\s+\u044d\u0442\u0438\u043c\s+\u0434\u0435\u043b\u0430\u0442\u044c|what\s+do\s+we\s+know|what\s+is\s+missing|next\s+step|final\s+summary)/iu.test(normalized);
return hasEvidenceContinuationCue || hasAnalystContinuationCue || hasTaxContinuationCue || hasFinalSummaryCue;
const hasMoneyBreakdownCue = /(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*\s+\u0434\u0435\u043d\p{L}*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u0432\u0441\u0435\u0433\u043e\s+\u043f\u043e\u043b\u0443\u0447|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u0432\u0441\u0435\u0433\u043e\s+)?\u0437\u0430\u043f\u043b\u0430\u0442|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\u043d\p{L}*\s+\u043f\u043e\u0442\u043e\u043a|\u0433\u043b\u0430\u0432\u043d\p{L}*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a)|top\s+(?:customer|supplier)|cash\s+breakdown)/iu.test(normalized) &&
/(?:\u043f\u043e\u043b\u0443\u0447|\u0437\u0430\u043f\u043b\u0430\u0442|\u043d\u0435\u0442\u0442\u043e|\u0434\u0435\u043d\p{L}*|\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|received|paid|net|cash|customer|supplier)/iu.test(normalized);
return (hasEvidenceContinuationCue ||
hasAnalystContinuationCue ||
hasTaxContinuationCue ||
hasFinalSummaryCue ||
hasMoneyBreakdownCue);
}
function hasExplicitTopicSwitchSignal(text) {
return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test(text);
@@ -1134,7 +1142,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
predecomposeOrganizationMirrorsCounterparty));
const normalizedPredecomposeCounterparty = organizationMirrorsPredecomposeCounterparty
? null
: predecomposeEntities.counterparty;
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
const predecomposeDateScope = collectDateScope(predecomposeContract);
const periodClarificationFollowupApplicable = Boolean(followupSeed.domain &&
followupSeed.loopStatus === "awaiting_clarification" &&
@@ -164,6 +164,18 @@ function hasOrganizationLevelSupplierQualityOverviewSignal(text) {
const hasCompanyScopeCue = /(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\u043e\u0431\u0449\w*|\u043a\u0430\u043a\w*|\u043f\u043e\u043a\u0430\u0436|\u0434\u0430\u0439|\u0441\u0440\u0435\u0437|\u0430\u043d\u0430\u043b\u0438\u0437|(?:19|20)\d{2}|company|business|organization|overall|our|we|us|show|give|analysis)/iu.test(normalized);
return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue;
}
function hasOrganizationLevelMoneyBreakdownSignal(text) {
const normalized = String(text ?? "");
if (!normalized) {
return false;
}
const hasIncomingCue = /(?:\u043f\u043e\u043b\u0443\u0447|\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u0441\u0442\u0443\u043f|\u043a\u043b\u0438\u0435\u043d\u0442|received|incoming|customer)/iu.test(normalized);
const hasOutgoingCue = /(?:\u0437\u0430\u043f\u043b\u0430\u0442|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0441\u043f\u0438\u0441\u0430\u043d|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|paid|outgoing|supplier)/iu.test(normalized);
const hasNetCue = /(?:\u043d\u0435\u0442\u0442\u043e|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\u043d\p{L}*\s+\u043f\u043e\u0442\u043e\u043a|net\s+(?:cash|flow)|cash\s+flow)/iu.test(normalized);
const hasRankingCue = /(?:\u0433\u043b\u0430\u0432\u043d\p{L}*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a)|top\s+(?:customer|supplier))/iu.test(normalized);
const hasBreakdownCue = /(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*|\u043f\u043e\u0434\u0440\u043e\u0431\u043d|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u0432\u0441\u0435\u0433\u043e|\u0441\u0432\u043e\u0434\p{L}*|breakdown|detail)/iu.test(normalized);
return hasBreakdownCue && hasIncomingCue && hasOutgoingCue && (hasNetCue || hasRankingCue);
}
function detectBroadBusinessEvaluation(text) {
const normalized = String(text ?? "");
if (!normalized) {
@@ -199,6 +211,11 @@ function detectBroadBusinessEvaluation(text) {
family: "broad_business_evaluation"
};
}
if (hasOrganizationLevelMoneyBreakdownSignal(normalized)) {
return {
family: "broad_business_evaluation"
};
}
return null;
}
function buildEntityCandidates(counterpartyTurnover) {
@@ -1116,6 +1116,20 @@ function buildWhereClause(filters: AddressFilterSet, fieldPath: string, extraCon
return "";
}
function buildBankDocumentWhereClause(
filters: AddressFilterSet,
dateFieldPath: string,
counterpartyFieldPath: string
): string {
return buildWhereClause(
filters,
dateFieldPath,
[buildCounterpartyReferenceCondition(filters, [counterpartyFieldPath])].filter((item): item is string =>
Boolean(item)
)
);
}
function buildManagementWhereClause(filters: AddressFilterSet, fieldPath: string): string {
return buildWhereClause(filters, fieldPath);
}
@@ -1538,8 +1552,14 @@ export function buildAddressRecipePlan(
recipe.query_template === "bank_docs"
? BANK_DOCS_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replace("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
.replace(
"__WHERE_OUT__",
buildBankDocumentWhereClause(filters, "БанкСписание.Дата", "БанкСписание.Контрагент")
)
.replace(
"__WHERE_IN__",
buildBankDocumentWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.Контрагент")
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "period_profile"
? PERIOD_COVERAGE_PROFILE_QUERY_TEMPLATE.replaceAll(
@@ -1553,12 +1573,18 @@ export function buildAddressRecipePlan(
)
: recipe.query_template === "counterparty_roles_profile"
? COUNTERPARTY_POPULATION_AND_ROLES_QUERY_TEMPLATE
.replaceAll("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replaceAll("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
.replaceAll(
"__WHERE_OUT__",
buildBankDocumentWhereClause(filters, "БанкСписание.Дата", "БанкСписание.Контрагент")
)
.replaceAll(
"__WHERE_IN__",
buildBankDocumentWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.Контрагент")
)
: recipe.query_template === "counterparty_lifecycle_profile"
? COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE.replaceAll(
"__WHERE_IN__",
buildWhereClause(filters, "БанкПоступление.Дата")
buildBankDocumentWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.Контрагент")
)
: recipe.query_template === "contract_usage_profile"
? CONTRACT_USAGE_OVERVIEW_QUERY_TEMPLATE
@@ -1573,12 +1599,18 @@ export function buildAddressRecipePlan(
: recipe.query_template === "customer_revenue_profile"
? CUSTOMER_REVENUE_PROFILE_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
.replaceAll(
"__WHERE_IN__",
buildBankDocumentWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.Контрагент")
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "supplier_payout_profile"
? SUPPLIER_PAYOUT_PROFILE_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replaceAll(
"__WHERE_OUT__",
buildBankDocumentWhereClause(filters, "БанкСписание.Дата", "БанкСписание.Контрагент")
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "contract_value_profile"
? CONTRACT_VALUE_PROFILE_QUERY_TEMPLATE
@@ -76,6 +76,60 @@ function toRecordObject(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function sessionOrganizationName(
sessionOrganizationScope: BuildAssistantAddressOrchestrationRuntimeInput["sessionOrganizationScope"],
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
): string | null {
const scope = toRecordObject(sessionOrganizationScope);
return toNonEmptyString(scope?.selectedOrganization) ?? toNonEmptyString(scope?.activeOrganization);
}
function predecomposeOrganizationName(
predecomposeContract: Record<string, unknown> | null,
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
): string | null {
const entities = toRecordObject(predecomposeContract?.entities);
return (
toNonEmptyString(entities?.organization) ??
toNonEmptyString(predecomposeContract?.organization)
);
}
function mergeOrganizationIntoDiscoveryFollowupContext(
followupContext: Record<string, unknown> | null,
organization: string | null
): Record<string, unknown> | null {
if (!organization) {
return followupContext;
}
const base = followupContext ? { ...followupContext } : {};
const previousFilters = toRecordObject(base.previous_filters)
? { ...(base.previous_filters as Record<string, unknown>) }
: {};
if (!previousFilters.organization) {
previousFilters.organization = organization;
}
base.previous_filters = previousFilters;
const rootFilters = toRecordObject(base.root_filters)
? { ...(base.root_filters as Record<string, unknown>) }
: {};
if (!rootFilters.organization) {
rootFilters.organization = organization;
}
base.root_filters = rootFilters;
if (!base.previous_anchor_type) {
base.previous_anchor_type = "organization";
}
if (!base.previous_anchor_value) {
base.previous_anchor_value = organization;
}
return base;
}
function hasSelectedObjectInventorySignal(text: string | null): boolean {
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ним|selected\s+object)/iu.test(
String(text ?? "")
@@ -308,6 +362,13 @@ export async function buildAssistantAddressOrchestrationRuntime(
const orchestrationDecision = routePolicyRuntime.orchestrationDecision;
const orchestrationContract = toRecordObject(orchestrationDecision.orchestrationContract);
const predecomposeContract = toRecordObject(addressPreDecompose.predecomposeContract);
const explicitPredecomposeOrganization = predecomposeOrganizationName(predecomposeContract, input.toNonEmptyString);
const discoveryFollowupContext = mergeOrganizationIntoDiscoveryFollowupContext(
followupContext,
explicitPredecomposeOrganization
? null
: sessionOrganizationName(input.sessionOrganizationScope ?? null, input.toNonEmptyString)
);
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(
input.userMessage,
addressInputMessage,
@@ -323,7 +384,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
effectiveMessage: addressInputMessage,
assistantTurnMeaning: toRecordObject(orchestrationContract?.assistant_turn_meaning),
predecomposeContract,
followupContext
followupContext: discoveryFollowupContext
})) as Record<string, unknown>;
} catch (error) {
mcpDiscoveryRuntimeEntryPointError = String(error instanceof Error ? error.message : error ?? "unknown_error").slice(0, 280);
@@ -160,6 +160,13 @@ function isValueFlowPilot(pilot: AssistantMcpDiscoveryPilotExecutionContract): b
);
}
function isSingleDirectionValueFlowPilot(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
return (
pilot.pilot_scope === "counterparty_value_flow_query_movements_v1" ||
pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1"
);
}
function isBusinessOverviewPilot(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
return pilot.pilot_scope === "business_overview_route_template_v1";
}
@@ -251,6 +258,61 @@ function explicitOrganizationScope(pilot: AssistantMcpDiscoveryPilotExecutionCon
return normalized.length > 0 ? normalized : null;
}
function hasExecutedZeroValueFlowRows(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
const summary = pilot.source_rows_summary ?? "";
return (
pilot.mcp_execution_performed &&
isSingleDirectionValueFlowPilot(pilot) &&
!pilot.derived_value_flow &&
(/0\s+MCP\s+value-flow\s+rows\s+fetched/i.test(summary) ||
/\b0\s+matched\s+value-flow\s+scope\b/i.test(summary))
);
}
function valueFlowDirectionLabelRu(pilot: AssistantMcpDiscoveryPilotExecutionContract): string {
return pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1"
? "исходящих платежей/списаний"
: "входящих денежных поступлений";
}
function valueFlowZeroResultConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
if (!hasExecutedZeroValueFlowRows(pilot)) {
return null;
}
const counterparty = firstEntityCandidate(pilot);
if (!counterparty) {
return null;
}
const organization = explicitOrganizationScope(pilot);
const period = explicitDateScope(pilot);
const organizationPart = organization ? ` по организации ${organization}` : "";
const periodPart = period ? ` за период ${period}` : " в проверенном окне";
return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart} ${valueFlowDirectionLabelRu(
pilot
)} не найдено.`;
}
function valueFlowZeroResultUnknownLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
if (!hasExecutedZeroValueFlowRows(pilot)) {
return null;
}
const counterparty = firstEntityCandidate(pilot);
if (!counterparty) {
return null;
}
const period = explicitDateScope(pilot);
const periodPart = period ? ` вне периода ${period}` : " вне проверенного окна";
return `Это не доказывает отсутствие операций с контрагентом ${counterparty}${periodPart} или вне доступного банковского контура.`;
}
function valueFlowZeroResultHeadline(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
const confirmedLine = valueFlowZeroResultConfirmedLine(pilot);
if (!confirmedLine) {
return null;
}
return confirmedLine;
}
function hasAllTimeScope(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
return (
dryRunHasAxis(pilot, "all_time_scope") ||
@@ -600,6 +662,10 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
}
return "По данным 1С найдены строки входящих денежных поступлений; сумму можно называть только в рамках проверенного периода и найденных строк.";
}
const zeroValueFlowHeadline = valueFlowZeroResultHeadline(pilot);
if (mode === "checked_sources_only" && zeroValueFlowHeadline) {
return zeroValueFlowHeadline;
}
if (isDocumentPilot(pilot) && mode === "confirmed_with_bounded_inference") {
return `По документам${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`;
}
@@ -1487,6 +1553,11 @@ function businessOverviewUnknownLines(pilot: AssistantMcpDiscoveryPilotExecution
return userFacingUnknowns(pilot.evidence.unknown_facts);
}
function appendValueFlowZeroResultUnknown(lines: string[], pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] {
const zeroLine = valueFlowZeroResultUnknownLine(pilot);
return zeroLine ? uniqueStrings([zeroLine, ...lines]) : lines;
}
export function buildAssistantMcpDiscoveryAnswerDraft(
pilot: AssistantMcpDiscoveryPilotExecutionContract
): AssistantMcpDiscoveryAnswerDraftContract {
@@ -1581,6 +1652,8 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
? [derivedValueLine]
: derivedValueLine
? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines]
: valueFlowZeroResultConfirmedLine(pilot)
? [valueFlowZeroResultConfirmedLine(pilot)!]
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
@@ -1592,7 +1665,7 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
? pilot.derived_metadata_surface.available_fields.length > 0
? userFacingUnknowns(pilot.evidence.unknown_facts)
: ["Детальный список полей этих объектов этим шагом не получен."]
: rankedValueFlowUnknownLines(pilot);
: appendValueFlowZeroResultUnknown(rankedValueFlowUnknownLines(pilot), pilot);
return {
schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
@@ -235,7 +235,11 @@ function pushScopedEntityCandidate(
if (!text) {
return;
}
if ((groundedFollowupEntity && isReferentialEntityPlaceholder(text)) || isValueFlowPredicateEntityCandidate(text)) {
if (
isInvalidEntityCandidate(text) ||
(groundedFollowupEntity && isReferentialEntityPlaceholder(text)) ||
isValueFlowPredicateEntityCandidate(text)
) {
return;
}
pushUnique(target, text);
@@ -911,7 +915,20 @@ function hasBusinessOverviewContinuationSignal(text: string): boolean {
/(?:\u0447\u0442\u043e\s+\u043c\u044b\s+\u0437\u043d\u0430\u0435\u043c|\u0447\u0442\u043e\s+\u043f\u043e\u043d\u044f\u0442\u043d\u043e|\u0447\u0442\u043e\s+\u043f\u0440\u043e\u0432\u0435\u0440\w*\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0441\u043b\u0435\u0434\u0443\u044e\u0449\w*\s+\u0448\u0430\u0433|\u0438\u0442\u043e\u0433\w*\s+\u0432\u044b\u0432\u043e\u0434|\u043a\u0430\u043a\u043e\u0439\s+\u0432\u044b\u0432\u043e\u0434|\u0447\u0442\u043e\s+\u0441\s+\u044d\u0442\u0438\u043c\s+\u0434\u0435\u043b\u0430\u0442\u044c|what\s+do\s+we\s+know|what\s+is\s+missing|next\s+step|final\s+summary)/iu.test(
normalized
);
return hasEvidenceContinuationCue || hasAnalystContinuationCue || hasTaxContinuationCue || hasFinalSummaryCue;
const hasMoneyBreakdownCue =
/(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*\s+\u0434\u0435\u043d\p{L}*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u0432\u0441\u0435\u0433\u043e\s+\u043f\u043e\u043b\u0443\u0447|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u0432\u0441\u0435\u0433\u043e\s+)?\u0437\u0430\u043f\u043b\u0430\u0442|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\u043d\p{L}*\s+\u043f\u043e\u0442\u043e\u043a|\u0433\u043b\u0430\u0432\u043d\p{L}*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a)|top\s+(?:customer|supplier)|cash\s+breakdown)/iu.test(
normalized
) &&
/(?:\u043f\u043e\u043b\u0443\u0447|\u0437\u0430\u043f\u043b\u0430\u0442|\u043d\u0435\u0442\u0442\u043e|\u0434\u0435\u043d\p{L}*|\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|received|paid|net|cash|customer|supplier)/iu.test(
normalized
);
return (
hasEvidenceContinuationCue ||
hasAnalystContinuationCue ||
hasTaxContinuationCue ||
hasFinalSummaryCue ||
hasMoneyBreakdownCue
);
}
function hasExplicitTopicSwitchSignal(text: string): boolean {
@@ -1568,7 +1585,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
);
const normalizedPredecomposeCounterparty = organizationMirrorsPredecomposeCounterparty
? null
: predecomposeEntities.counterparty;
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
const predecomposeDateScope = collectDateScope(predecomposeContract);
const periodClarificationFollowupApplicable = Boolean(
followupSeed.domain &&
@@ -223,6 +223,29 @@ function hasOrganizationLevelSupplierQualityOverviewSignal(text) {
return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue;
}
function hasOrganizationLevelMoneyBreakdownSignal(text) {
const normalized = String(text ?? "");
if (!normalized) {
return false;
}
const hasIncomingCue = /(?:\u043f\u043e\u043b\u0443\u0447|\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u0441\u0442\u0443\u043f|\u043a\u043b\u0438\u0435\u043d\u0442|received|incoming|customer)/iu.test(
normalized
);
const hasOutgoingCue = /(?:\u0437\u0430\u043f\u043b\u0430\u0442|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0441\u043f\u0438\u0441\u0430\u043d|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|paid|outgoing|supplier)/iu.test(
normalized
);
const hasNetCue = /(?:\u043d\u0435\u0442\u0442\u043e|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\u043d\p{L}*\s+\u043f\u043e\u0442\u043e\u043a|net\s+(?:cash|flow)|cash\s+flow)/iu.test(
normalized
);
const hasRankingCue = /(?:\u0433\u043b\u0430\u0432\u043d\p{L}*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a)|top\s+(?:customer|supplier))/iu.test(
normalized
);
const hasBreakdownCue = /(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*|\u043f\u043e\u0434\u0440\u043e\u0431\u043d|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u0432\u0441\u0435\u0433\u043e|\u0441\u0432\u043e\u0434\p{L}*|breakdown|detail)/iu.test(
normalized
);
return hasBreakdownCue && hasIncomingCue && hasOutgoingCue && (hasNetCue || hasRankingCue);
}
function detectBroadBusinessEvaluation(text) {
const normalized = String(text ?? "");
if (!normalized) {
@@ -264,6 +287,11 @@ function detectBroadBusinessEvaluation(text) {
family: "broad_business_evaluation"
};
}
if (hasOrganizationLevelMoneyBreakdownSignal(normalized)) {
return {
family: "broad_business_evaluation"
};
}
return null;
}
@@ -5110,6 +5110,23 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("БанкПоступление.ДоговорКонтрагента");
});
it("injects counterparty condition into customer value recipe", () => {
const selected = selectAddressRecipe("customer_revenue_and_payments", {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
});
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
});
expect(plan.query).toContain('БанкПоступление.Контрагент.Наименование ПОДОБНО "%Группа%"');
expect(plan.query).toContain('БанкПоступление.Контрагент.Наименование ПОДОБНО "%СВК%"');
});
it("expands customer value analytics sample independently from visible ranking size", () => {
const filters = extractAddressFilters("какой у нас самый доходный год", "customer_revenue_and_payments");
const selected = selectAddressRecipe("customer_revenue_and_payments", filters.extracted_filters);
@@ -5132,6 +5149,23 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("БанкСписание.ДоговорКонтрагента");
});
it("injects counterparty condition into supplier payout recipe", () => {
const selected = selectAddressRecipe("supplier_payouts_profile", {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
});
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
});
expect(plan.query).toContain('БанкСписание.Контрагент.Наименование ПОДОБНО "%Группа%"');
expect(plan.query).toContain('БанкСписание.Контрагент.Наименование ПОДОБНО "%СВК%"');
});
it("selects contract value recipe and keeps top-20 default", () => {
const selected = selectAddressRecipe("contract_usage_and_value", {});
expect(selected.selected_recipe).toBeTruthy();
@@ -5168,6 +5202,27 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ПоступлениеНаРасчетныйСчет");
});
it("injects counterparty condition into lifecycle and bank document recipes", () => {
const lifecycle = selectAddressRecipe("counterparty_activity_lifecycle", {
counterparty: "Группа СВК"
});
const bankDocs = selectAddressRecipe("bank_operations_by_counterparty", {
counterparty: "Группа СВК"
});
expect(lifecycle.selected_recipe).toBeTruthy();
expect(bankDocs.selected_recipe).toBeTruthy();
const lifecyclePlan = buildAddressRecipePlan(lifecycle.selected_recipe!, {
counterparty: "Группа СВК"
});
const bankDocsPlan = buildAddressRecipePlan(bankDocs.selected_recipe!, {
counterparty: "Группа СВК"
});
expect(lifecyclePlan.query).toContain('БанкПоступление.Контрагент.Наименование ПОДОБНО "%СВК%"');
expect(bankDocsPlan.query).toContain('БанкПоступление.Контрагент.Наименование ПОДОБНО "%СВК%"');
expect(bankDocsPlan.query).toContain('БанкСписание.Контрагент.Наименование ПОДОБНО "%СВК%"');
});
it("boosts limit for all-time counterparty queries", () => {
const filters = extractAddressFilters(
"Покажи документы по контрагенту тестовый за все время",
@@ -154,6 +154,75 @@ describe("assistant address orchestration runtime adapter", () => {
);
});
it("passes active session organization into MCP discovery when carryover context is absent", async () => {
const runMcpDiscoveryRuntimeEntryPoint = vi.fn(async () => ({
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
entry_status: "bridge_executed",
hot_runtime_wired: false,
discovery_attempted: true
}));
const input = buildInput({
userMessage: "money breakdown 2020",
sessionOrganizationScope: {
activeOrganization: "Org A",
selectedOrganization: null,
knownOrganizations: ["Org A"]
},
runAddressLlmPreDecompose: vi.fn(async () => ({
attempted: true,
applied: false,
effectiveMessage: "money breakdown 2020",
reason: "raw_kept",
predecomposeContract: {
mode: "unsupported",
intent: "unknown",
period: {
scope: "year",
period_from: "2020-01-01",
period_to: "2020-12-31",
has_explicit_period: true
}
}
})),
resolveAddressFollowupCarryoverContext: vi.fn(() => null),
resolveAssistantOrchestrationDecision: vi.fn(() => ({
runAddressLane: false,
livingMode: "chat",
livingReason: "unsupported_current_turn_meaning_boundary",
toolGateDecision: "skip_address_lane",
toolGateReason: "unsupported_current_turn_meaning_boundary",
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
assistant_turn_meaning: {
schema_version: "assistant_turn_meaning_v1",
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation"
}
}
})),
runMcpDiscoveryRuntimeEntryPoint
});
await buildAssistantAddressOrchestrationRuntime(input);
expect(runMcpDiscoveryRuntimeEntryPoint).toHaveBeenCalledWith(
expect.objectContaining({
followupContext: expect.objectContaining({
previous_anchor_type: "organization",
previous_anchor_value: "Org A",
previous_filters: expect.objectContaining({
organization: "Org A"
}),
root_filters: expect.objectContaining({
organization: "Org A"
})
})
})
);
});
it("passes grounded discovery follow-up carryover into MCP discovery entry point for a short year switch", async () => {
const runMcpDiscoveryRuntimeEntryPoint = vi.fn(async () => ({
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
@@ -1287,6 +1287,35 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.unknown_lines).toContain("Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot");
});
it("renders zero-row supplier payout as a checked negative with period and counterparty", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "payout",
explicit_entity_candidates: ["Группа СВК"],
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
}
});
const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([]));
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
const confirmedText = draft.confirmed_lines.join("\n");
const unknownText = draft.unknown_lines.join("\n");
expect(draft.answer_mode).toBe("checked_sources_only");
expect(draft.headline).toContain("Группа СВК");
expect(draft.headline).toContain("2020");
expect(draft.headline).toContain("исходящих платежей");
expect(confirmedText).toContain("Группа СВК");
expect(confirmedText).toContain("ООО Альтернатива Плюс");
expect(confirmedText).toContain("2020");
expect(confirmedText).toContain("не найдено");
expect(unknownText).toContain("вне периода 2020");
expect(unknownText).toContain("вне доступного банковского контура");
});
it("turns bidirectional value-flow evidence into a bounded net cash answer draft", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {
@@ -177,7 +177,7 @@ describe("assistant MCP discovery runtime entry point", () => {
expect(result.bridge?.answer_draft.inference_lines.join("\n")).toContain("\u043d\u0435 \u043f\u0440\u0438\u0431\u044b\u043b\u044c");
expect(result.bridge?.answer_draft.unknown_lines.join("\n")).toContain("VAT");
expect(result.reason_codes).toContain("pilot_derived_business_overview_from_confirmed_rows");
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(3);
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(6);
});
it("runs the bridge for raw metadata wording without an exact route owner", async () => {
@@ -2787,6 +2787,54 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
});
it("keeps detailed money-breakdown follow-up in business overview without pseudo counterparty anchors", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0420\u0430\u0441\u043a\u0440\u043e\u0439 \u0434\u0435\u043d\u044c\u0433\u0438 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0435\u0435: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u0441\u0435\u0433\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438, \u043a\u0430\u043a\u043e\u0439 \u0447\u0438\u0441\u0442\u044b\u0439 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a, \u043a\u0442\u043e \u0433\u043b\u0430\u0432\u043d\u044b\u0439 \u043a\u043b\u0438\u0435\u043d\u0442 \u0438 \u043a\u0442\u043e \u0433\u043b\u0430\u0432\u043d\u044b\u0439 \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a \u0432 2020.",
assistantTurnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_intent_candidate: "customer_revenue_and_payments",
explicit_entity_candidates: [
"\u0438 \u043a\u0442\u043e \u0433\u043b\u0430\u0432\u043d\u044b\u0439 \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a \u0432"
]
},
predecomposeContract: {
entities: {
counterparty: "\u0438 \u043a\u0442\u043e \u0433\u043b\u0430\u0432\u043d\u044b\u0439 \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a \u0432"
},
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation");
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.data_need_graph?.subject_candidates).toEqual([]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_predecompose");
});
it("continues business overview on by-these-data profit wording without grounding pseudo anchors", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
@@ -146,6 +146,25 @@ describe("assistantTurnMeaningPolicy", () => {
expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal");
});
it("treats compound money breakdown as business overview instead of narrow customer revenue", () => {
const policy = buildPolicy({
resolveAddressIntent: () => ({ intent: "customer_revenue_and_payments", confidence: "high" })
});
const meaning = policy.resolveAssistantTurnMeaning({
rawUserMessage:
"\u0420\u0430\u0441\u043a\u0440\u043e\u0439 \u0434\u0435\u043d\u044c\u0433\u0438 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0435\u0435: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u0441\u0435\u0433\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438, \u043a\u0430\u043a\u043e\u0439 \u0447\u0438\u0441\u0442\u044b\u0439 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a, \u043a\u0442\u043e \u0433\u043b\u0430\u0432\u043d\u044b\u0439 \u043a\u043b\u0438\u0435\u043d\u0442 \u0438 \u043a\u0442\u043e \u0433\u043b\u0430\u0432\u043d\u044b\u0439 \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a \u0432 2020."
});
expect(meaning.explicit_intent_candidate).toBeNull();
expect(meaning.asked_domain_family).toBe("business_summary");
expect(meaning.asked_action_family).toBe("broad_evaluation");
expect(meaning.explicit_entity_candidates).toEqual([]);
expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation");
expect(meaning.stale_replay_forbidden).toBe(true);
expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal");
});
it("treats organization-level earnings and best-year wording as business overview", () => {
const policy = buildPolicy({
resolveAddressIntent: () => ({ intent: "customer_revenue_and_payments", confidence: "high" })