import { BadRequestException, Injectable, ServiceUnavailableException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { Repository } from 'typeorm'; import { ListTemplate, UserList, 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, AssistantChatMessage, AssistantChatLog, AssistantPageContext, AssistantChatRequest, AssistantChatResponse, } from './assistant.types'; type MistralMessage = | AssistantChatMessage | { role: 'system'; content: string }; interface NormalizedContextItem { id: string; title: string; notes?: string; quantity?: number; required?: boolean; checked?: boolean; } interface NormalizedContextList { id: string; name: string; kind: string; description?: string; items: NormalizedContextItem[]; omittedItems: number; } interface NormalizedContextTemplate { id: string; name: string; kind: string; description?: string; items: NormalizedContextItem[]; omittedItems: number; } type NormalizedAssistantPageContext = | { page: 'lists_overview'; route: string } | { page: 'list_detail'; route: string; list: NormalizedContextList } | { page: 'templates_overview'; route: string } | { page: 'template_detail'; route: string; template: NormalizedContextTemplate; } | { page: 'unknown'; route: string }; interface MistralAgentCompletionResponse { choices?: Array<{ message?: { content?: string | null; }; messages?: Array<{ role?: string; content?: string | null | unknown[]; tool_call_id?: string; tool_calls?: Array<{ id?: string; function?: { name?: string; }; }>; metadata?: { mcp_meta?: { structuredContent?: unknown; }; }; }>; }>; } @Injectable() export class AssistantService { private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions'; constructor( @InjectRepository(AssistantChatLogEntity) private readonly chatLogsRepository: Repository, private readonly listRealtimeService: ListRealtimeService, private readonly listsService: ListsService, ) {} async chat( userId: string, request: AssistantChatRequest, ): 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 localMutationResponse = await this.tryHandleLocalListMutation( userId, messages, context, ); if (localMutationResponse) { return localMutationResponse; } const response = await this.callMistralAgent(userId, messages, context); 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) { await this.recordChatLog({ userId, provider: 'listify', endpoint: 'assistant:emptyResponse', agentId: null, requestPayload: { latestUserMessage: latestUserMessage?.content, actionCount: actions.length, }, responsePayload: { actions }, statusCode: null, durationMs: 0, assistantContent: null, errorMessage: 'Mistral response was empty.', }); throw new ServiceUnavailableException('Mistral response was empty.'); } return { message: { role: 'assistant', content, }, actions, }; } async listChatLogs(userId: string): Promise { const logs = await this.chatLogsRepository.find({ where: { userId }, order: { createdAt: 'DESC' }, take: 50, }); return logs.slice(0, 50).map((log) => ({ id: log.id, provider: log.provider, endpoint: log.endpoint, agentId: log.agentId, statusCode: log.statusCode, durationMs: log.durationMs, requestPayload: log.requestPayload, responsePayload: log.responsePayload, assistantContent: log.assistantContent, errorMessage: log.errorMessage, createdAt: log.createdAt.toISOString(), })); } private async tryHandleLocalListQuery( userId: string, messages: AssistantChatMessage[], ): Promise { const latestUserMessage = this.latestUserMessage(messages); 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 async tryHandleLocalListMutation( userId: string, messages: AssistantChatMessage[], context: NormalizedAssistantPageContext | null, ): Promise { if (context?.page !== 'list_detail') { return null; } const latestUserMessage = this.latestUserMessage(messages); const itemTitle = this.extractItemTitleToAdd( latestUserMessage?.content ?? '', ); if (!itemTitle) { return null; } const startedAt = Date.now(); const list = await this.listsService.addItem(userId, context.list.id, { title: itemTitle, }); const assistantContent = `Ich habe **${itemTitle}** zu **${list.name}** hinzugefuegt.`; const action: AssistantAction = { type: 'list.item_added', listId: list.id, itemTitle, list, }; this.listRealtimeService.publishSnapshot(userId, list); await this.recordChatLog({ userId, provider: 'listify', endpoint: 'local:addItem', agentId: null, requestPayload: { intent: 'list.add_item', latestUserMessage: latestUserMessage?.content, listId: context.list.id, itemTitle, }, responsePayload: { list }, statusCode: 200, durationMs: Date.now() - startedAt, assistantContent, errorMessage: null, }); return { message: { role: 'assistant', content: assistantContent, }, actions: [action], }; } private extractItemTitleToAdd(content: string): string | null { const compacted = content.replace(/\s+/g, ' ').trim(); if (!compacted) { return null; } const patterns = [ /\b(?:fuege|füge)\s+(.+?)\s+(?:hinzu|dazu|drauf|auf die liste|auf diese liste)\b/i, /\b(?:schreib|schreibe|notier|notiere|pack|setze|setz)\s+(.+?)(?:\s+(?:drauf|dazu|hinzu|auf die liste|auf diese liste))?$/i, /\b(.+?)\s+(?:draufschreiben|aufschreiben|hinzufuegen|hinzufügen)\b/i, ]; for (const pattern of patterns) { const match = compacted.match(pattern); const title = this.cleanExtractedItemTitle(match?.[1]); if (title) { return title; } } return null; } private cleanExtractedItemTitle(value: string | undefined): string | null { if (!value) { return null; } const cleaned = value .replace(/^(?:bitte|noch|auch|mal|hier)\s+/i, '') .replace(/\s+(?:bitte|noch|auch|mal)$/i, '') .replace(/^["'`]+|["'`.,!?]+$/g, '') .trim(); if (!cleaned || cleaned.length > 220) { return null; } return cleaned; } 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 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) ); } 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[], context: NormalizedAssistantPageContext | null, ): Promise { const apiKey = process.env.MISTRAL_API_KEY; const agentId = process.env.MISTRAL_AGENT_ID; const contextMessage = this.createContextSystemMessage(context); const requestPayload = { agent_id: agentId, messages: [ ...messages, ...(contextMessage ? [contextMessage] : []), { 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: [ { type: 'connector', connector_id: 'listify', }, ], stream: false, response_format: { type: 'text', }, }; if (!apiKey) { await this.recordChatLog({ userId, agentId: agentId ?? null, requestPayload, responsePayload: null, statusCode: null, durationMs: 0, assistantContent: null, errorMessage: 'Mistral API key is not configured.', }); throw new ServiceUnavailableException( 'Mistral API key is not configured.', ); } if (!agentId) { await this.recordChatLog({ userId, agentId: null, requestPayload, responsePayload: null, statusCode: null, durationMs: 0, assistantContent: null, errorMessage: 'Mistral agent id is not configured.', }); throw new ServiceUnavailableException( 'Mistral agent id is not configured.', ); } const startedAt = Date.now(); let statusCode: number | null = null; let responsePayload: unknown = null; let assistantContent: string | null = null; const response = await fetch(this.endpoint, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(requestPayload), }); statusCode = response.status; responsePayload = await this.readResponsePayload(response); if (!response.ok) { await this.recordChatLog({ userId, agentId, requestPayload, responsePayload, statusCode, durationMs: Date.now() - startedAt, assistantContent, errorMessage: 'Mistral agent request failed.', }); throw new ServiceUnavailableException('Mistral agent request failed.'); } assistantContent = this.extractAssistantContent(responsePayload); await this.recordChatLog({ userId, agentId, requestPayload, responsePayload, statusCode, durationMs: Date.now() - startedAt, assistantContent, errorMessage: null, }); return responsePayload as MistralAgentCompletionResponse; } private async readResponsePayload(response: Response): Promise { const rawBody = await response.text(); if (!rawBody) { return null; } try { return JSON.parse(rawBody) as unknown; } catch { return { rawBody }; } } private extractAssistantContent(responsePayload: unknown): string | null { if (!responsePayload || typeof responsePayload !== 'object') { return null; } const response = responsePayload as MistralAgentCompletionResponse; const directContent = response.choices?.[0]?.message?.content?.trim(); if (directContent && !this.looksLikeJson(directContent)) { return directContent; } for (const choice of response.choices ?? []) { const assistantMessages = (choice.messages ?? []) .filter((message) => message.role === 'assistant') .reverse(); for (const message of assistantMessages) { const content = this.contentToText(message.content).trim(); if (content && !this.looksLikeJson(content)) { return content; } } } return null; } private extractActions(responsePayload: unknown): AssistantAction[] { if (!responsePayload || typeof responsePayload !== 'object') { return []; } const actions: AssistantAction[] = []; const response = responsePayload as MistralAgentCompletionResponse; for (const choice of response.choices ?? []) { const toolNamesById = new Map(); for (const message of choice.messages ?? []) { for (const toolCall of message.tool_calls ?? []) { if (toolCall.id && toolCall.function?.name) { toolNamesById.set(toolCall.id, toolCall.function.name); } } } for (const message of choice.messages ?? []) { if (message.role !== 'tool') { continue; } const structuredContent = message.metadata?.mcp_meta?.structuredContent; const list = this.listFromStructuredContent(structuredContent) ?? this.listFromToolContent(message.content); if (!list) { continue; } const toolName = message.tool_call_id ? toolNamesById.get(message.tool_call_id) : undefined; if (toolName?.includes('create_list')) { actions.push({ type: 'list.created', listId: list.id, list, }); continue; } if (toolName?.includes('add_list_item')) { actions.push({ type: 'list.item_added', listId: list.id, itemTitle: this.lastItemTitle(list), list, }); continue; } actions.push({ type: 'list.item_added', listId: list.id, itemTitle: this.lastItemTitle(list), list, }); } } return this.uniqueActions(actions); } private listFromStructuredContent(value: unknown): UserList | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } const structuredContent = value as { list?: unknown }; const list = structuredContent.list; if (!list || typeof list !== 'object' || Array.isArray(list)) { return null; } const candidate = list as Partial; if ( typeof candidate.id !== 'string' || typeof candidate.name !== 'string' || !Array.isArray(candidate.items) ) { return null; } 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'; } private uniqueActions(actions: AssistantAction[]): AssistantAction[] { const seen = new Set(); return actions.filter((action) => { const key = `${action.type}:${action.listId}:${action.type === 'list.item_added' ? action.itemTitle : ''}`; if (seen.has(key)) { return false; } seen.add(key); return true; }); } private async recordChatLog(input: { userId: string; provider?: string; endpoint?: string; agentId: string | null; requestPayload: Record; responsePayload: unknown; statusCode: number | null; durationMs: number; assistantContent: string | null; errorMessage: string | null; }): Promise { await this.chatLogsRepository.save( this.chatLogsRepository.create({ id: randomUUID(), userId: input.userId, provider: input.provider ?? 'mistral', endpoint: input.endpoint ?? this.endpoint, agentId: input.agentId, statusCode: input.statusCode, durationMs: input.durationMs, requestPayload: input.requestPayload, responsePayload: input.responsePayload, assistantContent: input.assistantContent, errorMessage: input.errorMessage, }), ); } private latestUserMessage( messages: AssistantChatMessage[], ): AssistantChatMessage | undefined { return [...messages].reverse().find((message) => message.role === 'user'); } private normalizeMessages( messages: AssistantChatMessage[] | undefined, ): AssistantChatMessage[] { if (!Array.isArray(messages) || messages.length === 0) { throw new BadRequestException('At least one chat message is required.'); } return messages.slice(-12).map((message) => { if (message.role !== 'user' && message.role !== 'assistant') { throw new BadRequestException('Chat message role is invalid.'); } const content = message.content?.trim(); if (!content) { throw new BadRequestException('Chat message content is required.'); } return { role: message.role, content }; }); } private normalizeContext( context: AssistantPageContext | undefined, ): NormalizedAssistantPageContext | null { if (!context || typeof context !== 'object') { return null; } const route = this.compactString(context.route, 240) ?? ''; if (context.page === 'lists_overview') { return { page: 'lists_overview', route }; } if (context.page === 'templates_overview') { return { page: 'templates_overview', route }; } if (context.page === 'unknown') { return { page: 'unknown', route }; } if (context.page === 'list_detail') { const list = this.normalizeListContext(context.list); return list ? { page: 'list_detail', route, list } : null; } if (context.page === 'template_detail') { const template = this.normalizeTemplateContext(context.template); return template ? { page: 'template_detail', route, template } : null; } return null; } private normalizeListContext(list: UserList): NormalizedContextList | null { if (!list || typeof list !== 'object' || Array.isArray(list)) { return null; } const id = this.compactString(list.id, 120); const name = this.compactString(list.name, 160); const kind = this.compactString(list.kind, 80); if (!id || !name || !kind || !Array.isArray(list.items)) { return null; } const items = list.items .slice(0, 40) .map((item) => this.normalizeItemContext(item, true)) .filter((item): item is NormalizedContextItem => item !== null); return { id, name, kind, description: this.compactString(list.description, 240), items, omittedItems: Math.max(0, list.items.length - items.length), }; } private normalizeTemplateContext( template: ListTemplate, ): NormalizedContextTemplate | null { if (!template || typeof template !== 'object' || Array.isArray(template)) { return null; } const id = this.compactString(template.id, 120); const name = this.compactString(template.name, 160); const kind = this.compactString(template.kind, 80); if (!id || !name || !kind || !Array.isArray(template.items)) { return null; } const items = template.items .slice(0, 40) .map((item) => this.normalizeItemContext(item, false)) .filter((item): item is NormalizedContextItem => item !== null); return { id, name, kind, description: this.compactString(template.description, 240), items, omittedItems: Math.max(0, template.items.length - items.length), }; } private normalizeItemContext( item: Partial, includeChecked: boolean, ): NormalizedContextItem | null { if (!item || typeof item !== 'object' || Array.isArray(item)) { return null; } const id = this.compactString(item.id, 120); const title = this.compactString(item.title, 160); if (!id || !title) { return null; } return { id, title, notes: this.compactString(item.notes, 220), quantity: typeof item.quantity === 'number' ? item.quantity : undefined, required: typeof item.required === 'boolean' ? item.required : undefined, checked: includeChecked && typeof item.checked === 'boolean' ? item.checked : undefined, }; } private createContextSystemMessage( context: NormalizedAssistantPageContext | null, ): MistralMessage | null { if (!context) { return null; } const lines = ['Aktueller Listify-Kontext:']; if (context.page === 'lists_overview') { lines.push(`Der User befindet sich auf der Listenuebersicht.`); lines.push(`Route: ${context.route}`); return { role: 'system', content: lines.join('\n') }; } if (context.page === 'templates_overview') { lines.push(`Der User befindet sich auf der Vorlagenuebersicht.`); lines.push(`Route: ${context.route}`); return { role: 'system', content: lines.join('\n') }; } if (context.page === 'unknown') { lines.push(`Die aktuelle Seite ist nicht eindeutig zugeordnet.`); lines.push(`Route: ${context.route}`); return { role: 'system', content: lines.join('\n') }; } if (context.page === 'list_detail') { lines.push(`Der User befindet sich auf einer Listendetailseite.`); lines.push(`Route: ${context.route}`); lines.push(this.formatListContext(context.list)); lines.push( `Wenn der User "diese Liste", "hier" oder aehnliche Verweise nutzt, bezieht sich das auf die Liste mit ID ${context.list.id}.`, ); return { role: 'system', content: lines.join('\n') }; } lines.push(`Der User befindet sich auf einer Vorlagendetailseite.`); lines.push(`Route: ${context.route}`); lines.push(this.formatTemplateContext(context.template)); lines.push( `Wenn der User "diese Vorlage", "hier" oder aehnliche Verweise nutzt, bezieht sich das auf die Vorlage mit ID ${context.template.id}.`, ); return { role: 'system', content: lines.join('\n') }; } private formatListContext(list: NormalizedContextList): string { return [ `Offene Liste: ${list.name} (ID: ${list.id}, Typ: ${list.kind})`, ...(list.description ? [`Beschreibung: ${list.description}`] : []), ...this.formatItems(list.items, list.omittedItems, true), ].join('\n'); } private formatTemplateContext(template: NormalizedContextTemplate): string { return [ `Offene Vorlage: ${template.name} (ID: ${template.id}, Typ: ${template.kind})`, ...(template.description ? [`Beschreibung: ${template.description}`] : []), ...this.formatItems(template.items, template.omittedItems, false), ].join('\n'); } private formatItems( items: NormalizedContextItem[], omittedItems: number, includeChecked: boolean, ): string[] { if (items.length === 0) { return ['Items: keine']; } const lines = ['Items:']; for (const item of items) { const parts = [ `ID: ${item.id}`, typeof item.quantity === 'number' ? `Menge: ${item.quantity}` : null, typeof item.required === 'boolean' ? `Pflicht: ${item.required ? 'ja' : 'nein'}` : null, includeChecked && typeof item.checked === 'boolean' ? `Erledigt: ${item.checked ? 'ja' : 'nein'}` : null, item.notes ? `Notizen: ${item.notes}` : null, ].filter((part): part is string => part !== null); lines.push(`- ${item.title} (${parts.join(', ')})`); } if (omittedItems > 0) { lines.push(`- ${omittedItems} weitere Eintraege ausgelassen.`); } return lines; } private compactString( value: string | undefined, maxLength: number, ): string | undefined { if (typeof value !== 'string') { return undefined; } const compacted = value.replace(/\s+/g, ' ').trim(); if (!compacted) { return undefined; } if (compacted.length <= maxLength) { return compacted; } return `${compacted.slice(0, maxLength - 3)}...`; } }