refactor(api): extract assistant chat orchestration

This commit is contained in:
2026-03-31 13:15:44 +02:00
parent 002114fcb1
commit 4bea9ddd14
4 changed files with 804 additions and 178 deletions
+21 -178
View File
@@ -7,31 +7,26 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { createAiClient, isAiConfigured } from "../ai-client.js";
import { TOOL_DEFINITIONS, type ToolContext, type ToolAction } from "./assistant-tools.js";
import {
AssistantApprovalStorageUnavailableError,
createPendingAssistantApproval,
listPendingAssistantApprovals,
peekPendingAssistantApproval,
toApprovalPayload,
type PendingAssistantApproval,
} from "./assistant-approvals.js";
import {
ASSISTANT_CONFIRMATION_PREFIX,
parseToolArguments,
type ChatMessage,
} from "./assistant-confirmation.js";
import {
buildAssistantChatResponse,
handlePendingAssistantApproval,
mergeInsights,
} from "./assistant-chat-response.js";
import { runAssistantToolLoop } from "./assistant-chat-loop.js";
import { getAvailableAssistantTools } from "./assistant-tool-policy.js";
import { selectAssistantToolsForRequest } from "./assistant-tool-selection.js";
import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
import { type AssistantInsight } from "./assistant-insights.js";
import { checkPromptInjection } from "../lib/prompt-guard.js";
import { checkAiOutput } from "../lib/content-filter.js";
import { createAuditEntry } from "../lib/audit.js";
import { logger } from "../lib/logger.js";
@@ -236,174 +231,22 @@ export const assistantRouter = createTRPCRouter({
return pendingApprovalResult.response;
}
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
const provider = settings!.aiProvider ?? "openai";
const msgLen = openaiMessages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
try {
response = await loggedAiCall(provider, model, msgLen, () =>
client.chat.completions.create({
model,
messages: openaiMessages,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools: availableTools as any,
max_completion_tokens: maxTokens,
temperature,
}),
);
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `AI error: ${parseAiError(err)}`,
});
}
const choice = response.choices?.[0];
if (!choice) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No response from AI" });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msg = choice.message as any;
// If the AI wants to call tools
if (msg.tool_calls && msg.tool_calls.length > 0) {
openaiMessages.push(msg);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const toolCall of msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }>) {
if (MUTATION_TOOLS.has(toolCall.function.name)) {
let approval: PendingAssistantApproval;
try {
approval = await createPendingAssistantApproval(
ctx.db,
userId,
conversationId,
toolCall.function.name,
toolCall.function.arguments,
);
} catch (error) {
if (!(error instanceof AssistantApprovalStorageUnavailableError)) {
throw error;
}
return {
...buildAssistantChatResponse({
content: "Schreibende Assistant-Aktionen sind gerade nicht verfuegbar, weil der Bestaetigungsspeicher in der Datenbank fehlt. Bitte die CapaKraken-DB-Migration anwenden und dann erneut versuchen.",
insights: collectedInsights,
actions: collectedActions,
}),
};
}
void createAuditEntry({
db: ctx.db,
entityType: "AiToolExecution",
entityId: toolCall.id,
entityName: toolCall.function.name,
action: "CREATE",
userId: ctx.dbUser?.id,
source: "ai",
summary: `AI tool blocked pending confirmation: ${toolCall.function.name}`,
after: {
approvalId: approval.id,
params: parseToolArguments(toolCall.function.arguments),
executed: false,
},
});
return {
...buildAssistantChatResponse({
content: `${ASSISTANT_CONFIRMATION_PREFIX} ${approval.summary}. Bitte bestätigen.`,
approval: toApprovalPayload(approval, "pending"),
insights: collectedInsights,
actions: collectedActions,
}),
};
}
const result = await executeTool(
toolCall.function.name,
toolCall.function.arguments,
toolCtx,
);
const insight = buildAssistantInsight(toolCall.function.name, result.data);
if (insight) {
collectedInsights = mergeInsights(collectedInsights, insight);
}
// Collect any actions (e.g. navigation)
if (result.action) {
collectedActions.push(result.action);
}
openaiMessages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result.content,
});
// Audit trail for AI tool execution (IAAI 3.6.35)
let parsedArgs: Record<string, unknown> = {};
try {
parsedArgs = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
} catch {
// keep empty object if args are not valid JSON
}
void createAuditEntry({
db: ctx.db,
entityType: "AiToolExecution",
entityId: toolCall.id,
entityName: toolCall.function.name,
action: "CREATE",
userId: ctx.dbUser?.id,
source: "ai",
summary: `AI executed tool: ${toolCall.function.name}`,
after: { params: parsedArgs, executed: true },
});
}
continue;
}
// AI returned a text response — apply content filter (EGAI 4.3.2.1)
let finalContent = (msg.content as string) ?? "I couldn't generate a response.";
const contentCheck = checkAiOutput(finalContent);
if (!contentCheck.clean) {
logger.warn(
{ userId: ctx.dbUser?.id },
"AI output contained sensitive content — redacted before delivery",
);
finalContent = contentCheck.redacted;
void createAuditEntry({
db: ctx.db,
entityType: "SecurityAlert",
entityId: crypto.randomUUID(),
entityName: "AiOutputRedacted",
action: "CREATE",
userId: ctx.dbUser?.id,
source: "ai",
summary: "AI output contained potentially sensitive content and was redacted",
});
}
return {
...buildAssistantChatResponse({
content: finalContent,
insights: collectedInsights,
actions: collectedActions,
}),
};
}
// Exceeded max iterations
return {
...buildAssistantChatResponse({
content: "I had to stop after too many tool calls. Please try a simpler question.",
insights: collectedInsights,
actions: collectedActions,
}),
};
return runAssistantToolLoop({
db: ctx.db,
dbUserId: ctx.dbUser?.id,
client,
provider: settings!.aiProvider ?? "openai",
model,
maxTokens,
temperature,
openaiMessages,
availableTools,
toolCtx,
userId,
conversationId,
collectedActions,
collectedInsights,
maxToolIterations: MAX_TOOL_ITERATIONS,
});
}),
});