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:
2026-08-28 16:39:38 -03:00
parent 7597b35454
commit 91c0448dd4
29 changed files with 1427 additions and 5 deletions

View 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;
}
}

View File

@@ -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 {}

View 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;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class CreateAIPromptVersionDto {
@IsString()
@MinLength(1)
content!: string;
}