Packages

A library for testing LLM classifiers with a custom DSL.

Current section

Files

Jump to
llm_classifier_test lib llm_classifier_test.ex
Raw

lib/llm_classifier_test.ex

defmodule LLMClassifierTest do
defmacro __using__(opts) do
quote do
import LLMClassifierTest
@prompt_name unquote(opts[:prompt_name] || "default_prompt")
Module.register_attribute(__MODULE__, :categories_acc, accumulate: true)
@model_function unquote(opts[:model_function] || quote(do: &default_model_function/3))
@before_compile LLMClassifierTest
def run_all_tests(model_name, prompt_name) do
categories = categories()
IO.puts("Running all tests for model: #{model_name}, prompt: #{prompt_name}")
results =
Enum.map(categories, fn {name, tests} ->
category_results =
LLMClassifierTest.run_category_tests(
name,
tests,
model_name,
prompt_name,
@model_function
)
{name, category_results}
end)
overall_results = LLMClassifierTest.aggregate_results(results)
LLMClassifierTest.print_overall_summary(overall_results)
{__MODULE__, overall_results}
end
defoverridable run_all_tests: 2
end
end
defmacro category(name, do: block) do
quote do
Module.register_attribute(__MODULE__, :current_tests, accumulate: true)
@current_category unquote(name)
unquote(block)
@categories_acc {unquote(name), @current_tests}
Module.delete_attribute(__MODULE__, :current_tests)
@current_category nil
end
end
defmacro __before_compile__(_env) do
quote do
def categories do
@categories_acc
|> Enum.group_by(fn {name, _} -> name end, fn {_, tests} -> tests end)
|> Enum.map(fn {name, tests} -> {name, List.flatten(tests)} end)
end
end
end
defmacro positive(text, fallback_category \\ nil) do
quote do
@current_tests [{:positive, unquote(text), unquote(fallback_category)}]
end
end
defmacro negative(text, expected_category \\ nil) do
quote do
@current_tests [{:negative, unquote(text), unquote(expected_category)}]
end
end
def run_category_tests(category_name, tests, model_name, prompt_name, model_function) do
IO.puts("\nRunning tests for category: [#{category_name}]")
IO.puts("Model: [#{model_name}], Prompt: [#{prompt_name}]")
results =
Enum.reduce(
tests,
%{positive: %{passed: 0, failed: 0}, negative: %{passed: 0, failed: 0}},
fn test, acc ->
case test do
{:positive, text, mode} ->
run_positive_test(
category_name,
text,
model_name,
prompt_name,
model_function,
acc,
mode
)
{:negative, text, expected_category} ->
run_negative_test(
category_name,
text,
expected_category,
model_name,
prompt_name,
model_function,
acc
)
end
end
)
{category_name, results}
end
defp run_positive_test(
category_name,
text,
model_name,
prompt_name,
model_function,
results,
fallback_category
) do
test_name = format_text(text)
case model_function.(text, model_name, prompt_name) do
categories when is_list(categories) ->
cond do
Enum.member?(categories, category_name) ->
IO.puts("\s\s\s\tPositive: #{test_name}")
update_in(results, [:positive, :passed], &(&1 + 1))
Enum.member?(categories, fallback_category) ->
details = "Expected: #{category_name} | Got: #{fallback_category}"
IO.puts("\s\s\s⚠️\tPositive: #{test_name} [#{details}]")
update_in(results, [:positive, :passed], &(&1 + 1))
true ->
details = "Expected: #{category_name} | Got: #{Enum.join(categories, ", ")}"
IO.puts("\s\s\s\tPositive: #{test_name} [#{details}]")
update_in(results, [:positive, :failed], &(&1 + 1))
end
other ->
IO.puts("\s\s\s\tPositive: #{test_name} [model error: #{inspect(other)}]")
update_in(results, [:positive, :failed], &(&1 + 1))
end
end
defp run_negative_test(
category_name,
text,
expected_category,
model_name,
prompt_name,
model_function,
results
) do
test_name = format_text(text)
case model_function.(text, model_name, prompt_name) do
categories when is_list(categories) ->
cond do
Enum.member?(categories, category_name) ->
details = "Expected: NOT #{category_name} | Got: #{Enum.join(categories, ", ")}"
IO.puts("\s\s\s\tNegative: #{test_name} [#{details}]")
update_in(results, [:negative, :failed], &(&1 + 1))
is_nil(expected_category) or Enum.member?(categories, expected_category) ->
details = "Expected: #{expected_category || "any"}"
IO.puts("\s\s\s\tNegative: #{test_name} [#{details}]")
update_in(results, [:negative, :passed], &(&1 + 1))
true ->
details = "Expected: #{expected_category} | Got: #{Enum.join(categories, ", ")}"
IO.puts("\s\s\s\tNegative: #{test_name} [#{details}]")
update_in(results, [:negative, :failed], &(&1 + 1))
end
other ->
IO.puts("\s\s\s\tNegative: #{test_name} [model error: #{inspect(other)}]")
update_in(results, [:negative, :failed], &(&1 + 1))
end
end
def aggregate_results(results) do
Enum.reduce(
results,
%{positive: %{passed: 0, failed: 0}, negative: %{passed: 0, failed: 0}},
fn {_, {_, category_results}}, acc ->
update_in(acc, [:positive, :passed], &(&1 + category_results.positive.passed))
|> update_in([:positive, :failed], &(&1 + category_results.positive.failed))
|> update_in([:negative, :passed], &(&1 + category_results.negative.passed))
|> update_in([:negative, :failed], &(&1 + category_results.negative.failed))
end
)
end
def print_overall_summary(results) do
total_tests =
results.positive.passed + results.positive.failed + results.negative.passed +
results.negative.failed
total_passed = results.positive.passed + results.negative.passed
IO.puts("\nModule summary:")
IO.puts("\tTotal tests: #{total_tests}")
IO.puts("\s\s\s\tTotal passed: #{total_passed}")
IO.puts("\s\s\s\tTotal failed: #{total_tests - total_passed} ")
if total_tests > 0 do
IO.puts("\tSuccess rate: #{Float.round(total_passed / total_tests * 100, 2)}%")
else
IO.puts("\tSuccess rate: N/A (no tests run)")
end
end
defp format_text(text) do
case text do
{question, answer} = _ when is_tuple(text) ->
"Q: #{question} A: #{answer}"
_ ->
text
end
end
end