lib/openagents_web/live/admin_tokens_live.ex

436d37facc96 · 8 KB

defmodule OpenAgentsWeb.AdminTokensLive do
  @moduledoc """
  Operator view of productive token usage versus raw volume.

  Every number is computed by `OpenAgents.TokenProductivity` at request time
  straight from PostgreSQL, the same tables the leaderboard reads, so the
  surface adds no second aggregation authority. Read-only and operator-gated.

  It shows aggregate token counts and rates only. It never renders
  conversation content, objectives, reports, or anything a person wrote.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Accounts
  alias OpenAgents.TokenProductivity

  @impl true
  def mount(_params, _session, socket) do
    if Accounts.admin?(socket.assigns.current_user) do
      socket =
        socket
        |> assign(:page_title, "Operator · Tokens")
        |> assign(:status, :loading)
        |> assign(:report, nil)

      if connected?(socket) do
        send(self(), :load)
      end

      {:ok, socket}
    else
      {:ok, redirect(socket, to: ~p"/")}
    end
  end

  @impl true
  def handle_event("refresh", _params, socket) do
    # Re-checked per event, not only at mount: a long-lived socket outlives the
    # decision that opened it.
    if Accounts.admin?(socket.assigns.current_user) do
      {:noreply,
       socket
       |> assign(:status, :loading)
       |> load()}
    else
      {:noreply, redirect(socket, to: ~p"/")}
    end
  end

  @impl true
  def handle_info(:load, socket), do: {:noreply, load(socket)}

  defp load(%{assigns: %{status: :loading}} = socket) do
    assign(socket, status: :loaded, report: TokenProductivity.report())
  end

  # A refresh while a load is already resolving must not clobber the newer
  # state with an older response.
  defp load(socket), do: socket

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Token productivity"
    >
      <main id="admin-tokens-page" class="app-shell admin-shell">
        <section class="admin space-y-8" aria-labelledby="tokens-heading">
          <header class="admin-heading">
            <h1 id="tokens-heading">Token productivity</h1>
            <p>
              Raw token volume next to the tokens that produced durable outcomes —
              merged work, closed issues, and verified receipts. Aggregate counts
              only; no conversation or run content is readable from this page.
            </p>
            <div class="admin-totals">
              <.badge
                :if={@status == :loaded && @report}
                variant={:dim}
                id="tokens-generated-at"
              >
                GENERATED {Calendar.strftime(@report.generated_at, "%Y-%m-%d %H:%M UTC")}
              </.badge>
              <.text_button id="tokens-refresh" phx-click="refresh" disabled={@status == :loading}>
                {if(@status == :loading, do: "REFRESHING…", else: "REFRESH")}
              </.text_button>
            </div>
          </header>

          <.alert :if={@status == :loading} id="tokens-loading" appearance={:row}>
            Computing token totals…
          </.alert>

          <div :if={@status == :loaded && @report} class="space-y-8">
            <section aria-labelledby="productive-heading">
              <.card id="tokens-productive">
                <h2 id="productive-heading" class="card-title">Productive versus raw</h2>
                <div class="flex flex-wrap items-center gap-3 pb-3">
                  <.badge>{format_tokens(@report.raw.total_tokens)} RAW</.badge>
                  <.badge variant={:success}>
                    {format_tokens(@report.productive.total_tokens)} PRODUCTIVE
                  </.badge>
                  <.badge :if={@report.productive.share} variant={:info}>
                    {format_share(@report.productive.share)} PRODUCTIVE SHARE
                  </.badge>
                </div>
                <ul class="divide-y divide-border">
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Merged work</span>
                    <span class="font-semibold">
                      {format_tokens(@report.productive.merged_work.total_tokens)}
                    </span>
                  </li>
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Closed issues</span>
                    <span class="font-semibold">
                      {format_tokens(@report.productive.closed_issues.total_tokens)}
                    </span>
                  </li>
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Verified receipts</span>
                    <span class="font-semibold">
                      {format_tokens(@report.productive.verified_receipts.total_tokens)}
                    </span>
                  </li>
                </ul>
              </.card>
            </section>

            <section aria-labelledby="rates-heading">
              <.card id="tokens-rates">
                <h2 id="rates-heading" class="card-title">Cache and split</h2>
                <ul class="divide-y divide-border">
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Cache hit rate</span>
                    <span class="font-semibold">{format_share(@report.cache.hit_rate)}</span>
                  </li>
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Cached input tokens</span>
                    <span class="font-semibold">
                      {format_tokens(@report.cache.cached_input_tokens)}
                    </span>
                  </li>
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Input share</span>
                    <span class="font-semibold">{format_share(@report.split.input_share)}</span>
                  </li>
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Input / output tokens</span>
                    <span class="font-semibold">
                      {format_tokens(@report.split.input_tokens)} / {format_tokens(
                        @report.split.output_tokens
                      )}
                    </span>
                  </li>
                </ul>
              </.card>
            </section>

            <section aria-labelledby="sources-heading">
              <.card id="tokens-sources">
                <h2 id="sources-heading" class="card-title">Raw volume by source</h2>
                <.table id="tokens-sources-table" rows={source_rows(@report.sources)}>
                  <:col :let={row} label="Source">{row.label}</:col>
                  <:col :let={row} label="Input">{format_tokens(row.totals.input_tokens)}</:col>
                  <:col :let={row} label="Output">{format_tokens(row.totals.output_tokens)}</:col>
                  <:col :let={row} label="Total">{format_tokens(row.totals.total_tokens)}</:col>
                </.table>
              </.card>
            </section>

            <section aria-labelledby="providers-heading">
              <.card id="tokens-providers">
                <h2 id="providers-heading" class="card-title">Provider throughput</h2>
                <p :if={@report.providers == []} class="text-muted-foreground">
                  No completed provider steps recorded yet.
                </p>
                <.table
                  :if={@report.providers != []}
                  id="tokens-providers-table"
                  rows={@report.providers}
                >
                  <:col :let={row} label="Provider">{row.provider_id}</:col>
                  <:col :let={row} label="Steps">{row.steps}</:col>
                  <:col :let={row} label="Input">{format_tokens(row.input_tokens)}</:col>
                  <:col :let={row} label="Output">{format_tokens(row.output_tokens)}</:col>
                  <:col :let={row} label="Tokens/s">{format_rate(row.tokens_per_second)}</:col>
                </.table>
              </.card>
            </section>
          </div>
        </section>
      </main>
    </Layouts.app>
    """
  end

  defp source_rows(sources) do
    [
      %{label: "Typed turns", totals: sources.typed_turns},
      %{label: "Voice sessions", totals: sources.voice_sessions},
      %{label: "Work jobs", totals: sources.work_jobs},
      %{label: "SCV runs", totals: sources.scv_runs}
    ]
  end

  defp format_tokens(count) when is_integer(count) do
    count
    |> Integer.to_charlist()
    |> Enum.reverse()
    |> Enum.chunk_every(3)
    |> Enum.join(",")
    |> String.reverse()
  end

  defp format_share(nil), do: "n/a"

  defp format_share(share) when is_float(share),
    do: "#{:erlang.float_to_binary(share * 100, decimals: 1)}%"

  defp format_rate(nil), do: "n/a"

  defp format_rate(rate) when is_float(rate),
    do: :erlang.float_to_binary(rate, decimals: 1)
end