refactor(api): modularize assistant router workflow

This commit is contained in:
2026-03-31 10:30:28 +02:00
parent 45c90438ba
commit f08b47171c
13 changed files with 2186 additions and 1875 deletions
+47 -874
View File
@@ -5,126 +5,63 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db";
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 { ADVANCED_ASSISTANT_TOOLS, MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import {
AssistantApprovalStorageUnavailableError,
createPendingAssistantApproval,
clearPendingAssistantApproval,
consumePendingAssistantApproval,
listPendingAssistantApprovals,
peekPendingAssistantApproval,
toApprovalPayload,
type PendingAssistantApproval,
} from "./assistant-approvals.js";
import {
ASSISTANT_CONFIRMATION_PREFIX,
canExecuteMutationTool,
isCancellationReply,
parseToolArguments,
type ChatMessage,
} from "./assistant-confirmation.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";
import { createAuditEntry } from "../lib/audit.js";
import { logger } from "../lib/logger.js";
export {
AssistantApprovalStorageUnavailableError,
createPendingAssistantApproval,
clearPendingAssistantApproval,
consumePendingAssistantApproval,
listPendingAssistantApprovals,
peekPendingAssistantApproval,
resetAssistantApprovalStorageWarningStateForTests,
toApprovalPayload,
type AssistantApprovalPayload,
type PendingAssistantApproval,
} from "./assistant-approvals.js";
export {
ASSISTANT_CONFIRMATION_PREFIX,
buildApprovalSummary,
canExecuteMutationTool,
formatApprovalValue,
hasPendingAssistantConfirmation,
isAffirmativeConfirmationReply,
isCancellationReply,
parseToolArguments,
type ChatMessage,
} from "./assistant-confirmation.js";
export { getAvailableAssistantTools } from "./assistant-tool-policy.js";
export { selectAssistantToolsForRequest } from "./assistant-tool-selection.js";
const MAX_TOOL_ITERATIONS = 8;
const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:";
const ASSISTANT_APPROVALS_TABLE_NAME = "public.assistant_approvals";
const MAX_OPENAI_TOOL_DEFINITIONS = 128;
let hasLoggedAssistantApprovalStorageUnavailable = false;
const ALWAYS_INCLUDED_TOOL_NAMES = new Set([
"get_current_user",
"get_resource",
"search_projects",
"get_project",
"list_allocations",
"get_statistics",
"navigate_to_page",
]);
const MUTATION_INTENT_KEYWORDS = [
"create", "add", "new", "update", "change", "edit", "delete", "remove", "cancel", "approve", "reject",
"anlegen", "erstellen", "neu", "aendern", "ändern", "bearbeiten", "loeschen", "löschen", "entfernen",
"stornieren", "genehmigen", "ablehnen", "setzen",
];
const TOOL_SELECTION_HINTS = [
{
keywords: ["holiday", "holidays", "feiertag", "feiertage", "vacation", "vacations", "urlaub", "ferien", "abwesen"],
nameFragments: ["holiday", "vacation", "entitlement"],
exactTools: ["list_holidays_by_region", "get_resource_holidays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"],
},
{
keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"],
nameFragments: ["resource", "skill", "role", "user", "staffing", "capacity"],
exactTools: ["search_resources", "get_resource", "search_by_skill", "check_resource_availability", "get_staffing_suggestions", "find_capacity"],
},
{
keywords: ["capacity", "availability", "available", "kapazitaet", "kapazität", "verfuegbar", "verfügbar", "auslastung", "chargeability", "sah", "lcr"],
nameFragments: ["capacity", "availability", "chargeability", "staffing", "rate", "budget"],
exactTools: ["check_resource_availability", "get_staffing_suggestions", "find_capacity", "get_chargeability", "find_best_project_resource", "resolve_rate"],
},
{
keywords: ["project", "projects", "projekt", "projekte", "allocation", "allocations", "allokation", "allokationen", "assignment", "assignments", "demand", "demands", "timeline"],
nameFragments: ["project", "allocation", "demand", "timeline", "assignment", "blueprint"],
exactTools: ["search_projects", "get_project", "list_allocations", "list_demands", "get_timeline_entries_view", "get_project_timeline_context"],
},
{
keywords: ["dashboard", "widget", "widgets", "peak", "forecast", "insight", "insights", "anomaly", "anomalies", "report", "reports", "analyse", "analysis", "bericht"],
nameFragments: ["dashboard", "statistics", "report", "insight", "anomal", "health", "forecast", "skill"],
exactTools: ["get_statistics", "get_dashboard_detail", "detect_anomalies", "get_skill_gaps", "get_project_health", "get_budget_forecast", "get_insights_summary", "run_report"],
},
{
keywords: ["estimate", "estimates", "angebot", "angebote", "budget", "budgets", "cost", "costs", "kosten", "rate", "rates", "preis", "preise"],
nameFragments: ["estimate", "budget", "rate", "cost"],
exactTools: ["get_budget_status", "list_rate_cards", "resolve_rate", "lookup_rate", "search_estimates", "get_estimate_detail"],
},
{
keywords: ["notification", "notifications", "benachrichtigung", "benachrichtigungen", "task", "tasks", "aufgabe", "aufgaben", "reminder", "reminders", "broadcast"],
nameFragments: ["notification", "task", "reminder", "broadcast"],
exactTools: ["list_notifications", "get_unread_notification_count", "list_tasks", "get_task_counts", "list_reminders", "get_broadcast_detail"],
},
{
keywords: ["country", "countries", "land", "laender", "länder", "city", "cities", "stadt", "staedte", "städte", "region", "regions", "state", "bundesland"],
nameFragments: ["country", "metro_city", "holiday_calendar"],
exactTools: ["list_countries", "get_country", "list_holidays_by_region", "list_holiday_calendars"],
},
{
keywords: ["user", "users", "permission", "permissions", "rolle", "rollen", "admin", "system", "webhook", "import", "audit", "history", "rechte"],
nameFragments: ["user", "permission", "role", "system", "webhook", "import", "audit", "history", "org_unit", "country"],
exactTools: ["list_users", "get_effective_user_permissions", "list_audit_log_entries", "query_change_history", "get_system_settings", "list_webhooks"],
},
];
const TOOL_SELECTION_STOP_WORDS = new Set([
"the", "and", "for", "with", "from", "that", "this", "what", "when", "where", "who", "how",
"und", "der", "die", "das", "ein", "eine", "einer", "einem", "einen", "mit", "von", "fuer", "für",
"auf", "ist", "sind", "im", "in", "am", "an", "zu", "zum", "zur", "mir", "bitte", "can", "you",
"mir", "alle", "all", "den", "dem", "des",
]);
type ChatMessage = { role: "user" | "assistant"; content: string };
type AssistantApprovalStore = Pick<PrismaClient, "assistantApproval">;
class AssistantApprovalStorageUnavailableError extends Error {
constructor() {
super("Assistant approval storage is unavailable.");
this.name = "AssistantApprovalStorageUnavailableError";
}
}
export interface PendingAssistantApproval {
id: string;
userId: string;
conversationId: string;
toolName: string;
toolArguments: string;
summary: string;
createdAt: number;
expiresAt: number;
}
export interface AssistantApprovalPayload {
id: string;
status: "pending" | "approved" | "cancelled";
conversationId: string;
toolName: string;
summary: string;
createdAt: string;
expiresAt: string;
}
const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
@@ -168,348 +105,6 @@ Datenmodell:
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
`;
/** Map tool names to the permission required to use them */
const TOOL_PERMISSION_MAP: Record<string, string> = {
// Resource management
update_resource: "manageResources",
create_resource: "manageResources",
deactivate_resource: "manageResources",
create_role: PermissionKey.MANAGE_ROLES,
update_role: PermissionKey.MANAGE_ROLES,
delete_role: PermissionKey.MANAGE_ROLES,
// Project management
update_project: "manageProjects",
create_project: "manageProjects",
delete_project: "manageProjects",
create_estimate: "manageProjects",
clone_estimate: "manageProjects",
update_estimate_draft: "manageProjects",
submit_estimate_version: "manageProjects",
approve_estimate_version: "manageProjects",
create_estimate_revision: "manageProjects",
create_estimate_export: "manageProjects",
generate_estimate_weekly_phasing: "manageProjects",
update_estimate_commercial_terms: "manageProjects",
generate_project_cover: "manageProjects",
remove_project_cover: "manageProjects",
import_csv_data: PermissionKey.IMPORT_DATA,
// Allocation management
create_allocation: "manageAllocations",
cancel_allocation: "manageAllocations",
update_allocation_status: "manageAllocations",
update_timeline_allocation_inline: "manageAllocations",
apply_timeline_project_shift: "manageAllocations",
quick_assign_timeline_resource: "manageAllocations",
batch_quick_assign_timeline_resources: "manageAllocations",
batch_shift_timeline_allocations: "manageAllocations",
create_demand: "manageAllocations",
fill_demand: "manageAllocations",
create_estimate_planning_handoff: "manageAllocations",
// Vacation management
// Task management
execute_task_action: "manageAllocations",
};
/** Tools that require cost visibility */
const COST_TOOLS = new Set([
"get_budget_status",
"get_chargeability",
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
"resolve_rate",
"list_rate_cards",
"get_estimate_detail",
"get_estimate_version_snapshot",
"find_best_project_resource",
"get_staffing_suggestions",
]);
/** Tools that follow planningReadProcedure access rules in the main API. */
const PLANNING_READ_TOOLS = new Set([
"list_allocations",
"list_demands",
"list_blueprints",
"get_blueprint",
"list_clients",
"list_roles",
"list_management_levels",
"list_utilization_categories",
"check_resource_availability",
"get_staffing_suggestions",
"find_capacity",
"find_best_project_resource",
]);
/** Tools that require broad people-directory visibility because the backing routes expose resource-linked counts. */
const RESOURCE_OVERVIEW_TOOLS = new Set([
"search_resources",
"get_country",
"list_org_units",
]);
/** Tools that follow controllerProcedure access rules in the main API. */
const CONTROLLER_ONLY_TOOLS = new Set([
"search_by_skill",
"list_comments",
"create_comment",
"resolve_comment",
"search_projects",
"get_project",
"search_estimates",
"get_timeline_entries_view",
"get_timeline_holiday_overlays",
"get_project_timeline_context",
"preview_project_shift",
"get_statistics",
"get_dashboard_detail",
"get_skill_gaps",
"get_project_health",
"get_budget_forecast",
"query_change_history",
"get_entity_timeline",
"export_resources_csv",
"export_projects_csv",
"list_audit_log_entries",
"get_audit_log_entry",
"get_audit_log_timeline",
"get_audit_activity_summary",
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
"get_estimate_detail",
"list_estimate_versions",
"get_estimate_version_snapshot",
"get_estimate_weekly_phasing",
"get_estimate_commercial_terms",
]);
/** Tools that follow managerProcedure access rules in the main API. */
const MANAGER_ONLY_TOOLS = new Set([
"import_csv_data",
"list_assignable_users",
"create_notification",
"update_timeline_allocation_inline",
"apply_timeline_project_shift",
"quick_assign_timeline_resource",
"batch_quick_assign_timeline_resources",
"batch_shift_timeline_allocations",
"create_estimate",
"clone_estimate",
"update_estimate_draft",
"submit_estimate_version",
"approve_estimate_version",
"create_estimate_revision",
"create_estimate_export",
"create_estimate_planning_handoff",
"generate_estimate_weekly_phasing",
"update_estimate_commercial_terms",
"create_task_for_user",
"assign_task",
"send_broadcast",
"list_broadcasts",
"get_broadcast_detail",
"approve_vacation",
"reject_vacation",
"get_pending_vacation_approvals",
"get_entitlement_summary",
"set_entitlement",
"create_role",
"update_role",
"delete_role",
"create_client",
"update_client",
]);
/** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */
const ADMIN_ONLY_TOOLS = new Set([
"list_users",
"get_active_user_count",
"create_user",
"set_user_password",
"update_user_role",
"update_user_name",
"link_user_resource",
"auto_link_users_by_email",
"set_user_permissions",
"reset_user_permissions",
"get_effective_user_permissions",
"disable_user_totp",
"list_dispo_import_batches",
"get_dispo_import_batch",
"stage_dispo_import_batch",
"validate_dispo_import_batch",
"cancel_dispo_import_batch",
"list_dispo_staged_resources",
"list_dispo_staged_projects",
"list_dispo_staged_assignments",
"list_dispo_staged_vacations",
"list_dispo_staged_unresolved_records",
"resolve_dispo_staged_record",
"commit_dispo_import_batch",
"get_system_settings",
"update_system_settings",
"clear_stored_runtime_secrets",
"get_ai_configured",
"test_ai_connection",
"test_smtp_connection",
"test_gemini_connection",
"list_system_role_configs",
"update_system_role_config",
"list_webhooks",
"get_webhook",
"create_webhook",
"update_webhook",
"delete_webhook",
"test_webhook",
"create_org_unit",
"update_org_unit",
"create_country",
"update_country",
"create_metro_city",
"update_metro_city",
"delete_metro_city",
"list_holiday_calendars",
"get_holiday_calendar",
"create_holiday_calendar",
"update_holiday_calendar",
"delete_holiday_calendar",
"create_holiday_calendar_entry",
"update_holiday_calendar_entry",
"delete_holiday_calendar_entry",
]);
export function getAvailableAssistantTools(permissions: Set<PermissionKey>, userRole: string) {
return TOOL_DEFINITIONS.filter((tool) => {
const toolName = tool.function.name;
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
const hasResourceOverviewAccess = permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
const hasControllerAccess = userRole === SystemRole.ADMIN
|| userRole === SystemRole.MANAGER
|| userRole === SystemRole.CONTROLLER;
const hasManagerAccess = userRole === SystemRole.ADMIN
|| userRole === SystemRole.MANAGER;
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
return false;
}
if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== "ADMIN") {
return false;
}
if (MANAGER_ONLY_TOOLS.has(toolName) && !hasManagerAccess) {
return false;
}
if (RESOURCE_OVERVIEW_TOOLS.has(toolName) && !hasResourceOverviewAccess) {
return false;
}
if (CONTROLLER_ONLY_TOOLS.has(toolName) && !hasControllerAccess) {
return false;
}
if (PLANNING_READ_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_PLANNING)) {
return false;
}
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
return false;
}
if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
return false;
}
return true;
});
}
function normalizeAssistantText(input: string): string {
return input
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, " ")
.replace(/[^a-z0-9_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAssistantIntent(input: string): string[] {
return normalizeAssistantText(input)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !TOOL_SELECTION_STOP_WORDS.has(token));
}
export function selectAssistantToolsForRequest(
availableTools: typeof TOOL_DEFINITIONS,
messages: ChatMessage[],
pageContext?: string,
) {
if (availableTools.length <= MAX_OPENAI_TOOL_DEFINITIONS) {
return availableTools;
}
const recentUserText = messages
.filter((message) => message.role === "user")
.slice(-4)
.map((message) => message.content)
.join(" ");
const intentText = [recentUserText, pageContext ?? ""].filter(Boolean).join(" ");
const normalizedIntent = normalizeAssistantText(intentText);
const intentTokens = tokenizeAssistantIntent(intentText);
const mutationIntent = MUTATION_INTENT_KEYWORDS.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
const selectedHintTools = new Set<string>();
for (const hint of TOOL_SELECTION_HINTS) {
const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
if (!matchedKeyword) continue;
for (const toolName of hint.exactTools) {
selectedHintTools.add(toolName);
}
}
const scoredTools = availableTools
.map((tool, index) => {
const name = tool.function.name;
const normalizedName = normalizeAssistantText(name.replace(/_/g, " "));
const normalizedDescription = normalizeAssistantText(tool.function.description);
let score = 0;
if (ALWAYS_INCLUDED_TOOL_NAMES.has(name)) score += 1000;
if (selectedHintTools.has(name)) score += 400;
for (const hint of TOOL_SELECTION_HINTS) {
const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
if (!matchedKeyword) continue;
if (hint.exactTools.includes(name)) score += 160;
if (hint.nameFragments.some((fragment) => name.includes(fragment))) score += 120;
if (hint.nameFragments.some((fragment) => normalizedDescription.includes(normalizeAssistantText(fragment)))) score += 40;
}
for (const token of intentTokens) {
if (normalizedName.includes(token)) score += 45;
if (normalizedDescription.includes(token)) score += 10;
}
if (name.startsWith("search_")) score += 18;
if (name.startsWith("get_")) score += 12;
if (name.startsWith("list_")) score += 10;
if (MUTATION_TOOLS.has(name)) {
score += mutationIntent ? 40 : -30;
} else {
score += 8;
}
return { tool, index, score };
})
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.index - right.index;
});
return scoredTools
.slice(0, MAX_OPENAI_TOOL_DEFINITIONS)
.map((entry) => entry.tool);
}
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) {
@@ -520,428 +115,6 @@ function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): As
return [...existing, next].slice(-6);
}
function parseToolArguments(args: string): Record<string, unknown> {
try {
const parsed = JSON.parse(args) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
}
function formatApprovalValue(value: unknown): string {
if (typeof value === "string") {
return value.length > 48 ? `${value.slice(0, 45)}...` : value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
if (value.length === 0) return "[]";
return `[${value.slice(0, 3).map((item) => formatApprovalValue(item)).join(", ")}${value.length > 3 ? ", ..." : ""}]`;
}
if (value && typeof value === "object") {
return "{...}";
}
return "null";
}
function buildApprovalSummary(toolName: string, toolArguments: string): string {
const params = parseToolArguments(toolArguments);
const details = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== "")
.slice(0, 4)
.map(([key, value]) => `${key}=${formatApprovalValue(value)}`)
.join(", ");
const action = toolName.replace(/_/g, " ");
return details ? `${action} (${details})` : action;
}
function mapPendingApproval(record: {
id: string;
userId: string;
conversationId: string;
toolName: string;
toolArguments: string;
summary: string;
createdAt: Date;
expiresAt: Date;
}): PendingAssistantApproval {
return {
id: record.id,
userId: record.userId,
conversationId: record.conversationId,
toolName: record.toolName,
toolArguments: record.toolArguments,
summary: record.summary,
createdAt: record.createdAt.getTime(),
expiresAt: record.expiresAt.getTime(),
};
}
function toApprovalPayload(
approval: PendingAssistantApproval,
status: AssistantApprovalPayload["status"],
): AssistantApprovalPayload {
return {
id: approval.id,
status,
conversationId: approval.conversationId,
toolName: approval.toolName,
summary: approval.summary,
createdAt: new Date(approval.createdAt).toISOString(),
expiresAt: new Date(approval.expiresAt).toISOString(),
};
}
function isAssistantApprovalTableMissingError(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code !== "P2021") return false;
const table = typeof error.meta?.table === "string" ? error.meta.table : "";
return table.includes("assistant_approvals") || error.message.includes("assistant_approvals");
}
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const candidate = error as {
code?: unknown;
message?: unknown;
meta?: {
table?: unknown;
};
};
const code = typeof candidate.code === "string" ? candidate.code : "";
if (code !== "P2021") return false;
const message = typeof candidate.message === "string"
? candidate.message
: "";
const metaTable = typeof candidate.meta?.table === "string"
? candidate.meta.table
: "";
return metaTable.includes("assistant_approvals") || message.includes("assistant_approvals");
}
function logAssistantApprovalStorageUnavailable(error: unknown) {
if (hasLoggedAssistantApprovalStorageUnavailable) {
return;
}
hasLoggedAssistantApprovalStorageUnavailable = true;
logger.warn(
{
err: error,
table: ASSISTANT_APPROVALS_TABLE_NAME,
},
"Assistant approval storage is unavailable",
);
}
async function withAssistantApprovalFallback<T>(
operation: () => Promise<T>,
fallback: () => T,
): Promise<T> {
try {
return await operation();
} catch (error) {
if (!isAssistantApprovalTableMissingError(error)) throw error;
logAssistantApprovalStorageUnavailable(error);
return fallback();
}
}
export function resetAssistantApprovalStorageWarningStateForTests(): void {
hasLoggedAssistantApprovalStorageUnavailable = false;
}
export async function listPendingAssistantApprovals(
db: AssistantApprovalStore,
userId: string,
): Promise<PendingAssistantApproval[]> {
return withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const approvals = await db.assistantApproval.findMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
return approvals.map(mapPendingApproval);
}, () => []);
}
export async function clearPendingAssistantApproval(
db: AssistantApprovalStore,
userId: string,
conversationId: string,
): Promise<void> {
await withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
data: {
status: AssistantApprovalStatus.CANCELLED,
cancelledAt: new Date(),
},
});
}, () => undefined);
}
export async function peekPendingAssistantApproval(
db: AssistantApprovalStore,
userId: string,
conversationId: string,
): Promise<PendingAssistantApproval | null> {
return withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const pending = await db.assistantApproval.findFirst({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
if (!pending) return null;
return mapPendingApproval(pending);
}, () => null);
}
export async function consumePendingAssistantApproval(
db: AssistantApprovalStore,
userId: string,
conversationId: string,
): Promise<PendingAssistantApproval | null> {
const pending = await peekPendingAssistantApproval(db, userId, conversationId);
if (!pending) return null;
const approvedAt = new Date();
const updateResult = await db.assistantApproval.updateMany({
where: {
id: pending.id,
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: approvedAt },
},
data: {
status: AssistantApprovalStatus.APPROVED,
approvedAt,
},
});
if (updateResult.count === 0) return null;
const approved = await db.assistantApproval.findFirst({
where: {
id: pending.id,
userId,
conversationId,
},
});
if (!approved) return null;
return mapPendingApproval(approved);
}
export async function createPendingAssistantApproval(
db: AssistantApprovalStore,
userId: string,
conversationId: string,
toolName: string,
toolArguments: string,
options?: { summary?: string; ttlMs?: number },
): Promise<PendingAssistantApproval> {
const now = new Date();
const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS));
const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments);
try {
await clearPendingAssistantApproval(db, userId, conversationId);
const pendingApproval = await db.assistantApproval.create({
data: {
userId,
conversationId,
toolName,
toolArguments,
summary,
createdAt: now,
expiresAt,
},
});
return mapPendingApproval(pendingApproval);
} catch (error) {
if (!isAssistantApprovalTableMissingError(error)) throw error;
logAssistantApprovalStorageUnavailable(error);
throw new AssistantApprovalStorageUnavailableError();
}
}
function isAffirmativeConfirmationReply(content: string): boolean {
const normalized = content.trim().toLowerCase();
if (!normalized) return false;
const exactMatches = new Set([
"ja",
"yes",
"y",
"ok",
"okay",
"okey",
"mach das",
"bitte machen",
"bitte ausführen",
"bitte ausfuehren",
"ausführen",
"ausfuehren",
"bestätigt",
"bestaetigt",
"bestätigen",
"bestaetigen",
"confirm",
"confirmed",
"do it",
"go ahead",
"proceed",
]);
if (exactMatches.has(normalized)) return true;
const affirmativePatterns = [
/^(ja|yes|ok|okay)\b/,
/\b(mach|make|do|führ|fuehr|execute|run)\b.*\b(das|it|bitte|jetzt)\b/,
/\b(bit(?:te)?|please)\b.*\b(ausführen|ausfuehren|execute|run|machen|do)\b/,
/\b(bestätig|bestaetig|confirm)\w*\b/,
/\b(go ahead|proceed)\b/,
];
return affirmativePatterns.some((pattern) => pattern.test(normalized));
}
function isCancellationReply(content: string): boolean {
const normalized = content.trim().toLowerCase();
if (!normalized) return false;
const exactMatches = new Set([
"nein",
"no",
"abbrechen",
"cancel",
"stopp",
"stop",
"doch nicht",
"nicht ausführen",
"nicht ausfuehren",
]);
if (exactMatches.has(normalized)) return true;
return [
/\b(nein|no|cancel|abbrechen|stop|stopp)\b/,
/\b(doch nicht|nicht ausführen|nicht ausfuehren)\b/,
].some((pattern) => pattern.test(normalized));
}
function hasPendingAssistantConfirmation(messages: ChatMessage[]): boolean {
if (messages.length < 2) return false;
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role !== "user") return false;
for (let index = messages.length - 2; index >= 0; index -= 1) {
const message = messages[index];
if (!message) continue;
if (message.role === "assistant") {
return message.content.trimStart().startsWith(ASSISTANT_CONFIRMATION_PREFIX);
}
}
return false;
}
export function canExecuteMutationTool(
messages: ChatMessage[],
toolName: string,
pendingApproval?: PendingAssistantApproval | null,
): boolean {
if (!MUTATION_TOOLS.has(toolName)) return true;
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role !== "user") return false;
if (!isAffirmativeConfirmationReply(lastMessage.content)) return false;
if (pendingApproval) {
return pendingApproval.toolName === toolName && pendingApproval.expiresAt > Date.now();
}
return hasPendingAssistantConfirmation(messages);
}
function readToolError(result: Awaited<ReturnType<typeof executeTool>>): string | null {
if (result.data && typeof result.data === "object" && result.data !== null && "error" in (result.data as Record<string, unknown>)) {
const error = (result.data as Record<string, unknown>).error;
return typeof error === "string" ? error : null;
}
try {
const parsed = JSON.parse(result.content) as unknown;
if (parsed && typeof parsed === "object" && "error" in (parsed as Record<string, unknown>)) {
const error = (parsed as Record<string, unknown>).error;
return typeof error === "string" ? error : null;
}
} catch {
// tool content may be plain text
}
return null;
}
function readToolSuccessMessage(result: Awaited<ReturnType<typeof executeTool>>): string | null {
if (result.data && typeof result.data === "object" && result.data !== null) {
const data = result.data as Record<string, unknown>;
if (typeof data.message === "string" && data.message.trim().length > 0) return data.message;
if (typeof data.description === "string" && data.description.trim().length > 0) return data.description;
}
try {
const parsed = JSON.parse(result.content) as unknown;
if (parsed && typeof parsed === "object") {
const content = parsed as Record<string, unknown>;
if (typeof content.message === "string" && content.message.trim().length > 0) return content.message;
if (typeof content.description === "string" && content.description.trim().length > 0) return content.description;
}
} catch {
// tool content may be plain text
}
return typeof result.content === "string" && result.content.trim().length > 0
? result.content
: null;
}
export const assistantRouter = createTRPCRouter({
listPendingApprovals: protectedProcedure
.query(async ({ ctx }) => {