towerops/lib/towerops_web/live/insights_live/index.ex
Graham McIntire fd235d05b7 feat(insights): AI surfaces its own observations from full network state
Adds a parallel LLM path that reviews the whole org snapshot — Preseem
APs, SNMP signals, backhaul, agents, and the insight types the rules
already covered — and emits novel observations as ai_observation
insights (source=ai). Existing rule-based insights and per-insight
enrichment continue unchanged.

- lib/towerops/llm/network_snapshot.ex builds the bounded snapshot
- lib/towerops/llm/network_insight_prompt.ex builds the prompt and
  parses the LLM's JSON observations array
- lib/towerops/workers/ai_network_insight_worker.ex runs nightly at
  03:30 UTC (after the rule pass + per-insight enrichment) and on
  the superuser regenerate button
- preseem/insight.ex extends @valid_types with "ai_observation" and
  @valid_sources with "ai"
- insights_live/index.ex includes the new worker in the regen list
  and adds an "AI" source badge style

Bumps the SnmpKit Manager timeout-options test threshold from 500ms
to 3000ms (was flaking on loaded dev machines), and scopes
list_unenriched_insights/1 assertion in insights_test:573 to the
test's org so cross-suite leaks don't fail the count.
2026-05-10 14:29:56 -05:00

202 lines
6.2 KiB
Elixir

defmodule ToweropsWeb.InsightsLive.Index do
@moduledoc """
Unified insights page showing insights from all sources (Preseem, Gaiia, SNMP, System).
"""
use ToweropsWeb, :live_view
alias Towerops.Accounts.Scope
alias Towerops.Preseem
alias Towerops.Repo
@regeneration_workers [
Towerops.Workers.PreseemBaselineWorker,
Towerops.Workers.CapacityInsightWorker,
Towerops.Workers.DeviceHealthInsightWorker,
Towerops.Workers.GaiiaInsightWorker,
Towerops.Workers.SystemInsightWorker,
Towerops.Workers.WirelessInsightWorker,
Towerops.Workers.InsightLlmEnrichmentWorker,
Towerops.Workers.AiNetworkInsightWorker
]
@impl true
def mount(_params, _session, socket) do
org = socket.assigns.current_scope.organization
{:ok,
socket
|> assign(:organization, org)
|> assign(:page_title, t("Insights"))
|> assign(:selected_ids, MapSet.new())}
end
@impl true
def handle_params(params, _url, socket) do
filter_source = params["source"]
filter_urgency = params["urgency"]
filter_status = params["status"] || "active"
{:noreply,
socket
|> assign(:filter_source, filter_source)
|> assign(:filter_urgency, filter_urgency)
|> assign(:filter_status, filter_status)
|> assign(:selected_ids, MapSet.new())
|> load_insights()}
end
@impl true
def handle_event("filter", params, socket) do
query_params = %{}
query_params =
if params["source"] && params["source"] != "",
do: Map.put(query_params, "source", params["source"]),
else: query_params
query_params =
if params["urgency"] && params["urgency"] != "",
do: Map.put(query_params, "urgency", params["urgency"]),
else: query_params
query_params =
if params["status"] && params["status"] != "",
do: Map.put(query_params, "status", params["status"]),
else: query_params
{:noreply, push_patch(socket, to: ~p"/insights?#{query_params}")}
end
@impl true
def handle_event("dismiss", %{"id" => id}, socket) do
case Preseem.dismiss_insight(id) do
{:ok, _} ->
{:noreply,
socket
|> load_insights()
|> put_flash(:info, t("Insight dismissed"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to dismiss"))}
end
end
@impl true
def handle_event("toggle_select", %{"id" => id}, socket) do
selected = socket.assigns.selected_ids
selected =
if MapSet.member?(selected, id),
do: MapSet.delete(selected, id),
else: MapSet.put(selected, id)
{:noreply, assign(socket, :selected_ids, selected)}
end
@impl true
def handle_event("select_all", _params, socket) do
ids = MapSet.new(socket.assigns.insights, & &1.id)
{:noreply, assign(socket, :selected_ids, ids)}
end
@impl true
def handle_event("deselect_all", _params, socket) do
{:noreply, assign(socket, :selected_ids, MapSet.new())}
end
@impl true
def handle_event("regenerate_insights", _params, socket) do
if Scope.superuser?(socket.assigns.current_scope) do
Enum.each(@regeneration_workers, fn worker ->
%{} |> worker.new() |> Oban.insert()
end)
{:noreply,
put_flash(
socket,
:info,
t("Insight regeneration queued. New insights will appear shortly.")
)}
else
{:noreply, put_flash(socket, :error, t("Not authorized."))}
end
end
@impl true
def handle_event("bulk_dismiss", _params, socket) do
ids = MapSet.to_list(socket.assigns.selected_ids)
if ids == [] do
{:noreply, socket}
else
{:ok, count} = Preseem.dismiss_insights(ids)
{:noreply,
socket
|> assign(:selected_ids, MapSet.new())
|> load_insights()
|> put_flash(:info, "#{count} insight(s) dismissed")}
end
end
defp load_insights(socket) do
org_id = socket.assigns.organization.id
opts = [status: socket.assigns.filter_status]
opts =
if socket.assigns.filter_source,
do: Keyword.put(opts, :source, socket.assigns.filter_source),
else: opts
opts =
if socket.assigns.filter_urgency,
do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency),
else: opts
insights =
org_id
|> Preseem.list_insights(opts)
|> Repo.preload([:preseem_access_point, :device])
assign(socket, :insights, insights)
end
@doc false
def urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
def urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
def urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
def urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
@doc false
def source_classes("preseem"), do: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400"
def source_classes("gaiia"), do: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400"
def source_classes("snmp"), do: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400"
def source_classes("system"), do: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
def source_classes("ai"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
def source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
@doc """
Display label for a source. Acronyms render uppercase; everything else
capitalises the first letter so the badge reads "Preseem" rather than
"preseem". Picked over CSS `capitalize` because that produces "Snmp"
and "Ai" instead of "SNMP" and "AI".
"""
def source_label("ai"), do: "AI"
def source_label("snmp"), do: "SNMP"
def source_label(other) when is_binary(other), do: String.capitalize(other)
def source_label(other), do: other
@doc false
def build_filter_params(base, overrides) do
base
|> Map.merge(overrides)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
@doc false
def selected?(selected_ids, id), do: MapSet.member?(selected_ids, id)
@doc false
def any_selected?(selected_ids), do: MapSet.size(selected_ids) > 0
end