feat: add emoji-regex and remove-accents dependencies; enhance chat and setup pages

- Added `emoji-regex` and `remove-accents` packages to improve text processing capabilities.
- Implemented automatic Ollama startup in the chat page for better user experience.
- Enhanced setup page to include GPU selection based on hardware specifications.
- Updated chat input to support real-time validation and improved user feedback.
- Refactored message handling in chat to incorporate new processing features.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-26 16:12:16 -03:00
1 parent 4275295e4f
commit 84ea2dec19
51 files changed
+7857 -573

No files matched your search

+387
View File
@@ -0,0 +1,387 @@
import type { ResearchEntry } from '@/hooks/use-deep-research';
export interface Chunk {
content: string;
score: number;
sourceIndex: number;
startIndex: number;
endIndex: number;
sourceUrl?: string;
sourceTitle?: string;
}
export interface ProcessedContent {
original: string;
summarized?: string;
chunks: Chunk[];
keyFacts: string[];
totalTokens: number;
}
export interface ProcessingOptions {
maxChunkSize?: number;
summarizeThreshold?: number;
maxSummaryLength?: number;
}
const DEFAULT_OPTIONS: Required<ProcessingOptions> = {
maxChunkSize: 1024,
summarizeThreshold: 5000,
maxSummaryLength: 2000,
};
/**
* Sumariza conteúdo markdown mantendo estrutura e informações-chave
*
* @param content - Conteúdo markdown a ser sumarizado
* @param maxLen - Comprimento máximo do sumário
* @returns Conteúdo sumarizado
*/
export function summarizeMarkdown(content: string, maxLen: number = 2000): string {
if (!content || content.length <= maxLen) {
return content;
}
// Dividir por headings (###, ##, #)
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
const sections: Array<{ level: number; title: string; content: string; startIndex: number }> = [];
let lastIndex = 0;
let match;
const contentLines = content.split('\n');
// Encontrar todas as seções
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const title = match[2].trim();
const startIndex = content.substring(0, match.index).split('\n').length - 1;
// Encontrar conteúdo da seção (até próximo heading ou fim)
let endIndex = contentLines.length;
const nextHeadingRegex = /^(#{1,3})\s+/gm;
nextHeadingRegex.lastIndex = match.index + match[0].length;
const nextMatch = nextHeadingRegex.exec(content);
if (nextMatch) {
endIndex = content.substring(0, nextMatch.index).split('\n').length - 1;
}
const sectionContent = contentLines.slice(startIndex + 1, endIndex).join('\n').trim();
sections.push({
level,
title,
content: sectionContent,
startIndex,
});
lastIndex = endIndex;
}
// Se não encontrou headings, dividir por parágrafos
if (sections.length === 0) {
const paragraphs = content.split(/\n\s*\n/).filter(p => p.trim().length > 0);
let summary = '';
for (const para of paragraphs) {
if (summary.length + para.length + 2 <= maxLen) {
summary += para.trim() + '\n\n';
} else {
// Adicionar parte do parágrafo se ainda houver espaço
const remaining = maxLen - summary.length - 2;
if (remaining > 100) {
summary += para.substring(0, remaining).trim() + '...';
}
break;
}
}
return summary.trim() || content.substring(0, maxLen);
}
// Construir sumário priorizando seções de nível superior
let summary = '';
const sortedSections = sections.sort((a, b) => a.level - b.level);
for (const section of sortedSections) {
const sectionText = `## ${section.title}\n\n${section.content}\n\n`;
if (summary.length + sectionText.length <= maxLen) {
summary += sectionText;
} else {
// Adicionar parte da seção se ainda houver espaço
const remaining = maxLen - summary.length - 50; // 50 para heading
if (remaining > 100) {
const truncatedContent = section.content.substring(0, remaining).trim();
summary += `## ${section.title}\n\n${truncatedContent}...\n\n`;
}
break;
}
}
return summary.trim() || content.substring(0, maxLen);
}
/**
* Divide conteúdo markdown em chunks por seções
*
* @param markdown - Conteúdo markdown
* @param maxChunkSize - Tamanho máximo de cada chunk
* @returns Array de chunks
*/
export function chunkBySections(
markdown: string,
maxChunkSize: number = 1024
): Chunk[] {
if (!markdown || markdown.length === 0) {
return [];
}
const chunks: Chunk[] = [];
const lines = markdown.split('\n');
// Dividir por headings primeiro
const headingRegex = /^(#{1,3})\s+(.+)$/;
let currentChunk: string[] = [];
let currentHeading = '';
let startIndex = 0;
let chunkIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headingMatch = line.match(headingRegex);
if (headingMatch) {
// Se já temos um chunk, salvá-lo
if (currentChunk.length > 0) {
const chunkContent = currentChunk.join('\n');
if (chunkContent.trim().length > 0) {
chunks.push({
content: chunkContent,
score: 0, // Será calculado depois
sourceIndex: 0, // Será atribuído depois
startIndex: startIndex,
endIndex: i - 1,
});
chunkIndex++;
}
}
// Iniciar novo chunk
currentHeading = line;
currentChunk = [line];
startIndex = i;
} else {
currentChunk.push(line);
// Se chunk ficou muito grande, dividir por parágrafos
const currentContent = currentChunk.join('\n');
if (currentContent.length > maxChunkSize) {
// Salvar chunk atual (sem a linha atual)
const chunkWithoutLast = currentChunk.slice(0, -1).join('\n');
if (chunkWithoutLast.trim().length > 0) {
chunks.push({
content: chunkWithoutLast,
score: 0,
sourceIndex: 0,
startIndex: startIndex,
endIndex: i - 1,
});
chunkIndex++;
}
// Iniciar novo chunk com a linha atual
currentChunk = [currentHeading, line].filter(Boolean);
startIndex = i;
}
}
}
// Adicionar último chunk
if (currentChunk.length > 0) {
const chunkContent = currentChunk.join('\n');
if (chunkContent.trim().length > 0) {
chunks.push({
content: chunkContent,
score: 0,
sourceIndex: 0,
startIndex: startIndex,
endIndex: lines.length - 1,
});
}
}
// Se não encontrou headings, dividir por parágrafos
if (chunks.length === 0) {
const paragraphs = markdown.split(/\n\s*\n/).filter(p => p.trim().length > 0);
let currentChunkContent = '';
let paraStartIndex = 0;
for (let i = 0; i < paragraphs.length; i++) {
const para = paragraphs[i].trim();
if (currentChunkContent.length + para.length + 2 <= maxChunkSize) {
currentChunkContent += (currentChunkContent ? '\n\n' : '') + para;
} else {
// Salvar chunk atual
if (currentChunkContent.length > 0) {
chunks.push({
content: currentChunkContent,
score: 0,
sourceIndex: 0,
startIndex: paraStartIndex,
endIndex: i - 1,
});
}
// Iniciar novo chunk
currentChunkContent = para;
paraStartIndex = i;
}
}
// Adicionar último chunk
if (currentChunkContent.length > 0) {
chunks.push({
content: currentChunkContent,
score: 0,
sourceIndex: 0,
startIndex: paraStartIndex,
endIndex: paragraphs.length - 1,
});
}
}
return chunks;
}
/**
* Extrai fatos-chave do conteúdo (entidades, datas, números)
*
* @param markdown - Conteúdo markdown
* @returns Array de fatos extraídos
*/
export function extractKeyFacts(markdown: string): string[] {
if (!markdown) {
return [];
}
const facts: string[] = [];
const normalized = markdown.toLowerCase();
// Extrair datas (formato brasileiro: DD/MM/YYYY ou DD-MM-YYYY)
const dateRegex = /\b(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})\b/g;
const dates = [...markdown.matchAll(dateRegex)];
dates.forEach(match => {
if (!facts.includes(match[0])) {
facts.push(match[0]);
}
});
// Extrair anos (4 dígitos entre 1900-2100)
const yearRegex = /\b(19\d{2}|20[0-2]\d)\b/g;
const years = [...markdown.matchAll(yearRegex)];
years.forEach(match => {
if (!facts.includes(match[0])) {
facts.push(match[0]);
}
});
// Extrair números relevantes (valores, estatísticas)
// Padrão: número seguido de unidade ou contexto
const numberPatterns = [
/\b(\d{1,3}(?:\.\d{3})*(?:,\d+)?)\s*(?:milhões?|milhões?|bilhões?|bilhões?|mil|km|metros?|metros?|anos?|anos?|pessoas?|habitantes?)/gi,
/\bR\$\s*(\d{1,3}(?:\.\d{3})*(?:,\d+)?)/gi,
/\b(\d{1,3}(?:\.\d{3})*(?:,\d+)?)\s*%/g,
];
numberPatterns.forEach(pattern => {
const matches = [...markdown.matchAll(pattern)];
matches.forEach(match => {
const fact = match[0].trim();
if (fact.length > 0 && !facts.includes(fact)) {
facts.push(fact);
}
});
});
// Extrair locais (padrões comuns: "em [Local]", "de [Local]", "para [Local]")
const locationPatterns = [
/\b(?:em|de|para|no|na|nos|nas)\s+([A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+(?:\s+[A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+)*)/g,
/\b([A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+\s+(?:de\s+)?[A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+)\b/g,
];
locationPatterns.forEach(pattern => {
const matches = [...markdown.matchAll(pattern)];
matches.forEach(match => {
const location = match[1]?.trim();
if (location && location.length > 3 && location.length < 50 && !facts.includes(location)) {
// Filtrar palavras comuns que não são locais
const commonWords = ['o', 'a', 'os', 'as', 'um', 'uma', 'de', 'da', 'do', 'das', 'dos'];
if (!commonWords.includes(location.toLowerCase())) {
facts.push(location);
}
}
});
});
// Limitar a 20 fatos mais relevantes
return facts.slice(0, 20);
}
/**
* Processa conteúdo: sumariza, extrai fatos e faz chunking
*
* @param content - Conteúdo original
* @param query - Query do usuário (para contexto)
* @param options - Opções de processamento
* @returns Conteúdo processado
*/
export function processContent(
content: string,
query: string = '',
options: ProcessingOptions = {}
): ProcessedContent {
const opts = { ...DEFAULT_OPTIONS, ...options };
if (!content || content.length === 0) {
return {
original: content,
chunks: [],
keyFacts: [],
totalTokens: 0,
};
}
// Sumarizar se muito longo
let processedContent = content;
let summarized: string | undefined;
if (content.length > opts.summarizeThreshold) {
summarized = summarizeMarkdown(content, opts.maxSummaryLength);
processedContent = summarized;
}
// Extrair fatos-chave
const keyFacts = extractKeyFacts(content);
// Fazer chunking
const chunks = chunkBySections(processedContent, opts.maxChunkSize);
// Atribuir sourceIndex aos chunks (todos do mesmo source por enquanto)
chunks.forEach((chunk, idx) => {
chunk.sourceIndex = 0;
});
// Calcular tokens totais (aproximação: 1 token ≈ 4 caracteres)
const totalTokens = Math.ceil(processedContent.length / 4);
return {
original: content,
summarized,
chunks,
keyFacts,
totalTokens,
};
}
+97
View File
@@ -0,0 +1,97 @@
import { invoke } from '@tauri-apps/api/core';
/**
* URLs oficiais dos instaladores Ollama
*/
export const OLLAMA_DOWNLOAD_URLS = {
windows: 'https://ollama.com/download/OllamaSetup.exe',
mac: 'https://ollama.com/download/Ollama-darwin.zip',
linux: 'https://ollama.com/install.sh',
} as const;
/**
* Nomes dos arquivos de fallback locais
*/
export const LOCAL_INSTALLER_FILES = {
windows: 'OllamaSetup.exe',
mac: 'Ollama-darwin.zip',
linux: 'install.sh',
} as const;
export type OS = 'windows' | 'mac' | 'linux';
/**
* Verifica se a URL oficial está disponível
*/
export async function checkOfficialUrlAvailable(os: OS): Promise<boolean> {
try {
const url = OLLAMA_DOWNLOAD_URLS[os];
return await invoke<boolean>('check_download_url', { url });
} catch (error) {
console.error('Erro ao verificar URL oficial:', error);
return false;
}
}
/**
* Verifica se o instalador local existe
*/
export async function checkLocalInstallerExists(os: OS): Promise<boolean> {
try {
const filename = LOCAL_INSTALLER_FILES[os];
const path = await invoke<string | null>('get_local_installer_path', {
filename
});
return path !== null;
} catch (error) {
console.error('Erro ao verificar instalador local:', error);
return false;
}
}
/**
* Faz download do instalador (oficial ou fallback)
* Retorna o caminho do arquivo baixado
*/
export async function downloadInstaller(os: OS): Promise<string> {
const url = OLLAMA_DOWNLOAD_URLS[os];
const filename = LOCAL_INSTALLER_FILES[os];
try {
const filePath = await invoke<string>('download_installer', {
url,
filename
});
return filePath;
} catch (error) {
throw new Error(`Falha ao baixar instalador: ${error}`);
}
}
/**
* Verifica se o instalador já foi baixado
*/
export async function getDownloadedInstallerPath(os: OS): Promise<string | null> {
const filename = LOCAL_INSTALLER_FILES[os];
try {
const path = await invoke<string | null>('get_downloaded_installer_path', {
filename
});
return path;
} catch (error) {
console.error('Erro ao verificar instalador baixado:', error);
return null;
}
}
/**
* Executa o instalador baixado
*/
export async function runInstaller(filePath: string): Promise<void> {
try {
await invoke('run_installer', { filePath });
} catch (error) {
throw new Error(`Falha ao executar instalador: ${error}`);
}
}
+261
View File
@@ -0,0 +1,261 @@
import type { ResearchEntry } from '@/hooks/use-deep-research';
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
import { scoreAndSelectChunks } from './relevance-scorer';
import { summarizeMarkdown } from './content-condenser';
export interface CondensationResult {
context: string;
totalTokens: number;
originalTokens: number;
compressionRatio: number;
chunksUsed: number;
chunksTotal: number;
method: 'chunks' | 'summarized' | 'fallback';
sources: Array<{ title: string; url: string; chunksUsed: number }>;
}
export interface CondensationOptions {
maxTokens?: number;
minRelevanceScore?: number;
autoSummarize?: boolean;
summarizeThreshold?: number;
fallbackToSummarization?: boolean;
}
const DEFAULT_OPTIONS: Required<CondensationOptions> = {
maxTokens: 12000,
minRelevanceScore: 0.1,
autoSummarize: true,
summarizeThreshold: 5000,
fallbackToSummarization: true,
};
/**
* Estima tokens de um texto (aproximação: 1 token ≈ 4 caracteres)
*/
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Sumariza todas as entradas e combina em um único contexto
*
* @param entries - Entradas da Knowledge Base
* @param query - Query do usuário
* @param maxSummaryLength - Comprimento máximo de cada sumário
* @returns Contexto sumarizado
*/
function fallbackSummarization(
entries: ResearchEntry[],
query: string,
maxSummaryLength: number = 1500
): string {
const summaries: string[] = [];
entries.forEach((entry, idx) => {
const summary = summarizeMarkdown(entry.content, maxSummaryLength);
summaries.push(
`[ID: ${idx + 1}] **${entry.title}**\nURL: ${entry.sourceUrl}\n\n${summary}\n\n---\n\n`
);
});
const combined = summaries.join('');
return `## KNOWLEDGE BASE (Resumo Condensado)\n\n${combined}`;
}
/**
* Condensa Knowledge Base usando chunking inteligente e scoring
*
* @param entries - Entradas da Knowledge Base
* @param query - Query do usuário
* @param options - Opções de condensação
* @returns Resultado da condensação
*/
export function condenseKnowledgeBase(
entries: ResearchEntry[],
query: string,
options: CondensationOptions = {}
): CondensationResult {
const opts = { ...DEFAULT_OPTIONS, ...options };
if (entries.length === 0) {
return {
context: '',
totalTokens: 0,
originalTokens: 0,
compressionRatio: 0,
chunksUsed: 0,
chunksTotal: 0,
method: 'fallback',
sources: [],
};
}
// Calcular tokens originais
const originalContent = entries.map(e => e.content).join('\n\n');
const originalTokens = estimateTokens(originalContent);
// Processar cada entrada
const allChunks: Chunk[] = [];
const processedEntries: Array<{
entry: ResearchEntry;
processed: ProcessedContent;
chunks: Chunk[];
}> = [];
entries.forEach((entry, entryIndex) => {
const processed = processContent(entry.content, query, {
summarizeThreshold: opts.summarizeThreshold,
autoSummarize: opts.autoSummarize,
});
// Atribuir sourceIndex e metadados aos chunks
const chunksWithSource = processed.chunks.map((chunk, chunkIdx) => ({
...chunk,
sourceIndex: entryIndex,
sourceUrl: entry.sourceUrl,
sourceTitle: entry.title,
}));
processedEntries.push({
entry,
processed,
chunks: chunksWithSource,
});
allChunks.push(...chunksWithSource);
});
// Calcular tokens disponíveis (reservar 20% para margem)
const availableTokens = Math.floor(opts.maxTokens * 0.8);
// Selecionar chunks mais relevantes
let selectedChunks = scoreAndSelectChunks(
allChunks,
query,
availableTokens,
opts.minRelevanceScore
);
// Se não selecionou chunks suficientes ou método de fallback está ativo
let method: 'chunks' | 'summarized' | 'fallback' = 'chunks';
let context = '';
if (selectedChunks.length === 0 && opts.fallbackToSummarization) {
// Fallback: sumarizar tudo
method = 'fallback';
context = fallbackSummarization(entries, query);
} else {
// Construir contexto a partir dos chunks selecionados
const chunksBySource = new Map<number, Chunk[]>();
selectedChunks.forEach(chunk => {
if (!chunksBySource.has(chunk.sourceIndex)) {
chunksBySource.set(chunk.sourceIndex, []);
}
chunksBySource.get(chunk.sourceIndex)!.push(chunk);
});
// Agrupar chunks por fonte
const sourceBlocks: string[] = [];
const sourceStats: Array<{ title: string; url: string; chunksUsed: number }> = [];
chunksBySource.forEach((chunks, sourceIndex) => {
const entry = entries[sourceIndex];
if (!entry) return;
const sourceChunks = chunks
.sort((a, b) => a.startIndex - b.startIndex)
.map(c => c.content)
.join('\n\n');
sourceBlocks.push(
`[ID: ${sourceIndex + 1}] **${entry.title}**\nURL: ${entry.sourceUrl}\n\n${sourceChunks}\n\n---\n\n`
);
sourceStats.push({
title: entry.title,
url: entry.sourceUrl,
chunksUsed: chunks.length,
});
});
context = `## KNOWLEDGE BASE (Verified Sources)\n\n${sourceBlocks.join('')}`;
// Verificar se contexto excede limite
const contextTokens = estimateTokens(context);
if (contextTokens > opts.maxTokens && opts.fallbackToSummarization) {
// Se ainda exceder, usar sumarização
method = 'summarized';
context = fallbackSummarization(entries, query);
}
}
const totalTokens = estimateTokens(context);
const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0;
return {
context: context.trim(),
totalTokens,
originalTokens,
compressionRatio,
chunksUsed: selectedChunks.length,
chunksTotal: allChunks.length,
method,
sources: entries.map((entry, idx) => ({
title: entry.title,
url: entry.sourceUrl,
chunksUsed: selectedChunks.filter(c => c.sourceIndex === idx).length,
})),
};
}
/**
* Valida se o contexto condensado contém informações relevantes
*
* @param result - Resultado da condensação
* @param query - Query do usuário
* @returns true se válido
*/
export function validateCondensedContext(
result: CondensationResult,
query: string
): { isValid: boolean; warnings: string[] } {
const warnings: string[] = [];
if (!result.context || result.context.length < 100) {
return {
isValid: false,
warnings: ['Contexto condensado está vazio ou muito curto'],
};
}
if (result.chunksUsed === 0 && result.method !== 'fallback') {
warnings.push('Nenhum chunk relevante foi selecionado');
}
if (result.compressionRatio > 0.5) {
warnings.push(`Alta taxa de compressão (${(result.compressionRatio * 100).toFixed(1)}%) - informações podem ter sido perdidas`);
}
if (result.chunksUsed < result.chunksTotal * 0.3) {
warnings.push(`Apenas ${result.chunksUsed} de ${result.chunksTotal} chunks foram selecionados`);
}
// Verificar se query aparece no contexto (básico)
const normalizedQuery = query.toLowerCase();
const normalizedContext = result.context.toLowerCase();
const queryWords = normalizedQuery.split(/\s+/).filter(w => w.length > 3);
const matchingWords = queryWords.filter(word => normalizedContext.includes(word));
if (matchingWords.length < queryWords.length * 0.5) {
warnings.push('Poucas palavras-chave da query aparecem no contexto');
}
return {
isValid: result.context.length >= 100,
warnings,
};
}
+133
View File
@@ -0,0 +1,133 @@
import type { Message } from '@/hooks/use-chat';
import { useSettingsStore } from '@/store/settings-store';
export interface ModelContextInfo {
modelName: string;
maxContextWindow: number;
recommendedContextWindow: number;
}
/**
* Estima tokens de um texto (aproximação: 1 token ≈ 4 caracteres)
*/
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Detecta o context window máximo do modelo consultando Ollama API
*
* @param modelName - Nome do modelo
* @returns Limite máximo de tokens do modelo
*/
export async function detectModelContextWindow(modelName: string): Promise<number> {
if (!modelName) {
// Fallback para settings
const settings = useSettingsStore.getState();
return settings.contextWindow || 4096;
}
try {
// Tentar obter informações do modelo via Ollama API
const response = await fetch('http://localhost:11434/api/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: modelName,
}),
});
if (response.ok) {
const data = await response.json();
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
// Mapear tamanhos conhecidos de modelos
const modelInfo = data.modelfile || '';
const contextMatch = modelInfo.match(/context[_\s]*length[:\s]*(\d+)/i);
if (contextMatch) {
return parseInt(contextMatch[1], 10);
}
// Tentar inferir do tamanho do modelo
const size = data.size || '';
if (size.includes('7b') || size.includes('8b')) {
return 8192; // Modelos 7B-8B geralmente têm 8k
}
if (size.includes('13b') || size.includes('14b')) {
return 16384; // Modelos 13B-14B geralmente têm 16k
}
if (size.includes('32k') || size.includes('128k')) {
return 32768; // Modelos com contexto estendido
}
}
} catch (error) {
console.warn('Erro ao detectar context window do modelo:', error);
}
// Fallback: usar contextWindow das settings
const settings = useSettingsStore.getState();
return settings.contextWindow || 4096;
}
/**
* Calcula tokens disponíveis para Knowledge Base
*
* @param systemPrompt - System prompt
* @param messages - Mensagens do histórico
* @param modelMaxTokens - Limite máximo de tokens do modelo
* @returns Tokens disponíveis para Knowledge Base
*/
export function calculateAvailableTokens(
systemPrompt: string,
messages: Message[],
modelMaxTokens: number
): number {
// Calcular tokens usados
let usedTokens = 0;
// System prompt
if (systemPrompt) {
usedTokens += estimateTokens(systemPrompt);
}
// Mensagens do histórico
messages.forEach(msg => {
usedTokens += estimateTokens(msg.content);
});
// Reservar 10% para margem de segurança e resposta da IA
const reservedTokens = Math.ceil(modelMaxTokens * 0.1);
const availableTokens = modelMaxTokens - usedTokens - reservedTokens;
// Garantir mínimo de 1000 tokens disponíveis
return Math.max(availableTokens, 1000);
}
/**
* Obtém informações completas do contexto do modelo
*
* @param modelName - Nome do modelo
* @param systemPrompt - System prompt atual
* @param messages - Mensagens do histórico
* @returns Informações do contexto
*/
export async function getModelContextInfo(
modelName: string,
systemPrompt: string = '',
messages: Message[] = []
): Promise<ModelContextInfo> {
const maxContextWindow = await detectModelContextWindow(modelName);
const availableTokens = calculateAvailableTokens(systemPrompt, messages, maxContextWindow);
// Recommended é 80% do disponível para garantir margem
const recommendedContextWindow = Math.floor(availableTokens * 0.8);
return {
modelName,
maxContextWindow,
recommendedContextWindow,
};
}
+177
View File
@@ -0,0 +1,177 @@
import emojiRegex from 'emoji-regex';
import { normalizeText } from './text-normalization';
export interface ValidationOptions {
minLength?: number;
maxLength?: number;
irrelevantPatterns?: string[];
allowEmojis?: boolean;
}
export interface ValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
normalizedLength: number;
}
const DEFAULT_OPTIONS: Required<ValidationOptions> = {
minLength: 3,
maxLength: 2000,
irrelevantPatterns: [
'oi',
'olá',
'ola',
'teste',
'test',
'hello',
'hi',
'hey',
'ok',
'okay',
'sim',
'não',
'nao',
'yes',
'no',
],
allowEmojis: false,
};
/**
* Valida uma query do usuário
*
* @param text - Texto a ser validado
* @param options - Opções de validação
* @returns Resultado da validação
*/
export function validateQuery(
text: string,
options: ValidationOptions = {}
): ValidationResult {
const opts = { ...DEFAULT_OPTIONS, ...options };
const errors: string[] = [];
const warnings: string[] = [];
let normalizedLength = 0;
// Verificar se o texto existe e é string
if (!text || typeof text !== 'string') {
return {
isValid: false,
errors: ['Texto vazio ou inválido'],
warnings: [],
normalizedLength: 0,
};
}
// Normalizar texto para validação
const normalized = normalizeText(text);
normalizedLength = normalized.length;
// Verificar comprimento mínimo
if (normalizedLength < opts.minLength) {
errors.push(
`Texto muito curto. Mínimo de ${opts.minLength} caracteres necessário.`
);
}
// Verificar comprimento máximo
if (normalizedLength > opts.maxLength) {
errors.push(
`Texto muito longo. Máximo de ${opts.maxLength} caracteres permitido.`
);
}
// Verificar se é apenas espaços em branco após normalização
if (normalized.trim().length === 0) {
errors.push('Texto contém apenas espaços em branco ou caracteres especiais.');
}
// Verificar se contém apenas emojis
if (!opts.allowEmojis) {
const emojiPattern = emojiRegex();
const textWithoutEmojis = text.replace(emojiPattern, '').trim();
if (textWithoutEmojis.length === 0 && text.trim().length > 0) {
errors.push('Texto contém apenas emojis.');
}
}
// Verificar se contém apenas pontuação
const textWithoutPunctuation = normalized.replace(/[.,?!:;]/g, '').trim();
if (textWithoutPunctuation.length === 0 && normalized.length > 0) {
errors.push('Texto contém apenas pontuação.');
}
// Verificar padrões irrelevantes
if (isIrrelevantQuery(normalized, opts.irrelevantPatterns)) {
warnings.push(
'Pergunta pode ser irrelevante ou muito simples. Considere ser mais específico.'
);
}
return {
isValid: errors.length === 0,
errors,
warnings,
normalizedLength,
};
}
/**
* Verifica se uma query é irrelevante baseado em padrões
*
* @param text - Texto normalizado a ser verificado
* @param patterns - Lista de padrões irrelevantes
* @returns true se a query for irrelevante
*/
export function isIrrelevantQuery(
text: string,
patterns: string[] = DEFAULT_OPTIONS.irrelevantPatterns
): boolean {
if (!text || typeof text !== 'string') {
return true;
}
const normalized = normalizeText(text);
const normalizedPatterns = patterns.map((p) => normalizeText(p));
// Verificar se o texto normalizado está exatamente na lista de padrões
if (normalizedPatterns.includes(normalized)) {
return true;
}
// Verificar se o texto começa com um padrão irrelevante seguido apenas de pontuação/espaços
for (const pattern of normalizedPatterns) {
const regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s.,?!]*$`, 'i');
if (regex.test(normalized)) {
return true;
}
}
return false;
}
/**
* Validação rápida (apenas comprimento)
* Útil para validação em tempo real no input
*
* @param text - Texto a ser validado
* @param minLength - Comprimento mínimo
* @param maxLength - Comprimento máximo
* @returns true se válido
*/
export function quickValidate(
text: string,
minLength: number = DEFAULT_OPTIONS.minLength,
maxLength: number = DEFAULT_OPTIONS.maxLength
): boolean {
if (!text || typeof text !== 'string') {
return false;
}
const normalized = normalizeText(text);
return (
normalized.length >= minLength && normalized.length <= maxLength
);
}
+217
View File
@@ -0,0 +1,217 @@
import { normalizeText } from './text-normalization';
export interface SplitResult {
questions: string[];
originalText: string;
splitCount: number;
}
// Palavras interrogativas em português e inglês
const INTERROGATIVE_WORDS = [
'o que',
'que',
'qual',
'quais',
'quem',
'onde',
'quando',
'como',
'por que',
'porque',
'por quê',
'porquê',
'quantos',
'quantas',
'what',
'which',
'who',
'where',
'when',
'how',
'why',
'how many',
'how much',
];
// Conectores que podem separar perguntas
const QUESTION_SEPARATORS = [
/\s+e\s+/gi, // " e "
/\s+ou\s+/gi, // " ou "
/[.!?]\s+/g, // ". ", "! ", "? "
];
/**
* Verifica se um texto é uma pergunta
*
* @param text - Texto a ser verificado
* @returns true se for uma pergunta
*/
export function isQuestion(text: string): boolean {
if (!text || typeof text !== 'string') {
return false;
}
const trimmed = text.trim();
if (trimmed.length === 0) {
return false;
}
// Verifica se termina com ponto de interrogação
if (trimmed.endsWith('?')) {
return true;
}
// Verifica se começa com palavra interrogativa
const normalized = normalizeText(trimmed);
for (const word of INTERROGATIVE_WORDS) {
const normalizedWord = normalizeText(word);
if (normalized.startsWith(normalizedWord + ' ')) {
return true;
}
}
// Verifica padrões de pergunta
const questionPatterns = [
/^(o que|que|qual|quais|quem|onde|quando|como|por que|porque|quantos|quantas)/i,
/^(what|which|who|where|when|how|why|how many|how much)/i,
/\?$/,
];
for (const pattern of questionPatterns) {
if (pattern.test(trimmed)) {
return true;
}
}
return false;
}
/**
* Calcula similaridade básica entre duas strings (Jaccard similarity simplificada)
*
* @param str1 - Primeira string
* @param str2 - Segunda string
* @returns Similaridade entre 0 e 1
*/
function calculateSimilarity(str1: string, str2: string): number {
const words1 = new Set(normalizeText(str1).split(/\s+/).filter((w) => w.length > 2));
const words2 = new Set(normalizeText(str2).split(/\s+/).filter((w) => w.length > 2));
if (words1.size === 0 && words2.size === 0) {
return 1;
}
if (words1.size === 0 || words2.size === 0) {
return 0;
}
const intersection = new Set([...words1].filter((w) => words2.has(w)));
const union = new Set([...words1, ...words2]);
return intersection.size / union.size;
}
/**
* Remove perguntas duplicadas ou muito similares
*
* @param questions - Array de perguntas
* @param similarityThreshold - Limiar de similaridade (0-1)
* @returns Array de perguntas únicas
*/
function removeDuplicates(
questions: string[],
similarityThreshold: number = 0.7
): string[] {
const unique: string[] = [];
for (const question of questions) {
let isDuplicate = false;
for (const existing of unique) {
const similarity = calculateSimilarity(question, existing);
if (similarity >= similarityThreshold) {
isDuplicate = true;
// Manter a versão mais longa (geralmente mais completa)
if (question.length > existing.length) {
const index = unique.indexOf(existing);
unique[index] = question;
}
break;
}
}
if (!isDuplicate) {
unique.push(question);
}
}
return unique;
}
/**
* Divide um texto em múltiplas perguntas
*
* @param text - Texto a ser dividido
* @returns Resultado com array de perguntas
*/
export function splitQuestions(text: string): SplitResult {
if (!text || typeof text !== 'string') {
return {
questions: [],
originalText: text || '',
splitCount: 0,
};
}
const originalText = text.trim();
if (originalText.length === 0) {
return {
questions: [],
originalText: '',
splitCount: 0,
};
}
// Tentar dividir por separadores de sentenças primeiro
let parts: string[] = [originalText];
// Dividir por pontuação de fim de sentença
const sentenceSplit = originalText.split(/[.!?]\s+/);
if (sentenceSplit.length > 1) {
parts = sentenceSplit.map((p) => p.trim()).filter((p) => p.length > 0);
} else {
// Tentar dividir por conectores
for (const separator of QUESTION_SEPARATORS) {
const split = originalText.split(separator);
if (split.length > 1) {
parts = split.map((p) => p.trim()).filter((p) => p.length > 0);
break;
}
}
}
// Filtrar apenas partes que são perguntas
const questions = parts.filter((part) => {
// Adicionar "?" se não tiver mas for pergunta
const withQuestionMark = part.endsWith('?') ? part : part + '?';
return isQuestion(withQuestionMark);
});
// Se nenhuma parte foi identificada como pergunta, tratar o texto inteiro como uma pergunta
if (questions.length === 0) {
return {
questions: [originalText],
originalText,
splitCount: 1,
};
}
// Remover duplicatas
const uniqueQuestions = removeDuplicates(questions);
return {
questions: uniqueQuestions,
originalText,
splitCount: uniqueQuestions.length,
};
}
+8
View File
@@ -1,7 +1,15 @@
export interface GpuInfo {
id: string;
name: string;
vendor: string | null;
memory_mb: number | null;
}
export interface SystemSpecs {
total_memory: number; // Bytes
cpu_count: number;
os_name: string;
gpus: GpuInfo[];
}
export interface ModelRecommendation {
+193
View File
@@ -0,0 +1,193 @@
import type { Chunk } from './content-condenser';
import { normalizeText } from './text-normalization';
/**
* Calcula similaridade simples entre query e chunk baseado em palavras-chave
* Implementação simplificada de TF-IDF
*
* @param query - Query do usuário
* @param chunk - Chunk de conteúdo
* @returns Score de similaridade (0-1)
*/
export function calculateSimilarity(query: string, chunk: string): number {
if (!query || !chunk) {
return 0;
}
// Normalizar textos
const normalizedQuery = normalizeText(query);
const normalizedChunk = normalizeText(chunk);
// Extrair palavras-chave da query (remover stop words 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',
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
'of', 'with', 'by', 'from', 'as', 'is', 'are', 'was', 'were',
]);
const queryWords = normalizedQuery
.split(/\s+/)
.filter(word => word.length > 2 && !stopWords.has(word));
if (queryWords.length === 0) {
return 0.1; // Score mínimo se não houver palavras-chave
}
// Contar ocorrências de cada palavra-chave no chunk
let totalMatches = 0;
let uniqueMatches = 0;
const matchedWords = new Set<string>();
for (const word of queryWords) {
// Buscar palavra completa (word boundary)
const wordRegex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
const matches = normalizedChunk.match(wordRegex);
if (matches) {
totalMatches += matches.length;
if (!matchedWords.has(word)) {
matchedWords.add(word);
uniqueMatches++;
}
}
}
// Calcular score baseado em:
// 1. Proporção de palavras-chave encontradas (peso: 0.6)
// 2. Frequência de matches (peso: 0.3)
// 3. Normalização por tamanho do chunk (peso: 0.1)
const wordCoverage = uniqueMatches / queryWords.length;
const frequencyScore = Math.min(totalMatches / queryWords.length, 2) / 2; // Cap em 2x
const sizeNormalization = Math.min(1, 1000 / normalizedChunk.length); // Penalizar chunks muito longos
const score = (
wordCoverage * 0.6 +
frequencyScore * 0.3 +
sizeNormalization * 0.1
);
// Garantir que score esteja entre 0 e 1
return Math.min(Math.max(score, 0), 1);
}
/**
* Calcula scores de relevância para todos os chunks
*
* @param chunks - Array de chunks
* @param query - Query do usuário
* @returns Chunks com scores atribuídos, ordenados por relevância
*/
export function scoreChunks(chunks: Chunk[], query: string): Chunk[] {
if (!query || chunks.length === 0) {
return chunks;
}
// Calcular score para cada chunk
const scoredChunks = chunks.map(chunk => ({
...chunk,
score: calculateSimilarity(query, chunk.content),
}));
// Ordenar por score (maior primeiro)
return scoredChunks.sort((a, b) => b.score - a.score);
}
/**
* Seleciona os chunks mais relevantes que cabem no limite de tokens
*
* @param chunks - Chunks com scores
* @param maxTokens - Limite máximo de tokens
* @param query - Query do usuário (para contexto)
* @returns Chunks selecionados
*/
export function selectTopChunks(
chunks: Chunk[],
maxTokens: number,
query: string = ''
): Chunk[] {
if (chunks.length === 0) {
return [];
}
// Estimar tokens (aproximação: 1 token ≈ 4 caracteres)
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
// Garantir que pelo menos um chunk seja selecionado
const selected: Chunk[] = [];
let totalTokens = 0;
const maxChars = maxTokens * 4; // Converter tokens para chars
// Selecionar chunks por ordem de relevância até atingir limite
for (const chunk of chunks) {
const chunkTokens = estimateTokens(chunk.content);
if (totalTokens + chunkTokens <= maxTokens) {
selected.push(chunk);
totalTokens += chunkTokens;
} else {
// Se ainda não selecionou nenhum, forçar seleção do primeiro (mesmo que exceda)
if (selected.length === 0) {
selected.push(chunk);
break;
}
// Verificar se podemos adicionar parte do chunk
const remainingChars = maxChars - (totalTokens * 4);
if (remainingChars > 200) { // Mínimo de 200 chars para valer a pena
// Criar chunk truncado
const truncatedChunk: Chunk = {
...chunk,
content: chunk.content.substring(0, remainingChars) + '...',
};
selected.push(truncatedChunk);
}
break;
}
}
return selected;
}
/**
* Filtra chunks por score mínimo
*
* @param chunks - Chunks com scores
* @param minScore - Score mínimo (0-1)
* @returns Chunks que atendem ao score mínimo
*/
export function filterByMinScore(chunks: Chunk[], minScore: number = 0.1): Chunk[] {
return chunks.filter(chunk => chunk.score >= minScore);
}
/**
* Combina scoring e seleção em uma única função
*
* @param chunks - Chunks originais
* @param query - Query do usuário
* @param maxTokens - Limite máximo de tokens
* @param minScore - Score mínimo (opcional)
* @returns Chunks selecionados e ordenados por relevância
*/
export function scoreAndSelectChunks(
chunks: Chunk[],
query: string,
maxTokens: number,
minScore?: number
): Chunk[] {
// Calcular scores
let scoredChunks = scoreChunks(chunks, query);
// Filtrar por score mínimo se especificado
if (minScore !== undefined) {
scoredChunks = filterByMinScore(scoredChunks, minScore);
}
// Selecionar top chunks
return selectTopChunks(scoredChunks, maxTokens, query);
}
+38
View File
@@ -0,0 +1,38 @@
import { invoke } from '@tauri-apps/api/core';
/**
* Envia logs do frontend para o terminal via Tauri
* Use esta função em vez de console.log para ver logs no terminal
*/
export async function logToTerminal(level: 'info' | 'warn' | 'error' | 'debug', message: string) {
try {
await invoke('log_to_terminal', { level, message });
} catch (error) {
// Fallback para console se o comando falhar
console[level](message);
}
}
/**
* Helper para logs com prefixo [Chat]
*/
export const chatLog = {
info: (message: string) => logToTerminal('info', `[Chat] ${message}`),
warn: (message: string) => logToTerminal('warn', `[Chat] ${message}`),
error: (message: string) => logToTerminal('error', `[Chat] ${message}`),
debug: (message: string) => logToTerminal('debug', `[Chat] ${message}`),
};
/**
* Helper para logs com prefixo [DeepResearch]
*/
export const deepResearchLog = {
info: (message: string) => logToTerminal('info', `[DeepResearch] ${message}`),
warn: (message: string) => logToTerminal('warn', `[DeepResearch] ${message}`),
error: (message: string) => logToTerminal('error', `[DeepResearch] ${message}`),
debug: (message: string) => logToTerminal('debug', `[DeepResearch] ${message}`),
};
+81
View File
@@ -0,0 +1,81 @@
import removeAccents from 'remove-accents';
/**
* Normaliza texto removendo acentos, padronizando caixa e limpando caracteres especiais
*
* @param text - Texto a ser normalizado
* @returns Texto normalizado
*/
export function normalizeText(text: string): string {
if (!text || typeof text !== 'string') {
return '';
}
// Remove acentos
let normalized = removeAccents(text);
// Padroniza para lowercase
normalized = normalized.toLowerCase();
// Remove caracteres especiais não alfanuméricos (exceto espaços e pontuação básica)
// Mantém: letras, números, espaços, ponto, vírgula, interrogação, exclamação, dois pontos, ponto e vírgula
normalized = normalized.replace(/[^\w\s.,?!:;]/g, '');
// Normaliza espaços em branco múltiplos para um único espaço
normalized = normalized.replace(/\s+/g, ' ');
return normalized.trim();
}
/**
* Normaliza texto de forma mais agressiva para busca
* Remove pontuação e normaliza completamente
*
* @param text - Texto a ser normalizado para busca
* @returns Texto normalizado para busca
*/
export function normalizeForSearch(text: string): string {
if (!text || typeof text !== 'string') {
return '';
}
// Remove acentos
let normalized = removeAccents(text);
// Padroniza para lowercase
normalized = normalized.toLowerCase();
// Remove toda pontuação e caracteres especiais
normalized = normalized.replace(/[^\w\s]/g, '');
// Normaliza espaços em branco múltiplos para um único espaço
normalized = normalized.replace(/\s+/g, ' ');
return normalized.trim();
}
/**
* Preserva nomes próprios quando possível (versão menos agressiva)
* Mantém capitalização inicial de palavras que podem ser nomes próprios
*
* @param text - Texto a ser normalizado
* @returns Texto normalizado preservando possível capitalização de nomes
*/
export function normalizeTextPreserveCase(text: string): string {
if (!text || typeof text !== 'string') {
return '';
}
// Remove acentos
let normalized = removeAccents(text);
// Remove caracteres especiais não alfanuméricos (exceto espaços e pontuação básica)
normalized = normalized.replace(/[^\w\s.,?!:;]/g, '');
// Normaliza espaços em branco múltiplos para um único espaço
normalized = normalized.replace(/\s+/g, ' ');
return normalized.trim();
}
+298
View File
@@ -0,0 +1,298 @@
/**
* Sistema de Fallback Progressivo para Pesquisa Web
*
* Implementa múltiplas tentativas de busca com query expansion,
* validação de relevância e fallback para conhecimento interno.
*/
import type { ScrapedContent } from '@/services/webSearch';
import { chatLog } from '@/lib/terminal-logger';
export interface FallbackConfig {
maxRounds: number; // Máximo de rodadas de busca (padrão: 4)
maxResultsPerRound: number; // Máximo de resultados por rodada (padrão: 10)
maxTotalResults: number; // Máximo total de resultados analisados (padrão: 40)
minRelevanceScore: number; // Score mínimo de relevância (0-1, padrão: 0.3)
enableQueryExpansion: boolean; // Ativar expansão de queries (padrão: true)
}
export interface FallbackAttempt {
round: number;
query: string;
results: ScrapedContent[];
relevanceScore: number;
timestamp: number;
}
export interface FallbackResult {
success: boolean;
knowledgeBaseContext: string;
scrapedSources: ScrapedContent[];
attempts: FallbackAttempt[];
totalResultsAnalyzed: number;
usedFallback: boolean; // true se usou conhecimento interno
finalQuery?: string;
}
const DEFAULT_CONFIG: FallbackConfig = {
maxRounds: 4,
maxResultsPerRound: 10,
maxTotalResults: 40,
minRelevanceScore: 0.3,
enableQueryExpansion: true,
};
/**
* Expande uma query usando técnicas de query expansion
*/
export async function expandQuery(
originalQuery: string,
model: string,
round: number
): Promise<string> {
if (round === 1) {
// Primeira rodada: usar query original
return originalQuery;
}
try {
const expansionPrompt = `Você é um especialista em busca de informações. Expanda e refine a query de busca abaixo para encontrar informações mais relevantes.
Query original: "${originalQuery}"
Instruções:
- Se a query for muito específica, generalize um pouco (ex: "bomba gás lacrimogêneo" → "gás lacrimogêneo composição química")
- Se a query for muito genérica, adicione termos específicos relacionados
- Use sinônimos e termos relacionados
- Mantenha o foco no tópico principal
- Retorne APENAS a query expandida, sem explicações
Query expandida:`;
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: expansionPrompt,
stream: false,
options: {
temperature: 0.5,
num_predict: 100,
},
}),
});
if (!response.ok) {
chatLog.warn('Failed to expand query, using original');
return originalQuery;
}
const data = await response.json();
const expanded = (data.response || '').trim()
.replace(/^["']|["']$/g, '')
.replace(/\.$/, '')
.trim();
if (expanded.length < 3) {
return originalQuery;
}
chatLog.info(`Query expanded: "${originalQuery}" → "${expanded}"`);
return expanded;
} catch (error) {
chatLog.warn('Error expanding query, using original:', error);
return originalQuery;
}
}
/**
* Calcula score de relevância de um resultado baseado na query
*/
export function calculateRelevanceScore(
result: ScrapedContent,
query: string
): number {
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
// Extrair texto para análise
const title = (result.title || '').toLowerCase();
const content = (result.markdown || '').toLowerCase();
const combinedText = `${title} ${content}`;
// Contar matches de palavras-chave
let matches = 0;
let totalWords = queryWords.length;
for (const word of queryWords) {
if (combinedText.includes(word)) {
matches++;
}
}
// Score base: proporção de palavras encontradas
let score = matches / totalWords;
// Bônus: título tem mais peso
const titleMatches = queryWords.filter(w => title.includes(w)).length;
if (titleMatches > 0) {
score += (titleMatches / totalWords) * 0.3; // 30% de bônus
}
// Bônus: conteúdo não vazio
if (content.length > 100) {
score += 0.1;
}
// Penalidade: conteúdo muito curto
if (content.length < 50) {
score *= 0.5;
}
// Normalizar para 0-1
return Math.min(1, Math.max(0, score));
}
/**
* Verifica se os resultados são relevantes o suficiente
*/
export function areResultsRelevant(
results: ScrapedContent[],
query: string,
minScore: number = 0.3
): boolean {
if (results.length === 0) {
return false;
}
// Calcular scores de relevância
const scores = results.map(r => calculateRelevanceScore(r, query));
// Verificar se pelo menos um resultado tem score suficiente
const maxScore = Math.max(...scores);
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
chatLog.info(`Relevance scores - Max: ${maxScore.toFixed(2)}, Avg: ${avgScore.toFixed(2)}, Min required: ${minScore}`);
// Considerar relevante se:
// 1. Pelo menos um resultado tem score alto, OU
// 2. Média dos scores é razoável
return maxScore >= minScore || avgScore >= (minScore * 0.7);
}
/**
* Executa busca progressiva com fallback
*/
export async function executeProgressiveSearch(
originalQuery: string,
searchFn: (query: string, limit: number) => Promise<ScrapedContent[]>,
model: string,
config: Partial<FallbackConfig> = {}
): Promise<FallbackResult> {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
const attempts: FallbackAttempt[] = [];
let totalResultsAnalyzed = 0;
let allResults: ScrapedContent[] = [];
const seenUrls = new Set<string>();
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
chatLog.info(`Original Query: "${originalQuery}"`);
chatLog.info(`Config: ${JSON.stringify(finalConfig)}`);
for (let round = 1; round <= finalConfig.maxRounds; round++) {
chatLog.info(`\n--- Round ${round}/${finalConfig.maxRounds} ---`);
// Expandir query se necessário
const query = finalConfig.enableQueryExpansion
? await expandQuery(originalQuery, model, round)
: originalQuery;
chatLog.info(`Searching with query: "${query}"`);
try {
// Executar busca
const results = await searchFn(query, finalConfig.maxResultsPerRound);
// Filtrar duplicatas
const uniqueResults = results.filter(r => {
if (seenUrls.has(r.url)) {
return false;
}
seenUrls.add(r.url);
return true;
});
chatLog.info(`Found ${uniqueResults.length} unique results (${results.length} total)`);
// Calcular relevância
const relevanceScore = uniqueResults.length > 0
? uniqueResults.reduce((sum, r) => sum + calculateRelevanceScore(r, query), 0) / uniqueResults.length
: 0;
// Registrar tentativa
const attempt: FallbackAttempt = {
round,
query,
results: uniqueResults,
relevanceScore,
timestamp: Date.now(),
};
attempts.push(attempt);
allResults.push(...uniqueResults);
totalResultsAnalyzed += uniqueResults.length;
// Verificar se encontrou resultados relevantes
if (areResultsRelevant(uniqueResults, query, finalConfig.minRelevanceScore)) {
chatLog.info(`✓ Relevant results found in round ${round}!`);
chatLog.info(`Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
return {
success: true,
knowledgeBaseContext: '', // Será preenchido pelo caller
scrapedSources: allResults,
attempts,
totalResultsAnalyzed,
usedFallback: false,
finalQuery: query,
};
}
// Verificar se atingiu limite de resultados
if (totalResultsAnalyzed >= finalConfig.maxTotalResults) {
chatLog.warn(`⚠️ Reached max total results limit (${finalConfig.maxTotalResults})`);
break;
}
// Se não encontrou resultados relevantes, continuar para próxima rodada
chatLog.warn(`⚠️ Round ${round} did not find relevant results, continuing...`);
} catch (error) {
chatLog.error(`✗ Error in round ${round}:`, error);
attempts.push({
round,
query,
results: [],
relevanceScore: 0,
timestamp: Date.now(),
});
}
}
// Se chegou aqui, não encontrou resultados relevantes
chatLog.warn(`\n⚠️ PROGRESSIVE SEARCH FAILED`);
chatLog.warn(`Total rounds: ${attempts.length}`);
chatLog.warn(`Total results analyzed: ${totalResultsAnalyzed}`);
chatLog.warn(`Using internal knowledge fallback`);
return {
success: false,
knowledgeBaseContext: '',
scrapedSources: allResults, // Retornar resultados encontrados mesmo que não relevantes
attempts,
totalResultsAnalyzed,
usedFallback: true,
};
}