diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index bc9f6cd6ce..1634415536 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -106,9 +106,7 @@ describe('AiService', () => { getAccounts: jest.fn().mockResolvedValue({ content: [{ text: '## Accounts', type: 'text' }] }), - getPortfolio: jest.fn().mockResolvedValue({ - content: [{ text: '## Holdings', type: 'text' }] - }), + getAssetProfilesOfUser: jest.fn().mockResolvedValue([]), searchAssetProfiles: jest.fn().mockResolvedValue({ content: [{ text: '{ "assetProfiles": [] }', type: 'text' }] }) @@ -538,24 +536,22 @@ describe('AiService', () => { function mockHoldings({ dataSource = 'YAHOO' }: { dataSource?: string } = {}) { - jest.mocked(mcpService.getPortfolio).mockResolvedValue({ - content: [ - { - text: [ - '## Holdings', - '', - '| Name | ISIN | Symbol | Data Source | Currency | Asset Class | Asset Sub Class | Date of First Activity | Activities Count | Allocation in Percentage |', - '| :-------------------------------- | :----------- | :------ | :---------- | :------- | :---------- | :-------------- | :--------------------- | ---------------: | -----------------------: |', - '| Vanguard FTSE All-World UCITS ETF | IE00BK5BQT80 | VWRA.SW | YAHOO | USD | Equity | ETF | 2021-03-15 | 4 | 70.000% |', - '| Apple Inc. | | AAPL | YAHOO | USD | Equity | Stock | 2022-06-01 | 2 | 20.000% |', - '| US Dollar | | USD | | USD | Liquidity | Cash | 2021-03-15 | 1 | 10.000% |' - ] - .join('\n') - .replaceAll('| YAHOO |', `| ${dataSource.padEnd(11)} |`), - type: 'text' - } - ] - }); + jest.mocked(mcpService.getAssetProfilesOfUser).mockResolvedValue([ + { + dataSource, + currency: 'USD', + isin: 'IE00BK5BQT80', + name: 'Vanguard FTSE All-World UCITS ETF', + symbol: 'VWRA.SW' + }, + { + dataSource, + currency: 'USD', + isin: null, + name: 'Apple Inc.', + symbol: 'AAPL' + } + ]); } it('Gives an activity for each row whose asset matches an asset profile', async () => { @@ -874,31 +870,6 @@ describe('AiService', () => { ).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] }); }); - it('Gives a row whose asset match has the symbol in the currency column of a holding as a row without a match', async () => { - jest.spyOn(Logger.prototype, 'warn').mockImplementation(); - - const row = createActivityRow({ - accountName: null, - isin: 'IE00BK5BQT80' - }); - - mockRowMatches({ - assetProfilesOfSearch: [], - assets: [{ currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'USD' }] - }); - - mockHoldings(); - - await expect( - aiService.getActivitiesFromRows({ - accounts, - userCurrency, - userId, - rows: [row] - }) - ).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] }); - }); - it('Gives a row whose asset match is a holding with another ISIN as a row without a match and logs the left out match', async () => { jest.spyOn(Logger.prototype, 'warn').mockImplementation(); @@ -1009,28 +980,6 @@ describe('AiService', () => { expect(JSON.parse(prompt as string)).toHaveProperty('holdings', []); }); - it('Gives a row whose asset match is a cash position of the holdings as a row without a match', async () => { - jest.spyOn(Logger.prototype, 'warn').mockImplementation(); - - const row = createActivityRow({ accountName: null }); - - mockRowMatches({ - assetProfilesOfSearch: [], - assets: [{ currency: 'USD', dataSource: '', id: 0, symbol: 'USD' }] - }); - - mockHoldings(); - - await expect( - aiService.getActivitiesFromRows({ - accounts, - userCurrency, - userId, - rows: [row] - }) - ).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] }); - }); - it('Matches a row without an account name by the broker of the file', async () => { mockRowMatches({ accounts: [{ accountId: 'account-id', id: 0 }], @@ -1438,7 +1387,7 @@ describe('AiService', () => { ); }); - it('Gets the holdings with the tool of the server of the Model Context Protocol without values', async () => { + it('Gets the holdings of the user from the server of the Model Context Protocol', async () => { mockRowMatches({}); await aiService.getActivitiesFromRows({ @@ -1448,13 +1397,12 @@ describe('AiService', () => { rows: [createActivityRow()] }); - expect(mcpService.getPortfolio).toHaveBeenCalledWith({ - userId, - withValues: false + expect(mcpService.getAssetProfilesOfUser).toHaveBeenCalledWith({ + userId }); }); - it('Passes the currency, the data source, the ISIN, the name and the symbol of each holding without the cash position to the model', async () => { + it('Passes the currency, the data source, the ISIN, the name and the symbol of each holding to the model', async () => { mockRowMatches({}); mockHoldings(); @@ -1486,44 +1434,6 @@ describe('AiService', () => { ]); }); - it('Restores an escaped pipe in a value of the holdings', async () => { - mockRowMatches({}); - - jest.mocked(mcpService.getPortfolio).mockResolvedValue({ - content: [ - { - text: [ - '## Holdings', - '', - '| Name | ISIN | Symbol | Data Source | Currency |', - '| :---------- | :--- | :----- | :---------- | :------- |', - '| Fund A \\| B | | FAB | YAHOO | USD |' - ].join('\n'), - type: 'text' - } - ] - }); - - await aiService.getActivitiesFromRows({ - accounts, - userCurrency, - userId, - rows: [createActivityRow()] - }); - - const { prompt } = jest.mocked(generateText).mock.calls[0][0]; - - expect(JSON.parse(prompt as string)).toHaveProperty('holdings', [ - { - currency: 'USD', - dataSource: 'YAHOO', - isin: null, - name: 'Fund A | B', - symbol: 'FAB' - } - ]); - }); - it('Does not get the holdings without an asset', async () => { mockRowMatches({}); @@ -1542,7 +1452,7 @@ describe('AiService', () => { ] }); - expect(mcpService.getPortfolio).not.toHaveBeenCalled(); + expect(mcpService.getAssetProfilesOfUser).not.toHaveBeenCalled(); expect(dataProviderService.getDataSources).not.toHaveBeenCalled(); const { prompt } = jest.mocked(generateText).mock.calls[0][0]; @@ -1560,8 +1470,8 @@ describe('AiService', () => { }); jest - .mocked(mcpService.getPortfolio) - .mockRejectedValueOnce(new Error('Calculation failed')); + .mocked(mcpService.getAssetProfilesOfUser) + .mockRejectedValueOnce(new Error('Query failed')); const { activities } = await aiService.getActivitiesFromRows({ accounts, @@ -1577,7 +1487,7 @@ describe('AiService', () => { expect(JSON.parse(prompt as string)).not.toHaveProperty('holdings'); expect(Logger.prototype.warn).toHaveBeenCalledWith( - 'Could not get the holdings: Calculation failed' + 'Could not get the holdings: Query failed' ); }); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 02af5d8d0a..643f75c0af 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -136,15 +136,14 @@ export class AiService { /** * Matches the rows of a file to the asset profiles with the agent model, the - * tool search-asset-profiles and the holdings of the tool get-portfolio, and - * to the accounts of the user. Each account name of the rows, also a missing - * one, gives an account hint with the broker of the file, as a file often - * shows the broker only, for example as a logo. A row which does not give a - * valid activity, for example because its asset has no match, is a row - * without a match. An account id which is not the id of an account of the - * user is ignored. The fee, the quantity and the unit price are taken as - * absolute values, as a broker can give a sell or a fee as a negative - * number. + * tool search-asset-profiles and the holdings of the user, and to the + * accounts of the user. Each account name of the rows, also a missing one, + * gives an account hint with the broker of the file, as a file often shows + * the broker only, for example as a logo. A row which does not give a valid + * activity, for example because its asset has no match, is a row without a + * match. An account id which is not the id of an account of the user is + * ignored. The fee, the quantity and the unit price are taken as absolute + * values, as a broker can give a sell or a fee as a negative number. */ public async getActivitiesFromRows({ abortSignal, @@ -414,7 +413,7 @@ export class AiService { * 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 and get-portfolio give it, which POST + * is given as search-asset-profiles and the holdings give it, which POST * api/v1/import transforms back. */ private getActivitiesWithCustomAssetProfiles( @@ -472,67 +471,20 @@ export class AiService { } /** - * Gives the currency, the data source, the ISIN, the name and the symbol of - * each holding of the table of the holdings which has a data source and a - * symbol. A cash position has no data source, hence it is left out. The ISIN - * is null if the asset profile has none, which is the case for most stocks - * of Yahoo Finance. The columns are found by the header line. A line is - * split at each pipe which is not escaped, as the renderer escapes a pipe in - * a value, and the escaped pipes are restored. - */ - private getHoldingsFromTable(holdings = '') { - const [headerLine, , ...lines] = holdings.split('\n').filter((line) => { - return line.startsWith('|'); - }); - - if (!headerLine) { - return []; - } - - const getCells = (line: string) => { - return line - .split(/(? { - return cell.trim().replaceAll('\\|', '|'); - }); - }; - - const columnNames = getCells(headerLine); - - return lines - .map((line) => { - const cells = getCells(line); - - return { - currency: cells[columnNames.indexOf('Currency')], - dataSource: cells[columnNames.indexOf('Data Source')], - isin: cells[columnNames.indexOf('ISIN')] || null, - name: cells[columnNames.indexOf('Name')], - symbol: cells[columnNames.indexOf('Symbol')] - }; - }) - .filter(({ dataSource, symbol }) => { - return dataSource && symbol; - }); - } - - /** - * Gives the holdings of the user with the tool get-portfolio of the server - * of the Model Context Protocol, without the holdings whose data source the - * import refuses + * Gives the holdings of the user from the server of the Model Context + * Protocol, also the closed positions and the holdings of the excluded + * accounts, without a portfolio calculation and without the holdings whose + * data source the import refuses */ private async getHoldingsToMatch({ userId }: { userId: string }) { - const [{ content }, dataSources] = await Promise.all([ - this.mcpService.getPortfolio({ userId, withValues: false }), + const [assetProfiles, dataSources] = await Promise.all([ + this.mcpService.getAssetProfilesOfUser({ userId }), this.dataProviderService.getDataSources() ]); - return this.getHoldingsFromTable(content[0].text).filter( - ({ dataSource }) => { - return this.isValidDataSourceForImport({ dataSource, dataSources }); - } - ); + return assetProfiles.filter(({ dataSource }) => { + return this.isValidDataSourceForImport({ dataSource, dataSources }); + }); } /** @@ -585,27 +537,27 @@ export class AiService { } /** - * Matches the assets and the account hints with the agent model. The code - * and not the model calls the tools search-asset-profiles, get-accounts and - * get-portfolio of the server of the Model Context Protocol and passes their - * results to the model, as some providers ignore a required tool call, for - * example if the output has a schema. The accounts are given without - * values, the holdings with their currency, data source, ISIN, name and - * symbol only. The holdings let the model select an asset profile which the - * user already has, also if no search gives it, for example as no data - * provider finds its ISIN. A holding whose data source the import refuses - * is not given to the model, so that the model selects an asset profile of - * the search instead and the whole import does not fail. - * If get-accounts fails, the model gets no accounts, so that only the - * account hints have no match. If get-portfolio or the data sources fail, - * the model gets no holdings. The reasoning is disabled, as it can add - * thousands of output tokens. An asset match is left out if neither the - * search of its own asset nor the holdings gave its asset profile, or if its - * holding has another ISIN than its own asset, as the import refuses a - * symbol which the data source cannot resolve and as the model can mix up - * the ids of the assets. The ISINs are compared without spaces and in upper - * case. A holding without an ISIN is matched by the model by its name only, - * hence the code cannot check it against the asset. + * Matches the assets and the account hints with the agent model. The code and + * not the model calls the tools search-asset-profiles and get-accounts of the + * server of the Model Context Protocol, gets the holdings from it as well and + * passes their results to the model, as some providers ignore a required tool + * call, for example if the output has a schema. The accounts are given + * without values, the holdings with their currency, data source, ISIN, name + * and symbol only. The holdings let the model select an asset profile which + * the user already has, also if no search gives it, for example as no data + * provider finds its ISIN. A holding whose data source the import refuses is + * not given to the model, so that the model selects an asset profile of the + * search instead and the whole import does not fail. If get-accounts fails, + * the model gets no accounts, so that only the account hints have no match. + * If the holdings or the data sources cannot be got, the model gets no + * holdings. The reasoning is disabled, as it can add thousands of output + * tokens. An asset match is left out if neither the search of its own asset + * nor the holdings gave its asset profile, or if its holding has another ISIN + * than its own asset, as the import refuses a symbol which the data source + * cannot resolve and as the model can mix up the ids of the assets. The ISINs + * are compared without spaces and in upper case. A holding without an ISIN is + * matched by the model by its name only, hence the code cannot check it + * against the asset. */ private async getRowMatches({ abortSignal, diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index 26704c6924..e6bfa43747 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -8,6 +8,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { Module } from '@nestjs/common'; @@ -29,6 +30,7 @@ import { McpService } from './mcp.service'; ImportModule, PortfolioTableModule, SymbolModule, + SymbolProfileModule, UserModule ], providers: [ 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 d6a3211144..233cbea29d 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -6,6 +6,7 @@ import { encodeDataSource } 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DEFAULT_LANGUAGE_CODE, MCP_MAX_ACTIVITIES @@ -36,6 +37,7 @@ describe('McpService', () => { let importService: ImportService; let mcpService: McpService; let portfolioTableService: PortfolioTableService; + let symbolProfileService: SymbolProfileService; let symbolService: SymbolService; let userService: UserService; @@ -78,6 +80,10 @@ describe('McpService', () => { getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist') } as unknown as PortfolioTableService; + symbolProfileService = { + getSymbolProfilesOfUser: jest.fn().mockResolvedValue([]) + } as unknown as SymbolProfileService; + symbolService = { lookup: jest.fn().mockResolvedValue({ items: [] }) } as unknown as SymbolService; @@ -89,6 +95,7 @@ describe('McpService', () => { configurationService, importService, portfolioTableService, + symbolProfileService, symbolService, userService ); @@ -290,6 +297,63 @@ describe('McpService', () => { }); }); + describe('getAssetProfilesOfUser', () => { + it('Gives the currency, the data source, the ISIN, the name and the symbol of the asset profiles of the user', async () => { + jest + .spyOn(symbolProfileService, 'getSymbolProfilesOfUser') + .mockResolvedValue([ + { + currency: 'USD', + dataSource: DataSource.YAHOO, + isin: 'US0378331005', + name: 'Apple Inc.', + symbol: 'AAPL' + } + ]); + + await expect( + mcpService.getAssetProfilesOfUser({ userId }) + ).resolves.toEqual([ + { + currency: 'USD', + dataSource: DataSource.YAHOO, + isin: 'US0378331005', + name: 'Apple Inc.', + symbol: 'AAPL' + } + ]); + + expect(symbolProfileService.getSymbolProfilesOfUser).toHaveBeenCalledWith( + { userId } + ); + }); + + it('Encodes the data source if the subscription is enabled', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + jest + .spyOn(symbolProfileService, 'getSymbolProfilesOfUser') + .mockResolvedValue([ + { + currency: 'USD', + dataSource: DataSource.YAHOO, + isin: 'US0378331005', + name: 'Apple Inc.', + symbol: 'AAPL' + } + ]); + + await expect( + mcpService.getAssetProfilesOfUser({ userId }) + ).resolves.toEqual([ + expect.objectContaining({ + dataSource: encodeDataSource(DataSource.YAHOO) + }) + ]); + }); + }); + describe('getPerformance', () => { it('Maps the parameters of the tool to the filters', async () => { await mcpService.getPerformance({ diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 4b4c32f6c5..e6daba2238 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -8,6 +8,7 @@ import { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -31,6 +32,7 @@ export class McpService { private readonly configurationService: ConfigurationService, private readonly importService: ImportService, private readonly portfolioTableService: PortfolioTableService, + private readonly symbolProfileService: SymbolProfileService, private readonly symbolService: SymbolService, private readonly userService: UserService ) {} @@ -104,6 +106,33 @@ export class McpService { return this.getTextResult(table); } + /** + * Gives the currency, the data source, the ISIN, the name and the symbol of + * the asset profiles of the investment activities of the user, also of the + * closed positions and of the excluded accounts. It is not a tool: the + * import of activities with Ghostfolio AI uses it to match the assets + * without the portfolio calculation of the tool get-portfolio. + */ + public async getAssetProfilesOfUser({ userId }: { userId: string }) { + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesOfUser({ userId }); + + return symbolProfiles.map( + ({ currency, dataSource, isin, name, symbol }) => { + return { + currency, + isin, + name, + symbol, + dataSource: transformDataSourceInResponse({ + dataSource, + configurationService: this.configurationService + }) + }; + } + ); + } + public async getPerformance({ accountIds, assetClasses, diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts new file mode 100644 index 0000000000..4b070d5d51 --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts @@ -0,0 +1,73 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; + +import { DataSource } from '@prisma/client'; + +import { SymbolProfileService } from './symbol-profile.service'; + +describe('SymbolProfileService', () => { + describe('getSymbolProfilesOfUser', () => { + const userId = 'user-id'; + + let findManySymbolProfiles: jest.Mock; + let symbolProfileService: SymbolProfileService; + + beforeEach(() => { + findManySymbolProfiles = jest.fn().mockResolvedValue([ + { + assetProfileOverrides: null, + currency: 'USD', + dataSource: DataSource.YAHOO, + isin: 'US0378331005', + name: 'Apple Inc.', + symbol: 'AAPL' + } + ]); + + symbolProfileService = new SymbolProfileService({ + symbolProfile: { findMany: findManySymbolProfiles } + } as unknown as PrismaService); + }); + + it('gives the asset profiles of the investment activities of the user', async () => { + await expect( + symbolProfileService.getSymbolProfilesOfUser({ userId }) + ).resolves.toEqual([ + { + currency: 'USD', + dataSource: DataSource.YAHOO, + isin: 'US0378331005', + name: 'Apple Inc.', + symbol: 'AAPL' + } + ]); + + expect(findManySymbolProfiles).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + activities: { + some: { userId, type: { in: INVESTMENT_ACTIVITY_TYPES } } + } + } + }) + ); + }); + + it('gives the name of the overrides', async () => { + findManySymbolProfiles.mockResolvedValue([ + { + assetProfileOverrides: { name: 'Apple' }, + currency: 'USD', + dataSource: DataSource.YAHOO, + isin: 'US0378331005', + name: 'Apple Inc.', + symbol: 'AAPL' + } + ]); + + await expect( + symbolProfileService.getSymbolProfilesOfUser({ userId }) + ).resolves.toEqual([expect.objectContaining({ name: 'Apple' })]); + }); + }); +}); diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index ebc8a94c76..dbfe811111 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,5 +1,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { + INVESTMENT_ACTIVITY_TYPES, + UNKNOWN_KEY +} from '@ghostfolio/common/config'; import { applyAssetProfileOverrides } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, @@ -182,6 +185,37 @@ export class SymbolProfileService { }); } + /** + * Gives the currency, the data source, the ISIN, the name and the symbol of + * the asset profiles of the investment activities of the user, also of the + * closed positions and of the excluded accounts, with the overrides + */ + public async getSymbolProfilesOfUser({ userId }: { userId: string }) { + const symbolProfiles = await this.prismaService.symbolProfile.findMany({ + orderBy: [{ symbol: 'asc' }], + select: { + assetProfileOverrides: true, + currency: true, + dataSource: true, + isin: true, + name: true, + symbol: true + }, + where: { + activities: { + some: { userId, type: { in: INVESTMENT_ACTIVITY_TYPES } } + } + } + }); + + return symbolProfiles.map(({ assetProfileOverrides, ...symbolProfile }) => { + const { currency, dataSource, isin, name, symbol } = + applyAssetProfileOverrides(symbolProfile, assetProfileOverrides); + + return { currency, dataSource, isin, name, symbol }; + }); + } + public updateAssetProfileIdentifier( oldAssetProfileIdentifier: AssetProfileIdentifier, newAssetProfileIdentifier: AssetProfileIdentifier