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:
1 parent
4275295e4f
commit
84ea2dec19
51 files changed
+7857
-573
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user