import { BadRequestException, Injectable } from '@nestjs/common'; import { ListTemplate, ListTemplateKind, UserList, } from '../list-templates/list-template.types'; import { ListTemplatesService } from '../list-templates/list-templates.service'; import { ListsService } from '../lists/lists.service'; import { ListSuggestionsResult, SuggestListsInput, SuggestedList, SuggestedListItem, } from './list-suggestion.types'; @Injectable() export class ListSuggestionAgentService { constructor( private readonly listsService: ListsService, private readonly listTemplatesService: ListTemplatesService, ) {} async suggestLists( userId: string, input: SuggestListsInput, ): Promise { const goal = this.requireGoal(input.goal); const kind = this.normalizeKind(input.kind) ?? this.inferKind(goal); const constraints = this.normalizeConstraints(input.constraints); const [lists, templates] = await Promise.all([ this.listsService.listLists(userId), this.listTemplatesService.listTemplates(userId), ]); const existingNames = new Set(lists.map((list) => this.nameKey(list.name))); const matchingTemplates = this.rankTemplates(templates, goal, kind).slice(0, 2); const suggestions = matchingTemplates.map((template) => this.suggestFromTemplate(template, goal, constraints, existingNames), ); if (suggestions.length < 3) { suggestions.push( this.suggestFallbackList(goal, kind, constraints, existingNames), ); } return { suggestions: suggestions.slice(0, 3), }; } private suggestFromTemplate( template: ListTemplate, goal: string, constraints: string[], existingNames: Set, ): SuggestedList { const name = this.uniqueName( `${template.name}: ${this.toTitleFragment(goal)}`, existingNames, ); const items = [...template.items] .sort((left, right) => left.position - right.position) .slice(0, 12) .map((item) => ({ title: item.title, notes: item.notes, quantity: item.quantity, required: item.required, })); return { name, description: template.description ?? `Vorschlag auf Basis der Vorlage "${template.name}".`, kind: template.kind, items: this.withConstraintItems(items, constraints), sourceTemplateId: template.id, sourceTemplateName: template.name, rationale: `Nutzt die bestehende Vorlage "${template.name}", weil sie zum Ziel passt.`, }; } private suggestFallbackList( goal: string, kind: ListTemplateKind, constraints: string[], existingNames: Set, ): SuggestedList { return { name: this.uniqueName(this.toTitleFragment(goal), existingNames), description: `Neue ${this.kindLabel(kind)} fuer ${goal}.`, kind, items: this.withConstraintItems(this.defaultItems(kind), constraints), rationale: 'Erzeugt eine neue Liste, weil keine passendere Vorlage priorisiert wurde.', }; } private rankTemplates( templates: ListTemplate[], goal: string, kind: ListTemplateKind, ): ListTemplate[] { const goalTokens = this.tokenize(goal); return [...templates] .map((template) => ({ template, score: (template.kind === kind ? 5 : 0) + this.tokenScore(goalTokens, template.name) + this.tokenScore(goalTokens, template.description ?? '') + template.items.reduce( (score, item) => score + this.tokenScore(goalTokens, item.title), 0, ), })) .filter((entry) => entry.score > 0) .sort((left, right) => right.score - left.score) .map((entry) => entry.template); } private withConstraintItems( items: SuggestedListItem[], constraints: string[], ): SuggestedListItem[] { const constraintItems = constraints.map((constraint) => ({ title: constraint, notes: 'Vom Nutzer genannte Randbedingung.', required: true, })); return [...constraintItems, ...items].slice(0, 15); } private defaultItems(kind: ListTemplateKind): SuggestedListItem[] { if (kind === 'packing') { return [ { title: 'Reisedokumente pruefen', required: true }, { title: 'Tickets und Buchungen sichern', required: true }, { title: 'Ladegeraete einpacken', required: true }, { title: 'Kleidung nach Wetter planen', required: true }, { title: 'Reiseapotheke vorbereiten', required: false }, ]; } if (kind === 'shopping') { return [ { title: 'Grundnahrungsmittel', required: true }, { title: 'Frisches Obst und Gemuese', required: true }, { title: 'Getraenke', required: false }, { title: 'Haushaltsartikel', required: false }, { title: 'Vorratsschrank pruefen', required: true }, ]; } if (kind === 'todo') { return [ { title: 'Ziel klaeren', required: true }, { title: 'Naechste konkrete Aufgabe festlegen', required: true }, { title: 'Abhaengigkeiten pruefen', required: true }, { title: 'Zeitfenster einplanen', required: false }, { title: 'Offene Punkte nachfassen', required: false }, ]; } return [ { title: 'Ziel pruefen', required: true }, { title: 'Wichtige Punkte sammeln', required: true }, { title: 'Prioritaeten setzen', required: true }, { title: 'Naechsten Schritt festlegen', required: false }, ]; } private inferKind(goal: string): ListTemplateKind { const normalizedGoal = goal.toLowerCase(); if (/(urlaub|reise|pack|koffer|trip|flug|hotel)/.test(normalizedGoal)) { return 'packing'; } if (/(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal)) { return 'shopping'; } if (/(todo|aufgabe|projekt|planung|woche|erledigen)/.test(normalizedGoal)) { return 'todo'; } return 'custom'; } private normalizeKind(kind?: ListTemplateKind): ListTemplateKind | undefined { if (kind === undefined) { return undefined; } if ( kind !== 'packing' && kind !== 'shopping' && kind !== 'todo' && kind !== 'custom' ) { throw new BadRequestException('List kind is invalid.'); } return kind; } private normalizeConstraints(constraints?: string[]): string[] { if (constraints === undefined) { return []; } if (!Array.isArray(constraints)) { throw new BadRequestException('Constraints must be an array.'); } return constraints .map((constraint) => constraint.trim()) .filter((constraint) => constraint.length > 0) .slice(0, 5); } private requireGoal(goal?: string): string { const normalizedGoal = goal?.trim(); if (!normalizedGoal) { throw new BadRequestException('Suggestion goal is required.'); } return normalizedGoal; } private uniqueName(name: string, existingNames: Set): string { let candidate = name; let suffix = 2; while (existingNames.has(this.nameKey(candidate))) { candidate = `${name} ${suffix}`; suffix += 1; } existingNames.add(this.nameKey(candidate)); return candidate; } private tokenScore(tokens: string[], value: string): number { const normalizedValue = value.toLowerCase(); return tokens.filter((token) => normalizedValue.includes(token)).length; } private tokenize(value: string): string[] { return value .toLowerCase() .split(/[^a-z0-9]+/i) .map((token) => token.trim()) .filter((token) => token.length >= 3); } private toTitleFragment(value: string): string { const compactValue = value.replace(/\s+/g, ' ').trim(); return compactValue.charAt(0).toUpperCase() + compactValue.slice(1); } private nameKey(name: string): string { return name.trim().toLowerCase(); } private kindLabel(kind: ListTemplateKind): string { return kind === 'packing' ? 'Packliste' : kind === 'shopping' ? 'Einkaufsliste' : kind === 'todo' ? 'Todo-Liste' : 'Liste'; } }