Browse Source

Extend tools with data source

pull/8019/head
Thomas Kaul 2 weeks ago
parent
commit
7b5c738dff
  1. 15
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  2. 12
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  3. 7
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  4. 3
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  5. 2
      apps/api/src/helper/data-source.helper.ts
  6. 47
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  7. 72
      apps/api/src/services/portfolio-table/portfolio-table.service.ts

15
apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts

@ -56,6 +56,21 @@ describe('GET_ACCOUNTS_PARAMETERS', () => {
}).success }).success
).toBe(false); ).toBe(false);
}); });
it('Gives the valid data sources in the error of an unknown data source of the holding', () => {
for (const dataSource of ['yahoo', 1]) {
const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({
holding: { dataSource, symbol: 'AAPL' }
});
expect(error.issues).toEqual([
expect.objectContaining({
message: expect.stringContaining(`|${DataSource.YAHOO}`),
path: ['holding', 'dataSource']
})
]);
}
});
}); });
describe('GET_ACTIVITIES_PARAMETERS', () => { describe('GET_ACTIVITIES_PARAMETERS', () => {

12
apps/api/src/app/endpoints/mcp/mcp.schemas.ts

@ -18,8 +18,18 @@ import {
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`;
const DATA_SOURCE_PARAMETER = z const DATA_SOURCE_PARAMETER = z
.union([z.enum(DataSource), z.string().refine(isValidEncodedDataSource)]) .union(
[
z.enum(DataSource),
z.string().refine(isValidEncodedDataSource, {
error: DATA_SOURCE_PARAMETER_ERROR
})
],
{ error: DATA_SOURCE_PARAMETER_ERROR }
)
.describe('The data source of the asset profile'); .describe('The data source of the asset profile');
const SYMBOL_PARAMETER = z const SYMBOL_PARAMETER = z

7
apps/api/src/app/endpoints/mcp/mcp.service.spec.ts

@ -353,14 +353,15 @@ describe('McpService', () => {
}); });
describe('getPortfolio', () => { describe('getPortfolio', () => {
it('Gives the table of the holdings in the default language', async () => { it('Gives the table of the holdings with the data source in the default language', async () => {
expect(await mcpService.getPortfolio({ userId })).toEqual({ expect(await mcpService.getPortfolio({ userId })).toEqual({
content: [{ text: '## Holdings', type: 'text' }] content: [{ text: '## Holdings', type: 'text' }]
}); });
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({ expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({
userId, userId,
languageCode: DEFAULT_LANGUAGE_CODE languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
}); });
}); });
}); });
@ -437,8 +438,6 @@ describe('McpService', () => {
}); });
}); });
// A response of the instance encodes the data source for a user who is
// not an admin, and the client of the access is not a user
it('Encodes the data source if the subscription is enabled', async () => { it('Encodes the data source if the subscription is enabled', async () => {
setupUser([permissions.createActivity]); setupUser([permissions.createActivity]);

3
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -121,7 +121,8 @@ export class McpService {
public async getPortfolio({ userId }: { userId: string }) { public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({ const table = await this.portfolioTableService.getHoldingsTable({
userId, userId,
languageCode: DEFAULT_LANGUAGE_CODE languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
}); });
return this.getTextResult(table); return this.getTextResult(table);

2
apps/api/src/helper/data-source.helper.ts

@ -115,7 +115,7 @@ export function transformDataSourceInRequest({
configurationService: ConfigurationService; configurationService: ConfigurationService;
dataSource?: string; dataSource?: string;
}) { }) {
if (DataSource[dataSource]) { if (Object.hasOwn(DataSource, dataSource)) {
return getUnmaskedGhostfolioDataSource({ return getUnmaskedGhostfolioDataSource({
dataSource: dataSource as DataSource, dataSource: dataSource as DataSource,
ghostfolioDataSources: getGhostfolioDataSources({ configurationService }) ghostfolioDataSources: getGhostfolioDataSources({ configurationService })

47
apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts

@ -380,9 +380,8 @@ describe('PortfolioTableService', () => {
expect(result).toContain('No accounts found.'); expect(result).toContain('No accounts found.');
}); });
}); });
describe('getActivitiesTable', () => { describe('getActivitiesTable', () => {
// The holding parameter of the tools takes the data source, which a
// response of the instance encodes, hence the table encodes it alike
it('gives the data source, encoded if the subscription is enabled', async () => { it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await createPortfolioTableService({ const result = await createPortfolioTableService({
activities: [ activities: [
@ -411,17 +410,33 @@ describe('PortfolioTableService', () => {
describe('getHoldingsTable', () => { describe('getHoldingsTable', () => {
function getHoldingsTable( function getHoldingsTable(
holdings: PortfolioPosition[], holdings: PortfolioPosition[],
configuration?: Record<string, unknown> {
configuration,
withDataSource
}: {
configuration?: Record<string, unknown>;
withDataSource?: boolean;
} = {}
) { ) {
return createPortfolioTableService({ return createPortfolioTableService({
configuration, configuration,
holdings holdings
}).getHoldingsTable({ }).getHoldingsTable({
withDataSource,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id' userId: 'user-id'
}); });
} }
it('gives no data source by default', async () => {
const result = await getHoldingsTable([createHolding()], {
configuration: SUBSCRIPTION_CONFIGURATION
});
expect(result).not.toContain('Data Source');
expect(result).not.toContain(encodeDataSource(DataSource.YAHOO));
});
it('gives the data source, encoded if the subscription is enabled', async () => { it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await getHoldingsTable( const result = await getHoldingsTable(
[ [
@ -432,7 +447,7 @@ describe('PortfolioTableService', () => {
symbol: 'GF_GOLD' symbol: 'GF_GOLD'
}) })
], ],
SUBSCRIPTION_CONFIGURATION { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true }
); );
const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => {
@ -445,7 +460,8 @@ describe('PortfolioTableService', () => {
it('gives the data source unencoded if the subscription is disabled', async () => { it('gives the data source unencoded if the subscription is disabled', async () => {
const result = await getHoldingsTable([createHolding()], { const result = await getHoldingsTable([createHolding()], {
ENABLE_FEATURE_SUBSCRIPTION: false configuration: { ENABLE_FEATURE_SUBSCRIPTION: false },
withDataSource: true
}); });
const [row] = result.split('\n').filter((line) => { const [row] = result.split('\n').filter((line) => {
@ -455,6 +471,27 @@ describe('PortfolioTableService', () => {
expect(row).toContain(`| ${DataSource.YAHOO} |`); expect(row).toContain(`| ${DataSource.YAHOO} |`);
}); });
it('gives no data source of a cash position', async () => {
const result = await getHoldingsTable(
[
createHolding({
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
dataSource: DataSource.YAHOO,
symbol: 'USD'
})
],
{ configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true }
);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of USD');
});
expect(row).toContain('| USD | | CHF |');
expect(row).not.toContain(encodeDataSource(DataSource.YAHOO));
});
it('gives the translation of the asset class and of the asset sub class', async () => { it('gives the translation of the asset class and of the asset sub class', async () => {
const result = await getHoldingsTable([createHolding()]); const result = await getHoldingsTable([createHolding()]);

72
apps/api/src/services/portfolio-table/portfolio-table.service.ts

@ -6,7 +6,11 @@ import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-c
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
isAccountExcluded,
isCashPosition
} from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
Filter, Filter,
@ -19,6 +23,7 @@ import { Injectable } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource,
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -26,6 +31,23 @@ import { format } from 'date-fns';
import { DataSourceTableContext } from './interfaces/data-source-table-context.interface'; import { DataSourceTableContext } from './interfaces/data-source-table-context.interface';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
const DATA_SOURCE_COLUMN_NAME = 'Data Source';
function getDataSourceColumnDefinition<T>(
getDataSource: (row: T) => DataSource | undefined
): TableColumnDefinition<T, DataSourceTableContext> {
return {
getValue: (row, { configurationService }) => {
const dataSource = getDataSource(row);
return dataSource
? transformDataSourceInResponse({ configurationService, dataSource })
: '';
},
name: DATA_SOURCE_COLUMN_NAME
};
}
function getPercentage(value: number) { function getPercentage(value: number) {
return `${(value * 100).toFixed(3)}%`; return `${(value * 100).toFixed(3)}%`;
} }
@ -114,15 +136,9 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
{ getDataSourceColumnDefinition(({ assetProfile }) => {
getValue: ({ assetProfile }, { configurationService }) => { return assetProfile.dataSource;
return transformDataSourceInResponse({ }),
configurationService,
dataSource: assetProfile.dataSource
});
},
name: 'Data Source'
},
{ {
getValue: ({ assetProfile, currency }) => { getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency; return currency ?? assetProfile.currency;
@ -158,15 +174,11 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
{ getDataSourceColumnDefinition(({ assetProfile }) => {
getValue: ({ assetProfile }, { configurationService }) => { return isCashPosition(assetProfile)
return transformDataSourceInResponse({ ? undefined
configurationService, : assetProfile.dataSource;
dataSource: assetProfile.dataSource }),
});
},
name: 'Data Source'
},
{ {
getValue: ({ assetProfile }) => { getValue: ({ assetProfile }) => {
return assetProfile.currency; return assetProfile.currency;
@ -260,15 +272,9 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
{ getDataSourceColumnDefinition(({ dataSource }) => {
getValue: ({ dataSource }, { configurationService }) => { return dataSource;
return transformDataSourceInResponse({ }),
configurationService,
dataSource
});
},
name: 'Data Source'
},
{ {
getValue: ({ trend50d }) => { getValue: ({ trend50d }) => {
return trend50d; return trend50d;
@ -445,11 +451,13 @@ export class PortfolioTableService {
public async getHoldingsTable({ public async getHoldingsTable({
filters, filters,
languageCode, languageCode,
userId userId,
withDataSource = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
languageCode: string; languageCode: string;
userId: string; userId: string;
withDataSource?: boolean;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters, filters,
@ -477,7 +485,11 @@ export class PortfolioTableService {
'', '',
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter(
({ name }) => {
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
}
),
context: { context: {
assetClassTranslations, assetClassTranslations,
assetSubClassTranslations, assetSubClassTranslations,

Loading…
Cancel
Save