import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js"; import { controllerProcedure, createTRPCRouter } from "../trpc.js"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; // ─── Types ─────────────────────────────────────────────────────────────────── export interface Anomaly { type: "budget" | "staffing" | "utilization" | "timeline"; severity: "warning" | "critical"; entityId: string; entityName: string; message: string; } // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Count business days between two dates (Mon–Fri). */ function countBusinessDays(start: Date, end: Date): number { let count = 0; const d = new Date(start); while (d <= end) { const dow = d.getDay(); if (dow !== 0 && dow !== 6) count++; d.setDate(d.getDate() + 1); } return count; } // ─── Router ────────────────────────────────────────────────────────────────── export const insightsRouter = createTRPCRouter({ /** * Generate an AI-powered executive narrative for a project. * Caches the result in the project's dynamicFields.aiNarrative to avoid * calling the AI on every click. */ generateProjectNarrative: controllerProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { const [project, settings] = await Promise.all([ ctx.db.project.findUnique({ where: { id: input.projectId }, include: { demandRequirements: { select: { id: true, role: true, headcount: true, hoursPerDay: true, startDate: true, endDate: true, status: true, _count: { select: { assignments: true } }, }, }, assignments: { select: { id: true, role: true, hoursPerDay: true, startDate: true, endDate: true, status: true, dailyCostCents: true, resource: { select: { displayName: true } }, }, }, }, }), ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }), ]); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } if (!isAiConfigured(settings)) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "AI is not configured. Please set credentials in Admin \u2192 Settings.", }); } // Build context data for the prompt const now = new Date(); const totalDays = countBusinessDays(project.startDate, project.endDate); const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate); const progressPercent = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0; const totalDemandHeadcount = project.demandRequirements.reduce((s, d) => s + d.headcount, 0); const filledDemandHeadcount = project.demandRequirements.reduce( (s, d) => s + Math.min(d._count.assignments, d.headcount), 0, ); const staffingPercent = totalDemandHeadcount > 0 ? Math.round((filledDemandHeadcount / totalDemandHeadcount) * 100) : 100; // Estimated cost from assignments const totalCostCents = project.assignments.reduce((s, a) => { const days = countBusinessDays(a.startDate, a.endDate); return s + a.dailyCostCents * days; }, 0); const budgetCents = project.budgetCents; const budgetUsedPercent = budgetCents > 0 ? Math.round((totalCostCents / budgetCents) * 100) : 0; const overrunAssignments = project.assignments.filter( (a) => a.endDate > project.endDate, ); const dataContext = [ `Project: ${project.name} (${project.shortCode})`, `Status: ${project.status}`, `Timeline: ${project.startDate.toISOString().slice(0, 10)} to ${project.endDate.toISOString().slice(0, 10)} (${progressPercent}% elapsed)`, `Budget: ${(budgetCents / 100).toLocaleString("en-US", { style: "currency", currency: "EUR" })} | Estimated cost: ${(totalCostCents / 100).toLocaleString("en-US", { style: "currency", currency: "EUR" })} (${budgetUsedPercent}% of budget)`, `Staffing: ${filledDemandHeadcount}/${totalDemandHeadcount} positions filled (${staffingPercent}%)`, `Active assignments: ${project.assignments.filter((a) => a.status === "ACTIVE" || a.status === "CONFIRMED").length}`, overrunAssignments.length > 0 ? `Timeline risk: ${overrunAssignments.length} assignment(s) extend beyond project end date` : "No timeline overruns detected", ].join("\n"); const prompt = `Generate a concise executive summary for this project covering: budget status, staffing completeness, timeline risk, and key action items. Be specific with numbers. Keep it to 3-5 sentences. ${dataContext}`; const client = createAiClient(settings!); const model = settings!.azureOpenAiDeployment!; const maxTokens = settings!.aiMaxCompletionTokens ?? 300; const temperature = settings!.aiTemperature ?? 1; let narrative = ""; try { const completion = await client.chat.completions.create({ messages: [ { role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." }, { role: "user", content: prompt }, ], max_completion_tokens: maxTokens, model, ...(temperature !== 1 ? { temperature } : {}), }); narrative = completion.choices[0]?.message?.content?.trim() ?? ""; } catch (err) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `AI call failed: ${parseAiError(err)}`, }); } if (!narrative) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "AI returned an empty response.", }); } const generatedAt = new Date().toISOString(); // Cache in project dynamicFields const existingDynamic = (project.dynamicFields as Record) ?? {}; await ctx.db.project.update({ where: { id: input.projectId }, data: { dynamicFields: { ...existingDynamic, aiNarrative: narrative, aiNarrativeGeneratedAt: generatedAt, }, }, }); return { narrative, generatedAt }; }), /** * Rule-based anomaly detection across all active projects. * No AI involved — pure data analysis. */ detectAnomalies: controllerProcedure.query(async ({ ctx }) => { const now = new Date(); const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); const anomalies: Anomaly[] = []; // Fetch all active projects with their demands and assignments const projects = await ctx.db.project.findMany({ where: { status: { in: ["ACTIVE", "DRAFT"] } }, include: { demandRequirements: { select: { id: true, headcount: true, startDate: true, endDate: true, status: true, _count: { select: { assignments: true } }, }, }, assignments: { select: { id: true, resourceId: true, startDate: true, endDate: true, hoursPerDay: true, dailyCostCents: true, status: true, }, }, }, }); for (const project of projects) { // ── Budget anomaly: spending faster than expected burn rate ── if (project.budgetCents > 0) { const totalDays = countBusinessDays(project.startDate, project.endDate); const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate); if (totalDays > 0 && elapsedDays > 0) { const expectedBurnRate = elapsedDays / totalDays; // fraction of timeline elapsed const totalCostCents = project.assignments.reduce((s, a) => { const aStart = a.startDate < project.startDate ? project.startDate : a.startDate; const aEnd = a.endDate > now ? now : a.endDate; if (aEnd < aStart) return s; const days = countBusinessDays(aStart, aEnd); return s + a.dailyCostCents * days; }, 0); const actualBurnRate = totalCostCents / project.budgetCents; if (actualBurnRate > expectedBurnRate * 1.2) { const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100); anomalies.push({ type: "budget", severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning", entityId: project.id, entityName: project.name, message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`, }); } } } // ── Staffing anomaly: unfilled demands close to start ── const upcomingDemands = project.demandRequirements.filter( (d) => d.startDate <= twoWeeksFromNow && d.endDate >= now, ); for (const demand of upcomingDemands) { const unfilledCount = demand.headcount - demand._count.assignments; const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0; if (unfillPct > 0.3) { anomalies.push({ type: "staffing", severity: unfillPct > 0.6 ? "critical" : "warning", entityId: project.id, entityName: project.name, message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`, }); } } // ── Timeline anomaly: assignments extending beyond project end ── const overrunAssignments = project.assignments.filter( (a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"), ); if (overrunAssignments.length > 0) { anomalies.push({ type: "timeline", severity: "warning", entityId: project.id, entityName: project.name, message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`, }); } } // ── Utilization anomaly: resources at extreme utilization ── const resources = await ctx.db.resource.findMany({ where: { isActive: true }, select: { id: true, displayName: true, availability: true, }, }); // Get all active assignments for current period const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); const activeAssignments = await ctx.db.assignment.findMany({ where: { status: { in: ["ACTIVE", "CONFIRMED"] }, startDate: { lte: periodEnd }, endDate: { gte: periodStart }, }, select: { resourceId: true, hoursPerDay: true, }, }); // Build resource utilization map const resourceHoursMap = new Map(); for (const assignment of activeAssignments) { const current = resourceHoursMap.get(assignment.resourceId) ?? 0; resourceHoursMap.set(assignment.resourceId, current + assignment.hoursPerDay); } for (const resource of resources) { const avail = resource.availability as Record | null; if (!avail) continue; const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5; if (dailyAvailHours <= 0) continue; const bookedHours = resourceHoursMap.get(resource.id) ?? 0; const utilizationPercent = Math.round((bookedHours / dailyAvailHours) * 100); if (utilizationPercent > 110) { anomalies.push({ type: "utilization", severity: utilizationPercent > 130 ? "critical" : "warning", entityId: resource.id, entityName: resource.displayName, message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`, }); } else if (utilizationPercent < 40 && utilizationPercent > 0) { // Only flag under-utilization if resource has at least some bookings // to avoid flagging bench resources if (bookedHours > 0) { anomalies.push({ type: "utilization", severity: "warning", entityId: resource.id, entityName: resource.displayName, message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`, }); } } } // Sort: critical first, then by type anomalies.sort((a, b) => { if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1; return a.type.localeCompare(b.type); }); return anomalies; }), /** * Dashboard-friendly summary: anomaly counts by category + total. */ getInsightsSummary: controllerProcedure.query(async ({ ctx }) => { // Re-use the detectAnomalies logic inline (calling it directly would // require the full context to be passed through — simpler to share code // via the router caller pattern, but for now we duplicate the call). const now = new Date(); const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); const projects = await ctx.db.project.findMany({ where: { status: { in: ["ACTIVE", "DRAFT"] } }, include: { demandRequirements: { select: { headcount: true, startDate: true, endDate: true, _count: { select: { assignments: true } }, }, }, assignments: { select: { resourceId: true, startDate: true, endDate: true, hoursPerDay: true, dailyCostCents: true, status: true, }, }, }, }); let budgetCount = 0; let staffingCount = 0; let timelineCount = 0; let criticalCount = 0; for (const project of projects) { // Budget check if (project.budgetCents > 0) { const totalDays = countBusinessDays(project.startDate, project.endDate); const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate); if (totalDays > 0 && elapsedDays > 0) { const expectedBurnRate = elapsedDays / totalDays; const totalCostCents = project.assignments.reduce((s, a) => { const aStart = a.startDate < project.startDate ? project.startDate : a.startDate; const aEnd = a.endDate > now ? now : a.endDate; if (aEnd < aStart) return s; return s + a.dailyCostCents * countBusinessDays(aStart, aEnd); }, 0); const actualBurnRate = totalCostCents / project.budgetCents; if (actualBurnRate > expectedBurnRate * 1.2) { budgetCount++; if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++; } } } // Staffing check const upcomingDemands = project.demandRequirements.filter( (d) => d.startDate <= twoWeeksFromNow && d.endDate >= now, ); for (const demand of upcomingDemands) { const unfillPct = demand.headcount > 0 ? (demand.headcount - demand._count.assignments) / demand.headcount : 0; if (unfillPct > 0.3) { staffingCount++; if (unfillPct > 0.6) criticalCount++; } } // Timeline check const overruns = project.assignments.filter( (a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"), ); if (overruns.length > 0) timelineCount++; } // Utilization check const resources = await ctx.db.resource.findMany({ where: { isActive: true }, select: { id: true, availability: true }, }); const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); const activeAssignments = await ctx.db.assignment.findMany({ where: { status: { in: ["ACTIVE", "CONFIRMED"] }, startDate: { lte: periodEnd }, endDate: { gte: periodStart }, }, select: { resourceId: true, hoursPerDay: true }, }); const resourceHoursMap = new Map(); for (const a of activeAssignments) { resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay); } let utilizationCount = 0; for (const resource of resources) { const avail = resource.availability as Record | null; if (!avail) continue; const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5; if (dailyAvail <= 0) continue; const booked = resourceHoursMap.get(resource.id) ?? 0; const pct = Math.round((booked / dailyAvail) * 100); if (pct > 110) { utilizationCount++; if (pct > 130) criticalCount++; } else if (pct < 40 && booked > 0) { utilizationCount++; } } const total = budgetCount + staffingCount + timelineCount + utilizationCount; return { total, criticalCount, budget: budgetCount, staffing: staffingCount, timeline: timelineCount, utilization: utilizationCount, }; }), /** * Retrieve a cached AI narrative for a project (if one was previously generated). */ getCachedNarrative: controllerProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.projectId }, select: { dynamicFields: true }, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } const df = project.dynamicFields as Record | null; const narrative = (df?.aiNarrative as string) ?? null; const generatedAt = (df?.aiNarrativeGeneratedAt as string) ?? null; return { narrative, generatedAt }; }), });