ARCH: усилить semantic integrity и добавить human AGENT сценарии

This commit is contained in:
2026-04-23 19:10:28 +03:00
parent 62e2b3323f
commit 65d15c156b
29 changed files with 1966 additions and 93 deletions
@@ -5,6 +5,7 @@ exports.cloneAddressNavigationState = cloneAddressNavigationState;
exports.normalizeAddressNavigationState = normalizeAddressNavigationState;
exports.evolveAddressNavigationStateWithAssistantItem = evolveAddressNavigationStateWithAssistantItem;
const nanoid_1 = require("nanoid");
const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy");
const addressNavigation_1 = require("../types/addressNavigation");
const MAX_RESULT_SETS = 40;
const MAX_NAVIGATION_EVENTS = 120;
@@ -242,24 +243,32 @@ function resolveNavigationAction(debug, hasFocusObject) {
}
return hasFocusObject ? "drilldown" : "open";
}
function buildFocusObjectFromDebug(debug, resultSetId, createdAt) {
const extractedFilters = toObject(debug.extracted_filters) ?? {};
const rawValue = toNonEmptyString(debug.anchor_value_resolved) ??
toNonEmptyString(debug.anchor_value_raw) ??
toNonEmptyString(extractedFilters.item);
if (!rawValue) {
return null;
}
const objectType = toAddressFocusObjectType(debug.anchor_type);
const canonicalType = objectType === "unknown" ? inferDisplayEntityType(toAddressIntent(debug.detected_intent)) : objectType;
function buildFocusObject(objectType, label, resultSetId, createdAt) {
return {
object_type: canonicalType,
object_id: `${canonicalType}:${rawValue}`.toLowerCase(),
label: rawValue,
object_type: objectType,
object_id: `${objectType}:${label}`.toLowerCase(),
label,
provenance_result_set_id: resultSetId,
selected_at: createdAt
};
}
function buildFocusObjectFromDebug(debug, resultSetId, createdAt) {
const extractedFilters = toObject(debug.extracted_filters) ?? {};
const objectType = toAddressFocusObjectType(debug.anchor_type);
const canonicalType = objectType === "unknown" ? inferDisplayEntityType(toAddressIntent(debug.detected_intent)) : objectType;
if (canonicalType === "item") {
const item = (0, assistantContinuityPolicy_1.readAddressDebugItem)(debug, toNonEmptyString);
return item ? buildFocusObject(canonicalType, item, resultSetId, createdAt) : null;
}
if (canonicalType === "counterparty" && debug.mcp_discovery_response_applied === true) {
const counterparty = (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString);
return counterparty ? buildFocusObject(canonicalType, counterparty, resultSetId, createdAt) : null;
}
const rawValue = toNonEmptyString(debug.anchor_value_resolved) ??
toNonEmptyString(debug.anchor_value_raw) ??
toNonEmptyString(extractedFilters.item);
return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null;
}
function capResultSets(resultSets) {
if (resultSets.length <= MAX_RESULT_SETS) {
return resultSets;
@@ -0,0 +1,73 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.repairAddressMojibakeText = repairAddressMojibakeText;
exports.normalizeRussianComparableText = normalizeRussianComparableText;
const iconv_lite_1 = __importDefault(require("iconv-lite"));
function compactWhitespace(value) {
return value.replace(/\s+/g, " ").trim();
}
function textMojibakeScore(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[\u0400-\u04ff]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const replacement = (source.match(/[�]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
const doubleEncodedMarkers = (source.match(/(?:Р“[Р-џ]|Р’[Р-џ]|Ã.|Â.)/gu) ?? []).length;
return cyrillic + latin - replacement * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
}
function looksLikeAddressMojibake(value) {
const source = String(value ?? "");
if (!source.trim()) {
return false;
}
if (/[�]/.test(source)) {
return true;
}
if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
return true;
}
if ((source.match(/(?:Р“[Р-џ]|Р’[Р-џ]|Ã.|Â.)/gu) ?? []).length >= 2) {
return true;
}
return false;
}
function repairAddressMojibakeText(value) {
const source = String(value ?? "");
if (!looksLikeAddressMojibake(source)) {
return source;
}
let candidate = source;
for (let pass = 0; pass < 3; pass += 1) {
let improved = false;
try {
const fromWin1251 = iconv_lite_1.default.encode(candidate, "win1251").toString("utf8");
if (textMojibakeScore(fromWin1251) > textMojibakeScore(candidate)) {
candidate = fromWin1251;
improved = true;
}
}
catch {
// Ignore decode failures and keep the current candidate.
}
try {
const fromLatin1 = Buffer.from(candidate, "latin1").toString("utf8");
if (textMojibakeScore(fromLatin1) > textMojibakeScore(candidate)) {
candidate = fromLatin1;
improved = true;
}
}
catch {
// Ignore decode failures and keep the current candidate.
}
if (!improved) {
break;
}
}
return candidate;
}
function normalizeRussianComparableText(value) {
return compactWhitespace(repairAddressMojibakeText(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
@@ -77,12 +77,17 @@ function shouldPreferRawFollowupMessage(userMessage, addressInputMessage, carryo
const previousIntent = toNonEmptyString(followupContext?.previous_intent);
const rootIntent = toNonEmptyString(followupContext?.root_intent);
const previousAnchorType = toNonEmptyString(followupContext?.previous_anchor_type);
const hasReferentialDocumentExclusionFollowupCue = /(?:\u043a\u0440\u043e\u043c\u0435|\u043f\u043e\u043c\u0438\u043c\u043e)\s+(?:\u044d\u0442\u043e\u0433\u043e|\u044d\u0442\u043e\u0439|\u044d\u0442\u043e\u0442|\u044d\u0442\u0443|\u044d\u0442\u0438\u0445)(?:\s+(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430|\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430))?/iu.test(rawMessage);
const hasInventoryItemCarryover = previousAnchorType === "item" && isInventorySelectedObjectOrRootIntent(previousIntent);
const hasInventoryFrameCarryover = isInventorySelectedObjectOrRootIntent(previousIntent) ||
isInventorySelectedObjectOrRootIntent(rootIntent);
const hasDocumentCarryover = previousIntent === "list_documents_by_counterparty" || previousIntent === "list_documents_by_contract";
if (mode === "unsupported" && intent === "unknown") {
return true;
}
if (hasDocumentCarryover && hasReferentialDocumentExclusionFollowupCue) {
return true;
}
if (hasSameDateFollowupSignal(rawMessage) && hasExplicitCurrentDateSignal(canonicalMessage)) {
return true;
}
@@ -51,6 +51,7 @@ exports.resolveAssistantContinuitySnapshot = resolveAssistantContinuitySnapshot;
exports.resolveAssistantOrganizationAuthority = resolveAssistantOrganizationAuthority;
exports.resolveOrganizationClarificationContinuation = resolveOrganizationClarificationContinuation;
const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher");
const addressTextRepair_1 = require("./addressTextRepair");
function fallbackToNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
@@ -365,14 +366,45 @@ function readAddressDebugItem(debug, toNonEmptyString = fallbackToNonEmptyString
? toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw)
: null));
}
function readAddressDebugCounterparty(debug, toNonEmptyString = fallbackToNonEmptyString) {
const extractedFilters = readAddressDebugFilters(debug);
if (toNonEmptyString(extractedFilters?.counterparty)) {
return toNonEmptyString(extractedFilters?.counterparty);
function isReferentialCounterpartyPlaceholder(value) {
if (!value) {
return false;
}
if (String(debug?.anchor_type ?? "") === "counterparty") {
return toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw);
return new Set([
"он",
"она",
"оно",
"они",
"ему",
"ней",
"нему",
"ним",
"ними",
"его",
"ее",
"их",
"этому",
"этой",
"этом",
"этим",
"эта",
"этот",
"эти"
]).has((0, addressTextRepair_1.normalizeRussianComparableText)(value));
}
function normalizeCounterpartyCandidate(value, toNonEmptyString) {
const text = toNonEmptyString(value);
if (!text || isReferentialCounterpartyPlaceholder(text)) {
return null;
}
return text;
}
function sameCounterpartyCandidate(left, right) {
return Boolean(left &&
right &&
(0, addressTextRepair_1.normalizeRussianComparableText)(left) === (0, addressTextRepair_1.normalizeRussianComparableText)(right));
}
function readGroundedDiscoveryCounterparty(debug, toNonEmptyString = fallbackToNonEmptyString) {
const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString);
const suppressDiscoveryEntityCarryover = discoveryPilotScope === "metadata_inspection_v1" ||
readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug);
@@ -381,12 +413,27 @@ function readAddressDebugCounterparty(debug, toNonEmptyString = fallbackToNonEmp
}
const discoveryEntities = collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString);
for (const entity of discoveryEntities) {
const text = toNonEmptyString(entity);
if (text) {
return text;
const normalized = normalizeCounterpartyCandidate(entity, toNonEmptyString);
if (normalized) {
return normalized;
}
}
return null;
return normalizeCounterpartyCandidate(readAssistantMcpDiscoveryLoopMetadataScopeHint(debug, toNonEmptyString), toNonEmptyString);
}
function readAddressDebugCounterparty(debug, toNonEmptyString = fallbackToNonEmptyString) {
const extractedFilters = readAddressDebugFilters(debug);
const extractedCounterparty = normalizeCounterpartyCandidate(extractedFilters?.counterparty, toNonEmptyString);
const anchorCounterparty = String(debug?.anchor_type ?? "") === "counterparty"
? normalizeCounterpartyCandidate(toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw), toNonEmptyString)
: null;
const groundedDiscoveryCounterparty = readGroundedDiscoveryCounterparty(debug, toNonEmptyString);
if (hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && groundedDiscoveryCounterparty) {
if (!extractedCounterparty || !sameCounterpartyCandidate(extractedCounterparty, groundedDiscoveryCounterparty)) {
return groundedDiscoveryCounterparty;
}
return extractedCounterparty;
}
return extractedCounterparty ?? anchorCounterparty ?? groundedDiscoveryCounterparty;
}
function readAddressDebugIntent(debug, toNonEmptyString = fallbackToNonEmptyString) {
const detectedIntent = toNonEmptyString(debug?.detected_intent);
@@ -431,8 +478,16 @@ function readAddressDebugTemporalScope(debug, toNonEmptyString = fallbackToNonEm
}
function resolveAddressDebugAnchorContext(debug, toNonEmptyString = fallbackToNonEmptyString) {
const explicitAnchorType = toNonEmptyString(debug?.anchor_type);
const explicitAnchorValue = toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw);
if (explicitAnchorType || explicitAnchorValue) {
const explicitAnchorValueRaw = toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw);
const explicitAnchorValue = explicitAnchorType === "counterparty"
? normalizeCounterpartyCandidate(explicitAnchorValueRaw, toNonEmptyString)
: explicitAnchorValueRaw;
const groundedDiscoveryCounterparty = readGroundedDiscoveryCounterparty(debug, toNonEmptyString);
const shouldPreferDiscoveryCounterparty = explicitAnchorType === "counterparty" &&
Boolean(groundedDiscoveryCounterparty &&
hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) &&
(!explicitAnchorValue || !sameCounterpartyCandidate(explicitAnchorValue, groundedDiscoveryCounterparty)));
if ((explicitAnchorType || explicitAnchorValue) && !shouldPreferDiscoveryCounterparty) {
return {
anchorType: explicitAnchorType,
anchorValue: explicitAnchorValue
@@ -446,8 +501,11 @@ function resolveAddressDebugAnchorContext(debug, toNonEmptyString = fallbackToNo
anchorValue: item
};
}
const counterparty = toNonEmptyString(extractedFilters?.counterparty);
if (counterparty) {
const counterparty = normalizeCounterpartyCandidate(extractedFilters?.counterparty, toNonEmptyString);
if (counterparty &&
!(groundedDiscoveryCounterparty &&
hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) &&
!sameCounterpartyCandidate(counterparty, groundedDiscoveryCounterparty))) {
return {
anchorType: "counterparty",
anchorValue: counterparty
@@ -512,7 +570,9 @@ function resolveAddressDebugCarryoverFilters(debug, toNonEmptyString = fallbackT
Boolean(discoveryDateScope.asOfDate || discoveryDateScope.periodFrom || discoveryDateScope.periodTo);
const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString);
const organization = readAddressDebugOrganization(debug, toNonEmptyString);
if (counterparty && !toNonEmptyString(nextFilters.counterparty)) {
const preferGroundedDiscoveryCounterparty = hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty);
const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString);
if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) {
nextFilters.counterparty = counterparty;
}
if (organization && !toNonEmptyString(nextFilters.organization)) {
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = void 0;
exports.buildAssistantMcpDiscoveryTurnInput = buildAssistantMcpDiscoveryTurnInput;
const assistantMcpDiscoveryDataNeedGraph_1 = require("./assistantMcpDiscoveryDataNeedGraph");
const addressTextRepair_1 = require("./addressTextRepair");
exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = "assistant_mcp_discovery_turn_input_v1";
function toRecordObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -38,7 +39,32 @@ function pushUnique(target, value) {
}
}
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());
return new Set([
"он",
"она",
"оно",
"они",
"ему",
"ней",
"нему",
"ним",
"ними",
"его",
"ее",
"их",
"этому",
"этой",
"этим",
"этими",
"этом"
]).has((0, addressTextRepair_1.normalizeRussianComparableText)(value));
}
function normalizeFollowupCounterpartyCandidate(value) {
const text = candidateValue(value);
if (!text || isReferentialEntityPlaceholder(text)) {
return null;
}
return text;
}
function pushScopedEntityCandidate(target, value, groundedFollowupEntity) {
const text = candidateValue(value);
@@ -347,14 +373,22 @@ function collectFollowupDiscoverySeed(followupContext) {
const entityResolutionAmbiguityCandidates = collectEntityCandidates(followupContext?.previous_discovery_entity_ambiguity_candidates);
const ambiguityBlocksImplicitGrounding = effectivePilotScope === "entity_resolution_search_v1" && entityResolutionStatus === "ambiguous";
const metadataPilotCarriesScopeOnly = effectivePilotScope === "metadata_inspection_v1" || loopSubjectResolutionOptional;
const normalizedDiscoveryEntities = discoveryEntities
.map((entity) => normalizeFollowupCounterpartyCandidate(entity))
.filter((entity) => Boolean(entity));
const groundedDiscoveryCounterparty = ambiguityBlocksImplicitGrounding || metadataPilotCarriesScopeOnly
? null
: normalizedDiscoveryEntities[0] ?? normalizeFollowupCounterpartyCandidate(loopMetadataScopeHint);
const metadataScopeHint = loopMetadataScopeHint ??
(loopSubjectResolutionOptional ? discoveryEntities[0] ?? null : null);
const counterparty = toNonEmptyString(previousFilters?.counterparty) ??
toNonEmptyString(rootFilters?.counterparty) ??
(toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty"
? toNonEmptyString(followupContext?.previous_anchor_value)
: null) ??
(ambiguityBlocksImplicitGrounding || metadataPilotCarriesScopeOnly ? null : discoveryEntities[0] ?? null);
(loopSubjectResolutionOptional ? normalizedDiscoveryEntities[0] ?? null : null);
const previousFiltersCounterparty = normalizeFollowupCounterpartyCandidate(previousFilters?.counterparty);
const rootFiltersCounterparty = normalizeFollowupCounterpartyCandidate(rootFilters?.counterparty);
const previousAnchorCounterparty = toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty"
? normalizeFollowupCounterpartyCandidate(followupContext?.previous_anchor_value)
: null;
const counterparty = groundedDiscoveryCounterparty
? groundedDiscoveryCounterparty
: previousFiltersCounterparty ?? rootFiltersCounterparty ?? previousAnchorCounterparty;
const organization = toNonEmptyString(previousFilters?.organization) ??
toNonEmptyString(rootFilters?.organization) ??
(toNonEmptyString(followupContext?.previous_anchor_type) === "organization"
@@ -372,7 +406,7 @@ function collectFollowupDiscoverySeed(followupContext) {
loopPendingAxes,
loopProvidedAxes,
counterparty,
discoveryEntity: ambiguityBlocksImplicitGrounding || loopSubjectResolutionOptional ? null : discoveryEntities[0] ?? null,
discoveryEntity: ambiguityBlocksImplicitGrounding || loopSubjectResolutionOptional ? null : normalizedDiscoveryEntities[0] ?? null,
entityResolutionStatus,
entityResolutionAmbiguityCandidates,
rankingNeed: toNonEmptyString(followupContext?.previous_discovery_ranking_need),
@@ -472,6 +506,9 @@ function hasMetadataSignal(text) {
return (/(?:\u043e\u0431\u044a\u0435\u043a\u0442(?:\u044b|\u0430|\u043e\u0432)?|\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b|\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438|\u043f\u043e\u043b(?:\u0435|\u044f)|objects?|registers?|documents?|catalogs?|fields?)/iu.test(text) &&
/(?:\u0435\u0441\u0442\u044c|\u043a\u0430\u043a\u0438\u0435|\u0434\u043e\u0441\u0442\u0443\u043f\u043d|\u0432\s+1\u0441|1\u0441|available|exist|which)/iu.test(text));
}
function hasReferentialDocumentExclusionFollowupSignal(text) {
return /(?:\u043a\u0440\u043e\u043c\u0435|\u043f\u043e\u043c\u0438\u043c\u043e)\s+(?:\u044d\u0442\u043e\u0433\u043e|\u044d\u0442\u043e\u0439|\u044d\u0442\u043e\u0442|\u044d\u0442\u0443|\u044d\u0442\u0438\u0445)(?:\s+(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430|\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430))?/iu.test(text);
}
function hasMetadataObjectHint(text) {
return /(?:\u043e\u0431\u044a\u0435\u043a\u0442(?:\u044b|\u0430|\u043e\u0432)?|\u0440\u0435\u0433\u0438\u0441\u0442\u0440(?:\u044b)?|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442(?:\u044b)?|\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a(?:\u0438)?|\u043f\u043e\u043b(?:\u0435|\u044f)|objects?|registers?|documents?|catalogs?|fields?)/iu.test(text);
}
@@ -732,14 +769,20 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const reasonCodes = [];
const rawUserText = toNonEmptyString(input.userMessage);
const rawEffectiveText = toNonEmptyString(input.effectiveMessage);
const rawSignalSourceText = `${rawUserText ?? ""} ${rawEffectiveText ?? ""}`.trim();
const rawEntitySourceText = rawUserText ?? rawEffectiveText ?? rawSignalSourceText;
const repairedUserText = rawUserText ? (0, addressTextRepair_1.repairAddressMojibakeText)(rawUserText) : null;
const repairedEffectiveText = rawEffectiveText ? (0, addressTextRepair_1.repairAddressMojibakeText)(rawEffectiveText) : null;
const rawSignalSourceText = `${repairedUserText ?? rawUserText ?? ""} ${repairedEffectiveText ?? rawEffectiveText ?? ""}`.trim();
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
const rawText = compactLower(rawSignalSourceText);
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? "");
const rawLifecycleSignal = hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowSignal = !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText);
const rawValueFlowSignal = !rawLifecycleSignal &&
(hasValueFlowSignal(rawText) || hasValueRankingSignal(rawText) || rawBidirectionalValueFlowSignal);
const rawMetadataSignal = !rawLifecycleSignal && !rawValueFlowSignal && hasMetadataSignal(rawText);
const rawMetadataSignal = !rawLifecycleSignal &&
!rawValueFlowSignal &&
!rawReferentialDocumentExclusionSignal &&
hasMetadataSignal(rawText);
const rawEntityResolutionSignal = !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText);
const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText);
@@ -495,12 +495,35 @@ function createAssistantRoutePolicy(deps) {
!effectiveAddressFollowupSignal &&
resolvedModeDetection.mode === "unsupported" &&
resolvedIntentResolution.intent === "unknown");
const groundedValueFlowFollowupContextDetected = Boolean(followupContext &&
[
"counterparty_value_flow_query_movements_v1",
"counterparty_supplier_payout_query_movements_v1",
"counterparty_bidirectional_value_flow_query_movements_v1"
].includes(String(toNonEmptyString(followupContext?.previous_discovery_pilot_scope) ?? "")) &&
!dangerOrCoercionSignal &&
(toNonEmptyString(assistantTurnMeaning?.asked_domain_family) === "counterparty_value" ||
[
"turnover",
"payout",
"net_value_flow"
].includes(String(toNonEmptyString(assistantTurnMeaning?.asked_action_family) ?? "")) ||
/(?:нетто|сальдо|сколько\s+мы\s+(?:получили|заплатили)|incoming|outgoing)/iu.test(analyticsSample)));
const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane &&
["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? "")));
[
"address_intent_resolver_detected",
"address_mode_classifier_detected",
"address_signal_detected",
"llm_canonical_data_signal_detected"
].includes(String(baseToolGate?.reason ?? ""))) ||
Boolean(baseToolGate?.runAddressLane &&
String(baseToolGate?.reason ?? "") === "followup_context_detected" &&
groundedValueFlowFollowupContextDetected);
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
deterministicNonDomainGuard &&
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
!baseToolGatePreservesAddressLane &&
!groundedValueFlowFollowupContextDetected &&
!protectedInventoryShortFollowup &&
!organizationClarificationContinuationDetected);
const lastAddressAssistantDebug = sessionItems
@@ -664,9 +687,11 @@ function createAssistantRoutePolicy(deps) {
const unsupportedCurrentTurnMeaningBoundary = Boolean(assistantTurnMeaning?.unsupported_but_understood_family &&
assistantTurnMeaning?.stale_replay_forbidden === true &&
!turnMeaningIntentCandidate &&
!aggregateBusinessAnalyticsSignal &&
!dataScopeMetaQuery &&
!capabilityMetaQuery &&
!dangerOrCoercionSignal &&
!groundedValueFlowFollowupContextDetected &&
!organizationClarificationContinuationDetected);
if (unsupportedCurrentTurnMeaningBoundary) {
return {
@@ -1,6 +1,10 @@
import { nanoid } from "nanoid";
import type { AssistantConversationItem } from "../types/assistant";
import type { AddressIntent } from "../types/addressQuery";
import {
readAddressDebugCounterparty,
readAddressDebugItem
} from "./assistantContinuityPolicy";
import {
ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION,
type AddressFocusObject,
@@ -277,24 +281,38 @@ function resolveNavigationAction(debug: Record<string, unknown>, hasFocusObject:
return hasFocusObject ? "drilldown" : "open";
}
function buildFocusObject(
objectType: AddressFocusObjectType,
label: string,
resultSetId: string,
createdAt: string
): AddressFocusObject {
return {
object_type: objectType,
object_id: `${objectType}:${label}`.toLowerCase(),
label,
provenance_result_set_id: resultSetId,
selected_at: createdAt
};
}
function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId: string, createdAt: string): AddressFocusObject | null {
const extractedFilters = toObject(debug.extracted_filters) ?? {};
const objectType = toAddressFocusObjectType(debug.anchor_type);
const canonicalType = objectType === "unknown" ? inferDisplayEntityType(toAddressIntent(debug.detected_intent)) : objectType;
if (canonicalType === "item") {
const item = readAddressDebugItem(debug, toNonEmptyString);
return item ? buildFocusObject(canonicalType, item, resultSetId, createdAt) : null;
}
if (canonicalType === "counterparty" && debug.mcp_discovery_response_applied === true) {
const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString);
return counterparty ? buildFocusObject(canonicalType, counterparty, resultSetId, createdAt) : null;
}
const rawValue =
toNonEmptyString(debug.anchor_value_resolved) ??
toNonEmptyString(debug.anchor_value_raw) ??
toNonEmptyString(extractedFilters.item);
if (!rawValue) {
return null;
}
const objectType = toAddressFocusObjectType(debug.anchor_type);
const canonicalType = objectType === "unknown" ? inferDisplayEntityType(toAddressIntent(debug.detected_intent)) : objectType;
return {
object_type: canonicalType,
object_id: `${canonicalType}:${rawValue}`.toLowerCase(),
label: rawValue,
provenance_result_set_id: resultSetId,
selected_at: createdAt
};
return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null;
}
function capResultSets(resultSets: AddressResultSet[]): AddressResultSet[] {
@@ -0,0 +1,74 @@
import iconv from "iconv-lite";
function compactWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function textMojibakeScore(value: string): number {
const source = String(value ?? "");
const cyrillic = (source.match(/[\u0400-\u04ff]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const replacement = (source.match(/[�]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
const doubleEncodedMarkers = (source.match(/(?:Р“[Р-џ]|Р’[Р-џ]|Ã.|Â.)/gu) ?? []).length;
return cyrillic + latin - replacement * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
}
function looksLikeAddressMojibake(value: string): boolean {
const source = String(value ?? "");
if (!source.trim()) {
return false;
}
if (/[�]/.test(source)) {
return true;
}
if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
return true;
}
if ((source.match(/(?:Р“[Р-џ]|Р’[Р-џ]|Ã.|Â.)/gu) ?? []).length >= 2) {
return true;
}
return false;
}
export function repairAddressMojibakeText(value: string): string {
const source = String(value ?? "");
if (!looksLikeAddressMojibake(source)) {
return source;
}
let candidate = source;
for (let pass = 0; pass < 3; pass += 1) {
let improved = false;
try {
const fromWin1251 = iconv.encode(candidate, "win1251").toString("utf8");
if (textMojibakeScore(fromWin1251) > textMojibakeScore(candidate)) {
candidate = fromWin1251;
improved = true;
}
} catch {
// Ignore decode failures and keep the current candidate.
}
try {
const fromLatin1 = Buffer.from(candidate, "latin1").toString("utf8");
if (textMojibakeScore(fromLatin1) > textMojibakeScore(candidate)) {
candidate = fromLatin1;
improved = true;
}
} catch {
// Ignore decode failures and keep the current candidate.
}
if (!improved) {
break;
}
}
return candidate;
}
export function normalizeRussianComparableText(value: unknown): string {
return compactWhitespace(repairAddressMojibakeText(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
@@ -178,16 +178,25 @@ function shouldPreferRawFollowupMessage(
const previousIntent = toNonEmptyString(followupContext?.previous_intent);
const rootIntent = toNonEmptyString(followupContext?.root_intent);
const previousAnchorType = toNonEmptyString(followupContext?.previous_anchor_type);
const hasReferentialDocumentExclusionFollowupCue = /(?:\u043a\u0440\u043e\u043c\u0435|\u043f\u043e\u043c\u0438\u043c\u043e)\s+(?:\u044d\u0442\u043e\u0433\u043e|\u044d\u0442\u043e\u0439|\u044d\u0442\u043e\u0442|\u044d\u0442\u0443|\u044d\u0442\u0438\u0445)(?:\s+(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430|\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430))?/iu.test(
rawMessage
);
const hasInventoryItemCarryover =
previousAnchorType === "item" && isInventorySelectedObjectOrRootIntent(previousIntent);
const hasInventoryFrameCarryover =
isInventorySelectedObjectOrRootIntent(previousIntent) ||
isInventorySelectedObjectOrRootIntent(rootIntent);
const hasDocumentCarryover =
previousIntent === "list_documents_by_counterparty" || previousIntent === "list_documents_by_contract";
if (mode === "unsupported" && intent === "unknown") {
return true;
}
if (hasDocumentCarryover && hasReferentialDocumentExclusionFollowupCue) {
return true;
}
if (hasSameDateFollowupSignal(rawMessage) && hasExplicitCurrentDateSignal(canonicalMessage)) {
return true;
}
@@ -2,6 +2,7 @@ import {
mergeKnownOrganizations as mergeKnownOrganizationsFromMatcher,
normalizeOrganizationScopeValue as normalizeOrganizationScopeValueFromMatcher
} from "./assistantOrganizationMatcher";
import { normalizeRussianComparableText } from "./addressTextRepair";
export interface AssistantContinuitySnapshotInput {
sessionItems?: unknown[];
@@ -567,17 +568,61 @@ export function readAddressDebugItem(
);
}
export function readAddressDebugCounterparty(
function isReferentialCounterpartyPlaceholder(
value: string | null
): boolean {
if (!value) {
return false;
}
return new Set([
"он",
"она",
"оно",
"они",
"ему",
"ней",
"нему",
"ним",
"ними",
"его",
"ее",
"их",
"этому",
"этой",
"этом",
"этим",
"эта",
"этот",
"эти"
]).has(normalizeRussianComparableText(value));
}
function normalizeCounterpartyCandidate(
value: unknown,
toNonEmptyString: (value: unknown) => string | null
): string | null {
const text = toNonEmptyString(value);
if (!text || isReferentialCounterpartyPlaceholder(text)) {
return null;
}
return text;
}
function sameCounterpartyCandidate(
left: string | null,
right: string | null
): boolean {
return Boolean(
left &&
right &&
normalizeRussianComparableText(left) === normalizeRussianComparableText(right)
);
}
function readGroundedDiscoveryCounterparty(
debug: Record<string, unknown> | null,
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
): string | null {
const extractedFilters = readAddressDebugFilters(debug);
if (toNonEmptyString(extractedFilters?.counterparty)) {
return toNonEmptyString(extractedFilters?.counterparty);
}
if (String(debug?.anchor_type ?? "") === "counterparty") {
return toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw);
}
const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString);
const suppressDiscoveryEntityCarryover =
discoveryPilotScope === "metadata_inspection_v1" ||
@@ -587,12 +632,38 @@ export function readAddressDebugCounterparty(
}
const discoveryEntities = collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString);
for (const entity of discoveryEntities) {
const text = toNonEmptyString(entity);
if (text) {
return text;
const normalized = normalizeCounterpartyCandidate(entity, toNonEmptyString);
if (normalized) {
return normalized;
}
}
return null;
return normalizeCounterpartyCandidate(
readAssistantMcpDiscoveryLoopMetadataScopeHint(debug, toNonEmptyString),
toNonEmptyString
);
}
export function readAddressDebugCounterparty(
debug: Record<string, unknown> | null,
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
): string | null {
const extractedFilters = readAddressDebugFilters(debug);
const extractedCounterparty = normalizeCounterpartyCandidate(extractedFilters?.counterparty, toNonEmptyString);
const anchorCounterparty =
String(debug?.anchor_type ?? "") === "counterparty"
? normalizeCounterpartyCandidate(
toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw),
toNonEmptyString
)
: null;
const groundedDiscoveryCounterparty = readGroundedDiscoveryCounterparty(debug, toNonEmptyString);
if (hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && groundedDiscoveryCounterparty) {
if (!extractedCounterparty || !sameCounterpartyCandidate(extractedCounterparty, groundedDiscoveryCounterparty)) {
return groundedDiscoveryCounterparty;
}
return extractedCounterparty;
}
return extractedCounterparty ?? anchorCounterparty ?? groundedDiscoveryCounterparty;
}
export function readAddressDebugIntent(
@@ -664,9 +735,21 @@ export function resolveAddressDebugAnchorContext(
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
): AssistantAddressDebugAnchorContext {
const explicitAnchorType = toNonEmptyString(debug?.anchor_type);
const explicitAnchorValue =
const explicitAnchorValueRaw =
toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw);
if (explicitAnchorType || explicitAnchorValue) {
const explicitAnchorValue =
explicitAnchorType === "counterparty"
? normalizeCounterpartyCandidate(explicitAnchorValueRaw, toNonEmptyString)
: explicitAnchorValueRaw;
const groundedDiscoveryCounterparty = readGroundedDiscoveryCounterparty(debug, toNonEmptyString);
const shouldPreferDiscoveryCounterparty =
explicitAnchorType === "counterparty" &&
Boolean(
groundedDiscoveryCounterparty &&
hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) &&
(!explicitAnchorValue || !sameCounterpartyCandidate(explicitAnchorValue, groundedDiscoveryCounterparty))
);
if ((explicitAnchorType || explicitAnchorValue) && !shouldPreferDiscoveryCounterparty) {
return {
anchorType: explicitAnchorType,
anchorValue: explicitAnchorValue
@@ -681,8 +764,15 @@ export function resolveAddressDebugAnchorContext(
anchorValue: item
};
}
const counterparty = toNonEmptyString(extractedFilters?.counterparty);
if (counterparty) {
const counterparty = normalizeCounterpartyCandidate(extractedFilters?.counterparty, toNonEmptyString);
if (
counterparty &&
!(
groundedDiscoveryCounterparty &&
hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) &&
!sameCounterpartyCandidate(counterparty, groundedDiscoveryCounterparty)
)
) {
return {
anchorType: "counterparty",
anchorValue: counterparty
@@ -761,7 +851,10 @@ export function resolveAddressDebugCarryoverFilters(
Boolean(discoveryDateScope.asOfDate || discoveryDateScope.periodFrom || discoveryDateScope.periodTo);
const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString);
const organization = readAddressDebugOrganization(debug, toNonEmptyString);
if (counterparty && !toNonEmptyString(nextFilters.counterparty)) {
const preferGroundedDiscoveryCounterparty =
hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty);
const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString);
if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) {
nextFilters.counterparty = counterparty;
}
if (organization && !toNonEmptyString(nextFilters.organization)) {
@@ -3,6 +3,7 @@ import {
buildAssistantMcpDiscoveryDataNeedGraph,
type AssistantMcpDiscoveryDataNeedGraphContract
} from "./assistantMcpDiscoveryDataNeedGraph";
import { normalizeRussianComparableText, repairAddressMojibakeText } from "./addressTextRepair";
import type {
AssistantMcpDiscoveryMetadataRecommendedPrimitive,
AssistantMcpDiscoveryMetadataRouteFamily,
@@ -80,9 +81,33 @@ 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()
);
return new Set([
"он",
"она",
"оно",
"они",
"ему",
"ней",
"нему",
"ним",
"ними",
"его",
"ее",
"их",
"этому",
"этой",
"этим",
"этими",
"этом"
]).has(normalizeRussianComparableText(value));
}
function normalizeFollowupCounterpartyCandidate(value: unknown): string | null {
const text = candidateValue(value);
if (!text || isReferentialEntityPlaceholder(text)) {
return null;
}
return text;
}
function pushScopedEntityCandidate(
@@ -482,16 +507,25 @@ function collectFollowupDiscoverySeed(followupContext: Record<string, unknown> |
effectivePilotScope === "entity_resolution_search_v1" && entityResolutionStatus === "ambiguous";
const metadataPilotCarriesScopeOnly =
effectivePilotScope === "metadata_inspection_v1" || loopSubjectResolutionOptional;
const normalizedDiscoveryEntities = discoveryEntities
.map((entity) => normalizeFollowupCounterpartyCandidate(entity))
.filter((entity): entity is string => Boolean(entity));
const groundedDiscoveryCounterparty =
ambiguityBlocksImplicitGrounding || metadataPilotCarriesScopeOnly
? null
: normalizedDiscoveryEntities[0] ?? normalizeFollowupCounterpartyCandidate(loopMetadataScopeHint);
const metadataScopeHint =
loopMetadataScopeHint ??
(loopSubjectResolutionOptional ? discoveryEntities[0] ?? null : null);
const counterparty =
toNonEmptyString(previousFilters?.counterparty) ??
toNonEmptyString(rootFilters?.counterparty) ??
(toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty"
? toNonEmptyString(followupContext?.previous_anchor_value)
: null) ??
(ambiguityBlocksImplicitGrounding || metadataPilotCarriesScopeOnly ? null : discoveryEntities[0] ?? null);
(loopSubjectResolutionOptional ? normalizedDiscoveryEntities[0] ?? null : null);
const previousFiltersCounterparty = normalizeFollowupCounterpartyCandidate(previousFilters?.counterparty);
const rootFiltersCounterparty = normalizeFollowupCounterpartyCandidate(rootFilters?.counterparty);
const previousAnchorCounterparty =
toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty"
? normalizeFollowupCounterpartyCandidate(followupContext?.previous_anchor_value)
: null;
const counterparty = groundedDiscoveryCounterparty
? groundedDiscoveryCounterparty
: previousFiltersCounterparty ?? rootFiltersCounterparty ?? previousAnchorCounterparty;
const organization =
toNonEmptyString(previousFilters?.organization) ??
toNonEmptyString(rootFilters?.organization) ??
@@ -512,7 +546,7 @@ function collectFollowupDiscoverySeed(followupContext: Record<string, unknown> |
loopProvidedAxes,
counterparty,
discoveryEntity:
ambiguityBlocksImplicitGrounding || loopSubjectResolutionOptional ? null : discoveryEntities[0] ?? null,
ambiguityBlocksImplicitGrounding || loopSubjectResolutionOptional ? null : normalizedDiscoveryEntities[0] ?? null,
entityResolutionStatus,
entityResolutionAmbiguityCandidates,
rankingNeed: toNonEmptyString(followupContext?.previous_discovery_ranking_need),
@@ -672,6 +706,12 @@ function hasMetadataSignal(text: string): boolean {
);
}
function hasReferentialDocumentExclusionFollowupSignal(text: string): boolean {
return /(?:\u043a\u0440\u043e\u043c\u0435|\u043f\u043e\u043c\u0438\u043c\u043e)\s+(?:\u044d\u0442\u043e\u0433\u043e|\u044d\u0442\u043e\u0439|\u044d\u0442\u043e\u0442|\u044d\u0442\u0443|\u044d\u0442\u0438\u0445)(?:\s+(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430|\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430))?/iu.test(
text
);
}
function hasMetadataObjectHint(text: string): boolean {
return /(?:\u043e\u0431\u044a\u0435\u043a\u0442(?:\u044b|\u0430|\u043e\u0432)?|\u0440\u0435\u0433\u0438\u0441\u0442\u0440(?:\u044b)?|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442(?:\u044b)?|\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a(?:\u0438)?|\u043f\u043e\u043b(?:\u0435|\u044f)|objects?|registers?|documents?|catalogs?|fields?)/iu.test(
text
@@ -1007,15 +1047,24 @@ export function buildAssistantMcpDiscoveryTurnInput(
const reasonCodes: string[] = [];
const rawUserText = toNonEmptyString(input.userMessage);
const rawEffectiveText = toNonEmptyString(input.effectiveMessage);
const rawSignalSourceText = `${rawUserText ?? ""} ${rawEffectiveText ?? ""}`.trim();
const rawEntitySourceText = rawUserText ?? rawEffectiveText ?? rawSignalSourceText;
const repairedUserText = rawUserText ? repairAddressMojibakeText(rawUserText) : null;
const repairedEffectiveText = rawEffectiveText ? repairAddressMojibakeText(rawEffectiveText) : null;
const rawSignalSourceText = `${repairedUserText ?? rawUserText ?? ""} ${repairedEffectiveText ?? rawEffectiveText ?? ""}`.trim();
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
const rawText = compactLower(rawSignalSourceText);
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(
repairedUserText ?? rawUserText ?? ""
);
const rawLifecycleSignal = hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowSignal = !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText);
const rawValueFlowSignal =
!rawLifecycleSignal &&
(hasValueFlowSignal(rawText) || hasValueRankingSignal(rawText) || rawBidirectionalValueFlowSignal);
const rawMetadataSignal = !rawLifecycleSignal && !rawValueFlowSignal && hasMetadataSignal(rawText);
const rawMetadataSignal =
!rawLifecycleSignal &&
!rawValueFlowSignal &&
!rawReferentialDocumentExclusionSignal &&
hasMetadataSignal(rawText);
const rawEntityResolutionSignal =
!rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText);
@@ -579,12 +579,35 @@ export function createAssistantRoutePolicy(deps) {
!effectiveAddressFollowupSignal &&
resolvedModeDetection.mode === "unsupported" &&
resolvedIntentResolution.intent === "unknown");
const groundedValueFlowFollowupContextDetected = Boolean(followupContext &&
[
"counterparty_value_flow_query_movements_v1",
"counterparty_supplier_payout_query_movements_v1",
"counterparty_bidirectional_value_flow_query_movements_v1"
].includes(String(toNonEmptyString(followupContext?.previous_discovery_pilot_scope) ?? "")) &&
!dangerOrCoercionSignal &&
(toNonEmptyString(assistantTurnMeaning?.asked_domain_family) === "counterparty_value" ||
[
"turnover",
"payout",
"net_value_flow"
].includes(String(toNonEmptyString(assistantTurnMeaning?.asked_action_family) ?? "")) ||
/(?:нетто|сальдо|сколько\s+мы\s+(?:получили|заплатили)|incoming|outgoing)/iu.test(analyticsSample)));
const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane &&
["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? "")));
[
"address_intent_resolver_detected",
"address_mode_classifier_detected",
"address_signal_detected",
"llm_canonical_data_signal_detected"
].includes(String(baseToolGate?.reason ?? ""))) ||
Boolean(baseToolGate?.runAddressLane &&
String(baseToolGate?.reason ?? "") === "followup_context_detected" &&
groundedValueFlowFollowupContextDetected);
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
deterministicNonDomainGuard &&
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
!baseToolGatePreservesAddressLane &&
!groundedValueFlowFollowupContextDetected &&
!protectedInventoryShortFollowup &&
!organizationClarificationContinuationDetected);
const lastAddressAssistantDebug = sessionItems
@@ -749,9 +772,11 @@ export function createAssistantRoutePolicy(deps) {
assistantTurnMeaning?.unsupported_but_understood_family &&
assistantTurnMeaning?.stale_replay_forbidden === true &&
!turnMeaningIntentCandidate &&
!aggregateBusinessAnalyticsSignal &&
!dataScopeMetaQuery &&
!capabilityMetaQuery &&
!dangerOrCoercionSignal &&
!groundedValueFlowFollowupContextDetected &&
!organizationClarificationContinuationDetected);
if (unsupportedCurrentTurnMeaningBoundary) {
return {
@@ -112,6 +112,89 @@ describe("address navigation state", () => {
expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31");
});
it("prefers grounded discovery counterparty over stale referential anchor when updating focus", () => {
const initial = normalizeAddressNavigationState(
{
schema_version: "address_navigation_state_v1",
session_id: "asst-3b",
updated_at: "2026-04-12T10:00:00.000Z",
session_context: {
active_result_set_id: "rs-prev",
active_focus_object: {
object_type: "counterparty",
object_id: "counterparty:нортон",
label: "НОРТОН",
provenance_result_set_id: "rs-prev",
selected_at: "2026-04-12T09:59:00.000Z"
},
last_confirmed_route: "address_customer_revenue_and_payments_v1",
date_scope: {
as_of_date: null,
period_from: "2020-01-01",
period_to: "2020-12-31"
},
organization_scope: "ООО Альтернатива Плюс"
},
result_sets: [],
navigation_history: []
} as any,
"asst-3b"
);
const assistantItem = {
message_id: "msg-a2b",
session_id: "asst-3b",
role: "assistant",
text: "По Группа СВК подтверждены исходящие платежи за 2020 год.",
reply_type: "factual",
created_at: "2026-04-12T10:02:30.000Z",
trace_id: "address-456b",
debug: {
detected_mode: "address_query",
detected_intent: "supplier_payouts_profile",
selected_recipe: "address_supplier_payouts_profile_v1",
extracted_filters: {
counterparty: "НОРТОН",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "counterparty",
anchor_value_raw: "он",
anchor_value_resolved: "он",
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: "counterparty_value",
asked_action_family: "payout",
explicit_entity_candidates: ["Группа СВК"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
business_fact_answer_allowed: true,
pilot: {
pilot_scope: "counterparty_supplier_payout_query_movements_v1"
},
answer_draft: {
answer_mode: "confirmed_factual"
}
}
},
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const evolved = evolveAddressNavigationStateWithAssistantItem(initial, assistantItem, 4);
expect(evolved.session_context.active_focus_object?.object_type).toBe("counterparty");
expect(evolved.session_context.active_focus_object?.label).toBe("Группа СВК");
expect(evolved.session_context.active_focus_object?.object_id).toBe("counterparty:группа свк");
expect(evolved.navigation_history[0]?.target_object_id).toBe("counterparty:группа свк");
});
it("captures item focus from inventory answers when no anchor is materialized", () => {
const base = createEmptyAddressNavigationState("asst-4", "2026-04-12T10:00:00.000Z");
const assistantItem = {
@@ -731,13 +731,14 @@ describe("assistant address follow-up carryover", () => {
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "")).toContain("Жуковка 51");
expect(String(calls[1].options?.followupContext?.previous_filters?.counterparty ?? "")).toContain("жуковке 51");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("retries with raw user message after rewrite degraded anchor and returns factual follow-up result", async () => {
it("prefers the raw referential document follow-up over a degraded rewrite and returns factual follow-up result", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по жуковке 51";
const followupMessage = "кроме этого документа есть еще чтото?";
@@ -826,12 +827,10 @@ describe("assistant address follow-up carryover", () => {
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.address_retry_audit?.attempted).toBe(true);
expect(second.debug?.address_retry_audit?.initial_limited_category).toBe("missing_anchor");
expect(second.debug?.address_retry_audit?.retry_message).toBe(followupMessage);
expect(second.debug?.address_retry_audit?.attempted).toBe(false);
expect(calls.some((entry) => String(entry.message).toLowerCase().startsWith("документы по контрагенту"))).toBe(true);
expect(calls.some((entry) => String(entry.message).toLowerCase() === followupMessage)).toBe(true);
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
@@ -230,6 +230,102 @@ describe("assistantContinuityPolicy organization authority", () => {
});
});
it("replaces referential counterparty placeholders with grounded discovery entity during carryover", () => {
const debug = {
execution_lane: "address_query",
extracted_filters: {
counterparty: "РѕРЅ",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "counterparty",
anchor_value_raw: "РѕРЅ",
anchor_value_resolved: "РѕРЅ",
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: "counterparty_value",
asked_action_family: "turnover",
explicit_entity_candidates: ["Группа СВК"],
explicit_date_scope: "2020"
}
},
bridge: {
bridge_status: "answer_draft_ready",
business_fact_answer_allowed: true,
pilot: {
pilot_scope: "counterparty_value_flow_query_movements_v1"
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
}
}
}
};
expect(readAddressDebugCounterparty(debug)).toBe("Группа СВК");
expect(resolveAddressDebugCarryoverFilters(debug)).toEqual({
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
});
expect(resolveAddressDebugAnchorContext(debug)).toEqual({
anchorType: "counterparty",
anchorValue: "Группа СВК"
});
});
it("prefers grounded discovery metadata scope over conflicting stale exact-route counterparty", () => {
const debug = {
execution_lane: "address_query",
extracted_filters: {
counterparty: "РќРћР РўРћРќ",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "counterparty",
anchor_value_resolved: "СервисКонсалт, ООО",
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: "counterparty_value",
asked_action_family: "payout",
explicit_entity_candidates: ["РѕРЅ"],
metadata_scope_hint: "Группа СВК",
explicit_date_scope: "2020"
}
},
bridge: {
bridge_status: "answer_draft_ready",
business_fact_answer_allowed: true,
pilot: {
pilot_scope: "counterparty_supplier_payout_query_movements_v1"
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
}
}
}
};
expect(readAddressDebugCounterparty(debug)).toBe("Группа СВК");
expect(resolveAddressDebugCarryoverFilters(debug)).toEqual({
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
});
expect(resolveAddressDebugAnchorContext(debug)).toEqual({
anchorType: "counterparty",
anchorValue: "Группа СВК"
});
});
it("prefers the resolved entity from grounded entity-resolution discovery for counterparty carryover", () => {
const debug = {
execution_lane: "living_chat",
@@ -235,6 +235,40 @@ describe("assistant orchestration contract", () => {
expect(decision.orchestrationContract?.hard_meta_mode).toBe("non_domain");
});
it("keeps address lane for a short net follow-up over grounded value-flow context", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "А какое получилось нетто?",
effectiveAddressUserMessage: "А какое получилось нетто?",
followupContext: {
previous_intent: "customer_revenue_and_payments",
previous_filters: {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
previous_anchor_type: "counterparty",
previous_anchor_value: "Группа СВК",
previous_discovery_pilot_scope: "counterparty_supplier_payout_query_movements_v1"
},
llmPreDecomposeMeta: {
applied: false,
reason: "no_usable_fragment",
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.toolGateReason).not.toBe("non_domain_query_indexed");
expect(decision.livingMode).toBe("address_data");
});
it("routes historical capability follow-up over grounded inventory answer to contextual chat", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "а исторические данные ты можешь же показать?",
@@ -585,6 +585,41 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
});
it("prefers grounded discovery metadata scope over stale conflicting counterparty in a short net follow-up", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "а какое нетто?",
assistantTurnMeaning: {
asked_domain_family: "counterparty",
asked_action_family: "turnover",
explicit_intent_candidate: "customer_revenue_and_payments"
},
followupContext: {
previous_discovery_pilot_scope: "counterparty_supplier_payout_query_movements_v1",
previous_filters: {
counterparty: "РќРћР РўРћРќ",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
previous_anchor_type: "counterparty",
previous_anchor_value: "СервисКонсалт, ООО",
previous_discovery_loop_metadata_scope_hint: "Группа СВК"
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: ["Группа СВК"],
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).not.toContain("РќРћР РўРћРќ");
expect(result.reason_codes).toContain("mcp_discovery_grounded_value_flow_followup");
});
it.skip("switches from a grounded exact value-flow answer into document evidence without restating the counterparty", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "а по документам?",
@@ -1268,6 +1303,35 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
});
it("does not bootstrap metadata discovery from a referential document exclusion follow-up over exact document context", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "кроме этого документа есть еще что-то?",
effectiveMessage: "документы по контрагенту документа",
assistantTurnMeaning: {
asked_domain_family: "counterparty",
asked_action_family: "list_documents",
explicit_intent_candidate: "list_documents_by_counterparty",
explicit_entity_candidates: [{ value: "ТСЖ \"Жуковка 51\"" }]
},
followupContext: {
previous_intent: "list_documents_by_counterparty",
target_intent: "list_documents_by_counterparty",
previous_anchor_type: "counterparty",
previous_anchor_value: "ТСЖ \"Жуковка 51\"",
previous_filters: {
counterparty: "жуковке 51"
}
}
});
expect(result.adapter_status).toBe("not_applicable");
expect(result.should_run_discovery).toBe(false);
expect(result.turn_meaning_ref).toBeNull();
expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
expect(result.reason_codes).not.toContain("mcp_discovery_metadata_signal_detected");
expect(result.reason_codes).not.toContain("mcp_discovery_metadata_scope_hint_from_raw_text");
});
it("never serializes object candidates as [object Object]", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
assistantTurnMeaning: {