321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
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("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");
|
||
});
|
||
});
|