diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 873981c..d677051 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1352,6 +1352,10 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) { return (hasSelectedObjectInventoryCue(text) && /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text)); } +function hasSelectedObjectInventorySaleTraceSignal(text) { + return (hasSelectedObjectInventoryCue(text) && + /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test(text)); +} function hasInventoryProvenanceSignalV2(text) { const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); const hasSupplierCue = /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(text); @@ -1369,8 +1373,8 @@ function hasInventoryPurchaseDocumentsSignalV2(text) { return hasItemCue && hasPurchaseDocCue; } function hasInventorySaleTraceSignalV2(text) { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasTraceCue = /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:я|ю|и)|продукци(?:я|ю|и))/iu.test(text); + const hasTraceCue = /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|товар|позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text); return hasItemCue && hasTraceCue; } function hasInventorySupplierStockOverlapSignal(text) { @@ -1588,6 +1592,13 @@ function resolveAddressIntent(userMessage) { reasons: ["inventory_purchase_documents_signal_detected"] }; } + if (hasSelectedObjectInventorySaleTraceSignal(text)) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_sale_trace_signal_detected"] + }; + } if (hasInventorySaleTraceSignalV2(text)) { return { intent: "inventory_sale_trace_for_item", diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index f9e62b3..54bd10f 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -21,7 +21,14 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT = { list_documents_by_contract: "document_ref", bank_operations_by_counterparty: "document_ref", bank_operations_by_contract: "document_ref", - open_items_by_counterparty_or_contract: "counterparty" + open_items_by_counterparty_or_contract: "counterparty", + inventory_on_hand_as_of_date: "item", + inventory_purchase_provenance_for_item: "item", + inventory_purchase_documents_for_item: "item", + inventory_supplier_stock_overlap_as_of_date: "item", + inventory_sale_trace_for_item: "item", + inventory_purchase_to_sale_chain: "item", + inventory_aging_by_purchase_date: "item" }; const RESULT_SET_TYPE_BY_INTENT = { counterparty_activity_lifecycle: "counterparty_list", @@ -39,6 +46,13 @@ const RESULT_SET_TYPE_BY_INTENT = { bank_operations_by_counterparty: "bank_operations_list", bank_operations_by_contract: "bank_operations_list", open_items_by_counterparty_or_contract: "open_items_list", + inventory_on_hand_as_of_date: "inventory_snapshot", + inventory_purchase_provenance_for_item: "inventory_trace", + inventory_purchase_documents_for_item: "inventory_trace", + inventory_supplier_stock_overlap_as_of_date: "inventory_trace", + inventory_sale_trace_for_item: "inventory_trace", + inventory_purchase_to_sale_chain: "inventory_trace", + inventory_aging_by_purchase_date: "inventory_trace", period_coverage_profile: "profile_summary", document_type_and_account_section_profile: "profile_summary", counterparty_population_and_roles: "profile_summary", @@ -64,7 +78,13 @@ function toAddressFocusObjectType(value) { if (!normalized) { return "unknown"; } - if (normalized === "counterparty" || normalized === "contract" || normalized === "document_ref" || normalized === "account") { + if (normalized === "counterparty" || + normalized === "contract" || + normalized === "document_ref" || + normalized === "account" || + normalized === "item" || + normalized === "organization" || + normalized === "warehouse") { return normalized; } return "unknown"; @@ -127,6 +147,38 @@ function extractEntityRefsFromAssistantReply(replyText, intent, limit = MAX_ENTI } return Array.from(dedup.values()); } +function extractOrganizationsFromAssistantReply(replyText, limit = 10) { + const dedup = new Map(); + const lines = String(replyText ?? "").split(/\r?\n/); + for (const line of lines) { + const match = line.match(/(?:^|\|)\s*организац(?:ия|ии)\s*:\s*([^|]+)/iu); + if (!match) { + continue; + } + const organization = toNonEmptyString(match[1]); + if (!organization) { + continue; + } + const key = organization.toLowerCase(); + if (!dedup.has(key)) { + dedup.set(key, organization); + } + if (dedup.size >= limit) { + break; + } + } + return Array.from(dedup.values()); +} +function resolveDerivedOrganizationScope(debug, filters, replyText) { + const rootFrameContext = toObject(debug.address_root_frame_context) ?? {}; + const candidates = [ + toNonEmptyString(filters.organization), + toNonEmptyString(rootFrameContext.organization), + ...extractOrganizationsFromAssistantReply(replyText) + ].filter((value) => Boolean(value)); + const dedup = Array.from(new Map(candidates.map((value) => [value.toLowerCase(), value])).values()); + return dedup.length === 1 ? dedup[0] : null; +} function cloneFocusObject(value) { if (!value) { return null; @@ -345,6 +397,13 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { const resultSetId = `rs-${item.message_id}`; const routeId = toNonEmptyString(debug.selected_recipe); const filters = normalizeFilters(debug.extracted_filters); + const derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text); + const filtersWithDerivedScope = derivedOrganizationScope && !toNonEmptyString(filters.organization) + ? { + ...filters, + organization: derivedOrganizationScope + } + : filters; const sourceRefs = routeId ? [routeId] : []; const entityRefs = extractEntityRefsFromAssistantReply(item.text, intent); const resultSet = { @@ -352,7 +411,7 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { type: inferResultSetType(intent), intent, route_id: routeId, - filters, + filters: filtersWithDerivedScope, source_refs: sourceRefs, entity_refs: entityRefs, created_from_turn: turnIndex, @@ -371,11 +430,11 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { created_at: createdAt }; const normalizedDateScope = { - as_of_date: toNonEmptyString(filters.as_of_date), - period_from: toNonEmptyString(filters.period_from), - period_to: toNonEmptyString(filters.period_to) + as_of_date: toNonEmptyString(filtersWithDerivedScope.as_of_date), + period_from: toNonEmptyString(filtersWithDerivedScope.period_from), + period_to: toNonEmptyString(filtersWithDerivedScope.period_to) }; - const organizationScope = toNonEmptyString(filters.organization); + const organizationScope = toNonEmptyString(filtersWithDerivedScope.organization); const nextResultSets = capResultSets([...state.result_sets.filter((itemSet) => itemSet.result_set_id !== resultSetId), resultSet].sort((left, right) => left.created_from_turn - right.created_from_turn)); const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]); return { diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 684c5ab..739bb4d 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1219,8 +1219,32 @@ function applyPreExecutionOrganizationScopeGrounding(input) { input.semanticFrame.anchor_value = resolvedOrganizationFromMessage; } } + if (!input.filters.organization && !activeOrganization && !resolvedOrganizationFromMessage && candidateOrganizations.length === 1) { + input.filters.organization = candidateOrganizations[0]; + if (!input.warnings.includes("organization_auto_selected_from_single_scope_candidate")) { + input.warnings.push("organization_auto_selected_from_single_scope_candidate"); + } + if (!input.baseReasons.includes("organization_auto_selected_from_single_scope_candidate")) { + input.baseReasons.push("organization_auto_selected_from_single_scope_candidate"); + } + if (input.semanticFrame?.anchor_kind === "organization") { + input.semanticFrame.anchor_value = candidateOrganizations[0]; + } + } return resolvedOrganizationFromMessage; } +function isOrganizationScopedInventoryIntent(intent) { + return (intent === "inventory_on_hand_as_of_date" || + intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_supplier_stock_overlap_as_of_date" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date"); +} +function collectOrganizationCandidatesFromRows(rows) { + return (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(rows.map((row) => row.organization).filter((value) => Boolean(value))); +} function isHeuristicCandidatesIntent(intent) { return (intent === "list_receivables_counterparties" || intent === "list_payables_counterparties" || @@ -1587,7 +1611,21 @@ function shouldBoostAutoBroadenedLimit(intent) { intent === "inventory_aging_by_purchase_date"); } function shouldClearAsOfDateForHistoryRecovery(intent) { - return intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item"; + return (intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain"); +} +function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) { + if (intent !== "inventory_purchase_provenance_for_item" && + intent !== "inventory_purchase_documents_for_item" && + intent !== "inventory_sale_trace_for_item" && + intent !== "inventory_purchase_to_sale_chain") { + return false; + } + return (reasons.includes("as_of_date_from_followup_context") || + reasons.includes("period_from_followup_context") || + reasons.includes("as_of_date_from_open_items_followup_context")); } function invertSort(sort) { return sort === "period_asc" ? "period_desc" : "period_asc"; @@ -2241,6 +2279,48 @@ function buildLimitedExecutionResult(input) { } }; } +function composeOrganizationClarificationReply(organizations) { + const normalizedOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(organizations).slice(0, 10); + const lines = [ + normalizedOrganizations.length > 1 + ? "Нужно уточнить организацию, чтобы не смешивать компании в одном ответе." + : "Нужно уточнить организацию, чтобы продолжить запрос.", + normalizedOrganizations.length > 0 + ? "Сейчас в доступном контуре вижу такие организации:" + : "Уточни, по какой организации продолжать." + ]; + for (const organization of normalizedOrganizations) { + lines.push(`- ${organization}`); + } + lines.push("Можешь ответить просто названием компании, и я продолжу этот же запрос."); + return lines.join("\n"); +} +function buildOrganizationClarificationExecutionResult(input) { + const result = buildLimitedExecutionResult({ + mode: input.mode, + shape: input.shape, + intent: input.intent, + filters: input.filters, + missingRequiredFilters: ["organization"], + selectedRecipe: null, + anchor: input.anchor, + mcpCallStatus: "skipped", + rowsFetched: 0, + rowsMatched: 0, + category: "missing_anchor", + reasonText: "не указана организация, а в доступном контуре найдено несколько компаний", + nextStep: "уточните организацию из списка, и я продолжу этот же запрос", + limitations: ["organization_clarification_required", "multiple_known_organizations_detected"], + reasons: [...input.reasons, "organization_clarification_required", "multiple_known_organizations_detected"], + semanticFrame: input.semanticFrame, + capabilityAudit: input.capabilityAudit, + shadowRouteAudit: input.shadowRouteAudit, + routeExpectationAudit: input.routeExpectationAudit + }); + result.reply_text = composeOrganizationClarificationReply(input.organizations); + result.debug.organization_candidates = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(input.organizations); + return result; +} class AddressQueryService { async tryHandle(userMessage, options = {}) { if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) { @@ -2277,6 +2357,29 @@ class AddressQueryService { activeOrganization: options.activeOrganization ?? null, knownOrganizations: options.knownOrganizations ?? [] }); + const knownOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(options.knownOrganizations ?? []); + const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.activeOrganization ?? null); + if (isOrganizationScopedInventoryIntent(intent.intent) && + !toNonEmptyFilterValue(filters.extracted_filters.organization) && + !activeOrganization && + !resolvedOrganizationFromMessage && + knownOrganizations.length > 1) { + return buildOrganizationClarificationExecutionResult({ + mode, + shape, + intent, + filters: filters.extracted_filters, + organizations: knownOrganizations, + reasons: [...baseReasons, "organization_candidates_from_scope_context"], + semanticFrame, + capabilityAudit: buildCapabilityAudit(intent.intent), + shadowRouteAudit: buildShadowRouteAudit({ + intent: intent.intent, + requestedResultMode: resolveRequestedResultMode(intent.intent, filters.extracted_filters, semanticFrame), + filters: filters.extracted_filters + }) + }); + } const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters, semanticFrame); const confirmedBalancePayablesIntent = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") && requestedResultMode === "confirmed_balance"; @@ -2336,6 +2439,44 @@ class AddressQueryService { baseReasons.push("as_of_date_derived_for_inventory_on_hand"); } } + if (shouldDetachLifecycleExecutionFromSnapshotContext(intent.intent, baseReasons)) { + const detachedExecutionFilters = { ...executionFilters }; + let periodDetached = false; + let asOfDetached = false; + if (toNonEmptyFilterValue(detachedExecutionFilters.period_from) || + toNonEmptyFilterValue(detachedExecutionFilters.period_to)) { + delete detachedExecutionFilters.period_from; + delete detachedExecutionFilters.period_to; + periodDetached = true; + } + if (toNonEmptyFilterValue(detachedExecutionFilters.as_of_date)) { + delete detachedExecutionFilters.as_of_date; + asOfDetached = true; + } + if (periodDetached || asOfDetached) { + executionFilters = detachedExecutionFilters; + if (periodDetached && !filters.warnings.includes("period_window_detached_for_lifecycle_execution")) { + filters.warnings.push("period_window_detached_for_lifecycle_execution"); + } + if (periodDetached && !baseReasons.includes("period_window_detached_for_lifecycle_execution")) { + baseReasons.push("period_window_detached_for_lifecycle_execution"); + } + if ((periodDetached || asOfDetached) && + !filters.warnings.includes("lifecycle_execution_detached_from_snapshot_date")) { + filters.warnings.push("lifecycle_execution_detached_from_snapshot_date"); + } + if ((periodDetached || asOfDetached) && + !baseReasons.includes("lifecycle_execution_detached_from_snapshot_date")) { + baseReasons.push("lifecycle_execution_detached_from_snapshot_date"); + } + if (asOfDetached && !filters.warnings.includes("as_of_date_cleared_for_history_recovery")) { + filters.warnings.push("as_of_date_cleared_for_history_recovery"); + } + if (asOfDetached && !baseReasons.includes("as_of_date_cleared_for_history_recovery")) { + baseReasons.push("as_of_date_cleared_for_history_recovery"); + } + } + } const capabilityDecision = (0, addressCapabilityPolicy_1.resolveAddressCapabilityRouteDecision)(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent); const shadowRouteAudit = buildShadowRouteAudit({ @@ -2789,6 +2930,41 @@ class AddressQueryService { baseReasons.push("organization_scope_live_grounding_recovered_rows"); } } + if (filteredRows.length > 0 && + isOrganizationScopedInventoryIntent(intent.intent) && + !toNonEmptyFilterValue(filters.extracted_filters.organization)) { + const observedOrganizations = collectOrganizationCandidatesFromRows(filteredRows); + if (observedOrganizations.length === 1) { + filters.extracted_filters = { + ...filters.extracted_filters, + organization: observedOrganizations[0] + }; + executionFilters = { + ...executionFilters, + organization: observedOrganizations[0] + }; + if (!filters.warnings.includes("organization_grounded_from_observed_rows")) { + filters.warnings.push("organization_grounded_from_observed_rows"); + } + if (!baseReasons.includes("organization_grounded_from_observed_rows")) { + baseReasons.push("organization_grounded_from_observed_rows"); + } + } + else if (observedOrganizations.length > 1) { + return buildOrganizationClarificationExecutionResult({ + mode, + shape, + intent, + filters: filters.extracted_filters, + anchor, + organizations: observedOrganizations, + reasons: [...baseReasons, "organization_candidates_from_observed_rows"], + semanticFrame, + capabilityAudit, + shadowRouteAudit + }); + } + } if (filteredRows.length === 0 && intent.intent === "list_documents_by_contract" && filterByAnchors.length > 0) { const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors); const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors; @@ -2989,7 +3165,7 @@ class AddressQueryService { const broadenedAdjustments = []; delete autoBroadenedFilters.period_from; delete autoBroadenedFilters.period_to; - if (stageStatus === "no_raw_rows" && shouldClearAsOfDateForHistoryRecovery(intent.intent) && toNonEmptyFilterValue(autoBroadenedFilters.as_of_date)) { + if (shouldClearAsOfDateForHistoryRecovery(intent.intent) && toNonEmptyFilterValue(autoBroadenedFilters.as_of_date)) { delete autoBroadenedFilters.as_of_date; broadenedAdjustments.push("as_of_date_cleared_for_history_recovery"); } diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 73d23be..584f5fa 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -34,7 +34,8 @@ const INVENTORY_MOVEMENTS_QUERY_TEMPLATE = ` ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3, ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1, ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2, - ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3 + ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения __WHERE_CLAUSE__ @@ -934,6 +935,18 @@ function toDateTimeExpr(isoDate, endOfDay) { const second = endOfDay ? 59 : 0; return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`; } +function toQueryStringLiteral(value) { + return String(value ?? "").replace(/"/g, '""'); +} +function buildOrganizationPresentationCondition(filters, fieldPath) { + const organization = typeof filters.organization === "string" && filters.organization.trim().length > 0 + ? filters.organization.trim() + : ""; + if (!organization) { + return null; + } + return `ПРЕДСТАВЛЕНИЕ(${fieldPath}) = "${toQueryStringLiteral(organization)}"`; +} function buildWhereClause(filters, fieldPath, extraConditions = []) { const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0 ? toDateTimeExpr(filters.period_from, false) @@ -1074,9 +1087,10 @@ function buildInventoryMovementQuery(filters, resolvedLimit, side) { : side === "kt" ? creditPredicate : `(${debitPredicate} ИЛИ ${creditPredicate})`; + const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация"); return INVENTORY_MOVEMENTS_QUERY_TEMPLATE .replace("__LIMIT__", String(resolvedLimit)) - .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition])) + .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition, organizationCondition].filter((item) => Boolean(item)))) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); } function shouldBoostLimitForAllTimeCounterparty(filters) { diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 585b1be..ccc0563 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -264,6 +264,12 @@ function isInventoryDrilldownFrameIntent(intent) { intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } +function isInventoryLifecycleHistoryIntent(intent) { + return (intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain"); +} function buildInventoryRootFollowupContext(followupContext) { if (!followupContext || !followupContext.root_intent || !followupContext.root_filters) { return followupContext; @@ -412,7 +418,7 @@ function hasBareInventoryPurchaseDateFollowupCue(text) { return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3; } function hasInventorySaleFollowupCue(text) { - return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|кто\s+купил|buyer|покупател)/iu.test(String(text ?? "")); + return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил|buyer|покупател)/iu.test(String(text ?? "")); } function hasInventoryPurchaseToSaleChainFollowupCue(text) { return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(String(text ?? "")); @@ -618,20 +624,18 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { } } if (!sameDateRequested && - (intent === "inventory_purchase_provenance_for_item" || - intent === "inventory_purchase_documents_for_item" || - intent === "inventory_sale_trace_for_item" || - intent === "inventory_purchase_to_sale_chain" || - intent === "inventory_aging_by_purchase_date") && + (intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage)) { - const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; - const currentAsOfDate = toNonEmptyString(merged.as_of_date); - const todayIso = new Date().toISOString().slice(0, 10); - const currentLooksDefaultedToToday = currentAsOfDate === todayIso; - if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { - merged.as_of_date = inheritedAsOfDate; - reasons.push("as_of_date_from_followup_context"); + if (intent === "inventory_aging_by_purchase_date") { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } } } if (!sameDateRequested && @@ -706,6 +710,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage); const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage); const hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage); + const inventoryLifecycleHistoryIntent = isInventoryLifecycleHistoryIntent(intent); const asOfPrimaryIntent = intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "open_contracts_confirmed_as_of_date" || @@ -739,7 +744,11 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("period_from_followup_context"); } } - if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) { + if (!currentHasPeriod && + previousHasPeriod && + hasFollowupSignal && + !hasExplicitPeriodInMessage && + !inventoryLifecycleHistoryIntent) { if (previousPeriodFrom) { merged.period_from = previousPeriodFrom; } diff --git a/llm_normalizer/backend/dist/services/assistantAddressAttemptRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressAttemptRuntimeAdapter.js index c98c129..286ac05 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressAttemptRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressAttemptRuntimeAdapter.js @@ -108,6 +108,8 @@ async function runAssistantAddressAttemptRuntime(input) { sessionId: input.sessionId, userMessage: input.userMessage, sessionItems: input.sessionItems, + sessionAddressNavigationState: input.sessionAddressNavigationState, + sessionScope: input.sessionScope, payload: input.payload, featureAddressLlmPredecomposeV1: input.featureAddressLlmPredecomposeV1, runAddressLlmPreDecompose: input.runAddressLlmPreDecompose, diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js index 6d02ae3..2c275c1 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js @@ -151,7 +151,13 @@ function runAssistantAddressLaneResponseRuntime(input) { if (followupOffer) { debug.address_followup_offer = followupOffer; } - const debugKnownOrganizations = input.mergeKnownOrganizations(input.knownOrganizations); + const laneOrganizationCandidates = Array.isArray(input.addressLane.debug?.organization_candidates) + ? input.addressLane.debug.organization_candidates + : []; + const debugKnownOrganizations = input.mergeKnownOrganizations([ + ...input.knownOrganizations, + ...laneOrganizationCandidates + ]); const debugFilters = debug?.extracted_filters && typeof debug.extracted_filters === "object" ? debug.extracted_filters : null; diff --git a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js index 3bc6c45..16da78d 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js @@ -5,7 +5,7 @@ function hasSelectedObjectInventorySignal(text) { return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(text ?? "")); } function hasSelectedObjectInventoryActionCue(text) { - return /(?:кому[\s\S]{0,80}продал[аи]?|кому[\s\S]{0,80}реализова[нлт][а-я]*|кому\s+был\s+продан|кто[\s\S]{0,40}купил|кто\s+это\s+поставил|кто\s+поставил|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+мы\s+купили|где\s+куплено|по\s+каким\s+документам|какими\s+документами|покажи\s+документы|документы\s+закупки|buyer|sale\s+trace|supplier|vendor|purchase\s+documents|purchase[\s-]?to[\s-]?sale|old\s+purchase|aged\s+stock)/iu.test(String(text ?? "")); + return /(?:кому[\s\S]{0,80}продал[аи]?|кому[\s\S]{0,80}реализова[нлт][а-я]*|кому\s+был\s+продан|куда[\s\S]{0,80}продал[аи]?|куда[\s\S]{0,80}реализова[нлт][а-я]*|кто[\s\S]{0,40}купил|кто\s+это\s+поставил|кто\s+поставил|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+мы\s+купили|где\s+куплено|по\s+каким\s+документам|какими\s+документами|покажи\s+документы|документы\s+закупки|buyer|sale\s+trace|supplier|vendor|purchase\s+documents|purchase[\s-]?to[\s-]?sale|old\s+purchase|aged\s+stock)/iu.test(String(text ?? "")); } function isGenericCanonicalDriftIntent(intent) { return (intent === "open_items_by_counterparty_or_contract" || @@ -62,7 +62,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) { : fallbackAddressPreDecompose(input.userMessage, input.llmProvider, input.buildAddressLlmPredecomposeContractV1, input.sanitizeAddressMessageForFallback); let addressPreDecompose = initialAddressPreDecompose; let addressInputMessage = input.toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? input.userMessage; - let carryover = input.resolveAddressFollowupCarryoverContext(input.userMessage, input.sessionItems, addressInputMessage, addressPreDecompose); + let carryover = input.resolveAddressFollowupCarryoverContext(input.userMessage, input.sessionItems, addressInputMessage, addressPreDecompose, input.sessionAddressNavigationState); if (shouldPreferRawFollowupMessage(input.userMessage, addressInputMessage, carryover, addressPreDecompose, input.toNonEmptyString)) { addressInputMessage = input.userMessage; addressPreDecompose = { @@ -75,7 +75,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) { canonicalMessage: input.userMessage }) }; - carryover = input.resolveAddressFollowupCarryoverContext(input.userMessage, input.sessionItems, addressInputMessage, addressPreDecompose); + carryover = input.resolveAddressFollowupCarryoverContext(input.userMessage, input.sessionItems, addressInputMessage, addressPreDecompose, input.sessionAddressNavigationState); } const followupContext = carryover?.followupContext ?? null; const orchestrationDecision = input.resolveAssistantOrchestrationDecision({ @@ -84,6 +84,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) { followupContext, llmPreDecomposeMeta: addressPreDecompose, sessionItems: input.sessionItems, + sessionOrganizationScope: input.sessionOrganizationScope ?? null, useMock: input.useMock }); const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose); diff --git a/llm_normalizer/backend/dist/services/assistantAddressRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressRuntimeAdapter.js index b035b09..192b691 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressRuntimeAdapter.js @@ -18,6 +18,8 @@ async function runAssistantAddressRuntime(input) { const addressOrchestrationRuntime = await runAddressOrchestrationRuntimeSafe({ userMessage: input.userMessage, sessionItems: input.sessionItems, + sessionAddressNavigationState: input.sessionAddressNavigationState, + sessionOrganizationScope: input.sessionOrganizationScope ?? null, llmProvider: input.llmProvider, useMock: input.useMock, featureAddressLlmPredecomposeV1: input.featureAddressLlmPredecomposeV1, diff --git a/llm_normalizer/backend/dist/services/assistantAddressRuntimeInputBuilder.js b/llm_normalizer/backend/dist/services/assistantAddressRuntimeInputBuilder.js index 08e609f..e1fcfc6 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressRuntimeInputBuilder.js +++ b/llm_normalizer/backend/dist/services/assistantAddressRuntimeInputBuilder.js @@ -7,6 +7,8 @@ function buildAssistantAddressRuntimeInput(input) { sessionId: input.sessionId, userMessage: input.userMessage, sessionItems: input.sessionItems, + sessionAddressNavigationState: input.sessionAddressNavigationState, + sessionOrganizationScope: input.sessionScope, llmProvider: input.payload.llmProvider, useMock: Boolean(input.payload.useMock), featureAddressLlmPredecomposeV1: input.featureAddressLlmPredecomposeV1, diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index dac4830..148df1f 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -1,6 +1,70 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.runAssistantLivingChatRuntime = runAssistantLivingChatRuntime; +function formatIsoDateForReply(value) { + const source = String(value ?? "").trim(); + const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + return `${match[3]}.${match[2]}.${match[1]}`; +} +function findLastGroundedInventoryAddressDebug(items) { + if (!Array.isArray(items)) { + return null; + } + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const debug = item.debug; + const answerGroundingCheck = debug.answer_grounding_check && typeof debug.answer_grounding_check === "object" + ? debug.answer_grounding_check + : null; + const groundingStatus = String(answerGroundingCheck?.status ?? ""); + const detectedIntent = String(debug.detected_intent ?? ""); + const capabilityId = String(debug.capability_id ?? ""); + const rootFrameContext = debug.address_root_frame_context && typeof debug.address_root_frame_context === "object" + ? debug.address_root_frame_context + : null; + const rootIntent = String(rootFrameContext?.root_intent ?? ""); + const isInventoryContext = detectedIntent === "inventory_on_hand_as_of_date" || + capabilityId === "confirmed_inventory_on_hand_as_of_date" || + rootIntent === "inventory_on_hand_as_of_date"; + if (groundingStatus === "grounded" && isInventoryContext) { + return debug; + } + } + return null; +} +function buildInventoryHistoryCapabilityFollowupReply(input) { + const rootFrameContext = input.addressDebug?.address_root_frame_context && typeof input.addressDebug.address_root_frame_context === "object" + ? input.addressDebug.address_root_frame_context + : null; + const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? input.addressDebug.extracted_filters + : null; + const organization = input.organization ?? + input.toNonEmptyString(rootFrameContext?.organization) ?? + input.toNonEmptyString(extractedFilters?.organization); + const lastAsOfDate = formatIsoDateForReply(rootFrameContext?.as_of_date) ?? + formatIsoDateForReply(extractedFilters?.as_of_date); + const organizationPart = organization ? ` по компании «${organization}»` : ""; + const referenceLine = lastAsOfDate + ? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.` + : `Да, могу показать исторические данные${organizationPart} в этом же складском контуре.`; + return [ + referenceLine, + `Могу показать исторические остатки${organizationPart} за нужный месяц, дату или год.`, + "Например:", + "- `на март 2020`", + "- `на июнь 2016`", + "- `за 2017 год`", + "- `сравни июнь 2016 с текущим срезом`", + "Если хочешь, сразу покажу нужный исторический период." + ].join("\n"); +} async function runAssistantLivingChatRuntime(input) { const userMessage = String(input.userMessage ?? ""); const dataScopeMetaQuery = input.hasAssistantDataScopeMetaQuestionSignal(userMessage); @@ -18,6 +82,10 @@ async function runAssistantLivingChatRuntime(input) { let knownOrganizations = input.mergeKnownOrganizations(input.sessionScope.knownOrganizations ?? []); let selectedOrganization = input.toNonEmptyString(input.sessionScope.selectedOrganization); let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization); + const contextualInventoryHistoryCapabilityFollowup = input.modeDecision?.reason === "inventory_history_capability_followup_detected"; + const lastGroundedInventoryAddressDebug = contextualInventoryHistoryCapabilityFollowup + ? findLastGroundedInventoryAddressDebug(input.sessionItems) + : null; if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { chatText = input.buildAssistantSafetyRefusalReply(); livingChatSource = "deterministic_safety_refusal"; @@ -61,6 +129,16 @@ async function runAssistantLivingChatRuntime(input) { chatText = input.buildAssistantOperationalBoundaryReply(); livingChatSource = "deterministic_operational_boundary"; } + else if (contextualInventoryHistoryCapabilityFollowup) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = buildInventoryHistoryCapabilityFollowupReply({ + organization: scopedOrganization, + addressDebug: lastGroundedInventoryAddressDebug, + toNonEmptyString: input.toNonEmptyString + }); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_inventory_history_capability_contract"; + } else if (capabilityMetaQuery) { chatText = input.buildAssistantCapabilityContractReply(); livingChatSource = "deterministic_capability_contract"; diff --git a/llm_normalizer/backend/dist/services/assistantOrganizationMatcher.js b/llm_normalizer/backend/dist/services/assistantOrganizationMatcher.js index 9af0ea9..6ede5a7 100644 --- a/llm_normalizer/backend/dist/services/assistantOrganizationMatcher.js +++ b/llm_normalizer/backend/dist/services/assistantOrganizationMatcher.js @@ -65,7 +65,7 @@ function normalizeOrganizationScopeValue(value) { if (!normalized) { return null; } - let unwrapped = normalized.replace(/^\\+|\\+$/g, "").trim(); + let unwrapped = normalized.trim(); if ((unwrapped.startsWith('"') && unwrapped.endsWith('"')) || (unwrapped.startsWith("'") && unwrapped.endsWith("'"))) { unwrapped = unwrapped.slice(1, -1).trim(); diff --git a/llm_normalizer/backend/dist/services/assistantOrganizationScopeRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantOrganizationScopeRuntimeAdapter.js index 525d947..179f608 100644 --- a/llm_normalizer/backend/dist/services/assistantOrganizationScopeRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantOrganizationScopeRuntimeAdapter.js @@ -2,11 +2,41 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveSessionOrganizationScopeContextRuntime = resolveSessionOrganizationScopeContextRuntime; exports.mergeFollowupContextWithOrganizationScopeRuntime = mergeFollowupContextWithOrganizationScopeRuntime; +function extractOrganizationsFromNavigationState(addressNavigationState, normalizeOrganizationScopeValue) { + if (!addressNavigationState || typeof addressNavigationState !== "object") { + return []; + } + const collected = []; + const directOrganization = normalizeOrganizationScopeValue(addressNavigationState.session_context?.organization_scope); + if (directOrganization) { + collected.push(directOrganization); + } + for (const resultSet of Array.isArray(addressNavigationState.result_sets) ? addressNavigationState.result_sets : []) { + const scopedOrganization = normalizeOrganizationScopeValue(resultSet?.filters?.organization); + if (scopedOrganization) { + collected.push(scopedOrganization); + } + } + return Array.from(new Map(collected.map((value) => [value.toLowerCase(), value])).values()); +} +function resolveActiveOrganizationFromNavigationState(addressNavigationState, normalizeOrganizationScopeValue) { + if (!addressNavigationState || typeof addressNavigationState !== "object") { + return null; + } + return normalizeOrganizationScopeValue(addressNavigationState.session_context?.organization_scope); +} function resolveSessionOrganizationScopeContextRuntime(input) { - const knownOrganizations = input.extractKnownOrganizationsFromHistory(input.items); + const knownOrganizations = Array.from(new Map([ + ...extractOrganizationsFromNavigationState(input.addressNavigationState, input.normalizeOrganizationScopeValue), + ...input.extractKnownOrganizationsFromHistory(input.items) + ].map((value) => [String(value).toLowerCase(), value])).values()); const selectedOrganization = input.resolveOrganizationSelectionFromMessage(input.userMessage, knownOrganizations); const lastActiveOrganization = input.findLastAssistantActiveOrganization(input.items); - const activeOrganization = selectedOrganization ?? input.normalizeOrganizationScopeValue(lastActiveOrganization); + const navigationActiveOrganization = resolveActiveOrganizationFromNavigationState(input.addressNavigationState, input.normalizeOrganizationScopeValue); + const activeOrganization = selectedOrganization ?? + navigationActiveOrganization ?? + input.normalizeOrganizationScopeValue(lastActiveOrganization) ?? + (knownOrganizations.length === 1 ? knownOrganizations[0] : null); return { knownOrganizations, selectedOrganization, @@ -28,5 +58,15 @@ function mergeFollowupContextWithOrganizationScopeRuntime(input) { previousFilters.organization = normalizedOrganization; } base.previous_filters = previousFilters; + const rootFiltersRaw = base.root_filters; + const rootFilters = rootFiltersRaw && typeof rootFiltersRaw === "object" + ? { ...rootFiltersRaw } + : {}; + if (!input.toNonEmptyString(rootFilters.organization)) { + rootFilters.organization = normalizedOrganization; + } + if (Object.keys(rootFilters).length > 0) { + base.root_filters = rootFilters; + } return base; } diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 7dc7b80..a78c532 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -1473,6 +1473,7 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) { account_scope_drop_reason: addressDebug.account_scope_drop_reason, runtime_readiness: addressDebug.runtime_readiness, limited_reason_category: addressDebug.limited_reason_category, + organization_candidates: addressDebug.organization_candidates ?? undefined, response_type: addressDebug.response_type, requested_result_mode: addressDebug.requested_result_mode ?? undefined, result_mode: addressDebug.result_mode ?? undefined, @@ -2790,9 +2791,18 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { } return null; } -function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) { +function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) { const previousAddressItem = findLastAddressAssistantItem(items); const previousAddressDebug = previousAddressItem?.debug ?? null; + const lastOrganizationClarificationDebug = findLastOrganizationClarificationAddressDebug(items); + const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates) + ? mergeKnownOrganizations(lastOrganizationClarificationDebug.organization_candidates) + : []; + const organizationClarificationSelection = resolveOrganizationSelectionFromMessage(userMessage, organizationClarificationCandidates) ?? + (toNonEmptyString(alternateMessage) + ? resolveOrganizationSelectionFromMessage(String(alternateMessage ?? ""), organizationClarificationCandidates) + : null); + const hasOrganizationClarificationContinuation = Boolean(lastOrganizationClarificationDebug && organizationClarificationSelection); const followupOffer = previousAddressDebug ? buildAddressFollowupOffer(previousAddressDebug) : null; const hasImplicitContinuationSignal = Boolean(previousAddressDebug) && Boolean(followupOffer?.enabled) && @@ -2823,10 +2833,15 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes !hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && + !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal) { return null; } - if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { + if (!hasPrimaryFollowupSignal && + !hasAlternateFollowupSignal && + !hasImplicitContinuationSignal && + !hasOrganizationClarificationContinuation && + !hasIndexReferenceSignal) { return null; } if (!previousAddressDebug) { @@ -2854,7 +2869,45 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes readAddressFilterString(previousAddressDebug, "counterparty") ?? readAddressFilterString(previousAddressDebug, "account") ?? readAddressFilterString(previousAddressDebug, "contract"); - const inventoryRootFrame = findRecentInventoryRootFrame(items); + const navigationSessionContext = addressNavigationState && typeof addressNavigationState === "object" + ? (addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null) + : null; + const navigationDateScope = navigationSessionContext && typeof navigationSessionContext.date_scope === "object" + ? navigationSessionContext.date_scope + : null; + const navigationOrganization = normalizeOrganizationScopeValue(navigationSessionContext?.organization_scope); + const navigationFocusObject = navigationSessionContext && typeof navigationSessionContext.active_focus_object === "object" + ? navigationSessionContext.active_focus_object + : null; + const navigationFocusObjectType = toNonEmptyString(navigationFocusObject?.object_type); + const navigationFocusObjectLabel = toNonEmptyString(navigationFocusObject?.label); + const hasSelectedObjectInventorySignalPrimary = /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(userMessage ?? "")); + const hasSelectedObjectInventorySignalAlternate = toNonEmptyString(alternateMessage) + ? /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(alternateMessage ?? "")) + : false; + let inventoryRootFrame = findRecentInventoryRootFrame(items); + if (inventoryRootFrame && navigationOrganization && !toNonEmptyString(inventoryRootFrame.filters?.organization)) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + organization: navigationOrganization + } + }; + } + if (inventoryRootFrame && navigationDateScope) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + as_of_date: toNonEmptyString(inventoryRootFrame.filters?.as_of_date) ?? toNonEmptyString(navigationDateScope.as_of_date) ?? undefined, + period_from: toNonEmptyString(inventoryRootFrame.filters?.period_from) ?? toNonEmptyString(navigationDateScope.period_from) ?? undefined, + period_to: toNonEmptyString(inventoryRootFrame.filters?.period_to) ?? toNonEmptyString(navigationDateScope.period_to) ?? undefined + } + }; + } const currentFrameKind = inventoryRootFrame ? isInventoryDrilldownFrameIntent(sourceIntent) ? "inventory_drilldown" @@ -2885,6 +2938,21 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes previousFilters.organization = historicalOrganization; } } + if (!toNonEmptyString(previousFilters.organization) && navigationOrganization) { + previousFilters.organization = navigationOrganization; + } + if (!toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) { + previousFilters.organization = organizationClarificationSelection; + } + if (!toNonEmptyString(previousFilters.as_of_date) && toNonEmptyString(navigationDateScope?.as_of_date)) { + previousFilters.as_of_date = toNonEmptyString(navigationDateScope?.as_of_date); + } + if (!toNonEmptyString(previousFilters.period_from) && toNonEmptyString(navigationDateScope?.period_from)) { + previousFilters.period_from = toNonEmptyString(navigationDateScope?.period_from); + } + if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) { + previousFilters.period_to = toNonEmptyString(navigationDateScope?.period_to); + } const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent); const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType); const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ?? @@ -2912,6 +2980,36 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes followupSelectionMode = "carry_referenced_entity"; } } + if (!toNonEmptyString(previousFilters.item) && + navigationFocusObjectType === "item" && + navigationFocusObjectLabel && + (sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_purchase_provenance_for_item" || + sourceIntentHint === "inventory_purchase_documents_for_item" || + sourceIntentHint === "inventory_sale_trace_for_item" || + sourceIntentHint === "inventory_purchase_to_sale_chain" || + sourceIntentHint === "inventory_aging_by_purchase_date" || + hasSelectedObjectInventorySignalPrimary || + hasSelectedObjectInventorySignalAlternate)) { + previousFilters.item = navigationFocusObjectLabel; + if (!previousAnchor) { + previousAnchorType = "item"; + previousAnchor = navigationFocusObjectLabel; + } + } + if (organizationClarificationSelection && !previousAnchor) { + previousAnchorType = "organization"; + previousAnchor = organizationClarificationSelection; + } + if (inventoryRootFrame && organizationClarificationSelection && !toNonEmptyString(inventoryRootFrame.filters?.organization)) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + organization: organizationClarificationSelection + } + }; + } if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) { return null; } @@ -4036,6 +4134,28 @@ function resolveAssistantOrchestrationDecision(input) { const llmPreDecomposeMeta = input?.llmPreDecomposeMeta ?? null; const useMock = Boolean(input?.useMock); const sessionItems = Array.isArray(input?.sessionItems) ? input.sessionItems : null; + const sessionOrganizationScope = input?.sessionOrganizationScope && typeof input.sessionOrganizationScope === "object" + ? input.sessionOrganizationScope + : null; + const lastGroundedAddressDebug = findLastGroundedAddressAnswerDebug(sessionItems); + const lastOrganizationClarificationDebug = findLastOrganizationClarificationAddressDebug(sessionItems); + const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates) + ? mergeKnownOrganizations([ + ...lastOrganizationClarificationDebug.organization_candidates, + ...((Array.isArray(sessionOrganizationScope?.knownOrganizations) + ? sessionOrganizationScope.knownOrganizations + : [])) + ]) + : []; + const organizationClarificationSelectionFromScope = normalizeOrganizationScopeValue(sessionOrganizationScope?.selectedOrganization); + const organizationClarificationSelection = resolveOrganizationSelectionFromMessage(rawUserMessage, organizationClarificationCandidates) ?? + resolveOrganizationSelectionFromMessage(repairedRawUserMessage, organizationClarificationCandidates) ?? + resolveOrganizationSelectionFromMessage(effectiveAddressUserMessage, organizationClarificationCandidates) ?? + resolveOrganizationSelectionFromMessage(repairedEffectiveAddressUserMessage, organizationClarificationCandidates) ?? + (organizationClarificationSelectionFromScope && + organizationClarificationCandidates.some((candidate) => normalizeOrganizationScopeValue(candidate) === organizationClarificationSelectionFromScope) + ? organizationClarificationSelectionFromScope + : null); const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawUserMessage) || hasAssistantDataScopeMetaQuestionSignal(repairedRawUserMessage) || hasAssistantDataScopeMetaQuestionSignal(effectiveAddressUserMessage) || @@ -4123,6 +4243,12 @@ function resolveAssistantOrchestrationDecision(input) { hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) || hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) || hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage))); + const organizationClarificationContinuationDetected = Boolean(followupContext && + lastOrganizationClarificationDebug && + organizationClarificationSelection && + !dataScopeMetaQuery && + !capabilityMetaQuery && + !dataRetrievalSignal); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && !capabilityMetaQuery && @@ -4133,7 +4259,16 @@ function resolveAssistantOrchestrationDecision(input) { const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && deterministicNonDomainGuard && (llmFirstUnsupportedCandidate || llmContractMode === null) && - !protectedInventoryShortFollowup); + !protectedInventoryShortFollowup && + !organizationClarificationContinuationDetected); + const contextualHistoricalCapabilityFollowupDetected = Boolean(capabilityMetaQuery && + !dataScopeMetaQuery && + !dataRetrievalSignal && + (hasHistoricalCapabilityFollowupSignal(rawUserMessage) || + hasHistoricalCapabilityFollowupSignal(repairedRawUserMessage) || + hasHistoricalCapabilityFollowupSignal(effectiveAddressUserMessage) || + hasHistoricalCapabilityFollowupSignal(repairedEffectiveAddressUserMessage)) && + isGroundedInventoryContextDebug(lastGroundedAddressDebug)); const hardMetaMode = dataScopeMetaQuery ? "data_scope" : capabilityMetaQuery && !dataRetrievalSignal @@ -4168,6 +4303,34 @@ function resolveAssistantOrchestrationDecision(input) { }; } if (hardMetaMode === "capability") { + if (contextualHistoricalCapabilityFollowupDetected) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "inventory_history_capability_followup_detected", + livingMode: "chat", + livingReason: "inventory_history_capability_followup_detected", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: "capability", + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + followup_context_detected: Boolean(followupContext || lastGroundedAddressDebug), + unsupported_address_intent_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "inventory_history_capability_followup_detected", + living_mode: "chat", + living_reason: "inventory_history_capability_followup_detected" + } + } + }; + } return { runAddressLane: false, toolGateDecision: "skip_address_lane", @@ -4223,6 +4386,10 @@ function resolveAssistantOrchestrationDecision(input) { } }; } + const metaAnswerFollowupSignal = hasMetaAnswerFollowupSignal(rawUserMessage) || + hasMetaAnswerFollowupSignal(repairedRawUserMessage) || + hasMetaAnswerFollowupSignal(effectiveAddressUserMessage) || + hasMetaAnswerFollowupSignal(repairedEffectiveAddressUserMessage); const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && @@ -4317,6 +4484,19 @@ function resolveAssistantOrchestrationDecision(input) { repairedEffectiveAddressUserMessage, sessionItems })); + const hasPriorAddressAnswerContext = Boolean(lastGroundedAddressDebug || toNonEmptyString(followupContext?.previous_intent)); + const metaFollowupOverGroundedAnswer = Boolean(followupContext && + hasPriorAddressAnswerContext && + metaAnswerFollowupSignal && + !dataScopeMetaQuery && + !capabilityMetaQuery && + !aggregateBusinessAnalyticsSignal && + !dataRetrievalSignal && + !strongDataSignal && + resolvedModeDetection.mode !== "address_query" && + resolvedIntentResolution.intent === "unknown" && + (!llmContractIntent || llmContractIntent === "unknown") && + llmContractMode !== "address_query"); let runAddressLane = Boolean(baseToolGate?.runAddressLane); let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane"); let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0"); @@ -4342,6 +4522,11 @@ function resolveAssistantOrchestrationDecision(input) { toolGateDecision = "skip_address_lane"; toolGateReason = "deep_session_continuation_fallback_to_deep"; } + if (metaFollowupOverGroundedAnswer) { + runAddressLane = false; + toolGateDecision = "skip_address_lane"; + toolGateReason = "meta_followup_over_grounded_answer"; + } let livingDecision = resolveLivingAssistantModeDecision({ userMessage: rawUserMessage, addressLaneTriggered: runAddressLane, @@ -4375,6 +4560,12 @@ function resolveAssistantOrchestrationDecision(input) { reason: "deep_session_continuation_fallback_to_deep" }; } + if (metaFollowupOverGroundedAnswer) { + livingDecision = { + mode: "chat", + reason: "meta_followup_over_grounded_answer" + }; + } return { runAddressLane, toolGateDecision, @@ -4476,6 +4667,105 @@ function findLastAssistantLivingChatDebug(items) { } return null; } +function findLastGroundedAddressAnswerDebug(items) { + if (!Array.isArray(items)) { + return null; + } + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const debug = item.debug; + if (debug.execution_lane !== "address_query") { + continue; + } + const groundingStatus = toNonEmptyString(debug.answer_grounding_check?.status); + if (groundingStatus === "grounded") { + return debug; + } + } + return null; +} +function findLastOrganizationClarificationAddressDebug(items) { + if (!Array.isArray(items)) { + return null; + } + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const debug = item.debug; + if (debug.execution_lane !== "address_query" && debug.detected_mode !== "address_query") { + continue; + } + const limitedCategory = toNonEmptyString(debug.limited_reason_category); + const candidates = Array.isArray(debug.organization_candidates) + ? mergeKnownOrganizations(debug.organization_candidates) + : []; + if (limitedCategory === "missing_anchor" && candidates.length > 0) { + return debug; + } + } + return null; +} +function hasMetaAnswerFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText] + .filter((item) => item.length > 0) + .map((item) => item.replace(/ё/g, "е")); + if (samples.length === 0) { + return false; + } + const hasReflectionCue = samples.some((sample) => sample.includes("дума") || + sample.includes("скаж") || + sample.includes("мнение") || + sample.includes("как тебе") || + sample.includes("норм") || + sample.includes("стран") || + sample.includes("логич") || + sample.includes("смуща") || + sample.includes("выгляд")); + const hasTopicPointerCue = samples.some((sample) => sample.includes("на эту тему") || + sample.includes("по этому поводу") || + sample.includes("об этом") || + (sample.includes("это") && hasReferentialPointer(sample))); + if (!(hasReflectionCue && hasTopicPointerCue)) { + return false; + } + return !samples.some((sample) => hasAssistantDataScopeMetaQuestionSignal(sample) || + shouldHandleAsAssistantCapabilityMetaQuery(sample) || + hasDataRetrievalRequestSignal(sample) || + hasStrongDataIntentSignal(sample)); +} +function hasHistoricalCapabilityFollowupSignal(text) { + const repaired = repairAddressMojibake(String(text ?? "")); + const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + const hasHistoryCue = /(?:историческ|история|архив|прошл(?:ый|ые|ую|ых)?|раньше|ретро|старые\s+данные)/iu.test(normalized); + if (!hasHistoryCue) { + return false; + } + return /(?:мож(?:ешь|ете|но)|уме(?:ешь|ете)|показ|вывед|дай|раскрой)/iu.test(normalized); +} +function isGroundedInventoryContextDebug(debug) { + if (!debug || typeof debug !== "object") { + return false; + } + const detectedIntent = toNonEmptyString(debug.detected_intent); + const capabilityId = toNonEmptyString(debug.capability_id); + const rootFrameContext = debug.address_root_frame_context && typeof debug.address_root_frame_context === "object" + ? debug.address_root_frame_context + : null; + const rootIntent = toNonEmptyString(rootFrameContext?.root_intent); + return detectedIntent === "inventory_on_hand_as_of_date" || + capabilityId === "confirmed_inventory_on_hand_as_of_date" || + rootIntent === "inventory_on_hand_as_of_date"; +} function hasOrganizationFactFollowupSignal(userMessage, items) { const repaired = repairAddressMojibake(String(userMessage ?? "")); const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е"); @@ -4764,7 +5054,6 @@ function normalizeOrganizationScopeValue(value) { return null; } const unwrapped = normalized - .replace(/^\\+|\\+$/g, "") .replace(/^"+|"+$/g, "") .replace(/^'+|'+$/g, "") .trim(); @@ -4905,8 +5194,34 @@ function mergeKnownOrganizations(values) { } return Array.from(dedup.values()).slice(0, 20); } -function extractKnownOrganizationsFromHistory(items) { +function extractKnownOrganizationsFromNavigationState(addressNavigationState) { + if (!addressNavigationState || typeof addressNavigationState !== "object") { + return []; + } const collected = []; + const sessionContext = addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null; + const directOrganization = normalizeOrganizationScopeValue(sessionContext?.organization_scope); + if (directOrganization) { + collected.push(directOrganization); + } + const resultSets = Array.isArray(addressNavigationState.result_sets) ? addressNavigationState.result_sets : []; + for (const resultSet of resultSets) { + const filters = resultSet?.filters && typeof resultSet.filters === "object" ? resultSet.filters : null; + const scopedOrganization = normalizeOrganizationScopeValue(filters?.organization); + if (scopedOrganization) { + collected.push(scopedOrganization); + } + } + return mergeKnownOrganizations(collected); +} +function extractKnownOrganizationsFromHistory(items, addressNavigationState = null) { + const collected = []; + const navigationOrganizations = extractKnownOrganizationsFromNavigationState(addressNavigationState); + if (navigationOrganizations.length > 0) { + collected.push(...navigationOrganizations); + } for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) { const item = items[index]; if (!item || item.role !== "assistant") { @@ -4920,8 +5235,17 @@ function extractKnownOrganizationsFromHistory(items) { const knownFromDebug = Array.isArray(debug.assistant_known_organizations) ? debug.assistant_known_organizations : []; - if (directFromProbe.length > 0 || knownFromDebug.length > 0) { - collected.push(...directFromProbe, ...knownFromDebug); + const directFromCandidates = Array.isArray(debug.organization_candidates) + ? debug.organization_candidates + : []; + const directFromResolved = [ + normalizeOrganizationScopeValue(debug.assistant_active_organization), + normalizeOrganizationScopeValue(debug.living_chat_selected_organization), + normalizeOrganizationScopeValue(debug.extracted_filters?.organization), + normalizeOrganizationScopeValue(debug.address_root_frame_context?.organization) + ].filter(Boolean); + if (directFromProbe.length > 0 || knownFromDebug.length > 0 || directFromCandidates.length > 0 || directFromResolved.length > 0) { + collected.push(...directFromProbe, ...knownFromDebug, ...directFromCandidates, ...directFromResolved); } } const parsedFromText = parseOrganizationsFromDataScopeAssistantText(item.text); @@ -4934,7 +5258,16 @@ function extractKnownOrganizationsFromHistory(items) { } return mergeKnownOrganizations(collected); } -function findLastAssistantActiveOrganization(items) { +function findLastAssistantActiveOrganization(items, addressNavigationState = null) { + const sessionContext = addressNavigationState && typeof addressNavigationState === "object" + ? (addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null) + : null; + const navigationOrganization = normalizeOrganizationScopeValue(sessionContext?.organization_scope); + if (navigationOrganization) { + return navigationOrganization; + } for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) { const item = items[index]; if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { @@ -4980,10 +5313,11 @@ function resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations } return best.organization; } -function resolveSessionOrganizationScopeContext(userMessage, items) { +function resolveSessionOrganizationScopeContext(userMessage, items, addressNavigationState = null) { return (0, assistantOrganizationScopeRuntimeAdapter_1.resolveSessionOrganizationScopeContextRuntime)({ userMessage, items, + addressNavigationState, extractKnownOrganizationsFromHistory, resolveOrganizationSelectionFromMessage, findLastAssistantActiveOrganization, @@ -4998,8 +5332,8 @@ function mergeFollowupContextWithOrganizationScope(followupContext, organization toNonEmptyString }); } -function resolveSessionOrganizationScopeContextForTests(userMessage, items) { - return resolveSessionOrganizationScopeContext(userMessage, items); +function resolveSessionOrganizationScopeContextForTests(userMessage, items, addressNavigationState = null) { + return resolveSessionOrganizationScopeContext(userMessage, items, addressNavigationState); } function normalizeGuidValue(value) { const source = normalizeScopeLabel(value); diff --git a/llm_normalizer/backend/dist/services/assistantTurnAttemptInputBuilder.js b/llm_normalizer/backend/dist/services/assistantTurnAttemptInputBuilder.js index 7d6787e..e560c68 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnAttemptInputBuilder.js +++ b/llm_normalizer/backend/dist/services/assistantTurnAttemptInputBuilder.js @@ -8,6 +8,7 @@ function buildAssistantTurnAttemptAddressRuntimeInput(input) { sessionId: input.userTurn.sessionId, userMessage: input.userTurn.userMessage, sessionItems: input.userTurn.session.items, + sessionAddressNavigationState: input.userTurn.session.address_navigation_state ?? null, runtimeAnalysisContext: input.userTurn.runtimeAnalysisContext, sessionOrganizationScope: input.sessionOrganizationScope }; diff --git a/llm_normalizer/backend/dist/services/assistantTurnAttemptRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantTurnAttemptRuntimeAdapter.js index e8005c7..c1f0ef3 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnAttemptRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantTurnAttemptRuntimeAdapter.js @@ -4,7 +4,7 @@ exports.runAssistantTurnAttemptRuntime = runAssistantTurnAttemptRuntime; const assistantTurnAttemptInputBuilder_1 = require("./assistantTurnAttemptInputBuilder"); async function runAssistantTurnAttemptRuntime(input) { const userTurn = input.runUserTurnBootstrapRuntime(input.payload); - const sessionOrganizationScope = input.resolveSessionOrganizationScopeContext(userTurn.userMessage, userTurn.session.items); + const sessionOrganizationScope = input.resolveSessionOrganizationScopeContext(userTurn.userMessage, userTurn.session.items, userTurn.session.address_navigation_state ?? null); const addressRuntime = await input.runAddressAttemptRuntime((0, assistantTurnAttemptInputBuilder_1.buildAssistantTurnAttemptAddressRuntimeInput)({ payload: input.payload, userTurn, diff --git a/llm_normalizer/backend/dist/services/assistantTurnRuntimeInputBuilder.js b/llm_normalizer/backend/dist/services/assistantTurnRuntimeInputBuilder.js index 4b5ce9e..2e91856 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnRuntimeInputBuilder.js +++ b/llm_normalizer/backend/dist/services/assistantTurnRuntimeInputBuilder.js @@ -23,6 +23,7 @@ function buildAssistantAddressAttemptRuntimeInput(runtimeInput, deps) { sessionId: runtimeInput.sessionId, userMessage: runtimeInput.userMessage, sessionItems: runtimeInput.sessionItems, + sessionAddressNavigationState: runtimeInput.sessionAddressNavigationState, payload: runtimeInput.payload, sessionScope: { knownOrganizations: runtimeInput.sessionOrganizationScope.knownOrganizations, diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index f3c4124..dfe1d93 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1633,6 +1633,15 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolea ); } +function hasSelectedObjectInventorySaleTraceSignal(text: string): boolean { + return ( + hasSelectedObjectInventoryCue(text) && + /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test( + text + ) + ); +} + function hasInventoryProvenanceSignalV2(text: string): boolean { const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); const hasSupplierCue = @@ -1663,9 +1672,9 @@ function hasInventoryPurchaseDocumentsSignalV2(text: string): boolean { } function hasInventorySaleTraceSignalV2(text: string): boolean { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:я|ю|и)|продукци(?:я|ю|и))/iu.test(text); const hasTraceCue = - /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test( + /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|товар|позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test( text ); return hasItemCue && hasTraceCue; @@ -1944,6 +1953,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti }; } + if (hasSelectedObjectInventorySaleTraceSignal(text)) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_sale_trace_signal_detected"] + }; + } + if (hasInventorySaleTraceSignalV2(text)) { return { intent: "inventory_sale_trace_for_item", diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts index 5db9346..772c1d4 100644 --- a/llm_normalizer/backend/src/services/addressNavigationState.ts +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -29,7 +29,14 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT: Partial> = { @@ -48,6 +55,13 @@ const RESULT_SET_TYPE_BY_INTENT: Partial(); + const lines = String(replyText ?? "").split(/\r?\n/); + for (const line of lines) { + const match = line.match(/(?:^|\|)\s*организац(?:ия|ии)\s*:\s*([^|]+)/iu); + if (!match) { + continue; + } + const organization = toNonEmptyString(match[1]); + if (!organization) { + continue; + } + const key = organization.toLowerCase(); + if (!dedup.has(key)) { + dedup.set(key, organization); + } + if (dedup.size >= limit) { + break; + } + } + return Array.from(dedup.values()); +} + +function resolveDerivedOrganizationScope( + debug: Record, + filters: Record, + replyText: string +): string | null { + const rootFrameContext = toObject(debug.address_root_frame_context) ?? {}; + const candidates = [ + toNonEmptyString(filters.organization), + toNonEmptyString(rootFrameContext.organization), + ...extractOrganizationsFromAssistantReply(replyText) + ].filter((value): value is string => Boolean(value)); + const dedup = Array.from(new Map(candidates.map((value) => [value.toLowerCase(), value])).values()); + return dedup.length === 1 ? dedup[0] : null; +} + function cloneFocusObject(value: AddressFocusObject | null): AddressFocusObject | null { if (!value) { return null; @@ -392,6 +452,14 @@ export function evolveAddressNavigationStateWithAssistantItem( const resultSetId = `rs-${item.message_id}`; const routeId = toNonEmptyString(debug.selected_recipe); const filters = normalizeFilters(debug.extracted_filters); + const derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text); + const filtersWithDerivedScope = + derivedOrganizationScope && !toNonEmptyString(filters.organization) + ? { + ...filters, + organization: derivedOrganizationScope + } + : filters; const sourceRefs = routeId ? [routeId] : []; const entityRefs = extractEntityRefsFromAssistantReply(item.text, intent); const resultSet: AddressResultSet = { @@ -399,7 +467,7 @@ export function evolveAddressNavigationStateWithAssistantItem( type: inferResultSetType(intent), intent, route_id: routeId, - filters, + filters: filtersWithDerivedScope, source_refs: sourceRefs, entity_refs: entityRefs, created_from_turn: turnIndex, @@ -418,11 +486,11 @@ export function evolveAddressNavigationStateWithAssistantItem( created_at: createdAt }; const normalizedDateScope = { - as_of_date: toNonEmptyString(filters.as_of_date), - period_from: toNonEmptyString(filters.period_from), - period_to: toNonEmptyString(filters.period_to) + as_of_date: toNonEmptyString(filtersWithDerivedScope.as_of_date), + period_from: toNonEmptyString(filtersWithDerivedScope.period_from), + period_to: toNonEmptyString(filtersWithDerivedScope.period_to) }; - const organizationScope = toNonEmptyString(filters.organization); + const organizationScope = toNonEmptyString(filtersWithDerivedScope.organization); const nextResultSets = capResultSets( [...state.result_sets.filter((itemSet) => itemSet.result_set_id !== resultSetId), resultSet].sort( (left, right) => left.created_from_turn - right.created_from_turn diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 333a27c..0a7d56e 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -18,11 +18,13 @@ import type { AddressLimitedReasonCategory, AddressMatchFailureStage, AddressMcpCallStatus, + AddressModeDetection, AddressQueryShapeDetection, AddressResultMode, AddressResponseType, AddressRuntimeReadiness, - AddressSemanticFrame + AddressSemanticFrame, + AddressIntentResolution } from "../types/addressQuery"; import { buildAddressRecipePlan, @@ -1508,9 +1510,38 @@ function applyPreExecutionOrganizationScopeGrounding(input: { } } + if (!input.filters.organization && !activeOrganization && !resolvedOrganizationFromMessage && candidateOrganizations.length === 1) { + input.filters.organization = candidateOrganizations[0]; + if (!input.warnings.includes("organization_auto_selected_from_single_scope_candidate")) { + input.warnings.push("organization_auto_selected_from_single_scope_candidate"); + } + if (!input.baseReasons.includes("organization_auto_selected_from_single_scope_candidate")) { + input.baseReasons.push("organization_auto_selected_from_single_scope_candidate"); + } + if (input.semanticFrame?.anchor_kind === "organization") { + input.semanticFrame.anchor_value = candidateOrganizations[0]; + } + } + return resolvedOrganizationFromMessage; } +function isOrganizationScopedInventoryIntent(intent: AddressIntent): boolean { + return ( + intent === "inventory_on_hand_as_of_date" || + intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_supplier_stock_overlap_as_of_date" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date" + ); +} + +function collectOrganizationCandidatesFromRows(rows: NormalizedAddressRow[]): string[] { + return mergeKnownOrganizations(rows.map((row) => row.organization).filter((value): value is string => Boolean(value))); +} + function isHeuristicCandidatesIntent(intent: AddressIntent): boolean { return ( intent === "list_receivables_counterparties" || @@ -1976,7 +2007,32 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean { } function shouldClearAsOfDateForHistoryRecovery(intent: AddressIntent): boolean { - return intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item"; + return ( + intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" + ); +} + +function shouldDetachLifecycleExecutionFromSnapshotContext( + intent: AddressIntent, + reasons: string[] +): boolean { + if ( + intent !== "inventory_purchase_provenance_for_item" && + intent !== "inventory_purchase_documents_for_item" && + intent !== "inventory_sale_trace_for_item" && + intent !== "inventory_purchase_to_sale_chain" + ) { + return false; + } + + return ( + reasons.includes("as_of_date_from_followup_context") || + reasons.includes("period_from_followup_context") || + reasons.includes("as_of_date_from_open_items_followup_context") + ); } function invertSort(sort: AddressFilterSet["sort"]): AddressFilterSet["sort"] { @@ -2804,6 +2860,62 @@ function buildLimitedExecutionResult(input: { }; } +function composeOrganizationClarificationReply(organizations: string[]): string { + const normalizedOrganizations = mergeKnownOrganizations(organizations).slice(0, 10); + const lines = [ + normalizedOrganizations.length > 1 + ? "Нужно уточнить организацию, чтобы не смешивать компании в одном ответе." + : "Нужно уточнить организацию, чтобы продолжить запрос.", + normalizedOrganizations.length > 0 + ? "Сейчас в доступном контуре вижу такие организации:" + : "Уточни, по какой организации продолжать." + ]; + for (const organization of normalizedOrganizations) { + lines.push(`- ${organization}`); + } + lines.push("Можешь ответить просто названием компании, и я продолжу этот же запрос."); + return lines.join("\n"); +} + +function buildOrganizationClarificationExecutionResult(input: { + mode: AddressModeDetection; + shape: AddressQueryShapeDetection; + intent: AddressIntentResolution; + filters: AddressFilterSet; + anchor?: AnchorResolutionDebug; + organizations: string[]; + reasons: string[]; + semanticFrame?: AddressSemanticFrame | null; + capabilityAudit?: AddressCapabilityAudit; + shadowRouteAudit?: AddressShadowRouteAudit; + routeExpectationAudit?: AddressRouteExpectationAuditState; +}): AddressExecutionResult { + const result = buildLimitedExecutionResult({ + mode: input.mode, + shape: input.shape, + intent: input.intent, + filters: input.filters, + missingRequiredFilters: ["organization"], + selectedRecipe: null, + anchor: input.anchor, + mcpCallStatus: "skipped", + rowsFetched: 0, + rowsMatched: 0, + category: "missing_anchor", + reasonText: "не указана организация, а в доступном контуре найдено несколько компаний", + nextStep: "уточните организацию из списка, и я продолжу этот же запрос", + limitations: ["organization_clarification_required", "multiple_known_organizations_detected"], + reasons: [...input.reasons, "organization_clarification_required", "multiple_known_organizations_detected"], + semanticFrame: input.semanticFrame, + capabilityAudit: input.capabilityAudit, + shadowRouteAudit: input.shadowRouteAudit, + routeExpectationAudit: input.routeExpectationAudit + }); + result.reply_text = composeOrganizationClarificationReply(input.organizations); + result.debug.organization_candidates = mergeKnownOrganizations(input.organizations); + return result; +} + export class AddressQueryService { public async tryHandle(userMessage: string, options: AddressTryHandleOptions = {}): Promise { if (!FEATURE_ASSISTANT_ADDRESS_QUERY_V1) { @@ -2843,6 +2955,31 @@ export class AddressQueryService { activeOrganization: options.activeOrganization ?? null, knownOrganizations: options.knownOrganizations ?? [] }); + const knownOrganizations = mergeKnownOrganizations(options.knownOrganizations ?? []); + const activeOrganization = normalizeOrganizationScopeValue(options.activeOrganization ?? null); + if ( + isOrganizationScopedInventoryIntent(intent.intent) && + !toNonEmptyFilterValue(filters.extracted_filters.organization) && + !activeOrganization && + !resolvedOrganizationFromMessage && + knownOrganizations.length > 1 + ) { + return buildOrganizationClarificationExecutionResult({ + mode, + shape, + intent, + filters: filters.extracted_filters, + organizations: knownOrganizations, + reasons: [...baseReasons, "organization_candidates_from_scope_context"], + semanticFrame, + capabilityAudit: buildCapabilityAudit(intent.intent), + shadowRouteAudit: buildShadowRouteAudit({ + intent: intent.intent, + requestedResultMode: resolveRequestedResultMode(intent.intent, filters.extracted_filters, semanticFrame), + filters: filters.extracted_filters + }) + }); + } const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters, semanticFrame); const confirmedBalancePayablesIntent = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") && @@ -2916,6 +3053,50 @@ export class AddressQueryService { baseReasons.push("as_of_date_derived_for_inventory_on_hand"); } } + if (shouldDetachLifecycleExecutionFromSnapshotContext(intent.intent, baseReasons)) { + const detachedExecutionFilters: AddressFilterSet = { ...executionFilters }; + let periodDetached = false; + let asOfDetached = false; + if ( + toNonEmptyFilterValue(detachedExecutionFilters.period_from) || + toNonEmptyFilterValue(detachedExecutionFilters.period_to) + ) { + delete detachedExecutionFilters.period_from; + delete detachedExecutionFilters.period_to; + periodDetached = true; + } + if (toNonEmptyFilterValue(detachedExecutionFilters.as_of_date)) { + delete detachedExecutionFilters.as_of_date; + asOfDetached = true; + } + if (periodDetached || asOfDetached) { + executionFilters = detachedExecutionFilters; + if (periodDetached && !filters.warnings.includes("period_window_detached_for_lifecycle_execution")) { + filters.warnings.push("period_window_detached_for_lifecycle_execution"); + } + if (periodDetached && !baseReasons.includes("period_window_detached_for_lifecycle_execution")) { + baseReasons.push("period_window_detached_for_lifecycle_execution"); + } + if ( + (periodDetached || asOfDetached) && + !filters.warnings.includes("lifecycle_execution_detached_from_snapshot_date") + ) { + filters.warnings.push("lifecycle_execution_detached_from_snapshot_date"); + } + if ( + (periodDetached || asOfDetached) && + !baseReasons.includes("lifecycle_execution_detached_from_snapshot_date") + ) { + baseReasons.push("lifecycle_execution_detached_from_snapshot_date"); + } + if (asOfDetached && !filters.warnings.includes("as_of_date_cleared_for_history_recovery")) { + filters.warnings.push("as_of_date_cleared_for_history_recovery"); + } + if (asOfDetached && !baseReasons.includes("as_of_date_cleared_for_history_recovery")) { + baseReasons.push("as_of_date_cleared_for_history_recovery"); + } + } + } const capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent); const shadowRouteAudit = buildShadowRouteAudit({ @@ -3419,6 +3600,42 @@ export class AddressQueryService { baseReasons.push("organization_scope_live_grounding_recovered_rows"); } } + if ( + filteredRows.length > 0 && + isOrganizationScopedInventoryIntent(intent.intent) && + !toNonEmptyFilterValue(filters.extracted_filters.organization) + ) { + const observedOrganizations = collectOrganizationCandidatesFromRows(filteredRows); + if (observedOrganizations.length === 1) { + filters.extracted_filters = { + ...filters.extracted_filters, + organization: observedOrganizations[0] + }; + executionFilters = { + ...executionFilters, + organization: observedOrganizations[0] + }; + if (!filters.warnings.includes("organization_grounded_from_observed_rows")) { + filters.warnings.push("organization_grounded_from_observed_rows"); + } + if (!baseReasons.includes("organization_grounded_from_observed_rows")) { + baseReasons.push("organization_grounded_from_observed_rows"); + } + } else if (observedOrganizations.length > 1) { + return buildOrganizationClarificationExecutionResult({ + mode, + shape, + intent, + filters: filters.extracted_filters, + anchor, + organizations: observedOrganizations, + reasons: [...baseReasons, "organization_candidates_from_observed_rows"], + semanticFrame, + capabilityAudit, + shadowRouteAudit + }); + } + } if (filteredRows.length === 0 && intent.intent === "list_documents_by_contract" && filterByAnchors.length > 0) { const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors); @@ -3655,7 +3872,7 @@ export class AddressQueryService { const broadenedAdjustments: string[] = []; delete autoBroadenedFilters.period_from; delete autoBroadenedFilters.period_to; - if (stageStatus === "no_raw_rows" && shouldClearAsOfDateForHistoryRecovery(intent.intent) && toNonEmptyFilterValue(autoBroadenedFilters.as_of_date)) { + if (shouldClearAsOfDateForHistoryRecovery(intent.intent) && toNonEmptyFilterValue(autoBroadenedFilters.as_of_date)) { delete autoBroadenedFilters.as_of_date; broadenedAdjustments.push("as_of_date_cleared_for_history_recovery"); } diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index a1a0865..5c3bf92 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -38,7 +38,8 @@ const INVENTORY_MOVEMENTS_QUERY_TEMPLATE = ` ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3, ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1, ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2, - ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3 + ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения __WHERE_CLAUSE__ @@ -967,6 +968,21 @@ function toDateTimeExpr(isoDate: string, endOfDay: boolean): string | null { return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`; } +function toQueryStringLiteral(value: string): string { + return String(value ?? "").replace(/"/g, '""'); +} + +function buildOrganizationPresentationCondition(filters: AddressFilterSet, fieldPath: string): string | null { + const organization = + typeof filters.organization === "string" && filters.organization.trim().length > 0 + ? filters.organization.trim() + : ""; + if (!organization) { + return null; + } + return `ПРЕДСТАВЛЕНИЕ(${fieldPath}) = "${toQueryStringLiteral(organization)}"`; +} + function buildWhereClause(filters: AddressFilterSet, fieldPath: string, extraConditions: string[] = []): string { const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0 @@ -1138,11 +1154,16 @@ function buildInventoryMovementQuery( : side === "kt" ? creditPredicate : `(${debitPredicate} ИЛИ ${creditPredicate})`; + const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация"); return INVENTORY_MOVEMENTS_QUERY_TEMPLATE .replace("__LIMIT__", String(resolvedLimit)) .replace( "__WHERE_CLAUSE__", - buildWhereClause(filters, "Движения.Период", [inventoryCondition]) + buildWhereClause( + filters, + "Движения.Период", + [inventoryCondition, organizationCondition].filter((item): item is string => Boolean(item)) + ) ) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); } diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 149bfbc..de5a288 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -359,6 +359,15 @@ function isInventoryDrilldownFrameIntent(intent: AddressIntent | undefined): boo ); } +function isInventoryLifecycleHistoryIntent(intent: AddressIntent | undefined): boolean { + return ( + intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" + ); +} + function buildInventoryRootFollowupContext( followupContext: AddressFollowupContext | null ): AddressFollowupContext | null { @@ -529,7 +538,7 @@ function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean { } function hasInventorySaleFollowupCue(text: string): boolean { - return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|кто\s+купил|buyer|покупател)/iu.test( + return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил|buyer|покупател)/iu.test( String(text ?? "") ); } @@ -786,21 +795,19 @@ function mergeFollowupFilters( } if ( !sameDateRequested && - (intent === "inventory_purchase_provenance_for_item" || - intent === "inventory_purchase_documents_for_item" || - intent === "inventory_sale_trace_for_item" || - intent === "inventory_purchase_to_sale_chain" || - intent === "inventory_aging_by_purchase_date") && + (intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage) ) { - const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; - const currentAsOfDate = toNonEmptyString(merged.as_of_date); - const todayIso = new Date().toISOString().slice(0, 10); - const currentLooksDefaultedToToday = currentAsOfDate === todayIso; - if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { - merged.as_of_date = inheritedAsOfDate; - reasons.push("as_of_date_from_followup_context"); + if (intent === "inventory_aging_by_purchase_date") { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } } } if ( @@ -890,6 +897,7 @@ function mergeFollowupFilters( const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage); const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage); const hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage); + const inventoryLifecycleHistoryIntent = isInventoryLifecycleHistoryIntent(intent); const asOfPrimaryIntent = intent === "account_balance_snapshot" || intent === "documents_forming_balance" || @@ -928,7 +936,13 @@ function mergeFollowupFilters( } } - if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) { + if ( + !currentHasPeriod && + previousHasPeriod && + hasFollowupSignal && + !hasExplicitPeriodInMessage && + !inventoryLifecycleHistoryIntent + ) { if (previousPeriodFrom) { merged.period_from = previousPeriodFrom; } diff --git a/llm_normalizer/backend/src/services/assistantAddressAttemptRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressAttemptRuntimeAdapter.ts index 3cf95fb..7cc44b5 100644 --- a/llm_normalizer/backend/src/services/assistantAddressAttemptRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressAttemptRuntimeAdapter.ts @@ -42,10 +42,16 @@ interface AddressSessionScope { export interface RunAssistantAddressAttemptRuntimeInput extends Omit< RunAssistantAddressRuntimeInput, - "llmProvider" | "useMock" | "payloadContextPeriodHint" | "runAddressLaneAttempt" | "finalizeAddressLaneResponse" | "tryHandleLivingChat" + | "llmProvider" + | "useMock" + | "payloadContextPeriodHint" + | "runAddressLaneAttempt" + | "finalizeAddressLaneResponse" + | "tryHandleLivingChat" > { payload: AddressAttemptPayload; sessionScope: AddressSessionScope; + sessionAddressNavigationState?: unknown; mergeFollowupContextWithOrganizationScope: RunAssistantAddressLaneAttemptRuntimeInput["mergeFollowupContextWithOrganizationScope"]; runAddressQueryTryHandle: RunAssistantAddressLaneAttemptRuntimeInput["runAddressQueryTryHandle"]; mergeKnownOrganizations: RunAssistantLivingChatAttemptRuntimeInput["mergeKnownOrganizations"]; @@ -227,6 +233,8 @@ export async function runAssistantAddressAttemptRuntime( sessionId: input.sessionId, userMessage: input.userMessage, sessionItems: input.sessionItems, + sessionAddressNavigationState: input.sessionAddressNavigationState, + sessionScope: input.sessionScope, payload: input.payload, featureAddressLlmPredecomposeV1: input.featureAddressLlmPredecomposeV1, runAddressLlmPreDecompose: input.runAddressLlmPreDecompose, diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts index 9e267e8..f060f8a 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts @@ -206,7 +206,13 @@ export function runAssistantAddressLaneResponseRuntime) diff --git a/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts index fa68143..ba21bf4 100644 --- a/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts @@ -1,6 +1,12 @@ export interface BuildAssistantAddressOrchestrationRuntimeInput { userMessage: string; sessionItems: unknown[]; + sessionAddressNavigationState?: unknown; + sessionOrganizationScope?: { + knownOrganizations?: unknown; + selectedOrganization?: unknown; + activeOrganization?: unknown; + } | null; llmProvider: unknown; useMock: boolean; featureAddressLlmPredecomposeV1: boolean; @@ -15,7 +21,8 @@ export interface BuildAssistantAddressOrchestrationRuntimeInput { userMessage: string, sessionItems: unknown[], addressInputMessage: string, - addressPreDecompose: Record + addressPreDecompose: Record, + sessionAddressNavigationState?: unknown ) => AssistantAddressCarryoverLike | null; resolveAssistantOrchestrationDecision: (input: { rawUserMessage: string; @@ -23,6 +30,7 @@ export interface BuildAssistantAddressOrchestrationRuntimeInput { followupContext: unknown; llmPreDecomposeMeta: Record; sessionItems?: unknown[]; + sessionOrganizationScope?: unknown; useMock: boolean; }) => Record; buildAddressDialogContinuationContractV2: ( @@ -57,7 +65,7 @@ function hasSelectedObjectInventorySignal(text: string | null): boolean { } function hasSelectedObjectInventoryActionCue(text: string | null): boolean { - return /(?:кому[\s\S]{0,80}продал[аи]?|кому[\s\S]{0,80}реализова[нлт][а-я]*|кому\s+был\s+продан|кто[\s\S]{0,40}купил|кто\s+это\s+поставил|кто\s+поставил|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+мы\s+купили|где\s+куплено|по\s+каким\s+документам|какими\s+документами|покажи\s+документы|документы\s+закупки|buyer|sale\s+trace|supplier|vendor|purchase\s+documents|purchase[\s-]?to[\s-]?sale|old\s+purchase|aged\s+stock)/iu.test( + return /(?:кому[\s\S]{0,80}продал[аи]?|кому[\s\S]{0,80}реализова[нлт][а-я]*|кому\s+был\s+продан|куда[\s\S]{0,80}продал[аи]?|куда[\s\S]{0,80}реализова[нлт][а-я]*|кто[\s\S]{0,40}купил|кто\s+это\s+поставил|кто\s+поставил|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+мы\s+купили|где\s+куплено|по\s+каким\s+документам|какими\s+документами|покажи\s+документы|документы\s+закупки|buyer|sale\s+trace|supplier|vendor|purchase\s+documents|purchase[\s-]?to[\s-]?sale|old\s+purchase|aged\s+stock)/iu.test( String(text ?? "") ); } @@ -154,7 +162,8 @@ export async function buildAssistantAddressOrchestrationRuntime( input.userMessage, input.sessionItems, addressInputMessage, - addressPreDecompose + addressPreDecompose, + input.sessionAddressNavigationState ); if ( shouldPreferRawFollowupMessage( @@ -180,7 +189,8 @@ export async function buildAssistantAddressOrchestrationRuntime( input.userMessage, input.sessionItems, addressInputMessage, - addressPreDecompose + addressPreDecompose, + input.sessionAddressNavigationState ); } @@ -191,6 +201,7 @@ export async function buildAssistantAddressOrchestrationRuntime( followupContext, llmPreDecomposeMeta: addressPreDecompose, sessionItems: input.sessionItems, + sessionOrganizationScope: input.sessionOrganizationScope ?? null, useMock: input.useMock }); const dialogContinuationContract = input.buildAddressDialogContinuationContractV2( diff --git a/llm_normalizer/backend/src/services/assistantAddressRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressRuntimeAdapter.ts index cdd61fc..c63f1ed 100644 --- a/llm_normalizer/backend/src/services/assistantAddressRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressRuntimeAdapter.ts @@ -19,6 +19,12 @@ export interface RunAssistantAddressRuntimeInput { sessionId: string; userMessage: string; sessionItems: unknown[]; + sessionAddressNavigationState?: unknown; + sessionOrganizationScope?: { + knownOrganizations?: unknown; + selectedOrganization?: unknown; + activeOrganization?: unknown; + } | null; llmProvider: unknown; useMock: boolean; featureAddressLlmPredecomposeV1: boolean; @@ -112,6 +118,8 @@ export async function runAssistantAddressRuntime( const addressOrchestrationRuntime = await runAddressOrchestrationRuntimeSafe({ userMessage: input.userMessage, sessionItems: input.sessionItems, + sessionAddressNavigationState: input.sessionAddressNavigationState, + sessionOrganizationScope: input.sessionOrganizationScope ?? null, llmProvider: input.llmProvider, useMock: input.useMock, featureAddressLlmPredecomposeV1: input.featureAddressLlmPredecomposeV1, diff --git a/llm_normalizer/backend/src/services/assistantAddressRuntimeInputBuilder.ts b/llm_normalizer/backend/src/services/assistantAddressRuntimeInputBuilder.ts index 7388fb5..5c2c008 100644 --- a/llm_normalizer/backend/src/services/assistantAddressRuntimeInputBuilder.ts +++ b/llm_normalizer/backend/src/services/assistantAddressRuntimeInputBuilder.ts @@ -11,6 +11,11 @@ interface AssistantAddressAttemptPayloadLike { export interface BuildAssistantAddressRuntimeInputInput extends Omit, "llmProvider" | "useMock" | "payloadContextPeriodHint"> { payload: AssistantAddressAttemptPayloadLike; + sessionScope?: { + knownOrganizations?: unknown; + selectedOrganization?: unknown; + activeOrganization?: unknown; + } | null; } export function buildAssistantAddressRuntimeInput( @@ -21,6 +26,8 @@ export function buildAssistantAddressRuntimeInput( sessionId: input.sessionId, userMessage: input.userMessage, sessionItems: input.sessionItems, + sessionAddressNavigationState: input.sessionAddressNavigationState, + sessionOrganizationScope: input.sessionScope, llmProvider: input.payload.llmProvider, useMock: Boolean(input.payload.useMock), featureAddressLlmPredecomposeV1: input.featureAddressLlmPredecomposeV1, diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 8b4de57..bf7c3cc 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -57,6 +57,84 @@ export interface AssistantLivingChatRuntimeOutput { debug: Record | null; } +function formatIsoDateForReply(value: unknown): string | null { + const source = String(value ?? "").trim(); + const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + return `${match[3]}.${match[2]}.${match[1]}`; +} + +function findLastGroundedInventoryAddressDebug(items: unknown[]): Record | null { + if (!Array.isArray(items)) { + return null; + } + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] as { role?: string; debug?: Record } | null; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const debug = item.debug; + const answerGroundingCheck = + debug.answer_grounding_check && typeof debug.answer_grounding_check === "object" + ? (debug.answer_grounding_check as Record) + : null; + const groundingStatus = String(answerGroundingCheck?.status ?? ""); + const detectedIntent = String(debug.detected_intent ?? ""); + const capabilityId = String(debug.capability_id ?? ""); + const rootFrameContext = + debug.address_root_frame_context && typeof debug.address_root_frame_context === "object" + ? (debug.address_root_frame_context as Record) + : null; + const rootIntent = String(rootFrameContext?.root_intent ?? ""); + const isInventoryContext = + detectedIntent === "inventory_on_hand_as_of_date" || + capabilityId === "confirmed_inventory_on_hand_as_of_date" || + rootIntent === "inventory_on_hand_as_of_date"; + if (groundingStatus === "grounded" && isInventoryContext) { + return debug; + } + } + return null; +} + +function buildInventoryHistoryCapabilityFollowupReply(input: { + organization: string | null; + addressDebug: Record | null; + toNonEmptyString: (value: unknown) => string | null; +}): string { + const rootFrameContext = + input.addressDebug?.address_root_frame_context && typeof input.addressDebug.address_root_frame_context === "object" + ? (input.addressDebug.address_root_frame_context as Record) + : null; + const extractedFilters = + input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? (input.addressDebug.extracted_filters as Record) + : null; + const organization = + input.organization ?? + input.toNonEmptyString(rootFrameContext?.organization) ?? + input.toNonEmptyString(extractedFilters?.organization); + const lastAsOfDate = + formatIsoDateForReply(rootFrameContext?.as_of_date) ?? + formatIsoDateForReply(extractedFilters?.as_of_date); + const organizationPart = organization ? ` по компании «${organization}»` : ""; + const referenceLine = lastAsOfDate + ? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.` + : `Да, могу показать исторические данные${organizationPart} в этом же складском контуре.`; + return [ + referenceLine, + `Могу показать исторические остатки${organizationPart} за нужный месяц, дату или год.`, + "Например:", + "- `на март 2020`", + "- `на июнь 2016`", + "- `за 2017 год`", + "- `сравни июнь 2016 с текущим срезом`", + "Если хочешь, сразу покажу нужный исторический период." + ].join("\n"); +} + export async function runAssistantLivingChatRuntime( input: AssistantLivingChatRuntimeInput ): Promise { @@ -77,6 +155,11 @@ export async function runAssistantLivingChatRuntime( let knownOrganizations = input.mergeKnownOrganizations(input.sessionScope.knownOrganizations ?? []); let selectedOrganization = input.toNonEmptyString(input.sessionScope.selectedOrganization); let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization); + const contextualInventoryHistoryCapabilityFollowup = + input.modeDecision?.reason === "inventory_history_capability_followup_detected"; + const lastGroundedInventoryAddressDebug = contextualInventoryHistoryCapabilityFollowup + ? findLastGroundedInventoryAddressDebug(input.sessionItems) + : null; if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { chatText = input.buildAssistantSafetyRefusalReply(); @@ -119,6 +202,15 @@ export async function runAssistantLivingChatRuntime( } else if (capabilityMetaQuery && operationalSignal && !input.hasAssistantCapabilityQuestionSignal(userMessage)) { chatText = input.buildAssistantOperationalBoundaryReply(); livingChatSource = "deterministic_operational_boundary"; + } else if (contextualInventoryHistoryCapabilityFollowup) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = buildInventoryHistoryCapabilityFollowupReply({ + organization: scopedOrganization, + addressDebug: lastGroundedInventoryAddressDebug, + toNonEmptyString: input.toNonEmptyString + }); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_inventory_history_capability_contract"; } else if (capabilityMetaQuery) { chatText = input.buildAssistantCapabilityContractReply(); livingChatSource = "deterministic_capability_contract"; diff --git a/llm_normalizer/backend/src/services/assistantOrganizationMatcher.ts b/llm_normalizer/backend/src/services/assistantOrganizationMatcher.ts index 723f18a..7acc968 100644 --- a/llm_normalizer/backend/src/services/assistantOrganizationMatcher.ts +++ b/llm_normalizer/backend/src/services/assistantOrganizationMatcher.ts @@ -61,7 +61,7 @@ export function normalizeOrganizationScopeValue(value: unknown): string | null { if (!normalized) { return null; } - let unwrapped = normalized.replace(/^\\+|\\+$/g, "").trim(); + let unwrapped = normalized.trim(); if ( (unwrapped.startsWith('"') && unwrapped.endsWith('"')) || (unwrapped.startsWith("'") && unwrapped.endsWith("'")) diff --git a/llm_normalizer/backend/src/services/assistantOrganizationScopeRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantOrganizationScopeRuntimeAdapter.ts index a0ac02d..cdb5309 100644 --- a/llm_normalizer/backend/src/services/assistantOrganizationScopeRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantOrganizationScopeRuntimeAdapter.ts @@ -1,3 +1,5 @@ +import type { AddressNavigationState } from "../types/addressNavigation"; + export interface AssistantSessionOrganizationScopeContext { knownOrganizations: string[]; selectedOrganization: string | null; @@ -7,6 +9,7 @@ export interface AssistantSessionOrganizationScopeContext { export interface ResolveSessionOrganizationScopeContextRuntimeInput { userMessage: string; items: ItemType[]; + addressNavigationState?: AddressNavigationState | null; extractKnownOrganizationsFromHistory: (items: ItemType[]) => string[]; resolveOrganizationSelectionFromMessage: (userMessage: string, knownOrganizations: string[]) => string | null; findLastAssistantActiveOrganization: (items: ItemType[]) => string | null; @@ -20,16 +23,62 @@ export interface MergeFollowupContextWithOrganizationScopeRuntimeInput { toNonEmptyString: (value: unknown) => string | null; } +function extractOrganizationsFromNavigationState( + addressNavigationState: AddressNavigationState | null | undefined, + normalizeOrganizationScopeValue: ResolveSessionOrganizationScopeContextRuntimeInput["normalizeOrganizationScopeValue"] +): string[] { + if (!addressNavigationState || typeof addressNavigationState !== "object") { + return []; + } + const collected: string[] = []; + const directOrganization = normalizeOrganizationScopeValue(addressNavigationState.session_context?.organization_scope); + if (directOrganization) { + collected.push(directOrganization); + } + for (const resultSet of Array.isArray(addressNavigationState.result_sets) ? addressNavigationState.result_sets : []) { + const scopedOrganization = normalizeOrganizationScopeValue(resultSet?.filters?.organization); + if (scopedOrganization) { + collected.push(scopedOrganization); + } + } + return Array.from(new Map(collected.map((value) => [value.toLowerCase(), value])).values()); +} + +function resolveActiveOrganizationFromNavigationState( + addressNavigationState: AddressNavigationState | null | undefined, + normalizeOrganizationScopeValue: ResolveSessionOrganizationScopeContextRuntimeInput["normalizeOrganizationScopeValue"] +): string | null { + if (!addressNavigationState || typeof addressNavigationState !== "object") { + return null; + } + return normalizeOrganizationScopeValue(addressNavigationState.session_context?.organization_scope); +} + export function resolveSessionOrganizationScopeContextRuntime( input: ResolveSessionOrganizationScopeContextRuntimeInput ): AssistantSessionOrganizationScopeContext { - const knownOrganizations = input.extractKnownOrganizationsFromHistory(input.items); + const knownOrganizations = Array.from( + new Map( + [ + ...extractOrganizationsFromNavigationState(input.addressNavigationState, input.normalizeOrganizationScopeValue), + ...input.extractKnownOrganizationsFromHistory(input.items) + ].map((value) => [String(value).toLowerCase(), value]) + ).values() + ); const selectedOrganization = input.resolveOrganizationSelectionFromMessage( input.userMessage, knownOrganizations ); const lastActiveOrganization = input.findLastAssistantActiveOrganization(input.items); - const activeOrganization = selectedOrganization ?? input.normalizeOrganizationScopeValue(lastActiveOrganization); + const navigationActiveOrganization = resolveActiveOrganizationFromNavigationState( + input.addressNavigationState, + input.normalizeOrganizationScopeValue + ); + const activeOrganization = + selectedOrganization ?? + navigationActiveOrganization ?? + input.normalizeOrganizationScopeValue(lastActiveOrganization) ?? + (knownOrganizations.length === 1 ? knownOrganizations[0] : null); return { knownOrganizations, @@ -57,5 +106,16 @@ export function mergeFollowupContextWithOrganizationScopeRuntime( previousFilters.organization = normalizedOrganization; } base.previous_filters = previousFilters; + const rootFiltersRaw = base.root_filters; + const rootFilters = + rootFiltersRaw && typeof rootFiltersRaw === "object" + ? { ...(rootFiltersRaw as Record) } + : {}; + if (!input.toNonEmptyString(rootFilters.organization)) { + rootFilters.organization = normalizedOrganization; + } + if (Object.keys(rootFilters).length > 0) { + base.root_filters = rootFilters; + } return base; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 03a39e1..212157a 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -20,6 +20,7 @@ import * as assistantAddressAttemptRuntimeAdapter_1 from "./assistantAddressAtte import * as assistantCoverageGrounding_1 from "./assistantCoverageGrounding"; import * as assistantDeepTurnAttemptRuntimeAdapter_1 from "./assistantDeepTurnAttemptRuntimeAdapter"; import * as assistantOrganizationScopeRuntimeAdapter_1 from "./assistantOrganizationScopeRuntimeAdapter"; +import * as assistantOrganizationMatcher_1 from "./assistantOrganizationMatcher"; import * as assistantTurnAttemptRuntimeAdapter_1 from "./assistantTurnAttemptRuntimeAdapter"; import * as assistantTurnRuntimeDepsAdapter_1 from "./assistantTurnRuntimeDepsAdapter"; import * as assistantTurnRuntimeInputBuilder_1 from "./assistantTurnRuntimeInputBuilder"; @@ -1427,6 +1428,7 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) { account_scope_drop_reason: addressDebug.account_scope_drop_reason, runtime_readiness: addressDebug.runtime_readiness, limited_reason_category: addressDebug.limited_reason_category, + organization_candidates: addressDebug.organization_candidates ?? undefined, response_type: addressDebug.response_type, requested_result_mode: addressDebug.requested_result_mode ?? undefined, result_mode: addressDebug.result_mode ?? undefined, @@ -2747,9 +2749,18 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { } return null; } -function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) { +function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) { const previousAddressItem = findLastAddressAssistantItem(items); const previousAddressDebug = previousAddressItem?.debug ?? null; + const lastOrganizationClarificationDebug = findLastOrganizationClarificationAddressDebug(items); + const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates) + ? mergeKnownOrganizations(lastOrganizationClarificationDebug.organization_candidates) + : []; + const organizationClarificationSelection = resolveOrganizationSelectionFromMessage(userMessage, organizationClarificationCandidates) ?? + (toNonEmptyString(alternateMessage) + ? resolveOrganizationSelectionFromMessage(String(alternateMessage ?? ""), organizationClarificationCandidates) + : null); + const hasOrganizationClarificationContinuation = Boolean(lastOrganizationClarificationDebug && organizationClarificationSelection); const followupOffer = previousAddressDebug ? buildAddressFollowupOffer(previousAddressDebug) : null; const hasImplicitContinuationSignal = Boolean(previousAddressDebug) && Boolean(followupOffer?.enabled) && @@ -2780,10 +2791,15 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes !hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && + !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal) { return null; } - if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { + if (!hasPrimaryFollowupSignal && + !hasAlternateFollowupSignal && + !hasImplicitContinuationSignal && + !hasOrganizationClarificationContinuation && + !hasIndexReferenceSignal) { return null; } if (!previousAddressDebug) { @@ -2811,7 +2827,45 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes readAddressFilterString(previousAddressDebug, "counterparty") ?? readAddressFilterString(previousAddressDebug, "account") ?? readAddressFilterString(previousAddressDebug, "contract"); - const inventoryRootFrame = findRecentInventoryRootFrame(items); + const navigationSessionContext = addressNavigationState && typeof addressNavigationState === "object" + ? (addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null) + : null; + const navigationDateScope = navigationSessionContext && typeof navigationSessionContext.date_scope === "object" + ? navigationSessionContext.date_scope + : null; + const navigationOrganization = normalizeOrganizationScopeValue(navigationSessionContext?.organization_scope); + const navigationFocusObject = navigationSessionContext && typeof navigationSessionContext.active_focus_object === "object" + ? navigationSessionContext.active_focus_object + : null; + const navigationFocusObjectType = toNonEmptyString(navigationFocusObject?.object_type); + const navigationFocusObjectLabel = toNonEmptyString(navigationFocusObject?.label); + const hasSelectedObjectInventorySignalPrimary = /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(userMessage ?? "")); + const hasSelectedObjectInventorySignalAlternate = toNonEmptyString(alternateMessage) + ? /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(alternateMessage ?? "")) + : false; + let inventoryRootFrame = findRecentInventoryRootFrame(items); + if (inventoryRootFrame && navigationOrganization && !toNonEmptyString(inventoryRootFrame.filters?.organization)) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + organization: navigationOrganization + } + }; + } + if (inventoryRootFrame && navigationDateScope) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + as_of_date: toNonEmptyString(inventoryRootFrame.filters?.as_of_date) ?? toNonEmptyString(navigationDateScope.as_of_date) ?? undefined, + period_from: toNonEmptyString(inventoryRootFrame.filters?.period_from) ?? toNonEmptyString(navigationDateScope.period_from) ?? undefined, + period_to: toNonEmptyString(inventoryRootFrame.filters?.period_to) ?? toNonEmptyString(navigationDateScope.period_to) ?? undefined + } + }; + } const currentFrameKind = inventoryRootFrame ? isInventoryDrilldownFrameIntent(sourceIntent) ? "inventory_drilldown" @@ -2842,6 +2896,21 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes previousFilters.organization = historicalOrganization; } } + if (!toNonEmptyString(previousFilters.organization) && navigationOrganization) { + previousFilters.organization = navigationOrganization; + } + if (!toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) { + previousFilters.organization = organizationClarificationSelection; + } + if (!toNonEmptyString(previousFilters.as_of_date) && toNonEmptyString(navigationDateScope?.as_of_date)) { + previousFilters.as_of_date = toNonEmptyString(navigationDateScope?.as_of_date); + } + if (!toNonEmptyString(previousFilters.period_from) && toNonEmptyString(navigationDateScope?.period_from)) { + previousFilters.period_from = toNonEmptyString(navigationDateScope?.period_from); + } + if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) { + previousFilters.period_to = toNonEmptyString(navigationDateScope?.period_to); + } const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent); const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType); const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ?? @@ -2869,6 +2938,36 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes followupSelectionMode = "carry_referenced_entity"; } } + if (!toNonEmptyString(previousFilters.item) && + navigationFocusObjectType === "item" && + navigationFocusObjectLabel && + (sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_purchase_provenance_for_item" || + sourceIntentHint === "inventory_purchase_documents_for_item" || + sourceIntentHint === "inventory_sale_trace_for_item" || + sourceIntentHint === "inventory_purchase_to_sale_chain" || + sourceIntentHint === "inventory_aging_by_purchase_date" || + hasSelectedObjectInventorySignalPrimary || + hasSelectedObjectInventorySignalAlternate)) { + previousFilters.item = navigationFocusObjectLabel; + if (!previousAnchor) { + previousAnchorType = "item"; + previousAnchor = navigationFocusObjectLabel; + } + } + if (organizationClarificationSelection && !previousAnchor) { + previousAnchorType = "organization"; + previousAnchor = organizationClarificationSelection; + } + if (inventoryRootFrame && organizationClarificationSelection && !toNonEmptyString(inventoryRootFrame.filters?.organization)) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + organization: organizationClarificationSelection + } + }; + } if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) { return null; } @@ -3994,6 +4093,28 @@ export function resolveAssistantOrchestrationDecision(input) { const llmPreDecomposeMeta = input?.llmPreDecomposeMeta ?? null; const useMock = Boolean(input?.useMock); const sessionItems = Array.isArray(input?.sessionItems) ? input.sessionItems : null; + const sessionOrganizationScope = input?.sessionOrganizationScope && typeof input.sessionOrganizationScope === "object" + ? input.sessionOrganizationScope + : null; + const lastGroundedAddressDebug = findLastGroundedAddressAnswerDebug(sessionItems); + const lastOrganizationClarificationDebug = findLastOrganizationClarificationAddressDebug(sessionItems); + const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates) + ? mergeKnownOrganizations([ + ...lastOrganizationClarificationDebug.organization_candidates, + ...((Array.isArray(sessionOrganizationScope?.knownOrganizations) + ? sessionOrganizationScope.knownOrganizations + : [])) + ]) + : []; + const organizationClarificationSelectionFromScope = normalizeOrganizationScopeValue(sessionOrganizationScope?.selectedOrganization); + const organizationClarificationSelection = resolveOrganizationSelectionFromMessage(rawUserMessage, organizationClarificationCandidates) ?? + resolveOrganizationSelectionFromMessage(repairedRawUserMessage, organizationClarificationCandidates) ?? + resolveOrganizationSelectionFromMessage(effectiveAddressUserMessage, organizationClarificationCandidates) ?? + resolveOrganizationSelectionFromMessage(repairedEffectiveAddressUserMessage, organizationClarificationCandidates) ?? + (organizationClarificationSelectionFromScope && + organizationClarificationCandidates.some((candidate) => normalizeOrganizationScopeValue(candidate) === organizationClarificationSelectionFromScope) + ? organizationClarificationSelectionFromScope + : null); const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawUserMessage) || hasAssistantDataScopeMetaQuestionSignal(repairedRawUserMessage) || hasAssistantDataScopeMetaQuestionSignal(effectiveAddressUserMessage) || @@ -4081,6 +4202,12 @@ export function resolveAssistantOrchestrationDecision(input) { hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) || hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) || hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage))); + const organizationClarificationContinuationDetected = Boolean(followupContext && + lastOrganizationClarificationDebug && + organizationClarificationSelection && + !dataScopeMetaQuery && + !capabilityMetaQuery && + !dataRetrievalSignal); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && !capabilityMetaQuery && @@ -4091,7 +4218,16 @@ export function resolveAssistantOrchestrationDecision(input) { const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && deterministicNonDomainGuard && (llmFirstUnsupportedCandidate || llmContractMode === null) && - !protectedInventoryShortFollowup); + !protectedInventoryShortFollowup && + !organizationClarificationContinuationDetected); + const contextualHistoricalCapabilityFollowupDetected = Boolean(capabilityMetaQuery && + !dataScopeMetaQuery && + !dataRetrievalSignal && + (hasHistoricalCapabilityFollowupSignal(rawUserMessage) || + hasHistoricalCapabilityFollowupSignal(repairedRawUserMessage) || + hasHistoricalCapabilityFollowupSignal(effectiveAddressUserMessage) || + hasHistoricalCapabilityFollowupSignal(repairedEffectiveAddressUserMessage)) && + isGroundedInventoryContextDebug(lastGroundedAddressDebug)); const hardMetaMode = dataScopeMetaQuery ? "data_scope" : capabilityMetaQuery && !dataRetrievalSignal @@ -4126,6 +4262,34 @@ export function resolveAssistantOrchestrationDecision(input) { }; } if (hardMetaMode === "capability") { + if (contextualHistoricalCapabilityFollowupDetected) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "inventory_history_capability_followup_detected", + livingMode: "chat", + livingReason: "inventory_history_capability_followup_detected", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: "capability", + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + followup_context_detected: Boolean(followupContext || lastGroundedAddressDebug), + unsupported_address_intent_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "inventory_history_capability_followup_detected", + living_mode: "chat", + living_reason: "inventory_history_capability_followup_detected" + } + } + }; + } return { runAddressLane: false, toolGateDecision: "skip_address_lane", @@ -4181,6 +4345,10 @@ export function resolveAssistantOrchestrationDecision(input) { } }; } + const metaAnswerFollowupSignal = hasMetaAnswerFollowupSignal(rawUserMessage) || + hasMetaAnswerFollowupSignal(repairedRawUserMessage) || + hasMetaAnswerFollowupSignal(effectiveAddressUserMessage) || + hasMetaAnswerFollowupSignal(repairedEffectiveAddressUserMessage); const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && @@ -4275,6 +4443,19 @@ export function resolveAssistantOrchestrationDecision(input) { repairedEffectiveAddressUserMessage, sessionItems })); + const hasPriorAddressAnswerContext = Boolean(lastGroundedAddressDebug || toNonEmptyString(followupContext?.previous_intent)); + const metaFollowupOverGroundedAnswer = Boolean(followupContext && + hasPriorAddressAnswerContext && + metaAnswerFollowupSignal && + !dataScopeMetaQuery && + !capabilityMetaQuery && + !aggregateBusinessAnalyticsSignal && + !dataRetrievalSignal && + !strongDataSignal && + resolvedModeDetection.mode !== "address_query" && + resolvedIntentResolution.intent === "unknown" && + (!llmContractIntent || llmContractIntent === "unknown") && + llmContractMode !== "address_query"); let runAddressLane = Boolean(baseToolGate?.runAddressLane); let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane"); let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0"); @@ -4300,6 +4481,11 @@ export function resolveAssistantOrchestrationDecision(input) { toolGateDecision = "skip_address_lane"; toolGateReason = "deep_session_continuation_fallback_to_deep"; } + if (metaFollowupOverGroundedAnswer) { + runAddressLane = false; + toolGateDecision = "skip_address_lane"; + toolGateReason = "meta_followup_over_grounded_answer"; + } let livingDecision = resolveLivingAssistantModeDecision({ userMessage: rawUserMessage, addressLaneTriggered: runAddressLane, @@ -4333,6 +4519,12 @@ export function resolveAssistantOrchestrationDecision(input) { reason: "deep_session_continuation_fallback_to_deep" }; } + if (metaFollowupOverGroundedAnswer) { + livingDecision = { + mode: "chat", + reason: "meta_followup_over_grounded_answer" + }; + } return { runAddressLane, toolGateDecision, @@ -4434,6 +4626,105 @@ function findLastAssistantLivingChatDebug(items) { } return null; } +function findLastGroundedAddressAnswerDebug(items) { + if (!Array.isArray(items)) { + return null; + } + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const debug = item.debug; + if (debug.execution_lane !== "address_query") { + continue; + } + const groundingStatus = toNonEmptyString(debug.answer_grounding_check?.status); + if (groundingStatus === "grounded") { + return debug; + } + } + return null; +} +function findLastOrganizationClarificationAddressDebug(items) { + if (!Array.isArray(items)) { + return null; + } + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const debug = item.debug; + if (debug.execution_lane !== "address_query" && debug.detected_mode !== "address_query") { + continue; + } + const limitedCategory = toNonEmptyString(debug.limited_reason_category); + const candidates = Array.isArray(debug.organization_candidates) + ? mergeKnownOrganizations(debug.organization_candidates) + : []; + if (limitedCategory === "missing_anchor" && candidates.length > 0) { + return debug; + } + } + return null; +} +function hasMetaAnswerFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText] + .filter((item) => item.length > 0) + .map((item) => item.replace(/ё/g, "е")); + if (samples.length === 0) { + return false; + } + const hasReflectionCue = samples.some((sample) => sample.includes("дума") || + sample.includes("скаж") || + sample.includes("мнение") || + sample.includes("как тебе") || + sample.includes("норм") || + sample.includes("стран") || + sample.includes("логич") || + sample.includes("смуща") || + sample.includes("выгляд")); + const hasTopicPointerCue = samples.some((sample) => sample.includes("на эту тему") || + sample.includes("по этому поводу") || + sample.includes("об этом") || + (sample.includes("это") && hasReferentialPointer(sample))); + if (!(hasReflectionCue && hasTopicPointerCue)) { + return false; + } + return !samples.some((sample) => hasAssistantDataScopeMetaQuestionSignal(sample) || + shouldHandleAsAssistantCapabilityMetaQuery(sample) || + hasDataRetrievalRequestSignal(sample) || + hasStrongDataIntentSignal(sample)); +} +function hasHistoricalCapabilityFollowupSignal(text) { + const repaired = repairAddressMojibake(String(text ?? "")); + const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + const hasHistoryCue = /(?:историческ|история|архив|прошл(?:ый|ые|ую|ых)?|раньше|ретро|старые\s+данные)/iu.test(normalized); + if (!hasHistoryCue) { + return false; + } + return /(?:мож(?:ешь|ете|но)|уме(?:ешь|ете)|показ|вывед|дай|раскрой)/iu.test(normalized); +} +function isGroundedInventoryContextDebug(debug) { + if (!debug || typeof debug !== "object") { + return false; + } + const detectedIntent = toNonEmptyString(debug.detected_intent); + const capabilityId = toNonEmptyString(debug.capability_id); + const rootFrameContext = debug.address_root_frame_context && typeof debug.address_root_frame_context === "object" + ? debug.address_root_frame_context + : null; + const rootIntent = toNonEmptyString(rootFrameContext?.root_intent); + return detectedIntent === "inventory_on_hand_as_of_date" || + capabilityId === "confirmed_inventory_on_hand_as_of_date" || + rootIntent === "inventory_on_hand_as_of_date"; +} function hasOrganizationFactFollowupSignal(userMessage, items) { const repaired = repairAddressMojibake(String(userMessage ?? "")); const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е"); @@ -4722,7 +5013,6 @@ function normalizeOrganizationScopeValue(value) { return null; } const unwrapped = normalized - .replace(/^\\+|\\+$/g, "") .replace(/^"+|"+$/g, "") .replace(/^'+|'+$/g, "") .trim(); @@ -4862,8 +5152,34 @@ function mergeKnownOrganizations(values) { } return Array.from(dedup.values()).slice(0, 20); } -function extractKnownOrganizationsFromHistory(items) { +function extractKnownOrganizationsFromNavigationState(addressNavigationState) { + if (!addressNavigationState || typeof addressNavigationState !== "object") { + return []; + } const collected = []; + const sessionContext = addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null; + const directOrganization = normalizeOrganizationScopeValue(sessionContext?.organization_scope); + if (directOrganization) { + collected.push(directOrganization); + } + const resultSets = Array.isArray(addressNavigationState.result_sets) ? addressNavigationState.result_sets : []; + for (const resultSet of resultSets) { + const filters = resultSet?.filters && typeof resultSet.filters === "object" ? resultSet.filters : null; + const scopedOrganization = normalizeOrganizationScopeValue(filters?.organization); + if (scopedOrganization) { + collected.push(scopedOrganization); + } + } + return mergeKnownOrganizations(collected); +} +function extractKnownOrganizationsFromHistory(items, addressNavigationState = null) { + const collected = []; + const navigationOrganizations = extractKnownOrganizationsFromNavigationState(addressNavigationState); + if (navigationOrganizations.length > 0) { + collected.push(...navigationOrganizations); + } for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) { const item = items[index]; if (!item || item.role !== "assistant") { @@ -4877,8 +5193,17 @@ function extractKnownOrganizationsFromHistory(items) { const knownFromDebug = Array.isArray(debug.assistant_known_organizations) ? debug.assistant_known_organizations : []; - if (directFromProbe.length > 0 || knownFromDebug.length > 0) { - collected.push(...directFromProbe, ...knownFromDebug); + const directFromCandidates = Array.isArray(debug.organization_candidates) + ? debug.organization_candidates + : []; + const directFromResolved = [ + normalizeOrganizationScopeValue(debug.assistant_active_organization), + normalizeOrganizationScopeValue(debug.living_chat_selected_organization), + normalizeOrganizationScopeValue(debug.extracted_filters?.organization), + normalizeOrganizationScopeValue(debug.address_root_frame_context?.organization) + ].filter(Boolean); + if (directFromProbe.length > 0 || knownFromDebug.length > 0 || directFromCandidates.length > 0 || directFromResolved.length > 0) { + collected.push(...directFromProbe, ...knownFromDebug, ...directFromCandidates, ...directFromResolved); } } const parsedFromText = parseOrganizationsFromDataScopeAssistantText(item.text); @@ -4891,7 +5216,16 @@ function extractKnownOrganizationsFromHistory(items) { } return mergeKnownOrganizations(collected); } -function findLastAssistantActiveOrganization(items) { +function findLastAssistantActiveOrganization(items, addressNavigationState = null) { + const sessionContext = addressNavigationState && typeof addressNavigationState === "object" + ? (addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null) + : null; + const navigationOrganization = normalizeOrganizationScopeValue(sessionContext?.organization_scope); + if (navigationOrganization) { + return navigationOrganization; + } for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) { const item = items[index]; if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { @@ -4937,10 +5271,11 @@ function resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations } return best.organization; } -function resolveSessionOrganizationScopeContext(userMessage, items) { +function resolveSessionOrganizationScopeContext(userMessage, items, addressNavigationState = null) { return (0, assistantOrganizationScopeRuntimeAdapter_1.resolveSessionOrganizationScopeContextRuntime)({ userMessage, items, + addressNavigationState, extractKnownOrganizationsFromHistory, resolveOrganizationSelectionFromMessage, findLastAssistantActiveOrganization, @@ -4955,8 +5290,8 @@ function mergeFollowupContextWithOrganizationScope(followupContext, organization toNonEmptyString }); } -export function resolveSessionOrganizationScopeContextForTests(userMessage, items) { - return resolveSessionOrganizationScopeContext(userMessage, items); +export function resolveSessionOrganizationScopeContextForTests(userMessage, items, addressNavigationState = null) { + return resolveSessionOrganizationScopeContext(userMessage, items, addressNavigationState); } function normalizeGuidValue(value) { const source = normalizeScopeLabel(value); diff --git a/llm_normalizer/backend/src/services/assistantTurnAttemptInputBuilder.ts b/llm_normalizer/backend/src/services/assistantTurnAttemptInputBuilder.ts index 1d7e3d4..c18e27a 100644 --- a/llm_normalizer/backend/src/services/assistantTurnAttemptInputBuilder.ts +++ b/llm_normalizer/backend/src/services/assistantTurnAttemptInputBuilder.ts @@ -19,6 +19,7 @@ export function buildAssistantTurnAttemptAddressRuntimeInput RunAssistantUserTurnBootstrapRuntimeOutput; resolveSessionOrganizationScopeContext: ( userMessage: string, - sessionItems: unknown[] + sessionItems: unknown[], + sessionAddressNavigationState?: unknown ) => AssistantSessionOrganizationScopeContext; runAddressAttemptRuntime: ( input: RunAssistantTurnAttemptRuntimeAddressInput @@ -59,7 +61,8 @@ export async function runAssistantTurnAttemptRuntime sessionId: runtimeInput.sessionId, userMessage: runtimeInput.userMessage, sessionItems: runtimeInput.sessionItems, + sessionAddressNavigationState: runtimeInput.sessionAddressNavigationState, payload: runtimeInput.payload, sessionScope: { knownOrganizations: runtimeInput.sessionOrganizationScope.knownOrganizations, diff --git a/llm_normalizer/backend/src/types/addressNavigation.ts b/llm_normalizer/backend/src/types/addressNavigation.ts index 40bfaa9..71e72a5 100644 --- a/llm_normalizer/backend/src/types/addressNavigation.ts +++ b/llm_normalizer/backend/src/types/addressNavigation.ts @@ -8,11 +8,21 @@ export type AddressResultSetType = | "document_list" | "bank_operations_list" | "open_items_list" + | "inventory_snapshot" + | "inventory_trace" | "balance_snapshot" | "profile_summary" | "unknown"; -export type AddressFocusObjectType = "counterparty" | "contract" | "document_ref" | "account" | "unknown"; +export type AddressFocusObjectType = + | "counterparty" + | "contract" + | "document_ref" + | "account" + | "item" + | "organization" + | "warehouse" + | "unknown"; export type AddressNavigationAction = "open" | "drilldown" | "refine" | "back" | "reset"; diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index 07247eb..a19c3d2 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -260,6 +260,7 @@ export interface AddressExecutionDebug { | "rows_remaining_after_scope_filter"; runtime_readiness: AddressRuntimeReadiness; limited_reason_category: AddressLimitedReasonCategory | null; + organization_candidates?: string[]; semantic_frame?: AddressSemanticFrame | null; response_type: AddressResponseType; requested_result_mode?: AddressResultMode; diff --git a/llm_normalizer/backend/tests/addressInventoryOrganizationScope.test.ts b/llm_normalizer/backend/tests/addressInventoryOrganizationScope.test.ts new file mode 100644 index 0000000..2c7698c --- /dev/null +++ b/llm_normalizer/backend/tests/addressInventoryOrganizationScope.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { executeAddressMcpQueryMock } = vi.hoisted(() => ({ + executeAddressMcpQueryMock: vi.fn() +})); + +vi.mock("../src/services/addressMcpClient", async () => { + const actual = await vi.importActual( + "../src/services/addressMcpClient" + ); + return { + ...actual, + executeAddressMcpQuery: executeAddressMcpQueryMock + }; +}); + +import { AddressQueryService } from "../src/services/addressQueryService"; + +afterEach(() => { + executeAddressMcpQueryMock.mockReset(); + vi.restoreAllMocks(); +}); + +describe("inventory organization scope grounding", () => { + it("asks for organization clarification when multiple known companies exist", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("покажи остатки по складу", { + knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"] + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("LIMITED_WITH_REASON"); + expect(result?.debug.limited_reason_category).toBe("missing_anchor"); + expect(result?.debug.organization_candidates).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]); + expect(String(result?.reply_text ?? "")).toContain("ООО Альтернатива Плюс"); + expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд"); + expect(executeAddressMcpQueryMock).not.toHaveBeenCalled(); + }); + + it("auto-selects the only known organization for inventory root queries", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2026-04-15T23:59:59Z", + Registrator: "Остатки товаров на складах", + AccountDt: "41.01", + AccountKt: "00.00", + Amount: 6490, + Quantity: 1, + SubcontoDt1: "Пуф арий", + Warehouse: "Основной склад", + Organization: "ООО Альтернатива Плюс" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("покажи остатки по складу", { + knownOrganizations: ["ООО Альтернатива Плюс"] + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.extracted_filters?.organization).toBe("ООО Альтернатива Плюс"); + expect(result?.debug.reasons).toContain("organization_auto_selected_from_single_scope_candidate"); + expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); + }); + + it("grounds organization from observed rows when the result belongs to a single company", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-05-31T23:59:59Z", + Registrator: "Остатки товаров на складах", + AccountDt: "41.01", + AccountKt: "00.00", + Amount: 13490, + Quantity: 1, + SubcontoDt1: "Кресло орион", + Warehouse: "Основной склад", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("покажи остатки по складу"); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.extracted_filters?.organization).toBe("ООО \\Альтернатива Плюс\\"); + expect(result?.debug.reasons).toContain("organization_grounded_from_observed_rows"); + }); + + it("asks for organization clarification when observed rows contain multiple companies", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 2, + matched_rows: 2, + raw_rows: [ + { + Period: "2020-05-31T23:59:59Z", + Registrator: "Остатки товаров на складах", + AccountDt: "41.01", + AccountKt: "00.00", + Amount: 6490, + Quantity: 1, + SubcontoDt1: "Пуф арий", + Warehouse: "Основной склад", + Organization: "ООО Альтернатива Плюс" + }, + { + Period: "2020-05-31T23:59:59Z", + Registrator: "Остатки товаров на складах", + AccountDt: "41.01", + AccountKt: "00.00", + Amount: 34490, + Quantity: 1, + SubcontoDt1: "Диван трехместный", + Warehouse: "Основной склад", + Organization: "ООО Лайсвуд" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("покажи остатки по складу"); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("LIMITED_WITH_REASON"); + expect(result?.debug.limited_reason_category).toBe("missing_anchor"); + expect(result?.debug.organization_candidates).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]); + expect(String(result?.reply_text ?? "")).toContain("ООО Альтернатива Плюс"); + expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд"); + expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index b1e410f..f21435f 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -292,8 +292,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1"); expect(result?.debug.extracted_filters?.item).toBe("Конструкция трансформер рабочей станции 1300*900*2000"); expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-06-30"); - expect(result?.debug.extracted_filters?.period_from).toBe("2020-06-01"); - expect(result?.debug.extracted_filters?.period_to).toBe("2020-06-30"); + expect(result?.debug.extracted_filters?.period_from).toBeUndefined(); + expect(result?.debug.extracted_filters?.period_to).toBeUndefined(); expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_provenance_for_item"); expect(result?.debug.capability_route_mode).toBe("exact"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\"); @@ -491,6 +491,107 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); }); + it("routes selected-object wording 'куда мы продали эту позицию' into sale trace instead of replaying stock slice", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-06-18T00:00:00Z", + Registrator: "Реализация товаров и услуг 00000000131 от 18.06.2020 0:00:00", + AccountDt: "90.02", + AccountKt: "41.01", + Amount: 6490, + SubcontoKt1: "Пуф арий", + SubcontoKt3: "Основной склад", + SubcontoDt1: "ООО \\Ромашка\\", + SubcontoDt2: "Договор реализации № 14 от 17.06.2020", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle('По выбранному объекту "Пуф арий": куда мы продали эту позицию', { + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + as_of_date: "2020-05-31", + period_from: "2020-05-01", + period_to: "2020-05-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); + expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1"); + expect(result?.debug.extracted_filters?.item).toBe("Пуф арий"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-05-31"); + expect(result?.debug.reasons).toContain("inventory_selected_object_sale_trace_signal_detected"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ООО \\Ромашка\\"); + expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); + }); + + it("detaches snapshot date from execution query during sale-trace history recovery", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2025-10-12T00:00:00Z", + Registrator: "Реализация товаров и услуг 00000000421 от 12.10.2025 0:00:00", + AccountDt: "90.02", + AccountKt: "41.01", + Amount: 165.83, + SubcontoKt1: "Кромка с клеем 33 дуб ниагара 137 м", + SubcontoKt3: "Основной склад", + SubcontoDt1: "ООО \\Покупатель\\", + SubcontoDt2: "Договор реализации № 55 от 01.10.2025", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle( + 'По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": куда в итоге продали эту позицию?', + { + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + as_of_date: "2019-03-31", + period_from: "2019-03-01", + period_to: "2019-03-31", + organization: "ООО \\Альтернатива Плюс\\" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "ООО \\Альтернатива Плюс\\" + } + } + ); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); + expect(result?.debug.reasons).toContain("lifecycle_execution_detached_from_snapshot_date"); + expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery"); + expect(result?.debug.limitations).toContain("lifecycle_execution_detached_from_snapshot_date"); + expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery"); + expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); + const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); + expect(query).not.toContain("2019-03-31"); + expect(query).not.toContain("2019-03-01"); + expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\"); + }); + it("matches sale-trace item anchors from subconto fields when the item is not materialized explicitly", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, @@ -522,16 +623,8 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось"); }); - it("clears carried as-of date during history recovery for selected-object provenance after dated stock slice", async () => { - executeAddressMcpQueryMock - .mockResolvedValueOnce({ - fetched_rows: 0, - matched_rows: 0, - raw_rows: [], - rows: [], - error: null - }) - .mockResolvedValueOnce({ + it("detaches snapshot date from execution query for selected-object provenance after dated stock slice", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, matched_rows: 1, raw_rows: [ @@ -572,13 +665,18 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.extracted_filters?.item).toBe("Кресло орион"); expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); - expect(result?.debug.extracted_filters?.period_from).toBe("2020-03-01"); - expect(result?.debug.extracted_filters?.period_to).toBe("2020-03-31"); + expect(result?.debug.extracted_filters?.period_from).toBeUndefined(); + expect(result?.debug.extracted_filters?.period_to).toBeUndefined(); + expect(result?.debug.reasons).toContain("lifecycle_execution_detached_from_snapshot_date"); expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery"); - expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data"); + expect(result?.debug.limitations).toContain("lifecycle_execution_detached_from_snapshot_date"); expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery"); - expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\"); - expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2); + expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); + const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); + expect(query).not.toContain("2020-03-31"); + expect(query).not.toContain("2020-03-01"); + expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК Организация"); + expect(query).toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"'); }); }); diff --git a/llm_normalizer/backend/tests/addressNavigationState.test.ts b/llm_normalizer/backend/tests/addressNavigationState.test.ts index 1ddc004..b65c19c 100644 --- a/llm_normalizer/backend/tests/addressNavigationState.test.ts +++ b/llm_normalizer/backend/tests/addressNavigationState.test.ts @@ -144,4 +144,41 @@ describe("address navigation state", () => { expect(evolved.session_context.active_focus_object?.label).toBe("Диван трехместный"); expect(evolved.session_context.active_focus_object?.provenance_result_set_id).toBe("rs-msg-a3"); }); + it("derives single organization scope from inventory answer text when filters omit organization", () => { + const base = createEmptyAddressNavigationState("asst-5", "2026-04-12T10:00:00.000Z"); + const assistantItem = { + message_id: "msg-a4", + session_id: "asst-5", + role: "assistant", + text: [ + "На 31.05.2020 на складе подтверждено 1 позиция.", + "1. Пуф арий | склад: Основной склад | количество: 1,000 | стоимость: 6.490,00 ₽ | организация: ООО Альтернатива Плюс | дата строки: 2020-05-31T23:59:59Z" + ].join("\n"), + reply_type: "factual", + created_at: "2026-04-12T10:04:00.000Z", + trace_id: "address-790", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_on_hand_as_of_date", + selected_recipe: "address_inventory_on_hand_as_of_date_v1", + extracted_filters: { + as_of_date: "2020-05-31" + }, + anchor_type: "unknown", + anchor_value_resolved: null, + anchor_value_raw: null, + dialog_continuation_contract_v2: { + decision: "new_topic" + } + } + } as any; + + const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 4); + expect(evolved.result_sets[0]?.type).toBe("inventory_snapshot"); + expect(evolved.result_sets[0]?.filters.organization).toBe("ООО Альтернатива Плюс"); + expect(evolved.result_sets[0]?.entity_refs[0]?.entity_type).toBe("item"); + expect(evolved.result_sets[0]?.entity_refs[0]?.value).toBe("Пуф арий"); + expect(evolved.session_context.organization_scope).toBe("ООО Альтернатива Плюс"); + expect(evolved.session_context.active_focus_object).toBeNull(); + }); }); diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index dcd14d7..eecd878 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -1857,5 +1857,94 @@ describe("assistant address follow-up carryover", () => { expect(scopedCall).toBeTruthy(); expect(scopedCall?.options?.followupContext?.previous_filters?.organization).toBe("Alternative Plus LLC"); }); + + it("continues the original inventory query after organization clarification with a bare company reply", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = "покажи остатки по складу"; + const secondMessage = "Альтернатива"; + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === firstMessage) { + return buildAddressLimitedLaneResult("missing_anchor", { + reply_text: [ + "Нужно уточнить организацию, чтобы не смешивать компании в одном ответе.", + "Сейчас в доступном контуре вижу такие организации:", + "- ООО Альтернатива Плюс", + "- ООО Лайсвуд" + ].join("\n"), + debug: { + ...buildAddressLimitedLaneResult("missing_anchor").debug, + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + as_of_date: "2026-04-15" + }, + selected_recipe: null, + organization_candidates: ["ООО Альтернатива Плюс", "ООО Лайсвуд"], + reasons: ["organization_clarification_required", "multiple_known_organizations_detected"] + } + }); + } + if (message === secondMessage && options?.followupContext && options?.activeOrganization === "ООО Альтернатива Плюс") { + return buildAddressLaneResult({ + reply_text: "На 15.04.2026 по ООО Альтернатива Плюс подтвержден складской остаток.", + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + as_of_date: "2026-04-15", + organization: "ООО Альтернатива Плюс" + }, + reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"] + } + }); + } + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-org-clarification-${Date.now()}`; + const first = await service.handleMessage({ + session_id: sessionId, + user_message: firstMessage, + useMock: true + } as any); + expect(first.ok).toBe(true); + expect(first.reply_type).toBe("partial_coverage"); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: secondMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(calls).toHaveLength(2); + expect(calls[1].message).toBe(secondMessage); + expect(calls[1].options?.activeOrganization).toBe("ООО Альтернатива Плюс"); + expect(calls[1].options?.knownOrganizations).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]); + expect(calls[1].options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date"); + expect(calls[1].options?.followupContext?.previous_filters?.organization).toBe("ООО Альтернатива Плюс"); + expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); }); diff --git a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts index 8c08030..356603a 100644 --- a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts @@ -237,4 +237,71 @@ describe("assistant address orchestration runtime adapter", () => { }) ); }); + + it("prefers raw selected-object sale-destination wording over generic canonical drift intent", async () => { + const resolveAddressFollowupCarryoverContext = vi.fn(() => ({ + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + as_of_date: "2020-05-31", + period_from: "2020-05-01", + period_to: "2020-05-31" + } + } + })); + const resolveAssistantOrchestrationDecision = vi.fn(() => ({ + runAddressLane: true, + livingMode: "address_data", + livingReason: "address_lane_triggered", + toolGateDecision: "run_address_lane", + toolGateReason: "address_mode_classifier_detected", + orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" } + })); + const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({ + schema_version: "address_llm_predecompose_contract_v1", + source_message: sourceMessage, + canonical_message: canonicalMessage, + mode: "address_query", + intent: "unknown" + })); + + const rawMessage = 'По выбранному объекту "Пуф арий": куда мы продали эту позицию'; + + const output = await buildAssistantAddressOrchestrationRuntime( + buildInput({ + userMessage: rawMessage, + runAddressLlmPreDecompose: vi.fn(async () => ({ + attempted: true, + applied: true, + effectiveMessage: "Определить контрагента по реализации позиции «Пуф арий»", + reason: "normalized_fragment_applied", + predecomposeContract: { + mode: "address_query", + intent: "open_items_by_counterparty_or_contract", + semantics: { + selected_object_scope_detected: true + } + } + })), + buildAddressLlmPredecomposeContractV1, + resolveAddressFollowupCarryoverContext, + resolveAssistantOrchestrationDecision + }) + ); + + expect(output.addressInputMessage).toBe(rawMessage); + expect(output.addressPreDecompose.applied).toBe(false); + expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite"); + expect(buildAddressLlmPredecomposeContractV1).toHaveBeenCalledWith({ + sourceMessage: rawMessage, + canonicalMessage: rawMessage + }); + expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2); + expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith( + expect.objectContaining({ + rawUserMessage: rawMessage, + effectiveAddressUserMessage: rawMessage + }) + ); + }); }); diff --git a/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts b/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts index cfd815a..3fd72a1 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts @@ -657,6 +657,88 @@ describe("assistant living chat mode", () => { expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0); }); + it("answers historical capability follow-up in current inventory context instead of generic capability contract", async () => { + const normalizer = { + normalize: vi.fn().mockResolvedValue({ + ok: false, + trace_id: "norm-inventory-history-capability", + prompt_version: "normalizer_v2_0_2", + schema_version: "v2_0_2", + normalized: null, + validation: { passed: false, errors: ["mock"] }, + route_hint_summary: null, + raw_model_output: {}, + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + latency_ms: 1, + request_count_for_case: 1 + }) + } as any; + + const sessions = new AssistantSessionStore(); + const sessionId = "asst-living-chat-inventory-history-capability"; + sessions.ensureSession(sessionId); + sessions.appendItem(sessionId, { + message_id: "msg-seed-inventory-slice", + session_id: sessionId, + role: "assistant", + text: "На 15.04.2026 на складе подтверждено 11 позиций.", + reply_type: "factual", + created_at: new Date().toISOString(), + trace_id: "address-seed-inventory-history-capability", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_on_hand_as_of_date", + capability_id: "confirmed_inventory_on_hand_as_of_date", + assistant_active_organization: "альтернатива", + extracted_filters: { + organization: "альтернатива", + as_of_date: "2026-04-15" + }, + address_root_frame_context: { + root_intent: "inventory_on_hand_as_of_date", + current_frame_kind: "inventory_root", + organization: "альтернатива", + as_of_date: "2026-04-15" + } + } + } as any); + + const addressQueryService = { + tryHandle: vi.fn().mockResolvedValue({ handled: false }) + } as any; + const chatClient = { + chat: vi.fn().mockResolvedValue({ + raw: { id: "chat-inventory-history-capability-should-not-run" }, + outputText: "unused", + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } + }) + } as any; + + const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient); + + const response = await service.handleMessage({ + session_id: sessionId, + user_message: "а исторические данные ты можешь же показать?", + llmProvider: "local", + model: "qwen3", + useMock: false + } as any); + + expect(response.ok).toBe(true); + expect(response.reply_type).toBe("factual_with_explanation"); + expect(String(response.assistant_reply).toLowerCase()).toContain("историческ"); + expect(String(response.assistant_reply).toLowerCase()).toContain("альтернатив"); + expect(String(response.assistant_reply).toLowerCase()).toContain("март 2020"); + expect(String(response.assistant_reply)).not.toContain("Что умею по группам"); + expect(response.debug?.tool_gate_reason).toBe("inventory_history_capability_followup_detected"); + expect(response.debug?.living_chat_response_source).toBe("deterministic_inventory_history_capability_contract"); + expect(chatClient.chat).toHaveBeenCalledTimes(0); + expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0); + }); + it("handles data-scope meta question as deterministic chat contract", async () => { const normalizer = { normalize: vi.fn().mockResolvedValue({ diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index 4dbc109..2e2ad73 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -230,9 +230,59 @@ describe("assistant orchestration contract", () => { expect(decision.runAddressLane).toBe(false); expect(decision.toolGateDecision).toBe("skip_address_lane"); expect(decision.toolGateReason).toBe("non_domain_query_indexed"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("non_domain_query_indexed"); + expect(decision.orchestrationContract?.hard_meta_mode).toBe("non_domain"); + }); + + it("routes historical capability follow-up over grounded inventory answer to contextual chat", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "а исторические данные ты можешь же показать?", + effectiveAddressUserMessage: "а исторические данные ты можешь же показать?", + followupContext: null, + llmPreDecomposeMeta: { + applied: false, + reason: "normalized_fragment_rejected_semantic_guard", + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + }, + semanticExtractionContract: { + valid: false, + apply_canonical_recommended: false + } + } as any, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_on_hand_as_of_date", + capability_id: "confirmed_inventory_on_hand_as_of_date", + assistant_active_organization: "альтернатива", + address_root_frame_context: { + root_intent: "inventory_on_hand_as_of_date", + current_frame_kind: "inventory_root", + organization: "альтернатива", + as_of_date: "2026-04-15" + } + } + } + ], + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("inventory_history_capability_followup_detected"); expect(decision.livingMode).toBe("chat"); - expect(decision.livingReason).toBe("non_domain_query_indexed"); - expect(decision.orchestrationContract?.hard_meta_mode).toBe("non_domain"); + expect(decision.livingReason).toBe("inventory_history_capability_followup_detected"); + expect(decision.orchestrationContract?.followup_context_detected).toBe(true); }); it("keeps VAT payable forecast query in address lane", () => { @@ -631,6 +681,55 @@ describe("assistant orchestration contract", () => { expect(decision.livingReason).toBe("address_lane_triggered"); }); + it("routes meta follow-up over grounded inventory answer to chat instead of rerunning address lane", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "\u0447\u0435 \u0434\u0443\u043c\u0430\u0435\u0448\u044c \u043d\u0430 \u044d\u0442\u0443 \u0442\u0435\u043c\u0443", + effectiveAddressUserMessage: "\u0447\u0435 \u0434\u0443\u043c\u0430\u0435\u0448\u044c \u043d\u0430 \u044d\u0442\u0443 \u0442\u0435\u043c\u0443", + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + as_of_date: "2016-06-30", + organization: "alt" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "ALT" + }, + llmPreDecomposeMeta: { + applied: false, + reason: "normalized_fragment_rejected_semantic_guard", + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + }, + semanticExtractionContract: { + valid: false, + apply_canonical_recommended: false + } + } as any, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + } + } + } + ], + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("meta_followup_over_grounded_answer"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("meta_followup_over_grounded_answer"); + }); + it("keeps documentary inventory chain verification in address lane for supported exact intent", () => { const question = "Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"; diff --git a/llm_normalizer/backend/tests/assistantOrganizationScopeRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantOrganizationScopeRuntimeAdapter.test.ts index af95d43..dcf970a 100644 --- a/llm_normalizer/backend/tests/assistantOrganizationScopeRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantOrganizationScopeRuntimeAdapter.test.ts @@ -51,6 +51,45 @@ describe("assistant organization scope runtime adapter", () => { expect(normalizeOrganizationScopeValue).toHaveBeenCalledWith("Org A"); }); + it("prefers organization scope from address navigation state when present", () => { + const normalizeOrganizationScopeValue = vi.fn((value: unknown) => + typeof value === "string" && value.trim() ? value.trim() : null + ); + + const context = resolveSessionOrganizationScopeContextRuntime({ + userMessage: "просто продолжай", + items: [] as any[], + addressNavigationState: { + schema_version: "address_navigation_state_v1", + session_id: "asst-nav-org", + updated_at: "2026-04-15T10:00:00.000Z", + session_context: { + active_result_set_id: "rs-1", + active_focus_object: null, + last_confirmed_route: "address_inventory_on_hand_as_of_date_v1", + date_scope: { + as_of_date: "2020-05-31", + period_from: null, + period_to: null + }, + organization_scope: "Org B" + }, + result_sets: [], + navigation_history: [] + } as any, + extractKnownOrganizationsFromHistory: () => ["Org A"], + resolveOrganizationSelectionFromMessage: () => null, + findLastAssistantActiveOrganization: () => "Org A", + normalizeOrganizationScopeValue + }); + + expect(context).toEqual({ + knownOrganizations: ["Org B", "Org A"], + selectedOrganization: null, + activeOrganization: "Org B" + }); + }); + it("merges organization into followup previous filters when organization is missing", () => { const merged = mergeFollowupContextWithOrganizationScopeRuntime({ followupContext: { @@ -69,6 +108,9 @@ describe("assistant organization scope runtime adapter", () => { previous_filters: { period: "2020-07", organization: "Org A" + }, + root_filters: { + organization: "Org A" } }); });