/** * TASK-0123 — AI writing tools (summarize / rewrite / improve / translate). * * Endpoints (documented in `docs/api/ai.md`): * * POST /workspaces/:workspaceId/ai/writing (SSE) * body: { blockIds: string[] (1..50), pageId, action, language? } * → `text/event-stream` frames (same contract as Ask AI chat): * delta / tool_call / usage / error / done * `tool_call` = the model's `generateBlocks` call (validated * server-side: unknown blockIds are DROPPED + noted, never applied); * `done.assistantMessageId` = the SUGGESTION id (the assistant row * whose `toolResults` hold the full suggestion). * * POST /workspaces/:workspaceId/ai/writing/apply * body: { suggestionId, blockIds?: string[] } * → 200 { results: [{ blockId, status: "applied" | "failed", code?, * message?, actualVersion? }] } — per-block failure isolation (one * 403/409 never aborts the rest). Each applied block goes through the * normal `BlockService.update` (EDIT re-check + `block.updated` audit * + `page.updated` realtime — the spec's "not a special AI write") * with the suggestion-time version as `baseVersion` (a stale block → * a per-result 409 entry carrying `actualVersion` for the rebase UI). * * Security (spec §Security, AGENTS.md #19): * - the client sends block IDs only; content is assembled server-side * (`assembleWritingContext`) with per-page VIEW re-checks (IDOR guard); * - the prompt frames block content as UNTRUSTED data; * - `generateBlocks` arguments are validated against the REQUESTED set — * unknown IDs are dropped (the mock `forceUnknownId` seam exercises it); * - apply re-validates every `{blockId, newText}` pair against the STORED * suggestion (no client-smuggled edits) and re-checks EDIT per block at * APPLY time (permissions may change between suggest and apply); * - hidden per-call conversations (`title: "writing:"`) never * appear in the panel history (filtered in `listConversations`). * * Limits (spec §Limits): ≤50 blocks (422), ≤100k chars of selected text * (413 — thrown by the assembler), language ≤64 chars, action enum; the * per-user cap is the SHARED `ai.calls` limiter (30 chat+writing calls/min). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request, Response } from "express"; import type { PrismaClient } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError, type ErrorCode, type ErrorDetail } from "../../common/errors.js"; import { logger } from "../../common/logger.js"; import { assertCan } from "../../lib/authorize.js"; import { AI_GATEWAY, mapAiError, primeWorkspaceCache, type AiBudgetGuard } from "./gateway-provider.js"; import { AiBudgetGuardService } from "./budget-guard.service.js"; import { AiRateLimitService } from "./ai-rate-limit.js"; import { type AiWritingInput, type ApplyWritingInput } from "./ai-writing.dto.js"; import { assembleWritingContext, GENERATE_BLOCKS_TOOL, parseGeneratedBlocks, renderWritingSections, WRITING_CONV_PREFIX, WRITING_MAX_BLOCKS, WRITING_SYSTEM_PROMPT, writingTaskLine, WritingContextError, type WritingAction, } from "./prompts.js"; import { BlockService } from "../blocks/block.service.js"; import type { AiGateway } from "@factory/ai"; const AI_WRITING_TIMEOUT_MS = 60_000; const KEEPALIVE_MS = 10_000; /** One SSE frame — the documented wire contract (same as Ask AI). */ export type AiWritingSseEvent = | { type: "delta"; text: string } | { type: "tool_call"; id: string; name: string; arguments: Record } | { type: "usage"; tokensIn: number; tokensOut: number } | { type: "error"; code: string; message: string; retryable?: boolean; retryAfterSeconds?: number } | { type: "done"; assistantMessageId?: string; userMessageId?: string; reason?: "ok" | "aborted" | "error" }; function sseFrame(event: AiWritingSseEvent): string { return `data: ${JSON.stringify(event)}\n\n`; } /** The persisted suggestion (`assistant.toolResults`): the server-side * record that apply re-validates against. */ export interface WritingSuggestion { action: WritingAction; language?: string; pageId: string; blockIds: string[]; blocks: Array<{ blockId: string; newText: string; /** The block's version at SUGGEST time (apply's `baseVersion`). */ version: number; /** The block's full content snapshot (apply replaces `text` on it). */ content: Record; /** The original plain text (the UI's diff "old" side). */ text: string; }>; /** blockIds the model emitted that were NOT in the request (dropped). */ droppedIds: string[]; } /** One apply result row (the documented per-block isolation contract). */ export interface ApplyResultRow { blockId: string; status: "applied" | "failed"; code?: string; message?: string; actualVersion?: number; } @Injectable() export class AiWritingService { constructor( @Inject(PrismaService) private readonly prisma: PrismaClient, @Inject(AI_GATEWAY) private readonly gateway: AiGateway, @Inject(AiBudgetGuardService) private readonly guardService: AiBudgetGuardService, @Inject(AiRateLimitService) private readonly rateLimit: AiRateLimitService, @Inject(BlockService) private readonly blocks: BlockService, @Inject(AuditService) private readonly audit: AuditService, ) {} private get guard(): AiBudgetGuard { return this.guardService.guard; } // ---------------------------------------------------------------- write --- async generate(workspaceId: string, userId: string, input: AiWritingInput, req: Request, res: Response): Promise { // ---- pre-stream gates (canonical JSON envelope on failure) ---- // VIEW is the SUGGESTION's gate (reading the blocks' content); the // spec's acceptance criteria run SUGGEST as a view-only user and expect // the 403 only at APPLY (per-block EDIT). Apply keeps its EDIT gate. await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); if (input.blockIds.length > WRITING_MAX_BLOCKS) { throw new ApiError("VALIDATION_ERROR", 422, `Too many blocks: maximum is ${WRITING_MAX_BLOCKS}.`, [ { reason: "too_many_blocks", count: input.blockIds.length, max: WRITING_MAX_BLOCKS }, ]); } // shared ai.calls cap (chat + writing combined; degrades OPEN w/o Redis) const rl = await this.rateLimit.check(userId); if (!rl.allowed) { throw new ApiError("RATE_LIMITED", 429, "AI call limit reached.", [ { reason: "ai_call_rate_limited", retryAfterSeconds: rl.retryAfterSeconds }, ]); } // budget BEFORE any SSE header (402 JSON envelope — spec: "budget 402") await primeWorkspaceCache(this.prisma, workspaceId); try { await this.guard.check(workspaceId, undefined, req); } catch (err) { throw mapAiError(err); } // ---- context (permission re-check + ±5 + caps; 413 on >100k chars) ---- const uniqueIds = [...new Set(input.blockIds)]; let ctx; try { ctx = await assembleWritingContext(this.prisma, workspaceId, userId, uniqueIds); } catch (err) { // The assembler's 413 (PAYLOAD_TOO_LARGE) is a plain Error — map it to // the canonical envelope BEFORE any SSE byte is written. if (err instanceof WritingContextError) { throw new ApiError(err.code as ErrorCode, err.status, err.message, (err.details ?? undefined) as ErrorDetail[] | undefined); } throw err; } const noteParts: string[] = []; if (ctx.missing.length > 0) noteParts.push(`${ctx.missing.length} selected block(s) are no longer available.`); if (ctx.droppedPages.length > 0) noteParts.push(`${ctx.droppedPages.length} selected page(s) are unavailable (no view access).`); if (ctx.nonText.length > 0) noteParts.push(`${ctx.nonText.length} selected block(s) have no editable text.`); // `sections` can be non-empty with ZERO selected text blocks (all the // found blocks were non-text — the page is still listed, just with no // selection). The tool call would come back empty → 422 up front. if (ctx.sections.every((s) => s.selected.length === 0)) { throw new ApiError("VALIDATION_ERROR", 422, "No editable blocks available for this request.", [ { reason: "no_editable_blocks" }, ]); } // ---- hidden per-call conversation (never listed in the panel) ---- const conversation = await this.prisma.aiConversation.create({ data: { workspaceId, userId, title: `${WRITING_CONV_PREFIX}${input.action}`, context: { pageId: input.pageId, blockIds: uniqueIds, action: input.action, language: input.language ?? null } as object, }, }); // ---- SSE header (the first byte — after this, errors are in-band) ---- res.status(200); res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); res.setHeader("Cache-Control", "no-cache, no-transform"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders?.(); if (typeof res.socket?.setNoDelay === "function") res.socket.setNoDelay(true); // abort plumbing (same as Ask AI: `res` close + writableEnded check) const ac = new AbortController(); let clientClosed = false; const onResClose = (): void => { if (res.writableEnded) return; clientClosed = true; ac.abort(); }; res.on("close", onResClose); const keepalive = setInterval(() => { if (!res.writableEnded) res.write(": keepalive\n\n"); }, KEEPALIVE_MS); let timer: ReturnType | undefined; const armTimeout = (): void => { if (timer) clearTimeout(timer); timer = setTimeout(() => ac.abort(), AI_WRITING_TIMEOUT_MS); }; armTimeout(); // ---- the request (persisted as the user row — a deterministic // description; the user never types free-form text here) ---- const userRow = await this.prisma.aiMessage.create({ data: { conversationId: conversation.id, role: "user", content: `writing:${input.action}${input.language ? `:${input.language}` : ""} blocks=${uniqueIds.length} page=${input.pageId}`, }, }); const messages: Array<{ role: "system" | "user"; content: string }> = [ { role: "system", content: WRITING_SYSTEM_PROMPT }, { role: "user", content: `${writingTaskLine(input.action, input.language)}\n\n${renderWritingSections(ctx.sections)}`, }, ]; const chatModel = this.gateway.resolve(workspaceId).chatModel; const startedAt = Date.now(); let usage = { in: 0, out: 0 }; let toolArgs: unknown; let toolSeen = false; let finished = false; const finish = (event: AiWritingSseEvent): void => { if (finished) return; finished = true; if (timer) clearTimeout(timer); clearInterval(keepalive); res.off("close", onResClose); if (!res.writableEnded) { res.write(sseFrame(event)); res.end(); } }; const sendErrorDelta = (err: unknown): void => { const mapped = mapAiError(err); const detail = (mapped.details?.[0] ?? {}) as Record; const retryable = detail.retryable === true || detail.retryAfterSeconds != null || mapped.code === "RATE_LIMITED"; const event: AiWritingSseEvent = { type: "error", code: mapped.code, message: mapped.message, ...(retryable ? { retryable: true } : {}), ...(typeof detail.retryAfterSeconds === "number" ? { retryAfterSeconds: detail.retryAfterSeconds } : {}), }; res.write(sseFrame(event)); logger.info(`ai writing stream error workspace=${workspaceId} code=${mapped.code} retryable=${retryable}`, { event: "ai.writing.error", workspaceId, code: mapped.code, retryable, }); }; /** Persist the assistant row = THE SUGGESTION. Only when the model * actually returned a tool call (an aborted/error stream before output * has no suggestion to persist — the error frame + user row cover it). * `done.assistantMessageId` = this row's id (the suggestion id). */ const persistSuggestion = async (errorTag?: string): Promise => { if (!toolSeen) return undefined; try { const requestedIds = ctx.sections.flatMap((s) => s.selected.map((b) => b.id)); const parsed = parseGeneratedBlocks(toolArgs, requestedIds); const selById = new Map(ctx.sections.flatMap((s) => s.selected.map((b) => [b.id, b] as const))); const suggestion: WritingSuggestion = { action: input.action, ...(input.language ? { language: input.language } : {}), pageId: input.pageId, blockIds: uniqueIds, blocks: parsed.ok ? parsed.blocks .filter((b) => selById.has(b.blockId)) .map((b) => { const sel = selById.get(b.blockId)!; return { blockId: b.blockId, newText: b.newText, version: sel.version, content: (typeof sel.content === "object" && sel.content !== null ? sel.content : { text: sel.text }) as Record, text: sel.text, }; }) : [], droppedIds: parsed.ok ? parsed.dropped : [], }; const row = await this.prisma.aiMessage.create({ data: { conversationId: conversation.id, role: "assistant", content: suggestion.blocks.length > 0 ? `suggestion:${suggestion.blocks.length} block(s)` : "suggestion:0 blocks", model: chatModel, ...(usage.in > 0 ? { tokensIn: usage.in } : {}), ...(usage.out > 0 ? { tokensOut: usage.out } : {}), latencyMs: Date.now() - startedAt, toolResults: suggestion as unknown as object, metadata: { feature: "writing", action: input.action, ...(input.language ? { language: input.language } : {}), ...(parsed.ok && parsed.dropped.length > 0 ? { droppedIds: parsed.dropped } : {}), ...(errorTag ? { error: errorTag } : {}), } as object, }, }); return row.id; } catch (err) { // persistence must never crash the stream teardown logger.error(`ai writing suggestion persist failed: ${(err as Error).message}`); return undefined; } }; try { for await (const chunk of this.gateway.chat({ workspaceId, userId, capability: "writing", messages, tools: [GENERATE_BLOCKS_TOOL], toolChoice: { name: "generateBlocks" }, metadata: { mode: "writing", action: input.action, ...(input.language ? { language: input.language } : {}), conversationId: conversation.id, }, signal: ac.signal, })) { if (timer) armTimeout(); if (chunk.delta) res.write(sseFrame({ type: "delta", text: chunk.delta })); if (chunk.toolCallDelta) { toolSeen = true; toolArgs = chunk.toolCallDelta.arguments; res.write(sseFrame({ type: "tool_call", ...chunk.toolCallDelta })); } if (chunk.done) { if (chunk.usage) { usage = { in: chunk.usage.in, out: chunk.usage.out }; res.write(sseFrame({ type: "usage", tokensIn: usage.in, tokensOut: usage.out })); } const note = noteParts.join(" "); if (note && !clientClosed) res.write(sseFrame({ type: "delta", text: `> ${note}\n\n` })); const suggestionId = await persistSuggestion(); finish({ type: "done", assistantMessageId: suggestionId, userMessageId: userRow.id, reason: "ok" }); } } // provider stream ended without a done chunk (defensive). if (!finished) { const suggestionId = await persistSuggestion(); finish({ type: "done", assistantMessageId: suggestionId, userMessageId: userRow.id, reason: "ok" }); } } catch (err) { if (timer) clearTimeout(timer); clearInterval(keepalive); res.off("close", onResClose); if (clientClosed || ac.signal.aborted) { // client hang-up / Stop: cancelled — a partial suggestion (the tool // call may already have arrived) is still persisted. const suggestionId = await persistSuggestion("aborted"); if (!clientClosed && !res.writableEnded) { res.write(sseFrame({ type: "done", assistantMessageId: suggestionId, userMessageId: userRow.id, reason: "aborted" })); res.end(); } return; } // provider failure (normalized — raw provider text never surfaces): // error delta + terminal done; persist the suggestion if the tool // call already arrived mid-stream. sendErrorDelta(err); const suggestionId = await persistSuggestion("provider_error"); finish({ type: "done", assistantMessageId: suggestionId, userMessageId: userRow.id, reason: "error" }); } } // ---------------------------------------------------------------- apply --- async apply(workspaceId: string, userId: string, input: ApplyWritingInput, req: Request): Promise<{ results: ApplyResultRow[] }> { // NO workspace-level EDIT gate here: the spec's contract is PER-BLOCK // failure isolation (a view-only user's apply 200s with per-block // `FORBIDDEN requires_EDIT` entries — "other blocks apply"). The real // check is the per-page EDIT inside BlockService.update below. Ownership // (the suggestion is the caller's, in this workspace) is enforced next. // 1) the suggestion (ownership-scoped 404 — existence hidden). const suggestionRow = await this.prisma.aiMessage.findUnique({ where: { id: input.suggestionId }, include: { conversation: { select: { id: true, workspaceId: true, userId: true } } }, }); if ( !suggestionRow || !suggestionRow.conversation || suggestionRow.conversation.workspaceId !== workspaceId || suggestionRow.conversation.userId !== userId || suggestionRow.role !== "assistant" ) { throw ApiError.notFound("Suggestion not found."); } const suggestion = suggestionRow.toolResults as unknown as WritingSuggestion | null; if (!suggestion || !Array.isArray(suggestion.blocks) || suggestion.blocks.length === 0) { throw ApiError.notFound("Suggestion not found."); } // 2) which blocks: the whole suggestion by default; a SUBSET is allowed // (the UI's "accept one") — but every requested id must be IN the // suggestion (no smuggling new blockIds / newText pairs). const byId = new Map(suggestion.blocks.map((b) => [b.blockId, b] as const)); const targetIds = input.blockIds ? input.blockIds : suggestion.blocks.map((b) => b.blockId); const smuggled = targetIds.filter((id) => !byId.has(id)); if (smuggled.length > 0) { throw new ApiError("VALIDATION_ERROR", 422, "blockIds must be a subset of the suggestion.", [ { reason: "unknown_block_id", blockIds: smuggled }, ]); } // 3) per-block apply with failure isolation: EDIT is re-checked per // block's page (permissions may have changed since the suggestion), // then the normal BlockService.update with the suggestion-time // version as baseVersion (stale → 409 entry with actualVersion). const results: ApplyResultRow[] = []; for (const blockId of targetIds) { const entry = byId.get(blockId)!; try { // Preserve the block's content SHAPE (object canonical vs the web // editor's HTML-string model): spreading a string primitive would // corrupt it into an index-keyed object, and writing `{ text }` over // a string block would leave the web editor showing the old markup. const newContent: Record | string = typeof entry.content === "string" ? `

${entry.newText}

` : { ...(entry.content ?? {}), text: entry.newText }; await this.blocks.update( blockId, userId, { content: newContent as unknown as Record, baseVersion: entry.version }, req, ); results.push({ blockId, status: "applied" }); } catch (err) { const e = err as { code?: string; message?: string; details?: Array> }; const detail = e.details?.[0] ?? {}; const row: ApplyResultRow = { blockId, status: "failed", code: e.code ?? "ERROR", message: e.message ?? "Failed to apply.", }; if (e.code === "CONFLICT" && typeof detail.actualVersion === "number") row.actualVersion = detail.actualVersion; results.push(row); logger.info(`ai writing apply failed block=${blockId} code=${row.code}`, { event: "ai.writing.apply_failed", workspaceId, blockId, code: row.code, }); } } const applied = results.filter((r) => r.status === "applied").length; await this.audit.audit(req, req?.auth, { action: "ai.writing_applied", resourceType: "ai_suggestion", resourceId: suggestionRow.id, workspaceId, pageId: suggestion.pageId, metadata: { action: suggestion.action, applied, failed: results.length - applied }, }); return { results }; } }