feat(ai): provider layer — abstracao, OpenAI/Anthropic, global+BYOK
Fecha agente.md secao 95-103. Primeira peca do modulo de IA — a
abstracao de provider, os adapters OpenAI/Anthropic, capabilities, e o
cadastro de providers/modelos (global + BYOK). O pipeline que aciona
isso depois de uma chamada terminar (transcricao, analise, jobs
assincronos, prompts, scorecards, usage metering — secao 104-124) fica
pra proxima fase.
## Schema completo do modulo de IA numa unica migration
Todas as tabelas das secoes 95-124 de uma vez (ai_providers/ai_models/
ai_prompt_templates+versions/ai_jobs/call_transcriptions+segments/
call_ai_analyses/quality_scorecards+items+evaluations/ai_usage_records) —
mais barato revisar o desenho relacional inteiro numa unica passada do
que fatiar em migrations pequenas que se emendam. O codigo que usa essas
tabelas vem em fases separadas, so' Provider/Model nesta.
## packages/ai
Interface AIProvider (secao 96: nunca hardcoda OpenAI no dominio).
OpenAIProvider/AnthropicProvider (secao 97-98, nomenclatura "OpenAI API"/
"Anthropic API", nunca "ChatGPT") via fetch nativo direto contra cada API
— sem SDK oficial, request/response inteiramente visivel no proprio
codigo (relevante ja' que manda dado de cliente pra fora, secao 122-123).
transcribe so' na OpenAI (Anthropic nao tem endpoint de audio, secao 102:
"nem todo provider tem todas as capacidades"); analyze/structuredGenerate
via Structured Outputs na OpenAI e "tool use" forcado na Anthropic.
SensitiveDataRedactor (secao 123): CPF/CNPJ/telefone/email/cartao.
**Nunca exercitados contra rede real** — esta sessao so' tem autorizacao
de rede pro servidor git (restricao definida desde o primeiro pedido do
usuario). Mesmo padrao de honestidade ja' usado pro S3ObjectStorageProvider
e o caminho PSTN real.
## ai_providers/ai_models — global vs. BYOK
scope=GLOBAL (platform admin, tenant_id null) visivel de qualquer tenant;
scope=TENANT (BYOK) so' do dono. RLS hibrida (tenant_id = current OR
tenant_id IS NULL, mesma tecnica de tenant_memberships no login); quem
pode ESCREVER num GLOBAL e' decidido na camada de servico
(isPlatformUser), nao pela RLS. Key nunca reexposta (so' apiKeyPreview).
## Dois bugs reais achados testando esta fase
- Delete de provider fazia hard delete, bloqueado por FK quando um
AIModel (mesmo soft-deleted) ainda referenciava — inconsistente com o
resto do sistema (tudo soft delete). Corrigido; GET /ai/providers
tambem nao filtrava desabilitados, corrigido junto.
- SensitiveDataRedactor: \b antes de \(? opcional falha quando o char
anterior tambem nao e' de palavra (espaco + "("), vazando um parenteses
solto (nenhum dado sensivel de verdade vazava). Corrigido com (?<!\w).
Verificado ponta a ponta com 2 tenants + platform admin: GLOBAL so'
platform admin cria/apaga, BYOK isolado por RLS (tenant B nunca ve' BYOK
do tenant A, 404 em id direto), modelo de provider GLOBAL visivel dos
dois tenants, redactor com 5 tipos de dado sensivel todos corretos.
typecheck do workspace inteiro limpo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
102
packages/ai/src/anthropic-provider.ts
Normal file
102
packages/ai/src/anthropic-provider.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
AIProvider,
|
||||
AICapabilityName,
|
||||
AIProviderCredentials,
|
||||
AnalyzeParams,
|
||||
AnalyzeResult,
|
||||
} from "./types";
|
||||
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
const TOOL_NAME = "emit_structured_result";
|
||||
|
||||
/**
|
||||
* Adapter pra "Anthropic API" (agente.md secao 97-98). Sem endpoint de
|
||||
* transcrição de áudio (`transcribe` fica undefined — secao 102: "nem todo
|
||||
* provider terá todas as capacidades"). Output estruturado (secao 113) via
|
||||
* "tool use" forçado: define uma tool única com `input_schema` = o JSON
|
||||
* Schema pedido e `tool_choice` forçando essa tool — o bloco `input` da
|
||||
* resposta já vem no formato certo, técnica documentada da própria
|
||||
* Anthropic pra output estruturado confiável.
|
||||
*
|
||||
* **Nunca exercitado nesta sessão** — mesma restrição de rede do
|
||||
* `OpenAIProvider` (só o servidor git é autorizado nesta sessão).
|
||||
*/
|
||||
export class AnthropicProvider implements AIProvider {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly credentials: AIProviderCredentials) {
|
||||
this.baseUrl = credentials.baseUrl ?? "https://api.anthropic.com/v1";
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
"x-api-key": this.credentials.apiKey,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AICapabilityName[]> {
|
||||
return ["TEXT_ANALYSIS", "STRUCTURED_OUTPUT"];
|
||||
}
|
||||
|
||||
async validateCredentials(): Promise<boolean> {
|
||||
const res = await fetch(`${this.baseUrl}/models`, { headers: this.headers() });
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async analyze(params: AnalyzeParams): Promise<AnalyzeResult> {
|
||||
const res = await fetch(`${this.baseUrl}/messages`, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({
|
||||
model: "claude-opus-5",
|
||||
max_tokens: 4096,
|
||||
system: params.promptContent,
|
||||
messages: [{ role: "user", content: params.transcriptText }],
|
||||
tools: [
|
||||
{
|
||||
name: TOOL_NAME,
|
||||
description: "Emite o resultado estruturado da análise da chamada.",
|
||||
input_schema: params.jsonSchema,
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "tool", name: TOOL_NAME },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Anthropic analysis falhou: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
id: string;
|
||||
content: { type: string; name?: string; input?: Record<string, unknown> }[];
|
||||
usage?: { input_tokens: number; output_tokens: number };
|
||||
};
|
||||
|
||||
const toolUse = body.content.find((block) => block.type === "tool_use" && block.name === TOOL_NAME);
|
||||
if (!toolUse?.input) {
|
||||
throw new Error("Anthropic nao retornou o tool_use esperado com o resultado estruturado");
|
||||
}
|
||||
|
||||
return {
|
||||
data: toolUse.input,
|
||||
providerRequestId: body.id,
|
||||
inputTokens: body.usage?.input_tokens,
|
||||
outputTokens: body.usage?.output_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async summarize(text: string): Promise<string> {
|
||||
const result = await this.analyze({
|
||||
transcriptText: text,
|
||||
promptContent: "Resuma o texto a seguir em até 3 frases.",
|
||||
jsonSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
|
||||
});
|
||||
return result.data.summary as string;
|
||||
}
|
||||
|
||||
async structuredGenerate(prompt: string, schema: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const result = await this.analyze({ transcriptText: "", promptContent: prompt, jsonSchema: schema });
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
14
packages/ai/src/index.ts
Normal file
14
packages/ai/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
AIProvider,
|
||||
AICapabilityName,
|
||||
AIProviderCredentials,
|
||||
TranscribeParams,
|
||||
TranscribeResult,
|
||||
TranscribeSegment,
|
||||
AnalyzeParams,
|
||||
AnalyzeResult,
|
||||
} from "./types";
|
||||
export { OpenAIProvider } from "./openai-provider";
|
||||
export { AnthropicProvider } from "./anthropic-provider";
|
||||
export { createAIProvider, SUPPORTED_PROVIDER_TYPES } from "./registry";
|
||||
export { SensitiveDataRedactor } from "./redactor";
|
||||
133
packages/ai/src/openai-provider.ts
Normal file
133
packages/ai/src/openai-provider.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import type {
|
||||
AIProvider,
|
||||
AICapabilityName,
|
||||
AIProviderCredentials,
|
||||
AnalyzeParams,
|
||||
AnalyzeResult,
|
||||
TranscribeParams,
|
||||
TranscribeResult,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Adapter pra "OpenAI API" (agente.md secao 97-98 — nomenclatura da API,
|
||||
* nunca "ChatGPT": o domínio não deve ficar amarrado ao nome do produto de
|
||||
* consumidor). **Nunca exercitado nesta sessão** — esta sessão só tem
|
||||
* autorização de rede pro servidor git do repositório (restrição definida
|
||||
* no início da sessão), então nenhuma chamada real chegou a sair daqui.
|
||||
* Implementado seguindo o contrato documentado da OpenAI API o mais fiel
|
||||
* possível; revisar contra a API real antes de confiar em produção.
|
||||
*/
|
||||
export class OpenAIProvider implements AIProvider {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly credentials: AIProviderCredentials) {
|
||||
this.baseUrl = credentials.baseUrl ?? "https://api.openai.com/v1";
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${this.credentials.apiKey}`,
|
||||
...extra,
|
||||
};
|
||||
if (this.credentials.organization) headers["OpenAI-Organization"] = this.credentials.organization;
|
||||
if (this.credentials.project) headers["OpenAI-Project"] = this.credentials.project;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AICapabilityName[]> {
|
||||
return ["TRANSCRIPTION", "TEXT_ANALYSIS", "STRUCTURED_OUTPUT", "EMBEDDINGS"];
|
||||
}
|
||||
|
||||
async validateCredentials(): Promise<boolean> {
|
||||
const res = await fetch(`${this.baseUrl}/models`, { headers: this.headers() });
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async transcribe(params: TranscribeParams): Promise<TranscribeResult> {
|
||||
const fileBuffer = await readFile(params.audioFilePath);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([fileBuffer]), basename(params.audioFilePath));
|
||||
form.append("model", "whisper-1");
|
||||
form.append("response_format", "verbose_json");
|
||||
if (params.language) form.append("language", params.language);
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/audio/transcriptions`, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenAI transcription falhou: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
text: string;
|
||||
language?: string;
|
||||
duration?: number;
|
||||
segments?: { start: number; end: number; text: string }[];
|
||||
};
|
||||
|
||||
return {
|
||||
text: body.text,
|
||||
language: body.language,
|
||||
durationSeconds: body.duration,
|
||||
segments: body.segments?.map((s) => ({
|
||||
startMs: Math.round(s.start * 1000),
|
||||
endMs: Math.round(s.end * 1000),
|
||||
text: s.text,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async analyze(params: AnalyzeParams): Promise<AnalyzeResult> {
|
||||
// "Structured Outputs" (response_format json_schema, strict) — a API
|
||||
// já valida contra o schema do lado do provider; ainda assim quem
|
||||
// chama esta função deve validar de novo antes de persistir (secao
|
||||
// 113), nunca confiar cegamente.
|
||||
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: this.headers({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: params.promptContent },
|
||||
{ role: "user", content: params.transcriptText },
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: { name: "call_analysis", strict: true, schema: params.jsonSchema },
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenAI analysis falhou: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
id: string;
|
||||
choices: { message: { content: string } }[];
|
||||
usage?: { prompt_tokens: number; completion_tokens: number };
|
||||
};
|
||||
|
||||
return {
|
||||
data: JSON.parse(body.choices[0].message.content),
|
||||
providerRequestId: body.id,
|
||||
inputTokens: body.usage?.prompt_tokens,
|
||||
outputTokens: body.usage?.completion_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async summarize(text: string): Promise<string> {
|
||||
const result = await this.analyze({
|
||||
transcriptText: text,
|
||||
promptContent: "Resuma o texto a seguir em até 3 frases.",
|
||||
jsonSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
|
||||
});
|
||||
return result.data.summary as string;
|
||||
}
|
||||
|
||||
async structuredGenerate(prompt: string, schema: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const result = await this.analyze({ transcriptText: "", promptContent: prompt, jsonSchema: schema });
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
38
packages/ai/src/redactor.ts
Normal file
38
packages/ai/src/redactor.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* `SensitiveDataRedactor` (agente.md secao 123) — mascara dados sensíveis
|
||||
* ANTES de mandar texto pra um provider de IA externo, quando a política
|
||||
* de privacidade exigir (secao 122: níveis por tenant/campanha/fila).
|
||||
* Preparado inicialmente pro Brasil (mesmo escopo de
|
||||
* `packages/shared/src/phone.ts`) — CPF/CNPJ são específicos daqui;
|
||||
* telefone/email/cartão são padrões razoavelmente universais.
|
||||
*
|
||||
* Ordem de aplicação importa: CNPJ (14 dígitos) e cartão (13-19 dígitos)
|
||||
* precisam ser checados antes de padrões mais curtos, senão um CNPJ sem
|
||||
* pontuação poderia ser parcialmente capturado por um regex de telefone.
|
||||
*/
|
||||
const PATTERNS: { label: string; regex: RegExp }[] = [
|
||||
// CNPJ: XX.XXX.XXX/XXXX-XX ou 14 dígitos corridos.
|
||||
{ label: "CNPJ", regex: /\b\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}\b/g },
|
||||
// CPF: XXX.XXX.XXX-XX ou 11 dígitos corridos.
|
||||
{ label: "CPF", regex: /\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/g },
|
||||
// Cartão de crédito: 13-19 dígitos, opcionalmente agrupados de 4 em 4.
|
||||
{ label: "CARTAO", regex: /\b(?:\d[ -]?){13,19}\b/g },
|
||||
// E-mail.
|
||||
{ label: "EMAIL", regex: /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g },
|
||||
// Telefone BR: com ou sem +55/DDD/parênteses/traço. Sem `\b` no começo —
|
||||
// `\(` não é caractere de palavra, então `\b` logo antes de um `\(?`
|
||||
// opcional falha em casar quando o char anterior também não é de
|
||||
// palavra (ex.: espaço seguido de "("), deixando o parêntese de fora do
|
||||
// match. `(?<!\w)` cobre o mesmo caso sem esse problema.
|
||||
{ label: "TELEFONE", regex: /(?<!\w)(?:\+?55\s?)?\(?\d{2}\)?[\s.-]?\d{4,5}[\s.-]?\d{4}\b/g },
|
||||
];
|
||||
|
||||
export class SensitiveDataRedactor {
|
||||
redact(text: string): string {
|
||||
let result = text;
|
||||
for (const { label, regex } of PATTERNS) {
|
||||
result = result.replace(regex, `[${label}]`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
25
packages/ai/src/registry.ts
Normal file
25
packages/ai/src/registry.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { AIProvider, AIProviderCredentials } from "./types";
|
||||
import { OpenAIProvider } from "./openai-provider";
|
||||
import { AnthropicProvider } from "./anthropic-provider";
|
||||
|
||||
/**
|
||||
* Registro de adapters implementados de verdade — `providerType` é uma
|
||||
* `string` livre no banco (agente.md secao 96-97: "arquitetura deve
|
||||
* permitir Google/Azure/Bedrock/modelos locais/outros futuramente"), não
|
||||
* um enum fechado. Adicionar um provider novo é só criar o adapter e
|
||||
* registrar aqui, nunca uma migration.
|
||||
*/
|
||||
const ADAPTERS: Record<string, (credentials: AIProviderCredentials) => AIProvider> = {
|
||||
openai: (c) => new OpenAIProvider(c),
|
||||
anthropic: (c) => new AnthropicProvider(c),
|
||||
};
|
||||
|
||||
export const SUPPORTED_PROVIDER_TYPES = Object.keys(ADAPTERS);
|
||||
|
||||
export function createAIProvider(providerType: string, credentials: AIProviderCredentials): AIProvider {
|
||||
const factory = ADAPTERS[providerType];
|
||||
if (!factory) {
|
||||
throw new Error(`Provider de IA nao suportado: ${providerType}`);
|
||||
}
|
||||
return factory(credentials);
|
||||
}
|
||||
77
packages/ai/src/types.ts
Normal file
77
packages/ai/src/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Abstração de provider de IA (agente.md secao 96): "não hardcode OpenAI
|
||||
* no domínio". Nenhum código fora deste pacote deve importar um SDK de
|
||||
* provider específico ou saber o formato de request/response de uma API
|
||||
* de IA em particular — só fala com esta interface.
|
||||
*/
|
||||
|
||||
// Secao 102: nem todo provider tem todas as capacidades (ex.: Anthropic
|
||||
// não tem endpoint de transcrição de áudio).
|
||||
export type AICapabilityName =
|
||||
| "TRANSCRIPTION"
|
||||
| "DIARIZATION"
|
||||
| "TEXT_ANALYSIS"
|
||||
| "STRUCTURED_OUTPUT"
|
||||
| "EMBEDDINGS"
|
||||
| "REALTIME_AUDIO";
|
||||
|
||||
export interface TranscribeParams {
|
||||
audioFilePath: string;
|
||||
language?: string;
|
||||
diarization?: boolean;
|
||||
}
|
||||
|
||||
export interface TranscribeSegment {
|
||||
speaker?: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface TranscribeResult {
|
||||
text: string;
|
||||
language?: string;
|
||||
durationSeconds?: number;
|
||||
segments?: TranscribeSegment[];
|
||||
providerRequestId?: string;
|
||||
inputUsage?: number;
|
||||
outputUsage?: number;
|
||||
}
|
||||
|
||||
export interface AnalyzeParams {
|
||||
/** Texto já passado pelo SensitiveDataRedactor quando a política exigir
|
||||
* (secao 123) — o provider nunca decide isso sozinho. */
|
||||
transcriptText: string;
|
||||
promptContent: string;
|
||||
/** JSON Schema que a resposta precisa satisfazer (secao 113: "não usar
|
||||
* somente texto livre... validar antes de persistir"). */
|
||||
jsonSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AnalyzeResult {
|
||||
/** JSON já validado contra `jsonSchema` — quem chama ainda faz a própria
|
||||
* validação de novo antes de persistir (defesa em profundidade, nunca
|
||||
* confia cegamente na promessa do provider de que respeitou o schema). */
|
||||
data: Record<string, unknown>;
|
||||
providerRequestId?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
getCapabilities(): Promise<AICapabilityName[]>;
|
||||
validateCredentials(): Promise<boolean>;
|
||||
|
||||
transcribe?(params: TranscribeParams): Promise<TranscribeResult>;
|
||||
analyze?(params: AnalyzeParams): Promise<AnalyzeResult>;
|
||||
summarize?(text: string): Promise<string>;
|
||||
structuredGenerate?(prompt: string, schema: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface AIProviderCredentials {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
organization?: string;
|
||||
project?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user