АРЧ АП11 - Вынести политику оркестрационной маршрутизации из assistantService в отдельный модуль
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAssistantLivingModePolicy } from "../src/services/assistantLivingModePolicy";
|
||||
|
||||
function buildPolicy() {
|
||||
return createAssistantLivingModePolicy({
|
||||
featureAssistantLivingChatRouterV1: true,
|
||||
compactWhitespace: (text: string) => text.replace(/\s+/g, " ").trim(),
|
||||
repairAddressMojibake: (text: string) => text,
|
||||
toNonEmptyString: (value: unknown) => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
},
|
||||
normalizeOrganizationScopeValue: (value: unknown) => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim().replace(/^"+|"+$/g, "").replace(/^'+|'+$/g, "");
|
||||
return text.length > 0 ? text : null;
|
||||
},
|
||||
hasReferentialPointer: (text: string) =>
|
||||
/(по этому|по тому|это же|этой|этим|этому|этого|этот|эту|этом|это|эти|этих|из этого|из них|из этих|из тех|в этом|тот же|same thing|that one|po etomu|po tomu)/i.test(
|
||||
text.toLowerCase()
|
||||
),
|
||||
hasSmallTalkSignal: (text: string) => /(привет|как дела|спасибо|благодарю|thanks|thank you|hello|hi)\b/i.test(text.toLowerCase()),
|
||||
hasAssistantCapabilityQuestionSignal: (text: string) =>
|
||||
/(?:кто ты|что ты можешь|какие фичи|полный список возможностей|чем ты можешь помочь|что ты умеешь)/i.test(text),
|
||||
hasOperationalAdminActionRequestSignal: (text: string) =>
|
||||
/(?:настро|установ|подключ|обнов|почин|исправ|удал|снеси|delete\s+database|drop\s+database)/i.test(text)
|
||||
});
|
||||
}
|
||||
|
||||
describe("assistantLivingModePolicy", () => {
|
||||
it("routes data-scope question to chat mode", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
const decision = policy.resolveLivingAssistantModeDecision({
|
||||
userMessage: "по какой компании мы можем работать?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_data_scope_query_detected");
|
||||
});
|
||||
|
||||
it("keeps explicit accounting question in deep mode", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
const decision = policy.resolveLivingAssistantModeDecision({
|
||||
userMessage: "покажи документы по сверке за 2020",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
|
||||
expect(decision.mode).toBe("deep_analysis");
|
||||
expect(decision.reason).toBe("strong_data_signal_detected");
|
||||
});
|
||||
|
||||
it("detects organization fact follow-up after prior boundary reply", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
const detected = policy.hasOrganizationFactFollowupSignal("давай", [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
living_chat_response_source: "deterministic_organization_fact_boundary",
|
||||
living_chat_grounding_guard_reason: null
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
expect(detected).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
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,
|
||||
hasAssistantDataScopeMetaQuestionSignal: (text: string) => /по какой компании|какая база|по каким конторам/i.test(text),
|
||||
shouldHandleAsAssistantCapabilityMetaQuery: (text: string) => /что ты можешь|что ты умеешь/i.test(text),
|
||||
hasDataRetrievalRequestSignal: () => 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,
|
||||
hasHistoricalCapabilityFollowupSignal: () => false,
|
||||
isGroundedInventoryContextDebug: (debug: unknown) => Boolean(debug),
|
||||
hasConversationMemoryRecallFollowupSignal: () => false,
|
||||
findLastAddressAssistantItem: () => null,
|
||||
hasMetaAnswerFollowupSignal: () => false,
|
||||
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" },
|
||||
...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");
|
||||
});
|
||||
|
||||
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({
|
||||
hasConversationMemoryRecallFollowupSignal: () => 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user