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
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
import { access, rm } from "node:fs/promises";
|
|
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");
|
|
|
|
const SPOOL_DIR = process.env.RECORDINGS_SPOOL_DIR ?? "/recordings";
|
|
const STORAGE_KIND = (process.env.STORAGE_PROVIDER ?? "local").toUpperCase() as "LOCAL" | "S3";
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function fileExists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sobe pro object storage a gravação de uma chamada que acabou de
|
|
* terminar (agente.md secao 90-93) — chamado a partir de CALL_ENDED em
|
|
* main.ts, sempre depois do CDR já ter persistido `Call` (precisa de
|
|
* `Call.createdAt`/`talkTime` pra `recordedAt`/`durationSeconds`).
|
|
*
|
|
* `originateSimulatedAnswerLeg`/`originateRealPstnLeg`
|
|
* (apps/predictive-dialer) só setam `execute_on_answer='record_session
|
|
* ...'` quando `Campaign.recordingEnabled` é true — pra qualquer outra
|
|
* chamada, o arquivo simplesmente não existe, e essa função não faz nada
|
|
* (checagem por existência de arquivo, não por reconsultar a campanha).
|
|
*/
|
|
export async function uploadRecordingIfPresent(tenantId: string, callId: string): Promise<void> {
|
|
const spoolPath = join(SPOOL_DIR, `${callId}.wav`);
|
|
|
|
// record_session termina de gravar no CHANNEL_HANGUP_COMPLETE, mas pode
|
|
// levar um instante pra flush terminar — tenta algumas vezes antes de
|
|
// desistir, em vez de perder a gravação por uma corrida.
|
|
let exists = await fileExists(spoolPath);
|
|
for (let attempt = 0; !exists && attempt < 5; attempt++) {
|
|
await sleep(300);
|
|
exists = await fileExists(spoolPath);
|
|
}
|
|
if (!exists) return;
|
|
|
|
const prisma = getPrismaClient();
|
|
|
|
try {
|
|
const call = await withTenantContext(prisma, tenantId, (tx) => tx.call.findUniqueOrThrow({ where: { id: callId } }));
|
|
const tenant = await prisma.tenant.findUniqueOrThrow({ where: { id: tenantId }, include: { plan: true } });
|
|
|
|
const recordedAt = call.createdAt;
|
|
const objectKey = buildRecordingObjectKey(tenantId, callId, recordedAt);
|
|
const storage = getObjectStorageProvider();
|
|
const { sizeBytes, checksum } = await storage.putObject(objectKey, spoolPath);
|
|
|
|
const retentionDays = tenant.plan.recordingRetentionDays;
|
|
const retentionUntil = retentionDays
|
|
? new Date(recordedAt.getTime() + retentionDays * 24 * 60 * 60 * 1000)
|
|
: null;
|
|
|
|
await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.recording.create({
|
|
data: {
|
|
tenantId,
|
|
callId,
|
|
storageProvider: STORAGE_KIND,
|
|
objectKey,
|
|
format: "wav",
|
|
durationSeconds: call.talkTime ?? call.durationSeconds,
|
|
channels: 2,
|
|
sizeBytes,
|
|
checksum,
|
|
recordedAt,
|
|
retentionUntil,
|
|
},
|
|
}),
|
|
);
|
|
|
|
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) =>
|
|
tx.recording
|
|
.create({
|
|
data: {
|
|
tenantId,
|
|
callId,
|
|
storageProvider: STORAGE_KIND,
|
|
objectKey: "",
|
|
recordedAt: new Date(),
|
|
status: "FAILED",
|
|
},
|
|
})
|
|
.catch(() => undefined),
|
|
);
|
|
}
|
|
}
|