defmodule ArcanaWeb.GraphLive do @moduledoc """ LiveView for exploring the GraphRAG knowledge graph. Provides three sub-views: - Entities: Browse and search entities with their relationships and source chunks - Relationships: Explore entity relationships with strength indicators - Communities: View community clusters with LLM-generated summaries """ use Phoenix.LiveView import ArcanaWeb.DashboardComponents import Ecto.Query alias Arcana.Graph.{Entity, GraphStore} @impl true def mount(_params, session, socket) do repo = get_repo_from_session(session) {:ok, socket |> assign(repo: repo) |> assign( current_subtab: :entities, selected_collection: nil, collections: [], entities: [], relationships: [], communities: [], stats: nil, entity_filter: "", entity_type_filter: nil, selected_entity: nil, entity_details: nil, relationship_filter: "", relationship_type_filter: nil, relationship_strength_filter: nil, selected_relationship: nil, relationship_details: nil, community_filter: "", community_level_filter: nil, selected_community: nil, community_details: nil )} end @impl true def handle_params(params, _uri, socket) do subtab = case params["tab"] do "relationships" -> :relationships "communities" -> :communities _ -> :entities end selected_collection = params["collection"] {:noreply, socket |> assign(current_subtab: subtab, selected_collection: selected_collection) |> load_data()} end defp load_data(socket) do repo = socket.assigns.repo socket |> assign(stats: load_stats(repo)) |> load_collections_with_graph_status() |> load_subtab_data() end defp load_collections_with_graph_status(socket) do repo = socket.assigns.repo collections = repo.all( from(c in Arcana.Collection, left_join: e in Entity, on: e.collection_id == c.id, group_by: c.id, order_by: c.name, select: %{ id: c.id, name: c.name, description: c.description, entity_count: count(e.id, :distinct) } ) ) |> Enum.map(fn c -> Map.put(c, :graph_enabled, c.entity_count > 0) end) assign(socket, collections: collections) end defp load_subtab_data(%{assigns: %{current_subtab: :entities}} = socket) do load_entities(socket) end defp load_subtab_data(%{assigns: %{current_subtab: :relationships}} = socket) do load_relationships(socket) end defp load_subtab_data(%{assigns: %{current_subtab: :communities}} = socket) do load_communities(socket) end defp load_entities(socket) do repo = socket.assigns.repo collection_id = get_selected_collection_id(socket) name_filter = socket.assigns.entity_filter || "" type_filter = socket.assigns.entity_type_filter opts = [repo: repo, limit: 50] |> maybe_add_opt(:collection_id, collection_id) |> maybe_add_opt(:search, if(name_filter != "", do: name_filter)) |> maybe_add_opt(:type, if(type_filter && type_filter != "", do: type_filter)) entities = GraphStore.list_entities(opts) assign(socket, entities: entities) end defp load_relationships(socket) do repo = socket.assigns.repo collection_id = get_selected_collection_id(socket) search_filter = socket.assigns.relationship_filter type_filter = socket.assigns.relationship_type_filter strength_filter = socket.assigns.relationship_strength_filter opts = [repo: repo, limit: 50] |> maybe_add_opt(:collection_id, collection_id) |> maybe_add_opt(:search, if(search_filter && search_filter != "", do: search_filter)) |> maybe_add_opt(:type, if(type_filter && type_filter != "", do: type_filter)) |> maybe_add_opt(:strength, strength_filter) relationships = GraphStore.list_relationships(opts) assign(socket, relationships: relationships) end defp load_communities(socket) do repo = socket.assigns.repo collection_id = get_selected_collection_id(socket) search_filter = socket.assigns.community_filter level_filter = socket.assigns.community_level_filter # Parse level filter to integer if it's a string level = case level_filter do nil -> nil "" -> nil level when is_integer(level) -> level level when is_binary(level) -> case Integer.parse(level) do {int, _} -> int :error -> nil end end opts = [repo: repo, limit: 50] |> maybe_add_opt(:collection_id, collection_id) |> maybe_add_opt(:search, if(search_filter && search_filter != "", do: search_filter)) |> maybe_add_opt(:level, level) communities = GraphStore.list_communities(opts) assign(socket, communities: communities) end @impl true def handle_event("switch_subtab", %{"tab" => tab}, socket) do {:noreply, push_patch(socket, to: build_path(socket, tab: tab))} end def handle_event("select_collection", %{"collection" => ""}, socket) do {:noreply, push_patch(socket, to: build_path(socket, collection: nil))} end def handle_event("select_collection", %{"collection" => collection}, socket) do {:noreply, push_patch(socket, to: build_path(socket, collection: collection))} end def handle_event("filter_entities", params, socket) do name_filter = params["name"] || "" type_filter = params["type"] {:noreply, socket |> assign(entity_filter: name_filter, entity_type_filter: type_filter) |> load_entities()} end def handle_event("select_entity", %{"id" => id}, socket) do details = load_entity_details(socket.assigns.repo, id) {:noreply, assign(socket, selected_entity: id, entity_details: details)} end def handle_event("close_entity_detail", _params, socket) do {:noreply, assign(socket, selected_entity: nil, entity_details: nil)} end def handle_event("filter_relationships", params, socket) do search_filter = params["search"] || "" type_filter = params["type"] strength_filter = params["strength"] {:noreply, socket |> assign( relationship_filter: search_filter, relationship_type_filter: type_filter, relationship_strength_filter: strength_filter ) |> load_relationships()} end def handle_event("select_relationship", %{"id" => id}, socket) do details = load_relationship_details(socket.assigns.repo, id) {:noreply, assign(socket, selected_relationship: id, relationship_details: details)} end def handle_event("close_relationship_detail", _params, socket) do {:noreply, assign(socket, selected_relationship: nil, relationship_details: nil)} end def handle_event("filter_communities", params, socket) do search_filter = params["search"] || "" level_filter = params["level"] {:noreply, socket |> assign( community_filter: search_filter, community_level_filter: level_filter ) |> load_communities()} end def handle_event("select_community", %{"id" => id}, socket) do details = load_community_details(socket.assigns.repo, id) {:noreply, assign(socket, selected_community: id, community_details: details)} end def handle_event("close_community_detail", _params, socket) do {:noreply, assign(socket, selected_community: nil, community_details: nil)} end defp load_relationship_details(repo, relationship_id) do case GraphStore.get_relationship(relationship_id, repo: repo) do {:ok, relationship} -> %{relationship: relationship} {:error, :not_found} -> %{relationship: nil} end end defp load_entity_details(repo, entity_id) do entity = case GraphStore.get_entity(entity_id, repo: repo) do {:ok, e} -> e {:error, :not_found} -> nil end relationships = GraphStore.get_relationships(entity_id, repo: repo) mentions = GraphStore.get_mentions(entity_id, repo: repo, limit: 5) %{entity: entity, relationships: relationships, mentions: mentions} end defp load_community_details(repo, community_id) do case GraphStore.get_community(community_id, repo: repo) do {:ok, community} -> build_community_details(community, repo) {:error, :not_found} -> %{community: nil, entities: [], internal_relationships: []} end end defp build_community_details(community, repo) do entity_ids = community.entity_ids || [] entities = load_community_entities(entity_ids, repo) internal_relationships = load_internal_relationships(entity_ids, repo) %{community: community, entities: entities, internal_relationships: internal_relationships} end defp load_community_entities(entity_ids, repo) do entity_ids |> Enum.map(&fetch_entity_summary(&1, repo)) |> Enum.reject(&is_nil/1) end defp fetch_entity_summary(id, repo) do case GraphStore.get_entity(id, repo: repo) do {:ok, entity} -> %{id: entity.id, name: entity.name, type: entity.type} {:error, :not_found} -> nil end end defp load_internal_relationships(entity_ids, _repo) when length(entity_ids) < 2, do: [] defp load_internal_relationships(entity_ids, repo) do entity_ids |> Enum.flat_map(fn id -> GraphStore.get_relationships(id, repo: repo) end) |> Enum.filter(fn rel -> rel.source_id in entity_ids and rel.target_id in entity_ids end) |> Enum.uniq_by(& &1.id) end defp build_path(socket, overrides) do tab = Keyword.get(overrides, :tab, Atom.to_string(socket.assigns.current_subtab)) collection = case Keyword.fetch(overrides, :collection) do {:ok, nil} -> nil {:ok, c} -> c :error -> socket.assigns.selected_collection end params = [tab: tab, collection: collection] |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Enum.into(%{}) "/arcana/graph?" <> URI.encode_query(params) end defp has_any_graph_data?(collections) do Enum.any?(collections, & &1.graph_enabled) end defp get_selected_collection_id(socket) do selected_name = socket.assigns.selected_collection if selected_name do socket.assigns.collections |> Enum.find(fn c -> c.name == selected_name end) |> case do nil -> nil collection -> collection.id end else nil end end defp maybe_add_opt(opts, _key, nil), do: opts defp maybe_add_opt(opts, key, value), do: Keyword.put(opts, key, value) @impl true def render(assigns) do ~H""" <.dashboard_layout stats={@stats} current_tab={:graph}>

Graph

Explore entities, relationships, and communities extracted from your documents.

<%= if not has_any_graph_data?(@collections) do %>

No Graph Data Yet

Enable GraphRAG during document ingestion:

Arcana.ingest(text, repo: Repo, graph: true)

This extracts entities and relationships to enhance search.

<% else %>
<%= case @current_subtab do %> <% :entities -> %> <.entities_view entities={@entities} entity_filter={@entity_filter} entity_type_filter={@entity_type_filter} selected_entity={@selected_entity} entity_details={@entity_details} /> <% :relationships -> %> <.relationships_view relationships={@relationships} relationship_filter={@relationship_filter} relationship_type_filter={@relationship_type_filter} relationship_strength_filter={@relationship_strength_filter} selected_relationship={@selected_relationship} relationship_details={@relationship_details} /> <% :communities -> %> <.communities_view communities={@communities} community_filter={@community_filter} community_level_filter={@community_level_filter} selected_community={@selected_community} community_details={@community_details} /> <% end %> <% end %>
""" end defp entities_view(assigns) do ~H"""
<%= if Enum.empty?(@entities) do %>
No entities found matching your criteria.
<% else %> <%= for entity <- @entities do %> <% end %>
Name Type Mentions Relationships
<%= entity.name %> <%= entity.type %> <%= entity.mention_count %> <%= entity.relationship_count %>
<% end %> <%= if @entity_details do %> <.entity_detail_panel details={@entity_details} /> <% end %>
""" end defp entity_detail_panel(assigns) do ~H"""

<%= @details.entity.name %>

<%= @details.entity.type %>
<%= if @details.entity.description do %>

<%= @details.entity.description %>

<% end %> <%= if Enum.any?(@details.relationships) do %>

Relationships

<% end %> <%= if Enum.any?(@details.mentions) do %>

Source Chunks

<%= for mention <- @details.mentions do %>

<%= String.slice(mention.context || mention.chunk_text || "", 0, 200) %>

View in Documents →
<% end %>
<% end %>
""" end defp relationships_view(assigns) do ~H"""
<%= if Enum.empty?(@relationships) do %>
No relationships found matching your criteria.
<% else %> <%= for rel <- @relationships do %> <% end %>
Source Relationship Target Strength
<%= rel.source_name %> <%= rel.type %> <%= rel.target_name %> <.strength_meter value={rel.strength || 5} />
<% end %> <%= if @relationship_details do %> <.relationship_detail_panel details={@relationship_details} /> <% end %>
""" end defp relationship_detail_panel(assigns) do ~H"""
<%= @details.relationship.source_name %> <%= @details.relationship.type %> <%= @details.relationship.target_name %>
Strength: <%= @details.relationship.strength || 5 %>/10
<%= if @details.relationship.description do %>

<%= @details.relationship.description %>

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

No Communities Detected

Communities are generated when there are enough entities and relationships to form meaningful clusters.

<% else %> <%= for community <- @communities do %> <% end %>
Community Level Entities Status
<%= if community.summary do %> <%= String.slice(community.summary, 0, 50) %><%= if String.length(community.summary || "") > 50, do: "...", else: "" %> <% else %> No summary <% end %> <%= community.level %> <%= community.entity_count %> <%= cond do %> <% community.dirty -> %> <% community.summary -> %> <% true -> %> <% end %>
<% end %> <%= if @community_details do %> <.community_detail_panel details={@community_details} /> <% end %>
""" end defp community_detail_panel(assigns) do ~H"""

Community

Level <%= @details.community.level %>
<%= if @details.community.summary do %>

Summary

<%= @details.community.summary %>

<% else %>

No summary generated yet.

<% end %> <%= if Enum.any?(@details.entities) do %>

Member Entities

<% end %> <%= if Enum.any?(@details.internal_relationships) do %>

Internal Relationships

<% end %>
""" end defp strength_meter(assigns) do value = assigns.value || 5 filled = min(max(round(value), 0), 10) assigns = assign(assigns, filled: filled) ~H""" <%= for i <- 1..10 do %> <% end %> """ end end