feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { AssistantApprovalStatus, type PrismaClient } from "@capakraken/db";
|
||||
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";
|
||||
@@ -19,11 +19,92 @@ import { logger } from "../lib/logger.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;
|
||||
|
||||
const ALWAYS_INCLUDED_TOOL_NAMES = new Set([
|
||||
"get_current_user",
|
||||
"search_resources",
|
||||
"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;
|
||||
@@ -83,29 +164,32 @@ Datenmodell:
|
||||
- Projekte: ShortCode, Budget (Cent), Win-Probability, Status (DRAFT/ACTIVE/ON_HOLD/COMPLETED/CANCELLED)
|
||||
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
|
||||
- Chargeability = gebuchte/verfügbare Stunden × 100%
|
||||
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
|
||||
- Urlaub: Typen ANNUAL/SICK/OTHER/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED. PUBLIC_HOLIDAY wird nicht manuell beantragt, sondern über Feiertagskalender verwaltet.
|
||||
- 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> = {
|
||||
list_users: PermissionKey.MANAGE_USERS,
|
||||
// Resource management
|
||||
update_resource: "manageResources",
|
||||
create_resource: "manageResources",
|
||||
deactivate_resource: "manageResources",
|
||||
create_role: "manageResources",
|
||||
update_role: "manageResources",
|
||||
delete_role: "manageResources",
|
||||
create_org_unit: "manageResources",
|
||||
update_org_unit: "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_client: "manageProjects",
|
||||
update_client: "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,
|
||||
@@ -120,15 +204,9 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
batch_shift_timeline_allocations: "manageAllocations",
|
||||
create_demand: "manageAllocations",
|
||||
fill_demand: "manageAllocations",
|
||||
create_estimate_planning_handoff: "manageAllocations",
|
||||
// Vacation management
|
||||
create_vacation: "manageVacations",
|
||||
approve_vacation: "manageVacations",
|
||||
reject_vacation: "manageVacations",
|
||||
cancel_vacation: "manageVacations",
|
||||
set_entitlement: "manageVacations",
|
||||
// Task management
|
||||
create_task_for_user: "manageProjects",
|
||||
send_broadcast: "manageProjects",
|
||||
execute_task_action: "manageAllocations",
|
||||
};
|
||||
|
||||
@@ -142,6 +220,7 @@ const COST_TOOLS = new Set([
|
||||
"resolve_rate",
|
||||
"list_rate_cards",
|
||||
"get_estimate_detail",
|
||||
"get_estimate_version_snapshot",
|
||||
"find_best_project_resource",
|
||||
]);
|
||||
|
||||
@@ -158,22 +237,90 @@ const CONTROLLER_ONLY_TOOLS = new Set([
|
||||
"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",
|
||||
"test_ai_connection",
|
||||
"test_smtp_connection",
|
||||
"test_gemini_connection",
|
||||
"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",
|
||||
@@ -220,6 +367,96 @@ export function getAvailableAssistantTools(permissions: Set<PermissionKey>, user
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -307,31 +544,87 @@ function toApprovalPayload(
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
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 async function listPendingAssistantApprovals(
|
||||
db: AssistantApprovalStore,
|
||||
userId: string,
|
||||
): Promise<PendingAssistantApproval[]> {
|
||||
await db.assistantApproval.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
status: AssistantApprovalStatus.PENDING,
|
||||
expiresAt: { lte: new Date() },
|
||||
},
|
||||
data: {
|
||||
status: AssistantApprovalStatus.EXPIRED,
|
||||
},
|
||||
});
|
||||
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" },
|
||||
});
|
||||
const approvals = await db.assistantApproval.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: AssistantApprovalStatus.PENDING,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return approvals.map(mapPendingApproval);
|
||||
return approvals.map(mapPendingApproval);
|
||||
}, () => []);
|
||||
}
|
||||
|
||||
export async function clearPendingAssistantApproval(
|
||||
@@ -339,17 +632,19 @@ export async function clearPendingAssistantApproval(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
): Promise<void> {
|
||||
await db.assistantApproval.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
conversationId,
|
||||
status: AssistantApprovalStatus.PENDING,
|
||||
},
|
||||
data: {
|
||||
status: AssistantApprovalStatus.CANCELLED,
|
||||
cancelledAt: new Date(),
|
||||
},
|
||||
});
|
||||
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(
|
||||
@@ -357,28 +652,30 @@ export async function peekPendingAssistantApproval(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
): Promise<PendingAssistantApproval | null> {
|
||||
await db.assistantApproval.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
conversationId,
|
||||
status: AssistantApprovalStatus.PENDING,
|
||||
expiresAt: { lte: new Date() },
|
||||
},
|
||||
data: {
|
||||
status: AssistantApprovalStatus.EXPIRED,
|
||||
},
|
||||
});
|
||||
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);
|
||||
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(
|
||||
@@ -426,19 +723,25 @@ export async function createPendingAssistantApproval(
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS));
|
||||
const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments);
|
||||
await clearPendingAssistantApproval(db, userId, conversationId);
|
||||
const pendingApproval = await db.assistantApproval.create({
|
||||
data: {
|
||||
userId,
|
||||
conversationId,
|
||||
toolName,
|
||||
toolArguments,
|
||||
summary,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
return mapPendingApproval(pendingApproval);
|
||||
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 {
|
||||
@@ -669,7 +972,11 @@ export const assistantRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// 4. Filter tools based on granular permissions
|
||||
const availableTools = getAvailableAssistantTools(permissions, userRole);
|
||||
const availableTools = selectAssistantToolsForRequest(
|
||||
getAvailableAssistantTools(permissions, userRole),
|
||||
input.messages,
|
||||
input.pageContext,
|
||||
);
|
||||
|
||||
// 5. Function calling loop
|
||||
const toolCtx: ToolContext = {
|
||||
@@ -799,13 +1106,26 @@ export const assistantRouter = createTRPCRouter({
|
||||
// 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)) {
|
||||
const approval = await createPendingAssistantApproval(
|
||||
ctx.db,
|
||||
userId,
|
||||
conversationId,
|
||||
toolCall.function.name,
|
||||
toolCall.function.arguments,
|
||||
);
|
||||
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 {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
|
||||
Reference in New Issue
Block a user