294 lines
9.4 KiB
TypeScript
294 lines
9.4 KiB
TypeScript
import type { AuditConfig, AuditScenario, AuditReport } from './types';
|
|
import { buildPrompt } from './prompt-builder';
|
|
import { parseLLMResponse, buildAuditReport } from './parser';
|
|
|
|
interface OpenAIMessage {
|
|
role: 'system' | 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
interface OpenAIRequest {
|
|
model: string;
|
|
messages: OpenAIMessage[];
|
|
temperature: number;
|
|
response_format: { type: 'json_object' };
|
|
}
|
|
|
|
interface AnthropicRequest {
|
|
model: string;
|
|
messages: { role: 'user' | 'assistant'; content: string }[];
|
|
system?: string;
|
|
temperature: number;
|
|
max_tokens: number;
|
|
}
|
|
|
|
export async function runLLMAudit(
|
|
config: AuditConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
onProgress?: (msg: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
config = { ...config, apiKey: config.apiKey?.trim() || '' };
|
|
if (config.provider === 'openai') {
|
|
return callOpenAI(config, systemPrompt, userPrompt, onProgress, signal);
|
|
} else if (config.provider === 'anthropic') {
|
|
return callAnthropic(config, systemPrompt, userPrompt, onProgress, signal);
|
|
} else if (config.provider === 'minimax') {
|
|
return callMinimax(config, systemPrompt, userPrompt, onProgress, signal);
|
|
} else if (config.provider === 'openrouter') {
|
|
return callOpenRouter(config, systemPrompt, userPrompt, onProgress, signal);
|
|
} else {
|
|
return callOllama(config, systemPrompt, userPrompt, onProgress, signal);
|
|
}
|
|
}
|
|
|
|
async function callOpenAI(
|
|
config: AuditConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
onProgress?: (msg: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
onProgress?.('Enviando para OpenAI...');
|
|
const url = 'https://api.openai.com/v1/chat/completions';
|
|
const body: OpenAIRequest = {
|
|
model: config.model,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
temperature: 0,
|
|
response_format: { type: 'json_object' },
|
|
};
|
|
const response = await fetchWithTimeout(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${config.apiKey}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
}, 180000, signal);
|
|
const data = await response.json() as { choices?: { message?: { content?: string } }[]; error?: { message?: string } };
|
|
if (data.error) throw new Error(`OpenAI error: ${data.error.message}`);
|
|
const content = data.choices?.[0]?.message?.content;
|
|
if (!content) throw new Error('OpenAI returned empty response');
|
|
return content;
|
|
}
|
|
|
|
async function callAnthropic(
|
|
config: AuditConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
onProgress?: (msg: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
onProgress?.('Enviando para Anthropic...');
|
|
const url = 'https://api.anthropic.com/v1/messages';
|
|
const body: AnthropicRequest = {
|
|
model: config.model,
|
|
messages: [
|
|
{ role: 'user', content: `${systemPrompt}\n\n${userPrompt}` },
|
|
],
|
|
system: systemPrompt,
|
|
temperature: 0,
|
|
max_tokens: 8192,
|
|
};
|
|
const response = await fetchWithTimeout(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': config.apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
'anthropic-dangerous-direct-browser-access': 'true',
|
|
},
|
|
body: JSON.stringify(body),
|
|
}, 180000, signal);
|
|
const data = await response.json() as { content?: { text?: string }[]; error?: { type?: string; message?: string } };
|
|
if (data.error) throw new Error(`Anthropic error: ${data.error.message}`);
|
|
const text = data.content?.[0]?.text;
|
|
if (!text) throw new Error('Anthropic returned empty response');
|
|
return text;
|
|
}
|
|
|
|
async function callMinimax(
|
|
config: AuditConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
onProgress?: (msg: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
onProgress?.('Enviando para MiniMax...');
|
|
const url = config.baseUrl || 'https://api.minimax.chat/v1/chat/completions';
|
|
const body = {
|
|
model: config.model,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
temperature: 0,
|
|
response_format: { type: 'json_object' },
|
|
};
|
|
const response = await fetchWithTimeout(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${config.apiKey}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
}, 180000, signal);
|
|
const data = await response.json() as { choices?: { message?: { content?: string } }[]; error?: { message?: string } };
|
|
if (data.error) throw new Error(`MiniMax error: ${data.error.message}`);
|
|
const content = data.choices?.[0]?.message?.content;
|
|
if (!content) throw new Error('MiniMax returned empty response');
|
|
return content;
|
|
}
|
|
|
|
async function callOpenRouter(
|
|
config: AuditConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
onProgress?: (msg: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
onProgress?.('Enviando para OpenRouter...');
|
|
const url = config.baseUrl || 'https://openrouter.ai/api/v1/chat/completions';
|
|
const body = {
|
|
model: config.model,
|
|
messages: [
|
|
{ role: 'system' as const, content: systemPrompt },
|
|
{ role: 'user' as const, content: userPrompt },
|
|
],
|
|
temperature: 0,
|
|
};
|
|
const response = await fetchWithTimeout(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${config.apiKey}`,
|
|
'HTTP-Referer': 'https://brainwind.app',
|
|
'X-Title': 'BrainWind NBR 6123',
|
|
},
|
|
body: JSON.stringify(body),
|
|
}, 180000, signal);
|
|
if (!response.ok) {
|
|
const errText = await response.text();
|
|
throw new Error(`OpenRouter HTTP ${response.status}: ${errText}`);
|
|
}
|
|
const data = await response.json() as { choices?: { message?: { content?: string } }[]; error?: { message?: string } };
|
|
if (data.error) throw new Error(`OpenRouter error: ${data.error.message}`);
|
|
const content = data.choices?.[0]?.message?.content;
|
|
if (!content) throw new Error('OpenRouter returned empty response');
|
|
return content;
|
|
}
|
|
|
|
async function callOllama(
|
|
config: AuditConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
onProgress?: (msg: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
onProgress?.('Enviando para Ollama (local)...');
|
|
const baseUrl = config.baseUrl || 'http://localhost:11434';
|
|
const body = {
|
|
model: config.model,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
stream: false,
|
|
format: 'json',
|
|
options: { temperature: 0 },
|
|
};
|
|
const response = await fetchWithTimeout(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
}, 300000, signal);
|
|
const data = await response.json() as { message?: { content?: string }; error?: string };
|
|
if (data.error) throw new Error(`Ollama error: ${data.error}`);
|
|
if (!data.message?.content) throw new Error('Ollama returned empty response');
|
|
return data.message.content;
|
|
}
|
|
|
|
export async function runAudit(
|
|
config: AuditConfig,
|
|
scenarios: AuditScenario[],
|
|
onProgress?: (progress: number) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<AuditReport> {
|
|
onProgress?.(0);
|
|
const maxRetriesPerChunk = 2;
|
|
const chunkSize = 5; // Reduzido de 10 para 5 devido ao alto volume de tokens do Chain of Thought
|
|
|
|
const allLlmResults = [];
|
|
let completed = 0;
|
|
let rawResponseFull = '';
|
|
|
|
for (let i = 0; i < scenarios.length; i += chunkSize) {
|
|
if (signal?.aborted) {
|
|
throw new Error('Abortado pelo usuário');
|
|
}
|
|
const chunk = scenarios.slice(i, i + chunkSize);
|
|
let chunkSuccess = false;
|
|
let chunkError: Error | null = null;
|
|
|
|
for (let retry = 0; retry <= maxRetriesPerChunk; retry++) {
|
|
if (signal?.aborted) {
|
|
throw new Error('Abortado pelo usuário');
|
|
}
|
|
try {
|
|
const prompt = buildPrompt(chunk);
|
|
// Avisar a UI do progresso absoluto
|
|
onProgress?.(completed);
|
|
|
|
const rawResponse = await runLLMAudit(config, prompt.system, prompt.user, (msg) => {
|
|
// opcional
|
|
}, signal);
|
|
rawResponseFull += rawResponse + '\n\n';
|
|
|
|
const llmResults = parseLLMResponse(rawResponse, chunk.map(s => s.id));
|
|
allLlmResults.push(...llmResults);
|
|
chunkSuccess = true;
|
|
break; // Sucesso, sai do retry loop
|
|
} catch (e) {
|
|
chunkError = e instanceof Error ? e : new Error(String(e));
|
|
console.warn(`Erro no chunk ${i} (tentativa ${retry}):`, chunkError);
|
|
}
|
|
}
|
|
|
|
if (!chunkSuccess) {
|
|
throw chunkError ?? new Error(`Falha ao auditar cenários no índice ${i} após retentativas.`);
|
|
}
|
|
completed += chunk.length;
|
|
onProgress?.(completed);
|
|
}
|
|
|
|
const scenarioMap = new Map(scenarios.map(s => [s.id, { module: s.module, moduleLabel: s.moduleLabel }]));
|
|
const report = buildAuditReport(allLlmResults, scenarioMap, config.provider, config.model, rawResponseFull);
|
|
return report;
|
|
}
|
|
|
|
async function fetchWithTimeout(
|
|
url: string,
|
|
init: RequestInit,
|
|
timeoutMs: number,
|
|
parentSignal?: AbortSignal,
|
|
): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
if (parentSignal) {
|
|
parentSignal.addEventListener('abort', () => controller.abort());
|
|
if (parentSignal.aborted) controller.abort();
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
return response;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|