defmodule ArcanaWeb.AskLive do
@moduledoc """
LiveView for asking questions about documents in Arcana.
"""
use Phoenix.LiveView
import Ecto.Query
import ArcanaWeb.DashboardComponents
alias Arcana.Graph.Entity
@impl true
def mount(_params, session, socket) do
repo = get_repo_from_session(session)
{:ok,
socket
|> assign(repo: repo)
|> assign(
ask_mode: :agentic,
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
)}
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))
|> 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)
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: "")}
end
def handle_event("ask_switch_mode", %{"mode" => mode}, socket) do
mode = String.to_existing_atom(mode)
# Reset llm_select when switching to simple mode
llm_select = if mode == :simple, do: false, else: socket.assigns.llm_select
{:noreply, assign(socket, ask_mode: mode, llm_select: llm_select)}
end
def handle_event("form_changed", params, socket) do
selected = params["collections"] || []
{:noreply, assign(socket, selected_collections: selected)}
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
defp start_ask_task(socket, params, llm, question) do
repo = socket.assigns.repo
mode = params["mode"] || "simple"
selected_collections = params["collections"] || []
parent = self()
Arcana.TaskSupervisor.start_child(fn ->
handler_id = "pipeline-progress-#{inspect(parent)}"
graph_enabled = params["graph_search"] == "true"
steps = [:expand, :decompose, :select, :search, :self_correct, :rerank, :answer, :ground]
events =
Enum.map(steps, &[:arcana, :agent, &1, :start]) ++
[[:arcana, :graph, :search, :start]]
:telemetry.attach_many(
handler_id,
events,
fn
[:arcana, :graph, :search, :start], _measurements, _metadata, _config ->
send(parent, {:pipeline_progress, "Searching with graph connections..."})
[:arcana, :agent, 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
)
result =
run_ask(
mode,
question,
repo,
llm,
socket.assigns.collections,
params,
selected_collections
)
:telemetry.detach(handler_id)
send(parent, {:ask_complete, result})
end)
end
defp run_ask("simple", question, repo, llm, _all_collections, params, selected_collections) do
run_simple_ask(question, repo, llm, selected_collections, params)
end
defp run_ask(_mode, question, repo, llm, all_collections, params, selected_collections) do
run_agentic_ask(
question,
repo,
llm,
all_collections,
collections: selected_collections,
use_llm_select: params["llm_select"] == "true",
use_expand: params["use_expand"] == "true",
use_decompose: params["use_decompose"] == "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({:ask_complete, result}, socket) do
socket =
case result do
{:ok, ctx} ->
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
defp run_simple_ask(question, repo, llm, selected_collections, params) do
graph = params["graph_search"] == "true"
opts = [repo: repo, llm: llm, graph: graph]
opts =
case selected_collections do
[] -> opts
[single] -> Keyword.put(opts, :collection, single)
multiple -> Keyword.put(opts, :collections, multiple)
end
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_agentic_ask(question, repo, llm, all_collections, opts) do
alias Arcana.Agent
all_collection_names = Enum.map(all_collections, & &1.name)
search_opts = build_search_opts(opts, all_collection_names)
Agent.new(question, repo: repo, llm: llm)
|> maybe_select(opts, all_collection_names)
|> maybe_expand(opts)
|> maybe_decompose(opts)
|> Agent.search(search_opts)
|> maybe_rerank(opts)
|> maybe_answer_with_hallucinations(opts)
|> maybe_ground(opts)
|> format_agentic_result(question)
rescue
e -> {:error, Exception.message(e)}
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.Agent.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.Agent.expand(ctx), else: ctx
end
defp maybe_decompose(ctx, opts) do
if Keyword.get(opts, :use_decompose, false), do: Arcana.Agent.decompose(ctx), else: ctx
end
defp maybe_rerank(ctx, opts) do
if Keyword.get(opts, :use_rerank, false), do: Arcana.Agent.rerank(ctx), else: ctx
end
defp maybe_answer_with_hallucinations(ctx, opts) do
if Keyword.get(opts, :hallucinate_demo, false) do
Arcana.Agent.answer(ctx, prompt: &hallucination_demo_prompt/2)
else
Arcana.Agent.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
if Keyword.get(opts, :use_ground, false), do: Arcana.Agent.ground(ctx), else: ctx
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, [single]), do: Keyword.put(opts, :collection, single)
defp add_collection_opts(opts, multiple), do: Keyword.put(opts, :collections, multiple)
defp format_agentic_result(%{error: error}, _question) when not is_nil(error) do
{:error, error}
end
defp format_agentic_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)
{:ok,
%{
question: question,
answer: ctx.answer,
results: all_chunks,
expanded_query: ctx.expanded_query,
sub_questions: ctx.sub_questions,
selected_collections: ctx.collections,
grounding: ctx.grounding
}}
end
@impl true
def render(assigns) do
~H"""
<.dashboard_layout stats={@stats} current_tab={:ask}>
Ask
Ask questions about your documents. Choose Simple for basic RAG or Agentic for advanced pipeline features.
<%= if @ask_mode == :simple do %>
Basic RAG: search for relevant chunks and generate an answer.
<% else %>
Advanced RAG: query expansion, decomposition, self-correction, and reranking.
<% end %>
<%= if @ask_context.answer do %>
<%= if Map.get(@ask_context, :grounding) do %>
<%= render_highlighted_answer(@ask_context.answer, @ask_context.grounding) %>
<% else %>
<%= @ask_context.answer %>
<% end %>
<% else %>
No answer generated
<% end %>