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