mcp
This commit is contained in:
@@ -12,6 +12,9 @@ describe('AssistantService', () => {
|
|||||||
let listRealtimeService: {
|
let listRealtimeService: {
|
||||||
publishSnapshot: jest.Mock;
|
publishSnapshot: jest.Mock;
|
||||||
};
|
};
|
||||||
|
let listsService: {
|
||||||
|
listLists: jest.Mock;
|
||||||
|
};
|
||||||
let service: AssistantService;
|
let service: AssistantService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -25,9 +28,13 @@ describe('AssistantService', () => {
|
|||||||
listRealtimeService = {
|
listRealtimeService = {
|
||||||
publishSnapshot: jest.fn(),
|
publishSnapshot: jest.fn(),
|
||||||
};
|
};
|
||||||
|
listsService = {
|
||||||
|
listLists: jest.fn(),
|
||||||
|
};
|
||||||
service = new AssistantService(
|
service = new AssistantService(
|
||||||
chatLogsRepository as never,
|
chatLogsRepository as never,
|
||||||
listRealtimeService as never,
|
listRealtimeService as never,
|
||||||
|
listsService as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +121,82 @@ describe('AssistantService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('answers open list read requests locally without calling Mistral', async () => {
|
||||||
|
listsService.listLists.mockResolvedValue([
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item-2',
|
||||||
|
title: 'Brot',
|
||||||
|
required: true,
|
||||||
|
checked: true,
|
||||||
|
position: 1,
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-2',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
accessRole: 'owner',
|
||||||
|
name: 'Fertig',
|
||||||
|
kind: 'todo',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-3',
|
||||||
|
title: 'Done',
|
||||||
|
required: true,
|
||||||
|
checked: true,
|
||||||
|
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 result = await service.chat('user-1', {
|
||||||
|
messages: [{ role: 'user', content: 'Zeige mir meine offenen Listen' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(result.message.content).toContain('Du hast 1 offene Liste');
|
||||||
|
expect(result.message.content).toContain('**Einkauf**');
|
||||||
|
expect(result.message.content).toContain('Milch');
|
||||||
|
expect(result.message.content).not.toContain('Fertig');
|
||||||
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
provider: 'listify',
|
||||||
|
endpoint: 'local:listLists',
|
||||||
|
agentId: null,
|
||||||
|
statusCode: 200,
|
||||||
|
assistantContent: expect.stringContaining('Einkauf'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('adds a normalized list context system message when context is present', async () => {
|
it('adds a normalized list context system message when context is present', async () => {
|
||||||
const providerResponse = {
|
const providerResponse = {
|
||||||
choices: [
|
choices: [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
UserListItem,
|
UserListItem,
|
||||||
} from '../list-templates/list-template.types';
|
} from '../list-templates/list-template.types';
|
||||||
import { ListRealtimeService } from '../lists/list-realtime.service';
|
import { ListRealtimeService } from '../lists/list-realtime.service';
|
||||||
|
import { ListsService } from '../lists/lists.service';
|
||||||
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
||||||
import {
|
import {
|
||||||
AssistantAction,
|
AssistantAction,
|
||||||
@@ -93,6 +94,7 @@ export class AssistantService {
|
|||||||
@InjectRepository(AssistantChatLogEntity)
|
@InjectRepository(AssistantChatLogEntity)
|
||||||
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
|
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
|
||||||
private readonly listRealtimeService: ListRealtimeService,
|
private readonly listRealtimeService: ListRealtimeService,
|
||||||
|
private readonly listsService: ListsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
@@ -101,6 +103,12 @@ export class AssistantService {
|
|||||||
): Promise<AssistantChatResponse> {
|
): Promise<AssistantChatResponse> {
|
||||||
const messages = this.normalizeMessages(request.messages);
|
const messages = this.normalizeMessages(request.messages);
|
||||||
const context = this.normalizeContext(request.context);
|
const context = this.normalizeContext(request.context);
|
||||||
|
const localResponse = await this.tryHandleLocalListQuery(userId, messages);
|
||||||
|
|
||||||
|
if (localResponse) {
|
||||||
|
return localResponse;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.callMistralAgent(userId, messages, context);
|
const response = await this.callMistralAgent(userId, messages, context);
|
||||||
const content = this.extractAssistantContent(response);
|
const content = this.extractAssistantContent(response);
|
||||||
const actions = this.extractActions(response);
|
const actions = this.extractActions(response);
|
||||||
@@ -122,6 +130,106 @@ export class AssistantService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async tryHandleLocalListQuery(
|
||||||
|
userId: string,
|
||||||
|
messages: AssistantChatMessage[],
|
||||||
|
): Promise<AssistantChatResponse | null> {
|
||||||
|
const latestUserMessage = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.role === 'user');
|
||||||
|
const content = latestUserMessage?.content.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
if (!this.isListReadRequest(content)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const lists = await this.listsService.listLists(userId);
|
||||||
|
const wantsOpenLists = /\boffen\w*\b/.test(content);
|
||||||
|
const visibleLists = wantsOpenLists
|
||||||
|
? lists.filter((list) => !this.isCompletedList(list))
|
||||||
|
: lists;
|
||||||
|
const assistantContent = this.formatListsAnswer(visibleLists, wantsOpenLists);
|
||||||
|
|
||||||
|
await this.recordChatLog({
|
||||||
|
userId,
|
||||||
|
provider: 'listify',
|
||||||
|
endpoint: 'local:listLists',
|
||||||
|
agentId: null,
|
||||||
|
requestPayload: {
|
||||||
|
intent: wantsOpenLists ? 'list.open_lists' : 'list.list_lists',
|
||||||
|
latestUserMessage: latestUserMessage?.content,
|
||||||
|
},
|
||||||
|
responsePayload: { lists: visibleLists },
|
||||||
|
statusCode: 200,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
assistantContent,
|
||||||
|
errorMessage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantContent,
|
||||||
|
},
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isListReadRequest(content: string): boolean {
|
||||||
|
const asksForLists = /\b(listen|liste)\b/.test(content);
|
||||||
|
const asksToRead =
|
||||||
|
/\b(zeig|zeige|anzeigen|abrufen|auflisten|welche|was|habe|gib|nenn)\w*\b/.test(
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
const asksForOpenLists = /\boffen\w*\b/.test(content) && asksForLists;
|
||||||
|
const asksForOwnLists = /\bmeine listen\b/.test(content);
|
||||||
|
|
||||||
|
return asksToRead && (asksForOpenLists || asksForOwnLists);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCompletedList(list: UserList): boolean {
|
||||||
|
return (
|
||||||
|
list.items.length > 0 && list.items.every((item) => item.checked === true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatListsAnswer(lists: UserList[], openOnly: boolean): string {
|
||||||
|
if (lists.length === 0) {
|
||||||
|
return openOnly
|
||||||
|
? 'Du hast aktuell keine offenen Listen.'
|
||||||
|
: 'Du hast aktuell keine Listen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = openOnly
|
||||||
|
? `Du hast ${lists.length} offene ${lists.length === 1 ? 'Liste' : 'Listen'}:`
|
||||||
|
: `Du hast ${lists.length} ${lists.length === 1 ? 'Liste' : 'Listen'}:`;
|
||||||
|
const lines = [heading, ''];
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
const checkedCount = list.items.filter((item) => item.checked).length;
|
||||||
|
const openItems = list.items.filter((item) => !item.checked);
|
||||||
|
const ownerSuffix =
|
||||||
|
list.accessRole === 'collaborator'
|
||||||
|
? `, geteilt von ${list.ownerName || list.ownerEmail || 'Owner'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`- **${list.name}** (${checkedCount}/${list.items.length} erledigt${ownerSuffix})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of openItems.slice(0, 5)) {
|
||||||
|
lines.push(` - ${item.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openItems.length > 5) {
|
||||||
|
lines.push(` - ${openItems.length - 5} weitere offene Punkte`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
private async callMistralAgent(
|
private async callMistralAgent(
|
||||||
userId: string,
|
userId: string,
|
||||||
messages: AssistantChatMessage[],
|
messages: AssistantChatMessage[],
|
||||||
@@ -359,7 +467,9 @@ export class AssistantService {
|
|||||||
|
|
||||||
private async recordChatLog(input: {
|
private async recordChatLog(input: {
|
||||||
userId: string;
|
userId: string;
|
||||||
agentId: string;
|
provider?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
agentId: string | null;
|
||||||
requestPayload: Record<string, unknown>;
|
requestPayload: Record<string, unknown>;
|
||||||
responsePayload: unknown;
|
responsePayload: unknown;
|
||||||
statusCode: number | null;
|
statusCode: number | null;
|
||||||
@@ -371,8 +481,8 @@ export class AssistantService {
|
|||||||
this.chatLogsRepository.create({
|
this.chatLogsRepository.create({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
provider: 'mistral',
|
provider: input.provider ?? 'mistral',
|
||||||
endpoint: this.endpoint,
|
endpoint: input.endpoint ?? this.endpoint,
|
||||||
agentId: input.agentId,
|
agentId: input.agentId,
|
||||||
statusCode: input.statusCode,
|
statusCode: input.statusCode,
|
||||||
durationMs: input.durationMs,
|
durationMs: input.durationMs,
|
||||||
|
|||||||
Reference in New Issue
Block a user