refactor(api): extract assistant approval flow helpers
This commit is contained in:
@@ -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<typeof toApprovalPayload>;
|
||||||
|
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<typeof toApprovalPayload> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,8 +12,6 @@ import { MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type T
|
|||||||
import {
|
import {
|
||||||
AssistantApprovalStorageUnavailableError,
|
AssistantApprovalStorageUnavailableError,
|
||||||
createPendingAssistantApproval,
|
createPendingAssistantApproval,
|
||||||
clearPendingAssistantApproval,
|
|
||||||
consumePendingAssistantApproval,
|
|
||||||
listPendingAssistantApprovals,
|
listPendingAssistantApprovals,
|
||||||
peekPendingAssistantApproval,
|
peekPendingAssistantApproval,
|
||||||
toApprovalPayload,
|
toApprovalPayload,
|
||||||
@@ -21,14 +19,16 @@ import {
|
|||||||
} from "./assistant-approvals.js";
|
} from "./assistant-approvals.js";
|
||||||
import {
|
import {
|
||||||
ASSISTANT_CONFIRMATION_PREFIX,
|
ASSISTANT_CONFIRMATION_PREFIX,
|
||||||
canExecuteMutationTool,
|
|
||||||
isCancellationReply,
|
|
||||||
parseToolArguments,
|
parseToolArguments,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "./assistant-confirmation.js";
|
} from "./assistant-confirmation.js";
|
||||||
|
import {
|
||||||
|
buildAssistantChatResponse,
|
||||||
|
handlePendingAssistantApproval,
|
||||||
|
mergeInsights,
|
||||||
|
} from "./assistant-chat-response.js";
|
||||||
import { getAvailableAssistantTools } from "./assistant-tool-policy.js";
|
import { getAvailableAssistantTools } from "./assistant-tool-policy.js";
|
||||||
import { selectAssistantToolsForRequest } from "./assistant-tool-selection.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 { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
|
||||||
import { checkPromptInjection } from "../lib/prompt-guard.js";
|
import { checkPromptInjection } from "../lib/prompt-guard.js";
|
||||||
import { checkAiOutput } from "../lib/content-filter.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
|
- 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({
|
export const assistantRouter = createTRPCRouter({
|
||||||
listPendingApprovals: protectedProcedure
|
listPendingApprovals: protectedProcedure
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
@@ -222,84 +212,28 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
dbUser: ctx.dbUser,
|
dbUser: ctx.dbUser,
|
||||||
roleDefaults: ctx.roleDefaults,
|
roleDefaults: ctx.roleDefaults,
|
||||||
};
|
};
|
||||||
const collectedActions: ToolAction[] = [];
|
let collectedActions: ToolAction[] = [];
|
||||||
let collectedInsights: AssistantInsight[] = [];
|
let collectedInsights: AssistantInsight[] = [];
|
||||||
const userId = ctx.dbUser!.id;
|
const userId = ctx.dbUser!.id;
|
||||||
const conversationId = input.conversationId?.trim().slice(0, 120) || "default";
|
const conversationId = input.conversationId?.trim().slice(0, 120) || "default";
|
||||||
const pendingApproval = await peekPendingAssistantApproval(ctx.db, userId, conversationId);
|
const pendingApproval = await peekPendingAssistantApproval(ctx.db, userId, conversationId);
|
||||||
|
|
||||||
if (pendingApproval && lastUserMsg?.role === "user") {
|
const pendingApprovalResult = await handlePendingAssistantApproval({
|
||||||
if (isCancellationReply(lastUserMsg.content)) {
|
db: ctx.db,
|
||||||
await clearPendingAssistantApproval(ctx.db, userId, conversationId);
|
dbUserId: ctx.dbUser?.id,
|
||||||
void createAuditEntry({
|
toolCtx,
|
||||||
db: ctx.db,
|
conversationId,
|
||||||
entityType: "AiToolExecution",
|
pendingApproval,
|
||||||
entityId: pendingApproval.id,
|
lastUserMessage: lastUserMsg,
|
||||||
entityName: pendingApproval.toolName,
|
messages: input.messages,
|
||||||
action: "DELETE",
|
collectedActions,
|
||||||
userId: ctx.dbUser?.id,
|
collectedInsights,
|
||||||
source: "ai",
|
});
|
||||||
summary: `AI approval cancelled: ${pendingApproval.toolName}`,
|
|
||||||
after: { approvalId: pendingApproval.id, params: parseToolArguments(pendingApproval.toolArguments), executed: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
if (pendingApprovalResult) {
|
||||||
content: `Aktion verworfen: ${pendingApproval.summary}`,
|
collectedActions = pendingApprovalResult.collectedActions;
|
||||||
role: "assistant" as const,
|
collectedInsights = pendingApprovalResult.collectedInsights;
|
||||||
approval: toApprovalPayload(pendingApproval, "cancelled"),
|
return pendingApprovalResult.response;
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||||
@@ -354,10 +288,11 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return {
|
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.",
|
...buildAssistantChatResponse({
|
||||||
role: "assistant" as const,
|
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.",
|
||||||
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
insights: collectedInsights,
|
||||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
actions: collectedActions,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,11 +313,12 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: `${ASSISTANT_CONFIRMATION_PREFIX} ${approval.summary}. Bitte bestätigen.`,
|
...buildAssistantChatResponse({
|
||||||
role: "assistant" as const,
|
content: `${ASSISTANT_CONFIRMATION_PREFIX} ${approval.summary}. Bitte bestätigen.`,
|
||||||
approval: toApprovalPayload(approval, "pending"),
|
approval: toApprovalPayload(approval, "pending"),
|
||||||
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
insights: collectedInsights,
|
||||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
actions: collectedActions,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,19 +389,21 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: finalContent,
|
...buildAssistantChatResponse({
|
||||||
role: "assistant" as const,
|
content: finalContent,
|
||||||
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
insights: collectedInsights,
|
||||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
actions: collectedActions,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exceeded max iterations
|
// Exceeded max iterations
|
||||||
return {
|
return {
|
||||||
content: "I had to stop after too many tool calls. Please try a simpler question.",
|
...buildAssistantChatResponse({
|
||||||
role: "assistant" as const,
|
content: "I had to stop after too many tool calls. Please try a simpler question.",
|
||||||
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
insights: collectedInsights,
|
||||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
actions: collectedActions,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user