1
23import { Inject, Injectable } from "@nestjs/common";
24import type { Request } from "express";
25import type { Prisma, PrismaClient } from "@prisma/client";
26
27import { PrismaService } from "../../../common/prisma.service.js";
28import { AuditService } from "../../../common/audit.service.js";
29import { ApiError } from "../../../common/errors.js";
30import { logger } from "../../../common/logger.js";
31import { assertCan, actorLevel } from "../../../lib/authorize.js";
32import { AI_GATEWAY, mapAiError, primeWorkspaceCache, type AiBudgetGuard } from "../gateway-provider.js";
33import { AiBudgetGuardService } from "../budget-guard.service.js";
34import { AiRateLimitService } from "../ai-rate-limit.js";
35import { stripHtml } from "../prompts.js";
36import { SearchService } from "../../search/search.service.js";
37import { PageService } from "../../pages/page.service.js";
38import { BlockService } from "../../blocks/block.service.js";
39import { TasksService } from "../../tasks/tasks.service.js";
40import type { AiGateway } from "@factory/ai";
41
42import {
43 ASSISTANT_CONV_PREFIX,
44 EXTRACT_ACTION_ITEMS_TOOL,
45 EXTRACT_ACTIONS_SYSTEM_PROMPT,
46 FIND_DUPLICATE_GROUPS_TOOL,
47 FIND_DUPLICATES_SYSTEM_PROMPT,
48 FIND_STALE_PAGES_TOOL,
49 FIND_STALE_SYSTEM_PROMPT,
50 GENERATE_FAQS_SYSTEM_PROMPT,
51 GENERATE_FAQS_TOOL,
52 SUGGEST_METADATA_SYSTEM_PROMPT,
53 SUGGEST_PAGE_METADATA_TOOL,
54 renderExtractPrompt,
55 renderFaqsPrompt,
56 renderDuplicatesPrompt,
57 renderMetadataPrompt,
58 renderStalePrompt,
59 validateDuplicateGroups,
60 validateExtractActions,
61 validateGenerateFaqs,
62 validateMetadataSuggestion,
63 validateStalePages,
64} from "./assistant-prompts.js";
65import type {
66 AiExtractActionsInput,
67 AiGenerateFaqsInput,
68 AiFindDuplicatesInput,
69 AiFindStaleInput,
70 AiSuggestMetadataInput,
71 AiApplyMetadataInput,
72} from "./assistant.dto.js";
73
74type AssistantTool =
75 | typeof EXTRACT_ACTION_ITEMS_TOOL
76 | typeof GENERATE_FAQS_TOOL
77 | typeof FIND_DUPLICATE_GROUPS_TOOL
78 | typeof FIND_STALE_PAGES_TOOL
79 | typeof SUGGEST_PAGE_METADATA_TOOL;
80
81const CANDIDATE_CAP = 200;
82const CANDIDATE_FLOOR = 10;
83
84@Injectable()
85export class AssistantService {
86 constructor(
87 @Inject(PrismaService) private readonly prisma: PrismaClient,
88 @Inject(AuditService) private readonly audit: AuditService,
89 @Inject(AI_GATEWAY) private readonly gateway: AiGateway,
90 @Inject(AiBudgetGuardService) private readonly guardService: AiBudgetGuardService,
91 @Inject(AiRateLimitService) private readonly rateLimit: AiRateLimitService,
92 @Inject(SearchService) private readonly search: SearchService,
93 @Inject(PageService) private readonly pages: PageService,
94 @Inject(BlockService) private readonly blocks: BlockService,
95 @Inject(TasksService) private readonly tasks: TasksService,
96 ) {}
97
98 private get guard(): AiBudgetGuard {
99 return this.guardService.guard;
100 }
101
102
103
104
105 private async preGate(workspaceId: string, userId: string, req: Request): Promise<void> {
106 const rl = await this.rateLimit.check(userId);
107 if (!rl.allowed) {
108 throw new ApiError("RATE_LIMITED", 429, "AI call limit reached.", [
109 { reason: "ai_call_rate_limited", retryAfterSeconds: rl.retryAfterSeconds },
110 ]);
111 }
112 await primeWorkspaceCache(this.prisma, workspaceId);
113 try {
114 await this.guard.check(workspaceId, undefined, req);
115 } catch (err) {
116 throw mapAiError(err);
117 }
118 }
119
120
121 private async beginConversation(
122 workspaceId: string,
123 userId: string,
124 action: string,
125 userContent: string,
126 context: Record<string, unknown>,
127 ) {
128 const conversation = await this.prisma.aiConversation.create({
129 data: {
130 workspaceId,
131 userId,
132 title: `${ASSISTANT_CONV_PREFIX}${action}`,
133 context: context as object,
134 },
135 });
136 await this.prisma.aiMessage.create({
137 data: { conversationId: conversation.id, role: "user", content: userContent },
138 });
139 return conversation;
140 }
141
142 private async persistAssistant(
143 conversationId: string,
144 model: string,
145 usage: { in: number; out: number },
146 metadata: Record<string, unknown>,
147 ): Promise<string | undefined> {
148 try {
149 const row = await this.prisma.aiMessage.create({
150 data: {
151 conversationId,
152 role: "assistant",
153 content: metadata.error ? `error: ${String(metadata.error)}` : "ok",
154 model,
155 ...(usage.in > 0 ? { tokensIn: usage.in } : {}),
156 ...(usage.out > 0 ? { tokensOut: usage.out } : {}),
157 metadata: metadata as object,
158 },
159 });
160 return row.id;
161 } catch (err) {
162 logger.warn(`assistant: persist failed workspace conversation=${conversationId}: ${String(err)}`);
163 return undefined;
164 }
165 }
166
167
171 private async chatTool(opts: {
172 workspaceId: string;
173 userId: string;
174 system: string;
175 user: string;
176 tool: AssistantTool;
177 action: string;
178 feedback?: string;
179 }): Promise<{ args: unknown; usage: { in: number; out: number } }> {
180 const messages: Array<{ role: "system" | "user"; content: string }> = [
181 { role: "system", content: opts.system },
182 {
183 role: "user",
184 content: opts.feedback
185 ? `${opts.user}\n\nPrevious attempt was INVALID: ${opts.feedback}\nCorrect it and call the tool again.`
186 : opts.user,
187 },
188 ];
189 let args: unknown;
190 let usage = { in: 0, out: 0 };
191 for await (const chunk of this.gateway.chat({
192 workspaceId: opts.workspaceId,
193 userId: opts.userId,
194 capability: "assistant",
195 messages,
196 tools: [opts.tool],
197 toolChoice: { name: opts.tool.name },
198 metadata: { mode: "assistant", action: opts.action },
199 })) {
200 if (chunk.toolCallDelta) args = chunk.toolCallDelta.arguments;
201 if (chunk.done && chunk.usage) usage = { in: chunk.usage.in, out: chunk.usage.out };
202 }
203 if (args === undefined) {
204 throw ApiError.unprocessable("The model returned no tool call.", [{ reason: "ai_invalid_structure", issue: "no tool call" }]);
205 }
206 return { args, usage };
207 }
208
209
210 private async callValidated<T>(
211 opts: {
212 workspaceId: string;
213 userId: string;
214 system: string;
215 user: string;
216 tool: AssistantTool;
217 action: string;
218 },
219 validate: (raw: unknown) => { ok: true; value: T; dropped?: string[] } | { ok: false; feedback: string },
220 ): Promise<{ value: T; dropped: string[]; usage: { in: number; out: number }; invalid?: string }> {
221 let attempt = await this.chatTool(opts);
222 let parsed = validate(attempt.args);
223 if (!parsed.ok) {
224 attempt = await this.chatTool({ ...opts, feedback: parsed.feedback });
225 parsed = validate(attempt.args);
226 }
227 if (!parsed.ok) {
228 return { value: undefined as T, dropped: [], usage: attempt.usage, invalid: parsed.feedback };
229 }
230 return { value: parsed.value, dropped: parsed.dropped ?? [], usage: attempt.usage };
231 }
232
233
234
235
236 private async pageText(pageId: string, cap: number): Promise<string> {
237 const rows = await this.prisma.block.findMany({
238 where: { pageId, deletedAt: null },
239 select: { content: true, position: true },
240 orderBy: { position: "asc" },
241 });
242 return rows.map((r) => blockText(r.content)).filter(Boolean).join("\n").slice(0, cap);
243 }
244
245
251 private async viewableCandidates(
252 workspaceId: string,
253 userId: string,
254 opts: { pageIds?: string[]; limit: number; olderThan?: Date },
255 ): Promise<Array<{ id: string; title: string; text: string; updatedAt: string }>> {
256 const limit = Math.max(CANDIDATE_FLOOR, Math.min(CANDIDATE_CAP, opts.limit));
257 const where: Prisma.PageWhereInput = {
258 workspaceId,
259 deletedAt: null,
260 trashedAt: null,
261 ...(opts.pageIds ? { id: { in: opts.pageIds } } : {}),
262 ...(opts.olderThan ? { updatedAt: { lt: opts.olderThan } } : {}),
263 };
264 const pool = await this.prisma.page.findMany({
265 where,
266 select: { id: true, title: true, updatedAt: true },
267 orderBy: { updatedAt: "desc" },
268 take: opts.pageIds ? undefined : limit,
269 });
270
271
272
273 const visible: Array<{ id: string; title: string; updatedAt: Date }> = [];
274 for (const p of pool) {
275 const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: p.id } });
276 if (level === "NONE") {
277 logger.info(`assistant: page ${p.id} dropped from candidates — user ${userId} lacks VIEW`);
278 continue;
279 }
280 visible.push(p);
281 }
282
283 const ids = visible.map((p) => p.id);
284 const blockRows = ids.length
285 ? await this.prisma.block.findMany({
286 where: { pageId: { in: ids }, deletedAt: null },
287 select: { pageId: true, content: true, position: true },
288 orderBy: [{ pageId: "asc" }, { position: "asc" }],
289 })
290 : [];
291 const textByPage = new Map<string, string>();
292 for (const b of blockRows) {
293 const t = blockText(b.content);
294 if (!t) continue;
295 textByPage.set(b.pageId, (textByPage.get(b.pageId) ?? "") + (textByPage.get(b.pageId) ? "\n" : "") + t);
296 }
297 return visible.map((p) => ({
298 id: p.id,
299 title: p.title,
300 updatedAt: p.updatedAt.toISOString(),
301 text: (textByPage.get(p.id) ?? "").slice(0, 1200),
302 }));
303 }
304
305
306
307
308 async extractActions(workspaceId: string, userId: string, input: AiExtractActionsInput, req: Request) {
309 await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
310 await this.preGate(workspaceId, userId, req);
311
312 const conversation = await this.beginConversation(
313 workspaceId,
314 userId,
315 "extract-actions",
316 `extract-actions: ${input.text.slice(0, 200)}`,
317 { feature: "assistant.extract-actions", chars: input.text.length },
318 );
319
320 const result = await this.callValidated(
321 {
322 workspaceId,
323 userId,
324 system: EXTRACT_ACTIONS_SYSTEM_PROMPT,
325 user: renderExtractPrompt(input.text),
326 tool: EXTRACT_ACTION_ITEMS_TOOL,
327 action: "extract-actions",
328 },
329 validateExtractActions,
330 );
331 if ((result as { invalid?: string }).invalid) {
332 await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
333 feature: "assistant.extract-actions",
334 error: "ai_invalid_structure",
335 });
336 throw ApiError.unprocessable("The AI returned an invalid action list.", [
337 { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid },
338 ]);
339 }
340
341 const tasksOut: Array<{ id: string; title: string; dueDate?: string }> = [];
342 for (const a of result.value) {
343 const row = await this.tasks.create(
344 workspaceId,
345 userId,
346 { title: a.title, dueAt: a.dueDate ?? null },
347 req,
348 );
349 tasksOut.push({ id: row.id, title: a.title, dueDate: a.dueDate });
350 }
351
352 const assistantId = await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
353 feature: "assistant.extract-actions",
354 taskIds: tasksOut.map((t) => t.id),
355 });
356 await this.audit.audit(req, req?.auth, {
357 action: "ai.assistant_extract_actions",
358 resourceType: "task",
359 resourceId: tasksOut[0]?.id ?? assistantId ?? conversation.id,
360 workspaceId,
361 metadata: { taskCount: tasksOut.length },
362 });
363 return { tasks: tasksOut, tokensIn: result.usage.in, tokensOut: result.usage.out };
364 }
365
366
367
368
369 async generateFaqs(workspaceId: string, userId: string, input: AiGenerateFaqsInput, req: Request) {
370 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
371 await this.preGate(workspaceId, userId, req);
372
373 const unique = [...new Set(input.pageIds)];
374 const pages_ = await this.prisma.page.findMany({
375 where: { id: { in: unique }, workspaceId, deletedAt: null },
376 select: { id: true, title: true },
377 });
378 const sections: Array<{ label: string; text: string }> = [];
379 for (const p of pages_) {
380 const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: p.id } });
381 if (level === "NONE") {
382 logger.info(`assistant: faqs — page ${p.id} dropped, user ${userId} lacks VIEW`);
383 continue;
384 }
385 sections.push({ label: p.title || p.id, text: await this.pageText(p.id, 4000) });
386 }
387 if (sections.length === 0) throw ApiError.notFound("Pages not found.");
388
389 const conversation = await this.beginConversation(
390 workspaceId,
391 userId,
392 "generate-faqs",
393 `generate-faqs: ${sections.map((s) => s.label).join(", ").slice(0, 200)}`,
394 { feature: "assistant.generate-faqs", pageIds: sections.map((s) => s.label) },
395 );
396
397 const result = await this.callValidated(
398 {
399 workspaceId,
400 userId,
401 system: GENERATE_FAQS_SYSTEM_PROMPT,
402 user: renderFaqsPrompt(sections, sections.length),
403 tool: GENERATE_FAQS_TOOL,
404 action: "generate-faqs",
405 },
406 validateGenerateFaqs,
407 );
408 if ((result as { invalid?: string }).invalid) {
409 await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
410 feature: "assistant.generate-faqs",
411 error: "ai_invalid_structure",
412 });
413 throw ApiError.unprocessable("The AI returned an invalid FAQ list.", [
414 { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid },
415 ]);
416 }
417
418
419 const page = await this.pages.create(workspaceId, userId, { title: result.value.title }, req);
420 const pageId = page.id;
421 for (const faq of result.value.faqs) {
422 await this.blocks.create(pageId, userId, { type: "HEADING_2", content: { text: faq.question }, properties: {} }, req);
423 await this.blocks.create(pageId, userId, { type: "PARAGRAPH", content: { text: faq.answer }, properties: {} }, req);
424 }
425
426 const assistantId = await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
427 feature: "assistant.generate-faqs",
428 pageId,
429 faqCount: result.value.faqs.length,
430 });
431 await this.audit.audit(req, req?.auth, {
432 action: "ai.assistant_faq_page",
433 resourceType: "page",
434 resourceId: pageId,
435 workspaceId,
436 pageId,
437 metadata: { faqCount: result.value.faqs.length },
438 });
439 return { page: { id: pageId, title: result.value.title }, faqCount: result.value.faqs.length, tokensIn: result.usage.in, tokensOut: result.usage.out };
440 }
441
442
443
444
445 async findDuplicates(workspaceId: string, userId: string, input: AiFindDuplicatesInput, req: Request) {
446 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
447 await this.preGate(workspaceId, userId, req);
448
449 const limit = input.limit ?? 100;
450 let pageIds: string[] | undefined;
451 if (input.query) {
452 const hits = await this.search.query(userId, { q: input.query, workspaceId, types: ["page"], limit: CANDIDATE_CAP });
453 pageIds = hits.map((h) => h.id);
454 }
455 const candidates = await this.viewableCandidates(workspaceId, userId, { pageIds, limit });
456 if (candidates.length < 2) {
457 return { groups: [], considered: candidates.length, tokensIn: 0, tokensOut: 0 };
458 }
459
460 const conversation = await this.beginConversation(
461 workspaceId,
462 userId,
463 "find-duplicates",
464 `find-duplicates: ${input.query ?? "recent pages"} (${candidates.length} candidates)`,
465 { feature: "assistant.find-duplicates", candidateCount: candidates.length, query: input.query ?? null },
466 );
467
468 const known = new Set(candidates.map((c) => c.id));
469 const result = await this.callValidated(
470 {
471 workspaceId,
472 userId,
473 system: FIND_DUPLICATES_SYSTEM_PROMPT,
474 user: renderDuplicatesPrompt(candidates),
475 tool: FIND_DUPLICATE_GROUPS_TOOL,
476 action: "find-duplicates",
477 },
478 (raw) => validateDuplicateGroups(raw, known),
479 );
480 if ((result as { invalid?: string }).invalid) {
481 await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
482 feature: "assistant.find-duplicates",
483 error: "ai_invalid_structure",
484 });
485 throw ApiError.unprocessable("The AI returned an invalid duplicate list.", [
486 { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid },
487 ]);
488 }
489
490 const byId = new Map(candidates.map((c) => [c.id, c]));
491 const groups = result.value.map((g) => ({
492 similarity: g.similarity,
493 reason: g.reason,
494 pages: g.pageIds.map((id) => ({ pageId: id, title: byId.get(id)?.title ?? "", updatedAt: byId.get(id)?.updatedAt ?? "" })),
495 }));
496
497 await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
498 feature: "assistant.find-duplicates",
499 groups: result.value,
500 ...(result.dropped.length ? { droppedIds: result.dropped } : {}),
501 });
502 await this.audit.audit(req, req?.auth, {
503 action: "ai.assistant_duplicates_found",
504 resourceType: "ai_suggestion",
505 resourceId: conversation.id,
506 workspaceId,
507 metadata: { groupCount: groups.length, candidateCount: candidates.length },
508 });
509 return { groups, considered: candidates.length, tokensIn: result.usage.in, tokensOut: result.usage.out };
510 }
511
512
513
514
515 async findStale(workspaceId: string, userId: string, input: AiFindStaleInput, req: Request) {
516 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
517 await this.preGate(workspaceId, userId, req);
518
519 const cutoff = new Date(Date.now() - input.days * 86_400_000);
520 const candidates = await this.viewableCandidates(workspaceId, userId, { limit: input.limit ?? 100, olderThan: cutoff });
521 if (candidates.length === 0) {
522 return { pages: [], considered: 0, tokensIn: 0, tokensOut: 0 };
523 }
524
525 const conversation = await this.beginConversation(
526 workspaceId,
527 userId,
528 "find-stale",
529 `find-stale: ${candidates.length} candidates (older than ${input.days} days)`,
530 { feature: "assistant.find-stale", candidateCount: candidates.length, days: input.days },
531 );
532
533 const known = new Set(candidates.map((c) => c.id));
534 const result = await this.callValidated(
535 {
536 workspaceId,
537 userId,
538 system: FIND_STALE_SYSTEM_PROMPT,
539 user: renderStalePrompt(candidates),
540 tool: FIND_STALE_PAGES_TOOL,
541 action: "find-stale",
542 },
543 (raw) => validateStalePages(raw, known),
544 );
545 if ((result as { invalid?: string }).invalid) {
546 await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
547 feature: "assistant.find-stale",
548 error: "ai_invalid_structure",
549 });
550 throw ApiError.unprocessable("The AI returned an invalid stale list.", [
551 { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid },
552 ]);
553 }
554
555 const byId = new Map(candidates.map((c) => [c.id, c]));
556 const pages_ = result.value.map((p) => ({
557 pageId: p.pageId,
558 title: byId.get(p.pageId)?.title ?? "",
559 updatedAt: byId.get(p.pageId)?.updatedAt ?? "",
560 reason: p.reason,
561 }));
562
563 await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
564 feature: "assistant.find-stale",
565 pageIds: pages_.map((p) => p.pageId),
566 ...(result.dropped.length ? { droppedIds: result.dropped } : {}),
567 });
568 await this.audit.audit(req, req?.auth, {
569 action: "ai.assistant_stale_found",
570 resourceType: "ai_suggestion",
571 resourceId: conversation.id,
572 workspaceId,
573 metadata: { staleCount: pages_.length, candidateCount: candidates.length },
574 });
575 return { pages: pages_, considered: candidates.length, tokensIn: result.usage.in, tokensOut: result.usage.out };
576 }
577
578
579
580
581 async suggestMetadata(workspaceId: string, userId: string, input: AiSuggestMetadataInput, req: Request) {
582 const page = await this.prisma.page.findFirst({
583 where: { id: input.pageId, workspaceId, deletedAt: null },
584 select: { id: true, title: true, description: true },
585 });
586 if (!page) throw ApiError.notFound("Page not found.");
587 const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: page.id } });
588 if (level === "NONE") throw ApiError.notFound("Page not found.");
589 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
590 await this.preGate(workspaceId, userId, req);
591
592 const conversation = await this.beginConversation(
593 workspaceId,
594 userId,
595 "suggest-metadata",
596 `suggest-metadata: ${page.title || page.id}`,
597 { feature: "assistant.suggest-metadata", pageId: page.id },
598 );
599
600 const result = await this.callValidated(
601 {
602 workspaceId,
603 userId,
604 system: SUGGEST_METADATA_SYSTEM_PROMPT,
605 user: renderMetadataPrompt({ title: page.title, text: await this.pageText(page.id, 8000) }),
606 tool: SUGGEST_PAGE_METADATA_TOOL,
607 action: "suggest-metadata",
608 },
609 validateMetadataSuggestion,
610 );
611 if ((result as { invalid?: string }).invalid) {
612 await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
613 feature: "assistant.suggest-metadata",
614 pageId: page.id,
615 error: "ai_invalid_structure",
616 });
617 throw ApiError.unprocessable("The AI returned an invalid suggestion.", [
618 { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid },
619 ]);
620 }
621
622 const assistantId = await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, {
623 feature: "assistant.suggest-metadata",
624 pageId: page.id,
625 suggestion: result.value,
626 });
627 await this.audit.audit(req, req?.auth, {
628 action: "ai.assistant_metadata_suggested",
629 resourceType: "page",
630 resourceId: page.id,
631 workspaceId,
632 pageId: page.id,
633 metadata: { fields: Object.keys(result.value) },
634 });
635 return {
636 suggestionId: assistantId,
637 pageId: page.id,
638 current: { title: page.title, description: page.description },
639 suggested: result.value,
640 tokensIn: result.usage.in,
641 tokensOut: result.usage.out,
642 };
643 }
644
645
646 async applyMetadata(workspaceId: string, userId: string, input: AiApplyMetadataInput, req: Request) {
647
648 const message = await this.prisma.aiMessage.findFirst({
649 where: { id: input.suggestionId, role: "assistant" },
650 select: { id: true, conversationId: true, metadata: true },
651 });
652 const conversation = message
653 ? await this.prisma.aiConversation.findUnique({ where: { id: message.conversationId }, select: { workspaceId: true, userId: true } })
654 : null;
655 if (!message || !conversation || conversation.workspaceId !== workspaceId || conversation.userId !== userId) {
656 throw ApiError.notFound("Suggestion not found.");
657 }
658 const meta = (message.metadata ?? {}) as { feature?: string; pageId?: string; suggestion?: { title?: string; description?: string } };
659 if (meta.feature !== "assistant.suggest-metadata" || !meta.pageId || !meta.suggestion) {
660 throw ApiError.notFound("Suggestion not found.");
661 }
662
663
664 const updates: { title?: string; description?: string } = {};
665 if (input.fields.title !== undefined) {
666 if (meta.suggestion.title !== input.fields.title) {
667 throw new ApiError("VALIDATION_ERROR", 400, "fields.title does not match the suggestion.", [{ field: "fields.title", issue: "not_in_suggestion" }]);
668 }
669 updates.title = input.fields.title;
670 }
671 if (input.fields.description !== undefined) {
672 if (meta.suggestion.description !== input.fields.description) {
673 throw new ApiError("VALIDATION_ERROR", 400, "fields.description does not match the suggestion.", [{ field: "fields.description", issue: "not_in_suggestion" }]);
674 }
675 updates.description = input.fields.description;
676 }
677
678 await this.pages.update(meta.pageId, userId, updates, req);
679
680 await this.audit.audit(req, req?.auth, {
681 action: "ai.assistant_metadata_applied",
682 resourceType: "page",
683 resourceId: meta.pageId,
684 workspaceId,
685 pageId: meta.pageId,
686 metadata: { fields: Object.keys(updates), suggestionId: input.suggestionId },
687 });
688 return { pageId: meta.pageId, applied: Object.keys(updates) };
689 }
690}
691
692
693function blockText(content: unknown): string {
694 if (typeof content === "string") return stripHtml(content);
695 if (typeof content !== "object" || content === null) return "";
696 const c = content as { text?: unknown; title?: unknown };
697 const raw = typeof c.text === "string" ? c.text : typeof c.title === "string" ? c.title : "";
698 return stripHtml(raw);
699}
700