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
397 lines
12 KiB
Elixir
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
|