defmodule ArcanaWeb.ChunkResultsComponent do
@moduledoc """
Shared component for rendering retrieved chunks across the Advanced,
Pipeline, and Loop sub-tabs of the Ask dashboard.
## Features
- Groups chunks by document with a compact header per document.
- Shows document titles when available (falls back to a shortened UUID).
- Click-to-expand details for each chunk, revealing the full text.
- When `:grounding` is supplied, centers the snippet around the text the
grounder cited from this chunk (instead of the first N chars).
- Highlights chunks that the grounder used as a source.
- Score rendered as `score: 0.0246`.
## Usage
"""
use Phoenix.Component
attr(:chunks, :list,
required: true,
doc: "List of chunk maps with id/text/score/document_id/chunk_index"
)
attr(:document_titles, :map, default: %{}, doc: "Map of document_id to display title")
attr(:grounding, :any, default: nil, doc: "Optional %Arcana.Grounding.Result{}")
attr(:id, :string, default: "chunk-results")
def chunk_results(assigns) do
assigns =
assigns
|> assign(:grouped, group_by_document(assigns.chunks))
|> assign(:chunk_sources, build_chunk_source_index(assigns.grounding))
~H"""
<%= for {doc_id, doc_chunks} <- @grouped do %>
<%= for chunk <- doc_chunks do %>
-
score: <%= score_str(chunk.score) %>
Chunk <%= chunk.chunk_index %>
<%= if graph_sources = Map.get(chunk, :graph_sources) do %>
<%= if is_list(graph_sources) and graph_sources != [] do %>
Enum.join(graph_sources, ", ")}>via: <%= Enum.join(graph_sources, ", ") %>
<% end %>
<% end %>
<%= if Map.has_key?(@chunk_sources, Map.get(chunk, :id)) do %>
✓ source
<% end %>
<%= snippet(chunk, @chunk_sources) %>
<%= String.trim(chunk.text || "") %>
<% end %>
<% end %>
"""
end
# --- Grouping ---
# Sort docs by their highest-scoring chunk descending so the most
# relevant document is rendered first.
defp group_by_document(chunks) do
chunks
|> Enum.group_by(&Map.get(&1, :document_id))
|> Enum.map(fn {doc_id, cs} -> {doc_id, Enum.sort_by(cs, & &1.score, :desc)} end)
|> Enum.sort_by(fn {_doc_id, cs} ->
-(cs |> Enum.map(& &1.score) |> Enum.max(fn -> 0 end))
end)
end
# --- Document label resolution ---
defp document_label(nil, _titles), do: "(unknown document)"
defp document_label(doc_id, titles) do
case Map.get(titles, doc_id) do
title when is_binary(title) and title != "" -> title
_ -> shorten_uuid(doc_id)
end
end
defp shorten_uuid(uuid) when is_binary(uuid), do: String.slice(uuid, 0, 8)
defp shorten_uuid(other), do: inspect(other)
# Native title tooltip showing the full chunk id and owning document id.
# Falls back gracefully when either is missing (search results can lack
# the chunk id field).
defp chunk_tooltip(chunk) do
[
chunk |> Map.get(:id) |> format_tooltip_line("Chunk"),
chunk |> Map.get(:document_id) |> format_tooltip_line("Document")
]
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
end
defp format_tooltip_line(nil, _label), do: nil
defp format_tooltip_line(id, label), do: "#{label}: #{id}"
# --- Score formatting ---
defp score_str(score) when is_number(score) do
:io_lib.format("~.4f", [score * 1.0]) |> IO.iodata_to_binary()
end
defp score_str(_), do: "—"
# --- Chunk count wording ---
defp chunk_word(1), do: "chunk"
defp chunk_word(_), do: "chunks"
# --- Grounding source index ---
# Builds a map of chunk_id -> list of spans that cited this chunk, so
# downstream helpers can check membership in O(1) and pick the first
# span to center the snippet on.
defp build_chunk_source_index(nil), do: %{}
defp build_chunk_source_index(%{hallucinated_spans: hs, faithful_spans: fs}) do
(hs ++ fs)
|> Enum.flat_map(fn span ->
span
|> Map.get(:sources, [])
|> Enum.map(fn %{chunk_id: id} -> {id, span} end)
end)
|> Enum.group_by(fn {id, _} -> id end, fn {_, span} -> span end)
end
defp build_chunk_source_index(_), do: %{}
defp any_source?(chunks, chunk_sources) do
Enum.any?(chunks, &Map.has_key?(chunk_sources, Map.get(&1, :id)))
end
# --- Snippet selection ---
@preview_window 220
# When grounding says this chunk backed a span, find the span text in
# the chunk and return a window centered on it. Otherwise, fall back
# to the first N characters. The snippet always gets trimmed to drop
# leading/trailing whitespace from the source text.
defp snippet(chunk, chunk_sources) do
text = String.trim(chunk.text || "")
case Map.get(chunk_sources, Map.get(chunk, :id)) do
[span | _] ->
center_snippet(text, Map.get(span, :text, ""), @preview_window)
_ ->
head_snippet(text, @preview_window)
end
end
defp head_snippet(text, window) do
if String.length(text) > window do
String.slice(text, 0, window) <> "..."
else
text
end
end
defp center_snippet(text, "", window), do: head_snippet(text, window)
defp center_snippet(text, span_text, window) do
case :binary.match(text, span_text) do
:nomatch ->
head_snippet(text, window)
{start, len} ->
span_bytes = len
context = max(div(window - span_bytes, 2), 0)
snippet_start = max(0, start - context)
snippet_end = min(byte_size(text), start + span_bytes + context)
prefix = if snippet_start > 0, do: "...", else: ""
suffix = if snippet_end < byte_size(text), do: "...", else: ""
prefix <> binary_part(text, snippet_start, snippet_end - snippet_start) <> suffix
end
end
end