Files
listify/listify-api/src/assistant/assistant.service.ts
Bastian Wagner cbf1451255 logs
2026-06-24 10:37:14 +02:00

1068 lines
29 KiB
TypeScript

import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import {
ListTemplate,
UserList,
UserListItem,
} from '../list-templates/list-template.types';
import { ListRealtimeService } from '../lists/list-realtime.service';
import { ListsService } from '../lists/lists.service';
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
import {
AssistantAction,
AssistantChatMessage,
AssistantChatLog,
AssistantPageContext,
AssistantChatRequest,
AssistantChatResponse,
} from './assistant.types';
type MistralMessage =
| AssistantChatMessage
| { role: 'system'; content: string };
interface NormalizedContextItem {
id: string;
title: string;
notes?: string;
quantity?: number;
required?: boolean;
checked?: boolean;
}
interface NormalizedContextList {
id: string;
name: string;
kind: string;
description?: string;
items: NormalizedContextItem[];
omittedItems: number;
}
interface NormalizedContextTemplate {
id: string;
name: string;
kind: string;
description?: string;
items: NormalizedContextItem[];
omittedItems: number;
}
type NormalizedAssistantPageContext =
| { page: 'lists_overview'; route: string }
| { page: 'list_detail'; route: string; list: NormalizedContextList }
| { page: 'templates_overview'; route: string }
| {
page: 'template_detail';
route: string;
template: NormalizedContextTemplate;
}
| { page: 'unknown'; route: string };
interface MistralAgentCompletionResponse {
choices?: Array<{
message?: {
content?: string | null;
};
messages?: Array<{
role?: string;
content?: string | null | unknown[];
tool_call_id?: string;
tool_calls?: Array<{
id?: string;
function?: {
name?: string;
};
}>;
metadata?: {
mcp_meta?: {
structuredContent?: unknown;
};
};
}>;
}>;
}
@Injectable()
export class AssistantService {
private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions';
constructor(
@InjectRepository(AssistantChatLogEntity)
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
private readonly listRealtimeService: ListRealtimeService,
private readonly listsService: ListsService,
) {}
async chat(
userId: string,
request: AssistantChatRequest,
): Promise<AssistantChatResponse> {
const messages = this.normalizeMessages(request.messages);
const context = this.normalizeContext(request.context);
const localResponse = await this.tryHandleLocalListQuery(userId, messages);
if (localResponse) {
return localResponse;
}
const localMutationResponse = await this.tryHandleLocalListMutation(
userId,
messages,
context,
);
if (localMutationResponse) {
return localMutationResponse;
}
const response = await this.callMistralAgent(userId, messages, context);
const actions = this.extractActions(response);
const content =
this.extractAssistantContent(response) ??
this.createActionContent(actions);
const latestUserMessage = this.latestUserMessage(messages);
actions.forEach((action) => {
this.listRealtimeService.publishSnapshot(userId, action.list);
});
if (
this.isListCreationRequest(latestUserMessage?.content ?? '') &&
!actions.some((action) => action.type === 'list.created')
) {
return {
message: {
role: 'assistant',
content:
'Ich konnte die Liste nicht anlegen, weil der Listify-Connector keine Liste erstellt hat.',
},
actions: [],
};
}
if (!content) {
await this.recordChatLog({
userId,
provider: 'listify',
endpoint: 'assistant:emptyResponse',
agentId: null,
requestPayload: {
latestUserMessage: latestUserMessage?.content,
actionCount: actions.length,
},
responsePayload: { actions },
statusCode: null,
durationMs: 0,
assistantContent: null,
errorMessage: 'Mistral response was empty.',
});
throw new ServiceUnavailableException('Mistral response was empty.');
}
return {
message: {
role: 'assistant',
content,
},
actions,
};
}
async listChatLogs(userId: string): Promise<AssistantChatLog[]> {
const logs = await this.chatLogsRepository.find({
where: { userId },
order: { createdAt: 'DESC' },
take: 50,
});
return logs.slice(0, 50).map((log) => ({
id: log.id,
provider: log.provider,
endpoint: log.endpoint,
agentId: log.agentId,
statusCode: log.statusCode,
durationMs: log.durationMs,
requestPayload: log.requestPayload,
responsePayload: log.responsePayload,
assistantContent: log.assistantContent,
errorMessage: log.errorMessage,
createdAt: log.createdAt.toISOString(),
}));
}
private async tryHandleLocalListQuery(
userId: string,
messages: AssistantChatMessage[],
): Promise<AssistantChatResponse | null> {
const latestUserMessage = this.latestUserMessage(messages);
const content = latestUserMessage?.content.toLowerCase() ?? '';
if (!this.isListReadRequest(content)) {
return null;
}
const startedAt = Date.now();
const lists = await this.listsService.listLists(userId);
const wantsOpenLists = /\boffen\w*\b/.test(content);
const visibleLists = wantsOpenLists
? lists.filter((list) => !this.isCompletedList(list))
: lists;
const assistantContent = this.formatListsAnswer(
visibleLists,
wantsOpenLists,
);
await this.recordChatLog({
userId,
provider: 'listify',
endpoint: 'local:listLists',
agentId: null,
requestPayload: {
intent: wantsOpenLists ? 'list.open_lists' : 'list.list_lists',
latestUserMessage: latestUserMessage?.content,
},
responsePayload: { lists: visibleLists },
statusCode: 200,
durationMs: Date.now() - startedAt,
assistantContent,
errorMessage: null,
});
return {
message: {
role: 'assistant',
content: assistantContent,
},
actions: [],
};
}
private async tryHandleLocalListMutation(
userId: string,
messages: AssistantChatMessage[],
context: NormalizedAssistantPageContext | null,
): Promise<AssistantChatResponse | null> {
if (context?.page !== 'list_detail') {
return null;
}
const latestUserMessage = this.latestUserMessage(messages);
const itemTitle = this.extractItemTitleToAdd(
latestUserMessage?.content ?? '',
);
if (!itemTitle) {
return null;
}
const startedAt = Date.now();
const list = await this.listsService.addItem(userId, context.list.id, {
title: itemTitle,
});
const assistantContent = `Ich habe **${itemTitle}** zu **${list.name}** hinzugefuegt.`;
const action: AssistantAction = {
type: 'list.item_added',
listId: list.id,
itemTitle,
list,
};
this.listRealtimeService.publishSnapshot(userId, list);
await this.recordChatLog({
userId,
provider: 'listify',
endpoint: 'local:addItem',
agentId: null,
requestPayload: {
intent: 'list.add_item',
latestUserMessage: latestUserMessage?.content,
listId: context.list.id,
itemTitle,
},
responsePayload: { list },
statusCode: 200,
durationMs: Date.now() - startedAt,
assistantContent,
errorMessage: null,
});
return {
message: {
role: 'assistant',
content: assistantContent,
},
actions: [action],
};
}
private extractItemTitleToAdd(content: string): string | null {
const compacted = content.replace(/\s+/g, ' ').trim();
if (!compacted) {
return null;
}
const patterns = [
/\b(?:fuege|füge)\s+(.+?)\s+(?:hinzu|dazu|drauf|auf die liste|auf diese liste)\b/i,
/\b(?:schreib|schreibe|notier|notiere|pack|setze|setz)\s+(.+?)(?:\s+(?:drauf|dazu|hinzu|auf die liste|auf diese liste))?$/i,
/\b(.+?)\s+(?:draufschreiben|aufschreiben|hinzufuegen|hinzufügen)\b/i,
];
for (const pattern of patterns) {
const match = compacted.match(pattern);
const title = this.cleanExtractedItemTitle(match?.[1]);
if (title) {
return title;
}
}
return null;
}
private cleanExtractedItemTitle(value: string | undefined): string | null {
if (!value) {
return null;
}
const cleaned = value
.replace(/^(?:bitte|noch|auch|mal|hier)\s+/i, '')
.replace(/\s+(?:bitte|noch|auch|mal)$/i, '')
.replace(/^["'`]+|["'`.,!?]+$/g, '')
.trim();
if (!cleaned || cleaned.length > 220) {
return null;
}
return cleaned;
}
private isListReadRequest(content: string): boolean {
const asksForLists = /\b(listen|liste)\b/.test(content);
const asksToRead =
/\b(zeig|zeige|anzeigen|abrufen|auflisten|welche|was|habe|gib|nenn)\w*\b/.test(
content,
);
const asksForOpenLists = /\boffen\w*\b/.test(content) && asksForLists;
const asksForOwnLists = /\bmeine listen\b/.test(content);
return asksToRead && (asksForOpenLists || asksForOwnLists);
}
private isListCreationRequest(content: string): boolean {
const normalized = this.normalizeIntentText(content);
const mentionsList =
/\b(list|liste|listen|packliste|einkaufsliste|todo|aufgabenliste)\b/.test(
normalized,
);
const asksToCreate =
/\b(erstell|erstelle|erstellt|erzeugen|generier|generiere|mach|mache|bau|baue)\w*\b/.test(
normalized,
) || /\b(?:leg|lege)\s+.*\b(?:an|neu)\b/.test(normalized);
return mentionsList && asksToCreate;
}
private normalizeIntentText(content: string): string {
return content
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
private isCompletedList(list: UserList): boolean {
return (
list.items.length > 0 && list.items.every((item) => item.checked === true)
);
}
private formatListsAnswer(lists: UserList[], openOnly: boolean): string {
if (lists.length === 0) {
return openOnly
? 'Du hast aktuell keine offenen Listen.'
: 'Du hast aktuell keine Listen.';
}
const heading = openOnly
? `Du hast ${lists.length} offene ${lists.length === 1 ? 'Liste' : 'Listen'}:`
: `Du hast ${lists.length} ${lists.length === 1 ? 'Liste' : 'Listen'}:`;
const lines = [heading, ''];
for (const list of lists) {
const checkedCount = list.items.filter((item) => item.checked).length;
const openItems = list.items.filter((item) => !item.checked);
const ownerSuffix =
list.accessRole === 'collaborator'
? `, geteilt von ${list.ownerName || list.ownerEmail || 'Owner'}`
: '';
lines.push(
`- **${list.name}** (${checkedCount}/${list.items.length} erledigt${ownerSuffix})`,
);
for (const item of openItems.slice(0, 5)) {
lines.push(` - ${item.title}`);
}
if (openItems.length > 5) {
lines.push(` - ${openItems.length - 5} weitere offene Punkte`);
}
}
return lines.join('\n');
}
private async callMistralAgent(
userId: string,
messages: AssistantChatMessage[],
context: NormalizedAssistantPageContext | null,
): Promise<MistralAgentCompletionResponse> {
const apiKey = process.env.MISTRAL_API_KEY;
const agentId = process.env.MISTRAL_AGENT_ID;
const contextMessage = this.createContextSystemMessage(context);
const requestPayload = {
agent_id: agentId,
messages: [
...messages,
...(contextMessage ? [contextMessage] : []),
{
role: 'system',
content: [
'Benutze fuer Listify-Daten immer den listify Connector.',
'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.',
'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.',
].join(' '),
},
],
tools: [
{
type: 'connector',
connector_id: 'listify',
},
],
stream: false,
response_format: {
type: 'text',
},
};
if (!apiKey) {
await this.recordChatLog({
userId,
agentId: agentId ?? null,
requestPayload,
responsePayload: null,
statusCode: null,
durationMs: 0,
assistantContent: null,
errorMessage: 'Mistral API key is not configured.',
});
throw new ServiceUnavailableException(
'Mistral API key is not configured.',
);
}
if (!agentId) {
await this.recordChatLog({
userId,
agentId: null,
requestPayload,
responsePayload: null,
statusCode: null,
durationMs: 0,
assistantContent: null,
errorMessage: 'Mistral agent id is not configured.',
});
throw new ServiceUnavailableException(
'Mistral agent id is not configured.',
);
}
const startedAt = Date.now();
let statusCode: number | null = null;
let responsePayload: unknown = null;
let assistantContent: string | null = null;
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestPayload),
});
statusCode = response.status;
responsePayload = await this.readResponsePayload(response);
if (!response.ok) {
await this.recordChatLog({
userId,
agentId,
requestPayload,
responsePayload,
statusCode,
durationMs: Date.now() - startedAt,
assistantContent,
errorMessage: 'Mistral agent request failed.',
});
throw new ServiceUnavailableException('Mistral agent request failed.');
}
assistantContent = this.extractAssistantContent(responsePayload);
await this.recordChatLog({
userId,
agentId,
requestPayload,
responsePayload,
statusCode,
durationMs: Date.now() - startedAt,
assistantContent,
errorMessage: null,
});
return responsePayload as MistralAgentCompletionResponse;
}
private async readResponsePayload(response: Response): Promise<unknown> {
const rawBody = await response.text();
if (!rawBody) {
return null;
}
try {
return JSON.parse(rawBody) as unknown;
} catch {
return { rawBody };
}
}
private extractAssistantContent(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 && !this.looksLikeJson(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 = this.contentToText(message.content).trim();
if (content && !this.looksLikeJson(content)) {
return content;
}
}
}
return null;
}
private extractActions(responsePayload: unknown): AssistantAction[] {
if (!responsePayload || typeof responsePayload !== 'object') {
return [];
}
const actions: AssistantAction[] = [];
const response = responsePayload as MistralAgentCompletionResponse;
for (const choice of response.choices ?? []) {
const toolNamesById = new Map<string, string>();
for (const message of choice.messages ?? []) {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.id && toolCall.function?.name) {
toolNamesById.set(toolCall.id, toolCall.function.name);
}
}
}
for (const message of choice.messages ?? []) {
if (message.role !== 'tool') {
continue;
}
const structuredContent = message.metadata?.mcp_meta?.structuredContent;
const list =
this.listFromStructuredContent(structuredContent) ??
this.listFromToolContent(message.content);
if (!list) {
continue;
}
const toolName = message.tool_call_id
? toolNamesById.get(message.tool_call_id)
: undefined;
if (toolName?.includes('create_list')) {
actions.push({
type: 'list.created',
listId: list.id,
list,
});
continue;
}
if (toolName?.includes('add_list_item')) {
actions.push({
type: 'list.item_added',
listId: list.id,
itemTitle: this.lastItemTitle(list),
list,
});
continue;
}
actions.push({
type: 'list.item_added',
listId: list.id,
itemTitle: this.lastItemTitle(list),
list,
});
}
}
return this.uniqueActions(actions);
}
private listFromStructuredContent(value: unknown): UserList | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const structuredContent = value as { list?: unknown };
const list = structuredContent.list;
if (!list || typeof list !== 'object' || Array.isArray(list)) {
return null;
}
const candidate = list as Partial<UserList>;
if (
typeof candidate.id !== 'string' ||
typeof candidate.name !== 'string' ||
!Array.isArray(candidate.items)
) {
return null;
}
return candidate as UserList;
}
private listFromToolContent(value: unknown): UserList | null {
for (const text of this.contentTextParts(value)) {
try {
const parsed = JSON.parse(text) as unknown;
const list = this.listFromStructuredContent(parsed);
if (list) {
return list;
}
} catch {
continue;
}
}
return null;
}
private contentToText(value: unknown): string {
return this.contentTextParts(value).join('\n');
}
private contentTextParts(value: unknown): string[] {
if (typeof value === 'string') {
return [value];
}
if (!Array.isArray(value)) {
return [];
}
return value
.map((part) => {
if (typeof part === 'string') {
return part;
}
if (!part || typeof part !== 'object' || Array.isArray(part)) {
return null;
}
const candidate = part as { text?: unknown };
return typeof candidate.text === 'string' ? candidate.text : null;
})
.filter((part): part is string => part !== null);
}
private looksLikeJson(content: string): boolean {
const trimmed = content.trim();
return (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
);
}
private createActionContent(actions: AssistantAction[]): string | null {
const createdList = actions.find(
(action) => action.type === 'list.created',
);
if (createdList) {
return `Ich habe die Liste **${createdList.list.name}** angelegt.`;
}
const addedItem = actions.find(
(action) => action.type === 'list.item_added',
);
if (addedItem) {
return `Ich habe **${addedItem.itemTitle}** zu **${addedItem.list.name}** hinzugefuegt.`;
}
return null;
}
private lastItemTitle(list: UserList): string {
return list.items.at(-1)?.title ?? 'Eintrag';
}
private uniqueActions(actions: AssistantAction[]): AssistantAction[] {
const seen = new Set<string>();
return actions.filter((action) => {
const key = `${action.type}:${action.listId}:${action.type === 'list.item_added' ? action.itemTitle : ''}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
private async recordChatLog(input: {
userId: string;
provider?: string;
endpoint?: string;
agentId: string | null;
requestPayload: Record<string, unknown>;
responsePayload: unknown;
statusCode: number | null;
durationMs: number;
assistantContent: string | null;
errorMessage: string | null;
}): Promise<void> {
await this.chatLogsRepository.save(
this.chatLogsRepository.create({
id: randomUUID(),
userId: input.userId,
provider: input.provider ?? 'mistral',
endpoint: input.endpoint ?? this.endpoint,
agentId: input.agentId,
statusCode: input.statusCode,
durationMs: input.durationMs,
requestPayload: input.requestPayload,
responsePayload: input.responsePayload,
assistantContent: input.assistantContent,
errorMessage: input.errorMessage,
}),
);
}
private latestUserMessage(
messages: AssistantChatMessage[],
): AssistantChatMessage | undefined {
return [...messages].reverse().find((message) => message.role === 'user');
}
private normalizeMessages(
messages: AssistantChatMessage[] | undefined,
): AssistantChatMessage[] {
if (!Array.isArray(messages) || messages.length === 0) {
throw new BadRequestException('At least one chat message is required.');
}
return messages.slice(-12).map((message) => {
if (message.role !== 'user' && message.role !== 'assistant') {
throw new BadRequestException('Chat message role is invalid.');
}
const content = message.content?.trim();
if (!content) {
throw new BadRequestException('Chat message content is required.');
}
return { role: message.role, content };
});
}
private normalizeContext(
context: AssistantPageContext | undefined,
): NormalizedAssistantPageContext | null {
if (!context || typeof context !== 'object') {
return null;
}
const route = this.compactString(context.route, 240) ?? '';
if (context.page === 'lists_overview') {
return { page: 'lists_overview', route };
}
if (context.page === 'templates_overview') {
return { page: 'templates_overview', route };
}
if (context.page === 'unknown') {
return { page: 'unknown', route };
}
if (context.page === 'list_detail') {
const list = this.normalizeListContext(context.list);
return list ? { page: 'list_detail', route, list } : null;
}
if (context.page === 'template_detail') {
const template = this.normalizeTemplateContext(context.template);
return template ? { page: 'template_detail', route, template } : null;
}
return null;
}
private normalizeListContext(list: UserList): NormalizedContextList | null {
if (!list || typeof list !== 'object' || Array.isArray(list)) {
return null;
}
const id = this.compactString(list.id, 120);
const name = this.compactString(list.name, 160);
const kind = this.compactString(list.kind, 80);
if (!id || !name || !kind || !Array.isArray(list.items)) {
return null;
}
const items = list.items
.slice(0, 40)
.map((item) => this.normalizeItemContext(item, true))
.filter((item): item is NormalizedContextItem => item !== null);
return {
id,
name,
kind,
description: this.compactString(list.description, 240),
items,
omittedItems: Math.max(0, list.items.length - items.length),
};
}
private normalizeTemplateContext(
template: ListTemplate,
): NormalizedContextTemplate | null {
if (!template || typeof template !== 'object' || Array.isArray(template)) {
return null;
}
const id = this.compactString(template.id, 120);
const name = this.compactString(template.name, 160);
const kind = this.compactString(template.kind, 80);
if (!id || !name || !kind || !Array.isArray(template.items)) {
return null;
}
const items = template.items
.slice(0, 40)
.map((item) => this.normalizeItemContext(item, false))
.filter((item): item is NormalizedContextItem => item !== null);
return {
id,
name,
kind,
description: this.compactString(template.description, 240),
items,
omittedItems: Math.max(0, template.items.length - items.length),
};
}
private normalizeItemContext(
item: Partial<UserListItem>,
includeChecked: boolean,
): NormalizedContextItem | null {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null;
}
const id = this.compactString(item.id, 120);
const title = this.compactString(item.title, 160);
if (!id || !title) {
return null;
}
return {
id,
title,
notes: this.compactString(item.notes, 220),
quantity: typeof item.quantity === 'number' ? item.quantity : undefined,
required: typeof item.required === 'boolean' ? item.required : undefined,
checked:
includeChecked && typeof item.checked === 'boolean'
? item.checked
: undefined,
};
}
private createContextSystemMessage(
context: NormalizedAssistantPageContext | null,
): MistralMessage | null {
if (!context) {
return null;
}
const lines = ['Aktueller Listify-Kontext:'];
if (context.page === 'lists_overview') {
lines.push(`Der User befindet sich auf der Listenuebersicht.`);
lines.push(`Route: ${context.route}`);
return { role: 'system', content: lines.join('\n') };
}
if (context.page === 'templates_overview') {
lines.push(`Der User befindet sich auf der Vorlagenuebersicht.`);
lines.push(`Route: ${context.route}`);
return { role: 'system', content: lines.join('\n') };
}
if (context.page === 'unknown') {
lines.push(`Die aktuelle Seite ist nicht eindeutig zugeordnet.`);
lines.push(`Route: ${context.route}`);
return { role: 'system', content: lines.join('\n') };
}
if (context.page === 'list_detail') {
lines.push(`Der User befindet sich auf einer Listendetailseite.`);
lines.push(`Route: ${context.route}`);
lines.push(this.formatListContext(context.list));
lines.push(
`Wenn der User "diese Liste", "hier" oder aehnliche Verweise nutzt, bezieht sich das auf die Liste mit ID ${context.list.id}.`,
);
return { role: 'system', content: lines.join('\n') };
}
lines.push(`Der User befindet sich auf einer Vorlagendetailseite.`);
lines.push(`Route: ${context.route}`);
lines.push(this.formatTemplateContext(context.template));
lines.push(
`Wenn der User "diese Vorlage", "hier" oder aehnliche Verweise nutzt, bezieht sich das auf die Vorlage mit ID ${context.template.id}.`,
);
return { role: 'system', content: lines.join('\n') };
}
private formatListContext(list: NormalizedContextList): string {
return [
`Offene Liste: ${list.name} (ID: ${list.id}, Typ: ${list.kind})`,
...(list.description ? [`Beschreibung: ${list.description}`] : []),
...this.formatItems(list.items, list.omittedItems, true),
].join('\n');
}
private formatTemplateContext(template: NormalizedContextTemplate): string {
return [
`Offene Vorlage: ${template.name} (ID: ${template.id}, Typ: ${template.kind})`,
...(template.description
? [`Beschreibung: ${template.description}`]
: []),
...this.formatItems(template.items, template.omittedItems, false),
].join('\n');
}
private formatItems(
items: NormalizedContextItem[],
omittedItems: number,
includeChecked: boolean,
): string[] {
if (items.length === 0) {
return ['Items: keine'];
}
const lines = ['Items:'];
for (const item of items) {
const parts = [
`ID: ${item.id}`,
typeof item.quantity === 'number' ? `Menge: ${item.quantity}` : null,
typeof item.required === 'boolean'
? `Pflicht: ${item.required ? 'ja' : 'nein'}`
: null,
includeChecked && typeof item.checked === 'boolean'
? `Erledigt: ${item.checked ? 'ja' : 'nein'}`
: null,
item.notes ? `Notizen: ${item.notes}` : null,
].filter((part): part is string => part !== null);
lines.push(`- ${item.title} (${parts.join(', ')})`);
}
if (omittedItems > 0) {
lines.push(`- ${omittedItems} weitere Eintraege ausgelassen.`);
}
return lines;
}
private compactString(
value: string | undefined,
maxLength: number,
): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const compacted = value.replace(/\s+/g, ' ').trim();
if (!compacted) {
return undefined;
}
if (compacted.length <= maxLength) {
return compacted;
}
return `${compacted.slice(0, maxLength - 3)}...`;
}
}