Browse Source

Fix import of activities with Ghostfolio AI

pull/8128/head
Thomas Kaul 4 days ago
parent
commit
4cf70e2a46
  1. 14
      apps/api/src/app/endpoints/ai/ai.controller.spec.ts
  2. 6
      apps/api/src/app/endpoints/ai/ai.controller.ts
  3. 54
      apps/api/src/app/endpoints/ai/ai.schemas.spec.ts
  4. 30
      apps/api/src/app/endpoints/ai/ai.schemas.ts
  5. 117
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  6. 148
      apps/api/src/app/endpoints/ai/ai.service.ts
  7. 62
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  8. 10
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  9. 6
      libs/common/src/lib/interfaces/responses/ai-activities-response.interface.ts

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

@ -73,9 +73,11 @@ describe('AiController', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(); jest.spyOn(Logger.prototype, 'warn').mockImplementation();
aiService = { aiService = {
getActivitiesFromFile: jest getActivitiesFromFile: jest.fn().mockResolvedValue({
.fn() activities: [],
.mockResolvedValue({ activities: [], rowsWithoutMatch: [] }) assetProfiles: [],
rowsWithoutMatch: []
})
} as unknown as AiService; } as unknown as AiService;
aiController = new AiController( aiController = new AiController(
@ -158,7 +160,11 @@ describe('AiController', () => {
it('Passes the file with its media type and the user of the request', async () => { it('Passes the file with its media type and the user of the request', async () => {
await expect( await expect(
aiController.getActivitiesFromFile(createResponse(), pdfFile) aiController.getActivitiesFromFile(createResponse(), pdfFile)
).resolves.toEqual({ activities: [], rowsWithoutMatch: [] }); ).resolves.toEqual({
activities: [],
assetProfiles: [],
rowsWithoutMatch: []
});
expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({

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

@ -66,14 +66,15 @@ export class AiController {
/** /**
* Reads the activities of a CSV, JPEG, PDF or PNG file with Ghostfolio AI, * 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 * without saving anything. The client imports the activities which the user
* selects with POST api/v1/import. * 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
* request before the response.
*/ */
@HasPermission(permissions.importActivitiesWithAi) @HasPermission(permissions.importActivitiesWithAi)
@HttpCode(StatusCodes.OK) @HttpCode(StatusCodes.OK)
@Post('activities') @Post('activities')
@Throttle({ @Throttle({
default: { default: {
// The limit applies to each user instead of each IP address
getTracker: ({ user }: RequestWithUser) => { getTracker: ({ user }: RequestWithUser) => {
return user.id; return user.id;
}, },
@ -128,7 +129,6 @@ export class AiController {
} }
} }
// Stops the models if the client closes the request before the response
const abortController = new AbortController(); const abortController = new AbortController();
response.on('close', () => { response.on('close', () => {

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

@ -34,25 +34,6 @@ describe('ACTIVITY_ROWS_OUTPUT', () => {
expect(ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [row] }).success).toBe(false); expect(ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [row] }).success).toBe(false);
}); });
it('Refuses a currency which is not an ISO 4217 code in upper case', () => {
for (const currency of ['usd', 'USDOLLAR', '$']) {
expect(
ACTIVITY_ROWS_OUTPUT.safeParse({
rows: [createActivityRow({ currency })]
}).success
).toBe(false);
}
});
it('Refuses a date which is not an ISO 8601 date after 1970', () => {
for (const date of ['15.01.2026', '1969-12-31']) {
expect(
ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [createActivityRow({ date })] })
.success
).toBe(false);
}
});
it('Refuses a negative fee, quantity or unit price', () => { it('Refuses a negative fee, quantity or unit price', () => {
for (const row of [{ fee: -1 }, { quantity: -1 }, { unitPrice: -1 }]) { for (const row of [{ fee: -1 }, { quantity: -1 }, { unitPrice: -1 }]) {
expect( expect(
@ -62,24 +43,21 @@ describe('ACTIVITY_ROWS_OUTPUT', () => {
} }
}); });
it('Refuses a printed text which is longer than the maximum length', () => { 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', () => {
expect(
ACTIVITY_ROWS_OUTPUT.safeParse({
rows: [
createActivityRow({ name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH) })
]
}).success
).toBe(true);
expect( expect(
ACTIVITY_ROWS_OUTPUT.safeParse({ ACTIVITY_ROWS_OUTPUT.safeParse({
rows: [ rows: [
createActivityRow({ createActivityRow({
currency: 'GBX',
date: '15.01.2026',
name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1) name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1)
}),
...Array.from({ length: MCP_MAX_ACTIVITIES }, () => {
return createActivityRow();
}) })
] ]
}).success }).success
).toBe(false); ).toBe(true);
}); });
it('Trims a printed text', () => { it('Trims a printed text', () => {
@ -89,24 +67,6 @@ describe('ACTIVITY_ROWS_OUTPUT', () => {
expect(data.rows[0].name).toBe('Apple Inc.'); expect(data.rows[0].name).toBe('Apple Inc.');
}); });
it(`Refuses more than ${MCP_MAX_ACTIVITIES} rows`, () => {
expect(
ACTIVITY_ROWS_OUTPUT.safeParse({
rows: Array.from({ length: MCP_MAX_ACTIVITIES }, () => {
return createActivityRow();
})
}).success
).toBe(true);
expect(
ACTIVITY_ROWS_OUTPUT.safeParse({
rows: Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => {
return createActivityRow();
})
}).success
).toBe(false);
});
}); });
describe.each([ describe.each([

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

@ -1,29 +1,14 @@
import { import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
MCP_MAX_ACTIVITIES,
SEARCH_QUERY_MAXIMUM_LENGTH
} from '@ghostfolio/common/config';
import {
isValidCurrencyCode,
isValidDateAfter1970
} from '@ghostfolio/common/helper';
import { Type as ActivityType } from '@prisma/client'; import { Type as ActivityType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
// The strict mode of the structured outputs requires every property, hence a // The strict mode of the structured outputs requires every property, hence a
// value which the file does not show is null instead of being left out. The // value which the file does not show is null instead of being left out. The
// maximum length is checked by a refinement, because the strict mode does not // values are checked when the rows and the matches are joined to the
// support every keyword of JSON Schema // activities to import, so that a wrong value or a wrong match skips its rows
const PRINTED_TEXT = z // only
.string() const PRINTED_TEXT = z.string().trim().nullable();
.trim()
.refine(
(value) => {
return value.length <= SEARCH_QUERY_MAXIMUM_LENGTH;
},
{ error: `Too long: expected at most ${SEARCH_QUERY_MAXIMUM_LENGTH}` }
)
.nullable();
export const ACTIVITY_ROWS_OUTPUT = z.object({ export const ACTIVITY_ROWS_OUTPUT = z.object({
rows: z rows: z
@ -34,14 +19,12 @@ export const ACTIVITY_ROWS_OUTPUT = z.object({
), ),
currency: z currency: z
.string() .string()
.refine(isValidCurrencyCode)
.nullable() .nullable()
.describe( .describe(
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
), ),
date: z date: z
.string() .string()
.refine(isValidDateAfter1970)
.describe('The date of the activity, as an ISO 8601 date'), .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().min(0).describe('The fee of the activity'),
isin: PRINTED_TEXT.describe('The ISIN of the asset, as printed'), isin: PRINTED_TEXT.describe('The ISIN of the asset, as printed'),
@ -56,12 +39,9 @@ export const ACTIVITY_ROWS_OUTPUT = z.object({
unitPrice: z.number().min(0).describe('The unit price of the activity') unitPrice: z.number().min(0).describe('The unit price of the activity')
}) })
) )
.max(MCP_MAX_ACTIVITIES)
.describe(`The activities of the file, at most ${MCP_MAX_ACTIVITIES}`) .describe(`The activities of the file, at most ${MCP_MAX_ACTIVITIES}`)
}); });
// The values are checked when the rows and the matches are joined to the
// activities to import, so that a wrong match skips its rows only
export const ROW_MATCHES_OUTPUT = z.object({ export const ROW_MATCHES_OUTPUT = z.object({
accounts: z accounts: z
.array( .array(

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

@ -9,11 +9,12 @@ import {
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 { isValidCustomAssetProfileSymbol } from '@ghostfolio/common/helper';
import type { UserWithSettings } from '@ghostfolio/common/types'; 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 { Type as ActivityType } from '@prisma/client'; import { DataSource, Type as ActivityType } from '@prisma/client';
import { generateText, isStepCount, ToolSet } from 'ai'; import { generateText, isStepCount, ToolSet } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
@ -157,7 +158,8 @@ describe('AiService', () => {
expect(chat).toHaveBeenCalledWith('vision-model', { expect(chat).toHaveBeenCalledWith('vision-model', {
plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }], plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }],
provider: { zdr: true } provider: { zdr: true },
usage: { include: true }
}); });
}); });
@ -228,7 +230,11 @@ describe('AiService', () => {
await expect( await expect(
aiService.getActivitiesFromFile({ abortSignal, file, user }) aiService.getActivitiesFromFile({ abortSignal, file, user })
).resolves.toEqual({ activities, rowsWithoutMatch: [] }); ).resolves.toEqual({
activities,
assetProfiles: [],
rowsWithoutMatch: []
});
expect(aiService.getActivityRowsFromFile).toHaveBeenCalledWith({ expect(aiService.getActivityRowsFromFile).toHaveBeenCalledWith({
abortSignal, abortSignal,
@ -245,6 +251,46 @@ describe('AiService', () => {
}); });
}); });
it('Gives a fee, an interest or a liability a custom asset profile with its description as name', async () => {
const fee = {
currency: 'CHF',
date: '2026-01-15',
fee: 5,
quantity: 0,
symbol: 'Custody fee',
type: ActivityType.FEE,
unitPrice: 0
};
jest.spyOn(aiService, 'getActivityRowsFromFile').mockResolvedValue(rows);
jest.spyOn(aiService, 'getActivitiesFromRows').mockResolvedValue({
activities: [fee, { ...fee, date: '2026-02-15' }],
rowsWithoutMatch: []
});
const { activities, assetProfiles } =
await aiService.getActivitiesFromFile({ file, user });
const [{ symbol }] = assetProfiles;
expect(isValidCustomAssetProfileSymbol(symbol)).toBe(true);
expect(assetProfiles).toEqual([
{
symbol,
currency: 'CHF',
dataSource: DataSource.MANUAL,
name: 'Custody fee'
}
]);
expect(activities).toEqual([
{ ...fee, symbol, dataSource: DataSource.MANUAL },
{ ...fee, symbol, dataSource: DataSource.MANUAL, date: '2026-02-15' }
]);
});
it('Reads the file in the default locale if the user has no locale', async () => { it('Reads the file in the default locale if the user has no locale', async () => {
jest.spyOn(aiService, 'getActivityRowsFromFile').mockResolvedValue([]); jest.spyOn(aiService, 'getActivityRowsFromFile').mockResolvedValue([]);
@ -276,13 +322,34 @@ describe('AiService', () => {
function mockRowMatches({ function mockRowMatches({
accounts: accountMatches = [], accounts: accountMatches = [],
assetProfilesOfSearch,
assets: assetMatches = [] assets: assetMatches = []
}: Partial<z.infer<typeof ROW_MATCHES_OUTPUT>>) { }: Partial<z.infer<typeof ROW_MATCHES_OUTPUT>> & {
jest.mocked(generateText).mockResolvedValueOnce({ assetProfilesOfSearch?: { dataSource: string; symbol: string }[];
}) {
jest.mocked(mcpService.searchAssetProfiles).mockResolvedValueOnce({
content: [
{
text: JSON.stringify({
assetProfiles: assetProfilesOfSearch ?? assetMatches
}),
type: 'text'
}
]
});
jest.mocked(generateText).mockImplementationOnce(async ({ tools }) => {
await tools['search-asset-profiles'].execute(
{ query: 'US0378331005' },
undefined
);
return {
output: { accounts: accountMatches, assets: assetMatches }, output: { accounts: accountMatches, assets: assetMatches },
steps: [{}, {}], steps: [{}, {}],
totalUsage: { totalTokens: 2000 } totalUsage: { totalTokens: 2000 }
} as unknown as Awaited<ReturnType<typeof generateText>>); } as unknown as Awaited<ReturnType<typeof generateText>>;
});
} }
it('Gives an activity for each row whose asset matches an asset profile', async () => { it('Gives an activity for each row whose asset matches an asset profile', async () => {
@ -373,6 +440,26 @@ describe('AiService', () => {
).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] }); ).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] });
}); });
it('Gives a row whose asset profile no search gave as a row without a match', async () => {
const row = createActivityRow({ accountName: null });
mockRowMatches({
assetProfilesOfSearch: [{ dataSource: 'YAHOO', symbol: 'AAPL' }],
assets: [
{ currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'AAPL.XX' }
]
});
await expect(
aiService.getActivitiesFromRows({
accounts,
userCurrency,
userId,
rows: [row]
})
).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] });
});
it('Ignores an account id which is not the id of an account of the user', async () => { it('Ignores an account id which is not the id of an account of the user', async () => {
mockRowMatches({ mockRowMatches({
accounts: [{ accountId: 'unknown-account-id', name: 'Brokerage' }], accounts: [{ accountId: 'unknown-account-id', name: 'Brokerage' }],
@ -547,7 +634,7 @@ describe('AiService', () => {
}); });
}); });
it('Restricts the routing to endpoints with zero data retention and limits the number of steps', async () => { it('Restricts the routing to endpoints with zero data retention, limits the number of steps and allows no tool call in the last step', async () => {
mockRowMatches({}); mockRowMatches({});
await aiService.getActivitiesFromRows({ await aiService.getActivitiesFromRows({
@ -559,7 +646,8 @@ describe('AiService', () => {
}); });
expect(chat).toHaveBeenCalledWith('agent-model', { expect(chat).toHaveBeenCalledWith('agent-model', {
provider: { zdr: true } provider: { zdr: true },
usage: { include: true }
}); });
expect(isStepCount).toHaveBeenCalled(); expect(isStepCount).toHaveBeenCalled();
@ -572,9 +660,20 @@ describe('AiService', () => {
}) })
); );
const { stopWhen } = jest.mocked(generateText).mock.calls[0][0]; const { prepareStep, stopWhen } = jest.mocked(generateText).mock
.calls[0][0] as {
prepareStep: (options: { stepNumber: number }) => unknown;
stopWhen: unknown;
};
expect(stopWhen).toBe(jest.mocked(isStepCount).mock.results[0].value); expect(stopWhen).toBe(jest.mocked(isStepCount).mock.results[0].value);
const [stepCount] = jest.mocked(isStepCount).mock.calls[0];
expect(prepareStep({ stepNumber: stepCount - 2 })).toBeUndefined();
expect(prepareStep({ stepNumber: stepCount - 1 })).toEqual({
toolChoice: 'none'
});
}); });
it('Falls back to the general model if no agent model is set', async () => { it('Falls back to the general model if no agent model is set', async () => {

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

@ -16,8 +16,16 @@ import {
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 { DATE_FORMAT } from '@ghostfolio/common/helper'; import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/common/dtos';
import { AiActivitiesResponse, Filter } from '@ghostfolio/common/interfaces'; import {
DATE_FORMAT,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import {
AiActivitiesResponse,
AssetProfileIdentifier,
Filter
} from '@ghostfolio/common/interfaces';
import type { import type {
AiPromptMode, AiPromptMode,
PropertyKey, PropertyKey,
@ -27,7 +35,8 @@ import type {
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
createOpenRouter, createOpenRouter,
OpenRouterChatSettings OpenRouterChatSettings,
OpenRouterUsageAccounting
} from '@openrouter/ai-sdk-provider'; } from '@openrouter/ai-sdk-provider';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
@ -35,11 +44,14 @@ import {
generateText, generateText,
isStepCount, isStepCount,
Output, Output,
ProviderMetadata,
TextPart, TextPart,
tool tool
} from 'ai'; } from 'ai';
import { Big } from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { keyBy, uniq } from 'lodash-es'; import { keyBy, uniq } from 'lodash-es';
import { randomUUID } from 'node:crypto';
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';
@ -50,6 +62,7 @@ import { AssetToMatch } from './types/asset-to-match.type';
@Injectable() @Injectable()
export class AiService { export class AiService {
private static readonly AGENT_MAXIMUM_STEP_COUNT = 10; private static readonly AGENT_MAXIMUM_STEP_COUNT = 10;
private static readonly VISION_MAXIMUM_OUTPUT_TOKEN_COUNT = 16384;
private readonly logger = new Logger(AiService.name); private readonly logger = new Logger(AiService.name);
@ -114,12 +127,8 @@ export class AiService {
}); });
return { return {
rowsWithoutMatch, ...this.getActivitiesWithCustomAssetProfiles(activities),
activities: activities.map(({ dataSource, ...activity }) => { rowsWithoutMatch
// The data source is given as search-asset-profiles gives it, which
// POST api/v1/import transforms back
return { ...activity, dataSource: dataSource as DataSource };
})
}; };
} }
@ -127,7 +136,8 @@ export class AiService {
* 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
* 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. * no match, is a row without a match. An account id which is not the id of
* an account of the user is ignored.
*/ */
public async getActivitiesFromRows({ public async getActivitiesFromRows({
abortSignal, abortSignal,
@ -203,7 +213,6 @@ export class AiService {
unitPrice unitPrice
} = row; } = row;
// An id which is not the id of an account of the user is ignored
const account = accounts.find(({ id }) => { const account = accounts.find(({ id }) => {
return id === accountIdByName.get(accountName); return id === accountIdByName.get(accountName);
}); });
@ -251,9 +260,12 @@ export class AiService {
/** /**
* Reads the activities of a file (CSV, JPEG, PNG or PDF) with the vision * Reads the activities of a file (CSV, JPEG, PNG or PDF) with the vision
* model. The model gets no tools and copies the asset and the account as * model. A CSV file is passed as text, every other file as a file part. The
* printed, hence the rows still have to be matched to asset profiles and * native engine passes a PDF file to the model itself, as a parser plugin of
* accounts. * 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
* still have to be matched to asset profiles and accounts. A row above the
* maximum number of activities is left out.
*/ */
public async getActivityRowsFromFile({ public async getActivityRowsFromFile({
abortSignal, abortSignal,
@ -266,13 +278,12 @@ export class AiService {
}): Promise<ActivityRow[]> { }): Promise<ActivityRow[]> {
const startTime = performance.now(); const startTime = performance.now();
// 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, totalUsage } = await generateText({ const { output, steps, 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.',
@ -288,24 +299,24 @@ export class AiService {
`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 ${MCP_MAX_ACTIVITIES} rows.`
].join('\n'), ].join('\n'),
maxOutputTokens: AiService.VISION_MAXIMUM_OUTPUT_TOKEN_COUNT,
messages: [{ content, role: 'user' }], messages: [{ content, role: 'user' }],
model: await this.getOpenRouterChatModel({ model: await this.getOpenRouterChatModel({
modelPropertyKey: PROPERTY_OPENROUTER_MODEL_VISION, modelPropertyKey: PROPERTY_OPENROUTER_MODEL_VISION,
settings: { settings: {
// The native engine passes a PDF file to the model itself. A parser
// plugin of OpenRouter is not covered by the zero data retention.
plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }], plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }],
provider: { zdr: true } provider: { zdr: true },
usage: { include: true }
} }
}), }),
output: Output.object({ schema: ACTIVITY_ROWS_OUTPUT }) output: Output.object({ schema: ACTIVITY_ROWS_OUTPUT })
}); });
this.logger.log( this.logger.log(
`Read ${output.rows.length} rows from a file in ${this.getSecondsSince(startTime)} seconds with ${totalUsage.totalTokens} tokens` `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; return output.rows.slice(0, MCP_MAX_ACTIVITIES);
} }
public async getPrompt({ public async getPrompt({
@ -346,10 +357,67 @@ export class AiService {
].join('\n'); ].join('\n');
} }
/**
* Gives each fee, interest and liability a custom asset profile with its
* description as name, as the import after the dry run requires a valid
* symbol for the data source MANUAL. The data source of every other activity
* is given as search-asset-profiles gives it, which POST api/v1/import
* transforms back.
*/
private getActivitiesWithCustomAssetProfiles(
activities: ActivityToImport[]
): Omit<AiActivitiesResponse, 'rowsWithoutMatch'> {
const assetProfiles: CreateAssetProfileWithMarketDataDto[] = [];
const activitiesWithCustomAssetProfiles = activities.map((activity) => {
if (activity.dataSource) {
return { ...activity, dataSource: activity.dataSource as DataSource };
}
let assetProfile = assetProfiles.find(({ name }) => {
return name === activity.symbol;
});
if (!assetProfile) {
assetProfile = {
currency: activity.currency,
dataSource: DataSource.MANUAL,
name: activity.symbol,
symbol: randomUUID()
};
assetProfiles.push(assetProfile);
}
return {
...activity,
dataSource: DataSource.MANUAL,
symbol: assetProfile.symbol
};
});
return { assetProfiles, activities: activitiesWithCustomAssetProfiles };
}
private getAssetKey({ currency, isin, name, symbol }: ActivityRow) { private getAssetKey({ currency, isin, name, symbol }: ActivityRow) {
return JSON.stringify([currency, isin, name, symbol]); return JSON.stringify([currency, isin, name, symbol]);
} }
/**
* Gives the cost in USD of the steps, as OpenRouter gives it
*/
private getCost(steps: { providerMetadata: ProviderMetadata | undefined }[]) {
return steps
.reduce((cost, { providerMetadata }) => {
const { usage } = (providerMetadata?.openrouter ?? {}) as {
usage?: OpenRouterUsageAccounting;
};
return cost.plus(usage?.cost ?? 0);
}, new Big(0))
.toNumber();
}
/** /**
* Gives the chat model of the property, with the general model as fallback * Gives the chat model of the property, with the general model as fallback
*/ */
@ -377,6 +445,12 @@ export class AiService {
); );
} }
/**
* 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
* asset match whose asset profile no search gave is left out, as the import
* refuses a symbol which the data source cannot resolve.
*/
private async getRowMatches({ private async getRowMatches({
abortSignal, abortSignal,
accountNames, accountNames,
@ -390,6 +464,7 @@ export class AiService {
assets: AssetToMatch[]; assets: AssetToMatch[];
userId: string; userId: string;
}) { }) {
const assetProfileIdentifiersOfSearches = new Set<string>();
const startTime = performance.now(); const startTime = performance.now();
const { output, steps, totalUsage } = await generateText({ const { output, steps, totalUsage } = await generateText({
@ -404,9 +479,14 @@ export class AiService {
].join('\n'), ].join('\n'),
model: await this.getOpenRouterChatModel({ model: await this.getOpenRouterChatModel({
modelPropertyKey: PROPERTY_OPENROUTER_MODEL_AGENT, modelPropertyKey: PROPERTY_OPENROUTER_MODEL_AGENT,
settings: { provider: { zdr: true } } settings: { provider: { zdr: true }, usage: { include: true } }
}), }),
output: Output.object({ schema: ROW_MATCHES_OUTPUT }), output: Output.object({ schema: ROW_MATCHES_OUTPUT }),
prepareStep: ({ stepNumber }) => {
return stepNumber === AiService.AGENT_MAXIMUM_STEP_COUNT - 1
? { toolChoice: 'none' }
: undefined;
},
prompt: JSON.stringify({ prompt: JSON.stringify({
accountNames, accountNames,
assets, assets,
@ -427,6 +507,16 @@ export class AiService {
userId userId
}); });
const { assetProfiles } = JSON.parse(content[0].text) as {
assetProfiles: AssetProfileIdentifier[];
};
for (const assetProfile of assetProfiles) {
assetProfileIdentifiersOfSearches.add(
getAssetProfileIdentifier(assetProfile)
);
}
return content[0].text; return content[0].text;
}, },
inputSchema: SEARCH_ASSET_PROFILES_PARAMETERS inputSchema: SEARCH_ASSET_PROFILES_PARAMETERS
@ -435,10 +525,20 @@ export class AiService {
}); });
this.logger.log( 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` `Matched ${assets.length} assets and ${accountNames.length} account names in ${steps.length} steps and ${this.getSecondsSince(startTime)} seconds with ${totalUsage.totalTokens} tokens for ${this.getCost(steps)} USD`
); );
return output; return {
accounts: output.accounts,
assets: output.assets.filter(({ dataSource, symbol }) => {
return assetProfileIdentifiersOfSearches.has(
getAssetProfileIdentifier({
symbol,
dataSource: dataSource as DataSource
})
);
})
};
} }
private getSecondsSince(startTime: number) { private getSecondsSince(startTime: number) {

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

@ -1,6 +1,7 @@
import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive'; import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive';
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,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
MCP_MAX_ACTIVITIES MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -133,6 +134,7 @@ export class GfImportActivitiesDialogComponent {
protected importStep: ImportStep = ImportStep.UPLOAD_FILE; protected importStep: ImportStep = ImportStep.UPLOAD_FILE;
protected isGhostfolioAiEnabled = false; protected isGhostfolioAiEnabled = false;
protected isLoading = false; protected isLoading = false;
protected isMaxActivitiesOfGhostfolioAiReached = false;
protected readonly maxActivitiesOfGhostfolioAi = MCP_MAX_ACTIVITIES; protected readonly maxActivitiesOfGhostfolioAi = MCP_MAX_ACTIVITIES;
protected mode: 'DIVIDEND'; protected mode: 'DIVIDEND';
protected pageIndex = 0; protected pageIndex = 0;
@ -308,6 +310,7 @@ export class GfImportActivitiesDialogComponent {
this.details = []; this.details = [];
this.errorMessages = []; this.errorMessages = [];
this.importStep = ImportStep.SELECT_ACTIVITIES; this.importStep = ImportStep.SELECT_ACTIVITIES;
this.isMaxActivitiesOfGhostfolioAiReached = false;
this.pageIndex = 0; this.pageIndex = 0;
this.rowsWithoutMatch = []; this.rowsWithoutMatch = [];
this.assetProfileForm.controls.assetProfileIdentifier.enable(); this.assetProfileForm.controls.assetProfileIdentifier.enable();
@ -348,6 +351,32 @@ export class GfImportActivitiesDialogComponent {
}); });
} }
/**
* Gives the error message of a file which the server rejects, as a rejected
* file counts towards the daily limit of Ghostfolio AI
*/
private async getErrorMessageOfFileForGhostfolioAi({
file,
fileExtension
}: {
file: File;
fileExtension?: string;
}) {
if (file.size > AI_ACTIVITIES_FILE_MAXIMUM_SIZE) {
return $localize`The file is larger than ${AI_ACTIVITIES_FILE_MAXIMUM_SIZE / 1024 / 1024}:maxFileSize: MB`;
}
if (
fileExtension === 'csv' &&
this.importActivitiesService.getDataRowCountOfCsv(await file.text()) >
MCP_MAX_ACTIVITIES
) {
return $localize`The CSV file has more than ${MCP_MAX_ACTIVITIES}:maxActivities: rows of activities`;
}
return undefined;
}
private handleFile({ file, stepper }: { file: File; stepper: MatStepper }) { private handleFile({ file, stepper }: { file: File; stepper: MatStepper }) {
const fileExtension = file.name.split('.').pop()?.toLowerCase(); const fileExtension = file.name.split('.').pop()?.toLowerCase();
@ -468,6 +497,10 @@ 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.
*/
private async handleFileWithGhostfolioAi({ private async handleFileWithGhostfolioAi({
file, file,
fileExtension, fileExtension,
@ -480,25 +513,19 @@ export class GfImportActivitiesDialogComponent {
this.accounts = []; this.accounts = [];
this.activities = []; this.activities = [];
this.assetProfiles = []; this.assetProfiles = [];
this.isMaxActivitiesOfGhostfolioAiReached = false;
this.platforms = []; this.platforms = [];
this.tags = []; this.tags = [];
// A CSV file is checked before the upload, because a file which the const errorMessage = await this.getErrorMessageOfFileForGhostfolioAi({
// server rejects counts towards the daily limit of Ghostfolio AI file,
if ( fileExtension
fileExtension === 'csv' && });
this.importActivitiesService.getDataRowCountOfCsv(await file.text()) >
MCP_MAX_ACTIVITIES if (errorMessage) {
) {
this.handleImportError({ this.handleImportError({
activities: [], activities: [],
error: { error: { error: { message: [errorMessage] } }
error: {
message: [
$localize`The CSV file has more than ${MCP_MAX_ACTIVITIES}:maxActivities: rows of activities`
]
}
}
}); });
this.showSelectActivitiesStep(stepper); this.showSelectActivitiesStep(stepper);
@ -511,17 +538,20 @@ export class GfImportActivitiesDialogComponent {
let activitiesOfFile: CreateOrderDto[] = []; let activitiesOfFile: CreateOrderDto[] = [];
// The request is canceled if the dialog is closed
this.dataService this.dataService
.postAiActivities(file) .postAiActivities(file)
.pipe( .pipe(
switchMap(({ activities, rowsWithoutMatch }) => { switchMap(({ activities, assetProfiles, rowsWithoutMatch }) => {
activitiesOfFile = activities; activitiesOfFile = activities;
this.assetProfiles = assetProfiles;
this.isMaxActivitiesOfGhostfolioAiReached =
activities.length + rowsWithoutMatch.length >= MCP_MAX_ACTIVITIES;
this.rowsWithoutMatch = rowsWithoutMatch; this.rowsWithoutMatch = rowsWithoutMatch;
return from( return from(
this.importActivitiesService.importJson({ this.importActivitiesService.importJson({
activities, activities,
assetProfiles,
isDryRun: true isDryRun: true
}) })
); );

10
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -185,6 +185,16 @@
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>
} }
@if (isMaxActivitiesOfGhostfolioAiReached) {
<div class="align-items-center d-flex mb-3 pl-1">
<ion-icon class="mr-2" name="warning-outline" />
<span i18n
>Ghostfolio AI has read the maximum of
{{ maxActivitiesOfGhostfolioAi }} activities. The file can
have more activities.</span
>
</div>
}
<gf-activities-table <gf-activities-table
[baseCurrency]="data?.user?.settings?.baseCurrency" [baseCurrency]="data?.user?.settings?.baseCurrency"
[dataSource]="dataSource" [dataSource]="dataSource"

6
libs/common/src/lib/interfaces/responses/ai-activities-response.interface.ts

@ -1,8 +1,12 @@
import type { CreateOrderDto } from '@ghostfolio/common/dtos'; import type {
CreateAssetProfileWithMarketDataDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { AiActivityRow } from '../ai-activity-row.interface'; import { AiActivityRow } from '../ai-activity-row.interface';
export interface AiActivitiesResponse { export interface AiActivitiesResponse {
activities: CreateOrderDto[]; activities: CreateOrderDto[];
assetProfiles: CreateAssetProfileWithMarketDataDto[];
rowsWithoutMatch: AiActivityRow[]; rowsWithoutMatch: AiActivityRow[];
} }

Loading…
Cancel
Save