refactor(api): extract insights procedures
This commit is contained in:
@@ -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<typeof import("../ai-client.js")>();
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<Partial<TRPCError>>({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Project not found",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -195,4 +195,29 @@ describe("insights router", () => {
|
|||||||
vi.useRealTimers();
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<TRPCContext, "db">;
|
||||||
|
|
||||||
|
export const projectNarrativeInputSchema = z.object({ projectId: z.string() });
|
||||||
|
|
||||||
|
type ProjectNarrativeInput = z.infer<typeof projectNarrativeInputSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>) ?? {};
|
||||||
|
|
||||||
|
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<string, unknown> | null;
|
||||||
|
const narrative = (dynamicFields?.aiNarrative as string) ?? null;
|
||||||
|
const generatedAt = (dynamicFields?.aiNarrativeGeneratedAt as string) ?? null;
|
||||||
|
return { narrative, generatedAt };
|
||||||
|
}
|
||||||
@@ -1,215 +1,25 @@
|
|||||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
|
||||||
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
|
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
|
||||||
import { TRPCError } from "@trpc/server";
|
import {
|
||||||
import { z } from "zod";
|
detectAnomalies,
|
||||||
import { buildInsightSnapshot, type InsightsDbAccess } from "./insights-anomalies.js";
|
generateProjectNarrative,
|
||||||
|
getAnomalyDetail,
|
||||||
/**
|
getCachedNarrative,
|
||||||
* Count business days between two dates (Mon–Fri).
|
getInsightsSummary,
|
||||||
*/
|
projectNarrativeInputSchema,
|
||||||
function countBusinessDays(start: Date, end: Date): number {
|
} from "./insights-procedure-support.js";
|
||||||
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({
|
export const insightsRouter = createTRPCRouter({
|
||||||
getAnomalyDetail: controllerProcedure.query(async ({ ctx }) => {
|
getAnomalyDetail: controllerProcedure.query(({ ctx }) => getAnomalyDetail(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
|
generateProjectNarrative: controllerProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(projectNarrativeInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(({ ctx, input }) => generateProjectNarrative(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) {
|
detectAnomalies: controllerProcedure.query(({ ctx }) => detectAnomalies(ctx)),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAiConfigured(settings)) {
|
getInsightsSummary: controllerProcedure.query(({ ctx }) => getInsightsSummary(ctx)),
|
||||||
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
|
getCachedNarrative: controllerProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(projectNarrativeInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => getCachedNarrative(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 };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user