defmodule ArcanaWeb.EvaluationLive do @moduledoc """ LiveView for retrieval evaluation in Arcana. """ use Phoenix.LiveView import ArcanaWeb.DashboardComponents alias Arcana.Evaluation @impl true def mount(_params, session, socket) do repo = get_repo_from_session(session) {:ok, socket |> assign(repo: repo) |> assign( eval_view: :test_cases, eval_running: false, eval_generating: false, eval_progress: nil, eval_tick: 0, eval_message: nil, expanded_test_case_id: nil, stats: nil, eval_test_cases: [], eval_runs: [], eval_test_case_count: 0, collections: [] )} end @impl true def handle_params(_params, _uri, socket) do {:noreply, load_data(socket)} end defp load_data(socket) do repo = socket.assigns.repo socket |> assign(stats: load_stats(repo)) |> load_evaluation_data() end defp load_evaluation_data(socket) do repo = socket.assigns.repo test_cases = Evaluation.list_test_cases(repo: repo) runs = Evaluation.list_runs(repo: repo, limit: 10) test_case_count = Evaluation.count_test_cases(repo: repo) collections = load_collections(repo) assign(socket, eval_test_cases: test_cases, eval_runs: runs, eval_test_case_count: test_case_count, collections: collections ) end @impl true def handle_event("eval_switch_view", %{"view" => view}, socket) do {:noreply, assign(socket, eval_view: parse_eval_view(view))} end def handle_event("eval_run", params, socket) do repo = socket.assigns.repo mode = parse_mode(params["mode"]) retriever_name = params["retriever"] || "pipeline" evaluate_answers = params["evaluate_answers"] == "true" # Check if LLM is configured when evaluate_answers or Loop is requested llm = Application.get_env(:arcana, :llm) needs_llm? = evaluate_answers or retriever_name == "loop" if needs_llm? and is_nil(llm) do {:noreply, assign(socket, eval_message: {:error, "No LLM configured. Set :arcana, :llm in your config."} )} else socket = assign(socket, eval_running: true, eval_message: nil, eval_progress: %{ done: 0, total: socket.assigns.eval_test_case_count, started_at: System.monotonic_time(:millisecond) } ) opts = build_run_opts(repo, mode, evaluate_answers, llm, retriever_name) parent = self() handler_id = "eval-progress-#{inspect(parent)}" :telemetry.attach_many( handler_id, [ [:arcana, :evaluation, :test_case, :start], [:arcana, :evaluation, :test_case, :complete] ], fn event, measurements, metadata, _config -> send(parent, {:eval_progress, event, measurements, metadata}) end, nil ) Task.Supervisor.start_child(Arcana.TaskSupervisor, fn -> result = Evaluation.run(opts) :telemetry.detach(handler_id) send(parent, {:eval_run_complete, result}) end) # Self-tick every second so the elapsed-time counter updates live # while a test case is in flight. LiveView only re-renders on # messages; without a tick the elapsed-time label freezes at # whatever it was when the last telemetry event fired. Process.send_after(self(), :eval_tick, 1000) {:noreply, socket} end end def handle_event("eval_generate", params, socket) do repo = socket.assigns.repo sample_size = parse_int(params["sample_size"], 10) collection = blank_to_nil(params["collection"]) case Application.get_env(:arcana, :llm) do nil -> {:noreply, assign(socket, eval_message: {:error, "No LLM configured. Set :arcana, :llm in your config."} )} llm -> socket = assign(socket, eval_generating: true, eval_message: nil) parent = self() opts = build_generate_opts(repo, llm, sample_size, collection) Task.Supervisor.start_child(Arcana.TaskSupervisor, fn -> result = Evaluation.generate_test_cases(opts) send(parent, {:eval_generate_complete, result}) end) {:noreply, socket} end end def handle_event("toggle_test_case", %{"id" => id}, socket) do current = socket.assigns.expanded_test_case_id next = if current == id, do: nil, else: id {:noreply, assign(socket, expanded_test_case_id: next)} end def handle_event("eval_delete_test_case", %{"id" => id}, socket) do repo = socket.assigns.repo case Evaluation.delete_test_case(id, repo: repo) do {:ok, _} -> {:noreply, load_evaluation_data(socket)} {:error, _} -> {:noreply, socket} end end def handle_event("eval_delete_run", %{"id" => id}, socket) do repo = socket.assigns.repo case Evaluation.delete_run(id, repo: repo) do {:ok, _} -> {:noreply, load_evaluation_data(socket)} {:error, _} -> {:noreply, socket} end end @impl true def handle_info(:eval_tick, socket) do # Keep ticking while the eval task is running. No assign change # beyond the implicit re-render; the template reads the monotonic # clock each render so the elapsed display refreshes on its own. if socket.assigns.eval_running do Process.send_after(self(), :eval_tick, 1000) {:noreply, assign(socket, eval_tick: System.monotonic_time(:millisecond))} else {:noreply, socket} end end def handle_info({:eval_progress, event, _measurements, metadata}, socket) do %{index: index, total: total, question: question} = metadata base = socket.assigns.eval_progress || %{done: 0, total: total, started_at: System.monotonic_time(:millisecond), current: nil} progress = case List.last(event) do :start -> # New test case just kicked off; surface it as "now running" # so the user sees motion even while a slow Loop iteration is # in flight (can take several minutes per case). %{base | total: total, current: %{index: index, question: question}} :complete -> # Test case finished; bump the done count and clear current # so the bar reflects the new done-out-of-total. %{base | done: index, total: total, current: nil} end {:noreply, assign(socket, eval_progress: progress)} end def handle_info({:eval_run_complete, result}, socket) do socket = case result do {:ok, _run} -> socket |> assign( eval_running: false, eval_progress: nil, eval_message: {:success, "Evaluation completed!"} ) |> load_evaluation_data() |> assign(eval_view: :history) {:error, :no_test_cases} -> assign(socket, eval_running: false, eval_progress: nil, eval_message: {:error, "No test cases. Generate some first."} ) other -> # Fallback: the eval task raised or returned an unexpected # result. Clear the running flag so the UI doesn't get stuck # on "Running..." forever. require Logger Logger.error("[EvaluationLive] unexpected eval result: #{inspect(other)}") assign(socket, eval_running: false, eval_progress: nil, eval_message: {:error, "Evaluation failed: #{inspect(other)}"} ) end {:noreply, socket} end def handle_info({:eval_generate_complete, result}, socket) do socket = case result do {:ok, test_cases} -> socket |> assign( eval_generating: false, eval_message: {:success, "Generated #{length(test_cases)} test case(s)!"} ) |> load_evaluation_data() {:error, reason} -> assign(socket, eval_generating: false, eval_message: {:error, "Generation failed: #{inspect(reason)}"} ) end {:noreply, socket} end defp build_run_opts(repo, mode, evaluate_answers, llm, retriever_name) do base = [repo: repo, mode: mode] |> maybe_put_evaluate_answers(evaluate_answers, llm) |> maybe_put_retriever(retriever_name, repo, llm) base end defp maybe_put_evaluate_answers(opts, false, _llm), do: opts defp maybe_put_evaluate_answers(opts, true, llm) do Keyword.merge(opts, evaluate_answers: true, llm: llm) end # Pipeline is the default — no :retriever needed, Arcana.Evaluation.run/1 # falls back to &Arcana.search/2. defp maybe_put_retriever(opts, "pipeline", _repo, _llm), do: opts defp maybe_put_retriever(opts, "loop", repo, llm) do # Loop retriever: run a full Arcana.Loop.run/2 per test case and # return the 3-tuple {:ok, chunks, answer}. The answer is the one # the loop produced, so when evaluate_answers: true is on, the # existing machinery skips regeneration and scores it directly. runner = loop_runner() retriever = fn question, _opts -> ctx = Arcana.Loop.new(question, repo: repo) case runner.(ctx, controller_llm: llm) do {:ok, %Arcana.Loop.Context{} = result_ctx} -> {:ok, result_ctx.chunks, result_ctx.answer} {:error, _} = err -> err end end Keyword.put(opts, :retriever, retriever) end # Testability seam — same pattern used in ask_live.ex so tests can # stub Loop execution without spinning up a real controller. defp loop_runner do Application.get_env(:arcana, :loop_runner) || (&Arcana.Loop.run/2) end defp build_generate_opts(repo, llm, sample_size, nil) do [repo: repo, llm: llm, sample_size: sample_size] end defp build_generate_opts(repo, llm, sample_size, collection) do [repo: repo, llm: llm, sample_size: sample_size, collection: collection] end defp parse_eval_view("test_cases"), do: :test_cases defp parse_eval_view("run"), do: :run defp parse_eval_view("history"), do: :history # Any other value (an unknown view name from a stale tab or a malformed # phx-value-view payload) falls back to the default landing. defp parse_eval_view(_), do: :test_cases defp progress_pct(%{done: done, total: total}) when total > 0, do: Float.round(done / total * 100, 1) defp progress_pct(_), do: 0.0 defp format_elapsed(ms) when ms < 1000, do: "#{ms}ms" defp format_elapsed(ms) when ms < 60_000, do: "#{Float.round(ms / 1000, 1)}s" defp format_elapsed(ms) do total_s = div(ms, 1000) "#{div(total_s, 60)}m #{rem(total_s, 60)}s" end # Humanized "2 hours ago"-style relative time for timestamps in lists. # Falls back to the absolute string for anything older than a week so # months/years don't get ambiguous. defp relative_time(nil), do: "" defp relative_time(%NaiveDateTime{} = dt) do now = NaiveDateTime.utc_now() diff_s = NaiveDateTime.diff(now, dt, :second) cond do diff_s < 0 -> "just now" diff_s < 60 -> "just now" diff_s < 3600 -> "#{div(diff_s, 60)}m ago" diff_s < 86_400 -> "#{div(diff_s, 3600)}h ago" diff_s < 604_800 -> "#{div(diff_s, 86_400)}d ago" true -> NaiveDateTime.to_date(dt) |> Date.to_string() end end defp relative_time(other), do: to_string(other) defp absolute_time(%NaiveDateTime{} = dt), do: NaiveDateTime.to_string(dt) defp absolute_time(other), do: to_string(other) @impl true def render(assigns) do ~H""" <.dashboard_layout stats={@stats} current_tab={:evaluation}>

Evaluation

Test retrieval quality with test cases and measure recall and precision metrics.

<%= if @eval_message do %>
<%= elem(@eval_message, 1) %>
<% end %> <%= case @eval_view do %> <% :test_cases -> %> <.eval_test_cases_view test_cases={@eval_test_cases} generating={@eval_generating} collections={@collections} expanded_id={@expanded_test_case_id} /> <% :run -> %> <.eval_run_view running={@eval_running} progress={@eval_progress} test_case_count={@eval_test_case_count} /> <% :history -> %> <.eval_history_view runs={@eval_runs} /> <% end %>
""" end defp eval_test_cases_view(assigns) do ~H"""
Leave blank to sample from every collection.
Random chunks sampled and paired with LLM-generated questions.
<%= if Enum.empty?(@test_cases) do %>

No test cases yet

Generate some with the form above, or seed from Mix with mix adept.eval.seed.

<% else %>
<%= for tc <- @test_cases do %> <% expanded? = @expanded_id == tc.id %>
<%= tc.question %> <%= tc.source %> <%= length(tc.relevant_chunks) %> chunk<%= if length(tc.relevant_chunks) == 1, do: "", else: "s" %> <%= relative_time(tc.inserted_at) %>
<%= if expanded? do %>
<%= if tc.reference_answer do %>
Reference answer

<%= tc.reference_answer %>

<% end %>
Expected chunks (<%= length(tc.relevant_chunks) %>)
<%= if Enum.empty?(tc.relevant_chunks) do %>

No chunks linked.

<% else %>
    <%= for chunk <- tc.relevant_chunks do %>
  • <%= String.slice(to_string(chunk.id), 0, 8) %> <%= String.slice(chunk.text || "", 0, 200) %><%= if String.length(chunk.text || "") > 200, do: "..." %>
  • <% end %>
<% end %>
<% end %>
<% end %>
<% end %>
""" end defp eval_run_view(assigns) do ~H"""

Run evaluation against your <%= @test_case_count %> test case{if @test_case_count == 1, do: "", else: "s"} to measure retrieval quality.

Retriever

Vector: embedding similarity. Keyword: exact terms. Hybrid: both fused by RRF. Pipeline only; Loop always uses vector.
<%= if @running and @progress do %>
<%= @progress.done %> / <%= @progress.total %> test cases <%= format_elapsed(System.monotonic_time(:millisecond) - @progress.started_at) %>
<%= if current = @progress[:current] do %>
Running case <%= current.index %>: <%= current.question %>
<% end %>
<% end %>
<%= if @test_case_count == 0 do %>

No test cases available. Generate some first using mix arcana.eval.generate.

<% end %>
""" end defp eval_history_view(assigns) do ~H"""
<%= if Enum.empty?(@runs) do %>

No evaluation runs yet. Run an evaluation to see results here.

<% else %> <%= for run <- @runs do %>
<%= run.status %> <%= run.test_case_count %> test cases
<%= run.inserted_at %>
Mode: <%= run.config["mode"] || run.config[:mode] || "vector" %>
<%= if run.status == :completed and run.metrics do %>
<%= format_pct(run.metrics["recall_at_5"] || run.metrics[:recall_at_5]) %>
Recall@5
<%= format_pct(run.metrics["precision_at_5"] || run.metrics[:precision_at_5]) %>
Precision@5
<%= format_pct(run.metrics["mrr"] || run.metrics[:mrr]) %>
MRR
<%= format_pct(run.metrics["hit_rate_at_5"] || run.metrics[:hit_rate_at_5]) %>
Hit Rate@5
<%= if run.metrics["faithfulness"] || run.metrics[:faithfulness] do %>
<%= format_score(run.metrics["faithfulness"] || run.metrics[:faithfulness]) %>
Faithfulness
<% end %> <%= if run.metrics["correctness"] || run.metrics[:correctness] do %>
<%= format_score(run.metrics["correctness"] || run.metrics[:correctness]) %>
Correctness
<% end %>
<% end %>
<% end %> <% end %>
""" end end