diff --git a/apps/api/src/app/endpoints/ai/ai.controller.spec.ts b/apps/api/src/app/endpoints/ai/ai.controller.spec.ts new file mode 100644 index 0000000000..3e4fc7abe5 --- /dev/null +++ b/apps/api/src/app/endpoints/ai/ai.controller.spec.ts @@ -0,0 +1,205 @@ +import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { + MCP_MAX_ACTIVITIES, + THROTTLE_AI_ACTIVITIES_LIMIT, + THROTTLE_DAILY_TTL +} from '@ghostfolio/common/config'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { HttpException, Logger } from '@nestjs/common'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { + THROTTLER_LIMIT, + THROTTLER_TRACKER, + THROTTLER_TTL +} from '@nestjs/throttler/dist/throttler.constants'; +import type { Response } from 'express'; +import { EventEmitter } from 'node:events'; + +import { AiController } from './ai.controller'; +import { AiService } from './ai.service'; + +// The packages are ECMAScript modules, which Jest cannot load +jest.mock('@openrouter/ai-sdk-provider', () => { + return {}; +}); +jest.mock('ai', () => { + return {}; +}); + +describe('AiController', () => { + const pdfFile = { + buffer: Buffer.from('%PDF-1.7'), + originalname: 'statement.pdf' + }; + + const user = { id: 'user-id' }; + + let aiController: AiController; + let aiService: AiService; + + function createCsvFile(dataRowCount: number) { + return { + buffer: Buffer.from( + [ + 'Date,Symbol', + ...Array.from({ length: dataRowCount }, () => { + return '2026-01-15,AAPL'; + }) + ].join('\n') + ), + originalname: 'statement.csv' + }; + } + + function createResponse() { + return Object.assign(new EventEmitter(), { + writableFinished: false + }) as unknown as Response; + } + + function getMetadataOfMethod(metadataKey: string) { + return Reflect.getMetadata( + metadataKey, + AiController.prototype.getActivitiesFromFile + ) as T; + } + + beforeEach(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + aiService = { + getActivitiesFromFile: jest + .fn() + .mockResolvedValue({ activities: [], rowsWithoutMatch: [] }) + } as unknown as AiService; + + aiController = new AiController( + aiService, + {} as ApiService, + { + user + } as unknown as RequestWithUser + ); + }); + + describe('getActivitiesFromFile', () => { + it('Requires the permission and limits the requests of each user per day', () => { + expect(getMetadataOfMethod(HAS_PERMISSION_KEY)).toBe( + permissions.importActivitiesWithAi + ); + + const guards = getMetadataOfMethod(GUARDS_METADATA); + + expect(guards.slice(1)).toEqual([ + HasPermissionGuard, + CustomThrottlerGuard + ]); + + expect(getMetadataOfMethod(`${THROTTLER_LIMIT}default`)).toBe( + THROTTLE_AI_ACTIVITIES_LIMIT + ); + + expect(getMetadataOfMethod(`${THROTTLER_TTL}default`)).toBe( + THROTTLE_DAILY_TTL + ); + + expect( + getMetadataOfMethod<(request: unknown) => string>( + `${THROTTLER_TRACKER}default` + )({ ip: '127.0.0.1', user }) + ).toBe(user.id); + }); + + it('Refuses a request without a file or with a file of another type', async () => { + for (const file of [ + undefined, + { buffer: Buffer.from('Date,Symbol'), originalname: 'statement.txt' } + ]) { + await expect( + aiController.getActivitiesFromFile(createResponse(), file) + ).rejects.toThrow(HttpException); + } + + expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); + }); + + it(`Refuses a CSV file with more than ${MCP_MAX_ACTIVITIES} rows before a model call`, async () => { + await expect( + aiController.getActivitiesFromFile( + createResponse(), + createCsvFile(MCP_MAX_ACTIVITIES + 1) + ) + ).rejects.toThrow(HttpException); + + expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); + + expect(Logger.prototype.warn).toHaveBeenCalledWith( + `Rejected a CSV file with ${MCP_MAX_ACTIVITIES + 1} rows` + ); + }); + + it(`Accepts a CSV file with ${MCP_MAX_ACTIVITIES} rows`, async () => { + const file = createCsvFile(MCP_MAX_ACTIVITIES); + + await aiController.getActivitiesFromFile(createResponse(), file); + + expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( + expect.objectContaining({ + file: { buffer: file.buffer, mediaType: 'text/csv' } + }) + ); + }); + + it('Passes the file with its media type and the user of the request', async () => { + await expect( + aiController.getActivitiesFromFile(createResponse(), pdfFile) + ).resolves.toEqual({ activities: [], rowsWithoutMatch: [] }); + + expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( + expect.objectContaining({ + user, + file: { buffer: pdfFile.buffer, mediaType: 'application/pdf' } + }) + ); + + const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) + .mock.calls; + + expect(abortSignal).toBeInstanceOf(AbortSignal); + }); + + it('Stops the models if the client closes the request before the response', async () => { + const response = createResponse(); + + await aiController.getActivitiesFromFile(response, pdfFile); + + const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) + .mock.calls; + + expect(abortSignal.aborted).toBe(false); + + response.emit('close'); + + expect(abortSignal.aborted).toBe(true); + }); + + it('Does not stop the models when the response is finished', async () => { + const response = createResponse(); + + await aiController.getActivitiesFromFile(response, pdfFile); + + const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) + .mock.calls; + + Object.assign(response, { writableFinished: true }); + response.emit('close'); + + expect(abortSignal.aborted).toBe(false); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts index 3c7f6661d7..e23aae4812 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.ts @@ -2,9 +2,21 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; +import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; -import { AiPromptResponse } from '@ghostfolio/common/interfaces'; +import { + AI_ACTIVITIES_FILE_MAXIMUM_SIZE, + AI_ACTIVITIES_TIMEOUT, + MCP_MAX_ACTIVITIES, + THROTTLE_AI_ACTIVITIES_LIMIT, + THROTTLE_DAILY_TTL +} from '@ghostfolio/common/config'; +import { + AiActivitiesResponse, + AiPromptResponse +} from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import { scopes } from '@ghostfolio/common/scopes'; import type { @@ -16,23 +28,125 @@ import type { import { Controller, Get, + HttpCode, + HttpException, Inject, + Logger, Param, + Post, Query, + Res, + UploadedFile, + UseGuards, UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Throttle } from '@nestjs/throttler'; +import type { Response } from 'express'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { + getDataRowCountOfCsvFile, + getMediaTypeOfActivityFile +} from './ai.helper'; import { AiService } from './ai.service'; @Controller('ai') export class AiController { + private readonly logger = new Logger(AiController.name); + public constructor( private readonly aiService: AiService, private readonly apiService: ApiService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} + /** + * Reads the activities of a CSV, JPEG, PDF or PNG file with Ghostfolio AI, + * without saving anything. The client imports the activities which the user + * selects with POST api/v1/import. + */ + @HasPermission(permissions.importActivitiesWithAi) + @HttpCode(StatusCodes.OK) + @Post('activities') + @Throttle({ + default: { + // The limit applies to each user instead of each IP address + getTracker: ({ user }: RequestWithUser) => { + return user.id; + }, + limit: THROTTLE_AI_ACTIVITIES_LIMIT, + ttl: THROTTLE_DAILY_TTL + } + }) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, CustomThrottlerGuard) + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: AI_ACTIVITIES_FILE_MAXIMUM_SIZE } + }) + ) + public async getActivitiesFromFile( + @Res({ passthrough: true }) response: Response, + @UploadedFile() file?: { buffer: Buffer; originalname: string } + ): Promise { + const mediaType = file + ? getMediaTypeOfActivityFile({ + buffer: file.buffer, + fileName: file.originalname + }) + : undefined; + + if (!mediaType) { + throw new HttpException( + { + error: getReasonPhrase(StatusCodes.BAD_REQUEST), + message: [ + 'The file is not a CSV file in UTF-8, a JPEG, a PDF or a PNG file' + ] + }, + StatusCodes.BAD_REQUEST + ); + } + + if (mediaType === 'text/csv') { + const dataRowCount = getDataRowCountOfCsvFile(file.buffer); + + if (dataRowCount > MCP_MAX_ACTIVITIES) { + this.logger.warn(`Rejected a CSV file with ${dataRowCount} rows`); + + throw new HttpException( + { + error: getReasonPhrase(StatusCodes.BAD_REQUEST), + message: [ + `The CSV file has more than ${MCP_MAX_ACTIVITIES} rows of activities` + ] + }, + StatusCodes.BAD_REQUEST + ); + } + } + + // Stops the models if the client closes the request before the response + const abortController = new AbortController(); + + response.on('close', () => { + if (!response.writableFinished) { + abortController.abort(); + } + }); + + return this.aiService.getActivitiesFromFile({ + abortSignal: AbortSignal.any([ + abortController.signal, + AbortSignal.timeout(AI_ACTIVITIES_TIMEOUT) + ]), + file: { mediaType, buffer: file.buffer }, + user: this.request.user + }); + } + @Get('prompt/:mode') @HasPermission(permissions.readAiPrompt) @RequiresScope(scopes.portfolioRead) diff --git a/apps/api/src/app/endpoints/ai/ai.helper.spec.ts b/apps/api/src/app/endpoints/ai/ai.helper.spec.ts new file mode 100644 index 0000000000..7bd004361f --- /dev/null +++ b/apps/api/src/app/endpoints/ai/ai.helper.spec.ts @@ -0,0 +1,83 @@ +import { + getDataRowCountOfCsvFile, + getMediaTypeOfActivityFile +} from './ai.helper'; + +describe('getDataRowCountOfCsvFile', () => { + it('Gives the number of the lines which are not empty, without the header', () => { + expect( + getDataRowCountOfCsvFile( + Buffer.from('Date,Symbol\n2026-01-15,AAPL\n\n2026-01-16,MSFT\n') + ) + ).toBe(2); + }); + + it('Counts a line with a line break in quotes as one line', () => { + expect( + getDataRowCountOfCsvFile( + Buffer.from('Date,Comment\n2026-01-15,"First\nsecond"\n') + ) + ).toBe(1); + }); + + it('Gives 0 for a file with a header only or without content', () => { + expect(getDataRowCountOfCsvFile(Buffer.from('Date,Symbol\n'))).toBe(0); + expect(getDataRowCountOfCsvFile(Buffer.from(''))).toBe(0); + }); +}); + +describe('getMediaTypeOfActivityFile', () => { + it('Identifies a JPEG, a PDF and a PNG file by the first bytes', () => { + expect( + getMediaTypeOfActivityFile({ + buffer: Buffer.from([0xff, 0xd8, 0xff, 0xe0]), + fileName: 'statement' + }) + ).toBe('image/jpeg'); + + expect( + getMediaTypeOfActivityFile({ + buffer: Buffer.from('%PDF-1.7'), + fileName: 'statement.csv' + }) + ).toBe('application/pdf'); + + expect( + getMediaTypeOfActivityFile({ + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + fileName: 'statement.jpg' + }) + ).toBe('image/png'); + }); + + it('Identifies a CSV file by the extension and the content in UTF-8', () => { + for (const fileName of ['statement.csv', 'STATEMENT.CSV']) { + expect( + getMediaTypeOfActivityFile({ + fileName, + buffer: Buffer.from('Datum;Stück\n15.01.2026;10\n') + }) + ).toBe('text/csv'); + } + }); + + it('Refuses a CSV file which is not in UTF-8', () => { + expect( + getMediaTypeOfActivityFile({ + buffer: Buffer.from([0x53, 0x74, 0xfc, 0x63, 0x6b]), + fileName: 'statement.csv' + }) + ).toBeUndefined(); + }); + + it('Refuses any other file', () => { + for (const { buffer, fileName } of [ + { buffer: Buffer.from('Date,Symbol'), fileName: 'statement.txt' }, + { buffer: Buffer.from(''), fileName: 'statement.pdf' }, + { buffer: Buffer.from([0xff, 0xd8]), fileName: 'statement.jpg' }, + { buffer: Buffer.from(''), fileName: 'statement.png' } + ]) { + expect(getMediaTypeOfActivityFile({ buffer, fileName })).toBeUndefined(); + } + }); +}); diff --git a/apps/api/src/app/endpoints/ai/ai.helper.ts b/apps/api/src/app/endpoints/ai/ai.helper.ts new file mode 100644 index 0000000000..8ca4fde61c --- /dev/null +++ b/apps/api/src/app/endpoints/ai/ai.helper.ts @@ -0,0 +1,52 @@ +import { isUtf8 } from 'node:buffer'; +import { parse as parseCsv } from 'papaparse'; + +const JPEG_SIGNATURE = [0xff, 0xd8, 0xff]; +const PDF_SIGNATURE = [0x25, 0x50, 0x44, 0x46, 0x2d]; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** + * Gives the number of the lines of a CSV file which are not empty, without + * the first line. A line before the header is counted as a data row, hence + * the number is never too low. + */ +export function getDataRowCountOfCsvFile(buffer: Buffer) { + const { data } = parseCsv(buffer.toString('utf8'), { skipEmptyLines: true }); + + return Math.max(data.length - 1, 0); +} + +/** + * Gives the media type of a file which Ghostfolio AI reads. A JPEG, a PDF or + * a PNG file is identified by its first bytes, a CSV file by its extension + * and by its content in UTF-8, as the media type which a client sends for a + * CSV file varies. + */ +export function getMediaTypeOfActivityFile({ + buffer, + fileName +}: { + buffer: Buffer; + fileName: string; +}) { + if (startsWith(buffer, JPEG_SIGNATURE)) { + return 'image/jpeg'; + } else if (startsWith(buffer, PDF_SIGNATURE)) { + return 'application/pdf'; + } else if (startsWith(buffer, PNG_SIGNATURE)) { + return 'image/png'; + } else if (fileName.toLowerCase().endsWith('.csv') && isUtf8(buffer)) { + return 'text/csv'; + } + + return undefined; +} + +function startsWith(buffer: Buffer, signature: number[]) { + return ( + buffer.length >= signature.length && + signature.every((byte, index) => { + return buffer[index] === byte; + }) + ); +} diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index da05c971c7..747d6ec3b2 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -3,12 +3,15 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { + DEFAULT_LOCALE, PROPERTY_API_KEY_OPENROUTER, PROPERTY_OPENROUTER_MODEL, PROPERTY_OPENROUTER_MODEL_AGENT, PROPERTY_OPENROUTER_MODEL_VISION } from '@ghostfolio/common/config'; +import type { UserWithSettings } from '@ghostfolio/common/types'; +import { Logger } from '@nestjs/common'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { Type as ActivityType } from '@prisma/client'; import { generateText, isStepCount, ToolSet } from 'ai'; @@ -57,6 +60,8 @@ describe('AiService', () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + chat = jest.fn().mockReturnValue(model); jest @@ -66,7 +71,9 @@ describe('AiService', () => { >); jest.mocked(generateText).mockResolvedValue({ - output: { rows } + output: { rows }, + steps: [{}], + totalUsage: { totalTokens: 1000 } } as unknown as Awaited>); properties = { @@ -187,6 +194,78 @@ describe('AiService', () => { }); }); + describe('getActivitiesFromFile', () => { + const file = { + buffer: Buffer.from('%PDF-1.7'), + mediaType: 'application/pdf' + }; + + const user = { + accounts: [{ currency: 'CHF', id: 'account-id', name: 'Brokerage' }], + id: 'user-id', + settings: { settings: { baseCurrency: 'EUR', locale: 'de-CH' } } + } as unknown as UserWithSettings; + + it('Reads the rows of the file and matches them with the accounts, the base currency and the id of the user', async () => { + const activities = [ + { + currency: 'USD', + dataSource: 'YAHOO', + date: '2026-01-15', + fee: 1, + quantity: 10, + symbol: 'AAPL', + type: ActivityType.BUY, + unitPrice: 230 + } + ]; + + jest.spyOn(aiService, 'getActivityRowsFromFile').mockResolvedValue(rows); + + jest + .spyOn(aiService, 'getActivitiesFromRows') + .mockResolvedValue({ activities, rowsWithoutMatch: [] }); + + await expect( + aiService.getActivitiesFromFile({ abortSignal, file, user }) + ).resolves.toEqual({ activities, rowsWithoutMatch: [] }); + + expect(aiService.getActivityRowsFromFile).toHaveBeenCalledWith({ + abortSignal, + file, + locale: 'de-CH' + }); + + expect(aiService.getActivitiesFromRows).toHaveBeenCalledWith({ + abortSignal, + rows, + accounts: user.accounts, + userCurrency: 'EUR', + userId: 'user-id' + }); + }); + + it('Reads the file in the default locale if the user has no locale', async () => { + jest.spyOn(aiService, 'getActivityRowsFromFile').mockResolvedValue([]); + + jest + .spyOn(aiService, 'getActivitiesFromRows') + .mockResolvedValue({ activities: [], rowsWithoutMatch: [] }); + + await aiService.getActivitiesFromFile({ + file, + user: { + ...user, + settings: { settings: { baseCurrency: 'EUR' } } + } as unknown as UserWithSettings + }); + + expect(aiService.getActivityRowsFromFile).toHaveBeenCalledWith( + expect.objectContaining({ locale: DEFAULT_LOCALE }) + ); + }); + }); + describe('getActivitiesFromRows', () => { const accounts: AccountToMatch[] = [ { currency: 'CHF', id: 'account-id', name: 'Brokerage' } @@ -200,7 +279,9 @@ describe('AiService', () => { assets: assetMatches = [] }: Partial>) { jest.mocked(generateText).mockResolvedValueOnce({ - output: { accounts: accountMatches, assets: assetMatches } + output: { accounts: accountMatches, assets: assetMatches }, + steps: [{}, {}], + totalUsage: { totalTokens: 2000 } } as unknown as Awaited>); } diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 8577b982d6..8a2f4219f1 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -8,6 +8,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { + DEFAULT_LOCALE, MCP_MAX_ACTIVITIES, NON_INVESTMENT_ACTIVITY_TYPES, PROPERTY_API_KEY_OPENROUTER, @@ -16,14 +17,19 @@ import { PROPERTY_OPENROUTER_MODEL_VISION } from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; -import { Filter } from '@ghostfolio/common/interfaces'; -import type { AiPromptMode, PropertyKey } from '@ghostfolio/common/types'; - -import { Injectable } from '@nestjs/common'; +import { AiActivitiesResponse, Filter } from '@ghostfolio/common/interfaces'; +import type { + AiPromptMode, + PropertyKey, + UserWithSettings +} from '@ghostfolio/common/types'; + +import { Injectable, Logger } from '@nestjs/common'; import { createOpenRouter, OpenRouterChatSettings } from '@openrouter/ai-sdk-provider'; +import { DataSource } from '@prisma/client'; import { FilePart, generateText, @@ -45,6 +51,8 @@ import { AssetToMatch } from './types/asset-to-match.type'; export class AiService { private static readonly AGENT_MAXIMUM_STEP_COUNT = 10; + private readonly logger = new Logger(AiService.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly mcpService: McpService, @@ -78,6 +86,43 @@ export class AiService { }); } + /** + * Reads the activities of a file and matches them to the asset profiles and + * to the accounts of the user, without saving anything + */ + public async getActivitiesFromFile({ + abortSignal, + file, + user + }: { + abortSignal?: AbortSignal; + file: { buffer: Buffer; mediaType: string }; + user: UserWithSettings; + }): Promise { + const rows = await this.getActivityRowsFromFile({ + abortSignal, + file, + locale: user.settings.settings.locale ?? DEFAULT_LOCALE + }); + + const { activities, rowsWithoutMatch } = await this.getActivitiesFromRows({ + abortSignal, + rows, + accounts: user.accounts, + userCurrency: user.settings.settings.baseCurrency, + userId: user.id + }); + + return { + rowsWithoutMatch, + activities: activities.map(({ dataSource, ...activity }) => { + // The data source is given as search-asset-profiles gives it, which + // POST api/v1/import transforms back + return { ...activity, dataSource: dataSource as DataSource }; + }) + }; + } + /** * Matches the rows of a file to the asset profiles with the agent model and * the tool search-asset-profiles, and to the accounts of the user. A row @@ -219,13 +264,15 @@ export class AiService { file: { buffer: Buffer; mediaType: string }; locale: string; }): Promise { + const startTime = performance.now(); + // A CSV file is passed as text, every other file as a file part const content: (FilePart | TextPart)[] = file.mediaType === 'text/csv' ? [{ text: file.buffer.toString('utf8'), type: 'text' }] : [{ data: file.buffer, mediaType: file.mediaType, type: 'file' }]; - const { output } = await generateText({ + const { output, totalUsage } = await generateText({ abortSignal, instructions: [ 'You read the activities of a portfolio from a file, for example a CSV export or a statement of a broker, a confirmation of a trade or a screenshot.', @@ -254,6 +301,10 @@ export class AiService { output: Output.object({ schema: ACTIVITY_ROWS_OUTPUT }) }); + this.logger.log( + `Read ${output.rows.length} rows from a file in ${this.getSecondsSince(startTime)} seconds with ${totalUsage.totalTokens} tokens` + ); + return output.rows; } @@ -339,7 +390,9 @@ export class AiService { assets: AssetToMatch[]; userId: string; }) { - const { output } = await generateText({ + const startTime = performance.now(); + + const { output, steps, totalUsage } = await generateText({ abortSignal, instructions: [ 'You match the assets and the account names of a file to the asset profiles of Ghostfolio and to the accounts of the user.', @@ -381,6 +434,14 @@ export class AiService { } }); + this.logger.log( + `Matched ${assets.length} assets and ${accountNames.length} account names in ${steps.length} steps and ${this.getSecondsSince(startTime)} seconds with ${totalUsage.totalTokens} tokens` + ); + return output; } + + private getSecondsSince(startTime: number) { + return ((performance.now() - startTime) / 1000).toFixed(3); + } } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 227d04da80..4e77faeefb 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -475,6 +475,10 @@ export class UserService { currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.readMarketDataOfMarkets); currentPermissions.push(permissions.reportDataGlitch); + + if ((user.settings.settings as UserSettings).isExperimentalFeatures) { + currentPermissions.push(permissions.importActivitiesWithAi); + } } currentPermissions = without( diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 42f0c37d6c..14e25d0549 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -38,6 +38,9 @@ export const warnColorRgb = { b: 69 }; +export const AI_ACTIVITIES_FILE_MAXIMUM_SIZE = 10 * 1024 * 1024; +export const AI_ACTIVITIES_TIMEOUT = ms('2 minutes'); + export const ASSET_CLASS_MAPPING = new Map([ [AssetClass.ALTERNATIVE_INVESTMENT, [AssetSubClass.COLLECTIBLE]], [AssetClass.COMMODITY, [AssetSubClass.PRECIOUS_METAL]], @@ -415,6 +418,7 @@ export const TAG_IDS_SYSTEM = [ TAG_ID_EXCLUDE_FROM_ANALYSIS ]; +export const THROTTLE_AI_ACTIVITIES_LIMIT = 25; export const THROTTLE_DAILY_KEY = 'daily'; export const THROTTLE_DAILY_TTL = ms('1 day'); export const THROTTLE_DEFAULT_LIMIT = 10; diff --git a/libs/common/src/lib/interfaces/ai-activity-row.interface.ts b/libs/common/src/lib/interfaces/ai-activity-row.interface.ts new file mode 100644 index 0000000000..1ba92be6fa --- /dev/null +++ b/libs/common/src/lib/interfaces/ai-activity-row.interface.ts @@ -0,0 +1,18 @@ +import { Type as ActivityType } from '@ghostfolio/prisma/enums'; + +/** + * An activity as Ghostfolio AI reads it from a file, with the asset and the + * account as printed + */ +export interface AiActivityRow { + accountName?: string | null; + currency?: string | null; + date: string; + fee: number; + isin?: string | null; + name?: string | null; + quantity: number; + symbol?: string | null; + type: ActivityType; + unitPrice: number; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 7e663f5776..5f22f4734e 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -6,6 +6,7 @@ import type { AdminData } from './admin-data.interface'; import type { AdminJobs } from './admin-jobs.interface'; import type { AdminMarketDataDetails } from './admin-market-data-details.interface'; import type { AdminUser } from './admin-user.interface'; +import type { AiActivityRow } from './ai-activity-row.interface'; import type { AssetClassSelectorOption } from './asset-class-selector-option.interface'; import type { AssetProfileIdentifier } from './asset-profile-identifier.interface'; import type { AssetProfileItem } from './asset-profile-item.interface'; @@ -45,6 +46,7 @@ import type { ActivitiesResponse } from './responses/activities-response.interfa import type { ActivityResponse } from './responses/activity-response.interface'; import type { AdminUserResponse } from './responses/admin-user-response.interface'; import type { AdminUsersResponse } from './responses/admin-users-response.interface'; +import type { AiActivitiesResponse } from './responses/ai-activities-response.interface'; import type { AiPromptResponse } from './responses/ai-prompt-response.interface'; import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface'; @@ -118,6 +120,8 @@ export { AdminUser, AdminUserResponse, AdminUsersResponse, + AiActivitiesResponse, + AiActivityRow, AiPromptResponse, AiServiceHealthResponse, ApiKeyResponse, diff --git a/libs/common/src/lib/interfaces/responses/ai-activities-response.interface.ts b/libs/common/src/lib/interfaces/responses/ai-activities-response.interface.ts new file mode 100644 index 0000000000..7a7346e0f5 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/ai-activities-response.interface.ts @@ -0,0 +1,8 @@ +import type { CreateOrderDto } from '@ghostfolio/common/dtos'; + +import { AiActivityRow } from '../ai-activity-row.interface'; + +export interface AiActivitiesResponse { + activities: CreateOrderDto[]; + rowsWithoutMatch: AiActivityRow[]; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index d2b23e871a..90802317e8 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -47,6 +47,7 @@ export const permissions = { enableSubscriptionInterstitial: 'enableSubscriptionInterstitial', enableSystemMessage: 'enableSystemMessage', impersonateAllUsers: 'impersonateAllUsers', + importActivitiesWithAi: 'importActivitiesWithAi', readAiPrompt: 'readAiPrompt', readMarketData: 'readMarketData', readMarketDataOfMarkets: 'readMarketDataOfMarkets',