feat(ai): pipeline assincrono — transcricao, analise, prompts (fase 20)
Sub-fase B do modulo de IA: novo servico apps/ai-worker (poll + FOR UPDATE SKIP LOCKED) processa AIJob de TRANSCRIPTION/ANALYSIS disparados automaticamente apos uma gravacao ficar disponivel, respeitando a cascata de privacidade Tenant>Queue>Campaign e o entitlement do Plan. Transcricao separa o WAV estereo em 2 canais (parser proprio, sem ffmpeg) e transcreve cada perna independente; analise sempre redige dados sensiveis antes de sair pro provider e valida o resultado contra o schema antes de persistir. CRUD de AIPromptTemplate/AIPromptVersion em apps/api. Testado ponta a ponta contra o worker real em Docker e Postgres real com RLS (cascata de privacidade em 3 cenarios, WAV sintetico real no object storage, claim/retry/dead-letter reais) — chamada de rede contra OpenAI/Anthropic continua nunca exercitada (mesma restricao de rede desde o Provider Layer). Detalhes em docs/AI_PIPELINE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
153
apps/api/src/ai/ai-prompts.controller.ts
Normal file
153
apps/api/src/ai/ai-prompts.controller.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { CreateAIPromptTemplateDto } from "./dto/create-ai-prompt-template.dto";
|
||||
import { CreateAIPromptVersionDto } from "./dto/create-ai-prompt-version.dto";
|
||||
|
||||
/**
|
||||
* Templates/versões de prompt (agente.md secao 114-116). Mesma RLS híbrida
|
||||
* dos providers/models de IA: `tenantId null` = template padrão da
|
||||
* plataforma, visível de qualquer tenant, só platform admin
|
||||
* cria/gerencia; `tenantId` setado = só o próprio tenant vê. Versões nunca
|
||||
* são editadas in-place — cada mudança de conteúdo cria uma linha nova em
|
||||
* `AIPromptVersion`, `activeVersionId` aponta pra qual está em uso.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("ai/prompt-templates")
|
||||
export class AIPromptsController {
|
||||
@RequirePermission("ai.manage")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAIPromptTemplateDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const scope = dto.scope ?? "TENANT";
|
||||
|
||||
if (scope === "GLOBAL") {
|
||||
const isPlatform = await isPlatformUser(user.sub);
|
||||
if (!isPlatform) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode cadastrar um template GLOBAL");
|
||||
}
|
||||
}
|
||||
const ownerTenantId = scope === "GLOBAL" ? null : tenantId;
|
||||
|
||||
const template = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
const created = await tx.aIPromptTemplate.create({
|
||||
data: { tenantId: ownerTenantId, purpose: dto.purpose, name: dto.name },
|
||||
});
|
||||
if (!dto.content) return created;
|
||||
|
||||
const version = await tx.aIPromptVersion.create({
|
||||
data: { tenantId: ownerTenantId, templateId: created.id, version: 1, content: dto.content },
|
||||
});
|
||||
return tx.aIPromptTemplate.update({
|
||||
where: { id: created.id },
|
||||
data: { activeVersionId: version.id },
|
||||
});
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_PROMPT_TEMPLATE_CREATE",
|
||||
tenantId: ownerTenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_prompt_template",
|
||||
entityId: template.id,
|
||||
after: { name: template.name, purpose: template.purpose },
|
||||
});
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
@RequirePermission("ai.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
return withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIPromptTemplate.findMany({
|
||||
include: { activeVersion: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission("ai.manage")
|
||||
@Post(":id/versions")
|
||||
async createVersion(
|
||||
@CurrentUser() user: AccessTokenClaims,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: CreateAIPromptVersionDto,
|
||||
) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const version = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
const template = await tx.aIPromptTemplate.findFirst({ where: { id } });
|
||||
if (!template) return null;
|
||||
if (template.tenantId === null) {
|
||||
const isPlatform = await isPlatformUser(user.sub);
|
||||
if (!isPlatform) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode versionar um template GLOBAL");
|
||||
}
|
||||
}
|
||||
const last = await tx.aIPromptVersion.findFirst({
|
||||
where: { templateId: id },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
return tx.aIPromptVersion.create({
|
||||
data: {
|
||||
tenantId: template.tenantId,
|
||||
templateId: id,
|
||||
version: (last?.version ?? 0) + 1,
|
||||
content: dto.content,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!version) throw new NotFoundException("Template nao encontrado");
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_PROMPT_VERSION_CREATE",
|
||||
tenantId: version.tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_prompt_version",
|
||||
entityId: version.id,
|
||||
after: { templateId: id, version: version.version },
|
||||
});
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
@RequirePermission("ai.manage")
|
||||
@Post(":id/versions/:versionId/activate")
|
||||
async activateVersion(
|
||||
@CurrentUser() user: AccessTokenClaims,
|
||||
@Param("id") id: string,
|
||||
@Param("versionId") versionId: string,
|
||||
) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const template = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
const existing = await tx.aIPromptTemplate.findFirst({ where: { id } });
|
||||
if (!existing) return null;
|
||||
const version = await tx.aIPromptVersion.findFirst({ where: { id: versionId, templateId: id } });
|
||||
if (!version) throw new NotFoundException("Versao nao encontrada para este template");
|
||||
return tx.aIPromptTemplate.update({ where: { id }, data: { activeVersionId: versionId } });
|
||||
});
|
||||
if (!template) throw new NotFoundException("Template nao encontrado");
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_PROMPT_TEMPLATE_ACTIVATE_VERSION",
|
||||
tenantId: template.tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_prompt_template",
|
||||
entityId: template.id,
|
||||
after: { activeVersionId: versionId },
|
||||
});
|
||||
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AIProvidersController } from "./ai-providers.controller";
|
||||
import { AIModelsController } from "./ai-models.controller";
|
||||
import { AIPromptsController } from "./ai-prompts.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [AIProvidersController, AIModelsController],
|
||||
controllers: [AIProvidersController, AIModelsController, AIPromptsController],
|
||||
})
|
||||
export class AIModule {}
|
||||
|
||||
25
apps/api/src/ai/dto/create-ai-prompt-template.dto.ts
Normal file
25
apps/api/src/ai/dto/create-ai-prompt-template.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
const PURPOSES = ["ANALYSIS", "SCORECARD"];
|
||||
const SCOPES = ["GLOBAL", "TENANT"];
|
||||
|
||||
export class CreateAIPromptTemplateDto {
|
||||
@IsIn(PURPOSES)
|
||||
purpose!: "ANALYSIS" | "SCORECARD";
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
/** Mesmo padrão de CreateAIProviderDto — GLOBAL exige isPlatformUser,
|
||||
* checado no controller, nunca confiado daqui. Default TENANT. */
|
||||
@IsOptional()
|
||||
@IsIn(SCOPES)
|
||||
scope?: "GLOBAL" | "TENANT";
|
||||
|
||||
/** Se enviado, já cria a v1 com esse conteúdo e a torna a versão ativa —
|
||||
* evita 2 chamadas pro caso comum de "criar template com um texto". */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
}
|
||||
7
apps/api/src/ai/dto/create-ai-prompt-version.dto.ts
Normal file
7
apps/api/src/ai/dto/create-ai-prompt-version.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class CreateAIPromptVersionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
content!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user