- Added `emoji-regex` and `remove-accents` packages to improve text processing capabilities. - Implemented automatic Ollama startup in the chat page for better user experience. - Enhanced setup page to include GPU selection based on hardware specifications. - Updated chat input to support real-time validation and improved user feedback. - Refactored message handling in chat to incorporate new processing features.
299 lines
8.8 KiB
TypeScript
299 lines
8.8 KiB
TypeScript
/**
|
|
* Sistema de Fallback Progressivo para Pesquisa Web
|
|
*
|
|
* Implementa múltiplas tentativas de busca com query expansion,
|
|
* validação de relevância e fallback para conhecimento interno.
|
|
*/
|
|
|
|
import type { ScrapedContent } from '@/services/webSearch';
|
|
import { chatLog } from '@/lib/terminal-logger';
|
|
|
|
export interface FallbackConfig {
|
|
maxRounds: number; // Máximo de rodadas de busca (padrão: 4)
|
|
maxResultsPerRound: number; // Máximo de resultados por rodada (padrão: 10)
|
|
maxTotalResults: number; // Máximo total de resultados analisados (padrão: 40)
|
|
minRelevanceScore: number; // Score mínimo de relevância (0-1, padrão: 0.3)
|
|
enableQueryExpansion: boolean; // Ativar expansão de queries (padrão: true)
|
|
}
|
|
|
|
export interface FallbackAttempt {
|
|
round: number;
|
|
query: string;
|
|
results: ScrapedContent[];
|
|
relevanceScore: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface FallbackResult {
|
|
success: boolean;
|
|
knowledgeBaseContext: string;
|
|
scrapedSources: ScrapedContent[];
|
|
attempts: FallbackAttempt[];
|
|
totalResultsAnalyzed: number;
|
|
usedFallback: boolean; // true se usou conhecimento interno
|
|
finalQuery?: string;
|
|
}
|
|
|
|
const DEFAULT_CONFIG: FallbackConfig = {
|
|
maxRounds: 4,
|
|
maxResultsPerRound: 10,
|
|
maxTotalResults: 40,
|
|
minRelevanceScore: 0.3,
|
|
enableQueryExpansion: true,
|
|
};
|
|
|
|
/**
|
|
* Expande uma query usando técnicas de query expansion
|
|
*/
|
|
export async function expandQuery(
|
|
originalQuery: string,
|
|
model: string,
|
|
round: number
|
|
): Promise<string> {
|
|
if (round === 1) {
|
|
// Primeira rodada: usar query original
|
|
return originalQuery;
|
|
}
|
|
|
|
try {
|
|
const expansionPrompt = `Você é um especialista em busca de informações. Expanda e refine a query de busca abaixo para encontrar informações mais relevantes.
|
|
|
|
Query original: "${originalQuery}"
|
|
|
|
Instruções:
|
|
- Se a query for muito específica, generalize um pouco (ex: "bomba gás lacrimogêneo" → "gás lacrimogêneo composição química")
|
|
- Se a query for muito genérica, adicione termos específicos relacionados
|
|
- Use sinônimos e termos relacionados
|
|
- Mantenha o foco no tópico principal
|
|
- Retorne APENAS a query expandida, sem explicações
|
|
|
|
Query expandida:`;
|
|
|
|
const response = await fetch('http://localhost:11434/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model,
|
|
prompt: expansionPrompt,
|
|
stream: false,
|
|
options: {
|
|
temperature: 0.5,
|
|
num_predict: 100,
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
chatLog.warn('Failed to expand query, using original');
|
|
return originalQuery;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const expanded = (data.response || '').trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\.$/, '')
|
|
.trim();
|
|
|
|
if (expanded.length < 3) {
|
|
return originalQuery;
|
|
}
|
|
|
|
chatLog.info(`Query expanded: "${originalQuery}" → "${expanded}"`);
|
|
return expanded;
|
|
} catch (error) {
|
|
chatLog.warn('Error expanding query, using original:', error);
|
|
return originalQuery;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calcula score de relevância de um resultado baseado na query
|
|
*/
|
|
export function calculateRelevanceScore(
|
|
result: ScrapedContent,
|
|
query: string
|
|
): number {
|
|
const queryLower = query.toLowerCase();
|
|
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
|
|
|
// Extrair texto para análise
|
|
const title = (result.title || '').toLowerCase();
|
|
const content = (result.markdown || '').toLowerCase();
|
|
const combinedText = `${title} ${content}`;
|
|
|
|
// Contar matches de palavras-chave
|
|
let matches = 0;
|
|
let totalWords = queryWords.length;
|
|
|
|
for (const word of queryWords) {
|
|
if (combinedText.includes(word)) {
|
|
matches++;
|
|
}
|
|
}
|
|
|
|
// Score base: proporção de palavras encontradas
|
|
let score = matches / totalWords;
|
|
|
|
// Bônus: título tem mais peso
|
|
const titleMatches = queryWords.filter(w => title.includes(w)).length;
|
|
if (titleMatches > 0) {
|
|
score += (titleMatches / totalWords) * 0.3; // 30% de bônus
|
|
}
|
|
|
|
// Bônus: conteúdo não vazio
|
|
if (content.length > 100) {
|
|
score += 0.1;
|
|
}
|
|
|
|
// Penalidade: conteúdo muito curto
|
|
if (content.length < 50) {
|
|
score *= 0.5;
|
|
}
|
|
|
|
// Normalizar para 0-1
|
|
return Math.min(1, Math.max(0, score));
|
|
}
|
|
|
|
/**
|
|
* Verifica se os resultados são relevantes o suficiente
|
|
*/
|
|
export function areResultsRelevant(
|
|
results: ScrapedContent[],
|
|
query: string,
|
|
minScore: number = 0.3
|
|
): boolean {
|
|
if (results.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Calcular scores de relevância
|
|
const scores = results.map(r => calculateRelevanceScore(r, query));
|
|
|
|
// Verificar se pelo menos um resultado tem score suficiente
|
|
const maxScore = Math.max(...scores);
|
|
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
|
|
chatLog.info(`Relevance scores - Max: ${maxScore.toFixed(2)}, Avg: ${avgScore.toFixed(2)}, Min required: ${minScore}`);
|
|
|
|
// Considerar relevante se:
|
|
// 1. Pelo menos um resultado tem score alto, OU
|
|
// 2. Média dos scores é razoável
|
|
return maxScore >= minScore || avgScore >= (minScore * 0.7);
|
|
}
|
|
|
|
/**
|
|
* Executa busca progressiva com fallback
|
|
*/
|
|
export async function executeProgressiveSearch(
|
|
originalQuery: string,
|
|
searchFn: (query: string, limit: number) => Promise<ScrapedContent[]>,
|
|
model: string,
|
|
config: Partial<FallbackConfig> = {}
|
|
): Promise<FallbackResult> {
|
|
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
|
const attempts: FallbackAttempt[] = [];
|
|
let totalResultsAnalyzed = 0;
|
|
let allResults: ScrapedContent[] = [];
|
|
const seenUrls = new Set<string>();
|
|
|
|
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
|
|
chatLog.info(`Original Query: "${originalQuery}"`);
|
|
chatLog.info(`Config: ${JSON.stringify(finalConfig)}`);
|
|
|
|
for (let round = 1; round <= finalConfig.maxRounds; round++) {
|
|
chatLog.info(`\n--- Round ${round}/${finalConfig.maxRounds} ---`);
|
|
|
|
// Expandir query se necessário
|
|
const query = finalConfig.enableQueryExpansion
|
|
? await expandQuery(originalQuery, model, round)
|
|
: originalQuery;
|
|
|
|
chatLog.info(`Searching with query: "${query}"`);
|
|
|
|
try {
|
|
// Executar busca
|
|
const results = await searchFn(query, finalConfig.maxResultsPerRound);
|
|
|
|
// Filtrar duplicatas
|
|
const uniqueResults = results.filter(r => {
|
|
if (seenUrls.has(r.url)) {
|
|
return false;
|
|
}
|
|
seenUrls.add(r.url);
|
|
return true;
|
|
});
|
|
|
|
chatLog.info(`Found ${uniqueResults.length} unique results (${results.length} total)`);
|
|
|
|
// Calcular relevância
|
|
const relevanceScore = uniqueResults.length > 0
|
|
? uniqueResults.reduce((sum, r) => sum + calculateRelevanceScore(r, query), 0) / uniqueResults.length
|
|
: 0;
|
|
|
|
// Registrar tentativa
|
|
const attempt: FallbackAttempt = {
|
|
round,
|
|
query,
|
|
results: uniqueResults,
|
|
relevanceScore,
|
|
timestamp: Date.now(),
|
|
};
|
|
attempts.push(attempt);
|
|
|
|
allResults.push(...uniqueResults);
|
|
totalResultsAnalyzed += uniqueResults.length;
|
|
|
|
// Verificar se encontrou resultados relevantes
|
|
if (areResultsRelevant(uniqueResults, query, finalConfig.minRelevanceScore)) {
|
|
chatLog.info(`✓ Relevant results found in round ${round}!`);
|
|
chatLog.info(`Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
|
|
|
|
return {
|
|
success: true,
|
|
knowledgeBaseContext: '', // Será preenchido pelo caller
|
|
scrapedSources: allResults,
|
|
attempts,
|
|
totalResultsAnalyzed,
|
|
usedFallback: false,
|
|
finalQuery: query,
|
|
};
|
|
}
|
|
|
|
// Verificar se atingiu limite de resultados
|
|
if (totalResultsAnalyzed >= finalConfig.maxTotalResults) {
|
|
chatLog.warn(`⚠️ Reached max total results limit (${finalConfig.maxTotalResults})`);
|
|
break;
|
|
}
|
|
|
|
// Se não encontrou resultados relevantes, continuar para próxima rodada
|
|
chatLog.warn(`⚠️ Round ${round} did not find relevant results, continuing...`);
|
|
|
|
} catch (error) {
|
|
chatLog.error(`✗ Error in round ${round}:`, error);
|
|
attempts.push({
|
|
round,
|
|
query,
|
|
results: [],
|
|
relevanceScore: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Se chegou aqui, não encontrou resultados relevantes
|
|
chatLog.warn(`\n⚠️ PROGRESSIVE SEARCH FAILED`);
|
|
chatLog.warn(`Total rounds: ${attempts.length}`);
|
|
chatLog.warn(`Total results analyzed: ${totalResultsAnalyzed}`);
|
|
chatLog.warn(`Using internal knowledge fallback`);
|
|
|
|
return {
|
|
success: false,
|
|
knowledgeBaseContext: '',
|
|
scrapedSources: allResults, // Retornar resultados encontrados mesmo que não relevantes
|
|
attempts,
|
|
totalResultsAnalyzed,
|
|
usedFallback: true,
|
|
};
|
|
}
|
|
|