This commit is contained in:
Bastian Wagner
2026-06-13 15:41:57 +02:00
parent 6642575ea9
commit 22c93f9ca1
10 changed files with 874 additions and 19 deletions

View File

@@ -114,6 +114,92 @@ describe('AssistantService', () => {
);
});
it('adds a normalized list context system message when context is present', async () => {
const providerResponse = {
choices: [
{
message: {
content: 'Ich nutze diese Liste.',
},
},
],
};
mockMistralResponse(providerResponse);
await service.chat('user-1', {
messages: [{ role: 'user', content: 'Fuege hier Brot hinzu' }],
context: {
page: 'list_detail',
route: '/lists/list-1',
list: {
id: 'list-1',
ownerId: 'user-1',
ownerName: 'Ada',
ownerEmail: 'ada@example.com',
accessRole: 'owner',
name: 'Einkauf',
description: 'Wochenende',
kind: 'shopping',
items: [
{
id: 'item-1',
title: 'Milch',
notes: '1,5 Prozent',
quantity: 2,
required: true,
checked: false,
checkedByUserId: 'user-2',
checkedByName: 'Grace',
position: 0,
createdAt: '2026-06-12T00:00:00.000Z',
updatedAt: '2026-06-12T00:00:00.000Z',
},
],
collaborators: [
{
id: 'user-2',
email: 'grace@example.com',
role: 'collaborator',
},
],
createdAt: '2026-06-12T00:00:00.000Z',
updatedAt: '2026-06-12T00:00:00.000Z',
},
},
});
const payload = getMistralRequestPayload();
const contextMessage = payload.messages.at(-2);
const contextContent = contextMessage?.content ?? '';
expect(contextMessage).toEqual(
expect.objectContaining({
role: 'system',
content: expect.stringContaining('Aktueller Listify-Kontext:'),
}),
);
expect(contextContent).toContain(
'Der User befindet sich auf einer Listendetailseite.',
);
expect(contextContent).toContain('Route: /lists/list-1');
expect(contextContent).toContain(
'Offene Liste: Einkauf (ID: list-1, Typ: shopping)',
);
expect(contextContent).toContain(
'- Milch (ID: item-1, Menge: 2, Pflicht: ja, Erledigt: nein, Notizen: 1,5 Prozent)',
);
expect(contextContent).toContain(
'bezieht sich das auf die Liste mit ID list-1.',
);
expect(contextContent).not.toContain('ada@example.com');
expect(contextContent).not.toContain('grace@example.com');
expect(contextContent).not.toContain('checkedByUserId');
expect(payload.messages.at(-1)).toEqual({
role: 'system',
content: 'benutze immer den listify connector',
});
});
it('logs full failed provider responses before throwing', async () => {
const providerResponse = {
message: 'connector failed',
@@ -263,3 +349,18 @@ function mockMistralResponse(
text: async () => JSON.stringify(response),
} as Response);
}
function getMistralRequestPayload(): {
messages: Array<{ role: string; content: string }>;
} {
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
const body = init?.body;
if (typeof body !== 'string') {
throw new Error('Expected Mistral request body to be JSON.');
}
return JSON.parse(body) as {
messages: Array<{ role: string; content: string }>;
};
}

View File

@@ -6,16 +6,61 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { UserList } from '../list-templates/list-template.types';
import {
ListTemplate,
UserList,
UserListItem,
} from '../list-templates/list-template.types';
import { ListRealtimeService } from '../lists/list-realtime.service';
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
import {
AssistantAction,
AssistantChatMessage,
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?: {
@@ -55,7 +100,8 @@ export class AssistantService {
request: AssistantChatRequest,
): Promise<AssistantChatResponse> {
const messages = this.normalizeMessages(request.messages);
const response = await this.callMistralAgent(userId, messages);
const context = this.normalizeContext(request.context);
const response = await this.callMistralAgent(userId, messages, context);
const content = this.extractAssistantContent(response);
const actions = this.extractActions(response);
@@ -79,6 +125,7 @@ export class AssistantService {
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;
@@ -91,16 +138,18 @@ export class AssistantService {
throw new ServiceUnavailableException('Mistral agent id is not configured.');
}
const contextMessage = this.createContextSystemMessage(context);
const requestPayload = {
agent_id: agentId,
messages: [
...messages,
{ "role": "system", "content": "benutze immer den listify connector"}
...(contextMessage ? [contextMessage] : []),
{ role: 'system', content: 'benutze immer den listify connector' },
],
tools: [
{
"type": "connector",
"connector_id": "listify"
type: 'connector',
connector_id: 'listify',
}
],
stream: false,
@@ -356,4 +405,246 @@ export class AssistantService {
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)}...`;
}
}

View File

@@ -1,4 +1,8 @@
import { ListTemplateKind, UserList } from '../list-templates/list-template.types';
import {
ListTemplate,
ListTemplateKind,
UserList,
} from '../list-templates/list-template.types';
export type AssistantMessageRole = 'user' | 'assistant';
@@ -9,8 +13,16 @@ export interface AssistantChatMessage {
export interface AssistantChatRequest {
messages?: AssistantChatMessage[];
context?: AssistantPageContext;
}
export type AssistantPageContext =
| { page: 'lists_overview'; route: string }
| { page: 'list_detail'; route: string; list: UserList }
| { page: 'templates_overview'; route: string }
| { page: 'template_detail'; route: string; template: ListTemplate }
| { page: 'unknown'; route: string };
export type AssistantAction =
| {
type: 'list.created';