diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 3284d2a15..435f7dec4 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -1,3 +1,4 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; @@ -12,13 +13,58 @@ import type { AiPromptMode } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -import { AssetClass, AssetSubClass } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + Type as ActivityType +} from '@prisma/client'; import { generateText } from 'ai'; import { format } from 'date-fns'; import type { ColumnDescriptor } from 'tablemark'; @Injectable() export class AiService { + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ + key: + | 'ACCOUNT' + | 'CURRENCY' + | 'DATE' + | 'FEE' + | 'NAME' + | 'QUANTITY' + | 'SYMBOL' + | 'TYPE' + | 'UNIT_PRICE' + | 'VALUE'; + requiresScopeToReadValues?: boolean; + } & ColumnDescriptor)[] = [ + { key: 'DATE', name: 'Date' }, + { key: 'TYPE', name: 'Type' }, + { key: 'NAME', name: 'Name' }, + { key: 'SYMBOL', name: 'Symbol' }, + { key: 'CURRENCY', name: 'Currency' }, + { + align: 'right', + key: 'QUANTITY', + name: 'Quantity', + requiresScopeToReadValues: true + }, + { align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, + { + align: 'right', + key: 'FEE', + name: 'Fee', + requiresScopeToReadValues: true + }, + { + align: 'right', + key: 'VALUE', + name: 'Value', + requiresScopeToReadValues: true + }, + { key: 'ACCOUNT', name: 'Account' } + ]; + private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ key: | 'ACTIVITIES_COUNT' @@ -45,12 +91,19 @@ export class AiService { ]; public constructor( + private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, private readonly i18nService: I18nService, private readonly portfolioService: PortfolioService, private readonly propertyService: PropertyService ) {} + public static getActivitiesTableColumnNames() { + return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { + return name; + }); + } + public static getHoldingsTableColumnNames() { return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { return name; @@ -83,6 +136,139 @@ export class AiService { }); } + public async getActivitiesTable({ + endDate, + filters, + skip = 0, + startDate, + take, + types, + userCurrency, + userId, + withValues + }: { + endDate?: Date; + filters?: Filter[]; + skip?: number; + startDate?: Date; + take: number; + types?: ActivityType[]; + userCurrency: string; + userId: string; + withValues: boolean; + }) { + const { activities, count } = await this.activitiesService.getActivities({ + endDate, + filters, + skip, + startDate, + take, + types, + userCurrency, + userId, + includeDrafts: true, + sortColumn: 'date', + sortDirection: 'desc', + withExcludedAccountsAndActivities: true + }); + + const activitiesTableColumnDefinitions = + AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter( + ({ requiresScopeToReadValues }) => { + return withValues || !requiresScopeToReadValues; + } + ); + + const activitiesTableRows = activities.map( + ({ + account, + assetProfile, + currency, + date, + fee, + quantity, + type, + unitPrice, + value + }) => { + return activitiesTableColumnDefinitions.reduce( + (row, { key, name }) => { + switch (key) { + case 'ACCOUNT': + row[name] = account?.name ?? ''; + break; + + case 'CURRENCY': + row[name] = currency ?? assetProfile.currency; + break; + + case 'DATE': + row[name] = format(date, DATE_FORMAT); + break; + + case 'FEE': + row[name] = fee.toString(); + break; + + case 'NAME': + row[name] = assetProfile.name ?? ''; + break; + + case 'QUANTITY': + row[name] = quantity.toString(); + break; + + case 'SYMBOL': + row[name] = assetProfile.symbol; + break; + + case 'TYPE': + row[name] = type; + break; + + case 'UNIT_PRICE': + row[name] = unitPrice.toString(); + break; + + case 'VALUE': + row[name] = value.toString(); + break; + + default: + row[name] = ''; + break; + } + + return row; + }, + {} as Record + ); + } + ); + + const activitiesSection = [ + '## Activities', + '', + this.getActivitiesSummary({ + count, + skip, + numberOfActivities: activities.length + }) + ]; + + if (activitiesTableRows.length > 0) { + activitiesSection.push( + '', + await this.getMarkdownTable({ + columnDefinitions: activitiesTableColumnDefinitions, + rows: activitiesTableRows + }) + ); + } + + return activitiesSection.join('\n'); + } + public async getPrompt({ filters, languageCode, @@ -101,11 +287,6 @@ export class AiService { userId }); - const holdingsTableColumns: ColumnDescriptor[] = - AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => { - return { name, align: align ?? 'left' }; - }); - const assetClassTranslations = this.getEnumTranslations({ languageCode, id: 'assetClass', @@ -184,18 +365,12 @@ export class AiService { } ); - // Dynamic import to load ESM module from CommonJS context - // eslint-disable-next-line @typescript-eslint/no-implied-eval - const dynamicImport = new Function('s', 'return import(s)') as ( - s: string - ) => Promise; - const { tablemark } = await dynamicImport('tablemark'); - const holdingsSection = [ '## Holdings', '', - tablemark(holdingsTableRows, { - columns: holdingsTableColumns + await this.getMarkdownTable({ + columnDefinitions: AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, + rows: holdingsTableRows }) ].join('\n'); @@ -218,6 +393,40 @@ export class AiService { ].join('\n'); } + private getActivitiesSummary({ + count, + numberOfActivities, + skip + }: { + count: number; + numberOfActivities: number; + skip: number; + }) { + if (count === 0) { + return 'No activities found.'; + } + + if (numberOfActivities === 0) { + return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`; + } + + if (numberOfActivities === count) { + return `Showing all ${count} activities, the most recent first.`; + } + + const lastActivity = skip + numberOfActivities; + + const summary = `Showing the activities ${ + skip + 1 + } to ${lastActivity} of ${count}, the most recent first.`; + + if (lastActivity === count) { + return summary; + } + + return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; + } + private getEnumTranslations({ id, languageCode, @@ -240,4 +449,25 @@ export class AiService { {} as Record ); } + + private async getMarkdownTable({ + columnDefinitions, + rows + }: { + columnDefinitions: readonly ColumnDescriptor[]; + rows: Record[]; + }) { + // Dynamic import to load ESM module from CommonJS context + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const dynamicImport = new Function('s', 'return import(s)') as ( + s: string + ) => Promise; + const { tablemark } = await dynamicImport('tablemark'); + + return tablemark(rows, { + columns: columnDefinitions.map(({ align, name }) => { + return { name, align: align ?? 'left' }; + }) + }); + } } diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 3fc93219a..81c82a1c2 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -1,18 +1,153 @@ import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator'; +import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; -import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; -import { scopes } from '@ghostfolio/common/scopes'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + DATE_RANGES, + DEFAULT_LANGUAGE_CODE, + MCP_MAX_ACTIVITIES +} from '@ghostfolio/common/config'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext } from '@ghostfolio/common/types'; import { UseFilters } from '@nestjs/common'; +import { Payload } from '@nestjs/microservices'; +import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { McpController, Tool } from '@rekog/mcp-nest'; +import { z } from 'zod'; + +const GET_ACTIVITIES_PARAMETERS = z.object({ + activityTypes: z + .array(z.enum(ActivityType)) + .optional() + .describe('The types of the activities to get'), + assetClasses: z + .array(z.enum(AssetClass)) + .optional() + .describe('The asset classes of the activities to get'), + dataSource: z + .enum(DataSource) + .optional() + .describe( + 'The data source of the asset profile, which only takes effect together with the symbol' + ), + range: z + .string() + .regex(DATE_RANGE_PATTERN) + .optional() + .describe( + `The date range of the activities to get, either ${DATE_RANGES.join( + ', ' + )} or a calendar year like 2024` + ), + skip: z + .number() + .int() + .min(0) + .optional() + .describe('The number of activities to skip'), + symbol: z + .string() + .optional() + .describe( + 'The symbol of the asset profile, which only takes effect together with the data source' + ), + take: z + .number() + .int() + .min(1) + .max(MCP_MAX_ACTIVITIES) + .optional() + .describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) +}); @McpController() @UseFilters(McpToolExceptionFilter) export class GhostfolioMcpController { - public constructor(private readonly aiService: AiService) {} + public constructor( + private readonly aiService: AiService, + private readonly apiService: ApiService + ) {} + + @RequiresScopeOfAccess(scopes.activityRead) + @Tool({ + annotations: { + openWorldHint: false, + readOnlyHint: true, + title: 'Get activities' + }, + description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames().join( + ', ' + )}. The columns with a monetary value are omitted if the access does not grant to read them. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`, + name: 'get-activities', + parameters: GET_ACTIVITIES_PARAMETERS + }) + public async getActivities( + @Impersonation() + { + filters, + scopes: scopesOfAccess, + userId, + userSettings + }: ImpersonationContext, + @Payload() + { + activityTypes, + assetClasses, + dataSource, + range, + skip, + symbol, + take + }: z.infer + ) { + let endDate: Date; + let startDate: Date; + + if (range) { + ({ endDate, startDate } = getIntervalFromDateRange({ + dateRange: range + })); + } + + const filtersOfAccess = filters ?? []; + + const typesOfFiltersOfAccess = new Set( + filtersOfAccess.map(({ type }) => { + return type; + }) + ); + + // A filter of the tool is dropped if the access already restricts its + // type, because the filters of a type are combined with a logical or and + // a tool must never widen the access + const filtersOfTool = this.apiService + .buildFiltersFromQueryParams({ + filterByAssetClasses: assetClasses?.join(','), + filterByDataSource: dataSource, + filterBySymbol: symbol + }) + .filter(({ type }) => { + return !typesOfFiltersOfAccess.has(type); + }); + + const table = await this.aiService.getActivitiesTable({ + endDate, + skip, + startDate, + userId, + filters: [...filtersOfAccess, ...filtersOfTool], + take: take ?? MCP_MAX_ACTIVITIES, + types: activityTypes, + userCurrency: userSettings.baseCurrency, + withValues: hasScope(scopesOfAccess, scopes.portfolioReadValues) + }); + + return { content: [{ text: table, type: 'text' as const }] }; + } @RequiresScopeOfAccess(scopes.portfolioRead) @Tool({ diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index e77bc3b19..264f14083 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -1,5 +1,6 @@ import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; import { environment } from '@ghostfolio/api/environments/environment'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { MCP_ENDPOINT } from '@ghostfolio/common/config'; @@ -15,7 +16,7 @@ import { GhostfolioMcpController } from './mcp.controller'; @Module({ controllers: [GhostfolioMcpController], - imports: [AiModule, ConfigurationModule], + imports: [AiModule, ApiModule, ConfigurationModule], providers: [ { inject: [ConfigurationService], diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 228503042..f90d1e663 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -269,6 +269,7 @@ export const HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED = export const MAX_TOP_HOLDINGS = 50; export const MCP_ENDPOINT = '/mcp'; +export const MCP_MAX_ACTIVITIES = 100; export const MCP_REALM = 'Ghostfolio'; export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100;