This commit is contained in:
Bastian Wagner
2026-06-24 12:56:42 +02:00
parent 712136a5e9
commit 17ac2953d6
5 changed files with 361 additions and 175 deletions

View File

@@ -46,14 +46,14 @@ $ npm run start:prod
## Mistral assistant ## Mistral assistant
The in-app assistant calls a configured Mistral agent from the API server. Configure the key and agent id in the API environment, never in the Angular client: The in-app assistant calls the Mistral Conversations API from the API server. Configure the key and optional model in the API environment, never in the Angular client:
```bash ```bash
MISTRAL_API_KEY=your-mistral-api-key MISTRAL_API_KEY=your-mistral-api-key
MISTRAL_AGENT_ID=your-mistral-agent-id MISTRAL_MODEL=mistral-large-latest
``` ```
The Mistral agent should have the `listify` connector attached. The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/agents/completions` and returns the assistant text. Listify does not parse or execute tool calls locally in this path. The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/conversations` with the `listify` connector enabled and returns the assistant text. Listify inspects returned tool outputs and refreshes local list state when a list was created or changed.
Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata. Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata.
@@ -116,8 +116,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
} }
``` ```
- Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`. - Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`.
- Schreibt keine Daten und legt keine Liste an. - Schreibt keine Daten und legt keine Liste an.
- `create_list` - `create_list`
- Erstellt eine neue Liste fuer den angemeldeten User. - Erstellt eine neue Liste fuer den angemeldeten User.
@@ -135,8 +135,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
} }
``` ```
- `items` ist optional. Wenn Items angegeben sind, werden sie nach dem Erstellen der Liste in Reihenfolge hinzugefuegt. - `items` ist optional. Wenn Items angegeben sind, werden sie nach dem Erstellen der Liste in Reihenfolge hinzugefuegt.
- Output enthaelt `list` mit der erstellten Liste inklusive Items. - Output enthaelt `list` mit der erstellten Liste inklusive Items.
- `add_list_item` - `add_list_item`
- Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der angemeldete User Zugriff hat. - Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der angemeldete User Zugriff hat.
@@ -151,7 +151,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
} }
``` ```
- Output enthaelt `list` mit der aktualisierten Liste. - Output enthaelt `list` mit der aktualisierten Liste.
- `create_template` - `create_template`
- Erstellt ein neues Template fuer den angemeldeten User. - Erstellt ein neues Template fuer den angemeldeten User.
@@ -169,7 +169,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
} }
``` ```
- Output enthaelt `template` mit dem erstellten Template inklusive Items. - Output enthaelt `template` mit dem erstellten Template inklusive Items.
- `add_template_item` - `add_template_item`
- Fuegt ein Item zu einem bestehenden Template hinzu, das dem angemeldeten User gehoert. - Fuegt ein Item zu einem bestehenden Template hinzu, das dem angemeldeten User gehoert.
@@ -183,7 +183,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
} }
``` ```
- Output enthaelt `template` mit dem aktualisierten Template. - Output enthaelt `template` mit dem aktualisierten Template.
### Minimaler MCP-Request ### Minimaler MCP-Request

View File

@@ -9,7 +9,7 @@ describe('AssistantService', () => {
].join(' '); ].join(' ');
const originalFetch = global.fetch; const originalFetch = global.fetch;
const originalApiKey = process.env.MISTRAL_API_KEY; const originalApiKey = process.env.MISTRAL_API_KEY;
const originalAgentId = process.env.MISTRAL_AGENT_ID; const originalModel = process.env.MISTRAL_MODEL;
let chatLogsRepository: { let chatLogsRepository: {
create: jest.Mock; create: jest.Mock;
find: jest.Mock; find: jest.Mock;
@@ -26,7 +26,7 @@ describe('AssistantService', () => {
beforeEach(() => { beforeEach(() => {
process.env.MISTRAL_API_KEY = 'test-key'; process.env.MISTRAL_API_KEY = 'test-key';
process.env.MISTRAL_AGENT_ID = 'agent-listify'; process.env.MISTRAL_MODEL = 'mistral-large-test';
global.fetch = jest.fn(); global.fetch = jest.fn();
chatLogsRepository = { chatLogsRepository = {
create: jest.fn((input) => input), create: jest.fn((input) => input),
@@ -51,10 +51,10 @@ describe('AssistantService', () => {
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
process.env.MISTRAL_API_KEY = originalApiKey; process.env.MISTRAL_API_KEY = originalApiKey;
process.env.MISTRAL_AGENT_ID = originalAgentId; process.env.MISTRAL_MODEL = originalModel;
}); });
it('forwards messages to the configured Mistral agent', async () => { it('starts a Mistral conversation with the configured model', async () => {
const providerResponse = { const providerResponse = {
choices: [ choices: [
{ {
@@ -78,7 +78,7 @@ describe('AssistantService', () => {
}); });
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
'https://api.mistral.ai/v1/agents/completions', 'https://api.mistral.ai/v1/conversations',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
headers: { headers: {
@@ -86,12 +86,12 @@ describe('AssistantService', () => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
agent_id: 'agent-listify', model: 'mistral-large-test',
messages: [ inputs: [
{ role: 'assistant', content: 'Hallo' }, { role: 'assistant', content: 'Hallo' },
{ role: 'user', content: 'Hallo' }, { role: 'user', content: 'Hallo' },
{ role: 'system', content: connectorSystemMessage },
], ],
instructions: connectorSystemMessage,
tools: [ tools: [
{ {
type: 'connector', type: 'connector',
@@ -99,9 +99,6 @@ describe('AssistantService', () => {
}, },
], ],
stream: false, stream: false,
response_format: {
type: 'text',
},
}), }),
}), }),
); );
@@ -117,11 +114,16 @@ describe('AssistantService', () => {
expect.objectContaining({ expect.objectContaining({
userId: 'user-1', userId: 'user-1',
provider: 'mistral', provider: 'mistral',
endpoint: 'https://api.mistral.ai/v1/agents/completions', endpoint: 'https://api.mistral.ai/v1/conversations',
agentId: 'agent-listify', agentId: null,
statusCode: 200, statusCode: 200,
requestPayload: expect.objectContaining({ requestPayload: expect.objectContaining({
agent_id: 'agent-listify', model: 'mistral-large-test',
inputs: [
{ role: 'assistant', content: 'Hallo' },
{ role: 'user', content: 'Hallo' },
],
instructions: connectorSystemMessage,
tools: [{ type: 'connector', connector_id: 'listify' }], tools: [{ type: 'connector', connector_id: 'listify' }],
}), }),
responsePayload: providerResponse, responsePayload: providerResponse,
@@ -131,6 +133,64 @@ describe('AssistantService', () => {
); );
}); });
it('extracts assistant content and created list actions from conversation outputs', async () => {
const createdList = {
id: 'list-1',
ownerId: 'user-1',
accessRole: 'owner',
name: 'Einkauf',
kind: 'shopping',
items: [],
collaborators: [],
createdAt: '2026-06-12T00:00:00.000Z',
updatedAt: '2026-06-12T00:00:00.000Z',
};
const providerResponse = {
conversation_id: 'conv-1',
outputs: [
{
type: 'tool.execution',
tool_name: 'listify_create_list',
result: { list: createdList },
},
{
type: 'message.output',
role: 'assistant',
content: 'Ich habe die Liste Einkauf angelegt.',
},
],
};
mockMistralResponse(providerResponse);
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }],
});
expect(result).toEqual({
message: {
role: 'assistant',
content: 'Ich habe die Liste Einkauf angelegt.',
},
actions: [
{
type: 'list.created',
listId: 'list-1',
list: createdList,
},
],
});
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
'user-1',
createdList,
);
expect(chatLogsRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
responsePayload: providerResponse,
assistantContent: 'Ich habe die Liste Einkauf angelegt.',
}),
);
});
it('answers open list read requests locally without calling Mistral', async () => { it('answers open list read requests locally without calling Mistral', async () => {
listsService.listLists.mockResolvedValue([ listsService.listLists.mockResolvedValue([
{ {
@@ -262,15 +322,9 @@ describe('AssistantService', () => {
}); });
const payload = getMistralRequestPayload(); const payload = getMistralRequestPayload();
const contextMessage = payload.messages.at(-2); const contextContent = payload.instructions;
const contextContent = contextMessage?.content ?? '';
expect(contextMessage).toEqual( expect(contextContent).toContain('Aktueller Listify-Kontext:');
expect.objectContaining({
role: 'system',
content: expect.stringContaining('Aktueller Listify-Kontext:'),
}),
);
expect(contextContent).toContain( expect(contextContent).toContain(
'Der User befindet sich auf einer Listendetailseite.', 'Der User befindet sich auf einer Listendetailseite.',
); );
@@ -287,10 +341,7 @@ describe('AssistantService', () => {
expect(contextContent).not.toContain('ada@example.com'); expect(contextContent).not.toContain('ada@example.com');
expect(contextContent).not.toContain('grace@example.com'); expect(contextContent).not.toContain('grace@example.com');
expect(contextContent).not.toContain('checkedByUserId'); expect(contextContent).not.toContain('checkedByUserId');
expect(payload.messages.at(-1)).toEqual({ expect(contextContent).toContain(connectorSystemMessage);
role: 'system',
content: connectorSystemMessage,
});
}); });
it('adds list items locally when the user writes onto the current list', async () => { it('adds list items locally when the user writes onto the current list', async () => {
@@ -390,7 +441,7 @@ describe('AssistantService', () => {
statusCode: 502, statusCode: 502,
responsePayload: providerResponse, responsePayload: providerResponse,
assistantContent: null, assistantContent: null,
errorMessage: 'Mistral agent request failed.', errorMessage: 'Mistral conversation request failed.',
}), }),
); );
}); });
@@ -720,40 +771,20 @@ describe('AssistantService', () => {
); );
}); });
it('fails clearly when the agent id is missing', async () => {
delete process.env.MISTRAL_AGENT_ID;
await expect(
service.chat('user-1', {
messages: [{ role: 'user', content: 'Hallo' }],
}),
).rejects.toThrow(ServiceUnavailableException);
expect(chatLogsRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
agentId: null,
statusCode: null,
responsePayload: null,
assistantContent: null,
errorMessage: 'Mistral agent id is not configured.',
}),
);
});
it('lists the latest chat logs for the current user', async () => { it('lists the latest chat logs for the current user', async () => {
chatLogsRepository.find.mockResolvedValue([ chatLogsRepository.find.mockResolvedValue([
{ {
id: 'log-1', id: 'log-1',
userId: 'user-1', userId: 'user-1',
provider: 'mistral', provider: 'mistral',
endpoint: 'https://api.mistral.ai/v1/agents/completions', endpoint: 'https://api.mistral.ai/v1/conversations',
agentId: 'agent-listify', agentId: null,
statusCode: 502, statusCode: 502,
durationMs: 123, durationMs: 123,
requestPayload: { messages: [] }, requestPayload: { inputs: [] },
responsePayload: { message: 'connector failed' }, responsePayload: { message: 'connector failed' },
assistantContent: null, assistantContent: null,
errorMessage: 'Mistral agent request failed.', errorMessage: 'Mistral conversation request failed.',
createdAt: new Date('2026-06-24T08:00:00.000Z'), createdAt: new Date('2026-06-24T08:00:00.000Z'),
}, },
]); ]);
@@ -769,14 +800,14 @@ describe('AssistantService', () => {
{ {
id: 'log-1', id: 'log-1',
provider: 'mistral', provider: 'mistral',
endpoint: 'https://api.mistral.ai/v1/agents/completions', endpoint: 'https://api.mistral.ai/v1/conversations',
agentId: 'agent-listify', agentId: null,
statusCode: 502, statusCode: 502,
durationMs: 123, durationMs: 123,
requestPayload: { messages: [] }, requestPayload: { inputs: [] },
responsePayload: { message: 'connector failed' }, responsePayload: { message: 'connector failed' },
assistantContent: null, assistantContent: null,
errorMessage: 'Mistral agent request failed.', errorMessage: 'Mistral conversation request failed.',
createdAt: '2026-06-24T08:00:00.000Z', createdAt: '2026-06-24T08:00:00.000Z',
}, },
]); ]);
@@ -792,7 +823,8 @@ function mockMistralResponse(response: object, ok = true, status = 200): void {
} }
function getMistralRequestPayload(): { function getMistralRequestPayload(): {
messages: Array<{ role: string; content: string }>; inputs: Array<{ role: string; content: string }>;
instructions: string;
} { } {
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? []; const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
const body = init?.body; const body = init?.body;
@@ -802,6 +834,7 @@ function getMistralRequestPayload(): {
} }
return JSON.parse(body) as { return JSON.parse(body) as {
messages: Array<{ role: string; content: string }>; inputs: Array<{ role: string; content: string }>;
instructions: string;
}; };
} }

View File

@@ -65,7 +65,7 @@ type NormalizedAssistantPageContext =
} }
| { page: 'unknown'; route: string }; | { page: 'unknown'; route: string };
interface MistralAgentCompletionResponse { interface MistralCompletionResponse {
choices?: Array<{ choices?: Array<{
message?: { message?: {
content?: string | null; content?: string | null;
@@ -89,9 +89,28 @@ interface MistralAgentCompletionResponse {
}>; }>;
} }
interface MistralConversationResponse {
outputs?: Array<{
type?: string;
role?: string;
content?: string | null | unknown[];
result?: unknown;
output?: unknown;
tool_name?: string;
name?: string;
metadata?: {
mcp_meta?: {
structuredContent?: unknown;
};
};
}>;
output_text?: string | null;
}
@Injectable() @Injectable()
export class AssistantService { export class AssistantService {
private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions'; private readonly endpoint = 'https://api.mistral.ai/v1/conversations';
private readonly defaultModel = 'mistral-large-latest';
constructor( constructor(
@InjectRepository(AssistantChatLogEntity) @InjectRepository(AssistantChatLogEntity)
@@ -129,7 +148,11 @@ export class AssistantService {
return localMutationResponse; return localMutationResponse;
} }
const response = await this.callMistralAgent(userId, messages, context); const response = await this.callMistralConversation(
userId,
messages,
context,
);
let actions = this.extractActions(response, { let actions = this.extractActions(response, {
assumeCreatedListForUnknownToolResult: isCreationRequest, assumeCreatedListForUnknownToolResult: isCreationRequest,
}); });
@@ -470,28 +493,28 @@ export class AssistantService {
return lines.join('\n'); return lines.join('\n');
} }
private async callMistralAgent( private async callMistralConversation(
userId: string, userId: string,
messages: AssistantChatMessage[], messages: AssistantChatMessage[],
context: NormalizedAssistantPageContext | null, context: NormalizedAssistantPageContext | null,
): Promise<MistralAgentCompletionResponse> { ): Promise<MistralCompletionResponse> {
const apiKey = process.env.MISTRAL_API_KEY; const apiKey = process.env.MISTRAL_API_KEY;
const agentId = process.env.MISTRAL_AGENT_ID; const model = process.env.MISTRAL_MODEL ?? this.defaultModel;
const contextMessage = this.createContextSystemMessage(context); const contextMessage = this.createContextSystemMessage(context);
const instructions = [
contextMessage?.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(' '),
]
.filter((content): content is string => Boolean(content))
.join('\n\n');
const requestPayload = { const requestPayload = {
agent_id: agentId, model,
messages: [ inputs: messages,
...messages, instructions,
...(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: [ tools: [
{ {
type: 'connector', type: 'connector',
@@ -499,15 +522,12 @@ export class AssistantService {
}, },
], ],
stream: false, stream: false,
response_format: {
type: 'text',
},
}; };
if (!apiKey) { if (!apiKey) {
await this.recordChatLog({ await this.recordChatLog({
userId, userId,
agentId: agentId ?? null, agentId: null,
requestPayload, requestPayload,
responsePayload: null, responsePayload: null,
statusCode: null, statusCode: null,
@@ -520,22 +540,6 @@ export class AssistantService {
); );
} }
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(); const startedAt = Date.now();
let statusCode: number | null = null; let statusCode: number | null = null;
let responsePayload: unknown = null; let responsePayload: unknown = null;
@@ -555,22 +559,24 @@ export class AssistantService {
if (!response.ok) { if (!response.ok) {
await this.recordChatLog({ await this.recordChatLog({
userId, userId,
agentId, agentId: null,
requestPayload, requestPayload,
responsePayload, responsePayload,
statusCode, statusCode,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
assistantContent, assistantContent,
errorMessage: 'Mistral agent request failed.', errorMessage: 'Mistral conversation request failed.',
}); });
throw new ServiceUnavailableException('Mistral agent request failed.'); throw new ServiceUnavailableException(
'Mistral conversation request failed.',
);
} }
assistantContent = this.extractAssistantContent(responsePayload); assistantContent = this.extractAssistantContent(responsePayload);
await this.recordChatLog({ await this.recordChatLog({
userId, userId,
agentId, agentId: null,
requestPayload, requestPayload,
responsePayload, responsePayload,
statusCode, statusCode,
@@ -579,7 +585,7 @@ export class AssistantService {
errorMessage: null, errorMessage: null,
}); });
return responsePayload as MistralAgentCompletionResponse; return responsePayload as MistralCompletionResponse;
} }
private async readResponsePayload(response: Response): Promise<unknown> { private async readResponsePayload(response: Response): Promise<unknown> {
@@ -601,7 +607,7 @@ export class AssistantService {
return null; return null;
} }
const response = responsePayload as MistralAgentCompletionResponse; const response = responsePayload as MistralCompletionResponse;
const directContent = response.choices?.[0]?.message?.content?.trim(); const directContent = response.choices?.[0]?.message?.content?.trim();
if (directContent && !this.looksLikeJson(directContent)) { if (directContent && !this.looksLikeJson(directContent)) {
@@ -622,6 +628,25 @@ export class AssistantService {
} }
} }
const conversation = responsePayload as MistralConversationResponse;
const outputText = conversation.output_text?.trim();
if (outputText && !this.looksLikeJson(outputText)) {
return outputText;
}
for (const output of [...(conversation.outputs ?? [])].reverse()) {
const content = this.contentToText(output.content).trim();
if (
content &&
!this.looksLikeJson(content) &&
(output.role === 'assistant' || output.type?.includes('message'))
) {
return content;
}
}
return null; return null;
} }
@@ -634,7 +659,7 @@ export class AssistantService {
} }
const actions: AssistantAction[] = []; const actions: AssistantAction[] = [];
const response = responsePayload as MistralAgentCompletionResponse; const response = responsePayload as MistralCompletionResponse;
for (const choice of response.choices ?? []) { for (const choice of response.choices ?? []) {
const toolNamesById = new Map<string, string>(); const toolNamesById = new Map<string, string>();
@@ -701,6 +726,43 @@ export class AssistantService {
} }
} }
const conversation = responsePayload as MistralConversationResponse;
for (const output of conversation.outputs ?? []) {
const structuredContent = output.metadata?.mcp_meta?.structuredContent;
const list =
this.listFromStructuredContent(structuredContent) ??
this.listFromStructuredContent(output.result) ??
this.listFromStructuredContent(output.output) ??
this.listFromToolContent(output.content) ??
this.listFromNestedContent(output);
if (!list) {
continue;
}
const toolName = output.tool_name ?? output.name;
if (
toolName?.includes('create_list') ||
options.assumeCreatedListForUnknownToolResult
) {
actions.push({
type: 'list.created',
listId: list.id,
list,
});
continue;
}
actions.push({
type: 'list.item_added',
listId: list.id,
itemTitle: this.lastItemTitle(list),
list,
});
}
return this.uniqueActions(actions); return this.uniqueActions(actions);
} }
@@ -746,6 +808,40 @@ export class AssistantService {
return null; return null;
} }
private listFromNestedContent(value: unknown): UserList | null {
if (!value || typeof value !== 'object') {
return null;
}
const directList = this.listFromStructuredContent(value);
if (directList) {
return directList;
}
if (Array.isArray(value)) {
for (const item of value) {
const nestedList = this.listFromNestedContent(item);
if (nestedList) {
return nestedList;
}
}
return null;
}
for (const nestedValue of Object.values(value as Record<string, unknown>)) {
const nestedList = this.listFromNestedContent(nestedValue);
if (nestedList) {
return nestedList;
}
}
return null;
}
private contentToText(value: unknown): string { private contentToText(value: unknown): string {
return this.contentTextParts(value).join('\n'); return this.contentTextParts(value).join('\n');
} }

View File

@@ -17,7 +17,7 @@ import { UserListShareEntity } from './user-list-share.entity';
describe('ListsService', () => { describe('ListsService', () => {
const originalFetch = global.fetch; const originalFetch = global.fetch;
const originalApiKey = process.env.MISTRAL_API_KEY; const originalApiKey = process.env.MISTRAL_API_KEY;
const originalAgentId = process.env.MISTRAL_AGENT_ID; const originalModel = process.env.MISTRAL_MODEL;
let service: ListsService; let service: ListsService;
let listsRepository: InMemoryRepository<UserListEntity>; let listsRepository: InMemoryRepository<UserListEntity>;
let templatesRepository: InMemoryRepository<ListTemplateEntity>; let templatesRepository: InMemoryRepository<ListTemplateEntity>;
@@ -45,7 +45,7 @@ describe('ListsService', () => {
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
restoreEnv('MISTRAL_API_KEY', originalApiKey); restoreEnv('MISTRAL_API_KEY', originalApiKey);
restoreEnv('MISTRAL_AGENT_ID', originalAgentId); restoreEnv('MISTRAL_MODEL', originalModel);
}); });
it('creates and lists concrete lists for the owning user', async () => { it('creates and lists concrete lists for the owning user', async () => {
@@ -116,8 +116,9 @@ describe('ListsService', () => {
service.updateList('user-1', list.id, { name: 'Wieder da' }), service.updateList('user-1', list.id, { name: 'Wieder da' }),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
expect( expect(
(await listsRepository.find()).find((storedList) => storedList.id === list.id) (await listsRepository.find()).find(
?.deletedAt, (storedList) => storedList.id === list.id,
)?.deletedAt,
).toBeInstanceOf(Date); ).toBeInstanceOf(Date);
}); });
@@ -313,12 +314,9 @@ describe('ListsService', () => {
quantity: 2, quantity: 2,
required: false, required: false,
}); });
await service.updateItem( await service.updateItem('user-1', list.id, withFirstItem.items[0].id, {
'user-1', checked: true,
list.id, });
withFirstItem.items[0].id,
{ checked: true },
);
const template = await service.createTemplateFromList( const template = await service.createTemplateFromList(
'user-1', 'user-1',
@@ -343,7 +341,9 @@ describe('ListsService', () => {
position: 1, position: 1,
}), }),
]); ]);
expect((template.items[0] as { checked?: unknown }).checked).toBeUndefined(); expect(
(template.items[0] as { checked?: unknown }).checked,
).toBeUndefined();
expect(await templatesRepository.find()).toHaveLength(1); expect(await templatesRepository.find()).toHaveLength(1);
}); });
@@ -416,7 +416,7 @@ describe('ListsService', () => {
it('suggests normalized items and filters existing titles', async () => { it('suggests normalized items and filters existing titles', async () => {
process.env.MISTRAL_API_KEY = 'test-key'; process.env.MISTRAL_API_KEY = 'test-key';
process.env.MISTRAL_AGENT_ID = 'agent-listify'; process.env.MISTRAL_MODEL = 'mistral-large-test';
const list = await service.createList('user-1', { const list = await service.createList('user-1', {
name: 'Sommerurlaub', name: 'Sommerurlaub',
description: 'Eine Woche am Meer', description: 'Eine Woche am Meer',
@@ -479,7 +479,7 @@ describe('ListsService', () => {
], ],
}); });
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
'https://api.mistral.ai/v1/agents/completions', 'https://api.mistral.ai/v1/conversations',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
headers: { headers: {
@@ -489,17 +489,21 @@ describe('ListsService', () => {
}), }),
); );
const requestPayload = getMistralRequestPayload(); const requestPayload = getMistralRequestPayload();
expect(requestPayload.messages[1].content).toContain('Name: Sommerurlaub'); expect(requestPayload.model).toBe('mistral-large-test');
expect(requestPayload.messages[1].content).toContain( expect(requestPayload.inputs[0].content).toContain('Name: Sommerurlaub');
expect(requestPayload.inputs[0].content).toContain(
'Beschreibung: Eine Woche am Meer', 'Beschreibung: Eine Woche am Meer',
); );
expect(requestPayload.messages[1].content).toContain('Titel: Pass'); expect(requestPayload.inputs[0].content).toContain('Titel: Pass');
expect(requestPayload.instructions).toContain(
'Du erzeugst Listify-Item-Vorschlaege.',
);
expect(requestPayload.tools).toBeUndefined(); expect(requestPayload.tools).toBeUndefined();
}); });
it('creates a list and returns item suggestions for it', async () => { it('creates a list and returns item suggestions for it', async () => {
process.env.MISTRAL_API_KEY = 'test-key'; process.env.MISTRAL_API_KEY = 'test-key';
process.env.MISTRAL_AGENT_ID = 'agent-listify'; process.env.MISTRAL_MODEL = 'mistral-large-test';
mockMistralResponse({ mockMistralResponse({
choices: [ choices: [
{ {
@@ -537,18 +541,18 @@ describe('ListsService', () => {
await expect(service.listLists('user-1')).resolves.toHaveLength(1); await expect(service.listLists('user-1')).resolves.toHaveLength(1);
const requestPayload = getMistralRequestPayload(); const requestPayload = getMistralRequestPayload();
expect(requestPayload.messages[1].content).toContain('Name: Sommerfest'); expect(requestPayload.inputs[0].content).toContain('Name: Sommerfest');
expect(requestPayload.messages[1].content).toContain( expect(requestPayload.inputs[0].content).toContain(
'Beschreibung: Planung fuer Team-Event', 'Beschreibung: Planung fuer Team-Event',
); );
expect(requestPayload.messages[1].content).toContain( expect(requestPayload.inputs[0].content).toContain(
'Vorhandene Items: keine', 'Vorhandene Items: keine',
); );
}); });
it('returns an empty suggestion list for malformed provider content', async () => { it('returns an empty suggestion list for malformed provider content', async () => {
process.env.MISTRAL_API_KEY = 'test-key'; process.env.MISTRAL_API_KEY = 'test-key';
process.env.MISTRAL_AGENT_ID = 'agent-listify'; process.env.MISTRAL_MODEL = 'mistral-large-test';
const list = await service.createList('user-1', { const list = await service.createList('user-1', {
name: 'Einkauf', name: 'Einkauf',
kind: 'shopping', kind: 'shopping',
@@ -564,7 +568,6 @@ describe('ListsService', () => {
it('fails clearly when item suggestions are not configured', async () => { it('fails clearly when item suggestions are not configured', async () => {
delete process.env.MISTRAL_API_KEY; delete process.env.MISTRAL_API_KEY;
process.env.MISTRAL_AGENT_ID = 'agent-listify';
const list = await service.createList('user-1', { const list = await service.createList('user-1', {
name: 'Einkauf', name: 'Einkauf',
kind: 'shopping', kind: 'shopping',
@@ -586,7 +589,9 @@ function mockMistralResponse(response: object, ok = true, status = 200): void {
} }
function getMistralRequestPayload(): { function getMistralRequestPayload(): {
messages: Array<{ role: string; content: string }>; model: string;
inputs: Array<{ role: string; content: string }>;
instructions: string;
tools?: unknown; tools?: unknown;
} { } {
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? []; const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
@@ -597,7 +602,9 @@ function getMistralRequestPayload(): {
} }
return JSON.parse(body) as { return JSON.parse(body) as {
messages: Array<{ role: string; content: string }>; model: string;
inputs: Array<{ role: string; content: string }>;
instructions: string;
tools?: unknown; tools?: unknown;
}; };
} }

View File

@@ -46,7 +46,7 @@ export interface CreateListWithItemSuggestionsResponse {
suggestions: ListItemSuggestion[]; suggestions: ListItemSuggestion[];
} }
interface MistralAgentCompletionResponse { interface MistralCompletionResponse {
choices?: Array<{ choices?: Array<{
message?: { message?: {
content?: string | null; content?: string | null;
@@ -56,12 +56,19 @@ interface MistralAgentCompletionResponse {
content?: string | null | unknown[]; content?: string | null | unknown[];
}>; }>;
}>; }>;
outputs?: Array<{
type?: string;
role?: string;
content?: string | null | unknown[];
}>;
output_text?: string | null;
} }
@Injectable() @Injectable()
export class ListsService { export class ListsService {
private readonly itemSuggestionsEndpoint = private readonly itemSuggestionsEndpoint =
'https://api.mistral.ai/v1/agents/completions'; 'https://api.mistral.ai/v1/conversations';
private readonly defaultMistralModel = 'mistral-large-latest';
constructor( constructor(
@InjectRepository(UserListEntity) @InjectRepository(UserListEntity)
@@ -84,7 +91,10 @@ export class ListsService {
private readonly templateItemsRepository?: Repository<ListTemplateItemEntity>, private readonly templateItemsRepository?: Repository<ListTemplateItemEntity>,
) {} ) {}
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> { async createList(
ownerId: string,
createDto: CreateListDto,
): Promise<UserList> {
const list = this.listsRepository.create({ const list = this.listsRepository.create({
id: randomUUID(), id: randomUUID(),
ownerId, ownerId,
@@ -122,7 +132,10 @@ export class ListsService {
const list = await this.createList(ownerId, createDto); const list = await this.createList(ownerId, createDto);
const listEntity = await this.findAccessibleList(ownerId, list.id); const listEntity = await this.findAccessibleList(ownerId, list.id);
const response = await this.callMistralForItemSuggestions(listEntity); const response = await this.callMistralForItemSuggestions(listEntity);
const suggestions = this.normalizeItemSuggestions(response, listEntity.items); const suggestions = this.normalizeItemSuggestions(
response,
listEntity.items,
);
return { return {
list, list,
@@ -220,7 +233,10 @@ export class ListsService {
} }
async getList(ownerId: string, listId: string): Promise<UserList> { async getList(ownerId: string, listId: string): Promise<UserList> {
return this.toUserList(await this.findAccessibleList(ownerId, listId), ownerId); return this.toUserList(
await this.findAccessibleList(ownerId, listId),
ownerId,
);
} }
async updateList( async updateList(
@@ -268,7 +284,10 @@ export class ListsService {
return userList; return userList;
} }
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> { async deleteList(
ownerId: string,
listId: string,
): Promise<{ message: string }> {
const list = await this.findOwnedList(ownerId, listId); const list = await this.findOwnedList(ownerId, listId);
const accessorIds = this.listAccessorIds(list); const accessorIds = this.listAccessorIds(list);
const metadata = { const metadata = {
@@ -303,7 +322,9 @@ export class ListsService {
const targetUserId = this.requireShareUserId(shareDto.userId); const targetUserId = this.requireShareUserId(shareDto.userId);
if (targetUserId === ownerId) { if (targetUserId === ownerId) {
throw new BadRequestException('List owner cannot be added as collaborator.'); throw new BadRequestException(
'List owner cannot be added as collaborator.',
);
} }
const targetUser = await this.usersRepository.findOne({ const targetUser = await this.usersRepository.findOne({
@@ -612,7 +633,9 @@ export class ListsService {
const list = await this.findAccessibleList(ownerId, listId); const list = await this.findAccessibleList(ownerId, listId);
if (list.ownerId !== ownerId) { if (list.ownerId !== ownerId) {
throw new ForbiddenException('Only the list owner can perform this action.'); throw new ForbiddenException(
'Only the list owner can perform this action.',
);
} }
return list; return list;
@@ -730,7 +753,9 @@ export class ListsService {
} }
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw new BadRequestException('Reminder time must be an ISO date string.'); throw new BadRequestException(
'Reminder time must be an ISO date string.',
);
} }
const normalizedValue = value.trim(); const normalizedValue = value.trim();
@@ -742,7 +767,9 @@ export class ListsService {
const reminderAt = new Date(normalizedValue); const reminderAt = new Date(normalizedValue);
if (Number.isNaN(reminderAt.getTime())) { if (Number.isNaN(reminderAt.getTime())) {
throw new BadRequestException('Reminder time must be an ISO date string.'); throw new BadRequestException(
'Reminder time must be an ISO date string.',
);
} }
return reminderAt; return reminderAt;
@@ -779,10 +806,13 @@ export class ListsService {
].filter((userId, index, userIds) => userIds.indexOf(userId) === index); ].filter((userId, index, userIds) => userIds.indexOf(userId) === index);
} }
private async hydrateListAccessRelations(list: UserListEntity): Promise<void> { private async hydrateListAccessRelations(
list.owner ??= (await this.usersRepository.findOne({ list: UserListEntity,
where: { id: list.ownerId }, ): Promise<void> {
})) ?? undefined; list.owner ??=
(await this.usersRepository.findOne({
where: { id: list.ownerId },
})) ?? undefined;
const storedShares = await this.listSharesRepository.find({ const storedShares = await this.listSharesRepository.find({
where: { listId: list.id }, where: { listId: list.id },
@@ -791,9 +821,10 @@ export class ListsService {
list.shares = storedShares; list.shares = storedShares;
for (const share of list.shares) { for (const share of list.shares) {
share.user ??= (await this.usersRepository.findOne({ share.user ??=
where: { id: share.userId }, (await this.usersRepository.findOne({
})) ?? undefined; where: { id: share.userId },
})) ?? undefined;
} }
} }
@@ -833,7 +864,9 @@ export class ListsService {
name: list.name, name: list.name,
description: list.description ?? undefined, description: list.description ?? undefined,
kind: list.kind, kind: list.kind,
reminderAt: list.reminderAt ? this.toIsoString(list.reminderAt) : undefined, reminderAt: list.reminderAt
? this.toIsoString(list.reminderAt)
: undefined,
items: (list.items ?? []) items: (list.items ?? [])
.sort((left, right) => left.position - right.position) .sort((left, right) => left.position - right.position)
.map((item) => this.toUserListItem(item)), .map((item) => this.toUserListItem(item)),
@@ -904,14 +937,12 @@ export class ListsService {
list: UserListEntity, list: UserListEntity,
): Promise<unknown> { ): Promise<unknown> {
const apiKey = process.env.MISTRAL_API_KEY; const apiKey = process.env.MISTRAL_API_KEY;
const agentId = process.env.MISTRAL_AGENT_ID; const model = process.env.MISTRAL_MODEL ?? this.defaultMistralModel;
if (!apiKey) { if (!apiKey) {
throw new ServiceUnavailableException('Mistral API key is not configured.'); 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, { const response = await fetch(this.itemSuggestionsEndpoint, {
@@ -921,28 +952,24 @@ export class ListsService {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
agent_id: agentId, model,
messages: [ inputs: [
{
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', role: 'user',
content: this.createItemSuggestionPrompt(list), content: this.createItemSuggestionPrompt(list),
}, },
], ],
instructions:
'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.',
stream: false, stream: false,
response_format: {
type: 'json_object',
},
}), }),
}); });
const responsePayload = await this.readMistralResponsePayload(response); const responsePayload = await this.readMistralResponsePayload(response);
if (!response.ok) { if (!response.ok) {
throw new ServiceUnavailableException('Mistral agent request failed.'); throw new ServiceUnavailableException(
'Mistral conversation request failed.',
);
} }
return responsePayload; return responsePayload;
@@ -983,7 +1010,9 @@ export class ListsService {
return lines.join('\n'); return lines.join('\n');
} }
private async readMistralResponsePayload(response: Response): Promise<unknown> { private async readMistralResponsePayload(
response: Response,
): Promise<unknown> {
const rawBody = await response.text(); const rawBody = await response.text();
if (!rawBody) { if (!rawBody) {
@@ -1038,7 +1067,7 @@ export class ListsService {
return null; return null;
} }
const response = responsePayload as MistralAgentCompletionResponse; const response = responsePayload as MistralCompletionResponse;
const directContent = response.choices?.[0]?.message?.content?.trim(); const directContent = response.choices?.[0]?.message?.content?.trim();
if (directContent) { if (directContent) {
@@ -1060,6 +1089,24 @@ export class ListsService {
} }
} }
const outputText = response.output_text?.trim();
if (outputText) {
return outputText;
}
for (const output of [...(response.outputs ?? [])].reverse()) {
const content =
typeof output.content === 'string' ? output.content.trim() : '';
if (
content &&
(output.role === 'assistant' || output.type?.includes('message'))
) {
return content;
}
}
return null; return null;
} }
@@ -1129,7 +1176,10 @@ export class ListsService {
return value.trim().replace(/\s+/g, ' ').toLowerCase(); return value.trim().replace(/\s+/g, ' ').toLowerCase();
} }
private compactText(value: string | undefined, maxLength: number): string | undefined { private compactText(
value: string | undefined,
maxLength: number,
): string | undefined {
const compacted = value?.replace(/\s+/g, ' ').trim(); const compacted = value?.replace(/\s+/g, ' ').trim();
if (!compacted) { if (!compacted) {