АРЧ - Ассистент: отделить meta-followup по прошлому ответу от повторного запуска address lane

This commit is contained in:
2026-04-15 12:38:44 +03:00
parent 8866176be6
commit 70cc5a99f1
61 changed files with 4023 additions and 298 deletions
@@ -1,4 +1,4 @@
import type { AddressFilterExtraction, AddressFilterSet, AddressIntent } from "../types/addressQuery";
import type { AddressFilterExtraction, AddressFilterSet, AddressIntent, AddressSemanticFrame } from "../types/addressQuery";
import iconv from "iconv-lite";
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
@@ -1088,6 +1088,29 @@ function isTemporalWarehousePhrase(candidate: string): boolean {
);
}
function normalizeSemanticAnchorCandidate(value: string): string {
return cleanupAnchorValue(value)
.toLowerCase()
.replace(/С‘/g, "Рµ")
.replace(/\s+/g, " ")
.trim();
}
function hasImplicitSelfScopeSignal(text: string): boolean {
return /(?:^|[\s,.;:!?()\-])(?:у\s+нас|у\s+себя|у\s+меня|наш(?:ем|ей|его|их|а|е)?|сво(?:ем|ей|его|их|я|е)?)(?=$|[\s,.;:!?()\-])/iu.test(
String(text ?? "")
);
}
function isImplicitSelfScopeWarehouseAnchor(candidate: string): boolean {
const normalized = normalizeSemanticAnchorCandidate(candidate);
return /^(?:у\s+нас|у\s+себя|у\s+меня|наш(?:ем|ей|его|их|а|е)?|сво(?:ем|ей|его|их|я|е)?)$/iu.test(normalized);
}
function hasSelectedObjectScopeSignal(text: string): boolean {
return /(?:по\s+выбранному\s+объекту|selected\s+object)/iu.test(String(text ?? ""));
}
function extractInventoryWarehouseAnchor(text: string): string | undefined {
const patterns = [
/(?:на|по)\s+склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu,
@@ -1109,6 +1132,7 @@ function extractInventoryWarehouseAnchor(text: string): string | undefined {
!candidate ||
candidate.includes("->") ||
candidate.includes("=>") ||
isImplicitSelfScopeWarehouseAnchor(candidate) ||
normalizedCandidate.startsWith("по состоянию") ||
isTemporalWarehousePhrase(candidate) ||
/^(?:сейчас|на|дату|дате|остаток|остатки)$/iu.test(candidate)
@@ -1244,6 +1268,114 @@ function shouldDefaultAsOfDateToToday(intent: AddressIntent): boolean {
);
}
function resolveSemanticDateScopeKind(
filters: AddressFilterSet,
warnings: string[]
): AddressSemanticFrame["date_scope_kind"] {
if (warnings.includes("as_of_date_defaulted_today")) {
return "implicit_current";
}
if (
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0) ||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
) {
return "explicit";
}
return "none";
}
function resolveSemanticDateBasisHint(filters: AddressFilterSet, warnings: string[]): AddressSemanticFrame["date_basis_hint"] {
if (warnings.includes("as_of_date_defaulted_today")) {
return "implicit_current_snapshot";
}
const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0;
const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0;
const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0;
if (hasPeriodFrom && hasPeriodTo) {
return "period_range";
}
if (hasAsOfDate) {
return "explicit_as_of_date";
}
if (hasPeriodTo) {
return "period_end";
}
if (hasPeriodFrom) {
return "period_range";
}
return null;
}
function buildSemanticFrame(
text: string,
filters: AddressFilterSet,
warnings: string[]
): AddressSemanticFrame {
const selfScopeDetected = hasImplicitSelfScopeSignal(text);
const selectedObjectScopeDetected = hasSelectedObjectScopeSignal(text);
const itemAnchor = typeof filters.item === "string" && filters.item.trim().length > 0 ? filters.item.trim() : null;
const warehouseAnchor = typeof filters.warehouse === "string" && filters.warehouse.trim().length > 0 ? filters.warehouse.trim() : null;
const counterpartyAnchor =
typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0 ? filters.counterparty.trim() : null;
const contractAnchor = typeof filters.contract === "string" && filters.contract.trim().length > 0 ? filters.contract.trim() : null;
const organizationAnchor =
typeof filters.organization === "string" && filters.organization.trim().length > 0 ? filters.organization.trim() : null;
if (selectedObjectScopeDetected && itemAnchor) {
return {
scope_kind: "selected_object_scope",
anchor_kind: "item",
anchor_value: itemAnchor,
date_scope_kind: resolveSemanticDateScopeKind(filters, warnings),
date_basis_hint: resolveSemanticDateBasisHint(filters, warnings),
self_scope_detected: selfScopeDetected,
selected_object_scope_detected: true
};
}
if (selfScopeDetected && !warehouseAnchor) {
return {
scope_kind: "implicit_self_scope",
anchor_kind: "self_scope",
anchor_value: null,
date_scope_kind: resolveSemanticDateScopeKind(filters, warnings),
date_basis_hint: resolveSemanticDateBasisHint(filters, warnings),
self_scope_detected: true,
selected_object_scope_detected: selectedObjectScopeDetected
};
}
const explicitAnchor =
itemAnchor ??
warehouseAnchor ??
counterpartyAnchor ??
contractAnchor ??
organizationAnchor ??
null;
const anchorKind: AddressSemanticFrame["anchor_kind"] = itemAnchor
? "item"
: warehouseAnchor
? "warehouse"
: counterpartyAnchor
? "counterparty"
: contractAnchor
? "contract"
: organizationAnchor
? "organization"
: "none";
return {
scope_kind: explicitAnchor ? "explicit_anchor" : "none",
anchor_kind: anchorKind,
anchor_value: explicitAnchor,
date_scope_kind: resolveSemanticDateScopeKind(filters, warnings),
date_basis_hint: resolveSemanticDateBasisHint(filters, warnings),
self_scope_detected: selfScopeDetected,
selected_object_scope_detected: selectedObjectScopeDetected
};
}
export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction {
const rawText = String(userMessage ?? "").trim();
const text = normalizeMojibakeString(rawText);
@@ -1302,6 +1434,11 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
const warehouseAnchor = extractInventoryWarehouseAnchor(text);
if (warehouseAnchor) {
filters.warehouse = warehouseAnchor;
} else if (
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") &&
hasImplicitSelfScopeSignal(text)
) {
warnings.push("warehouse_self_scope_detected");
}
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
@@ -1511,10 +1648,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
const semanticFrame = buildSemanticFrame(text, filters, warnings);
return {
extracted_filters: filters,
missing_required_filters: missingRequiredFilters,
warnings
warnings,
semantic_frame: semanticFrame
};
}
@@ -1553,7 +1553,10 @@ function hasInventoryAsOfCue(text: string): boolean {
}
function hasInventoryOnHandSignal(text: string): boolean {
const hasColloquialStockSnapshotCue = /(?:что|ч[её])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом)(?=$|[\s,.;:!?])/iu.test(
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(
text
);
const hasStockStateCue = /(?:(?:что|ч[еёо])\s+там\s+на\s+склад(?:е|у|ом|ах)|(?:что|ч[еёо]).*происход(?:ит|ило|ящее).*(?:на\s+)?склад(?:е|у|ом|ах)|происход(?:ит|ило|ящее)\s+на\s+склад(?:е|у|ом|ах)|ситуац(?:ия|ии)\s+на\s+склад(?:е|у|ом|ах)|обстановк(?:а|и)\s+на\s+склад(?:е|у|ом|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test(
text
);
const hasAccount41Anchor = hasInventoryAccount41Anchor(text);
@@ -1574,15 +1577,18 @@ function hasInventoryOnHandSignal(text: string): boolean {
const hasGoodsLexeme =
/(?:товар(?:ы|ов|ом|а|ные)?|номенклатур|материал(?:ы|ов|а|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text);
const hasBalanceLexeme =
/(?:леж(?:ит|ат)|есть|числ(?:ит(?:ся|сь)|ятся)|остат(?:ок|ки)|срез|на\s+дат|по\s+состоянию|на\s+конец|today|now|current|as\s+of)/iu.test(
/(?:леж(?:ит|ат)|есть|числ(?:ит(?:ся|сь)|ятся)|остат(?:ок|ки)|срез|на\s+дат|по\s+состоянию|на\s+конец|происход(?:ит|ило|ящее)|ситуац(?:ия|ии)|обстановк(?:а|и)|today|now|current|as\s+of)/iu.test(
text
);
const hasRequestCue =
/(?:покажи|показать|выведи|дай|какие|что|ч[еёо]|какой|сколько|проверь|проверить|чекни|check|show|list|which|what)/iu.test(
text
);
const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|какой|сколько|show|list|which|what)/iu.test(text);
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
return true;
}
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue) &&
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue);
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) &&
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue);
}
function hasInventoryProvenanceSignal(text: string): boolean {
@@ -13,6 +13,14 @@ const ADDRESS_ACTION_TOKENS = [
"покажи",
"покаж",
"показ",
"проверь",
"провер",
"чекни",
"чекн",
"глянь",
"глян",
"посмотри",
"смотри",
"список",
"найди",
"найд",
@@ -14,13 +14,15 @@ import type {
AddressExecutionResult,
AddressFilterSet,
AddressIntent,
AddressLlmSemanticHints,
AddressLimitedReasonCategory,
AddressMatchFailureStage,
AddressMcpCallStatus,
AddressQueryShapeDetection,
AddressResultMode,
AddressResponseType,
AddressRuntimeReadiness
AddressRuntimeReadiness,
AddressSemanticFrame
} from "../types/addressQuery";
import {
buildAddressRecipePlan,
@@ -47,6 +49,12 @@ import {
resolveShadowRouteIntent
} from "./addressCapabilityPolicy";
import { evaluateAddressRouteExpectation, type AddressRouteExpectationAudit } from "./addressRouteExpectations";
import {
mergeKnownOrganizations,
normalizeOrganizationScopeSearchText,
normalizeOrganizationScopeValue,
resolveOrganizationSelectionFromMessage
} from "./assistantOrganizationMatcher";
interface NormalizedAddressRow {
period: string | null;
@@ -64,6 +72,9 @@ interface NormalizedAddressRow {
interface AddressTryHandleOptions {
followupContext?: AddressFollowupContext | null;
analysisDateHint?: string | null;
llmSemanticHints?: AddressLlmSemanticHints | null;
activeOrganization?: string | null;
knownOrganizations?: string[];
}
interface AddressCapabilityAudit {
@@ -1446,6 +1457,60 @@ function isCounterpartyRiskIntent(intent: AddressIntent): boolean {
);
}
function sameNormalizedOrganizationScope(left: string | null | undefined, right: string | null | undefined): boolean {
return normalizeOrganizationScopeSearchText(left ?? "") === normalizeOrganizationScopeSearchText(right ?? "");
}
function applyPreExecutionOrganizationScopeGrounding(input: {
userMessage: string;
filters: AddressFilterSet;
semanticFrame: AddressSemanticFrame | null;
warnings: string[];
baseReasons: string[];
activeOrganization?: string | null;
knownOrganizations?: string[];
}): string | null {
const activeOrganization = normalizeOrganizationScopeValue(input.activeOrganization ?? null);
const candidateOrganizations = mergeKnownOrganizations([
...(Array.isArray(input.knownOrganizations) ? input.knownOrganizations : []),
activeOrganization
]);
const resolvedOrganizationFromMessage = resolveOrganizationSelectionFromMessage(input.userMessage, candidateOrganizations);
if (
!input.filters.organization &&
input.semanticFrame?.scope_kind === "implicit_self_scope" &&
activeOrganization
) {
input.filters.organization = activeOrganization;
if (!input.warnings.includes("organization_from_active_scope")) {
input.warnings.push("organization_from_active_scope");
}
if (!input.baseReasons.includes("organization_from_active_scope")) {
input.baseReasons.push("organization_from_active_scope");
}
}
if (
resolvedOrganizationFromMessage &&
(!input.filters.organization || input.semanticFrame?.anchor_kind === "organization") &&
!sameNormalizedOrganizationScope(input.filters.organization ?? null, resolvedOrganizationFromMessage)
) {
input.filters.organization = resolvedOrganizationFromMessage;
if (!input.warnings.includes("organization_grounded_from_scope_candidates")) {
input.warnings.push("organization_grounded_from_scope_candidates");
}
if (!input.baseReasons.includes("organization_grounded_from_scope_candidates")) {
input.baseReasons.push("organization_grounded_from_scope_candidates");
}
if (input.semanticFrame?.anchor_kind === "organization") {
input.semanticFrame.anchor_value = resolvedOrganizationFromMessage;
}
}
return resolvedOrganizationFromMessage;
}
function isHeuristicCandidatesIntent(intent: AddressIntent): boolean {
return (
intent === "list_receivables_counterparties" ||
@@ -1472,7 +1537,10 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
);
}
function resolveAsOfDateBasis(filters: AddressFilterSet): AddressAsOfDateBasis | null {
function resolveAsOfDateBasis(filters: AddressFilterSet, semanticFrame?: AddressSemanticFrame | null): AddressAsOfDateBasis | null {
if (semanticFrame?.date_basis_hint) {
return semanticFrame.date_basis_hint;
}
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
if (asOfDate) {
return "explicit_as_of_date";
@@ -1515,7 +1583,11 @@ function deriveAddressEvidenceStrength(input: {
return undefined;
}
function resolveRequestedResultMode(intent: AddressIntent, filters: AddressFilterSet): AddressResultMode | undefined {
function resolveRequestedResultMode(
intent: AddressIntent,
filters: AddressFilterSet,
semanticFrame?: AddressSemanticFrame | null
): AddressResultMode | undefined {
if (isConfirmedBalanceIntent(intent)) {
return "confirmed_balance";
}
@@ -1523,8 +1595,13 @@ function resolveRequestedResultMode(intent: AddressIntent, filters: AddressFilte
return "heuristic_candidates";
}
if (isHeuristicCandidatesIntent(intent)) {
const asOfDateBasis = resolveAsOfDateBasis(filters);
if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") {
const asOfDateBasis = resolveAsOfDateBasis(filters, semanticFrame);
if (
asOfDateBasis === "explicit_as_of_date" ||
asOfDateBasis === "period_end" ||
asOfDateBasis === "period_range" ||
asOfDateBasis === "implicit_current_snapshot"
) {
return "confirmed_balance";
}
return "heuristic_candidates";
@@ -1536,6 +1613,7 @@ function deriveAddressResultSemantics(input: {
intent: AddressIntent;
selectedRecipe: string | null;
filters: AddressFilterSet;
semanticFrame?: AddressSemanticFrame | null;
responseType: AddressResponseType;
rowsMatched: number;
}): {
@@ -1545,8 +1623,8 @@ function deriveAddressResultSemantics(input: {
balance_confirmed?: boolean;
as_of_date_basis?: AddressAsOfDateBasis | null;
} {
const asOfDateBasis = resolveAsOfDateBasis(input.filters);
const requestedResultMode = resolveRequestedResultMode(input.intent, input.filters);
const asOfDateBasis = resolveAsOfDateBasis(input.filters, input.semanticFrame);
const requestedResultMode = resolveRequestedResultMode(input.intent, input.filters, input.semanticFrame);
if (isHeuristicCandidatesIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
@@ -1897,6 +1975,10 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean {
);
}
function shouldClearAsOfDateForHistoryRecovery(intent: AddressIntent): boolean {
return intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item";
}
function invertSort(sort: AddressFilterSet["sort"]): AddressFilterSet["sort"] {
return sort === "period_asc" ? "period_desc" : "period_asc";
}
@@ -2609,16 +2691,18 @@ function buildLimitedExecutionResult(input: {
capabilityAudit?: AddressCapabilityAudit;
shadowRouteAudit?: AddressShadowRouteAudit;
routeExpectationAudit?: AddressRouteExpectationAuditState;
semanticFrame?: AddressSemanticFrame | null;
}): AddressExecutionResult {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
const resultSemantics = deriveAddressResultSemantics({
intent: input.intent.intent,
selectedRecipe: input.selectedRecipe,
filters: input.filters,
semanticFrame: input.semanticFrame,
responseType: "LIMITED_WITH_REASON",
rowsMatched: input.rowsMatched
});
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters, input.semanticFrame);
const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(
input.reasons,
requestedResultMode,
@@ -2698,6 +2782,7 @@ function buildLimitedExecutionResult(input: {
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
semantic_frame: input.semanticFrame ?? null,
response_type: "LIMITED_WITH_REASON",
capability_id: input.capabilityAudit?.capabilityId ?? null,
capability_layer: input.capabilityAudit?.layer ?? null,
@@ -2726,11 +2811,12 @@ export class AddressQueryService {
}
const followupContext = options.followupContext ?? null;
const decompose = runAddressDecomposeStage(userMessage, followupContext);
const decompose = runAddressDecomposeStage(userMessage, followupContext, options.llmSemanticHints ?? null);
if (!decompose) {
return null;
}
const { mode, shape, intent, filters } = decompose;
const semanticFrame = filters.semantic_frame ?? null;
const baseReasons = [...decompose.baseReasons];
const analysisDate = normalizeAnalysisDateHint(options.analysisDateHint);
if (analysisDate) {
@@ -2748,7 +2834,16 @@ export class AddressQueryService {
baseReasons.push("as_of_date_from_analysis_context");
}
}
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
const resolvedOrganizationFromMessage = applyPreExecutionOrganizationScopeGrounding({
userMessage,
filters: filters.extracted_filters,
semanticFrame,
warnings: filters.warnings,
baseReasons,
activeOrganization: options.activeOrganization ?? null,
knownOrganizations: options.knownOrganizations ?? []
});
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";
@@ -2771,7 +2866,7 @@ export class AddressQueryService {
const inventoryConfirmedExecution = confirmedBalanceInventoryIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const executionFilters =
let executionFilters =
inventoryConfirmedExecution?.executionFilters ??
payablesConfirmedExecution?.executionFilters ??
receivablesConfirmedExecution?.executionFilters ??
@@ -2847,6 +2942,7 @@ export class AddressQueryService {
...baseReasons,
FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 ? "capability_route_guard_blocked" : "capability_route_guard_skipped"
],
semanticFrame,
capabilityAudit,
shadowRouteAudit
});
@@ -2949,6 +3045,7 @@ export class AddressQueryService {
nextStep: "могу проверить близкие сценарии: документы/платежи по контрагенту, договоры или остаток по счету",
limitations: ["intent_not_supported_in_v1"],
reasons: baseReasons,
semanticFrame,
capabilityAudit,
shadowRouteAudit
});
@@ -2971,6 +3068,7 @@ export class AddressQueryService {
nextStep: "можно выбрать близкий поддерживаемый сценарий или переключить запрос в режим расширенной проверки",
limitations: ["recipe_not_available"],
reasons: [...baseReasons, ...recipeSelection.selection_reason],
semanticFrame,
capabilityAudit,
shadowRouteAudit
});
@@ -2993,6 +3091,7 @@ export class AddressQueryService {
nextStep: `уточните: ${recipeSelection.missing_required_filters.join(", ")}`,
limitations: ["missing_required_filters"],
reasons: [...baseReasons, ...recipeSelection.selection_reason],
semanticFrame,
capabilityAudit,
shadowRouteAudit
});
@@ -3015,6 +3114,7 @@ export class AddressQueryService {
nextStep: "включите FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1",
limitations: ["address_live_lane_disabled"],
reasons: baseReasons,
semanticFrame,
capabilityAudit,
shadowRouteAudit
});
@@ -3200,6 +3300,7 @@ export class AddressQueryService {
nextStep: mcp.error,
limitations: ["mcp_call_failed"],
reasons: [...baseReasons, mcp.error],
semanticFrame,
capabilityAudit,
shadowRouteAudit
});
@@ -3214,7 +3315,7 @@ export class AddressQueryService {
scopedRows.length === 0;
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
anchor = refineAnchorFromRows(anchor, normalizedRows);
const filtersForMatching: AddressFilterSet =
let filtersForMatching: AddressFilterSet =
anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? { ...executionFilters, counterparty: anchor.anchor_value_resolved }
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
@@ -3227,15 +3328,65 @@ export class AddressQueryService {
rowsBeforeScope: normalizedRawRows.length,
rowsAfterScope: normalizedRows.length
});
const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
const filterByAnchors = anchorFilter.rows;
const filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors);
const filteredRowsFutureGuard = applyFutureDatedRowsGuard(
let anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
let filterByAnchors = anchorFilter.rows;
let filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors);
let filteredRowsFutureGuard = applyFutureDatedRowsGuard(
filteredRowsBeforeFutureGuard,
intent.intent,
futureGuardReferenceDate
);
const filteredRows = filteredRowsFutureGuard.rows;
let filteredRows = filteredRowsFutureGuard.rows;
let organizationWarehouseRecoveryApplied = false;
if (
filteredRows.length === 0 &&
anchorFilter.mismatchReason === "warehouse_anchor_not_matched_in_materialized_rows" &&
resolvedOrganizationFromMessage
) {
filters.extracted_filters = {
...filters.extracted_filters,
organization: resolvedOrganizationFromMessage
};
delete filters.extracted_filters.warehouse;
executionFilters = {
...executionFilters,
organization: resolvedOrganizationFromMessage
};
delete executionFilters.warehouse;
filtersForMatching = {
...filtersForMatching,
organization: resolvedOrganizationFromMessage
};
delete filtersForMatching.warehouse;
anchor = {
...anchor,
anchor_type: "organization",
anchor_value_raw: anchor.anchor_value_raw,
anchor_value_resolved: resolvedOrganizationFromMessage,
resolver_confidence: "medium"
};
if (semanticFrame) {
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "organization";
semanticFrame.anchor_value = resolvedOrganizationFromMessage;
}
if (!filters.warnings.includes("warehouse_anchor_regrounded_to_organization_scope")) {
filters.warnings.push("warehouse_anchor_regrounded_to_organization_scope");
}
if (!baseReasons.includes("warehouse_anchor_regrounded_to_organization_scope")) {
baseReasons.push("warehouse_anchor_regrounded_to_organization_scope");
}
anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
filterByAnchors = anchorFilter.rows;
filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors);
filteredRowsFutureGuard = applyFutureDatedRowsGuard(
filteredRowsBeforeFutureGuard,
intent.intent,
futureGuardReferenceDate
);
filteredRows = filteredRowsFutureGuard.rows;
organizationWarehouseRecoveryApplied = filteredRows.length > 0;
}
if (filteredRowsFutureGuard.droppedCount > 0) {
if (!filters.warnings.includes("future_rows_excluded_from_response")) {
filters.warnings.push("future_rows_excluded_from_response");
@@ -3263,6 +3414,11 @@ export class AddressQueryService {
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
: null;
if (organizationWarehouseRecoveryApplied) {
if (!baseReasons.includes("organization_scope_live_grounding_recovered_rows")) {
baseReasons.push("organization_scope_live_grounding_recovered_rows");
}
}
if (filteredRows.length === 0 && intent.intent === "list_documents_by_contract" && filterByAnchors.length > 0) {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
@@ -3324,6 +3480,7 @@ export class AddressQueryService {
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
semanticFrame,
responseType: factual.responseType,
rowsMatched: recoveredRows.length
}),
@@ -3472,6 +3629,7 @@ export class AddressQueryService {
intent: intent.intent,
selectedRecipe: expandedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
semanticFrame,
responseType: expandedFactual.responseType,
rowsMatched: expandedFilteredRows.length
}),
@@ -3494,8 +3652,13 @@ export class AddressQueryService {
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
const broadenedAdjustments: string[] = [];
delete autoBroadenedFilters.period_from;
delete autoBroadenedFilters.period_to;
if (stageStatus === "no_raw_rows" && shouldClearAsOfDateForHistoryRecovery(intent.intent) && toNonEmptyFilterValue(autoBroadenedFilters.as_of_date)) {
delete autoBroadenedFilters.as_of_date;
broadenedAdjustments.push("as_of_date_cleared_for_history_recovery");
}
if (shouldBoostAutoBroadenedLimit(intent.intent)) {
autoBroadenedFilters.limit = Math.max(
ADDRESS_ANCHOR_RECOVERY_LIMIT,
@@ -3571,13 +3734,18 @@ export class AddressQueryService {
broadenedFilteredRows,
composeOptionsFromFilters(autoBroadenedFilters)
);
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
const broadenedLimitations = [
...filters.warnings,
...broadenedAdjustments,
"period_window_auto_broadened_to_available_data"
];
const broadenedReasons = [...baseReasons, ...broadenedAdjustments, "period_window_auto_broadened_to_available_data"];
const broadenedResultSemantics = mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: broadenedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
semanticFrame,
responseType: broadenedFactual.responseType,
rowsMatched: broadenedFilteredRows.length
}),
@@ -3645,6 +3813,7 @@ export class AddressQueryService {
route_expectation_expected_requested_result_modes:
broadenedRouteExpectationAudit.expectedRequestedResultModes,
route_expectation_expected_result_modes: broadenedRouteExpectationAudit.expectedResultModes,
semantic_frame: semanticFrame,
...broadenedResultSemantics,
limitations: broadenedLimitations,
reasons: withConfirmedBalanceFallbackReason(
@@ -3793,6 +3962,7 @@ export class AddressQueryService {
intent: intent.intent,
selectedRecipe: historicalSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
semanticFrame,
responseType: historicalFactual.responseType,
rowsMatched: historicalFilteredRows.length
}),
@@ -3879,6 +4049,7 @@ export class AddressQueryService {
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
semanticFrame,
responseType: fallbackFactual.responseType,
rowsMatched: documentBankFallbackRows.length
}),
@@ -4016,6 +4187,7 @@ export class AddressQueryService {
nextStep,
limitations,
reasons: baseReasons,
semanticFrame,
capabilityAudit,
shadowRouteAudit
});
@@ -4059,6 +4231,7 @@ export class AddressQueryService {
intent: composeIntent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
semanticFrame,
responseType: factual.responseType,
rowsMatched: filteredRows.length
}),
@@ -4098,6 +4271,7 @@ export class AddressQueryService {
nextStep: "проверьте intent/recipe mapping или отключите FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1 для безопасного rollout",
limitations: ["route_expectation_mismatch_guard_blocked"],
reasons: [...baseReasons, `route_expectation_mismatch:${finalRouteExpectationAudit.reason}`],
semanticFrame,
capabilityAudit,
shadowRouteAudit,
routeExpectationAudit: finalRouteExpectationAudit
@@ -4150,6 +4324,7 @@ export class AddressQueryService {
: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance",
limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`],
reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`],
semanticFrame,
capabilityAudit,
shadowRouteAudit,
routeExpectationAudit: finalRouteExpectationAudit
@@ -4214,6 +4389,7 @@ export class AddressQueryService {
route_expectation_expected_selected_recipes: finalRouteExpectationAudit.expectedSelectedRecipes,
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
semantic_frame: semanticFrame,
...factualResultSemantics,
limitations: factualLimitations,
reasons: withConfirmedBalanceFallbackReason(
@@ -3,19 +3,45 @@
AddressIntent,
AddressIntentResolution,
AddressModeDetection,
AddressQueryShapeDetection
AddressQueryShapeDetection,
AddressSemanticFrame
} from "../../types/addressQuery";
import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver";
import { extractAddressFilters } from "../addressFilterExtractor";
import { applyAddressLlmSemanticHintsToExtraction } from "./semanticHintOverlay";
import type { AddressLlmSemanticHints } from "../../types/addressQuery";
export interface AddressFollowupContext {
previous_intent?: AddressIntent;
previous_filters?: AddressFilterSet;
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null;
previous_anchor_type?:
| "account"
| "counterparty"
| "contract"
| "document_ref"
| "item"
| "organization"
| "warehouse"
| "unknown"
| null;
previous_anchor_value?: string | null;
resolved_counterparty_from_display?: boolean;
root_intent?: AddressIntent;
root_filters?: AddressFilterSet;
root_anchor_type?:
| "account"
| "counterparty"
| "contract"
| "document_ref"
| "item"
| "organization"
| "warehouse"
| "unknown"
| null;
root_anchor_value?: string | null;
current_frame_kind?: "generic" | "inventory_root" | "inventory_drilldown";
}
export interface AddressDecomposeStageResult {
@@ -26,6 +52,7 @@ export interface AddressDecomposeStageResult {
extracted_filters: AddressFilterSet;
missing_required_filters: string[];
warnings: string[];
semantic_frame?: AddressSemanticFrame;
};
baseReasons: string[];
}
@@ -318,6 +345,159 @@ function isInventoryIntent(intent: AddressIntent | undefined): boolean {
);
}
function isInventoryRootFrameIntent(intent: AddressIntent | undefined): boolean {
return intent === "inventory_on_hand_as_of_date";
}
function isInventoryDrilldownFrameIntent(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" ||
intent === "inventory_aging_by_purchase_date"
);
}
function buildInventoryRootFollowupContext(
followupContext: AddressFollowupContext | null
): AddressFollowupContext | null {
if (!followupContext || !followupContext.root_intent || !followupContext.root_filters) {
return followupContext;
}
return {
...followupContext,
previous_intent: followupContext.root_intent,
previous_filters: { ...followupContext.root_filters },
previous_anchor_type: followupContext.root_anchor_type ?? followupContext.previous_anchor_type,
previous_anchor_value: followupContext.root_anchor_value ?? followupContext.previous_anchor_value,
current_frame_kind: "inventory_root"
};
}
function getTokenCount(text: string): number {
return String(text ?? "")
.trim()
.split(/\s+/)
.filter(Boolean).length;
}
function resolveMonthNumberFromText(text: string): number | null {
const normalized = String(text ?? "").toLowerCase();
if (!normalized) {
return null;
}
if (/январ|january|jan/iu.test(normalized)) return 1;
if (/феврал|february|feb/iu.test(normalized)) return 2;
if (/март|march|mar/iu.test(normalized)) return 3;
if (/апрел|april|apr/iu.test(normalized)) return 4;
if (/(?:^|[\s,.;:!?()\-])ма(?:й|е|я)(?=$|[\s,.;:!?()\-])|may/iu.test(normalized)) return 5;
if (/июн|june|jun/iu.test(normalized)) return 6;
if (/июл|july|jul/iu.test(normalized)) return 7;
if (/август|august|aug/iu.test(normalized)) return 8;
if (/сентябр|september|sep/iu.test(normalized)) return 9;
if (/октябр|october|oct/iu.test(normalized)) return 10;
if (/ноябр|november|nov/iu.test(normalized)) return 11;
if (/декабр|december|dec/iu.test(normalized)) return 12;
return null;
}
function resolveYearFromFilters(filters: AddressFilterSet | null | undefined): number | null {
const candidates = [
toNonEmptyString(filters?.as_of_date),
toNonEmptyString(filters?.period_to),
toNonEmptyString(filters?.period_from)
];
for (const candidate of candidates) {
const match = candidate?.match(/\b((?:19|20)\d{2})\b/u);
if (match) {
const year = Number(match[1]);
if (Number.isFinite(year)) {
return year;
}
}
}
return null;
}
function hasRelativeYearHint(text: string): boolean {
return /(?:эт(?:от|ого)(?:\s+же)?\s+год|этого\s+же\s+года|того\s+же\s+года|this\s+year|same\s+year|that\s+year)/iu.test(
String(text ?? "")
);
}
function resolveRelativeMonthPeriodFromInventoryRoot(
userMessage: string,
followupContext: AddressFollowupContext | null
): { period_from: string; period_to: string; as_of_date: string } | null {
if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) {
return null;
}
const month = resolveMonthNumberFromText(userMessage);
if (!month) {
return null;
}
const normalized = String(userMessage ?? "");
if (hasExplicitPeriodLiteral(normalized) || hasExplicitCurrentDateHint(normalized)) {
return null;
}
const shortTemporalPatch = getTokenCount(normalized) <= 8 || hasRelativeYearHint(normalized);
if (!shortTemporalPatch) {
return null;
}
const year = resolveYearFromFilters(followupContext.root_filters);
if (!year) {
return null;
}
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const periodFrom = `${year}-${String(month).padStart(2, "0")}-01`;
const periodTo = `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
return {
period_from: periodFrom,
period_to: periodTo,
as_of_date: periodTo
};
}
function shouldRestoreInventoryRootFrame(
userMessage: string,
intent: AddressIntent,
extractedFilters: AddressFilterSet,
followupContext: AddressFollowupContext | null
): boolean {
if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) {
return false;
}
const currentFrameKind = followupContext.current_frame_kind ?? null;
const previousIntent = followupContext.previous_intent;
const comingFromInventoryDrilldown =
currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
if (!comingFromInventoryDrilldown) {
return false;
}
const normalized = String(userMessage ?? "");
if (
hasSelectedObjectInventorySignal(normalized) ||
hasInventorySupplierFollowupCue(normalized) ||
hasInventoryPurchaseDocumentsFollowupCue(normalized) ||
hasInventoryPurchaseDateFollowupCue(normalized) ||
hasBareInventoryPurchaseDateFollowupCue(normalized) ||
hasInventorySaleFollowupCue(normalized) ||
hasInventoryPurchaseToSaleChainFollowupCue(normalized)
) {
return false;
}
if (intent === "inventory_on_hand_as_of_date") {
return true;
}
const hasTemporalPatch =
hasExplicitPeriodWindow(extractedFilters) ||
Boolean(toNonEmptyString(extractedFilters.as_of_date)) ||
hasExplicitPeriodLiteral(normalized) ||
Boolean(resolveRelativeMonthPeriodFromInventoryRoot(normalized, followupContext));
return hasTemporalPatch;
}
function hasSelectedObjectInventorySignal(text: string): boolean {
return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
}
@@ -456,6 +636,7 @@ function mergeFollowupFilters(
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to);
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
const allTimeRequested = hasAllTimeHint(userMessage);
const sameDateRequested = hasSameDateHint(userMessage);
if (!toNonEmptyString(merged.organization) && previousOrganization) {
@@ -648,6 +829,15 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_open_items_followup_context");
}
}
if (
relativeMonthFromInventoryRoot &&
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date")
) {
merged.period_from = relativeMonthFromInventoryRoot.period_from;
merged.period_to = relativeMonthFromInventoryRoot.period_to;
merged.as_of_date = relativeMonthFromInventoryRoot.as_of_date;
reasons.push("period_derived_from_inventory_root_frame_year");
}
if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
String(userMessage ?? "")
@@ -1016,7 +1206,8 @@ function deriveIntentWithFollowupContext(
export function runAddressDecomposeStage(
userMessage: string,
followupContext: AddressFollowupContext | null
followupContext: AddressFollowupContext | null,
llmSemanticHints: AddressLlmSemanticHints | null = null
): AddressDecomposeStageResult | null {
const detectedMode = detectAddressQuestionMode(userMessage);
const shape = classifyAddressQueryShape(userMessage);
@@ -1047,18 +1238,48 @@ export function runAddressDecomposeStage(
if (mode.mode !== "address_query") {
return null;
}
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
const extractedFilters = extractAddressFilters(userMessage, intent.intent);
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
let effectiveFollowupContext = followupContext;
let intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, effectiveFollowupContext);
let extractedFilters = applyAddressLlmSemanticHintsToExtraction(
extractAddressFilters(userMessage, intent.intent),
llmSemanticHints
);
if (
shouldRestoreInventoryRootFrame(
userMessage,
intent.intent,
extractedFilters.extracted_filters,
effectiveFollowupContext
)
) {
effectiveFollowupContext = buildInventoryRootFollowupContext(effectiveFollowupContext);
intent = {
intent: effectiveFollowupContext?.root_intent ?? "inventory_on_hand_as_of_date",
confidence: "low",
reasons: [...intent.reasons, "intent_restored_to_inventory_root_frame"]
};
extractedFilters = applyAddressLlmSemanticHintsToExtraction(
extractAddressFilters(userMessage, intent.intent),
llmSemanticHints
);
}
const followupMerged = mergeFollowupFilters(
extractedFilters.extracted_filters,
intent.intent,
userMessage,
effectiveFollowupContext
);
const filters = {
extracted_filters: followupMerged.filters,
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])],
semantic_frame: extractedFilters.semantic_frame
};
const followupContextApplied =
Boolean(followupContext) &&
Boolean(effectiveFollowupContext) &&
(mode.reasons.includes("address_mode_from_followup_context") ||
intent.reasons.includes("intent_from_followup_context") ||
intent.reasons.includes("intent_restored_to_inventory_root_frame") ||
followupMerged.reasons.length > 0);
const baseReasons = [
...mode.reasons,
@@ -1,8 +1,16 @@
import type { AddressFilterSet, AddressIntent, AddressQuestionMode, AddressQueryShape } from "../../types/addressQuery";
import type {
AddressLlmSemanticHints,
AddressFilterSet,
AddressIntent,
AddressQuestionMode,
AddressQueryShape,
AddressSemanticFrame
} from "../../types/addressQuery";
import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver";
import { extractAddressFilters } from "../addressFilterExtractor";
import { applyAddressLlmSemanticHintsToExtraction } from "./semanticHintOverlay";
export type AddressPredecomposePeriodScope = "all_time" | "year" | "range" | "as_of" | "unspecified";
@@ -40,6 +48,7 @@ export interface AddressLlmPredecomposeContractV1 {
as_of_date: string | null;
has_explicit_period: boolean;
};
semantics: AddressSemanticFrame;
aggregation_profile: AddressPredecomposeAggregationProfile;
}
@@ -59,6 +68,7 @@ export interface AddressSemanticExtractionContractV1 {
};
entities: AddressLlmPredecomposeContractV1["entities"];
period: AddressLlmPredecomposeContractV1["period"];
semantics: AddressLlmPredecomposeContractV1["semantics"];
guard_hints: {
source_data_signal_detected: boolean;
canonical_data_signal_detected: boolean;
@@ -75,7 +85,7 @@ export interface AddressSemanticExtractionContractV1 {
}
const ADDRESS_SEMANTIC_DATA_SIGNAL_PATTERN =
/(?:\u0434\u043e\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|counterparty|contract|document|account|balance|turnover|operations?|doki|doky|dokument|dogovor|kontragent|schet|saldo|platezh|oplata)/iu;
/(?:\u0434\u043e\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|\u0441\u043a\u043b\u0430\u0434|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|counterparty|contract|document|account|balance|turnover|operations?|warehouse|stock|inventory|item|goods|doki|doky|dokument|dogovor|kontragent|schet|saldo|platezh|oplata)/iu;
const ADDRESS_SEMANTIC_ENTITY_SIGNAL_PATTERN =
/(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043d\u0442\u043e\u0440|customer|supplier|counterparty|company|vendor|client)/iu;
@@ -84,7 +94,7 @@ const ADDRESS_SEMANTIC_SCOPE_META_PATTERN =
/(?:\u043a\u0430\u043a\u0430\u044f\s+\u0431\u0430\u0437\u0430|\u0431\u0430\u0437\u0430\s+\u043a\u0430\u043a\u043e\u0439\s+\u043a\u043e\u043d\u0442\u043e\u0440|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c\s+\u043a\u043e\u043d\u0442\u043e\u0440|which\s+company\s+base|which\s+tenant|data\s+scope)/iu;
const ADDRESS_SEMANTIC_DEEP_INVESTIGATION_PATTERN =
/(?:\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c)|\u0440\u0430\u0437\u0431\u0435\u0440(?:\u0438|\u0430\u0442\u044c)|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0440\u0430\u0437\u0440\u044b\u0432|\u0445\u0432\u043e\u0441\u0442|root\s*cause|trace\s*chain|state\s+transition)/iu;
/(?:\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0440\u0430\u0437\u0440\u044b\u0432|\u0445\u0432\u043e\u0441\u0442|root\s*cause|trace\s*chain|state\s+transition|\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c).*(?:\u0445\u0432\u043e\u0441\u0442|\u0440\u0430\u0437\u0440\u044b\u0432|\u0437\u0430\u043a\u0440\u044b\u0442|\u0446\u0435\u043f\u043e\u0447|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u043e\u0448\u0438\u0431|\u0430\u043d\u043e\u043c\u0430\u043b|\u0440\u0438\u0441\u043a|\u0441\u0432\u0435\u0440\u043a)|\u0440\u0430\u0437\u0431\u0435\u0440(?:\u0438|\u0430\u0442\u044c).*(?:\u043f\u043e\u0447\u0435\u043c\u0443|\u0445\u0432\u043e\u0441\u0442|\u0440\u0430\u0437\u0440\u044b\u0432|\u0437\u0430\u043a\u0440\u044b\u0442|\u0446\u0435\u043f\u043e\u0447|\u043e\u0448\u0438\u0431|\u0430\u043d\u043e\u043c\u0430\u043b|\u0440\u0438\u0441\u043a))/iu;
function normalizeCompact(value: unknown): string {
return String(value ?? "")
@@ -232,6 +242,7 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
export function buildAddressLlmPredecomposeContractV1(input: {
sourceMessage: string;
canonicalMessage: string;
semanticHints?: AddressLlmSemanticHints | null;
}): AddressLlmPredecomposeContractV1 {
const sourceMessage = String(input.sourceMessage ?? "").trim();
const canonicalMessage = String(input.canonicalMessage ?? "").trim() || sourceMessage;
@@ -239,8 +250,20 @@ export function buildAddressLlmPredecomposeContractV1(input: {
const mode = detectAddressQuestionMode(canonicalMessage);
const shape = classifyAddressQueryShape(canonicalMessage);
const intent = resolveAddressIntent(canonicalMessage);
const extraction = extractAddressFilters(canonicalMessage, intent.intent);
const extraction = applyAddressLlmSemanticHintsToExtraction(
extractAddressFilters(canonicalMessage, intent.intent),
input.semanticHints ?? null
);
const filters = extraction.extracted_filters;
const semanticFrame: AddressSemanticFrame = extraction.semantic_frame ?? {
scope_kind: "none",
anchor_kind: "none",
anchor_value: null,
date_scope_kind: "none",
date_basis_hint: null,
self_scope_detected: false,
selected_object_scope_detected: false
};
const periodScope = inferPeriodScope(filters, canonicalMessage);
return {
@@ -266,10 +289,9 @@ export function buildAddressLlmPredecomposeContractV1(input: {
period_from: toNonEmptyString(filters.period_from),
period_to: toNonEmptyString(filters.period_to),
as_of_date: toNonEmptyString(filters.as_of_date),
has_explicit_period: Boolean(
toNonEmptyString(filters.as_of_date) || toNonEmptyString(filters.period_from) || toNonEmptyString(filters.period_to)
)
has_explicit_period: semanticFrame.date_scope_kind === "explicit"
},
semantics: semanticFrame,
aggregation_profile: inferAggregationProfile(intent.intent, shape.shape)
};
}
@@ -370,6 +392,7 @@ export function buildAddressSemanticExtractionContractV1(input: {
as_of_date: predecomposeContract.period.as_of_date,
has_explicit_period: predecomposeContract.period.has_explicit_period
},
semantics: predecomposeContract.semantics,
guard_hints: {
source_data_signal_detected: sourceDataSignal,
canonical_data_signal_detected: canonicalDataSignal,
@@ -16,7 +16,16 @@ const PARTY_ANCHOR_STOPWORDS = new Set([
]);
export interface AnchorResolutionDebug {
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null;
anchor_type:
| "account"
| "counterparty"
| "contract"
| "document_ref"
| "item"
| "warehouse"
| "organization"
| "unknown"
| null;
anchor_value_raw: string | null;
anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null;
@@ -30,6 +39,7 @@ export interface ResolveStageRow {
analytics: string[];
item?: string | null;
warehouse?: string | null;
organization?: string | null;
}
function transliterateCyrillicToLatin(value: string): string {
@@ -175,6 +185,7 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
const item = typeof filters.item === "string" ? filters.item.trim() : "";
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
const organization = typeof filters.organization === "string" ? filters.organization.trim() : "";
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
@@ -260,6 +271,16 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
};
}
if (organization) {
return {
anchor_type: "organization",
anchor_value_raw: organization,
anchor_value_resolved: organization,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (documentRef) {
return {
anchor_type: "document_ref",
@@ -287,7 +308,8 @@ export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: Resolv
anchor.anchor_type !== "counterparty" &&
anchor.anchor_type !== "contract" &&
anchor.anchor_type !== "item" &&
anchor.anchor_type !== "warehouse"
anchor.anchor_type !== "warehouse" &&
anchor.anchor_type !== "organization"
) {
return anchor;
}
@@ -296,8 +318,16 @@ export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: Resolv
return anchor;
}
const searchableRows =
anchor.anchor_type === "item" || anchor.anchor_type === "warehouse"
? rows.flatMap((row) => [row.registrator, row.item ?? "", row.warehouse ?? "", row.account_dt ?? "", row.account_kt ?? "", ...row.analytics])
anchor.anchor_type === "item" || anchor.anchor_type === "warehouse" || anchor.anchor_type === "organization"
? rows.flatMap((row) => [
row.registrator,
row.item ?? "",
row.warehouse ?? "",
row.organization ?? "",
row.account_dt ?? "",
row.account_kt ?? "",
...row.analytics
])
: rows.flatMap((row) => row.analytics);
const candidates = uniqueStrings(
searchableRows
@@ -0,0 +1,168 @@
import type {
AddressAsOfDateBasis,
AddressFilterExtraction,
AddressLlmSemanticHints,
AddressSemanticFrame
} from "../../types/addressQuery";
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function normalizeToken(value: unknown): string {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, "_");
}
export function normalizeAddressLlmSemanticHints(value: unknown): AddressLlmSemanticHints | null {
if (!value || typeof value !== "object") {
return null;
}
const source = value as Record<string, unknown>;
const scopeToken = normalizeToken(source.scope_target_kind);
const dateToken = normalizeToken(source.date_scope_kind);
const scopeTargetKind: AddressLlmSemanticHints["scope_target_kind"] =
scopeToken === "self_scope" ||
scopeToken === "selected_object" ||
scopeToken === "organization" ||
scopeToken === "warehouse" ||
scopeToken === "counterparty" ||
scopeToken === "contract" ||
scopeToken === "item"
? (scopeToken as AddressLlmSemanticHints["scope_target_kind"])
: "none";
const dateScopeKind: AddressLlmSemanticHints["date_scope_kind"] =
dateToken === "explicit" || dateToken === "implicit_current" ? (dateToken as AddressLlmSemanticHints["date_scope_kind"]) : "missing";
return {
scope_target_kind: scopeTargetKind,
scope_target_text: toNonEmptyString(source.scope_target_text),
date_scope_kind: dateScopeKind,
self_scope_detected: source.self_scope_detected === true || scopeTargetKind === "self_scope",
selected_object_scope_detected:
source.selected_object_scope_detected === true || scopeTargetKind === "selected_object"
};
}
function defaultSemanticFrame(extraction: AddressFilterExtraction): AddressSemanticFrame {
return (
extraction.semantic_frame ?? {
scope_kind: "none",
anchor_kind: "none",
anchor_value: null,
date_scope_kind: "none",
date_basis_hint: null,
self_scope_detected: false,
selected_object_scope_detected: false
}
);
}
function pushWarning(warnings: string[], value: string): void {
if (!warnings.includes(value)) {
warnings.push(value);
}
}
function applyDateScopeHint(frame: AddressSemanticFrame, dateScopeKind: AddressLlmSemanticHints["date_scope_kind"]): void {
if (dateScopeKind === "explicit") {
frame.date_scope_kind = "explicit";
return;
}
if (dateScopeKind === "implicit_current" && frame.date_scope_kind !== "explicit") {
frame.date_scope_kind = "implicit_current";
frame.date_basis_hint = "implicit_current_snapshot" satisfies AddressAsOfDateBasis;
}
}
export function applyAddressLlmSemanticHintsToExtraction(
extraction: AddressFilterExtraction,
semanticHintsInput: unknown
): AddressFilterExtraction {
const semanticHints = normalizeAddressLlmSemanticHints(semanticHintsInput);
if (!semanticHints) {
return extraction;
}
const extractedFilters = { ...(extraction.extracted_filters ?? {}) };
const warnings = [...(Array.isArray(extraction.warnings) ? extraction.warnings : [])];
const semanticFrame = { ...defaultSemanticFrame(extraction) };
const scopeTargetText = semanticHints.scope_target_text;
applyDateScopeHint(semanticFrame, semanticHints.date_scope_kind);
if (semanticHints.self_scope_detected) {
semanticFrame.scope_kind = "implicit_self_scope";
semanticFrame.anchor_kind = "self_scope";
semanticFrame.anchor_value = null;
semanticFrame.self_scope_detected = true;
}
if (semanticHints.selected_object_scope_detected) {
if (semanticFrame.scope_kind === "none") {
semanticFrame.scope_kind = "selected_object_scope";
semanticFrame.anchor_kind = "selected_object";
semanticFrame.anchor_value = null;
}
semanticFrame.selected_object_scope_detected = true;
}
if (semanticHints.scope_target_kind === "organization" && scopeTargetText) {
extractedFilters.organization = scopeTargetText;
pushWarning(warnings, "organization_from_llm_semantics");
if (toNonEmptyString(extractedFilters.warehouse)) {
delete extractedFilters.warehouse;
pushWarning(warnings, "warehouse_cleared_by_llm_organization_semantics");
}
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "organization";
semanticFrame.anchor_value = scopeTargetText;
}
if (semanticHints.scope_target_kind === "warehouse" && scopeTargetText) {
extractedFilters.warehouse = scopeTargetText;
pushWarning(warnings, "warehouse_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "warehouse";
semanticFrame.anchor_value = scopeTargetText;
}
if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) {
extractedFilters.counterparty = scopeTargetText;
pushWarning(warnings, "counterparty_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "counterparty";
semanticFrame.anchor_value = scopeTargetText;
}
if (semanticHints.scope_target_kind === "contract" && scopeTargetText) {
extractedFilters.contract = scopeTargetText;
pushWarning(warnings, "contract_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "contract";
semanticFrame.anchor_value = scopeTargetText;
}
if (semanticHints.scope_target_kind === "item" && scopeTargetText) {
extractedFilters.item = scopeTargetText;
pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "item";
semanticFrame.anchor_value = scopeTargetText;
}
return {
...extraction,
extracted_filters: extractedFilters,
warnings,
semantic_frame: semanticFrame
};
}
@@ -205,14 +205,17 @@ export async function runAssistantAddressAttemptRuntime<ResponseType = unknown>(
const runAddressLaneAttempt: RunAssistantAddressRuntimeInput<ResponseType>["runAddressLaneAttempt"] = async (
messageUsed,
carryMeta,
analysisDateHint
analysisDateHint,
llmSemanticHints = null
) =>
runAddressLaneAttemptRuntimeSafe(
buildAssistantAddressLaneAttemptRuntimeInput({
messageUsed,
carryMeta,
analysisDateHint,
llmSemanticHints,
activeOrganization: input.sessionScope.activeOrganization,
knownOrganizations: input.sessionScope.knownOrganizations,
mergeFollowupContextWithOrganizationScope: input.mergeFollowupContextWithOrganizationScope,
runAddressQueryTryHandle: input.runAddressQueryTryHandle
})
@@ -4,7 +4,9 @@ export interface BuildAssistantAddressLaneAttemptRuntimeInputInput {
messageUsed: RunAssistantAddressLaneAttemptRuntimeInput["messageUsed"];
carryMeta: RunAssistantAddressLaneAttemptRuntimeInput["carryMeta"];
analysisDateHint: RunAssistantAddressLaneAttemptRuntimeInput["analysisDateHint"];
llmSemanticHints: RunAssistantAddressLaneAttemptRuntimeInput["llmSemanticHints"];
activeOrganization: RunAssistantAddressLaneAttemptRuntimeInput["activeOrganization"];
knownOrganizations: RunAssistantAddressLaneAttemptRuntimeInput["knownOrganizations"];
mergeFollowupContextWithOrganizationScope:
RunAssistantAddressLaneAttemptRuntimeInput["mergeFollowupContextWithOrganizationScope"];
runAddressQueryTryHandle: RunAssistantAddressLaneAttemptRuntimeInput["runAddressQueryTryHandle"];
@@ -17,7 +19,9 @@ export function buildAssistantAddressLaneAttemptRuntimeInput(
messageUsed: input.messageUsed,
carryMeta: input.carryMeta,
analysisDateHint: input.analysisDateHint,
llmSemanticHints: input.llmSemanticHints,
activeOrganization: input.activeOrganization,
knownOrganizations: input.knownOrganizations,
mergeFollowupContextWithOrganizationScope: input.mergeFollowupContextWithOrganizationScope,
runAddressQueryTryHandle: input.runAddressQueryTryHandle
};
@@ -11,18 +11,28 @@ export function resolveAssistantAddressLaneAttemptFollowupContext(
export interface BuildAssistantAddressLaneAttemptQueryOptionsInput {
analysisDateHint: RunAssistantAddressLaneAttemptRuntimeInput["analysisDateHint"];
scopedFollowupContext: Record<string, unknown> | null;
llmSemanticHints: RunAssistantAddressLaneAttemptRuntimeInput["llmSemanticHints"];
activeOrganization: RunAssistantAddressLaneAttemptRuntimeInput["activeOrganization"];
knownOrganizations: RunAssistantAddressLaneAttemptRuntimeInput["knownOrganizations"];
}
export function buildAssistantAddressLaneAttemptQueryOptions(
input: BuildAssistantAddressLaneAttemptQueryOptionsInput
): Parameters<RunAssistantAddressLaneAttemptRuntimeInput["runAddressQueryTryHandle"]>[1] {
if (input.scopedFollowupContext) {
return {
followupContext: input.scopedFollowupContext,
analysisDateHint: input.analysisDateHint
};
}
return {
const base = {
analysisDateHint: input.analysisDateHint
};
} as Parameters<RunAssistantAddressLaneAttemptRuntimeInput["runAddressQueryTryHandle"]>[1];
if (input.scopedFollowupContext) {
base.followupContext = input.scopedFollowupContext;
}
if (input.llmSemanticHints) {
base.llmSemanticHints = input.llmSemanticHints;
}
if (input.activeOrganization) {
base.activeOrganization = input.activeOrganization;
}
if (input.knownOrganizations.length > 0) {
base.knownOrganizations = input.knownOrganizations;
}
return base;
}
@@ -9,7 +9,9 @@ export interface RunAssistantAddressLaneAttemptRuntimeInput {
messageUsed: string;
carryMeta: AssistantAddressCarryoverLike | null;
analysisDateHint: string | null;
llmSemanticHints?: Record<string, unknown> | null;
activeOrganization: string | null;
knownOrganizations: string[];
mergeFollowupContextWithOrganizationScope: (
followupContext: Record<string, unknown> | null,
organization: string | null
@@ -19,6 +21,9 @@ export interface RunAssistantAddressLaneAttemptRuntimeInput {
options: {
followupContext?: Record<string, unknown>;
analysisDateHint?: string | null;
llmSemanticHints?: Record<string, unknown> | null;
activeOrganization?: string | null;
knownOrganizations?: string[];
}
) => Promise<AssistantAddressLaneLike | null>;
}
@@ -35,7 +40,10 @@ export async function runAssistantAddressLaneAttemptRuntime(
input.messageUsed,
buildAssistantAddressLaneAttemptQueryOptions({
analysisDateHint: input.analysisDateHint,
scopedFollowupContext
scopedFollowupContext,
llmSemanticHints: input.llmSemanticHints ?? null,
activeOrganization: input.activeOrganization,
knownOrganizations: input.knownOrganizations
})
);
}
@@ -214,12 +214,32 @@ export function runAssistantAddressLaneResponseRuntime<ResponseType = AssistantM
const debugActiveOrganization =
input.toNonEmptyString(debugFilters?.organization) ??
input.toNonEmptyString(input.activeOrganization);
const followupContextSource =
input.carryoverMeta?.followupContext && typeof input.carryoverMeta.followupContext === "object"
? (input.carryoverMeta.followupContext as Record<string, unknown>)
: null;
if (debugKnownOrganizations.length > 0) {
debug.assistant_known_organizations = debugKnownOrganizations;
}
if (debugActiveOrganization) {
debug.assistant_active_organization = debugActiveOrganization;
}
const rootIntent = input.toNonEmptyString(followupContextSource?.root_intent);
const currentFrameKind = input.toNonEmptyString(followupContextSource?.current_frame_kind);
const rootFilters =
followupContextSource?.root_filters && typeof followupContextSource.root_filters === "object"
? (followupContextSource.root_filters as Record<string, unknown>)
: null;
if (rootIntent || currentFrameKind) {
debug.address_root_frame_context = {
root_intent: rootIntent,
current_frame_kind: currentFrameKind,
organization: input.toNonEmptyString(rootFilters?.organization),
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date),
period_from: input.toNonEmptyString(rootFilters?.period_from),
period_to: input.toNonEmptyString(rootFilters?.period_to)
};
}
const finalization = finalizeAddressTurnSafe({
sessionId: input.sessionId,
userMessage: input.userMessage,
@@ -31,11 +31,13 @@ export interface RunAssistantAddressLaneRuntimeInput {
userMessage: string;
addressInputMessage: string;
carryover: AssistantAddressFollowupCarryoverLike | null;
llmSemanticHints?: Record<string, unknown> | null;
shouldPreferContextualLane: boolean;
canRetryWithRawUserMessage: boolean;
runAddressLaneAttempt: (
messageUsed: string,
carryMeta: AssistantAddressFollowupCarryoverLike | null
carryMeta: AssistantAddressFollowupCarryoverLike | null,
llmSemanticHints?: Record<string, unknown> | null
) => Promise<AssistantAddressLaneLike | null>;
isRetryableAddressLimitedResult: (addressLane: AssistantAddressLaneLike | null | undefined) => boolean;
}
@@ -95,7 +97,11 @@ export async function runAssistantAddressLaneRuntime(
};
if (input.shouldPreferContextualLane) {
const contextualAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, input.carryover);
const contextualAddressLane = await input.runAddressLaneAttempt(
input.addressInputMessage,
input.carryover,
input.llmSemanticHints ?? null
);
const decision = evaluateAddressLane(contextualAddressLane, input.addressInputMessage, input.carryover);
if (decision.action === "return") {
return {
@@ -106,7 +112,11 @@ export async function runAssistantAddressLaneRuntime(
}
}
const primaryAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, null);
const primaryAddressLane = await input.runAddressLaneAttempt(
input.addressInputMessage,
null,
input.llmSemanticHints ?? null
);
const primaryDecision = evaluateAddressLane(primaryAddressLane, input.addressInputMessage, null);
if (primaryDecision.action === "return") {
return {
@@ -117,7 +127,11 @@ export async function runAssistantAddressLaneRuntime(
}
if (!input.shouldPreferContextualLane && input.carryover?.followupContext) {
const contextualAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, input.carryover);
const contextualAddressLane = await input.runAddressLaneAttempt(
input.addressInputMessage,
input.carryover,
input.llmSemanticHints ?? null
);
const contextualDecision = evaluateAddressLane(contextualAddressLane, input.addressInputMessage, input.carryover);
if (contextualDecision.action === "return") {
return {
@@ -139,7 +153,11 @@ export async function runAssistantAddressLaneRuntime(
if (input.carryover?.followupContext) {
retryAudit.retry_used_followup_context = true;
const rawContextualLane = await input.runAddressLaneAttempt(input.userMessage, input.carryover);
const rawContextualLane = await input.runAddressLaneAttempt(
input.userMessage,
input.carryover,
input.llmSemanticHints ?? null
);
const rawContextualDecision = evaluateAddressLane(rawContextualLane, input.userMessage, input.carryover);
if (rawContextualDecision.action === "return") {
retryAudit.retry_result_category = limitedCategory(rawContextualDecision.selection.addressLane);
@@ -151,7 +169,7 @@ export async function runAssistantAddressLaneRuntime(
}
}
const rawPrimaryLane = await input.runAddressLaneAttempt(input.userMessage, null);
const rawPrimaryLane = await input.runAddressLaneAttempt(input.userMessage, null, input.llmSemanticHints ?? null);
retryAudit.retry_result_category = limitedCategory(rawPrimaryLane);
const rawPrimaryDecision = evaluateAddressLane(rawPrimaryLane, input.userMessage, null);
if (rawPrimaryDecision.action === "return") {
@@ -50,6 +50,64 @@ export interface BuildAssistantAddressOrchestrationRuntimeOutput {
};
}
function hasSelectedObjectInventorySignal(text: string | null): boolean {
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(
String(text ?? "")
);
}
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(
String(text ?? "")
);
}
function isGenericCanonicalDriftIntent(intent: string | null): boolean {
return (
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_documents_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_counterparty" ||
intent === "bank_operations_by_contract" ||
intent === "documents_forming_balance"
);
}
function shouldPreferRawFollowupMessage(
userMessage: string,
addressInputMessage: string,
carryover: AssistantAddressCarryoverLike | null,
addressPreDecompose: Record<string, unknown>,
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
): boolean {
if (!carryover?.followupContext || typeof carryover.followupContext !== "object") {
return false;
}
const rawMessage = toNonEmptyString(userMessage);
const canonicalMessage = toNonEmptyString(addressInputMessage);
if (!rawMessage || !canonicalMessage || rawMessage === canonicalMessage) {
return false;
}
const predecomposeContract =
addressPreDecompose?.predecomposeContract && typeof addressPreDecompose.predecomposeContract === "object"
? (addressPreDecompose.predecomposeContract as Record<string, unknown>)
: null;
const mode = toNonEmptyString(predecomposeContract?.mode) ?? "unknown";
const intent = toNonEmptyString(predecomposeContract?.intent) ?? "unknown";
if (mode === "unsupported" && intent === "unknown") {
return true;
}
return (
hasSelectedObjectInventorySignal(rawMessage) &&
hasSelectedObjectInventoryActionCue(rawMessage) &&
isGenericCanonicalDriftIntent(intent)
);
}
function fallbackAddressPreDecompose(
userMessage: string,
llmProvider: unknown,
@@ -80,7 +138,7 @@ function fallbackAddressPreDecompose(
export async function buildAssistantAddressOrchestrationRuntime(
input: BuildAssistantAddressOrchestrationRuntimeInput
): Promise<BuildAssistantAddressOrchestrationRuntimeOutput> {
const addressPreDecompose = input.featureAddressLlmPredecomposeV1
const initialAddressPreDecompose = input.featureAddressLlmPredecomposeV1
? await input.runAddressLlmPreDecompose()
: fallbackAddressPreDecompose(
input.userMessage,
@@ -89,14 +147,43 @@ export async function buildAssistantAddressOrchestrationRuntime(
input.sanitizeAddressMessageForFallback
);
const addressInputMessage =
let addressPreDecompose = initialAddressPreDecompose;
let addressInputMessage =
input.toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? input.userMessage;
const carryover = input.resolveAddressFollowupCarryoverContext(
let carryover = input.resolveAddressFollowupCarryoverContext(
input.userMessage,
input.sessionItems,
addressInputMessage,
addressPreDecompose
);
if (
shouldPreferRawFollowupMessage(
input.userMessage,
addressInputMessage,
carryover,
addressPreDecompose,
input.toNonEmptyString
)
) {
addressInputMessage = input.userMessage;
addressPreDecompose = {
...addressPreDecompose,
applied: false,
effectiveMessage: input.userMessage,
reason: "followup_raw_message_preferred_over_llm_rewrite",
predecomposeContract: input.buildAddressLlmPredecomposeContractV1({
sourceMessage: input.userMessage,
canonicalMessage: input.userMessage
})
};
carryover = input.resolveAddressFollowupCarryoverContext(
input.userMessage,
input.sessionItems,
addressInputMessage,
addressPreDecompose
);
}
const followupContext = carryover?.followupContext ?? null;
const orchestrationDecision = input.resolveAssistantOrchestrationDecision({
rawUserMessage: input.userMessage,
@@ -35,7 +35,8 @@ export interface RunAssistantAddressRuntimeInput<ResponseType = unknown> {
runAddressLaneAttempt: (
messageUsed: string,
carryMeta: AssistantAddressCarryoverLike | null,
analysisDateHint: string | null
analysisDateHint: string | null,
llmSemanticHints?: Record<string, unknown> | null
) => Promise<AssistantAddressLaneLike | null>;
isRetryableAddressLimitedResult: (addressLane: AssistantAddressLaneLike | null | undefined) => boolean;
finalizeAddressLaneResponse: (
@@ -78,7 +79,8 @@ export interface RunAssistantAddressRuntimeInput<ResponseType = unknown> {
canRetryWithRawUserMessage: boolean;
runAddressLaneAttempt: (
messageUsed: string,
carryMeta: AssistantAddressCarryoverLike | null
carryMeta: AssistantAddressCarryoverLike | null,
llmSemanticHints?: Record<string, unknown> | null
) => Promise<AssistantAddressLaneLike | null>;
isRetryableAddressLimitedResult: (addressLane: AssistantAddressLaneLike | null | undefined) => boolean;
}
@@ -157,10 +159,14 @@ export async function runAssistantAddressRuntime<ResponseType = unknown>(
userMessage: input.userMessage,
addressInputMessage,
carryover,
llmSemanticHints:
addressRuntimeMeta && typeof addressRuntimeMeta === "object"
? ((addressRuntimeMeta as { semanticHints?: unknown }).semanticHints as Record<string, unknown> | null) ?? null
: null,
shouldPreferContextualLane,
canRetryWithRawUserMessage,
runAddressLaneAttempt: (messageUsed, carryMeta) =>
input.runAddressLaneAttempt(messageUsed, carryMeta, analysisDateHint),
runAddressLaneAttempt: (messageUsed, carryMeta, llmSemanticHints = null) =>
input.runAddressLaneAttempt(messageUsed, carryMeta, analysisDateHint, llmSemanticHints),
isRetryableAddressLimitedResult: input.isRetryableAddressLimitedResult
});
if (addressLaneRuntime.handled && addressLaneRuntime.selection) {
@@ -0,0 +1,221 @@
const ORGANIZATION_SCOPE_STOPWORDS = new Set([
"ооо",
"зао",
"оао",
"пао",
"ао",
"ип",
"llc",
"inc",
"ltd",
"corp",
"group",
"company",
"co",
"the",
"and",
"org",
"organization",
"компания",
"организация",
"контора",
"фирма",
"база",
"по",
"в",
"во",
"на",
"для",
"из",
"у",
"к",
"от",
"это",
"эта",
"этой",
"этот",
"сегодня",
"сейчас",
"текущая",
"текущей",
"наш",
"наша",
"нашей",
"нашу",
"наши"
]);
function normalizeScopeLabel(value: unknown): string {
return String(value ?? "")
.replace(/[“”«»]/g, '"')
.replace(/\s+/g, " ")
.trim();
}
function normalizeScopeKey(value: unknown): string {
return normalizeScopeLabel(value).toLowerCase().replace(/ё/g, "е");
}
export function normalizeOrganizationScopeValue(value: unknown): string | null {
const normalized = normalizeScopeLabel(value);
if (!normalized) {
return null;
}
let unwrapped = normalized.replace(/^\\+|\\+$/g, "").trim();
if (
(unwrapped.startsWith('"') && unwrapped.endsWith('"')) ||
(unwrapped.startsWith("'") && unwrapped.endsWith("'"))
) {
unwrapped = unwrapped.slice(1, -1).trim();
}
return unwrapped.length > 0 ? unwrapped : null;
}
export function normalizeOrganizationScopeSearchText(value: unknown): string {
return normalizeScopeKey(value)
.replace(/[^\p{L}\p{N}]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeOrganizationScope(value: unknown): string[] {
const normalized = normalizeOrganizationScopeSearchText(value);
if (!normalized) {
return [];
}
return normalized
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !ORGANIZATION_SCOPE_STOPWORDS.has(token));
}
function organizationTokenVariants(token: string): string[] {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return [];
}
const variants = new Set([source]);
const withoutLongEnding = source.replace(
/(?:ами|ями|ого|ему|ому|ыми|ими|иях|ях|ах|ей|ой|ом|ем|ам|ям|ую|юю|ая|яя|ое|ее|ые|ие|ов|ев|ий|ый|ой)$/iu,
""
);
if (withoutLongEnding.length >= 4) {
variants.add(withoutLongEnding);
}
const withoutShortEnding = source.replace(/[аеёиоуыэюя]$/iu, "");
if (withoutShortEnding.length >= 4) {
variants.add(withoutShortEnding);
}
return Array.from(variants);
}
export function scoreOrganizationMentionInMessage(message: unknown, organization: unknown): number {
const messageNorm = normalizeOrganizationScopeSearchText(message);
const organizationNorm = normalizeOrganizationScopeSearchText(organization);
if (!messageNorm || !organizationNorm) {
return 0;
}
if (messageNorm.includes(organizationNorm)) {
return 10_000 + organizationNorm.length;
}
const organizationTokens = tokenizeOrganizationScope(organizationNorm);
const messageTokens = tokenizeOrganizationScope(messageNorm);
if (organizationTokens.length === 0 || messageTokens.length === 0) {
return 0;
}
let matchedTokens = 0;
let score = 0;
for (const token of organizationTokens) {
const variants = organizationTokenVariants(token);
let matched = false;
let variantScore = 0;
for (const variant of variants) {
if (!variant) {
continue;
}
if (messageNorm.includes(variant)) {
matched = true;
variantScore = Math.max(variantScore, variant.length * 5);
continue;
}
const fuzzyMatched = messageTokens.some((messageToken) => {
if (messageToken === variant) {
return true;
}
if (messageToken.length >= 5 && variant.length >= 5) {
return messageToken.startsWith(variant) || variant.startsWith(messageToken);
}
return false;
});
if (fuzzyMatched) {
matched = true;
variantScore = Math.max(variantScore, Math.max(20, variant.length * 3));
}
}
if (matched) {
matchedTokens += 1;
score += variantScore > 0 ? variantScore : 10;
}
}
if (matchedTokens === 0) {
return 0;
}
if (matchedTokens === organizationTokens.length) {
score += 400;
} else {
score += matchedTokens * 50;
}
return score;
}
export function mergeKnownOrganizations(values: unknown[], limit = 50): string[] {
const dedup = new Map<string, string>();
for (const raw of Array.isArray(values) ? values : []) {
const normalized = normalizeOrganizationScopeValue(raw);
if (!normalized) {
continue;
}
const key = normalizeOrganizationScopeSearchText(normalized);
if (!key || dedup.has(key)) {
continue;
}
dedup.set(key, normalized);
}
return Array.from(dedup.values()).slice(0, limit);
}
export function resolveOrganizationSelectionFromMessage(
userMessage: string,
knownOrganizations: unknown[]
): string | null {
const known = mergeKnownOrganizations(Array.isArray(knownOrganizations) ? knownOrganizations : []);
if (!userMessage || known.length === 0) {
return null;
}
const messageNorm = normalizeOrganizationScopeSearchText(userMessage);
if (!messageNorm) {
return null;
}
const scored = known
.map((organization) => ({
organization,
score: scoreOrganizationMentionInMessage(messageNorm, organization)
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.organization.length - b.organization.length);
if (scored.length === 0) {
return null;
}
const best = scored[0];
const second = scored[1];
if (best.score < 90) {
return null;
}
if (second && second.score === best.score) {
return null;
}
return best.organization;
}
@@ -2453,6 +2453,62 @@ function findRecentAddressFilterValue(items, key) {
}
return null;
}
function isInventoryRootFrameIntent(intent) {
return intent === "inventory_on_hand_as_of_date";
}
function isInventoryDrilldownFrameIntent(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" ||
intent === "inventory_aging_by_purchase_date";
}
function extractAddressCarryoverAnchor(addressDebug) {
if (!isAddressLaneDebugPayload(addressDebug)) {
return {
anchorType: null,
anchorValue: null
};
}
return {
anchorType: toNonEmptyString(addressDebug.anchor_type),
anchorValue: toNonEmptyString(addressDebug.anchor_value_resolved) ??
toNonEmptyString(addressDebug.anchor_value_raw) ??
readAddressInventoryItemFilter(addressDebug) ??
readAddressFilterString(addressDebug, "counterparty") ??
readAddressFilterString(addressDebug, "contract") ??
readAddressFilterString(addressDebug, "account")
};
}
function findRecentInventoryRootFrame(items) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug) {
continue;
}
const debug = item.debug;
if (!isAddressLaneDebugPayload(debug)) {
continue;
}
const detectedIntent = toNonEmptyString(debug.detected_intent);
if (!isInventoryRootFrameIntent(detectedIntent)) {
continue;
}
const anchor = extractAddressCarryoverAnchor(debug);
const filtersRaw = debug.extracted_filters;
const filters = filtersRaw && typeof filtersRaw === "object"
? { ...filtersRaw }
: {};
return {
intent: detectedIntent,
filters,
anchorType: anchor.anchorType,
anchorValue: anchor.anchorValue,
messageId: toNonEmptyString(item.message_id)
};
}
return null;
}
const ADDRESS_FOLLOWUP_OFFER_BY_INTENT = {
list_documents_by_counterparty: ["bank_operations_by_counterparty", "list_contracts_by_counterparty"],
bank_operations_by_counterparty: ["list_documents_by_counterparty", "list_contracts_by_counterparty"],
@@ -2755,6 +2811,14 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
readAddressFilterString(previousAddressDebug, "counterparty") ??
readAddressFilterString(previousAddressDebug, "account") ??
readAddressFilterString(previousAddressDebug, "contract");
const inventoryRootFrame = findRecentInventoryRootFrame(items);
const currentFrameKind = inventoryRootFrame
? isInventoryDrilldownFrameIntent(sourceIntent)
? "inventory_drilldown"
: isInventoryRootFrameIntent(sourceIntent)
? "inventory_root"
: "generic"
: null;
let resolvedCounterpartyFromDisplay = false;
const previousFiltersRaw = previousAddressDebug.extracted_filters;
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
@@ -2814,7 +2878,12 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previous_filters: previousFilters,
previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor,
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
root_intent: inventoryRootFrame?.intent ?? undefined,
root_filters: inventoryRootFrame?.filters ?? undefined,
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
root_anchor_value: inventoryRootFrame?.anchorValue ?? undefined,
current_frame_kind: currentFrameKind ?? undefined
},
previousAddressIntent: previousIntent,
previousAddressAnchor: previousAnchor,
@@ -2890,19 +2959,32 @@ function isAddressLlmPreDecomposeCandidate(userMessage) {
}
return /(?:\bдок\b|доки|документ|контрагент|договор|остаток|сч(?:е|ё)т|сальдо|банк|выписк|платеж|оплат|поступлен|поступлени|списан|реализац|сверк|взаиморасч|кто\s+должен|show|list|documents?|counterparty|contract|account|balance|bank\s+operations?|doki|dokument(?:y|ov|am|a)?|platezh|oplata|schet|saldo)/i.test(text);
}
function extractAddressQuestionFromNormalized(normalized) {
if (!normalized || typeof normalized !== "object") {
function normalizeAddressSemanticHintsFromFragment(fragment) {
if (!fragment || typeof fragment !== "object") {
return null;
}
const source = normalized;
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
for (const item of fragments) {
const hints = fragment.semantic_hints;
if (!hints || typeof hints !== "object") {
return null;
}
const scopeTargetKind = toNonEmptyString(hints.scope_target_kind);
const dateScopeKind = toNonEmptyString(hints.date_scope_kind);
return {
scope_target_kind: scopeTargetKind ?? "none",
scope_target_text: toNonEmptyString(hints.scope_target_text),
date_scope_kind: dateScopeKind ?? "missing",
self_scope_detected: hints.self_scope_detected === true || scopeTargetKind === "self_scope",
selected_object_scope_detected: hints.selected_object_scope_detected === true || scopeTargetKind === "selected_object"
};
}
function extractAddressPredecomposeCandidateFromFragments(fragments) {
for (const item of Array.isArray(fragments) ? fragments : []) {
if (!item || typeof item !== "object") {
continue;
}
const fragment = item;
const domainRelevance = String(fragment.domain_relevance ?? "").trim().toLowerCase();
if (domainRelevance === "out_of_scope") {
if (domainRelevance === "out_of_scope" || domainRelevance === "offtopic") {
continue;
}
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
@@ -2912,11 +2994,20 @@ function extractAddressQuestionFromNormalized(normalized) {
continue;
}
if (candidate.length >= 3 && candidate.length <= 500) {
return candidate;
return {
candidate,
semanticHints: normalizeAddressSemanticHintsFromFragment(fragment)
};
}
}
return null;
}
function extractAddressPredecomposeCandidateFromNormalized(normalized) {
if (!normalized || typeof normalized !== "object") {
return null;
}
return extractAddressPredecomposeCandidateFromFragments(normalized.fragments);
}
function stripMarkdownJsonFence(text) {
return String(text ?? "")
.trim()
@@ -2994,7 +3085,7 @@ function extractOutputTextFromRawNormalizerOutput(raw) {
}
return null;
}
function extractAddressQuestionFromRawNormalizerOutput(rawModelOutput) {
function extractAddressPredecomposeCandidateFromRawNormalizerOutput(rawModelOutput) {
const outputText = extractOutputTextFromRawNormalizerOutput(rawModelOutput);
if (!outputText) {
return null;
@@ -3003,31 +3094,7 @@ function extractAddressQuestionFromRawNormalizerOutput(rawModelOutput) {
if (!parsed || typeof parsed !== "object") {
return null;
}
const source = parsed;
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
for (const item of fragments) {
if (!item || typeof item !== "object") {
continue;
}
const fragment = item;
const domainRelevance = fragment.domain_relevance;
if (typeof domainRelevance === "string" && domainRelevance.trim().toLowerCase() === "out_of_scope") {
continue;
}
if (domainRelevance === false) {
continue;
}
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
const rawText = toNonEmptyString(fragment.raw_fragment_text);
const candidate = selectPreferredAddressFragmentCandidate(rawText ?? "", normalizedText ?? "");
if (!candidate) {
continue;
}
if (candidate.length >= 3 && candidate.length <= 500) {
return candidate;
}
}
return null;
return extractAddressPredecomposeCandidateFromFragments(parsed.fragments);
}
const ADDRESS_PREDECOMPOSE_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"есть",
@@ -3267,7 +3334,8 @@ function attachAddressPredecomposeContract(meta, sourceMessage) {
const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? "");
const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
sourceMessage: String(sourceMessage ?? ""),
canonicalMessage
canonicalMessage,
semanticHints: meta?.semanticHints ?? null
});
const semanticExtractionContract = (0, predecomposeContract_1.buildAddressSemanticExtractionContractV1)({
sourceMessage: String(sourceMessage ?? ""),
@@ -3332,31 +3400,34 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
};
try {
const normalized = await normalizerService.normalize(normalizePayload);
const candidateFromNormalized = extractAddressQuestionFromNormalized(normalized?.normalized);
const candidateFromRaw = candidateFromNormalized ? null : extractAddressQuestionFromRawNormalizerOutput(normalized?.raw_model_output);
const candidate = candidateFromNormalized ?? candidateFromRaw;
const candidateFromNormalized = extractAddressPredecomposeCandidateFromNormalized(normalized?.normalized);
const candidateFromRaw = candidateFromNormalized ? null : extractAddressPredecomposeCandidateFromRawNormalizerOutput(normalized?.raw_model_output);
const candidateMeta = candidateFromNormalized ?? candidateFromRaw;
const candidate = candidateMeta?.candidate ?? null;
if (!candidate) {
if (fallbackCandidate) {
const fallbackCompact = compactWhitespace(String(fallbackCandidate.candidate ?? "").toLowerCase());
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: true,
traceId: normalized?.trace_id ?? null,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_after_llm",
fallbackRuleHit: fallbackCandidate.rule
}, userMessage);
}
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: true,
traceId: normalized?.trace_id ?? null,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_after_llm",
fallbackRuleHit: fallbackCandidate.rule,
semanticHints: null
}, userMessage);
}
}
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
traceId: normalized?.trace_id ?? null,
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed",
semanticHints: null
}, userMessage);
}
const repairedSourceMessage = repairAddressMojibake(userMessage);
@@ -3375,7 +3446,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_diagnostic_rewrite",
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const intentConflict = sourceIntentKnown &&
@@ -3397,7 +3469,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
? "normalized_fragment_rejected_intent_drop"
: "normalized_fragment_rejected_intent_conflict",
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const sourceHasExplicitDrilldownSignal = hasPredecomposeExplicitDrilldownSignal(repairedSourceMessage || userMessage);
@@ -3418,7 +3491,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_followup_intent_injection",
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const sourceHasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignalForPredecompose(repairedSourceMessage || userMessage);
@@ -3438,7 +3512,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_selected_object_context_loss",
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
@@ -3464,7 +3539,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
@@ -3481,7 +3557,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_degradation",
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
if (fallbackCandidate) {
@@ -3500,19 +3577,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_preferred_over_llm_candidate_anchor_quality",
fallbackRuleHit: fallbackCandidate.rule,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
}
const semanticContractForCandidate = (0, predecomposeContract_1.buildAddressSemanticExtractionContractV1)({
sourceMessage: String(userMessage ?? ""),
canonicalMessage: candidate
canonicalMessage: candidate,
predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
sourceMessage: String(userMessage ?? ""),
canonicalMessage: candidate,
semanticHints: candidateMeta?.semanticHints ?? null
})
});
if (!semanticContractForCandidate.apply_canonical_recommended) {
const sourceDataSignalDetected = Boolean(semanticContractForCandidate?.guard_hints?.source_data_signal_detected);
const rawFragmentCandidatePreferred = Boolean(sourceDataSignalDetected &&
candidateFromNormalized &&
candidateFromNormalized === candidate &&
candidateFromNormalized.candidate === candidate &&
toNonEmptyString(candidate));
if (rawFragmentCandidatePreferred) {
return attachAddressPredecomposeContract({
@@ -3524,7 +3607,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: candidate,
reason: "normalized_fragment_semantic_guard_raw_fragment_preferred",
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
if (fallbackCandidate) {
@@ -3545,7 +3629,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: String(fallbackCandidate.candidate ?? ""),
reason: "fallback_rule_preferred_over_llm_candidate_semantic_guard",
fallbackRuleHit: fallbackCandidate.rule,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
}
@@ -3558,7 +3643,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_semantic_guard",
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
@@ -3585,7 +3671,8 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
reason,
llmCanonicalCandidateDetected: true,
fallbackRuleHit: null,
sanitizedUserMessage
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
catch (error) {
@@ -3933,7 +4020,11 @@ export function resolveAssistantOrchestrationDecision(input) {
hasOpenContractsAddressSignal(repairedEffectiveAddressUserMessage);
const modeSample = repairedEffectiveAddressUserMessage || effectiveAddressUserMessage;
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(modeSample);
const modeDetectionRaw = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedRawUserMessage || rawUserMessage);
const resolvedModeDetection = modeDetection.mode === "address_query" ? modeDetection : modeDetectionRaw;
const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(modeSample);
const intentResolutionRaw = (0, addressIntentResolver_1.resolveAddressIntent)(repairedRawUserMessage || rawUserMessage);
const resolvedIntentResolution = intentResolution.intent !== "unknown" ? intentResolution : intentResolutionRaw;
const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmPreDecomposeReason = toNonEmptyString(llmPreDecomposeMeta?.reason);
const llmRuntimeUnavailableDetected = Boolean(llmPreDecomposeReason &&
@@ -3951,10 +4042,10 @@ export function resolveAssistantOrchestrationDecision(input) {
hasStrictDeepInvestigationCue(repairedRawUserMessage) ||
hasStrictDeepInvestigationCue(effectiveAddressUserMessage) ||
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
const strictDeepInvestigationBypassAllowed = shouldBypassStrictDeepInvestigationCueForAddressIntent(intentResolution.intent) ||
const strictDeepInvestigationBypassAllowed = shouldBypassStrictDeepInvestigationCueForAddressIntent(resolvedIntentResolution.intent) ||
shouldBypassStrictDeepInvestigationCueForAddressIntent(llmContractIntent);
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal) &&
(!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed);
@@ -3995,8 +4086,8 @@ export function resolveAssistantOrchestrationDecision(input) {
!capabilityMetaQuery &&
!dataRetrievalSignal &&
!effectiveAddressFollowupSignal &&
modeDetection.mode === "unsupported" &&
intentResolution.intent === "unknown");
resolvedModeDetection.mode === "unsupported" &&
resolvedIntentResolution.intent === "unknown");
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
deterministicNonDomainGuard &&
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
@@ -4016,10 +4107,10 @@ export function resolveAssistantOrchestrationDecision(input) {
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: "data_scope",
address_mode: modeDetection.mode,
address_mode_confidence: modeDetection.confidence,
address_intent: intentResolution.intent,
address_intent_confidence: intentResolution.confidence,
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),
@@ -4044,10 +4135,10 @@ export function resolveAssistantOrchestrationDecision(input) {
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: "capability",
address_mode: modeDetection.mode,
address_mode_confidence: modeDetection.confidence,
address_intent: intentResolution.intent,
address_intent_confidence: intentResolution.confidence,
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),
@@ -4072,10 +4163,10 @@ export function resolveAssistantOrchestrationDecision(input) {
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: "non_domain",
address_mode: modeDetection.mode,
address_mode_confidence: modeDetection.confidence,
address_intent: intentResolution.intent,
address_intent_confidence: intentResolution.confidence,
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),
@@ -4111,7 +4202,7 @@ export function resolveAssistantOrchestrationDecision(input) {
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
const supportedAddressIntentDetected = (!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed) &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal);
const semanticGuardHints = semanticExtractionContract?.guard_hints &&
@@ -4131,7 +4222,7 @@ export function resolveAssistantOrchestrationDecision(input) {
semanticAggregateShapeDetected ||
semanticDeepInvestigationHintDetected ||
!semanticApplyCanonicalRecommended));
const unsupportedIntentOrMode = (modeDetection.mode !== "address_query" && intentResolution.intent === "unknown") ||
const unsupportedIntentOrMode = (resolvedModeDetection.mode !== "address_query" && resolvedIntentResolution.intent === "unknown") ||
llmContractMode === "unsupported";
const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
@@ -4251,10 +4342,10 @@ export function resolveAssistantOrchestrationDecision(input) {
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: null,
address_mode: modeDetection.mode,
address_mode_confidence: modeDetection.confidence,
address_intent: intentResolution.intent,
address_intent_confidence: intentResolution.confidence,
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,
semantic_contract_valid: semanticContractValid,
@@ -14,6 +14,7 @@ import type {
NormalizedFragmentV2,
NormalizedFragmentV2_0_1,
NormalizedFragmentV2_0_2,
NormalizedFragmentSemanticHints,
NormalizedPayload,
NormalizedQueryV1,
NormalizedQueryV2,
@@ -352,6 +353,90 @@ function coerceFlags(
};
}
function inferSemanticHints(
rawText: string,
timeScope: NormalizedFragmentV2["time_scope"]
): NormalizedFragmentSemanticHints {
return {
scope_target_kind: "none",
scope_target_text: null,
date_scope_kind: timeScope.type === "explicit" ? "explicit" : "missing",
self_scope_detected: false,
selected_object_scope_detected: /(?:по\s+выбранному\s+объекту|selected\s+object)/iu.test(String(rawText ?? ""))
};
}
function coerceSemanticScopeTargetKind(value: unknown): NormalizedFragmentSemanticHints["scope_target_kind"] {
const token = normalizeToken(value);
if (
token === "none" ||
token === "self_scope" ||
token === "selected_object" ||
token === "organization" ||
token === "warehouse" ||
token === "counterparty" ||
token === "contract" ||
token === "item"
) {
return token;
}
if (["organization_scope", "company_scope", "org_scope", "company", "organization_anchor"].includes(token)) {
return "organization";
}
if (["warehouse_scope", "stock_scope", "warehouse_anchor"].includes(token)) {
return "warehouse";
}
if (["own_company_scope", "implicit_self_scope", "our_scope"].includes(token)) {
return "self_scope";
}
if (["selected_object_scope", "selected_object_anchor"].includes(token)) {
return "selected_object";
}
return "none";
}
function coerceSemanticDateScopeKind(value: unknown): NormalizedFragmentSemanticHints["date_scope_kind"] {
const token = normalizeToken(value);
if (token === "explicit" || token === "implicit_current" || token === "missing") {
return token;
}
if (["implicit_current_snapshot", "current", "today", "default_current"].includes(token)) {
return "implicit_current";
}
return "missing";
}
function coerceSemanticHints(
value: unknown,
rawText: string,
timeScope: NormalizedFragmentV2["time_scope"]
): NormalizedFragmentSemanticHints {
const fallback = inferSemanticHints(rawText, timeScope);
if (!value || typeof value !== "object") {
return fallback;
}
const source = value as Record<string, unknown>;
return {
scope_target_kind: coerceSemanticScopeTargetKind(source.scope_target_kind ?? source.anchor_kind ?? source.scope_kind),
scope_target_text:
toOptionalString(
source.scope_target_text ??
source.anchor_value ??
source.organization ??
source.warehouse ??
source.counterparty ??
source.contract ??
source.item
) ?? fallback.scope_target_text,
date_scope_kind: coerceSemanticDateScopeKind(source.date_scope_kind ?? source.date_scope ?? source.time_scope_kind),
self_scope_detected: coerceBoolean(source.self_scope_detected, fallback.self_scope_detected),
selected_object_scope_detected: coerceBoolean(
source.selected_object_scope_detected,
fallback.selected_object_scope_detected
)
};
}
function mapCandidateLabel(value: string): NormalizedFragmentV2["candidate_labels"][number] | null {
const token = normalizeToken(value);
if (CANDIDATE_LABEL_VALUES.includes(token as NormalizedFragmentV2["candidate_labels"][number])) {
@@ -421,6 +506,7 @@ function coerceFragmentV2(rawFragment: unknown, index: number, userMessage: stri
const accountHints = coerceStringArray(source.account_hints);
const documentHints = coerceStringArray(source.document_hints);
const registerHints = coerceStringArray(source.register_hints);
const timeScope = coerceTimeScope(source.time_scope, rawText, base.time_scope);
return {
fragment_id: coerceFragmentId(source.fragment_id, index, base.fragment_id),
@@ -432,8 +518,9 @@ function coerceFragmentV2(rawFragment: unknown, index: number, userMessage: stri
account_hints: accountHints.length > 0 ? accountHints : base.account_hints,
document_hints: documentHints.length > 0 ? documentHints : base.document_hints,
register_hints: registerHints.length > 0 ? registerHints : base.register_hints,
time_scope: coerceTimeScope(source.time_scope, rawText, base.time_scope),
time_scope: timeScope,
flags,
semantic_hints: coerceSemanticHints(source.semantic_hints, rawText, timeScope),
candidate_labels: coerceCandidateLabels(source.candidate_labels, flags, domainRelevance, base.candidate_labels),
confidence: coerceConfidence(source.confidence, base.confidence)
};
@@ -923,6 +1010,7 @@ function buildFragmentV2(rawText: string, index: number): NormalizedFragmentV2 |
} else if (flags.asks_for_exact_object_trace || flags.asks_for_ranking_or_top) {
confidence = "high";
}
const timeScope = inferTimeScope(text);
return {
fragment_id: `F${index + 1}`,
@@ -940,8 +1028,9 @@ function buildFragmentV2(rawText: string, index: number): NormalizedFragmentV2 |
account_hints: extractAccounts(text),
document_hints: Array.from(new Set(Array.from(lower.matchAll(/(документ|реализац|поступлен|платеж|выписк|акт сверк)/g)).map((item) => item[0]))),
register_hints: Array.from(new Set(Array.from(lower.matchAll(/(регистр|движен|остатк|сальдо)/g)).map((item) => item[0]))),
time_scope: inferTimeScope(text),
time_scope: timeScope,
flags,
semantic_hints: inferSemanticHints(text, timeScope),
candidate_labels: candidateLabels,
confidence
};