Browse Source

Merge 122abc2424 into 716697ec6a

pull/7744/merge
Thomas Kaul 14 hours ago
committed by GitHub
parent
commit
15fcbaae6c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 46
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  3. 167
      apps/api/src/app/endpoints/ai/ai.service.ts
  4. 53
      apps/api/src/app/endpoints/mcp/mcp.controller.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the support for the filters of the assistant to the accounts page
- Added a tool to get the accounts of the portfolio to the server of the Model Context Protocol (MCP) (experimental)
- Extended the `GET api/v1/account` endpoint by the filters `accounts`, `assetClasses` and `tags`
### Changed

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

@ -0,0 +1,46 @@
import { AiService } from './ai.service';
// The service imports two packages which ship as an ECMAScript module only,
// which Jest cannot transform. The tests use a static method which does not
// call them, hence the mocks only make the imports resolvable.
jest.mock('@openrouter/ai-sdk-provider', () => {
return { createOpenRouter: jest.fn() };
});
jest.mock('ai', () => {
return { generateText: jest.fn() };
});
describe('AiService', () => {
describe('getAccountsTableColumnNames', () => {
it('omits the columns with a monetary value if the access does not grant to read them', () => {
const result = AiService.getAccountsTableColumnNames({
withValues: false
});
expect(result).toEqual([
'Name',
'Currency',
'Platform',
'Activities Count',
'Allocation in Percentage'
]);
});
it('gives the columns with a monetary value if the access grants to read them', () => {
const result = AiService.getAccountsTableColumnNames({
withValues: true
});
expect(result).toEqual([
'Name',
'Currency',
'Platform',
'Activities Count',
'Cash Balance',
'Value',
'Allocation in Percentage'
]);
});
});
});

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

@ -24,6 +24,40 @@ import type { ColumnDescriptor } from 'tablemark';
@Injectable()
export class AiService {
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACTIVITIES_COUNT'
| 'ALLOCATION_PERCENTAGE'
| 'BALANCE'
| 'CURRENCY'
| 'NAME'
| 'PLATFORM'
| 'VALUE';
requiresScopeToReadValues?: boolean;
} & ColumnDescriptor)[] = [
{ key: 'NAME', name: 'Name' },
{ key: 'CURRENCY', name: 'Currency' },
{ key: 'PLATFORM', name: 'Platform' },
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' },
{
align: 'right',
key: 'BALANCE',
name: 'Cash Balance',
requiresScopeToReadValues: true
},
{
align: 'right',
key: 'VALUE',
name: 'Value',
requiresScopeToReadValues: true
},
{
align: 'right',
key: 'ALLOCATION_PERCENTAGE',
name: 'Allocation in Percentage'
}
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACCOUNT'
@ -98,16 +132,30 @@ export class AiService {
private readonly propertyService: PropertyService
) {}
public static getAccountsTableColumnNames({
withValues
}: {
withValues: boolean;
}) {
return AiService.getTableColumnDefinitions({
withValues,
columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS
}).map(({ name }) => {
return name;
});
}
public static getActivitiesTableColumnNames({
withValues
}: {
withValues: boolean;
}) {
return AiService.getActivitiesTableColumnDefinitions({ withValues }).map(
({ name }) => {
return name;
}
);
return AiService.getTableColumnDefinitions({
withValues,
columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS
}).map(({ name }) => {
return name;
});
}
public static getHoldingsTableColumnNames() {
@ -116,16 +164,18 @@ export class AiService {
});
}
private static getActivitiesTableColumnDefinitions({
private static getTableColumnDefinitions<
T extends { requiresScopeToReadValues?: boolean }
>({
columnDefinitions,
withValues
}: {
columnDefinitions: readonly T[];
withValues: boolean;
}) {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter(
({ requiresScopeToReadValues }) => {
return withValues || !requiresScopeToReadValues;
}
);
return columnDefinitions.filter(({ requiresScopeToReadValues }) => {
return withValues || !requiresScopeToReadValues;
});
}
public async generateText({
@ -154,6 +204,96 @@ export class AiService {
});
}
public async getAccountsTable({
filters,
userId,
withValues
}: {
filters?: Filter[];
userId: string;
withValues: boolean;
}) {
const { accounts } =
await this.portfolioService.getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts: true
});
const accountsTableColumnDefinitions = AiService.getTableColumnDefinitions({
withValues,
columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS
});
const accountsTableRows = accounts.map(
({
activitiesCount,
allocationInPercentage,
balance,
currency,
name: label,
platform,
value
}) => {
return accountsTableColumnDefinitions.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACTIVITIES_COUNT':
row[name] = activitiesCount.toString();
break;
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'BALANCE':
row[name] = balance.toString();
break;
case 'CURRENCY':
row[name] = currency ?? '';
break;
case 'NAME':
row[name] = label ?? '';
break;
case 'PLATFORM':
row[name] = platform?.name ?? '';
break;
case 'VALUE':
row[name] = value.toString();
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const accountsSection = ['## Accounts', ''];
if (accountsTableRows.length > 0) {
accountsSection.push(
await this.getMarkdownTable({
columnDefinitions: accountsTableColumnDefinitions,
rows: accountsTableRows
})
);
} else {
accountsSection.push('No accounts found.');
}
return accountsSection.join('\n');
}
public async getActivitiesTable({
endDate,
filters,
@ -191,7 +331,10 @@ export class AiService {
});
const activitiesTableColumnDefinitions =
AiService.getActivitiesTableColumnDefinitions({ withValues });
AiService.getTableColumnDefinitions({
withValues,
columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS
});
const activitiesTableRows = activities.map(
({

53
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -19,6 +19,23 @@ import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { McpController, Tool } from '@rekog/mcp-nest';
import { z } from 'zod';
const GET_ACCOUNTS_PARAMETERS = z.object({
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the accounts to get'),
holding: z
.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: z.string().describe('The symbol of the asset profile')
})
.optional()
.describe('The asset profile of the accounts to get')
});
const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z
.array(z.enum(ActivityType))
@ -71,6 +88,42 @@ export class GhostfolioMcpController {
private readonly apiService: ApiService
) {}
@RequiresScopeOfAccess(scopes.accountRead)
@Tool({
annotations: {
openWorldHint: false,
readOnlyHint: true,
title: 'Get accounts'
},
description: `Gives the accounts of the portfolio with these columns: ${AiService.getAccountsTableColumnNames(
{ withValues: false }
).join(
', '
)}. More columns with a monetary value are added if the access grants to read them.`,
name: 'get-accounts',
parameters: GET_ACCOUNTS_PARAMETERS
})
public async getAccounts(
@Impersonation()
{ scopes: scopesOfAccess, userId }: ImpersonationContext,
@Payload()
{ assetClasses, holding }: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getAccountsTable({
filters,
userId,
withValues: hasScope(scopesOfAccess, scopes.portfolioReadValues)
});
return { content: [{ text: table, type: 'text' as const }] };
}
@RequiresScopeOfAccess(scopes.activityRead)
@Tool({
annotations: {

Loading…
Cancel
Save