From 002114fcb116dfcd169f1b37fe2f6433d204a969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 12:16:20 +0200 Subject: [PATCH] refactor(api): extract assistant approval flow helpers --- .../api/src/router/assistant-chat-response.ts | 162 ++++++++++++++++++ packages/api/src/router/assistant.ts | 146 +++++----------- 2 files changed, 204 insertions(+), 104 deletions(-) create mode 100644 packages/api/src/router/assistant-chat-response.ts diff --git a/packages/api/src/router/assistant-chat-response.ts b/packages/api/src/router/assistant-chat-response.ts new file mode 100644 index 0000000..da28491 --- /dev/null +++ b/packages/api/src/router/assistant-chat-response.ts @@ -0,0 +1,162 @@ +import { createAuditEntry } from "../lib/audit.js"; +import { + clearPendingAssistantApproval, + consumePendingAssistantApproval, + toApprovalPayload, + type PendingAssistantApproval, +} from "./assistant-approvals.js"; +import { + canExecuteMutationTool, + isCancellationReply, + parseToolArguments, + type ChatMessage, +} from "./assistant-confirmation.js"; +import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js"; +import { readToolError, readToolSuccessMessage } from "./assistant-tool-results.js"; +import { executeTool, type ToolAction, type ToolContext } from "./assistant-tools.js"; + +export type AssistantChatResponse = { + content: string; + role: "assistant"; + approval?: ReturnType; + insights?: AssistantInsight[]; + actions?: ToolAction[]; +}; + +export function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] { + const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle); + if (duplicateIndex >= 0) { + const copy = [...existing]; + copy[duplicateIndex] = next; + return copy; + } + + return [...existing, next].slice(-6); +} + +export function buildAssistantChatResponse(input: { + content: string; + approval?: ReturnType | undefined; + insights: AssistantInsight[]; + actions: ToolAction[]; +}): AssistantChatResponse { + return { + content: input.content, + role: "assistant", + ...(input.approval !== undefined ? { approval: input.approval } : {}), + ...(input.insights.length > 0 ? { insights: input.insights } : {}), + ...(input.actions.length > 0 ? { actions: input.actions } : {}), + }; +} + +export async function handlePendingAssistantApproval(input: { + db: ToolContext["db"]; + dbUserId?: string | undefined; + toolCtx: ToolContext; + conversationId: string; + pendingApproval: PendingAssistantApproval | null; + lastUserMessage?: ChatMessage | undefined; + messages: ChatMessage[]; + collectedActions: ToolAction[]; + collectedInsights: AssistantInsight[]; +}): Promise<{ + response: AssistantChatResponse; + collectedActions: ToolAction[]; + collectedInsights: AssistantInsight[]; +} | null> { + const { pendingApproval, lastUserMessage } = input; + + if (!pendingApproval || lastUserMessage?.role !== "user") { + return null; + } + + if (isCancellationReply(lastUserMessage.content)) { + await clearPendingAssistantApproval(input.db, input.toolCtx.userId, input.conversationId); + void createAuditEntry({ + db: input.db, + entityType: "AiToolExecution", + entityId: pendingApproval.id, + entityName: pendingApproval.toolName, + action: "DELETE", + source: "ai", + summary: `AI approval cancelled: ${pendingApproval.toolName}`, + after: { + approvalId: pendingApproval.id, + params: parseToolArguments(pendingApproval.toolArguments), + executed: false, + }, + ...(input.dbUserId !== undefined ? { userId: input.dbUserId } : {}), + }); + + return { + response: buildAssistantChatResponse({ + content: `Aktion verworfen: ${pendingApproval.summary}`, + approval: toApprovalPayload(pendingApproval, "cancelled"), + insights: input.collectedInsights, + actions: input.collectedActions, + }), + collectedActions: input.collectedActions, + collectedInsights: input.collectedInsights, + }; + } + + if (!canExecuteMutationTool(input.messages, pendingApproval.toolName, pendingApproval)) { + return null; + } + + const approvedAction = + await consumePendingAssistantApproval(input.db, input.toolCtx.userId, input.conversationId) + ?? pendingApproval; + const result = await executeTool( + approvedAction.toolName, + approvedAction.toolArguments, + input.toolCtx, + ); + + let collectedInsights = input.collectedInsights; + const insight = buildAssistantInsight(approvedAction.toolName, result.data); + if (insight) { + collectedInsights = mergeInsights(collectedInsights, insight); + } + + const collectedActions = result.action + ? [...input.collectedActions, result.action] + : input.collectedActions; + + const errorMessage = readToolError(result); + const successMessage = readToolSuccessMessage(result); + const finalContent = errorMessage + ? `Die bestätigte Aktion konnte nicht ausgeführt werden: ${errorMessage}` + : successMessage + ? `Ausgeführt: ${successMessage}` + : `Ausgeführt: ${approvedAction.summary}`; + + void createAuditEntry({ + db: input.db, + entityType: "AiToolExecution", + entityId: approvedAction.id, + entityName: approvedAction.toolName, + action: "CREATE", + source: "ai", + summary: errorMessage + ? `AI confirmed tool failed: ${approvedAction.toolName}` + : `AI executed previously approved tool: ${approvedAction.toolName}`, + after: { + approvalId: approvedAction.id, + params: parseToolArguments(approvedAction.toolArguments), + executed: !errorMessage, + }, + ...(input.dbUserId !== undefined ? { userId: input.dbUserId } : {}), + }); + + return { + response: buildAssistantChatResponse({ + content: finalContent, + approval: toApprovalPayload(approvedAction, "approved"), + insights: collectedInsights, + actions: collectedActions, + }), + collectedActions, + collectedInsights, + }; +} diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 6d47ff2..2013db0 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -12,8 +12,6 @@ import { MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type T import { AssistantApprovalStorageUnavailableError, createPendingAssistantApproval, - clearPendingAssistantApproval, - consumePendingAssistantApproval, listPendingAssistantApprovals, peekPendingAssistantApproval, toApprovalPayload, @@ -21,14 +19,16 @@ import { } from "./assistant-approvals.js"; import { ASSISTANT_CONFIRMATION_PREFIX, - canExecuteMutationTool, - isCancellationReply, parseToolArguments, type ChatMessage, } from "./assistant-confirmation.js"; +import { + buildAssistantChatResponse, + handlePendingAssistantApproval, + mergeInsights, +} from "./assistant-chat-response.js"; import { getAvailableAssistantTools } from "./assistant-tool-policy.js"; import { selectAssistantToolsForRequest } from "./assistant-tool-selection.js"; -import { readToolError, readToolSuccessMessage } from "./assistant-tool-results.js"; import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js"; import { checkPromptInjection } from "../lib/prompt-guard.js"; import { checkAiOutput } from "../lib/content-filter.js"; @@ -105,16 +105,6 @@ Datenmodell: - Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten `; -function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] { - const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle); - if (duplicateIndex >= 0) { - const copy = [...existing]; - copy[duplicateIndex] = next; - return copy; - } - return [...existing, next].slice(-6); -} - export const assistantRouter = createTRPCRouter({ listPendingApprovals: protectedProcedure .query(async ({ ctx }) => { @@ -222,84 +212,28 @@ export const assistantRouter = createTRPCRouter({ dbUser: ctx.dbUser, roleDefaults: ctx.roleDefaults, }; - const collectedActions: ToolAction[] = []; + let collectedActions: ToolAction[] = []; let collectedInsights: AssistantInsight[] = []; const userId = ctx.dbUser!.id; const conversationId = input.conversationId?.trim().slice(0, 120) || "default"; const pendingApproval = await peekPendingAssistantApproval(ctx.db, userId, conversationId); - if (pendingApproval && lastUserMsg?.role === "user") { - if (isCancellationReply(lastUserMsg.content)) { - await clearPendingAssistantApproval(ctx.db, userId, conversationId); - void createAuditEntry({ - db: ctx.db, - entityType: "AiToolExecution", - entityId: pendingApproval.id, - entityName: pendingApproval.toolName, - action: "DELETE", - userId: ctx.dbUser?.id, - source: "ai", - summary: `AI approval cancelled: ${pendingApproval.toolName}`, - after: { approvalId: pendingApproval.id, params: parseToolArguments(pendingApproval.toolArguments), executed: false }, - }); + const pendingApprovalResult = await handlePendingAssistantApproval({ + db: ctx.db, + dbUserId: ctx.dbUser?.id, + toolCtx, + conversationId, + pendingApproval, + lastUserMessage: lastUserMsg, + messages: input.messages, + collectedActions, + collectedInsights, + }); - return { - content: `Aktion verworfen: ${pendingApproval.summary}`, - role: "assistant" as const, - approval: toApprovalPayload(pendingApproval, "cancelled"), - }; - } - - if (canExecuteMutationTool(input.messages, pendingApproval.toolName, pendingApproval)) { - const approvedAction = await consumePendingAssistantApproval(ctx.db, userId, conversationId) ?? pendingApproval; - const result = await executeTool( - approvedAction.toolName, - approvedAction.toolArguments, - toolCtx, - ); - - const insight = buildAssistantInsight(approvedAction.toolName, result.data); - if (insight) { - collectedInsights = mergeInsights(collectedInsights, insight); - } - if (result.action) { - collectedActions.push(result.action); - } - - const errorMessage = readToolError(result); - const successMessage = readToolSuccessMessage(result); - const finalContent = errorMessage - ? `Die bestätigte Aktion konnte nicht ausgeführt werden: ${errorMessage}` - : successMessage - ? `Ausgeführt: ${successMessage}` - : `Ausgeführt: ${approvedAction.summary}`; - - void createAuditEntry({ - db: ctx.db, - entityType: "AiToolExecution", - entityId: approvedAction.id, - entityName: approvedAction.toolName, - action: "CREATE", - userId: ctx.dbUser?.id, - source: "ai", - summary: errorMessage - ? `AI confirmed tool failed: ${approvedAction.toolName}` - : `AI executed previously approved tool: ${approvedAction.toolName}`, - after: { - approvalId: approvedAction.id, - params: parseToolArguments(approvedAction.toolArguments), - executed: !errorMessage, - }, - }); - - return { - content: finalContent, - role: "assistant" as const, - approval: toApprovalPayload(approvedAction, "approved"), - ...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}), - ...(collectedActions.length > 0 ? { actions: collectedActions } : {}), - }; - } + if (pendingApprovalResult) { + collectedActions = pendingApprovalResult.collectedActions; + collectedInsights = pendingApprovalResult.collectedInsights; + return pendingApprovalResult.response; } for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { @@ -354,10 +288,11 @@ export const assistantRouter = createTRPCRouter({ throw error; } return { - 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.", - role: "assistant" as const, - ...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}), - ...(collectedActions.length > 0 ? { actions: collectedActions } : {}), + ...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, + }), }; } @@ -378,11 +313,12 @@ export const assistantRouter = createTRPCRouter({ }); return { - content: `${ASSISTANT_CONFIRMATION_PREFIX} ${approval.summary}. Bitte bestätigen.`, - role: "assistant" as const, - approval: toApprovalPayload(approval, "pending"), - ...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}), - ...(collectedActions.length > 0 ? { actions: collectedActions } : {}), + ...buildAssistantChatResponse({ + content: `${ASSISTANT_CONFIRMATION_PREFIX} ${approval.summary}. Bitte bestätigen.`, + approval: toApprovalPayload(approval, "pending"), + insights: collectedInsights, + actions: collectedActions, + }), }; } @@ -453,19 +389,21 @@ export const assistantRouter = createTRPCRouter({ } return { - content: finalContent, - role: "assistant" as const, - ...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}), - ...(collectedActions.length > 0 ? { actions: collectedActions } : {}), + ...buildAssistantChatResponse({ + content: finalContent, + insights: collectedInsights, + actions: collectedActions, + }), }; } // Exceeded max iterations return { - content: "I had to stop after too many tool calls. Please try a simpler question.", - role: "assistant" as const, - ...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}), - ...(collectedActions.length > 0 ? { actions: collectedActions } : {}), + ...buildAssistantChatResponse({ + content: "I had to stop after too many tool calls. Please try a simpler question.", + insights: collectedInsights, + actions: collectedActions, + }), }; }), });