Compare commits

...

13 Commits

Author SHA1 Message Date
dctouch e9f0ef6b41 Сохранить accepted агентный прогон Phase107 2026-06-15 22:56:39 +03:00
dctouch 41463c9965 Укрепить агентный loop Phase107 2026-06-15 22:56:22 +03:00
dctouch b3f2e405bb Удержать контекст СВК в follow-up ассистента 2026-06-15 22:56:10 +03:00
dctouch 7e70ec1d46 Сохранить phase106 в агентные автопрогоны 2026-06-15 20:48:50 +03:00
dctouch 631391f47a Настроить phase106 и детекторы агентного лупа 2026-06-15 20:48:39 +03:00
dctouch e4eac8e465 Закрепить маржу и контекст организации в phase106 2026-06-15 20:48:29 +03:00
dctouch d0d7962b65 Сохранить phase104 в агентные автопрогоны 2026-06-15 20:22:59 +03:00
dctouch 10cc779de8 Зафиксировать phase104 для проверки ролевых хвостов 2026-06-15 20:01:36 +03:00
dctouch 13c48ea96e Направить денежный смысл контрагента в двусторонний поток 2026-06-15 20:01:30 +03:00
dctouch e2b928ac19 Укрепить валидацию агентного лупа по сценарным якорям 2026-06-15 20:01:22 +03:00
dctouch bbb3620956 Очистить память ассистента от ролевых фрагментов контрагентов 2026-06-15 18:57:11 +03:00
dctouch 1507ca2bb9 Закрепить бизнес-обзор контрагентских потоков 2026-06-15 18:29:22 +03:00
dctouch 77c88a018a Укрепить агентный луп для проверки контуров 1С 2026-06-15 18:29:12 +03:00
54 changed files with 3877 additions and 2048 deletions

File diff suppressed because it is too large Load Diff

View File

@ -66,6 +66,9 @@
"required_patterns_any": [
"(?i)(выруч|себестоим|валов|марж|не хватает|не могу подтвердить|не подтвержден|unknown)"
],
"artifact_path_include_patterns": [
"(?i)steps[\\\\/][^\\\\/]*(?:margin|profitability|марж)"
],
"artifact_path_exclude_patterns": [
"(?i)selected_item",
"(?i)supplier",
@ -208,6 +211,45 @@
"forbidden_patterns": ["(?im)^\\s*(Что проверили|Опорные документы):"]
}
},
"counterparty_value_flow_required_surface": {
"kind": "answer_text_required_any",
"automation_level": "automatic",
"description": "Counterparty value-flow answers must surface counterparty scope, incoming amount, outgoing amount, and net/saldo wording.",
"issue_codes": ["counterparty_value_flow_misrouted_to_company_profit"],
"inputs": ["steps/<step_id>/output.md"],
"check": {
"artifact_path_include_patterns": [
"(?i)(s01_svk_money_documents|s01_select_svk_counterparty_money|step_05_net_after_payout)"
],
"required_patterns_any": [
"(?is)(?=.*(СВК|Группа\\s+СВК))(?=.*(входящ|получил|получено|получили))(?=.*(исходящ|заплатил|заплачено|заплатили|ушло))(?=.*(нетто|сальдо|разниц|чистый\\s+денежный))"
]
}
},
"counterparty_value_flow_profit_accounts_forbidden": {
"kind": "answer_text_regex_forbidden",
"automation_level": "automatic",
"description": "Counterparty received/paid/net-flow answers must not use company profit or 90/91/99 accounts as the direct answer.",
"issue_codes": ["counterparty_value_flow_misrouted_to_company_profit"],
"inputs": ["steps/<step_id>/output.md"],
"check": {
"artifact_path_include_patterns": [
"(?i)(s01_svk_money_documents|s01_select_svk_counterparty_money|step_03_incoming_by_resolved_entity|step_04_payout_switch_by_resolved_entity|step_05_net_after_payout)"
],
"forbidden_patterns": ["(?i)(сч[её]т\\s*(90|91|99)|90[\\./](01|02|09)|91[\\./]|99\\b|company[- ]level\\s+profit)"]
}
},
"bank_counterparty_boundary_required": {
"kind": "answer_text_required_any",
"automation_level": "semi_automatic",
"description": "Money-leader answers that explicitly mention bank role must include a boundary instead of presenting a bank as an ordinary customer/supplier.",
"issue_codes": ["bank_counterparty_misclassified_as_business_partner"],
"inputs": ["steps/<step_id>/output.md"],
"check": {
"artifact_path_include_patterns": ["(?i)(s02_colloquial_money_leaders|step_03_sberbank_role_after_role_tail_overview)"],
"required_patterns_any": ["(?is)(банк|банковск|финансов|не\\s+обычн|роль|назначени[ея]|плат[её]ж)"]
}
},
"route_candidate_needs_enablement": {
"kind": "stage_review_signal",
"automation_level": "semi_automatic",

View File

@ -411,7 +411,11 @@
"severity": "P0",
"business_meaning": "Контрагентский вопрос про получили/заплатили/нетто отвечен company-level прибылью или 90/91/99 вместо контрагентского денежного потока.",
"root_layers": ["followup_action_resolution_gap", "answer_shape_mismatch", "domain_scope_leak"],
"detectors": ["counterparty_value_flow_profit_mismatch"],
"detectors": [
"counterparty_value_flow_required_surface",
"counterparty_value_flow_profit_accounts_forbidden",
"counterparty_value_flow_profit_mismatch"
],
"allowed_patch_targets": [
"llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts",
"llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts",
@ -489,7 +493,10 @@
"severity": "P1",
"business_meaning": "Банк/финансовая организация ошибочно подана как обычный клиент/поставщик без роли, назначения платежа и договорного контекста.",
"root_layers": ["business_semantic_role_gap", "domain_scope"],
"detectors": ["bank_counterparty_role_boundary_missing"],
"detectors": [
"bank_counterparty_boundary_required",
"bank_counterparty_role_boundary_missing"
],
"allowed_patch_targets": [
"llm_normalizer/backend/src/services/address_runtime/composeStage.ts",
"llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts",

View File

@ -1694,7 +1694,8 @@ function hasNomenclatureMarginRankingSignal(text) {
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized);
const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(normalized);
const hasCalculationCue = /(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue);
const hasInspectionCue = /(?:провер\p{L}*|посмотр\p{L}*|разобра\p{L}*|какую|какие|что\s+можно|можно\s+(?:провер|посчит|рассчит|понять)|check|inspect|verify|estimate)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue || hasInspectionCue);
}
function hasVatPeriodInspectionBridgeSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase();

View File

@ -33,7 +33,8 @@ function hasInventoryMarginRankingSignal(text) {
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized);
const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(normalized);
const hasCalculationCue = /(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue);
const hasInspectionCue = /(?:провер\p{L}*|посмотр\p{L}*|разобра\p{L}*|какую|какие|что\s+можно|можно\s+(?:провер|посчит|рассчит|понять)|check|inspect|verify|estimate)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue || hasInspectionCue);
}
function hasInventoryOnHandSignal(text) {
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(text);

View File

@ -5,6 +5,7 @@ exports.cloneAddressNavigationState = cloneAddressNavigationState;
exports.normalizeAddressNavigationState = normalizeAddressNavigationState;
exports.evolveAddressNavigationStateWithAssistantItem = evolveAddressNavigationStateWithAssistantItem;
const nanoid_1 = require("nanoid");
const addressTextRepair_1 = require("./addressTextRepair");
const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy");
const addressNavigation_1 = require("../types/addressNavigation");
const MAX_RESULT_SETS = 40;
@ -126,6 +127,48 @@ function readNavigationDiscoveryCounterparty(debug) {
}
return null;
}
function readDiscoveryPilot(debug) {
const entry = toObject(debug.assistant_mcp_discovery_entry_point_v1);
const bridge = toObject(entry?.bridge);
return toObject(bridge?.pilot);
}
function readCurrentDiscoveryCounterparty(debug) {
const pilot = readDiscoveryPilot(debug);
const entityResolution = toObject(pilot?.derived_entity_resolution);
const bidirectionalValueFlow = toObject(pilot?.derived_bidirectional_value_flow);
const valueFlow = toObject(pilot?.derived_value_flow);
return (toNonEmptyString(entityResolution?.resolved_entity) ??
toNonEmptyString(bidirectionalValueFlow?.counterparty) ??
toNonEmptyString(valueFlow?.counterparty) ??
(0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ??
readNavigationDiscoveryCounterparty(debug));
}
function isCounterpartyDiscoveryChain(value) {
return (value === "entity_resolution" ||
value === "value_flow" ||
value === "value_flow_comparison" ||
value === "document_evidence" ||
value === "movement_evidence" ||
value === "lifecycle");
}
function intentFromDiscoveryChain(value) {
if (value === "business_overview") {
return "business_overview";
}
if (value === "value_flow" || value === "value_flow_comparison") {
return "customer_revenue_and_payments";
}
if (value === "document_evidence") {
return "list_documents_by_counterparty";
}
if (value === "movement_evidence") {
return "bank_operations_by_counterparty";
}
if (value === "lifecycle") {
return "counterparty_activity_lifecycle";
}
return "unknown";
}
function toAddressFocusObjectType(value) {
const normalized = toNonEmptyString(value);
if (!normalized) {
@ -295,6 +338,32 @@ function normalizeFilters(value) {
}
return { ...record };
}
function isBusinessOverviewRoleCounterpartyFragment(value) {
const text = (0, addressTextRepair_1.normalizeRussianComparableText)(value);
if (!text) {
return false;
}
const hasRoleWord = /(?:^|\s)(?:клиент|поставщик|контрагент|получатель|источник|покупатель|заказчик)(?:\s|$)/u.test(text);
if (!hasRoleWord) {
return false;
}
const hasRankingWord = /(?:^|\s)(?:главн[а-яёa-z0-9_-]*|крупнейш[а-яёa-z0-9_-]*|основн[а-яёa-z0-9_-]*|топ(?:-\d+)?)(?:\s|$)/u.test(text);
const hasRoleOnlyExplanation = /(?:^|\s)(?:клиент|поставщик)(?:\s|$).{0,40}(?:^|\s)как(?:\s|$).{0,30}(?:^|\s)рол/u.test(text);
const endsWithDanglingPreposition = /(?:^|\s)(?:в|за|по|на|от|для|к)$/u.test(text);
return hasRoleOnlyExplanation || (hasRankingWord && (endsWithDanglingPreposition || text.length <= 48));
}
function shouldSuppressBusinessOverviewCounterpartyFocus(debug, counterparty) {
return (toNonEmptyString(debug.mcp_discovery_selected_chain_id) === "business_overview" &&
debug.mcp_discovery_response_applied === true &&
isBusinessOverviewRoleCounterpartyFragment(counterparty));
}
function sanitizeBusinessOverviewNavigationFilters(filters, debug) {
if (!shouldSuppressBusinessOverviewCounterpartyFocus(debug, filters.counterparty)) {
return filters;
}
const { counterparty: _counterparty, ...rest } = filters;
return rest;
}
function resolveNavigationAction(debug, hasFocusObject) {
const continuationContract = toObject(debug.dialog_continuation_contract_v2);
const decision = toNonEmptyString(continuationContract?.decision);
@ -337,8 +406,7 @@ function readBusinessOverviewComparisonProofBundles(debug) {
const entryPoint = toObject(debug.assistant_mcp_discovery_entry_point_v1);
const turnInput = toObject(entryPoint?.turn_input);
const turnMeaning = toObject(turnInput?.turn_meaning_ref);
const bridge = toObject(entryPoint?.bridge);
const pilot = toObject(bridge?.pilot);
const pilot = readDiscoveryPilot(debug);
const counterpartyValueFlowBundle = cloneRecord(turnMeaning?.previous_counterparty_value_flow_bundle) ??
cloneRecord(pilot?.derived_bidirectional_value_flow);
const counterpartyDocumentBundle = cloneRecord(turnMeaning?.previous_counterparty_document_bundle);
@ -356,12 +424,24 @@ function readBusinessOverviewComparisonProofBundles(debug) {
counterparty_document_bundle: counterpartyDocumentBundle
};
}
function readCounterpartyValueFlowProofBundles(debug) {
const pilot = readDiscoveryPilot(debug);
const counterpartyValueFlowBundle = cloneRecord(pilot?.derived_bidirectional_value_flow);
const counterparty = toNonEmptyString(counterpartyValueFlowBundle?.counterparty);
if (!counterparty || !counterpartyValueFlowBundle) {
return null;
}
return {
counterparty_value_flow_bundle: counterpartyValueFlowBundle,
counterparty_document_bundle: null
};
}
function buildFocusObjectFromDebug(debug, resultSetId, createdAt) {
const extractedFilters = toObject(debug.extracted_filters) ?? {};
const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id);
if (selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true) {
const counterparty = (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug);
if (counterparty) {
if (counterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, counterparty)) {
return buildFocusObject("counterparty", counterparty, resultSetId, createdAt);
}
const organization = (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug, toNonEmptyString);
@ -369,8 +449,8 @@ function buildFocusObjectFromDebug(debug, resultSetId, createdAt) {
return buildFocusObject("organization", organization, resultSetId, createdAt);
}
}
if (selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true) {
const counterparty = (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug);
if (isCounterpartyDiscoveryChain(selectedDiscoveryChain) && debug.mcp_discovery_response_applied === true) {
const counterparty = readCurrentDiscoveryCounterparty(debug);
if (counterparty) {
return buildFocusObject("counterparty", counterparty, resultSetId, createdAt);
}
@ -560,24 +640,23 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) {
}
const debug = item.debug;
const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id);
const discoveryIntent = selectedDiscoveryChain === "business_overview"
? "business_overview"
: selectedDiscoveryChain === "value_flow_comparison"
? "customer_revenue_and_payments"
: "unknown";
const detectedIntent = toNonEmptyString(debug.detected_intent);
const intent = toAddressIntent(detectedIntent && detectedIntent !== "unknown" ? detectedIntent : discoveryIntent);
const discoveryIntent = intentFromDiscoveryChain(selectedDiscoveryChain);
const trackableDiscoveryTurn = debug.mcp_discovery_response_applied === true && Boolean(selectedDiscoveryChain);
const shouldPreferDiscoveryIntent = trackableDiscoveryTurn &&
discoveryIntent !== "unknown" &&
selectedDiscoveryChain !== "business_overview";
const intent = toAddressIntent(shouldPreferDiscoveryIntent ? discoveryIntent : detectedIntent && detectedIntent !== "unknown" ? detectedIntent : discoveryIntent);
if (intent === "unknown" && !trackableDiscoveryTurn) {
return state;
}
const createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString();
const resultSetId = `rs-${item.message_id}`;
const routeId = toNonEmptyString(debug.selected_recipe) ?? selectedDiscoveryChain;
const filters = normalizeFilters(debug.extracted_filters);
const filters = sanitizeBusinessOverviewNavigationFilters(normalizeFilters(debug.extracted_filters), debug);
const derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text) ?? (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug, toNonEmptyString);
const derivedCounterpartyScope = selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true
? (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug)
const derivedCounterpartyScope = isCounterpartyDiscoveryChain(selectedDiscoveryChain) && debug.mcp_discovery_response_applied === true
? readCurrentDiscoveryCounterparty(debug)
: null;
const filtersWithDerivedScope = derivedOrganizationScope && !toNonEmptyString(filters.organization)
? {
@ -610,15 +689,24 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) {
const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt);
const focusObject = resolveFocusObjectForNavigation(state, intent, resultSet, debugFocusObject, primaryEntityFocusObject);
const comparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
? readNavigationDiscoveryCounterparty(debug)
const rawComparisonCounterparty = debug.mcp_discovery_response_applied === true
? selectedDiscoveryChain === "business_overview"
? readNavigationDiscoveryCounterparty(debug)
: isCounterpartyDiscoveryChain(selectedDiscoveryChain)
? readCurrentDiscoveryCounterparty(debug)
: null
: null;
const comparisonCounterparty = rawComparisonCounterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, rawComparisonCounterparty)
? rawComparisonCounterparty
: null;
const comparisonOrganization = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
? derivedOrganizationScope ?? toNonEmptyString(filtersWithDerivedScope.organization)
: null;
const currentComparisonProofBundles = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
? readBusinessOverviewComparisonProofBundles(debug)
: null;
: selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true
? readCounterpartyValueFlowProofBundles(debug)
: null;
const inheritedComparisonScope = state.session_context.comparison_scope;
const inheritedComparisonProofBundles = comparisonCounterparty &&
sameBusinessLabel(inheritedComparisonScope?.counterparty?.label, comparisonCounterparty)

View File

@ -1313,6 +1313,15 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "vat_payable_confirmed_as_of_date";
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
const currentPeriodFromForCarryover = toNonEmptyString(merged.period_from);
const currentPeriodToForCarryover = toNonEmptyString(merged.period_to);
const todayIsoForPeriodCarryover = new Date().toISOString().slice(0, 10);
const currentValueFlowPeriodDefaultsToToday = isValueCounterpartyIntent(intent) &&
hasFollowupSignal &&
!hasExplicitPeriodInMessage &&
!hasExplicitCurrentDateInMessage &&
!currentPeriodFromForCarryover &&
currentPeriodToForCarryover === todayIsoForPeriodCarryover;
const currentCounterpartyExplicit = toNonEmptyString(merged.counterparty);
const currentContractExplicit = toNonEmptyString(merged.contract);
const currentItemExplicit = toNonEmptyString(merged.item);
@ -1387,7 +1396,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
reasons.push("period_from_followup_context");
}
if (!currentHasPeriod &&
if ((!currentHasPeriod || currentValueFlowPeriodDefaultsToToday) &&
previousHasPeriod &&
hasFollowupSignal &&
!hasExplicitPeriodInMessage &&

View File

@ -411,11 +411,25 @@ function isReferentialCounterpartyPlaceholder(value) {
}
function normalizeCounterpartyCandidate(value, toNonEmptyString) {
const text = toNonEmptyString(value);
if (!text || isReferentialCounterpartyPlaceholder(text)) {
if (!text || isReferentialCounterpartyPlaceholder(text) || isCounterpartyRoleFragment(text)) {
return null;
}
return text;
}
function isCounterpartyRoleFragment(value) {
const text = (0, addressTextRepair_1.normalizeRussianComparableText)(value);
if (!text) {
return false;
}
const hasRoleWord = /(?:^|\s)(?:клиент|поставщик|контрагент|получатель|источник|покупатель|заказчик)(?:\s|$)/u.test(text);
if (!hasRoleWord) {
return false;
}
const hasRankingWord = /(?:^|\s)(?:главн[а-яёa-z0-9_-]*|крупнейш[а-яёa-z0-9_-]*|основн[а-яёa-z0-9_-]*|топ(?:-\d+)?)(?:\s|$)/u.test(text);
const hasRoleOnlyExplanation = /(?:^|\s)(?:клиент|поставщик)(?:\s|$).{0,40}(?:^|\s)как(?:\s|$).{0,30}(?:^|\s)рол/u.test(text);
const endsWithDanglingPreposition = /(?:^|\s)(?:в|за|по|на|от|для|к)$/u.test(text);
return hasRoleOnlyExplanation || (hasRankingWord && (endsWithDanglingPreposition || text.length <= 48));
}
function sameCounterpartyCandidate(left, right) {
return Boolean(left &&
right &&
@ -590,6 +604,9 @@ function resolveAddressDebugCarryoverFilters(debug, toNonEmptyString = fallbackT
const organization = readAddressDebugOrganization(debug, toNonEmptyString);
const preferGroundedDiscoveryCounterparty = hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty);
const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString);
if (!existingCounterparty) {
delete nextFilters.counterparty;
}
if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) {
nextFilters.counterparty = counterparty;
}
@ -761,7 +778,11 @@ function resolveFollowupTargetIntent(inventoryPurchaseDateVatBridge, selectedObj
}
function shouldUseNavigationTemporalCarryover(sourceIntentHint) {
const normalizedIntent = fallbackToNonEmptyString(sourceIntentHint);
return (normalizedIntent === "inventory_on_hand_as_of_date" ||
return (normalizedIntent === "customer_revenue_and_payments" ||
normalizedIntent === "supplier_payouts_profile" ||
normalizedIntent === "contract_usage_and_value" ||
normalizedIntent === "counterparty_activity_lifecycle" ||
normalizedIntent === "inventory_on_hand_as_of_date" ||
normalizedIntent === "inventory_supplier_stock_overlap_as_of_date" ||
normalizedIntent === "inventory_purchase_provenance_for_item" ||
normalizedIntent === "inventory_purchase_documents_for_item" ||

View File

@ -212,7 +212,7 @@ function valueFlowDirectionLabelRu(pilot) {
? "исходящих платежей/списаний"
: "входящих денежных поступлений";
}
function valueFlowZeroResultConfirmedLine(pilot) {
function valueFlowZeroResultInsufficiencyLine(pilot) {
if (!hasExecutedZeroValueFlowRows(pilot)) {
return null;
}
@ -224,7 +224,22 @@ function valueFlowZeroResultConfirmedLine(pilot) {
const period = explicitDateScope(pilot);
const organizationPart = organization ? ` по организации ${organization}` : "";
const periodPart = period ? ` за период ${period}` : " в проверенном окне";
return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart}: 0 руб.; ${valueFlowDirectionLabelRu(pilot)} не найдено.`;
const direction = valueFlowDirectionLabelRu(pilot);
return `Точную сумму ${direction} по контрагенту ${counterparty}${organizationPart}${periodPart} не подтверждаю: в выполненной проверке 1С строки ${direction} не найдены; это не доказывает отсутствие операций вне доступного банковского контура.`;
}
function valueFlowZeroResultCheckedLine(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)) {
@ -239,11 +254,11 @@ function valueFlowZeroResultUnknownLine(pilot) {
return `Это не доказывает отсутствие операций с контрагентом ${counterparty}${periodPart} или вне доступного банковского контура.`;
}
function valueFlowZeroResultHeadline(pilot) {
const confirmedLine = valueFlowZeroResultConfirmedLine(pilot);
if (!confirmedLine) {
const insufficiencyLine = valueFlowZeroResultInsufficiencyLine(pilot);
if (!insufficiencyLine) {
return null;
}
return confirmedLine;
return insufficiencyLine;
}
function hasAllTimeScope(pilot) {
return (dryRunHasAxis(pilot, "all_time_scope") ||
@ -938,6 +953,12 @@ function nextStepFor(mode, pilot) {
}
return "Следующий шаг - связать inventory route-template с exact inventory runtime и затем проверить live-прогоном.";
}
if (mode === "checked_sources_only" && hasExecutedZeroValueFlowRows(pilot)) {
const flowLabel = pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1"
? "исходящие платежи/списания"
: "входящие денежные поступления";
return `Можно следующим шагом проверить ${flowLabel} по этому контрагенту в соседних периодах, по договорам или документам, либо собрать двусторонний денежный поток, чтобы отделить отсутствие строк в текущем срезе от операций вне проверенного окна.`;
}
if (mode === "confirmed_with_bounded_inference" && isBusinessOverviewPilot(pilot)) {
return pilot.derived_business_overview
? businessOverviewNextStepLine(pilot.derived_business_overview)
@ -1840,20 +1861,20 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
? [derivedValueLine]
: derivedValueLine
? [derivedValueLine, ...monthlyConfirmedLines]
: valueFlowZeroResultConfirmedLine(pilot)
? [valueFlowZeroResultConfirmedLine(pilot)]
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
? [derivedMetadataLine]
: pilot.evidence.confirmed_facts;
: 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)
: ["Детальный список полей этих объектов этим шагом не получен."]
: appendValueFlowZeroResultUnknown(rankedValueFlowUnknownLines(pilot), pilot);
: appendValueFlowZeroResultUnknown(valueFlowZeroResultCheckedLine(pilot)
? [valueFlowZeroResultCheckedLine(pilot), ...rankedValueFlowUnknownLines(pilot)]
: rankedValueFlowUnknownLines(pilot), pilot);
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryAnswerAdapter",

View File

@ -2,6 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION = void 0;
exports.buildAssistantMcpDiscoveryDataNeedGraph = buildAssistantMcpDiscoveryDataNeedGraph;
const addressTextRepair_1 = require("./addressTextRepair");
exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION = "assistant_data_need_graph_v1";
function toNonEmptyString(value) {
if (value === null || value === undefined) {
@ -203,6 +204,28 @@ function hasDetailedBusinessOverviewMoneyBreakdownHint(rawUtterance) {
}
return /(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*|\u043f\u043e\u0434\u0440\u043e\u0431\p{L}*|\u0440\u0430\u0437\u0431\u0435\p{L}*|\u0440\u0430\u0437\u0432\u0435\u0440\u043d\p{L}*|\u043a\u0442\u043e\s+\u0433\u043b\u0430\u0432\p{L}*|\u0433\u043b\u0430\u0432\p{L}*\s+\u043a\u043b\u0438\u0435\u043d\p{L}*|\u0433\u043b\u0430\u0432\p{L}*\s+\u043f\u043e\u0441\u0442\u0430\u0432\p{L}*|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\p{L}*\s+\u043f\u043e\u0442\u043e\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0432\u0445\u043e\u0434\p{L}*[\s\S]{0,80}\u0438\u0441\u0445\u043e\u0434\p{L}*|detail|breakdown|drill\s*down|main\s+customer|main\s+supplier)/iu.test(rawUtterance);
}
function hasWideBusinessOverviewSurfaceHint(rawUtterance) {
if (!rawUtterance) {
return false;
}
const textVariants = Array.from(new Set([rawUtterance, (0, addressTextRepair_1.normalizeRussianComparableText)(rawUtterance)]));
if (textVariants.some((text) => /(?:\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0434\u0435\u043d\p{L}*)/iu.test(text))) {
return false;
}
const hasBroadCue = textVariants.some((text) => /(?:\u0431\u0438\u0437\u043d\u0435\u0441[-\s]*\u043e\u0431\u0437\u043e\u0440|\u043e\u0431\u0437\u043e\u0440\s+\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\u0437\u0440\u043e\u0441\u043b\p{L}*\s+\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\p{L}*\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0446\u0435\u043d\p{L}*\s+\u0431\u0438\u0437\u043d\u0435\u0441|business\s+overview|business\s+review|company\s+overview)/iu.test(text));
if (!hasBroadCue) {
return false;
}
const surfaceMarkers = [
/(?:\u043d\u0434\u0441|vat|\u043d\u0430\u043b\u043e\u0433\p{L}*)/iu,
/(?:\u0434\u043e\u043b\u0433\p{L}*|\u0437\u0430\u0434\u043e\u043b\u0436\p{L}*|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440)/iu,
/(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442\u043a|\u0437\u0430\u043f\u0430\u0441|\u0442\u043e\u0432\u0430\u0440\p{L}*)/iu,
/(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b)/iu,
/(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b)/iu,
/(?:\u0447\u0442\u043e\s+(?:\u043f\u043e\u043a\u0430\s+)?\u043d\u0435\u043b\u044c\u0437\u044f\s+\u0443\u0442\u0432\u0435\u0440\u0436\u0434|\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d|\u0433\u0440\u0430\u043d\u0438\u0446\p{L}*)/iu
];
return textVariants.some((text) => surfaceMarkers.filter((marker) => marker.test(text)).length >= 3);
}
function supportsOrganizationScopedOpenTotal(action) {
return action === "turnover" || action === "payout";
}
@ -421,7 +444,8 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
const rawAction = lower(turnMeaning?.asked_action_family);
const unsupported = lower(turnMeaning?.unsupported_but_understood_family);
const rawUtterance = lower(input.rawUtterance);
const rawQuestionSignal = lower([input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" "));
const rawQuestionSource = [input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" ");
const rawQuestionSignal = lower(rawQuestionSource);
const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis);
const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need);
const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope);
@ -439,10 +463,13 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
});
const oneSidedValueAction = inferredOpenScopeOneSidedValueAction(rawQuestionSignal);
const detailedBusinessOverviewMoneyBreakdownHint = hasDetailedBusinessOverviewMoneyBreakdownHint(rawQuestionSignal);
const wideBusinessOverviewSurfaceHint = hasWideBusinessOverviewSurfaceHint(rawQuestionSource);
const oneSidedBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" &&
Boolean(oneSidedValueAction) &&
!detailedBusinessOverviewMoneyBreakdownHint;
const bidirectionalBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" && hasIncomingVsOutgoingComparisonHint(rawQuestionSignal);
const bidirectionalBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" &&
hasIncomingVsOutgoingComparisonHint(rawQuestionSignal) &&
!wideBusinessOverviewSurfaceHint;
const oneSidedValueFlowActionOverride = initialBusinessFactFamily === "value_flow" &&
Boolean(oneSidedValueAction) &&
(rawAction === "counterparty_value_or_turnover" || rawAction === "counterparty_value");
@ -574,6 +601,9 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
if (businessFactFamily === "business_overview" && !explicitDateScope) {
pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope");
}
if (initialBusinessFactFamily === "business_overview" && wideBusinessOverviewSurfaceHint) {
pushReason(reasonCodes, "data_need_graph_business_overview_wide_surface_preserved");
}
if (directBusinessOverviewMoneyAnswerHint) {
pushReason(reasonCodes, "data_need_graph_business_overview_direct_money_answer");
}

View File

@ -2209,9 +2209,28 @@ function deriveBusinessOverviewContractUsageProfile(result, periodScope) {
};
}
function deriveValueFlow(result, counterparty, periodScope, direction, aggregationAxis) {
if (!result || result.error || result.matched_rows <= 0) {
if (!result || result.error) {
return null;
}
if (result.matched_rows <= 0) {
return {
value_flow_direction: direction,
counterparty,
period_scope: periodScope,
aggregation_axis: aggregationAxis,
rows_matched: 0,
rows_with_amount: 0,
total_amount: 0,
total_amount_human_ru: formatAmountHumanRu(0),
first_movement_date: null,
latest_movement_date: null,
coverage_limited_by_probe_limit: result.coverage_limited_by_probe_limit,
coverage_recovered_by_period_chunking: result.coverage_recovered_by_period_chunking,
period_chunking_granularity: result.period_chunking_granularity,
monthly_breakdown: [],
inference_basis: "sum_of_confirmed_1c_value_flow_rows"
};
}
let totalAmount = 0;
let rowsWithAmount = 0;
for (const row of result.rows) {
@ -3926,16 +3945,30 @@ function buildMovementConfirmedFacts(result, counterparty, periodScope) {
return [`В 1С найдены строки движений${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)}.`];
}
function buildValueFlowConfirmedFacts(result, counterparty, direction) {
if (result.error || result.matched_rows <= 0) {
if (result.error) {
return [];
}
if (direction === "outgoing_supplier_payout") {
if (result.matched_rows <= 0) {
return [
counterparty
? `1C supplier-payout rows were checked for counterparty ${counterparty}: not_found`
: "1C supplier-payout rows were checked for the requested counterparty scope: not_found"
];
}
return [
counterparty
? `1C supplier-payout rows were found for counterparty ${counterparty}`
: "1C supplier-payout rows were found for the requested counterparty scope"
];
}
if (result.matched_rows <= 0) {
return [
counterparty
? `1C value-flow rows were checked for counterparty ${counterparty}: not_found`
: "1C value-flow rows were checked for the requested counterparty scope: not_found"
];
}
return [
counterparty
? `1C value-flow rows were found for counterparty ${counterparty}`
@ -4115,6 +4148,12 @@ function buildValueFlowInferredFacts(derived) {
return [];
}
const facts = [];
if (derived.rows_matched <= 0) {
facts.push(derived.value_flow_direction === "outgoing_supplier_payout"
? "Counterparty supplier-payout not_found result was treated as zero only inside the scoped checked 1C result set"
: "Counterparty incoming value-flow not_found result was treated as zero only inside the scoped checked 1C result set");
return facts;
}
if (derived.value_flow_direction === "outgoing_supplier_payout") {
facts.push("Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows");
}

View File

@ -261,13 +261,14 @@ function resolveAssistantMcpDiscoveryEvidence(input) {
const rowsMatched = probeRowsMatched(probeResults);
const rowsReceived = probeRowsReceived(probeResults);
const bypassDetected = hasProbeBypass(input.plan, probeResults);
const confirmedNoMatchFact = confirmedFacts.some((fact) => /\bnot_found\b/iu.test(String(fact ?? "")));
if (bypassDetected) {
pushReason(reasonCodes, "probe_result_used_primitive_outside_runtime_plan");
}
if (input.plan.plan_status !== "allowed") {
pushReason(reasonCodes, "plan_not_allowed_by_runtime");
}
if (confirmedFacts.length > 0 && rowsMatched <= 0) {
if (confirmedFacts.length > 0 && rowsMatched <= 0 && !confirmedNoMatchFact) {
pushReason(reasonCodes, "confirmed_facts_without_matched_probe_rows");
}
if (!sourceRowsSummary && rowsReceived > 0) {
@ -287,6 +288,12 @@ function resolveAssistantMcpDiscoveryEvidence(input) {
answerPermission = "confirmed_answer";
pushReason(reasonCodes, "confirmed_facts_with_allowed_mcp_evidence");
}
else if (confirmedFacts.length > 0 && confirmedNoMatchFact && rowsMatched <= 0 && sourceRowsSummary) {
evidenceStatus = "confirmed";
coverageStatus = "full";
answerPermission = "confirmed_answer";
pushReason(reasonCodes, "confirmed_no_match_fact_with_allowed_mcp_evidence");
}
else if (inferredFacts.length > 0 && rowsReceived > 0) {
evidenceStatus = "inferred_only";
coverageStatus = "partial";

View File

@ -211,9 +211,15 @@ function localizeLine(value) {
if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки входящих денежных поступлений в запрошенном срезе.";
}
if (/^1C value-flow rows were checked for the requested counterparty scope: not_found$/i.test(value)) {
return "В 1С проверены входящие денежные строки в запрошенном срезе: строки не найдены.";
}
if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки исходящих платежей и списаний в запрошенном срезе.";
}
if (/^1C supplier-payout rows were checked for the requested counterparty scope: not_found$/i.test(value)) {
return "В 1С проверены исходящие платежи/списания в запрошенном срезе: строки не найдены.";
}
const openScopeBidirectionalMatch = value.match(/^1C bidirectional value-flow rows were checked for the requested counterparty scope: incoming=(found|not_found), outgoing=(found|not_found)$/i);
if (openScopeBidirectionalMatch) {
const incoming = openScopeBidirectionalMatch[1] === "found"
@ -238,6 +244,10 @@ function localizeLine(value) {
if (valueFlowMatch) {
return `В 1С найдены строки входящих денежных поступлений по контрагенту ${valueFlowMatch[1]}.`;
}
const valueFlowNotFoundMatch = value.match(/^1C value-flow rows were checked for counterparty\s+(.+): not_found$/i);
if (valueFlowNotFoundMatch) {
return `В 1С проверены входящие денежные строки по контрагенту ${valueFlowNotFoundMatch[1]}: строки не найдены.`;
}
if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки входящих денежных поступлений по запрошенному контрагентскому контуру.";
}
@ -259,6 +269,10 @@ function localizeLine(value) {
if (supplierPayoutMatch) {
return `В 1С найдены строки исходящих платежей/списаний по контрагенту ${supplierPayoutMatch[1]}.`;
}
const supplierPayoutNotFoundMatch = value.match(/^1C supplier-payout rows were checked for counterparty\s+(.+): not_found$/i);
if (supplierPayoutNotFoundMatch) {
return `В 1С проверены исходящие платежи/списания по контрагенту ${supplierPayoutNotFoundMatch[1]}: строки не найдены.`;
}
if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки исходящих платежей/списаний по запрошенному контрагентскому контуру.";
}
@ -292,6 +306,12 @@ function localizeLine(value) {
if (/^Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows$/i.test(value)) {
return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С.";
}
if (/^Counterparty supplier-payout not_found result was treated as zero only inside the scoped checked 1C result set$/i.test(value)) {
return "Нулевую сумму исходящих платежей можно трактовать только внутри этого проверенного среза 1С, где строки списаний не найдены.";
}
if (/^Counterparty incoming value-flow not_found result was treated as zero only inside the scoped checked 1C result set$/i.test(value)) {
return "Нулевую сумму входящих поступлений можно трактовать только внутри этого проверенного среза 1С, где строки поступлений не найдены.";
}
if (/^Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows$/i.test(value)) {
return "Нетто денежного потока рассчитано как подтвержденные входящие платежи минус подтвержденные исходящие платежи в 1С.";
}
@ -703,6 +723,7 @@ function businessOverviewOrganizationScopeLabel(value) {
}
const comparable = compactComparable(text);
if (/^(?:с|без)\s+разбивк/.test(comparable) ||
/(?:без\s+(?:предположен\p{L}*|допущен\p{L}*|гадан\p{L}*|фантази\p{L}*|догад\p{L}*)|without\s+(?:guesses|assumptions|guessing))/iu.test(comparable) ||
/\b(?:входящ|исходящ|нетто|топ|контрагент|платеж|поступлен)\b/.test(comparable)) {
return null;
}
@ -1158,7 +1179,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
outgoingAmount ? `исходящие ${outgoingAmount}` : null,
netAmount ? `${netDirection} ${sentenceAmount(netAmount) ?? netAmount}` : null
].filter((value) => Boolean(value));
lines.push(`По подтвержденным строкам 1С ${subject} за ${periodWithoutPrefix}: ${executiveFacts.length > 0 ? executiveFacts.join(", ") : "денежные метрики не подтверждены"}; это ограниченный проверенный срез, не аудиторское заключение и не подтвержденная чистая прибыль.`);
lines.push(`По подтвержденным строкам 1С ${subject} за ${periodWithoutPrefix}: ${executiveFacts.length > 0 ? executiveFacts.join(", ") : "денежные метрики не подтверждены"}; это ограниченный проверенный срез, а не финансовый аудит и не подтверждение чистой прибыли.`);
lines.push("Интерпретация: по этому срезу видны крупные контрактные денежные потоки и заметная зависимость от нескольких крупных контрагентов, а не равномерный поток мелких продаж.");
lines.push("Что видно в ограниченном денежном срезе:");
if (incomingAmount) {
@ -1193,7 +1214,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
lines.push(`- ${localizeLine(debtLine)}`);
}
if (!limitLine) {
lines.push("Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль.");
lines.push("Ограничение: это оценка по денежным потокам и найденным срезам 1С, а не финансовый аудит и не подтверждение чистой прибыли.");
}
const missingOverviewFamilies = [];
if (!taxLine) {

View File

@ -364,6 +364,51 @@ function hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarificati
const boundedMovementAnswerAllowed = bridge?.user_facing_response_allowed === true && bridge?.business_fact_answer_allowed === true;
return asksForMovements && staleInventoryItemClarification && mcpExecutionPerformed && boundedMovementAnswerAllowed;
}
function hasBusinessOverviewBoundaryCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate) {
if (!hasCurrentClarificationRequiredAddressReply(input)) {
return false;
}
if (candidate.candidate_status !== "clarification_candidate" && candidate.candidate_status !== "ready_for_guarded_use") {
return false;
}
if (!candidate.eligible_for_future_hot_runtime || !toNonEmptyString(candidate.reply_text)) {
return false;
}
if (candidate.reply_text && hasInternalMechanics(candidate.reply_text)) {
return false;
}
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
const graph = readDiscoveryDataNeedGraph(entryPoint);
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const selectedChainId = readDiscoverySelectedChainId(entryPoint);
const discoveryFamily = toNonEmptyString(graph?.business_fact_family);
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
const separateCounterpartyCandidates = readStringArray(turnMeaning?.business_overview_separate_entity_candidates);
const hasPreviousCounterpartyBundle = Boolean(toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle) ||
toRecordObject(turnMeaning?.previous_counterparty_document_bundle));
const asksForBoundarySummary = Boolean((selectedChainId === "business_overview" ||
discoveryFamily === "business_overview" ||
askedDomain === "business_overview") &&
(separateCounterpartyCandidates.length > 0 || hasPreviousCounterpartyBundle));
const truthAnswerShape = readTruthAnswerShape(input);
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const capabilityId = toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id) ??
toNonEmptyString(truthAnswerShape?.capability_contract_id);
const missingFilters = [
...readStringArray(input.addressRuntimeMeta?.missing_required_filters),
...readStringArray(input.addressRuntimeMeta?.missing_anchors),
...readStringArray(toRecordObject(input.addressRuntimeMeta?.capability_binding_contract)?.missing_anchors)
];
const staleInventoryItemClarification = Boolean(missingFilters.includes("item") &&
(detectedIntent?.startsWith("inventory_") ||
selectedRecipe?.includes("inventory_") ||
capabilityId?.includes("inventory_")));
return asksForBoundarySummary && staleInventoryItemClarification;
}
function hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, entryPoint, candidate) {
if (!hasCurrentClarificationRequiredAddressReply(input)) {
return false;
@ -570,6 +615,53 @@ function hasExactBankOperationsAddressReply(input, entryPoint) {
routeMode === "exact" ||
hasFullConfirmedTruth(input));
}
function extractExplicitDateScopeYear(value) {
const source = toNonEmptyString(value);
if (!source) {
return null;
}
const match = source.match(/\b((?:19|20)\d{2})\b/u);
return match ? match[1] : null;
}
function extractDocumentReplyDateYears(value) {
const source = String(value ?? "");
if (!source) {
return [];
}
const years = new Set();
for (const match of source.matchAll(/\b((?:19|20)\d{2})-\d{2}-\d{2}(?:T|\b)/gu)) {
years.add(match[1]);
}
for (const match of source.matchAll(/\b\d{1,2}\.\d{1,2}\.((?:19|20)\d{2})\b/gu)) {
years.add(match[1]);
}
return [...years];
}
function hasDocumentListTemporalConflictWithDiscovery(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const isDocumentIntent = detectedIntent === "list_documents_by_counterparty" || detectedIntent === "list_documents_by_contract";
const isDocumentRecipe = selectedRecipe === "address_documents_by_counterparty_v1" ||
selectedRecipe === "address_documents_by_contract_v1";
if (!isDocumentIntent || !isDocumentRecipe) {
return false;
}
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const expectedYear = extractExplicitDateScopeYear(turnMeaning?.explicit_date_scope);
if (!expectedYear) {
return false;
}
const filters = toRecordObject(input.addressRuntimeMeta?.extracted_filters);
const periodFrom = toNonEmptyString(filters?.period_from);
const periodTo = toNonEmptyString(filters?.period_to);
const hasMatchingPeriod = Boolean(periodFrom?.startsWith(`${expectedYear}-`)) && Boolean(periodTo?.startsWith(`${expectedYear}-`));
const replyDateYears = extractDocumentReplyDateYears(input.currentReply);
const hasOutOfScopeReplyDates = replyDateYears.some((year) => year !== expectedYear);
return hasOutOfScopeReplyDates || (!hasMatchingPeriod && replyDateYears.length > 0);
}
function hasExactDocumentListAddressReply(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
@ -923,7 +1015,8 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
const staleMetadataDiscoveryFallbackAgainstExactAddressReply = hasStaleMetadataDiscoveryFallbackAgainstExactAddressReply(input, entryPoint);
const exactValueFlowReplyForBusinessOverviewDirectMoneyNeed = hasExactValueFlowReplyForBusinessOverviewDirectMoneyNeed(input, entryPoint);
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint);
const documentListTemporalConflictWithDiscovery = hasDocumentListTemporalConflictWithDiscovery(input, entryPoint);
const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint) && !documentListTemporalConflictWithDiscovery;
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply(input, entryPoint);
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
@ -934,16 +1027,20 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
const discoveryClarificationCandidatePriority = hasDiscoveryClarificationCandidatePriorityOverCurrentClarification(input, entryPoint, candidate);
const groundedValueFlowCandidatePriority = hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, entryPoint, candidate);
const groundedMovementCandidatePriority = hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate);
const businessOverviewBoundaryCandidatePriority = hasBusinessOverviewBoundaryCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate);
const groundedValueFlowRankingCandidatePriority = hasGroundedValueFlowRankingCandidatePriorityOverCurrentExactReply(input, entryPoint, candidate);
const currentClarificationProtectsCurrentReply = currentClarificationRequiredAddressReply &&
!discoveryClarificationCandidatePriority &&
!groundedValueFlowCandidatePriority &&
!groundedMovementCandidatePriority &&
!businessOverviewBoundaryCandidatePriority &&
!groundedValueFlowRankingCandidatePriority;
const alignedFactualAddressReplyProtectsCurrent = alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority;
const matchedFactualAddressContinuationTargetProtectsCurrent = matchedFactualAddressContinuationTarget && !groundedValueFlowRankingCandidatePriority;
const fullConfirmedFactualAddressReplyProtectsCurrent = fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority;
const exactMatchedFactualAddressReplyProtectsCurrent = exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority;
const alignedFactualAddressReplyProtectsCurrent = alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery;
const matchedFactualAddressContinuationTargetProtectsCurrent = matchedFactualAddressContinuationTarget &&
!groundedValueFlowRankingCandidatePriority &&
!documentListTemporalConflictWithDiscovery;
const fullConfirmedFactualAddressReplyProtectsCurrent = fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery;
const exactMatchedFactualAddressReplyProtectsCurrent = exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery;
const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply &&
!semanticConflictWithDiscoveryTurnMeaning &&
!valueFlowActionConflictWithDiscoveryTurnMeaning;
@ -977,6 +1074,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
if (evidenceLaneConflictWithDiscoveryTurnMeaning) {
pushReason(reasonCodes, "mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override");
}
if (documentListTemporalConflictWithDiscovery) {
pushReason(reasonCodes, "mcp_discovery_response_policy_document_list_temporal_conflict_allows_candidate_override");
}
if (currentClarificationProtectsCurrentReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_current_clarification_required_reply");
}
@ -989,6 +1089,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
if (groundedMovementCandidatePriority) {
pushReason(reasonCodes, "mcp_discovery_response_policy_grounded_movement_candidate_priority_over_inventory_item_clarification");
}
if (businessOverviewBoundaryCandidatePriority) {
pushReason(reasonCodes, "mcp_discovery_response_policy_business_overview_boundary_candidate_priority_over_inventory_item_clarification");
}
if (groundedValueFlowRankingCandidatePriority) {
pushReason(reasonCodes, "mcp_discovery_response_policy_grounded_value_flow_ranking_candidate_priority_over_current_exact_reply");
}

View File

@ -79,6 +79,10 @@ function isReferentialOrganizationPlaceholder(value) {
if (isNegativeCounterpartyScopePlaceholder(value)) {
return true;
}
const text = compactLower((0, addressTextRepair_1.repairAddressMojibakeText)(value));
if (/(?:без\s+(?:предположен\p{L}*|допущен\p{L}*|гадан\p{L}*|фантази\p{L}*|догад\p{L}*)|without\s+(?:guesses|assumptions|guessing))/iu.test(text)) {
return true;
}
return new Set([
"эта организация",
"этой организации",
@ -882,6 +886,10 @@ function hasBidirectionalValueFlowSignal(text) {
if (/(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0431\u0430\u043b\u0430\u043d\u0441\s+(?:\u043f\u043b\u0430\u0442|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436)|\u0432\u0437\u0430\u0438\u043c\u043e\u0440\u0430\u0441\u0447[\u0435\u0451]\u0442|\u043f\u043e\u043b\u0443\u0447\p{L}*.*(?:\u0437\u0430)?\u043f\u043b\u0430\u0442\p{L}*|(?:\u0437\u0430)?\u043f\u043b\u0430\u0442\p{L}*.*\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\u044f\u0449.*\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449.*\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u0440\u0438\u0448\p{L}*.*\u0443\u0448\p{L}*|\u0443\u0448\p{L}*.*\u043f\u0440\u0438\u0448\p{L}*|\u043f\u043b\u044e\u0441.*\u043c\u0438\u043d\u0443\u0441|\u043c\u0438\u043d\u0443\u0441.*\u043f\u043b\u044e\u0441|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(text)) {
return true;
}
if (/(?:\u043e\u0431\u0449\p{L}*[\s\S]{0,60}\u0434\u0435\u043d\p{L}*[\s\S]{0,60}\u0441\u043c\u044b\u0441\u043b|\u0434\u0435\u043d\p{L}*[\s\S]{0,40}\u0441\u043c\u044b\u0441\u043b|overall[\s\S]{0,40}(?:cash|money)[\s\S]{0,40}(?:meaning|sense)|money[\s\S]{0,40}(?:meaning|sense))/iu.test(text) &&
hasPayoutSignal(text)) {
return true;
}
return /(?:нетто|сальдо|баланс\s+(?:плат|денег|денеж)|взаиморасч[её]т|получил[иа]?.*(?:за)?платил|(?:за)?платил[иа]?.*получил|входящ.*исходящ|исходящ.*входящ|дебет.*кредит|кредит.*дебет|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(text);
}
function hasValueRankingSignal(text) {
@ -921,7 +929,7 @@ function normalizeLooseOrganizationAlias(value) {
if (!text) {
return null;
}
if (isNegativeCounterpartyScopePlaceholder(text)) {
if (isReferentialOrganizationPlaceholder(text)) {
return null;
}
const normalized = text

View File

@ -184,7 +184,10 @@ function detectScopedCounterpartyEntity(text) {
if (!rawEntity) {
continue;
}
const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim();
const entity = rawEntity
.replace(/^["'«»]+|["'«»]+$/gu, "")
.replace(/^(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a(?:\u0443|\u0430|\u043e\u043c|\u0435)?|\u043a\u043b\u0438\u0435\u043d\u0442(?:\u0443|\u0430|\u043e\u043c|\u0435)?|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442(?:\u0443|\u0430|\u043e\u043c|\u0435)?)\s+/iu, "")
.trim();
if (entity.length >= 2 && !ignored.has(entity)) {
return entity;
}
@ -195,7 +198,8 @@ function detectCounterpartyBidirectionalValueFlowFamily(text) {
const hasNetCue = /(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|net\s+(?:flow|cash|payment)|cash\s+net)/iu.test(text);
const hasIncomingCue = /(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text);
const hasOutgoingCue = /(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text);
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) {
const hasOverallMoneyMeaningCue = /(?:\u043e\u0431\u0449\p{L}*[\s\S]{0,60}\u0434\u0435\u043d\p{L}*[\s\S]{0,60}\u0441\u043c\u044b\u0441\u043b|\u0434\u0435\u043d\p{L}*[\s\S]{0,40}\u0441\u043c\u044b\u0441\u043b|overall[\s\S]{0,40}(?:cash|money)[\s\S]{0,40}(?:meaning|sense)|money[\s\S]{0,40}(?:meaning|sense))/iu.test(text);
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue) || (hasOverallMoneyMeaningCue && hasOutgoingCue))) {
return null;
}
const entity = detectScopedCounterpartyEntity(text);
@ -410,7 +414,9 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
: detectBroadBusinessEvaluation(joinedText);
const explicitIntentCandidate = broadBusinessEvaluation?.family
? null
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
: counterpartyBidirectionalValueFlow?.family
? null
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
const unsupportedFamily = broadBusinessEvaluation?.family
? broadBusinessEvaluation.family
: !explicitIntentCandidate && counterpartyBidirectionalValueFlow?.family

View File

@ -2200,7 +2200,11 @@ function hasNomenclatureMarginRankingSignal(text: string): boolean {
/(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue);
const hasInspectionCue =
/(?:провер\p{L}*|посмотр\p{L}*|разобра\p{L}*|какую|какие|что\s+можно|можно\s+(?:провер|посчит|рассчит|понять)|check|inspect|verify|estimate)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue || hasInspectionCue);
}
function hasVatPeriodInspectionBridgeSignal(text: string): boolean {

View File

@ -57,7 +57,11 @@ function hasInventoryMarginRankingSignal(text: string): boolean {
/(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue);
const hasInspectionCue =
/(?:провер\p{L}*|посмотр\p{L}*|разобра\p{L}*|какую|какие|что\s+можно|можно\s+(?:провер|посчит|рассчит|понять)|check|inspect|verify|estimate)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue || hasInspectionCue);
}
function hasInventoryOnHandSignal(text: string): boolean {

View File

@ -1,6 +1,7 @@
import { nanoid } from "nanoid";
import type { AssistantConversationItem } from "../types/assistant";
import type { AddressIntent } from "../types/addressQuery";
import { normalizeRussianComparableText } from "./addressTextRepair";
import {
readAddressDebugCounterparty,
readAddressDebugItem,
@ -150,6 +151,56 @@ function readNavigationDiscoveryCounterparty(debug: Record<string, unknown>): st
return null;
}
function readDiscoveryPilot(debug: Record<string, unknown>): Record<string, unknown> | null {
const entry = toObject(debug.assistant_mcp_discovery_entry_point_v1);
const bridge = toObject(entry?.bridge);
return toObject(bridge?.pilot);
}
function readCurrentDiscoveryCounterparty(debug: Record<string, unknown>): string | null {
const pilot = readDiscoveryPilot(debug);
const entityResolution = toObject(pilot?.derived_entity_resolution);
const bidirectionalValueFlow = toObject(pilot?.derived_bidirectional_value_flow);
const valueFlow = toObject(pilot?.derived_value_flow);
return (
toNonEmptyString(entityResolution?.resolved_entity) ??
toNonEmptyString(bidirectionalValueFlow?.counterparty) ??
toNonEmptyString(valueFlow?.counterparty) ??
readAddressDebugCounterparty(debug, toNonEmptyString) ??
readNavigationDiscoveryCounterparty(debug)
);
}
function isCounterpartyDiscoveryChain(value: string | null): boolean {
return (
value === "entity_resolution" ||
value === "value_flow" ||
value === "value_flow_comparison" ||
value === "document_evidence" ||
value === "movement_evidence" ||
value === "lifecycle"
);
}
function intentFromDiscoveryChain(value: string | null): AddressIntent {
if (value === "business_overview") {
return "business_overview";
}
if (value === "value_flow" || value === "value_flow_comparison") {
return "customer_revenue_and_payments";
}
if (value === "document_evidence") {
return "list_documents_by_counterparty";
}
if (value === "movement_evidence") {
return "bank_operations_by_counterparty";
}
if (value === "lifecycle") {
return "counterparty_activity_lifecycle";
}
return "unknown";
}
function toAddressFocusObjectType(value: unknown): AddressFocusObjectType {
const normalized = toNonEmptyString(value);
if (!normalized) {
@ -346,6 +397,43 @@ function normalizeFilters(value: unknown): Record<string, unknown> {
return { ...record };
}
function isBusinessOverviewRoleCounterpartyFragment(value: unknown): boolean {
const text = normalizeRussianComparableText(value);
if (!text) {
return false;
}
const hasRoleWord = /(?:^|\s)(?:клиент|поставщик|контрагент|получатель|источник|покупатель|заказчик)(?:\s|$)/u.test(text);
if (!hasRoleWord) {
return false;
}
const hasRankingWord = /(?:^|\s)(?:главн[а-яёa-z0-9_-]*|крупнейш[а-яёa-z0-9_-]*|основн[а-яёa-z0-9_-]*|топ(?:-\d+)?)(?:\s|$)/u.test(text);
const hasRoleOnlyExplanation = /(?:^|\s)(?:клиент|поставщик)(?:\s|$).{0,40}(?:^|\s)как(?:\s|$).{0,30}(?:^|\s)рол/u.test(text);
const endsWithDanglingPreposition = /(?:^|\s)(?:в|за|по|на|от|для|к)$/u.test(text);
return hasRoleOnlyExplanation || (hasRankingWord && (endsWithDanglingPreposition || text.length <= 48));
}
function shouldSuppressBusinessOverviewCounterpartyFocus(
debug: Record<string, unknown>,
counterparty: unknown
): boolean {
return (
toNonEmptyString(debug.mcp_discovery_selected_chain_id) === "business_overview" &&
debug.mcp_discovery_response_applied === true &&
isBusinessOverviewRoleCounterpartyFragment(counterparty)
);
}
function sanitizeBusinessOverviewNavigationFilters(
filters: Record<string, unknown>,
debug: Record<string, unknown>
): Record<string, unknown> {
if (!shouldSuppressBusinessOverviewCounterpartyFocus(debug, filters.counterparty)) {
return filters;
}
const { counterparty: _counterparty, ...rest } = filters;
return rest;
}
function resolveNavigationAction(debug: Record<string, unknown>, hasFocusObject: boolean): AddressNavigationAction {
const continuationContract = toObject(debug.dialog_continuation_contract_v2);
const decision = toNonEmptyString(continuationContract?.decision);
@ -399,8 +487,7 @@ function readBusinessOverviewComparisonProofBundles(debug: Record<string, unknow
const entryPoint = toObject(debug.assistant_mcp_discovery_entry_point_v1);
const turnInput = toObject(entryPoint?.turn_input);
const turnMeaning = toObject(turnInput?.turn_meaning_ref);
const bridge = toObject(entryPoint?.bridge);
const pilot = toObject(bridge?.pilot);
const pilot = readDiscoveryPilot(debug);
const counterpartyValueFlowBundle =
cloneRecord(turnMeaning?.previous_counterparty_value_flow_bundle) ??
cloneRecord(pilot?.derived_bidirectional_value_flow);
@ -421,12 +508,25 @@ function readBusinessOverviewComparisonProofBundles(debug: Record<string, unknow
};
}
function readCounterpartyValueFlowProofBundles(debug: Record<string, unknown>): AddressComparisonProofBundles | null {
const pilot = readDiscoveryPilot(debug);
const counterpartyValueFlowBundle = cloneRecord(pilot?.derived_bidirectional_value_flow);
const counterparty = toNonEmptyString(counterpartyValueFlowBundle?.counterparty);
if (!counterparty || !counterpartyValueFlowBundle) {
return null;
}
return {
counterparty_value_flow_bundle: counterpartyValueFlowBundle,
counterparty_document_bundle: null
};
}
function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId: string, createdAt: string): AddressFocusObject | null {
const extractedFilters = toObject(debug.extracted_filters) ?? {};
const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id);
if (selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true) {
const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug);
if (counterparty) {
if (counterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, counterparty)) {
return buildFocusObject("counterparty", counterparty, resultSetId, createdAt);
}
const organization = readAddressDebugOrganization(debug, toNonEmptyString);
@ -434,9 +534,8 @@ function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId:
return buildFocusObject("organization", organization, resultSetId, createdAt);
}
}
if (selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true) {
const counterparty =
readAddressDebugCounterparty(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug);
if (isCounterpartyDiscoveryChain(selectedDiscoveryChain) && debug.mcp_discovery_response_applied === true) {
const counterparty = readCurrentDiscoveryCounterparty(debug);
if (counterparty) {
return buildFocusObject("counterparty", counterparty, resultSetId, createdAt);
}
@ -659,27 +758,28 @@ export function evolveAddressNavigationStateWithAssistantItem(
}
const debug = item.debug as unknown as Record<string, unknown>;
const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id);
const discoveryIntent =
selectedDiscoveryChain === "business_overview"
? "business_overview"
: selectedDiscoveryChain === "value_flow_comparison"
? "customer_revenue_and_payments"
: "unknown";
const detectedIntent = toNonEmptyString(debug.detected_intent);
const intent = toAddressIntent(detectedIntent && detectedIntent !== "unknown" ? detectedIntent : discoveryIntent);
const discoveryIntent = intentFromDiscoveryChain(selectedDiscoveryChain);
const trackableDiscoveryTurn = debug.mcp_discovery_response_applied === true && Boolean(selectedDiscoveryChain);
const shouldPreferDiscoveryIntent =
trackableDiscoveryTurn &&
discoveryIntent !== "unknown" &&
selectedDiscoveryChain !== "business_overview";
const intent = toAddressIntent(
shouldPreferDiscoveryIntent ? discoveryIntent : detectedIntent && detectedIntent !== "unknown" ? detectedIntent : discoveryIntent
);
if (intent === "unknown" && !trackableDiscoveryTurn) {
return state;
}
const createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString();
const resultSetId = `rs-${item.message_id}`;
const routeId = toNonEmptyString(debug.selected_recipe) ?? selectedDiscoveryChain;
const filters = normalizeFilters(debug.extracted_filters);
const filters = sanitizeBusinessOverviewNavigationFilters(normalizeFilters(debug.extracted_filters), debug);
const derivedOrganizationScope =
resolveDerivedOrganizationScope(debug, filters, item.text) ?? readAddressDebugOrganization(debug, toNonEmptyString);
const derivedCounterpartyScope =
selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true
? readAddressDebugCounterparty(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug)
isCounterpartyDiscoveryChain(selectedDiscoveryChain) && debug.mcp_discovery_response_applied === true
? readCurrentDiscoveryCounterparty(debug)
: null;
const filtersWithDerivedScope =
derivedOrganizationScope && !toNonEmptyString(filters.organization)
@ -719,9 +819,17 @@ export function evolveAddressNavigationStateWithAssistantItem(
debugFocusObject,
primaryEntityFocusObject
);
const rawComparisonCounterparty =
debug.mcp_discovery_response_applied === true
? selectedDiscoveryChain === "business_overview"
? readNavigationDiscoveryCounterparty(debug)
: isCounterpartyDiscoveryChain(selectedDiscoveryChain)
? readCurrentDiscoveryCounterparty(debug)
: null
: null;
const comparisonCounterparty =
selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
? readNavigationDiscoveryCounterparty(debug)
rawComparisonCounterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, rawComparisonCounterparty)
? rawComparisonCounterparty
: null;
const comparisonOrganization =
selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
@ -730,6 +838,8 @@ export function evolveAddressNavigationStateWithAssistantItem(
const currentComparisonProofBundles =
selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
? readBusinessOverviewComparisonProofBundles(debug)
: selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true
? readCounterpartyValueFlowProofBundles(debug)
: null;
const inheritedComparisonScope = state.session_context.comparison_scope;
const inheritedComparisonProofBundles =

View File

@ -1629,6 +1629,16 @@ function mergeFollowupFilters(
intent === "vat_payable_confirmed_as_of_date";
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
const currentPeriodFromForCarryover = toNonEmptyString(merged.period_from);
const currentPeriodToForCarryover = toNonEmptyString(merged.period_to);
const todayIsoForPeriodCarryover = new Date().toISOString().slice(0, 10);
const currentValueFlowPeriodDefaultsToToday =
isValueCounterpartyIntent(intent) &&
hasFollowupSignal &&
!hasExplicitPeriodInMessage &&
!hasExplicitCurrentDateInMessage &&
!currentPeriodFromForCarryover &&
currentPeriodToForCarryover === todayIsoForPeriodCarryover;
const currentCounterpartyExplicit = toNonEmptyString(merged.counterparty);
const currentContractExplicit = toNonEmptyString(merged.contract);
const currentItemExplicit = toNonEmptyString(merged.item);
@ -1717,7 +1727,7 @@ function mergeFollowupFilters(
}
if (
!currentHasPeriod &&
(!currentHasPeriod || currentValueFlowPeriodDefaultsToToday) &&
previousHasPeriod &&
hasFollowupSignal &&
!hasExplicitPeriodInMessage &&

View File

@ -624,12 +624,27 @@ function normalizeCounterpartyCandidate(
toNonEmptyString: (value: unknown) => string | null
): string | null {
const text = toNonEmptyString(value);
if (!text || isReferentialCounterpartyPlaceholder(text)) {
if (!text || isReferentialCounterpartyPlaceholder(text) || isCounterpartyRoleFragment(text)) {
return null;
}
return text;
}
function isCounterpartyRoleFragment(value: unknown): boolean {
const text = normalizeRussianComparableText(value);
if (!text) {
return false;
}
const hasRoleWord = /(?:^|\s)(?:клиент|поставщик|контрагент|получатель|источник|покупатель|заказчик)(?:\s|$)/u.test(text);
if (!hasRoleWord) {
return false;
}
const hasRankingWord = /(?:^|\s)(?:главн[а-яёa-z0-9_-]*|крупнейш[а-яёa-z0-9_-]*|основн[а-яёa-z0-9_-]*|топ(?:-\d+)?)(?:\s|$)/u.test(text);
const hasRoleOnlyExplanation = /(?:^|\s)(?:клиент|поставщик)(?:\s|$).{0,40}(?:^|\s)как(?:\s|$).{0,30}(?:^|\s)рол/u.test(text);
const endsWithDanglingPreposition = /(?:^|\s)(?:в|за|по|на|от|для|к)$/u.test(text);
return hasRoleOnlyExplanation || (hasRankingWord && (endsWithDanglingPreposition || text.length <= 48));
}
function sameCounterpartyCandidate(
left: string | null,
right: string | null
@ -877,6 +892,9 @@ export function resolveAddressDebugCarryoverFilters(
const preferGroundedDiscoveryCounterparty =
hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty);
const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString);
if (!existingCounterparty) {
delete nextFilters.counterparty;
}
if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) {
nextFilters.counterparty = counterparty;
}
@ -1140,6 +1158,10 @@ export function resolveFollowupTargetIntent(
export function shouldUseNavigationTemporalCarryover(sourceIntentHint: unknown): boolean {
const normalizedIntent = fallbackToNonEmptyString(sourceIntentHint);
return (
normalizedIntent === "customer_revenue_and_payments" ||
normalizedIntent === "supplier_payouts_profile" ||
normalizedIntent === "contract_usage_and_value" ||
normalizedIntent === "counterparty_activity_lifecycle" ||
normalizedIntent === "inventory_on_hand_as_of_date" ||
normalizedIntent === "inventory_supplier_stock_overlap_as_of_date" ||
normalizedIntent === "inventory_purchase_provenance_for_item" ||

View File

@ -281,7 +281,7 @@ function valueFlowDirectionLabelRu(pilot: AssistantMcpDiscoveryPilotExecutionCon
: "входящих денежных поступлений";
}
function valueFlowZeroResultConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
function valueFlowZeroResultInsufficiencyLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
if (!hasExecutedZeroValueFlowRows(pilot)) {
return null;
}
@ -293,7 +293,23 @@ function valueFlowZeroResultConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecu
const period = explicitDateScope(pilot);
const organizationPart = organization ? ` по организации ${organization}` : "";
const periodPart = period ? ` за период ${period}` : " в проверенном окне";
return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart}: 0 руб.; ${valueFlowDirectionLabelRu(
const direction = valueFlowDirectionLabelRu(pilot);
return `Точную сумму ${direction} по контрагенту ${counterparty}${organizationPart}${periodPart} не подтверждаю: в выполненной проверке 1С строки ${direction} не найдены; это не доказывает отсутствие операций вне доступного банковского контура.`;
}
function valueFlowZeroResultCheckedLine(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
)} не найдено.`;
}
@ -312,11 +328,11 @@ function valueFlowZeroResultUnknownLine(pilot: AssistantMcpDiscoveryPilotExecuti
}
function valueFlowZeroResultHeadline(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
const confirmedLine = valueFlowZeroResultConfirmedLine(pilot);
if (!confirmedLine) {
const insufficiencyLine = valueFlowZeroResultInsufficiencyLine(pilot);
if (!insufficiencyLine) {
return null;
}
return confirmedLine;
return insufficiencyLine;
}
function hasAllTimeScope(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
@ -1097,6 +1113,13 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
}
return "Следующий шаг - связать inventory route-template с exact inventory runtime и затем проверить live-прогоном.";
}
if (mode === "checked_sources_only" && hasExecutedZeroValueFlowRows(pilot)) {
const flowLabel =
pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1"
? "исходящие платежи/списания"
: "входящие денежные поступления";
return `Можно следующим шагом проверить ${flowLabel} по этому контрагенту в соседних периодах, по договорам или документам, либо собрать двусторонний денежный поток, чтобы отделить отсутствие строк в текущем срезе от операций вне проверенного окна.`;
}
if (mode === "confirmed_with_bounded_inference" && isBusinessOverviewPilot(pilot)) {
return pilot.derived_business_overview
? businessOverviewNextStepLine(pilot.derived_business_overview)
@ -2119,8 +2142,6 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
? [derivedValueLine]
: derivedValueLine
? [derivedValueLine, ...monthlyConfirmedLines]
: valueFlowZeroResultConfirmedLine(pilot)
? [valueFlowZeroResultConfirmedLine(pilot)!]
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
@ -2132,7 +2153,12 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
? pilot.derived_metadata_surface.available_fields.length > 0
? userFacingUnknowns(pilot.evidence.unknown_facts)
: ["Детальный список полей этих объектов этим шагом не получен."]
: appendValueFlowZeroResultUnknown(rankedValueFlowUnknownLines(pilot), pilot);
: appendValueFlowZeroResultUnknown(
valueFlowZeroResultCheckedLine(pilot)
? [valueFlowZeroResultCheckedLine(pilot)!, ...rankedValueFlowUnknownLines(pilot)]
: rankedValueFlowUnknownLines(pilot),
pilot
);
return {
schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,

View File

@ -1,4 +1,5 @@
import type { AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy";
import { normalizeRussianComparableText } from "./addressTextRepair";
export const ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION =
"assistant_data_need_graph_v1" as const;
@ -307,6 +308,40 @@ function hasDetailedBusinessOverviewMoneyBreakdownHint(rawUtterance: string): bo
);
}
function hasWideBusinessOverviewSurfaceHint(rawUtterance: string): boolean {
if (!rawUtterance) {
return false;
}
const textVariants = Array.from(new Set([rawUtterance, normalizeRussianComparableText(rawUtterance)]));
if (
textVariants.some((text) =>
/(?:\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0434\u0435\u043d\p{L}*)/iu.test(
text
)
)
) {
return false;
}
const hasBroadCue =
textVariants.some((text) =>
/(?:\u0431\u0438\u0437\u043d\u0435\u0441[-\s]*\u043e\u0431\u0437\u043e\u0440|\u043e\u0431\u0437\u043e\u0440\s+\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\u0437\u0440\u043e\u0441\u043b\p{L}*\s+\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\p{L}*\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0446\u0435\u043d\p{L}*\s+\u0431\u0438\u0437\u043d\u0435\u0441|business\s+overview|business\s+review|company\s+overview)/iu.test(
text
)
);
if (!hasBroadCue) {
return false;
}
const surfaceMarkers = [
/(?:\u043d\u0434\u0441|vat|\u043d\u0430\u043b\u043e\u0433\p{L}*)/iu,
/(?:\u0434\u043e\u043b\u0433\p{L}*|\u0437\u0430\u0434\u043e\u043b\u0436\p{L}*|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440)/iu,
/(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442\u043a|\u0437\u0430\u043f\u0430\u0441|\u0442\u043e\u0432\u0430\u0440\p{L}*)/iu,
/(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b)/iu,
/(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b)/iu,
/(?:\u0447\u0442\u043e\s+(?:\u043f\u043e\u043a\u0430\s+)?\u043d\u0435\u043b\u044c\u0437\u044f\s+\u0443\u0442\u0432\u0435\u0440\u0436\u0434|\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d|\u0433\u0440\u0430\u043d\u0438\u0446\p{L}*)/iu
];
return textVariants.some((text) => surfaceMarkers.filter((marker) => marker.test(text)).length >= 3);
}
function supportsOrganizationScopedOpenTotal(action: string): boolean {
return action === "turnover" || action === "payout";
}
@ -565,7 +600,8 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
const rawAction = lower(turnMeaning?.asked_action_family);
const unsupported = lower(turnMeaning?.unsupported_but_understood_family);
const rawUtterance = lower(input.rawUtterance);
const rawQuestionSignal = lower([input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" "));
const rawQuestionSource = [input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" ");
const rawQuestionSignal = lower(rawQuestionSource);
const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis);
const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need);
const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope);
@ -583,12 +619,15 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
});
const oneSidedValueAction = inferredOpenScopeOneSidedValueAction(rawQuestionSignal);
const detailedBusinessOverviewMoneyBreakdownHint = hasDetailedBusinessOverviewMoneyBreakdownHint(rawQuestionSignal);
const wideBusinessOverviewSurfaceHint = hasWideBusinessOverviewSurfaceHint(rawQuestionSource);
const oneSidedBusinessOverviewValueFlowOverride =
initialBusinessFactFamily === "business_overview" &&
Boolean(oneSidedValueAction) &&
!detailedBusinessOverviewMoneyBreakdownHint;
const bidirectionalBusinessOverviewValueFlowOverride =
initialBusinessFactFamily === "business_overview" && hasIncomingVsOutgoingComparisonHint(rawQuestionSignal);
initialBusinessFactFamily === "business_overview" &&
hasIncomingVsOutgoingComparisonHint(rawQuestionSignal) &&
!wideBusinessOverviewSurfaceHint;
const oneSidedValueFlowActionOverride =
initialBusinessFactFamily === "value_flow" &&
Boolean(oneSidedValueAction) &&
@ -735,6 +774,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
if (businessFactFamily === "business_overview" && !explicitDateScope) {
pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope");
}
if (initialBusinessFactFamily === "business_overview" && wideBusinessOverviewSurfaceHint) {
pushReason(reasonCodes, "data_need_graph_business_overview_wide_surface_preserved");
}
if (directBusinessOverviewMoneyAnswerHint) {
pushReason(reasonCodes, "data_need_graph_business_overview_direct_money_answer");
}

View File

@ -3217,9 +3217,28 @@ function deriveValueFlow(
direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"],
aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null
): AssistantMcpDiscoveryDerivedValueFlow | null {
if (!result || result.error || result.matched_rows <= 0) {
if (!result || result.error) {
return null;
}
if (result.matched_rows <= 0) {
return {
value_flow_direction: direction,
counterparty,
period_scope: periodScope,
aggregation_axis: aggregationAxis,
rows_matched: 0,
rows_with_amount: 0,
total_amount: 0,
total_amount_human_ru: formatAmountHumanRu(0),
first_movement_date: null,
latest_movement_date: null,
coverage_limited_by_probe_limit: result.coverage_limited_by_probe_limit,
coverage_recovered_by_period_chunking: result.coverage_recovered_by_period_chunking,
period_chunking_granularity: result.period_chunking_granularity,
monthly_breakdown: [],
inference_basis: "sum_of_confirmed_1c_value_flow_rows"
};
}
let totalAmount = 0;
let rowsWithAmount = 0;
for (const row of result.rows) {
@ -5294,16 +5313,30 @@ function buildValueFlowConfirmedFacts(
counterparty: string | null,
direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"]
): string[] {
if (result.error || result.matched_rows <= 0) {
if (result.error) {
return [];
}
if (direction === "outgoing_supplier_payout") {
if (result.matched_rows <= 0) {
return [
counterparty
? `1C supplier-payout rows were checked for counterparty ${counterparty}: not_found`
: "1C supplier-payout rows were checked for the requested counterparty scope: not_found"
];
}
return [
counterparty
? `1C supplier-payout rows were found for counterparty ${counterparty}`
: "1C supplier-payout rows were found for the requested counterparty scope"
];
}
if (result.matched_rows <= 0) {
return [
counterparty
? `1C value-flow rows were checked for counterparty ${counterparty}: not_found`
: "1C value-flow rows were checked for the requested counterparty scope: not_found"
];
}
return [
counterparty
? `1C value-flow rows were found for counterparty ${counterparty}`
@ -5521,6 +5554,14 @@ function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueF
return [];
}
const facts: string[] = [];
if (derived.rows_matched <= 0) {
facts.push(
derived.value_flow_direction === "outgoing_supplier_payout"
? "Counterparty supplier-payout not_found result was treated as zero only inside the scoped checked 1C result set"
: "Counterparty incoming value-flow not_found result was treated as zero only inside the scoped checked 1C result set"
);
return facts;
}
if (derived.value_flow_direction === "outgoing_supplier_payout") {
facts.push("Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows");
} else {

View File

@ -386,6 +386,7 @@ export function resolveAssistantMcpDiscoveryEvidence(
const rowsMatched = probeRowsMatched(probeResults);
const rowsReceived = probeRowsReceived(probeResults);
const bypassDetected = hasProbeBypass(input.plan, probeResults);
const confirmedNoMatchFact = confirmedFacts.some((fact) => /\bnot_found\b/iu.test(String(fact ?? "")));
if (bypassDetected) {
pushReason(reasonCodes, "probe_result_used_primitive_outside_runtime_plan");
@ -393,7 +394,7 @@ export function resolveAssistantMcpDiscoveryEvidence(
if (input.plan.plan_status !== "allowed") {
pushReason(reasonCodes, "plan_not_allowed_by_runtime");
}
if (confirmedFacts.length > 0 && rowsMatched <= 0) {
if (confirmedFacts.length > 0 && rowsMatched <= 0 && !confirmedNoMatchFact) {
pushReason(reasonCodes, "confirmed_facts_without_matched_probe_rows");
}
if (!sourceRowsSummary && rowsReceived > 0) {
@ -413,6 +414,11 @@ export function resolveAssistantMcpDiscoveryEvidence(
coverageStatus = "full";
answerPermission = "confirmed_answer";
pushReason(reasonCodes, "confirmed_facts_with_allowed_mcp_evidence");
} else if (confirmedFacts.length > 0 && confirmedNoMatchFact && rowsMatched <= 0 && sourceRowsSummary) {
evidenceStatus = "confirmed";
coverageStatus = "full";
answerPermission = "confirmed_answer";
pushReason(reasonCodes, "confirmed_no_match_fact_with_allowed_mcp_evidence");
} else if (inferredFacts.length > 0 && rowsReceived > 0) {
evidenceStatus = "inferred_only";
coverageStatus = "partial";

View File

@ -284,9 +284,15 @@ function localizeLine(value: string): string {
if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки входящих денежных поступлений в запрошенном срезе.";
}
if (/^1C value-flow rows were checked for the requested counterparty scope: not_found$/i.test(value)) {
return "В 1С проверены входящие денежные строки в запрошенном срезе: строки не найдены.";
}
if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки исходящих платежей и списаний в запрошенном срезе.";
}
if (/^1C supplier-payout rows were checked for the requested counterparty scope: not_found$/i.test(value)) {
return "В 1С проверены исходящие платежи/списания в запрошенном срезе: строки не найдены.";
}
const openScopeBidirectionalMatch = value.match(
/^1C bidirectional value-flow rows were checked for the requested counterparty scope: incoming=(found|not_found), outgoing=(found|not_found)$/i
);
@ -319,6 +325,10 @@ function localizeLine(value: string): string {
if (valueFlowMatch) {
return `В 1С найдены строки входящих денежных поступлений по контрагенту ${valueFlowMatch[1]}.`;
}
const valueFlowNotFoundMatch = value.match(/^1C value-flow rows were checked for counterparty\s+(.+): not_found$/i);
if (valueFlowNotFoundMatch) {
return `В 1С проверены входящие денежные строки по контрагенту ${valueFlowNotFoundMatch[1]}: строки не найдены.`;
}
if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки входящих денежных поступлений по запрошенному контрагентскому контуру.";
}
@ -340,6 +350,10 @@ function localizeLine(value: string): string {
if (supplierPayoutMatch) {
return `В 1С найдены строки исходящих платежей/списаний по контрагенту ${supplierPayoutMatch[1]}.`;
}
const supplierPayoutNotFoundMatch = value.match(/^1C supplier-payout rows were checked for counterparty\s+(.+): not_found$/i);
if (supplierPayoutNotFoundMatch) {
return `В 1С проверены исходящие платежи/списания по контрагенту ${supplierPayoutNotFoundMatch[1]}: строки не найдены.`;
}
if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки исходящих платежей/списаний по запрошенному контрагентскому контуру.";
}
@ -377,6 +391,12 @@ function localizeLine(value: string): string {
if (/^Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows$/i.test(value)) {
return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С.";
}
if (/^Counterparty supplier-payout not_found result was treated as zero only inside the scoped checked 1C result set$/i.test(value)) {
return "Нулевую сумму исходящих платежей можно трактовать только внутри этого проверенного среза 1С, где строки списаний не найдены.";
}
if (/^Counterparty incoming value-flow not_found result was treated as zero only inside the scoped checked 1C result set$/i.test(value)) {
return "Нулевую сумму входящих поступлений можно трактовать только внутри этого проверенного среза 1С, где строки поступлений не найдены.";
}
if (/^Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows$/i.test(value)) {
return "Нетто денежного потока рассчитано как подтвержденные входящие платежи минус подтвержденные исходящие платежи в 1С.";
}
@ -838,6 +858,9 @@ function businessOverviewOrganizationScopeLabel(value: unknown): string | null {
const comparable = compactComparable(text);
if (
/^(?:с|без)\s+разбивк/.test(comparable) ||
/(?:без\s+(?:предположен\p{L}*|допущен\p{L}*|гадан\p{L}*|фантази\p{L}*|догад\p{L}*)|without\s+(?:guesses|assumptions|guessing))/iu.test(
comparable
) ||
/\b(?:входящ|исходящ|нетто|топ|контрагент|платеж|поступлен)\b/.test(comparable)
) {
return null;
@ -1426,7 +1449,7 @@ function buildCompactBusinessOverviewReply(
netAmount ? `${netDirection} ${sentenceAmount(netAmount) ?? netAmount}` : null
].filter((value): value is string => Boolean(value));
lines.push(
`По подтвержденным строкам 1С ${subject} за ${periodWithoutPrefix}: ${executiveFacts.length > 0 ? executiveFacts.join(", ") : "денежные метрики не подтверждены"}; это ограниченный проверенный срез, не аудиторское заключение и не подтвержденная чистая прибыль.`
`По подтвержденным строкам 1С ${subject} за ${periodWithoutPrefix}: ${executiveFacts.length > 0 ? executiveFacts.join(", ") : "денежные метрики не подтверждены"}; это ограниченный проверенный срез, а не финансовый аудит и не подтверждение чистой прибыли.`
);
lines.push(
"Интерпретация: по этому срезу видны крупные контрактные денежные потоки и заметная зависимость от нескольких крупных контрагентов, а не равномерный поток мелких продаж."
@ -1469,7 +1492,7 @@ function buildCompactBusinessOverviewReply(
}
if (!limitLine) {
lines.push(
"Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль."
"Ограничение: это оценка по денежным потокам и найденным срезам 1С, а не финансовый аудит и не подтверждение чистой прибыли."
);
}
const missingOverviewFamilies: string[] = [];

View File

@ -526,6 +526,66 @@ function hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarificati
return asksForMovements && staleInventoryItemClarification && mcpExecutionPerformed && boundedMovementAnswerAllowed;
}
function hasBusinessOverviewBoundaryCandidatePriorityOverCurrentInventoryItemClarification(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null,
candidate: AssistantMcpDiscoveryResponseCandidateContract
): boolean {
if (!hasCurrentClarificationRequiredAddressReply(input)) {
return false;
}
if (candidate.candidate_status !== "clarification_candidate" && candidate.candidate_status !== "ready_for_guarded_use") {
return false;
}
if (!candidate.eligible_for_future_hot_runtime || !toNonEmptyString(candidate.reply_text)) {
return false;
}
if (candidate.reply_text && hasInternalMechanics(candidate.reply_text)) {
return false;
}
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
const graph = readDiscoveryDataNeedGraph(entryPoint);
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const selectedChainId = readDiscoverySelectedChainId(entryPoint);
const discoveryFamily = toNonEmptyString(graph?.business_fact_family);
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
const separateCounterpartyCandidates = readStringArray(turnMeaning?.business_overview_separate_entity_candidates);
const hasPreviousCounterpartyBundle = Boolean(
toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle) ||
toRecordObject(turnMeaning?.previous_counterparty_document_bundle)
);
const asksForBoundarySummary = Boolean(
(selectedChainId === "business_overview" ||
discoveryFamily === "business_overview" ||
askedDomain === "business_overview") &&
(separateCounterpartyCandidates.length > 0 || hasPreviousCounterpartyBundle)
);
const truthAnswerShape = readTruthAnswerShape(input);
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const capabilityId =
toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id) ??
toNonEmptyString(truthAnswerShape?.capability_contract_id);
const missingFilters = [
...readStringArray(input.addressRuntimeMeta?.missing_required_filters),
...readStringArray(input.addressRuntimeMeta?.missing_anchors),
...readStringArray(toRecordObject(input.addressRuntimeMeta?.capability_binding_contract)?.missing_anchors)
];
const staleInventoryItemClarification = Boolean(
missingFilters.includes("item") &&
(detectedIntent?.startsWith("inventory_") ||
selectedRecipe?.includes("inventory_") ||
capabilityId?.includes("inventory_"))
);
return asksForBoundarySummary && staleInventoryItemClarification;
}
function hasGroundedValueFlowCandidatePriorityOverCurrentClarification(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null,
@ -795,6 +855,65 @@ function hasExactBankOperationsAddressReply(
);
}
function extractExplicitDateScopeYear(value: unknown): string | null {
const source = toNonEmptyString(value);
if (!source) {
return null;
}
const match = source.match(/\b((?:19|20)\d{2})\b/u);
return match ? match[1] : null;
}
function extractDocumentReplyDateYears(value: unknown): string[] {
const source = String(value ?? "");
if (!source) {
return [];
}
const years = new Set<string>();
for (const match of source.matchAll(/\b((?:19|20)\d{2})-\d{2}-\d{2}(?:T|\b)/gu)) {
years.add(match[1]);
}
for (const match of source.matchAll(/\b\d{1,2}\.\d{1,2}\.((?:19|20)\d{2})\b/gu)) {
years.add(match[1]);
}
return [...years];
}
function hasDocumentListTemporalConflictWithDiscovery(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
): boolean {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const isDocumentIntent =
detectedIntent === "list_documents_by_counterparty" || detectedIntent === "list_documents_by_contract";
const isDocumentRecipe =
selectedRecipe === "address_documents_by_counterparty_v1" ||
selectedRecipe === "address_documents_by_contract_v1";
if (!isDocumentIntent || !isDocumentRecipe) {
return false;
}
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const expectedYear = extractExplicitDateScopeYear(turnMeaning?.explicit_date_scope);
if (!expectedYear) {
return false;
}
const filters = toRecordObject(input.addressRuntimeMeta?.extracted_filters);
const periodFrom = toNonEmptyString(filters?.period_from);
const periodTo = toNonEmptyString(filters?.period_to);
const hasMatchingPeriod =
Boolean(periodFrom?.startsWith(`${expectedYear}-`)) && Boolean(periodTo?.startsWith(`${expectedYear}-`));
const replyDateYears = extractDocumentReplyDateYears(input.currentReply);
const hasOutOfScopeReplyDates = replyDateYears.some((year) => year !== expectedYear);
return hasOutOfScopeReplyDates || (!hasMatchingPeriod && replyDateYears.length > 0);
}
function hasExactDocumentListAddressReply(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
@ -1239,7 +1358,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
entryPoint
);
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint);
const documentListTemporalConflictWithDiscovery = hasDocumentListTemporalConflictWithDiscovery(input, entryPoint);
const exactDocumentListAddressReply =
hasExactDocumentListAddressReply(input, entryPoint) && !documentListTemporalConflictWithDiscovery;
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply(
input,
@ -1268,6 +1389,8 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
);
const groundedMovementCandidatePriority =
hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate);
const businessOverviewBoundaryCandidatePriority =
hasBusinessOverviewBoundaryCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate);
const groundedValueFlowRankingCandidatePriority =
hasGroundedValueFlowRankingCandidatePriorityOverCurrentExactReply(input, entryPoint, candidate);
const currentClarificationProtectsCurrentReply =
@ -1275,15 +1398,18 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
!discoveryClarificationCandidatePriority &&
!groundedValueFlowCandidatePriority &&
!groundedMovementCandidatePriority &&
!businessOverviewBoundaryCandidatePriority &&
!groundedValueFlowRankingCandidatePriority;
const alignedFactualAddressReplyProtectsCurrent =
alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority;
alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery;
const matchedFactualAddressContinuationTargetProtectsCurrent =
matchedFactualAddressContinuationTarget && !groundedValueFlowRankingCandidatePriority;
matchedFactualAddressContinuationTarget &&
!groundedValueFlowRankingCandidatePriority &&
!documentListTemporalConflictWithDiscovery;
const fullConfirmedFactualAddressReplyProtectsCurrent =
fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority;
fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery;
const exactMatchedFactualAddressReplyProtectsCurrent =
exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority;
exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery;
const exactBankOperationsProtectsCurrent =
exactBankOperationsAddressReply &&
!semanticConflictWithDiscoveryTurnMeaning &&
@ -1320,6 +1446,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
if (evidenceLaneConflictWithDiscoveryTurnMeaning) {
pushReason(reasonCodes, "mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override");
}
if (documentListTemporalConflictWithDiscovery) {
pushReason(reasonCodes, "mcp_discovery_response_policy_document_list_temporal_conflict_allows_candidate_override");
}
if (currentClarificationProtectsCurrentReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_current_clarification_required_reply");
}
@ -1341,6 +1470,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
"mcp_discovery_response_policy_grounded_movement_candidate_priority_over_inventory_item_clarification"
);
}
if (businessOverviewBoundaryCandidatePriority) {
pushReason(
reasonCodes,
"mcp_discovery_response_policy_business_overview_boundary_candidate_priority_over_inventory_item_clarification"
);
}
if (groundedValueFlowRankingCandidatePriority) {
pushReason(
reasonCodes,

View File

@ -126,6 +126,14 @@ function isReferentialOrganizationPlaceholder(value: string | null): boolean {
if (isNegativeCounterpartyScopePlaceholder(value)) {
return true;
}
const text = compactLower(repairAddressMojibakeText(value));
if (
/(?:без\s+(?:предположен\p{L}*|допущен\p{L}*|гадан\p{L}*|фантази\p{L}*|догад\p{L}*)|without\s+(?:guesses|assumptions|guessing))/iu.test(
text
)
) {
return true;
}
return new Set([
"эта организация",
"этой организации",
@ -1258,6 +1266,12 @@ function hasBidirectionalValueFlowSignal(text: string): boolean {
) {
return true;
}
if (
/(?:\u043e\u0431\u0449\p{L}*[\s\S]{0,60}\u0434\u0435\u043d\p{L}*[\s\S]{0,60}\u0441\u043c\u044b\u0441\u043b|\u0434\u0435\u043d\p{L}*[\s\S]{0,40}\u0441\u043c\u044b\u0441\u043b|overall[\s\S]{0,40}(?:cash|money)[\s\S]{0,40}(?:meaning|sense)|money[\s\S]{0,40}(?:meaning|sense))/iu.test(text) &&
hasPayoutSignal(text)
) {
return true;
}
return /(?:нетто|сальдо|баланс\s+(?:плат|денег|денеж)|взаиморасч[её]т|получил[иа]?.*(?:за)?платил|(?:за)?платил[иа]?.*получил|входящ.*исходящ|исходящ.*входящ|дебет.*кредит|кредит.*дебет|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(
text
);
@ -1320,7 +1334,7 @@ function normalizeLooseOrganizationAlias(value: string | null): string | null {
if (!text) {
return null;
}
if (isNegativeCounterpartyScopePlaceholder(text)) {
if (isReferentialOrganizationPlaceholder(text)) {
return null;
}
const normalized = text

View File

@ -202,7 +202,10 @@ function detectScopedCounterpartyEntity(text) {
if (!rawEntity) {
continue;
}
const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim();
const entity = rawEntity
.replace(/^["'«»]+|["'«»]+$/gu, "")
.replace(/^(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a(?:\u0443|\u0430|\u043e\u043c|\u0435)?|\u043a\u043b\u0438\u0435\u043d\u0442(?:\u0443|\u0430|\u043e\u043c|\u0435)?|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442(?:\u0443|\u0430|\u043e\u043c|\u0435)?)\s+/iu, "")
.trim();
if (entity.length >= 2 && !ignored.has(entity)) {
return entity;
}
@ -217,7 +220,11 @@ function detectCounterpartyBidirectionalValueFlowFamily(text) {
/(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text);
const hasOutgoingCue =
/(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text);
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) {
const hasOverallMoneyMeaningCue =
/(?:\u043e\u0431\u0449\p{L}*[\s\S]{0,60}\u0434\u0435\u043d\p{L}*[\s\S]{0,60}\u0441\u043c\u044b\u0441\u043b|\u0434\u0435\u043d\p{L}*[\s\S]{0,40}\u0441\u043c\u044b\u0441\u043b|overall[\s\S]{0,40}(?:cash|money)[\s\S]{0,40}(?:meaning|sense)|money[\s\S]{0,40}(?:meaning|sense))/iu.test(
text
);
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue) || (hasOverallMoneyMeaningCue && hasOutgoingCue))) {
return null;
}
const entity = detectScopedCounterpartyEntity(text);
@ -553,6 +560,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
const explicitIntentCandidate =
broadBusinessEvaluation?.family
? null
: counterpartyBidirectionalValueFlow?.family
? null
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
const unsupportedFamily = broadBusinessEvaluation?.family
? broadBusinessEvaluation.family

View File

@ -164,6 +164,28 @@ describe("address follow-up temporal regressions", () => {
expect(result?.baseReasons).not.toContain("counterparty_carryover_suppressed_for_short_debt_mirror");
});
it("keeps explicit year period for selected counterparty payout follow-up", () => {
const result = runAddressDecomposeStage("а теперь сколько заплатили?", {
previous_intent: "customer_revenue_and_payments" as const,
target_intent: "customer_revenue_and_payments" as const,
previous_filters: {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
previous_anchor_type: "counterparty" as const,
previous_anchor_value: "Группа СВК",
resolved_counterparty_from_display: true
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("customer_revenue_and_payments");
expect(result?.filters.extracted_filters.counterparty).toBe("Группа СВК");
expect(result?.filters.extracted_filters.period_from).toBe("2020-01-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-12-31");
expect(result?.baseReasons).toContain("period_from_followup_context");
});
it("keeps same-date inventory pivot anchored to the previous VAT date", () => {
const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", {
previous_intent: "vat_payable_confirmed_as_of_date",
@ -454,6 +476,29 @@ describe("address follow-up temporal regressions", () => {
expect(movements?.baseReasons).toContain("counterparty_from_followup_context");
});
it("keeps value-flow period for counterparty document evidence follow-up", () => {
const followupContext = {
previous_intent: "customer_revenue_and_payments" as const,
target_intent: "list_documents_by_counterparty" as const,
previous_filters: {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
previous_anchor_type: "counterparty" as const,
previous_anchor_value: "Группа СВК",
resolved_counterparty_from_display: true
};
const documents = runAddressDecomposeStage("а по документам?", followupContext);
expect(documents?.intent.intent).toBe("list_documents_by_counterparty");
expect(documents?.filters.extracted_filters.counterparty).toBe("Группа СВК");
expect(documents?.filters.extracted_filters.period_from).toBe("2020-01-01");
expect(documents?.filters.extracted_filters.period_to).toBe("2020-12-31");
expect(documents?.baseReasons).toContain("period_from_followup_context");
});
it("replaces pronoun chain anchors from counterparty follow-up context", () => {
const followupContext = {
previous_intent: "customer_revenue_and_payments" as const,

View File

@ -132,6 +132,16 @@ describe("addressIntentResolver regression bridges", () => {
expect(result.reasons).toContain("unicode_nomenclature_margin_ranking_bridge_signal_detected");
});
it("routes agent margin-inspection follow-up to inventory margin instead of unknown", () => {
const result = resolveAddressIntent(
"\u0442\u043e\u0433\u0434\u0430 \u043a\u0430\u043a\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438\u043b\u0438 \u043c\u0430\u0440\u0436\u0443 \u043c\u043e\u0436\u043d\u043e \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043f\u043e \u0442\u043e\u0432\u0430\u0440\u0430\u043c \u0437\u0430 2020?"
);
expect(result.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result.intent).not.toBe("unknown");
expect(result.reasons).toContain("unicode_nomenclature_margin_ranking_bridge_signal_detected");
});
it("detects bare historical inventory root with explicit month-year", () => {
const result = resolveAddressIntent("остатки на март 2016");

View File

@ -44,6 +44,15 @@ describe("addressInventoryIntentSignals", () => {
expect(result.intent).toBe("inventory_margin_ranking_for_nomenclature");
});
it("classifies margin inspection wording from the phase106 replay as inventory margin", () => {
const result = resolveInventoryAddressIntent(
"\u0442\u043e\u0433\u0434\u0430 \u043a\u0430\u043a\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438\u043b\u0438 \u043c\u0430\u0440\u0436\u0443 \u043c\u043e\u0436\u043d\u043e \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043f\u043e \u0442\u043e\u0432\u0430\u0440\u0430\u043c \u0437\u0430 2020?"
);
expect(result?.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.reasons).toContain("inventory_margin_ranking_signal_detected");
});
it("classifies selected-object purchase provenance wording through the extracted inventory owner", () => {
const result = resolveInventoryAddressIntent("selected object supplier provenance");

View File

@ -105,6 +105,68 @@ describe("address navigation state", () => {
expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31");
});
it("does not store broad money role wording as a counterparty focus object", () => {
const base = createEmptyAddressNavigationState("asst-bo-money", "2026-04-12T10:00:00.000Z");
const organization = 'ООО "Альтернатива Плюс"';
const assistantItem = {
message_id: "msg-bo-money",
session_id: "asst-bo-money",
role: "assistant",
text:
"По компании ООО \"Альтернатива Плюс\" за 2020 получили 47 628 853,03 руб.; крупнейший небанковский входящий контрагент: Группа СВК.",
reply_type: "partial_coverage",
created_at: "2026-04-12T10:11:00.000Z",
trace_id: "address-bo-money",
debug: {
detected_mode: "address_query",
detected_intent: "unknown",
selected_recipe: null,
extracted_filters: {
counterparty: "и главный поставщик в",
organization,
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
mcp_discovery_response_applied: true,
mcp_discovery_selected_chain_id: "business_overview",
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: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: organization,
explicit_date_scope: "2020"
},
data_need_graph: {
business_fact_family: "business_overview",
subject_candidates: []
}
},
bridge: {
bridge_status: "answer_draft_ready",
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
}
}
},
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 2);
expect(evolved.result_sets[0]?.filters.counterparty).toBeUndefined();
expect(evolved.result_sets[0]?.filters.organization).toBe(organization);
expect(evolved.session_context.active_focus_object?.object_type).toBe("organization");
expect(evolved.session_context.active_focus_object?.label).toBe(organization);
expect(evolved.navigation_history[0]?.target_object_id).toBe(`organization:${organization}`.toLowerCase());
});
it("captures counterparty focus from applied discovery turns even when the response stayed in chat mode", () => {
const base = createEmptyAddressNavigationState("asst-vf", "2026-04-12T10:00:00.000Z");
const assistantItem = {
@ -158,6 +220,262 @@ describe("address navigation state", () => {
expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31");
});
it("captures resolved entity-resolution focus as fresh current discovery state", () => {
const base = createEmptyAddressNavigationState("asst-entity", "2026-04-12T10:00:00.000Z");
const assistantItem = {
message_id: "msg-entity",
session_id: "asst-entity",
role: "assistant",
text: "В проверенном каталожном срезе 1С найден наиболее вероятный контрагент: Группа СВК.",
created_at: "2026-04-12T10:12:30.000Z",
debug: {
detected_mode: "chat",
detected_intent: "unknown",
extracted_filters: {},
anchor_type: "unknown",
mcp_discovery_response_applied: true,
mcp_discovery_selected_chain_id: "entity_resolution",
assistant_mcp_discovery_entry_point_v1: {
turn_input: {
turn_meaning_ref: {
asked_domain_family: "entity_resolution",
asked_action_family: "search_business_entity",
explicit_entity_candidates: ["СВК"],
stale_replay_forbidden: true
}
},
bridge: {
pilot: {
pilot_scope: "entity_resolution_search_v1",
derived_entity_resolution: {
requested_entity: "СВК",
resolution_status: "resolved",
resolved_entity: "Группа СВК"
}
}
}
},
dialog_continuation_contract_v2: {
decision: "new_topic"
}
}
} as any;
const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 1);
expect(evolved.session_context.active_focus_object?.object_type).toBe("counterparty");
expect(evolved.session_context.active_focus_object?.label).toBe("Группа СВК");
expect(evolved.result_sets[0]?.filters.counterparty).toBe("Группа СВК");
expect(evolved.navigation_history[0]?.target_object_id).toBe("counterparty:группа свк");
});
it("captures single-direction value-flow focus before the net bundle exists", () => {
const base = createEmptyAddressNavigationState("asst-vf-single", "2026-04-12T10:00:00.000Z");
const assistantItem = {
message_id: "msg-vf-single",
session_id: "asst-vf-single",
role: "assistant",
text: "Входящие денежные поступления по контрагенту Группа СВК за период 2020: 12 093 465 руб.",
created_at: "2026-04-12T10:13:00.000Z",
debug: {
detected_mode: "chat",
detected_intent: "unknown",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
mcp_discovery_response_applied: true,
mcp_discovery_selected_chain_id: "value_flow",
assistant_mcp_discovery_entry_point_v1: {
turn_input: {
turn_meaning_ref: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_entity_candidates: ["Группа СВК"],
explicit_date_scope: "2020",
stale_replay_forbidden: true
}
},
bridge: {
pilot: {
pilot_scope: "counterparty_value_flow_query_movements_v1",
derived_value_flow: {
value_flow_direction: "incoming_customer_revenue",
counterparty: "Группа СВК",
period_scope: "2020",
total_amount_human_ru: "12 093 465 руб."
}
}
}
},
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 2);
expect(evolved.result_sets[0]?.intent).toBe("customer_revenue_and_payments");
expect(evolved.result_sets[0]?.filters.counterparty).toBe("Группа СВК");
expect(evolved.session_context.active_focus_object?.label).toBe("Группа СВК");
expect(evolved.session_context.date_scope.period_from).toBe("2020-01-01");
expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31");
});
it("stores bidirectional value-flow proof bundle for adjacent evidence drilldowns", () => {
const base = createEmptyAddressNavigationState("asst-vf-bundle", "2026-04-12T10:00:00.000Z");
const valueFlowBundle = {
counterparty: "Группа СВК",
period_scope: "2020",
incoming_customer_revenue: {
total_amount_human_ru: "12 093 465 руб."
},
outgoing_supplier_payout: {
total_amount_human_ru: "0 руб."
},
net_amount_human_ru: "12 093 465 руб.",
net_direction: "net_incoming"
};
const netItem = {
message_id: "msg-vf-net",
session_id: "asst-vf-bundle",
role: "assistant",
text: "По контрагенту Группа СВК за период 2020 получили 12 093 465 руб., заплатили 0 руб.; расчетное нетто в нашу сторону: 12 093 465 руб.",
created_at: "2026-04-12T10:14:00.000Z",
debug: {
detected_mode: "chat",
detected_intent: "customer_revenue_and_payments",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
mcp_discovery_response_applied: true,
mcp_discovery_selected_chain_id: "value_flow_comparison",
assistant_mcp_discovery_entry_point_v1: {
turn_input: {
turn_meaning_ref: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: ["Группа СВК"],
explicit_date_scope: "2020",
stale_replay_forbidden: true
}
},
bridge: {
pilot: {
pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1",
derived_bidirectional_value_flow: valueFlowBundle
}
}
},
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const afterNet = evolveAddressNavigationStateWithAssistantItem(base, netItem, 5);
expect(afterNet.session_context.comparison_scope?.counterparty?.label).toBe("Группа СВК");
expect(afterNet.session_context.comparison_scope?.proof_bundles?.counterparty_value_flow_bundle).toEqual(
valueFlowBundle
);
const docsItem = {
message_id: "msg-vf-docs",
session_id: "asst-vf-bundle",
role: "assistant",
text: "По документам по контрагенту Группа СВК за 2020 полный срез не подтвержден.",
created_at: "2026-04-12T10:15:00.000Z",
debug: {
detected_mode: "chat",
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
mcp_discovery_response_applied: true,
mcp_discovery_selected_chain_id: "document_evidence",
assistant_mcp_discovery_entry_point_v1: {
turn_input: {
turn_meaning_ref: {
asked_domain_family: "documents",
asked_action_family: "list_documents",
explicit_entity_candidates: ["Группа СВК"],
explicit_date_scope: "2020",
stale_replay_forbidden: true
}
},
bridge: {
pilot: {
pilot_scope: "counterparty_document_evidence_query_documents_v1"
}
}
},
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const afterDocs = evolveAddressNavigationStateWithAssistantItem(afterNet, docsItem, 6);
expect(afterDocs.session_context.active_focus_object?.label).toBe("Группа СВК");
expect(afterDocs.session_context.comparison_scope?.proof_bundles?.counterparty_value_flow_bundle).toEqual(
valueFlowBundle
);
const movementsItem = {
message_id: "msg-vf-movements",
session_id: "asst-vf-bundle",
role: "assistant",
text: "По движениям по контрагенту Группа СВК за 2020 полный срез не подтвержден.",
created_at: "2026-04-12T10:16:00.000Z",
debug: {
detected_mode: "chat",
detected_intent: "inventory_purchase_documents_for_item",
extracted_filters: {
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
mcp_discovery_response_applied: true,
mcp_discovery_selected_chain_id: "movement_evidence",
assistant_mcp_discovery_entry_point_v1: {
turn_input: {
turn_meaning_ref: {
asked_domain_family: "movements",
asked_action_family: "list_movements",
explicit_entity_candidates: ["Группа СВК"],
explicit_date_scope: "2020",
stale_replay_forbidden: true
}
},
bridge: {
pilot: {
pilot_scope: "counterparty_movement_evidence_query_movements_v1"
}
}
},
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const afterMovements = evolveAddressNavigationStateWithAssistantItem(afterDocs, movementsItem, 7);
expect(afterMovements.result_sets.at(-1)?.intent).toBe("bank_operations_by_counterparty");
expect(afterMovements.result_sets.at(-1)?.type).toBe("bank_operations_list");
expect(afterMovements.session_context.active_focus_object?.label).toBe("Группа СВК");
expect(afterMovements.session_context.comparison_scope?.proof_bundles?.counterparty_value_flow_bundle).toEqual(
valueFlowBundle
);
});
it("keeps selected counterparty focus for company boundary summaries", () => {
const initial = createEmptyAddressNavigationState("asst-boundary", "2026-04-12T10:00:00.000Z");
const assistantItem = {

View File

@ -150,6 +150,33 @@ describe("assistantContinuityPolicy organization authority", () => {
});
});
it("does not expose broad money role fragments as counterparty carryover", () => {
const debug = {
execution_lane: "address_query",
detected_intent: "business_overview",
extracted_filters: {
counterparty: "и главный поставщик в",
organization: 'ООО "Альтернатива Плюс"',
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "counterparty",
anchor_value_raw: "и главный поставщик в",
anchor_value_resolved: "и главный поставщик в"
};
expect(readAddressDebugCounterparty(debug)).toBeNull();
expect(resolveAddressDebugCarryoverFilters(debug)).toEqual({
organization: 'ООО "Альтернатива Плюс"',
period_from: "2020-01-01",
period_to: "2020-12-31"
});
expect(resolveAddressDebugAnchorContext(debug)).toEqual({
anchorType: "counterparty",
anchorValue: null
});
});
it("hydrates intent and carryover filters from grounded MCP discovery payout scope", () => {
const debug = {
execution_lane: "living_chat",
@ -657,6 +684,27 @@ describe("assistantContinuityPolicy organization authority", () => {
});
});
it("carries value-flow temporal scope into follow-up evidence drilldowns", () => {
const filters = applyTemporalCarryoverFilters(
{
counterparty: "Группа СВК"
},
{
as_of_date: null,
period_from: "2020-01-01",
period_to: "2020-12-31"
},
null,
"customer_revenue_and_payments"
);
expect(filters).toEqual({
counterparty: "Группа СВК",
period_from: "2020-01-01",
period_to: "2020-12-31"
});
});
it("resolves inventory root pivots through one shared helper", () => {
const flags = resolveInventoryFollowupPivotFlags(
{ intent: "inventory_on_hand_as_of_date" },

View File

@ -130,13 +130,14 @@ describe("assistant MCP discovery answer adapter", () => {
const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([]));
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
const answerText = [draft.headline, ...draft.confirmed_lines, ...draft.unknown_lines].join("\n");
const answerText = [draft.headline, ...draft.confirmed_lines, ...draft.inference_lines, ...draft.unknown_lines].join("\n");
expect(draft.answer_mode).toBe("checked_sources_only");
expect(draft.answer_mode).toBe("confirmed_with_bounded_inference");
expect(answerText).toContain("0 руб.");
expect(answerText).toContain("исходящих платежей/списаний не найдено");
expect(answerText).toContain("not_found");
expect(answerText).toContain("Группа СВК");
expect(answerText).toContain("2020");
expect(draft.unknown_lines.join("\n")).toContain("outside the checked period");
});
it("turns generic document evidence into a bounded document answer draft", async () => {
@ -1462,7 +1463,7 @@ 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 () => {
it("renders zero-row supplier payout as a confirmed scoped zero with period and counterparty", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {
asked_domain_family: "counterparty_value",
@ -1477,18 +1478,18 @@ describe("assistant MCP discovery answer adapter", () => {
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
const confirmedText = draft.confirmed_lines.join("\n");
const inferenceText = draft.inference_lines.join("\n");
const unknownText = draft.unknown_lines.join("\n");
expect(draft.answer_mode).toBe("checked_sources_only");
expect(draft.answer_mode).toBe("confirmed_with_bounded_inference");
expect(draft.headline).toContain("Группа СВК");
expect(draft.headline).toContain("2020");
expect(draft.headline).toContain("исходящих платежей");
expect(draft.headline).toContain("0 руб.");
expect(confirmedText).toContain("Группа СВК");
expect(confirmedText).toContain("ООО Альтернатива Плюс");
expect(confirmedText).toContain("2020");
expect(confirmedText).toContain("не найдено");
expect(unknownText).toContain("вне периода 2020");
expect(unknownText).toContain("вне доступного банковского контура");
expect(confirmedText).toContain("0 руб.");
expect(confirmedText).toContain("Граница");
expect(inferenceText).toContain("not_found result was treated as zero");
expect(unknownText).toContain("outside the checked period");
});
it("turns bidirectional value-flow evidence into a bounded net cash answer draft", async () => {

View File

@ -1,6 +1,11 @@
import iconv from "iconv-lite";
import { describe, expect, it } from "vitest";
import { buildAssistantMcpDiscoveryDataNeedGraph } from "../src/services/assistantMcpDiscoveryDataNeedGraph";
function toWin1251Mojibake(value: string): string {
return iconv.decode(Buffer.from(value, "utf8"), "win1251");
}
describe("assistant MCP discovery data need graph", () => {
it("builds a monthly bidirectional value-flow graph from grounded turn meaning", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
@ -403,6 +408,59 @@ describe("assistant MCP discovery data need graph", () => {
expect(result.reason_codes).toContain("data_need_graph_comparison_incoming_vs_outgoing");
});
it("keeps a wide formal business overview on the business-overview graph even when money net is requested", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "business overview evidence with bounded analyst interpretation",
rawUtterance:
"\u0414\u0430\u0439 \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c 1\u0421: \u043e\u0431\u043e\u0440\u043e\u0442\u044b, \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u044c\u0433\u0438, \u043d\u0435\u0442\u0442\u043e, \u041d\u0414\u0421, \u0434\u043e\u043b\u0433\u0438, \u0441\u043a\u043b\u0430\u0434, \u043a\u043b\u0438\u0435\u043d\u0442\u044b, \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0438 \u0438 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043d\u0435\u043b\u044c\u0437\u044f \u0443\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0442\u044c.",
turnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
explicit_date_scope: "2020",
explicit_organization_scope:
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
}
});
expect(result.business_fact_family).toBe("business_overview");
expect(result.action_family).toBe("broad_evaluation");
expect(result.comparison_need).toBeNull();
expect(result.subject_candidates).toEqual([]);
expect(result.reason_codes).toContain("data_need_graph_business_overview_wide_surface_preserved");
expect(result.reason_codes).not.toContain(
"data_need_graph_business_overview_bidirectional_money_comparison_routed_to_value_flow"
);
});
it("keeps a wide formal business overview when the runtime signal is win1251 mojibake", () => {
const wideQuestion =
"\u0414\u0430\u0439 \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c 1\u0421: \u043e\u0431\u043e\u0440\u043e\u0442\u044b, \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u044c\u0433\u0438, \u043d\u0435\u0442\u0442\u043e, \u041d\u0414\u0421, \u0434\u043e\u043b\u0433\u0438, \u0441\u043a\u043b\u0430\u0434, \u043a\u043b\u0438\u0435\u043d\u0442\u044b, \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0438 \u0438 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043d\u0435\u043b\u044c\u0437\u044f \u0443\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0442\u044c.";
const legacyQuestion = toWin1251Mojibake(wideQuestion);
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "business overview evidence with bounded analyst interpretation",
rawUtterance: legacyQuestion,
turnMeaning: {
raw_message: legacyQuestion,
effective_message: legacyQuestion,
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
explicit_date_scope: "2020",
explicit_organization_scope:
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
}
});
expect(result.business_fact_family).toBe("business_overview");
expect(result.action_family).toBe("broad_evaluation");
expect(result.comparison_need).toBeNull();
expect(result.reason_codes).toContain("data_need_graph_business_overview_wide_surface_preserved");
expect(result.reason_codes).not.toContain(
"data_need_graph_business_overview_bidirectional_money_comparison_routed_to_value_flow"
);
});
it("treats a generic incoming total as an understood open-scope ask that still needs organization", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "counterparty value-flow evidence",

View File

@ -1650,6 +1650,38 @@ describe("assistant MCP discovery pilot executor", () => {
expect(String(call?.query ?? "")).toContain("СписаниеСРасчетногоСчета");
});
it("treats zero-row supplier payout query as confirmed not-found inside checked scope", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "payout",
explicit_entity_candidates: ["SVK"],
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
}
});
const deps = buildDeps([]);
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
expect(result.pilot_status).toBe("executed");
expect(result.pilot_scope).toBe("counterparty_supplier_payout_query_movements_v1");
expect(result.evidence.evidence_status).toBe("confirmed");
expect(result.evidence.answer_permission).toBe("confirmed_answer");
expect(result.evidence.confirmed_facts[0]).toContain("not_found");
expect(result.reason_codes).toContain("pilot_derived_value_flow_from_confirmed_rows");
expect(result.evidence.reason_codes).toContain("confirmed_no_match_fact_with_allowed_mcp_evidence");
expect(result.derived_value_flow).toMatchObject({
value_flow_direction: "outgoing_supplier_payout",
counterparty: "SVK",
period_scope: "2020",
rows_matched: 0,
rows_with_amount: 0,
total_amount: 0,
total_amount_human_ru: "0 руб."
});
});
it("marks value-flow coverage as limited when the probe row limit is reached", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {

View File

@ -1132,6 +1132,55 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("generic advice");
});
it("does not render negative next-check instruction as organization scope", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
raw_message:
"\u0447\u0442\u043e \u0434\u0430\u043b\u044c\u0448\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043d\u044f\u0442\u044c \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0431\u0438\u0437\u043d\u0435\u0441\u0430, \u0431\u0435\u0437 \u0433\u0430\u0434\u0430\u043d\u0438\u0439?",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
explicit_organization_scope: "\u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
incoming_customer_revenue: { total_amount_human_ru: "47 628 853,03 руб." },
outgoing_supplier_payout: { total_amount_human_ru: "43 763 351,53 руб." },
net_amount_human_ru: "3 865 501,50 руб.",
net_direction: "net_incoming"
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "generic advice should not leak",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("\u0412 \u043f\u0435\u0440\u0432\u0443\u044e \u043e\u0447\u0435\u0440\u0435\u0434\u044c \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438");
expect(candidate.reply_text).not.toContain("\u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438 \u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439");
});
it("keeps counterparty leaders for money followups instead of compact cashflow", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -1553,8 +1602,9 @@ describe("assistant MCP discovery response candidate", () => {
expect(firstLine).toContain("По подтвержденным строкам 1С");
expect(firstLine).toContain("входящие 157 192 981,43 руб.");
expect(firstLine).toContain("исходящие 35 439 044,74 руб.");
expect(firstLine).toContain("не аудиторское заключение");
expect(firstLine).toContain("не подтвержденная чистая прибыль");
expect(firstLine).toContain("не финансовый аудит");
expect(firstLine).toContain("не подтверждение чистой прибыли");
expect(firstLine).not.toContain("аудиторское заключение");
expect(candidate.reply_text).toContain("Интерпретация:");
expect(candidate.reply_text).toContain("Что видно в ограниченном денежном срезе");
expect(candidate.reply_text).toContain("Что не подтверждено в этом срезе");

View File

@ -525,6 +525,177 @@ describe("assistant MCP discovery response policy", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply");
});
it("uses a business-overview boundary clarification over a stale inventory-item clarification", () => {
const valueFlowBundle = {
counterparty: "Group SVK",
incoming_customer_revenue: { total_amount_human_ru: "20 653 490 rub." },
outgoing_supplier_payout: { total_amount_human_ru: "2 129 651 rub." },
net_amount_human_ru: "18 523 839 rub.",
net_direction: "net_incoming"
};
const documentBundle = { counterparty: "Group SVK", document_count: 19 };
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "Need item before I can continue.",
currentReplySource: "address_query_runtime_v1",
currentReplyType: "partial_coverage",
addressRuntimeMeta: {
detected_intent: "inventory_purchase_documents_for_item",
selected_recipe: "address_inventory_purchase_documents_for_item_v1",
capability_id: "inventory_inventory_purchase_documents_for_item",
truth_mode: "clarification_required",
answer_shape: "clarification_required",
missing_required_filters: ["item"],
answer_shape_contract: {
answer_shape: "clarification_required",
reply_type: "partial_coverage",
capability_contract_id: "inventory_inventory_purchase_documents_for_item"
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
business_overview_separate_entity_candidates: ["Group SVK"],
previous_counterparty_value_flow_bundle: valueFlowBundle,
previous_counterparty_document_bundle: documentBundle,
unsupported_but_understood_family: "broad_business_evaluation"
},
data_need_graph: {
business_fact_family: "business_overview",
action_family: "broad_evaluation",
clarification_gaps: ["organization"],
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "needs_clarification",
user_facing_response_allowed: true,
business_fact_answer_allowed: false,
requires_user_clarification: true,
answer_draft: {
answer_mode: "needs_clarification",
headline: "Specify organization.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: "Specify organization."
}
}
})
}
});
expect(result.applied).toBe(true);
expect(result.decision).toBe("apply_candidate");
expect(result.reply_text).toContain("Group SVK");
expect(result.reply_text).toContain("20 653 490 rub.");
expect(result.reply_text).toContain("19");
expect(result.reply_text).not.toContain("Need item");
expect(result.reason_codes).toContain(
"mcp_discovery_response_policy_business_overview_boundary_candidate_priority_over_inventory_item_clarification"
);
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply");
});
it("uses a ready business-overview boundary answer over a stale inventory-item clarification", () => {
const valueFlowBundle = {
counterparty: "Group SVK",
incoming_customer_revenue: { total_amount_human_ru: "20 653 490 rub." },
outgoing_supplier_payout: { total_amount_human_ru: "2 129 651 rub." },
net_amount_human_ru: "18 523 839 rub.",
net_direction: "net_incoming"
};
const documentBundle = { counterparty: "Group SVK", document_count: 19 };
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "Need item before I can continue.",
currentReplySource: "address_query_runtime_v1",
currentReplyType: "partial_coverage",
addressRuntimeMeta: {
detected_intent: "inventory_purchase_documents_for_item",
selected_recipe: "address_inventory_purchase_documents_for_item_v1",
capability_id: "inventory_inventory_purchase_documents_for_item",
truth_mode: "clarification_required",
answer_shape: "clarification_required",
missing_required_filters: ["item"],
answer_shape_contract: {
answer_shape: "clarification_required",
reply_type: "partial_coverage",
capability_contract_id: "inventory_inventory_purchase_documents_for_item"
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
hot_runtime_wired: true,
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: "Alternative Plus",
business_overview_separate_entity_candidates: ["Group SVK"],
previous_counterparty_value_flow_bundle: valueFlowBundle,
previous_counterparty_document_bundle: documentBundle,
unsupported_but_understood_family: "broad_business_evaluation"
},
data_need_graph: {
business_fact_family: "business_overview",
action_family: "broad_evaluation",
clarification_gaps: [],
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
hot_runtime_wired: true,
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
execution_handoff: {
mcp_execution_performed: true,
can_use_guarded_response: true,
must_keep_internal_mechanics_hidden: true
},
pilot: {
pilot_scope: "business_overview_route_template_v1",
mcp_execution_performed: true,
derived_business_overview: {
period_scope: "all_time",
incoming_customer_revenue: { total_amount_human_ru: "285 819 547 rub." },
outgoing_supplier_payout: { total_amount_human_ru: "137 963 720 rub." },
net_amount_human_ru: "147 855 827 rub.",
net_direction: "net_incoming",
top_customers: [],
top_suppliers: []
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Company summary.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
}
});
expect(result.applied).toBe(true);
expect(result.decision).toBe("apply_candidate");
expect(result.reply_text).toContain("Alternative Plus");
expect(result.reply_text).toContain("Group SVK");
expect(result.reply_text).toContain("20 653 490 rub.");
expect(result.reply_text).not.toContain("Need item");
expect(result.reason_codes).toContain(
"mcp_discovery_response_policy_business_overview_boundary_candidate_priority_over_inventory_item_clarification"
);
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply");
});
it("overrides exact document-list replies for explicit counterparty received-paid-net questions", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "Контрагент: Группа СВК. Найдено документов: 19.",
@ -582,7 +753,73 @@ describe("assistant MCP discovery response policy", () => {
expect(result.reply_text).toContain("20 653 490");
expect(result.reply_text).toContain("2 129 651");
expect(result.reason_codes).toContain("mcp_discovery_response_policy_value_flow_action_conflict_allows_candidate_override");
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_document_list_address_reply");
});
it("overrides exact document-list replies when they contradict the discovery period scope", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply:
"Контрагент: Группа СВК. Найдено документов: 19.\n" +
"1. 2021-11-10T12:00:07Z | Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07 | аналитика: Группа СВК",
currentReplySource: "address_query_runtime_v1",
currentReplyType: "factual",
addressRuntimeMeta: {
detected_intent: "list_documents_by_counterparty",
selected_recipe: "address_documents_by_counterparty_v1",
mcp_call_status: "matched_non_empty",
response_type: "FACTUAL_LIST",
truth_mode: "confirmed",
extracted_filters: {
counterparty: "Группа СВК",
as_of_date: "2026-06-15"
},
dialog_continuation_contract_v2: {
target_intent: "list_documents_by_counterparty"
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "documents",
asked_action_family: "list_documents",
explicit_entity_candidates: ["Группа СВК"],
explicit_date_scope: "2020",
unsupported_but_understood_family: "document_evidence"
},
data_need_graph: {
business_fact_family: "document_evidence",
action_family: "list_documents",
subject_candidates: ["Группа СВК"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
answer_draft: {
answer_mode: "bounded_inference_only",
headline: "По документам по контрагенту Группа СВК за 2020 полный срез не подтвержден.",
confirmed_lines: [],
inference_lines: ["Подтвержденных строк документов за 2020 этим поиском не найдено."],
unknown_lines: ["Полный исторический срез вне 2020 этим поиском не подтвержден."],
limitation_lines: [],
next_step_line: null
}
}
})
}
});
expect(result.applied).toBe(true);
expect(result.decision).toBe("apply_candidate");
expect(result.reply_text).toContain("за 2020");
expect(result.reply_text).not.toContain("2021-11-10");
expect(result.reason_codes).toContain(
"mcp_discovery_response_policy_document_list_temporal_conflict_allows_candidate_override"
);
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_document_list_address_reply");
});

View File

@ -163,6 +163,42 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected");
});
it("treats supplier payout plus overall money meaning as bidirectional value-flow discovery", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"А теперь поставщику Группа СВК за 2020 сколько мы ему заплатили и какой общий денежный смысл?",
assistantTurnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: [{ value: "группа свк" }],
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
stale_replay_forbidden: true
},
predecomposeContract: {
entities: { counterparty: "Группа СВК" },
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
}
});
expect(result.adapter_status).toBe("ready");
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.data_need_graph).toMatchObject({
business_fact_family: "value_flow",
action_family: "net_value_flow",
comparison_need: "incoming_vs_outgoing"
});
expect(result.reason_codes).toContain("mcp_discovery_bidirectional_value_flow_signal_detected");
expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected");
});
it("keeps explicit counterparty money-flow basis questions executable without requiring a period", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "How much money passed with SVK, incoming and outgoing, and what documents or movements prove it?",
@ -4076,4 +4112,39 @@ describe("assistant MCP discovery turn input adapter", () => {
);
expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_suppressed_stale_counterparty");
});
it("uses retained organization scope instead of negative next-check instruction text", () => {
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:
"\u0447\u0442\u043e \u0434\u0430\u043b\u044c\u0448\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043d\u044f\u0442\u044c \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0431\u0438\u0437\u043d\u0435\u0441\u0430, \u0431\u0435\u0437 \u0433\u0430\u0434\u0430\u043d\u0438\u0439?",
assistantTurnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: "\u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439",
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true
},
predecomposeContract: {
entities: {
counterparty: "\u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439"
}
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: { organization: orgName },
root_filters: { organization: orgName },
previous_anchor_type: "organization",
previous_anchor_value: orgName,
previous_intent: "business_overview",
root_intent: "business_overview"
}
});
expect(result.turn_meaning_ref?.explicit_organization_scope).toBe(orgName);
expect(result.turn_meaning_ref?.explicit_organization_scope).not.toBe(
"\u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439"
);
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
});
});

View File

@ -85,6 +85,31 @@ describe("assistantTurnMeaningPolicy", () => {
expect(meaning.stale_replay_forbidden).toBe(true);
});
it("treats payout plus overall money meaning as bidirectional value-flow", () => {
const policy = buildPolicy({
resolveAddressIntent: () => ({ intent: "supplier_payouts_profile", confidence: "high" })
});
const meaning = policy.resolveAssistantTurnMeaning({
rawUserMessage:
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0443 \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043c\u044b \u0435\u043c\u0443 \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u0449\u0438\u0439 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u0441\u043c\u044b\u0441\u043b?"
});
expect(meaning.explicit_intent_candidate).toBeNull();
expect(meaning.asked_domain_family).toBe("counterparty_value");
expect(meaning.asked_action_family).toBe("net_value_flow");
expect(meaning.unsupported_but_understood_family).toBe("counterparty_bidirectional_value_flow_or_netting");
expect(meaning.explicit_entity_candidates).toEqual([
{
type: "counterparty",
value: "\u0433\u0440\u0443\u043f\u043f\u0430 \u0441\u0432\u043a",
source: "current_turn_loose_entity_tail"
}
]);
expect(meaning.reason_codes).toContain("counterparty_bidirectional_value_flow_current_turn_signal");
expect(meaning.stale_replay_forbidden).toBe(true);
});
it("ignores temporal tail words in all-time revenue ranking questions", () => {
const policy = buildPolicy({
resolveAddressIntent: (text: string) =>

View File

@ -1,4 +1,170 @@
[
{
"generation_id": "gen-ag06151952-5d8f12",
"created_at": "2026-06-15T19:52:40+00:00",
"mode": "saved_user_sessions",
"title": "AGENT | Phase107 mixed planner brain dogfood",
"count": 9,
"domain": "address_phase107_mixed_planner_brain_dogfood",
"questions": [
"привет, ты на связи?",
"найди в 1С контрагента СВК",
"сколько получили по нему за 2020 год",
"а теперь сколько заплатили?",
"а какое нетто?",
"а по документам?",
"а по движениям?",
"Как ты оценишь деятельность компании ООО Альтернатива Плюс по данным 1С?",
"а чем капибара отличается от утки?"
],
"generated_by": "codex_agent",
"saved_case_set_file": "assistant_autogen_saved_user_sessions_20260615195240_gen-ag06151952-5d8f12.json",
"context": {
"llm_provider": null,
"model": null,
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"autogen_personality_id": null,
"autogen_personality_prompt": null,
"source_session_id": null,
"saved_session_file": "assistant_saved_session_20260615195240_gen-ag06151952-5d8f12.json",
"saved_case_set_kind": "agent_semantic_scenario",
"agent_run": true,
"agent_focus": "mixed planner autonomy and counterparty evidence carryover",
"architecture_phase": "assistant-stage1 phase107",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"alias_grounding",
"broad_business_evaluation",
"context_boundary",
"context_switch_from_counterparty_to_company",
"date_carryover",
"document_evidence",
"document_pivot",
"entity_resolution",
"followup_anchor",
"followup_reuse",
"grounded_summary",
"human_answer",
"incoming_value_flow",
"mcp_discovery_gate_sanity",
"meta_smalltalk",
"movement_evidence",
"net_value_flow",
"off_domain_living_chat",
"payout_switch",
"planner_catalog_alignment",
"stale_replay_forbidden",
"value_flow_pivot"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase107_mixed_planner_brain_dogfood_20260615_p11_loop",
"saved_after_validated_replay": true
}
},
{
"generation_id": "gen-ag06151746-7d3a57",
"created_at": "2026-06-15T17:46:11+00:00",
"mode": "saved_user_sessions",
"title": "AGENT | Phase106 profit cashflow margin next checks",
"count": 5,
"domain": "address_phase106_profit_cashflow_margin_next_step",
"questions": [
"ООО Альтернатива Плюс",
"скока денег альтернатива заработала за 20 год?",
"а это чистая прибыль?",
"тогда какую прибыль или маржу можно проверить по товарам за 2020?",
"что дальше проверить, чтобы понять здоровье бизнеса, без гаданий?"
],
"generated_by": "codex_agent",
"saved_case_set_file": "assistant_autogen_saved_user_sessions_20260615174611_gen-ag06151746-7d3a57.json",
"context": {
"llm_provider": null,
"model": null,
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"autogen_personality_id": null,
"autogen_personality_prompt": null,
"source_session_id": null,
"saved_session_file": "assistant_saved_session_20260615174611_gen-ag06151746-7d3a57.json",
"saved_case_set_kind": "agent_semantic_scenario",
"agent_run": true,
"agent_focus": "Profit/cashflow boundary, inventory margin pivot, organization carryover, and concrete next-check plan after accepted phase106 p03 live replay.",
"architecture_phase": "turnaround_11",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"bare_org_scope",
"business_next_step",
"business_overview",
"cashflow_not_profit",
"cashflow_to_margin_pivot",
"colloquial_cashflow",
"company_scope",
"evidence_plan",
"followup_boundary",
"inventory",
"margin_profitability",
"no_generic_advice",
"profitability_boundary",
"wrong_domain_trap"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase106_profit_cashflow_margin_next_step_20260615_p03_loop",
"saved_after_validated_replay": true
}
},
{
"generation_id": "gen-ag06151720-c4b54f",
"created_at": "2026-06-15T17:20:44+00:00",
"mode": "saved_user_sessions",
"title": "AGENT | Phase104 role-tail anchor hygiene and bidirectional value-flow",
"count": 4,
"domain": "address_phase104_generic_role_tail_anchor_hygiene",
"questions": [
"ООО Альтернатива Плюс",
"Теперь дай взрослый обзор за 2020 по компании: входящие, исходящие, нетто, топы, но банк в топах отдельно объясни как финансовый поток, если по назначению он не обычный клиент или поставщик.",
"А отдельно по СБЕРБАНКУ: он для нас клиент, поставщик или финансовый поток? Дай коротко по подтвержденным строкам.",
"А теперь по поставщику Группа СВК за 2020: сколько мы ему заплатили и какой общий денежный смысл?"
],
"generated_by": "codex_agent",
"saved_case_set_file": "assistant_autogen_saved_user_sessions_20260615172044_gen-ag06151720-c4b54f.json",
"context": {
"llm_provider": null,
"model": null,
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"autogen_personality_id": null,
"autogen_personality_prompt": null,
"source_session_id": null,
"saved_session_file": "assistant_saved_session_20260615172044_gen-ag06151720-c4b54f.json",
"saved_case_set_kind": "agent_semantic_scenario",
"agent_run": true,
"agent_focus": "Role-tail anchor hygiene, bank boundary, and supplier-prefix bidirectional money-flow after live domain-case-loop p04.",
"architecture_phase": "turnaround_11",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"bank_like_counterparty",
"bare_org_scope",
"business_overview",
"company_scope",
"counterparty_net_cash_flow",
"financial_role_purpose",
"generic_role_tail_anchor_hygiene",
"post_overview_anchor_integrity",
"stale_scope_guard",
"supplier_prefix_canary"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop",
"saved_after_validated_replay": true
}
},
{
"generation_id": "gen-ag06031328-3e1dd1",
"created_at": "2026-06-03T13:28:09+00:00",

View File

@ -0,0 +1,171 @@
{
"saved_at": "2026-06-15T17:20:44+00:00",
"generation_id": "gen-ag06151720-c4b54f",
"mode": "saved_user_sessions",
"title": "AGENT | Phase104 role-tail anchor hygiene and bidirectional value-flow",
"agent_run": true,
"questions": [
"ООО Альтернатива Плюс",
"Теперь дай взрослый обзор за 2020 по компании: входящие, исходящие, нетто, топы, но банк в топах отдельно объясни как финансовый поток, если по назначению он не обычный клиент или поставщик.",
"А отдельно по СБЕРБАНКУ: он для нас клиент, поставщик или финансовый поток? Дай коротко по подтвержденным строкам.",
"А теперь по поставщику Группа СВК за 2020: сколько мы ему заплатили и какой общий денежный смысл?"
],
"metadata": {
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"agent_focus": "Role-tail anchor hygiene, bank boundary, and supplier-prefix bidirectional money-flow after live domain-case-loop p04.",
"architecture_phase": "turnaround_11",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"bank_like_counterparty",
"bare_org_scope",
"business_overview",
"company_scope",
"counterparty_net_cash_flow",
"financial_role_purpose",
"generic_role_tail_anchor_hygiene",
"post_overview_anchor_integrity",
"stale_scope_guard",
"supplier_prefix_canary"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop",
"saved_after_validated_replay": true,
"save_gate": {
"schema_version": "agent_semantic_save_gate_v1",
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop",
"final_status": "accepted",
"loop_id": "phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop",
"target_score": 88,
"iterations_ran": 1,
"quality_score": 91,
"repair_target_count": 0,
"repair_target_severity_counts": {
"P0": 0,
"P1": 0,
"P2": 0
},
"accepted_gate": true,
"effective_runtime": {
"manifest_path": "artifacts\\domain_runs\\phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop\\effective_runtime.json",
"runner": "domain_case_loop.run-pack-loop",
"git_sha": "bbb3620956f85aa05bb0c2b76c109cb05a066185",
"backend_url": "http://127.0.0.1:8787",
"mcp_proxy_url": "http://127.0.0.1:6003",
"llm_provider": "local",
"llm_model": "unsloth/qwen3-30b-a3b-instruct-2507",
"temperature": 0.8,
"max_output_tokens": 900,
"prompt_version": "normalizer_v2_0_2",
"prompt_source": "file",
"prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1",
"prompt_registry_status": "pass"
},
"saved_after_validated_replay": true
}
},
"source_session_id": null,
"session": {
"session_id": null,
"mode": "agent_semantic_run",
"items": [
{
"message_id": "agent-user-001",
"role": "user",
"text": "ООО Альтернатива Плюс",
"created_at": "2026-06-15T17:20:44+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-002",
"role": "user",
"text": "Теперь дай взрослый обзор за 2020 по компании: входящие, исходящие, нетто, топы, но банк в топах отдельно объясни как финансовый поток, если по назначению он не обычный клиент или поставщик.",
"created_at": "2026-06-15T17:20:44+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-003",
"role": "user",
"text": "А отдельно по СБЕРБАНКУ: он для нас клиент, поставщик или финансовый поток? Дай коротко по подтвержденным строкам.",
"created_at": "2026-06-15T17:20:44+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-004",
"role": "user",
"text": "А теперь по поставщику Группа СВК за 2020: сколько мы ему заплатили и какой общий денежный смысл?",
"created_at": "2026-06-15T17:20:44+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
}
],
"agent_run": true,
"metadata": {
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"agent_focus": "Role-tail anchor hygiene, bank boundary, and supplier-prefix bidirectional money-flow after live domain-case-loop p04.",
"architecture_phase": "turnaround_11",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"bank_like_counterparty",
"bare_org_scope",
"business_overview",
"company_scope",
"counterparty_net_cash_flow",
"financial_role_purpose",
"generic_role_tail_anchor_hygiene",
"post_overview_anchor_integrity",
"stale_scope_guard",
"supplier_prefix_canary"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop",
"saved_after_validated_replay": true,
"save_gate": {
"schema_version": "agent_semantic_save_gate_v1",
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop",
"final_status": "accepted",
"loop_id": "phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop",
"target_score": 88,
"iterations_ran": 1,
"quality_score": 91,
"repair_target_count": 0,
"repair_target_severity_counts": {
"P0": 0,
"P1": 0,
"P2": 0
},
"accepted_gate": true,
"effective_runtime": {
"manifest_path": "artifacts\\domain_runs\\phase104_generic_role_tail_anchor_hygiene_20260615_p04_loop\\effective_runtime.json",
"runner": "domain_case_loop.run-pack-loop",
"git_sha": "bbb3620956f85aa05bb0c2b76c109cb05a066185",
"backend_url": "http://127.0.0.1:8787",
"mcp_proxy_url": "http://127.0.0.1:6003",
"llm_provider": "local",
"llm_model": "unsloth/qwen3-30b-a3b-instruct-2507",
"temperature": 0.8,
"max_output_tokens": 900,
"prompt_version": "normalizer_v2_0_2",
"prompt_source": "file",
"prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1",
"prompt_registry_status": "pass"
},
"saved_after_validated_replay": true
}
}
}
}

View File

@ -0,0 +1,189 @@
{
"saved_at": "2026-06-15T17:46:11+00:00",
"generation_id": "gen-ag06151746-7d3a57",
"mode": "saved_user_sessions",
"title": "AGENT | Phase106 profit cashflow margin next checks",
"agent_run": true,
"questions": [
"ООО Альтернатива Плюс",
"скока денег альтернатива заработала за 20 год?",
"а это чистая прибыль?",
"тогда какую прибыль или маржу можно проверить по товарам за 2020?",
"что дальше проверить, чтобы понять здоровье бизнеса, без гаданий?"
],
"metadata": {
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"agent_focus": "Profit/cashflow boundary, inventory margin pivot, organization carryover, and concrete next-check plan after accepted phase106 p03 live replay.",
"architecture_phase": "turnaround_11",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"bare_org_scope",
"business_next_step",
"business_overview",
"cashflow_not_profit",
"cashflow_to_margin_pivot",
"colloquial_cashflow",
"company_scope",
"evidence_plan",
"followup_boundary",
"inventory",
"margin_profitability",
"no_generic_advice",
"profitability_boundary",
"wrong_domain_trap"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase106_profit_cashflow_margin_next_step_20260615_p03_loop",
"saved_after_validated_replay": true,
"save_gate": {
"schema_version": "agent_semantic_save_gate_v1",
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase106_profit_cashflow_margin_next_step_20260615_p03_loop",
"final_status": "accepted",
"loop_id": "phase106_profit_cashflow_margin_next_step_20260615_p03_loop",
"target_score": 88,
"iterations_ran": 1,
"quality_score": 92,
"repair_target_count": 0,
"repair_target_severity_counts": {
"P0": 0,
"P1": 0,
"P2": 0
},
"accepted_gate": true,
"effective_runtime": {
"manifest_path": "artifacts\\domain_runs\\phase106_profit_cashflow_margin_next_step_20260615_p03_loop\\effective_runtime.json",
"runner": "domain_case_loop.run-pack-loop",
"git_sha": "d0d7962b65ea699bc5b6dfc82308efaf08d513e7",
"backend_url": "http://127.0.0.1:8787",
"mcp_proxy_url": "http://127.0.0.1:6003",
"llm_provider": "local",
"llm_model": "unsloth/qwen3-30b-a3b-instruct-2507",
"temperature": 0.8,
"max_output_tokens": 900,
"prompt_version": "normalizer_v2_0_2",
"prompt_source": "file",
"prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1",
"prompt_registry_status": "pass"
},
"saved_after_validated_replay": true
}
},
"source_session_id": null,
"session": {
"session_id": null,
"mode": "agent_semantic_run",
"items": [
{
"message_id": "agent-user-001",
"role": "user",
"text": "ООО Альтернатива Плюс",
"created_at": "2026-06-15T17:46:11+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-002",
"role": "user",
"text": "скока денег альтернатива заработала за 20 год?",
"created_at": "2026-06-15T17:46:11+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-003",
"role": "user",
"text": "а это чистая прибыль?",
"created_at": "2026-06-15T17:46:11+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-004",
"role": "user",
"text": "тогда какую прибыль или маржу можно проверить по товарам за 2020?",
"created_at": "2026-06-15T17:46:11+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-005",
"role": "user",
"text": "что дальше проверить, чтобы понять здоровье бизнеса, без гаданий?",
"created_at": "2026-06-15T17:46:11+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
}
],
"agent_run": true,
"metadata": {
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"agent_focus": "Profit/cashflow boundary, inventory margin pivot, organization carryover, and concrete next-check plan after accepted phase106 p03 live replay.",
"architecture_phase": "turnaround_11",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"bare_org_scope",
"business_next_step",
"business_overview",
"cashflow_not_profit",
"cashflow_to_margin_pivot",
"colloquial_cashflow",
"company_scope",
"evidence_plan",
"followup_boundary",
"inventory",
"margin_profitability",
"no_generic_advice",
"profitability_boundary",
"wrong_domain_trap"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase106_profit_cashflow_margin_next_step_20260615_p03_loop",
"saved_after_validated_replay": true,
"save_gate": {
"schema_version": "agent_semantic_save_gate_v1",
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase106_profit_cashflow_margin_next_step_20260615_p03_loop",
"final_status": "accepted",
"loop_id": "phase106_profit_cashflow_margin_next_step_20260615_p03_loop",
"target_score": 88,
"iterations_ran": 1,
"quality_score": 92,
"repair_target_count": 0,
"repair_target_severity_counts": {
"P0": 0,
"P1": 0,
"P2": 0
},
"accepted_gate": true,
"effective_runtime": {
"manifest_path": "artifacts\\domain_runs\\phase106_profit_cashflow_margin_next_step_20260615_p03_loop\\effective_runtime.json",
"runner": "domain_case_loop.run-pack-loop",
"git_sha": "d0d7962b65ea699bc5b6dfc82308efaf08d513e7",
"backend_url": "http://127.0.0.1:8787",
"mcp_proxy_url": "http://127.0.0.1:6003",
"llm_provider": "local",
"llm_model": "unsloth/qwen3-30b-a3b-instruct-2507",
"temperature": 0.8,
"max_output_tokens": 900,
"prompt_version": "normalizer_v2_0_2",
"prompt_source": "file",
"prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1",
"prompt_registry_status": "pass"
},
"saved_after_validated_replay": true
}
}
}
}

View File

@ -0,0 +1,245 @@
{
"saved_at": "2026-06-15T19:52:40+00:00",
"generation_id": "gen-ag06151952-5d8f12",
"mode": "saved_user_sessions",
"title": "AGENT | Phase107 mixed planner brain dogfood",
"agent_run": true,
"questions": [
"привет, ты на связи?",
"найди в 1С контрагента СВК",
"сколько получили по нему за 2020 год",
"а теперь сколько заплатили?",
"а какое нетто?",
"а по документам?",
"а по движениям?",
"Как ты оценишь деятельность компании ООО Альтернатива Плюс по данным 1С?",
"а чем капибара отличается от утки?"
],
"metadata": {
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"agent_focus": "mixed planner autonomy and counterparty evidence carryover",
"architecture_phase": "assistant-stage1 phase107",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"alias_grounding",
"broad_business_evaluation",
"context_boundary",
"context_switch_from_counterparty_to_company",
"date_carryover",
"document_evidence",
"document_pivot",
"entity_resolution",
"followup_anchor",
"followup_reuse",
"grounded_summary",
"human_answer",
"incoming_value_flow",
"mcp_discovery_gate_sanity",
"meta_smalltalk",
"movement_evidence",
"net_value_flow",
"off_domain_living_chat",
"payout_switch",
"planner_catalog_alignment",
"stale_replay_forbidden",
"value_flow_pivot"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase107_mixed_planner_brain_dogfood_20260615_p11_loop",
"saved_after_validated_replay": true,
"save_gate": {
"schema_version": "agent_semantic_save_gate_v1",
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase107_mixed_planner_brain_dogfood_20260615_p11_loop",
"final_status": "accepted",
"loop_id": "phase107_mixed_planner_brain_dogfood_20260615_p11_loop",
"target_score": 88,
"iterations_ran": 1,
"quality_score": 91,
"repair_target_count": 0,
"repair_target_severity_counts": {
"P0": 0,
"P1": 0,
"P2": 0
},
"accepted_gate": true,
"effective_runtime": {
"manifest_path": "artifacts\\domain_runs\\phase107_mixed_planner_brain_dogfood_20260615_p11_loop\\effective_runtime.json",
"runner": "domain_case_loop.run-pack-loop",
"git_sha": "7e70ec1d464548a9f422e35b717834211f8d7683",
"backend_url": "http://127.0.0.1:8787",
"mcp_proxy_url": "http://127.0.0.1:6003",
"llm_provider": "local",
"llm_model": "unsloth/qwen3-30b-a3b-instruct-2507",
"temperature": 0.8,
"max_output_tokens": 900,
"prompt_version": "normalizer_v2_0_2",
"prompt_source": "file",
"prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1",
"prompt_registry_status": "pass"
},
"saved_after_validated_replay": true
}
},
"source_session_id": null,
"session": {
"session_id": null,
"mode": "agent_semantic_run",
"items": [
{
"message_id": "agent-user-001",
"role": "user",
"text": "привет, ты на связи?",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-002",
"role": "user",
"text": "найди в 1С контрагента СВК",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-003",
"role": "user",
"text": "сколько получили по нему за 2020 год",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-004",
"role": "user",
"text": "а теперь сколько заплатили?",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-005",
"role": "user",
"text": "а какое нетто?",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-006",
"role": "user",
"text": "а по документам?",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-007",
"role": "user",
"text": "а по движениям?",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-008",
"role": "user",
"text": "Как ты оценишь деятельность компании ООО Альтернатива Плюс по данным 1С?",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
},
{
"message_id": "agent-user-009",
"role": "user",
"text": "а чем капибара отличается от утки?",
"created_at": "2026-06-15T19:52:40+00:00",
"reply_type": null,
"trace_id": null,
"debug": null
}
],
"agent_run": true,
"metadata": {
"assistant_prompt_version": null,
"decomposition_prompt_version": null,
"prompt_fingerprint": null,
"agent_focus": "mixed planner autonomy and counterparty evidence carryover",
"architecture_phase": "assistant-stage1 phase107",
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\active_domain_contract.json",
"scenario_id": null,
"semantic_tags": [
"alias_grounding",
"broad_business_evaluation",
"context_boundary",
"context_switch_from_counterparty_to_company",
"date_carryover",
"document_evidence",
"document_pivot",
"entity_resolution",
"followup_anchor",
"followup_reuse",
"grounded_summary",
"human_answer",
"incoming_value_flow",
"mcp_discovery_gate_sanity",
"meta_smalltalk",
"movement_evidence",
"net_value_flow",
"off_domain_living_chat",
"payout_switch",
"planner_catalog_alignment",
"stale_replay_forbidden",
"value_flow_pivot"
],
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase107_mixed_planner_brain_dogfood_20260615_p11_loop",
"saved_after_validated_replay": true,
"save_gate": {
"schema_version": "agent_semantic_save_gate_v1",
"validation_status": "accepted_domain_pack_loop",
"validated_run_dir": "artifacts\\domain_runs\\phase107_mixed_planner_brain_dogfood_20260615_p11_loop",
"final_status": "accepted",
"loop_id": "phase107_mixed_planner_brain_dogfood_20260615_p11_loop",
"target_score": 88,
"iterations_ran": 1,
"quality_score": 91,
"repair_target_count": 0,
"repair_target_severity_counts": {
"P0": 0,
"P1": 0,
"P2": 0
},
"accepted_gate": true,
"effective_runtime": {
"manifest_path": "artifacts\\domain_runs\\phase107_mixed_planner_brain_dogfood_20260615_p11_loop\\effective_runtime.json",
"runner": "domain_case_loop.run-pack-loop",
"git_sha": "7e70ec1d464548a9f422e35b717834211f8d7683",
"backend_url": "http://127.0.0.1:8787",
"mcp_proxy_url": "http://127.0.0.1:6003",
"llm_provider": "local",
"llm_model": "unsloth/qwen3-30b-a3b-instruct-2507",
"temperature": 0.8,
"max_output_tokens": 900,
"prompt_version": "normalizer_v2_0_2",
"prompt_source": "file",
"prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1",
"prompt_registry_status": "pass"
},
"saved_after_validated_replay": true
}
}
}
}

View File

@ -0,0 +1,37 @@
{
"suite_id": "assistant_saved_session_gen-ag06151720-c4b54f",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_suite_v0_1",
"generated_at": "2026-06-15T17:20:44+00:00",
"generation_id": "gen-ag06151720-c4b54f",
"mode": "saved_user_sessions",
"title": "AGENT | Phase104 role-tail anchor hygiene and bidirectional value-flow",
"domain": "address_phase104_generic_role_tail_anchor_hygiene",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "agent_saved_user_sessions",
"title": "AGENT | Phase104 role-tail anchor hygiene and bidirectional value-flow",
"question_type": "followup",
"broadness_level": "medium",
"turns": [
{
"user_message": "ООО Альтернатива Плюс"
},
{
"user_message": "Теперь дай взрослый обзор за 2020 по компании: входящие, исходящие, нетто, топы, но банк в топах отдельно объясни как финансовый поток, если по назначению он не обычный клиент или поставщик."
},
{
"user_message": "А отдельно по СБЕРБАНКУ: он для нас клиент, поставщик или финансовый поток? Дай коротко по подтвержденным строкам."
},
{
"user_message": "А теперь по поставщику Группа СВК за 2020: сколько мы ему заплатили и какой общий денежный смысл?"
}
]
}
]
}

View File

@ -0,0 +1,40 @@
{
"suite_id": "assistant_saved_session_gen-ag06151746-7d3a57",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_suite_v0_1",
"generated_at": "2026-06-15T17:46:11+00:00",
"generation_id": "gen-ag06151746-7d3a57",
"mode": "saved_user_sessions",
"title": "AGENT | Phase106 profit cashflow margin next checks",
"domain": "address_phase106_profit_cashflow_margin_next_step",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "agent_saved_user_sessions",
"title": "AGENT | Phase106 profit cashflow margin next checks",
"question_type": "followup",
"broadness_level": "medium",
"turns": [
{
"user_message": "ООО Альтернатива Плюс"
},
{
"user_message": "скока денег альтернатива заработала за 20 год?"
},
{
"user_message": "а это чистая прибыль?"
},
{
"user_message": "тогда какую прибыль или маржу можно проверить по товарам за 2020?"
},
{
"user_message": "что дальше проверить, чтобы понять здоровье бизнеса, без гаданий?"
}
]
}
]
}

View File

@ -0,0 +1,52 @@
{
"suite_id": "assistant_saved_session_gen-ag06151952-5d8f12",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_suite_v0_1",
"generated_at": "2026-06-15T19:52:40+00:00",
"generation_id": "gen-ag06151952-5d8f12",
"mode": "saved_user_sessions",
"title": "AGENT | Phase107 mixed planner brain dogfood",
"domain": "address_phase107_mixed_planner_brain_dogfood",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "agent_saved_user_sessions",
"title": "AGENT | Phase107 mixed planner brain dogfood",
"question_type": "followup",
"broadness_level": "medium",
"turns": [
{
"user_message": "привет, ты на связи?"
},
{
"user_message": "найди в 1С контрагента СВК"
},
{
"user_message": "сколько получили по нему за 2020 год"
},
{
"user_message": "а теперь сколько заплатили?"
},
{
"user_message": "а какое нетто?"
},
{
"user_message": "а по документам?"
},
{
"user_message": "а по движениям?"
},
{
"user_message": "Как ты оценишь деятельность компании ООО Альтернатива Плюс по данным 1С?"
},
{
"user_message": "а чем капибара отличается от утки?"
}
]
}
]
}

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
@ -13,10 +14,40 @@ DETECTOR_REGISTRY_PATH = REPO_ROOT / "docs" / "orchestration" / "detector_regist
ISSUE_CATALOG_PATH = REPO_ROOT / "docs" / "orchestration" / "issue_catalog.json"
DEFAULT_GLOBAL_DETECTORS = ["missing_effective_runtime_json"]
DETECTOR_RESULTS_SCHEMA_VERSION = "agent_detector_results_v1"
DEFAULT_LIMITED_NEXT_ACTION_EXTRA_PATTERNS = [
r"(?i)(что\s+проверить\s+дальше|для\s+ответа.{0,120}нужно|нужно\s+отдельно\s+(?:считать|посчитать|проверить)|проверить\s+дальше)"
]
def path_for_io(path: Path) -> str:
if os.name != "nt":
return str(path)
raw_value = str(path)
if raw_value.startswith("\\\\?\\"):
return raw_value
absolute_path = path if path.is_absolute() else Path.cwd() / path
absolute_value = str(absolute_path.absolute())
if absolute_value.startswith("\\\\?\\"):
return absolute_value
if absolute_value.startswith("\\\\"):
return "\\\\?\\UNC\\" + absolute_value.lstrip("\\")
return "\\\\?\\" + absolute_value
def path_exists(path: Path) -> bool:
try:
return os.path.exists(path_for_io(path))
except OSError:
return False
def read_text(path: Path) -> str:
with open(path_for_io(path), "r", encoding="utf-8") as handle:
return handle.read()
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
return json.loads(read_text(path))
def read_json_object(path: Path) -> dict[str, Any]:
@ -28,8 +59,9 @@ def read_json_object(path: Path) -> dict[str, Any]:
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.makedirs(path_for_io(path.parent), exist_ok=True)
with open(path_for_io(path), "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
def utc_now() -> str:
@ -161,13 +193,50 @@ def select_detectors(
def read_text_or_empty(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
return read_text(path)
except OSError:
return ""
def decode_win1251_utf8_mojibake(value: str) -> str | None:
try:
return value.encode("cp1251").decode("utf-8")
except UnicodeError:
return None
def decode_latin1_utf8_mojibake(value: str) -> str | None:
try:
return value.encode("latin1").decode("utf-8")
except UnicodeError:
return None
def text_match_variants(value: str) -> list[str]:
variants: list[str] = []
queue = [str(value or "")]
while queue:
current = queue.pop(0)
if current in variants:
continue
variants.append(current)
for decoded in (decode_win1251_utf8_mojibake(current), decode_latin1_utf8_mojibake(current)):
if decoded and decoded not in variants and decoded not in queue:
queue.append(decoded)
return variants
def first_pattern_search(patterns: list[re.Pattern[str]], text: str) -> re.Match[str] | None:
for variant in text_match_variants(text):
for pattern in patterns:
match = pattern.search(variant)
if match:
return match
return None
def assistant_text_from_turn_path(path: Path | None) -> str:
if path is None or not path.exists():
if path is None or not path_exists(path):
return ""
payload = read_json_object(path)
assistant_message = payload.get("assistant_message") if isinstance(payload.get("assistant_message"), dict) else {}
@ -224,14 +293,21 @@ def output_turn_path(output_path: Path) -> Path | None:
name = output_path.name
if name == "output.md":
candidate = output_path.with_name("turn.json")
return candidate if candidate.exists() else None
return candidate if path_exists(candidate) else None
if name.endswith("_output.md"):
prefix = name[: -len("_output.md")]
candidate = output_path.with_name(f"{prefix}_turn.json")
return candidate if candidate.exists() else None
return candidate if path_exists(candidate) else None
return None
def output_step_state_path(output_path: Path) -> Path | None:
if output_path.name != "output.md":
return None
candidate = output_path.with_name("step_state.json")
return candidate if path_exists(candidate) else None
def collect_output_artifacts(artifact_dir: Path) -> list[dict[str, Any]]:
outputs: list[dict[str, Any]] = []
seen: set[Path] = set()
@ -252,6 +328,7 @@ def collect_output_artifacts(artifact_dir: Path) -> list[dict[str, Any]]:
"artifact_path": str(path.relative_to(artifact_dir)),
"text": read_text_or_empty(path),
"turn_path": turn_path,
"step_state_path": output_step_state_path(path),
}
)
return outputs
@ -384,7 +461,7 @@ def evaluate_forbidden_regex(
lines = [line for line in text.splitlines() if line.strip()]
text = "\n".join(lines[:prefix_line_count])
for pattern in patterns:
match = pattern.search(text)
match = first_pattern_search([pattern], text)
if match:
evidence.append(
{
@ -411,7 +488,7 @@ def evaluate_required_any(
matched: list[dict[str, Any]] = []
for output in outputs:
text = current_answer_text_for_output(output)
match = next((pattern.search(text) for pattern in patterns if pattern.search(text)), None)
match = first_pattern_search(patterns, text)
if match:
matched.append({"path": output["repo_path"], "match": match.group(0)[:240]})
else:
@ -428,18 +505,24 @@ def evaluate_limited_next_action(
) -> dict[str, Any]:
check = detector.get("check") if isinstance(detector.get("check"), dict) else {}
limited_patterns = compile_patterns(normalize_string_list(check.get("limited_patterns")))
next_action_patterns = compile_patterns(normalize_string_list(check.get("required_next_action_patterns_any")))
next_action_pattern_sources = [
*normalize_string_list(check.get("required_next_action_patterns_any")),
*normalize_string_list(check.get("required_next_action_patterns_extra_any")),
]
if detector_name == "limited_answer_without_next_action":
next_action_pattern_sources.extend(DEFAULT_LIMITED_NEXT_ACTION_EXTRA_PATTERNS)
next_action_patterns = compile_patterns(next_action_pattern_sources)
if not outputs:
return build_result(detector_name, detector, "skipped", "no output.md-style artifacts matched detector scope")
failures: list[dict[str, Any]] = []
limited_outputs = 0
for output in outputs:
text = current_answer_text_for_output(output)
limited_match = next((pattern.search(text) for pattern in limited_patterns if pattern.search(text)), None)
limited_match = first_pattern_search(limited_patterns, text)
if not limited_match:
continue
limited_outputs += 1
if not any(pattern.search(text) for pattern in next_action_patterns):
if not first_pattern_search(next_action_patterns, text):
failures.append({"path": output["repo_path"], "limited_match": limited_match.group(0)[:240]})
status = "fail" if failures else "pass"
if failures:
@ -451,6 +534,52 @@ def evaluate_limited_next_action(
return build_result(detector_name, detector, status, message, evidence=failures)
def evaluate_answer_text_shape(
detector_name: str,
detector: dict[str, Any],
outputs: list[dict[str, Any]],
) -> dict[str, Any]:
if not outputs:
return build_result(detector_name, detector, "skipped", "no output.md-style artifacts matched detector scope")
failures: list[dict[str, Any]] = []
reviewed: list[dict[str, Any]] = []
unknown: list[dict[str, Any]] = []
for output in outputs:
step_state_path = output.get("step_state_path")
step_state = read_json_object(step_state_path) if isinstance(step_state_path, Path) else {}
review = step_state.get("business_first_review") if isinstance(step_state.get("business_first_review"), dict) else {}
direct_answer_first_ok = review.get("direct_answer_first_ok")
if direct_answer_first_ok is True:
reviewed.append({"path": output["repo_path"], "direct_answer_first_ok": True})
elif direct_answer_first_ok is False:
failures.append({"path": output["repo_path"], "direct_answer_first_ok": False})
else:
unknown.append({"path": output["repo_path"], "reason": "business_first_review_missing"})
if failures:
return build_result(
detector_name,
detector,
"fail",
"first-line direct answer check failed",
evidence=[*failures, *unknown],
)
if unknown:
return build_result(
detector_name,
detector,
"review",
"direct-answer shape requires business review",
evidence=[*reviewed, *unknown],
)
return build_result(
detector_name,
detector,
"pass",
"business-first step reviews confirm direct-answer-first shape",
evidence=reviewed,
)
def evaluate_trace_guard(
detector_name: str,
detector: dict[str, Any],
@ -640,7 +769,7 @@ def evaluate_detector(
):
return evaluate_composite(detector_name, detector, results_by_name)
if kind == "answer_text_shape":
return evaluate_manual_review(detector_name, detector, "direct-answer shape requires business review")
return evaluate_answer_text_shape(detector_name, detector, scoped_outputs)
return build_result(detector_name, detector, "skipped", f"detector kind is not executable yet: {kind}")

View File

@ -317,6 +317,7 @@ DEFAULT_INVARIANT_SEVERITY: dict[str, str] = {
"forbidden_recipe_selected": "P0",
"focus_object_missing": "P0",
"wrong_date_scope_state": "P0",
"out_of_window_date_in_answer": "P0",
"direct_answer_missing": "P0",
"top_level_noise_present": "P0",
"business_direct_answer_missing": "P0",
@ -930,6 +931,40 @@ def first_non_empty_lines(text: str, limit: int = 3) -> list[str]:
return output
def expected_single_year_from_date_scope(date_scope: Any) -> str | None:
if not isinstance(date_scope, dict):
return None
if str(date_scope.get("scope") or "").strip().lower() == "all_time":
return None
period_from = normalize_iso_date(date_scope.get("period_from"))
period_to = normalize_iso_date(date_scope.get("period_to"))
if not period_from or not period_to:
return None
from_year = period_from[:4]
to_year = period_to[:4]
if from_year != to_year:
return None
return from_year if re.fullmatch(r"(?:19|20)\d{2}", from_year) else None
def dated_evidence_years_from_answer(text: Any) -> list[str]:
source = repair_text_mojibake(str(text or ""))
years: list[str] = []
for match in re.finditer(r"\b((?:19|20)\d{2})-\d{2}-\d{2}(?:T|\b)", source):
years.append(match.group(1))
for match in re.finditer(r"\b\d{1,2}\.\d{1,2}\.((?:19|20)\d{2})\b", source):
years.append(match.group(1))
return list(dict.fromkeys(years))
def answer_has_out_of_window_dates_for_scope(text: Any, date_scope: Any) -> bool:
expected_year = expected_single_year_from_date_scope(date_scope)
if not expected_year:
return False
years = dated_evidence_years_from_answer(text)
return any(year != expected_year for year in years)
def build_node_contract_index(raw_contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
scenario_tree = raw_contract.get("scenario_tree")
if not isinstance(scenario_tree, dict):
@ -1695,6 +1730,47 @@ def resolve_question_template(question_template: str, scenario_state: dict[str,
return pattern.sub(replace, question_template).strip()
def resolve_scenario_contract_placeholders(value: Any, scenario_state: dict[str, Any]) -> Any:
if isinstance(value, str):
runtime_resolved = str(resolve_runtime_placeholders(value))
if "{{" not in runtime_resolved:
return runtime_resolved
pattern = re.compile(r"{{\s*([^{}]+?)\s*}}")
def replace(match: re.Match[str]) -> str:
value = lookup_placeholder_value(match.group(1), scenario_state)
if isinstance(value, (dict, list)):
return dump_json(value)
return str(value)
return pattern.sub(replace, runtime_resolved)
if isinstance(value, list):
return [resolve_scenario_contract_placeholders(item, scenario_state) for item in value]
if isinstance(value, dict):
return {
key: resolve_scenario_contract_placeholders(item, scenario_state)
for key, item in value.items()
}
return value
def resolve_step_contract_placeholders(step: dict[str, Any], scenario_state: dict[str, Any]) -> dict[str, Any]:
resolved_step = dict(step)
resolved_step["required_filters"] = normalize_validation_filters(
resolve_scenario_contract_placeholders(step.get("required_filters") or {}, scenario_state)
)
for pattern_field in (
"forbidden_answer_patterns",
"required_answer_patterns_any",
"required_answer_patterns_all",
):
if pattern_field in step:
resolved_step[pattern_field] = normalize_pattern_list(
resolve_scenario_contract_placeholders(step.get(pattern_field) or [], scenario_state)
)
return resolved_step
def build_failed_step_state(
*,
scenario_id: str,
@ -2313,6 +2389,9 @@ def step_intent_matches_expected(state: dict[str, Any], expected_intents: list[s
observed_intents = [
str(state.get("detected_intent") or "").strip(),
*normalize_string_list(state.get("mcp_discovery_effective_intents")),
str(state.get("mcp_discovery_selected_chain_id") or "").strip(),
str(state.get("mcp_discovery_route_candidate_fact_family") or "").strip(),
str(state.get("mcp_discovery_route_candidate_action_family") or "").strip(),
]
return any(identifier_in_list(intent, expected_intents) for intent in observed_intents if intent)
@ -2551,6 +2630,8 @@ def is_validated_memory_checkpoint_answer(
) -> bool:
if is_validated_comparison_boundary_memory_checkpoint(state, business_review, violations):
return True
if is_validated_bare_organization_scope_checkpoint(state, business_review, violations):
return True
tags = set(normalize_string_list(state.get("semantic_tags")))
if "memory" not in tags:
return False
@ -2566,6 +2647,39 @@ def is_validated_memory_checkpoint_answer(
)
def is_validated_bare_organization_scope_checkpoint(
state: dict[str, Any],
business_review: dict[str, Any],
violations: list[str],
) -> bool:
if violations:
return False
tags = set(normalize_string_list(state.get("semantic_tags")))
if not tags.intersection({"bare_org_scope", "company_scope", "company_selected"}):
return False
if str(state.get("living_chat_response_source") or "").strip() != "deterministic_data_scope_selection_contract":
return False
if str(state.get("living_chat_data_scope_probe_status") or "").strip() != "resolved":
return False
selected_organization = first_non_empty_string(
state.get("living_chat_selected_organization"),
state.get("assistant_active_organization"),
)
if not selected_organization:
return False
assistant_text = str(state.get("assistant_text") or "")
normalized_answer = normalize_entity_filter_value_for_comparison(assistant_text)
normalized_organization = normalize_entity_filter_value_for_comparison(selected_organization)
if not normalized_organization or normalized_organization not in normalized_answer:
return False
return (
business_review.get("business_usefulness_ok") is True
and business_review.get("direct_answer_first_ok") is True
and business_review.get("answer_layering_ok") is True
and business_review.get("technical_garbage_present") is False
)
def is_validated_comparison_boundary_memory_checkpoint(
state: dict[str, Any],
business_review: dict[str, Any],
@ -2788,7 +2902,12 @@ def is_validated_clean_meta_chat_answer(
return False
semantic_tags = set(normalize_string_list(state.get("semantic_tags")))
allowed_tags = {
"human_answer",
"meta_smalltalk",
"mcp_discovery_gate_sanity",
"off_domain_living_chat",
"stale_replay_forbidden",
"context_boundary",
"company_selected",
"organization_authority",
"meta_capability",
@ -3033,6 +3152,10 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
if current_date_scope and current_date_scope != required_filters["as_of_date"]:
violated_invariants.append("wrong_date_scope_state")
if answer_has_out_of_window_dates_for_scope(assistant_text, date_scope):
violated_invariants.append("out_of_window_date_in_answer")
warnings.append("out_of_window_date_in_answer")
if should_require_direct_answer(state):
if not actual_direct_answer or is_top_level_noise_line(actual_direct_answer):
violated_invariants.append("direct_answer_missing")
@ -3114,8 +3237,10 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
state["clarification_answer_validated"] = clarification_validated
state["missing_axis_clarification_validated"] = missing_axis_clarification_validated
state["clean_meta_chat_answer_validated"] = clean_meta_chat_validated
effective_execution_status = "exact" if clean_meta_chat_validated else execution_status
state["execution_status"] = effective_execution_status
state["acceptance_status"] = acceptance_status_from_execution(
execution_status,
effective_execution_status,
hard_fail,
(
bounded_validated
@ -3235,6 +3360,10 @@ def build_scenario_step_state(
"detected_intent": debug.get("detected_intent"),
"selected_recipe": debug.get("selected_recipe"),
"capability_id": debug.get("capability_id"),
"living_chat_response_source": debug.get("living_chat_response_source"),
"living_chat_data_scope_probe_status": debug.get("living_chat_data_scope_probe_status"),
"living_chat_selected_organization": debug.get("living_chat_selected_organization"),
"assistant_active_organization": debug.get("assistant_active_organization"),
"capability_contract_id": first_non_empty_string(
debug.get("capability_contract_id"),
runtime_contract.get("capability_contract_id"),
@ -3600,6 +3729,7 @@ def execute_scenario_manifest(
step_dir = steps_dir / step["step_id"]
try:
resolved_question = resolve_question_template(step["question_template"], scenario_state)
resolved_step = resolve_step_contract_placeholders(step, scenario_state)
step_analysis_context = merge_analysis_context(manifest.get("analysis_context"), step.get("analysis_context"))
step_analysis_context = carry_forward_analysis_context(
scenario_state,
@ -3611,7 +3741,7 @@ def execute_scenario_manifest(
args=args,
domain=manifest["domain"],
scenario_id=manifest["scenario_id"],
step=step,
step=resolved_step,
step_index=step_index,
session_id=scenario_state.get("session_id"),
question_resolved=resolved_question,
@ -6225,7 +6355,22 @@ def summarize_detector_results(detector_results: dict[str, Any] | None, *, limit
return names[:limit]
status = str(summary.get("status") or "skipped")
declared_detector_count = len(normalize_string_list(detector_results.get("declared_detectors_under_test")))
declared_detectors = normalize_string_list(detector_results.get("declared_detectors_under_test"))
declared_detector_count = len(declared_detectors)
def _declared_with_status(status: str) -> list[str]:
names: list[str] = []
declared = set(declared_detectors)
if not declared:
return names
for item in results:
if not isinstance(item, dict) or str(item.get("status") or "") != status:
continue
detector_name = str(item.get("detector") or "").strip()
if detector_name in declared and detector_name not in names:
names.append(detector_name)
return names[:limit]
return {
"status": status,
"detector_count": int(summary.get("detector_count") or len(results)),
@ -6237,6 +6382,7 @@ def summarize_detector_results(detector_results: dict[str, Any] | None, *, limit
"failed_detectors": _detectors_with_status("fail"),
"review_detectors": _detectors_with_status("review"),
"skipped_detectors": _detectors_with_status("skipped"),
"declared_skipped_detectors": _declared_with_status("skipped"),
"signal_ok_for_auto_coder": status in {"fail", "review"},
}
@ -6247,6 +6393,9 @@ def evaluate_detector_results_gate(detector_results_summary: dict[str, Any]) ->
detector_count = int(detector_results_summary.get("detector_count") or 0)
if declared_detector_count > 0 and detector_count == 0:
return False, "declared_detector_results_missing"
declared_skipped = normalize_string_list(detector_results_summary.get("declared_skipped_detectors"))
if declared_skipped:
return False, f"declared_detector_results_skipped:{','.join(declared_skipped)}"
if declared_detector_count > 0 and status == "skipped":
return False, "declared_detector_results_skipped"
if status == "fail":

View File

@ -720,7 +720,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--prompt-fingerprint", help="Optional prompt fingerprint metadata.")
parser.add_argument(
"--validated-run-dir",
help="Accepted truth-harness artifact directory containing pack_state.json, truth_review.json, and business_review.json.",
help=(
"Accepted replay artifact directory. Supports truth-harness runs "
"(pack_state/truth_review/business_review), domain-case-loop pack/scenario dirs, "
"and run-pack-loop dirs with loop_state.json."
),
)
parser.add_argument(
"--allow-unvalidated",

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
@ -22,6 +23,10 @@ def write_text(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
def to_win1251_mojibake(value: str) -> str:
return value.encode("utf-8").decode("cp1251")
class AgentDetectorRunnerTests(unittest.TestCase):
def test_default_runner_fails_missing_effective_runtime(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@ -102,6 +107,59 @@ class AgentDetectorRunnerTests(unittest.TestCase):
self.assertEqual(results["summary"]["status"], "pass")
self.assertEqual(results["results"][0]["status"], "pass")
def test_limited_next_action_accepts_mojibake_what_to_check_next(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
artifact_dir = root / "run"
answer = (
"Это не подтвержденная чистая прибыль.\n"
"Что проверить дальше: чистую прибыль через счета 90/91/99 и закрытие периода."
)
write_text(artifact_dir / "steps" / "s01" / "output.md", to_win1251_mojibake(answer))
registry_path = root / "detector_registry.json"
issue_catalog_path = root / "issue_catalog.json"
write_json(
registry_path,
{
"schema_version": "agent_detector_registry_v1",
"detectors": {
"limited_answer_without_next_action": {
"kind": "answer_text_required_when_limited",
"automation_level": "semi_automatic",
"description": "Limited answers need a next action.",
"issue_codes": ["business_next_step_missing"],
"inputs": ["output.md"],
"check": {
"limited_patterns": ["(?i)не подтвержден"],
"required_next_action_patterns_any": ["(?i)(можно|проверь)"],
},
}
},
},
)
write_json(
issue_catalog_path,
{
"schema_version": "agent_issue_catalog_v1",
"issues": {
"business_next_step_missing": {
"detectors": ["limited_answer_without_next_action"],
}
},
},
)
results = runner.build_detector_results(
artifact_dir,
issue_codes=["business_next_step_missing"],
registry_path=registry_path,
issue_catalog_path=issue_catalog_path,
include_default_global=False,
)
self.assertEqual(results["summary"]["status"], "pass")
self.assertEqual(results["results"][0]["message"], "limited answer includes a next action")
def test_candidate_forbidden_regex_is_limited_to_evidence_path(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@ -171,7 +229,7 @@ class AgentDetectorRunnerTests(unittest.TestCase):
root = Path(tmp)
artifact_dir = root / "run"
write_text(
artifact_dir / "scenarios" / "margin_root" / "steps" / "step_01" / "output.md",
artifact_dir / "scenarios" / "profit_cashflow_boundary" / "steps" / "step_01_margin_root" / "output.md",
"Top margin item: revenue 100, cogs 60, gross profit 40, margin 40%.",
)
write_text(
@ -183,6 +241,15 @@ class AgentDetectorRunnerTests(unittest.TestCase):
/ "output.md",
"Supplier for the selected item: Example LLC.",
)
write_text(
artifact_dir
/ "scenarios"
/ "business_health"
/ "steps"
/ "step_05_next_checks"
/ "output.md",
"Check profit, debt, inventory, taxes, and customer concentration next.",
)
registry_path = root / "detector_registry.json"
issue_catalog_path = root / "issue_catalog.json"
write_json(
@ -198,6 +265,7 @@ class AgentDetectorRunnerTests(unittest.TestCase):
"inputs": ["output.md"],
"check": {
"required_patterns_any": ["(?i)(revenue|cogs|gross profit|margin)"],
"artifact_path_include_patterns": ["(?i)steps[\\\\/][^\\\\/]*(?:margin|profitability)"],
"artifact_path_exclude_patterns": ["(?i)selected_item"],
},
}
@ -218,7 +286,183 @@ class AgentDetectorRunnerTests(unittest.TestCase):
self.assertEqual(results["results"][0]["status"], "pass")
evidence_paths = [item["path"] for item in results["results"][0]["evidence"]]
self.assertEqual(len(evidence_paths), 1)
self.assertIn("margin_root", evidence_paths[0])
self.assertIn("step_01_margin_root", evidence_paths[0])
def test_counterparty_value_flow_required_surface_scopes_to_current_net_step(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
artifact_dir = root / "run"
write_text(
artifact_dir
/ "scenarios"
/ "mixed_planner_counterparty_evidence_and_living_guard"
/ "steps"
/ "step_03_incoming_by_resolved_entity"
/ "output.md",
"Входящие денежные поступления по контрагенту Группа СВК за 2020: 12 093 465 руб.",
)
write_text(
artifact_dir
/ "scenarios"
/ "mixed_planner_counterparty_evidence_and_living_guard"
/ "steps"
/ "step_05_net_after_payout"
/ "output.md",
"По контрагенту Группа СВК за период 2020 получили 12 093 465 руб., заплатили 0 руб.; расчетное нетто в нашу сторону: 12 093 465 руб.",
)
registry_path = root / "detector_registry.json"
issue_catalog_path = root / "issue_catalog.json"
write_json(
registry_path,
{
"schema_version": "agent_detector_registry_v1",
"detectors": {
"counterparty_value_flow_required_surface": {
"kind": "answer_text_required_any",
"automation_level": "automatic",
"description": "Net value-flow answer must surface all money directions.",
"issue_codes": ["counterparty_value_flow_misrouted_to_company_profit"],
"inputs": ["output.md"],
"check": {
"artifact_path_include_patterns": ["(?i)step_05_net_after_payout"],
"required_patterns_any": [
"(?is)(?=.*(СВК|Группа\\s+СВК))(?=.*(входящ|получил|получено|получили))(?=.*(исходящ|заплатил|заплачено|заплатили|ушло))(?=.*(нетто|сальдо|разниц|чистый\\s+денежный))"
],
},
}
},
},
)
write_json(issue_catalog_path, {"schema_version": "agent_issue_catalog_v1", "issues": {}})
results = runner.build_detector_results(
artifact_dir,
detector_names=["counterparty_value_flow_required_surface"],
registry_path=registry_path,
issue_catalog_path=issue_catalog_path,
include_default_global=False,
)
self.assertEqual(results["summary"]["status"], "pass")
self.assertEqual(results["results"][0]["status"], "pass")
evidence_paths = [item["path"] for item in results["results"][0]["evidence"]]
self.assertEqual(len(evidence_paths), 1)
self.assertIn("step_05_net_after_payout", evidence_paths[0])
def test_answer_text_shape_uses_business_first_step_review(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
artifact_dir = root / "run"
step_dir = artifact_dir / "scenarios" / "mixed" / "steps" / "step_01"
write_text(step_dir / "output.md", "Direct business answer first.")
write_json(step_dir / "step_state.json", {"business_first_review": {"direct_answer_first_ok": True}})
registry_path = root / "detector_registry.json"
issue_catalog_path = root / "issue_catalog.json"
write_json(
registry_path,
{
"schema_version": "agent_detector_registry_v1",
"detectors": {
"first_line_not_direct_answer": {
"kind": "answer_text_shape",
"automation_level": "semi_automatic",
"description": "First line should be direct.",
"issue_codes": ["business_direct_answer_missing"],
"inputs": ["output.md"],
"check": {"first_line_should_be": "business_answer_or_honest_boundary"},
}
},
},
)
write_json(issue_catalog_path, {"schema_version": "agent_issue_catalog_v1", "issues": {}})
results = runner.build_detector_results(
artifact_dir,
detector_names=["first_line_not_direct_answer"],
registry_path=registry_path,
issue_catalog_path=issue_catalog_path,
include_default_global=False,
)
self.assertEqual(results["summary"]["status"], "pass")
self.assertEqual(results["results"][0]["status"], "pass")
def test_answer_text_shape_fails_when_business_first_review_rejects_first_line(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
artifact_dir = root / "run"
step_dir = artifact_dir / "scenarios" / "mixed" / "steps" / "step_01"
write_text(step_dir / "output.md", "Let me inspect the route first.")
write_json(step_dir / "step_state.json", {"business_first_review": {"direct_answer_first_ok": False}})
registry_path = root / "detector_registry.json"
issue_catalog_path = root / "issue_catalog.json"
write_json(
registry_path,
{
"schema_version": "agent_detector_registry_v1",
"detectors": {
"first_line_not_direct_answer": {
"kind": "answer_text_shape",
"automation_level": "semi_automatic",
"description": "First line should be direct.",
"issue_codes": ["business_direct_answer_missing"],
"inputs": ["output.md"],
"check": {"first_line_should_be": "business_answer_or_honest_boundary"},
}
},
},
)
write_json(issue_catalog_path, {"schema_version": "agent_issue_catalog_v1", "issues": {}})
results = runner.build_detector_results(
artifact_dir,
detector_names=["first_line_not_direct_answer"],
registry_path=registry_path,
issue_catalog_path=issue_catalog_path,
include_default_global=False,
)
self.assertEqual(results["summary"]["status"], "fail")
self.assertEqual(results["results"][0]["status"], "fail")
def test_step_state_lookup_handles_long_artifact_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
step_dir = (
root
/ "run"
/ "scenarios"
/ ("mixed_planner_counterparty_evidence_and_living_guard_" + "s" * 48)
/ "steps"
/ ("step_04_payout_switch_by_resolved_entity_" + "x" * 80)
)
output_path = step_dir / "output.md"
step_state_path = step_dir / "step_state.json"
try:
os.makedirs(runner.path_for_io(step_dir), exist_ok=True)
with open(runner.path_for_io(output_path), "w", encoding="utf-8") as handle:
handle.write("Direct business answer first.")
runner.write_json(step_state_path, {"business_first_review": {"direct_answer_first_ok": True}})
self.assertGreater(len(str(step_state_path.absolute())), 260)
self.assertEqual(runner.output_step_state_path(output_path), step_state_path)
self.assertEqual(
runner.read_json_object(step_state_path).get("business_first_review"),
{"direct_answer_first_ok": True},
)
finally:
for path in (output_path, step_state_path):
try:
os.remove(runner.path_for_io(path))
except OSError:
pass
current = step_dir
while current != root:
try:
os.rmdir(runner.path_for_io(current))
except OSError:
pass
current = current.parent
def test_composite_detector_fails_after_child_detector_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:

View File

@ -404,6 +404,24 @@ class DomainCaseLoopLeadHandoffTests(unittest.TestCase):
self.assertEqual(summary["declared_detector_count"], 1)
self.assertEqual(reason, "declared_detector_results_missing")
def test_detector_gate_blocks_declared_detector_skipped_inside_mixed_pass_run(self) -> None:
summary = dcl.summarize_detector_results(
{
"summary": {"status": "pass", "detector_count": 2, "pass": 1, "fail": 0, "review": 0, "skipped": 1},
"results": [
{"detector": "runtime_tokens_in_user_answer", "status": "pass"},
{"detector": "counterparty_value_flow_profit_mismatch", "status": "skipped"},
],
"declared_detectors_under_test": ["counterparty_value_flow_profit_mismatch"],
}
)
ok, reason = dcl.evaluate_detector_results_gate(summary)
self.assertFalse(ok)
self.assertEqual(summary["declared_skipped_detectors"], ["counterparty_value_flow_profit_mismatch"])
self.assertEqual(reason, "declared_detector_results_skipped:counterparty_value_flow_profit_mismatch")
def test_auto_coder_gate_allows_when_detector_results_confirm_failure(self) -> None:
repair_targets = {
"targets": [

View File

@ -153,6 +153,81 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
self.assertEqual(dcl.derive_scenario_execution_status(step_outputs), "partial")
self.assertEqual(dcl.derive_scenario_status(step_outputs), "accepted")
def test_clean_meta_smalltalk_is_exact_without_1c_capability(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="meta_chat_demo",
domain="agentic_loop",
step={
"step_id": "step_01",
"title": "Human smalltalk",
"depends_on": [],
"question_template": "привет, ты на связи?",
"semantic_tags": ["human_answer", "meta_smalltalk", "mcp_discovery_gate_sanity"],
"required_answer_shape": "direct_answer_first",
},
step_index=1,
question_resolved="привет, ты на связи?",
analysis_context={},
turn_artifact={
"assistant_message": {
"reply_type": "factual_with_explanation",
"text": "Привет! Да, я на связи. Готов помочь с анализом данных из 1С в режиме чтения.",
"message_id": "msg-1",
"trace_id": "trace-1",
},
"technical_debug_payload": {
"detected_mode": "chat",
"fallback_type": "none",
"living_chat_response_source": "llm_chat",
},
"session_summary": {},
},
entries=[],
)
self.assertTrue(step_state["clean_meta_chat_answer_validated"])
self.assertEqual(step_state["execution_status"], "exact")
self.assertEqual(step_state["acceptance_status"], "validated")
def test_off_domain_living_chat_is_exact_without_1c_capability(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="off_domain_demo",
domain="agentic_loop",
step={
"step_id": "step_09",
"title": "Off-domain living chat",
"depends_on": [],
"question_template": "а чем капибара отличается от утки?",
"semantic_tags": ["off_domain_living_chat", "stale_replay_forbidden", "context_boundary"],
"required_answer_shape": "direct_answer_first",
},
step_index=9,
question_resolved="а чем капибара отличается от утки?",
analysis_context={},
turn_artifact={
"assistant_message": {
"reply_type": "factual_with_explanation",
"text": (
"Капибара и утка отличаются принципиально: капибара - млекопитающее-грызун, "
"а утка - птица. Поэтому у них разные тело, среда обитания и способ передвижения."
),
"message_id": "msg-2",
"trace_id": "trace-2",
},
"technical_debug_payload": {
"detected_mode": "chat",
"fallback_type": "none",
"living_chat_response_source": "llm_chat",
},
"session_summary": {},
},
entries=[],
)
self.assertTrue(step_state["clean_meta_chat_answer_validated"])
self.assertEqual(step_state["execution_status"], "exact")
self.assertEqual(step_state["acceptance_status"], "validated")
def test_today_scope_required_filter_and_direct_patterns_are_enforced(self) -> None:
self.assertTrue(dcl.question_resets_temporal_scope("мы должны комуто денег на сегодня?"))
@ -190,6 +265,109 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
self.assertIn("required_answer_patterns_any_missing", step_state["violated_invariants"])
self.assertEqual(step_state["acceptance_status"], "rejected")
def test_binding_placeholder_required_filter_is_resolved_before_step_validation(self) -> None:
step = dcl.normalize_step_definition(
1,
{
"step_id": "counterparty_binding_filter",
"title": "Counterparty binding filter",
"question": "Отдельно по {{bindings.counterparty}}: какой это поток?",
"required_filters": {"counterparty": "{{bindings.counterparty}}"},
"required_answer_patterns_all": ["(?i){{bindings.counterparty}}|банк"],
},
)
scenario_state = {
"bindings": {"counterparty": "СБЕРБАНК"},
"step_outputs": {},
"semantic_memory": {},
}
resolved_step = dcl.resolve_step_contract_placeholders(step, scenario_state)
self.assertEqual(resolved_step["required_filters"]["counterparty"], "СБЕРБАНК")
self.assertEqual(resolved_step["required_answer_patterns_all"], ["(?i)СБЕРБАНК|банк"])
step_state = dcl.build_scenario_step_state(
scenario_id="binding_filter_demo",
domain="agentic_loop",
step=resolved_step,
step_index=1,
question_resolved="Отдельно по СБЕРБАНК: какой это поток?",
analysis_context={},
turn_artifact={
"assistant_message": {
"reply_type": "factual",
"text": "СБЕРБАНК подтвержден как банковский/финансовый поток.",
"message_id": "msg-1",
"trace_id": "trace-1",
},
"technical_debug_payload": {
"detected_mode": "address_query",
"detected_intent": "bank_operations_by_counterparty",
"selected_recipe": "address_bank_operations_by_counterparty_v1",
"capability_id": "bank_operations_drilldown",
"truth_gate_contract_status": "full_confirmed",
"mcp_call_status": "matched_non_empty",
"extracted_filters": {"counterparty": "СБЕРБАНК"},
},
"session_summary": {},
},
entries=[],
)
self.assertNotIn("missing_required_filter", step_state["violated_invariants"])
def test_expected_intent_accepts_discovery_chain_id(self) -> None:
self.assertTrue(
dcl.step_intent_matches_expected(
{
"detected_intent": "supplier_payouts_profile",
"mcp_discovery_selected_chain_id": "value_flow",
"mcp_discovery_route_candidate_fact_family": "value_flow",
"mcp_discovery_route_candidate_action_family": "payout",
},
["value_flow"],
)
)
def test_bare_organization_scope_selection_is_validated_memory_checkpoint(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="org_scope_demo",
domain="agentic_loop",
step={
"step_id": "step_01",
"title": "Choose organization",
"depends_on": [],
"question_template": "ООО Альтернатива Плюс",
"semantic_tags": ["bare_org_scope", "company_scope"],
"required_answer_patterns_all": ["(?i)фиксир|рабоч.*организац|контур"],
},
step_index=1,
question_resolved="ООО Альтернатива Плюс",
analysis_context={},
turn_artifact={
"assistant_message": {
"reply_type": "factual_with_explanation",
"text": 'Отлично, фиксирую рабочую организацию: ООО "Альтернатива Плюс".',
"message_id": "msg-1",
"trace_id": "trace-1",
},
"technical_debug_payload": {
"detected_mode": "chat",
"fallback_type": "none",
"living_chat_response_source": "deterministic_data_scope_selection_contract",
"living_chat_data_scope_probe_status": "resolved",
"living_chat_selected_organization": 'ООО "Альтернатива Плюс"',
"assistant_active_organization": 'ООО "Альтернатива Плюс"',
},
"session_summary": {},
},
entries=[],
)
self.assertEqual(step_state["status"], "validated")
self.assertTrue(step_state["memory_checkpoint_validated"])
def test_repair_targets_promote_route_candidate_enablement_gaps(self) -> None:
repair_targets = dcl.build_deterministic_repair_targets(
{"pack_id": "route_candidate_pack", "domain": "open_world", "final_status": "accepted"},
@ -507,6 +685,32 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
self.assertIsNone(step_state["date_scope"]["as_of_date"])
self.assertEqual(step_state["date_scope"]["source"], "question_temporal_scope_reset")
def test_out_of_window_document_dates_reject_validated_step(self) -> None:
validated = dcl.validate_step_contract(
{
"execution_status": "exact",
"required_answer_shape": "direct_answer_first",
"reply_type": "factual",
"actual_direct_answer": "Контрагент: Группа СВК. Найдено документов: 19.",
"assistant_text": (
"Контрагент: Группа СВК. Найдено документов: 19.\n"
"1. 2021-11-10T12:00:07Z | Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07"
),
"top_non_empty_lines": [
"Контрагент: Группа СВК. Найдено документов: 19.",
"1. 2021-11-10T12:00:07Z | Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07",
],
"date_scope": {
"period_from": "2020-01-01",
"period_to": "2020-12-31",
},
}
)
self.assertEqual(validated["status"], "rejected")
self.assertIn("out_of_window_date_in_answer", validated["violated_invariants"])
self.assertIn("out_of_window_date_in_answer", validated["warnings"])
def test_open_items_exact_negative_answer_validates_without_rows(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="open_items_negative_demo",