diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index d70d5b1..dcbcdf6 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -38,12 +38,13 @@ - the project search, lifecycle, and cover-art helpers now live in their own domain module, keeping project 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 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 ## Next Up 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 block such as blueprint/rate-card/reference-data reads or another tightly bound CRUD/read-model cluster still in the monolithic router. +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. ## Remaining Major Themes diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index 8f8072f..08be140 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -121,6 +121,10 @@ import { resourceMutationToolDefinitions, resourceReadToolDefinitions, } from "./assistant-tools/resources.js"; +import { + blueprintsRateCardsToolDefinitions, + createBlueprintsRateCardsExecutors, +} from "./assistant-tools/blueprints-rate-cards.js"; import { withToolAccess, type ToolAccessRequirements, @@ -408,16 +412,6 @@ const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial, ctx: ToolContext) { - const caller = createBlueprintCaller(createScopedCallerContext(ctx)); - const blueprints = await caller.listSummaries(); - return blueprints.map((b) => ({ - id: b.id, - name: b.name, - projectCount: b._count.projects, - })); - }, - - async get_blueprint(params: { identifier: string }, ctx: ToolContext) { - const caller = createBlueprintCaller(createScopedCallerContext(ctx)); - const bp = await resolveEntityOrAssistantError( - () => caller.getByIdentifier({ identifier: params.identifier }), - `Blueprint not found: ${params.identifier}`, - ); - if ("error" in bp) { - return bp; - } - return { - id: bp.id, - name: bp.name, - fieldDefs: bp.fieldDefs, - rolePresets: bp.rolePresets, - }; - }, - - // ── RATE CARDS ── - - async list_rate_cards(params: { query?: string; limit?: number }, ctx: ToolContext) { - const caller = createRateCardCaller(createScopedCallerContext(ctx)); - const cards = await caller.list({ - isActive: true, - ...(params.query ? { search: params.query } : {}), - }); - return cards.map((c) => ({ - id: c.id, - name: c.name, - effectiveFrom: fmtDate(c.effectiveFrom), - effectiveTo: fmtDate(c.effectiveTo), - lineCount: c._count.lines, - })).slice(0, Math.min(params.limit ?? 20, 50)); - }, - - async resolve_rate(params: { resourceId?: string; roleName?: string; date?: string }, ctx: ToolContext) { - const caller = createRateCardCaller(createScopedCallerContext(ctx)); - const date = parseOptionalIsoDate(params.date, "date"); - if (params.resourceId) { - const resource = await resolveResourceIdentifier(ctx, params.resourceId); - if ("error" in resource) { - return resource; - } - return caller.resolveBestRate({ - resourceId: resource.id, - ...(date ? { date } : {}), - }); - } - - return caller.resolveBestRate({ - ...(params.roleName ? { roleName: params.roleName } : {}), - ...(date ? { date } : {}), - }); - }, - // ── ESTIMATES ── ...createEstimateExecutors({ assertPermission, diff --git a/packages/api/src/router/assistant-tools/blueprints-rate-cards.ts b/packages/api/src/router/assistant-tools/blueprints-rate-cards.ts new file mode 100644 index 0000000..5e8cc4d --- /dev/null +++ b/packages/api/src/router/assistant-tools/blueprints-rate-cards.ts @@ -0,0 +1,207 @@ +import { SystemRole } from "@capakraken/shared"; +import type { TRPCContext } from "../../trpc.js"; +import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type BlueprintRecord = { + id: string; + name: string; + fieldDefs: unknown; + rolePresets: unknown; +}; + +type ResolvedResource = { + id: string; +}; + +type BlueprintsRateCardsDeps = { + createBlueprintCaller: (ctx: TRPCContext) => { + listSummaries: () => Promise>; + getByIdentifier: (params: { identifier: string }) => Promise; + }; + createRateCardCaller: (ctx: TRPCContext) => { + list: (params: { + isActive: boolean; + search?: string; + }) => Promise>; + resolveBestRate: (params: { + resourceId?: string; + roleName?: string; + date?: Date; + }) => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + resolveResourceIdentifier: ( + ctx: ToolContext, + identifier: string, + ) => Promise; + resolveEntityOrAssistantError: ( + resolve: () => Promise, + notFoundMessage: string, + ) => Promise; + parseOptionalIsoDate: ( + value: string | undefined, + fieldName: string, + ) => Date | undefined; + fmtDate: (value: Date | null | undefined) => string | null; +}; + +export const blueprintsRateCardsToolDefinitions: ToolDef[] = withToolAccess([ + { + type: "function", + function: { + name: "list_blueprints", + description: "List available project blueprints with their field definitions.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_blueprint", + description: "Get detailed blueprint with all field definitions and role presets.", + parameters: { + type: "object", + properties: { + identifier: { type: "string", description: "Blueprint ID or name (partial match)" }, + }, + required: ["identifier"], + }, + }, + }, + { + type: "function", + function: { + name: "list_rate_cards", + description: "List rate cards with their effective dates and line items.", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search by name" }, + limit: { type: "integer", description: "Max results. Default: 20" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "resolve_rate", + description: "Look up the applicable rate for a resource, role, or management level from rate cards.", + parameters: { + type: "object", + properties: { + resourceId: { type: "string", description: "Resource ID or name" }, + roleName: { type: "string", description: "Role name" }, + date: { type: "string", description: "Date to check rate for (YYYY-MM-DD). Default: today" }, + }, + }, + }, + }, +], { + list_blueprints: { + requiresPlanningRead: true, + }, + get_blueprint: { + requiresPlanningRead: true, + }, + list_rate_cards: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + requiresCostView: true, + }, + resolve_rate: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + requiresCostView: true, + }, +}); + +export function createBlueprintsRateCardsExecutors( + deps: BlueprintsRateCardsDeps, +): Record { + return { + async list_blueprints(_params: Record, ctx: ToolContext) { + const caller = deps.createBlueprintCaller(deps.createScopedCallerContext(ctx)); + const blueprints = await caller.listSummaries(); + return blueprints.map((blueprint) => ({ + id: blueprint.id, + name: blueprint.name, + projectCount: blueprint._count.projects, + })); + }, + + async get_blueprint(params: { identifier: string }, ctx: ToolContext) { + const caller = deps.createBlueprintCaller(deps.createScopedCallerContext(ctx)); + const blueprint = await deps.resolveEntityOrAssistantError( + () => caller.getByIdentifier({ identifier: params.identifier }), + `Blueprint not found: ${params.identifier}`, + ); + if ("error" in blueprint) { + return blueprint; + } + + return { + id: blueprint.id, + name: blueprint.name, + fieldDefs: blueprint.fieldDefs, + rolePresets: blueprint.rolePresets, + }; + }, + + async list_rate_cards( + params: { query?: string; limit?: number }, + ctx: ToolContext, + ) { + const caller = deps.createRateCardCaller(deps.createScopedCallerContext(ctx)); + const cards = await caller.list({ + isActive: true, + ...(params.query ? { search: params.query } : {}), + }); + + return cards + .map((card) => ({ + id: card.id, + name: card.name, + effectiveFrom: deps.fmtDate(card.effectiveFrom), + effectiveTo: deps.fmtDate(card.effectiveTo), + lineCount: card._count.lines, + })) + .slice(0, Math.min(params.limit ?? 20, 50)); + }, + + async resolve_rate( + params: { resourceId?: string; roleName?: string; date?: string }, + ctx: ToolContext, + ) { + const caller = deps.createRateCardCaller(deps.createScopedCallerContext(ctx)); + const date = deps.parseOptionalIsoDate(params.date, "date"); + + if (params.resourceId) { + const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) { + return resource; + } + + return caller.resolveBestRate({ + resourceId: resource.id, + ...(date ? { date } : {}), + }); + } + + return caller.resolveBestRate({ + ...(params.roleName ? { roleName: params.roleName } : {}), + ...(date ? { date } : {}), + }); + }, + }; +}