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:
2026-08-28 15:05:00 -03:00
parent c24a86776c
commit 7597b35454
20 changed files with 1878 additions and 62 deletions

View 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
View 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";

View 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;
}
}

View 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;
}
}

View 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
View 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;
}