Browse Source

Extend import of activities with Ghostfolio AI

pull/8128/head
Thomas Kaul 4 days ago
parent
commit
555152255c
  1. 12
      apps/api/src/app/endpoints/ai/ai.controller.spec.ts
  2. 6
      apps/api/src/app/endpoints/ai/ai.controller.ts
  3. 4
      apps/api/src/app/endpoints/ai/ai.schemas.spec.ts
  4. 4
      apps/api/src/app/endpoints/ai/ai.schemas.ts
  5. 6
      apps/api/src/app/endpoints/ai/ai.service.ts
  6. 12
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  7. 14
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss
  8. 1
      libs/common/src/lib/config.ts

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

@ -4,7 +4,7 @@ import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.gu
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';
import { import {
MCP_MAX_ACTIVITIES, AI_MAX_ACTIVITIES,
THROTTLE_AI_ACTIVITIES_LIMIT, THROTTLE_AI_ACTIVITIES_LIMIT,
THROTTLE_DAILY_TTL THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -132,23 +132,23 @@ describe('AiController', () => {
expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled();
}); });
it(`Refuses a CSV file with more than ${MCP_MAX_ACTIVITIES} rows before a model call`, async () => { it(`Refuses a CSV file with more than ${AI_MAX_ACTIVITIES} rows before a model call`, async () => {
await expect( await expect(
aiController.getActivitiesFromFile( aiController.getActivitiesFromFile(
createResponse(), createResponse(),
createCsvFile(MCP_MAX_ACTIVITIES + 1) createCsvFile(AI_MAX_ACTIVITIES + 1)
) )
).rejects.toThrow(HttpException); ).rejects.toThrow(HttpException);
expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled();
expect(Logger.prototype.warn).toHaveBeenCalledWith( expect(Logger.prototype.warn).toHaveBeenCalledWith(
`Rejected a CSV file with ${MCP_MAX_ACTIVITIES + 1} rows` `Rejected a CSV file with ${AI_MAX_ACTIVITIES + 1} rows`
); );
}); });
it(`Accepts a CSV file with ${MCP_MAX_ACTIVITIES} rows`, async () => { it(`Accepts a CSV file with ${AI_MAX_ACTIVITIES} rows`, async () => {
const file = createCsvFile(MCP_MAX_ACTIVITIES); const file = createCsvFile(AI_MAX_ACTIVITIES);
await aiController.getActivitiesFromFile(createResponse(), file); await aiController.getActivitiesFromFile(createResponse(), file);

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

@ -10,7 +10,7 @@ import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { import {
AI_ACTIVITIES_FILE_MAXIMUM_SIZE, AI_ACTIVITIES_FILE_MAXIMUM_SIZE,
AI_ACTIVITIES_TIMEOUT, AI_ACTIVITIES_TIMEOUT,
MCP_MAX_ACTIVITIES, AI_MAX_ACTIVITIES,
THROTTLE_AI_ACTIVITIES_LIMIT, THROTTLE_AI_ACTIVITIES_LIMIT,
THROTTLE_DAILY_TTL THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -116,14 +116,14 @@ export class AiController {
if (mediaType === 'text/csv') { if (mediaType === 'text/csv') {
const dataRowCount = getDataRowCountOfCsvFile(file.buffer); const dataRowCount = getDataRowCountOfCsvFile(file.buffer);
if (dataRowCount > MCP_MAX_ACTIVITIES) { if (dataRowCount > AI_MAX_ACTIVITIES) {
this.logger.warn(`Rejected a CSV file with ${dataRowCount} rows`); this.logger.warn(`Rejected a CSV file with ${dataRowCount} rows`);
throw new HttpException( throw new HttpException(
{ {
error: getReasonPhrase(StatusCodes.BAD_REQUEST), error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [ message: [
`The CSV file has more than ${MCP_MAX_ACTIVITIES} rows of activities` `The CSV file has more than ${AI_MAX_ACTIVITIES} rows of activities`
] ]
}, },
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST

4
apps/api/src/app/endpoints/ai/ai.schemas.spec.ts

@ -1,5 +1,5 @@
import { import {
MCP_MAX_ACTIVITIES, AI_MAX_ACTIVITIES,
SEARCH_QUERY_MAXIMUM_LENGTH SEARCH_QUERY_MAXIMUM_LENGTH
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -44,7 +44,7 @@ describe('ACTIVITY_ROWS_OUTPUT', () => {
name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1) name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1)
}), }),
createActivityRow({ fee: -1, quantity: -1, unitPrice: -1 }), createActivityRow({ fee: -1, quantity: -1, unitPrice: -1 }),
...Array.from({ length: MCP_MAX_ACTIVITIES }, () => { ...Array.from({ length: AI_MAX_ACTIVITIES }, () => {
return createActivityRow(); return createActivityRow();
}) })
] ]

4
apps/api/src/app/endpoints/ai/ai.schemas.ts

@ -1,4 +1,4 @@
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; import { AI_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { Type as ActivityType } from '@prisma/client'; import { Type as ActivityType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@ -39,7 +39,7 @@ export const ACTIVITY_ROWS_OUTPUT = z.object({
unitPrice: z.number().describe('The unit price of the activity') unitPrice: z.number().describe('The unit price of the activity')
}) })
) )
.describe(`The activities of the file, at most ${MCP_MAX_ACTIVITIES}`) .describe(`The activities of the file, at most ${AI_MAX_ACTIVITIES}`)
}); });
export const ROW_MATCHES_OUTPUT = z.object({ export const ROW_MATCHES_OUTPUT = z.object({

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

@ -9,8 +9,8 @@ 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 {
AI_MAX_ACTIVITIES,
DEFAULT_LOCALE, DEFAULT_LOCALE,
MCP_MAX_ACTIVITIES,
NON_INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES,
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL, PROPERTY_OPENROUTER_MODEL,
@ -299,7 +299,7 @@ export class AiService {
'For an interest or a liability, give 1 as quantity and the amount as unit price.', 'For an interest or a liability, give 1 as quantity and the amount as unit price.',
'For a fee which is not part of a buy or a sell, give 0 as quantity, 0 as unit price and the amount as fee.', 'For a fee which is not part of a buy or a sell, give 0 as quantity, 0 as unit price and the amount as fee.',
`If the format of a date or of a number is ambiguous and the file gives no other hint, read it as in the locale ${locale}.`, `If the format of a date or of a number is ambiguous and the file gives no other hint, read it as in the locale ${locale}.`,
`Give at most ${MCP_MAX_ACTIVITIES} rows.` `Give at most ${AI_MAX_ACTIVITIES} rows.`
].join('\n'), ].join('\n'),
maxOutputTokens: AiService.VISION_MAXIMUM_OUTPUT_TOKEN_COUNT, maxOutputTokens: AiService.VISION_MAXIMUM_OUTPUT_TOKEN_COUNT,
messages: [{ content, role: 'user' }], messages: [{ content, role: 'user' }],
@ -333,7 +333,7 @@ export class AiService {
`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`
); );
return output.rows.slice(0, MCP_MAX_ACTIVITIES); return output.rows.slice(0, AI_MAX_ACTIVITIES);
} }
public async getPrompt({ public async getPrompt({

12
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -2,8 +2,8 @@ import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/fil
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { import {
AI_ACTIVITIES_FILE_MAXIMUM_SIZE, AI_ACTIVITIES_FILE_MAXIMUM_SIZE,
DEFAULT_DATE_RANGE, AI_MAX_ACTIVITIES,
MCP_MAX_ACTIVITIES DEFAULT_DATE_RANGE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
CreateAccountWithBalancesDto, CreateAccountWithBalancesDto,
@ -133,7 +133,7 @@ export class GfImportActivitiesDialogComponent {
protected isGhostfolioAiEnabled = false; protected isGhostfolioAiEnabled = false;
protected isLoading = false; protected isLoading = false;
protected isMaxActivitiesOfGhostfolioAiReached = false; protected isMaxActivitiesOfGhostfolioAiReached = false;
protected readonly maxActivitiesOfGhostfolioAi = MCP_MAX_ACTIVITIES; protected readonly maxActivitiesOfGhostfolioAi = AI_MAX_ACTIVITIES;
protected mode: 'DIVIDEND'; protected mode: 'DIVIDEND';
protected pageIndex = 0; protected pageIndex = 0;
protected readonly pageSize = 8; protected readonly pageSize = 8;
@ -387,9 +387,9 @@ export class GfImportActivitiesDialogComponent {
if ( if (
fileExtension === 'csv' && fileExtension === 'csv' &&
this.importActivitiesService.getDataRowCountOfCsv(await file.text()) > this.importActivitiesService.getDataRowCountOfCsv(await file.text()) >
MCP_MAX_ACTIVITIES AI_MAX_ACTIVITIES
) { ) {
return $localize`The CSV file has more than ${MCP_MAX_ACTIVITIES}:maxActivities: rows of activities`; return $localize`The CSV file has more than ${AI_MAX_ACTIVITIES}:maxActivities: rows of activities`;
} }
return undefined; return undefined;
@ -587,7 +587,7 @@ export class GfImportActivitiesDialogComponent {
activitiesOfFile = activities; activitiesOfFile = activities;
this.assetProfiles = assetProfiles; this.assetProfiles = assetProfiles;
this.isMaxActivitiesOfGhostfolioAiReached = this.isMaxActivitiesOfGhostfolioAiReached =
activities.length + rowsWithoutMatch.length >= MCP_MAX_ACTIVITIES; activities.length + rowsWithoutMatch.length >= AI_MAX_ACTIVITIES;
this.rowsWithoutMatch = rowsWithoutMatch; this.rowsWithoutMatch = rowsWithoutMatch;
return from( return from(

14
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss

@ -43,13 +43,6 @@
font-size: 2.5rem; font-size: 2.5rem;
} }
.mat-mdc-form-field {
.mat-mdc-progress-spinner {
right: 1.5rem;
top: calc(50% - 10px);
}
}
.mat-mdc-progress-spinner { .mat-mdc-progress-spinner {
@include mat.progress-spinner-overrides( @include mat.progress-spinner-overrides(
( (
@ -61,6 +54,13 @@
} }
} }
.mat-mdc-form-field {
.mat-mdc-progress-spinner {
right: 1.5rem;
top: calc(50% - 10px);
}
}
.mat-expansion-panel { .mat-expansion-panel {
@include mat.expansion-overrides( @include mat.expansion-overrides(
( (

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

@ -40,6 +40,7 @@ export const warnColorRgb = {
export const AI_ACTIVITIES_FILE_MAXIMUM_SIZE = 10 * 1024 * 1024; export const AI_ACTIVITIES_FILE_MAXIMUM_SIZE = 10 * 1024 * 1024;
export const AI_ACTIVITIES_TIMEOUT = ms('2 minutes'); export const AI_ACTIVITIES_TIMEOUT = ms('2 minutes');
export const AI_MAX_ACTIVITIES = 25;
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]],

Loading…
Cancel
Save