/**
* Insight Synthesis — LLM + methodology-library tool-use loop.
*
* Highest narrative-risk agent in the pipeline. Every claim must be
* traceable to a real comparison / metric / methodology id; the
* downstream check in index.ts re-validates that supportingEvidence
* references exist.
*/
import Anthropic from '@anthropic-ai/sdk';
import type {
Tool,
ToolUseBlock,
MessageParam,
ContentBlock,
TextBlock,
} from '@anthropic-ai/sdk/resources/messages.js';
import { z } from 'zod';
import { recordUsage } from '../../../observability/usage.js';
import { buildSystemPrompt } from './prompt.js';
import { METHODOLOGY_TOOLS, executeMethodologyTool } from '../../../intelligence/tools.js';
import {
insightSchema,
type InsightSynthesisInput,
type InsightSynthesisOutput,
} from './schema.js';
import type { JobRequest } from '../../../types.js';
const apiKey = process.env.ANTHROPIC_API_KEY;
const client = apiKey ? new Anthropic({ apiKey }) : null;
if (!client) {
// eslint-disable-next-line no-console
console.log(`[intelligence.insight-synthesis] ANTHROPIC_API_KEY not set — agent will return a structured 'needs-api-key' failure.`);
}
const MODEL = 'claude-haiku-4-5';
const MAX_TOOL_ITERATIONS = 25;
const MAX_TOKENS_PER_TURN = 8000;
const ANTHROPIC_TOOLS: Tool[] = METHODOLOGY_TOOLS.map(t => ({
name: t.name,
description: t.description,
input_schema: t.input_schema,
})) as Tool[];
export const MODEL_NAME = MODEL;
export const TOOL_COUNT = METHODOLOGY_TOOLS.length;
export const TOOL_NAMES: readonly string[] = METHODOLOGY_TOOLS.map(t => t.name);
export interface LlmFailure {
readonly category: 'needs-api-key' | 'invalid-response' | 'sdk-error' | 'empty-response' | 'tool-loop-overrun';
readonly reason: string;
readonly hint?: string;
}
export type LlmResult<T> = { ok: true; value: T } | { ok: false; failure: LlmFailure };
export interface ToolCallTrace {
readonly toolName: string;
readonly input: Record<string, unknown>;
readonly ok: boolean;
readonly resultSummary: string;
readonly errorMessage?: string;
readonly at: string;
}
const responseSchema = z.object({
insights: z.array(insightSchema),
unsupportedClaimsRemoved: z.array(
z.object({ claim: z.string(), reason: z.string() }),
).default([]),
appliedFrameworks: z.array(z.string()).default([]),
notes: z.array(z.string()).default([]),
});
function buildUserMessage(comparisons: InsightSynthesisInput, job: JobRequest): string {
return [
`## Insight Synthesis — runbook execution`,
``,
`## JobRequest`,
`analysisId: ${job.analysisId}`,
`question: ${job.question}`,
`entities: ${job.entities.map(e => e.id).join(', ')}`,
`targetMetrics: ${job.targetMetrics.map(m => m.key).join(', ')}`,
``,
`## Comparisons from Agent 3 (${comparisons.comparisons.length})`,
JSON.stringify(comparisons.comparisons, null, 2),
comparisons.comparabilityFailures.length > 0
? `\n## Comparability failures from Agent 3 (${comparisons.comparabilityFailures.length})\n${JSON.stringify(comparisons.comparabilityFailures, null, 2)}`
: '',
``,
`## What to do`,
`Read the JobRequest question. Produce insights that DIRECTLY answer it, grounded in the comparisons above:`,
` 1. Call find_methodologies(type="insight_framework") to see what's encoded. If a relevant entry exists, call get_methodology and apply its framework.`,
` 2. For each insight you generate:`,
` - Identify the comparisonId / metricKey / methodology_id that supports it.`,
` - If you cannot cite at least one, either DROP the claim into unsupportedClaimsRemoved, OR mark it isInference=true with a clear note.`,
` - Set frameworkUsed to the methodology_id (if a library framework was applied) or a short label like "peer-positioning" / "trend-observation" / "outlier-flag".`,
` 3. Keep the insight count tight — three to seven well-supported insights beat thirty weak ones.`,
``,
`## Output`,
`Return ONLY a JSON object — no prose, no markdown fence — in this exact shape:`,
`{`,
` "insights": [`,
` {`,
` "insightId": string, // e.g. "jpm-leads-peer-revenue"`,
` "claim": string, // the actual narrative sentence`,
` "frameworkUsed": string, // methodology_id or short framework label`,
` "isInference": boolean,`,
` "supportingEvidence": [`,
` { "kind": "comparison" | "metric" | "methodology", "ref": string, "detail": string }`,
` ],`,
` "reasoningLineage": string[], // source URLs / methodology references the chain rests on`,
` "confidence": number,`,
` "flags": string[]`,
` }`,
` ],`,
` "unsupportedClaimsRemoved": [`,
` { "claim": string, "reason": string }`,
` ],`,
` "appliedFrameworks": string[], // unique frameworkUsed values`,
` "notes": string[]`,
`}`,
].filter(Boolean).join('\n');
}
function jsonResponseFromText(text: string): unknown {
const cleaned = text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim();
try { return JSON.parse(cleaned); } catch { /* fall through */ }
const m = cleaned.match(/\{[\s\S]*\}/);
if (!m) return null;
try { return JSON.parse(m[0]); } catch { return null; }
}
function summarize(name: string, ok: boolean, result: unknown): string {
if (!ok) return 'error';
if (name === 'find_methodologies' && Array.isArray(result)) {
return `${result.length} match(es): ${result.slice(0, 5).map((r: any) => r.methodology_id).join(', ')}`;
}
if (name === 'get_methodology' && result && typeof result === 'object') {
const r = result as { methodology_id?: string; name?: string };
return `${r.methodology_id ?? '?'} — ${r.name ?? ''}`;
}
return 'ok';
}
export interface SynthesizeResult {
readonly insights: InsightSynthesisOutput;
readonly toolCalls: readonly ToolCallTrace[];
}
export async function synthesizeInsights(
comparisons: InsightSynthesisInput,
job: JobRequest,
onToolCall?: (t: ToolCallTrace) => void,
): Promise<LlmResult<SynthesizeResult>> {
if (!client) {
return {
ok: false,
failure: { category: 'needs-api-key', reason: 'Insight Synthesis requires the LLM but ANTHROPIC_API_KEY is not configured.' },
};
}
const system = buildSystemPrompt();
const messages: MessageParam[] = [{ role: 'user', content: buildUserMessage(comparisons, job) }];
const toolCalls: ToolCallTrace[] = [];
let finalText = '';
for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) {
let resp;
try {
resp = await client.messages.create({
model: MODEL,
max_tokens: MAX_TOKENS_PER_TURN,
system,
tools: ANTHROPIC_TOOLS,
messages,
});
} catch (err) {
return { ok: false, failure: { category: 'sdk-error', reason: err instanceof Error ? err.message : String(err) } };
}
recordUsage('intelligence.insight-synthesis', MODEL, resp.usage.input_tokens, resp.usage.output_tokens);
messages.push({ role: 'assistant', content: resp.content as ContentBlock[] });
if (resp.stop_reason !== 'tool_use') {
const textBlock = resp.content.find((b): b is TextBlock => b.type === 'text');
finalText = textBlock ? textBlock.text : '';
break;
}
const toolUses = resp.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
const toolResults: { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }[] = [];
for (const tu of toolUses) {
const r = await executeMethodologyTool(tu.name, tu.input);
const trace: ToolCallTrace = {
toolName: tu.name,
input: (tu.input ?? {}) as Record<string, unknown>,
ok: r.ok,
resultSummary: summarize(tu.name, r.ok, r.result),
errorMessage: r.error?.message,
at: new Date().toISOString(),
};
toolCalls.push(trace);
onToolCall?.(trace);
toolResults.push({
type: 'tool_result',
tool_use_id: tu.id,
content: r.ok ? JSON.stringify(r.result) : JSON.stringify({ error: r.error }),
is_error: !r.ok,
});
}
messages.push({ role: 'user', content: toolResults });
}
if (!finalText) {
return { ok: false, failure: { category: 'tool-loop-overrun', reason: `Tool-use loop exceeded ${MAX_TOOL_ITERATIONS} iterations.` } };
}
const parsed = responseSchema.safeParse(jsonResponseFromText(finalText));
if (!parsed.success) {
return { ok: false, failure: { category: 'invalid-response', reason: `final response did not match Insights schema: ${parsed.error.message}` } };
}
return { ok: true, value: { insights: parsed.data, toolCalls } };
}