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.{Community, Entity, GraphStore, Relationship} @page_size 50 @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, entity_types: [], selected_entity: nil, entity_details: nil, entities_page: 1, entities_total: 0, relationship_filter: "", relationship_type_filter: nil, relationship_types: [], relationship_strength_filter: nil, selected_relationship: nil, relationship_details: nil, relationships_page: 1, relationships_total: 0, community_filter: "", community_level_filter: nil, selected_community: nil, community_details: nil, communities_page: 1, communities_total: 0 )} 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 page = socket.assigns.entities_page offset = (page - 1) * @page_size opts = [repo: repo, limit: @page_size, offset: offset] |> 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) total = count_entities(repo, collection_id, name_filter, type_filter) entity_types = load_entity_types(repo, collection_id) assign(socket, entities: entities, entities_total: total, entity_types: entity_types) end defp count_entities(repo, collection_id, name_filter, type_filter) do query = from(e in Entity, select: count(e.id)) query = if collection_id, do: where(query, [e], e.collection_id == ^collection_id), else: query query = if name_filter && name_filter != "", do: where(query, [e], ilike(e.name, ^"%#{name_filter}%")), else: query query = if type_filter && type_filter != "", do: where(query, [e], e.type == ^type_filter), else: query repo.one(query) || 0 rescue _ -> 0 end defp load_entity_types(repo, collection_id) do query = from(e in Entity, group_by: e.type, select: %{type: e.type, count: count(e.id)}, order_by: [desc: count(e.id), asc: e.type] ) query = if collection_id, do: where(query, [e], e.collection_id == ^collection_id), else: query repo.all(query) |> Enum.map(& &1.type) rescue _ -> [] end defp load_relationship_types(repo, collection_id) do query = from(r in Relationship, join: source in Entity, on: source.id == r.source_id, group_by: r.type, select: %{type: r.type, count: count(r.id)}, order_by: [desc: count(r.id), asc: r.type], limit: 100 ) query = if collection_id, do: where(query, [r, source], source.collection_id == ^collection_id), else: query repo.all(query) |> Enum.map(& &1.type) rescue _ -> [] 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 page = socket.assigns.relationships_page offset = (page - 1) * @page_size opts = [repo: repo, limit: @page_size, offset: offset] |> 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) total = count_relationships(repo, collection_id, search_filter, type_filter, strength_filter) relationship_types = load_relationship_types(repo, collection_id) assign(socket, relationships: relationships, relationships_total: total, relationship_types: relationship_types ) end defp count_relationships(repo, collection_id, search_filter, type_filter, strength_filter) do query = from(r in Relationship, join: source in Entity, on: source.id == r.source_id, select: count(r.id) ) query = if collection_id, do: where(query, [r, source], source.collection_id == ^collection_id), else: query query = if search_filter && search_filter != "" do search_pattern = "%#{search_filter}%" from([r, source] in query, join: target in Entity, on: target.id == r.target_id, where: ilike(source.name, ^search_pattern) or ilike(target.name, ^search_pattern) or ilike(r.type, ^search_pattern) ) else query end query = if type_filter && type_filter != "", do: where(query, [r], r.type == ^type_filter), else: query query = case strength_filter do "strong" -> where(query, [r], r.strength >= 7) "medium" -> where(query, [r], r.strength >= 4 and r.strength < 7) "weak" -> where(query, [r], r.strength < 4) _ -> query end repo.one(query) || 0 rescue _ -> 0 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 page = socket.assigns.communities_page offset = (page - 1) * @page_size # Parse level filter to integer if it's a string level = parse_level_filter(level_filter) opts = [repo: repo, limit: @page_size, offset: offset] |> 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) total = count_communities(repo, collection_id, search_filter, level) assign(socket, communities: communities, communities_total: total) end defp parse_level_filter(nil), do: nil defp parse_level_filter(""), do: nil defp parse_level_filter(level) when is_integer(level), do: level defp parse_level_filter(level) when is_binary(level) do case Integer.parse(level) do {int, _} -> int :error -> nil end end defp count_communities(repo, collection_id, search_filter, level) do query = from(c in Community, select: count(c.id)) query = if collection_id, do: where(query, [c], c.collection_id == ^collection_id), else: query query = if search_filter && search_filter != "", do: where(query, [c], ilike(c.summary, ^"%#{search_filter}%")), else: query query = if level, do: where(query, [c], c.level == ^level), else: query repo.one(query) || 0 rescue _ -> 0 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, entities_page: 1) |> 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, relationships_page: 1 ) |> 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, communities_page: 1 ) |> 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 # Pagination handlers def handle_event("entities_page", %{"page" => page}, socket) do page = String.to_integer(page) {:noreply, socket |> assign(entities_page: page) |> load_entities()} end def handle_event("relationships_page", %{"page" => page}, socket) do page = String.to_integer(page) {:noreply, socket |> assign(relationships_page: page) |> load_relationships()} end def handle_event("communities_page", %{"page" => page}, socket) do page = String.to_integer(page) {:noreply, socket |> assign(communities_page: page) |> load_communities()} 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) defp total_pages(total) do max(1, ceil(total / @page_size)) end @impl true def render(assigns) do ~H""" <.dashboard_layout stats={@stats} current_tab={:graph}>
Explore entities, relationships, and communities extracted from your documents.
Enable GraphRAG during document ingestion:
Arcana.ingest(text, repo: Repo, graph: true)
This extracts entities and relationships to enhance search.
| Name | Type | Mentions | Relationships |
|---|---|---|---|
| <%= entity.name %> | <%= entity.type %> | <%= entity.mention_count %> | <%= entity.relationship_count %> |
<%= @details.entity.description %>
<% end %> <%= if Enum.any?(@details.relationships) do %><%= String.slice(mention.context || mention.chunk_text || "", 0, 200) %>
View in Documents →| Source | Relationship | Target | Strength |
|---|---|---|---|
| <%= rel.source_name %> | <%= rel.type %> |
<%= rel.target_name %> | <.strength_meter value={rel.strength || 5} /> |
<%= @details.relationship.type %>
→
<%= @details.relationship.target_name %>
<%= @details.relationship.description %>
<% end %>No Communities Detected
Communities are generated when there are enough entities and relationships to form meaningful clusters.
| 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 %> |
<%= @details.community.summary %>
No summary generated yet.
<%= rel.type %> <%= rel.target_name %>