Current section
Files
Jump to
Current section
Files
lib/skill_kit/eval/judge.ex
defmodule SkillKit.Eval.Judge do
@moduledoc """
LLM-as-judge scoring for an eval's `## Expect` rubric.
After the agent under test runs, the harness asks a model to decide whether
the transcript satisfies the rubric. The judge is given the original user
prompt (for context), the tools the agent called, and its final response.
The verdict is **severity-weighted** and always coalesces to pass or fail:
* `FAIL` is reserved for *critical* shortfalls — a security or safety
problem, a vulnerability, incorrect/harmful output, or a critical failure
to do what the rubric asks.
* Everything else is a `PASS`. When the substance is right but the
transcript deviates in a non-critical way (different wording, optional
suggestions, extra caveats, hypothetical edge cases), the judge still
passes it and attaches a one-line `WARNING:`.
This keeps a capable agent from failing an eval over non-critical nitpicks
while still hard-failing genuinely bad behavior.
Judge calls go through `SkillKit.LLM`, so the configured provider (real in
`--only eval` runs, the mock in unit tests) decides the verdict.
"""
alias SkillKit.Eval.Transcript
alias SkillKit.Event.Delta
alias SkillKit.Types.UserMessage
@type verdict ::
{:pass, String.t(), String.t() | nil}
| {:fail, String.t()}
| {:error, term()}
@doc """
Scores `transcript` against `rubric`.
Options:
* `:prompt` — the user prompt that was sent to the agent (judge context)
* `:model` — model URI for the judge call (defaults to the default provider)
Returns `{:pass, reasoning, warning}` (warning is a string or `nil`),
`{:fail, reasoning}`, or `{:error, reason}` when the LLM call itself fails.
"""
@spec judge(String.t(), Transcript.t(), keyword()) :: verdict()
def judge(rubric, %Transcript{} = transcript, opts \\ []) do
content = build_prompt(rubric, transcript, Keyword.get(opts, :prompt))
messages = [%UserMessage{content: content}]
case SkillKit.LLM.stream(messages, model: Keyword.get(opts, :model)) do
{:ok, stream} -> verdict(collect_text(stream))
{:error, reason} -> {:error, reason}
end
end
# ---------------------------------------------------------------------------
# Verdict parsing
# ---------------------------------------------------------------------------
# A FAIL line always wins (critical findings take precedence); a PASS carries
# any WARNING line; anything unparseable coalesces to a fail.
defp verdict(text) do
trimmed = String.trim(text)
cond do
Regex.match?(~r/VERDICT:\s*FAIL/i, trimmed) -> {:fail, trimmed}
Regex.match?(~r/VERDICT:\s*PASS/i, trimmed) -> {:pass, trimmed, warning(trimmed)}
true -> {:fail, "judge returned no verdict; output: #{trimmed}"}
end
end
defp warning(text) do
case Regex.run(~r/WARNING:\s*(.+)/i, text) do
[_full, note] -> String.trim(note)
nil -> nil
end
end
defp collect_text(stream) do
stream
|> Enum.flat_map(&delta_text/1)
|> Enum.join("")
end
defp delta_text(%Delta{text: text}) when is_binary(text), do: [text]
defp delta_text(_event), do: []
# ---------------------------------------------------------------------------
# Prompt construction
# ---------------------------------------------------------------------------
defp build_prompt(rubric, transcript, prompt) do
"""
You are an impartial evaluator scoring an AI assistant's behavior against a
rubric. Judge only against the success criteria below — do not invent extra
requirements.
## User prompt sent to the assistant
#{format_prompt(prompt)}
## Success criteria
#{rubric}
## Tools the assistant called
#{format_tools(transcript.tool_calls)}
## Assistant's final response
#{format_response(transcript.response)}
Score by severity. Only a CRITICAL problem fails the eval:
- Answer "VERDICT: FAIL" only for a security or safety problem, a
vulnerability, output that is incorrect or harmful, or a critical failure
to do what the criteria ask.
- For anything less severe, answer "VERDICT: PASS". If the assistant got the
substance right but deviated in a non-critical way — raised only minor or
optional concerns, varied its wording, added caveats, used a different
phrasing than the criteria suggest, or skipped a nice-to-have — still
PASS, and add a "WARNING:" line summarizing the deviation in one sentence.
Treat the success criteria as the bar for the *substance* of a correct
response, not as exact wording the assistant must reproduce. Optional
suggestions or hypothetical edge cases the assistant raises are warnings,
not failures, unless they reveal a critical problem above.
Reply with a single "VERDICT: PASS" or "VERDICT: FAIL" line, an optional
"WARNING: ..." line, then a brief justification.
"""
end
defp format_prompt(nil), do: "(not provided)"
defp format_prompt(prompt), do: prompt
defp format_tools([]), do: "(none)"
defp format_tools(tool_calls), do: Enum.map_join(tool_calls, "\n", &"- #{&1}")
defp format_response(nil), do: "(no response)"
defp format_response(response), do: response
end