defmodule ArcanaWeb.AskLive do
@moduledoc """
LiveView for asking questions about documents in Arcana.
"""
use Phoenix.LiveView
import Ecto.Query
import ArcanaWeb.DashboardComponents
alias Arcana.Document
alias Arcana.Graph.Entity
alias ArcanaWeb.ChunkResultsComponent
# Form param names for the Pipeline tab's optional steps. Tracked in
# socket assigns so the all/none toggle can update them server-side
# rather than reaching into the DOM with inline JS.
@pipeline_step_keys ~w(use_gate use_rewrite use_expand use_decompose use_reason self_correct use_rerank use_ground)
# Groups the pipeline steps into the three phases of the Singh taxonomy's
# Modular RAG: query preparation, retrieval, and answer. Used by the
# per-phase "all / none" toggles on the Pipeline UI.
@pipeline_step_groups %{
"query" => ~w(use_gate use_rewrite use_expand use_decompose),
"retrieval" => ~w(use_reason use_rerank),
"answer" => ~w(self_correct use_ground)
}
@impl true
def mount(_params, session, socket) do
repo = get_repo_from_session(session)
{:ok,
socket
|> assign(repo: repo)
|> assign(
ask_sub_tab: :advanced,
ask_question: "",
ask_running: false,
ask_context: nil,
ask_error: nil,
stats: nil,
collections: [],
graph_search: true,
selected_collections: [],
graph_context_expanded: true,
llm_select: false,
pipeline_step: nil,
pipeline_steps: Map.new(@pipeline_step_keys, &{&1, false}),
loop_live_history: [],
loop_phase: :idle,
trace_history: []
)}
end
@impl true
def handle_params(params, _uri, socket) do
sub_tab = parse_sub_tab(params["sub_tab"])
sub_tab_changed? = sub_tab != socket.assigns.ask_sub_tab
# LLM collection select only makes sense on the Pipeline sub-tab
llm_select = if sub_tab == :pipeline, do: socket.assigns.llm_select, else: false
socket =
socket
|> assign(ask_sub_tab: sub_tab, llm_select: llm_select)
|> maybe_reset_ask_state(sub_tab_changed?)
|> maybe_load_data()
{:noreply, socket}
end
# Stats and collections only need to load once per LiveView session.
# Sub-tab switches re-enter handle_params/3 via push_patch but don't
# need to re-query the DB.
defp maybe_load_data(%{assigns: %{stats: nil}} = socket), do: load_data(socket)
defp maybe_load_data(socket), do: socket
# Switching sub-tabs swaps in a different retrieval strategy, so any
# previously rendered context, error, or pipeline progress label belongs
# to the old strategy and would be confusing to leave visible.
defp maybe_reset_ask_state(socket, false), do: socket
defp maybe_reset_ask_state(socket, true) do
assign(socket,
ask_context: nil,
ask_error: nil,
pipeline_step: nil,
loop_live_history: [],
loop_phase: :idle,
trace_history: []
)
end
defp parse_sub_tab("advanced"), do: :advanced
defp parse_sub_tab("pipeline"), do: :pipeline
defp parse_sub_tab("loop"), do: :loop
# Any other value (including nil for /arcana/ask with no segment, or an
# unknown sub-tab name) falls back to the default landing.
defp parse_sub_tab(_), do: :advanced
defp load_data(socket) do
repo = socket.assigns.repo
socket
|> assign(stats: load_stats(repo))
|> assign(collections: load_collections_with_graph_status(repo))
end
defp load_collections_with_graph_status(repo) do
collections = load_collections(repo)
# Get entity counts per collection
entity_counts =
repo.all(
from(e in Entity,
group_by: e.collection_id,
select: {e.collection_id, count(e.id)}
)
)
|> Map.new()
Enum.map(collections, fn c ->
entity_count = Map.get(entity_counts, c.id, 0)
Map.put(c, :graph_enabled, entity_count > 0)
end)
end
defp selected_graph_enabled?(collections, selected) do
case selected do
[] -> false
names -> Enum.any?(collections, &(&1.name in names and &1[:graph_enabled]))
end
end
@impl true
def handle_event("ask_submit", params, socket) do
question = params["question"] || ""
case {Application.get_env(:arcana, :llm), question} do
{nil, _} ->
{:noreply,
assign(socket,
ask_error: "No LLM configured. Set :arcana, :llm in your config.",
ask_running: false
)}
{_, ""} ->
{:noreply, assign(socket, ask_error: "Please enter a question")}
{llm, question} ->
socket =
assign(socket,
ask_running: true,
ask_error: nil,
ask_question: question,
ask_context: nil,
loop_live_history: [],
loop_phase: :idle,
trace_history: []
)
start_ask_task(socket, params, llm, question)
{:noreply, socket}
end
end
def handle_event("ask_clear", _params, socket) do
{:noreply,
assign(socket,
ask_context: nil,
ask_error: nil,
ask_question: "",
loop_live_history: [],
loop_phase: :idle,
trace_history: []
)}
end
def handle_event("ask_switch_sub_tab", %{"sub_tab" => sub_tab}, socket) do
# push_patch to the corresponding URL so the sub-tab selection is
# shareable and survives page reloads. handle_params/3 will pick
# up the new :sub_tab param and update the assigns.
{:noreply, push_patch(socket, to: "/arcana/ask/#{sub_tab}")}
end
def handle_event("form_changed", params, socket) do
selected = params["collections"] || []
pipeline_steps = Map.new(@pipeline_step_keys, &{&1, params[&1] == "true"})
# Carry the textarea content forward on every form-change so a
# checkbox click (collections, pipeline steps) doesn't blow away
# what the user typed in the question box.
question = params["question"] || socket.assigns.ask_question
{:noreply,
assign(socket,
selected_collections: selected,
pipeline_steps: pipeline_steps,
ask_question: question
)}
end
def handle_event("toggle_graph_context", _params, socket) do
{:noreply, assign(socket, graph_context_expanded: !socket.assigns.graph_context_expanded)}
end
def handle_event("toggle_llm_select", _params, socket) do
{:noreply, assign(socket, llm_select: !socket.assigns.llm_select)}
end
def handle_event("set_pipeline_steps", %{"mode" => mode} = params, socket) do
enabled? = mode == "all"
keys =
case Map.get(params, "group") do
nil -> @pipeline_step_keys
group -> Map.get(@pipeline_step_groups, group, [])
end
steps =
Enum.reduce(keys, socket.assigns.pipeline_steps, fn key, acc ->
Map.put(acc, key, enabled?)
end)
{:noreply, assign(socket, pipeline_steps: steps)}
end
defp ask_loading_label(:advanced, _step, _phase), do: "Generating answer..."
defp ask_loading_label(:loop, _step, :grounding),
do: "Grounding answer against retrieved chunks..."
defp ask_loading_label(:loop, _step, _phase), do: "Running agent loop..."
defp ask_loading_label(:pipeline, step, _phase), do: step || "Running pipeline..."
# Renders an answer as markdown via MDEx. MDEx is an optional dep
# (see mix.exs) because the dashboard itself is optional. When it's
# missing, fall back to a minimal plain-text-to-html that preserves
# paragraph + line breaks (double newlines →
, single newlines →
# ) instead of collapsing the whole answer into one wall of text.
#
# MDEx's `:sanitize` enables ammonia-based HTML sanitization so any
# raw HTML the LLM emits gets scrubbed (the LLM is untrusted input).
# The fallback path also escapes HTML so it's safe to render.
defp render_markdown_answer(text) when is_binary(text) do
trimmed = String.trim(text)
if Code.ensure_loaded?(MDEx) do
Phoenix.HTML.raw(MDEx.to_html!(trimmed, sanitize: []))
else
plain_text_to_html(trimmed)
end
end
defp render_markdown_answer(_), do: ""
defp plain_text_to_html(text) do
paragraphs =
text
|> String.split(~r/\n{2,}/)
|> Enum.map_join("", fn paragraph ->
body =
paragraph
|> String.split("\n")
|> Enum.map_join(" ", fn line ->
line
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end)
"
#{body}
"
end)
Phoenix.HTML.raw(paragraphs)
end
# Resolves which LLM each role uses, mirroring the lookup chain inside
# Arcana.Loop.run/2:
# controller_llm = config :arcana, :loop, :controller_llm || :arcana, :llm
# answer_llm = config :arcana, :loop, :answer_llm (nil → uses controller text)
# fallback = answer_llm || controller_llm
defp loop_llm_roles do
loop_opts = Application.get_env(:arcana, :loop, [])
base_llm = Application.get_env(:arcana, :llm)
controller = Keyword.get(loop_opts, :controller_llm) || base_llm
answer = Keyword.get(loop_opts, :answer_llm)
%{
controller: format_llm_spec(controller),
answer: format_llm_spec(answer),
fallback: format_llm_spec(answer || controller)
}
end
defp format_llm_spec(nil), do: nil
defp format_llm_spec(fun) when is_function(fun), do: ""
defp format_llm_spec({model, _opts}) when is_binary(model), do: model
defp format_llm_spec({model, _opts}) when is_atom(model), do: Atom.to_string(model)
defp format_llm_spec(model) when is_binary(model), do: model
defp format_llm_spec(model) when is_atom(model), do: Atom.to_string(model)
defp format_llm_spec(other), do: inspect(other, limit: 1)
defp start_ask_task(socket, params, llm, question) do
repo = socket.assigns.repo
sub_tab = params["sub_tab"] || "advanced"
selected_collections = params["collections"] || []
parent = self()
Arcana.TaskSupervisor.start_child(fn ->
handler_id = "pipeline-progress-#{inspect(parent)}"
trace_handler_id = "trace-progress-#{inspect(parent)}"
loop_handler_id = "loop-progress-#{inspect(parent)}"
graph_enabled = params["graph_search"] == "true"
pipeline_steps = [
:gate,
:rewrite,
:expand,
:decompose,
:select,
:search,
:reason,
:self_correct,
:rerank,
:answer,
:ground
]
# Pipeline progress label events (the old "Running X..." text that
# updates the spinner label).
label_events =
Enum.map(pipeline_steps, &[:arcana, :pipeline, &1, :start]) ++
[[:arcana, :graph, :search, :start]]
:telemetry.attach_many(
handler_id,
label_events,
fn
[:arcana, :graph, :search, :start], _measurements, _metadata, _config ->
send(parent, {:pipeline_progress, "Searching with graph connections..."})
[:arcana, :pipeline, step, :start], _measurements, _metadata, _config ->
label =
if step == :search and graph_enabled,
do: "Searching with graph connections...",
else: pipeline_step_label(step)
if label, do: send(parent, {:pipeline_progress, label})
end,
nil
)
# Trace events for the live step-by-step panel. Every sub-tab
# subscribes, the handler normalizes the event path to a
# `{:trace_step_start, atom}` / `{:trace_step_stop, atom, ms, meta}`
# tuple. Pipeline and Advanced both flow through this pathway.
trace_events =
Enum.flat_map(pipeline_steps, fn step ->
[
[:arcana, :pipeline, step, :start],
[:arcana, :pipeline, step, :stop]
]
end) ++
[
[:arcana, :search, :start],
[:arcana, :search, :stop],
[:arcana, :graph, :search, :start],
[:arcana, :graph, :search, :stop],
[:arcana, :llm, :complete, :start],
[:arcana, :llm, :complete, :stop]
]
:telemetry.attach_many(
trace_handler_id,
trace_events,
fn event, measurements, metadata, _config ->
case {event, measurements} do
{[:arcana, :pipeline, step, :start], _} ->
send(parent, {:trace_step_start, step})
{[:arcana, :pipeline, step, :stop], %{duration: d}} ->
send(parent, {:trace_step_stop, step, native_to_ms(d), metadata})
{[:arcana, :search, :start], _} ->
send(parent, {:trace_step_start, :search})
{[:arcana, :search, :stop], %{duration: d}} ->
send(parent, {:trace_step_stop, :search, native_to_ms(d), metadata})
{[:arcana, :graph, :search, :start], _} ->
send(parent, {:trace_step_start, :graph_search})
{[:arcana, :graph, :search, :stop], %{duration: d}} ->
send(parent, {:trace_step_stop, :graph_search, native_to_ms(d), metadata})
{[:arcana, :llm, :complete, :start], _} ->
send(parent, {:trace_step_start, :llm_complete})
{[:arcana, :llm, :complete, :stop], %{duration: d}} ->
send(parent, {:trace_step_stop, :llm_complete, native_to_ms(d), metadata})
_ ->
:ok
end
end,
nil
)
# Per-tool-call telemetry for the Loop sub-tab. Each event carries
# the same shape as a tool_history entry, so the LV can render
# the live trace incrementally as the loop unfolds.
:telemetry.attach_many(
loop_handler_id,
[
[:arcana, :loop, :tool_call],
[:arcana, :loop, :ground, :start],
[:arcana, :loop, :ground, :stop]
],
fn
[:arcana, :loop, :tool_call], _measurements, metadata, _config ->
send(parent, {:loop_progress, metadata})
[:arcana, :loop, :ground, :start], _measurements, _metadata, _config ->
send(parent, {:loop_phase, :grounding})
[:arcana, :loop, :ground, :stop], _measurements, _metadata, _config ->
send(parent, {:loop_phase, :idle})
end,
nil
)
result =
run_ask(
sub_tab,
question,
repo,
llm,
socket.assigns.collections,
params,
selected_collections
)
:telemetry.detach(handler_id)
:telemetry.detach(trace_handler_id)
:telemetry.detach(loop_handler_id)
send(parent, {:ask_complete, result})
end)
end
defp native_to_ms(duration) do
System.convert_time_unit(duration, :native, :millisecond)
end
defp run_ask("advanced", question, repo, llm, _all_collections, params, selected_collections) do
run_advanced_ask(question, repo, llm, selected_collections, params)
end
defp run_ask("loop", question, repo, llm, _all_collections, params, selected_collections) do
run_loop_ask(question, repo, llm, selected_collections, params)
end
defp run_ask("pipeline", question, repo, llm, all_collections, params, selected_collections) do
run_pipeline_ask(
question,
repo,
llm,
all_collections,
collections: selected_collections,
use_llm_select: params["llm_select"] == "true",
use_gate: params["use_gate"] == "true",
use_rewrite: params["use_rewrite"] == "true",
use_expand: params["use_expand"] == "true",
use_decompose: params["use_decompose"] == "true",
use_reason: params["use_reason"] == "true",
use_rerank: params["use_rerank"] == "true",
use_ground: params["use_ground"] == "true",
self_correct: params["self_correct"] == "true",
hallucinate_demo: params["answer_mode"] == "hallucinate",
graph: params["graph_search"] == "true"
)
end
@impl true
def handle_info({:pipeline_progress, step}, socket) do
{:noreply, assign(socket, pipeline_step: step)}
end
def handle_info({:loop_progress, entry}, socket) do
# Append, not prepend: render order matches iteration order so the
# newest entry visually lands at the bottom of the trace.
{:noreply, assign(socket, loop_live_history: socket.assigns.loop_live_history ++ [entry])}
end
def handle_info({:loop_phase, phase}, socket) do
{:noreply, assign(socket, loop_phase: phase)}
end
def handle_info({:trace_step_start, step}, socket) do
entry = %{step: step, status: :running, duration_ms: nil, meta: nil}
{:noreply, assign(socket, trace_history: socket.assigns.trace_history ++ [entry])}
end
def handle_info({:trace_step_stop, step, duration_ms, metadata}, socket) do
history =
socket.assigns.trace_history
|> Enum.reverse()
|> mark_step_done(step, duration_ms, metadata)
|> Enum.reverse()
{:noreply, assign(socket, trace_history: history)}
end
def handle_info({:ask_complete, result}, socket) do
socket =
case result do
{:ok, ctx} ->
ctx = Map.put(ctx, :document_titles, load_document_titles(ctx, socket.assigns.repo))
assign(socket, ask_running: false, ask_context: ctx, ask_error: nil, pipeline_step: nil)
{:error, reason} ->
assign(socket, ask_running: false, ask_error: inspect(reason), pipeline_step: nil)
end
{:noreply, socket}
end
# Batch-loads document titles for every chunk in the result. All three
# sub-tabs put chunks under :results, so one helper covers everything.
# Falls back to an empty map on any error — the component already
# degrades to a shortened UUID when a title is missing.
defp load_document_titles(%{results: chunks}, repo)
when is_list(chunks) and not is_nil(repo) do
ids =
chunks
|> Enum.map(&Map.get(&1, :document_id))
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
case ids do
[] ->
%{}
ids ->
try do
from(d in Document, where: d.id in ^ids, select: {d.id, d.metadata})
|> repo.all()
|> Map.new(fn {id, metadata} -> {id, extract_title(metadata)} end)
rescue
_ -> %{}
end
end
end
defp load_document_titles(_ctx, _repo), do: %{}
defp extract_title(nil), do: nil
defp extract_title(metadata) when is_map(metadata) do
Map.get(metadata, "title") || Map.get(metadata, :title)
end
defp extract_title(_), do: nil
# Update the most recent :running entry that matches `step`. Walking
# the reversed list and replacing the first match is correct because
# pipeline steps are sequential — the latest :running entry for a
# given step name is always the one that just stopped.
defp mark_step_done(
[%{step: step, status: :running} = entry | rest],
step,
duration_ms,
metadata
) do
[%{entry | status: :done, duration_ms: duration_ms, meta: metadata} | rest]
end
defp mark_step_done([head | rest], step, duration_ms, metadata) do
[head | mark_step_done(rest, step, duration_ms, metadata)]
end
defp mark_step_done([], _step, _duration_ms, _metadata), do: []
defp run_loop_ask(question, repo, llm, selected_collections, params) do
max_iterations =
case Integer.parse(params["max_iterations"] || "10") do
{n, _} when n > 0 -> n
_ -> 10
end
chunk_cap =
case Integer.parse(params["chunk_cap"] || "50") do
{n, _} when n > 0 -> n
_ -> 50
end
controller_temperature = parse_temperature(params["controller_temperature"])
answer_temperature = parse_temperature(params["answer_temperature"])
fallback_temperature = parse_temperature(params["fallback_temperature"])
run_ground? = params["use_ground_loop"] == "true"
ground_opts = [grounder: Arcana.Grounder.LLMJudge, judge_model: llm]
new_opts =
[repo: repo]
|> maybe_put_collection_opt(selected_collections)
run_opts =
[
controller_llm: llm,
max_iterations: max_iterations,
chunk_cap: chunk_cap
]
|> maybe_put(:controller_temperature, controller_temperature)
|> maybe_put(:answer_temperature, answer_temperature)
|> maybe_put(:fallback_temperature, fallback_temperature)
ctx = Arcana.Loop.new(question, new_opts)
runner = loop_runner()
with {:ok, ctx} <- runner.(ctx, run_opts) do
ctx = if run_ground?, do: Arcana.Loop.ground(ctx, ground_opts), else: ctx
{:ok, format_loop_result(ctx, question)}
end
rescue
e ->
require Logger
Logger.error(Exception.format(:error, e, __STACKTRACE__))
{:error, Exception.message(e)}
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp parse_temperature(nil), do: nil
defp parse_temperature(""), do: nil
defp parse_temperature(str) when is_binary(str) do
case Float.parse(str) do
{t, _} when t >= 0 and t <= 2 -> t
_ -> nil
end
end
# Hook for tests to stub out Loop execution. Defaults to the real Loop.run/2.
defp loop_runner do
Application.get_env(:arcana, :loop_runner) || (&Arcana.Loop.run/2)
end
# Arcana.Loop.new/2 (and Arcana.search, Arcana.ask, Arcana.Pipeline.new)
# all normalize `:collection` and `:collections` to the same internal
# representation, so we can always pass the plural form and let the
# library sort it out.
defp maybe_put_collection_opt(opts, []), do: opts
defp maybe_put_collection_opt(opts, list), do: Keyword.put(opts, :collections, list)
defp format_loop_result(%Arcana.Loop.Context{} = ctx, question) do
%{
result_type: :loop,
question: question,
answer: ctx.answer,
tool_history: ctx.tool_history,
terminated_by: ctx.terminated_by,
iterations: ctx.iterations,
chunks: ctx.chunks,
grounding: ctx.grounding,
# Mirror the Pipeline result shape so existing result sections
# (grounding, chunks) can reuse the same rendering. The extras are
# what drives the agent trace view.
results: ctx.chunks,
expanded_query: nil,
sub_questions: nil,
selected_collections: nil
}
end
defp run_advanced_ask(question, repo, llm, selected_collections, params) do
graph = params["graph_search"] == "true"
opts =
[repo: repo, llm: llm, graph: graph]
|> maybe_put_collection_opt(selected_collections)
case Arcana.ask(question, opts) do
{:ok, answer, results} ->
# Build a context-like struct for consistent UI display
{:ok,
%{
question: question,
answer: answer,
results: results,
expanded_query: nil,
sub_questions: nil,
selected_collections:
if(selected_collections == [], do: nil, else: selected_collections)
}}
{:error, reason} ->
{:error, reason}
end
rescue
e -> {:error, Exception.message(e)}
end
defp run_pipeline_ask(question, repo, llm, all_collections, opts) do
alias Arcana.Pipeline
all_collection_names = Enum.map(all_collections, & &1.name)
search_opts = build_search_opts(opts, all_collection_names)
Pipeline.new(question, repo: repo, llm: llm)
|> maybe_gate(opts)
|> maybe_rewrite(opts)
|> maybe_select(opts, all_collection_names)
|> maybe_expand(opts)
|> maybe_decompose(opts)
|> Pipeline.search(search_opts)
|> maybe_reason(opts)
|> maybe_rerank(opts)
|> maybe_answer_with_hallucinations(opts)
|> maybe_ground(opts)
|> format_pipeline_result(question)
rescue
e -> {:error, Exception.message(e)}
end
defp maybe_gate(ctx, opts) do
if Keyword.get(opts, :use_gate, false), do: Arcana.Pipeline.gate(ctx), else: ctx
end
defp maybe_rewrite(ctx, opts) do
if Keyword.get(opts, :use_rewrite, false), do: Arcana.Pipeline.rewrite(ctx), else: ctx
end
defp maybe_select(ctx, opts, all_collection_names) do
if Keyword.get(opts, :use_llm_select, false) and length(all_collection_names) > 1 do
Arcana.Pipeline.select(ctx, collections: all_collection_names)
else
ctx
end
end
defp maybe_expand(ctx, opts) do
if Keyword.get(opts, :use_expand, false), do: Arcana.Pipeline.expand(ctx), else: ctx
end
defp maybe_decompose(ctx, opts) do
if Keyword.get(opts, :use_decompose, false), do: Arcana.Pipeline.decompose(ctx), else: ctx
end
defp maybe_reason(ctx, opts) do
if Keyword.get(opts, :use_reason, false), do: Arcana.Pipeline.reason(ctx), else: ctx
end
defp maybe_rerank(ctx, opts) do
# Use the local CrossEncoder reranker for the dashboard: it reorders
# without filtering, which matches the UI copy ("Cross-encoder
# rescoring") and avoids the LLM reranker's aggressive threshold that
# can drop every chunk on the demo questions. Passing :top_k bypasses
# the reranker's default threshold=0.0 filter since cross-encoder
# logits can land entirely negative on broad questions.
if Keyword.get(opts, :use_rerank, false) do
Arcana.Pipeline.rerank(ctx,
reranker: Arcana.Reranker.CrossEncoder,
top_k: 30
)
else
ctx
end
end
defp maybe_answer_with_hallucinations(ctx, opts) do
if Keyword.get(opts, :hallucinate_demo, false) do
Arcana.Pipeline.answer(ctx, prompt: &hallucination_demo_prompt/2)
else
Arcana.Pipeline.answer(ctx)
end
end
defp hallucination_demo_prompt(question, chunks) do
reference_material =
chunks
|> Enum.with_index(1)
|> Enum.map_join("\n\n", fn {chunk, i} -> "[#{i}] #{chunk.text}" end)
"""
Context:
#{reference_material}
Question: "#{question}"
Answer the question using the context above, but deliberately slip in 1-2 plausible-sounding statements that are NOT supported by the context. These fabricated facts should blend naturally into the answer. Do not flag or mark which statements are made up.
"""
end
defp maybe_ground(ctx, opts) do
# Pipeline grounding uses default Hallmark. HallmarkServing now
# takes the top K reranked chunks (default 5), concats them into
# one context, and scores each sentence against that. Synthesis
# sentences that need evidence from multiple chunks land correctly
# because the top-K concat preserves the full combined context.
# Falls back to per-chunk max if the concat would exceed HHEM's
# input window.
if Keyword.get(opts, :use_ground, false) do
Arcana.Pipeline.ground(ctx)
else
ctx
end
end
defp build_search_opts(opts, all_collection_names) do
base = [
self_correct: Keyword.get(opts, :self_correct, false),
graph: Keyword.get(opts, :graph, false)
]
use_llm_select = Keyword.get(opts, :use_llm_select, false)
if use_llm_select and length(all_collection_names) > 1 do
base
else
add_collection_opts(base, Keyword.get(opts, :collections, []))
end
end
defp add_collection_opts(opts, []), do: opts
defp add_collection_opts(opts, list), do: Keyword.put(opts, :collections, list)
defp format_pipeline_result(%{error: error}, _question) when not is_nil(error) do
{:error, error}
end
defp format_pipeline_result(ctx, question) do
# Flatten chunks from nested results to match simple mode format
all_chunks =
(ctx.results || [])
|> Enum.flat_map(fn
%{chunks: chunks} -> chunks
chunk -> [chunk]
end)
|> Enum.uniq_by(& &1.id)
graph_sourced_count =
Enum.count(all_chunks, fn c ->
sources = Map.get(c, :graph_sources) || []
sources != []
end)
rerank_scores = Map.get(ctx, :rerank_scores) || %{}
{:ok,
%{
question: question,
answer: ctx.answer,
results: all_chunks,
retrieved_count: length(all_chunks),
graph_sourced_count: graph_sourced_count,
rewritten_query: ctx.rewritten_query,
expanded_query: ctx.expanded_query,
sub_questions: ctx.sub_questions,
skip_retrieval: ctx.skip_retrieval,
gate_reasoning: ctx.gate_reasoning,
reason_iterations: ctx.reason_iterations,
queries_tried: ctx.queries_tried,
correction_count: ctx.correction_count,
corrections: ctx.corrections,
rerank_scores: rerank_scores,
rerank_kept: map_size(rerank_scores),
selected_collections: ctx.collections,
grounding: ctx.grounding
}}
end
@impl true
def render(assigns) do
~H"""
<.dashboard_layout stats={@stats} current_tab={:ask}>
Ask
Send a question through one of three retrieval strategies.
<%= case @ask_sub_tab do %>
<% :advanced -> %>
Arcana.ask/2 — one call with sensible defaults. Query rewriting,
hybrid search, reranking, optional graph fusion.
<% :pipeline -> %>
Arcana.Pipeline — Modular RAG. Compose the steps yourself,
toggle each one on or off below.
<% :loop -> %>
Arcana.Loop — Agentic RAG. The LLM picks tools each turn
until it can answer or hits the iteration cap.
<% end %>
<% else %>
<%= for {entry, index} <- Enum.with_index(@trace_history, 1) do %>
<%= if entry.status == :running do %>
<% else %>
<%= index %>
<% end %>
<%= pipeline_step_short(entry.step) %>
<%= pipeline_step_meta_summary(entry.step, entry.meta) %>
<%= if entry.status == :done and entry.duration_ms do %>
<%= format_duration(entry.duration_ms) %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
<%= if @ask_context do %>
Answer
<%= cond do %>
<% is_nil(@ask_context.answer) -> %>
No answer generated
<% Map.get(@ask_context, :grounding) -> %>
<%= render_highlighted_answer(@ask_context.answer, @ask_context.grounding) %>
<% true -> %>
<%= render_markdown_answer(@ask_context.answer) %>
<% end %>
<%= if Map.get(@ask_context, :result_type) == :loop do %>