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';
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 originalApiKey = process.env.MISTRAL_API_KEY;
const originalAgentId = process.env.MISTRAL_AGENT_ID;
@@ -65,7 +70,7 @@ describe('AssistantService', () => {
const result = await service.chat('user-1', {
messages: [
{ role: 'assistant', content: 'Hallo' },
{ role: 'user', content: 'Erstelle eine Liste' },
{ role: 'user', content: 'Hallo' },
],
});
@@ -81,8 +86,8 @@ describe('AssistantService', () => {
agent_id: 'agent-listify',
messages: [
{ role: 'assistant', content: 'Hallo' },
{ role: 'user', content: 'Erstelle eine Liste' },
{ role: 'system', content: 'benutze immer den listify connector' },
{ role: 'user', content: 'Hallo' },
{ role: 'system', content: connectorSystemMessage },
],
tools: [
{
@@ -281,7 +286,7 @@ describe('AssistantService', () => {
expect(contextContent).not.toContain('checkedByUserId');
expect(payload.messages.at(-1)).toEqual({
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 () => {
delete process.env.MISTRAL_API_KEY;

View File

@@ -120,13 +120,29 @@ export class AssistantService {
}
const response = await this.callMistralAgent(userId, messages, context);
const content = this.extractAssistantContent(response);
const actions = this.extractActions(response);
const content =
this.extractAssistantContent(response) ?? this.createActionContent(actions);
const latestUserMessage = this.latestUserMessage(messages);
actions.forEach((action) => {
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) {
throw new ServiceUnavailableException('Mistral response was empty.');
}
@@ -144,9 +160,7 @@ export class AssistantService {
userId: string,
messages: AssistantChatMessage[],
): Promise<AssistantChatResponse | null> {
const latestUserMessage = [...messages]
.reverse()
.find((message) => message.role === 'user');
const latestUserMessage = this.latestUserMessage(messages);
const content = latestUserMessage?.content.toLowerCase() ?? '';
if (!this.isListReadRequest(content)) {
@@ -195,9 +209,7 @@ export class AssistantService {
return null;
}
const latestUserMessage = [...messages]
.reverse()
.find((message) => message.role === 'user');
const latestUserMessage = this.latestUserMessage(messages);
const itemTitle = this.extractItemTitleToAdd(latestUserMessage?.content ?? '');
if (!itemTitle) {
@@ -300,6 +312,29 @@ export class AssistantService {
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 {
return (
list.items.length > 0 && list.items.every((item) => item.checked === true)
@@ -364,7 +399,14 @@ export class AssistantService {
messages: [
...messages,
...(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: [
{
@@ -445,7 +487,7 @@ export class AssistantService {
const response = responsePayload as MistralAgentCompletionResponse;
const directContent = response.choices?.[0]?.message?.content?.trim();
if (directContent) {
if (directContent && !this.looksLikeJson(directContent)) {
return directContent;
}
@@ -455,10 +497,9 @@ export class AssistantService {
.reverse();
for (const message of assistantMessages) {
const content =
typeof message.content === 'string' ? message.content.trim() : '';
const content = this.contentToText(message.content).trim();
if (content) {
if (content && !this.looksLikeJson(content)) {
return content;
}
}
@@ -492,7 +533,9 @@ export class AssistantService {
}
const structuredContent = message.metadata?.mcp_meta?.structuredContent;
const list = this.listFromStructuredContent(structuredContent);
const list =
this.listFromStructuredContent(structuredContent) ??
this.listFromToolContent(message.content);
if (!list) {
continue;
@@ -558,6 +601,78 @@ export class AssistantService {
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 {
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(
messages: AssistantChatMessage[] | undefined,
): AssistantChatMessage[] {