mirror of https://github.com/ghostfolio/ghostfolio
14 changed files with 1249 additions and 44 deletions
@ -0,0 +1,272 @@ |
|||
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' |
|||
); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue