This commit is contained in:
Bastian Wagner
2026-06-26 10:13:31 +02:00
parent 9078da9f66
commit 671fd62ad8
7 changed files with 852 additions and 0 deletions

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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<ListCleanupSuggestionsResponse> {
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<ApplyListCleanupResponse> {
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<unknown> {
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<unknown> {
@@ -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<string, unknown> | null {
if (!content) {
return null;
}
try {
const parsed = JSON.parse(content) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: 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<string>();
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<string>();
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();
}