import { createAiClient, isAiConfigured, loggedAiCall, 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; } interface InsightDemandRecord { headcount: number; startDate: Date; endDate: Date; _count: { assignments: number; }; } interface InsightProjectAssignmentRecord { resourceId: string; startDate: Date; endDate: Date; hoursPerDay: number; dailyCostCents: number; status: string; } interface InsightProjectRecord { id: string; name: string; budgetCents: number; startDate: Date; endDate: Date; demandRequirements: InsightDemandRecord[]; assignments: InsightProjectAssignmentRecord[]; } interface InsightResourceRecord { id: string; displayName: string; availability: unknown; } interface InsightAssignmentLoadRecord { resourceId: string; hoursPerDay: number; } interface InsightSnapshot { anomalies: Anomaly[]; summary: { total: number; criticalCount: number; budget: number; staffing: number; timeline: number; utilization: number; }; } interface InsightsDbAccess { project: { findMany(args: Record): Promise; }; resource: { findMany(args: Record): Promise; }; assignment: { findMany(args: Record): Promise; }; } // ─── 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; } async function loadInsightProjects(db: InsightsDbAccess["project"]) { return db.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, }, }, }, }) as Promise; } async function loadInsightResources(db: InsightsDbAccess["resource"]) { return db.findMany({ where: { isActive: true }, select: { id: true, displayName: true, availability: true, }, }) as Promise; } async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) { const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); return db.findMany({ where: { status: { in: ["ACTIVE", "CONFIRMED"] }, startDate: { lte: periodEnd }, endDate: { gte: periodStart }, }, select: { resourceId: true, hoursPerDay: true, }, }) as Promise; } function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] { return anomalies.reduce((summary, anomaly) => { summary.total += 1; summary[anomaly.type] += 1; if (anomaly.severity === "critical") { summary.criticalCount += 1; } return summary; }, { total: 0, criticalCount: 0, budget: 0, staffing: 0, timeline: 0, utilization: 0, }); } async function buildInsightSnapshot(db: InsightsDbAccess, now = new Date()): Promise { const [projects, resources, activeAssignments] = await Promise.all([ loadInsightProjects(db.project), loadInsightResources(db.resource), loadInsightAssignmentLoads(db.assignment, now), ]); const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); const anomalies: Anomaly[] = []; for (const project of projects) { 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((sum, assignment) => { const assignmentStart = assignment.startDate < project.startDate ? project.startDate : assignment.startDate; const assignmentEnd = assignment.endDate > now ? now : assignment.endDate; if (assignmentEnd < assignmentStart) { return sum; } return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd); }, 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.`, }); } } } const upcomingDemands = project.demandRequirements.filter( (demand) => demand.startDate <= twoWeeksFromNow && demand.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)}.`, }); } } const overrunAssignments = project.assignments.filter( (assignment) => assignment.endDate > project.endDate && (assignment.status === "ACTIVE" || assignment.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)}).`, }); } } const resourceHoursMap = new Map(); for (const assignment of activeAssignments) { const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0; resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay); } for (const resource of resources) { const availability = resource.availability as Record | null; if (!availability) { continue; } const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5; if (dailyAvailableHours <= 0) { continue; } const bookedHours = resourceHoursMap.get(resource.id) ?? 0; const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 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/${dailyAvailableHours.toFixed(1)}h per day).`, }); } else if (utilizationPercent < 40 && bookedHours > 0) { anomalies.push({ type: "utilization", severity: "warning", entityId: resource.id, entityName: resource.displayName, message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`, }); } } anomalies.sort((left, right) => { if (left.severity !== right.severity) { return left.severity === "critical" ? -1 : 1; } return left.type.localeCompare(right.type); }); return { anomalies, summary: summarizeAnomalies(anomalies), }; } // ─── Router ────────────────────────────────────────────────────────────────── export const insightsRouter = createTRPCRouter({ getAnomalyDetail: controllerProcedure.query(async ({ ctx }) => { const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess); return { anomalies: snapshot.anomalies, count: snapshot.anomalies.length, }; }), /** * 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; const provider = settings!.aiProvider ?? "openai"; let narrative = ""; try { const completion = await loggedAiCall(provider, model, prompt.length, () => 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 snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess); return snapshot.anomalies; }), /** * Dashboard-friendly summary: anomaly counts by category + total. */ getInsightsSummary: controllerProcedure.query(async ({ ctx }) => { const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess); return snapshot.summary; }), /** * 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 }; }), });