refactor(api): extract assistant comments slice

This commit is contained in:
2026-03-30 22:29:07 +02:00
parent 73fdf1c6ab
commit ab32c7804b
3 changed files with 213 additions and 143 deletions
+2 -1
View File
@@ -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 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 - 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 ## 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 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 ## Remaining Major Themes
+13 -142
View File
@@ -8,8 +8,6 @@ import {
CreateAssignmentSchema, CreateAssignmentSchema,
AllocationStatus, AllocationStatus,
EstimateStatus, EstimateStatus,
type CommentEntityType,
COMMENT_ENTITY_TYPE_VALUES,
PermissionKey, PermissionKey,
SystemRole, SystemRole,
} from "@capakraken/shared"; } from "@capakraken/shared";
@@ -129,6 +127,11 @@ import {
createDashboardInsightsReportsExecutors, createDashboardInsightsReportsExecutors,
dashboardInsightsReportsToolDefinitions, dashboardInsightsReportsToolDefinitions,
} from "./assistant-tools/dashboard-insights-reports.js"; } from "./assistant-tools/dashboard-insights-reports.js";
import {
commentMutationToolDefinitions,
commentReadToolDefinitions,
createCommentExecutors,
} from "./assistant-tools/comments.js";
import { import {
withToolAccess, withToolAccess,
type ToolAccessRequirements, type ToolAccessRequirements,
@@ -136,7 +139,6 @@ import {
type ToolDef, type ToolDef,
type ToolExecutor, type ToolExecutor,
} from "./assistant-tools/shared.js"; } from "./assistant-tools/shared.js";
import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../lib/comment-entity-registry.js";
export type { ToolContext } from "./assistant-tools/shared.js"; export type { ToolContext } from "./assistant-tools/shared.js";
@@ -2319,22 +2321,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
// ── TASK MANAGEMENT ── // ── TASK MANAGEMENT ──
...notificationTaskToolDefinitions, ...notificationTaskToolDefinitions,
...commentReadToolDefinitions,
{
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"],
},
},
},
{ {
type: "function", type: "function",
function: { function: {
@@ -2399,37 +2386,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
}, },
}, },
}, },
{ ...commentMutationToolDefinitions,
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"],
},
},
},
{ {
type: "function", type: "function",
function: { function: {
@@ -2861,6 +2818,12 @@ const executors = {
createReportCaller, createReportCaller,
createScopedCallerContext, createScopedCallerContext,
}), }),
...createCommentExecutors({
createCommentCaller,
createScopedCallerContext,
toAssistantCommentCreationError,
toAssistantCommentResolveError,
}),
async search_estimates(params: { async search_estimates(params: {
projectCode?: string; query?: string; status?: string; limit?: number; projectCode?: string; query?: string; status?: string; limit?: number;
@@ -3324,30 +3287,6 @@ const executors = {
toAssistantNotificationCreationError, 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: { async lookup_rate(params: {
clientId?: string; clientId?: string;
chapter?: string; chapter?: string;
@@ -3425,74 +3364,6 @@ const executors = {
return caller.generateProjectNarrative({ projectId: params.projectId }); 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: { async query_change_history(params: {
entityType?: string; entityType?: string;
search?: string; search?: string;
@@ -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<CommentListRecord[]>;
create: (params: {
entityType: CommentEntityType;
entityId: string;
body: string;
}) => Promise<CommentRecord>;
resolve: (params: {
id: string;
resolved: boolean;
}) => Promise<CommentRecord>;
};
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<string, ToolExecutor> {
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),
};
},
};
}