refactor(api): modularize assistant router workflow
This commit is contained in:
@@ -0,0 +1,166 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
export const TEST_USER_ID = "assistant-test-user";
|
||||||
|
export const TEST_CONVERSATION_ID = "assistant-test-conversation";
|
||||||
|
|
||||||
|
export function createApprovalStoreMock() {
|
||||||
|
const records = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
conversationId: string;
|
||||||
|
toolName: string;
|
||||||
|
toolArguments: string;
|
||||||
|
summary: string;
|
||||||
|
status: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||||
|
approvedAt: Date | null;
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
assistantApproval: {
|
||||||
|
findFirst: vi.fn(async ({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
}: {
|
||||||
|
where: {
|
||||||
|
id?: string;
|
||||||
|
userId?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||||
|
};
|
||||||
|
orderBy?: { createdAt: "desc" | "asc" };
|
||||||
|
}) => {
|
||||||
|
const matches = [...records.values()]
|
||||||
|
.filter((record) => (
|
||||||
|
(!where.id || record.id === where.id)
|
||||||
|
&& (!where.userId || record.userId === where.userId)
|
||||||
|
&& (!where.conversationId || record.conversationId === where.conversationId)
|
||||||
|
&& (!where.status || record.status === where.status)
|
||||||
|
))
|
||||||
|
.sort((a, b) => (
|
||||||
|
orderBy?.createdAt === "asc"
|
||||||
|
? a.createdAt.getTime() - b.createdAt.getTime()
|
||||||
|
: b.createdAt.getTime() - a.createdAt.getTime()
|
||||||
|
));
|
||||||
|
return matches[0] ?? null;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async ({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
}: {
|
||||||
|
where: {
|
||||||
|
userId?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||||
|
expiresAt?: { lte?: Date; gt?: Date };
|
||||||
|
};
|
||||||
|
orderBy?: { createdAt: "desc" | "asc" };
|
||||||
|
}) => (
|
||||||
|
[...records.values()]
|
||||||
|
.filter((record) => (
|
||||||
|
(!where.userId || record.userId === where.userId)
|
||||||
|
&& (!where.conversationId || record.conversationId === where.conversationId)
|
||||||
|
&& (!where.status || record.status === where.status)
|
||||||
|
&& (!where.expiresAt?.lte || record.expiresAt <= where.expiresAt.lte)
|
||||||
|
&& (!where.expiresAt?.gt || record.expiresAt > where.expiresAt.gt)
|
||||||
|
))
|
||||||
|
.sort((a, b) => (
|
||||||
|
orderBy?.createdAt === "asc"
|
||||||
|
? a.createdAt.getTime() - b.createdAt.getTime()
|
||||||
|
: b.createdAt.getTime() - a.createdAt.getTime()
|
||||||
|
))
|
||||||
|
)),
|
||||||
|
create: vi.fn(async ({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
conversationId: string;
|
||||||
|
toolName: string;
|
||||||
|
toolArguments: string;
|
||||||
|
summary: string;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const record = {
|
||||||
|
id: `approval-${records.size + 1}`,
|
||||||
|
...data,
|
||||||
|
status: "PENDING" as const,
|
||||||
|
approvedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
updatedAt: data.createdAt,
|
||||||
|
};
|
||||||
|
records.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
updateMany: vi.fn(async ({
|
||||||
|
where,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
where: {
|
||||||
|
id?: string;
|
||||||
|
userId?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||||
|
expiresAt?: { lte?: Date; gt?: Date };
|
||||||
|
};
|
||||||
|
data: Partial<{
|
||||||
|
status: "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||||
|
cancelledAt: Date;
|
||||||
|
approvedAt: Date;
|
||||||
|
}>;
|
||||||
|
}) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const [id, record] of records.entries()) {
|
||||||
|
if (where.id && record.id !== where.id) continue;
|
||||||
|
if (where.userId && record.userId !== where.userId) continue;
|
||||||
|
if (where.conversationId && record.conversationId !== where.conversationId) continue;
|
||||||
|
if (where.status && record.status !== where.status) continue;
|
||||||
|
if (where.expiresAt?.lte && record.expiresAt > where.expiresAt.lte) continue;
|
||||||
|
if (where.expiresAt?.gt && record.expiresAt <= where.expiresAt.gt) continue;
|
||||||
|
records.set(id, {
|
||||||
|
...record,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return { count };
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({
|
||||||
|
where,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
where: { id: string };
|
||||||
|
data: {
|
||||||
|
status: "APPROVED";
|
||||||
|
approvedAt: Date;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const record = records.get(where.id);
|
||||||
|
if (!record) throw new Error("Record not found");
|
||||||
|
const next = {
|
||||||
|
...record,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
records.set(where.id, next);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMissingApprovalTableError() {
|
||||||
|
return Object.assign(
|
||||||
|
new Error("The table `public.assistant_approvals` does not exist in the current database."),
|
||||||
|
{
|
||||||
|
code: "P2021",
|
||||||
|
meta: { table: "public.assistant_approvals" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
AssistantApprovalStorageUnavailableError,
|
||||||
|
clearPendingAssistantApproval,
|
||||||
|
consumePendingAssistantApproval,
|
||||||
|
createPendingAssistantApproval,
|
||||||
|
listPendingAssistantApprovals,
|
||||||
|
peekPendingAssistantApproval,
|
||||||
|
resetAssistantApprovalStorageWarningStateForTests,
|
||||||
|
} from "../router/assistant-approvals.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
import {
|
||||||
|
createApprovalStoreMock,
|
||||||
|
createMissingApprovalTableError,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
TEST_USER_ID,
|
||||||
|
} from "./assistant-approval-test-helpers.js";
|
||||||
|
|
||||||
|
describe("assistant approvals", () => {
|
||||||
|
let approvalStore = createApprovalStoreMock();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
approvalStore = createApprovalStoreMock();
|
||||||
|
resetAssistantApprovalStorageWarningStateForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores and consumes pending approvals independently from chat text", async () => {
|
||||||
|
const approval = await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Gelddruckmaschine", status: "DRAFT" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
|
||||||
|
id: approval.id,
|
||||||
|
toolName: "create_project",
|
||||||
|
summary: expect.stringContaining("create project"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(consumePendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
|
||||||
|
id: approval.id,
|
||||||
|
toolName: "create_project",
|
||||||
|
});
|
||||||
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires stale pending approvals", async () => {
|
||||||
|
await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Apollo" }),
|
||||||
|
{ ttlMs: -1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears pending approvals for cancellation semantics", async () => {
|
||||||
|
await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Apollo" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
|
||||||
|
|
||||||
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates pending approvals by conversation", async () => {
|
||||||
|
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
|
||||||
|
|
||||||
|
await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Apollo" }),
|
||||||
|
);
|
||||||
|
await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
otherConversationId,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Hermes" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
|
||||||
|
|
||||||
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||||
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, otherConversationId)).resolves.toMatchObject({
|
||||||
|
toolName: "create_project",
|
||||||
|
summary: expect.stringContaining("Hermes"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only still-pending approvals for the current user across conversations", async () => {
|
||||||
|
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
|
||||||
|
|
||||||
|
await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Apollo" }),
|
||||||
|
);
|
||||||
|
await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
otherConversationId,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Hermes" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelled = await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
`${TEST_CONVERSATION_ID}-cancelled`,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Cancelled" }),
|
||||||
|
);
|
||||||
|
await approvalStore.assistantApproval.updateMany({
|
||||||
|
where: { id: cancelled.id, userId: TEST_USER_ID, status: "PENDING" },
|
||||||
|
data: { status: "CANCELLED", cancelledAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
"other-user",
|
||||||
|
`${TEST_CONVERSATION_ID}-foreign`,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Foreign" }),
|
||||||
|
);
|
||||||
|
await createPendingAssistantApproval(
|
||||||
|
approvalStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
`${TEST_CONVERSATION_ID}-expired`,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Expired" }),
|
||||||
|
{ ttlMs: -1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const approvals = await listPendingAssistantApprovals(approvalStore, TEST_USER_ID);
|
||||||
|
const approvalSummaries = approvals.map((approval) => approval.summary).join(" ");
|
||||||
|
|
||||||
|
expect(approvals).toHaveLength(2);
|
||||||
|
expect([...approvals.map((approval) => approval.conversationId)].sort()).toEqual([
|
||||||
|
otherConversationId,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
].sort());
|
||||||
|
expect(approvals.every((approval) => approval.userId === TEST_USER_ID)).toBe(true);
|
||||||
|
expect(approvalSummaries).toContain("Apollo");
|
||||||
|
expect(approvalSummaries).toContain("Hermes");
|
||||||
|
expect(approvalSummaries).not.toContain("Cancelled");
|
||||||
|
expect(approvalSummaries).not.toContain("Expired");
|
||||||
|
expect(approvalSummaries).not.toContain("Foreign");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades approval reads gracefully when approval storage is missing", async () => {
|
||||||
|
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger);
|
||||||
|
const missingTableError = createMissingApprovalTableError();
|
||||||
|
const missingStore = {
|
||||||
|
assistantApproval: {
|
||||||
|
findFirst: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
create: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
updateMany: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]);
|
||||||
|
await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||||
|
await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||||
|
await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined();
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an explicit error when approval storage is missing for mutation confirmation", async () => {
|
||||||
|
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger);
|
||||||
|
const missingTableError = createMissingApprovalTableError();
|
||||||
|
const missingStore = {
|
||||||
|
assistantApproval: {
|
||||||
|
findFirst: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
create: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
updateMany: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(createPendingAssistantApproval(
|
||||||
|
missingStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Apollo" }),
|
||||||
|
)).rejects.toBeInstanceOf(AssistantApprovalStorageUnavailableError);
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ASSISTANT_CONFIRMATION_PREFIX,
|
||||||
|
buildApprovalSummary,
|
||||||
|
canExecuteMutationTool,
|
||||||
|
formatApprovalValue,
|
||||||
|
isAffirmativeConfirmationReply,
|
||||||
|
isCancellationReply,
|
||||||
|
parseToolArguments,
|
||||||
|
} from "../router/assistant-confirmation.js";
|
||||||
|
|
||||||
|
describe("assistant confirmation", () => {
|
||||||
|
it("parses tool arguments defensively", () => {
|
||||||
|
expect(parseToolArguments("{\"name\":\"Apollo\",\"status\":\"DRAFT\"}")).toEqual({
|
||||||
|
name: "Apollo",
|
||||||
|
status: "DRAFT",
|
||||||
|
});
|
||||||
|
expect(parseToolArguments("[]")).toEqual({});
|
||||||
|
expect(parseToolArguments("not-json")).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats approval values compactly", () => {
|
||||||
|
expect(formatApprovalValue("Apollo")).toBe("Apollo");
|
||||||
|
expect(formatApprovalValue("x".repeat(60))).toBe(`${"x".repeat(45)}...`);
|
||||||
|
expect(formatApprovalValue([1, "two", true, "four"])).toBe("[1, two, true, ...]");
|
||||||
|
expect(formatApprovalValue({ nested: true })).toBe("{...}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds compact approval summaries from tool calls", () => {
|
||||||
|
expect(buildApprovalSummary(
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Apollo", status: "DRAFT", budgetCents: 1200000 }),
|
||||||
|
)).toBe("create project (name=Apollo, status=DRAFT, budgetCents=1200000)");
|
||||||
|
expect(buildApprovalSummary("delete_project", "{}")).toBe("delete project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes affirmative and cancellation replies in multiple phrasings", () => {
|
||||||
|
expect(isAffirmativeConfirmationReply("ja, bitte ausführen")).toBe(true);
|
||||||
|
expect(isAffirmativeConfirmationReply("go ahead")).toBe(true);
|
||||||
|
expect(isAffirmativeConfirmationReply("vielleicht")).toBe(false);
|
||||||
|
|
||||||
|
expect(isCancellationReply("nein, abbrechen")).toBe(true);
|
||||||
|
expect(isCancellationReply("stop")).toBe(true);
|
||||||
|
expect(isCancellationReply("später")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
|
||||||
|
expect(canExecuteMutationTool([
|
||||||
|
{ role: "user", content: "Lege bitte ein Projekt an" },
|
||||||
|
], "create_project")).toBe(false);
|
||||||
|
|
||||||
|
expect(canExecuteMutationTool([
|
||||||
|
{ role: "user", content: "Lege bitte ein Projekt an" },
|
||||||
|
{ role: "assistant", content: "Ich werde jetzt das Projekt erstellen." },
|
||||||
|
{ role: "user", content: "ja" },
|
||||||
|
], "create_project")).toBe(false);
|
||||||
|
|
||||||
|
expect(canExecuteMutationTool([
|
||||||
|
{ role: "user", content: "Lege bitte ein Projekt an" },
|
||||||
|
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} Ich werde das Projekt \"Apollo\" in DRAFT anlegen. Bitte bestätigen.` },
|
||||||
|
{ role: "user", content: "ja, bitte ausführen" },
|
||||||
|
], "create_project")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a matching server-side pending approval for mutation execution when provided", () => {
|
||||||
|
const pendingApproval = {
|
||||||
|
id: "approval-1",
|
||||||
|
userId: "user-1",
|
||||||
|
conversationId: "conversation-1",
|
||||||
|
toolName: "create_project",
|
||||||
|
toolArguments: JSON.stringify({ name: "Apollo", status: "DRAFT" }),
|
||||||
|
summary: "create project (name=Apollo, status=DRAFT)",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canExecuteMutationTool([
|
||||||
|
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
|
||||||
|
{ role: "user", content: "ja" },
|
||||||
|
], "create_project", pendingApproval)).toBe(true);
|
||||||
|
|
||||||
|
expect(canExecuteMutationTool([
|
||||||
|
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
|
||||||
|
{ role: "user", content: "ja" },
|
||||||
|
], "delete_project", pendingApproval)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require confirmation for read-only assistant tools", () => {
|
||||||
|
expect(canExecuteMutationTool([
|
||||||
|
{ role: "user", content: "Zeig mir meine Notifications" },
|
||||||
|
], "list_notifications")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,631 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||||
|
import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js";
|
||||||
|
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
|
||||||
|
|
||||||
|
function getToolNames(
|
||||||
|
permissions: PermissionKeyValue[],
|
||||||
|
userRole: SystemRole = SystemRole.ADMIN,
|
||||||
|
) {
|
||||||
|
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assistant tool policy", () => {
|
||||||
|
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||||
|
const withoutAdvanced = getToolNames([
|
||||||
|
PermissionKey.VIEW_PLANNING,
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
]);
|
||||||
|
const withAdvanced = getToolNames([
|
||||||
|
PermissionKey.VIEW_PLANNING,
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
||||||
|
expect(withAdvanced).toContain("find_best_project_resource");
|
||||||
|
expect(withAdvanced).toContain("get_chargeability_report");
|
||||||
|
expect(withAdvanced).toContain("get_resource_computation_graph");
|
||||||
|
expect(withAdvanced).toContain("get_project_computation_graph");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps user self-service tools available to plain authenticated users", () => {
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(userNames).toContain("get_current_user");
|
||||||
|
expect(userNames).toContain("get_dashboard_layout");
|
||||||
|
expect(userNames).toContain("save_dashboard_layout");
|
||||||
|
expect(userNames).toContain("get_favorite_project_ids");
|
||||||
|
expect(userNames).toContain("toggle_favorite_project");
|
||||||
|
expect(userNames).toContain("get_column_preferences");
|
||||||
|
expect(userNames).toContain("set_column_preferences");
|
||||||
|
expect(userNames).toContain("get_mfa_status");
|
||||||
|
expect(userNames).toContain("list_notifications");
|
||||||
|
expect(userNames).toContain("get_unread_notification_count");
|
||||||
|
expect(userNames).toContain("list_tasks");
|
||||||
|
expect(userNames).toContain("get_task_counts");
|
||||||
|
expect(userNames).toContain("create_reminder");
|
||||||
|
expect(userNames).toContain("list_reminders");
|
||||||
|
expect(userNames).toContain("update_reminder");
|
||||||
|
expect(userNames).toContain("delete_reminder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps admin-only user tools hidden from non-admin roles", () => {
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(adminNames).toContain("list_users");
|
||||||
|
expect(adminNames).toContain("get_active_user_count");
|
||||||
|
expect(adminNames).toContain("create_user");
|
||||||
|
expect(adminNames).toContain("set_user_password");
|
||||||
|
expect(adminNames).toContain("update_user_role");
|
||||||
|
expect(adminNames).toContain("update_user_name");
|
||||||
|
expect(adminNames).toContain("link_user_resource");
|
||||||
|
expect(adminNames).toContain("auto_link_users_by_email");
|
||||||
|
expect(adminNames).toContain("set_user_permissions");
|
||||||
|
expect(adminNames).toContain("reset_user_permissions");
|
||||||
|
expect(adminNames).toContain("get_effective_user_permissions");
|
||||||
|
expect(adminNames).toContain("disable_user_totp");
|
||||||
|
|
||||||
|
expect(managerNames).not.toContain("list_users");
|
||||||
|
expect(managerNames).not.toContain("create_user");
|
||||||
|
expect(managerNames).not.toContain("set_user_permissions");
|
||||||
|
expect(managerNames).not.toContain("disable_user_totp");
|
||||||
|
expect(userNames).not.toContain("list_users");
|
||||||
|
expect(userNames).not.toContain("get_active_user_count");
|
||||||
|
expect(userNames).not.toContain("create_user");
|
||||||
|
expect(userNames).not.toContain("set_user_password");
|
||||||
|
expect(userNames).not.toContain("update_user_role");
|
||||||
|
expect(userNames).not.toContain("update_user_name");
|
||||||
|
expect(userNames).not.toContain("link_user_resource");
|
||||||
|
expect(userNames).not.toContain("auto_link_users_by_email");
|
||||||
|
expect(userNames).not.toContain("set_user_permissions");
|
||||||
|
expect(userNames).not.toContain("reset_user_permissions");
|
||||||
|
expect(userNames).not.toContain("get_effective_user_permissions");
|
||||||
|
expect(userNames).not.toContain("disable_user_totp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => {
|
||||||
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(managerNames).toContain("list_assignable_users");
|
||||||
|
expect(managerNames).toContain("create_notification");
|
||||||
|
expect(managerNames).toContain("create_task_for_user");
|
||||||
|
expect(managerNames).toContain("assign_task");
|
||||||
|
expect(managerNames).toContain("send_broadcast");
|
||||||
|
expect(managerNames).toContain("list_broadcasts");
|
||||||
|
expect(managerNames).toContain("get_broadcast_detail");
|
||||||
|
expect(adminNames).toContain("list_assignable_users");
|
||||||
|
expect(adminNames).toContain("create_task_for_user");
|
||||||
|
expect(adminNames).toContain("send_broadcast");
|
||||||
|
expect(userNames).not.toContain("list_assignable_users");
|
||||||
|
expect(userNames).not.toContain("create_notification");
|
||||||
|
expect(userNames).not.toContain("create_task_for_user");
|
||||||
|
expect(userNames).not.toContain("assign_task");
|
||||||
|
expect(userNames).not.toContain("send_broadcast");
|
||||||
|
expect(userNames).not.toContain("list_broadcasts");
|
||||||
|
expect(userNames).not.toContain("get_broadcast_detail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
||||||
|
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
|
||||||
|
|
||||||
|
expect(names).not.toContain("find_best_project_resource");
|
||||||
|
expect(names).not.toContain("get_chargeability_report");
|
||||||
|
expect(names).not.toContain("get_resource_computation_graph");
|
||||||
|
expect(names).not.toContain("get_project_computation_graph");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => {
|
||||||
|
const controllerNames = getToolNames([
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
], SystemRole.CONTROLLER);
|
||||||
|
const userNames = getToolNames([
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(controllerNames).toContain("query_change_history");
|
||||||
|
expect(controllerNames).toContain("get_entity_timeline");
|
||||||
|
expect(controllerNames).toContain("search_by_skill");
|
||||||
|
expect(controllerNames).toContain("export_resources_csv");
|
||||||
|
expect(controllerNames).toContain("export_projects_csv");
|
||||||
|
expect(controllerNames).toContain("list_audit_log_entries");
|
||||||
|
expect(controllerNames).toContain("get_audit_log_entry");
|
||||||
|
expect(controllerNames).toContain("get_audit_log_timeline");
|
||||||
|
expect(controllerNames).toContain("get_audit_activity_summary");
|
||||||
|
expect(controllerNames).toContain("get_chargeability_report");
|
||||||
|
expect(controllerNames).toContain("get_resource_computation_graph");
|
||||||
|
expect(controllerNames).toContain("get_project_computation_graph");
|
||||||
|
expect(userNames).not.toContain("query_change_history");
|
||||||
|
expect(userNames).not.toContain("get_entity_timeline");
|
||||||
|
expect(userNames).not.toContain("search_by_skill");
|
||||||
|
expect(userNames).not.toContain("export_resources_csv");
|
||||||
|
expect(userNames).not.toContain("export_projects_csv");
|
||||||
|
expect(userNames).not.toContain("list_audit_log_entries");
|
||||||
|
expect(userNames).not.toContain("get_audit_log_entry");
|
||||||
|
expect(userNames).not.toContain("get_audit_log_timeline");
|
||||||
|
expect(userNames).not.toContain("get_audit_activity_summary");
|
||||||
|
expect(userNames).not.toContain("get_chargeability_report");
|
||||||
|
expect(userNames).not.toContain("get_resource_computation_graph");
|
||||||
|
expect(userNames).not.toContain("get_project_computation_graph");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps entity-scoped comment tools available to plain authenticated users", () => {
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(userNames).toContain("list_comments");
|
||||||
|
expect(userNames).toContain("create_comment");
|
||||||
|
expect(userNames).toContain("resolve_comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps planning read tools behind the explicit planning permission", () => {
|
||||||
|
const userWithoutPlanning = getToolNames([], SystemRole.USER);
|
||||||
|
const userWithPlanning = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(userWithoutPlanning).not.toContain("list_allocations");
|
||||||
|
expect(userWithoutPlanning).not.toContain("list_demands");
|
||||||
|
expect(userWithoutPlanning).not.toContain("list_blueprints");
|
||||||
|
expect(userWithoutPlanning).not.toContain("get_blueprint");
|
||||||
|
expect(userWithoutPlanning).not.toContain("list_clients");
|
||||||
|
expect(userWithoutPlanning).not.toContain("list_roles");
|
||||||
|
expect(userWithoutPlanning).not.toContain("list_management_levels");
|
||||||
|
expect(userWithoutPlanning).not.toContain("list_utilization_categories");
|
||||||
|
expect(userWithoutPlanning).not.toContain("check_resource_availability");
|
||||||
|
expect(userWithoutPlanning).not.toContain("find_capacity");
|
||||||
|
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
|
||||||
|
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
|
||||||
|
expect(userWithPlanning).toContain("list_allocations");
|
||||||
|
expect(userWithPlanning).toContain("list_demands");
|
||||||
|
expect(userWithPlanning).toContain("list_blueprints");
|
||||||
|
expect(userWithPlanning).toContain("get_blueprint");
|
||||||
|
expect(userWithPlanning).toContain("list_clients");
|
||||||
|
expect(userWithPlanning).toContain("list_roles");
|
||||||
|
expect(userWithPlanning).toContain("list_management_levels");
|
||||||
|
expect(userWithPlanning).toContain("list_utilization_categories");
|
||||||
|
expect(userWithPlanning).toContain("check_resource_availability");
|
||||||
|
expect(userWithPlanning).toContain("find_capacity");
|
||||||
|
expect(userWithPlanning).not.toContain("get_staffing_suggestions");
|
||||||
|
expect(userWithPlanning).not.toContain("find_best_project_resource");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => {
|
||||||
|
const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
|
||||||
|
const planningAndCosts = getToolNames([
|
||||||
|
PermissionKey.VIEW_PLANNING,
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
], SystemRole.USER);
|
||||||
|
const planningCostsAndAdvanced = getToolNames([
|
||||||
|
PermissionKey.VIEW_PLANNING,
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(planningOnly).not.toContain("get_staffing_suggestions");
|
||||||
|
expect(planningOnly).not.toContain("find_best_project_resource");
|
||||||
|
expect(planningAndCosts).toContain("get_staffing_suggestions");
|
||||||
|
expect(planningAndCosts).not.toContain("find_best_project_resource");
|
||||||
|
expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions");
|
||||||
|
expect(planningCostsAndAdvanced).toContain("find_best_project_resource");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps controller-only project and dashboard reads hidden from plain users", () => {
|
||||||
|
const controllerNames = getToolNames([
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
], SystemRole.CONTROLLER);
|
||||||
|
const userNames = getToolNames([
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
PermissionKey.VIEW_PLANNING,
|
||||||
|
], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(controllerNames).toContain("search_projects");
|
||||||
|
expect(controllerNames).toContain("get_project");
|
||||||
|
expect(controllerNames).toContain("get_statistics");
|
||||||
|
expect(controllerNames).toContain("get_dashboard_detail");
|
||||||
|
expect(controllerNames).toContain("get_skill_gaps");
|
||||||
|
expect(controllerNames).toContain("get_project_health");
|
||||||
|
expect(controllerNames).toContain("get_budget_forecast");
|
||||||
|
expect(controllerNames).toContain("get_budget_status");
|
||||||
|
expect(controllerNames).toContain("get_shoring_ratio");
|
||||||
|
expect(userNames).not.toContain("search_projects");
|
||||||
|
expect(userNames).not.toContain("get_project");
|
||||||
|
expect(userNames).not.toContain("get_statistics");
|
||||||
|
expect(userNames).not.toContain("get_dashboard_detail");
|
||||||
|
expect(userNames).not.toContain("get_skill_gaps");
|
||||||
|
expect(userNames).not.toContain("get_project_health");
|
||||||
|
expect(userNames).not.toContain("get_budget_forecast");
|
||||||
|
expect(userNames).not.toContain("get_budget_status");
|
||||||
|
expect(userNames).not.toContain("get_shoring_ratio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps legacy controller-only analysis and report tools hidden from plain users", () => {
|
||||||
|
const controllerNames = getToolNames([
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.VIEW_PLANNING,
|
||||||
|
], SystemRole.CONTROLLER);
|
||||||
|
const userNames = getToolNames([
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.VIEW_PLANNING,
|
||||||
|
], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(controllerNames).toContain("detect_anomalies");
|
||||||
|
expect(controllerNames).toContain("get_insights_summary");
|
||||||
|
expect(controllerNames).toContain("run_report");
|
||||||
|
expect(controllerNames).toContain("lookup_rate");
|
||||||
|
expect(controllerNames).toContain("simulate_scenario");
|
||||||
|
expect(controllerNames).toContain("generate_project_narrative");
|
||||||
|
expect(controllerNames).toContain("list_rate_cards");
|
||||||
|
expect(controllerNames).toContain("resolve_rate");
|
||||||
|
expect(userNames).not.toContain("detect_anomalies");
|
||||||
|
expect(userNames).not.toContain("get_insights_summary");
|
||||||
|
expect(userNames).not.toContain("run_report");
|
||||||
|
expect(userNames).not.toContain("lookup_rate");
|
||||||
|
expect(userNames).not.toContain("simulate_scenario");
|
||||||
|
expect(userNames).not.toContain("generate_project_narrative");
|
||||||
|
expect(userNames).not.toContain("list_rate_cards");
|
||||||
|
expect(userNames).not.toContain("resolve_rate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps cost-sensitive legacy rate tools hidden without viewCosts", () => {
|
||||||
|
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||||
|
const controllerWithCosts = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||||
|
|
||||||
|
expect(controllerWithoutCosts).not.toContain("list_rate_cards");
|
||||||
|
expect(controllerWithoutCosts).not.toContain("resolve_rate");
|
||||||
|
expect(controllerWithCosts).toContain("list_rate_cards");
|
||||||
|
expect(controllerWithCosts).toContain("resolve_rate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires both controller role and advanced assistant access for timeline detail tools", () => {
|
||||||
|
const controllerWithAdvanced = getToolNames([
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
], SystemRole.CONTROLLER);
|
||||||
|
const controllerWithoutAdvanced = getToolNames([], SystemRole.CONTROLLER);
|
||||||
|
const userWithAdvanced = getToolNames([
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(controllerWithAdvanced).toContain("get_timeline_entries_view");
|
||||||
|
expect(controllerWithAdvanced).toContain("get_timeline_holiday_overlays");
|
||||||
|
expect(controllerWithAdvanced).toContain("get_project_timeline_context");
|
||||||
|
expect(controllerWithAdvanced).toContain("preview_project_shift");
|
||||||
|
expect(controllerWithoutAdvanced).not.toContain("get_timeline_entries_view");
|
||||||
|
expect(controllerWithoutAdvanced).not.toContain("get_timeline_holiday_overlays");
|
||||||
|
expect(controllerWithoutAdvanced).not.toContain("get_project_timeline_context");
|
||||||
|
expect(controllerWithoutAdvanced).not.toContain("preview_project_shift");
|
||||||
|
expect(userWithAdvanced).not.toContain("get_timeline_entries_view");
|
||||||
|
expect(userWithAdvanced).not.toContain("get_timeline_holiday_overlays");
|
||||||
|
expect(userWithAdvanced).not.toContain("get_project_timeline_context");
|
||||||
|
expect(userWithAdvanced).not.toContain("preview_project_shift");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes self-service timeline tools to authenticated users without advanced assistant access", () => {
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
const viewerNames = getToolNames([], SystemRole.VIEWER);
|
||||||
|
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||||
|
|
||||||
|
expect(userNames).toContain("get_my_timeline_entries_view");
|
||||||
|
expect(userNames).toContain("get_my_timeline_holiday_overlays");
|
||||||
|
expect(viewerNames).toContain("get_my_timeline_entries_view");
|
||||||
|
expect(viewerNames).toContain("get_my_timeline_holiday_overlays");
|
||||||
|
expect(controllerNames).toContain("get_my_timeline_entries_view");
|
||||||
|
expect(controllerNames).toContain("get_my_timeline_holiday_overlays");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => {
|
||||||
|
const managerNames = getToolNames([
|
||||||
|
PermissionKey.MANAGE_ALLOCATIONS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([
|
||||||
|
PermissionKey.MANAGE_ALLOCATIONS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
], SystemRole.USER);
|
||||||
|
const missingAdvancedNames = getToolNames([
|
||||||
|
PermissionKey.MANAGE_ALLOCATIONS,
|
||||||
|
], SystemRole.MANAGER);
|
||||||
|
|
||||||
|
expect(managerNames).toContain("update_timeline_allocation_inline");
|
||||||
|
expect(managerNames).toContain("apply_timeline_project_shift");
|
||||||
|
expect(managerNames).toContain("quick_assign_timeline_resource");
|
||||||
|
expect(managerNames).toContain("batch_quick_assign_timeline_resources");
|
||||||
|
expect(managerNames).toContain("batch_shift_timeline_allocations");
|
||||||
|
expect(userNames).not.toContain("update_timeline_allocation_inline");
|
||||||
|
expect(userNames).not.toContain("apply_timeline_project_shift");
|
||||||
|
expect(userNames).not.toContain("quick_assign_timeline_resource");
|
||||||
|
expect(userNames).not.toContain("batch_quick_assign_timeline_resources");
|
||||||
|
expect(userNames).not.toContain("batch_shift_timeline_allocations");
|
||||||
|
expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline");
|
||||||
|
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps estimate lifecycle mutations behind manager/admin role and their router permissions", () => {
|
||||||
|
const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER);
|
||||||
|
const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER);
|
||||||
|
const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(managerProjectNames).toContain("create_estimate");
|
||||||
|
expect(managerProjectNames).toContain("clone_estimate");
|
||||||
|
expect(managerProjectNames).toContain("update_estimate_draft");
|
||||||
|
expect(managerProjectNames).toContain("submit_estimate_version");
|
||||||
|
expect(managerProjectNames).toContain("approve_estimate_version");
|
||||||
|
expect(managerProjectNames).toContain("create_estimate_revision");
|
||||||
|
expect(managerProjectNames).toContain("create_estimate_export");
|
||||||
|
expect(managerProjectNames).toContain("generate_estimate_weekly_phasing");
|
||||||
|
expect(managerProjectNames).toContain("update_estimate_commercial_terms");
|
||||||
|
expect(managerProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||||
|
expect(managerAllocationNames).toContain("create_estimate_planning_handoff");
|
||||||
|
expect(managerAllocationNames).not.toContain("create_estimate");
|
||||||
|
expect(userProjectNames).not.toContain("create_estimate");
|
||||||
|
expect(userProjectNames).not.toContain("clone_estimate");
|
||||||
|
expect(userProjectNames).not.toContain("update_estimate_draft");
|
||||||
|
expect(userProjectNames).not.toContain("submit_estimate_version");
|
||||||
|
expect(userProjectNames).not.toContain("approve_estimate_version");
|
||||||
|
expect(userProjectNames).not.toContain("create_estimate_revision");
|
||||||
|
expect(userProjectNames).not.toContain("create_estimate_export");
|
||||||
|
expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing");
|
||||||
|
expect(userProjectNames).not.toContain("update_estimate_commercial_terms");
|
||||||
|
expect(userProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => {
|
||||||
|
const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||||
|
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||||
|
const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER);
|
||||||
|
const managerWithoutCosts = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(controllerNames).toContain("search_estimates");
|
||||||
|
expect(controllerNames).toContain("get_estimate_detail");
|
||||||
|
expect(controllerNames).toContain("list_estimate_versions");
|
||||||
|
expect(controllerNames).toContain("get_estimate_version_snapshot");
|
||||||
|
expect(controllerNames).toContain("get_estimate_weekly_phasing");
|
||||||
|
expect(controllerNames).toContain("get_estimate_commercial_terms");
|
||||||
|
expect(controllerWithoutCosts).toContain("search_estimates");
|
||||||
|
expect(controllerWithoutCosts).not.toContain("get_estimate_detail");
|
||||||
|
expect(controllerWithoutCosts).toContain("list_estimate_versions");
|
||||||
|
expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||||
|
expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing");
|
||||||
|
expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms");
|
||||||
|
expect(managerNames).toContain("search_estimates");
|
||||||
|
expect(managerNames).toContain("get_estimate_detail");
|
||||||
|
expect(managerNames).toContain("list_estimate_versions");
|
||||||
|
expect(managerNames).toContain("get_estimate_version_snapshot");
|
||||||
|
expect(managerNames).toContain("get_estimate_weekly_phasing");
|
||||||
|
expect(managerNames).toContain("get_estimate_commercial_terms");
|
||||||
|
expect(managerWithoutCosts).toContain("search_estimates");
|
||||||
|
expect(managerWithoutCosts).toContain("list_estimate_versions");
|
||||||
|
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||||
|
expect(userNames).not.toContain("search_estimates");
|
||||||
|
expect(userNames).not.toContain("get_estimate_detail");
|
||||||
|
expect(userNames).not.toContain("list_estimate_versions");
|
||||||
|
expect(userNames).not.toContain("get_estimate_version_snapshot");
|
||||||
|
expect(userNames).not.toContain("get_estimate_weekly_phasing");
|
||||||
|
expect(userNames).not.toContain("get_estimate_commercial_terms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps import/dispo parity tools aligned to router roles and permissions", () => {
|
||||||
|
const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER);
|
||||||
|
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(managerNames).toContain("import_csv_data");
|
||||||
|
expect(controllerNames).toContain("export_resources_csv");
|
||||||
|
expect(controllerNames).toContain("export_projects_csv");
|
||||||
|
expect(adminNames).toContain("list_dispo_import_batches");
|
||||||
|
expect(adminNames).toContain("get_dispo_import_batch");
|
||||||
|
expect(adminNames).toContain("stage_dispo_import_batch");
|
||||||
|
expect(adminNames).toContain("validate_dispo_import_batch");
|
||||||
|
expect(adminNames).toContain("cancel_dispo_import_batch");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_resources");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_projects");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_assignments");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_vacations");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_unresolved_records");
|
||||||
|
expect(adminNames).toContain("resolve_dispo_staged_record");
|
||||||
|
expect(adminNames).toContain("commit_dispo_import_batch");
|
||||||
|
expect(userNames).not.toContain("import_csv_data");
|
||||||
|
expect(userNames).not.toContain("export_resources_csv");
|
||||||
|
expect(userNames).not.toContain("export_projects_csv");
|
||||||
|
expect(userNames).not.toContain("list_dispo_import_batches");
|
||||||
|
expect(userNames).not.toContain("get_dispo_import_batch");
|
||||||
|
expect(userNames).not.toContain("stage_dispo_import_batch");
|
||||||
|
expect(userNames).not.toContain("validate_dispo_import_batch");
|
||||||
|
expect(userNames).not.toContain("list_dispo_staged_resources");
|
||||||
|
expect(userNames).not.toContain("commit_dispo_import_batch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => {
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(adminNames).toContain("get_system_settings");
|
||||||
|
expect(adminNames).toContain("update_system_settings");
|
||||||
|
expect(adminNames).toContain("clear_stored_runtime_secrets");
|
||||||
|
expect(adminNames).toContain("test_ai_connection");
|
||||||
|
expect(adminNames).toContain("test_smtp_connection");
|
||||||
|
expect(adminNames).toContain("test_gemini_connection");
|
||||||
|
expect(adminNames).toContain("list_system_role_configs");
|
||||||
|
expect(adminNames).toContain("update_system_role_config");
|
||||||
|
expect(adminNames).toContain("list_webhooks");
|
||||||
|
expect(adminNames).toContain("get_webhook");
|
||||||
|
expect(adminNames).toContain("create_webhook");
|
||||||
|
expect(adminNames).toContain("update_webhook");
|
||||||
|
expect(adminNames).toContain("delete_webhook");
|
||||||
|
expect(adminNames).toContain("test_webhook");
|
||||||
|
expect(adminNames).toContain("get_ai_configured");
|
||||||
|
|
||||||
|
expect(managerNames).not.toContain("get_system_settings");
|
||||||
|
expect(managerNames).not.toContain("update_system_settings");
|
||||||
|
expect(managerNames).not.toContain("clear_stored_runtime_secrets");
|
||||||
|
expect(managerNames).not.toContain("test_ai_connection");
|
||||||
|
expect(managerNames).not.toContain("get_ai_configured");
|
||||||
|
expect(managerNames).not.toContain("list_system_role_configs");
|
||||||
|
expect(managerNames).not.toContain("update_system_role_config");
|
||||||
|
expect(managerNames).not.toContain("list_webhooks");
|
||||||
|
expect(managerNames).not.toContain("create_webhook");
|
||||||
|
|
||||||
|
expect(userNames).not.toContain("get_system_settings");
|
||||||
|
expect(userNames).not.toContain("update_system_settings");
|
||||||
|
expect(userNames).not.toContain("clear_stored_runtime_secrets");
|
||||||
|
expect(userNames).not.toContain("test_ai_connection");
|
||||||
|
expect(userNames).not.toContain("get_ai_configured");
|
||||||
|
expect(userNames).not.toContain("list_system_role_configs");
|
||||||
|
expect(userNames).not.toContain("update_system_role_config");
|
||||||
|
expect(userNames).not.toContain("list_webhooks");
|
||||||
|
expect(userNames).not.toContain("create_webhook");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps client deletion admin-only while still allowing manager client maintenance", () => {
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(adminNames).toContain("create_client");
|
||||||
|
expect(adminNames).toContain("update_client");
|
||||||
|
expect(adminNames).toContain("delete_client");
|
||||||
|
expect(managerNames).toContain("create_client");
|
||||||
|
expect(managerNames).toContain("update_client");
|
||||||
|
expect(managerNames).not.toContain("delete_client");
|
||||||
|
expect(userNames).not.toContain("create_client");
|
||||||
|
expect(userNames).not.toContain("update_client");
|
||||||
|
expect(userNames).not.toContain("delete_client");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => {
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(adminNames).toContain("list_holiday_calendars");
|
||||||
|
expect(adminNames).toContain("get_holiday_calendar");
|
||||||
|
expect(adminNames).toContain("preview_resolved_holiday_calendar");
|
||||||
|
expect(adminNames).toContain("create_holiday_calendar");
|
||||||
|
expect(managerNames).not.toContain("list_holiday_calendars");
|
||||||
|
expect(managerNames).not.toContain("get_holiday_calendar");
|
||||||
|
expect(managerNames).toContain("preview_resolved_holiday_calendar");
|
||||||
|
expect(userNames).not.toContain("list_holiday_calendars");
|
||||||
|
expect(userNames).not.toContain("get_holiday_calendar");
|
||||||
|
expect(userNames).toContain("preview_resolved_holiday_calendar");
|
||||||
|
expect(managerNames).not.toContain("create_holiday_calendar");
|
||||||
|
expect(managerNames).not.toContain("update_holiday_calendar");
|
||||||
|
expect(managerNames).not.toContain("delete_holiday_calendar");
|
||||||
|
expect(managerNames).not.toContain("create_holiday_calendar_entry");
|
||||||
|
expect(managerNames).not.toContain("update_holiday_calendar_entry");
|
||||||
|
expect(managerNames).not.toContain("delete_holiday_calendar_entry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => {
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER);
|
||||||
|
const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(adminNames).toContain("list_countries");
|
||||||
|
expect(adminNames).toContain("create_country");
|
||||||
|
expect(adminNames).toContain("update_country");
|
||||||
|
expect(adminNames).toContain("create_metro_city");
|
||||||
|
expect(adminNames).toContain("update_metro_city");
|
||||||
|
expect(adminNames).toContain("delete_metro_city");
|
||||||
|
expect(managerNames).toContain("list_countries");
|
||||||
|
expect(managerNames).not.toContain("create_country");
|
||||||
|
expect(managerNames).not.toContain("update_country");
|
||||||
|
expect(managerNames).not.toContain("create_metro_city");
|
||||||
|
expect(managerNames).not.toContain("update_metro_city");
|
||||||
|
expect(managerNames).not.toContain("delete_metro_city");
|
||||||
|
expect(userNames).not.toContain("search_resources");
|
||||||
|
expect(userNames).not.toContain("get_country");
|
||||||
|
expect(userNames).not.toContain("list_org_units");
|
||||||
|
expect(userWithResourceOverview).toContain("search_resources");
|
||||||
|
expect(userWithResourceOverview).toContain("get_country");
|
||||||
|
expect(userWithResourceOverview).toContain("list_org_units");
|
||||||
|
expect(userWithManagedResources).toContain("search_resources");
|
||||||
|
expect(userWithManagedResources).toContain("get_country");
|
||||||
|
expect(userWithManagedResources).toContain("list_org_units");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches explicit access metadata to legacy monolithic tools with restricted visibility", () => {
|
||||||
|
const toolAccess = new Map(
|
||||||
|
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.access]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(toolAccess.get("run_report")).toEqual({
|
||||||
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
|
});
|
||||||
|
expect(toolAccess.get("simulate_scenario")).toEqual({
|
||||||
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
|
});
|
||||||
|
expect(toolAccess.get("detect_anomalies")).toEqual({
|
||||||
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
|
});
|
||||||
|
expect(toolAccess.get("get_insights_summary")).toEqual({
|
||||||
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
|
});
|
||||||
|
expect(toolAccess.get("lookup_rate")).toEqual({
|
||||||
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
|
});
|
||||||
|
expect(toolAccess.get("list_rate_cards")).toEqual({
|
||||||
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
|
requiresCostView: true,
|
||||||
|
});
|
||||||
|
expect(toolAccess.get("resolve_rate")).toEqual({
|
||||||
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
|
requiresCostView: true,
|
||||||
|
});
|
||||||
|
expect(toolAccess.get("import_csv_data")).toEqual({
|
||||||
|
requiredPermissions: [PermissionKey.IMPORT_DATA],
|
||||||
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aligns assistant tool visibility with router role and permission rules", () => {
|
||||||
|
const managerWithRolePermission = getToolNames(
|
||||||
|
[PermissionKey.MANAGE_ROLES],
|
||||||
|
SystemRole.MANAGER,
|
||||||
|
);
|
||||||
|
const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER);
|
||||||
|
|
||||||
|
expect(managerWithRolePermission).toContain("create_role");
|
||||||
|
expect(managerWithRolePermission).toContain("update_role");
|
||||||
|
expect(managerWithRolePermission).toContain("delete_role");
|
||||||
|
expect(managerWithRolePermission).toContain("create_client");
|
||||||
|
expect(managerWithRolePermission).toContain("update_client");
|
||||||
|
expect(managerWithRolePermission).not.toContain("create_org_unit");
|
||||||
|
expect(managerWithRolePermission).not.toContain("update_org_unit");
|
||||||
|
|
||||||
|
expect(managerWithoutRolePermission).not.toContain("create_role");
|
||||||
|
expect(managerWithoutRolePermission).not.toContain("update_role");
|
||||||
|
expect(managerWithoutRolePermission).not.toContain("delete_role");
|
||||||
|
expect(managerWithoutRolePermission).toContain("create_client");
|
||||||
|
expect(managerWithoutRolePermission).toContain("update_client");
|
||||||
|
|
||||||
|
const adminWithRolePermission = getToolNames(
|
||||||
|
[PermissionKey.MANAGE_ROLES],
|
||||||
|
SystemRole.ADMIN,
|
||||||
|
);
|
||||||
|
expect(adminWithRolePermission).toContain("create_org_unit");
|
||||||
|
expect(adminWithRolePermission).toContain("update_org_unit");
|
||||||
|
|
||||||
|
const standardUserTools = getToolNames([], SystemRole.USER);
|
||||||
|
expect(standardUserTools).toContain("get_vacation_balance");
|
||||||
|
expect(standardUserTools).toContain("create_vacation");
|
||||||
|
expect(standardUserTools).toContain("cancel_vacation");
|
||||||
|
expect(standardUserTools).not.toContain("approve_vacation");
|
||||||
|
expect(standardUserTools).not.toContain("reject_vacation");
|
||||||
|
expect(standardUserTools).not.toContain("set_entitlement");
|
||||||
|
|
||||||
|
const managerVacationTools = getToolNames([], SystemRole.MANAGER);
|
||||||
|
expect(managerVacationTools).toContain("approve_vacation");
|
||||||
|
expect(managerVacationTools).toContain("reject_vacation");
|
||||||
|
expect(managerVacationTools).toContain("set_entitlement");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readToolError, readToolSuccessMessage } from "../router/assistant-tool-results.js";
|
||||||
|
|
||||||
|
describe("assistant tool results", () => {
|
||||||
|
it("reads structured tool errors from result data or JSON content", () => {
|
||||||
|
expect(readToolError({
|
||||||
|
content: "",
|
||||||
|
data: { error: "Forbidden" },
|
||||||
|
})).toBe("Forbidden");
|
||||||
|
|
||||||
|
expect(readToolError({
|
||||||
|
content: JSON.stringify({ error: "Validation failed" }),
|
||||||
|
})).toBe("Validation failed");
|
||||||
|
|
||||||
|
expect(readToolError({
|
||||||
|
content: "plain text",
|
||||||
|
data: { ok: true },
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers structured success messages and falls back to plain content", () => {
|
||||||
|
expect(readToolSuccessMessage({
|
||||||
|
content: "",
|
||||||
|
data: { message: "Created project Apollo" },
|
||||||
|
})).toBe("Created project Apollo");
|
||||||
|
|
||||||
|
expect(readToolSuccessMessage({
|
||||||
|
content: JSON.stringify({ description: "Updated allocation" }),
|
||||||
|
})).toBe("Updated allocation");
|
||||||
|
|
||||||
|
expect(readToolSuccessMessage({
|
||||||
|
content: "Fallback tool response",
|
||||||
|
})).toBe("Fallback tool response");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||||
|
import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js";
|
||||||
|
import { selectAssistantToolsForRequest } from "../router/assistant-tool-selection.js";
|
||||||
|
|
||||||
|
function getSelectedToolNames(
|
||||||
|
permissions: PermissionKeyValue[],
|
||||||
|
messages: Array<{ role: "user" | "assistant"; content: string }>,
|
||||||
|
userRole: SystemRole = SystemRole.ADMIN,
|
||||||
|
pageContext?: string,
|
||||||
|
) {
|
||||||
|
return selectAssistantToolsForRequest(
|
||||||
|
getAvailableAssistantTools(new Set(permissions), userRole),
|
||||||
|
messages,
|
||||||
|
pageContext,
|
||||||
|
).map((tool) => tool.function.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assistant tool selection", () => {
|
||||||
|
it("caps the OpenAI tool payload to 128 definitions even for fully privileged admins", () => {
|
||||||
|
const allPermissions = Object.values(PermissionKey);
|
||||||
|
const selectedNames = getSelectedToolNames(
|
||||||
|
allPermissions,
|
||||||
|
[{ role: "user", content: "Bitte gib mir einen Überblick über das System." }],
|
||||||
|
SystemRole.ADMIN,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selectedNames.length).toBeLessThanOrEqual(128);
|
||||||
|
expect(selectedNames).toContain("get_current_user");
|
||||||
|
expect(selectedNames).toContain("search_resources");
|
||||||
|
expect(selectedNames).toContain("search_projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes holiday and resource tools for German holiday questions", () => {
|
||||||
|
const allPermissions = Object.values(PermissionKey);
|
||||||
|
const selectedNames = getSelectedToolNames(
|
||||||
|
allPermissions,
|
||||||
|
[{ role: "user", content: "Kannst du mir alle Feiertage nennen, die Peter Parker in 2026 zustehen?" }],
|
||||||
|
SystemRole.ADMIN,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selectedNames.length).toBeLessThanOrEqual(128);
|
||||||
|
expect(selectedNames).toContain("search_resources");
|
||||||
|
expect(selectedNames).toContain("get_resource");
|
||||||
|
expect(selectedNames).toContain("get_resource_holidays");
|
||||||
|
expect(selectedNames).toContain("list_holidays_by_region");
|
||||||
|
expect(selectedNames).toContain("list_holiday_calendars");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
import { buildApprovalSummary } from "./assistant-confirmation.js";
|
||||||
|
|
||||||
|
const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
|
||||||
|
const ASSISTANT_APPROVALS_TABLE_NAME = "public.assistant_approvals";
|
||||||
|
let hasLoggedAssistantApprovalStorageUnavailable = false;
|
||||||
|
|
||||||
|
export type AssistantApprovalStore = Pick<PrismaClient, "assistantApproval">;
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssistantApprovalRecord = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
conversationId: string;
|
||||||
|
toolName: string;
|
||||||
|
toolArguments: string;
|
||||||
|
summary: string;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapPendingApproval(record: AssistantApprovalRecord): 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { MUTATION_TOOLS } from "./assistant-tools.js";
|
||||||
|
import type { PendingAssistantApproval } from "./assistant-approvals.js";
|
||||||
|
|
||||||
|
export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:";
|
||||||
|
|
||||||
|
export type ChatMessage = { role: "user" | "assistant"; content: string };
|
||||||
|
|
||||||
|
export 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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
||||||
|
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS } from "./assistant-tools.js";
|
||||||
|
import type { ToolDef } from "./assistant-tools/shared.js";
|
||||||
|
|
||||||
|
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||||
|
update_resource: "manageResources",
|
||||||
|
create_resource: "manageResources",
|
||||||
|
deactivate_resource: "manageResources",
|
||||||
|
create_role: PermissionKey.MANAGE_ROLES,
|
||||||
|
update_role: PermissionKey.MANAGE_ROLES,
|
||||||
|
delete_role: PermissionKey.MANAGE_ROLES,
|
||||||
|
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,
|
||||||
|
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",
|
||||||
|
execute_task_action: "manageAllocations",
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RESOURCE_OVERVIEW_TOOLS = new Set([
|
||||||
|
"search_resources",
|
||||||
|
"get_country",
|
||||||
|
"list_org_units",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CONTROLLER_ONLY_TOOLS = new Set([
|
||||||
|
"search_by_skill",
|
||||||
|
"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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function hasLegacyToolAccess(
|
||||||
|
toolName: string,
|
||||||
|
permissions: Set<PermissionKey>,
|
||||||
|
userRole: string,
|
||||||
|
hasResourceOverviewAccess: boolean,
|
||||||
|
hasControllerAccess: boolean,
|
||||||
|
hasManagerAccess: boolean,
|
||||||
|
) {
|
||||||
|
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
||||||
|
|
||||||
|
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== SystemRole.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 hasToolAccess(
|
||||||
|
tool: ToolDef,
|
||||||
|
permissions: Set<PermissionKey>,
|
||||||
|
userRole: string,
|
||||||
|
hasResourceOverviewAccess: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (!tool.access) {
|
||||||
|
const hasControllerAccess = userRole === SystemRole.ADMIN
|
||||||
|
|| userRole === SystemRole.MANAGER
|
||||||
|
|| userRole === SystemRole.CONTROLLER;
|
||||||
|
const hasManagerAccess = userRole === SystemRole.ADMIN
|
||||||
|
|| userRole === SystemRole.MANAGER;
|
||||||
|
|
||||||
|
return hasLegacyToolAccess(
|
||||||
|
tool.function.name,
|
||||||
|
permissions,
|
||||||
|
userRole,
|
||||||
|
hasResourceOverviewAccess,
|
||||||
|
hasControllerAccess,
|
||||||
|
hasManagerAccess,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.access.requiredPermissions?.some((permission) => !permissions.has(permission))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tool.access.allowedSystemRoles && !tool.access.allowedSystemRoles.includes(userRole as SystemRole)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tool.access.requiresResourceOverview && !hasResourceOverviewAccess) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tool.access.requiresPlanningRead && !permissions.has(PermissionKey.VIEW_PLANNING)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tool.access.requiresCostView && !permissions.has(PermissionKey.VIEW_COSTS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tool.access.requiresAdvancedAssistant && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableAssistantTools(
|
||||||
|
permissions: Set<PermissionKey>,
|
||||||
|
userRole: string,
|
||||||
|
): ToolDef[] {
|
||||||
|
const hasResourceOverviewAccess = permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|
||||||
|
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
|
||||||
|
|
||||||
|
return TOOL_DEFINITIONS.filter((tool) => (
|
||||||
|
hasToolAccess(tool, permissions, userRole, hasResourceOverviewAccess)
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
type AssistantToolResult = {
|
||||||
|
content: string;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readToolError(result: AssistantToolResult): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readToolSuccessMessage(result: AssistantToolResult): 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { MUTATION_TOOLS } from "./assistant-tools.js";
|
||||||
|
import type { ToolDef } from "./assistant-tools/shared.js";
|
||||||
|
|
||||||
|
const MAX_OPENAI_TOOL_DEFINITIONS = 128;
|
||||||
|
|
||||||
|
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", "get_my_timeline_holiday_overlays", "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_my_timeline_entries_view", "get_my_timeline_holiday_overlays", "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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type AssistantChatMessage = { role: "user" | "assistant"; content: string };
|
||||||
|
|
||||||
|
export function normalizeAssistantText(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{Diacritic}/gu, " ")
|
||||||
|
.replace(/[^a-z0-9_]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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: ToolDef[],
|
||||||
|
messages: AssistantChatMessage[],
|
||||||
|
pageContext?: string,
|
||||||
|
): ToolDef[] {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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;
|
||||||
|
})
|
||||||
|
.slice(0, MAX_OPENAI_TOOL_DEFINITIONS)
|
||||||
|
.map((entry) => entry.tool);
|
||||||
|
}
|
||||||
@@ -5,126 +5,63 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db";
|
|
||||||
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
|
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.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 { 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";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
import { logger } from "../lib/logger.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 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.
|
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
|
- 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[] {
|
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
|
||||||
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
|
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
|
||||||
if (duplicateIndex >= 0) {
|
if (duplicateIndex >= 0) {
|
||||||
@@ -520,428 +115,6 @@ function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): As
|
|||||||
return [...existing, next].slice(-6);
|
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({
|
export const assistantRouter = createTRPCRouter({
|
||||||
listPendingApprovals: protectedProcedure
|
listPendingApprovals: protectedProcedure
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user