Files
OllaHub/lib/web-search-fallback.ts
2026-02-22 14:06:01 -03:00

485 lines
17 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.
*
* Agora suporta multi-round semantic search com estratégias diferentes.
*/
import type { ScrapedContent } from '@/services/webSearch';
import type { QueryContext } from './contextual-analyzer';
import type { EnrichedQueries } from './query-enricher';
import { chatLog } from '@/lib/terminal-logger';
import { calculateAdaptiveTimeout, TIMEOUT_CONFIG } from '@/lib/retry-utils';
export type SearchStrategy = 'literal' | 'semantic' | 'related' | 'expanded' | 'contextual';
export interface RoundConfig {
strategy: SearchStrategy;
queries: string[];
minResults: number;
}
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)
useSemanticSearch?: boolean; // Usar multi-round semantic search (padrão: false)
context?: QueryContext; // Contexto analisado (para semantic search)
enrichedQueries?: EnrichedQueries; // Queries enriquecidas (para semantic search)
initialTimeout?: number; // Timeout inicial em ms (padrão: 10000)
maxTimeoutPerQuery?: number; // Timeout máximo total por query (padrão: 25000)
maxTimeoutPerRound?: number; // Timeout máximo por round (padrão: 15000)
minResultsForEarlyExit?: number; // Mínimo de resultados para early exit (padrão: 5)
minRelevanceForEarlyExit?: number; // Relevância mínima para early exit (padrão: 0.7)
maxRoundsBeforePartial?: number; // Máximo de rounds antes de aceitar parciais (padrão: 2)
}
export interface FallbackAttempt {
round: number;
strategy?: SearchStrategy;
query: string;
queries?: string[]; // Múltiplas queries se usar estratégia
results: ScrapedContent[];
relevanceScore: number;
timestamp: number;
duration: 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.1,
enableQueryExpansion: true,
initialTimeout: TIMEOUT_CONFIG.initialTimeout,
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
maxTimeoutPerRound: TIMEOUT_CONFIG.maxTimeoutPerRound,
minResultsForEarlyExit: 5,
minRelevanceForEarlyExit: 0.7,
maxRoundsBeforePartial: 2,
};
/**
* 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 { invoke } = await import('@tauri-apps/api/core');
const expanded = (await invoke<string>('generate_completion', {
model,
prompt: expansionPrompt,
options: {
temperature: 0.5,
num_predict: 100,
},
}))
.replace(/^["']|["']$/g, '')
.replace(/\.$/, '')
.trim();
const sanitized = expanded
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/^\s*```[a-zA-Z]*\s*/g, '')
.replace(/\s*```\s*$/g, '')
.replace(/^(here\s+is\s+the\s+query|sure|okay|final\s+query)\s*[:,-]*\s*/i, '')
.trim()
.replace(/[\r\n]+/g, ' ');
if (sanitized.length < 3 || sanitized.length > 150 || /[<>]/.test(sanitized)) {
return originalQuery;
}
chatLog.info(`Query expanded: "${originalQuery}" → "${sanitized}"`);
return sanitized;
} catch (error) {
chatLog.warn(`Error expanding query, using original: ${error instanceof Error ? error.message : String(error)}`);
return originalQuery;
}
}
/**
* Calcula score de relevância de um resultado baseado na query
* (Mantido para compatibilidade, mas agora usa função de relevance-scorer)
*/
export function calculateRelevanceScoreForFallback(
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;
const 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 => calculateRelevanceScoreForFallback(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);
}
/**
* Cria configuração de rounds para multi-round semantic search
*/
function createSemanticRounds(
enriched: EnrichedQueries,
originalQuery: string
): RoundConfig[] {
const rounds: RoundConfig[] = [];
// Round 1: Literal + Semântico (mais direto)
if (enriched.literal.length > 0 || enriched.semantic.length > 0) {
rounds.push({
strategy: 'literal',
queries: [...enriched.literal, ...enriched.semantic].slice(0, 5),
minResults: 3,
});
}
// Round 2: Relacionadas (se Round 1 não foi suficiente)
if (enriched.related.length > 0) {
rounds.push({
strategy: 'related',
queries: enriched.related.slice(0, 4),
minResults: 2,
});
}
// Round 3: Contextual (adicionar contexto temporal/geográfico)
if (enriched.contextual.length > 0) {
rounds.push({
strategy: 'contextual',
queries: enriched.contextual.slice(0, 3),
minResults: 2,
});
}
// Round 4: Expandido (temas específicos, exemplos concretos)
if (enriched.expanded.length > 0) {
rounds.push({
strategy: 'expanded',
queries: enriched.expanded.slice(0, 3),
minResults: 1,
});
}
// Fallback: se não há queries enriquecidas, usar original
if (rounds.length === 0) {
rounds.push({
strategy: 'literal',
queries: [originalQuery],
minResults: 1,
});
}
return rounds;
}
/**
* Executa busca progressiva com fallback
* Agora suporta multi-round semantic search com estratégias diferentes
*/
export async function executeProgressiveSearch(
originalQuery: string,
searchFn: (query: string, limit: number, round?: number) => Promise<ScrapedContent[]>,
model: string,
config: Partial<FallbackConfig> = {}
): Promise<FallbackResult> {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
const attempts: FallbackAttempt[] = [];
let totalResultsAnalyzed = 0;
const allResults: ScrapedContent[] = [];
const seenUrls = new Set<string>();
const queryStartTime = Date.now();
const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery;
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
chatLog.info(`Original Query: "${originalQuery}"`);
chatLog.info(`Config: ${JSON.stringify({ ...finalConfig, context: finalConfig.context ? 'present' : 'none', enrichedQueries: finalConfig.enrichedQueries ? 'present' : 'none' })}`);
chatLog.info(`Timeout config: initial=${finalConfig.initialTimeout}ms, max per query=${maxQueryTimeout}ms, max per round=${finalConfig.maxTimeoutPerRound}ms`);
// Se usar semantic search, criar rounds baseados em estratégias
let rounds: Array<{ round: number; config: RoundConfig }> = [];
if (finalConfig.useSemanticSearch && finalConfig.enrichedQueries) {
const semanticRounds = createSemanticRounds(finalConfig.enrichedQueries, originalQuery);
rounds = semanticRounds.map((roundConfig, idx) => ({
round: idx + 1,
config: roundConfig,
}));
chatLog.info(`[SemanticSearch] Created ${rounds.length} semantic rounds with strategies`);
} else {
// Modo tradicional: rounds sequenciais com expansão
for (let i = 1; i <= finalConfig.maxRounds; i++) {
rounds.push({
round: i,
config: {
strategy: 'literal',
queries: [originalQuery],
minResults: finalConfig.maxResultsPerRound,
},
});
}
}
// Limitar número de rounds
rounds = rounds.slice(0, finalConfig.maxRounds);
for (const { round, config: roundConfig } of rounds) {
// Verificar timeout total da query
const elapsedTime = Date.now() - queryStartTime;
if (elapsedTime >= maxQueryTimeout) {
chatLog.warn(`[Round ${round}] Timeout total da query atingido (${elapsedTime}ms >= ${maxQueryTimeout}ms), parando busca`);
break;
}
// Calcular timeout adaptativo para este round
const roundTimeout = calculateAdaptiveTimeout(round, finalConfig.initialTimeout || TIMEOUT_CONFIG.initialTimeout);
const maxRoundTimeout = finalConfig.maxTimeoutPerRound || TIMEOUT_CONFIG.maxTimeoutPerRound;
const actualRoundTimeout = Math.min(roundTimeout, maxRoundTimeout);
chatLog.info(`\n--- Round ${round}/${rounds.length} [${roundConfig.strategy}] ---`);
chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`);
const roundStartTime = Date.now();
const roundResults: ScrapedContent[] = [];
const roundQueries: string[] = [];
// Executar todas as queries do round em paralelo com timeout por round
const queryPromises = roundConfig.queries.map(async (query) => {
// Expandir query se necessário (modo tradicional)
const finalQuery = finalConfig.enableQueryExpansion && !finalConfig.useSemanticSearch
? await expandQuery(query, model, round)
: query;
try {
// Passar round para searchFn para timeout adaptativo
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round);
return { query: finalQuery, results };
<<<<<<< HEAD
} catch (err) {
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${err instanceof Error ? err.message : String(err)}`);
=======
} catch (error) {
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${error instanceof Error ? error.message : String(error)}`);
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
return { query: finalQuery, results: [] };
}
});
// Adicionar timeout total para o round
const roundPromise = Promise.all(queryPromises);
type QueryResult = { query: string; results: ScrapedContent[] };
const timeoutPromise = new Promise<QueryResult[]>((_, reject) => {
setTimeout(() => reject(new Error(`Round ${round} timeout após ${actualRoundTimeout}ms`)), actualRoundTimeout);
});
let queryResults: QueryResult[];
try {
queryResults = await Promise.race([roundPromise, timeoutPromise]);
} catch (_err) {
chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`);
// Tentar obter resultados parciais das promises que já completaram
queryResults = await Promise.allSettled(queryPromises).then(results =>
results.map(r => r.status === 'fulfilled' ? r.value : { query: '', results: [] })
);
}
// Agregar resultados de todas as queries do round
for (const { query, results } of queryResults) {
roundQueries.push(query);
// Filtrar duplicatas
const uniqueResults = results.filter(r => {
if (seenUrls.has(r.url)) {
return false;
}
seenUrls.add(r.url);
return true;
});
roundResults.push(...uniqueResults);
}
const roundDuration = Date.now() - roundStartTime;
chatLog.info(`[Round ${round}] Executed ${roundQueries.length} queries, found ${roundResults.length} unique results in ${roundDuration}ms`);
// Log detalhado se houver resultados
if (roundResults.length > 0) {
roundResults.slice(0, 3).forEach((result, idx) => {
chatLog.info(` → Result ${idx + 1}: ${result.title.substring(0, 60)}... (${result.url.substring(0, 50)}...)`);
});
if (roundResults.length > 3) {
chatLog.info(` ... and ${roundResults.length - 3} more results`);
}
}
// Calcular relevância usando contexto se disponível
let semanticSimilarity: ((ctx: QueryContext, text: string) => number) | null = null;
if (finalConfig.context) {
const mod = await import('./relevance-scorer');
semanticSimilarity = mod.calculateSemanticSimilarity;
}
const relevanceScore = roundResults.length > 0
? roundResults.reduce((sum, r) => {
if (semanticSimilarity && finalConfig.context) {
return sum + semanticSimilarity(finalConfig.context, r.markdown || r.title);
}
return sum + calculateRelevanceScoreForFallback(r, originalQuery);
}, 0) / roundResults.length
: 0;
// Registrar tentativa
const attempt: FallbackAttempt = {
round,
strategy: roundConfig.strategy,
query: roundQueries[0] || originalQuery,
queries: roundQueries.length > 1 ? roundQueries : undefined,
results: roundResults,
relevanceScore,
timestamp: Date.now(),
duration: roundDuration,
};
attempts.push(attempt);
allResults.push(...roundResults);
totalResultsAnalyzed += roundResults.length;
// Verificar early exit: resultados suficientes com alta relevância
const minResultsForEarlyExit = finalConfig.minResultsForEarlyExit || 5;
const minRelevanceForEarlyExit = finalConfig.minRelevanceForEarlyExit || 0.7;
if (roundResults.length >= minResultsForEarlyExit && relevanceScore >= minRelevanceForEarlyExit) {
chatLog.info(`[Round ${round}] 🎯 Early exit: resultados suficientes encontrados!`);
chatLog.info(`[Round ${round}] Results: ${roundResults.length} (mínimo: ${minResultsForEarlyExit}), Relevance: ${relevanceScore.toFixed(2)} (mínimo: ${minRelevanceForEarlyExit})`);
chatLog.info(`[Round ${round}] Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
return {
success: true,
knowledgeBaseContext: '', // Será preenchido pelo caller
scrapedSources: allResults,
attempts,
totalResultsAnalyzed,
usedFallback: false,
finalQuery: roundQueries.join(' | '),
};
}
// Verificar se atingiu limite de resultados
if (totalResultsAnalyzed >= finalConfig.maxTotalResults) {
chatLog.warn(`[Round ${round}] ⚠️ 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 (min score: ${finalConfig.minRelevanceScore}), continuing...`);
}
// 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,
};
}