Packages
nous
0.15.0
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/eval/test_case.ex
defmodule Nous.Eval.TestCase do
@moduledoc """
Defines a single test case for agent evaluation.
A test case specifies an input prompt, expected output, and evaluation criteria.
## Example
test_case = TestCase.new(
id: "weather_query",
name: "Weather Query Test",
input: "What's the weather in Tokyo?",
expected: %{contains: ["Tokyo", "weather"]},
eval_type: :contains,
tags: [:tool, :basic],
timeout: 30_000
)
## Evaluation Types
- `:exact_match` - Output must exactly match expected string
- `:fuzzy_match` - String similarity must exceed threshold
- `:contains` - Output must contain all expected substrings
- `:tool_usage` - Verify correct tools were called with correct args
- `:schema` - Validate output against Ecto schema
- `:llm_judge` - Use LLM to judge output quality
- `:custom` - Use custom evaluator module
## Expected Formats
The expected value format depends on the eval_type:
- `:exact_match` - `"expected string"`
- `:fuzzy_match` - `"expected string"` (with threshold in eval_config)
- `:contains` - `%{contains: ["word1", "word2"]}` or `["word1", "word2"]`
- `:tool_usage` - `%{tools_called: ["tool_name"], output_contains: ["..."]}`
- `:schema` - `MyApp.Schema` (module name)
- `:llm_judge` - `%{criteria: "...", rubric: "..."}`
- `:custom` - Any format understood by your evaluator
"""
@type eval_type ::
:exact_match
| :fuzzy_match
| :contains
| :tool_usage
| :schema
| :llm_judge
| :custom
@type t :: %__MODULE__{
id: String.t(),
name: String.t() | nil,
description: String.t() | nil,
input: String.t() | [Nous.Message.t()],
expected: term(),
eval_type: eval_type(),
eval_config: map(),
tags: [atom()],
deps: map(),
tools: [Nous.Tool.t()] | nil,
agent_config: keyword(),
timeout: non_neg_integer(),
metadata: map()
}
@enforce_keys [:id, :input]
defstruct [
:id,
:name,
:description,
:input,
:expected,
eval_type: :contains,
eval_config: %{},
tags: [],
deps: %{},
tools: nil,
agent_config: [],
timeout: 30_000,
metadata: %{}
]
@doc """
Create a new test case.
## Options
* `:id` - Unique identifier (required)
* `:input` - Input prompt or messages (required)
* `:name` - Human-readable name
* `:description` - Longer description
* `:expected` - Expected output (format depends on eval_type)
* `:eval_type` - Evaluation type (default: :contains)
* `:eval_config` - Configuration for the evaluator
* `:tags` - List of tags for filtering
* `:deps` - Dependencies to pass to agent
* `:tools` - Tools to provide to the agent
* `:agent_config` - Additional agent configuration
* `:timeout` - Timeout in milliseconds (default: 30_000)
* `:metadata` - Additional metadata
## Examples
# Simple contains check
TestCase.new(
id: "greeting",
input: "Say hello",
expected: %{contains: ["hello"]}
)
# Exact match
TestCase.new(
id: "math",
input: "What is 2+2?",
expected: "4",
eval_type: :exact_match
)
# Fuzzy match with threshold
TestCase.new(
id: "fuzzy",
input: "What is the capital of France?",
expected: "Paris is the capital of France",
eval_type: :fuzzy_match,
eval_config: %{threshold: 0.7}
)
# Tool usage verification
TestCase.new(
id: "tool_test",
input: "What's the weather?",
expected: %{tools_called: ["get_weather"]},
eval_type: :tool_usage,
tools: [weather_tool]
)
"""
@spec new(keyword()) :: t()
def new(opts) when is_list(opts) do
id = Keyword.fetch!(opts, :id)
input = Keyword.fetch!(opts, :input)
%__MODULE__{
id: to_string(id),
name: Keyword.get(opts, :name),
description: Keyword.get(opts, :description),
input: input,
expected: Keyword.get(opts, :expected),
eval_type: Keyword.get(opts, :eval_type, :contains),
eval_config: Keyword.get(opts, :eval_config, %{}),
tags: Keyword.get(opts, :tags, []) |> Enum.map(&safe_to_atom/1) |> Enum.reject(&is_nil/1),
deps: Keyword.get(opts, :deps, %{}),
tools: Keyword.get(opts, :tools),
agent_config: Keyword.get(opts, :agent_config, []),
timeout: Keyword.get(opts, :timeout, 30_000),
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Create a test case from a map (used by YAML loader).
"""
@spec from_map(map()) :: {:ok, t()} | {:error, term()}
def from_map(map) when is_map(map) do
with {:ok, id} <- fetch_required(map, [:id, "id"]),
{:ok, input} <- fetch_required(map, [:input, "input"]) do
test_case = %__MODULE__{
id: to_string(id),
name: get_any(map, [:name, "name"]),
description: get_any(map, [:description, "description"]),
input: input,
expected: get_any(map, [:expected, "expected"]),
eval_type: get_any(map, [:eval_type, "eval_type"], :contains) |> parse_eval_type(),
eval_config: get_any(map, [:eval_config, "eval_config"], %{}) |> atomize_keys(),
tags:
get_any(map, [:tags, "tags"], []) |> Enum.map(&safe_to_atom/1) |> Enum.reject(&is_nil/1),
deps: get_any(map, [:deps, "deps"], %{}),
tools: nil,
agent_config: get_any(map, [:agent_config, "agent_config"], %{}) |> atomize_keys(),
timeout: get_any(map, [:timeout, "timeout"], 30_000),
metadata: get_any(map, [:metadata, "metadata"], %{})
}
{:ok, test_case}
end
end
# YAML files come from arbitrary user paths (--suite). Whitelist eval_type so
# untrusted suite files cannot inject arbitrary atoms into the global table.
@valid_eval_types ~w(exact_match fuzzy_match contains tool_usage schema llm_judge custom)a
defp parse_eval_type(value) when is_atom(value) do
if value in @valid_eval_types, do: value, else: :contains
end
defp parse_eval_type(value) when is_binary(value) do
case Enum.find(@valid_eval_types, fn a -> Atom.to_string(a) == value end) do
nil -> value
atom -> atom
end
end
defp parse_eval_type(_), do: :contains
# Convert a YAML scalar to an existing atom; unknown values are dropped.
# NEVER use String.to_atom/1 here - YAML files are user-controllable input.
defp safe_to_atom(nil), do: nil
defp safe_to_atom(value) when is_atom(value), do: value
defp safe_to_atom(value) when is_binary(value) do
String.to_existing_atom(value)
rescue
ArgumentError -> nil
end
defp safe_to_atom(_), do: nil
@doc """
Validate a test case.
"""
@spec validate(t()) :: :ok | {:error, term()}
def validate(%__MODULE__{} = tc) do
cond do
is_nil(tc.id) or tc.id == "" ->
{:error, "Test case ID is required"}
is_nil(tc.input) ->
{:error, "Test case input is required"}
tc.eval_type not in [
:exact_match,
:fuzzy_match,
:contains,
:tool_usage,
:schema,
:llm_judge,
:custom
] ->
{:error, "Invalid eval_type: #{inspect(tc.eval_type)}"}
tc.eval_type == :custom and not Map.has_key?(tc.eval_config, :evaluator) ->
{:error, "Custom eval_type requires :evaluator in eval_config"}
true ->
:ok
end
end
@doc """
Get display name for the test case.
"""
@spec display_name(t()) :: String.t()
def display_name(%__MODULE__{name: name, id: id}) do
name || id
end
# Private helpers
defp fetch_required(map, keys) do
case Enum.find_value(keys, fn k -> Map.get(map, k) end) do
nil -> {:error, "Missing required field: #{inspect(hd(keys))}"}
value -> {:ok, value}
end
end
defp get_any(map, keys, default \\ nil) do
Enum.find_value(keys, default, fn k -> Map.get(map, k) end)
end
# eval_config / agent_config maps come from arbitrary YAML. Convert to atoms
# ONLY if the atom already exists in the BEAM; unknown keys remain as
# binaries. This prevents YAML files from exhausting the global atom table.
defp atomize_keys(map) when is_map(map) do
Map.new(map, fn
{k, v} when is_binary(k) -> {atom_key_or_binary(k), atomize_keys(v)}
{k, v} -> {k, atomize_keys(v)}
end)
end
defp atomize_keys(list) when is_list(list), do: Enum.map(list, &atomize_keys/1)
defp atomize_keys(other), do: other
defp atom_key_or_binary(binary) do
String.to_existing_atom(binary)
rescue
ArgumentError -> binary
end
end