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,106 @@
/**
* Schema do resultado estruturado de análise de chamada (agente.md secao
* 112-113: "não usar somente texto livre... validar antes de persistir").
*
* `CALL_ANALYSIS_JSON_SCHEMA` é o JSON Schema mandado pro provider (via
* `AnalyzeParams.jsonSchema`) pra guiar/restringir a geração.
* `validateCallAnalysisResult` é a validação de verdade, feita no NOSSO
* lado — nunca confia cegamente que o provider respeitou o schema pedido
* (defesa em profundidade), sem depender de uma lib de JSON Schema
* genérica pra um formato que já é fixo e conhecido.
*/
export const CALL_ANALYSIS_JSON_SCHEMA = {
type: "object",
properties: {
summary: { type: "string" },
customerIntent: { type: "string" },
outcome: { type: "string" },
sentiment: { type: "string", enum: ["POSITIVE", "NEUTRAL", "NEGATIVE"] },
topics: { type: "array", items: { type: "string" } },
keywords: { type: "array", items: { type: "string" } },
objections: { type: "array", items: { type: "string" } },
questions: { type: "array", items: { type: "string" } },
actionItems: { type: "array", items: { type: "string" } },
complianceFlags: { type: "array", items: { type: "string" } },
riskFlags: { type: "array", items: { type: "string" } },
qualityScore: { type: "integer", minimum: 0, maximum: 100 },
agentScore: { type: "integer", minimum: 0, maximum: 100 },
customerSentimentScore: { type: "number", minimum: -1, maximum: 1 },
salesOpportunity: { type: "boolean" },
nextBestAction: { type: "string" },
},
required: ["summary", "sentiment", "topics", "qualityScore"],
additionalProperties: false,
} as const;
export interface CallAnalysisResult {
summary: string;
sentiment: string;
topics: string[];
qualityScore: number;
customerIntent?: string;
outcome?: string;
keywords: string[];
objections: string[];
questions: string[];
actionItems: string[];
complianceFlags: string[];
riskFlags: string[];
agentScore?: number;
customerSentimentScore?: number;
salesOpportunity?: boolean;
nextBestAction?: string;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}
export class CallAnalysisValidationError extends Error {}
export function validateCallAnalysisResult(data: unknown): CallAnalysisResult {
if (typeof data !== "object" || data === null) {
throw new CallAnalysisValidationError("Resultado de análise não é um objeto");
}
const d = data as Record<string, unknown>;
if (typeof d.summary !== "string" || d.summary.trim() === "") {
throw new CallAnalysisValidationError("summary ausente ou inválido");
}
if (typeof d.sentiment !== "string") {
throw new CallAnalysisValidationError("sentiment ausente ou inválido");
}
if (!isStringArray(d.topics)) {
throw new CallAnalysisValidationError("topics ausente ou inválido");
}
if (typeof d.qualityScore !== "number" || d.qualityScore < 0 || d.qualityScore > 100) {
throw new CallAnalysisValidationError("qualityScore ausente ou fora do intervalo 0-100");
}
return {
summary: d.summary,
sentiment: d.sentiment,
topics: d.topics,
qualityScore: Math.round(d.qualityScore),
customerIntent: typeof d.customerIntent === "string" ? d.customerIntent : undefined,
outcome: typeof d.outcome === "string" ? d.outcome : undefined,
keywords: isStringArray(d.keywords) ? d.keywords : [],
objections: isStringArray(d.objections) ? d.objections : [],
questions: isStringArray(d.questions) ? d.questions : [],
actionItems: isStringArray(d.actionItems) ? d.actionItems : [],
complianceFlags: isStringArray(d.complianceFlags) ? d.complianceFlags : [],
riskFlags: isStringArray(d.riskFlags) ? d.riskFlags : [],
agentScore:
typeof d.agentScore === "number" && d.agentScore >= 0 && d.agentScore <= 100
? Math.round(d.agentScore)
: undefined,
customerSentimentScore:
typeof d.customerSentimentScore === "number" &&
d.customerSentimentScore >= -1 &&
d.customerSentimentScore <= 1
? d.customerSentimentScore
: undefined,
salesOpportunity: typeof d.salesOpportunity === "boolean" ? d.salesOpportunity : undefined,
nextBestAction: typeof d.nextBestAction === "string" ? d.nextBestAction : undefined,
};
}

View File

@@ -12,3 +12,18 @@ export { OpenAIProvider } from "./openai-provider";
export { AnthropicProvider } from "./anthropic-provider";
export { createAIProvider, SUPPORTED_PROVIDER_TYPES } from "./registry";
export { SensitiveDataRedactor } from "./redactor";
export {
CALL_ANALYSIS_JSON_SCHEMA,
validateCallAnalysisResult,
CallAnalysisValidationError,
type CallAnalysisResult,
} from "./call-analysis-schema";
export {
resolvePrivacyLevel,
allowsTranscription,
allowsAnalysis,
type AIPrivacyLevelName,
type ResolvePrivacyParams,
} from "./privacy";
export { computeBackoffDelayMs, computeNextScheduledAt, isDeadLetter } from "./retry";
export { splitStereoWav, type StereoSplitResult } from "./wav-stereo-split";

View File

@@ -0,0 +1,38 @@
/**
* Resolução de privacidade de IA em cascata (agente.md secao 122):
* Campaign > Queue > Tenant (o nível mais específico configurado vence).
* Tenant é o único nível obrigatório (nunca null no banco, default
* AI_OFF) — os outros dois só existem como *override* opcional.
*
* Simplificação conhecida: `Campaign` guarda a intenção como dois
* booleanos independentes (`aiTranscriptionEnabled`/`aiAnalysisEnabled`,
* já existiam desde a fase Campaigns, secao 63) em vez do enum de 3
* níveis usado em `Tenant`/`Queue` — não há como uma campanha forçar
* AI_OFF explicitamente quando o tenant/fila já tem um nível mais
* permissivo (só pode fazer *opt-in*, nunca *opt-out* explícito). Dado
* que "opt-in" é a direção mais segura por padrão, aceitável por agora;
* documentado em docs/AI_PIPELINE.md.
*/
export type AIPrivacyLevelName = "AI_OFF" | "TRANSCRIPTION_ONLY" | "TRANSCRIPTION_AND_ANALYSIS";
export interface ResolvePrivacyParams {
tenantLevel: AIPrivacyLevelName;
queueLevel?: AIPrivacyLevelName | null;
campaignTranscriptionEnabled?: boolean;
campaignAnalysisEnabled?: boolean;
}
export function resolvePrivacyLevel(params: ResolvePrivacyParams): AIPrivacyLevelName {
if (params.campaignAnalysisEnabled) return "TRANSCRIPTION_AND_ANALYSIS";
if (params.campaignTranscriptionEnabled) return "TRANSCRIPTION_ONLY";
if (params.queueLevel) return params.queueLevel;
return params.tenantLevel;
}
export function allowsTranscription(level: AIPrivacyLevelName): boolean {
return level === "TRANSCRIPTION_ONLY" || level === "TRANSCRIPTION_AND_ANALYSIS";
}
export function allowsAnalysis(level: AIPrivacyLevelName): boolean {
return level === "TRANSCRIPTION_AND_ANALYSIS";
}

23
packages/ai/src/retry.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Retry/backoff dos AI jobs (agente.md secao 108): "retry, exponential
* backoff, dead-letter strategy, limitar tentativas" — nunca retry
* infinito.
*/
const BASE_DELAY_MS = 30_000; // 30s
const MAX_DELAY_MS = 30 * 60_000; // 30min
/** Backoff exponencial com teto — 2^attempt * base, nunca passa do teto. */
export function computeBackoffDelayMs(attemptCount: number): number {
const delay = BASE_DELAY_MS * 2 ** Math.max(0, attemptCount - 1);
return Math.min(delay, MAX_DELAY_MS);
}
export function computeNextScheduledAt(attemptCount: number, now: Date = new Date()): Date {
return new Date(now.getTime() + computeBackoffDelayMs(attemptCount));
}
/** Secao 108: "limitar tentativas" — dead-letter (FAILED terminal) quando
* esgotar. */
export function isDeadLetter(attemptCount: number, maxAttempts: number): boolean {
return attemptCount >= maxAttempts;
}

View File

@@ -0,0 +1,118 @@
/**
* Separação de canais de um WAV estéreo PCM (agente.md secao 111: "sempre
* que possível utilizar canais estéreo pra identificar Agent/Customer...
* não confiar cegamente em diarização quando a direção do áudio permite
* identificação melhor"). Em vez de mandar o áudio estéreo misturado pro
* provider de transcrição (que faria uma diarização probabilística), cada
* canal vira um WAV mono separado, transcrito independentemente — o
* speaker de cada segmento fica 100% determinístico pelo canal de origem,
* não por inferência.
*
* Parser mínimo, não uma lib de áudio genérica: só entende exatamente o
* formato que `record_session`/RECORD_STEREO do FreeSWITCH produz (RIFF/
* WAVE, PCM linear, 16-bit) — o suficiente pro nosso pipeline, sem trazer
* uma dependência de processamento de áudio (ffmpeg) só pra isso.
*
* Convenção assumida (não verificada contra áudio real distinguível nesta
* sessão — as gravações de teste eram silêncio de `null/dummy`, sem como
* confirmar auditivamente qual canal é qual): canal 0 = a perna em que
* `record_session` foi chamado (o member/"cliente" no fluxo do discador),
* canal 1 = a perna bridgeada (o agente). Revisar com uma chamada real
* antes de confiar no mapeamento em produção — ver docs/AI_PIPELINE.md.
*/
interface WavInfo {
numChannels: number;
sampleRate: number;
bitsPerSample: number;
dataOffset: number;
dataLength: number;
}
function parseWavHeader(buffer: Buffer): WavInfo {
if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") {
throw new Error("Arquivo nao e' um WAV RIFF valido");
}
let offset = 12;
let fmt: { numChannels: number; sampleRate: number; bitsPerSample: number } | undefined;
let dataOffset: number | undefined;
let dataLength: number | undefined;
while (offset + 8 <= buffer.length) {
const chunkId = buffer.toString("ascii", offset, offset + 4);
const chunkSize = buffer.readUInt32LE(offset + 4);
const bodyStart = offset + 8;
if (chunkId === "fmt ") {
fmt = {
numChannels: buffer.readUInt16LE(bodyStart + 2),
sampleRate: buffer.readUInt32LE(bodyStart + 4),
bitsPerSample: buffer.readUInt16LE(bodyStart + 14),
};
} else if (chunkId === "data") {
dataOffset = bodyStart;
dataLength = chunkSize;
}
offset = bodyStart + chunkSize + (chunkSize % 2); // chunks são alinhados em 2 bytes
}
if (!fmt || dataOffset === undefined || dataLength === undefined) {
throw new Error("WAV sem chunk fmt/data reconhecível");
}
if (fmt.bitsPerSample !== 16) {
throw new Error(`Só suporta PCM 16-bit (recebido ${fmt.bitsPerSample}-bit)`);
}
return { ...fmt, dataOffset, dataLength };
}
function buildMonoWav(samples: Buffer, sampleRate: number): Buffer {
const header = Buffer.alloc(44);
header.write("RIFF", 0, "ascii");
header.writeUInt32LE(36 + samples.length, 4);
header.write("WAVE", 8, "ascii");
header.write("fmt ", 12, "ascii");
header.writeUInt32LE(16, 16); // fmt chunk size
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(1, 22); // 1 canal
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28); // byte rate (16-bit mono)
header.writeUInt16LE(2, 32); // block align
header.writeUInt16LE(16, 34); // bits per sample
header.write("data", 36, "ascii");
header.writeUInt32LE(samples.length, 40);
return Buffer.concat([header, samples]);
}
export interface StereoSplitResult {
channel0: Buffer;
channel1: Buffer;
sampleRate: number;
}
/** Recebe um WAV estéreo 16-bit e retorna dois buffers WAV mono (um por
* canal), prontos pra transcrever cada um separadamente. */
export function splitStereoWav(input: Buffer): StereoSplitResult {
const info = parseWavHeader(input);
if (info.numChannels !== 2) {
throw new Error(`Esperava WAV estéreo, recebeu ${info.numChannels} canal(is)`);
}
const frameCount = Math.floor(info.dataLength / 4); // 2 canais * 2 bytes
const left = Buffer.alloc(frameCount * 2);
const right = Buffer.alloc(frameCount * 2);
for (let i = 0; i < frameCount; i++) {
const frameOffset = info.dataOffset + i * 4;
left.writeInt16LE(input.readInt16LE(frameOffset), i * 2);
right.writeInt16LE(input.readInt16LE(frameOffset + 2), i * 2);
}
return {
channel0: buildMonoWav(left, info.sampleRate),
channel1: buildMonoWav(right, info.sampleRate),
sampleRate: info.sampleRate,
};
}