Project Files
src / synthesis / ai.ts
/**
* @file synthesis/ai.ts
* AI-powered report synthesis and contradiction detection.
* Now accepts depth-profile parameters so synthesis scales with depth.
*/
import { LMStudioClient } from "@lmstudio/sdk";
import { ReportSource, ContradictionEntry, StatusFn } from "../types";
import {
DepthProfile,
AI_SYNTHESIS_TEMPERATURE,
AI_SYNTHESIS_TIMEOUT_MS,
AI_CONTRADICTION_TEMPERATURE,
AI_CONTRADICTION_TIMEOUT_MS,
CONTRADICTION_SOURCE_CHARS,
} from "../constants";
async function callModel(
prompt: string,
maxTokens: number,
temperature: number,
timeoutMs: number,
): Promise<string | null> {
try {
const client = new LMStudioClient();
const models = await Promise.race<
Awaited<ReturnType<typeof client.llm.listLoaded>>
>([
client.llm.listLoaded(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), timeoutMs),
),
]);
if (!Array.isArray(models) || models.length === 0) return null;
const model = await client.llm.model(models[0].identifier);
const stream = model.respond([{ role: "user", content: prompt }], {
maxTokens,
temperature,
});
let result = "";
for await (const chunk of stream) result += chunk.content ?? "";
return result.trim() || null;
} catch {
return null;
}
}
function prepareSources(
sources: ReadonlyArray<ReportSource>,
charsPerSrc: number,
maxSources: number,
): string {
return sources
.slice(0, maxSources)
.map((s) => {
const preview = s.text.slice(0, charsPerSrc).replace(/\n+/g, " ").trim();
const pub = s.published ? ` (${s.published})` : "";
return `[${s.index}] "${s.title}"${pub} — ${s.tier} (score: ${s.domainScore})\n${preview}`;
})
.join("\n\n");
}
/**
* Asks the loaded model to write a coherent, well-structured narrative
* synthesis of the research findings.
*
* Now scales synthesis output size and input sources with depth profile.
*/
export async function synthesiseReport(
topic: string,
sources: ReadonlyArray<ReportSource>,
coveredDims: ReadonlyArray<string>,
gapDims: ReadonlyArray<string>,
status: StatusFn,
profile: DepthProfile,
): Promise<string | null> {
if (sources.length === 0) return null;
status(
`AI synthesis — writing narrative research analysis (${sources.length} sources, up to ${profile.synthesisMaxSources} in prompt)…`,
);
const sourceBlock = prepareSources(
sources,
profile.synthesisSourceChars,
profile.synthesisMaxSources,
);
const paragraphHint =
sources.length > 50
? "8-15 paragraphs"
: sources.length > 20
? "6-10 paragraphs"
: "4-8 paragraphs";
const prompt = `You are an expert research analyst. Write a comprehensive, well-structured narrative synthesis of these research findings.
TOPIC: "${topic}"
TOTAL SOURCES AVAILABLE: ${sources.length}
DIMENSIONS COVERED: ${coveredDims.join(", ")}
${gapDims.length > 0 ? `GAPS (not fully covered): ${gapDims.join(", ")}` : "All research dimensions covered."}
SOURCES:
${sourceBlock}
INSTRUCTIONS:
1. Write ${paragraphHint} of coherent analysis — NOT a list of bullet points
2. Synthesise thematically: group related findings across sources, don't just summarise each source
3. Use inline citations like [1], [2], [3] when referencing specific sources
4. Highlight areas where sources AGREE (consensus) and where they DISAGREE (contradictions)
5. Note any important limitations or gaps in the available evidence
6. End with 2-3 key takeaways
7. Write in a neutral, analytical tone — like a research brief
8. Do NOT start with "This report" or "This synthesis" — jump straight into the analysis
9. Be thorough — the user wants comprehensive coverage, not a summary
SYNTHESIS:`;
const timeoutMs = Math.max(
AI_SYNTHESIS_TIMEOUT_MS,
AI_SYNTHESIS_TIMEOUT_MS + sources.length * 500,
);
const result = await callModel(
prompt,
profile.synthesisMaxTokens,
AI_SYNTHESIS_TEMPERATURE,
timeoutMs,
);
if (result && result.length > 100) {
status(`AI synthesis complete (${result.length} chars)`);
return result;
}
status("AI synthesis unavailable — using structured extraction fallback");
return null;
}
/**
* Asks the model to identify claims where sources disagree.
* Now scales with depth profile.
*/
export async function detectContradictions(
topic: string,
sources: ReadonlyArray<ReportSource>,
status: StatusFn,
profile: DepthProfile,
): Promise<ReadonlyArray<ContradictionEntry>> {
if (sources.length < 3) return [];
status("Checking for cross-source contradictions...");
const sourceBlock = prepareSources(
sources,
CONTRADICTION_SOURCE_CHARS,
profile.contradictionMaxSources,
);
const maxContradictions = Math.min(
10,
Math.max(5, Math.floor(sources.length / 5)),
);
const prompt = `You are a fact-checking analyst. Given these research sources on "${topic}", identify any CONTRADICTIONS — places where two sources make conflicting claims about the same thing.
SOURCES:
${sourceBlock}
For each contradiction found, output ONE line in this exact format:
CLAIM: <what the disagreement is about> | SOURCE_A: [<index>] <their stance> | SOURCE_B: [<index>] <their stance> | SEVERITY: <minor/moderate/major>
Rules:
- Only report genuine factual contradictions, not stylistic differences
- SEVERITY: minor = different emphasis, moderate = conflicting data/claims, major = directly opposing conclusions
- If no contradictions found, output: NONE
- Maximum ${maxContradictions} contradictions
OUTPUT:`;
const raw = await callModel(
prompt,
Math.max(1500, maxContradictions * 200),
AI_CONTRADICTION_TEMPERATURE,
AI_CONTRADICTION_TIMEOUT_MS,
);
if (!raw || /^NONE$/im.test(raw.trim())) {
status("No contradictions detected");
return [];
}
const entries: ContradictionEntry[] = [];
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("CLAIM:")) continue;
try {
const claimMatch = /CLAIM:\s*(.+?)\s*\|/.exec(trimmed);
const sourceAMatch = /SOURCE_A:\s*\[(\d+)\]\s*(.+?)\s*\|/.exec(trimmed);
const sourceBMatch = /SOURCE_B:\s*\[(\d+)\]\s*(.+?)\s*\|/.exec(trimmed);
const sevMatch = /SEVERITY:\s*(minor|moderate|major)/i.exec(trimmed);
if (!claimMatch || !sourceAMatch || !sourceBMatch) continue;
const idxA = parseInt(sourceAMatch[1], 10);
const idxB = parseInt(sourceBMatch[1], 10);
const srcA = sources.find((s) => s.index === idxA);
const srcB = sources.find((s) => s.index === idxB);
entries.push({
claim: claimMatch[1].trim(),
sourceA: {
index: idxA,
title: srcA?.title ?? `Source ${idxA}`,
stance: sourceAMatch[2].trim(),
},
sourceB: {
index: idxB,
title: srcB?.title ?? `Source ${idxB}`,
stance: sourceBMatch[2].trim(),
},
severity: (sevMatch?.[1]?.toLowerCase() ?? "minor") as
| "minor"
| "moderate"
| "major",
});
} catch {
continue;
}
}
if (entries.length > 0) {
status(`${entries.length} contradiction(s) detected`);
} else {
status("No contradictions detected");
}
return entries;
}