initial import: @mikefreno/omp-deepi-research (omp port)
This commit is contained in:
150
src/agent.ts
Normal file
150
src/agent.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Deep Research — Agent Session helper
|
||||
*
|
||||
* Uses omp's in-process `createAgentSession` for LLM subtasks
|
||||
* (query generation, result analysis, report synthesis).
|
||||
* Pattern borrowed from ralpi's runAgentSession().
|
||||
*/
|
||||
import {
|
||||
createAgentSession,
|
||||
AgentRegistry,
|
||||
SessionManager,
|
||||
} from "@oh-my-pi/pi-coding-agent";
|
||||
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
|
||||
|
||||
/** Aggregate tool usage stats */
|
||||
export interface ToolUsage {
|
||||
read: number;
|
||||
write: number;
|
||||
edit: number;
|
||||
bash: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
text: string;
|
||||
error?: string;
|
||||
toolUsage: ToolUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a prompt through an in-process omp agent session.
|
||||
* Non-blocking — the event loop stays responsive.
|
||||
*/
|
||||
export async function runAnalysisAgent(
|
||||
systemPrompt: string,
|
||||
taskPrompt: string,
|
||||
cwd: string,
|
||||
timeoutMs: number = 120_000,
|
||||
onEvent?: (event: AgentSessionEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentResult> {
|
||||
const toolUsage: ToolUsage = {
|
||||
read: 0,
|
||||
write: 0,
|
||||
edit: 0,
|
||||
bash: 0,
|
||||
other: 0,
|
||||
};
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
if (timeoutMs > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
sessionRef.session?.agent.abort();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const sessionRef: {
|
||||
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||
} = {};
|
||||
|
||||
try {
|
||||
const result = await createAgentSession({
|
||||
cwd,
|
||||
sessionManager: SessionManager.inMemory(cwd),
|
||||
toolNames: ["read", "grep", "glob"],
|
||||
restrictToolNames: true,
|
||||
disableExtensionDiscovery: true,
|
||||
skills: [],
|
||||
promptTemplates: [],
|
||||
rules: [],
|
||||
contextFiles: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
agentRegistry: new AgentRegistry(),
|
||||
});
|
||||
sessionRef.session = result.session;
|
||||
|
||||
const abortHandler = () => result.session.agent.abort();
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
let finalText = "";
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
const unsubscribe = result.session.subscribe((event: AgentSessionEvent) => {
|
||||
onEvent?.(event);
|
||||
|
||||
if (event.type === "message_end") {
|
||||
const message = event.message as {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
errorMessage?: string;
|
||||
};
|
||||
if (message.role !== "assistant") return;
|
||||
if (message.errorMessage) errorMessage = message.errorMessage;
|
||||
const text = extractAssistantText(message.content);
|
||||
if (text) finalText = text;
|
||||
}
|
||||
|
||||
if (event.type === "tool_execution_start") {
|
||||
const name = event.toolName;
|
||||
if (name in toolUsage) {
|
||||
(toolUsage as unknown as Record<string, number>)[name]++;
|
||||
} else {
|
||||
toolUsage.other++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (signal?.aborted) throw new Error("Aborted");
|
||||
|
||||
await result.session.prompt(`${systemPrompt}\n\n${taskPrompt}`);
|
||||
await result.session.agent.waitForIdle();
|
||||
|
||||
unsubscribe();
|
||||
result.session.dispose();
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
|
||||
if (errorMessage && !finalText) {
|
||||
return { success: false, text: "", error: errorMessage, toolUsage };
|
||||
}
|
||||
|
||||
return { success: true, text: finalText.trim(), toolUsage };
|
||||
} catch (error) {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
return {
|
||||
success: false,
|
||||
text: "",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
toolUsage,
|
||||
};
|
||||
} finally {
|
||||
sessionRef.session?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function extractAssistantText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter(
|
||||
(c): c is { type: string; text?: string } =>
|
||||
!!c &&
|
||||
typeof c === "object" &&
|
||||
(c as { type?: string }).type === "text",
|
||||
)
|
||||
.map((c) => (c as { text?: string }).text ?? "")
|
||||
.join("");
|
||||
}
|
||||
525
src/firecrawl.ts
Normal file
525
src/firecrawl.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* Deep Research — direct Firecrawl HTTP client
|
||||
*
|
||||
* Calls the self-hosted Firecrawl API directly (same approach as the
|
||||
* firecrawl.ts extension)
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import type { SearchResult, EnrichedSearchResult, ContentType } from "./types";
|
||||
import { getAgentDir } from "@oh-my-pi/pi-coding-agent";
|
||||
|
||||
/* ── Config ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Read and merge Firecrawl settings from omp's config.yml files.
|
||||
*
|
||||
* Resolution order (later wins):
|
||||
* 1. env vars FIRECRAWL_BASE_URL / FIRECRAWL_API_KEY
|
||||
* 2. global ~/.omp/agent/config.yml → firecrawl.*
|
||||
* 3. project .omp/config.yml → firecrawl.*
|
||||
* 4. default http://localhost:3002 (if no baseUrl configured)
|
||||
*/
|
||||
function loadFirecrawlConfig() {
|
||||
// Start with env var defaults
|
||||
let baseUrl = process.env.FIRECRAWL_BASE_URL ?? "http://localhost:3002";
|
||||
let apiKey = process.env.FIRECRAWL_API_KEY;
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
|
||||
// Helper: read a config.yml and merge its firecrawl.* keys
|
||||
const tryReadConfig = (configPath: string): void => {
|
||||
try {
|
||||
const raw = parseYaml(fs.readFileSync(configPath, "utf-8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const fc = (raw?.firecrawl ?? {}) as Record<string, unknown>;
|
||||
if (typeof fc.baseUrl === "string" && fc.baseUrl.length > 0) {
|
||||
baseUrl = fc.baseUrl;
|
||||
}
|
||||
if (typeof fc.apiKey === "string" && fc.apiKey.length > 0) {
|
||||
apiKey = fc.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// File missing or unparseable — skip
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Global config
|
||||
tryReadConfig(path.join(agentDir, "config.yml"));
|
||||
|
||||
// 2. Project config (override global)
|
||||
tryReadConfig(path.join(process.cwd(), ".omp", "config.yml"));
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
const { baseUrl: BASE_URL, apiKey: API_KEY } = loadFirecrawlConfig();
|
||||
|
||||
/* ── Domain Authority Heuristics ─────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Known high-authority domains and their authority scores (0.0 – 1.0).
|
||||
* Academic, official, and established technical sources score highest.
|
||||
*/
|
||||
const AUTHORITY_DOMAINS: Record<string, number> = {
|
||||
// Academic & scholarly
|
||||
"arxiv.org": 0.95,
|
||||
"scholar.google.com": 0.95,
|
||||
"pubmed.ncbi.nlm.nih.gov": 0.95,
|
||||
"semanticscholar.org": 0.9,
|
||||
"ieee.org": 0.95,
|
||||
"acm.org": 0.95,
|
||||
"springer.com": 0.9,
|
||||
"sciencedirect.com": 0.9,
|
||||
"wiley.com": 0.85,
|
||||
"nature.com": 0.95,
|
||||
"science.org": 0.95,
|
||||
"plos.org": 0.85,
|
||||
// Official documentation
|
||||
"docs.python.org": 0.9,
|
||||
"developer.mozilla.org": 0.9,
|
||||
"learn.microsoft.com": 0.85,
|
||||
"developer.apple.com": 0.85,
|
||||
"kubernetes.io": 0.85,
|
||||
"react.dev": 0.85,
|
||||
"nextjs.org": 0.8,
|
||||
// Official language/platform docs
|
||||
"go.dev": 0.9,
|
||||
"golang.org": 0.9,
|
||||
"rust-lang.org": 0.9,
|
||||
"nodejs.org": 0.85,
|
||||
"python.org": 0.85,
|
||||
"typescriptlang.org": 0.85,
|
||||
"openai.com": 0.8,
|
||||
"anthropic.com": 0.8,
|
||||
"cloud.google.com": 0.8,
|
||||
"aws.amazon.com": 0.8,
|
||||
"azure.microsoft.com": 0.8,
|
||||
"postgresql.org": 0.85,
|
||||
"sqlite.org": 0.85,
|
||||
"redis.io": 0.85,
|
||||
"docker.com": 0.75,
|
||||
"elastic.co": 0.75,
|
||||
"grafana.com": 0.75,
|
||||
"datadoghq.com": 0.75,
|
||||
"cloudflare.com": 0.8,
|
||||
"blog.cloudflare.com": 0.8,
|
||||
"techempower.com": 0.8,
|
||||
"goframe.org": 0.75,
|
||||
"corrode.dev": 0.6,
|
||||
"evrone.com": 0.4,
|
||||
"rustify.rs": 0.4,
|
||||
"core.cz": 0.4,
|
||||
// Medical / clinical
|
||||
"mayoclinic.org": 0.9,
|
||||
"heart.org": 0.85,
|
||||
"researchgate.net": 0.6,
|
||||
"healthline.com": 0.5,
|
||||
"medicalnewstoday.com": 0.5,
|
||||
"webmd.com": 0.45,
|
||||
"verywellhealth.com": 0.5,
|
||||
// Databases & dev tools
|
||||
"mysql.com": 0.85,
|
||||
"mariadb.org": 0.85,
|
||||
"cockroachlabs.com": 0.7,
|
||||
"timescale.com": 0.7,
|
||||
"mongodb.com": 0.8,
|
||||
"liquibase.com": 0.6,
|
||||
"sqlpipe.com": 0.5,
|
||||
"data-tune.com": 0.4,
|
||||
"binaryigor.com": 0.4,
|
||||
// Government & non-profits
|
||||
".gov": 0.9,
|
||||
".edu": 0.85,
|
||||
"who.int": 0.9,
|
||||
"worldbank.org": 0.85,
|
||||
"oecd.org": 0.85,
|
||||
// Established tech & news
|
||||
"github.com": 0.8,
|
||||
"stackoverflow.com": 0.7,
|
||||
"medium.com": 0.4,
|
||||
"dev.to": 0.5,
|
||||
"wikipedia.org": 0.7,
|
||||
"reuters.com": 0.8,
|
||||
"apnews.com": 0.8,
|
||||
"bbc.com": 0.75,
|
||||
"nytimes.com": 0.75,
|
||||
"theguardian.com": 0.7,
|
||||
"techcrunch.com": 0.6,
|
||||
"arstechnica.com": 0.65,
|
||||
"wired.com": 0.65,
|
||||
"infoworld.com": 0.55,
|
||||
// Practitioner/aggregator content with measurable quality
|
||||
"github.io": 0.6,
|
||||
"crates.io": 0.7,
|
||||
"docs.rs": 0.75,
|
||||
"digitalocean.com": 0.6,
|
||||
"freecodecamp.org": 0.6,
|
||||
"geeksforgeeks.org": 0.35,
|
||||
"stackexchange.com": 0.65,
|
||||
"huggingface.co": 0.65,
|
||||
"nasa.gov": 0.9,
|
||||
"mit.edu": 0.9,
|
||||
"stanford.edu": 0.9,
|
||||
"harvard.edu": 0.9,
|
||||
"ox.ac.uk": 0.9,
|
||||
"cam.ac.uk": 0.9,
|
||||
// Low-authority: personal social / SEO content
|
||||
"linkedin.com": 0.25,
|
||||
"reddit.com": 0.25,
|
||||
"x.com": 0.3,
|
||||
"twitter.com": 0.3,
|
||||
"youtube.com": 0.3,
|
||||
"blogspot.com": 0.25,
|
||||
"substack.com": 0.3,
|
||||
"hashnode.dev": 0.35,
|
||||
"quora.com": 0.3,
|
||||
"netguru.com": 0.3,
|
||||
"relisoftware.com": 0.3,
|
||||
"dasroot.net": 0.3,
|
||||
"devgenius.io": 0.3,
|
||||
"devnewsletter.com": 0.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Known low-quality SEO/comparison-spam domains. Content is often
|
||||
* auto-generated, republished from other sites, or thin on substance.
|
||||
* These get a hard authority floor so they never rank above real content.
|
||||
*/
|
||||
const LOW_AUTHORITY_DOMAINS: Record<string, number> = {
|
||||
"markaicode.com": 0.15,
|
||||
"bytegoblin.io": 0.2,
|
||||
"towardsdev.com": 0.2,
|
||||
"rustvsgo.com": 0.3,
|
||||
"seekingalpha.com": 0.3,
|
||||
"investopedia.com": 0.55,
|
||||
"devops-daily.com": 0.3,
|
||||
};
|
||||
|
||||
/** Content-type hints based on domain patterns */
|
||||
const CONTENT_TYPE_HINTS: [RegExp, ContentType][] = [
|
||||
[
|
||||
/arxiv\.org|semanticscholar|ieee\.org|acm\.org|springer|sciencedirect|pubmed\.ncbi/,
|
||||
"paper",
|
||||
],
|
||||
[
|
||||
/docs\.|learn\.|developer\.|kubernetes\.io|react\.dev|nextjs\.org/,
|
||||
"documentation",
|
||||
],
|
||||
[/wikipedia\.org|stackoverflow\.com|medium\.com|dev\.to/, "forum"],
|
||||
[
|
||||
/reuters\.com|apnews\.com|bbc\.com|nytimes\.com|techcrunch|arstechnica|wired/,
|
||||
"news",
|
||||
],
|
||||
[/\.gov|\.edu|who\.int|worldbank|oecd\.org/, "official"],
|
||||
[/github\.com/, "documentation"],
|
||||
];
|
||||
|
||||
/* ── Source enrichment helpers ───────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Extract the registered domain from a URL (e.g., "blog.example.com" → "example.com").
|
||||
* Uses a simple 2-part TLD heuristic. For common cases like .co.uk this is approximate.
|
||||
*/
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
// Special-case common multi-part TLDs
|
||||
const multiPartTlds =
|
||||
/\.(co\.uk|org\.uk|ac\.uk|gov\.uk|com\.au|co\.jp|co\.kr|com\.br)$/;
|
||||
const parts = hostname.split(".");
|
||||
if (multiPartTlds.test(hostname) && parts.length >= 3) {
|
||||
return parts.slice(-3).join(".");
|
||||
}
|
||||
return parts.slice(-2).join(".");
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, "").split("/")[0] ?? url;
|
||||
}
|
||||
}
|
||||
|
||||
function computeAuthorityScore(domain: string): number {
|
||||
// Hard floor for known low-authority domains first
|
||||
if (LOW_AUTHORITY_DOMAINS[domain] !== undefined)
|
||||
return LOW_AUTHORITY_DOMAINS[domain];
|
||||
|
||||
// Direct match first
|
||||
if (AUTHORITY_DOMAINS[domain]) return AUTHORITY_DOMAINS[domain];
|
||||
|
||||
// Suffix matches (.gov, .edu, etc.)
|
||||
for (const [key, score] of Object.entries(AUTHORITY_DOMAINS)) {
|
||||
if (key.startsWith(".") && domain.endsWith(key)) return score;
|
||||
}
|
||||
|
||||
// Subdomain matches (e.g., blog.example.com matches example.com)
|
||||
const parent = domain.split(".").slice(-2).join(".");
|
||||
if (parent !== domain && AUTHORITY_DOMAINS[parent]) {
|
||||
return AUTHORITY_DOMAINS[parent] * 0.9;
|
||||
}
|
||||
|
||||
// github.io personal sites: treat as practitioner content (medium)
|
||||
if (domain.endsWith(".github.io")) return 0.55;
|
||||
|
||||
return 0.3; // Unknown / low-authority default
|
||||
}
|
||||
|
||||
function detectContentType(url: string, description: string): ContentType {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
const lowerDesc = description.toLowerCase();
|
||||
|
||||
for (const [pattern, type] of CONTENT_TYPE_HINTS) {
|
||||
if (pattern.test(lowerUrl)) return type;
|
||||
}
|
||||
|
||||
// Heuristics from description text
|
||||
if (/paper|research|study|experiment|analysis\b/.test(lowerDesc))
|
||||
return "paper";
|
||||
if (/documentation|guide|tutorial|api|reference/.test(lowerDesc))
|
||||
return "documentation";
|
||||
if (/blog|post|article|opinion/.test(lowerDesc)) return "blog";
|
||||
if (/news|report|announce|release/.test(lowerDesc)) return "news";
|
||||
if (/forum|discussion|question|answer|thread/.test(lowerDesc)) return "forum";
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
function tryParseDate(dateStr: string | undefined | null): Date | null {
|
||||
if (!dateStr) return null;
|
||||
const d = new Date(dateStr);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a title for near-duplicate detection: lowercase, strip
|
||||
* punctuation, collapse whitespace, drop common filler words.
|
||||
* Two syndicated copies of the same article normalize identically.
|
||||
*/
|
||||
export function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.replace(
|
||||
/\b(?:the|a|an|of|for|and|or|in|on|with|vs|versus|to|how|what|why|2024|2025|2026)\b/g,
|
||||
" ",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Near-duplicate check between two titles: normalized forms must share
|
||||
* a substantial token overlap (same core words in the same order).
|
||||
*/
|
||||
export function isNearDuplicateTitle(a: string, b: string): boolean {
|
||||
const normA = normalizeTitle(a);
|
||||
const normB = normalizeTitle(b);
|
||||
if (!normA || !normB) return false;
|
||||
if (normA === normB) return true;
|
||||
|
||||
const tokensA = normA.split(" ");
|
||||
const tokensB = normB.split(" ");
|
||||
if (tokensA.length < 3 || tokensB.length < 3) return normA === normB;
|
||||
|
||||
// Check if one title is a substring of the other (after normalization)
|
||||
if (normA.includes(normB) || normB.includes(normA)) return true;
|
||||
|
||||
// Jaccard-ish overlap on the shorter token set
|
||||
const [short, long] =
|
||||
tokensA.length <= tokensB.length ? [tokensA, tokensB] : [tokensB, tokensA];
|
||||
const overlap = short.filter((t) => long.includes(t)).length;
|
||||
return overlap / short.length >= 0.75;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a raw search result with source authority metadata.
|
||||
* Accepts extra fields (e.g. date) from the Firecrawl API response.
|
||||
*/
|
||||
export function enrichResult(
|
||||
result: SearchResult & Record<string, unknown>,
|
||||
): EnrichedSearchResult {
|
||||
const domain = extractDomain(result.url);
|
||||
return {
|
||||
...result,
|
||||
domain,
|
||||
authorityScore: computeAuthorityScore(domain),
|
||||
publishedDate: tryParseDate(result.date as string | undefined),
|
||||
contentType: detectContentType(result.url, result.description),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
async function firecrawlRequest(
|
||||
endpoint: string,
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (API_KEY) {
|
||||
headers["Authorization"] = `Bearer ${API_KEY}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/v1/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(
|
||||
`Firecrawl ${endpoint} failed (${res.status}): ${text.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* firecrawlRequest with retry-with-backoff for transient failures
|
||||
* (429 rate limits, 5xx server errors, network blips). Does NOT retry
|
||||
* 4xx client errors (invalid requests) or aborts.
|
||||
*/
|
||||
async function firecrawlRequestWithRetry(
|
||||
endpoint: string,
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
retries: number = 2,
|
||||
): Promise<unknown> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
if (signal?.aborted) throw new Error("Aborted");
|
||||
try {
|
||||
return await firecrawlRequest(endpoint, body, signal);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const status =
|
||||
error instanceof Error
|
||||
? Number(/failed \((\d+)\)/.exec(error.message)?.[1] ?? 0)
|
||||
: 0;
|
||||
// Don't retry aborts or 4xx client errors (other than 429)
|
||||
if (
|
||||
signal?.aborted ||
|
||||
(status >= 400 && status < 500 && status !== 429)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt < retries) {
|
||||
const delayMs = 400 * 2 ** attempt + Math.random() * 200;
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function isFirecrawlReachable(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/v1/scrape`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ url: "https://example.com", formats: ["links"] }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Search the web and return structured, enriched results.
|
||||
* Uses Firecrawl's search endpoint with scrape to get full page content.
|
||||
*/
|
||||
export async function searchWeb(
|
||||
query: string,
|
||||
limit: number = 5,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EnrichedSearchResult[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
query,
|
||||
limit: Math.min(limit, 10),
|
||||
scrapeOptions: {
|
||||
formats: ["markdown"],
|
||||
onlyMainContent: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await firecrawlRequestWithRetry("search", body, signal);
|
||||
|
||||
if (!result || typeof result !== "object") return [];
|
||||
|
||||
const res = result as {
|
||||
success?: boolean;
|
||||
data?: Record<string, unknown>[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!res.success || !res.data) return [];
|
||||
|
||||
const rawResults: (SearchResult & Record<string, unknown>)[] = res.data
|
||||
.map((doc) => ({
|
||||
title: (doc.title as string) ?? "",
|
||||
url: (doc.url as string) ?? "",
|
||||
description: (doc.description as string) ?? "",
|
||||
markdown: (doc.markdown as string) ?? "",
|
||||
// Preserve extra fields for date extraction
|
||||
...doc,
|
||||
}))
|
||||
.filter((r) => {
|
||||
// Keep results with a meaningful body OR a substantive description.
|
||||
// Filters out stub pages / pure navigation results that would
|
||||
// waste analysis tokens.
|
||||
const hasBody = (r.markdown ?? "").trim().length >= 150;
|
||||
const hasSubstantiveDesc = (r.description ?? "").trim().length >= 40;
|
||||
return hasBody || hasSubstantiveDesc;
|
||||
});
|
||||
|
||||
// Enrich each result with source metadata
|
||||
return rawResults.map(enrichResult);
|
||||
}
|
||||
|
||||
/* ── Scrape ───────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Scrape a single URL and return its markdown content.
|
||||
*/
|
||||
export async function scrapeUrl(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ title: string; markdown: string; links: string[] } | null> {
|
||||
const result = await firecrawlRequestWithRetry(
|
||||
"scrape",
|
||||
{ url, formats: ["markdown"] },
|
||||
signal,
|
||||
);
|
||||
|
||||
if (!result || typeof result !== "object") return null;
|
||||
|
||||
const res = result as {
|
||||
success?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!res.success || !res.data) return null;
|
||||
|
||||
return {
|
||||
title: (res.data.title as string) ?? "",
|
||||
markdown: (res.data.markdown as string) ?? "",
|
||||
links: (res.data.links as string[]) ?? [],
|
||||
};
|
||||
}
|
||||
625
src/queries.ts
Normal file
625
src/queries.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
/**
|
||||
* 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<string, unknown>): 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<string[]> {
|
||||
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<SearchQuery[]> {
|
||||
// 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<string, unknown>))
|
||||
.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<string, unknown>))
|
||||
.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<SearchQuery[]> {
|
||||
// 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<string, unknown>);
|
||||
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<Finding[]> {
|
||||
// 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<string, unknown>;
|
||||
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<string, number>,
|
||||
): 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<string, number>();
|
||||
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;
|
||||
}
|
||||
530
src/report.ts
Normal file
530
src/report.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Deep Research — Report synthesis
|
||||
*
|
||||
* Takes all research rounds and synthesizes a comprehensive report
|
||||
* using an LLM agent. Produces:
|
||||
* - Numbered inline citations with a bibliography
|
||||
* - Layered report: TL;DR → Executive Summary → Key Findings
|
||||
* → Detailed Analysis → Limitations/Gaps → References
|
||||
* - Audience-aware tone adjustment
|
||||
*/
|
||||
import type {
|
||||
ResearchRound,
|
||||
ResearchConfig,
|
||||
Reference,
|
||||
Finding,
|
||||
} from "./types";
|
||||
import { runAnalysisAgent } from "./agent";
|
||||
import { isNearDuplicateTitle } from "./firecrawl";
|
||||
|
||||
/** Return shape from synthesizeReport */
|
||||
export interface SynthesisResult {
|
||||
report: string;
|
||||
references: Reference[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum findings included in the synthesis prompt.
|
||||
* Keeps token usage bounded and forces the synthesizer to focus on
|
||||
* the highest-quality evidence.
|
||||
*/
|
||||
const MAX_SYNTHESIS_FINDINGS = 30;
|
||||
|
||||
/**
|
||||
* Rank a finding for inclusion in synthesis: authority-weighted,
|
||||
* confidence-weighted, with a corroboration bonus.
|
||||
*/
|
||||
function findingQualityScore(f: Finding): number {
|
||||
const authority =
|
||||
(f.bestSourceAuthority ?? f.avgSourceAuthority ?? 0.5) || 0.5;
|
||||
const confidenceWeight =
|
||||
f.confidence === "high" ? 1.0 : f.confidence === "medium" ? 0.7 : 0.45;
|
||||
const corroborationBonus = (f.corroborationScore ?? 0) * 0.3;
|
||||
return authority * confidenceWeight + corroborationBonus;
|
||||
}
|
||||
|
||||
/* ── System Prompts ──────────────────────────────────────────────── */
|
||||
|
||||
function buildSynthesisSystem(audience: string): string {
|
||||
const audienceGuidance: Record<string, string> = {
|
||||
expert:
|
||||
"Assume expert-level domain knowledge. Use precise technical terminology, reference specific methodologies and standards, and prioritize depth over hand-holding. The reader understands the field.",
|
||||
general:
|
||||
"Write for an informed general audience. Define technical terms on first use, explain context, and keep the tone accessible but not simplistic. Avoid jargon without explanation.",
|
||||
executive:
|
||||
"Write for a busy executive or decision-maker. Lead with actionable conclusions and recommendations. Be concise — use bold for key takeaways. Minimize technical detail; focus on implications, trade-offs, and decisions. Target 2-3 pages.",
|
||||
};
|
||||
|
||||
const guidance = audienceGuidance[audience] ?? audienceGuidance.general;
|
||||
|
||||
return `You are a senior research analyst synthesizing findings from multiple web searches into a comprehensive, well-structured report.
|
||||
|
||||
Audience: ${guidance}
|
||||
|
||||
Report structure (use ## headings):
|
||||
1. **TL;DR** — One paragraph (2-3 sentences) giving the single most important answer
|
||||
2. **Executive Summary** — 2-3 paragraphs covering what was found, how confident we are, and key implications
|
||||
3. **Key Findings** — Tiered by importance/confidence. Bullet points with inline citations
|
||||
4. **Detailed Analysis** — Organized by theme. Each section covers one aspect with evidence
|
||||
5. **Limitations & Knowledge Gaps** — What evidence is weak, missing, or contradictory
|
||||
6. **Conclusion** — Wrap up with actionable takeaways
|
||||
|
||||
Citation rules:
|
||||
- Use numbered references like [1], [2] etc. throughout the text
|
||||
- At the end, include a ## References section listing each citation
|
||||
- Format references as: [1] Title — Domain (URL)
|
||||
- Cite specific evidence, not vague associations
|
||||
- When multiple sources support a claim, cite all of them: [1][3][5]
|
||||
|
||||
Style guidelines:
|
||||
- Write in an objective, authoritative tone
|
||||
- Use bullet points for listing evidence
|
||||
- Note the confidence level for key claims
|
||||
- Be thorough but concise — every paragraph should add value
|
||||
- Use > for notable direct quotes with citations`;
|
||||
}
|
||||
|
||||
/* ── Evidence Builder ────────────────────────────────────────────── */
|
||||
|
||||
function buildEvidenceText(
|
||||
question: string,
|
||||
rounds: ResearchRound[],
|
||||
): { evidenceText: string; referenceMap: Map<string, Reference> } {
|
||||
const allFindings = rounds.flatMap((r) => r.findings);
|
||||
const totalSearches = rounds.reduce((sum, r) => sum + r.queries.length, 0);
|
||||
const totalPages = rounds.reduce((sum, r) => sum + r.results.length, 0);
|
||||
|
||||
// Build a bibliography map (url -> Reference)
|
||||
const seenUrls = new Map<string, Reference>();
|
||||
let refId = 0;
|
||||
|
||||
for (const round of rounds) {
|
||||
for (const result of round.results) {
|
||||
if (!seenUrls.has(result.url)) {
|
||||
refId++;
|
||||
seenUrls.set(result.url, {
|
||||
id: refId,
|
||||
url: result.url,
|
||||
title: result.title,
|
||||
domain: result.domain,
|
||||
authorityScore: result.authorityScore,
|
||||
accessedAt: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deduplicate findings across rounds ────────────────────────────
|
||||
// The same claim often surfaces in multiple rounds under slightly
|
||||
// different titles. Merge them (union of sources/quotes, keep the
|
||||
// highest-quality version) so the synthesizer isn't double-counting.
|
||||
const deduped: Finding[] = [];
|
||||
for (const finding of allFindings) {
|
||||
const dupIndex = deduped.findIndex(
|
||||
(f) =>
|
||||
f.title !== finding.title &&
|
||||
isNearDuplicateTitle(f.title, finding.title),
|
||||
);
|
||||
if (dupIndex === -1) {
|
||||
deduped.push({ ...finding });
|
||||
} else {
|
||||
const existing = deduped[dupIndex];
|
||||
deduped[dupIndex] = {
|
||||
title: existing.title,
|
||||
summary: existing.summary,
|
||||
sources: Array.from(new Set([...existing.sources, ...finding.sources])),
|
||||
keyQuotes: Array.from(
|
||||
new Set([...existing.keyQuotes, ...finding.keyQuotes]),
|
||||
).slice(0, 3),
|
||||
confidence:
|
||||
existing.confidence === "high" || finding.confidence === "high"
|
||||
? "high"
|
||||
: existing.confidence === "medium" ||
|
||||
finding.confidence === "medium"
|
||||
? "medium"
|
||||
: "low",
|
||||
query: existing.query ?? finding.query,
|
||||
angle: existing.angle ?? finding.angle,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rank and cap findings for synthesis ───────────────────────────
|
||||
const ranked = deduped
|
||||
.map((f) => ({ f, score: findingQualityScore(f) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_SYNTHESIS_FINDINGS)
|
||||
.map(({ f }) => f);
|
||||
|
||||
// Organize findings by their own angle (provenance-aware)
|
||||
const evidenceByAngle = new Map<string, Finding[]>();
|
||||
for (const finding of ranked) {
|
||||
const angle = finding.angle ?? "general";
|
||||
if (!evidenceByAngle.has(angle)) evidenceByAngle.set(angle, []);
|
||||
evidenceByAngle.get(angle)!.push(finding);
|
||||
}
|
||||
|
||||
let evidenceText = `## Research Question\n${question}\n\n`;
|
||||
evidenceText += `## Overview\n- Rounds of research: ${rounds.length}\n`;
|
||||
evidenceText += `- Total searches executed: ${totalSearches}\n`;
|
||||
evidenceText += `- Total pages analyzed: ${totalPages}\n`;
|
||||
evidenceText += `- Key findings extracted: ${allFindings.length} (${ranked.length} passed dedup/quality filter)\n\n`;
|
||||
|
||||
// Build evidence grouped by angle with reference IDs
|
||||
for (const [angle, findings] of Array.from(evidenceByAngle)) {
|
||||
if (findings.length === 0) continue;
|
||||
evidenceText += `## Angle: ${angle}\n\n`;
|
||||
for (const finding of findings) {
|
||||
// Get reference IDs for this finding's sources
|
||||
const refs = finding.sources
|
||||
.map((url) => seenUrls.get(url))
|
||||
.filter((r): r is Reference => !!r)
|
||||
.map((r) => `[${r.id}]`);
|
||||
|
||||
const avgAuth =
|
||||
finding.avgSourceAuthority !== undefined
|
||||
? ` | Avg Authority: ${(finding.avgSourceAuthority * 100).toFixed(0)}%`
|
||||
: "";
|
||||
const corr =
|
||||
finding.corroborationScore !== undefined
|
||||
? ` | Corroboration: ${(finding.corroborationScore * 100).toFixed(0)}%`
|
||||
: "";
|
||||
const bestAuthStr =
|
||||
finding.bestSourceAuthority !== undefined
|
||||
? ` | Best Source: ${(finding.bestSourceAuthority * 100).toFixed(0)}%`
|
||||
: "";
|
||||
|
||||
evidenceText += `### ${finding.title}\n`;
|
||||
evidenceText += `**Confidence:** ${finding.confidence}${avgAuth}${corr}${bestAuthStr}\n`;
|
||||
if (refs.length > 0) {
|
||||
evidenceText += `**Sources:** ${refs.join(", ")}\n`;
|
||||
}
|
||||
evidenceText += `${finding.summary}\n\n`;
|
||||
if (finding.keyQuotes.length > 0) {
|
||||
evidenceText += `> ${finding.keyQuotes[0]}\n\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include reference metadata for the LLM to build proper citations
|
||||
evidenceText += `## Reference Metadata\n\n`;
|
||||
for (const [, ref] of seenUrls) {
|
||||
evidenceText += `[${ref.id}] ${ref.title} (${ref.domain}, authority: ${(ref.authorityScore * 100).toFixed(0)}%) — ${ref.url}\n`;
|
||||
}
|
||||
|
||||
return { evidenceText, referenceMap: seenUrls };
|
||||
}
|
||||
|
||||
/* ── Main Synthesis ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Synthesize a research report from all rounds.
|
||||
* Returns both the formatted report and the full bibliography.
|
||||
*/
|
||||
export async function synthesizeReport(
|
||||
question: string,
|
||||
rounds: ResearchRound[],
|
||||
config: ResearchConfig,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SynthesisResult> {
|
||||
const audience = config.audience ?? "general";
|
||||
const { evidenceText, referenceMap } = buildEvidenceText(question, rounds);
|
||||
|
||||
const formatInstruction =
|
||||
config.format === "structured"
|
||||
? "Structured report with numbered sections, clear hierarchies, and data tables where appropriate."
|
||||
: "Well-formatted markdown report with ## headings, bullet points, and inline numbered citations like [1].";
|
||||
|
||||
const taskPrompt = `Synthesize the following research findings into a comprehensive, well-structured report.
|
||||
|
||||
${evidenceText}
|
||||
|
||||
Write a thorough report that answers the original question: "${question}"
|
||||
|
||||
Format: ${formatInstruction}
|
||||
Audience: ${audience}
|
||||
|
||||
Remember to use numbered citations like [1], [2] and include a ## References section at the end.`;
|
||||
|
||||
const result = await runAnalysisAgent(
|
||||
buildSynthesisSystem(audience),
|
||||
taskPrompt,
|
||||
cwd,
|
||||
120_000,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
|
||||
if (result.success && result.text) {
|
||||
// Build bibliography section
|
||||
const bibSection = buildBibliography(referenceMap);
|
||||
|
||||
let report = result.text;
|
||||
|
||||
// ── Citation integrity ─────────────────────────────────────────
|
||||
// 1. Strip any references section the LLM wrote and replace it with
|
||||
// the authoritative bibliography (built from real scraped sources).
|
||||
// 2. Remove inline [n] citations that point at IDs outside the
|
||||
// bibliography (hallucinated numbers), so every citation resolves.
|
||||
report = report.replace(
|
||||
/^#+\s*references\s*$/gim,
|
||||
"\n## END_OF_REPORT_MARKER",
|
||||
);
|
||||
const markerIdx = report.indexOf("## END_OF_REPORT_MARKER");
|
||||
if (markerIdx !== -1) {
|
||||
report = report.slice(0, markerIdx).trimEnd();
|
||||
}
|
||||
|
||||
const maxRefId = Math.max(
|
||||
0,
|
||||
...Array.from(referenceMap.values()).map((r) => r.id),
|
||||
);
|
||||
report = report.replace(/\[(\d+)\]/g, (match, id: string) => {
|
||||
const num = parseInt(id, 10);
|
||||
return num >= 1 && num <= maxRefId ? match : "";
|
||||
});
|
||||
|
||||
report = report.trimEnd() + `\n\n${bibSection}`;
|
||||
|
||||
return { report, references: Array.from(referenceMap.values()) };
|
||||
}
|
||||
|
||||
// Fallback: generate a simple structured report
|
||||
const fallbackReport = generateFallbackReport(
|
||||
question,
|
||||
rounds,
|
||||
referenceMap,
|
||||
audience,
|
||||
);
|
||||
return {
|
||||
report: fallbackReport + `\n\n${buildBibliography(referenceMap)}`,
|
||||
references: Array.from(referenceMap.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Bibliography Builder ────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Build a structured ## References section from the reference map.
|
||||
*/
|
||||
function buildBibliography(referenceMap: Map<string, Reference>): string {
|
||||
if (referenceMap.size === 0) return "## References\n\nNo sources cited.";
|
||||
|
||||
const refs = Array.from(referenceMap.values()).sort((a, b) => a.id - b.id);
|
||||
const lines: string[] = ["## References\n"];
|
||||
for (const ref of refs) {
|
||||
const authIcon =
|
||||
ref.authorityScore >= 0.8 ? "⭐" : ref.authorityScore >= 0.5 ? "✓" : "○";
|
||||
lines.push(
|
||||
`[${ref.id}] ${authIcon} **${ref.title}** — ${ref.domain} (${ref.url}) — accessed ${ref.accessedAt}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/* ── Fallback Report ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Fallback report when the LLM synthesis fails.
|
||||
* Produces a clean, structured report from the evidence.
|
||||
*/
|
||||
function generateFallbackReport(
|
||||
question: string,
|
||||
rounds: ResearchRound[],
|
||||
referenceMap: Map<string, Reference>,
|
||||
_audience: string,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const allFindings = rounds.flatMap((r) => r.findings);
|
||||
|
||||
// ── TL;DR ──
|
||||
lines.push(`# Research Report: ${question}`);
|
||||
lines.push("");
|
||||
|
||||
const highConfFindings = allFindings.filter((f) => f.confidence === "high");
|
||||
const totalHigh = highConfFindings.length;
|
||||
const total = allFindings.length;
|
||||
|
||||
lines.push("## TL;DR");
|
||||
lines.push("");
|
||||
if (highConfFindings.length > 0) {
|
||||
lines.push(
|
||||
`Based on analysis of ${total} findings across ${rounds.length} research round(s), ` +
|
||||
`${totalHigh} high-confidence conclusions were identified. ` +
|
||||
`${highConfFindings[0].title}: ${highConfFindings[0].summary}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`This report covers findings from ${rounds.length} research round(s) exploring "${question}". ` +
|
||||
`${total} findings were extracted, with varying levels of confidence.`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// ── Executive Summary ──
|
||||
lines.push("## Executive Summary");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`This report synthesizes findings from ${rounds.length} research round(s), ` +
|
||||
`${rounds.reduce((s, r) => s + r.queries.length, 0)} search queries, ` +
|
||||
`and ${rounds.reduce((s, r) => s + r.results.length, 0)} sources.`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// ── Key Findings (tiered) ──
|
||||
if (allFindings.length > 0) {
|
||||
lines.push("## Key Findings");
|
||||
lines.push("");
|
||||
|
||||
// High confidence first
|
||||
const highConf = allFindings.filter((f) => f.confidence === "high");
|
||||
if (highConf.length > 0) {
|
||||
lines.push("### High Confidence");
|
||||
for (const finding of highConf) {
|
||||
const refs = finding.sources
|
||||
.map((url) => referenceMap.get(url))
|
||||
.filter((r): r is Reference => !!r)
|
||||
.map((r) => `[${r.id}]`);
|
||||
lines.push(
|
||||
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
|
||||
);
|
||||
lines.push(` - ${finding.summary}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Medium confidence
|
||||
const medConf = allFindings.filter((f) => f.confidence === "medium");
|
||||
if (medConf.length > 0) {
|
||||
lines.push("### Moderate Confidence");
|
||||
for (const finding of medConf) {
|
||||
const refs = finding.sources
|
||||
.map((url) => referenceMap.get(url))
|
||||
.filter((r): r is Reference => !!r)
|
||||
.map((r) => `[${r.id}]`);
|
||||
lines.push(
|
||||
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
|
||||
);
|
||||
lines.push(` - ${finding.summary}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Low confidence
|
||||
const lowConf = allFindings.filter((f) => f.confidence === "low");
|
||||
if (lowConf.length > 0) {
|
||||
lines.push("### Lower Confidence (Needs Further Research)");
|
||||
for (const finding of lowConf) {
|
||||
const refs = finding.sources
|
||||
.map((url) => referenceMap.get(url))
|
||||
.filter((r): r is Reference => !!r)
|
||||
.map((r) => `[${r.id}]`);
|
||||
lines.push(
|
||||
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
|
||||
);
|
||||
lines.push(` - ${finding.summary}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── Detailed Analysis ──
|
||||
lines.push("## Detailed Analysis");
|
||||
lines.push("");
|
||||
|
||||
const byAngle = new Map<string, Finding[]>();
|
||||
for (const round of rounds) {
|
||||
for (const f of round.findings) {
|
||||
const angle = f.angle ?? round.queries[0]?.angle ?? "general";
|
||||
if (!byAngle.has(angle)) byAngle.set(angle, []);
|
||||
byAngle.get(angle)!.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [angle, findings] of byAngle) {
|
||||
lines.push(`### ${angle.charAt(0).toUpperCase() + angle.slice(1)}`);
|
||||
lines.push("");
|
||||
for (const f of findings) {
|
||||
const corrStr =
|
||||
f.corroborationScore !== undefined
|
||||
? ` (corroboration: ${(f.corroborationScore * 100).toFixed(0)}%)`
|
||||
: "";
|
||||
lines.push(`**${f.title}** — *${f.confidence} confidence${corrStr}*`);
|
||||
lines.push("");
|
||||
lines.push(f.summary);
|
||||
lines.push("");
|
||||
if (f.keyQuotes.length > 0) {
|
||||
lines.push(`> ${f.keyQuotes[0]}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Limitations ──
|
||||
const lowConfCount = allFindings.filter(
|
||||
(f) => f.confidence === "low",
|
||||
).length;
|
||||
const noCorr = allFindings.filter(
|
||||
(f) => (f.corroborationScore ?? 0) < 0.3,
|
||||
).length;
|
||||
|
||||
lines.push("## Limitations & Knowledge Gaps");
|
||||
lines.push("");
|
||||
if (lowConfCount > 0) {
|
||||
lines.push(
|
||||
`- **${lowConfCount} of ${allFindings.length} findings** have low confidence, indicating limited or conflicting evidence.`,
|
||||
);
|
||||
}
|
||||
if (noCorr > 0) {
|
||||
lines.push(
|
||||
`- **${noCorr} findings** lack corroboration from multiple independent sources.`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"- This research relied on web search results; some relevant sources may not be indexed or accessible.",
|
||||
);
|
||||
lines.push(
|
||||
"- Findings are dependent on search engine ranking and the quality of indexed content.",
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// ── Conclusion ──
|
||||
lines.push("## Conclusion");
|
||||
lines.push("");
|
||||
if (highConf.length > 0) {
|
||||
lines.push(
|
||||
`The research identified ${highConf.length} high-confidence finding(s) and ${medConf.length} moderately-supported finding(s). ` +
|
||||
`The strongest evidence relates to: ${highConf.map((f) => f.title).join(", ")}.`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"The research surfaced relevant information but with limited high-confidence evidence. Further investigation is recommended for the identified knowledge gaps.",
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── Methodology ──
|
||||
lines.push(`*Report prepared for: ${_audience} audience*`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Methodology");
|
||||
lines.push("");
|
||||
for (const round of rounds) {
|
||||
const failedSearches =
|
||||
round.failedSearches ?? round.queries.length - round.successfulSearches;
|
||||
lines.push(`### Round ${round.round}`);
|
||||
lines.push(
|
||||
`Queries: ${round.queries.map((q) => `"${q.query}" [${q.angle}]`).join(", ")}`,
|
||||
);
|
||||
lines.push(`Pages scraped: ${round.results.length}`);
|
||||
lines.push(`Findings extracted: ${round.findings.length}`);
|
||||
if (failedSearches > 0) {
|
||||
lines.push(`Searches failed: ${failedSearches}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
468
src/research.ts
Normal file
468
src/research.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Deep Research — Core research orchestration
|
||||
*
|
||||
* Manages the multi-round deep research process:
|
||||
* 1. Decompose the question into sub-questions (when depth > 1)
|
||||
* 2. Generate initial search queries (per sub-question for better diversity)
|
||||
* 3. Execute all queries in parallel via Firecrawl
|
||||
* 4. Analyze results and extract findings
|
||||
* 5. Compute corroboration scores
|
||||
* 6. Generate follow-up queries for gaps
|
||||
* 7. Iterate for depth rounds
|
||||
* 8. Synthesize final report with numbered references
|
||||
*
|
||||
* Widget and progress callback patterns borrowed from ralpi's executor.
|
||||
*/
|
||||
import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import type {
|
||||
Finding,
|
||||
ResearchConfig,
|
||||
EnrichedSearchResult,
|
||||
ResearchRound,
|
||||
ResearchReport,
|
||||
} from "./types";
|
||||
import type { SynthesisResult } from "./report";
|
||||
import { searchWeb, isNearDuplicateTitle } from "./firecrawl";
|
||||
import {
|
||||
generateQueries,
|
||||
generateFollowUpQueries,
|
||||
analyzeResults,
|
||||
computeCorroboration,
|
||||
decomposeQuestion,
|
||||
} from "./queries";
|
||||
import { synthesizeReport } from "./report";
|
||||
|
||||
/** Progress callback for UI updates */
|
||||
export type ResearchProgress = (update: {
|
||||
phase:
|
||||
| "decomposing"
|
||||
| "generating_queries"
|
||||
| "searching"
|
||||
| "analyzing"
|
||||
| "synthesizing"
|
||||
| "complete";
|
||||
round?: number;
|
||||
totalRounds?: number;
|
||||
message: string;
|
||||
detail?: string;
|
||||
fraction?: number; // 0-1
|
||||
}) => void;
|
||||
|
||||
// ── Round-Robin Parallel Execution ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maximum concurrent Firecrawl search requests.
|
||||
* Prevents rate limiting while still parallelizing queries.
|
||||
*/
|
||||
const MAX_SEARCH_CONCURRENT = 3;
|
||||
|
||||
/**
|
||||
* Maximum concurrent analysis agent sessions.
|
||||
*/
|
||||
const MAX_ANALYSIS_CONCURRENT = 2;
|
||||
|
||||
/**
|
||||
* Minimum findings per round before we consider early stopping.
|
||||
* If we're getting very few new findings, saturation is near.
|
||||
*/
|
||||
const SATURATION_THRESHOLD = 0.15; // < 15% new findings = likely saturated
|
||||
|
||||
/**
|
||||
* Bounded-concurrency parallel execution with round-robin slot assignment.
|
||||
*
|
||||
* Similar to ralpi's ModelRoundRobin: with N concurrent slots, items are
|
||||
* assigned to free slots in FIFO order. When a slot finishes, the next
|
||||
* item in the queue is assigned to it.
|
||||
*
|
||||
* This ensures even load distribution and avoids bursty concurrency.
|
||||
*/
|
||||
async function boundedConcurrency<T, R>(
|
||||
items: T[],
|
||||
maxConcurrent: number,
|
||||
mapper: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (true) {
|
||||
const currentIndex = nextIndex++;
|
||||
if (currentIndex >= items.length) return;
|
||||
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const numWorkers = Math.min(maxConcurrent, items.length);
|
||||
const workers = Array.from({ length: numWorkers }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess whether the research is reaching information saturation.
|
||||
*/
|
||||
function assessSaturation(
|
||||
previousRound: ResearchRound | undefined,
|
||||
currentRound: ResearchRound,
|
||||
): number {
|
||||
if (!previousRound || previousRound.findings.length === 0) return 0;
|
||||
|
||||
const prevUrls = new Set(previousRound.results.map((r) => r.url));
|
||||
const newUrls = currentRound.results.filter(
|
||||
(r) => !prevUrls.has(r.url),
|
||||
).length;
|
||||
const totalUrls = currentRound.results.length;
|
||||
const newRatio = totalUrls > 0 ? newUrls / totalUrls : 0;
|
||||
|
||||
// Also check finding novelty
|
||||
const prevFindingTitles = new Set(
|
||||
previousRound.findings.map((f) => f.title.toLowerCase()),
|
||||
);
|
||||
const newFindings = currentRound.findings.filter(
|
||||
(f) => !prevFindingTitles.has(f.title.toLowerCase()),
|
||||
).length;
|
||||
const totalFindings = currentRound.findings.length;
|
||||
const findingNovelty = totalFindings > 0 ? newFindings / totalFindings : 0;
|
||||
|
||||
// Weight: URL novelty (40%) + finding novelty (60%)
|
||||
return newRatio * 0.4 + findingNovelty * 0.6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a complete deep research session.
|
||||
*/
|
||||
export async function runDeepResearch(
|
||||
config: ResearchConfig,
|
||||
ctx: ExtensionContext,
|
||||
onProgress: ResearchProgress,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResearchReport> {
|
||||
const startTime = Date.now();
|
||||
const rounds: ResearchRound[] = [];
|
||||
let totalSearches = 0;
|
||||
let totalPages = 0;
|
||||
let subQuestions: string[] = [];
|
||||
|
||||
// ── Phase: Decompose question into sub-questions ────────────────
|
||||
|
||||
if (config.depth > 1) {
|
||||
onProgress({
|
||||
phase: "decomposing",
|
||||
round: 1,
|
||||
totalRounds: config.depth,
|
||||
message: "Decomposing research question into sub-topics...",
|
||||
fraction: 0,
|
||||
});
|
||||
|
||||
if (signal?.aborted) throw new Error("Research cancelled");
|
||||
|
||||
subQuestions = await decomposeQuestion(config.question, ctx.cwd, signal);
|
||||
}
|
||||
|
||||
// ── Phase: Generate initial queries ─────────────────────────────
|
||||
|
||||
onProgress({
|
||||
phase: "generating_queries",
|
||||
round: 1,
|
||||
totalRounds: config.depth,
|
||||
message:
|
||||
subQuestions.length > 0
|
||||
? `Generating queries across ${subQuestions.length} sub-topics...`
|
||||
: "Generating initial search queries...",
|
||||
fraction: 0.05,
|
||||
});
|
||||
|
||||
if (signal?.aborted) throw new Error("Research cancelled");
|
||||
|
||||
const queries = await generateQueries(
|
||||
config.question,
|
||||
config.breadth,
|
||||
ctx.cwd,
|
||||
signal,
|
||||
subQuestions.length > 0 ? subQuestions : undefined,
|
||||
);
|
||||
|
||||
if (queries.length === 0) {
|
||||
throw new Error("Failed to generate any search queries");
|
||||
}
|
||||
|
||||
// ── Execute rounds ───────────────────────────────────────────────
|
||||
|
||||
for (let round = 1; round <= config.depth; round++) {
|
||||
if (signal?.aborted) throw new Error("Research cancelled");
|
||||
|
||||
const isFirstRound = round === 1;
|
||||
const currentQueries = isFirstRound
|
||||
? queries
|
||||
: await generateFollowUpQueries(
|
||||
config.question,
|
||||
rounds,
|
||||
config.breadth,
|
||||
ctx.cwd,
|
||||
signal,
|
||||
);
|
||||
|
||||
if (!currentQueries || currentQueries.length === 0) {
|
||||
// No follow-up queries to generate — stop here
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Search phase (parallel with round-robin) ────────────────────
|
||||
|
||||
onProgress({
|
||||
phase: "searching",
|
||||
round,
|
||||
totalRounds: config.depth,
|
||||
message: `Searching ${currentQueries.length} queries in parallel...`,
|
||||
fraction: 0.25,
|
||||
});
|
||||
|
||||
if (signal?.aborted) throw new Error("Research cancelled");
|
||||
|
||||
// Run searches in parallel using round-robin bounded concurrency.
|
||||
// Each mapper call runs independently; failures are caught per-query.
|
||||
// Results keep their originating query index for later grouping.
|
||||
const searchResultsArrays: (EnrichedSearchResult[] | null)[] =
|
||||
await boundedConcurrency(
|
||||
currentQueries,
|
||||
MAX_SEARCH_CONCURRENT,
|
||||
async (q, i) => {
|
||||
onProgress({
|
||||
phase: "searching",
|
||||
round,
|
||||
totalRounds: config.depth,
|
||||
message: `Searching: "${q.query.slice(0, 60)}..."`,
|
||||
detail: q.rationale,
|
||||
fraction: 0.25 + (i / currentQueries.length) * 0.25,
|
||||
});
|
||||
|
||||
try {
|
||||
return await searchWeb(q.query, 5, signal);
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
onProgress({
|
||||
phase: "searching",
|
||||
round,
|
||||
totalRounds: config.depth,
|
||||
message: `Search failed: ${errorMsg.slice(0, 80)}`,
|
||||
fraction: 0.25 + ((i + 1) / currentQueries.length) * 0.25,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const successfulSearches = searchResultsArrays.filter(
|
||||
(r): r is EnrichedSearchResult[] => r !== null,
|
||||
).length;
|
||||
const failedSearches = currentQueries.length - successfulSearches;
|
||||
|
||||
totalSearches += currentQueries.length;
|
||||
|
||||
// Track which URLs were independently surfaced by MULTIPLE different
|
||||
// queries. This is the corroboration signal: a source found by
|
||||
// several independent searches is stronger evidence than one found
|
||||
// by a single query.
|
||||
const urlQueryCounts = new Map<string, number>();
|
||||
searchResultsArrays.forEach((results, queryIndex) => {
|
||||
if (!results) return;
|
||||
const queryUrls = new Set(results.map((r) => r.url));
|
||||
for (const url of queryUrls) {
|
||||
urlQueryCounts.set(url, (urlQueryCounts.get(url) ?? 0) + 1);
|
||||
}
|
||||
// (queryIndex is unused beyond the closure; kept for clarity)
|
||||
void queryIndex;
|
||||
});
|
||||
|
||||
// ── Per-query result collection ──────────────────────────────────
|
||||
// Deduplicate within each query's results by URL (prefer higher
|
||||
// authority) AND by near-identical title (catches syndicated copies
|
||||
// of the same article under different URLs).
|
||||
const resultsByQuery: EnrichedSearchResult[][] = currentQueries.map(
|
||||
() => [],
|
||||
);
|
||||
|
||||
searchResultsArrays.forEach((results, queryIndex) => {
|
||||
if (!results) return;
|
||||
const seenUrls = new Set<string>();
|
||||
const seenTitles: string[] = [];
|
||||
for (const r of results) {
|
||||
if (seenUrls.has(r.url)) continue;
|
||||
seenUrls.add(r.url);
|
||||
|
||||
// Skip syndicated duplicates (same article, different URL)
|
||||
if (
|
||||
r.title &&
|
||||
seenTitles.some((t) => isNearDuplicateTitle(t, r.title))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (r.title) seenTitles.push(r.title);
|
||||
resultsByQuery[queryIndex].push(r);
|
||||
}
|
||||
});
|
||||
|
||||
// Global URL dedup across queries: a URL found by multiple queries
|
||||
// stays attached to the FIRST query that surfaced it (most likely
|
||||
// the most relevant one).
|
||||
const globalSeen = new Set<string>();
|
||||
for (const list of resultsByQuery) {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (globalSeen.has(list[i].url)) {
|
||||
list.splice(i, 1);
|
||||
} else {
|
||||
globalSeen.add(list[i].url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueResults = resultsByQuery.flat();
|
||||
totalPages += uniqueResults.length;
|
||||
|
||||
// ── Analyze phase (parallel with round-robin) ──────────────────
|
||||
|
||||
onProgress({
|
||||
phase: "analyzing",
|
||||
round,
|
||||
totalRounds: config.depth,
|
||||
message: `Analyzing ${uniqueResults.length} search results in parallel...`,
|
||||
fraction: 0.6,
|
||||
});
|
||||
|
||||
if (signal?.aborted) throw new Error("Research cancelled");
|
||||
|
||||
// Build query-result pairs for parallel analysis.
|
||||
// Each query is analyzed with the results IT actually produced,
|
||||
// so findings stay coherent with the query's intent and angle.
|
||||
const analysisTasks: Array<{
|
||||
query: (typeof currentQueries)[number];
|
||||
results: EnrichedSearchResult[];
|
||||
index: number;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < currentQueries.length; i++) {
|
||||
const queryResults = resultsByQuery[i];
|
||||
if (!queryResults || queryResults.length === 0) continue;
|
||||
|
||||
analysisTasks.push({
|
||||
query: currentQueries[i],
|
||||
results: queryResults,
|
||||
index: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Run analyses in parallel using round-robin bounded concurrency
|
||||
const findingsArrays: Finding[][] = await boundedConcurrency(
|
||||
analysisTasks,
|
||||
MAX_ANALYSIS_CONCURRENT,
|
||||
async (task) => {
|
||||
onProgress({
|
||||
phase: "analyzing",
|
||||
round,
|
||||
totalRounds: config.depth,
|
||||
message: `Analyzing: "${task.query.query.slice(0, 40)}..."`,
|
||||
fraction:
|
||||
0.6 + (task.index / Math.max(analysisTasks.length, 1)) * 0.2,
|
||||
});
|
||||
|
||||
try {
|
||||
return await analyzeResults(
|
||||
task.query.query,
|
||||
task.results,
|
||||
ctx.cwd,
|
||||
signal,
|
||||
task.query.angle,
|
||||
);
|
||||
} catch {
|
||||
// Analysis failure shouldn't crash the round
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Flatten all findings
|
||||
const allFindings: ResearchRound["findings"] = findingsArrays.flat();
|
||||
|
||||
// ── Corroboration pass ────────────────────────────────────────
|
||||
// Cross-reference findings to compute corroboration scores.
|
||||
// Corroboration = fraction of a finding's sources that were
|
||||
// independently surfaced by multiple different search queries.
|
||||
const corroboratedFindings = computeCorroboration(
|
||||
allFindings,
|
||||
urlQueryCounts,
|
||||
);
|
||||
|
||||
// Record this round
|
||||
const followUpTopics = corroboratedFindings
|
||||
.filter(
|
||||
(f: Finding) =>
|
||||
f.confidence === "low" && (f.corroborationScore ?? 0) < 0.5,
|
||||
)
|
||||
.map((f: Finding) => f.title);
|
||||
|
||||
rounds.push({
|
||||
round,
|
||||
queries: currentQueries,
|
||||
results: uniqueResults,
|
||||
findings: corroboratedFindings,
|
||||
followUpTopics,
|
||||
successfulSearches,
|
||||
failedSearches,
|
||||
});
|
||||
|
||||
// ── Adaptive depth: check for saturation ──────────────────────
|
||||
if (round > 1 && round < config.depth) {
|
||||
const saturation = assessSaturation(
|
||||
rounds[rounds.length - 2],
|
||||
rounds[rounds.length - 1],
|
||||
);
|
||||
if (saturation < SATURATION_THRESHOLD) {
|
||||
onProgress({
|
||||
phase: "synthesizing",
|
||||
message: `Information saturation reached (${(saturation * 100).toFixed(0)}% novelty) — synthesizing early`,
|
||||
fraction: 0.85,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synthesis phase ───────────────────────────────────────────────
|
||||
|
||||
onProgress({
|
||||
phase: "synthesizing",
|
||||
message: "Synthesizing research into final report...",
|
||||
fraction: 0.9,
|
||||
});
|
||||
|
||||
if (signal?.aborted) throw new Error("Research cancelled");
|
||||
|
||||
const synthesisResult: SynthesisResult = await synthesizeReport(
|
||||
config.question,
|
||||
rounds,
|
||||
config,
|
||||
ctx.cwd,
|
||||
signal,
|
||||
);
|
||||
const finalReport = synthesisResult.report;
|
||||
const references = synthesisResult.references;
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
onProgress({
|
||||
phase: "complete",
|
||||
message: "Research complete!",
|
||||
fraction: 1.0,
|
||||
});
|
||||
|
||||
return {
|
||||
question: config.question,
|
||||
rounds,
|
||||
finalReport,
|
||||
totalSearches,
|
||||
totalPagesScraped: totalPages,
|
||||
durationMs,
|
||||
references,
|
||||
};
|
||||
}
|
||||
106
src/types.ts
Normal file
106
src/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Deep Research — type definitions
|
||||
*/
|
||||
|
||||
/** Content type classification for a source */
|
||||
export type ContentType =
|
||||
| "documentation"
|
||||
| "paper"
|
||||
| "news"
|
||||
| "blog"
|
||||
| "forum"
|
||||
| "official"
|
||||
| "other";
|
||||
|
||||
/** A single search result from Firecrawl */
|
||||
export interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
/** Enriched search result with source authority metadata */
|
||||
export interface EnrichedSearchResult extends SearchResult {
|
||||
domain: string;
|
||||
authorityScore: number; // 0.0 – 1.0
|
||||
publishedDate: Date | null;
|
||||
contentType: ContentType;
|
||||
}
|
||||
|
||||
/** A finding extracted from search results by an analysis agent */
|
||||
export interface Finding {
|
||||
title: string;
|
||||
summary: string;
|
||||
sources: string[];
|
||||
keyQuotes: string[];
|
||||
confidence: "high" | "medium" | "low";
|
||||
/** The search query this finding was extracted under (provenance) */
|
||||
query?: string;
|
||||
/** The research angle of the originating query (provenance) */
|
||||
angle?: string;
|
||||
/** 0.0 – 1.0: how many independent sources support this finding */
|
||||
corroborationScore?: number;
|
||||
/** Authority score of the best source supporting this finding */
|
||||
bestSourceAuthority?: number;
|
||||
/** Average authority score across all sources */
|
||||
avgSourceAuthority?: number;
|
||||
}
|
||||
|
||||
/** A numbered reference with full metadata */
|
||||
export interface Reference {
|
||||
id: number;
|
||||
url: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
authorityScore: number;
|
||||
accessedAt: string; // ISO date string
|
||||
}
|
||||
|
||||
/** A generated search query with its intent/rationale */
|
||||
export interface SearchQuery {
|
||||
query: string;
|
||||
rationale: string;
|
||||
angle: string;
|
||||
}
|
||||
|
||||
/** Output from one research round */
|
||||
export interface ResearchRound {
|
||||
round: number;
|
||||
queries: SearchQuery[];
|
||||
results: EnrichedSearchResult[];
|
||||
findings: Finding[];
|
||||
/** Any follow-up questions/angles the analysis suggests */
|
||||
followUpTopics: string[];
|
||||
/** Number of search queries that actually returned data (non-empty) */
|
||||
successfulSearches: number;
|
||||
/** Number of search queries that failed entirely */
|
||||
failedSearches: number;
|
||||
}
|
||||
|
||||
/** Target audience expertise level */
|
||||
export type Audience = "expert" | "general" | "executive";
|
||||
|
||||
/** Configuration for a research session */
|
||||
export interface ResearchConfig {
|
||||
question: string;
|
||||
depth: number; // 1-3 rounds
|
||||
breadth: number; // queries per round (1-5)
|
||||
format: "markdown" | "structured";
|
||||
audience?: Audience;
|
||||
/** Focus on specific research angles only (empty = all angles) */
|
||||
focus?: string[];
|
||||
/** Show the research methodology section in the report */
|
||||
showMethodology?: boolean;
|
||||
}
|
||||
|
||||
/** Final research report */
|
||||
export interface ResearchReport {
|
||||
question: string;
|
||||
rounds: ResearchRound[];
|
||||
finalReport: string;
|
||||
totalSearches: number;
|
||||
totalPagesScraped: number;
|
||||
durationMs: number;
|
||||
references: Reference[];
|
||||
}
|
||||
Reference in New Issue
Block a user