Browse Source

Fix import of activities with Ghostfolio AI

pull/8128/head
Thomas Kaul 4 days ago
parent
commit
f4e12a775a
  1. 27
      apps/api/src/app/endpoints/ai/ai.controller.spec.ts
  2. 34
      apps/api/src/app/endpoints/ai/ai.controller.ts
  3. 108
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  4. 57
      apps/api/src/app/endpoints/ai/ai.service.ts

27
apps/api/src/app/endpoints/ai/ai.controller.spec.ts

@ -1,4 +1,5 @@
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
@ -18,6 +19,7 @@ import {
THROTTLER_TTL THROTTLER_TTL
} from '@nestjs/throttler/dist/throttler.constants'; } from '@nestjs/throttler/dist/throttler.constants';
import type { Response } from 'express'; import type { Response } from 'express';
import { StatusCodes } from 'http-status-codes';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { AiController } from './ai.controller'; import { AiController } from './ai.controller';
@ -207,5 +209,30 @@ describe('AiController', () => {
expect(abortSignal.aborted).toBe(false); expect(abortSignal.aborted).toBe(false);
}); });
it('Gives the status 400 with the message of an error for the caller', async () => {
jest
.mocked(aiService.getActivitiesFromFile)
.mockRejectedValueOnce(
new CallerFacingError('The file has too many activities')
);
await expect(
aiController.getActivitiesFromFile(createResponse(), pdfFile)
).rejects.toMatchObject({
response: { message: ['The file has too many activities'] },
status: StatusCodes.BAD_REQUEST
});
});
it('Passes on every other error', async () => {
const error = new Error('Model error');
jest.mocked(aiService.getActivitiesFromFile).mockRejectedValueOnce(error);
await expect(
aiController.getActivitiesFromFile(createResponse(), pdfFile)
).rejects.toBe(error);
});
}); });
}); });

34
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -2,6 +2,7 @@ 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 { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.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';
@ -68,7 +69,8 @@ export class AiController {
* without saving anything. The client imports the activities which the user * without saving anything. The client imports the activities which the user
* selects with POST api/v1/import. The daily limit applies to each user * selects with POST api/v1/import. The daily limit applies to each user
* instead of each IP address. The models stop if the client closes the * instead of each IP address. The models stop if the client closes the
* request before the response. * request before the response. An error whose message is written for the
* caller gives the status 400 with this message.
*/ */
@HasPermission(permissions.importActivitiesWithAi) @HasPermission(permissions.importActivitiesWithAi)
@HttpCode(StatusCodes.OK) @HttpCode(StatusCodes.OK)
@ -137,14 +139,28 @@ export class AiController {
} }
}); });
return this.aiService.getActivitiesFromFile({ try {
abortSignal: AbortSignal.any([ return await this.aiService.getActivitiesFromFile({
abortController.signal, abortSignal: AbortSignal.any([
AbortSignal.timeout(AI_ACTIVITIES_TIMEOUT) abortController.signal,
]), AbortSignal.timeout(AI_ACTIVITIES_TIMEOUT)
file: { mediaType, buffer: file.buffer }, ]),
user: this.request.user file: { mediaType, buffer: file.buffer },
}); user: this.request.user
});
} catch (error) {
if (error instanceof CallerFacingError) {
throw new HttpException(
{
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [error.message]
},
StatusCodes.BAD_REQUEST
);
}
throw error;
}
} }
@Get('prompt/:mode') @Get('prompt/:mode')

108
apps/api/src/app/endpoints/ai/ai.service.spec.ts

@ -1,4 +1,5 @@
import { McpService } from '@ghostfolio/api/app/endpoints/mcp/mcp.service'; import { McpService } from '@ghostfolio/api/app/endpoints/mcp/mcp.service';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
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';
@ -15,7 +16,7 @@ import type { UserWithSettings } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { DataSource, Type as ActivityType } from '@prisma/client'; import { DataSource, Type as ActivityType } from '@prisma/client';
import { generateText, isStepCount, ToolSet } from 'ai'; import { generateText, isStepCount, NoObjectGeneratedError, ToolSet } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { ACTIVITY_ROWS_OUTPUT, ROW_MATCHES_OUTPUT } from './ai.schemas'; import { ACTIVITY_ROWS_OUTPUT, ROW_MATCHES_OUTPUT } from './ai.schemas';
@ -34,6 +35,19 @@ jest.mock('ai', () => {
isStepCount: jest.fn((stepCount: number) => { isStepCount: jest.fn((stepCount: number) => {
return { stepCount }; return { stepCount };
}), }),
NoObjectGeneratedError: class MockNoObjectGeneratedError extends Error {
public readonly finishReason: string;
public constructor({ finishReason }: { finishReason: string }) {
super('No object generated');
this.finishReason = finishReason;
}
public static isInstance(error: unknown) {
return error instanceof MockNoObjectGeneratedError;
}
},
Output: { Output: {
object: jest.fn((options: unknown) => { object: jest.fn((options: unknown) => {
return options; return options;
@ -194,6 +208,69 @@ describe('AiService', () => {
expect(instructions).toContain('the locale de-CH'); expect(instructions).toContain('the locale de-CH');
expect(instructions).toContain('Today is 2026-10-11'); expect(instructions).toContain('Today is 2026-10-11');
}); });
it('Gives an error for the caller if a cut output reaches the maximum number of output tokens', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.mocked(generateText).mockRejectedValueOnce(
new NoObjectGeneratedError({
finishReason: 'length'
} as ConstructorParameters<typeof NoObjectGeneratedError>[0])
);
await expect(
aiService.getActivityRowsFromFile({
file: {
buffer: Buffer.from('%PDF-1.7'),
mediaType: 'application/pdf'
},
locale: 'de-CH'
})
).rejects.toThrow(CallerFacingError);
expect(Logger.prototype.warn).toHaveBeenCalled();
});
it('Gives an error for the caller if an output without any text reaches the maximum number of output tokens', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.mocked(generateText).mockResolvedValueOnce({
finishReason: 'length',
get output() {
throw new Error('No output generated');
}
} as unknown as Awaited<ReturnType<typeof generateText>>);
await expect(
aiService.getActivityRowsFromFile({
file: {
buffer: Buffer.from('%PDF-1.7'),
mediaType: 'application/pdf'
},
locale: 'de-CH'
})
).rejects.toThrow(CallerFacingError);
expect(Logger.prototype.warn).toHaveBeenCalled();
});
it('Passes on every other error of the vision model', async () => {
const error = new NoObjectGeneratedError({
finishReason: 'stop'
} as ConstructorParameters<typeof NoObjectGeneratedError>[0]);
jest.mocked(generateText).mockRejectedValueOnce(error);
await expect(
aiService.getActivityRowsFromFile({
file: {
buffer: Buffer.from('%PDF-1.7'),
mediaType: 'application/pdf'
},
locale: 'de-CH'
})
).rejects.toBe(error);
});
}); });
describe('getActivitiesFromFile', () => { describe('getActivitiesFromFile', () => {
@ -404,6 +481,35 @@ describe('AiService', () => {
expect(activities[0].currency).toBe('CHF'); expect(activities[0].currency).toBe('CHF');
}); });
it('Gives the absolute values of a negative fee, quantity and unit price', async () => {
mockRowMatches({
assets: [
{ currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'AAPL' }
]
});
const { activities } = await aiService.getActivitiesFromRows({
accounts,
userCurrency,
userId,
rows: [
createActivityRow({
accountName: null,
fee: -1,
quantity: -10,
type: ActivityType.SELL,
unitPrice: -230
})
]
});
expect(activities[0]).toMatchObject({
fee: 1,
quantity: 10,
unitPrice: 230
});
});
it('Gives a row whose asset has no match as a row without a match', async () => { it('Gives a row whose asset has no match as a row without a match', async () => {
const row = createActivityRow({ accountName: null }); const row = createActivityRow({ accountName: null });

57
apps/api/src/app/endpoints/ai/ai.service.ts

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/api/app/endpoints/mcp/mcp.schemas'; } from '@ghostfolio/api/app/endpoints/mcp/mcp.schemas';
import { McpService } from '@ghostfolio/api/app/endpoints/mcp/mcp.service'; import { McpService } from '@ghostfolio/api/app/endpoints/mcp/mcp.service';
import { ActivityToImport } from '@ghostfolio/api/app/endpoints/mcp/types/activity-to-import.type'; import { ActivityToImport } from '@ghostfolio/api/app/endpoints/mcp/types/activity-to-import.type';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
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';
@ -43,6 +44,7 @@ import {
FilePart, FilePart,
generateText, generateText,
isStepCount, isStepCount,
NoObjectGeneratedError,
Output, Output,
ProviderMetadata, ProviderMetadata,
TextPart, TextPart,
@ -137,7 +139,9 @@ export class AiService {
* 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
* which does not give a valid activity, for example because its asset has * which does not give a valid activity, for example because its asset has
* no match, is a row without a match. An account id which is not the id of * no match, is a row without a match. An account id which is not the id of
* an account of the user is ignored. * an account of the user is ignored. The fee, the quantity and the unit
* price are taken as absolute values, as a broker can give a sell or a fee
* as a negative number.
*/ */
public async getActivitiesFromRows({ public async getActivitiesFromRows({
abortSignal, abortSignal,
@ -202,16 +206,11 @@ export class AiService {
const rowsWithoutMatch: ActivityRow[] = []; const rowsWithoutMatch: ActivityRow[] = [];
for (const row of rows) { for (const row of rows) {
const { const { accountName, currency, date, name, type } = row;
accountName,
currency, const fee = Math.abs(row.fee);
date, const quantity = Math.abs(row.quantity);
fee, const unitPrice = Math.abs(row.unitPrice);
name,
quantity,
type,
unitPrice
} = row;
const account = accounts.find(({ id }) => { const account = accounts.find(({ id }) => {
return id === accountIdByName.get(accountName); return id === accountIdByName.get(accountName);
@ -265,7 +264,10 @@ export class AiService {
* OpenRouter is not covered by the zero data retention. The model gets no * OpenRouter is not covered by the zero data retention. The model gets no
* tools and copies the asset and the account as printed, hence the rows * tools and copies the asset and the account as printed, hence the rows
* still have to be matched to asset profiles and accounts. A row above the * still have to be matched to asset profiles and accounts. A row above the
* maximum number of activities is left out. * maximum number of activities is left out. An output which reaches the
* maximum number of output tokens is cut, hence it gives an error for the
* caller. A cut output without any text is not parsed at all, hence the
* finish reason is checked as well.
*/ */
public async getActivityRowsFromFile({ public async getActivityRowsFromFile({
abortSignal, abortSignal,
@ -283,7 +285,7 @@ export class AiService {
? [{ 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, steps, totalUsage } = await generateText({ const result = 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.',
@ -310,8 +312,23 @@ export class AiService {
} }
}), }),
output: Output.object({ schema: ACTIVITY_ROWS_OUTPUT }) output: Output.object({ schema: ACTIVITY_ROWS_OUTPUT })
}).catch((error: unknown) => {
if (
NoObjectGeneratedError.isInstance(error) &&
error.finishReason === 'length'
) {
throw this.getOutputTokenLimitError();
}
throw error;
}); });
if (result.finishReason === 'length') {
throw this.getOutputTokenLimitError();
}
const { output, steps, totalUsage } = result;
this.logger.log( this.logger.log(
`Read ${output.rows.length} rows from a file in ${this.getSecondsSince(startTime)} seconds with ${totalUsage.totalTokens} tokens for ${this.getCost(steps)} USD` `Read ${output.rows.length} rows from a file in ${this.getSecondsSince(startTime)} seconds with ${totalUsage.totalTokens} tokens for ${this.getCost(steps)} USD`
); );
@ -445,6 +462,20 @@ export class AiService {
); );
} }
/**
* Logs and gives the error of an output of the vision model which reaches
* the maximum number of output tokens
*/
private getOutputTokenLimitError() {
this.logger.warn(
`Stopped reading a file at the maximum of ${AiService.VISION_MAXIMUM_OUTPUT_TOKEN_COUNT} output tokens`
);
return new CallerFacingError(
'The file has too many activities for Ghostfolio AI. Split it into smaller files.'
);
}
/** /**
* Matches the assets and the account names with the agent model. The last * Matches the assets and the account names with the agent model. The last
* step allows no tool call, so that the model always gives the matches. An * step allows no tool call, so that the model always gives the matches. An

Loading…
Cancel
Save