This commit is contained in:
Bastian Wagner
2026-06-23 15:46:57 +02:00
parent e543a2f4a1
commit 35613eddb6
2 changed files with 255 additions and 17 deletions

View File

@@ -2,6 +2,11 @@ import { ServiceUnavailableException } from '@nestjs/common';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
describe('AssistantService', () => { describe('AssistantService', () => {
const connectorSystemMessage = [
'Benutze fuer Listify-Daten immer den listify Connector.',
'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.',
'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.',
].join(' ');
const originalFetch = global.fetch; const originalFetch = global.fetch;
const originalApiKey = process.env.MISTRAL_API_KEY; const originalApiKey = process.env.MISTRAL_API_KEY;
const originalAgentId = process.env.MISTRAL_AGENT_ID; const originalAgentId = process.env.MISTRAL_AGENT_ID;
@@ -65,7 +70,7 @@ describe('AssistantService', () => {
const result = await service.chat('user-1', { const result = await service.chat('user-1', {
messages: [ messages: [
{ role: 'assistant', content: 'Hallo' }, { role: 'assistant', content: 'Hallo' },
{ role: 'user', content: 'Erstelle eine Liste' }, { role: 'user', content: 'Hallo' },
], ],
}); });
@@ -81,8 +86,8 @@ describe('AssistantService', () => {
agent_id: 'agent-listify', agent_id: 'agent-listify',
messages: [ messages: [
{ role: 'assistant', content: 'Hallo' }, { role: 'assistant', content: 'Hallo' },
{ role: 'user', content: 'Erstelle eine Liste' }, { role: 'user', content: 'Hallo' },
{ role: 'system', content: 'benutze immer den listify connector' }, { role: 'system', content: connectorSystemMessage },
], ],
tools: [ tools: [
{ {
@@ -281,7 +286,7 @@ describe('AssistantService', () => {
expect(contextContent).not.toContain('checkedByUserId'); expect(contextContent).not.toContain('checkedByUserId');
expect(payload.messages.at(-1)).toEqual({ expect(payload.messages.at(-1)).toEqual({
role: 'system', role: 'system',
content: 'benutze immer den listify connector', content: connectorSystemMessage,
}); });
}); });
@@ -480,6 +485,118 @@ describe('AssistantService', () => {
); );
}); });
it('extracts created list actions from raw tool JSON content', async () => {
const createdList = {
id: 'list-1',
ownerId: 'user-1',
accessRole: 'owner',
name: 'Einkauf',
kind: 'shopping',
items: [
{
id: 'item-1',
title: 'Milch',
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',
};
const providerResponse = {
choices: [
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call-1',
type: 'function',
function: {
name: 'listify_create_list',
arguments: '{"name": "Einkauf"}',
},
},
],
},
{
role: 'tool',
content: [
{
type: 'text',
text: JSON.stringify({ list: createdList }),
},
],
tool_call_id: 'call-1',
},
{
role: 'assistant',
content: JSON.stringify({ list: createdList }),
},
],
},
],
};
mockMistralResponse(providerResponse);
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }],
});
expect(result).toEqual({
message: {
role: 'assistant',
content: 'Ich habe die Liste **Einkauf** angelegt.',
},
actions: [
{
type: 'list.created',
listId: 'list-1',
list: createdList,
},
],
});
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
'user-1',
createdList,
);
});
it('returns a clear message when list creation did not use the connector', async () => {
mockMistralResponse({
choices: [
{
message: {
content: JSON.stringify({
name: 'Einkauf',
items: [{ title: 'Milch' }],
}),
},
},
],
});
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }],
});
expect(result).toEqual({
message: {
role: 'assistant',
content:
'Ich konnte die Liste nicht anlegen, weil der Listify-Connector keine Liste erstellt hat.',
},
actions: [],
});
expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled();
});
it('fails clearly when the api key is missing', async () => { it('fails clearly when the api key is missing', async () => {
delete process.env.MISTRAL_API_KEY; delete process.env.MISTRAL_API_KEY;

View File

@@ -120,13 +120,29 @@ export class AssistantService {
} }
const response = await this.callMistralAgent(userId, messages, context); const response = await this.callMistralAgent(userId, messages, context);
const content = this.extractAssistantContent(response);
const actions = this.extractActions(response); const actions = this.extractActions(response);
const content =
this.extractAssistantContent(response) ?? this.createActionContent(actions);
const latestUserMessage = this.latestUserMessage(messages);
actions.forEach((action) => { actions.forEach((action) => {
this.listRealtimeService.publishSnapshot(userId, action.list); this.listRealtimeService.publishSnapshot(userId, action.list);
}); });
if (
this.isListCreationRequest(latestUserMessage?.content ?? '') &&
!actions.some((action) => action.type === 'list.created')
) {
return {
message: {
role: 'assistant',
content:
'Ich konnte die Liste nicht anlegen, weil der Listify-Connector keine Liste erstellt hat.',
},
actions: [],
};
}
if (!content) { if (!content) {
throw new ServiceUnavailableException('Mistral response was empty.'); throw new ServiceUnavailableException('Mistral response was empty.');
} }
@@ -144,9 +160,7 @@ export class AssistantService {
userId: string, userId: string,
messages: AssistantChatMessage[], messages: AssistantChatMessage[],
): Promise<AssistantChatResponse | null> { ): Promise<AssistantChatResponse | null> {
const latestUserMessage = [...messages] const latestUserMessage = this.latestUserMessage(messages);
.reverse()
.find((message) => message.role === 'user');
const content = latestUserMessage?.content.toLowerCase() ?? ''; const content = latestUserMessage?.content.toLowerCase() ?? '';
if (!this.isListReadRequest(content)) { if (!this.isListReadRequest(content)) {
@@ -195,9 +209,7 @@ export class AssistantService {
return null; return null;
} }
const latestUserMessage = [...messages] const latestUserMessage = this.latestUserMessage(messages);
.reverse()
.find((message) => message.role === 'user');
const itemTitle = this.extractItemTitleToAdd(latestUserMessage?.content ?? ''); const itemTitle = this.extractItemTitleToAdd(latestUserMessage?.content ?? '');
if (!itemTitle) { if (!itemTitle) {
@@ -300,6 +312,29 @@ export class AssistantService {
return asksToRead && (asksForOpenLists || asksForOwnLists); return asksToRead && (asksForOpenLists || asksForOwnLists);
} }
private isListCreationRequest(content: string): boolean {
const normalized = this.normalizeIntentText(content);
const mentionsList =
/\b(list|liste|listen|packliste|einkaufsliste|todo|aufgabenliste)\b/.test(
normalized,
);
const asksToCreate =
/\b(erstell|erstelle|erstellt|erzeugen|generier|generiere|mach|mache|bau|baue)\w*\b/.test(
normalized,
) || /\b(?:leg|lege)\s+.*\b(?:an|neu)\b/.test(normalized);
return mentionsList && asksToCreate;
}
private normalizeIntentText(content: string): string {
return content
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
private isCompletedList(list: UserList): boolean { private isCompletedList(list: UserList): boolean {
return ( return (
list.items.length > 0 && list.items.every((item) => item.checked === true) list.items.length > 0 && list.items.every((item) => item.checked === true)
@@ -364,7 +399,14 @@ export class AssistantService {
messages: [ messages: [
...messages, ...messages,
...(contextMessage ? [contextMessage] : []), ...(contextMessage ? [contextMessage] : []),
{ role: 'system', content: 'benutze immer den listify connector' }, {
role: 'system',
content: [
'Benutze fuer Listify-Daten immer den listify Connector.',
'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.',
'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.',
].join(' '),
},
], ],
tools: [ tools: [
{ {
@@ -445,7 +487,7 @@ export class AssistantService {
const response = responsePayload as MistralAgentCompletionResponse; const response = responsePayload as MistralAgentCompletionResponse;
const directContent = response.choices?.[0]?.message?.content?.trim(); const directContent = response.choices?.[0]?.message?.content?.trim();
if (directContent) { if (directContent && !this.looksLikeJson(directContent)) {
return directContent; return directContent;
} }
@@ -455,10 +497,9 @@ export class AssistantService {
.reverse(); .reverse();
for (const message of assistantMessages) { for (const message of assistantMessages) {
const content = const content = this.contentToText(message.content).trim();
typeof message.content === 'string' ? message.content.trim() : '';
if (content) { if (content && !this.looksLikeJson(content)) {
return content; return content;
} }
} }
@@ -492,7 +533,9 @@ export class AssistantService {
} }
const structuredContent = message.metadata?.mcp_meta?.structuredContent; const structuredContent = message.metadata?.mcp_meta?.structuredContent;
const list = this.listFromStructuredContent(structuredContent); const list =
this.listFromStructuredContent(structuredContent) ??
this.listFromToolContent(message.content);
if (!list) { if (!list) {
continue; continue;
@@ -558,6 +601,78 @@ export class AssistantService {
return candidate as UserList; return candidate as UserList;
} }
private listFromToolContent(value: unknown): UserList | null {
for (const text of this.contentTextParts(value)) {
try {
const parsed = JSON.parse(text) as unknown;
const list = this.listFromStructuredContent(parsed);
if (list) {
return list;
}
} catch {
continue;
}
}
return null;
}
private contentToText(value: unknown): string {
return this.contentTextParts(value).join('\n');
}
private contentTextParts(value: unknown): string[] {
if (typeof value === 'string') {
return [value];
}
if (!Array.isArray(value)) {
return [];
}
return value
.map((part) => {
if (typeof part === 'string') {
return part;
}
if (!part || typeof part !== 'object' || Array.isArray(part)) {
return null;
}
const candidate = part as { text?: unknown };
return typeof candidate.text === 'string' ? candidate.text : null;
})
.filter((part): part is string => part !== null);
}
private looksLikeJson(content: string): boolean {
const trimmed = content.trim();
return (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
);
}
private createActionContent(actions: AssistantAction[]): string | null {
const createdList = actions.find((action) => action.type === 'list.created');
if (createdList) {
return `Ich habe die Liste **${createdList.list.name}** angelegt.`;
}
const addedItem = actions.find((action) => action.type === 'list.item_added');
if (addedItem) {
return `Ich habe **${addedItem.itemTitle}** zu **${addedItem.list.name}** hinzugefuegt.`;
}
return null;
}
private lastItemTitle(list: UserList): string { private lastItemTitle(list: UserList): string {
return list.items.at(-1)?.title ?? 'Eintrag'; return list.items.at(-1)?.title ?? 'Eintrag';
} }
@@ -606,6 +721,12 @@ export class AssistantService {
); );
} }
private latestUserMessage(
messages: AssistantChatMessage[],
): AssistantChatMessage | undefined {
return [...messages].reverse().find((message) => message.role === 'user');
}
private normalizeMessages( private normalizeMessages(
messages: AssistantChatMessage[] | undefined, messages: AssistantChatMessage[] | undefined,
): AssistantChatMessage[] { ): AssistantChatMessage[] {