towerops/test/towerops_web/live/insights_live_events_test.exs
Graham McIntire 03f364a956 feat(insights): LLM-powered insight enrichment
Adds an optional plain-language summary and recommended action to every
active insight. A new Oban cron worker runs every 5 minutes, picks up
unenriched insights, and asks the configured LLM to restate the finding
and suggest one specific action grounded in the structured metadata.

- Migration: adds llm_summary, recommended_action, llm_model,
  llm_enriched_at columns to preseem_insights
- Towerops.LLM context with swappable behaviour; real client uses Req
  with the standard Req.Test plug for test isolation
- MockClient + InsightPrompt module (builds chat messages, parses JSON
  with code-fence stripping and a raw-text fallback)
- Worker logs and skips on rate limits / missing key / parse errors so
  insights still render without an AI summary when the LLM is unavailable
- Optional towerops-llm k8s secret with example template; deployment.yaml
  references it as optional in both init and main containers
- UI renders summary + recommended action callout on /insights and on
  the org-level insights page
2026-05-09 16:41:48 -05:00

139 lines
4.4 KiB
Elixir

defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
@moduledoc """
Drives event handlers in the Insights LiveView (filter, dismiss,
toggle_select, select_all, deselect_all, bulk_dismiss).
"""
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Towerops.Preseem.Insight
alias Towerops.Repo
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Insights Org"}, user.id)
insights =
for i <- 1..3 do
{:ok, insight} =
Repo.insert(
Insight.changeset(%Insight{}, %{
organization_id: organization.id,
type: "snmp_cpu_high",
urgency: "warning",
status: "active",
channel: "proactive",
title: "Insight #{i}",
source: "preseem"
})
)
insight
end
%{organization: organization, insights: insights}
end
describe "filter event" do
test "patches the URL with chosen filters", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/insights")
_ =
render_hook(view, "filter", %{
"source" => "preseem",
"urgency" => "warning",
"status" => "active"
})
assert true
end
test "drops empty filter params", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/insights")
_ = render_hook(view, "filter", %{"source" => "", "urgency" => "", "status" => ""})
assert true
end
end
describe "select / toggle / deselect events" do
test "select_all + deselect_all flow", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/insights")
_ = render_hook(view, "select_all", %{})
_ = render_hook(view, "deselect_all", %{})
assert true
end
test "toggle_select flips membership of an id", %{conn: conn, insights: [i1 | _]} do
{:ok, view, _html} = live(conn, ~p"/insights")
_ = render_hook(view, "toggle_select", %{"id" => i1.id})
_ = render_hook(view, "toggle_select", %{"id" => i1.id})
assert true
end
end
describe "dismiss + bulk_dismiss" do
test "dismiss marks a single insight as dismissed", %{conn: conn, insights: [i1 | _]} do
{:ok, view, _html} = live(conn, ~p"/insights")
_ = render_hook(view, "dismiss", %{"id" => i1.id})
reloaded = Repo.get!(Insight, i1.id)
assert reloaded.status == "dismissed"
end
test "bulk_dismiss is a no-op when nothing selected", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/insights")
_ = render_hook(view, "bulk_dismiss", %{})
assert true
end
test "bulk_dismiss clears selection and dismisses all selected", %{conn: conn, insights: [i1, i2 | _]} do
{:ok, view, _html} = live(conn, ~p"/insights")
_ = render_hook(view, "toggle_select", %{"id" => i1.id})
_ = render_hook(view, "toggle_select", %{"id" => i2.id})
_ = render_hook(view, "bulk_dismiss", %{})
assert Repo.get!(Insight, i1.id).status == "dismissed"
assert Repo.get!(Insight, i2.id).status == "dismissed"
end
end
describe "LLM enrichment rendering" do
test "shows llm_summary, recommended_action, and model when present", %{
conn: conn,
organization: organization
} do
{:ok, _enriched} =
Repo.insert(
Insight.changeset(%Insight{}, %{
organization_id: organization.id,
type: "wireless_signal_weak",
urgency: "warning",
status: "active",
channel: "proactive",
title: "Enriched insight",
source: "snmp",
llm_summary: "AP has 3 clients pinned to MCS0; downstream is congested.",
recommended_action: "Re-aim CPE at -82 dBm or move it to AP-7.",
llm_model: "deepseek-v4-pro",
llm_enriched_at: ~U[2026-05-09 12:00:00Z]
})
)
{:ok, _view, html} = live(conn, ~p"/insights")
assert html =~ "AP has 3 clients pinned to MCS0; downstream is congested."
assert html =~ "Re-aim CPE at -82 dBm or move it to AP-7."
assert html =~ "deepseek-v4-pro"
end
test "does not render the summary block when llm_summary is nil", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/insights")
refute html =~ "Recommended action:"
refute html =~ "AI-generated"
end
end
end