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:
106
packages/ai/src/call-analysis-schema.ts
Normal file
106
packages/ai/src/call-analysis-schema.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
38
packages/ai/src/privacy.ts
Normal file
38
packages/ai/src/privacy.ts
Normal 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
23
packages/ai/src/retry.ts
Normal 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;
|
||||
}
|
||||
118
packages/ai/src/wav-stereo-split.ts
Normal file
118
packages/ai/src/wav-stereo-split.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user