From b547cf1e1583835f6cfac262074208347959bc78 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:41:14 +0200 Subject: [PATCH] Fix matching of assets and accounts --- .../src/app/endpoints/ai/ai.service.spec.ts | 200 +++++++++++++++++- apps/api/src/app/endpoints/ai/ai.service.ts | 78 ++++--- 2 files changed, 246 insertions(+), 32 deletions(-) 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 d038f59057..b12ef2fcea 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -461,6 +461,13 @@ describe('AiService', () => { { currency: 'CHF', id: 'account-id', name: 'Brokerage' } ]; + const rowOfMicrosoft = createActivityRow({ + accountName: null, + isin: 'US5949181045', + name: 'Microsoft Corporation', + symbol: 'MSFT' + }); + const userCurrency = 'EUR'; const userId = 'user-id'; @@ -489,6 +496,32 @@ describe('AiService', () => { } as unknown as Awaited>); } + function mockSearches( + searchResultByQuery: Record< + string, + { dataSource: string; symbol: string }[] | Error + > + ) { + jest + .mocked(mcpService.searchAssetProfiles) + .mockImplementation(({ query }) => { + const searchResult = searchResultByQuery[query] ?? []; + + if (searchResult instanceof Error) { + return Promise.reject(searchResult); + } + + return Promise.resolve({ + content: [ + { + text: JSON.stringify({ assetProfiles: searchResult }), + type: 'text' as const + } + ] + }); + }); + } + it('Gives an activity for each row whose asset matches an asset profile', async () => { const row = createActivityRow({ currency: null }); @@ -640,7 +673,41 @@ describe('AiService', () => { ); expect(Logger.prototype.warn).toHaveBeenCalledWith( - 'Left out 1 asset matches which no search gave' + 'Left out 1 asset matches which the search of their asset did not give' + ); + }); + + it('Gives a row whose asset match only the search of another asset gave as a row without a match', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + const rowOfApple = createActivityRow({ accountName: null }); + + mockRowMatches({ + assets: [ + { currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'MSFT' }, + { currency: 'USD', dataSource: 'YAHOO', id: 1, symbol: 'MSFT' } + ] + }); + + mockSearches({ + US0378331005: [{ dataSource: 'YAHOO', symbol: 'AAPL' }], + US5949181045: [{ dataSource: 'YAHOO', symbol: 'MSFT' }] + }); + + await expect( + aiService.getActivitiesFromRows({ + accounts, + userCurrency, + userId, + rows: [rowOfApple, rowOfMicrosoft] + }) + ).resolves.toEqual({ + activities: [expect.objectContaining({ symbol: 'MSFT' })], + rowsWithoutMatch: [rowOfApple] + }); + + expect(Logger.prototype.warn).toHaveBeenCalledWith( + 'Left out 1 asset matches which the search of their asset did not give' ); }); @@ -832,6 +899,37 @@ describe('AiService', () => { }); }); + it('Passes each asset with the asset profiles of its own search to the model', async () => { + mockRowMatches({}); + + mockSearches({ + US0378331005: [{ dataSource: 'YAHOO', symbol: 'AAPL' }], + US5949181045: [{ dataSource: 'YAHOO', symbol: 'MSFT' }] + }); + + await aiService.getActivitiesFromRows({ + accounts, + userCurrency, + userId, + rows: [createActivityRow({ accountName: null }), rowOfMicrosoft] + }); + + const { prompt } = jest.mocked(generateText).mock.calls[0][0]; + + const { assets } = JSON.parse(prompt as string) as { + assets: { assetProfiles: unknown[]; id: number }[]; + }; + + expect( + assets.map(({ assetProfiles, id }) => { + return { assetProfiles, id }; + }) + ).toEqual([ + { assetProfiles: [{ dataSource: 'YAHOO', symbol: 'AAPL' }], id: 0 }, + { assetProfiles: [{ dataSource: 'YAHOO', symbol: 'MSFT' }], id: 1 } + ]); + }); + it('Searches each asset with the tool of the server of the Model Context Protocol by the ISIN, then by the ticker symbol, then by the name, until a search gives asset profiles', async () => { mockRowMatches({}); @@ -886,6 +984,73 @@ describe('AiService', () => { ]); }); + it('Searches the next value of an asset if a search fails and logs the failure', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + mockRowMatches({ + assets: [ + { currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'AAPL' } + ] + }); + + jest + .mocked(mcpService.searchAssetProfiles) + .mockRejectedValueOnce(new Error('Search failed')); + + const { activities } = await aiService.getActivitiesFromRows({ + accounts, + userCurrency, + userId, + rows: [createActivityRow({ accountName: null })] + }); + + expect(activities).toEqual([expect.objectContaining({ symbol: 'AAPL' })]); + + expect(jest.mocked(mcpService.searchAssetProfiles).mock.calls).toEqual([ + [{ userId, query: 'US0378331005' }], + [{ userId, query: 'AAPL' }] + ]); + + expect(Logger.prototype.warn).toHaveBeenCalledWith( + 'Could not search the asset profiles: Search failed' + ); + }); + + it('Gives the rows of an asset whose searches all fail as rows without a match and matches the other assets', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + const rowOfApple = createActivityRow({ accountName: null }); + const searchError = new Error('Search failed'); + + mockRowMatches({ + assets: [ + { currency: null, dataSource: null, id: 0, symbol: null }, + { currency: 'USD', dataSource: 'YAHOO', id: 1, symbol: 'MSFT' } + ] + }); + + mockSearches({ + AAPL: searchError, + 'Apple Inc.': searchError, + US0378331005: searchError, + US5949181045: [{ dataSource: 'YAHOO', symbol: 'MSFT' }] + }); + + await expect( + aiService.getActivitiesFromRows({ + accounts, + userCurrency, + userId, + rows: [rowOfApple, rowOfMicrosoft] + }) + ).resolves.toEqual({ + activities: [expect.objectContaining({ symbol: 'MSFT' })], + rowsWithoutMatch: [rowOfApple] + }); + + expect(Logger.prototype.warn).toHaveBeenCalledTimes(3); + }); + it('Gets the accounts with the tool of the server of the Model Context Protocol without values', async () => { mockRowMatches({}); @@ -919,6 +1084,39 @@ describe('AiService', () => { expect(JSON.parse(prompt as string)).not.toHaveProperty('accounts'); }); + it('Passes no accounts to the model and continues if the accounts cannot be got', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + mockRowMatches({ + assets: [ + { currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'AAPL' } + ] + }); + + jest + .mocked(mcpService.getAccounts) + .mockRejectedValueOnce(new Error('Database failed')); + + const { activities } = await aiService.getActivitiesFromRows({ + accounts, + userCurrency, + userId, + rows: [createActivityRow()] + }); + + expect(activities).toEqual([ + expect.objectContaining({ accountId: undefined, symbol: 'AAPL' }) + ]); + + const { prompt } = jest.mocked(generateText).mock.calls[0][0]; + + expect(JSON.parse(prompt as string)).not.toHaveProperty('accounts'); + + expect(Logger.prototype.warn).toHaveBeenCalledWith( + 'Could not get the accounts: Database failed' + ); + }); + it('Restricts the routing to endpoints with zero data retention, disables the reasoning and gives the model no tools', async () => { mockRowMatches({}); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 81cd760ddf..ebc2513b33 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -513,10 +513,13 @@ export class AiService { * and not the model calls the tools search-asset-profiles and get-accounts * 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 - * reasoning is disabled, as it can add thousands of output tokens. An asset - * match whose asset profile no search gave is left out, as the import - * refuses a symbol which the data source cannot resolve. + * the output has a schema. The accounts are given without values. If + * get-accounts fails, the model gets no accounts, so that only the account + * hints have no match. The reasoning is disabled, as it can add thousands + * of output tokens. An asset match whose asset profile the search of its + * own asset did not give is left out, as the import refuses a symbol which + * the data source cannot resolve and as the model can mix up the ids of the + * assets. */ private async getRowMatches({ abortSignal, @@ -538,6 +541,11 @@ export class AiService { .then(({ content }) => { return content[0].text; }) + .catch((error: Error): undefined => { + this.logger.warn(`Could not get the accounts: ${error.message}`); + + return undefined; + }) : undefined, Promise.all( assets.map((asset) => { @@ -550,12 +558,6 @@ export class AiService { return assetProfiles; }); - const assetProfileIdentifiersOfSearches = new Set( - assetProfilesOfSearches.map((assetProfile) => { - return getAssetProfileIdentifier(assetProfile); - }) - ); - const { output, steps, totalUsage } = await generateText({ abortSignal, instructions: [ @@ -585,20 +587,24 @@ export class AiService { }) }); - const assetMatches = output.assets.filter(({ dataSource, symbol }) => { - return assetProfileIdentifiersOfSearches.has( - getAssetProfileIdentifier({ - symbol, - dataSource: dataSource as DataSource - }) - ); + const assetMatches = output.assets.filter(({ dataSource, id, symbol }) => { + const assetProfileIdentifier = getAssetProfileIdentifier({ + symbol, + dataSource: dataSource as DataSource + }); + + return (searches[id]?.assetProfiles ?? []).some((assetProfile) => { + return ( + getAssetProfileIdentifier(assetProfile) === assetProfileIdentifier + ); + }); }); const accountMatchCount = output.accounts.filter(({ accountId }) => { return accountId; }).length; - const assetMatchOfNoSearchCount = + const leftOutAssetMatchCount = output.assets.filter(({ symbol }) => { return symbol; }).length - assetMatches.length; @@ -607,9 +613,9 @@ export class AiService { `Matched ${assetMatches.length} of ${assets.length} assets and ${accountMatchCount} of ${accountHints.length} account hints with ${assetProfilesOfSearches.length} results of ${sumBy(searches, 'searchCount')} searches in ${this.getSecondsSince(startTime)} seconds with ${totalUsage.totalTokens} tokens for ${this.getCost(steps)} USD` ); - if (assetMatchOfNoSearchCount > 0) { + if (leftOutAssetMatchCount > 0) { this.logger.warn( - `Left out ${assetMatchOfNoSearchCount} asset matches which no search gave` + `Left out ${leftOutAssetMatchCount} asset matches which the search of their asset did not give` ); } @@ -624,7 +630,9 @@ export class AiService { * Searches the asset profiles of an asset with the tool * search-asset-profiles of the server of the Model Context Protocol: by the * ISIN first, then by the ticker symbol, then by the name, until a search - * gives asset profiles. A missing or too short value is not searched. + * gives asset profiles. A missing or too short value is not searched. A + * search which fails is logged and the next value is searched, so that the + * import continues. */ private async searchAssetProfilesOfAsset({ asset: { isin, name, symbol }, @@ -640,19 +648,27 @@ export class AiService { continue; } - const { content } = await this.mcpService.searchAssetProfiles({ - query, - userId - }); + try { + const { content } = await this.mcpService.searchAssetProfiles({ + query, + userId + }); - const { assetProfiles } = JSON.parse(content[0].text) as { - assetProfiles: AssetProfileIdentifier[]; - }; + const { assetProfiles } = JSON.parse(content[0].text) as { + assetProfiles: AssetProfileIdentifier[]; + }; - searchCount += 1; + searchCount += 1; - if (assetProfiles.length > 0) { - return { assetProfiles, searchCount }; + if (assetProfiles.length > 0) { + return { assetProfiles, searchCount }; + } + } catch (error) { + this.logger.warn( + `Could not search the asset profiles: ${ + error instanceof Error ? error.message : String(error) + }` + ); } }