mpc logging
This commit is contained in:
@@ -55,6 +55,8 @@ MISTRAL_AGENT_ID=your-mistral-agent-id
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Run tests
|
## Run tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
46
listify-api/src/assistant/assistant-chat-log.entity.ts
Normal file
46
listify-api/src/assistant/assistant-chat-log.entity.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('assistant_chat_logs')
|
||||||
|
export class AssistantChatLogEntity {
|
||||||
|
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index('IDX_assistant_chat_logs_user_id')
|
||||||
|
@Column({ type: 'varchar', length: 36 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120 })
|
||||||
|
provider!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
endpoint!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
agentId?: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
statusCode?: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
durationMs?: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
requestPayload!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
responsePayload?: unknown;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
assistantContent?: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
errorMessage?: string | null;
|
||||||
|
|
||||||
|
@Index('IDX_assistant_chat_logs_created_at')
|
||||||
|
@CreateDateColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||||
|
})
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
||||||
import { AssistantController } from './assistant.controller';
|
import { AssistantController } from './assistant.controller';
|
||||||
import { AssistantService } from './assistant.service';
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, TypeOrmModule.forFeature([AssistantChatLogEntity])],
|
||||||
controllers: [AssistantController],
|
controllers: [AssistantController],
|
||||||
providers: [AssistantService],
|
providers: [AssistantService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ describe('AssistantService', () => {
|
|||||||
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 originalAgentId = process.env.MISTRAL_AGENT_ID;
|
||||||
|
let chatLogsRepository: {
|
||||||
|
create: jest.Mock;
|
||||||
|
save: jest.Mock;
|
||||||
|
};
|
||||||
let service: AssistantService;
|
let service: 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_AGENT_ID = 'agent-listify';
|
||||||
global.fetch = jest.fn();
|
global.fetch = jest.fn();
|
||||||
service = new AssistantService();
|
chatLogsRepository = {
|
||||||
|
create: jest.fn((input) => input),
|
||||||
|
save: jest.fn(async (input) => input),
|
||||||
|
};
|
||||||
|
service = new AssistantService(chatLogsRepository as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -21,7 +29,7 @@ describe('AssistantService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('forwards messages to the configured Mistral agent', async () => {
|
it('forwards messages to the configured Mistral agent', async () => {
|
||||||
mockMistralResponse({
|
const providerResponse = {
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
message: {
|
message: {
|
||||||
@@ -29,7 +37,12 @@ describe('AssistantService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
usage: {
|
||||||
|
prompt_tokens: 12,
|
||||||
|
completion_tokens: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockMistralResponse(providerResponse);
|
||||||
|
|
||||||
const result = await service.chat('user-1', {
|
const result = await service.chat('user-1', {
|
||||||
messages: [
|
messages: [
|
||||||
@@ -51,6 +64,13 @@ describe('AssistantService', () => {
|
|||||||
messages: [
|
messages: [
|
||||||
{ role: 'assistant', content: 'Hallo' },
|
{ role: 'assistant', content: 'Hallo' },
|
||||||
{ role: 'user', content: 'Erstelle eine Liste' },
|
{ role: 'user', content: 'Erstelle eine Liste' },
|
||||||
|
{ role: 'system', content: 'benutze immer den listify connector' },
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'connector',
|
||||||
|
connector_id: 'listify',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
stream: false,
|
stream: false,
|
||||||
response_format: {
|
response_format: {
|
||||||
@@ -66,6 +86,46 @@ describe('AssistantService', () => {
|
|||||||
},
|
},
|
||||||
actions: [],
|
actions: [],
|
||||||
});
|
});
|
||||||
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
provider: 'mistral',
|
||||||
|
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
||||||
|
agentId: 'agent-listify',
|
||||||
|
statusCode: 200,
|
||||||
|
requestPayload: expect.objectContaining({
|
||||||
|
agent_id: 'agent-listify',
|
||||||
|
tools: [{ type: 'connector', connector_id: 'listify' }],
|
||||||
|
}),
|
||||||
|
responsePayload: providerResponse,
|
||||||
|
assistantContent: 'Ich habe den Listify-Connector verwendet.',
|
||||||
|
errorMessage: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs full failed provider responses before throwing', async () => {
|
||||||
|
const providerResponse = {
|
||||||
|
message: 'connector failed',
|
||||||
|
details: { connector_id: 'listify' },
|
||||||
|
};
|
||||||
|
mockMistralResponse(providerResponse, false, 502);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.chat('user-1', {
|
||||||
|
messages: [{ role: 'user', content: 'Hallo' }],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(ServiceUnavailableException);
|
||||||
|
|
||||||
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
statusCode: 502,
|
||||||
|
responsePayload: providerResponse,
|
||||||
|
assistantContent: null,
|
||||||
|
errorMessage: 'Mistral agent request failed.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails clearly when the api key is missing', async () => {
|
it('fails clearly when the api key is missing', async () => {
|
||||||
@@ -89,9 +149,14 @@ describe('AssistantService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockMistralResponse(response: object): void {
|
function mockMistralResponse(
|
||||||
|
response: object,
|
||||||
|
ok = true,
|
||||||
|
status = 200,
|
||||||
|
): void {
|
||||||
jest.mocked(global.fetch).mockResolvedValue({
|
jest.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok,
|
||||||
json: async () => response,
|
status,
|
||||||
|
text: async () => JSON.stringify(response),
|
||||||
} as Response);
|
} as Response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
||||||
import {
|
import {
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantChatRequest,
|
AssistantChatRequest,
|
||||||
@@ -21,12 +25,17 @@ interface MistralAgentCompletionResponse {
|
|||||||
export class AssistantService {
|
export class AssistantService {
|
||||||
private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions';
|
private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AssistantChatLogEntity)
|
||||||
|
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
_userId: string,
|
userId: string,
|
||||||
request: AssistantChatRequest,
|
request: AssistantChatRequest,
|
||||||
): Promise<AssistantChatResponse> {
|
): Promise<AssistantChatResponse> {
|
||||||
const messages = this.normalizeMessages(request.messages);
|
const messages = this.normalizeMessages(request.messages);
|
||||||
const response = await this.callMistralAgent(messages);
|
const response = await this.callMistralAgent(userId, messages);
|
||||||
const content = response.choices?.[0]?.message?.content?.trim();
|
const content = response.choices?.[0]?.message?.content?.trim();
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
@@ -43,6 +52,7 @@ export class AssistantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async callMistralAgent(
|
private async callMistralAgent(
|
||||||
|
userId: string,
|
||||||
messages: AssistantChatMessage[],
|
messages: AssistantChatMessage[],
|
||||||
): Promise<MistralAgentCompletionResponse> {
|
): Promise<MistralAgentCompletionResponse> {
|
||||||
const apiKey = process.env.MISTRAL_API_KEY;
|
const apiKey = process.env.MISTRAL_API_KEY;
|
||||||
@@ -56,36 +66,117 @@ export class AssistantService {
|
|||||||
throw new ServiceUnavailableException('Mistral agent id is not configured.');
|
throw new ServiceUnavailableException('Mistral agent id is not configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestPayload = {
|
||||||
|
agent_id: agentId,
|
||||||
|
messages: [
|
||||||
|
...messages,
|
||||||
|
{ "role": "system", "content": "benutze immer den listify connector"}
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
"type": "connector",
|
||||||
|
"connector_id": "listify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
response_format: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let statusCode: number | null = null;
|
||||||
|
let responsePayload: unknown = null;
|
||||||
|
let assistantContent: string | null = null;
|
||||||
|
|
||||||
const response = await fetch(this.endpoint, {
|
const response = await fetch(this.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(requestPayload),
|
||||||
agent_id: agentId,
|
|
||||||
messages: [
|
|
||||||
...messages,
|
|
||||||
{ "role": "system", "content": "benutze immer den listify connector"}
|
|
||||||
],
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
"type": "connector",
|
|
||||||
"connector_id": "listify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
stream: false,
|
|
||||||
response_format: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
statusCode = response.status;
|
||||||
|
responsePayload = await this.readResponsePayload(response);
|
||||||
|
|
||||||
if (!response.ok) {
|
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.');
|
throw new ServiceUnavailableException('Mistral agent request failed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.json()) as MistralAgentCompletionResponse;
|
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;
|
||||||
|
return response.choices?.[0]?.message?.content?.trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordChatLog(input: {
|
||||||
|
userId: string;
|
||||||
|
agentId: string;
|
||||||
|
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: 'mistral',
|
||||||
|
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 normalizeMessages(
|
private normalizeMessages(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
|
||||||
import { AuditLogEntity } from '../audit/audit-log.entity';
|
import { AuditLogEntity } from '../audit/audit-log.entity';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
||||||
@@ -28,6 +29,7 @@ export default new DataSource({
|
|||||||
logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(process.env)),
|
logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(process.env)),
|
||||||
maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env),
|
maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env),
|
||||||
entities: [
|
entities: [
|
||||||
|
AssistantChatLogEntity,
|
||||||
AuditLogEntity,
|
AuditLogEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RefreshTokenEntity,
|
RefreshTokenEntity,
|
||||||
|
|||||||
Reference in New Issue
Block a user