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:
@@ -16,6 +16,8 @@ COPY packages/shared packages/shared
|
||||
COPY packages/telephony packages/telephony
|
||||
COPY packages/database packages/database
|
||||
COPY packages/storage packages/storage
|
||||
COPY packages/entitlements packages/entitlements
|
||||
COPY packages/ai packages/ai
|
||||
COPY apps/freeswitch-events apps/freeswitch-events
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-events...
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@b2bcall/ai": "workspace:*",
|
||||
"@b2bcall/database": "workspace:*",
|
||||
"@b2bcall/entitlements": "workspace:*",
|
||||
"@b2bcall/shared": "workspace:*",
|
||||
"@b2bcall/storage": "workspace:*",
|
||||
"@b2bcall/telephony": "workspace:*",
|
||||
|
||||
71
apps/freeswitch-events/src/ai-trigger.ts
Normal file
71
apps/freeswitch-events/src/ai-trigger.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getPrismaClient, withTenantContext, type Tenant, type Plan } from "@b2bcall/database";
|
||||
import { isFeatureEnabled } from "@b2bcall/entitlements";
|
||||
import { resolvePrivacyLevel, allowsTranscription, type AIPrivacyLevelName } from "@b2bcall/ai";
|
||||
import { createLogger } from "@b2bcall/shared";
|
||||
|
||||
const logger = createLogger("b2bcall-fs-events");
|
||||
|
||||
/**
|
||||
* Decide se uma gravação recém-disponível deve virar um AIJob de
|
||||
* transcrição (agente.md secao 104-108: "pipeline sempre assíncrono,
|
||||
* disparado depois que a gravação existe"). Duas checagens independentes,
|
||||
* as duas precisam passar:
|
||||
*
|
||||
* 1. Entitlement do Plan (`aiEnabled` + `aiTranscriptionEnabled`) — "essa
|
||||
* conta contratou IA?", nada a ver com o que o tenant CONFIGUROU.
|
||||
* 2. Cascata de privacidade Tenant > Queue > Campaign (secao 122) — "esse
|
||||
* tenant/fila/campanha específica AUTORIZOU processar esta chamada?".
|
||||
*
|
||||
* Roda só uma vez, no momento em que a gravação é confirmada — não há
|
||||
* reconsulta depois (se a config mudar depois, não afeta jobs já
|
||||
* decididos, mesma filosofia de "decisão tomada no momento do evento" já
|
||||
* usada pro resto do CDR).
|
||||
*/
|
||||
export async function enqueueTranscriptionJobIfEligible(
|
||||
tenantId: string,
|
||||
callId: string,
|
||||
tenant: Tenant & { plan: Plan },
|
||||
queueId: string | null,
|
||||
campaignId: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [aiEnabled, transcriptionEnabled] = await Promise.all([
|
||||
isFeatureEnabled(tenantId, "aiEnabled"),
|
||||
isFeatureEnabled(tenantId, "aiTranscriptionEnabled"),
|
||||
]);
|
||||
if (!aiEnabled || !transcriptionEnabled) return;
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
let queueLevel: AIPrivacyLevelName | null = null;
|
||||
let campaignTranscriptionEnabled = false;
|
||||
let campaignAnalysisEnabled = false;
|
||||
|
||||
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
if (queueId) {
|
||||
const queue = await tx.queue.findFirst({ where: { id: queueId } });
|
||||
queueLevel = (queue?.aiPrivacyLevel as AIPrivacyLevelName | null) ?? null;
|
||||
}
|
||||
if (campaignId) {
|
||||
const campaign = await tx.campaign.findFirst({ where: { id: campaignId } });
|
||||
campaignTranscriptionEnabled = campaign?.aiTranscriptionEnabled ?? false;
|
||||
campaignAnalysisEnabled = campaign?.aiAnalysisEnabled ?? false;
|
||||
}
|
||||
});
|
||||
|
||||
const effectiveLevel = resolvePrivacyLevel({
|
||||
tenantLevel: tenant.aiPrivacyLevel as AIPrivacyLevelName,
|
||||
queueLevel,
|
||||
campaignTranscriptionEnabled,
|
||||
campaignAnalysisEnabled,
|
||||
});
|
||||
if (!allowsTranscription(effectiveLevel)) return;
|
||||
|
||||
await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIJob.create({ data: { tenantId, callId, type: "TRANSCRIPTION" } }),
|
||||
);
|
||||
logger.info("job de transcricao de IA enfileirado", { callId, effectiveLevel });
|
||||
} catch (err) {
|
||||
logger.error("falha ao decidir/enfileirar job de IA", { error: String(err), callId });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { getObjectStorageProvider, buildRecordingObjectKey } from "@b2bcall/storage";
|
||||
import { createLogger } from "@b2bcall/shared";
|
||||
import { enqueueTranscriptionJobIfEligible } from "./ai-trigger";
|
||||
|
||||
const logger = createLogger("b2bcall-fs-events");
|
||||
|
||||
@@ -83,6 +84,8 @@ export async function uploadRecordingIfPresent(tenantId: string, callId: string)
|
||||
|
||||
await rm(spoolPath, { force: true });
|
||||
logger.info("gravacao enviada pro object storage", { callId, objectKey, sizeBytes });
|
||||
|
||||
await enqueueTranscriptionJobIfEligible(tenantId, callId, tenant, call.queueId, call.campaignId);
|
||||
} catch (err) {
|
||||
logger.error("falha ao processar gravacao", { error: String(err), callId });
|
||||
await withTenantContext(prisma, tenantId, (tx) =>
|
||||
|
||||
Reference in New Issue
Block a user