281 lines
8.0 KiB
TypeScript
281 lines
8.0 KiB
TypeScript
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<ListSuggestionsResult> {
|
|
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<string>,
|
|
): 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<string>,
|
|
): 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>): 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';
|
|
}
|
|
}
|