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
+2 -1
View File
@@ -40,6 +40,7 @@
- the resource search, detail, and lifecycle assistant helpers now live in their own domain module, keeping resource CRUD orchestration out of the monolithic assistant router without changing the assistant contract
- the blueprint and rate-card read helpers now live in their own domain module, keeping reference-data and pricing lookups out of the monolithic assistant router without changing the assistant contract
- the dashboard detail, insight summary, anomaly detection, and dynamic report read helpers now live in their own domain module, keeping controller-side analytics reads out of the monolithic assistant router without changing the assistant contract
- the scenario simulation, project narrative, and rate lookup assistant helpers now live in their own domain module, keeping the remaining controller-side scenario/AI analytics wiring out of the monolithic assistant router without changing the assistant contract
- the comment listing and comment mutation assistant helpers now live in their own domain module, keeping collaboration-side comment flows out of the monolithic assistant router without changing the assistant contract
- the audit-history assistant helpers now live in their own domain module, keeping controller-side change-history reads out of the monolithic assistant router without changing the assistant contract
@@ -47,7 +48,7 @@
Pin the next structural cleanup on the API side:
continue splitting `packages/api/src/router/assistant-tools.ts` into domain-oriented tool modules without changing the public tool contract.
The next clean slice should stay adjacent to the extracted domains and target one cohesive analytics block such as the remaining scenario/narrative/rate-analysis helpers still living in the monolithic router.
The next clean slice should stay adjacent to the extracted domains and target one cohesive leftover block such as the remaining import/export or staged-dispo helpers still living in the monolithic router.
## Remaining Major Themes
+13 -144
View File
@@ -127,6 +127,10 @@ import {
createDashboardInsightsReportsExecutors,
dashboardInsightsReportsToolDefinitions,
} from "./assistant-tools/dashboard-insights-reports.js";
import {
createScenarioRateAnalysisExecutors,
scenarioRateAnalysisToolDefinitions,
} from "./assistant-tools/scenario-rate-analysis.js";
import {
commentMutationToolDefinitions,
commentReadToolDefinitions,
@@ -426,9 +430,6 @@ const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial<Record<string, ToolAccessRequiremen
delete_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
generate_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
remove_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
lookup_rate: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
simulate_scenario: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
generate_project_narrative: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
export_resources_csv: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
export_projects_csv: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
import_csv_data: {
@@ -2324,70 +2325,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
// ── TASK MANAGEMENT ──
...notificationTaskToolDefinitions,
...commentReadToolDefinitions,
{
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" },
},
},
},
},
// ── SCENARIO & AI ──
{
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"],
},
},
},
...scenarioRateAnalysisToolDefinitions,
...commentMutationToolDefinitions,
...auditHistoryToolDefinitions,
{
@@ -2787,6 +2725,14 @@ const executors = {
createReportCaller,
createScopedCallerContext,
}),
...createScenarioRateAnalysisExecutors({
assertPermission,
createRateCardCaller,
createScenarioCaller,
createInsightsCaller,
createScopedCallerContext,
fmtEur,
}),
...createCommentExecutors({
createCommentCaller,
createScopedCallerContext,
@@ -3260,83 +3206,6 @@ const executors = {
toAssistantNotificationCreationError,
}),
async lookup_rate(params: {
clientId?: string;
chapter?: string;
managementLevelId?: string;
roleName?: string;
seniority?: string;
}, ctx: ToolContext) {
assertPermission(ctx, "viewCosts" as PermissionKey);
const caller = createRateCardCaller(createScopedCallerContext(ctx));
const result = await caller.lookupBestMatch(params);
return {
...(result.message ? { message: result.message } : {}),
bestMatch: result.bestMatch
? {
...result.bestMatch,
costRate: fmtEur(result.bestMatch.costRateCents),
billRate: result.bestMatch.billRateCents ? fmtEur(result.bestMatch.billRateCents) : null,
}
: null,
alternatives: result.alternatives.map((alternative) => ({
...alternative,
costRate: fmtEur(alternative.costRateCents),
billRate: alternative.billRateCents ? fmtEur(alternative.billRateCents) : null,
})),
totalCandidates: result.totalCandidates,
};
},
// ── SCENARIO & AI ─────────────────────────────────────────────────────────
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 = createScenarioCaller(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: fmtEur(result.baseline.totalCostCents),
},
scenario: {
...result.scenario,
totalCost: fmtEur(result.scenario.totalCostCents),
},
delta: {
...result.delta,
cost: fmtEur(result.delta.costCents),
},
resourceImpacts: result.resourceImpacts,
warnings: result.warnings,
budgetCents: result.budgetCents,
};
},
async generate_project_narrative(params: { projectId: string }, ctx: ToolContext) {
const caller = createInsightsCaller(createScopedCallerContext(ctx));
return caller.generateProjectNarrative({ projectId: params.projectId });
},
async export_resources_csv(_params: Record<string, never>, ctx: ToolContext) {
const caller = createImportExportCaller(createScopedCallerContext(ctx));
const csv = await caller.exportResourcesCSV();
@@ -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 });
},
};
}