Browse Source

Fix import of activities with Ghostfolio AI

pull/8128/head
Thomas Kaul 4 days ago
parent
commit
19904577ae
  1. 12
      apps/api/src/app/endpoints/ai/ai.schemas.spec.ts
  2. 6
      apps/api/src/app/endpoints/ai/ai.schemas.ts
  3. 4
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  4. 7
      apps/api/src/app/endpoints/ai/ai.service.ts
  5. 1
      apps/api/src/app/user/user.service.ts
  6. 50
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

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

@ -34,16 +34,7 @@ describe('ACTIVITY_ROWS_OUTPUT', () => {
expect(ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [row] }).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: [createActivityRow(row)] })
.success
).toBe(false);
}
});
it('Accepts a currency, a date, a printed text and a number of rows which the join checks, so that one value does not refuse all rows', () => {
it('Accepts a currency, a date, a number, a printed text and a number of rows which the join checks, so that one value does not refuse all rows', () => {
expect(
ACTIVITY_ROWS_OUTPUT.safeParse({
rows: [
@ -52,6 +43,7 @@ describe('ACTIVITY_ROWS_OUTPUT', () => {
date: '15.01.2026',
name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1)
}),
createActivityRow({ fee: -1, quantity: -1, unitPrice: -1 }),
...Array.from({ length: MCP_MAX_ACTIVITIES }, () => {
return createActivityRow();
})

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

@ -26,17 +26,17 @@ export const ACTIVITY_ROWS_OUTPUT = z.object({
date: z
.string()
.describe('The date of the activity, as an ISO 8601 date'),
fee: z.number().min(0).describe('The fee of the activity'),
fee: z.number().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, or the description of a fee, an interest or a liability, as printed'
),
quantity: z.number().min(0).describe('The quantity of the activity'),
quantity: z.number().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')
unitPrice: z.number().describe('The unit price of the activity')
})
)
.describe(`The activities of the file, at most ${MCP_MAX_ACTIVITIES}`)

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

@ -158,7 +158,7 @@ describe('AiService', () => {
expect(chat).toHaveBeenCalledWith('vision-model', {
plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }],
provider: { zdr: true },
provider: { require_parameters: true, zdr: true },
usage: { include: true }
});
});
@ -646,7 +646,7 @@ describe('AiService', () => {
});
expect(chat).toHaveBeenCalledWith('agent-model', {
provider: { zdr: true },
provider: { require_parameters: true, zdr: true },
usage: { include: true }
});

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

@ -305,7 +305,7 @@ export class AiService {
modelPropertyKey: PROPERTY_OPENROUTER_MODEL_VISION,
settings: {
plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }],
provider: { zdr: true },
provider: { require_parameters: true, zdr: true },
usage: { include: true }
}
}),
@ -479,7 +479,10 @@ export class AiService {
].join('\n'),
model: await this.getOpenRouterChatModel({
modelPropertyKey: PROPERTY_OPENROUTER_MODEL_AGENT,
settings: { provider: { zdr: true }, usage: { include: true } }
settings: {
provider: { require_parameters: true, zdr: true },
usage: { include: true }
}
}),
output: Output.object({ schema: ROW_MATCHES_OUTPUT }),
prepareStep: ({ stepNumber }) => {

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

@ -529,6 +529,7 @@ export class UserService {
return !(
permission.startsWith('create') ||
permission.startsWith('delete') ||
permission.startsWith('import') ||
permission.startsWith('update')
);
});

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

@ -351,6 +351,26 @@ export class GfImportActivitiesDialogComponent {
});
}
/**
* Gives the activities of a CSV file in the format of Ghostfolio, which is
* imported without Ghostfolio AI, or undefined for any other CSV file
*/
private async getActivitiesOfCsvInGhostfolioFormat(file: File) {
this.snackBar.open('⏳ ' + $localize`Validating data...`);
try {
return await this.importActivitiesService.importCsv({
fileContent: await file.text(),
isDryRun: true,
userAccounts: this.data.user.accounts
});
} catch {
return undefined;
} finally {
this.snackBar.dismiss();
}
}
/**
* Gives the error message of a file which the server rejects, as a rejected
* file counts towards the daily limit of Ghostfolio AI
@ -380,6 +400,11 @@ export class GfImportActivitiesDialogComponent {
private handleFile({ file, stepper }: { file: File; stepper: MatStepper }) {
const fileExtension = file.name.split('.').pop()?.toLowerCase();
this.details = [];
this.errorMessages = [];
this.isMaxActivitiesOfGhostfolioAiReached = false;
this.rowsWithoutMatch = [];
if (
this.isGhostfolioAiEnabled &&
GfImportActivitiesDialogComponent.FILE_EXTENSIONS_OF_GHOSTFOLIO_AI.includes(
@ -498,8 +523,9 @@ export class GfImportActivitiesDialogComponent {
}
/**
* Reads the file with Ghostfolio AI. The request is canceled if the dialog
* is closed, which stops the models on the server.
* Reads the file with Ghostfolio AI, except a CSV file in the format of
* Ghostfolio. The request is canceled if the dialog is closed, which stops
* the models on the server.
*/
private async handleFileWithGhostfolioAi({
file,
@ -513,10 +539,28 @@ export class GfImportActivitiesDialogComponent {
this.accounts = [];
this.activities = [];
this.assetProfiles = [];
this.isMaxActivitiesOfGhostfolioAiReached = false;
this.platforms = [];
this.tags = [];
if (fileExtension === 'csv') {
const csvInGhostfolioFormat =
await this.getActivitiesOfCsvInGhostfolioFormat(file);
if (csvInGhostfolioFormat) {
const { activities, assetProfiles } = csvInGhostfolioFormat;
this.activities = activities;
this.assetProfiles = assetProfiles;
this.dataSource = new MatTableDataSource(activities.reverse());
this.pageIndex = 0;
this.totalItems = activities.length;
this.showSelectActivitiesStep(stepper);
return;
}
}
const errorMessage = await this.getErrorMessageOfFileForGhostfolioAi({
file,
fileExtension

Loading…
Cancel
Save