NODEDC_1C/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts

410 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from "vitest";
import { createAssistantRoutePolicy } from "../src/services/assistantRoutePolicy";
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function normalizeOrganizationScopeValue(value: unknown): string | null {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
return text.replace(/^"+|"+$/g, "").replace(/^'+|'+$/g, "");
}
function buildPolicy(overrides: Record<string, unknown> = {}) {
return createAssistantRoutePolicy({
repairAddressMojibake: (text: string) => text,
findLastGroundedAddressAnswerDebug: () => null,
findLastOrganizationClarificationAddressDebug: () => null,
mergeKnownOrganizations: (values: unknown[]) =>
Array.from(
new Set(
(Array.isArray(values) ? values : [])
.map((item) => normalizeOrganizationScopeValue(item))
.filter((item): item is string => Boolean(item))
)
),
normalizeOrganizationScopeValue,
resolveOrganizationSelectionFromMessage: () => null,
resolveMetaSignalSet: (input: {
rawUserMessage?: string;
repairedRawUserMessage?: string;
effectiveAddressUserMessage?: string;
repairedEffectiveAddressUserMessage?: string;
}) => {
const samples = [
input.rawUserMessage,
input.repairedRawUserMessage,
input.effectiveAddressUserMessage,
input.repairedEffectiveAddressUserMessage
].join(" ");
return {
dataScopeMetaQuery: /по какой компании|какая база|по каким конторам/i.test(samples),
capabilityMetaQuery: /что СС РјРѕР¶РµС€СЊ|что СС СѓРјРµРµС€СЊ/i.test(samples),
metaAnswerFollowupSignal: /это норм|что думаешь/i.test(samples)
};
},
resolveHardMetaMode: (input: {
dataScopeMetaQuery?: boolean;
capabilityMetaQuery?: boolean;
dataRetrievalSignal?: boolean;
}) =>
input.dataScopeMetaQuery
? "data_scope"
: input.capabilityMetaQuery && !input.dataRetrievalSignal
? "capability"
: null,
isMetaFollowupOverGroundedAnswer: () => false,
hasDataRetrievalRequestSignal: () => false,
hasOrganizationFactLookupSignal: () => false,
hasOrganizationFactFollowupSignal: () => false,
hasAggregateBusinessAnalyticsSignal: () => false,
hasStandaloneAddressTopicSignal: () => false,
hasOpenContractsAddressSignal: () => false,
detectAddressQuestionMode: () => ({ mode: "unsupported", confidence: "low" }),
resolveAddressIntent: () => ({ intent: "unknown", confidence: "low" }),
toNonEmptyString,
hasStrictDeepInvestigationCue: () => false,
hasStrongDataIntentSignal: () => false,
hasAccountingSignal: () => false,
hasDangerOrCoercionSignal: () => false,
hasAddressFollowupContextSignal: () => false,
hasShortDebtMirrorFollowupSignal: () => false,
isInventorySelectedObjectIntent: (intent: unknown) => /inventory/i.test(String(intent ?? "")),
hasShortInventoryObjectFollowupSignal: () => false,
resolveRouteMemorySignals: () => ({
contextualHistoricalCapabilityFollowupDetected: false,
contextualMemoryRecapFollowupDetected: false
}),
findLastAddressAssistantItem: () => null,
resolveAddressToolGateDecision: () => ({
runAddressLane: false,
decision: "skip_address_lane",
reason: "no_address_signal_after_l0"
}),
hasSameDateAccountFollowupSignalForPredecompose: () => false,
hasLooseAllTimeAddressLookupSignal: () => false,
hasDeepAnalysisPreferenceSignal: () => false,
hasDirectDeepAnalysisSignal: () => false,
compactWhitespace: (text: string) => String(text ?? "").replace(/\s+/g, " ").trim(),
hasDeepSessionContinuationSignal: () => false,
resolveLivingAssistantModeDecision: (input: { addressLaneTriggered?: boolean }) =>
input.addressLaneTriggered
? { mode: "address_data", reason: "address_lane_triggered" }
: { mode: "chat", reason: "living_chat_signal_detected" },
resolveProviderExecutionState: (input: { useMock?: unknown; llmPreDecomposeReason?: unknown }) => {
const reason = String(input?.llmPreDecomposeReason ?? "");
return {
provider_mode: Boolean(input?.useMock) ? "mock" : "unknown",
normalized_provider: null,
use_mock: Boolean(input?.useMock),
llm_runtime_unavailable_detected: /missing api key|authentication|api key is missing/i.test(reason),
living_mode_forced_deep: Boolean(input?.useMock),
living_mode_forced_reason: Boolean(input?.useMock) ? "mock_mode_keeps_deep_pipeline" : null
};
},
...overrides
});
}
describe("assistantRoutePolicy", () => {
it("routes data-scope meta question to chat contract", () => {
const policy = buildPolicy();
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "РїРѕ какой компании РјС РјРѕР¶РµРј работать?",
effectiveAddressUserMessage: "РїРѕ какой компании РјС РјРѕР¶РµРј работать?",
followupContext: null,
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("assistant_data_scope_query_detected");
expect(decision.livingMode).toBe("chat");
expect(decision.orchestrationContract?.hard_meta_mode).toBe("data_scope");
expect(decision.orchestrationContract?.provider_execution?.provider_mode).toBe("unknown");
});
it("keeps supported address intent in address lane", () => {
const policy = buildPolicy({
detectAddressQuestionMode: () => ({ mode: "address_query", confidence: "high" }),
resolveAddressIntent: () => ({ intent: "inventory_on_hand_as_of_date", confidence: "high" }),
resolveAddressToolGateDecision: () => ({
runAddressLane: true,
decision: "run_address_lane",
reason: "address_mode_classifier_detected"
})
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "какие товары сейчас лежат на складе",
effectiveAddressUserMessage: "какие товары сейчас лежат на складе",
followupContext: null,
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
expect(decision.livingMode).toBe("address_data");
expect(decision.orchestrationContract?.semantic_route_arbitration?.supported_address_intent_detected).toBe(true);
});
it("does not let deep session continuation override an exact VAT period route", () => {
const policy = buildPolicy({
detectAddressQuestionMode: () => ({ mode: "address_query", confidence: "high" }),
resolveAddressIntent: () => ({ intent: "vat_liability_confirmed_for_tax_period", confidence: "high" }),
resolveAddressToolGateDecision: () => ({
runAddressLane: true,
decision: "run_address_lane",
reason: "address_mode_classifier_detected"
}),
hasDeepSessionContinuationSignal: () => true
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "Р° какой НДС РјС РґРѕР»Р¶РЅС‹ сгрузить РЅР° март 2020?",
effectiveAddressUserMessage: "Р° какой НДС РјС РґРѕР»Р¶РЅС‹ сгрузить РЅР° март 2020?",
followupContext: null,
llmPreDecomposeMeta: {
applied: true,
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "address_query",
mode_confidence: "high",
intent: "vat_liability_confirmed_for_tax_period",
intent_confidence: "high"
},
semanticExtractionContract: {
valid: true,
apply_canonical_recommended: true,
extraction: {
query_shape: "UNKNOWN",
aggregation_profile: "unknown"
},
guard_hints: {
deep_investigation_signal_detected: false
}
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
expect(decision.livingMode).toBe("address_data");
expect(decision.orchestrationContract?.deep_session_continuation_fallback_to_deep).toBe(false);
});
it("routes memory recap follow-up over grounded answer to chat", () => {
const policy = buildPolicy({
resolveRouteMemorySignals: () => ({
contextualHistoricalCapabilityFollowupDetected: false,
contextualMemoryRecapFollowupDetected: true
}),
findLastGroundedAddressAnswerDebug: () => ({ execution_lane: "address_query" })
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "Р° СС РїРѕРјРЅРёС€СЊ что РјС РѕР±СЃСѓР¶РґР°Р»Рё?",
effectiveAddressUserMessage: "Р° СС РїРѕРјРЅРёС€СЊ что РјС РѕР±СЃСѓР¶РґР°Р»Рё?",
followupContext: null,
llmPreDecomposeMeta: {
applied: false,
reason: "normalized_fragment_rejected_semantic_guard",
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
}
},
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("memory_recap_followup_detected");
expect(decision.livingMode).toBe("chat");
expect(decision.livingReason).toBe("memory_recap_followup_detected");
});
it("routes organization fact lookup away from address lane even with follow-up context", () => {
const policy = buildPolicy({
hasDataRetrievalRequestSignal: () => true,
hasStrongDataIntentSignal: () => true,
detectAddressQuestionMode: () => ({ mode: "address_query", confidence: "high" }),
resolveAddressIntent: () => ({ intent: "inventory_on_hand_as_of_date", confidence: "low" }),
hasOrganizationFactLookupSignal: (text: unknown) =>
/возраст.*альтернатива плюс/i.test(String(text ?? "")),
resolveAddressToolGateDecision: () => ({
runAddressLane: true,
decision: "run_address_lane",
reason: "address_mode_classifier_detected"
})
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "а какой возраст у Альтернативы Плюс?",
effectiveAddressUserMessage: "Какой возраст у контрагента Альтернатива Плюс?",
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
organization: "ООО \"Альтернатива Плюс\"",
as_of_date: "2021-03-31"
},
previous_anchor_type: "item",
previous_anchor_value: "Модуль прямоугольный 1400*110*750"
},
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("organization_fact_lookup_signal_detected");
expect(decision.livingMode).toBe("chat");
expect(decision.livingReason).toBe("organization_fact_lookup_signal_detected");
});
it("routes explicit recap wording with selected-object phrasing to chat even when address-like cues exist", () => {
const policy = buildPolicy({
hasStrongDataIntentSignal: () => true,
hasDataRetrievalRequestSignal: () => true,
resolveRouteMemorySignals: () => ({
contextualHistoricalCapabilityFollowupDetected: false,
contextualMemoryRecapFollowupDetected: true
}),
findLastGroundedAddressAnswerDebug: () => ({
execution_lane: "address_query",
detected_intent: "inventory_purchase_provenance_for_item"
})
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "а ты помнишь, что мы по этой позиции уже выяснили?",
effectiveAddressUserMessage: "а ты помнишь, что мы по этой позиции уже выяснили?",
followupContext: null,
llmPreDecomposeMeta: {
applied: false,
reason: "normalized_fragment_rejected_semantic_guard",
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
}
},
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("memory_recap_followup_detected");
expect(decision.livingMode).toBe("chat");
expect(decision.livingReason).toBe("memory_recap_followup_detected");
});
it("does not force unsupported-intent fallback when predecompose runtime is unavailable", () => {
const policy = buildPolicy({
hasStrongDataIntentSignal: () => true,
hasDataRetrievalRequestSignal: () => true,
resolveAddressToolGateDecision: () => ({
runAddressLane: true,
decision: "run_address_lane",
reason: "address_mode_classifier_detected"
})
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "покажи документы по сверке",
effectiveAddressUserMessage: "покажи документы по сверке",
followupContext: { root_context_only: true },
llmPreDecomposeMeta: {
reason: "error:OpenAI API key is missing",
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
}
},
useMock: false
});
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
expect(decision.orchestrationContract?.provider_execution?.llm_runtime_unavailable_detected).toBe(true);
});
it("does not classify colloquial VAT root query as non-domain when L0 address gate is positive", () => {
const policy = buildPolicy({
hasStrongDataIntentSignal: () => true,
resolveAddressToolGateDecision: () => ({
runAddressLane: true,
decision: "run_address_lane",
reason: "address_signal_detected"
})
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "скок ндс надо заплатить в налоговую на февраль 2017",
effectiveAddressUserMessage: "скок ндс надо заплатить в налоговую на февраль 2017",
followupContext: null,
llmPreDecomposeMeta: null,
useMock: true
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateReason).toBe("address_signal_detected");
expect(decision.livingMode).toBe("address_data");
});
it("does not mark organization selection after grounded continuity as non-domain noise", () => {
const policy = buildPolicy({
findLastGroundedAddressAnswerDebug: () => null,
findLastOrganizationClarificationAddressDebug: () => ({
organization_candidates: ["Org A", "Org B"]
}),
resolveOrganizationSelectionFromMessage: (userMessage: unknown, knownOrganizations: unknown) => {
const normalized = String(userMessage ?? "").trim().toLowerCase();
const candidates = Array.isArray(knownOrganizations) ? knownOrganizations.map((item) => String(item)) : [];
return candidates.find((candidate) => candidate.toLowerCase() === normalized) ?? null;
}
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "Org A",
effectiveAddressUserMessage: "Org A",
followupContext: null,
llmPreDecomposeMeta: null,
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
answer_grounding_check: { status: "grounded" },
extracted_filters: {
organization: "Org A",
as_of_date: "2021-03-31"
},
detected_intent: "receivables_confirmed_as_of_date"
}
}
],
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("no_address_signal_after_l0");
expect(decision.livingMode).toBe("chat");
expect(decision.orchestrationContract?.hard_meta_mode).toBeNull();
expect(decision.orchestrationContract?.followup_context_detected).toBe(false);
});
});