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