refactor(api): extract assistant scenario rate-analysis slice

This commit is contained in:
2026-03-30 22:38:01 +02:00
parent d55ab67e04
commit 4d8c91d705
3 changed files with 257 additions and 145 deletions
@@ -0,0 +1,242 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type RateLookupCandidate = {
rateCardName: string;
clientId: string | null;
clientName: string | null;
lineId: string;
chapter: string | null;
seniority: string | null;
roleName: string | null;
costRateCents: number;
billRateCents: number | null;
score: number;
};
type ScenarioSimulationResult = {
baseline: {
totalCostCents: number;
totalHours: number;
headcount: number;
skillCount: number;
};
scenario: {
totalCostCents: number;
totalHours: number;
headcount: number;
skillCount: number;
};
delta: {
costCents: number;
hours: number;
headcount: number;
skillCoveragePct: number;
};
resourceImpacts: unknown[];
warnings: string[];
budgetCents: number;
};
type ScenarioRateAnalysisDeps = {
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
createRateCardCaller: (ctx: TRPCContext) => {
lookupBestMatch: (params: {
clientId?: string;
chapter?: string;
managementLevelId?: string;
roleName?: string;
seniority?: string;
}) => Promise<{
message?: string;
bestMatch: RateLookupCandidate | null;
alternatives: RateLookupCandidate[];
totalCandidates: number;
}>;
};
createScenarioCaller: (ctx: TRPCContext) => {
simulate: (params: {
projectId: string;
changes: Array<{
assignmentId?: string;
resourceId?: string;
roleId?: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
remove?: boolean;
}>;
}) => Promise<ScenarioSimulationResult>;
};
createInsightsCaller: (ctx: TRPCContext) => {
generateProjectNarrative: (params: { projectId: string }) => Promise<unknown>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
fmtEur: (value: number) => string;
};
export const scenarioRateAnalysisToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "lookup_rate",
description: "Find the best matching rate card line for given criteria (client, chapter, management level, role, seniority).",
parameters: {
type: "object",
properties: {
clientId: { type: "string", description: "Client ID to find rate card for" },
chapter: { type: "string", description: "Chapter to match" },
managementLevelId: { type: "string", description: "Management level ID to match" },
roleName: { type: "string", description: "Role name to match" },
seniority: { type: "string", description: "Seniority level to match" },
},
},
},
},
{
type: "function",
function: {
name: "simulate_scenario",
description: "Run a read-only what-if staffing simulation for a project. Shows cost/hours/utilization impact of adding, removing, or changing resource assignments without persisting changes.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID" },
changes: {
type: "array",
items: {
type: "object",
properties: {
assignmentId: { type: "string", description: "Existing assignment ID to modify (omit for new)" },
resourceId: { type: "string", description: "Resource ID" },
roleId: { type: "string", description: "Role ID" },
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
endDate: { type: "string", description: "End date YYYY-MM-DD" },
hoursPerDay: { type: "number", description: "Hours per day" },
remove: { type: "boolean", description: "Set true to remove an existing assignment" },
},
required: ["startDate", "endDate", "hoursPerDay"],
},
description: "Array of staffing changes to simulate",
},
},
required: ["projectId", "changes"],
},
},
},
{
type: "function",
function: {
name: "generate_project_narrative",
description: "Generate an AI-powered executive narrative for a project covering budget, staffing, timeline risk, and action items. Requires AI to be configured.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID" },
},
required: ["projectId"],
},
},
},
], {
lookup_rate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
simulate_scenario: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
generate_project_narrative: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
export function createScenarioRateAnalysisExecutors(
deps: ScenarioRateAnalysisDeps,
): Record<string, ToolExecutor> {
return {
async lookup_rate(
params: {
clientId?: string;
chapter?: string;
managementLevelId?: string;
roleName?: string;
seniority?: string;
},
ctx: ToolContext,
) {
deps.assertPermission(ctx, PermissionKey.VIEW_COSTS);
const caller = deps.createRateCardCaller(deps.createScopedCallerContext(ctx));
const result = await caller.lookupBestMatch(params);
return {
...(result.message ? { message: result.message } : {}),
bestMatch: result.bestMatch
? {
...result.bestMatch,
costRate: deps.fmtEur(result.bestMatch.costRateCents),
billRate: result.bestMatch.billRateCents ? deps.fmtEur(result.bestMatch.billRateCents) : null,
}
: null,
alternatives: result.alternatives.map((alternative) => ({
...alternative,
costRate: deps.fmtEur(alternative.costRateCents),
billRate: alternative.billRateCents ? deps.fmtEur(alternative.billRateCents) : null,
})),
totalCandidates: result.totalCandidates,
};
},
async simulate_scenario(
params: {
projectId: string;
changes: Array<{
assignmentId?: string;
resourceId?: string;
roleId?: string;
startDate: string;
endDate: string;
hoursPerDay: number;
remove?: boolean;
}>;
},
ctx: ToolContext,
) {
const caller = deps.createScenarioCaller(deps.createScopedCallerContext(ctx));
const result = await caller.simulate({
projectId: params.projectId,
changes: params.changes.map((change) => ({
...change,
startDate: new Date(change.startDate),
endDate: new Date(change.endDate),
})),
});
return {
baseline: {
...result.baseline,
totalCost: deps.fmtEur(result.baseline.totalCostCents),
},
scenario: {
...result.scenario,
totalCost: deps.fmtEur(result.scenario.totalCostCents),
},
delta: {
...result.delta,
cost: deps.fmtEur(result.delta.costCents),
},
resourceImpacts: result.resourceImpacts,
warnings: result.warnings,
budgetCents: result.budgetCents,
};
},
async generate_project_narrative(
params: { projectId: string },
ctx: ToolContext,
) {
const caller = deps.createInsightsCaller(deps.createScopedCallerContext(ctx));
return caller.generateProjectNarrative({ projectId: params.projectId });
},
};
}