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:
1 parent
84ea2dec19
commit
e5f062040c
33 files changed
+4670
-465
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -36,3 +36,5 @@ export const deepResearchLog = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+203
-67
@@ -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
|
||||
|
||||
Reference in new issue
Block a user