mirror of https://github.com/ghostfolio/ghostfolio
6 changed files with 448 additions and 2 deletions
@ -0,0 +1,136 @@ |
|||||
|
import { |
||||
|
MCP_MAX_ACTIVITIES, |
||||
|
SEARCH_QUERY_MAXIMUM_LENGTH |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { Type as ActivityType } from '@prisma/client'; |
||||
|
import { z } from 'zod'; |
||||
|
|
||||
|
import { ACTIVITY_ROWS_OUTPUT } from './ai.schemas'; |
||||
|
import { ActivityRow } from './types/activity-row.type'; |
||||
|
|
||||
|
function createRow(row: Partial<ActivityRow> = {}): ActivityRow { |
||||
|
return { |
||||
|
accountName: 'Brokerage', |
||||
|
currency: 'USD', |
||||
|
date: '2026-01-15', |
||||
|
fee: 1, |
||||
|
isin: 'US0378331005', |
||||
|
name: 'Apple Inc.', |
||||
|
quantity: 10, |
||||
|
symbol: 'AAPL', |
||||
|
type: ActivityType.BUY, |
||||
|
unitPrice: 230, |
||||
|
...row |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
describe('ACTIVITY_ROWS_OUTPUT', () => { |
||||
|
it('Accepts a row with null for the values which the file does not show', () => { |
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
rows: [ |
||||
|
createRow({ |
||||
|
accountName: null, |
||||
|
currency: null, |
||||
|
isin: null, |
||||
|
symbol: null |
||||
|
}) |
||||
|
] |
||||
|
}).success |
||||
|
).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('Refuses a row with a value which is left out instead of null', () => { |
||||
|
const row: Partial<ActivityRow> = createRow(); |
||||
|
|
||||
|
delete row.isin; |
||||
|
|
||||
|
expect(ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [row] }).success).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('Refuses a currency which is not an ISO 4217 code in upper case', () => { |
||||
|
for (const currency of ['usd', 'USDOLLAR', '$']) { |
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [createRow({ currency })] }) |
||||
|
.success |
||||
|
).toBe(false); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('Refuses a date which is not an ISO 8601 date after 1970', () => { |
||||
|
for (const date of ['15.01.2026', '1969-12-31']) { |
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [createRow({ date })] }).success |
||||
|
).toBe(false); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('Refuses a negative fee, quantity or unit price', () => { |
||||
|
for (const row of [{ fee: -1 }, { quantity: -1 }, { unitPrice: -1 }]) { |
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [createRow(row)] }).success |
||||
|
).toBe(false); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('Refuses a printed text which is longer than the maximum length', () => { |
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
rows: [createRow({ name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH) })] |
||||
|
}).success |
||||
|
).toBe(true); |
||||
|
|
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
rows: [createRow({ name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1) })] |
||||
|
}).success |
||||
|
).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('Trims a printed text', () => { |
||||
|
const { data } = ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
rows: [createRow({ name: ' Apple Inc. ' })] |
||||
|
}); |
||||
|
|
||||
|
expect(data.rows[0].name).toBe('Apple Inc.'); |
||||
|
}); |
||||
|
|
||||
|
it(`Refuses more than ${MCP_MAX_ACTIVITIES} rows`, () => { |
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
rows: Array.from({ length: MCP_MAX_ACTIVITIES }, () => { |
||||
|
return createRow(); |
||||
|
}) |
||||
|
}).success |
||||
|
).toBe(true); |
||||
|
|
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
rows: Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => { |
||||
|
return createRow(); |
||||
|
}) |
||||
|
}).success |
||||
|
).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('Gives a JSON schema which the strict mode of the structured outputs supports', () => { |
||||
|
// The options of the conversion in the AI SDK
|
||||
|
const jsonSchema = z.toJSONSchema(ACTIVITY_ROWS_OUTPUT, { |
||||
|
io: 'input', |
||||
|
target: 'draft-7' |
||||
|
}); |
||||
|
|
||||
|
const rowJsonSchema = ( |
||||
|
jsonSchema.properties.rows as { |
||||
|
items: { properties: object; required: string[] }; |
||||
|
} |
||||
|
).items; |
||||
|
|
||||
|
expect([...rowJsonSchema.required].sort()).toEqual( |
||||
|
Object.keys(rowJsonSchema.properties).sort() |
||||
|
); |
||||
|
|
||||
|
expect(JSON.stringify(jsonSchema)).not.toMatch(/"(max|min)Length"/); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,59 @@ |
|||||
|
import { |
||||
|
MCP_MAX_ACTIVITIES, |
||||
|
SEARCH_QUERY_MAXIMUM_LENGTH |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { |
||||
|
isValidCurrencyCode, |
||||
|
isValidDateAfter1970 |
||||
|
} from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Type as ActivityType } from '@prisma/client'; |
||||
|
import { z } from 'zod'; |
||||
|
|
||||
|
// The strict mode of the structured outputs requires every property, hence a
|
||||
|
// value which the file does not show is null instead of being left out. The
|
||||
|
// maximum length is checked by a refinement, because the strict mode does not
|
||||
|
// support every keyword of JSON Schema
|
||||
|
const PRINTED_TEXT = z |
||||
|
.string() |
||||
|
.trim() |
||||
|
.refine( |
||||
|
(value) => { |
||||
|
return value.length <= SEARCH_QUERY_MAXIMUM_LENGTH; |
||||
|
}, |
||||
|
{ error: `Too long: expected at most ${SEARCH_QUERY_MAXIMUM_LENGTH}` } |
||||
|
) |
||||
|
.nullable(); |
||||
|
|
||||
|
export const ACTIVITY_ROWS_OUTPUT = z.object({ |
||||
|
rows: z |
||||
|
.array( |
||||
|
z.object({ |
||||
|
accountName: PRINTED_TEXT.describe( |
||||
|
'The name or the number of the account, as printed' |
||||
|
), |
||||
|
currency: z |
||||
|
.string() |
||||
|
.refine(isValidCurrencyCode) |
||||
|
.nullable() |
||||
|
.describe( |
||||
|
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' |
||||
|
), |
||||
|
date: z |
||||
|
.string() |
||||
|
.refine(isValidDateAfter1970) |
||||
|
.describe('The date of the activity, as an ISO 8601 date'), |
||||
|
fee: z.number().min(0).describe('The fee of the activity'), |
||||
|
isin: PRINTED_TEXT.describe('The ISIN of the asset, as printed'), |
||||
|
name: PRINTED_TEXT.describe('The name of the asset, as printed'), |
||||
|
quantity: z.number().min(0).describe('The quantity of the activity'), |
||||
|
symbol: PRINTED_TEXT.describe( |
||||
|
'The ticker symbol of the asset, as printed' |
||||
|
), |
||||
|
type: z.enum(ActivityType).describe('The type of the activity'), |
||||
|
unitPrice: z.number().min(0).describe('The unit price of the activity') |
||||
|
}) |
||||
|
) |
||||
|
.max(MCP_MAX_ACTIVITIES) |
||||
|
.describe(`The activities of the file, at most ${MCP_MAX_ACTIVITIES}`) |
||||
|
}); |
||||
@ -0,0 +1,180 @@ |
|||||
|
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_VISION |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; |
||||
|
import { Type as ActivityType } from '@prisma/client'; |
||||
|
import { generateText } from 'ai'; |
||||
|
|
||||
|
import { ACTIVITY_ROWS_OUTPUT } from './ai.schemas'; |
||||
|
import { AiService } from './ai.service'; |
||||
|
import { ActivityRow } from './types/activity-row.type'; |
||||
|
|
||||
|
// The packages are ECMAScript modules, which Jest cannot load
|
||||
|
jest.mock('@openrouter/ai-sdk-provider', () => { |
||||
|
return { createOpenRouter: jest.fn() }; |
||||
|
}); |
||||
|
jest.mock('ai', () => { |
||||
|
return { |
||||
|
generateText: jest.fn(), |
||||
|
Output: { |
||||
|
object: jest.fn((options: unknown) => { |
||||
|
return options; |
||||
|
}) |
||||
|
} |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('AiService', () => { |
||||
|
const abortSignal = new AbortController().signal; |
||||
|
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 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
let aiService: AiService; |
||||
|
let chat: jest.Mock; |
||||
|
let properties: Record<string, string>; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
jest.clearAllMocks(); |
||||
|
|
||||
|
chat = jest.fn().mockReturnValue(model); |
||||
|
|
||||
|
jest |
||||
|
.mocked(createOpenRouter) |
||||
|
.mockReturnValue({ chat } as unknown as ReturnType< |
||||
|
typeof createOpenRouter |
||||
|
>); |
||||
|
|
||||
|
jest.mocked(generateText).mockResolvedValue({ |
||||
|
output: { rows } |
||||
|
} as unknown as Awaited<ReturnType<typeof generateText>>); |
||||
|
|
||||
|
properties = { |
||||
|
[PROPERTY_API_KEY_OPENROUTER]: 'api-key', |
||||
|
[PROPERTY_OPENROUTER_MODEL]: 'general-model', |
||||
|
[PROPERTY_OPENROUTER_MODEL_VISION]: 'vision-model' |
||||
|
}; |
||||
|
|
||||
|
const propertyService = { |
||||
|
getByKey: jest.fn().mockImplementation((key: string) => { |
||||
|
return Promise.resolve(properties[key]); |
||||
|
}) |
||||
|
} as unknown as PropertyService; |
||||
|
|
||||
|
aiService = new AiService( |
||||
|
{} as ConfigurationService, |
||||
|
{} as PortfolioTableService, |
||||
|
propertyService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('getActivityRowsFromFile', () => { |
||||
|
it('Passes a PDF file as a file part to the vision model and gives the rows', async () => { |
||||
|
const buffer = Buffer.from('%PDF-1.7'); |
||||
|
|
||||
|
await expect( |
||||
|
aiService.getActivityRowsFromFile({ |
||||
|
abortSignal, |
||||
|
file: { buffer, mediaType: 'application/pdf' }, |
||||
|
locale: 'de-CH' |
||||
|
}) |
||||
|
).resolves.toEqual(rows); |
||||
|
|
||||
|
expect(createOpenRouter).toHaveBeenCalledWith({ apiKey: 'api-key' }); |
||||
|
|
||||
|
expect(generateText).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ |
||||
|
abortSignal, |
||||
|
messages: [ |
||||
|
{ |
||||
|
content: [ |
||||
|
{ data: buffer, mediaType: 'application/pdf', type: 'file' } |
||||
|
], |
||||
|
role: 'user' |
||||
|
} |
||||
|
], |
||||
|
model, |
||||
|
output: { schema: ACTIVITY_ROWS_OUTPUT } |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('Passes a CSV file as text', async () => { |
||||
|
const csv = 'Date;Type;ISIN\n15.01.2026;Kauf;US0378331005'; |
||||
|
|
||||
|
await aiService.getActivityRowsFromFile({ |
||||
|
file: { buffer: Buffer.from(csv), mediaType: 'text/csv' }, |
||||
|
locale: 'de-CH' |
||||
|
}); |
||||
|
|
||||
|
expect(generateText).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ |
||||
|
messages: [{ content: [{ text: csv, type: 'text' }], role: 'user' }] |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('Restricts the routing to endpoints with zero data retention and passes a PDF file to the model itself', async () => { |
||||
|
await aiService.getActivityRowsFromFile({ |
||||
|
file: { buffer: Buffer.from('%PDF-1.7'), mediaType: 'application/pdf' }, |
||||
|
locale: 'de-CH' |
||||
|
}); |
||||
|
|
||||
|
expect(chat).toHaveBeenCalledWith('vision-model', { |
||||
|
plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }], |
||||
|
provider: { zdr: true } |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('Falls back to the general model if no vision model is set', async () => { |
||||
|
delete properties[PROPERTY_OPENROUTER_MODEL_VISION]; |
||||
|
|
||||
|
await aiService.getActivityRowsFromFile({ |
||||
|
file: { buffer: Buffer.from('%PDF-1.7'), mediaType: 'application/pdf' }, |
||||
|
locale: 'de-CH' |
||||
|
}); |
||||
|
|
||||
|
expect(chat).toHaveBeenCalledWith('general-model', expect.any(Object)); |
||||
|
}); |
||||
|
|
||||
|
it('Gives the locale and the date of today in the instructions', async () => { |
||||
|
jest.useFakeTimers({ now: new Date('2026-10-11T12:00:00Z') }); |
||||
|
|
||||
|
try { |
||||
|
await aiService.getActivityRowsFromFile({ |
||||
|
file: { |
||||
|
buffer: Buffer.from('%PDF-1.7'), |
||||
|
mediaType: 'application/pdf' |
||||
|
}, |
||||
|
locale: 'de-CH' |
||||
|
}); |
||||
|
} finally { |
||||
|
jest.useRealTimers(); |
||||
|
} |
||||
|
|
||||
|
const { instructions } = jest.mocked(generateText).mock.calls[0][0]; |
||||
|
|
||||
|
expect(instructions).toContain('the locale de-CH'); |
||||
|
expect(instructions).toContain('Today is 2026-10-11'); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,5 @@ |
|||||
|
import { z } from 'zod'; |
||||
|
|
||||
|
import { ACTIVITY_ROWS_OUTPUT } from '../ai.schemas'; |
||||
|
|
||||
|
export type ActivityRow = z.infer<typeof ACTIVITY_ROWS_OUTPUT>['rows'][number]; |
||||
Loading…
Reference in new issue