diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index 844737cc60..f46ed1df88 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -1,27 +1,8 @@ -import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; -import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; -import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; -import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; -import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; -import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; -import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; -import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; -import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; -import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -32,33 +13,12 @@ import { AiService } from './ai.service'; controllers: [AiController], exports: [AiService], imports: [ - ActivitiesModule, ApiModule, - BenchmarkModule, ConfigurationModule, - DataProviderModule, - ExchangeRateDataModule, - I18nModule, - ImpersonationModule, - MarketDataModule, - PortfolioSnapshotQueueModule, - PrismaModule, + PortfolioTableModule, PropertyModule, - RedisCacheModule, - SymbolProfileModule, - TagModule, - TransformDataSourceInRequestModule, - UserModule + TransformDataSourceInRequestModule ], - providers: [ - AccountBalanceService, - AccountService, - AiService, - CurrentRateService, - MarketDataService, - PortfolioCalculatorFactory, - PortfolioService, - RulesService - ] + providers: [AiService] }) export class AiModule {} diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 758499c6e6..401bee7d00 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -1,122 +1,25 @@ -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'; +import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PROPERTY_API_KEY_OPENROUTER, PROPERTY_OPENROUTER_MODEL } from '@ghostfolio/common/config'; -import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import type { AiPromptMode } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -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 ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACTIVITIES_COUNT' - | 'ALLOCATION_PERCENTAGE' - | 'CURRENCY' - | 'EXCLUDED_FROM_ANALYSIS' - | 'ID' - | 'NAME' - | 'PLATFORM'; - } & ColumnDescriptor)[] = [ - { key: 'ID', name: 'Id' }, - { key: 'NAME', name: 'Name' }, - { key: 'CURRENCY', name: 'Currency' }, - { key: 'PLATFORM', name: 'Platform' }, - { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, - { - align: 'right', - key: 'ALLOCATION_PERCENTAGE', - name: 'Allocation in Percentage' - }, - { key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' } - ]; - - private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACCOUNT' - | 'CURRENCY' - | 'DATE' - | 'NAME' - | 'SYMBOL' - | 'TYPE' - | 'UNIT_PRICE'; - } & ColumnDescriptor)[] = [ - { key: 'DATE', name: 'Date' }, - { key: 'TYPE', name: 'Type' }, - { key: 'NAME', name: 'Name' }, - { key: 'SYMBOL', name: 'Symbol' }, - { key: 'CURRENCY', name: 'Currency' }, - { align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, - { key: 'ACCOUNT', name: 'Account' } - ]; - - private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACTIVITIES_COUNT' - | 'ALLOCATION_PERCENTAGE' - | 'ASSET_CLASS' - | 'ASSET_SUB_CLASS' - | 'CURRENCY' - | 'DATE_OF_FIRST_ACTIVITY' - | 'NAME' - | 'SYMBOL'; - } & ColumnDescriptor)[] = [ - { key: 'NAME', name: 'Name' }, - { key: 'SYMBOL', name: 'Symbol' }, - { key: 'CURRENCY', name: 'Currency' }, - { key: 'ASSET_CLASS', name: 'Asset Class' }, - { key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' }, - { key: 'DATE_OF_FIRST_ACTIVITY', name: 'Date of First Activity' }, - { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, - { - align: 'right', - key: 'ALLOCATION_PERCENTAGE', - name: 'Allocation in Percentage' - } - ]; - public constructor( - private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, - private readonly i18nService: I18nService, - private readonly portfolioService: PortfolioService, + private readonly portfolioTableService: PortfolioTableService, private readonly propertyService: PropertyService ) {} - public static getAccountsTableColumnNames() { - return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { - return name; - }); - } - - 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; - }); - } - public async generateText({ prompt, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') @@ -143,191 +46,6 @@ export class AiService { }); } - public async getAccountsTable({ - filters, - userId - }: { - filters?: Filter[]; - userId: string; - }) { - const { accounts } = - await this.portfolioService.getAccountsWithAggregations({ - filters, - userId, - withExcludedAccounts: true - }); - - const accountsTableRows = accounts.map( - ({ - activitiesCount, - allocationInPercentage, - currency, - id, - name: label, - platform, - tags - }) => { - return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.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 'CURRENCY': - row[name] = currency ?? ''; - break; - - case 'EXCLUDED_FROM_ANALYSIS': - row[name] = isAccountExcluded({ tags }).toString(); - break; - - case 'ID': - row[name] = id; - break; - - case 'NAME': - row[name] = label ?? ''; - break; - - case 'PLATFORM': - row[name] = platform?.name ?? ''; - break; - - default: - row[name] = ''; - break; - } - - return row; - }, - {} as Record - ); - } - ); - - const accountsSection = ['## Accounts', '']; - - if (accountsTableRows.length > 0) { - accountsSection.push( - await this.getMarkdownTable({ - columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, - rows: accountsTableRows - }) - ); - } else { - accountsSection.push('No accounts found.'); - } - - return accountsSection.join('\n'); - } - - public async getActivitiesTable({ - endDate, - filters, - skip = 0, - startDate, - take, - types, - userCurrency, - userId - }: { - endDate?: Date; - filters?: Filter[]; - skip?: number; - startDate?: Date; - take: number; - types?: ActivityType[]; - userCurrency: string; - userId: string; - }) { - const { activities, count } = await this.activitiesService.getActivities({ - endDate, - filters, - skip, - startDate, - take, - types, - userCurrency, - userId, - includeDrafts: true, - sortColumn: 'date', - sortDirection: 'desc', - withExcludedAccountsAndActivities: true - }); - - const activitiesTableRows = activities.map( - ({ account, assetProfile, currency, date, type, unitPrice }) => { - return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.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 'NAME': - row[name] = assetProfile.name ?? ''; - break; - - case 'SYMBOL': - row[name] = assetProfile.symbol; - break; - - case 'TYPE': - row[name] = type; - break; - - case 'UNIT_PRICE': - row[name] = unitPrice.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: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, - rows: activitiesTableRows - }) - ); - } - - return activitiesSection.join('\n'); - } - public async getPrompt({ filters, languageCode, @@ -341,98 +59,12 @@ export class AiService { userCurrency: string; userId: string; }) { - const { holdings } = await this.portfolioService.getDetails({ + const holdingsSection = await this.portfolioTableService.getHoldingsTable({ filters, - userId - }); - - const assetClassTranslations = this.getEnumTranslations({ languageCode, - id: 'assetClass', - values: Object.values(AssetClass) - }); - - const assetSubClassTranslations = this.getEnumTranslations({ - languageCode, - id: 'assetSubClass', - values: Object.values(AssetSubClass) + userId }); - const holdingsTableRows = [...holdings] - .sort((a, b) => { - return b.allocationInPercentage - a.allocationInPercentage; - }) - .map( - ({ - activitiesCount, - allocationInPercentage, - assetProfile: { - assetClass, - assetSubClass, - currency, - name: label, - symbol - }, - dateOfFirstActivity - }) => { - return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.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 'ASSET_CLASS': - row[name] = assetClassTranslations[assetClass] ?? ''; - break; - - case 'ASSET_SUB_CLASS': - row[name] = assetSubClassTranslations[assetSubClass] ?? ''; - break; - - case 'CURRENCY': - row[name] = currency; - break; - - case 'DATE_OF_FIRST_ACTIVITY': - row[name] = dateOfFirstActivity - ? format(dateOfFirstActivity, DATE_FORMAT) - : ''; - break; - - case 'NAME': - row[name] = label; - break; - - case 'SYMBOL': - row[name] = symbol; - break; - - default: - row[name] = ''; - break; - } - - return row; - }, - {} as Record - ); - } - ); - - const holdingsSection = [ - '## Holdings', - '', - await this.getMarkdownTable({ - columnDefinitions: AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, - rows: holdingsTableRows - }) - ].join('\n'); - if (mode === 'portfolio') { return holdingsSection; } @@ -451,82 +83,4 @@ export class AiService { `Provide your answer in the following language: ${languageCode}.` ].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, - values - }: { - id: string; - languageCode: string; - values: T[]; - }) { - return values.reduce( - (translations, value) => { - translations[value] = - this.i18nService.getTranslation({ - languageCode, - id: `${id}.${value}` - }) || value; - - return translations; - }, - {} 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.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts index 2e89f5fbb0..741e2a1aec 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -11,18 +11,6 @@ import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest'; import { GhostfolioMcpController } from './mcp.controller'; -// The controller reads the columns of the tables from the AiService, which -// imports two packages which ship as an ECMAScript module only, which Jest -// cannot transform. The mocks only make the imports resolvable, because no -// test calls them. -jest.mock('@openrouter/ai-sdk-provider', () => { - return { createOpenRouter: jest.fn() }; -}); - -jest.mock('ai', () => { - return { generateText: jest.fn() }; -}); - /** * Gives the metadata which a decorator sets on the method of a tool. The * prototype is read by the name of the method, hence the type of the metadata diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 486c359c08..150347a9bf 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -1,7 +1,7 @@ -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 { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; +import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext } from '@ghostfolio/common/types'; @@ -30,7 +30,7 @@ export class GhostfolioMcpController { readOnlyHint: true, title: 'Get accounts' }, - description: `Gives the accounts of the portfolio with these columns: ${AiService.getAccountsTableColumnNames().join( + description: `Gives the accounts of the portfolio with these columns: ${PortfolioTableService.getAccountsTableColumnNames().join( ', ' )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`, name: 'get-accounts', @@ -50,7 +50,7 @@ export class GhostfolioMcpController { readOnlyHint: true, title: 'Get activities' }, - description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames().join( + description: `Gives the activities of the portfolio, the most recent first, with these columns: ${PortfolioTableService.getActivitiesTableColumnNames().join( ', ' )}. 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', @@ -74,18 +74,13 @@ export class GhostfolioMcpController { readOnlyHint: true, title: 'Get portfolio' }, - description: `Gives the holdings of the portfolio with these columns: ${AiService.getHoldingsTableColumnNames().join( + description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join( ', ' )}.`, name: 'get-portfolio' }) - public async getPortfolio( - @Impersonation() { userId, userSettings }: ImpersonationContext - ) { - return this.mcpService.getPortfolio({ - userId, - userCurrency: userSettings.baseCurrency - }); + public async getPortfolio(@Impersonation() { userId }: ImpersonationContext) { + return this.mcpService.getPortfolio({ userId }); } /** diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index 0c054c7fb3..b782736c8f 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -1,10 +1,10 @@ -import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; import { ImportModule } from '@ghostfolio/api/app/import/import.module'; import { UserModule } from '@ghostfolio/api/app/user/user.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 { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { Module } from '@nestjs/common'; @@ -19,7 +19,13 @@ import { McpService } from './mcp.service'; @Module({ controllers: [GhostfolioMcpController], - imports: [AiModule, ApiModule, ConfigurationModule, ImportModule, UserModule], + imports: [ + ApiModule, + ConfigurationModule, + ImportModule, + PortfolioTableModule, + UserModule + ], providers: [ McpService, { diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts index e7085a8aa7..248de09981 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -13,17 +13,6 @@ import { z } from 'zod'; import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas'; import { McpService } from './mcp.service'; -// The service reads the tables from the AiService, which imports two packages -// which ship as an ECMAScript module only, which Jest cannot transform. The -// mocks only make the imports resolvable, because no test calls them. -jest.mock('@openrouter/ai-sdk-provider', () => { - return { createOpenRouter: jest.fn() }; -}); - -jest.mock('ai', () => { - return { generateText: jest.fn() }; -}); - type ActivityToImport = z.infer< typeof IMPORT_ACTIVITIES_PARAMETERS >['activities'][number]; @@ -77,10 +66,10 @@ describe('McpService', () => { userService = { user: jest.fn() } as unknown as UserService; mcpService = new McpService( - undefined, undefined, configurationService, importService, + undefined, userService ); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 32e7b2090b..3cd95aaa0c 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -1,9 +1,9 @@ -import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -21,10 +21,10 @@ import { @Injectable() export class McpService { public constructor( - private readonly aiService: AiService, private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly importService: ImportService, + private readonly portfolioTableService: PortfolioTableService, private readonly userService: UserService ) {} @@ -41,7 +41,10 @@ export class McpService { filterBySymbol: holding?.symbol }); - const table = await this.aiService.getAccountsTable({ filters, userId }); + const table = await this.portfolioTableService.getAccountsTable({ + filters, + userId + }); return this.getTextResult(table); } @@ -74,7 +77,7 @@ export class McpService { filterBySymbol: holding?.symbol }); - const table = await this.aiService.getActivitiesTable({ + const table = await this.portfolioTableService.getActivitiesTable({ endDate, filters, skip, @@ -88,21 +91,13 @@ export class McpService { return this.getTextResult(table); } - public async getPortfolio({ - userCurrency, - userId - }: { - userCurrency: string; - userId: string; - }) { - const prompt = await this.aiService.getPrompt({ - userCurrency, + public async getPortfolio({ userId }: { userId: string }) { + const table = await this.portfolioTableService.getHoldingsTable({ userId, - languageCode: DEFAULT_LANGUAGE_CODE, - mode: 'portfolio' + languageCode: DEFAULT_LANGUAGE_CODE }); - return this.getTextResult(prompt); + return this.getTextResult(table); } public async importActivities({ diff --git a/apps/api/src/services/portfolio-table/portfolio-table.module.ts b/apps/api/src/services/portfolio-table/portfolio-table.module.ts new file mode 100644 index 0000000000..4411bf9c8a --- /dev/null +++ b/apps/api/src/services/portfolio-table/portfolio-table.module.ts @@ -0,0 +1,14 @@ +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; +import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; + +import { Module } from '@nestjs/common'; + +import { PortfolioTableService } from './portfolio-table.service'; + +@Module({ + exports: [PortfolioTableService], + imports: [ActivitiesModule, I18nModule, PortfolioModule], + providers: [PortfolioTableService] +}) +export class PortfolioTableModule {} diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts similarity index 72% rename from apps/api/src/app/endpoints/ai/ai.service.spec.ts rename to apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts index 6328df1952..6364cbd5b6 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -2,18 +2,7 @@ import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.s import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { AccountWithValue } from '@ghostfolio/common/types'; -import { AiService } from './ai.service'; - -// The service imports two packages which ship as an ECMAScript module only, -// which Jest cannot transform. The mocks only make the imports resolvable, -// because no test calls them. -jest.mock('@openrouter/ai-sdk-provider', () => { - return { createOpenRouter: jest.fn() }; -}); - -jest.mock('ai', () => { - return { generateText: jest.fn() }; -}); +import { PortfolioTableService } from './portfolio-table.service'; /** * The markdown table is rendered by a package which ships as an ECMAScript @@ -21,7 +10,7 @@ jest.mock('ai', () => { * cannot run. The tests replace the method by a simple renderer, so that they * can read the columns and the rows which the service gives to it. */ -interface AiServiceWithMarkdownTable { +interface PortfolioTableServiceWithMarkdownTable { getMarkdownTable(parameters: { columnDefinitions: readonly { name: string }[]; rows: Record[]; @@ -50,16 +39,20 @@ function createAccount({ } as unknown as AccountWithValue; } -function createAiService(accounts: AccountWithValue[]) { +function createPortfolioTableService(accounts: AccountWithValue[]) { const portfolioService = { getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }) } as unknown as PortfolioService; - const aiService = new AiService(null, null, null, portfolioService, null); + const portfolioTableService = new PortfolioTableService( + null, + null, + portfolioService + ); jest .spyOn( - aiService as unknown as AiServiceWithMarkdownTable, + portfolioTableService as unknown as PortfolioTableServiceWithMarkdownTable, 'getMarkdownTable' ) .mockImplementation(async ({ columnDefinitions, rows }) => { @@ -79,16 +72,16 @@ function createAiService(accounts: AccountWithValue[]) { ].join('\n'); }); - return aiService; + return portfolioTableService; } -describe('AiService', () => { +describe('PortfolioTableService', () => { // The tools of the model context protocol are the only callers, and an // access of that type never grants the scope to read the monetary values, // hence no table has a column with such a value describe('getAccountsTableColumnNames', () => { it('gives no column with a monetary value', () => { - expect(AiService.getAccountsTableColumnNames()).toEqual([ + expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([ 'Id', 'Name', 'Currency', @@ -102,7 +95,7 @@ describe('AiService', () => { describe('getActivitiesTableColumnNames', () => { it('gives no column with a monetary value', () => { - expect(AiService.getActivitiesTableColumnNames()).toEqual([ + expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([ 'Date', 'Type', 'Name', @@ -116,9 +109,13 @@ describe('AiService', () => { describe('getAccountsTable', () => { it('gives no cash balance and no value of an account', async () => { - const aiService = createAiService([createAccount()]); + const portfolioTableService = createPortfolioTableService([ + createAccount() + ]); - const result = await aiService.getAccountsTable({ userId: 'user-id' }); + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id' + }); expect(result).not.toContain('Cash Balance'); expect(result).not.toContain('1000'); @@ -128,20 +125,26 @@ describe('AiService', () => { // The accountIds parameter of the tool takes the identifiers, hence the // table has to give them it('gives the identifier of an account', async () => { - const aiService = createAiService([createAccount()]); + const portfolioTableService = createPortfolioTableService([ + createAccount() + ]); - const result = await aiService.getAccountsTable({ userId: 'user-id' }); + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id' + }); expect(result).toContain('account-a-id'); }); it('marks an account which is excluded from the analysis', async () => { - const aiService = createAiService([ + const portfolioTableService = createPortfolioTableService([ createAccount({ isExcluded: true }), createAccount({ id: 'account-b-id', name: 'Account B' }) ]); - const result = await aiService.getAccountsTable({ userId: 'user-id' }); + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id' + }); const [rowOfAccountA, rowOfAccountB] = result .split('\n') @@ -154,9 +157,11 @@ describe('AiService', () => { }); it('tells that no accounts are found if the result is empty', async () => { - const aiService = createAiService([]); + const portfolioTableService = createPortfolioTableService([]); - const result = await aiService.getAccountsTable({ userId: 'user-id' }); + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id' + }); expect(result).toContain('No accounts found.'); }); diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts new file mode 100644 index 0000000000..639545019a --- /dev/null +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -0,0 +1,487 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; +import { Filter } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + Type as ActivityType +} from '@prisma/client'; +import { format } from 'date-fns'; +import type { ColumnDescriptor } from 'tablemark'; + +/** + * Renders the accounts, the activities and the holdings of a portfolio as a + * markdown table. No table has a column with a quantity or with a monetary + * value, except the unit price of an activity. + */ +@Injectable() +export class PortfolioTableService { + private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({ + key: + | 'ACTIVITIES_COUNT' + | 'ALLOCATION_PERCENTAGE' + | 'CURRENCY' + | 'EXCLUDED_FROM_ANALYSIS' + | 'ID' + | 'NAME' + | 'PLATFORM'; + } & ColumnDescriptor)[] = [ + { key: 'ID', name: 'Id' }, + { key: 'NAME', name: 'Name' }, + { key: 'CURRENCY', name: 'Currency' }, + { key: 'PLATFORM', name: 'Platform' }, + { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, + { + align: 'right', + key: 'ALLOCATION_PERCENTAGE', + name: 'Allocation in Percentage' + }, + { key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' } + ]; + + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ + key: + | 'ACCOUNT' + | 'CURRENCY' + | 'DATE' + | 'NAME' + | 'SYMBOL' + | 'TYPE' + | 'UNIT_PRICE'; + } & ColumnDescriptor)[] = [ + { key: 'DATE', name: 'Date' }, + { key: 'TYPE', name: 'Type' }, + { key: 'NAME', name: 'Name' }, + { key: 'SYMBOL', name: 'Symbol' }, + { key: 'CURRENCY', name: 'Currency' }, + { align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, + { key: 'ACCOUNT', name: 'Account' } + ]; + + private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ + key: + | 'ACTIVITIES_COUNT' + | 'ALLOCATION_PERCENTAGE' + | 'ASSET_CLASS' + | 'ASSET_SUB_CLASS' + | 'CURRENCY' + | 'DATE_OF_FIRST_ACTIVITY' + | 'NAME' + | 'SYMBOL'; + } & ColumnDescriptor)[] = [ + { key: 'NAME', name: 'Name' }, + { key: 'SYMBOL', name: 'Symbol' }, + { key: 'CURRENCY', name: 'Currency' }, + { key: 'ASSET_CLASS', name: 'Asset Class' }, + { key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' }, + { key: 'DATE_OF_FIRST_ACTIVITY', name: 'Date of First Activity' }, + { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, + { + align: 'right', + key: 'ALLOCATION_PERCENTAGE', + name: 'Allocation in Percentage' + } + ]; + + public constructor( + private readonly activitiesService: ActivitiesService, + private readonly i18nService: I18nService, + private readonly portfolioService: PortfolioService + ) {} + + public static getAccountsTableColumnNames() { + return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + + public static getActivitiesTableColumnNames() { + return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + + public static getHoldingsTableColumnNames() { + return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + + public async getAccountsTable({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }) { + const { accounts } = + await this.portfolioService.getAccountsWithAggregations({ + filters, + userId, + withExcludedAccounts: true + }); + + const accountsTableRows = accounts.map( + ({ + activitiesCount, + allocationInPercentage, + currency, + id, + name: label, + platform, + tags + }) => { + return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.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 'CURRENCY': + row[name] = currency ?? ''; + break; + + case 'EXCLUDED_FROM_ANALYSIS': + row[name] = isAccountExcluded({ tags }).toString(); + break; + + case 'ID': + row[name] = id; + break; + + case 'NAME': + row[name] = label ?? ''; + break; + + case 'PLATFORM': + row[name] = platform?.name ?? ''; + break; + + default: + row[name] = ''; + break; + } + + return row; + }, + {} as Record + ); + } + ); + + const accountsSection = ['## Accounts', '']; + + if (accountsTableRows.length > 0) { + accountsSection.push( + await this.getMarkdownTable({ + columnDefinitions: + PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, + rows: accountsTableRows + }) + ); + } else { + accountsSection.push('No accounts found.'); + } + + return accountsSection.join('\n'); + } + + public async getActivitiesTable({ + endDate, + filters, + skip = 0, + startDate, + take, + types, + userCurrency, + userId + }: { + endDate?: Date; + filters?: Filter[]; + skip?: number; + startDate?: Date; + take: number; + types?: ActivityType[]; + userCurrency: string; + userId: string; + }) { + const { activities, count } = await this.activitiesService.getActivities({ + endDate, + filters, + skip, + startDate, + take, + types, + userCurrency, + userId, + includeDrafts: true, + sortColumn: 'date', + sortDirection: 'desc', + withExcludedAccountsAndActivities: true + }); + + const activitiesTableRows = activities.map( + ({ account, assetProfile, currency, date, type, unitPrice }) => { + return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.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 'NAME': + row[name] = assetProfile.name ?? ''; + break; + + case 'SYMBOL': + row[name] = assetProfile.symbol; + break; + + case 'TYPE': + row[name] = type; + break; + + case 'UNIT_PRICE': + row[name] = unitPrice.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: + PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, + rows: activitiesTableRows + }) + ); + } + + return activitiesSection.join('\n'); + } + + public async getHoldingsTable({ + filters, + languageCode, + userId + }: { + filters?: Filter[]; + languageCode: string; + userId: string; + }) { + const { holdings } = await this.portfolioService.getDetails({ + filters, + userId + }); + + const assetClassTranslations = this.getEnumTranslations({ + languageCode, + id: 'assetClass', + values: Object.values(AssetClass) + }); + + const assetSubClassTranslations = this.getEnumTranslations({ + languageCode, + id: 'assetSubClass', + values: Object.values(AssetSubClass) + }); + + const holdingsTableRows = [...holdings] + .sort((a, b) => { + return b.allocationInPercentage - a.allocationInPercentage; + }) + .map( + ({ + activitiesCount, + allocationInPercentage, + assetProfile: { + assetClass, + assetSubClass, + currency, + name: label, + symbol + }, + dateOfFirstActivity + }) => { + return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.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 'ASSET_CLASS': + row[name] = assetClassTranslations[assetClass] ?? ''; + break; + + case 'ASSET_SUB_CLASS': + row[name] = assetSubClassTranslations[assetSubClass] ?? ''; + break; + + case 'CURRENCY': + row[name] = currency; + break; + + case 'DATE_OF_FIRST_ACTIVITY': + row[name] = dateOfFirstActivity + ? format(dateOfFirstActivity, DATE_FORMAT) + : ''; + break; + + case 'NAME': + row[name] = label; + break; + + case 'SYMBOL': + row[name] = symbol; + break; + + default: + row[name] = ''; + break; + } + + return row; + }, + {} as Record + ); + } + ); + + return [ + '## Holdings', + '', + await this.getMarkdownTable({ + columnDefinitions: + PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, + rows: holdingsTableRows + }) + ].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, + values + }: { + id: string; + languageCode: string; + values: T[]; + }) { + return values.reduce( + (translations, value) => { + translations[value] = + this.i18nService.getTranslation({ + languageCode, + id: `${id}.${value}` + }) || value; + + return translations; + }, + {} 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' }; + }) + }); + } +}