Files
CapaKraken/packages/api/src/router/insights.ts
T

489 lines
17 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.
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<string, unknown>): Promise<InsightProjectRecord[]>;
};
resource: {
findMany(args: Record<string, unknown>): Promise<InsightResourceRecord[]>;
};
assignment: {
findMany(args: Record<string, unknown>): Promise<InsightAssignmentLoadRecord[]>;
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Count business days between two dates (MonFri).
*/
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<InsightProjectRecord[]>;
}
async function loadInsightResources(db: InsightsDbAccess["resource"]) {
return db.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
}) as Promise<InsightResourceRecord[]>;
}
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<InsightAssignmentLoadRecord[]>;
}
function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] {
return anomalies.reduce<InsightSnapshot["summary"]>((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<InsightSnapshot> {
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<string, number>();
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<string, number> | 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<string, unknown>) ?? {};
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<string, unknown> | null;
const narrative = (df?.aiNarrative as string) ?? null;
const generatedAt = (df?.aiNarrativeGeneratedAt as string) ?? null;
return { narrative, generatedAt };
}),
});