This commit is contained in:
Bastian Wagner
2026-06-18 17:12:59 +02:00
parent 85a43d1b08
commit 63ee20bcd8
2 changed files with 196 additions and 3 deletions

View File

@@ -12,6 +12,9 @@ describe('AssistantService', () => {
let listRealtimeService: {
publishSnapshot: jest.Mock;
};
let listsService: {
listLists: jest.Mock;
};
let service: AssistantService;
beforeEach(() => {
@@ -25,9 +28,13 @@ describe('AssistantService', () => {
listRealtimeService = {
publishSnapshot: jest.fn(),
};
listsService = {
listLists: jest.fn(),
};
service = new AssistantService(
chatLogsRepository 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 () => {
const providerResponse = {
choices: [

View File

@@ -12,6 +12,7 @@ import {
UserListItem,
} from '../list-templates/list-template.types';
import { ListRealtimeService } from '../lists/list-realtime.service';
import { ListsService } from '../lists/lists.service';
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
import {
AssistantAction,
@@ -93,6 +94,7 @@ export class AssistantService {
@InjectRepository(AssistantChatLogEntity)
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
private readonly listRealtimeService: ListRealtimeService,
private readonly listsService: ListsService,
) {}
async chat(
@@ -101,6 +103,12 @@ export class AssistantService {
): Promise<AssistantChatResponse> {
const messages = this.normalizeMessages(request.messages);
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 content = this.extractAssistantContent(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(
userId: string,
messages: AssistantChatMessage[],
@@ -359,7 +467,9 @@ export class AssistantService {
private async recordChatLog(input: {
userId: string;
agentId: string;
provider?: string;
endpoint?: string;
agentId: string | null;
requestPayload: Record<string, unknown>;
responsePayload: unknown;
statusCode: number | null;
@@ -371,8 +481,8 @@ export class AssistantService {
this.chatLogsRepository.create({
id: randomUUID(),
userId: input.userId,
provider: 'mistral',
endpoint: this.endpoint,
provider: input.provider ?? 'mistral',
endpoint: input.endpoint ?? this.endpoint,
agentId: input.agentId,
statusCode: input.statusCode,
durationMs: input.durationMs,