diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index 34034ce..899311d 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -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: [ diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 02ff167..178a4e5 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -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, private readonly listRealtimeService: ListRealtimeService, + private readonly listsService: ListsService, ) {} async chat( @@ -101,6 +103,12 @@ export class AssistantService { ): Promise { 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 { + 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; 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,