/** * Deep Research — Search query generation & refinement * * Uses an LLM agent to generate search queries from different research * angles, then analyzes results to produce follow-up queries. */ import type { SearchQuery, Finding, ResearchRound, EnrichedSearchResult, } from "./types"; import { runAnalysisAgent } from "./agent"; /* ── System Prompts ──────────────────────────────────────────────── */ const DECOMPOSE_SYSTEM = `You are a research methodology expert. Given a broad research question, your job is to break it down into 4-7 focused sub-questions that, when answered, collectively provide a complete answer to the original question. Guidelines: - Each sub-question should tackle ONE specific facet of the research question - Cover different dimensions: what, how, why, who, comparison, evidence, implications - Sub-questions should be independently researchable via web search - Avoid overlap between sub-questions - Prioritize questions that will surface concrete evidence over speculative ones Output ONLY a JSON array of sub-question strings. Example: Input: "What are the benefits and risks of artificial intelligence in healthcare?" Output: ["What specific AI technologies are currently deployed in clinical healthcare settings?", "What peer-reviewed evidence exists for AI improving diagnostic accuracy?", "What are the documented risks and failure cases of AI in healthcare?", "How do regulatory frameworks (FDA, EMA) address AI-based medical devices?", "What do healthcare practitioners report as barriers to AI adoption?"] `; const GENERATE_QUERIES_SYSTEM = `You are a research methodology expert. Your role is to generate effective web search queries that will yield high-quality, diverse information about a research topic. Guidelines: - Create queries from DIFFERENT angles (technical, practical, comparative, critical, forward-looking, authoritative) - Each query should target a specific facet of the question - Queries should use keywords that search engines rank well (avoid overly long questions) - Cover contrasting viewpoints and alternative approaches - Include queries for finding authoritative sources (docs, papers, official sites) - Prioritize recent information where relevant Output ONLY a JSON array of objects with fields: - "query": the search query string - "rationale": why this query will help answer the research question - "angle": one of "technical" | "practical" | "comparative" | "critical" | "forward-looking" | "authoritative" | "historical" | "case-study" | "data-statistics" | "ethical" Example: [ {"query": "Rust async/await performance benchmarks 2024", "rationale": "Understanding current performance characteristics", "angle": "technical"}, {"query": "Rust vs Go concurrency patterns comparison", "rationale": "Comparative analysis helps contextualize trade-offs", "angle": "comparative"} ] `; const FOLLOWUP_SYSTEM = `You are a research analyst. Given the research question, sub-questions, and findings so far, your job is to identify what's still unknown and generate follow-up search queries to fill those gaps. Look for: - Claims made without sufficient evidence - Conflicting information that needs resolution - Angles that haven't been explored yet - Missing authoritative sources (papers, official docs, primary data) - Practical implications that need more detail - Recent developments that might have updated findings Guidelines: - Do NOT repeat or paraphrase queries already explored — aim for genuinely new angles - Prefer querying for authoritative/primary sources over more blog posts when evidence is weak - When findings conflict, craft a query designed to resolve the contradiction - Keep queries concise and keyword-rich Output ONLY a JSON array of objects with fields: - "query": the search query string - "rationale": what gap this query fills or what angle it explores - "angle": one of "technical" | "practical" | "comparative" | "critical" | "forward-looking" | "authoritative" | "historical" | "case-study" | "data-statistics" | "ethical" `; /* ── JSON parsing helpers ────────────────────────────────────────── */ /** * Robustly parse a JSON array from LLM output. * * LLMs frequently wrap JSON in ```json fences, prepend prose like * "Here are the queries:", or emit trailing punctuation. This strips * fences and extracts the first bracketed array before parsing. * * Returns null when no array can be extracted. */ function parseJsonArray(text: string): unknown[] | null { if (!text) return null; // Strip markdown code fences const withoutFences = text .replace(/```(?:json|javascript)?\s*/gi, "") .replace(/```/g, ""); // Find the first '[' ... ']' block (arrays are our target shape) const start = withoutFences.indexOf("["); const end = withoutFences.lastIndexOf("]"); if (start === -1 || end === -1 || end <= start) return null; const candidate = withoutFences.slice(start, end + 1); try { const parsed = JSON.parse(candidate); return Array.isArray(parsed) ? parsed : null; } catch { // Try to salvage: strip trailing commas (common LLM artifact) try { const fixed = candidate.replace(/,\s*([}\]])/g, "$1"); const parsed = JSON.parse(fixed); return Array.isArray(parsed) ? parsed : null; } catch { return null; } } } /** Map a parsed array entry to a SearchQuery, tolerating missing fields. */ function toSearchQuery(q: Record): SearchQuery | null { const query = String(q.query ?? "").trim(); if (!query) return null; return { query, rationale: String(q.rationale ?? "").trim(), angle: String(q.angle ?? "technical").trim() || "technical", }; } /* ── Sub-Question Decomposition ───────────────────────────────────── */ /** * Decompose a broad research question into focused, independently * researchable sub-questions. Returns the sub-questions or an empty * array if the LLM call fails. */ export async function decomposeQuestion( question: string, cwd: string, signal?: AbortSignal, ): Promise { const taskPrompt = `Break down this research question into 4-7 focused sub-questions:\n\n${question}`; const result = await runAnalysisAgent( DECOMPOSE_SYSTEM, taskPrompt, cwd, 60_000, undefined, signal, ); if (!result.success || !result.text) return []; const parsed = parseJsonArray(result.text); if (parsed) { const subQuestions = parsed .map(String) .map((s: string) => s.trim()) .filter((s: string) => s.length > 10); if (subQuestions.length > 0) return subQuestions; } return []; } /* ── Query Generation ────────────────────────────────────────────── */ /** * Generate initial search queries for a research question. * When sub-questions are available, generates queries per sub-question * for better depth and diversity. */ export async function generateQueries( question: string, count: number, cwd: string, signal?: AbortSignal, subQuestions?: string[], ): Promise { // If we have sub-questions, generate queries distributed across them if (subQuestions && subQuestions.length > 0) { const queriesPerSub = Math.max(1, Math.ceil(count / subQuestions.length)); const allQueries: SearchQuery[] = []; for (const subQ of subQuestions) { if (allQueries.length >= count) break; const taskPrompt = `Research question: ${question}\nSub-question: ${subQ}\n\nGenerate ${queriesPerSub} search query(ies) to answer this sub-question specifically.`; const result = await runAnalysisAgent( GENERATE_QUERIES_SYSTEM, taskPrompt, cwd, 60_000, undefined, signal, ); if (!result.success || !result.text) continue; const parsed = parseJsonArray(result.text); if (parsed) { const queries = parsed .slice(0, queriesPerSub) .map((q) => toSearchQuery(q as Record)) .filter((q): q is SearchQuery => q !== null); allQueries.push(...queries); } } if (allQueries.length > 0) { return allQueries.slice(0, count); } } // Fall through to standard query generation const taskPrompt = `Research question: ${question} Generate ${count} diverse search queries to research this topic effectively. Cover different angles.`; const result = await runAnalysisAgent( GENERATE_QUERIES_SYSTEM, taskPrompt, cwd, 60_000, undefined, signal, ); if (!result.success || !result.text) { return generateFallbackQueries(question, count); } try { const parsed = parseJsonArray(result.text); if (parsed && parsed.length > 0) { return parsed .slice(0, count) .map((q) => toSearchQuery(q as Record)) .filter((q): q is SearchQuery => q !== null); } } catch { // JSON parse failed, fall back } return generateFallbackQueries(question, count); } /* ── Follow-up Query Generation ──────────────────────────────────── */ /** * Generate follow-up queries based on findings from previous rounds. */ export async function generateFollowUpQueries( question: string, rounds: ResearchRound[], count: number, cwd: string, signal?: AbortSignal, ): Promise { // Build a summary of findings so far const allFindings = rounds.flatMap((r) => r.findings); const findingsSummary = allFindings .map((f) => { const corr = f.corroborationScore !== undefined ? ` [corroboration: ${(f.corroborationScore * 100).toFixed(0)}%]` : ""; return `- ${f.title}: ${f.summary} (confidence: ${f.confidence}${corr})`; }) .join("\n"); const exploredAngles = rounds .flatMap((r) => r.queries) .map((q) => `[${q.angle}] ${q.query} — ${q.rationale}`) .join("\n"); // Find low-corroboration or low-confidence topics const gaps = allFindings .filter((f) => f.confidence === "low" || (f.corroborationScore ?? 1) < 0.5) .map((f) => `Gap: ${f.title} — ${f.summary}`) .join("\n"); const taskPrompt = `Research question: ${question} Queries already explored: ${exploredAngles} Findings so far: ${findingsSummary} ${gaps ? `Remaining knowledge gaps:\n${gaps}` : ""} Generate ${count} follow-up search queries to fill remaining gaps and deepen the research. Do not repeat or paraphrase the queries already explored.`; const result = await runAnalysisAgent( FOLLOWUP_SYSTEM, taskPrompt, cwd, 60_000, undefined, signal, ); if (!result.success || !result.text) { return []; } const exploredNormalized = new Set( rounds.flatMap((r) => r.queries).map((q) => normalizeQueryText(q.query)), ); const parsed = parseJsonArray(result.text); if (parsed && parsed.length > 0) { const fresh: SearchQuery[] = []; for (const q of parsed.slice(0, count)) { const sq = toSearchQuery(q as Record); if (!sq) continue; const normalized = normalizeQueryText(sq.query); // Skip queries that are near-duplicates of already-explored ones if (exploredNormalized.has(normalized)) continue; if (fresh.some((fq) => normalizeQueryText(fq.query) === normalized)) continue; exploredNormalized.add(normalized); fresh.push(sq); } return fresh; } return []; } /** * Lightweight query-text normalization for duplicate detection. */ function normalizeQueryText(query: string): string { return query .toLowerCase() .replace(/[^a-z0-9\s]/g, " ") .replace(/\s+/g, " ") .trim(); } /* ── Fallback Query Generation ────────────────────────────────────── */ /** * Fallback query generation when the LLM call fails. */ function generateFallbackQueries( question: string, count: number, ): SearchQuery[] { const queries: SearchQuery[] = []; const angles = [ { angle: "technical", desc: "technical details and specifications" }, { angle: "practical", desc: "practical examples, tutorials, and best practices", }, { angle: "comparative", desc: "comparisons with alternatives" }, { angle: "critical", desc: "limitations, challenges, and criticisms" }, { angle: "forward-looking", desc: "future trends and developments" }, ]; for (let i = 0; i < Math.min(count, angles.length); i++) { queries.push({ query: `${question} ${angles[i].desc}`, rationale: `Exploring ${angles[i].desc} related to the research question`, angle: angles[i].angle as SearchQuery["angle"], }); } return queries; } /* ── Analysis ────────────────────────────────────────────────────── */ const ANALYZE_SYSTEM = `You are a research analyst. Given search results for a specific query, extract key findings. For each finding: - Give it a concise, specific title (a claim, not a topic) - Summarize what was found in 1-3 sentences, focused on evidence - List which source URLs support this finding - Include 1-2 key quotes from the sources - Rate your confidence (high/medium/low) based on source authority and consistency Guidelines: - Extract 3-6 findings maximum, prioritizing the most decision-relevant - Prefer findings with concrete evidence over generic observations - Ignore boilerplate, navigation text, and irrelevant tangents in the content - Do NOT invent quotes — only use text that appears in the provided content - When sources conflict, note the conflict in the summary Output ONLY a JSON array of objects with fields: - "title": concise finding title - "summary": 1-3 sentence summary - "sources": array of source URLs - "keyQuotes": array of 1-2 key quotes - "confidence": "high" | "medium" | "low"`; /** * Analyze search results for a specific query and extract findings. */ export async function analyzeResults( query: string, results: EnrichedSearchResult[], cwd: string, signal?: AbortSignal, angle?: string, ): Promise { // Include authority metadata in the prompt so the LLM can consider source quality. // Token budget: give high-authority sources generous space, truncate // low-authority/SEO content aggressively so junk doesn't dominate the prompt. const MAX_CHARS_HIGH_AUTH = 3500; const MAX_CHARS_LOW_AUTH = 1200; const resultsText = results .map((r, i) => { const maxChars = r.authorityScore >= 0.6 ? MAX_CHARS_HIGH_AUTH : MAX_CHARS_LOW_AUTH; const content = r.markdown.slice(0, maxChars).trim(); const body = content.length > 0 ? content : `(no body content; description only)\n${r.description}`; return `--- Result ${i + 1} ---\nTitle: ${r.title}\nURL: ${r.url}\nDomain: ${r.domain}\nAuthority Score: ${(r.authorityScore * 100).toFixed(0)}%\nContent Type: ${r.contentType}\nDescription: ${r.description}\nContent:\n${body}`; }) .join("\n\n"); const taskPrompt = `Search query: "${query}"${angle ? ` (angle: ${angle})` : ""} Search results: ${resultsText} Extract key findings from these results. Consider source authority when rating confidence.`; const result = await runAnalysisAgent( ANALYZE_SYSTEM, taskPrompt, cwd, 90_000, undefined, signal, ); if (!result.success || !result.text) return []; const parsed = parseJsonArray(result.text); if (parsed) { return parsed .map((f) => { const entry = f as Record; return { title: String(entry.title ?? "").trim(), summary: String(entry.summary ?? "").trim(), sources: Array.isArray(entry.sources) ? entry.sources.map(String) : [], keyQuotes: Array.isArray(entry.keyQuotes) ? entry.keyQuotes.map(String) : [], confidence: (["high", "medium", "low"].includes( String(entry.confidence), ) ? String(entry.confidence) : "medium") as Finding["confidence"], // Provenance: which query and angle produced this finding query, angle, }; }) .filter((f) => f.title && f.summary); } return []; } /* ── Corroboration Tracking ──────────────────────────────────────── */ /** * Cross-reference all findings to compute corroboration scores. * * For each finding, we check: * 1. How many other findings reference the same or similar source URLs * 2. The authority scores of the supporting sources * 3. Whether independent domains support the same claim * * Returns the findings with added corroborationScore, bestSourceAuthority, * and avgSourceAuthority. */ export function computeCorroboration( findings: Finding[], urlQueryCounts?: Map, ): Finding[] { if (findings.length === 0) return []; // Collect all unique source URLs and their authority scores // In a real implementation, we'd map URLs to EnrichedSearchResult authority scores // For now, extract domain-level patterns // Build a map of domain -> authority scores from source URLs const domainAuthority = new Map(); for (const finding of findings) { for (const url of finding.sources) { try { const domain = extractDomainSimple(url); if (!domainAuthority.has(domain)) { domainAuthority.set(domain, heuristicDomainScore(domain)); } } catch { // skip invalid URLs } } } return findings.map((finding) => { if (finding.sources.length === 0) { return { ...finding, corroborationScore: 0, bestSourceAuthority: 0, avgSourceAuthority: 0, }; } // Compute source authority stats const authorities: number[] = finding.sources.map((url) => { try { const domain = extractDomainSimple(url); return domainAuthority.get(domain) ?? 0.3; } catch { return 0.3; } }); const bestAuthority = Math.max(...authorities); const avgAuthority = authorities.reduce((a, b) => a + b, 0) / authorities.length; // Compute corroboration. // // PRIMARY signal (when urlQueryCounts is provided): what fraction of // this finding's sources were independently surfaced by multiple // DIFFERENT search queries? A source found by several independent // searches is genuinely corroborated; same-query duplicates do not // count (findings from one query analyzed the same result set). // // FALLBACK signal (no map): domain-level agreement across findings // from different queries. let corroborationScore: number; if (urlQueryCounts && urlQueryCounts.size > 0) { const multiQuerySources = finding.sources.filter( (url) => (urlQueryCounts.get(url) ?? 1) > 1, ).length; corroborationScore = finding.sources.length > 0 ? multiQuerySources / finding.sources.length : 0; } else { // Fallback: cross-query agreement by shared domain const myDomains = new Set( finding.sources.map((u) => extractDomainSimple(u)), ); let corroboratingFindings = 0; let independentOthers = 0; for (const other of findings) { if (other === finding) continue; // Same query provenance = same analyzed result set = not independent if (other.query && finding.query && other.query === finding.query) { continue; } independentOthers++; const otherDomains = new Set( other.sources.map((u) => extractDomainSimple(u)), ); const shared = [...myDomains].some((d) => otherDomains.has(d)); if (shared) corroboratingFindings++; } corroborationScore = independentOthers > 0 ? Math.min(1, corroboratingFindings / independentOthers) : 0; } return { ...finding, corroborationScore: Math.round(corroborationScore * 100) / 100, bestSourceAuthority: Math.round(bestAuthority * 100) / 100, avgSourceAuthority: Math.round(avgAuthority * 100) / 100, }; }); } /** * Simple domain extraction (avoids URL constructor for compatibility). */ function extractDomainSimple(url: string): string { const match = url.match(/https?:\/\/([^/]+)/); if (!match) return url; const hostname = match[1].toLowerCase(); const parts = hostname.split("."); const multiPartTlds = /\.(co\.uk|org\.uk|ac\.uk|gov\.uk|com\.au|co\.jp|co\.kr|com\.br)$/; if (multiPartTlds.test(hostname) && parts.length >= 3) { return parts.slice(-3).join("."); } return parts.slice(-2).join("."); } /** * Very basic domain score heuristic without the full domain list. */ function heuristicDomainScore(domain: string): number { if (/\.gov$|\.edu$/.test(domain)) return 0.85; if (/arxiv|scholar|pubmed|ieee|acm|springer|nature|science/.test(domain)) return 0.9; if (/github|gitlab|bitbucket/.test(domain)) return 0.75; if (/wikipedia|stackoverflow|medium|dev\.to/.test(domain)) return 0.55; if (/docs\.|learn\.|developer\./.test(domain)) return 0.8; if (/reuters|apnews|bbc|nytimes|bloomberg/.test(domain)) return 0.75; if (/blog|forum|reddit/.test(domain)) return 0.3; return 0.4; }