diff --git a/listify-api/src/lists/lists.controller.ts b/listify-api/src/lists/lists.controller.ts index 3ebae39..4eaa8e9 100644 --- a/listify-api/src/lists/lists.controller.ts +++ b/listify-api/src/lists/lists.controller.ts @@ -137,6 +137,28 @@ export class ListsController { return this.listsService.suggestItems(this.requireUserId(request), listId); } + @Post(':listId/cleanup-suggestions') + suggestCleanup( + @Req() request: AuthenticatedRequest, + @Param('listId') listId: string, + ) { + return this.listsService.suggestListCleanup( + this.requireUserId(request), + listId, + ); + } + + @Post(':listId/apply-cleanup') + applyCleanup( + @Req() request: AuthenticatedRequest, + @Param('listId') listId: string, + ) { + return this.listsService.applyListCleanup( + this.requireUserId(request), + listId, + ); + } + @Post(':listId/template') createTemplateFromList( @Req() request: AuthenticatedRequest, diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index c234555..93e1ae4 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -550,6 +550,184 @@ describe('ListsService', () => { ); }); + it('applies AI cleanup suggestions to list details and items', async () => { + process.env.MISTRAL_API_KEY = 'test-key'; + process.env.MISTRAL_MODEL = 'mistral-large-test'; + const list = await service.createList('user-1', { + name: 'sommer urlaub', + description: 'zeug fuer reise', + kind: 'packing', + }); + const withPass = await service.addItem('user-1', list.id, { + title: 'Pass', + required: true, + }); + const withDuplicate = await service.addItem('user-1', list.id, { + title: 'Reisepass', + required: true, + }); + const withSnack = await service.addItem('user-1', list.id, { + title: 'snacks', + required: true, + }); + const pass = withPass.items[0]; + const duplicatePass = withDuplicate.items[1]; + const snack = withSnack.items[2]; + + mockMistralResponse({ + choices: [ + { + message: { + content: JSON.stringify({ + listUpdate: { + name: 'Sommerurlaub', + description: 'Packliste fuer die Sommerreise.', + reason: 'Titel und Beschreibung praezisiert.', + }, + itemUpdates: [ + { + itemId: snack.id, + title: 'Snacks fuer unterwegs', + notes: 'Kleine haltbare Snacks einpacken', + required: false, + reason: 'Item genauer beschrieben.', + }, + ], + duplicateDeletions: [ + { + itemId: duplicatePass.id, + keptItemId: pass.id, + reason: 'Reisepass ist ein Duplikat von Pass.', + }, + ], + itemAdditions: [ + { + title: 'Tickets', + notes: 'Digital und offline verfuegbar halten', + required: true, + reason: 'Typischer Reisegegenstand fehlt.', + }, + ], + }), + }, + }, + ], + }); + + const response = await service.applyListCleanup('user-1', list.id); + + expect(response.summary).toEqual({ + listUpdated: true, + itemsUpdated: 1, + duplicatesDeleted: 1, + itemsAdded: 1, + }); + expect(response.list.name).toBe('Sommerurlaub'); + expect(response.list.description).toBe('Packliste fuer die Sommerreise.'); + expect(response.list.items.map((item) => item.title)).toEqual([ + 'Pass', + 'Snacks fuer unterwegs', + 'Tickets', + ]); + expect(response.list.items[1]).toEqual( + expect.objectContaining({ + notes: 'Kleine haltbare Snacks einpacken', + required: false, + }), + ); + expect(response.cleanup.duplicateDeletions).toEqual([ + expect.objectContaining({ + itemId: duplicatePass.id, + keptItemId: pass.id, + }), + ]); + + const requestPayload = getMistralRequestPayload(); + expect(requestPayload.inputs[0].content).toContain('Name: sommer urlaub'); + expect(requestPayload.inputs[0].content).toContain(`itemId: ${snack.id}`); + expect(requestPayload.instructions).toContain( + 'Du bereinigst eine Listify-Liste.', + ); + }); + + it('ignores malformed AI cleanup content without mutating the list', async () => { + process.env.MISTRAL_API_KEY = 'test-key'; + const list = await service.createList('user-1', { + name: 'Einkauf', + description: 'Lebensmittel', + kind: 'shopping', + }); + await service.addItem('user-1', list.id, { title: 'Milch' }); + mockMistralResponse({ + choices: [{ message: { content: 'keine json antwort' } }], + }); + + const response = await service.applyListCleanup('user-1', list.id); + + expect(response.summary).toEqual({ + listUpdated: false, + itemsUpdated: 0, + duplicatesDeleted: 0, + itemsAdded: 0, + }); + expect(response.list.name).toBe('Einkauf'); + expect(response.list.description).toBe('Lebensmittel'); + expect(response.list.items.map((item) => item.title)).toEqual(['Milch']); + }); + + it('ignores unsafe AI cleanup actions', async () => { + process.env.MISTRAL_API_KEY = 'test-key'; + const list = await service.createList('user-1', { + name: 'Todo', + kind: 'todo', + }); + const withFirstItem = await service.addItem('user-1', list.id, { + title: 'Termin buchen', + }); + const withCheckedItem = await service.updateItem( + 'user-1', + list.id, + withFirstItem.items[0].id, + { checked: true }, + ); + mockMistralResponse({ + choices: [ + { + message: { + content: JSON.stringify({ + itemUpdates: [ + { itemId: 'missing', title: 'Soll ignoriert werden' }, + ], + duplicateDeletions: [ + { + itemId: withCheckedItem.items[0].id, + keptItemId: 'missing', + reason: 'Checked items must not be deleted.', + }, + ], + itemAdditions: [{ title: 'Termin buchen', required: true }], + }), + }, + }, + ], + }); + + const response = await service.applyListCleanup('user-1', list.id); + + expect(response.summary).toEqual({ + listUpdated: false, + itemsUpdated: 0, + duplicatesDeleted: 0, + itemsAdded: 0, + }); + expect(response.list.items).toEqual([ + expect.objectContaining({ + title: 'Termin buchen', + checked: true, + }), + ]); + }); + it('returns an empty suggestion list for malformed provider content', async () => { process.env.MISTRAL_API_KEY = 'test-key'; process.env.MISTRAL_MODEL = 'mistral-large-test'; @@ -576,6 +754,9 @@ describe('ListsService', () => { await expect(service.suggestItems('user-1', list.id)).rejects.toThrow( ServiceUnavailableException, ); + await expect(service.applyListCleanup('user-1', list.id)).rejects.toThrow( + ServiceUnavailableException, + ); expect(global.fetch).not.toHaveBeenCalled(); }); }); diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 145e257..7d0ec80 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -46,6 +46,53 @@ export interface CreateListWithItemSuggestionsResponse { suggestions: ListItemSuggestion[]; } +export interface ListCleanupListUpdate { + name?: string; + description?: string; + reason?: string; +} + +export interface ListCleanupItemUpdate { + itemId: string; + title?: string; + notes?: string; + quantity?: number; + required?: boolean; + reason?: string; +} + +export interface ListCleanupDuplicateDeletion { + itemId: string; + keptItemId: string; + reason?: string; +} + +export interface ListCleanupItemAddition extends ListItemSuggestion { + reason?: string; +} + +export interface ListCleanupSuggestion { + listUpdate?: ListCleanupListUpdate; + itemUpdates: ListCleanupItemUpdate[]; + duplicateDeletions: ListCleanupDuplicateDeletion[]; + itemAdditions: ListCleanupItemAddition[]; +} + +export interface ListCleanupSuggestionsResponse { + cleanup: ListCleanupSuggestion; +} + +export interface ApplyListCleanupResponse { + list: UserList; + cleanup: ListCleanupSuggestion; + summary: { + listUpdated: boolean; + itemsUpdated: number; + duplicatesDeleted: number; + itemsAdded: number; + }; +} + interface MistralCompletionResponse { choices?: Array<{ message?: { @@ -68,6 +115,8 @@ interface MistralCompletionResponse { export class ListsService { private readonly itemSuggestionsEndpoint = 'https://api.mistral.ai/v1/conversations'; + private readonly listCleanupEndpoint = + 'https://api.mistral.ai/v1/conversations'; private readonly defaultMistralModel = 'mistral-large-latest'; constructor( @@ -551,6 +600,95 @@ export class ListsService { return { suggestions }; } + async suggestListCleanup( + ownerId: string, + listId: string, + ): Promise { + const list = await this.findAccessibleList(ownerId, listId); + const response = await this.callMistralForListCleanup(list); + + return { + cleanup: this.normalizeListCleanup(response, list), + }; + } + + async applyListCleanup( + ownerId: string, + listId: string, + ): Promise { + const list = await this.findAccessibleList(ownerId, listId); + const response = await this.callMistralForListCleanup(list); + const cleanup = this.normalizeListCleanup(response, list); + const deletedItemIds = new Set( + cleanup.duplicateDeletions.map((deletion) => deletion.itemId), + ); + let currentList: UserList | null = null; + let listUpdated = false; + let itemsUpdated = 0; + let duplicatesDeleted = 0; + let itemsAdded = 0; + + if (cleanup.listUpdate) { + currentList = await this.updateList(ownerId, listId, { + ...(cleanup.listUpdate.name !== undefined + ? { name: cleanup.listUpdate.name } + : {}), + ...(cleanup.listUpdate.description !== undefined + ? { description: cleanup.listUpdate.description } + : {}), + }); + listUpdated = true; + } + + for (const itemUpdate of cleanup.itemUpdates) { + if (deletedItemIds.has(itemUpdate.itemId)) { + continue; + } + + currentList = await this.updateItem(ownerId, listId, itemUpdate.itemId, { + ...(itemUpdate.title !== undefined ? { title: itemUpdate.title } : {}), + ...(itemUpdate.notes !== undefined ? { notes: itemUpdate.notes } : {}), + ...(itemUpdate.quantity !== undefined + ? { quantity: itemUpdate.quantity } + : {}), + ...(itemUpdate.required !== undefined + ? { required: itemUpdate.required } + : {}), + }); + itemsUpdated += 1; + } + + for (const duplicateDeletion of cleanup.duplicateDeletions) { + currentList = await this.deleteItem( + ownerId, + listId, + duplicateDeletion.itemId, + ); + duplicatesDeleted += 1; + } + + for (const itemAddition of cleanup.itemAdditions) { + currentList = await this.addItem(ownerId, listId, { + title: itemAddition.title, + notes: itemAddition.notes, + quantity: itemAddition.quantity, + required: itemAddition.required, + }); + itemsAdded += 1; + } + + return { + list: currentList ?? (await this.getList(ownerId, listId)), + cleanup, + summary: { + listUpdated, + itemsUpdated, + duplicatesDeleted, + itemsAdded, + }, + }; + } + async createTemplateFromList( ownerId: string, listId: string, @@ -975,6 +1113,48 @@ export class ListsService { return responsePayload; } + private async callMistralForListCleanup( + list: UserListEntity, + ): Promise { + const apiKey = process.env.MISTRAL_API_KEY; + const model = process.env.MISTRAL_MODEL ?? this.defaultMistralModel; + + if (!apiKey) { + throw new ServiceUnavailableException( + 'Mistral API key is not configured.', + ); + } + + const response = await fetch(this.listCleanupEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + inputs: [ + { + role: 'user', + content: this.createListCleanupPrompt(list), + }, + ], + instructions: + 'Du bereinigst eine Listify-Liste. Antworte nur mit JSON im Format {"listUpdate":{"name":"...","description":"...","reason":"..."},"itemUpdates":[{"itemId":"...","title":"...","notes":"...","quantity":1,"required":true,"reason":"..."}],"duplicateDeletions":[{"itemId":"...","keptItemId":"...","reason":"..."}],"itemAdditions":[{"title":"...","notes":"...","quantity":1,"required":true,"reason":"..."}]}. Keine Markdown-Ausgabe. Nutze ausschliesslich vorhandene itemId-Werte fuer itemUpdates und duplicateDeletions.', + stream: false, + }), + }); + const responsePayload = await this.readMistralResponsePayload(response); + + if (!response.ok) { + throw new ServiceUnavailableException( + 'Mistral conversation request failed.', + ); + } + + return responsePayload; + } + private createItemSuggestionPrompt(list: UserListEntity): string { const lines = [ 'Erzeuge bis zu 6 sinnvolle neue Items fuer diese Liste.', @@ -1010,6 +1190,40 @@ export class ListsService { return lines.join('\n'); } + private createListCleanupPrompt(list: UserListEntity): string { + const lines = [ + 'Analysiere diese Liste und schlage konkrete Bereinigungen vor.', + 'Korrigiere Rechtschreibung und unklare Formulierungen im Listentitel, in der Beschreibung und in Items.', + 'Erkenne doppelte Items und markiere nur das schlechtere Duplikat zum Loeschen.', + 'Schlage fehlende sinnvolle Items vor, wenn sie die Liste praktisch ergaenzen.', + 'Aendere keine Bedeutung und loesche keine erledigten Items.', + `Name: ${this.compactText(list.name, 160)}`, + `Typ: ${list.kind}`, + `Beschreibung: ${this.compactText(list.description ?? undefined, 500) ?? 'keine'}`, + ]; + + if (list.items.length === 0) { + lines.push('Vorhandene Items: keine'); + } else { + lines.push('Vorhandene Items:'); + + for (const item of list.items.slice(0, 80)) { + const parts = [ + `itemId: ${item.id}`, + `Titel: ${this.compactText(item.title, 220)}`, + item.notes ? `Notizen: ${this.compactText(item.notes, 500)}` : null, + typeof item.quantity === 'number' ? `Menge: ${item.quantity}` : null, + `Pflicht: ${item.required ? 'ja' : 'nein'}`, + `Erledigt: ${item.checked ? 'ja' : 'nein'}`, + ].filter((part): part is string => part !== null); + + lines.push(`- ${parts.join(', ')}`); + } + } + + return lines.join('\n'); + } + private async readMistralResponsePayload( response: Response, ): Promise { @@ -1172,6 +1386,303 @@ export class ListsService { }; } + private normalizeListCleanup( + responsePayload: unknown, + list: UserListEntity, + ): ListCleanupSuggestion { + const content = this.extractMistralContent(responsePayload); + const parsed = this.parseObjectJson(content); + const listUpdate = this.normalizeCleanupListUpdate( + parsed?.listUpdate, + list, + ); + const duplicateDeletions = this.normalizeCleanupDuplicateDeletions( + parsed?.duplicateDeletions, + list, + ); + const deletedItemIds = new Set( + duplicateDeletions.map((deletion) => deletion.itemId), + ); + const itemUpdates = this.normalizeCleanupItemUpdates( + parsed?.itemUpdates, + list, + ).filter((update) => !deletedItemIds.has(update.itemId)); + + return { + ...(listUpdate ? { listUpdate } : {}), + itemUpdates, + duplicateDeletions, + itemAdditions: this.normalizeCleanupItemAdditions( + parsed?.itemAdditions, + list, + itemUpdates, + ), + }; + } + + private parseObjectJson( + content: string | null, + ): Record | null { + if (!content) { + return null; + } + + try { + const parsed = JSON.parse(content) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + + private normalizeCleanupListUpdate( + value: unknown, + list: UserListEntity, + ): ListCleanupListUpdate | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const candidate = value as { + name?: unknown; + description?: unknown; + reason?: unknown; + }; + const name = + typeof candidate.name === 'string' + ? this.compactText(candidate.name, 160) + : undefined; + const description = + typeof candidate.description === 'string' + ? this.compactText(candidate.description, 500) + : undefined; + const reason = + typeof candidate.reason === 'string' + ? this.compactText(candidate.reason, 360) + : undefined; + const update: ListCleanupListUpdate = { + ...(name && name !== list.name ? { name } : {}), + ...(description && description !== (list.description ?? '') + ? { description } + : {}), + ...(reason ? { reason } : {}), + }; + + return update.name || update.description ? update : undefined; + } + + private normalizeCleanupItemUpdates( + value: unknown, + list: UserListEntity, + ): ListCleanupItemUpdate[] { + if (!Array.isArray(value)) { + return []; + } + + const itemsById = new Map(list.items.map((item) => [item.id, item])); + const updates: ListCleanupItemUpdate[] = []; + const seenItemIds = new Set(); + + for (const itemValue of value) { + if ( + !itemValue || + typeof itemValue !== 'object' || + Array.isArray(itemValue) + ) { + continue; + } + + const candidate = itemValue as { + itemId?: unknown; + title?: unknown; + notes?: unknown; + quantity?: unknown; + required?: unknown; + reason?: unknown; + }; + const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : ''; + const existingItem = itemsById.get(itemId); + + if (!existingItem || seenItemIds.has(itemId)) { + continue; + } + + const title = + typeof candidate.title === 'string' + ? this.compactText(candidate.title, 220) + : undefined; + const notes = + typeof candidate.notes === 'string' + ? this.compactText(candidate.notes, 500) + : undefined; + const quantity = + typeof candidate.quantity === 'number' && + Number.isFinite(candidate.quantity) && + candidate.quantity > 0 + ? candidate.quantity + : undefined; + const required = + typeof candidate.required === 'boolean' + ? candidate.required + : undefined; + const reason = + typeof candidate.reason === 'string' + ? this.compactText(candidate.reason, 360) + : undefined; + const update: ListCleanupItemUpdate = { + itemId, + ...(title && title !== existingItem.title ? { title } : {}), + ...(notes !== undefined && notes !== (existingItem.notes ?? '') + ? { notes } + : {}), + ...(quantity !== undefined && quantity !== existingItem.quantity + ? { quantity } + : {}), + ...(required !== undefined && required !== existingItem.required + ? { required } + : {}), + ...(reason ? { reason } : {}), + }; + const hasChange = + update.title !== undefined || + update.notes !== undefined || + update.quantity !== undefined || + update.required !== undefined; + + if (!hasChange) { + continue; + } + + updates.push(update); + seenItemIds.add(itemId); + + if (updates.length === 40) { + break; + } + } + + return updates; + } + + private normalizeCleanupDuplicateDeletions( + value: unknown, + list: UserListEntity, + ): ListCleanupDuplicateDeletion[] { + if (!Array.isArray(value)) { + return []; + } + + const itemsById = new Map(list.items.map((item) => [item.id, item])); + const deletions: ListCleanupDuplicateDeletion[] = []; + const seenItemIds = new Set(); + + for (const deletionValue of value) { + if ( + !deletionValue || + typeof deletionValue !== 'object' || + Array.isArray(deletionValue) + ) { + continue; + } + + const candidate = deletionValue as { + itemId?: unknown; + keptItemId?: unknown; + reason?: unknown; + }; + const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : ''; + const keptItemId = + typeof candidate.keptItemId === 'string' ? candidate.keptItemId : ''; + const item = itemsById.get(itemId); + + if ( + !item || + item.checked || + !itemsById.has(keptItemId) || + itemId === keptItemId || + seenItemIds.has(itemId) + ) { + continue; + } + + deletions.push({ + itemId, + keptItemId, + reason: + typeof candidate.reason === 'string' + ? this.compactText(candidate.reason, 360) + : undefined, + }); + seenItemIds.add(itemId); + + if (deletions.length === 40) { + break; + } + } + + return deletions; + } + + private normalizeCleanupItemAdditions( + value: unknown, + list: UserListEntity, + itemUpdates: ListCleanupItemUpdate[], + ): ListCleanupItemAddition[] { + if (!Array.isArray(value)) { + return []; + } + + const seenTitles = new Set( + list.items.map((item) => this.suggestionKey(item.title)), + ); + + for (const itemUpdate of itemUpdates) { + if (itemUpdate.title) { + seenTitles.add(this.suggestionKey(itemUpdate.title)); + } + } + + const additions: ListCleanupItemAddition[] = []; + + for (const itemValue of value) { + const suggestion = this.normalizeItemSuggestion(itemValue); + + if (!suggestion) { + continue; + } + + const key = this.suggestionKey(suggestion.title); + + if (seenTitles.has(key)) { + continue; + } + + const reason = + itemValue && typeof itemValue === 'object' && !Array.isArray(itemValue) + ? this.compactText( + typeof (itemValue as { reason?: unknown }).reason === 'string' + ? ((itemValue as { reason?: unknown }).reason as string) + : undefined, + 360, + ) + : undefined; + + additions.push({ + ...suggestion, + ...(reason ? { reason } : {}), + }); + seenTitles.add(key); + + if (additions.length === 10) { + break; + } + } + + return additions; + } + private suggestionKey(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.html b/listify-client/src/app/lists/list-detail/list-detail.component.html index 37389b6..7f1832b 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.html +++ b/listify-client/src/app/lists/list-detail/list-detail.component.html @@ -57,6 +57,20 @@ Template + +