Vorschläge

This commit is contained in:
Bastian Wagner
2026-06-15 09:44:59 +02:00
parent 22c93f9ca1
commit cb938d3dc8
12 changed files with 681 additions and 13 deletions

View File

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