mcp
This commit is contained in:
@@ -137,6 +137,28 @@ export class ListsController {
|
|||||||
return this.listsService.suggestItems(this.requireUserId(request), listId);
|
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')
|
@Post(':listId/template')
|
||||||
createTemplateFromList(
|
createTemplateFromList(
|
||||||
@Req() request: AuthenticatedRequest,
|
@Req() request: AuthenticatedRequest,
|
||||||
|
|||||||
@@ -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 () => {
|
it('returns an empty suggestion list for malformed provider content', async () => {
|
||||||
process.env.MISTRAL_API_KEY = 'test-key';
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||||
@@ -576,6 +754,9 @@ describe('ListsService', () => {
|
|||||||
await expect(service.suggestItems('user-1', list.id)).rejects.toThrow(
|
await expect(service.suggestItems('user-1', list.id)).rejects.toThrow(
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
);
|
);
|
||||||
|
await expect(service.applyListCleanup('user-1', list.id)).rejects.toThrow(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,53 @@ export interface CreateListWithItemSuggestionsResponse {
|
|||||||
suggestions: ListItemSuggestion[];
|
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 {
|
interface MistralCompletionResponse {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
@@ -68,6 +115,8 @@ interface MistralCompletionResponse {
|
|||||||
export class ListsService {
|
export class ListsService {
|
||||||
private readonly itemSuggestionsEndpoint =
|
private readonly itemSuggestionsEndpoint =
|
||||||
'https://api.mistral.ai/v1/conversations';
|
'https://api.mistral.ai/v1/conversations';
|
||||||
|
private readonly listCleanupEndpoint =
|
||||||
|
'https://api.mistral.ai/v1/conversations';
|
||||||
private readonly defaultMistralModel = 'mistral-large-latest';
|
private readonly defaultMistralModel = 'mistral-large-latest';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -551,6 +600,95 @@ export class ListsService {
|
|||||||
return { suggestions };
|
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(
|
async createTemplateFromList(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
listId: string,
|
listId: string,
|
||||||
@@ -975,6 +1113,48 @@ export class ListsService {
|
|||||||
return responsePayload;
|
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 {
|
private createItemSuggestionPrompt(list: UserListEntity): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
'Erzeuge bis zu 6 sinnvolle neue Items fuer diese Liste.',
|
'Erzeuge bis zu 6 sinnvolle neue Items fuer diese Liste.',
|
||||||
@@ -1010,6 +1190,40 @@ export class ListsService {
|
|||||||
return lines.join('\n');
|
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(
|
private async readMistralResponsePayload(
|
||||||
response: Response,
|
response: Response,
|
||||||
): Promise<unknown> {
|
): 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 {
|
private suggestionKey(value: string): string {
|
||||||
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,20 @@
|
|||||||
Template
|
Template
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="cleaningList() || !onlineStatus.online()"
|
||||||
|
(click)="applyCleanup()"
|
||||||
|
>
|
||||||
|
@if (cleaningList()) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">auto_fix_high</mat-icon>
|
||||||
|
}
|
||||||
|
AI Cleanup
|
||||||
|
</button>
|
||||||
|
|
||||||
<button mat-stroked-button type="button" (click)="showEditor() ? cancelEditing() : startEditing()">
|
<button mat-stroked-button type="button" (click)="showEditor() ? cancelEditing() : startEditing()">
|
||||||
<mat-icon aria-hidden="true">{{ showEditor() ? 'close' : 'edit' }}</mat-icon>
|
<mat-icon aria-hidden="true">{{ showEditor() ? 'close' : 'edit' }}</mat-icon>
|
||||||
{{ showEditor() ? 'Abbrechen' : 'Bearbeiten' }}
|
{{ showEditor() ? 'Abbrechen' : 'Bearbeiten' }}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { OnboardingService } from '../../onboarding/onboarding.service';
|
|||||||
import { ConfirmDeleteListDialogComponent } from '../confirm-delete-list-dialog/confirm-delete-list-dialog.component';
|
import { ConfirmDeleteListDialogComponent } from '../confirm-delete-list-dialog/confirm-delete-list-dialog.component';
|
||||||
import { OnlineStatusService } from '../../offline/online-status.service';
|
import { OnlineStatusService } from '../../offline/online-status.service';
|
||||||
import {
|
import {
|
||||||
|
ApplyListCleanupResponse,
|
||||||
ListItemSuggestion,
|
ListItemSuggestion,
|
||||||
ListRealtimeEvent,
|
ListRealtimeEvent,
|
||||||
UserList,
|
UserList,
|
||||||
@@ -66,6 +67,7 @@ export class ListDetailComponent implements OnInit {
|
|||||||
protected readonly saving = signal(false);
|
protected readonly saving = signal(false);
|
||||||
protected readonly creatingWithAi = signal(false);
|
protected readonly creatingWithAi = signal(false);
|
||||||
protected readonly creatingTemplate = signal(false);
|
protected readonly creatingTemplate = signal(false);
|
||||||
|
protected readonly cleaningList = signal(false);
|
||||||
protected readonly deletingList = signal(false);
|
protected readonly deletingList = signal(false);
|
||||||
protected readonly editing = signal(false);
|
protected readonly editing = signal(false);
|
||||||
protected readonly addingItem = signal(false);
|
protected readonly addingItem = signal(false);
|
||||||
@@ -342,6 +344,32 @@ export class ListDetailComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected applyCleanup(): void {
|
||||||
|
const listId = this.currentListId();
|
||||||
|
|
||||||
|
if (!listId || this.cleaningList()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleaningList.set(true);
|
||||||
|
this.listsService
|
||||||
|
.applyCleanup(listId)
|
||||||
|
.pipe(finalize(() => this.cleaningList.set(false)))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.setList(response.list, !this.showEditor());
|
||||||
|
this.itemSuggestions.set([]);
|
||||||
|
this.suggestionsLoaded.set(false);
|
||||||
|
this.snackBar.open(this.cleanupSummary(response), 'OK', {
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected addSuggestion(suggestion: ListItemSuggestion): void {
|
protected addSuggestion(suggestion: ListItemSuggestion): void {
|
||||||
const listId = this.currentListId();
|
const listId = this.currentListId();
|
||||||
|
|
||||||
@@ -615,4 +643,23 @@ export class ListDetailComponent implements OnInit {
|
|||||||
private suggestionKey(value: string): string {
|
private suggestionKey(value: string): string {
|
||||||
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cleanupSummary(response: ApplyListCleanupResponse): string {
|
||||||
|
const parts = [
|
||||||
|
response.summary.listUpdated ? 'Details aktualisiert' : null,
|
||||||
|
response.summary.itemsUpdated > 0
|
||||||
|
? `${response.summary.itemsUpdated} Items verbessert`
|
||||||
|
: null,
|
||||||
|
response.summary.duplicatesDeleted > 0
|
||||||
|
? `${response.summary.duplicatesDeleted} Duplikate entfernt`
|
||||||
|
: null,
|
||||||
|
response.summary.itemsAdded > 0
|
||||||
|
? `${response.summary.itemsAdded} Items hinzugefuegt`
|
||||||
|
: null,
|
||||||
|
].filter((part): part is string => part !== null);
|
||||||
|
|
||||||
|
return parts.length > 0
|
||||||
|
? `Cleanup angewendet: ${parts.join(', ')}.`
|
||||||
|
: 'Cleanup geprueft: keine sinnvollen Aenderungen gefunden.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,53 @@ export interface CreateListWithItemSuggestionsResponse {
|
|||||||
suggestions: ListItemSuggestion[];
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateListItemRequest {
|
export interface UpdateListItemRequest {
|
||||||
title?: string;
|
title?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { Observable } from 'rxjs';
|
|||||||
import { of, tap, throwError } from 'rxjs';
|
import { of, tap, throwError } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
AddListItemRequest,
|
AddListItemRequest,
|
||||||
|
ApplyListCleanupResponse,
|
||||||
CreateListRequest,
|
CreateListRequest,
|
||||||
CreateListWithItemSuggestionsResponse,
|
CreateListWithItemSuggestionsResponse,
|
||||||
|
ListCleanupSuggestionsResponse,
|
||||||
ListItemSuggestionsResponse,
|
ListItemSuggestionsResponse,
|
||||||
UpdateListItemRequest,
|
UpdateListItemRequest,
|
||||||
UpdateListRequest,
|
UpdateListRequest,
|
||||||
@@ -173,6 +175,34 @@ export class ListsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suggestCleanup(listId: string): Observable<ListCleanupSuggestionsResponse> {
|
||||||
|
if (!this.onlineStatus.online()) {
|
||||||
|
return throwError(
|
||||||
|
() => new Error('AI Cleanup ist nur online verfuegbar.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<ListCleanupSuggestionsResponse>(
|
||||||
|
`${this.apiUrl}/${listId}/cleanup-suggestions`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCleanup(listId: string): Observable<ApplyListCleanupResponse> {
|
||||||
|
if (!this.onlineStatus.online()) {
|
||||||
|
return throwError(
|
||||||
|
() => new Error('AI Cleanup ist nur online verfuegbar.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.post<ApplyListCleanupResponse>(
|
||||||
|
`${this.apiUrl}/${listId}/apply-cleanup`,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
.pipe(tap((response) => this.upsertCachedList(response.list)));
|
||||||
|
}
|
||||||
|
|
||||||
createTemplateFromList(listId: string): Observable<ListTemplate> {
|
createTemplateFromList(listId: string): Observable<ListTemplate> {
|
||||||
if (!this.onlineStatus.online()) {
|
if (!this.onlineStatus.online()) {
|
||||||
return throwError(
|
return throwError(
|
||||||
|
|||||||
Reference in New Issue
Block a user