This commit is contained in:
Bastian Wagner
2026-06-18 19:02:49 +02:00
parent 63ee20bcd8
commit f86416e8bc
2 changed files with 193 additions and 1 deletions

View File

@@ -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',

View File

@@ -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 =