diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 0a2e10d..02ff167 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -150,7 +150,7 @@ export class AssistantService { { type: 'connector', connector_id: 'listify', - } + }, ], stream: false, response_format: { diff --git a/listify-api/src/lists/lists.controller.ts b/listify-api/src/lists/lists.controller.ts index ac57a15..e13d991 100644 --- a/listify-api/src/lists/lists.controller.ts +++ b/listify-api/src/lists/lists.controller.ts @@ -118,6 +118,14 @@ export class ListsController { ); } + @Post(':listId/item-suggestions') + suggestItems( + @Req() request: AuthenticatedRequest, + @Param('listId') listId: string, + ) { + return this.listsService.suggestItems(this.requireUserId(request), listId); + } + @Patch(':listId/items/:itemId') async updateItem( @Req() request: AuthenticatedRequest, diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index 0bb484b..b2e9185 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -1,4 +1,8 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; import { UserEntity } from '../auth/user.entity'; import { ListTemplate } from '../list-templates/list-template.types'; import { InMemoryRepository } from '../testing/in-memory-repository'; @@ -9,6 +13,9 @@ import { UserListItemEntity } from './user-list-item.entity'; import { UserListShareEntity } from './user-list-share.entity'; describe('ListsService', () => { + const originalFetch = global.fetch; + const originalApiKey = process.env.MISTRAL_API_KEY; + const originalAgentId = process.env.MISTRAL_AGENT_ID; let service: ListsService; let usersRepository: InMemoryRepository; @@ -20,6 +27,13 @@ describe('ListsService', () => { new InMemoryRepository() as never, usersRepository as never, ); + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + restoreEnv('MISTRAL_API_KEY', originalApiKey); + restoreEnv('MISTRAL_AGENT_ID', originalAgentId); }); it('creates and lists concrete lists for the owning user', async () => { @@ -252,4 +266,151 @@ describe('ListsService', () => { NotFoundException, ); }); + + it('suggests normalized items and filters existing titles', async () => { + process.env.MISTRAL_API_KEY = 'test-key'; + process.env.MISTRAL_AGENT_ID = 'agent-listify'; + const list = await service.createList('user-1', { + name: 'Sommerurlaub', + description: 'Eine Woche am Meer', + kind: 'packing', + }); + await service.addItem('user-1', list.id, { + title: 'Pass', + required: true, + }); + mockMistralResponse({ + choices: [ + { + message: { + content: JSON.stringify({ + suggestions: [ + { + title: 'Pass', + required: true, + }, + { + title: 'Sonnencreme', + notes: 'Reisegroesse', + quantity: 1, + required: true, + }, + { + title: 'sonnencreme', + required: false, + }, + { + title: 'Badehose', + quantity: 0, + }, + { + title: ' ', + }, + ], + }), + }, + }, + ], + }); + + const response = await service.suggestItems('user-1', list.id); + + expect(response).toEqual({ + suggestions: [ + { + title: 'Sonnencreme', + notes: 'Reisegroesse', + quantity: 1, + required: true, + }, + { + title: 'Badehose', + quantity: undefined, + notes: undefined, + required: true, + }, + ], + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.mistral.ai/v1/agents/completions', + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: 'Bearer test-key', + 'Content-Type': 'application/json', + }, + }), + ); + const requestPayload = getMistralRequestPayload(); + expect(requestPayload.messages[1].content).toContain('Name: Sommerurlaub'); + expect(requestPayload.messages[1].content).toContain( + 'Beschreibung: Eine Woche am Meer', + ); + expect(requestPayload.messages[1].content).toContain('Titel: Pass'); + expect(requestPayload.tools).toBeUndefined(); + }); + + it('returns an empty suggestion list for malformed provider content', async () => { + process.env.MISTRAL_API_KEY = 'test-key'; + process.env.MISTRAL_AGENT_ID = 'agent-listify'; + const list = await service.createList('user-1', { + name: 'Einkauf', + kind: 'shopping', + }); + mockMistralResponse({ + choices: [{ message: { content: 'keine json antwort' } }], + }); + + await expect(service.suggestItems('user-1', list.id)).resolves.toEqual({ + suggestions: [], + }); + }); + + it('fails clearly when item suggestions are not configured', async () => { + delete process.env.MISTRAL_API_KEY; + process.env.MISTRAL_AGENT_ID = 'agent-listify'; + const list = await service.createList('user-1', { + name: 'Einkauf', + kind: 'shopping', + }); + + await expect(service.suggestItems('user-1', list.id)).rejects.toThrow( + ServiceUnavailableException, + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); }); + +function mockMistralResponse(response: object, ok = true, status = 200): void { + jest.mocked(global.fetch).mockResolvedValue({ + ok, + status, + text: async () => JSON.stringify(response), + } as Response); +} + +function getMistralRequestPayload(): { + messages: Array<{ role: string; content: string }>; + tools?: unknown; +} { + const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? []; + const body = init?.body; + + if (typeof body !== 'string') { + throw new Error('Expected Mistral request body to be JSON.'); + } + + return JSON.parse(body) as { + messages: Array<{ role: string; content: string }>; + tools?: unknown; + }; +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + + process.env[key] = value; +} diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 842fa4f..c0d454a 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -4,6 +4,7 @@ import { Injectable, NotFoundException, Optional, + ServiceUnavailableException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; @@ -27,8 +28,34 @@ import { UserListEntity } from './user-list.entity'; import { UserListItemEntity } from './user-list-item.entity'; import { UserListShareEntity } from './user-list-share.entity'; +export interface ListItemSuggestion { + title: string; + notes?: string; + quantity?: number; + required: boolean; +} + +export interface ListItemSuggestionsResponse { + suggestions: ListItemSuggestion[]; +} + +interface MistralAgentCompletionResponse { + choices?: Array<{ + message?: { + content?: string | null; + }; + messages?: Array<{ + role?: string; + content?: string | null | unknown[]; + }>; + }>; +} + @Injectable() export class ListsService { + private readonly itemSuggestionsEndpoint = + 'https://api.mistral.ai/v1/agents/completions'; + constructor( @InjectRepository(UserListEntity) private readonly listsRepository: Repository, @@ -460,6 +487,17 @@ export class ListsService { return updatedList; } + async suggestItems( + ownerId: string, + listId: string, + ): Promise { + const list = await this.findAccessibleList(ownerId, listId); + const response = await this.callMistralForItemSuggestions(list); + const suggestions = this.normalizeItemSuggestions(response, list.items); + + return { suggestions }; + } + private async findAccessibleList( ownerId: string, listId: string, @@ -722,4 +760,247 @@ export class ListsService { private toIsoString(value?: Date): string { return (value ?? new Date()).toISOString(); } + + private async callMistralForItemSuggestions( + list: UserListEntity, + ): Promise { + const apiKey = process.env.MISTRAL_API_KEY; + const agentId = process.env.MISTRAL_AGENT_ID; + + if (!apiKey) { + throw new ServiceUnavailableException('Mistral API key is not configured.'); + } + + if (!agentId) { + throw new ServiceUnavailableException('Mistral agent id is not configured.'); + } + + const response = await fetch(this.itemSuggestionsEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + agent_id: agentId, + messages: [ + { + role: 'system', + content: + 'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.', + }, + { + role: 'user', + content: this.createItemSuggestionPrompt(list), + }, + ], + stream: false, + response_format: { + type: 'json_object', + }, + }), + }); + const responsePayload = await this.readMistralResponsePayload(response); + + if (!response.ok) { + throw new ServiceUnavailableException('Mistral agent request failed.'); + } + + return responsePayload; + } + + private createItemSuggestionPrompt(list: UserListEntity): string { + const lines = [ + 'Erzeuge bis zu 6 sinnvolle neue Items fuer diese Liste.', + 'Schlage keine Items vor, die bereits vorhanden sind.', + `Name: ${this.compactText(list.name, 160)}`, + `Typ: ${list.kind}`, + ]; + + const description = this.compactText(list.description ?? undefined, 240); + + if (description) { + lines.push(`Beschreibung: ${description}`); + } + + if (list.items.length === 0) { + lines.push('Vorhandene Items: keine'); + } else { + lines.push('Vorhandene Items:'); + + for (const item of list.items.slice(0, 40)) { + const parts = [ + `Titel: ${this.compactText(item.title, 160)}`, + item.notes ? `Notizen: ${this.compactText(item.notes, 220)}` : 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 { + const rawBody = await response.text(); + + if (!rawBody) { + return null; + } + + try { + return JSON.parse(rawBody) as unknown; + } catch { + return { rawBody }; + } + } + + private normalizeItemSuggestions( + responsePayload: unknown, + existingItems: UserListItemEntity[], + ): ListItemSuggestion[] { + const content = this.extractMistralContent(responsePayload); + const parsed = this.parseSuggestionsJson(content); + const existingTitles = new Set( + existingItems.map((item) => this.suggestionKey(item.title)), + ); + const seenTitles = new Set(); + const suggestions: ListItemSuggestion[] = []; + + for (const value of parsed) { + const suggestion = this.normalizeItemSuggestion(value); + + if (!suggestion) { + continue; + } + + const key = this.suggestionKey(suggestion.title); + + if (existingTitles.has(key) || seenTitles.has(key)) { + continue; + } + + seenTitles.add(key); + suggestions.push(suggestion); + + if (suggestions.length === 6) { + break; + } + } + + return suggestions; + } + + private extractMistralContent(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) { + 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 = + typeof message.content === 'string' ? message.content.trim() : ''; + + if (content) { + return content; + } + } + } + + return null; + } + + private parseSuggestionsJson(content: string | null): unknown[] { + if (!content) { + return []; + } + + try { + const parsed = JSON.parse(content) as unknown; + + if (Array.isArray(parsed)) { + return parsed; + } + + if (parsed && typeof parsed === 'object') { + const suggestions = (parsed as { suggestions?: unknown }).suggestions; + return Array.isArray(suggestions) ? suggestions : []; + } + } catch { + return []; + } + + return []; + } + + private normalizeItemSuggestion(value: unknown): ListItemSuggestion | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const candidate = value as { + title?: unknown; + notes?: unknown; + quantity?: unknown; + required?: unknown; + }; + const title = + typeof candidate.title === 'string' + ? this.compactText(candidate.title, 220) + : undefined; + + if (!title) { + return null; + } + + const quantity = + typeof candidate.quantity === 'number' && + Number.isFinite(candidate.quantity) && + candidate.quantity > 0 + ? candidate.quantity + : undefined; + + return { + title, + notes: + typeof candidate.notes === 'string' + ? this.compactText(candidate.notes, 500) + : undefined, + quantity, + required: + typeof candidate.required === 'boolean' ? candidate.required : true, + }; + } + + private suggestionKey(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); + } + + private compactText(value: string | undefined, maxLength: number): string | undefined { + const compacted = value?.replace(/\s+/g, ' ').trim(); + + if (!compacted) { + return undefined; + } + + if (compacted.length <= maxLength) { + return compacted; + } + + return `${compacted.slice(0, maxLength - 3)}...`; + } } diff --git a/listify-client/src/app/assistant/assistant-chat.component.spec.ts b/listify-client/src/app/assistant/assistant-chat.component.spec.ts index f10f790..b486226 100644 --- a/listify-client/src/app/assistant/assistant-chat.component.spec.ts +++ b/listify-client/src/app/assistant/assistant-chat.component.spec.ts @@ -88,7 +88,9 @@ describe('AssistantChatComponent', () => { }); it('falls back to route context when list context loading fails', async () => { - listsService.getList.mockReturnValue(throwError(() => new Error('not found'))); + listsService.getList.mockReturnValue( + throwError(() => new Error('not found')), + ); await router.navigateByUrl('/lists/list-1'); const fixture = TestBed.createComponent(AssistantChatComponent); const component = fixture.componentInstance as unknown as { 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 75d6f89..dd16919 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 @@ -214,6 +214,75 @@ Hinzufügen + + @if (canEditItems()) { +
+
+
+

Smart Suggestions

+

Passende Items basierend auf Name, Beschreibung und Inhalt.

+
+ + +
+ + @if (itemSuggestions().length > 0) { +
    + @for (suggestion of itemSuggestions(); track suggestion.title) { +
  • +
    + {{ suggestion.title }} + @if (suggestion.notes || suggestion.quantity || !suggestion.required) { + + @if (suggestion.quantity) { + Menge: {{ suggestion.quantity }} + } + @if (suggestion.notes) { + {{ suggestion.quantity ? '- ' : '' }}{{ suggestion.notes }} + } + @if (!suggestion.required) { + {{ suggestion.quantity || suggestion.notes ? '- ' : '' }}Optional + } + + } +
    + + +
  • + } +
+ } @else if (suggestionsLoaded()) { +
+ + Keine neuen Vorschlaege gefunden. +
+ } +
+ } } @if (!canEditItems()) { @@ -223,7 +292,7 @@ } @else if (list()?.items?.length) {
    - @for (item of list()!.items; track item.id) { + @for (item of visibleItems(list()!); track item.id) {
  • (null); protected readonly updatingItemId = signal(null); + protected readonly addingSuggestionTitle = signal(null); + protected readonly itemSuggestions = signal([]); protected readonly shareSearchTerm = signal(''); protected readonly shareSearchResults = signal([]); protected readonly searchingUsers = signal(false); @@ -201,6 +210,63 @@ export class ListDetailComponent implements OnInit { }); } + protected loadSuggestions(): void { + const listId = this.listId(); + + if (!listId || !this.canEditItems() || this.loadingSuggestions()) { + return; + } + + this.loadingSuggestions.set(true); + this.suggestionsLoaded.set(false); + + this.listsService + .suggestItems(listId) + .pipe(finalize(() => this.loadingSuggestions.set(false))) + .subscribe({ + next: (response) => { + this.itemSuggestions.set(response.suggestions); + this.suggestionsLoaded.set(true); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); + }, + }); + } + + protected addSuggestion(suggestion: ListItemSuggestion): void { + const listId = this.listId(); + + if (!listId || this.addingSuggestionTitle()) { + return; + } + + this.addingSuggestionTitle.set(suggestion.title); + this.listsService + .addItem(listId, { + title: suggestion.title, + notes: suggestion.notes, + quantity: suggestion.quantity, + required: suggestion.required, + }) + .pipe(finalize(() => this.addingSuggestionTitle.set(null))) + .subscribe({ + next: (list) => { + this.setList(list); + this.itemSuggestions.update((suggestions) => + suggestions.filter( + (itemSuggestion) => + this.suggestionKey(itemSuggestion.title) !== + this.suggestionKey(suggestion.title), + ), + ); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); + }, + }); + } + protected toggleItem(item: UserListItem, checked: boolean): void { const listId = this.listId(); const currentList = this.list(); @@ -258,6 +324,10 @@ export class ListDetailComponent implements OnInit { return list.items.filter((item) => item.checked).length; } + protected visibleItems(list: UserList): UserListItem[] { + return this.uncheckedFirst(list.items); + } + protected searchShareUsers(term: string): void { this.shareSearchTerm.set(term); @@ -374,4 +444,21 @@ export class ListDetailComponent implements OnInit { private listId(): string | null { return this.route.snapshot.paramMap.get('listId'); } + + private uncheckedFirst(items: UserListItem[]): UserListItem[] { + return items + .map((item, index) => ({ item, index })) + .sort((a, b) => { + if (a.item.checked !== b.item.checked) { + return a.item.checked ? 1 : -1; + } + + return a.index - b.index; + }) + .map(({ item }) => item); + } + + private suggestionKey(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); + } } diff --git a/listify-client/src/app/lists/lists.component.html b/listify-client/src/app/lists/lists.component.html index f19fcf6..1c00f4d 100644 --- a/listify-client/src/app/lists/lists.component.html +++ b/listify-client/src/app/lists/lists.component.html @@ -154,7 +154,7 @@ @if (list.items.length > 0) {
      - @for (item of list.items.slice(0, 4); track item.id) { + @for (item of previewItems(list); track item.id) {