towerops/test/towerops/preseem/insight_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

397 lines
12 KiB
Elixir

defmodule Towerops.Preseem.InsightTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insight
setup do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, ap} =
%AccessPoint{}
|> AccessPoint.changeset(%{organization_id: org.id, preseem_id: "ap-insight-1", name: "Insight AP"})
|> Repo.insert()
%{organization: org, access_point: ap}
end
describe "changeset/2" do
test "valid changeset with all fields", %{organization: org, access_point: ap} do
attrs = %{
organization_id: org.id,
preseem_access_point_id: ap.id,
type: "qoe_degradation",
urgency: "warning",
status: "active",
channel: "proactive",
title: "QoE degradation on Tower1-AP",
description: "QoE score dropped below baseline.",
metadata: %{"current" => 65.0, "baseline_mean" => 85.0}
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?
end
test "requires organization_id" do
attrs = %{type: "qoe_degradation", urgency: "warning", channel: "proactive", title: "Test"}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id
end
test "requires type" do
attrs = %{organization_id: Ecto.UUID.generate(), urgency: "warning", channel: "proactive", title: "Test"}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).type
end
test "requires urgency" do
attrs = %{organization_id: Ecto.UUID.generate(), type: "qoe_degradation", channel: "proactive", title: "Test"}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).urgency
end
test "requires channel" do
attrs = %{organization_id: Ecto.UUID.generate(), type: "qoe_degradation", urgency: "warning", title: "Test"}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).channel
end
test "requires title" do
attrs = %{organization_id: Ecto.UUID.generate(), type: "qoe_degradation", urgency: "warning", channel: "proactive"}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).title
end
test "validates type inclusion" do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "invalid_type",
urgency: "warning",
channel: "proactive",
title: "Test"
}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).type
end
test "accepts all valid types" do
valid_types =
~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend)
for type <- valid_types do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: type,
urgency: "info",
channel: "passive",
title: "Test insight"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected type '#{type}' to be valid"
end
end
test "validates urgency inclusion" do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "qoe_degradation",
urgency: "extreme",
channel: "proactive",
title: "Test"
}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).urgency
end
test "accepts all valid urgencies" do
for urgency <- ~w(critical warning info) do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "qoe_degradation",
urgency: urgency,
channel: "proactive",
title: "Test"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected urgency '#{urgency}' to be valid"
end
end
test "validates status inclusion" do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "qoe_degradation",
urgency: "warning",
channel: "proactive",
title: "Test",
status: "archived"
}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).status
end
test "accepts all valid statuses" do
for status <- ~w(active dismissed resolved) do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "qoe_degradation",
urgency: "warning",
channel: "proactive",
title: "Test",
status: status
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected status '#{status}' to be valid"
end
end
test "validates channel inclusion" do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "qoe_degradation",
urgency: "warning",
channel: "email",
title: "Test"
}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).channel
end
test "accepts all valid channels" do
for channel <- ~w(proactive contextual passive) do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "qoe_degradation",
urgency: "warning",
channel: channel,
title: "Test"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected channel '#{channel}' to be valid"
end
end
test "defaults status to active" do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "qoe_degradation",
urgency: "warning",
channel: "proactive",
title: "Test"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert Ecto.Changeset.get_field(changeset, :status) == "active"
end
test "persists and reloads from database", %{organization: org, access_point: ap} do
attrs = %{
organization_id: org.id,
preseem_access_point_id: ap.id,
type: "qoe_degradation",
urgency: "critical",
channel: "proactive",
title: "QoE drop on AP",
description: "Detailed explanation",
metadata: %{"current" => 60.0, "baseline_mean" => 85.0}
}
{:ok, insight} = %Insight{} |> Insight.changeset(attrs) |> Repo.insert()
reloaded = Repo.get!(Insight, insight.id)
assert reloaded.organization_id == org.id
assert reloaded.preseem_access_point_id == ap.id
assert reloaded.type == "qoe_degradation"
assert reloaded.urgency == "critical"
assert reloaded.status == "active"
assert reloaded.channel == "proactive"
assert reloaded.title == "QoE drop on AP"
assert reloaded.description == "Detailed explanation"
assert reloaded.metadata == %{"current" => 60.0, "baseline_mean" => 85.0}
end
test "defaults source to preseem", %{organization: org} do
attrs = %{
organization_id: org.id,
type: "qoe_degradation",
urgency: "warning",
channel: "proactive",
title: "Test"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert Ecto.Changeset.get_field(changeset, :source) == "preseem"
end
test "accepts all valid sources", %{organization: org} do
for source <- ~w(preseem snmp gaiia system) do
attrs = %{
organization_id: org.id,
type: "qoe_degradation",
urgency: "info",
channel: "passive",
title: "Test",
source: source
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected source '#{source}' to be valid"
end
end
test "rejects invalid source", %{organization: org} do
attrs = %{
organization_id: org.id,
type: "qoe_degradation",
urgency: "warning",
channel: "proactive",
title: "Test",
source: "invalid_source"
}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).source
end
test "accepts new SNMP insight types", %{organization: org} do
snmp_types = ~w(snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap)
for type <- snmp_types do
attrs = %{
organization_id: org.id,
type: type,
urgency: "warning",
channel: "proactive",
title: "Test",
source: "snmp"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected type '#{type}' to be valid"
end
end
test "accepts new system insight types", %{organization: org} do
system_types = ~w(agent_offline firmware_mismatch)
for type <- system_types do
attrs = %{
organization_id: org.id,
type: type,
urgency: "warning",
channel: "proactive",
title: "Test",
source: "system"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected type '#{type}' to be valid"
end
end
test "accepts new Gaiia insight types", %{organization: org} do
gaiia_types = ~w(reconciliation_finding subscriber_growth_trend)
for type <- gaiia_types do
attrs = %{
organization_id: org.id,
type: type,
urgency: "info",
channel: "passive",
title: "Test",
source: "gaiia"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected type '#{type}' to be valid"
end
end
test "accepts site_id and agent_token_id fields", %{organization: org} do
site_id = Ecto.UUID.generate()
agent_token_id = Ecto.UUID.generate()
attrs = %{
organization_id: org.id,
type: "agent_offline",
urgency: "critical",
channel: "proactive",
title: "Agent offline",
source: "system",
site_id: site_id,
agent_token_id: agent_token_id
}
changeset = Insight.changeset(%Insight{}, attrs)
assert Ecto.Changeset.get_field(changeset, :site_id) == site_id
assert Ecto.Changeset.get_field(changeset, :agent_token_id) == agent_token_id
end
test "persists source and associations", %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{name: "Test Site", organization_id: org.id})
attrs = %{
organization_id: org.id,
type: "snmp_cpu_high",
urgency: "warning",
channel: "proactive",
title: "CPU high on site",
source: "snmp",
site_id: site.id
}
{:ok, insight} = %Insight{} |> Insight.changeset(attrs) |> Repo.insert()
reloaded = Repo.get!(Insight, insight.id)
assert reloaded.source == "snmp"
assert reloaded.site_id == site.id
end
test "accepts and round-trips llm enrichment fields", %{organization: org} do
attrs = %{
organization_id: org.id,
type: "wireless_signal_weak",
urgency: "warning",
channel: "proactive",
title: "Weak signal",
source: "snmp",
llm_summary: "AP overloaded; consider rebalancing.",
recommended_action: "Move 3 clients to neighboring AP.",
llm_model: "deepseek-chat",
llm_enriched_at: ~U[2026-05-09 12:00:00Z]
}
{:ok, insight} = %Insight{} |> Insight.changeset(attrs) |> Repo.insert()
reloaded = Repo.get!(Insight, insight.id)
assert reloaded.llm_summary == "AP overloaded; consider rebalancing."
assert reloaded.recommended_action == "Move 3 clients to neighboring AP."
assert reloaded.llm_model == "deepseek-chat"
assert reloaded.llm_enriched_at == ~U[2026-05-09 12:00:00Z]
end
end
end