mcp
This commit is contained in:
@@ -14,6 +14,7 @@ describe('AssistantService', () => {
|
||||
};
|
||||
let listsService: {
|
||||
listLists: jest.Mock;
|
||||
addItem: jest.Mock;
|
||||
};
|
||||
let service: AssistantService;
|
||||
|
||||
@@ -30,6 +31,7 @@ describe('AssistantService', () => {
|
||||
};
|
||||
listsService = {
|
||||
listLists: jest.fn(),
|
||||
addItem: jest.fn(),
|
||||
};
|
||||
service = new AssistantService(
|
||||
chatLogsRepository as never,
|
||||
@@ -210,7 +212,7 @@ describe('AssistantService', () => {
|
||||
mockMistralResponse(providerResponse);
|
||||
|
||||
await service.chat('user-1', {
|
||||
messages: [{ role: 'user', content: 'Fuege hier Brot hinzu' }],
|
||||
messages: [{ role: 'user', content: 'Was ist hier wichtig?' }],
|
||||
context: {
|
||||
page: 'list_detail',
|
||||
route: '/lists/list-1',
|
||||
@@ -283,6 +285,84 @@ describe('AssistantService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('adds list items locally when the user writes onto the current list', async () => {
|
||||
const updatedList = {
|
||||
id: 'list-1',
|
||||
ownerId: 'user-1',
|
||||
accessRole: 'owner',
|
||||
name: 'Einkauf',
|
||||
kind: 'shopping',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
title: 'Brot',
|
||||
required: true,
|
||||
checked: false,
|
||||
position: 0,
|
||||
createdAt: '2026-06-12T00:00:00.000Z',
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
collaborators: [],
|
||||
createdAt: '2026-06-12T00:00:00.000Z',
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
};
|
||||
listsService.addItem.mockResolvedValue(updatedList);
|
||||
|
||||
const result = await service.chat('user-1', {
|
||||
messages: [{ role: 'user', content: 'Fuege hier Brot hinzu' }],
|
||||
context: {
|
||||
page: 'list_detail',
|
||||
route: '/lists/list-1',
|
||||
list: {
|
||||
id: 'list-1',
|
||||
ownerId: 'user-1',
|
||||
accessRole: 'owner',
|
||||
name: 'Einkauf',
|
||||
kind: 'shopping',
|
||||
items: [],
|
||||
collaborators: [],
|
||||
createdAt: '2026-06-12T00:00:00.000Z',
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
|
||||
title: 'Brot',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'Ich habe **Brot** zu **Einkauf** hinzugefuegt.',
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
type: 'list.item_added',
|
||||
listId: 'list-1',
|
||||
itemTitle: 'Brot',
|
||||
list: updatedList,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
updatedList,
|
||||
);
|
||||
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'listify',
|
||||
endpoint: 'local:addItem',
|
||||
requestPayload: expect.objectContaining({
|
||||
intent: 'list.add_item',
|
||||
listId: 'list-1',
|
||||
itemTitle: 'Brot',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs full failed provider responses before throwing', async () => {
|
||||
const providerResponse = {
|
||||
message: 'connector failed',
|
||||
|
||||
@@ -109,6 +109,16 @@ export class AssistantService {
|
||||
return localResponse;
|
||||
}
|
||||
|
||||
const localMutationResponse = await this.tryHandleLocalListMutation(
|
||||
userId,
|
||||
messages,
|
||||
context,
|
||||
);
|
||||
|
||||
if (localMutationResponse) {
|
||||
return localMutationResponse;
|
||||
}
|
||||
|
||||
const response = await this.callMistralAgent(userId, messages, context);
|
||||
const content = this.extractAssistantContent(response);
|
||||
const actions = this.extractActions(response);
|
||||
@@ -176,6 +186,108 @@ export class AssistantService {
|
||||
};
|
||||
}
|
||||
|
||||
private async tryHandleLocalListMutation(
|
||||
userId: string,
|
||||
messages: AssistantChatMessage[],
|
||||
context: NormalizedAssistantPageContext | null,
|
||||
): Promise<AssistantChatResponse | null> {
|
||||
if (context?.page !== 'list_detail') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'user');
|
||||
const itemTitle = this.extractItemTitleToAdd(latestUserMessage?.content ?? '');
|
||||
|
||||
if (!itemTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const list = await this.listsService.addItem(userId, context.list.id, {
|
||||
title: itemTitle,
|
||||
});
|
||||
const assistantContent = `Ich habe **${itemTitle}** zu **${list.name}** hinzugefuegt.`;
|
||||
const action: AssistantAction = {
|
||||
type: 'list.item_added',
|
||||
listId: list.id,
|
||||
itemTitle,
|
||||
list,
|
||||
};
|
||||
|
||||
this.listRealtimeService.publishSnapshot(userId, list);
|
||||
|
||||
await this.recordChatLog({
|
||||
userId,
|
||||
provider: 'listify',
|
||||
endpoint: 'local:addItem',
|
||||
agentId: null,
|
||||
requestPayload: {
|
||||
intent: 'list.add_item',
|
||||
latestUserMessage: latestUserMessage?.content,
|
||||
listId: context.list.id,
|
||||
itemTitle,
|
||||
},
|
||||
responsePayload: { list },
|
||||
statusCode: 200,
|
||||
durationMs: Date.now() - startedAt,
|
||||
assistantContent,
|
||||
errorMessage: null,
|
||||
});
|
||||
|
||||
return {
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
},
|
||||
actions: [action],
|
||||
};
|
||||
}
|
||||
|
||||
private extractItemTitleToAdd(content: string): string | null {
|
||||
const compacted = content.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!compacted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
/\b(?:fuege|füge)\s+(.+?)\s+(?:hinzu|dazu|drauf|auf die liste|auf diese liste)\b/i,
|
||||
/\b(?:schreib|schreibe|notier|notiere|pack|setze|setz)\s+(.+?)(?:\s+(?:drauf|dazu|hinzu|auf die liste|auf diese liste))?$/i,
|
||||
/\b(.+?)\s+(?:draufschreiben|aufschreiben|hinzufuegen|hinzufügen)\b/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = compacted.match(pattern);
|
||||
const title = this.cleanExtractedItemTitle(match?.[1]);
|
||||
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private cleanExtractedItemTitle(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned = value
|
||||
.replace(/^(?:bitte|noch|auch|mal|hier)\s+/i, '')
|
||||
.replace(/\s+(?:bitte|noch|auch|mal)$/i, '')
|
||||
.replace(/^["'`]+|["'`.,!?]+$/g, '')
|
||||
.trim();
|
||||
|
||||
if (!cleaned || cleaned.length > 220) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private isListReadRequest(content: string): boolean {
|
||||
const asksForLists = /\b(listen|liste)\b/.test(content);
|
||||
const asksToRead =
|
||||
|
||||
Reference in New Issue
Block a user