BID · Console
Baseline · Intelligence · Decision
src/agents/decision/output-ingestion/llm.ts 10,543 bytes · typescript
/**
 * Output Ingestion & Interpretation — LLM + Rule Library tool-use loop.
 *
 * Tool surface: find_rules + get_rule (the only two Pillar 3 tools).
 * The agent's index.ts has already filtered the candidate rules
 * deterministically per the spec's cost-appropriate execution guidance
 * (Std 6) — this LLM call's job is to APPLY the rule's natural-language
 * conditions to each insight, not to discover which rule applies.
 *
 * Output is the OutputIngestionOutput JSON object — recommendations
 * with rule citations + confidence + flags.
 */

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 { RULE_TOOLS, executeRuleTool } from '../../../decision/tools.js';
import {
  recommendationSchema,
  ruleGapEscalationSchema,
  type OutputIngestionInput,
  type OutputIngestionOutput,
} from './schema.js';
import type { Rule } from '../../../decision/library.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(`[decision.output-ingestion] ANTHROPIC_API_KEY not set — agent will return a structured 'needs-api-key' failure.`);
}

const MODEL = 'claude-haiku-4-5';
const MAX_TOOL_ITERATIONS = 15;
const MAX_TOKENS_PER_TURN = 6000;

const ANTHROPIC_TOOLS: Tool[] = RULE_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 = RULE_TOOLS.length;
export const TOOL_NAMES: readonly string[] = RULE_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({
  recommendations: z.array(recommendationSchema).default([]),
  ruleGapsEscalated: z.array(ruleGapEscalationSchema).default([]),
  appliedRules: z.array(z.string()).default([]),
  notes: z.array(z.string()).default([]),
});

export interface InterpretationCandidate {
  /** The insight this candidate corresponds to. */
  readonly insightId: string;
  readonly claim: string;
  readonly frameworkUsed: string;
  readonly isInference: boolean;
  readonly confidence: number;
  /** Rule(s) that matched on triggers, in precedence order — already
   *  resolved by index.ts so the LLM does not have to. */
  readonly candidateRules: readonly Rule[];
}

function buildUserMessage(
  candidates: readonly InterpretationCandidate[],
  audienceTier: string,
  job: JobRequest,
): string {
  return [
    `## Output Ingestion & Interpretation — runbook step 4 (apply-rule)`,
    ``,
    `## JobRequest`,
    `analysisId:   ${job.analysisId}`,
    `question:     ${job.question}`,
    `audienceTier: ${audienceTier}`,
    `entities:     ${job.entities.map(e => e.id).join(', ')}`,
    ``,
    `## Pre-resolved rule candidates per insight`,
    `(index.ts has already filtered candidates via find_rules and resolved precedence —`,
    ` you do NOT need to call find_rules again unless a candidate's full content is missing.)`,
    ``,
    JSON.stringify(candidates.map(c => ({
      insightId: c.insightId,
      claim: c.claim,
      frameworkUsed: c.frameworkUsed,
      isInference: c.isInference,
      confidence: c.confidence,
      candidateRules: c.candidateRules.map(r => ({
        rule_id: r.rule_id,
        name: r.name,
        type: r.type,
        domain: r.domain,
        conditions: r.conditions,
        action: r.action,
        confidence_framework: r.confidence_framework,
        disclosure_policy: r.disclosure_policy,
      })),
    })), null, 2),
    ``,
    `## What to do`,
    `For each insight: pick the highest-precedence candidate rule (already first in the list),`,
    `evaluate its conditions against the insight's claim and the audienceTier, and produce a`,
    `Recommendation per the rule's action specification.`,
    ``,
    `Rules of conduct (Std 3 + Std 4):`,
    `  - Apply the rule's language constraints — peer-positioning rules require factual, non-causal language.`,
    `  - Set ruleApplied to the rule_id you chose.`,
    `  - Set supportingFindings to include the insight (kind="insight", ref=<insightId>) plus any`,
    `    comparison/metric/methodology refs the insight itself cited.`,
    `  - Set audienceTier to the tier above (the JobRequest's audience).`,
    `  - Set entityIdentifier when the recommendation is about a specific entity (from the insight's claim).`,
    `  - Apply the rule's confidence_framework: start from the insight's confidence, then apply each`,
    `    adjustment that matches the data (e.g. isInference=true → -0.10).`,
    `  - Set severity per the rule's severity_mapping for the suggested_action_category you chose.`,
    `  - If a candidate's conditions genuinely do not match the insight, mark a ruleGapsEscalated entry`,
    `    with reason="no-rule-matched" (or "rule-conflict-unresolved" if it's a tie you cannot break).`,
    ``,
    `## Output`,
    `Return ONLY a JSON object — no prose, no markdown fence — in this exact shape:`,
    `{`,
    `  "recommendations": [`,
    `    {`,
    `      "recommendationId":       string,`,
    `      "sourceInsightId":        string,`,
    `      "ruleApplied":            string,`,
    `      "language":               string,`,
    `      "suggestedActionCategory":string,`,
    `      "severity":               "low" | "normal" | "material" | "high_impact",`,
    `      "statisticalPosition":    string | undefined,`,
    `      "entityIdentifier":       string | undefined,`,
    `      "audienceTier":           string,`,
    `      "supportingFindings":     [ { "kind": ..., "ref": ..., "detail": ... } ],`,
    `      "reasoningLineage":       string[],`,
    `      "confidence":             number,`,
    `      "flags":                  string[]`,
    `    }`,
    `  ],`,
    `  "ruleGapsEscalated": [ { "sourceInsightId": ..., "reason": ..., "detail": ..., "triedTriggers": [...] } ],`,
    `  "appliedRules": string[],`,
    `  "notes": string[]`,
    `}`,
  ].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_rules' && Array.isArray(result)) {
    return `${result.length} match(es): ${result.slice(0, 5).map((r: any) => r.rule_id).join(', ')}`;
  }
  if (name === 'get_rule' && result && typeof result === 'object') {
    const r = result as { rule_id?: string; name?: string };
    return `${r.rule_id ?? '?'} — ${r.name ?? ''}`;
  }
  return 'ok';
}

export interface InterpretResult {
  readonly output: OutputIngestionOutput;
  readonly toolCalls: readonly ToolCallTrace[];
}

export async function interpretFindings(
  candidates: readonly InterpretationCandidate[],
  audienceTier: string,
  job: JobRequest,
  onToolCall?: (t: ToolCallTrace) => void,
): Promise<LlmResult<InterpretResult>> {
  if (!client) {
    return {
      ok: false,
      failure: { category: 'needs-api-key', reason: 'Output Ingestion requires the LLM but ANTHROPIC_API_KEY is not configured.' },
    };
  }
  const system = buildSystemPrompt();
  const messages: MessageParam[] = [{
    role: 'user',
    content: buildUserMessage(candidates, audienceTier, 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('decision.output-ingestion', 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 executeRuleTool(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 OutputIngestion schema: ${parsed.error.message}` } };
  }
  return { ok: true, value: { output: parsed.data, toolCalls } };
}