Packages
Extract structured data from text using LLMs with source grounding. Maps every extraction back to exact byte positions in the source. Supports Claude, OpenAI, and Gemini providers. Elixir port of google/langextract.
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/benchmark/run.ex
defmodule Mix.Tasks.Benchmark.Run do
@moduledoc "Run LangExtract benchmark against corpus texts."
@shortdoc "Run extraction benchmark"
use Mix.Task
alias LangExtract.{Pipeline.ChunkError, Pipeline.Extraction, Prompt}
@default_corpus "benchmark/corpus"
@default_out "benchmark/results/elixir"
@impl Mix.Task
def run(args) do
Mix.Task.run("app.start")
{opts, _, _} =
OptionParser.parse(args,
strict: [task: :string, corpus: :string, out: :string, document: :string]
)
task_name = opts[:task] || raise "Missing --task argument"
corpus_dir = opts[:corpus] || @default_corpus
out_dir = opts[:out] || @default_out
task_def = load_task(task_name)
client = build_client()
template = build_template(task_def)
corpus_files =
case opts[:document] do
nil -> Path.wildcard(Path.join(corpus_dir, "*.txt")) |> Enum.sort()
slug -> [Path.join(corpus_dir, "#{slug}.txt")]
end
timestamp = Calendar.strftime(DateTime.utc_now(), "%Y%m%d_%H%M%S")
run_dir = Path.join(out_dir, "#{task_name}_#{timestamp}")
File.mkdir_p!(run_dir)
Mix.shell().info("Running task '#{task_name}' on #{length(corpus_files)} documents...")
Enum.each(corpus_files, fn file ->
run_document(file, client, template, task_name, run_dir)
end)
latest_link = Path.join(out_dir, "#{task_name}_latest")
case File.read_link(latest_link) do
{:ok, _} ->
File.rm!(latest_link)
{:error, :enoent} ->
:ok
{:error, reason} ->
Mix.shell().error("Warning: #{latest_link}: #{:file.format_error(reason)}")
end
File.ln_s!(Path.basename(run_dir), latest_link)
Mix.shell().info("\nResults written to #{run_dir}/")
Mix.shell().info("Symlink updated: #{latest_link} -> #{Path.basename(run_dir)}")
end
defp run_document(file, client, template, task_name, run_dir) do
slug = Path.basename(file, ".txt")
source = File.read!(file)
Mix.shell().info(" #{slug} (#{byte_size(source)} bytes)...")
{elapsed_us, run_result} =
:timer.tc(fn ->
LangExtract.run(client, source, template, max_concurrency: 2)
end)
elapsed_ms = div(elapsed_us, 1000)
result =
case run_result do
{:ok, {spans, errors}} ->
extractions = Enum.map(spans, &span_to_normalized/1)
case errors do
[] ->
Mix.shell().info(" #{length(spans)} extractions in #{elapsed_ms}ms")
_ ->
Mix.shell().error(
" #{length(errors)} chunk error(s), #{length(spans)} partial extractions in #{elapsed_ms}ms"
)
end
error_fields =
case errors do
[] -> %{}
_ -> %{"errors" => Enum.map(errors, &chunk_error_to_map/1)}
end
Map.merge(
%{
"source" => slug,
"task" => task_name,
"library" => "elixir",
"extractions" => extractions,
"timing" => %{"total_ms" => elapsed_ms}
},
error_fields
)
{:error, reason} ->
Mix.shell().error(" ERROR: #{inspect(reason)}")
%{
"source" => slug,
"task" => task_name,
"library" => "elixir",
"extractions" => [],
"timing" => %{"total_ms" => elapsed_ms},
"error" => inspect(reason)
}
end
out_path = Path.join(run_dir, "#{slug}.json")
File.write!(out_path, Jason.encode!(result, pretty: true))
end
defp load_task(name) do
path = Path.join("benchmark/tasks", "#{name}.json")
path |> File.read!() |> Jason.decode!()
end
defp build_client do
api_key = System.get_env("ANTHROPIC_API_KEY") || raise "ANTHROPIC_API_KEY not set"
LangExtract.new(:claude,
api_key: api_key,
model: "claude-sonnet-4-20250514",
temperature: 0,
req_options: [receive_timeout: 120_000]
)
end
defp build_template(task_def) do
examples =
Enum.map(task_def["examples"], fn ex ->
extractions =
Enum.map(ex["extractions"], fn e ->
%Extraction{
class: e["class"],
text: e["text"],
attributes: e["attributes"] || %{}
}
end)
%Prompt.ExampleData{text: ex["text"], extractions: extractions}
end)
%Prompt.Template{description: task_def["description"], examples: examples}
end
defp chunk_error_to_map(%ChunkError{} = err) do
%{
"byte_start" => err.byte_start,
"byte_end" => err.byte_end,
"reason" => inspect(err.reason)
}
end
defp span_to_normalized(span) do
%{
"class" => span.class,
"text" => span.text,
"byte_start" => span.byte_start,
"byte_end" => span.byte_end,
"status" => Atom.to_string(span.status),
"attributes" => span.attributes
}
end
end