feat: enhance UI and functionality across chat and settings

- Added custom scrollbar styles for improved aesthetics and usability across the interface.
- Implemented responsive adjustments for chat sidebar and input components to enhance mobile experience.
- Integrated tooltip components in select elements for better user guidance and information display.
- Updated chat input and message components for improved styling and functionality.
- Enhanced settings page with semantic query expansion options and improved model selection UI.
- Refactored sidebar list to include better date formatting and tooltip support for session titles.
- Introduced GPU stats monitoring in the system panel for better hardware insights.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-26 19:04:46 -03:00
1 parent 84ea2dec19
commit e5f062040c
33 files changed
+4670 -465

No files matched your search

+320
View File
@@ -0,0 +1,320 @@
import type { ResearchEntry } from '@/hooks/use-deep-research';
import type { QueryContext } from './contextual-analyzer';
import { chatLog } from './terminal-logger';
/**
* Fato extraído de uma fonte
*/
export interface ExtractedFact {
fact: string;
sourceId: string;
sourceUrl: string;
sourceTitle: string;
confidence: number; // 0-1
entities?: string[]; // Entidades mencionadas no fato
}
/**
* Resultado da unificação de contexto
*/
export interface UnifiedContext {
unifiedText: string;
keyFacts: ExtractedFact[];
contradictions: Array<{
fact1: ExtractedFact;
fact2: ExtractedFact;
description: string;
}>;
sources: Array<{
id: string;
title: string;
url: string;
factsCount: number;
}>;
totalFacts: number;
uniqueFacts: number;
}
/**
* Prompt para extração de fatos usando LLM
*/
const FACT_EXTRACTION_PROMPT = `Você é um especialista em extração de fatos e análise de informações.
Analise o conteúdo abaixo e extraia os fatos principais relevantes para responder à query do usuário.
## QUERY DO USUÁRIO
"{{query}}"
## CONTEXTO ANALISADO
- Intenção: {{intent}}
- Tópicos: {{topics}}
- Entidades: {{entities}}
## CONTEÚDO DA FONTE
Título: {{title}}
URL: {{url}}
{{content}}
## INSTRUÇÕES
Extraia os fatos principais do conteúdo acima que são relevantes para responder à query do usuário.
Para cada fato:
- Seja específico e factual
- Inclua entidades mencionadas (pessoas, lugares, datas, conceitos)
- Mantenha o contexto necessário para entender o fato
- Evite informações genéricas ou óbvias
## FORMATO DE SAÍDA
Retorne APENAS um JSON válido com um array de fatos:
[
{
"fact": "Fato específico extraído do conteúdo",
"entities": ["entidade1", "entidade2"],
"confidence": 0.9
},
...
]
IMPORTANTE:
- Retorne APENAS o JSON, sem markdown, sem explicações
- Confidence deve ser entre 0 e 1 (0.9 = muito confiável, 0.5 = incerto)
- Extraia 3-8 fatos principais, não mais
- Fatos devem ser independentes e específicos`;
/**
* Extrai fatos principais de uma entrada de pesquisa
*/
export async function extractKeyFacts(
entry: ResearchEntry,
query: string,
context: QueryContext | null,
model: string
): Promise<ExtractedFact[]> {
try {
const entitiesStr = context?.entities.map(e => `${e.type}: ${e.value}`).join(', ') || 'nenhuma';
const topicsStr = context?.topics.join(', ') || 'nenhum';
const intentStr = context?.intent || 'unknown';
const prompt = FACT_EXTRACTION_PROMPT
.replace('{{query}}', query)
.replace('{{intent}}', intentStr)
.replace('{{topics}}', topicsStr)
.replace('{{entities}}', entitiesStr)
.replace('{{title}}', entry.title)
.replace('{{url}}', entry.sourceUrl)
.replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
options: {
temperature: 0.3,
num_predict: 600,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const rawResponse = (data.response || '').trim();
// Limpar e extrair JSON
let jsonText = rawResponse
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
const jsonMatch = jsonText.match(/\[[\s\S]*\]/);
if (jsonMatch) {
jsonText = jsonMatch[0];
}
const facts = JSON.parse(jsonText) as Array<{
fact: string;
entities?: string[];
confidence: number;
}>;
return facts.map(f => ({
fact: f.fact,
sourceId: entry.id,
sourceUrl: entry.sourceUrl,
sourceTitle: entry.title,
confidence: f.confidence || 0.7,
entities: f.entities || [],
}));
} catch (error) {
chatLog.warn(`[ContextUnifier] Erro ao extrair fatos de ${entry.title}:`, error);
// Fallback: criar fato básico do título
return [{
fact: entry.title,
sourceId: entry.id,
sourceUrl: entry.sourceUrl,
sourceTitle: entry.title,
confidence: 0.5,
}];
}
}
/**
* Detecta contradições entre fatos
*/
function detectContradictions(facts: ExtractedFact[]): Array<{
fact1: ExtractedFact;
fact2: ExtractedFact;
description: string;
}> {
const contradictions: Array<{
fact1: ExtractedFact;
fact2: ExtractedFact;
description: string;
}> = [];
// Comparar fatos por similaridade de entidades
for (let i = 0; i < facts.length; i++) {
for (let j = i + 1; j < facts.length; j++) {
const fact1 = facts[i];
const fact2 = facts[j];
// Verificar se mencionam as mesmas entidades
const sharedEntities = fact1.entities?.filter(e =>
fact2.entities?.includes(e)
) || [];
if (sharedEntities.length > 0) {
// Verificar se os fatos são contraditórios (heurística simples)
const fact1Lower = fact1.fact.toLowerCase();
const fact2Lower = fact2.fact.toLowerCase();
// Palavras que indicam contradição
const contradictionIndicators = [
fact1Lower.includes('não') && fact2Lower.includes('sim'),
fact1Lower.includes('sim') && fact2Lower.includes('não'),
fact1Lower.includes('falso') && !fact2Lower.includes('falso'),
!fact1Lower.includes('falso') && fact2Lower.includes('falso'),
];
if (contradictionIndicators.some(Boolean)) {
contradictions.push({
fact1,
fact2,
description: `Possível contradição sobre ${sharedEntities.join(', ')}`,
});
}
}
}
}
return contradictions;
}
/**
* Agrupa fatos similares
*/
function groupSimilarFacts(facts: ExtractedFact[]): Map<string, ExtractedFact[]> {
const groups = new Map<string, ExtractedFact[]>();
for (const fact of facts) {
// Criar chave baseada em entidades principais
const key = fact.entities?.slice(0, 2).join('|') || fact.fact.substring(0, 50);
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(fact);
}
return groups;
}
/**
* Unifica contexto de múltiplas fontes
*/
export async function unifyContext(
entries: ResearchEntry[],
query: string,
context: QueryContext | null,
model: string
): Promise<UnifiedContext> {
chatLog.info(`[ContextUnifier] Unificando contexto de ${entries.length} fontes`);
// Extrair fatos de todas as fontes
const allFacts: ExtractedFact[] = [];
const factPromises = entries.map(entry => extractKeyFacts(entry, query, context, model));
const factArrays = await Promise.all(factPromises);
for (const facts of factArrays) {
allFacts.push(...facts);
}
chatLog.info(`[ContextUnifier] Extraídos ${allFacts.length} fatos de ${entries.length} fontes`);
// Agrupar fatos similares
const factGroups = groupSimilarFacts(allFacts);
// Detectar contradições
const contradictions = detectContradictions(allFacts);
if (contradictions.length > 0) {
chatLog.warn(`[ContextUnifier] Detectadas ${contradictions.length} possíveis contradições`);
}
// Criar texto unificado
const unifiedSections: string[] = [];
const sourceMap = new Map<string, { title: string; url: string; factsCount: number }>();
// Agrupar por fonte
for (const entry of entries) {
const entryFacts = allFacts.filter(f => f.sourceId === entry.id);
if (entryFacts.length > 0) {
sourceMap.set(entry.id, {
title: entry.title,
url: entry.sourceUrl,
factsCount: entryFacts.length,
});
unifiedSections.push(`## ${entry.title}`);
unifiedSections.push(`Fonte: ${entry.sourceUrl}\n`);
// Adicionar fatos principais
entryFacts
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5) // Top 5 fatos por fonte
.forEach(fact => {
unifiedSections.push(`- ${fact.fact}`);
});
unifiedSections.push('');
}
}
const unifiedText = unifiedSections.join('\n');
// Remover duplicatas de fatos (mesmo texto)
const uniqueFacts = Array.from(
new Map(allFacts.map(f => [f.fact.toLowerCase(), f])).values()
);
return {
unifiedText,
keyFacts: allFacts.sort((a, b) => b.confidence - a.confidence),
contradictions,
sources: Array.from(sourceMap.values()),
totalFacts: allFacts.length,
uniqueFacts: uniqueFacts.length,
};
}
+276
View File
@@ -0,0 +1,276 @@
import { chatLog } from './terminal-logger';
/**
* Tipos de intenção da query
*/
export type QueryIntent =
| 'factual' // Busca por fatos específicos
| 'comparison' // Comparação entre entidades
| 'how-to' // Instruções ou tutoriais
| 'definition' // Definição de conceito
| 'list' // Listagem de itens
| 'opinion' // Opiniões ou análises
| 'temporal' // Informações temporais (quando, histórico)
| 'causal' // Causa e efeito (por que, como funciona)
| 'unknown'; // Não identificado
/**
* Entidade extraída da query
*/
export interface ExtractedEntity {
type: 'person' | 'location' | 'date' | 'concept' | 'organization' | 'event' | 'product' | 'other';
value: string;
confidence: number; // 0-1
}
/**
* Relacionamento entre entidades
*/
export interface EntityRelationship {
from: string;
to: string;
relation: string; // ex: "localizado em", "criado por", "relacionado com"
confidence: number;
}
/**
* Contexto temporal extraído
*/
export interface TemporalContext {
period?: string; // ex: "2024", "últimos 5 anos"
date?: string; // Data específica
relative?: string; // ex: "recente", "atual", "histórico"
}
/**
* Contexto geográfico extraído
*/
export interface GeographicContext {
location?: string; // Cidade, estado, país
region?: string; // Região, continente
coordinates?: { lat: number; lng: number };
}
/**
* Análise contextual completa da query
*/
export interface QueryContext {
originalQuery: string;
intent: QueryIntent;
entities: ExtractedEntity[];
topics: string[]; // Tópicos principais identificados
relationships: EntityRelationship[]; // Relacionamentos entre entidades
implicitQuestions: string[]; // Perguntas implícitas na query
temporalContext?: TemporalContext;
geographicContext?: GeographicContext;
keywords: string[]; // Palavras-chave importantes
synonyms: string[]; // Sinônimos relevantes
domain?: string; // Domínio (ex: "acadêmico", "tecnologia", "saúde")
complexity: 'simple' | 'moderate' | 'complex';
}
/**
* Prompt para análise contextual usando LLM
*/
const CONTEXTUAL_ANALYSIS_PROMPT = `Você é um especialista em análise semântica e extração de contexto de queries de busca.
Analise a query abaixo e extraia TODAS as informações contextuais relevantes.
Query: "{{query}}"
## INSTRUÇÕES
1. **INTENÇÃO**: Identifique o tipo de intenção (factual, comparison, how-to, definition, list, opinion, temporal, causal)
2. **ENTIDADES**: Extraia todas as entidades mencionadas (pessoas, lugares, datas, conceitos, organizações, eventos, produtos)
3. **TÓPICOS**: Identifique os tópicos principais abordados
4. **RELACIONAMENTOS**: Identifique relacionamentos implícitos entre entidades
5. **PERGUNTAS IMPLÍCITAS**: Quais perguntas relacionadas estão implícitas?
6. **CONTEXTO TEMPORAL**: Há referências a tempo? (datas, períodos, "recente", "atual", "histórico")
7. **CONTEXTO GEOGRÁFICO**: Há referências a localização? (cidades, estados, países, regiões)
8. **PALAVRAS-CHAVE**: Identifique palavras-chave importantes (remover stop words)
9. **SINÔNIMOS**: Liste sinônimos relevantes para os termos principais
10. **DOMÍNIO**: Identifique o domínio da query (acadêmico, tecnologia, saúde, negócios, etc.)
11. **COMPLEXIDADE**: Avalie a complexidade (simple, moderate, complex)
## FORMATO DE SAÍDA
Retorne APENAS um JSON válido no seguinte formato:
{
"intent": "list",
"entities": [
{"type": "concept", "value": "resultados acadêmicos", "confidence": 0.9},
{"type": "concept", "value": "prêmios", "confidence": 0.9}
],
"topics": ["pesquisa científica", "reconhecimento acadêmico", "premiações"],
"relationships": [
{"from": "resultados acadêmicos", "to": "prêmios", "relation": "recebem", "confidence": 0.8}
],
"implicitQuestions": [
"Quais foram os prêmios acadêmicos mais recentes?",
"Quem recebeu prêmios acadêmicos?"
],
"temporalContext": {"relative": "recente", "period": "2024"},
"geographicContext": null,
"keywords": ["resultados", "acadêmicos", "premiados", "últimos"],
"synonyms": ["pesquisas", "estudos", "trabalhos científicos", "reconhecimento", "distinções"],
"domain": "acadêmico",
"complexity": "moderate"
}
IMPORTANTE:
- Retorne APENAS o JSON, sem markdown, sem explicações
- Use null para campos não aplicáveis
- Confidence deve ser entre 0 e 1
- Preserve nomes próprios e termos técnicos na forma original`;
/**
* Analisa o contexto de uma query usando LLM
*
* @param query - Query original do usuário
* @param model - Modelo LLM a ser usado
* @returns Análise contextual completa
*/
export async function analyzeQueryContext(
query: string,
model: string
): Promise<QueryContext> {
if (!query || !query.trim()) {
return {
originalQuery: query,
intent: 'unknown',
entities: [],
topics: [],
relationships: [],
implicitQuestions: [],
keywords: [],
synonyms: [],
complexity: 'simple',
};
}
chatLog.info(`[ContextualAnalysis] Analisando contexto da query: "${query}"`);
try {
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
options: {
temperature: 0.3, // Baixa temperatura para análise mais determinística
num_predict: 800, // Espaço suficiente para JSON completo
},
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const rawResponse = (data.response || '').trim();
// Limpar resposta (remover markdown code blocks se houver)
let jsonText = rawResponse
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
// Tentar extrair JSON se houver texto antes/depois
const jsonMatch = jsonText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonText = jsonMatch[0];
}
const analysis = JSON.parse(jsonText) as Partial<QueryContext>;
// Validar e preencher campos obrigatórios
const context: QueryContext = {
originalQuery: query,
intent: (analysis.intent as QueryIntent) || 'unknown',
entities: analysis.entities || [],
topics: analysis.topics || [],
relationships: analysis.relationships || [],
implicitQuestions: analysis.implicitQuestions || [],
temporalContext: analysis.temporalContext,
geographicContext: analysis.geographicContext,
keywords: analysis.keywords || [],
synonyms: analysis.synonyms || [],
domain: analysis.domain,
complexity: analysis.complexity || 'moderate',
};
chatLog.success(`[ContextualAnalysis] Contexto extraído: ${context.intent}, ${context.entities.length} entidades, ${context.topics.length} tópicos`);
return context;
} catch (error) {
chatLog.error(`[ContextualAnalysis] Erro ao analisar contexto:`, error);
// Fallback: análise básica usando heurísticas
return createFallbackContext(query);
}
}
/**
* Cria contexto básico usando heurísticas quando a análise LLM falha
*/
function createFallbackContext(query: string): QueryContext {
const lowerQuery = query.toLowerCase();
// Detectar intenção básica
let intent: QueryIntent = 'factual';
if (lowerQuery.includes('como') || lowerQuery.includes('tutorial')) {
intent = 'how-to';
} else if (lowerQuery.includes('o que é') || lowerQuery.includes('definição')) {
intent = 'definition';
} else if (lowerQuery.includes('quais') || lowerQuery.includes('lista')) {
intent = 'list';
} else if (lowerQuery.includes('comparar') || lowerQuery.includes('vs')) {
intent = 'comparison';
} else if (lowerQuery.includes('por que') || lowerQuery.includes('causa')) {
intent = 'causal';
}
// Extrair palavras-chave básicas
const stopWords = new Set([
'o', 'a', 'os', 'as', 'um', 'uma', 'de', 'da', 'do', 'das', 'dos',
'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'com', 'sem',
'que', 'qual', 'quais', 'onde', 'quando', 'como', 'porque',
]);
const keywords = query
.toLowerCase()
.split(/\s+/)
.filter(word => word.length > 2 && !stopWords.has(word));
// Detectar contexto temporal
let temporalContext: TemporalContext | undefined;
if (lowerQuery.includes('último') || lowerQuery.includes('recente') || lowerQuery.includes('atual')) {
temporalContext = { relative: 'recente' };
} else if (lowerQuery.includes('2024') || lowerQuery.includes('2023')) {
const yearMatch = query.match(/\b(20\d{2})\b/);
if (yearMatch) {
temporalContext = { period: yearMatch[1] };
}
}
return {
originalQuery: query,
intent,
entities: [],
topics: keywords.slice(0, 5), // Usar keywords como tópicos
relationships: [],
implicitQuestions: [],
temporalContext,
keywords,
synonyms: [],
complexity: keywords.length > 5 ? 'complex' : keywords.length > 2 ? 'moderate' : 'simple',
};
}
+5 -2
View File
@@ -1,4 +1,5 @@
import type { ResearchEntry } from '@/hooks/use-deep-research';
import type { QueryContext } from './contextual-analyzer';
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
import { scoreAndSelectChunks } from './relevance-scorer';
import { summarizeMarkdown } from './content-condenser';
@@ -20,6 +21,7 @@ export interface CondensationOptions {
autoSummarize?: boolean;
summarizeThreshold?: number;
fallbackToSummarization?: boolean;
context?: QueryContext; // Contexto analisado para ranqueamento semântico
}
const DEFAULT_OPTIONS: Required<CondensationOptions> = {
@@ -129,12 +131,13 @@ export function condenseKnowledgeBase(
// Calcular tokens disponíveis (reservar 20% para margem)
const availableTokens = Math.floor(opts.maxTokens * 0.8);
// Selecionar chunks mais relevantes
// Selecionar chunks mais relevantes (usando contexto se disponível)
let selectedChunks = scoreAndSelectChunks(
allChunks,
query,
availableTokens,
opts.minRelevanceScore
opts.minRelevanceScore,
opts.context
);
// Se não selecionou chunks suficientes ou método de fallback está ativo
+294
View File
@@ -0,0 +1,294 @@
import type { QueryContext } from './contextual-analyzer';
import { chatLog } from './terminal-logger';
/**
* Queries enriquecidas por categoria
*/
export interface EnrichedQueries {
literal: string[]; // Queries literais (original + variações diretas)
semantic: string[]; // Variantes semânticas (sinônimos, reformulações)
related: string[]; // Perguntas relacionadas
expanded: string[]; // Expansões temáticas (autores, eventos, tipos)
contextual: string[]; // Queries com contexto adicional (datas, locais)
}
/**
* Prompt para enriquecimento de queries usando LLM
*/
const QUERY_ENRICHMENT_PROMPT = `Você é um especialista em otimização de queries de busca e expansão semântica.
Com base na query original e no contexto analisado, gere múltiplas variantes de queries enriquecidas para maximizar a cobertura de resultados relevantes.
## QUERY ORIGINAL
"{{originalQuery}}"
## CONTEXTO ANALISADO
- Intenção: {{intent}}
- Tópicos: {{topics}}
- Entidades: {{entities}}
- Contexto Temporal: {{temporalContext}}
- Contexto Geográfico: {{geographicContext}}
- Palavras-chave: {{keywords}}
- Sinônimos: {{synonyms}}
- Domínio: {{domain}}
## INSTRUÇÕES
Gere queries enriquecidas nas seguintes categorias:
1. **LITERAL**: Variações diretas da query original (preservar termos principais)
- Ex: "resultados acadêmicos premiados" → ["resultados academicos premiados", "ultimos resultados academicos premiados"]
2. **SEMANTIC**: Variantes semânticas usando sinônimos e reformulações
- Ex: "resultados acadêmicos" → "pesquisas científicas", "estudos acadêmicos", "trabalhos científicos"
- Manter o significado, mas usar termos alternativos
3. **RELATED**: Perguntas relacionadas e FAQs
- Ex: "premios academicos 2024", "conferencias academicas premiadas", "descobertas cientificas premiadas"
- Explorar aspectos adjacentes do tópico
4. **EXPANDED**: Expansões temáticas específicas
- Ex: "Nobel Prize 2024", "Turing Award winners", "Fields Medal recentes"
- Incluir tipos específicos, categorias, exemplos concretos
5. **CONTEXTUAL**: Queries com contexto adicional (datas, locais, domínios)
- Ex: "resultados academicos brasileiros premiados 2024", "pesquisas premiadas universidades"
- Combinar tópico principal com contexto temporal/geográfico
## REGRAS CRÍTICAS
- **PRESERVAR ENTIDADES**: Se a query menciona "Garuva", TODAS as queries devem mencionar "Garuva"
- **NÃO TRADUZIR**: Preservar nomes próprios, termos técnicos na forma original
- **OTIMIZAR PARA BUSCA**: Usar termos que aparecem em resultados reais
- **COBERTURA AMPLA**: Cada categoria deve explorar diferentes ângulos
- **QUALIDADE > QUANTIDADE**: 3-5 queries por categoria é suficiente
- **RELEVÂNCIA**: Todas as queries devem ser relevantes para responder a query original
## FORMATO DE SAÍDA
Retorne APENAS um JSON válido no seguinte formato:
{
"literal": ["query literal 1", "query literal 2"],
"semantic": ["variante semantica 1", "variante semantica 2"],
"related": ["pergunta relacionada 1", "pergunta relacionada 2"],
"expanded": ["expansao tematica 1", "expansao tematica 2"],
"contextual": ["query contextual 1", "query contextual 2"]
}
IMPORTANTE:
- Retorne APENAS o JSON, sem markdown, sem explicações
- Cada array deve ter 3-5 queries
- Queries devem ser em português se a query original for em português
- Preservar termos técnicos e nomes próprios`;
/**
* Enriquece queries baseado no contexto analisado
*
* @param originalQuery - Query original do usuário
* @param context - Contexto analisado da query
* @param model - Modelo LLM a ser usado
* @returns Queries enriquecidas por categoria
*/
export async function enrichQueries(
originalQuery: string,
context: QueryContext,
model: string
): Promise<EnrichedQueries> {
chatLog.info(`[QueryEnricher] Enriquecendo queries para: "${originalQuery}"`);
try {
// Preparar contexto para o prompt
const entitiesStr = context.entities.map(e => `${e.type}: ${e.value}`).join(', ') || 'nenhuma';
const temporalStr = context.temporalContext
? JSON.stringify(context.temporalContext)
: 'nenhum';
const geographicStr = context.geographicContext
? JSON.stringify(context.geographicContext)
: 'nenhum';
const prompt = QUERY_ENRICHMENT_PROMPT
.replace('{{originalQuery}}', originalQuery)
.replace('{{intent}}', context.intent)
.replace('{{topics}}', context.topics.join(', ') || 'nenhum')
.replace('{{entities}}', entitiesStr)
.replace('{{temporalContext}}', temporalStr)
.replace('{{geographicContext}}', geographicStr)
.replace('{{keywords}}', context.keywords.join(', ') || 'nenhuma')
.replace('{{synonyms}}', context.synonyms.join(', ') || 'nenhum')
.replace('{{domain}}', context.domain || 'geral');
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
options: {
temperature: 0.5, // Temperatura média para criatividade controlada
num_predict: 1000, // Espaço suficiente para múltiplas queries
},
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const rawResponse = (data.response || '').trim();
// Limpar resposta (remover markdown code blocks se houver)
let jsonText = rawResponse
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
// Tentar extrair JSON se houver texto antes/depois
const jsonMatch = jsonText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonText = jsonMatch[0];
}
const enriched = JSON.parse(jsonText) as Partial<EnrichedQueries>;
// Validar e preencher campos
const result: EnrichedQueries = {
literal: enriched.literal || [originalQuery],
semantic: enriched.semantic || [],
related: enriched.related || [],
expanded: enriched.expanded || [],
contextual: enriched.contextual || [],
};
// Garantir que sempre há pelo menos a query original
if (result.literal.length === 0) {
result.literal = [originalQuery];
}
// Log estatísticas
const totalQueries =
result.literal.length +
result.semantic.length +
result.related.length +
result.expanded.length +
result.contextual.length;
chatLog.success(
`[QueryEnricher] ${totalQueries} queries geradas: ` +
`${result.literal.length} literal, ${result.semantic.length} semântica, ` +
`${result.related.length} relacionadas, ${result.expanded.length} expandidas, ` +
`${result.contextual.length} contextuais`
);
return result;
} catch (error) {
chatLog.error(`[QueryEnricher] Erro ao enriquecer queries:`, error);
// Fallback: usar apenas query original
return createFallbackEnrichment(originalQuery, context);
}
}
/**
* Cria enriquecimento básico usando heurísticas quando a análise LLM falha
*/
function createFallbackEnrichment(
originalQuery: string,
context: QueryContext
): EnrichedQueries {
const literal = [originalQuery];
// Adicionar variações básicas usando sinônimos do contexto
const semantic: string[] = [];
if (context.synonyms.length > 0) {
// Substituir algumas palavras por sinônimos
let semanticQuery = originalQuery;
context.synonyms.slice(0, 2).forEach(synonym => {
// Tentar substituir palavra-chave por sinônimo
const keywords = context.keywords;
if (keywords.length > 0) {
const keyword = keywords[0];
semanticQuery = semanticQuery.replace(
new RegExp(keyword, 'gi'),
synonym
);
if (semanticQuery !== originalQuery) {
semantic.push(semanticQuery);
}
}
});
}
// Adicionar contexto temporal se disponível
const contextual: string[] = [];
if (context.temporalContext?.period) {
contextual.push(`${originalQuery} ${context.temporalContext.period}`);
}
if (context.temporalContext?.relative === 'recente') {
contextual.push(`${originalQuery} recente`);
}
// Adicionar contexto geográfico se disponível
if (context.geographicContext?.location) {
contextual.push(`${originalQuery} ${context.geographicContext.location}`);
}
return {
literal,
semantic: semantic.length > 0 ? semantic : [],
related: [],
expanded: [],
contextual,
};
}
/**
* Combina todas as queries enriquecidas em uma lista única para busca
*
* @param enriched - Queries enriquecidas
* @param strategy - Estratégia de combinação
* @returns Lista de queries para buscar
*/
export function combineEnrichedQueries(
enriched: EnrichedQueries,
strategy: 'all' | 'prioritize-literal' | 'prioritize-semantic' = 'prioritize-literal'
): string[] {
const queries: string[] = [];
switch (strategy) {
case 'all':
queries.push(...enriched.literal);
queries.push(...enriched.semantic);
queries.push(...enriched.related);
queries.push(...enriched.expanded);
queries.push(...enriched.contextual);
break;
case 'prioritize-literal':
queries.push(...enriched.literal);
queries.push(...enriched.semantic);
queries.push(...enriched.contextual);
queries.push(...enriched.related);
queries.push(...enriched.expanded);
break;
case 'prioritize-semantic':
queries.push(...enriched.semantic);
queries.push(...enriched.literal);
queries.push(...enriched.contextual);
queries.push(...enriched.related);
queries.push(...enriched.expanded);
break;
}
// Remover duplicatas mantendo ordem
const unique = Array.from(new Set(queries.map(q => q.toLowerCase())))
.map(lower => queries.find(q => q.toLowerCase() === lower)!)
.filter(Boolean);
return unique;
}
+157 -4
View File
@@ -1,4 +1,5 @@
import type { Chunk } from './content-condenser';
import type { QueryContext } from './contextual-analyzer';
import { normalizeText } from './text-normalization';
/**
@@ -72,14 +73,162 @@ export function calculateSimilarity(query: string, chunk: string): number {
return Math.min(Math.max(score, 0), 1);
}
/**
* Calcula similaridade semântica avançada usando contexto completo
* Considera entidades, tópicos, relacionamentos e intenção
*
* @param context - Contexto analisado da query
* @param chunk - Chunk de conteúdo
* @returns Score de similaridade semântica (0-1)
*/
export function calculateSemanticSimilarity(
context: QueryContext,
chunk: string
): number {
if (!chunk || !context) {
return 0;
}
const normalizedChunk = normalizeText(chunk).toLowerCase();
let totalScore = 0;
let weightSum = 0;
// 1. Match de keywords (peso: 0.3)
if (context.keywords.length > 0) {
let keywordMatches = 0;
for (const keyword of context.keywords) {
const keywordLower = keyword.toLowerCase();
if (normalizedChunk.includes(keywordLower)) {
keywordMatches++;
}
}
const keywordScore = keywordMatches / context.keywords.length;
totalScore += keywordScore * 0.3;
weightSum += 0.3;
}
// 2. Match de entidades (peso: 0.3)
if (context.entities.length > 0) {
let entityMatches = 0;
for (const entity of context.entities) {
const entityLower = entity.value.toLowerCase();
// Buscar entidade completa (case-insensitive)
if (normalizedChunk.includes(entityLower)) {
entityMatches += entity.confidence; // Ponderar por confiança
}
}
const entityScore = entityMatches / context.entities.length;
totalScore += entityScore * 0.3;
weightSum += 0.3;
}
// 3. Match de tópicos (peso: 0.2)
if (context.topics.length > 0) {
let topicMatches = 0;
for (const topic of context.topics) {
const topicLower = topic.toLowerCase();
// Verificar se tópico aparece no chunk (pode ser parcial)
const topicWords = topicLower.split(/\s+/);
const matches = topicWords.filter(word =>
word.length > 3 && normalizedChunk.includes(word)
);
if (matches.length >= topicWords.length * 0.5) { // 50% das palavras do tópico
topicMatches++;
}
}
const topicScore = context.topics.length > 0
? topicMatches / context.topics.length
: 0;
totalScore += topicScore * 0.2;
weightSum += 0.2;
}
// 4. Match de sinônimos (peso: 0.1)
if (context.synonyms.length > 0) {
let synonymMatches = 0;
for (const synonym of context.synonyms) {
const synonymLower = synonym.toLowerCase();
if (normalizedChunk.includes(synonymLower)) {
synonymMatches++;
}
}
const synonymScore = context.synonyms.length > 0
? Math.min(synonymMatches / context.synonyms.length, 0.5) // Cap em 0.5 para não dominar
: 0;
totalScore += synonymScore * 0.1;
weightSum += 0.1;
}
// 5. Relevância contextual (peso: 0.1)
// Verificar se contexto temporal/geográfico aparece no chunk
let contextualScore = 0;
if (context.temporalContext?.period) {
if (normalizedChunk.includes(context.temporalContext.period)) {
contextualScore += 0.5;
}
}
if (context.temporalContext?.relative) {
const relativeTerms = ['recente', 'atual', 'novo', 'último', 'atualizado'];
if (relativeTerms.some(term => normalizedChunk.includes(term))) {
contextualScore += 0.3;
}
}
if (context.geographicContext?.location) {
const locationLower = context.geographicContext.location.toLowerCase();
if (normalizedChunk.includes(locationLower)) {
contextualScore += 0.5;
}
}
totalScore += Math.min(contextualScore, 1) * 0.1;
weightSum += 0.1;
// Normalizar pelo peso total
const finalScore = weightSum > 0 ? totalScore / weightSum : 0;
// Garantir que score esteja entre 0 e 1
return Math.min(Math.max(finalScore, 0), 1);
}
/**
* Calcula similaridade combinada (keywords + semântica)
*
* @param query - Query original
* @param context - Contexto analisado (opcional)
* @param chunk - Chunk de conteúdo
* @returns Score combinado (0-1)
*/
export function calculateCombinedSimilarity(
query: string,
chunk: string,
context?: QueryContext
): number {
// Score baseado em keywords
const keywordScore = calculateSimilarity(query, chunk);
// Se temos contexto, calcular score semântico também
if (context) {
const semanticScore = calculateSemanticSimilarity(context, chunk);
// Combinar: 60% keywords, 40% semântica
return keywordScore * 0.6 + semanticScore * 0.4;
}
return keywordScore;
}
/**
* Calcula scores de relevância para todos os chunks
*
* @param chunks - Array de chunks
* @param query - Query do usuário
* @param context - Contexto analisado (opcional, para ranqueamento semântico)
* @returns Chunks com scores atribuídos, ordenados por relevância
*/
export function scoreChunks(chunks: Chunk[], query: string): Chunk[] {
export function scoreChunks(
chunks: Chunk[],
query: string,
context?: QueryContext
): Chunk[] {
if (!query || chunks.length === 0) {
return chunks;
}
@@ -87,7 +236,9 @@ export function scoreChunks(chunks: Chunk[], query: string): Chunk[] {
// Calcular score para cada chunk
const scoredChunks = chunks.map(chunk => ({
...chunk,
score: calculateSimilarity(query, chunk.content),
score: context
? calculateCombinedSimilarity(query, chunk.content, context)
: calculateSimilarity(query, chunk.content),
}));
// Ordenar por score (maior primeiro)
@@ -170,16 +321,18 @@ export function filterByMinScore(chunks: Chunk[], minScore: number = 0.1): Chunk
* @param query - Query do usuário
* @param maxTokens - Limite máximo de tokens
* @param minScore - Score mínimo (opcional)
* @param context - Contexto analisado (opcional, para ranqueamento semântico)
* @returns Chunks selecionados e ordenados por relevância
*/
export function scoreAndSelectChunks(
chunks: Chunk[],
query: string,
maxTokens: number,
minScore?: number
minScore?: number,
context?: QueryContext
): Chunk[] {
// Calcular scores
let scoredChunks = scoreChunks(chunks, query);
let scoredChunks = scoreChunks(chunks, query, context);
// Filtrar por score mínimo se especificado
if (minScore !== undefined) {
+131
View File
@@ -0,0 +1,131 @@
/**
* Utilitários para retry com backoff exponencial
*/
export interface RetryConfig {
maxAttempts: number; // Máximo de tentativas (padrão: 3)
initialDelay: number; // Delay inicial em ms (padrão: 1000)
maxDelay: number; // Delay máximo em ms (padrão: 10000)
backoffMultiplier: number; // Multiplicador do backoff (padrão: 2)
retryableErrors?: string[]; // Padrões de erro que devem ser retentados
}
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxAttempts: 3,
initialDelay: 1000,
maxDelay: 10000,
backoffMultiplier: 2,
retryableErrors: [
'timeout',
'network',
'connection',
'ECONNRESET',
'ETIMEDOUT',
'ENOTFOUND',
'sending request',
],
};
export interface RetryResult<T> {
success: boolean;
result?: T;
error?: Error;
attempts: number;
lastError?: Error;
}
/**
* Classifica se um erro é recuperável (deve ser retentado)
*/
export function isRetryableError(error: unknown, config: RetryConfig = DEFAULT_RETRY_CONFIG): boolean {
if (!error) return false;
const errorMessage = error instanceof Error ? error.message : String(error);
const errorString = errorMessage.toLowerCase();
// Verificar padrões de erro recuperáveis
if (config.retryableErrors) {
return config.retryableErrors.some(pattern =>
errorString.includes(pattern.toLowerCase())
);
}
// Erros de rede/timeout são geralmente recuperáveis
return errorString.includes('timeout') ||
errorString.includes('network') ||
errorString.includes('connection');
}
/**
* Calcula o delay para a próxima tentativa usando backoff exponencial
*/
function calculateDelay(attempt: number, config: RetryConfig): number {
const delay = config.initialDelay * Math.pow(config.backoffMultiplier, attempt - 1);
return Math.min(delay, config.maxDelay);
}
/**
* Executa uma função com retry e backoff exponencial
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
config: Partial<RetryConfig> = {}
): Promise<RetryResult<T>> {
const finalConfig: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
let lastError: Error | undefined;
for (let attempt = 1; attempt <= finalConfig.maxAttempts; attempt++) {
try {
const result = await fn();
return {
success: true,
result,
attempts: attempt,
};
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Se não for erro recuperável, não tenta novamente
if (!isRetryableError(error, finalConfig)) {
return {
success: false,
error: lastError,
attempts: attempt,
lastError,
};
}
// Se não for a última tentativa, aguarda antes de tentar novamente
if (attempt < finalConfig.maxAttempts) {
const delay = calculateDelay(attempt, finalConfig);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
return {
success: false,
error: lastError,
attempts: finalConfig.maxAttempts,
lastError,
};
}
/**
* Cria um timeout para uma promise
*/
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage = 'Operation timed out'
): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs)
),
]);
}
+213
View File
@@ -0,0 +1,213 @@
import type { QueryContext } from './contextual-analyzer';
import type { EnrichedQueries } from './query-enricher';
import type { FallbackAttempt } from './web-search-fallback';
import type { ScrapedContent } from '@/services/webSearch';
import type { UnifiedContext } from './context-unifier';
/**
* Log completo de uma pesquisa
*/
export interface SearchLog {
timestamp: number;
originalQuery: string;
contextAnalysis?: QueryContext;
enrichedQueries?: EnrichedQueries;
rounds: Array<{
round: number;
strategy?: string;
queries: string[];
results: Array<{
query: string;
engine?: string;
resultsCount: number;
duration: number;
topResults: Array<{
title: string;
url: string;
score?: number;
}>;
}>;
totalResults: number;
relevanceScore: number;
}>;
finalResults: {
totalSources: number;
unifiedContext?: UnifiedContext;
keyFacts?: number;
contradictions?: number;
};
usedFallback: boolean;
totalDuration: number;
}
/**
* Armazena logs de pesquisa em memória
* (Pode ser expandido para salvar em arquivo ou banco de dados)
*/
class SearchLogger {
private logs: SearchLog[] = [];
private maxLogs = 100; // Manter apenas os últimos 100 logs
/**
* Adiciona um novo log
*/
addLog(log: SearchLog): void {
this.logs.unshift(log); // Adicionar no início
if (this.logs.length > this.maxLogs) {
this.logs = this.logs.slice(0, this.maxLogs);
}
}
/**
* Obtém todos os logs
*/
getLogs(): SearchLog[] {
return [...this.logs];
}
/**
* Obtém log por query
*/
getLogByQuery(query: string): SearchLog | undefined {
return this.logs.find(log =>
log.originalQuery.toLowerCase() === query.toLowerCase()
);
}
/**
* Limpa todos os logs
*/
clearLogs(): void {
this.logs = [];
}
/**
* Exporta logs como JSON
*/
exportLogs(): string {
return JSON.stringify(this.logs, null, 2);
}
}
// Instância singleton
export const searchLogger = new SearchLogger();
/**
* Cria um log de pesquisa a partir de dados coletados
*/
export function createSearchLog(
originalQuery: string,
contextAnalysis: QueryContext | null,
enrichedQueries: EnrichedQueries | null,
attempts: FallbackAttempt[],
finalSources: ScrapedContent[],
unifiedContext: UnifiedContext | null,
usedFallback: boolean,
startTime: number
): SearchLog {
const endTime = Date.now();
const totalDuration = endTime - startTime;
// Processar rounds dos attempts
const rounds = attempts.map(attempt => {
const queryResults = attempt.queries
? attempt.queries.map(q => ({
query: q,
resultsCount: attempt.results.length,
duration: attempt.duration,
topResults: attempt.results.slice(0, 3).map(r => ({
title: r.title,
url: r.url,
score: attempt.relevanceScore,
})),
}))
: [{
query: attempt.query,
resultsCount: attempt.results.length,
duration: attempt.duration,
topResults: attempt.results.slice(0, 3).map(r => ({
title: r.title,
url: r.url,
score: attempt.relevanceScore,
})),
}];
return {
round: attempt.round,
strategy: attempt.strategy,
queries: attempt.queries || [attempt.query],
results: queryResults,
totalResults: attempt.results.length,
relevanceScore: attempt.relevanceScore,
};
});
return {
timestamp: startTime,
originalQuery,
contextAnalysis: contextAnalysis || undefined,
enrichedQueries: enrichedQueries || undefined,
rounds,
finalResults: {
totalSources: finalSources.length,
unifiedContext: unifiedContext || undefined,
keyFacts: unifiedContext?.keyFacts.length,
contradictions: unifiedContext?.contradictions.length,
},
usedFallback,
totalDuration,
};
}
/**
* Formata log para exibição
*/
export function formatSearchLog(log: SearchLog): string {
const lines: string[] = [];
lines.push(`=== SEARCH LOG ===`);
lines.push(`Timestamp: ${new Date(log.timestamp).toISOString()}`);
lines.push(`Query: "${log.originalQuery}"`);
lines.push(`Duration: ${log.totalDuration}ms`);
lines.push(`Used Fallback: ${log.usedFallback}`);
lines.push('');
if (log.contextAnalysis) {
lines.push(`--- Context Analysis ---`);
lines.push(`Intent: ${log.contextAnalysis.intent}`);
lines.push(`Entities: ${log.contextAnalysis.entities.length}`);
lines.push(`Topics: ${log.contextAnalysis.topics.join(', ')}`);
lines.push('');
}
if (log.enrichedQueries) {
lines.push(`--- Enriched Queries ---`);
lines.push(`Literal: ${log.enrichedQueries.literal.length}`);
lines.push(`Semantic: ${log.enrichedQueries.semantic.length}`);
lines.push(`Related: ${log.enrichedQueries.related.length}`);
lines.push(`Expanded: ${log.enrichedQueries.expanded.length}`);
lines.push(`Contextual: ${log.enrichedQueries.contextual.length}`);
lines.push('');
}
lines.push(`--- Rounds (${log.rounds.length}) ---`);
log.rounds.forEach((round, idx) => {
lines.push(`Round ${round.round} [${round.strategy || 'N/A'}]:`);
lines.push(` Queries: ${round.queries.join(', ')}`);
lines.push(` Results: ${round.totalResults}`);
lines.push(` Relevance Score: ${round.relevanceScore.toFixed(2)}`);
lines.push('');
});
lines.push(`--- Final Results ---`);
lines.push(`Total Sources: ${log.finalResults.totalSources}`);
if (log.finalResults.keyFacts) {
lines.push(`Key Facts: ${log.finalResults.keyFacts}`);
}
if (log.finalResults.contradictions) {
lines.push(`Contradictions: ${log.finalResults.contradictions}`);
}
return lines.join('\n');
}
+2
View File
@@ -36,3 +36,5 @@ export const deepResearchLog = {
+203 -67
View File
@@ -3,25 +3,43 @@
*
* 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';
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)
}
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 {
@@ -108,8 +126,9 @@ Query expandida:`;
/**
* Calcula score de relevância de um resultado baseado na query
* (Mantido para compatibilidade, mas agora usa função de relevance-scorer)
*/
export function calculateRelevanceScore(
export function calculateRelevanceScoreForFallback(
result: ScrapedContent,
query: string
): number {
@@ -167,7 +186,7 @@ export function areResultsRelevant(
}
// Calcular scores de relevância
const scores = results.map(r => calculateRelevanceScore(r, query));
const scores = results.map(r => calculateRelevanceScoreForFallback(r, query));
// Verificar se pelo menos um resultado tem score suficiente
const maxScore = Math.max(...scores);
@@ -181,8 +200,66 @@ export function areResultsRelevant(
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,
@@ -198,21 +275,63 @@ export async function executeProgressiveSearch(
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
chatLog.info(`Original Query: "${originalQuery}"`);
chatLog.info(`Config: ${JSON.stringify(finalConfig)}`);
chatLog.info(`Config: ${JSON.stringify({ ...finalConfig, context: finalConfig.context ? 'present' : 'none', enrichedQueries: finalConfig.enrichedQueries ? 'present' : 'none' })}`);
for (let round = 1; round <= finalConfig.maxRounds; round++) {
chatLog.info(`\n--- Round ${round}/${finalConfig.maxRounds} ---`);
// 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) {
chatLog.info(`\n--- Round ${round}/${rounds.length} [${roundConfig.strategy}] ---`);
// Expandir query se necessário
const query = finalConfig.enableQueryExpansion
? await expandQuery(originalQuery, model, round)
: originalQuery;
const roundStartTime = Date.now();
let roundResults: ScrapedContent[] = [];
let roundQueries: string[] = [];
chatLog.info(`Searching with query: "${query}"`);
// Executar todas as queries do round em paralelo
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 {
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound);
return { query: finalQuery, results };
} catch (error) {
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed:`, error);
return { query: finalQuery, results: [] };
}
});
try {
// Executar busca
const results = await searchFn(query, finalConfig.maxResultsPerRound);
const queryResults = await Promise.all(queryPromises);
// Agregar resultados de todas as queries do round
for (const { query, results } of queryResults) {
roundQueries.push(query);
// Filtrar duplicatas
const uniqueResults = results.filter(r => {
@@ -223,61 +342,78 @@ export async function executeProgressiveSearch(
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(),
});
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
const relevanceScore = roundResults.length > 0
? roundResults.reduce((sum, r) => {
// Usar contexto para cálculo de relevância se disponível
if (finalConfig.context) {
// Importar função dinamicamente para evitar circular dependency
const { calculateSemanticSimilarity } = require('./relevance-scorer');
return sum + calculateSemanticSimilarity(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 se encontrou resultados relevantes
const queryForRelevance = roundQueries[0] || originalQuery;
if (areResultsRelevant(roundResults, queryForRelevance, finalConfig.minRelevanceScore)) {
chatLog.info(`[Round ${round}] ✓ Relevant results found!`);
chatLog.info(`[Round ${round}] Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
chatLog.info(`[Round ${round}] Final queries: ${roundQueries.join(', ')}`);
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