Browse Source

Fix matching of assets and accounts

pull/8140/head
Thomas Kaul 3 days ago
parent
commit
b547cf1e15
  1. 200
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  2. 52
      apps/api/src/app/endpoints/ai/ai.service.ts

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

@ -461,6 +461,13 @@ describe('AiService', () => {
{ currency: 'CHF', id: 'account-id', name: 'Brokerage' } { currency: 'CHF', id: 'account-id', name: 'Brokerage' }
]; ];
const rowOfMicrosoft = createActivityRow({
accountName: null,
isin: 'US5949181045',
name: 'Microsoft Corporation',
symbol: 'MSFT'
});
const userCurrency = 'EUR'; const userCurrency = 'EUR';
const userId = 'user-id'; const userId = 'user-id';
@ -489,6 +496,32 @@ describe('AiService', () => {
} as unknown as Awaited<ReturnType<typeof generateText>>); } as unknown as Awaited<ReturnType<typeof generateText>>);
} }
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 () => { it('Gives an activity for each row whose asset matches an asset profile', async () => {
const row = createActivityRow({ currency: null }); const row = createActivityRow({ currency: null });
@ -640,7 +673,41 @@ describe('AiService', () => {
); );
expect(Logger.prototype.warn).toHaveBeenCalledWith( 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 () => { 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({}); 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 () => { it('Gets the accounts with the tool of the server of the Model Context Protocol without values', async () => {
mockRowMatches({}); mockRowMatches({});
@ -919,6 +1084,39 @@ describe('AiService', () => {
expect(JSON.parse(prompt as string)).not.toHaveProperty('accounts'); 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 () => { it('Restricts the routing to endpoints with zero data retention, disables the reasoning and gives the model no tools', async () => {
mockRowMatches({}); mockRowMatches({});

52
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 * 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 * 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 model, as some providers ignore a required tool call, for example if
* the output has a schema. The accounts are given without values. The * the output has a schema. The accounts are given without values. If
* reasoning is disabled, as it can add thousands of output tokens. An asset * get-accounts fails, the model gets no accounts, so that only the account
* match whose asset profile no search gave is left out, as the import * hints have no match. The reasoning is disabled, as it can add thousands
* refuses a symbol which the data source cannot resolve. * 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({ private async getRowMatches({
abortSignal, abortSignal,
@ -538,6 +541,11 @@ export class AiService {
.then(({ content }) => { .then(({ content }) => {
return content[0].text; return content[0].text;
}) })
.catch((error: Error): undefined => {
this.logger.warn(`Could not get the accounts: ${error.message}`);
return undefined;
})
: undefined, : undefined,
Promise.all( Promise.all(
assets.map((asset) => { assets.map((asset) => {
@ -550,12 +558,6 @@ export class AiService {
return assetProfiles; return assetProfiles;
}); });
const assetProfileIdentifiersOfSearches = new Set(
assetProfilesOfSearches.map((assetProfile) => {
return getAssetProfileIdentifier(assetProfile);
})
);
const { output, steps, totalUsage } = await generateText({ const { output, steps, totalUsage } = await generateText({
abortSignal, abortSignal,
instructions: [ instructions: [
@ -585,20 +587,24 @@ export class AiService {
}) })
}); });
const assetMatches = output.assets.filter(({ dataSource, symbol }) => { const assetMatches = output.assets.filter(({ dataSource, id, symbol }) => {
return assetProfileIdentifiersOfSearches.has( const assetProfileIdentifier = getAssetProfileIdentifier({
getAssetProfileIdentifier({
symbol, symbol,
dataSource: dataSource as DataSource dataSource: dataSource as DataSource
}) });
return (searches[id]?.assetProfiles ?? []).some((assetProfile) => {
return (
getAssetProfileIdentifier(assetProfile) === assetProfileIdentifier
); );
}); });
});
const accountMatchCount = output.accounts.filter(({ accountId }) => { const accountMatchCount = output.accounts.filter(({ accountId }) => {
return accountId; return accountId;
}).length; }).length;
const assetMatchOfNoSearchCount = const leftOutAssetMatchCount =
output.assets.filter(({ symbol }) => { output.assets.filter(({ symbol }) => {
return symbol; return symbol;
}).length - assetMatches.length; }).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` `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( 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 * Searches the asset profiles of an asset with the tool
* search-asset-profiles of the server of the Model Context Protocol: by the * 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 * 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({ private async searchAssetProfilesOfAsset({
asset: { isin, name, symbol }, asset: { isin, name, symbol },
@ -640,6 +648,7 @@ export class AiService {
continue; continue;
} }
try {
const { content } = await this.mcpService.searchAssetProfiles({ const { content } = await this.mcpService.searchAssetProfiles({
query, query,
userId userId
@ -654,6 +663,13 @@ export class AiService {
if (assetProfiles.length > 0) { if (assetProfiles.length > 0) {
return { assetProfiles, searchCount }; return { assetProfiles, searchCount };
} }
} catch (error) {
this.logger.warn(
`Could not search the asset profiles: ${
error instanceof Error ? error.message : String(error)
}`
);
}
} }
return { searchCount, assetProfiles: [] as AssetProfileIdentifier[] }; return { searchCount, assetProfiles: [] as AssetProfileIdentifier[] };

Loading…
Cancel
Save