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: :simple, ask_question: "", ask_running: false, ask_context: nil, ask_error: nil, stats: nil, collections: [], graph_context_expanded: true )} 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 any_graph_enabled?(collections) do Enum.any?(collections, & &1[:graph_enabled]) 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 {:noreply, assign(socket, ask_mode: String.to_existing_atom(mode))} end def handle_event("toggle_graph_context", _params, socket) do {:noreply, assign(socket, graph_context_expanded: !socket.assigns.graph_context_expanded)} end defp start_ask_task(socket, params, llm, question) do repo = socket.assigns.repo mode = params["mode"] || "simple" selected_collections = params["collections"] || [] parent = self() Task.start(fn -> result = run_ask( mode, question, repo, llm, socket.assigns.collections, params, selected_collections ) 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) 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", self_correct: params["self_correct"] == "true" ) end @impl true 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) {:error, reason} -> assign(socket, ask_running: false, ask_error: inspect(reason)) end {:noreply, socket} end defp run_simple_ask(question, repo, llm, selected_collections) do opts = [repo: repo, llm: llm] # Add collection(s) option if user selected any 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) |> Agent.answer() |> 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 build_search_opts(opts, all_collection_names) do base = [self_correct: Keyword.get(opts, :self_correct, 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 {:ok, %{ question: question, answer: ctx.answer, results: ctx.results, expanded_query: ctx.expanded_query, sub_questions: ctx.sub_questions, selected_collections: ctx.collections }} 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 any_graph_enabled?(@collections) do %>
<% end %>
<%= if @ask_mode == :agentic and length(@collections) > 1 do %> <% end %> <%= for coll <- @collections do %> <% end %>
Select none for all collections
<%= if @ask_mode == :agentic do %>

Pipeline Options

<% end %>
<%= if @ask_context do %> <% end %>
<%= if @ask_running do %>
<%= if @ask_mode == :simple, do: "Generating answer...", else: "Running pipeline..." %>
<% end %> <%= if @ask_context do %>

Answer

<%= if @ask_context.answer do %> <%= @ask_context.answer %> <% else %> No answer generated <% 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 end