/** * `createScorer` and a batteries-included set of built-in scorers. * * A scorer is a 4-step pipeline (preprocess → analyze → generateScore → * generateReason). `createScorer` is a thin identity-with-validation factory: * it enforces the one hard contract (`generateScore` is required) and returns * a fully-typed `Scorer`. Built-in scorers below show both flavors: * * - `exactMatch` / `contains` — pure-JS analyze, no model. * - `llmJudge` — analyze runs an LLM judge through the resolved engine * (provider-agnostic; the model is whatever the runner resolved). */ import type { AgentRunOutput, Scorer, ScorerDefinition } from "./types.js"; /** * Create a scorer from a 4-step pipeline definition. * * `generateScore` is the only required step. `preprocess`/`analyze` default to * identity (the scorer sees the raw `AgentRunOutput`), and `generateReason` is * optional. */ export function createScorer
(
  def: ScorerDefinition,
): Scorer {
  if (!def.name || typeof def.name !== "string") {
    throw new Error("createScorer: `name` is required");
  }
  if (typeof def.generateScore !== "function") {
    throw new Error(
      `createScorer("${def.name}"): \`generateScore\` is required`,
    );
  }
  return {
    name: def.name,
    preprocess: def.preprocess,
    analyze: def.analyze,
    generateScore: def.generateScore,
    generateReason: def.generateReason,
  };
}

/** Clamp any number into [0, 1]; coerce non-finite to 0. */
export function clamp01(n: number): number {
  if (!Number.isFinite(n)) return 0;
  return Math.max(0, Math.min(1, n));
}

// ─── Built-in JS scorers ──────────────────────────────────────────────

/** Normalize for forgiving text comparison (case + surrounding whitespace). */
function normalize(s: string): string {
  return s.trim().toLowerCase();
}

/**
 * `exactMatch` — 1.0 when the agent's (trimmed, case-insensitive by default)
 * text equals `expected`, else 0.0. Pure JS, no model.
 */
export function exactMatch(
  expected: string,
  opts: { caseSensitive?: boolean } = {},
): Scorer {
  return createScorer({
    name: "exact_match",
    analyze(run) {
      const actual = opts.caseSensitive ? run.text.trim() : normalize(run.text);
      const want = opts.caseSensitive ? expected.trim() : normalize(expected);
      return { match: actual === want };
    },
    generateScore({ match }) {
      return match ? 1 : 0;
    },
    generateReason({ analysis }) {
      return analysis.match
        ? `Output exactly matched expected text`
        : `Output did not exactly match expected text`;
    },
  });
}

/**
 * `contains` — 1.0 when the agent's text contains every required substring
 * (case-insensitive by default). Score is the fraction matched, so a partial
 * hit still surfaces signal. Pure JS, no model.
 */
export function contains(
  needles: string | string[],
  opts: { caseSensitive?: boolean } = {},
): Scorer {
  const list = (Array.isArray(needles) ? needles : [needles]).filter(Boolean);
  return createScorer({
    name: "contains",
    analyze(run) {
      const hay = opts.caseSensitive ? run.text : run.text.toLowerCase();
      const found: string[] = [];
      const missing: string[] = [];
      for (const n of list) {
        const needle = opts.caseSensitive ? n : n.toLowerCase();
        if (hay.includes(needle)) found.push(n);
        else missing.push(n);
      }
      return { found, missing };
    },
    generateScore({ found }) {
      return list.length === 0 ? 1 : found.length / list.length;
    },
    generateReason({ analysis }) {
      if (analysis.missing.length === 0) {
        return `All ${list.length} required phrase(s) present`;
      }
      return `Missing: ${analysis.missing.join(", ")}`;
    },
  });
}

/**
 * `usesTool` — 1.0 when the agent invoked the named tool/action at least once.
 * Useful as a behavioral gate ("the agent must call send-email"). Pure JS.
 */
export function usesTool(
  toolName: string,
): Scorer {
  return createScorer({
    name: `uses_tool:${toolName}`,
    analyze(run) {
      return { used: run.toolCalls.includes(toolName) };
    },
    generateScore({ used }) {
      return used ? 1 : 0;
    },
    generateReason({ analysis }) {
      return analysis.used
        ? `Agent called \`${toolName}\``
        : `Agent never called \`${toolName}\``;
    },
  });
}

// ─── Built-in LLM-judge scorer ────────────────────────────────────────

interface JudgeVerdict {
  score: number;
  reasoning: string;
}

/**
 * Pull the first JSON object out of model text (which may be wrapped in prose
 * or a ```json fence) and parse it into a verdict. Returns null on garbage so
 * the caller can degrade gracefully instead of throwing.
 */
function parseJudgeVerdict(text: string): JudgeVerdict | null {
  const match = text.match(/\{[\s\S]*\}/);
  if (!match) return null;
  try {
    const parsed = JSON.parse(match[0]) as Partial;
    if (typeof parsed.score !== "number") return null;
    return {
      score: parsed.score,
      reasoning: typeof parsed.reasoning === "string" ? parsed.reasoning : "",
    };
  } catch {
    return null;
  }
}

export interface LlmJudgeOptions {
  /** Scorer name (defaults to `llm_judge`). */
  name?: string;
  /** What is being judged, e.g. "helpfulness". */
  criteria: string;
  /** A rubric describing what 0.0 vs 1.0 means. */
  rubric?: string;
  /**
   * The score scale the judge is told to use. Output is normalized to [0,1].
   * Defaults to a 0..1 scale.
   */
  scoreRange?: { min: number; max: number };
}

/**
 * `llmJudge` — an LLM-as-judge scorer. The analyze step asks the resolved
 * engine to score the agent output against a natural-language rubric and emit
 * `{ "score": , "reasoning": "" }`. The model is whatever the runner
 * resolved from the engine registry — this scorer NEVER hardcodes a provider
 * or model, so evals stay provider-agnostic.
 */
export function llmJudge(
  opts: LlmJudgeOptions,
): Scorer<
  AgentRunOutput,
  { verdict: JudgeVerdict | null; normalized: number }
> {
  const min = opts.scoreRange?.min ?? 0;
  const max = opts.scoreRange?.max ?? 1;
  const name = opts.name ?? "llm_judge";

  return createScorer<
    AgentRunOutput,
    { verdict: JudgeVerdict | null; normalized: number }
  >({
    name,
    async analyze(run, ctx) {
      const prompt = `You are an expert evaluator. Score the agent output below against the criteria.

## Criteria
${opts.criteria}${opts.rubric ? `\n\n## Rubric\n${opts.rubric}` : ""}

## Agent Output
${run.text || "(no text output)"}

## Tools the agent used
${run.toolCalls.length ? run.toolCalls.join(", ") : "(none)"}

## Instructions
Respond with ONLY a JSON object (no markdown, no prose outside the JSON):
{"score": , "reasoning": ""}`;

      const text = await ctx.judge({
        systemPrompt:
          "You are an evaluation judge. Respond only with valid JSON.",
        prompt,
        maxOutputTokens: 512,
      });
      const verdict = parseJudgeVerdict(text);
      const normalized =
        verdict === null
          ? 0
          : max > min
            ? (verdict.score - min) / (max - min)
            : verdict.score;
      return { verdict, normalized };
    },
    generateScore({ normalized }) {
      return clamp01(normalized);
    },
    generateReason({ analysis }) {
      if (analysis.verdict === null) {
        return "Judge did not return a parseable verdict";
      }
      return analysis.verdict.reasoning || "(no reasoning provided)";
    },
  });
}