@ -1,18 +1,23 @@
import { McpService } from '@ghostfolio/api/app/endpoints/mcp/mcp.service' ;
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service' ;
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service' ;
import { PropertyService } from '@ghostfolio/api/services/property/property.service' ;
import {
PROPERTY_API_KEY_OPENROUTER ,
PROPERTY_OPENROUTER_MODEL ,
PROPERTY_OPENROUTER_MODEL_AGENT ,
PROPERTY_OPENROUTER_MODEL_VISION
} from '@ghostfolio/common/config' ;
import { createOpenRouter } from '@openrouter/ai-sdk-provider' ;
import { Type as ActivityType } from '@prisma/client' ;
import { generateText } from 'ai' ;
import { generateText , isStepCount , ToolSet } from 'ai' ;
import { z } from 'zod' ;
import { ACTIVITY_ROWS_OUTPUT } from './ai.schemas' ;
import { ACTIVITY_ROWS_OUTPUT , ROW_MATCHES_OUTPUT } from './ai.schemas' ;
import { AiService } from './ai.service' ;
import { createActivityRow } from './ai.test-utils' ;
import { AccountToMatch } from './types/account-to-match.type' ;
import { ActivityRow } from './types/activity-row.type' ;
// The packages are ECMAScript modules, which Jest cannot load
@ -22,11 +27,17 @@ jest.mock('@openrouter/ai-sdk-provider', () => {
jest . mock ( 'ai' , ( ) = > {
return {
generateText : jest.fn ( ) ,
isStepCount : jest.fn ( ( stepCount : number ) = > {
return { stepCount } ;
} ) ,
Output : {
object : jest . fn ( ( options : unknown ) = > {
return options ;
} )
}
} ,
tool : jest.fn ( ( options : unknown ) = > {
return options ;
} )
} ;
} ) ;
@ -35,22 +46,12 @@ describe('AiService', () => {
const model = { modelId : 'model' } ;
const rows : ActivityRow [ ] = [
{
accountName : null ,
currency : 'USD' ,
date : '2026-01-15' ,
fee : 1 ,
isin : 'US0378331005' ,
name : 'Apple Inc.' ,
quantity : 10 ,
symbol : null ,
type : ActivityType . BUY ,
unitPrice : 230
}
createActivityRow ( { accountName : null , symbol : null } )
] ;
let aiService : AiService ;
let chat : jest.Mock ;
let mcpService : McpService ;
let properties : Record < string , string > ;
beforeEach ( ( ) = > {
@ -71,9 +72,16 @@ describe('AiService', () => {
properties = {
[ PROPERTY_API_KEY_OPENROUTER ] : 'api-key' ,
[ PROPERTY_OPENROUTER_MODEL ] : 'general-model' ,
[ PROPERTY_OPENROUTER_MODEL_AGENT ] : 'agent-model' ,
[ PROPERTY_OPENROUTER_MODEL_VISION ] : 'vision-model'
} ;
mcpService = {
searchAssetProfiles : jest.fn ( ) . mockResolvedValue ( {
content : [ { text : '{ "assetProfiles": [] }' , type : 'text' } ]
} )
} as unknown as McpService ;
const propertyService = {
getByKey : jest.fn ( ) . mockImplementation ( ( key : string ) = > {
return Promise . resolve ( properties [ key ] ) ;
@ -82,6 +90,7 @@ describe('AiService', () => {
aiService = new AiService (
{ } as ConfigurationService ,
mcpService ,
{ } as PortfolioTableService ,
propertyService
) ;
@ -177,4 +186,329 @@ describe('AiService', () => {
expect ( instructions ) . toContain ( 'Today is 2026-10-11' ) ;
} ) ;
} ) ;
describe ( 'getActivitiesFromRows' , ( ) = > {
const accounts : AccountToMatch [ ] = [
{ currency : 'CHF' , id : 'account-id' , name : 'Brokerage' }
] ;
const userCurrency = 'EUR' ;
const userId = 'user-id' ;
function mockRowMatches ( {
accounts : accountMatches = [ ] ,
assets : assetMatches = [ ]
} : Partial < z.infer < typeof ROW_MATCHES_OUTPUT > > ) {
jest . mocked ( generateText ) . mockResolvedValueOnce ( {
output : { accounts : accountMatches , assets : assetMatches }
} as unknown as Awaited < ReturnType < typeof generateText > > ) ;
}
it ( 'Gives an activity for each row whose asset matches an asset profile' , async ( ) = > {
const row = createActivityRow ( { currency : null } ) ;
mockRowMatches ( {
accounts : [ { accountId : 'account-id' , name : 'Brokerage' } ] ,
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'AAPL' }
]
} ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( {
activities : [
{
accountId : 'account-id' ,
currency : 'USD' ,
dataSource : 'YAHOO' ,
date : row.date ,
fee : row.fee ,
quantity : row.quantity ,
symbol : 'AAPL' ,
type : ActivityType . BUY ,
unitPrice : row.unitPrice
}
] ,
rowsWithoutMatch : [ ]
} ) ;
} ) ;
it ( 'Gives the currency of the row before the currency of the asset profile' , async ( ) = > {
mockRowMatches ( {
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'AAPL' }
]
} ) ;
const { activities } = await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( { accountName : null , currency : 'CHF' } ) ]
} ) ;
expect ( activities [ 0 ] . currency ) . toBe ( 'CHF' ) ;
} ) ;
it ( 'Gives a row whose asset has no match as a row without a match' , async ( ) = > {
const row = createActivityRow ( { accountName : null } ) ;
mockRowMatches ( {
assets : [ { currency : null , dataSource : null , id : 0 , symbol : null } ]
} ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
} ) ;
it ( 'Gives a row with a data source which is not valid as a row without a match' , async ( ) = > {
const row = createActivityRow ( { accountName : null } ) ;
mockRowMatches ( {
assets : [
{ currency : 'USD' , dataSource : 'yahoo' , id : 0 , symbol : 'AAPL' }
]
} ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
} ) ;
it ( 'Ignores an account id which is not the id of an account of the user' , async ( ) = > {
mockRowMatches ( {
accounts : [ { accountId : 'unknown-account-id' , name : 'Brokerage' } ] ,
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'AAPL' }
]
} ) ;
const { activities } = await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
expect ( activities [ 0 ] . accountId ) . toBeUndefined ( ) ;
} ) ;
it ( 'Gives a fee, an interest or a liability its description as symbol without a model call' , async ( ) = > {
const rowsOfNonInvestments = [
ActivityType . FEE ,
ActivityType . INTEREST ,
ActivityType . LIABILITY
] . map ( ( type ) = > {
return createActivityRow ( {
type ,
accountName : null ,
currency : null ,
isin : null ,
name : ` Description of ${ type } ` ,
symbol : null
} ) ;
} ) ;
const { activities , rowsWithoutMatch } =
await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : rowsOfNonInvestments
} ) ;
expect ( generateText ) . not . toHaveBeenCalled ( ) ;
expect (
activities . map ( ( { currency , dataSource , symbol , type } ) = > {
return { currency , dataSource , symbol , type } ;
} )
) . toEqual (
rowsOfNonInvestments . map ( ( { name , type } ) = > {
return {
type ,
currency : userCurrency ,
dataSource : undefined ,
symbol : name
} ;
} )
) ;
expect ( rowsWithoutMatch ) . toEqual ( [ ] ) ;
} ) ;
it ( 'Gives a fee without a currency the currency of its account' , async ( ) = > {
mockRowMatches ( {
accounts : [ { accountId : 'account-id' , name : 'Brokerage' } ]
} ) ;
const { activities } = await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [
createActivityRow ( {
currency : null ,
isin : null ,
name : 'Custody fee' ,
symbol : null ,
type : ActivityType . FEE
} )
]
} ) ;
expect ( activities [ 0 ] ) . toMatchObject ( {
accountId : 'account-id' ,
currency : 'CHF'
} ) ;
} ) ;
it ( 'Gives a fee without a description as a row without a match' , async ( ) = > {
const row = createActivityRow ( {
accountName : null ,
isin : null ,
name : null ,
symbol : null ,
type : ActivityType . FEE
} ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
} ) ;
it ( 'Passes each asset one time and only the id and the name of each account to the model' , async ( ) = > {
mockRowMatches ( {
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'AAPL' }
]
} ) ;
const { activities } = await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [
createActivityRow ( ) ,
createActivityRow ( { type : ActivityType . SELL } ) ,
createActivityRow ( {
accountName : 'Depot 1' ,
type : ActivityType . DIVIDEND
} )
]
} ) ;
expect ( activities ) . toHaveLength ( 3 ) ;
const { prompt } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( JSON . parse ( prompt as string ) ) . toEqual ( {
accountNames : [ 'Brokerage' , 'Depot 1' ] ,
accounts : [ { id : 'account-id' , name : 'Brokerage' } ] ,
assets : [
{
currency : 'USD' ,
id : 0 ,
isin : 'US0378331005' ,
name : 'Apple Inc.' ,
symbol : 'AAPL'
}
]
} ) ;
} ) ;
it ( 'Searches the asset profiles with the tool of the server of the Model Context Protocol' , async ( ) = > {
mockRowMatches ( { } ) ;
await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
const { tools } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] as {
tools : ToolSet ;
} ;
await expect (
tools [ 'search-asset-profiles' ] . execute (
{ query : 'US0378331005' } ,
undefined
)
) . resolves . toBe ( '{ "assetProfiles": [] }' ) ;
expect ( mcpService . searchAssetProfiles ) . toHaveBeenCalledWith ( {
userId ,
query : 'US0378331005'
} ) ;
} ) ;
it ( 'Restricts the routing to endpoints with zero data retention and limits the number of steps' , async ( ) = > {
mockRowMatches ( { } ) ;
await aiService . getActivitiesFromRows ( {
abortSignal ,
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
expect ( chat ) . toHaveBeenCalledWith ( 'agent-model' , {
provider : { zdr : true }
} ) ;
expect ( isStepCount ) . toHaveBeenCalled ( ) ;
expect ( generateText ) . toHaveBeenCalledWith (
expect . objectContaining ( {
abortSignal ,
model ,
output : { schema : ROW_MATCHES_OUTPUT }
} )
) ;
const { stopWhen } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( stopWhen ) . toBe ( jest . mocked ( isStepCount ) . mock . results [ 0 ] . value ) ;
} ) ;
it ( 'Falls back to the general model if no agent model is set' , async ( ) = > {
delete properties [ PROPERTY_OPENROUTER_MODEL_AGENT ] ;
mockRowMatches ( { } ) ;
await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
expect ( chat ) . toHaveBeenCalledWith ( 'general-model' , expect . any ( Object ) ) ;
} ) ;
} ) ;
} ) ;