diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index d2b4177..1a6fc43 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -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 diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index 24e2ffd..992e10f 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -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 ({ - ...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, ctx: ToolContext) { const caller = createImportExportCaller(createScopedCallerContext(ctx)); const csv = await caller.exportResourcesCSV(); diff --git a/packages/api/src/router/assistant-tools/scenario-rate-analysis.ts b/packages/api/src/router/assistant-tools/scenario-rate-analysis.ts new file mode 100644 index 0000000..35fe54b --- /dev/null +++ b/packages/api/src/router/assistant-tools/scenario-rate-analysis.ts @@ -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; + }; + createInsightsCaller: (ctx: TRPCContext) => { + generateProjectNarrative: (params: { projectId: string }) => Promise; + }; + 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 { + 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 }); + }, + }; +}