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_error do %>
<%= @ask_error %>
<% end %>
<%= if @ask_mode == :simple and selected_graph_enabled?(@collections, @selected_collections) do %> <% end %>
<%= if @ask_mode == :agentic and length(@collections) > 1 do %>
<% end %> <%= if not @llm_select do %>
<%= for coll <- @collections do %> <% end %>
Select none for all collections <% end %>
<%= if @ask_mode == :agentic do %>

Pipeline all / none

  1. <%= if selected_graph_enabled?(@collections, @selected_collections) do %>
    or
    <% else %>
    Search Retrieve relevant chunks
    <% end %>
  2. or
<% end %>
<%= if @ask_context do %> <% end %>
<%= if @ask_running do %>
<%= if @ask_mode == :simple, do: "Generating answer...", else: @pipeline_step || "Running pipeline..." %>
<% end %> <%= if @ask_context do %>

Answer

<%= 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 %>
<%= if Map.get(@ask_context, :grounding) do %>

Grounding = 0.8, do: "good", else: if(@ask_context.grounding.score >= 0.5, do: "warn", else: "bad")}"}> <%= Float.round(@ask_context.grounding.score * 100, 1) %>% faithful

<% chunks = Map.get(@ask_context, :results, []) %> <%= if length(@ask_context.grounding.hallucinated_spans) > 0 do %>
Hallucinated Spans
<%= for span <- @ask_context.grounding.hallucinated_spans do %>
<%= span.text %> <%= Float.round(span.score, 2) %> <.source_previews sources={Map.get(span, :sources, [])} chunks={chunks} />
<% end %>
<% end %> <%= if length(@ask_context.grounding.faithful_spans) > 0 do %>
Faithful Spans (<%= length(@ask_context.grounding.faithful_spans) %>)
<%= for span <- @ask_context.grounding.faithful_spans do %>
<%= span.text %> <.source_previews sources={Map.get(span, :sources, [])} chunks={chunks} />
<% end %>
<% end %>
<% end %> <%= if @ask_context.expanded_query do %>

Expanded Query

<%= @ask_context.expanded_query %>

<% end %> <%= if @ask_context.sub_questions && length(@ask_context.sub_questions) > 0 do %>

Sub-Questions

    <%= for sq <- @ask_context.sub_questions do %>
  • <%= sq %>
  • <% end %>
<% end %> <%= if @ask_context.selected_collections && length(@ask_context.selected_collections) > 0 do %>

Selected Collections

<%= for coll <- @ask_context.selected_collections do %> <%= coll %> <% end %>
<% end %> <%= if Map.get(@ask_context, :graph_enhanced) do %> <.graph_context_section matched_entities={Map.get(@ask_context, :matched_entities, [])} matched_relationships={Map.get(@ask_context, :matched_relationships, [])} expanded={@graph_context_expanded} /> <% end %> <%= if @ask_context.results && length(@ask_context.results) > 0 do %> <% all_chunks = @ask_context.results %>

Retrieved Chunks (<%= length(all_chunks) %>)

<%= for chunk <- all_chunks do %>
<%= Float.round(chunk.score, 4) %> <%= if Map.get(chunk, :graph_sources) && length(chunk.graph_sources) > 0 do %> via: <%= Enum.join(chunk.graph_sources, ", ") %> <% end %>
<%= chunk.document_id %> Chunk <%= chunk.chunk_index %>
<%= String.slice(chunk.text, 0, 300) %><%= if String.length(chunk.text) > 300, do: "...", else: "" %>
<% end %>
<% end %>
<% end %>
""" end defp graph_context_section(assigns) do ~H"""

Graph Context

<%= if @expanded do %>
<%= if length(@matched_entities) == 0 and length(@matched_relationships) == 0 do %>

No entity matches — used vector search only

<% else %> <%= if length(@matched_entities) > 0 do %>
Matched Entities
    <%= for entity <- @matched_entities do %>
  • <%= entity.name %> <%= entity.type %> <%= if Map.get(entity, :id) do %> View in Graph <% end %>
  • <% end %>
<% end %> <%= if length(@matched_relationships) > 0 do %>
Key Relationships
    <%= for rel <- @matched_relationships do %>
  • <%= rel.source %> —<%= rel.type %>→ <%= rel.target %>
  • <% end %>
<% end %> <% end %>
<% end %>
""" end defp source_previews(assigns) do ~H""" <%= if @sources != [] do %>
<%= for source <- @sources do %> <% chunk = find_chunk(@chunks, source.chunk_id) %>
<%= chunk_label(chunk) %> <%= Float.round(source.score * 100) %>% overlap <%= if chunk do %>
<%= chunk.text %>
<% end %>
<% end %>
<% end %> """ end defp find_chunk(chunks, chunk_id) do Enum.find(chunks, fn chunk -> id = if is_struct(chunk), do: chunk.id, else: Map.get(chunk, :id) to_string(id) == to_string(chunk_id) end) end defp chunk_label(nil), do: "unknown chunk" defp chunk_label(chunk) do index = Map.get(chunk, :chunk_index, nil) if index, do: "Chunk #{index}", else: "Source" end defp render_highlighted_answer(answer, grounding) do hallucinated = Map.get(grounding, :hallucinated_spans, []) faithful = Map.get(grounding, :faithful_spans, []) spans = (Enum.map(hallucinated, &Map.put(&1, :type, :hallucinated)) ++ Enum.map(faithful, &Map.put(&1, :type, :faithful))) |> Enum.sort_by(& &1.start) build_highlighted_parts(answer, spans, 0, []) |> Enum.reverse() |> Phoenix.HTML.raw() end defp build_highlighted_parts(answer, [], pos, acc) do rest = binary_slice(answer, pos, byte_size(answer) - pos) if rest == "", do: acc, else: [html_escape(rest) | acc] end defp build_highlighted_parts(answer, [span | rest], pos, acc) do span_start = span.start span_end = Map.get(span, :end, span_start) # Text before this span acc = if span_start > pos do gap = binary_slice(answer, pos, span_start - pos) [html_escape(gap) | acc] else acc end # The span itself span_text = binary_slice(answer, span_start, span_end - span_start) class = if span.type == :hallucinated, do: "arcana-hl-hallucinated", else: "arcana-hl-faithful" acc = [ ~s(#{html_escape(span_text)}) | acc ] build_highlighted_parts(answer, rest, span_end, acc) end defp html_escape(text) do text |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() end defp pipeline_step_label(:expand), do: "Expanding query..." defp pipeline_step_label(:decompose), do: "Decomposing question..." defp pipeline_step_label(:select), do: "Selecting collections..." defp pipeline_step_label(:search), do: "Searching..." defp pipeline_step_label(:self_correct), do: "Refining search..." defp pipeline_step_label(:rerank), do: "Reranking results..." defp pipeline_step_label(:answer), do: "Generating answer..." defp pipeline_step_label(:ground), do: "Checking for hallucinations..." defp pipeline_step_label(_), do: nil end