refactor(api): extract insights procedures

This commit is contained in:
2026-03-31 20:31:55 +02:00
parent a2f9b713c1
commit af88b3528a
4 changed files with 438 additions and 205 deletions
+15 -205
View File
@@ -1,215 +1,25 @@
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { buildInsightSnapshot, type InsightsDbAccess } from "./insights-anomalies.js";
/**
* 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;
}
// ─── Router ──────────────────────────────────────────────────────────────────
import {
detectAnomalies,
generateProjectNarrative,
getAnomalyDetail,
getCachedNarrative,
getInsightsSummary,
projectNarrativeInputSchema,
} from "./insights-procedure-support.js";
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,
};
}),
getAnomalyDetail: controllerProcedure.query(({ ctx }) => getAnomalyDetail(ctx)),
/**
* 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" } }),
]);
.input(projectNarrativeInputSchema)
.mutation(({ ctx, input }) => generateProjectNarrative(ctx, input)),
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
detectAnomalies: controllerProcedure.query(({ ctx }) => detectAnomalies(ctx)),
if (!isAiConfigured(settings)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set credentials in Admin \u2192 Settings.",
});
}
getInsightsSummary: controllerProcedure.query(({ ctx }) => getInsightsSummary(ctx)),
// 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 };
}),
.input(projectNarrativeInputSchema)
.query(({ ctx, input }) => getCachedNarrative(ctx, input)),
});