Browse Source

Add endpoint to extract activities with AI

pull/8126/head
Thomas Kaul 4 days ago
parent
commit
6abf21e8e6
  1. 205
      apps/api/src/app/endpoints/ai/ai.controller.spec.ts
  2. 116
      apps/api/src/app/endpoints/ai/ai.controller.ts
  3. 83
      apps/api/src/app/endpoints/ai/ai.helper.spec.ts
  4. 52
      apps/api/src/app/endpoints/ai/ai.helper.ts
  5. 85
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  6. 73
      apps/api/src/app/endpoints/ai/ai.service.ts
  7. 4
      apps/api/src/app/user/user.service.ts
  8. 4
      libs/common/src/lib/config.ts
  9. 18
      libs/common/src/lib/interfaces/ai-activity-row.interface.ts
  10. 4
      libs/common/src/lib/interfaces/index.ts
  11. 8
      libs/common/src/lib/interfaces/responses/ai-activities-response.interface.ts
  12. 1
      libs/common/src/lib/permissions.ts

205
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<T>(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<string>(HAS_PERMISSION_KEY)).toBe(
permissions.importActivitiesWithAi
);
const guards = getMetadataOfMethod<unknown[]>(GUARDS_METADATA);
expect(guards.slice(1)).toEqual([
HasPermissionGuard,
CustomThrottlerGuard
]);
expect(getMetadataOfMethod<number>(`${THROTTLER_LIMIT}default`)).toBe(
THROTTLE_AI_ACTIVITIES_LIMIT
);
expect(getMetadataOfMethod<number>(`${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);
});
});
});

116
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 { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; 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 { 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 { 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 { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes'; import { scopes } from '@ghostfolio/common/scopes';
import type { import type {
@ -16,23 +28,125 @@ import type {
import { import {
Controller, Controller,
Get, Get,
HttpCode,
HttpException,
Inject, Inject,
Logger,
Param, Param,
Post,
Query, Query,
Res,
UploadedFile,
UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; 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'; import { AiService } from './ai.service';
@Controller('ai') @Controller('ai')
export class AiController { export class AiController {
private readonly logger = new Logger(AiController.name);
public constructor( public constructor(
private readonly aiService: AiService, private readonly aiService: AiService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
@Inject(REQUEST) private readonly request: RequestWithUser @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<AiActivitiesResponse> {
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') @Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt) @HasPermission(permissions.readAiPrompt)
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)

83
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('<html></html>'), fileName: 'statement.pdf' },
{ buffer: Buffer.from([0xff, 0xd8]), fileName: 'statement.jpg' },
{ buffer: Buffer.from(''), fileName: 'statement.png' }
]) {
expect(getMediaTypeOfActivityFile({ buffer, fileName })).toBeUndefined();
}
});
});

52
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;
})
);
}

85
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 { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_LOCALE,
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL, PROPERTY_OPENROUTER_MODEL,
PROPERTY_OPENROUTER_MODEL_AGENT, PROPERTY_OPENROUTER_MODEL_AGENT,
PROPERTY_OPENROUTER_MODEL_VISION PROPERTY_OPENROUTER_MODEL_VISION
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import type { UserWithSettings } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { Type as ActivityType } from '@prisma/client'; import { Type as ActivityType } from '@prisma/client';
import { generateText, isStepCount, ToolSet } from 'ai'; import { generateText, isStepCount, ToolSet } from 'ai';
@ -57,6 +60,8 @@ describe('AiService', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.spyOn(Logger.prototype, 'log').mockImplementation();
chat = jest.fn().mockReturnValue(model); chat = jest.fn().mockReturnValue(model);
jest jest
@ -66,7 +71,9 @@ describe('AiService', () => {
>); >);
jest.mocked(generateText).mockResolvedValue({ jest.mocked(generateText).mockResolvedValue({
output: { rows } output: { rows },
steps: [{}],
totalUsage: { totalTokens: 1000 }
} as unknown as Awaited<ReturnType<typeof generateText>>); } as unknown as Awaited<ReturnType<typeof generateText>>);
properties = { 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', () => { describe('getActivitiesFromRows', () => {
const accounts: AccountToMatch[] = [ const accounts: AccountToMatch[] = [
{ currency: 'CHF', id: 'account-id', name: 'Brokerage' } { currency: 'CHF', id: 'account-id', name: 'Brokerage' }
@ -200,7 +279,9 @@ describe('AiService', () => {
assets: assetMatches = [] assets: assetMatches = []
}: Partial<z.infer<typeof ROW_MATCHES_OUTPUT>>) { }: Partial<z.infer<typeof ROW_MATCHES_OUTPUT>>) {
jest.mocked(generateText).mockResolvedValueOnce({ jest.mocked(generateText).mockResolvedValueOnce({
output: { accounts: accountMatches, assets: assetMatches } output: { accounts: accountMatches, assets: assetMatches },
steps: [{}, {}],
totalUsage: { totalTokens: 2000 }
} as unknown as Awaited<ReturnType<typeof generateText>>); } as unknown as Awaited<ReturnType<typeof generateText>>);
} }

73
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 { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_LOCALE,
MCP_MAX_ACTIVITIES, MCP_MAX_ACTIVITIES,
NON_INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES,
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
@ -16,14 +17,19 @@ import {
PROPERTY_OPENROUTER_MODEL_VISION PROPERTY_OPENROUTER_MODEL_VISION
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { AiActivitiesResponse, Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode, PropertyKey } from '@ghostfolio/common/types'; import type {
AiPromptMode,
import { Injectable } from '@nestjs/common'; PropertyKey,
UserWithSettings
} from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { import {
createOpenRouter, createOpenRouter,
OpenRouterChatSettings OpenRouterChatSettings
} from '@openrouter/ai-sdk-provider'; } from '@openrouter/ai-sdk-provider';
import { DataSource } from '@prisma/client';
import { import {
FilePart, FilePart,
generateText, generateText,
@ -45,6 +51,8 @@ import { AssetToMatch } from './types/asset-to-match.type';
export class AiService { export class AiService {
private static readonly AGENT_MAXIMUM_STEP_COUNT = 10; private static readonly AGENT_MAXIMUM_STEP_COUNT = 10;
private readonly logger = new Logger(AiService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly mcpService: McpService, 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<AiActivitiesResponse> {
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 * 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 * 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 }; file: { buffer: Buffer; mediaType: string };
locale: string; locale: string;
}): Promise<ActivityRow[]> { }): Promise<ActivityRow[]> {
const startTime = performance.now();
// A CSV file is passed as text, every other file as a file part // A CSV file is passed as text, every other file as a file part
const content: (FilePart | TextPart)[] = const content: (FilePart | TextPart)[] =
file.mediaType === 'text/csv' file.mediaType === 'text/csv'
? [{ text: file.buffer.toString('utf8'), type: 'text' }] ? [{ text: file.buffer.toString('utf8'), type: 'text' }]
: [{ data: file.buffer, mediaType: file.mediaType, type: 'file' }]; : [{ data: file.buffer, mediaType: file.mediaType, type: 'file' }];
const { output } = await generateText({ const { output, totalUsage } = await generateText({
abortSignal, abortSignal,
instructions: [ 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.', '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 }) 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; return output.rows;
} }
@ -339,7 +390,9 @@ export class AiService {
assets: AssetToMatch[]; assets: AssetToMatch[];
userId: string; userId: string;
}) { }) {
const { output } = await generateText({ const startTime = performance.now();
const { output, steps, totalUsage } = await generateText({
abortSignal, abortSignal,
instructions: [ 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.', '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; return output;
} }
private getSecondsSince(startTime: number) {
return ((performance.now() - startTime) / 1000).toFixed(3);
}
} }

4
apps/api/src/app/user/user.service.ts

@ -475,6 +475,10 @@ export class UserService {
currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.readMarketDataOfMarkets); currentPermissions.push(permissions.readMarketDataOfMarkets);
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
if ((user.settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions.push(permissions.importActivitiesWithAi);
}
} }
currentPermissions = without( currentPermissions = without(

4
libs/common/src/lib/config.ts

@ -38,6 +38,9 @@ export const warnColorRgb = {
b: 69 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, AssetSubClass[]>([ export const ASSET_CLASS_MAPPING = new Map<AssetClass, AssetSubClass[]>([
[AssetClass.ALTERNATIVE_INVESTMENT, [AssetSubClass.COLLECTIBLE]], [AssetClass.ALTERNATIVE_INVESTMENT, [AssetSubClass.COLLECTIBLE]],
[AssetClass.COMMODITY, [AssetSubClass.PRECIOUS_METAL]], [AssetClass.COMMODITY, [AssetSubClass.PRECIOUS_METAL]],
@ -415,6 +418,7 @@ export const TAG_IDS_SYSTEM = [
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
]; ];
export const THROTTLE_AI_ACTIVITIES_LIMIT = 25;
export const THROTTLE_DAILY_KEY = 'daily'; export const THROTTLE_DAILY_KEY = 'daily';
export const THROTTLE_DAILY_TTL = ms('1 day'); export const THROTTLE_DAILY_TTL = ms('1 day');
export const THROTTLE_DEFAULT_LIMIT = 10; export const THROTTLE_DEFAULT_LIMIT = 10;

18
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;
}

4
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 { AdminJobs } from './admin-jobs.interface';
import type { AdminMarketDataDetails } from './admin-market-data-details.interface'; import type { AdminMarketDataDetails } from './admin-market-data-details.interface';
import type { AdminUser } from './admin-user.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 { AssetClassSelectorOption } from './asset-class-selector-option.interface';
import type { AssetProfileIdentifier } from './asset-profile-identifier.interface'; import type { AssetProfileIdentifier } from './asset-profile-identifier.interface';
import type { AssetProfileItem } from './asset-profile-item.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 { ActivityResponse } from './responses/activity-response.interface';
import type { AdminUserResponse } from './responses/admin-user-response.interface'; import type { AdminUserResponse } from './responses/admin-user-response.interface';
import type { AdminUsersResponse } from './responses/admin-users-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 { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface'; import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface';
@ -118,6 +120,8 @@ export {
AdminUser, AdminUser,
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
AiActivitiesResponse,
AiActivityRow,
AiPromptResponse, AiPromptResponse,
AiServiceHealthResponse, AiServiceHealthResponse,
ApiKeyResponse, ApiKeyResponse,

8
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[];
}

1
libs/common/src/lib/permissions.ts

@ -47,6 +47,7 @@ export const permissions = {
enableSubscriptionInterstitial: 'enableSubscriptionInterstitial', enableSubscriptionInterstitial: 'enableSubscriptionInterstitial',
enableSystemMessage: 'enableSystemMessage', enableSystemMessage: 'enableSystemMessage',
impersonateAllUsers: 'impersonateAllUsers', impersonateAllUsers: 'impersonateAllUsers',
importActivitiesWithAi: 'importActivitiesWithAi',
readAiPrompt: 'readAiPrompt', readAiPrompt: 'readAiPrompt',
readMarketData: 'readMarketData', readMarketData: 'readMarketData',
readMarketDataOfMarkets: 'readMarketDataOfMarkets', readMarketDataOfMarkets: 'readMarketDataOfMarkets',

Loading…
Cancel
Save