From af88b3528a795fd8163b442a3c137742e2a7b8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:31:55 +0200 Subject: [PATCH] refactor(api): extract insights procedures --- .../insights-procedure-support.test.ts | 189 +++++++++++++++ .../api/src/__tests__/insights-router.test.ts | 25 ++ .../src/router/insights-procedure-support.ts | 209 +++++++++++++++++ packages/api/src/router/insights.ts | 220 ++---------------- 4 files changed, 438 insertions(+), 205 deletions(-) create mode 100644 packages/api/src/__tests__/insights-procedure-support.test.ts create mode 100644 packages/api/src/router/insights-procedure-support.ts diff --git a/packages/api/src/__tests__/insights-procedure-support.test.ts b/packages/api/src/__tests__/insights-procedure-support.test.ts new file mode 100644 index 0000000..786f132 --- /dev/null +++ b/packages/api/src/__tests__/insights-procedure-support.test.ts @@ -0,0 +1,189 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const aiCompletionCreate = vi.fn(); + +vi.mock("../ai-client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createAiClient: vi.fn(() => ({ + chat: { + completions: { + create: aiCompletionCreate, + }, + }, + })), + isAiConfigured: vi.fn().mockReturnValue(true), + loggedAiCall: vi.fn(async (_provider, _model, _promptLength, fn) => fn()), + parseAiError: vi.fn((error: unknown) => error instanceof Error ? error.message : String(error)), + }; +}); + +import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js"; +import { + countBusinessDays, + generateProjectNarrative, + getCachedNarrative, +} from "../router/insights-procedure-support.js"; + +function createContext(db: Record) { + return { db: db as never }; +} + +describe("insights procedure support", () => { + beforeEach(() => { + vi.clearAllMocks(); + aiCompletionCreate.mockResolvedValue({ + choices: [{ message: { content: "Project is stable but needs one more staffed role." } }], + }); + }); + + it("counts only weekdays between two dates", () => { + expect( + countBusinessDays( + new Date("2026-03-27T00:00:00.000Z"), + new Date("2026-03-31T00:00:00.000Z"), + ), + ).toBe(3); + }); + + it("generates and stores a project narrative", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-31T10:15:00.000Z")); + + const projectFindUnique = vi.fn().mockResolvedValue({ + id: "project_1", + name: "Apollo", + shortCode: "APO", + status: "ACTIVE", + startDate: new Date("2026-03-03T00:00:00.000Z"), + endDate: new Date("2026-04-30T00:00:00.000Z"), + budgetCents: 200_000_00, + dynamicFields: { existingFlag: true }, + demandRequirements: [ + { + id: "dem_1", + role: "Developer", + headcount: 2, + hoursPerDay: 8, + startDate: new Date("2026-03-10T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + status: "OPEN", + _count: { assignments: 1 }, + }, + ], + assignments: [ + { + id: "asn_1", + role: "Developer", + hoursPerDay: 8, + startDate: new Date("2026-03-10T00:00:00.000Z"), + endDate: new Date("2026-04-15T00:00:00.000Z"), + status: "ACTIVE", + dailyCostCents: 50_000, + resource: { displayName: "Carol Danvers" }, + }, + ], + }); + const projectUpdate = vi.fn().mockResolvedValue({ id: "project_1" }); + + try { + const result = await generateProjectNarrative( + createContext({ + project: { + findUnique: projectFindUnique, + update: projectUpdate, + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + id: "singleton", + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + aiMaxCompletionTokens: 220, + aiTemperature: 0.4, + }), + }, + }), + { projectId: "project_1" }, + ); + + expect(isAiConfigured).toHaveBeenCalledWith( + expect.objectContaining({ azureOpenAiDeployment: "gpt-4o-mini" }), + ); + expect(createAiClient).toHaveBeenCalledWith( + expect.objectContaining({ azureOpenAiDeployment: "gpt-4o-mini" }), + ); + expect(loggedAiCall).toHaveBeenCalledOnce(); + expect(aiCompletionCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-4o-mini", + max_completion_tokens: 220, + temperature: 0.4, + messages: expect.arrayContaining([ + expect.objectContaining({ role: "system" }), + expect.objectContaining({ + role: "user", + content: expect.stringContaining("Staffing: 1/2 positions filled (50%)"), + }), + ]), + }), + ); + expect(projectUpdate).toHaveBeenCalledWith({ + where: { id: "project_1" }, + data: { + dynamicFields: { + existingFlag: true, + aiNarrative: "Project is stable but needs one more staffed role.", + aiNarrativeGeneratedAt: "2026-03-31T10:15:00.000Z", + }, + }, + }); + expect(result).toEqual({ + narrative: "Project is stable but needs one more staffed role.", + generatedAt: "2026-03-31T10:15:00.000Z", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("returns the cached narrative for a project", async () => { + const result = await getCachedNarrative( + createContext({ + project: { + findUnique: vi.fn().mockResolvedValue({ + dynamicFields: { + aiNarrative: "Cached summary", + aiNarrativeGeneratedAt: "2026-03-30T08:00:00.000Z", + }, + }), + }, + }), + { projectId: "project_1" }, + ); + + expect(result).toEqual({ + narrative: "Cached summary", + generatedAt: "2026-03-30T08:00:00.000Z", + }); + }); + + it("fails when the project does not exist for the cached narrative lookup", async () => { + await expect( + getCachedNarrative( + createContext({ + project: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }), + { projectId: "missing" }, + ), + ).rejects.toEqual( + expect.objectContaining>({ + code: "NOT_FOUND", + message: "Project not found", + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/insights-router.test.ts b/packages/api/src/__tests__/insights-router.test.ts index 1325a21..df5753b 100644 --- a/packages/api/src/__tests__/insights-router.test.ts +++ b/packages/api/src/__tests__/insights-router.test.ts @@ -195,4 +195,29 @@ describe("insights router", () => { vi.useRealTimers(); } }); + + it("returns the cached narrative through the controller router", async () => { + const projectFindUnique = vi.fn().mockResolvedValue({ + dynamicFields: { + aiNarrative: "Cached summary", + aiNarrativeGeneratedAt: "2026-03-30T08:00:00.000Z", + }, + }); + + const caller = createControllerCaller({ + project: { + findUnique: projectFindUnique, + }, + }); + const result = await caller.getCachedNarrative({ projectId: "project_1" }); + + expect(projectFindUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + select: { dynamicFields: true }, + }); + expect(result).toEqual({ + narrative: "Cached summary", + generatedAt: "2026-03-30T08:00:00.000Z", + }); + }); }); diff --git a/packages/api/src/router/insights-procedure-support.ts b/packages/api/src/router/insights-procedure-support.ts new file mode 100644 index 0000000..0f40b9e --- /dev/null +++ b/packages/api/src/router/insights-procedure-support.ts @@ -0,0 +1,209 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; +import type { TRPCContext } from "../trpc.js"; +import { buildInsightSnapshot, type InsightsDbAccess } from "./insights-anomalies.js"; + +type InsightsProcedureContext = Pick; + +export const projectNarrativeInputSchema = z.object({ projectId: z.string() }); + +type ProjectNarrativeInput = z.infer; + +/** + * Count business days between two dates (Mon-Fri). + */ +export 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; +} + +export async function getAnomalyDetail(ctx: InsightsProcedureContext) { + const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess); + return { + anomalies: snapshot.anomalies, + count: snapshot.anomalies.length, + }; +} + +export async function generateProjectNarrative( + ctx: InsightsProcedureContext, + input: ProjectNarrativeInput, +) { + 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 → Settings.", + }); + } + + 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((sum, demand) => sum + demand.headcount, 0); + const filledDemandHeadcount = project.demandRequirements.reduce( + (sum, demand) => sum + Math.min(demand._count.assignments, demand.headcount), + 0, + ); + const staffingPercent = totalDemandHeadcount > 0 + ? Math.round((filledDemandHeadcount / totalDemandHeadcount) * 100) + : 100; + + const totalCostCents = project.assignments.reduce((sum, assignment) => { + const days = countBusinessDays(assignment.startDate, assignment.endDate); + return sum + assignment.dailyCostCents * days; + }, 0); + + const budgetCents = project.budgetCents; + const budgetUsedPercent = budgetCents > 0 ? Math.round((totalCostCents / budgetCents) * 100) : 0; + + const overrunAssignments = project.assignments.filter( + (assignment) => assignment.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((assignment) => assignment.status === "ACTIVE" || assignment.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 (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `AI call failed: ${parseAiError(error)}`, + }); + } + + if (!narrative) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "AI returned an empty response.", + }); + } + + const generatedAt = new Date().toISOString(); + 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 }; +} + +export async function detectAnomalies(ctx: InsightsProcedureContext) { + const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess); + return snapshot.anomalies; +} + +export async function getInsightsSummary(ctx: InsightsProcedureContext) { + const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess); + return snapshot.summary; +} + +export async function getCachedNarrative( + ctx: InsightsProcedureContext, + input: ProjectNarrativeInput, +) { + 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 dynamicFields = project.dynamicFields as Record | null; + const narrative = (dynamicFields?.aiNarrative as string) ?? null; + const generatedAt = (dynamicFields?.aiNarrativeGeneratedAt as string) ?? null; + return { narrative, generatedAt }; +} diff --git a/packages/api/src/router/insights.ts b/packages/api/src/router/insights.ts index bcce7ab..4d41591 100644 --- a/packages/api/src/router/insights.ts +++ b/packages/api/src/router/insights.ts @@ -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 (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 ────────────────────────────────────────────────────────────────── +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) ?? {}; - 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 }; - }), + .input(projectNarrativeInputSchema) + .query(({ ctx, input }) => getCachedNarrative(ctx, input)), });