Files
CapaKraken/packages/api/src/router/assistant.ts
T
Hartmut ddec3a927a feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes
Major timeline enhancements:
- Right-click drag multi-selection with floating action bar (batch delete/assign)
- DemandPopover for demand strip details (replaces broken "Loading" modal)
- ResourceHoverCard on name hover showing skills, rates, role, chapter
- Merged heatmap+vacation tooltips into unified TimelineTooltip component
- Fixed overbooking blink animation (date normalization, z-index ordering)
- Fixed dark mode sticky column bleed-through in project view
- System roles admin page, notification task management, performance review docs

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-18 23:43:51 +01:00

237 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AI Assistant router — provides a chat endpoint that uses OpenAI Function Calling
* to answer questions about plANARCHY data and modify resources/projects.
*/
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@planarchy/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js";
import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
const MAX_TOOL_ITERATIONS = 8;
const SYSTEM_PROMPT = `Du bist der plANARCHY-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
Deine Fähigkeiten:
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
- Rollen, Clients, Org-Units erstellen/aktualisieren/löschen
- Estimates erstellen, Rate Cards abrufen, Blueprints anzeigen
- Notifications anzeigen, Dashboard-Details abrufen
- Tasks einsehen, Status ändern, Tasks erledigen (approve vacation, confirm allocation, etc.)
- Persönliche Erinnerungen anlegen (einmalig oder wiederkehrend)
- Tasks für andere User erstellen, Broadcasts an Gruppen senden
- Den User zu relevanten Seiten navigieren (Timeline, Dashboard, etc. mit Filtern)
- Verfügbarkeit von Ressourcen prüfen, Kapazitäten suchen
Wichtige Regeln:
- Antworte in der Sprache des Users (Deutsch oder Englisch)
- Geldbeträge: intern in Cent, konvertiere zu EUR für den User
- Vor Datenänderungen: kurze Zusammenfassung + Bestätigung einholen
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
- Fasse Ergebnisse kompakt zusammen — keine unnötigen Wiederholungen der Tool-Ergebnisse
- Wenn eine Suche keine Treffer ergibt, versuche einzelne Wörter aus der Anfrage als Suchbegriffe. Die Tools unterstützen automatisch wort-basierte Fuzzy-Suche — zeige dem User die Vorschläge wenn welche gefunden werden
Datenmodell:
- Ressourcen: EID, FTE (0-1), LCR (EUR/h), Chargeability-Target, Skills, Chapter, OrgUnit
- Projekte: ShortCode, Budget (Cent), Win-Probability, Status (DRAFT/ACTIVE/ON_HOLD/COMPLETED/CANCELLED)
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
- Chargeability = gebuchte/verfügbare Stunden × 100%
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
`;
/** 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: "manageResources",
update_role: "manageResources",
delete_role: "manageResources",
create_org_unit: "manageResources",
update_org_unit: "manageResources",
// Project management
update_project: "manageProjects",
create_project: "manageProjects",
delete_project: "manageProjects",
create_client: "manageProjects",
update_client: "manageProjects",
create_estimate: "manageProjects",
generate_project_cover: "manageProjects",
remove_project_cover: "manageProjects",
// Allocation management
create_allocation: "manageAllocations",
cancel_allocation: "manageAllocations",
update_allocation_status: "manageAllocations",
create_demand: "manageAllocations",
fill_demand: "manageAllocations",
// Vacation management
create_vacation: "manageVacations",
approve_vacation: "manageVacations",
reject_vacation: "manageVacations",
cancel_vacation: "manageVacations",
set_entitlement: "manageVacations",
// Task management
create_task_for_user: "manageProjects",
send_broadcast: "manageProjects",
execute_task_action: "manageAllocations",
};
/** Tools that require cost visibility */
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail"]);
export const assistantRouter = createTRPCRouter({
chat: protectedProcedure
.input(z.object({
messages: z.array(z.object({
role: z.enum(["user", "assistant"]),
content: z.string(),
})).min(1).max(200),
pageContext: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
// 1. Load AI settings
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
if (!isAiConfigured(settings)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set up OpenAI credentials in Admin → Settings.",
});
}
const client = createAiClient(settings!);
const userRole = ctx.dbUser?.systemRole ?? "USER";
// Use configured token limit, but ensure a reasonable minimum for multi-tool responses
const maxTokens = Math.max(settings?.aiMaxCompletionTokens ?? 2500, 1500);
const temperature = settings?.aiTemperature ?? 0.7;
const model = settings?.azureOpenAiDeployment ?? "gpt-4o-mini";
// 2. Resolve granular permissions (using DB-based role defaults if available)
const permissions = resolvePermissions(
userRole as SystemRole,
(ctx.dbUser?.permissionOverrides as PermissionOverrides | null) ?? null,
ctx.roleDefaults ?? undefined,
);
const permissionList = [...permissions];
// 3. Build system prompt with user context
let contextBlock = `\n\nAktueller User: ${ctx.session?.user?.name ?? "Unknown"} (Rolle: ${userRole})`;
contextBlock += `\nBerechtigungen: ${permissionList.length > 0 ? permissionList.join(", ") : "Nur Lese-Zugriff auf eigene Daten"}`;
if (input.pageContext) {
contextBlock += `\nAktuelle Seite: ${input.pageContext}`;
contextBlock += `\nHinweis: Beziehe dich bevorzugt auf den Kontext der aktuellen Seite wenn die Frage des Users dazu passt.`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const openaiMessages: any[] = [
{ role: "system", content: SYSTEM_PROMPT + contextBlock },
...input.messages.slice(-20).map((m) => ({
role: m.role,
content: m.content,
})),
];
// 4. Filter tools based on granular permissions
const availableTools = TOOL_DEFINITIONS.filter((t) => {
const toolName = t.function.name;
// Check write permission
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
if (requiredPerm && !permissions.has(requiredPerm as import("@planarchy/shared").PermissionKey)) {
return false;
}
// Hide cost/budget tools if user lacks viewCosts
if (COST_TOOLS.has(toolName) && !permissions.has("viewCosts" as import("@planarchy/shared").PermissionKey)) {
return false;
}
return true;
});
// 5. Function calling loop
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
const collectedActions: ToolAction[] = [];
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
try {
response = await client.chat.completions.create({
model,
messages: openaiMessages,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools: availableTools as any,
max_completion_tokens: maxTokens,
temperature,
});
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `AI error: ${parseAiError(err)}`,
});
}
const choice = response.choices?.[0];
if (!choice) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No response from AI" });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msg = choice.message as any;
// If the AI wants to call tools
if (msg.tool_calls && msg.tool_calls.length > 0) {
openaiMessages.push(msg);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const toolCall of msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }>) {
const result = await executeTool(
toolCall.function.name,
toolCall.function.arguments,
toolCtx,
);
// Collect any actions (e.g. navigation)
if (result.action) {
collectedActions.push(result.action);
}
openaiMessages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result.content,
});
}
continue;
}
// AI returned a text response — we're done
return {
content: (msg.content as string) ?? "I couldn't generate a response.",
role: "assistant" as const,
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}
// Exceeded max iterations
return {
content: "I had to stop after too many tool calls. Please try a simpler question.",
role: "assistant" as const,
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}),
});