mcp
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user