mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
272 lines
7.6 KiB
272 lines
7.6 KiB
import { AiAgentToolName } from './ai-agent.interfaces';
|
|
import {
|
|
applyToolExecutionPolicy,
|
|
createPolicyRouteResponse,
|
|
formatPolicyVerificationDetails
|
|
} from './ai-agent.policy.utils';
|
|
|
|
describe('AiAgentPolicyUtils', () => {
|
|
it.each([
|
|
'hi',
|
|
'hello',
|
|
'hey',
|
|
'thanks',
|
|
'thank you',
|
|
'good morning',
|
|
'good afternoon',
|
|
'good evening'
|
|
])('routes greeting-like query "%s" to direct no-tool', (query) => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools: ['portfolio_analysis'],
|
|
query
|
|
});
|
|
|
|
expect(decision.route).toBe('direct');
|
|
expect(decision.blockReason).toBe('no_tool_query');
|
|
expect(decision.toolsToExecute).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
'who are you',
|
|
'what are you',
|
|
'what can you do',
|
|
'how do you work',
|
|
'how can i use this',
|
|
'help',
|
|
'assist me',
|
|
'what can you help with'
|
|
])('routes assistant capability query "%s" to direct no-tool', (query) => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools: [],
|
|
query
|
|
});
|
|
|
|
expect(decision.route).toBe('direct');
|
|
expect(decision.blockReason).toBe('no_tool_query');
|
|
expect(
|
|
createPolicyRouteResponse({ policyDecision: decision, query })
|
|
).toContain(
|
|
'Ghostfolio AI'
|
|
);
|
|
});
|
|
|
|
it.each<[string, string]>([
|
|
['2+2', '2+2 = 4'],
|
|
['what is 5 * 3', '5 * 3 = 15'],
|
|
['(2+3)*4', '(2+3)*4 = 20'],
|
|
['10 / 4', '10 / 4 = 2.5'],
|
|
['7 - 10', '7 - 10 = -3'],
|
|
['3.5 + 1.25', '3.5 + 1.25 = 4.75'],
|
|
['(8 - 2) / 3', '(8 - 2) / 3 = 2'],
|
|
['what is 3*(2+4)?', '3*(2+4) = 18'],
|
|
['2 + (3 * (4 - 1))', '2 + (3 * (4 - 1)) = 11'],
|
|
['10-3-2', '10-3-2 = 5']
|
|
])('returns arithmetic direct response for "%s"', (query, expected) => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools: [],
|
|
query
|
|
});
|
|
|
|
expect(decision.route).toBe('direct');
|
|
expect(
|
|
createPolicyRouteResponse({
|
|
policyDecision: decision,
|
|
query
|
|
})
|
|
).toBe(expected);
|
|
});
|
|
|
|
it.each(['1/0', '2+*2', '5 % 2'])(
|
|
'falls back to capability response for unsupported arithmetic expression "%s"',
|
|
(query) => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools: [],
|
|
query
|
|
});
|
|
|
|
expect(decision.route).toBe('direct');
|
|
expect(
|
|
createPolicyRouteResponse({
|
|
policyDecision: decision,
|
|
query
|
|
})
|
|
).toContain('portfolio analysis');
|
|
}
|
|
);
|
|
|
|
it('returns distinct direct no-tool responses for identity and capability prompts', () => {
|
|
const identityDecision = applyToolExecutionPolicy({
|
|
plannedTools: [],
|
|
query: 'who are you?'
|
|
});
|
|
const capabilityDecision = applyToolExecutionPolicy({
|
|
plannedTools: [],
|
|
query: 'what can you do?'
|
|
});
|
|
|
|
const identityResponse = createPolicyRouteResponse({
|
|
policyDecision: identityDecision,
|
|
query: 'who are you?'
|
|
});
|
|
const capabilityResponse = createPolicyRouteResponse({
|
|
policyDecision: capabilityDecision,
|
|
query: 'what can you do?'
|
|
});
|
|
|
|
expect(identityResponse).toContain('portfolio copilot');
|
|
expect(capabilityResponse).toContain('three modes');
|
|
expect(identityResponse).not.toBe(capabilityResponse);
|
|
});
|
|
|
|
it('routes finance read intent with empty planner output to clarify', () => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools: [],
|
|
query: 'Show portfolio risk and allocation'
|
|
});
|
|
|
|
expect(decision.route).toBe('clarify');
|
|
expect(decision.blockReason).toBe('unknown');
|
|
expect(createPolicyRouteResponse({ policyDecision: decision })).toContain(
|
|
'Which one should I run next?'
|
|
);
|
|
});
|
|
|
|
it('routes non-finance empty planner output to direct no-tool', () => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools: [],
|
|
query: 'Tell me a joke'
|
|
});
|
|
|
|
expect(decision.route).toBe('direct');
|
|
expect(decision.blockReason).toBe('no_tool_query');
|
|
});
|
|
|
|
it('deduplicates planned tools while preserving route decisions', () => {
|
|
const plannedTools: AiAgentToolName[] = [
|
|
'portfolio_analysis',
|
|
'portfolio_analysis',
|
|
'risk_assessment'
|
|
];
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools,
|
|
query: 'analyze concentration risk'
|
|
});
|
|
|
|
expect(decision.plannedTools).toEqual([
|
|
'portfolio_analysis',
|
|
'risk_assessment'
|
|
]);
|
|
expect(decision.toolsToExecute).toEqual([
|
|
'portfolio_analysis',
|
|
'risk_assessment'
|
|
]);
|
|
expect(decision.route).toBe('tools');
|
|
});
|
|
|
|
it.each<{
|
|
expectedTools: AiAgentToolName[];
|
|
plannedTools: AiAgentToolName[];
|
|
query: string;
|
|
reason: string;
|
|
route?: 'clarify' | 'direct' | 'tools';
|
|
}>([
|
|
{
|
|
expectedTools: ['portfolio_analysis', 'risk_assessment'] as AiAgentToolName[],
|
|
plannedTools: [
|
|
'portfolio_analysis',
|
|
'risk_assessment',
|
|
'rebalance_plan'
|
|
] as AiAgentToolName[],
|
|
query: 'review portfolio concentration risk',
|
|
reason: 'read-only intent strips rebalance'
|
|
},
|
|
{
|
|
expectedTools: [
|
|
'portfolio_analysis',
|
|
'risk_assessment',
|
|
'rebalance_plan'
|
|
] as AiAgentToolName[],
|
|
plannedTools: [
|
|
'portfolio_analysis',
|
|
'risk_assessment',
|
|
'rebalance_plan'
|
|
] as AiAgentToolName[],
|
|
query: 'invest 2000 and rebalance',
|
|
reason: 'action intent preserves rebalance'
|
|
},
|
|
{
|
|
expectedTools: [
|
|
'portfolio_analysis',
|
|
'risk_assessment',
|
|
'rebalance_plan',
|
|
'market_data_lookup'
|
|
] as AiAgentToolName[],
|
|
plannedTools: [
|
|
'portfolio_analysis',
|
|
'risk_assessment',
|
|
'rebalance_plan',
|
|
'market_data_lookup'
|
|
] as AiAgentToolName[],
|
|
query: 'invest and rebalance after checking market quote for NVDA',
|
|
reason: 'action + market intent keeps all planned tools'
|
|
},
|
|
{
|
|
expectedTools: ['stress_test'] as AiAgentToolName[],
|
|
plannedTools: ['stress_test'] as AiAgentToolName[],
|
|
query: 'run stress scenario read-only',
|
|
reason: 'read-only stress execution stays allowed'
|
|
}
|
|
])(
|
|
'applies policy gating: $reason',
|
|
({ expectedTools, plannedTools, query, route }) => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools,
|
|
query
|
|
});
|
|
|
|
if (route) {
|
|
expect(decision.route).toBe(route);
|
|
} else {
|
|
expect(decision.route).toBe('tools');
|
|
}
|
|
|
|
expect(decision.toolsToExecute).toEqual(expectedTools);
|
|
}
|
|
);
|
|
|
|
it('marks rebalance-only no-action prompts as clarify with needs_confirmation', () => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools: ['rebalance_plan'],
|
|
query: 'review concentration profile'
|
|
});
|
|
|
|
expect(decision.route).toBe('clarify');
|
|
expect(decision.blockReason).toBe('needs_confirmation');
|
|
expect(decision.blockedByPolicy).toBe(true);
|
|
expect(decision.toolsToExecute).toEqual([]);
|
|
});
|
|
|
|
it('formats policy verification details with planned and executed tools', () => {
|
|
const decision = applyToolExecutionPolicy({
|
|
plannedTools: [
|
|
'portfolio_analysis',
|
|
'risk_assessment',
|
|
'rebalance_plan'
|
|
],
|
|
query: 'review concentration risk'
|
|
});
|
|
const details = formatPolicyVerificationDetails({
|
|
policyDecision: decision
|
|
});
|
|
|
|
expect(details).toContain('route=tools');
|
|
expect(details).toContain('blocked_by_policy=true');
|
|
expect(details).toContain('block_reason=needs_confirmation');
|
|
expect(details).toContain(
|
|
'planned_tools=portfolio_analysis, risk_assessment, rebalance_plan'
|
|
);
|
|
expect(details).toContain(
|
|
'executed_tools=portfolio_analysis, risk_assessment'
|
|
);
|
|
});
|
|
});
|
|
|