Forked from khtsly/deep-swarm-research
Forked from khtsly/deep-swarm-research
src / report / builder.ts
src / report / builder.ts
/**
* @file report/builder.ts
* Compiles the final Markdown report: synthesis, contradictions, coverage,
* sources, citation index, and run diagnostics.
*/
import type {
CompiledReport, ContradictionEntry, CrawledSource, DepthProfile, StatusFn,
} from "../core/types";
import { RunLedger, renderDiagnostics } from "../core/ledger";
import { detectCoveredDimensions, detectGapDimensions, DIMENSIONS } from "../core/dimensions";
import { extractKeywords, truncate } from "../core/util";
import { synthesiseReport, detectContradictions } from "../synthesis/ai";
function labelOf(id: string): string {
return DIMENSIONS.find((d) => d.id === id)?.label ?? id;
}
export async function buildReport(
topic: string,
sources: ReadonlyArray<CrawledSource>,
queriesUsed: ReadonlyArray<string>,
ledger: RunLedger,
usedAI: boolean,
enableAI: boolean,
profile: DepthProfile,
status: StatusFn,
learnings: ReadonlyArray<string> = [],
abortSignal?: AbortSignal,
partialNotice?: string,
): Promise<CompiledReport> {
const indexed = sources.map((s, i) => ({ ...s, index: i + 1 }));
const keywords = extractKeywords(topic);
const coveredIds = detectCoveredDimensions(indexed.map((s) => s.text));
const gapIds = detectGapDimensions(coveredIds).map((d) => d.id);
let aiSynthesis: string | null = null;
let contradictions: ReadonlyArray<ContradictionEntry> = [];
const aiActive = enableAI && indexed.length > 0 && !(abortSignal?.aborted ?? false);
if (aiActive) {
const coveredLabels = coveredIds.map(labelOf);
const gapLabels = gapIds.map(labelOf);
const [synthesisResult, contradictionResult] = await Promise.all([
synthesiseReport(topic, indexed, coveredLabels, gapLabels, status, profile, learnings, abortSignal)
.catch(() => null),
detectContradictions(topic, indexed, status, profile, abortSignal)
.catch(() => [] as ContradictionEntry[]),
]);
aiSynthesis = synthesisResult;
contradictions = contradictionResult;
}
const sections: string[] = [buildHeader(topic, indexed.length, usedAI, coveredIds.length)];
if (partialNotice !== undefined && partialNotice.length > 0) {
sections.push(partialNotice, "---");
}
sections.push("---");
if (aiSynthesis !== null) sections.push(`## Research Analysis\n\n${aiSynthesis}`, "---");
if (learnings.length > 0 && aiSynthesis === null) {
sections.push(
"## Key Learnings\n\n" +
learnings.map((l) => `- ${l}`).join("\n"),
"---",
);
}
if (contradictions.length > 0) sections.push(buildContradictionSection(contradictions), "---");
sections.push(
buildCoverageSection(coveredIds),
"---",
buildSourcesSection(indexed),
"---",
renderDiagnostics(ledger.snapshot(totalBudgetOf(profile), ledger.fetched)),
"---",
`*Generated by DeepResearch v2 · ${indexed.length} sources · ${queriesUsed.length} queries · Verify important claims against primary sources.*`,
);
return {
markdown: sections.join("\n\n"),
sources: indexed,
coveredDims: coveredIds,
gapDims: gapIds,
aiSynthesis: aiSynthesis ?? undefined,
contradictions,
};
}
function totalBudgetOf(_profile: DepthProfile): number {
// The orchestrator records actual fetches; budget ceiling is informational.
return _profile.pagesPerCrawlerRound * Math.max(3, _profile.crawlerCount);
}
function buildHeader(
topic: string,
sourceCount: number,
usedAI: boolean,
coverageCount: number,
): string {
return [
`# Research Report: ${topic}`,
"",
`- **Generated:** ${new Date().toUTCString()}`,
`- **Sources:** ${sourceCount}`,
`- **Dimensions covered:** ${coverageCount}/12`,
`- **Planning:** ${usedAI ? "AI-decomposed" : "template-based"}`,
].join("\n");
}
function buildContradictionSection(entries: ReadonlyArray<ContradictionEntry>): string {
const lines = ["## Contradictions Detected", ""];
for (const entry of entries) {
lines.push(
`### ${entry.claim}`,
`- **[${entry.sourceA.index}] ${entry.sourceA.title}**: ${entry.sourceA.stance}`,
`- **[${entry.sourceB.index}] ${entry.sourceB.title}**: ${entry.sourceB.stance}`,
`- Severity: \`${entry.severity}\``,
"",
);
}
return lines.join("\n");
}
function buildCoverageSection(coveredIds: ReadonlyArray<string>): string {
const covered = new Set(coveredIds);
const rows = DIMENSIONS.map((dim) =>
`| ${covered.has(dim.id) ? "yes" : "—"} | ${dim.label} |`).join("\n");
return ["## Dimension Coverage", "", "| Covered | Dimension |", "|---|---|", rows].join("\n");
}
function buildSourcesSection(sources: ReadonlyArray<CrawledSource>): string {
const lines = ["## Sources", ""];
for (const s of sources) {
const originTag = s.origin === "local" ? " `[local]`" : "";
const pub = s.published !== null ? ` (${s.published})` : "";
lines.push(
`**[${s.index}]** [${truncate(s.title, 110)}](${s.url})${pub}${originTag}`,
`> rel=${s.relevanceScore.toFixed(2)} · tier=${s.tier} · authority=${s.authorityScore} · ${s.wordCount} words · via ${s.engine}`,
`> ${truncate(s.description.replace(/\n+/g, " "), 200)}`,
"",
);
}
return lines.join("\n");
}
/**
* @file report/builder.ts
* Compiles the final Markdown report: synthesis, contradictions, coverage,
* sources, citation index, and run diagnostics.
*/
import type {
CompiledReport, ContradictionEntry, CrawledSource, DepthProfile, StatusFn,
} from "../core/types";
import { RunLedger, renderDiagnostics } from "../core/ledger";
import { detectCoveredDimensions, detectGapDimensions, DIMENSIONS } from "../core/dimensions";
import { extractKeywords, truncate } from "../core/util";
import { synthesiseReport, detectContradictions } from "../synthesis/ai";
function labelOf(id: string): string {
return DIMENSIONS.find((d) => d.id === id)?.label ?? id;
}
export async function buildReport(
topic: string,
sources: ReadonlyArray<CrawledSource>,
queriesUsed: ReadonlyArray<string>,
ledger: RunLedger,
usedAI: boolean,
enableAI: boolean,
profile: DepthProfile,
status: StatusFn,
learnings: ReadonlyArray<string> = [],
abortSignal?: AbortSignal,
partialNotice?: string,
): Promise<CompiledReport> {
const indexed = sources.map((s, i) => ({ ...s, index: i + 1 }));
const keywords = extractKeywords(topic);
const coveredIds = detectCoveredDimensions(indexed.map((s) => s.text));
const gapIds = detectGapDimensions(coveredIds).map((d) => d.id);
let aiSynthesis: string | null = null;
let contradictions: ReadonlyArray<ContradictionEntry> = [];
const aiActive = enableAI && indexed.length > 0 && !(abortSignal?.aborted ?? false);
if (aiActive) {
const coveredLabels = coveredIds.map(labelOf);
const gapLabels = gapIds.map(labelOf);
const [synthesisResult, contradictionResult] = await Promise.all([
synthesiseReport(topic, indexed, coveredLabels, gapLabels, status, profile, learnings, abortSignal)
.catch(() => null),
detectContradictions(topic, indexed, status, profile, abortSignal)
.catch(() => [] as ContradictionEntry[]),
]);
aiSynthesis = synthesisResult;
contradictions = contradictionResult;
}
const sections: string[] = [buildHeader(topic, indexed.length, usedAI, coveredIds.length)];
if (partialNotice !== undefined && partialNotice.length > 0) {
sections.push(partialNotice, "---");
}
sections.push("---");
if (aiSynthesis !== null) sections.push(`## Research Analysis\n\n${aiSynthesis}`, "---");
if (learnings.length > 0 && aiSynthesis === null) {
sections.push(
"## Key Learnings\n\n" +
learnings.map((l) => `- ${l}`).join("\n"),
"---",
);
}
if (contradictions.length > 0) sections.push(buildContradictionSection(contradictions), "---");
sections.push(
buildCoverageSection(coveredIds),
"---",
buildSourcesSection(indexed),
"---",
renderDiagnostics(ledger.snapshot(totalBudgetOf(profile), ledger.fetched)),
"---",
`*Generated by DeepResearch v2 · ${indexed.length} sources · ${queriesUsed.length} queries · Verify important claims against primary sources.*`,
);
return {
markdown: sections.join("\n\n"),
sources: indexed,
coveredDims: coveredIds,
gapDims: gapIds,
aiSynthesis: aiSynthesis ?? undefined,
contradictions,
};
}
function totalBudgetOf(_profile: DepthProfile): number {
// The orchestrator records actual fetches; budget ceiling is informational.
return _profile.pagesPerCrawlerRound * Math.max(3, _profile.crawlerCount);
}
function buildHeader(
topic: string,
sourceCount: number,
usedAI: boolean,
coverageCount: number,
): string {
return [
`# Research Report: ${topic}`,
"",
`- **Generated:** ${new Date().toUTCString()}`,
`- **Sources:** ${sourceCount}`,
`- **Dimensions covered:** ${coverageCount}/12`,
`- **Planning:** ${usedAI ? "AI-decomposed" : "template-based"}`,
].join("\n");
}
function buildContradictionSection(entries: ReadonlyArray<ContradictionEntry>): string {
const lines = ["## Contradictions Detected", ""];
for (const entry of entries) {
lines.push(
`### ${entry.claim}`,
`- **[${entry.sourceA.index}] ${entry.sourceA.title}**: ${entry.sourceA.stance}`,
`- **[${entry.sourceB.index}] ${entry.sourceB.title}**: ${entry.sourceB.stance}`,
`- Severity: \`${entry.severity}\``,
"",
);
}
return lines.join("\n");
}
function buildCoverageSection(coveredIds: ReadonlyArray<string>): string {
const covered = new Set(coveredIds);
const rows = DIMENSIONS.map((dim) =>
`| ${covered.has(dim.id) ? "yes" : "—"} | ${dim.label} |`).join("\n");
return ["## Dimension Coverage", "", "| Covered | Dimension |", "|---|---|", rows].join("\n");
}
function buildSourcesSection(sources: ReadonlyArray<CrawledSource>): string {
const lines = ["## Sources", ""];
for (const s of sources) {
const originTag = s.origin === "local" ? " `[local]`" : "";
const pub = s.published !== null ? ` (${s.published})` : "";
lines.push(
`**[${s.index}]** [${truncate(s.title, 110)}](${s.url})${pub}${originTag}`,
`> rel=${s.relevanceScore.toFixed(2)} · tier=${s.tier} · authority=${s.authorityScore} · ${s.wordCount} words · via ${s.engine}`,
`> ${truncate(s.description.replace(/\n+/g, " "), 200)}`,
"",
);
}
return lines.join("\n");
}