Vorschläge
This commit is contained in:
@@ -150,7 +150,7 @@ export class AssistantService {
|
||||
{
|
||||
type: 'connector',
|
||||
connector_id: 'listify',
|
||||
}
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
response_format: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UserEntity>;
|
||||
|
||||
@@ -20,6 +27,13 @@ describe('ListsService', () => {
|
||||
new InMemoryRepository<UserListShareEntity>() 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;
|
||||
}
|
||||
|
||||
@@ -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<UserListEntity>,
|
||||
@@ -460,6 +487,17 @@ export class ListsService {
|
||||
return updatedList;
|
||||
}
|
||||
|
||||
async suggestItems(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
): Promise<ListItemSuggestionsResponse> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<string>();
|
||||
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)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user