/** * AI module — gateway-backed service (Phase 12, TASK-0121). * * Replaces the EchoProvider seam: every AI capability in the API now calls * the `@factory/ai` gateway (provider-agnostic chat/embed/tool-calling) * behind the service-level budget guard. Usage is recorded in * `ai_messages` (the ledger of record — the monthly rollup sums it per * workspace since month-start). * * Surface (this file — TASK-0121): * - `POST /workspaces/:id/ai/ping` — diagnostic: resolved provider/model, * embedding dimension, budget state. No tokens are spent. * - conversation CRUD (kept from the scaffold, now with soft delete — * TASK-0122 builds the full history UX on top). * - `POST /workspaces/:id/ai/chat` — single turn, gateway-streamed, both * messages persisted with token counts (the non-SSE contract the * scaffold had; the SSE streaming contract lands in TASK-0122). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { AiConfigSchema, type AiGateway } from "@factory/ai"; import type { PrismaClient } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { assertCan } from "../../lib/authorize.js"; import { AI_GATEWAY, embedDimension, mapAiError, primeWorkspaceCache, resetWorkspaceCache, tokensPerMonthFor, type AiBudgetGuard, type WorkspaceAiRow, } from "./gateway-provider.js"; import { AiBudgetGuardService } from "./budget-guard.service.js"; import type { ChatInput, CreateConversationInput } from "./dto.js"; import { RAG_CONV_PREFIX, WRITING_CONV_PREFIX } from "./prompts.js"; import { DB_EXTRACT_CONV_PREFIX, DB_FORMULA_CONV_PREFIX, DB_GEN_CONV_PREFIX } from "./db-prompts.js"; import { GENERATE_CONV_PREFIX } from "./generator/generate.service.js"; import { ASSISTANT_CONV_PREFIX } from "./assistant/assistant-prompts.js"; /** * Hidden per-call ledger conversations are titled `ai-usage:` — * the list endpoint filters them out of the user's conversation history. */ export const HIDDEN_LEDGER_PREFIX = "ai-usage:"; @Injectable() export class AiService { constructor( @Inject(PrismaService) private readonly prisma: PrismaClient, @Inject(AuditService) private readonly audit: AuditService, @Inject(AI_GATEWAY) private readonly gateway: AiGateway, @Inject(AiBudgetGuardService) private readonly guardService: AiBudgetGuardService ) {} /** The guard (available after module init; request-path access only — * Nest resolves onModuleInit before routing starts). */ private get guard(): AiBudgetGuard { return this.guardService.guard; } // ---------- ping (diagnostic) ---------- /** * POST /workspaces/:id/ai/ping — resolved provider/model + budget state. * No tokens are spent; the guard check runs WITHOUT consuming rate-window * budget (a diagnostic must not 402 the caller before they did anything). */ async ping(workspaceId: string, userId: string) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); await primeWorkspaceCache(this.prisma, workspaceId); const config = this.gateway.resolve(workspaceId); const row = await this.prisma.workspace.findUniqueOrThrow({ where: { id: workspaceId }, select: { usageLimits: true, settings: true, billingPlan: true } }); const parsed = AiConfigSchema.safeParse(((row.settings ?? {}) as { ai?: unknown }).ai ?? {}); return { data: { provider: config.provider, model: config.chatModel, embedModel: config.embedModel, embeddingDimension: embedDimension(), budget: { limit: tokensPerMonthFor(row as unknown as WorkspaceAiRow, parsed.success ? parsed.data : null), used: await this.guard.usedTokens(workspaceId), }, }, }; } // ---------- conversations ---------- private async requireConversation(conversationId: string, userId: string) { const conversation = await this.prisma.aiConversation.findUnique({ where: { id: conversationId } }); if (!conversation || conversation.userId !== userId || conversation.deletedAt) throw ApiError.notFound("Conversation not found."); return conversation; } async listConversations(workspaceId: string, userId: string) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const rows = await this.prisma.aiConversation.findMany({ where: { workspaceId, userId, deletedAt: null, // hidden per-call conversations: the usage ledger (TASK-0121), the // writing suggestions (TASK-0123), the database-generation calls // (TASK-0124) and the page-generation calls (TASK-0126) must never // appear in the panel's history. `startsWith` // — the old `not: prefix+"%"` was an exact match against a literal // `%` and filtered nothing. AND: [ { title: { not: { startsWith: HIDDEN_LEDGER_PREFIX } } }, { title: { not: { startsWith: WRITING_CONV_PREFIX } } }, { title: { not: { startsWith: RAG_CONV_PREFIX } } }, { title: { not: { startsWith: DB_GEN_CONV_PREFIX } } }, { title: { not: { startsWith: DB_EXTRACT_CONV_PREFIX } } }, { title: { not: { startsWith: DB_FORMULA_CONV_PREFIX } } }, { title: { not: { startsWith: GENERATE_CONV_PREFIX } } }, // T-172…T-175 (A-010): the assistant per-call conversations // (`assistant:extract-actions`, …) stay out of the panel too. { title: { not: { startsWith: ASSISTANT_CONV_PREFIX } } } ] }, orderBy: { updatedAt: "desc" }, take: 50, select: { id: true, title: true, context: true, createdAt: true, updatedAt: true, _count: { select: { messages: true } } } }); return { data: rows.map((r) => ({ ...r, messageCount: r._count.messages })) }; } async createConversation(workspaceId: string, userId: string, input: CreateConversationInput, req?: Request) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const conversation = await this.prisma.aiConversation.create({ data: { workspaceId, userId, title: input.title ?? null, context: input.context && Object.keys(input.context).length > 0 ? (input.context as object) : undefined } }); // TASK-0122: audit conversation creation (security-sensitive: the // context binding is the prompt-injection surface). NOT audited per // message — message volume is tracked in the usage ledger only. await this.audit.audit(req, req?.auth, { action: "ai.conversation_created", resourceType: "ai_conversation", resourceId: conversation.id, workspaceId, metadata: { context: input.context ?? null } }); return conversation; } async renameConversation(conversationId: string, userId: string, title: string) { const conversation = await this.requireConversation(conversationId, userId); return this.prisma.aiConversation.update({ where: { id: conversation.id }, data: { title: title.slice(0, 200) } }); } /** Soft delete (TASK-0122 contract): hides immediately; purge at 30 d. */ async deleteConversation(conversationId: string, userId: string) { const conversation = await this.requireConversation(conversationId, userId); await this.prisma.aiConversation.update({ where: { id: conversation.id }, data: { deletedAt: new Date() } }); return { deleted: true }; } async getConversation(conversationId: string, userId: string) { const conversation = await this.requireConversation(conversationId, userId); const messages = await this.prisma.aiMessage.findMany({ where: { conversationId: conversation.id }, orderBy: { createdAt: "asc" }, take: 200 }); return { data: { ...conversation, messages } }; } async listMessages(conversationId: string, userId: string, query: { cursor?: string; limit?: string }) { const conversation = await this.requireConversation(conversationId, userId); const limit = Math.min(query.limit ? Number.parseInt(query.limit, 10) : 50, 200) || 50; const rows = await this.prisma.aiMessage.findMany({ where: { conversationId: conversation.id }, ...(query.cursor ? { cursor: { id: query.cursor }, skip: 1 } : {}), orderBy: { createdAt: "asc" }, take: limit + 1 }); const page = rows.slice(0, limit); const next = rows.length > limit ? page[page.length - 1] : undefined; return { data: page, nextCursor: next ? next.id : null }; } // ---------- chat ---------- /** * POST /workspaces/:id/ai/chat — single turn through the gateway. * * Flow: membership (EDIT) → workspace row prime (config + budget) → * guard check (429/402 before any provider call) → gateway.stream chat → * persist user + assistant messages (assistant row carries the token * counts — it IS the usage ledger entry) → audit. * * Errors: the gateway/guard shared envelope is translated by * `mapAiError` (402 AI_BUDGET_EXCEEDED / 429 RATE_LIMITED / * 503 SERVICE_UNAVAILABLE — raw provider bodies never leak). */ async chat(workspaceId: string, userId: string, input: ChatInput, req: Request) { await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); await primeWorkspaceCache(this.prisma, workspaceId); await this.guardCheck(workspaceId, req); // resolve or create the conversation let conversation; if (input.conversationId) { const existing = await this.prisma.aiConversation.findUnique({ where: { id: input.conversationId } }); if (!existing || existing.userId !== userId || existing.workspaceId !== workspaceId || existing.deletedAt) { throw ApiError.notFound("Conversation not found."); } conversation = existing; } else { conversation = await this.prisma.aiConversation.create({ data: { workspaceId, userId, title: input.message.slice(0, 60), context: input.context ? (input.context as object) : undefined } }); } // history (last 20) + the new user message const history = await this.prisma.aiMessage.findMany({ where: { conversationId: conversation.id }, orderBy: { createdAt: "desc" }, take: 20, select: { role: true, content: true } }); const messages = [ ...history.reverse().map((m) => ({ role: m.role as "user" | "assistant" | "system" | "tool", content: m.content })), { role: "user" as const, content: input.message } ]; const chatModel = this.gateway.resolve(workspaceId).chatModel; const startedAt = Date.now(); let content = ""; let usage = { in: 0, out: 0, total: 0 }; try { for await (const chunk of this.gateway.chat({ workspaceId, userId, capability: "chat", messages, metadata: { conversationId: conversation.id } })) { if (chunk.delta) content += chunk.delta; if (chunk.done && chunk.usage) usage = chunk.usage; } } catch (err) { throw mapAiError(err); } const latencyMs = Date.now() - startedAt; const [userRow, assistantRow] = await this.prisma.$transaction([ this.prisma.aiMessage.create({ data: { conversationId: conversation.id, role: "user", content: input.message } }), this.prisma.aiMessage.create({ data: { conversationId: conversation.id, role: "assistant", content, model: chatModel, tokensIn: usage.in, tokensOut: usage.out, latencyMs, metadata: asJson({ feature: "chat" }) } }), this.prisma.aiConversation.update({ where: { id: conversation.id }, data: { updatedAt: new Date() } }) ]); await this.audit.audit(req, req.auth, { action: "ai.chat", resourceType: "ai_message", resourceId: conversation.id, workspaceId, metadata: { model: chatModel, tokensIn: usage.in, tokensOut: usage.out, latencyMs } }); return { conversationId: conversation.id, role: "assistant", content, model: chatModel, tokensIn: usage.in, tokensOut: usage.out, latencyMs, userMessageId: userRow.id, assistantMessageId: assistantRow.id }; } /** * The shared pre-gateway gate: budget guard (429/402) + workspace row * prime (config cache). Every AI endpoint calls this BEFORE the gateway. */ private async guardCheck(workspaceId: string, req?: Request): Promise { try { await this.guard.check(workspaceId, undefined, req); } catch (err) { throw mapAiError(err); } } // ---------- test hooks (capability suite, TASK-0121) ---------- /** Drop the workspace config cache (new settings take effect). */ flushCacheForTests(): void { resetWorkspaceCache(); } } function asJson(v: unknown): object { return v as object; }