Spaces:
Sleeping
Sleeping
| // deepResearchTool.ts — ARL (Adaptive Research Loop) | |
| // | |
| // Tool autocontenuto che incapsula un loop di ricerca multi-round. | |
| // Chiamato come qualsiasi altro tool dal loop principale ReAct: | |
| // executeToolGated("deep_research", { goal: "..." }) | |
| // | |
| // Design: | |
| // - Zero LLM aggiuntivo: refineQuery() è deterministico (token-gap detection) | |
| // - Budget fisso: max 3 round × max 4 URL = ≤12 fetch totali (~15s su iPhone free tier) | |
| // - Depth-2: esplora relevant_links della pagina se coverage < minCoverage | |
| // - Hard timeout 22s: non blocca mai il loop ReAct padre | |
| // - Nessuna modifica ad AgentDispatcher, GraphOrchestrator, agent types | |
| import { toolWebSearch, toolReadPage } from "./webSearchCore"; | |
| const ARL_BUDGET = { | |
| maxRounds: 3, // max iterazioni di ricerca | |
| maxUrls: 4, // URL da fetchare per round | |
| maxPages: 9, // max pagine totali fetched (iPhone free tier: ~15s totali) | |
| maxDepth: 2, // profondità link-following | |
| minCoverage: 0.70, // token goal coverage per uscita anticipata (doc spec: 0.7) | |
| timeoutMs: 22_000, | |
| } as const; | |
| const _STOP_WORDS = new Set([ | |
| "the","and","for","with","that","this","from","into","have","are","was","were", | |
| "che","del","della","per","con","una","uno","gli","dei","nel","nelle","alla", | |
| "sulle","degli","sono","essere","come","quando","dove","anche","però","quindi", | |
| ]); | |
| function _tokenize(txt: string): Set<string> { | |
| return new Set( | |
| txt.toLowerCase().split(/\W+/).filter(t => t.length > 3 && !_STOP_WORDS.has(t)) | |
| ); | |
| } | |
| /** Calcola la frazione di token-goal presenti nel corpus. */ | |
| function _goalCoverage(goal: string, corpus: string): number { | |
| const goalToks = _tokenize(goal); | |
| if (goalToks.size === 0) return 1; | |
| const foundToks = _tokenize(corpus); | |
| const covered = [...goalToks].filter(t => foundToks.has(t)).length; | |
| return covered / goalToks.size; | |
| } | |
| /** | |
| * Genera una query di refinement basata sui token-goal mancanti nel corpus. | |
| * Zero LLM — solo analisi lessicale. Ritorna null se il goal è già coperto. | |
| */ | |
| // BUG-FIX: dedup suffix tokens in refineQuery output | |
| function _refineQuery(goal: string, corpus: string): string | null { | |
| const goalToks = [..._tokenize(goal)]; | |
| const foundToks = _tokenize(corpus); | |
| const missing = goalToks.filter(t => !foundToks.has(t)); | |
| if (missing.length === 0) return null; | |
| // Escludi dal suffix i token già nei missing (evita duplicati es. "finanziamento finanziamento") | |
| const missingSet = new Set(missing.map(t => t.toLowerCase())); | |
| const suffix = goal.trim().split(/\s+/).slice(-2) | |
| .filter(w => !missingSet.has(w.toLowerCase().replace(/\W/g, ""))) | |
| .join(" "); | |
| const raw = `${missing.slice(0, 3).join(" ")} ${suffix}`.trim(); | |
| // Dedup finale mantenendo l'ordine | |
| const _seen = new Set<string>(); | |
| return raw.split(/\s+/).filter(w => { | |
| const k = w.toLowerCase(); | |
| return _seen.has(k) ? false : (_seen.add(k), true); | |
| }).join(" "); | |
| } | |
| /** | |
| * Esegui il loop ARL per raccogliere informazioni sul goal. | |
| * | |
| * @param goal — obiettivo di ricerca in linguaggio naturale | |
| * @returns stringa con corpus strutturato (pronto per sintesi del loop ReAct padre) | |
| */ | |
| export async function executeDeepResearch(goal: string): Promise<string> { | |
| const startMs = Date.now(); | |
| const visited = new Set<string>(); | |
| let corpus = ""; | |
| let round = 0; | |
| let query = goal; | |
| let totalFetched = 0; // budget totale pagine | |
| while (round < ARL_BUDGET.maxRounds && Date.now() - startMs < ARL_BUDGET.timeoutMs) { | |
| // ── Round ${round}: search ──────────────────────────────────────────────── | |
| const searchResult = await toolWebSearch(query); | |
| const roundUrls = (searchResult.results ?? []) | |
| .map(r => r.url) | |
| .filter(u => !visited.has(u)) | |
| .slice(0, Math.min(ARL_BUDGET.maxUrls, ARL_BUDGET.maxPages - totalFetched)); | |
| if (roundUrls.length === 0) break; | |
| // ── Fetch parallelo ─────────────────────────────────────────────────────── | |
| const pages = await Promise.all( | |
| roundUrls.map(async url => { | |
| visited.add(url); | |
| try { | |
| const r = await toolReadPage(url); | |
| return { url, text: r.output ?? "", links: r.relevant_links ?? [] }; | |
| } catch { | |
| return { url, text: "", links: [] }; | |
| } | |
| }) | |
| ); | |
| totalFetched += pages.length; | |
| // ── Aggiungi al corpus ──────────────────────────────────────────────────── | |
| for (const p of pages) { | |
| if (p.text && p.text.length > 50) { | |
| corpus += `\n\n--- [${p.url}] ---\n${p.text.slice(0, 1500)}`; | |
| } | |
| } | |
| // ── Coverage check + page budget ───────────────────────────────────────── | |
| const coverage = _goalCoverage(goal, corpus); | |
| if (coverage >= ARL_BUDGET.minCoverage) break; // obiettivo raggiunto | |
| if (totalFetched >= ARL_BUDGET.maxPages) break; // page budget esaurito | |
| // ── Link following (depth ≤ maxDepth) ───────────────────────────────────── | |
| if (round < ARL_BUDGET.maxDepth) { | |
| const linkUrls = pages | |
| .flatMap(p => p.links.map(l => l.url)) | |
| .filter(u => u.startsWith("http") && !visited.has(u)) | |
| .slice(0, 2); | |
| for (const lu of linkUrls) { | |
| if (Date.now() - startMs > ARL_BUDGET.timeoutMs * 0.80) break; | |
| visited.add(lu); | |
| try { | |
| const lr = await toolReadPage(lu); | |
| if (lr.output && lr.output.length > 50) { | |
| corpus += `\n\n--- [${lu}] (link) ---\n${lr.output.slice(0, 1000)}`; | |
| } | |
| } catch { /* link irraggiungibile — ignora */ } | |
| } | |
| } | |
| // ── Query refinement per prossimo round ─────────────────────────────────── | |
| const refined = _refineQuery(goal, corpus); | |
| if (!refined || refined === query) break; // nessun miglioramento possibile | |
| query = refined; | |
| round++; | |
| } | |
| if (!corpus.trim()) { | |
| return `⚠️ deep_research: nessun contenuto raccolto per "${goal}".`; | |
| } | |
| const coverage = _goalCoverage(goal, corpus); | |
| const elapsed = Math.round((Date.now() - startMs) / 1000); | |
| const sourceCount = visited.size; | |
| // OPT: aumentato da 8000→14000 — permette report più completi su argomenti complessi | |
| // senza superare il context window dell'LLM (14KB ≈ 3500 token, gestibili da tutti i modelli) | |
| const CORPUS_LIMIT = 14_000; | |
| return ( | |
| `📚 **Deep Research: "${goal}"**\n` + | |
| `_${sourceCount} fonti · ${round + 1} round · copertura ${Math.round(coverage * 100)}% · ${elapsed}s_\n\n` + | |
| corpus.trim().slice(0, CORPUS_LIMIT) | |
| ); | |
| } | |