import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import type { ChatCompletion, ChatCompletionCreateParamsNonStreaming, } from "openai/resources/chat/completions/completions"; 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 (!settings || !isAiConfigured(settings)) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "AI is not configured. Please set credentials in Admin → Settings.", }); } const configuredSettings = 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(configuredSettings); const model = configuredSettings.azureOpenAiDeployment ?? DEFAULT_OPENAI_MODEL; const maxTokens = configuredSettings.aiMaxCompletionTokens ?? 300; const temperature = configuredSettings.aiTemperature ?? 1; const provider = configuredSettings.aiProvider ?? "openai"; let narrative = ""; try { const completionRequest: ChatCompletionCreateParamsNonStreaming = { 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, stream: false, ...(temperature !== 1 ? { temperature } : {}), }; const completion = await loggedAiCall(provider, model, prompt.length, () => client.chat.completions.create(completionRequest), ); 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 }; }