From ab32c7804be047f2d81542d47cb170ca0eb037ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 22:29:07 +0200 Subject: [PATCH] refactor(api): extract assistant comments slice --- docs/architecture-hardening-backlog.md | 3 +- packages/api/src/router/assistant-tools.ts | 155 ++------------ .../src/router/assistant-tools/comments.ts | 198 ++++++++++++++++++ 3 files changed, 213 insertions(+), 143 deletions(-) create mode 100644 packages/api/src/router/assistant-tools/comments.ts diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index 2b400ae..a65a97a 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -40,12 +40,13 @@ - 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 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 ## 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 collaboration or analytics block such as comments/audit helpers or 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 analytics or audit block such as `query_change_history` / `get_entity_timeline` or the remaining scenario/narrative/rate-analysis 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 9b8bd92..c85d513 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -8,8 +8,6 @@ import { CreateAssignmentSchema, AllocationStatus, EstimateStatus, - type CommentEntityType, - COMMENT_ENTITY_TYPE_VALUES, PermissionKey, SystemRole, } from "@capakraken/shared"; @@ -129,6 +127,11 @@ import { createDashboardInsightsReportsExecutors, dashboardInsightsReportsToolDefinitions, } from "./assistant-tools/dashboard-insights-reports.js"; +import { + commentMutationToolDefinitions, + commentReadToolDefinitions, + createCommentExecutors, +} from "./assistant-tools/comments.js"; import { withToolAccess, type ToolAccessRequirements, @@ -136,7 +139,6 @@ import { type ToolDef, type ToolExecutor, } from "./assistant-tools/shared.js"; -import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../lib/comment-entity-registry.js"; export type { ToolContext } from "./assistant-tools/shared.js"; @@ -2319,22 +2321,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([ // ── TASK MANAGEMENT ── ...notificationTaskToolDefinitions, - - { - type: "function", - function: { - name: "list_comments", - description: `List comments (with replies) for a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required.`, - parameters: { - type: "object", - properties: { - entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() }, - entityId: { type: "string", description: "Entity ID" }, - }, - required: ["entityType", "entityId"], - }, - }, - }, + ...commentReadToolDefinitions, { type: "function", function: { @@ -2399,37 +2386,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([ }, }, }, - { - type: "function", - function: { - name: "create_comment", - description: `Add a comment to a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required. Supports @mentions. Always confirm with the user first.`, - parameters: { - type: "object", - properties: { - entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() }, - entityId: { type: "string", description: "Entity ID" }, - body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." }, - }, - required: ["entityType", "entityId", "body"], - }, - }, - }, - { - type: "function", - function: { - name: "resolve_comment", - description: `Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required, and only the comment author or an admin can change resolution.`, - parameters: { - type: "object", - properties: { - commentId: { type: "string", description: "Comment ID to resolve" }, - resolved: { type: "boolean", description: "Set to true to resolve, false to unresolve. Default: true" }, - }, - required: ["commentId"], - }, - }, - }, + ...commentMutationToolDefinitions, { type: "function", function: { @@ -2861,6 +2818,12 @@ const executors = { createReportCaller, createScopedCallerContext, }), + ...createCommentExecutors({ + createCommentCaller, + createScopedCallerContext, + toAssistantCommentCreationError, + toAssistantCommentResolveError, + }), async search_estimates(params: { projectCode?: string; query?: string; status?: string; limit?: number; @@ -3324,30 +3287,6 @@ const executors = { toAssistantNotificationCreationError, }), - async list_comments(params: { entityType: CommentEntityType; entityId: string }, ctx: ToolContext) { - const caller = createCommentCaller(createScopedCallerContext(ctx)); - const comments = await caller.list({ - entityType: params.entityType, - entityId: params.entityId, - }); - - return comments.map((c) => ({ - id: c.id, - author: c.author.name ?? c.author.email, - body: c.body, - resolved: c.resolved, - createdAt: c.createdAt.toISOString(), - replyCount: c.replies.length, - replies: c.replies.map((r) => ({ - id: r.id, - author: r.author.name ?? r.author.email, - body: r.body, - resolved: r.resolved, - createdAt: r.createdAt.toISOString(), - })), - })); - }, - async lookup_rate(params: { clientId?: string; chapter?: string; @@ -3425,74 +3364,6 @@ const executors = { return caller.generateProjectNarrative({ projectId: params.projectId }); }, - async create_comment(params: { - entityType: CommentEntityType; - entityId: string; - body: string; - }, ctx: ToolContext) { - if (params.body.length === 0) { - return { error: "Comment body is required." }; - } - - if (params.body.length > 10_000) { - return { error: "Comment body must be at most 10000 characters." }; - } - - const caller = createCommentCaller(createScopedCallerContext(ctx)); - let comment; - try { - comment = await caller.create({ - entityType: params.entityType, - entityId: params.entityId, - body: params.body, - }); - } catch (error) { - const mapped = toAssistantCommentCreationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["comment"], - id: comment.id, - author: comment.author.name ?? comment.author.email, - body: comment.body, - createdAt: comment.createdAt.toISOString(), - }; - }, - - async resolve_comment(params: { - commentId: string; - resolved?: boolean; - }, ctx: ToolContext) { - const caller = createCommentCaller(createScopedCallerContext(ctx)); - let updated; - try { - updated = await caller.resolve({ - id: params.commentId, - resolved: params.resolved !== false, - }); - } catch (error) { - const mapped = toAssistantCommentResolveError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["comment"], - id: updated.id, - resolved: updated.resolved, - author: updated.author.name ?? updated.author.email, - body: updated.body.slice(0, 100), - }; - }, - async query_change_history(params: { entityType?: string; search?: string; diff --git a/packages/api/src/router/assistant-tools/comments.ts b/packages/api/src/router/assistant-tools/comments.ts new file mode 100644 index 0000000..cac2b8b --- /dev/null +++ b/packages/api/src/router/assistant-tools/comments.ts @@ -0,0 +1,198 @@ +import { COMMENT_ENTITY_TYPE_VALUES, type CommentEntityType } from "@capakraken/shared"; +import type { TRPCContext } from "../../trpc.js"; +import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../../lib/comment-entity-registry.js"; +import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type CommentRecord = { + id: string; + body: string; + resolved: boolean; + createdAt: Date; + author: { + name: string | null; + email: string; + }; +}; + +type CommentListRecord = CommentRecord & { + replies: CommentRecord[]; +}; + +type CommentsDeps = { + createCommentCaller: (ctx: TRPCContext) => { + list: (params: { + entityType: CommentEntityType; + entityId: string; + }) => Promise; + create: (params: { + entityType: CommentEntityType; + entityId: string; + body: string; + }) => Promise; + resolve: (params: { + id: string; + resolved: boolean; + }) => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + toAssistantCommentCreationError: ( + error: unknown, + ) => AssistantToolErrorResult | null; + toAssistantCommentResolveError: ( + error: unknown, + ) => AssistantToolErrorResult | null; +}; + +export const commentReadToolDefinitions: ToolDef[] = withToolAccess([ + { + type: "function", + function: { + name: "list_comments", + description: `List comments (with replies) for a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required.`, + parameters: { + type: "object", + properties: { + entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() }, + entityId: { type: "string", description: "Entity ID" }, + }, + required: ["entityType", "entityId"], + }, + }, + }, +], {}); + +export const commentMutationToolDefinitions: ToolDef[] = withToolAccess([ + { + type: "function", + function: { + name: "create_comment", + description: `Add a comment to a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required. Supports @mentions. Always confirm with the user first.`, + parameters: { + type: "object", + properties: { + entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() }, + entityId: { type: "string", description: "Entity ID" }, + body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." }, + }, + required: ["entityType", "entityId", "body"], + }, + }, + }, + { + type: "function", + function: { + name: "resolve_comment", + description: `Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required, and only the comment author or an admin can change resolution.`, + parameters: { + type: "object", + properties: { + commentId: { type: "string", description: "Comment ID to resolve" }, + resolved: { type: "boolean", description: "Set to true to resolve, false to unresolve. Default: true" }, + }, + required: ["commentId"], + }, + }, + }, +], {}); + +export function createCommentExecutors( + deps: CommentsDeps, +): Record { + return { + async list_comments( + params: { entityType: CommentEntityType; entityId: string }, + ctx: ToolContext, + ) { + const caller = deps.createCommentCaller(deps.createScopedCallerContext(ctx)); + const comments = await caller.list({ + entityType: params.entityType, + entityId: params.entityId, + }); + + return comments.map((comment) => ({ + id: comment.id, + author: comment.author.name ?? comment.author.email, + body: comment.body, + resolved: comment.resolved, + createdAt: comment.createdAt.toISOString(), + replyCount: comment.replies.length, + replies: comment.replies.map((reply) => ({ + id: reply.id, + author: reply.author.name ?? reply.author.email, + body: reply.body, + resolved: reply.resolved, + createdAt: reply.createdAt.toISOString(), + })), + })); + }, + + async create_comment( + params: { entityType: CommentEntityType; entityId: string; body: string }, + ctx: ToolContext, + ) { + if (params.body.length === 0) { + return { error: "Comment body is required." }; + } + + if (params.body.length > 10_000) { + return { error: "Comment body must be at most 10000 characters." }; + } + + const caller = deps.createCommentCaller(deps.createScopedCallerContext(ctx)); + let comment; + try { + comment = await caller.create({ + entityType: params.entityType, + entityId: params.entityId, + body: params.body, + }); + } catch (error) { + const mapped = deps.toAssistantCommentCreationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate", + scope: ["comment"], + id: comment.id, + author: comment.author.name ?? comment.author.email, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + }; + }, + + async resolve_comment( + params: { commentId: string; resolved?: boolean }, + ctx: ToolContext, + ) { + const caller = deps.createCommentCaller(deps.createScopedCallerContext(ctx)); + let updated; + try { + updated = await caller.resolve({ + id: params.commentId, + resolved: params.resolved !== false, + }); + } catch (error) { + const mapped = deps.toAssistantCommentResolveError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate", + scope: ["comment"], + id: updated.id, + resolved: updated.resolved, + author: updated.author.name ?? updated.author.email, + body: updated.body.slice(0, 100), + }; + }, + }; +}