refactor(api): extract assistant dashboard insights slice

This commit is contained in:
2026-03-30 22:23:05 +02:00
parent 6c6afdd059
commit 73fdf1c6ab
3 changed files with 275 additions and 203 deletions
+2 -1
View File
@@ -39,12 +39,13 @@
- the demand, staffing-suggestion, capacity, and resource-availability assistant helpers now live in their own domain module, keeping staffing orchestration out of the monolithic assistant router without changing the assistant contract - the demand, staffing-suggestion, capacity, and resource-availability assistant helpers now live in their own domain module, keeping staffing orchestration out of the monolithic assistant router without changing the assistant contract
- 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 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 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
## Next Up ## Next Up
Pin the next structural cleanup on the API side: 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. 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 read-model block such as dashboard/insight/report helpers or another tightly bound cluster still in the monolithic router. The next clean slice should stay adjacent to the extracted domains and target one cohesive collaboration or analytics block such as comments/audit helpers or scenario/narrative/rate-analysis helpers still living in the monolithic router.
## Remaining Major Themes ## Remaining Major Themes
+12 -202
View File
@@ -125,6 +125,10 @@ import {
blueprintsRateCardsToolDefinitions, blueprintsRateCardsToolDefinitions,
createBlueprintsRateCardsExecutors, createBlueprintsRateCardsExecutors,
} from "./assistant-tools/blueprints-rate-cards.js"; } from "./assistant-tools/blueprints-rate-cards.js";
import {
createDashboardInsightsReportsExecutors,
dashboardInsightsReportsToolDefinitions,
} from "./assistant-tools/dashboard-insights-reports.js";
import { import {
withToolAccess, withToolAccess,
type ToolAccessRequirements, type ToolAccessRequirements,
@@ -413,16 +417,9 @@ const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial<Record<string, ToolAccessRequiremen
get_entitlement_summary: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] }, get_entitlement_summary: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
set_entitlement: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] }, set_entitlement: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
get_country: { requiresResourceOverview: true }, get_country: { requiresResourceOverview: true },
get_dashboard_detail: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
delete_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, delete_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
generate_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, generate_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
remove_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, remove_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
detect_anomalies: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
get_skill_gaps: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
get_project_health: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
get_budget_forecast: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
get_insights_summary: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
run_report: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
lookup_rate: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, lookup_rate: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
simulate_scenario: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, simulate_scenario: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
generate_project_narrative: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, generate_project_narrative: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
@@ -2315,24 +2312,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
...userAdminToolDefinitions, ...userAdminToolDefinitions,
...userSelfServiceToolDefinitions, ...userSelfServiceToolDefinitions,
...notificationInboxToolDefinitions, ...notificationInboxToolDefinitions,
...dashboardInsightsReportsToolDefinitions,
// ── DASHBOARD DETAIL ──
{
type: "function",
function: {
name: "get_dashboard_detail",
description: "Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview.",
parameters: {
type: "object",
properties: {
section: {
type: "string",
description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, or all",
},
},
},
},
},
// ── ORG UNIT MANAGEMENT ── // ── ORG UNIT MANAGEMENT ──
...orgUnitMutationToolDefinitions, ...orgUnitMutationToolDefinitions,
@@ -2340,103 +2320,6 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
// ── TASK MANAGEMENT ── // ── TASK MANAGEMENT ──
...notificationTaskToolDefinitions, ...notificationTaskToolDefinitions,
// ── INSIGHTS & ANOMALIES ──
{
type: "function",
function: {
name: "detect_anomalies",
description: "Detect anomalies across all active projects: budget burn rate issues, staffing gaps, utilization outliers, and timeline overruns.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_skill_gaps",
description: "Analyze skill supply vs demand across all active projects. Returns which skills are in short supply relative to demand requirements.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_project_health",
description: "Get health scores for all active projects based on budget utilization, staffing completeness, and timeline status.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_budget_forecast",
description: "Get budget utilization and burn rate per active project. Shows total budget, spent, remaining, and whether burn is ahead or behind schedule.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_insights_summary",
description: "Get a summary of anomaly counts by category (budget, staffing, timeline, utilization) plus critical count.",
parameters: { type: "object", properties: {} },
},
},
// ── REPORTS & COMMENTS ──
{
type: "function",
function: {
name: "run_report",
description: "Run a dynamic report query on resources, projects, assignments, or resource-month rows with flexible column selection and filtering.",
parameters: {
type: "object",
properties: {
entity: {
type: "string",
enum: ["resource", "project", "assignment", "resource_month"],
description: "Entity type to query",
},
columns: {
type: "array",
items: { type: "string" },
description: "Column keys to include (e.g. 'displayName', 'chapter', 'country.name')",
},
filters: {
type: "array",
items: {
type: "object",
properties: {
field: { type: "string", description: "Field to filter on" },
op: { type: "string", enum: ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"], description: "Filter operator" },
value: { type: "string", description: "Filter value (string)" },
},
required: ["field", "op", "value"],
},
description: "Filters to apply",
},
periodMonth: {
type: "string",
description: "Required for resource_month reports. Format: YYYY-MM",
},
groupBy: {
type: "string",
description: "Optional scalar field used to group result rows into labeled sections.",
},
sortBy: {
type: "string",
description: "Optional scalar field used to sort rows within the grouped result.",
},
sortDir: {
type: "string",
enum: ["asc", "desc"],
description: "Sort direction for sortBy. Default: asc",
},
limit: { type: "integer", description: "Max results. Default: 50" },
},
required: ["entity", "columns"],
},
},
},
{ {
type: "function", type: "function",
function: { function: {
@@ -2971,6 +2854,13 @@ const executors = {
parseOptionalIsoDate, parseOptionalIsoDate,
fmtDate, fmtDate,
}), }),
...createDashboardInsightsReportsExecutors({
assertPermission,
createDashboardCaller,
createInsightsCaller,
createReportCaller,
createScopedCallerContext,
}),
async search_estimates(params: { async search_estimates(params: {
projectCode?: string; query?: string; status?: string; limit?: number; projectCode?: string; query?: string; status?: string; limit?: number;
@@ -3434,86 +3324,6 @@ const executors = {
toAssistantNotificationCreationError, toAssistantNotificationCreationError,
}), }),
// ── DASHBOARD DETAIL ──
async get_dashboard_detail(params: { section?: string }, ctx: ToolContext) {
const caller = createDashboardCaller(createScopedCallerContext(ctx));
return caller.getDetail({ ...(params.section ? { section: params.section } : {}) });
},
// ── ORG UNIT MANAGEMENT ──
// ── INSIGHTS & ANOMALIES ──────────────────────────────────────────────────
async detect_anomalies(_params: Record<string, never>, ctx: ToolContext) {
const caller = createInsightsCaller(createScopedCallerContext(ctx));
return caller.getAnomalyDetail();
},
async get_skill_gaps(_params: Record<string, never>, ctx: ToolContext) {
const caller = createDashboardCaller(createScopedCallerContext(ctx));
return caller.getSkillGapSummary();
},
async get_project_health(_params: Record<string, never>, ctx: ToolContext) {
const caller = createDashboardCaller(createScopedCallerContext(ctx));
return caller.getProjectHealthDetail();
},
async get_budget_forecast(_params: Record<string, never>, ctx: ToolContext) {
assertPermission(ctx, "viewCosts" as PermissionKey);
const caller = createDashboardCaller(createScopedCallerContext(ctx));
return caller.getBudgetForecastDetail();
},
async get_insights_summary(_params: Record<string, never>, ctx: ToolContext) {
const caller = createInsightsCaller(createScopedCallerContext(ctx));
return caller.getInsightsSummary();
},
async run_report(params: {
entity: string;
columns: string[];
filters?: Array<{
field: string;
op: "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
value: string;
}>;
periodMonth?: string;
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
limit?: number;
}, ctx: ToolContext) {
const entity = params.entity as "resource" | "project" | "assignment" | "resource_month";
if (!["resource", "project", "assignment", "resource_month"].includes(entity)) {
return {
error:
`Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`,
};
}
const caller = createReportCaller(createScopedCallerContext(ctx));
const result = await caller.getReportData({
entity,
columns: params.columns,
filters: params.filters ?? [],
periodMonth: params.periodMonth,
groupBy: params.groupBy,
sortBy: params.sortBy,
sortDir: params.sortDir ?? "asc",
limit: Math.min(params.limit ?? 50, 200),
offset: 0,
});
return {
rows: result.rows,
rowCount: result.rows.length,
totalCount: result.totalCount,
columns: result.columns,
groups: result.groups,
};
},
async list_comments(params: { entityType: CommentEntityType; entityId: string }, ctx: ToolContext) { async list_comments(params: { entityType: CommentEntityType; entityId: string }, ctx: ToolContext) {
const caller = createCommentCaller(createScopedCallerContext(ctx)); const caller = createCommentCaller(createScopedCallerContext(ctx));
const comments = await caller.list({ const comments = await caller.list({
@@ -0,0 +1,261 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type ReportEntity = "resource" | "project" | "assignment" | "resource_month";
type ReportFilterOperator = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
type ReportFilter = {
field: string;
op: ReportFilterOperator;
value: string;
};
type ReportQueryResult = {
rows: unknown[];
totalCount: number;
columns: unknown;
groups: unknown;
};
type DashboardInsightsReportsDeps = {
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
createDashboardCaller: (ctx: TRPCContext) => {
getDetail: (params: { section?: string }) => Promise<unknown>;
getSkillGapSummary: () => Promise<unknown>;
getProjectHealthDetail: () => Promise<unknown>;
getBudgetForecastDetail: () => Promise<unknown>;
};
createInsightsCaller: (ctx: TRPCContext) => {
getAnomalyDetail: () => Promise<unknown>;
getInsightsSummary: () => Promise<unknown>;
};
createReportCaller: (ctx: TRPCContext) => {
getReportData: (params: {
entity: ReportEntity;
columns: string[];
filters: ReportFilter[];
periodMonth?: string;
groupBy?: string;
sortBy?: string;
sortDir: "asc" | "desc";
limit: number;
offset: number;
}) => Promise<ReportQueryResult>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
};
const REPORT_ENTITIES: ReportEntity[] = ["resource", "project", "assignment", "resource_month"];
export const dashboardInsightsReportsToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "get_dashboard_detail",
description: "Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview.",
parameters: {
type: "object",
properties: {
section: {
type: "string",
description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, or all",
},
},
},
},
},
{
type: "function",
function: {
name: "detect_anomalies",
description: "Detect anomalies across all active projects: budget burn rate issues, staffing gaps, utilization outliers, and timeline overruns.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_skill_gaps",
description: "Analyze skill supply vs demand across all active projects. Returns which skills are in short supply relative to demand requirements.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_project_health",
description: "Get health scores for all active projects based on budget utilization, staffing completeness, and timeline status.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_budget_forecast",
description: "Get budget utilization and burn rate per active project. Shows total budget, spent, remaining, and whether burn is ahead or behind schedule.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_insights_summary",
description: "Get a summary of anomaly counts by category (budget, staffing, timeline, utilization) plus critical count.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "run_report",
description: "Run a dynamic report query on resources, projects, assignments, or resource-month rows with flexible column selection and filtering.",
parameters: {
type: "object",
properties: {
entity: {
type: "string",
enum: ["resource", "project", "assignment", "resource_month"],
description: "Entity type to query",
},
columns: {
type: "array",
items: { type: "string" },
description: "Column keys to include (e.g. 'displayName', 'chapter', 'country.name')",
},
filters: {
type: "array",
items: {
type: "object",
properties: {
field: { type: "string", description: "Field to filter on" },
op: { type: "string", enum: ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"], description: "Filter operator" },
value: { type: "string", description: "Filter value (string)" },
},
required: ["field", "op", "value"],
},
description: "Filters to apply",
},
periodMonth: {
type: "string",
description: "Required for resource_month reports. Format: YYYY-MM",
},
groupBy: {
type: "string",
description: "Optional scalar field used to group result rows into labeled sections.",
},
sortBy: {
type: "string",
description: "Optional scalar field used to sort rows within the grouped result.",
},
sortDir: {
type: "string",
enum: ["asc", "desc"],
description: "Sort direction for sortBy. Default: asc",
},
limit: { type: "integer", description: "Max results. Default: 50" },
},
required: ["entity", "columns"],
},
},
},
], {
get_dashboard_detail: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
detect_anomalies: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_skill_gaps: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_project_health: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_budget_forecast: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_insights_summary: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
run_report: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
export function createDashboardInsightsReportsExecutors(
deps: DashboardInsightsReportsDeps,
): Record<string, ToolExecutor> {
return {
async get_dashboard_detail(params: { section?: string }, ctx: ToolContext) {
const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx));
return caller.getDetail({ ...(params.section ? { section: params.section } : {}) });
},
async detect_anomalies(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createInsightsCaller(deps.createScopedCallerContext(ctx));
return caller.getAnomalyDetail();
},
async get_skill_gaps(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx));
return caller.getSkillGapSummary();
},
async get_project_health(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx));
return caller.getProjectHealthDetail();
},
async get_budget_forecast(_params: Record<string, never>, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.VIEW_COSTS);
const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx));
return caller.getBudgetForecastDetail();
},
async get_insights_summary(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createInsightsCaller(deps.createScopedCallerContext(ctx));
return caller.getInsightsSummary();
},
async run_report(params: {
entity: string;
columns: string[];
filters?: ReportFilter[];
periodMonth?: string;
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
limit?: number;
}, ctx: ToolContext) {
const entity = params.entity as ReportEntity;
if (!REPORT_ENTITIES.includes(entity)) {
return {
error:
`Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`,
};
}
const caller = deps.createReportCaller(deps.createScopedCallerContext(ctx));
const result = await caller.getReportData({
entity,
columns: params.columns,
filters: params.filters ?? [],
...(params.periodMonth !== undefined ? { periodMonth: params.periodMonth } : {}),
...(params.groupBy !== undefined ? { groupBy: params.groupBy } : {}),
...(params.sortBy !== undefined ? { sortBy: params.sortBy } : {}),
sortDir: params.sortDir ?? "asc",
limit: Math.min(params.limit ?? 50, 200),
offset: 0,
});
return {
rows: result.rows,
rowCount: result.rows.length,
totalCount: result.totalCount,
columns: result.columns,
groups: result.groups,
};
},
};
}