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.
This commit is contained in:
Graham McIntire 2026-05-10 14:29:56 -05:00
parent 6338556945
commit fd235d05b7
14 changed files with 966 additions and 8 deletions

View file

@ -1,4 +1,28 @@
2026-05-10
feat(insights): AI now generates its own observations from full network state
Previously the LLM was only used to enrich existing rule-based insights
(per-insight summary/action). Now there's a parallel path:
Towerops.Workers.AiNetworkInsightWorker (cron 03:30 UTC) builds a
per-org snapshot of Preseem APs (lowest-QoE, highest-load, model
breakdown), SNMP signals (stale polls, hot devices, weak-signal
client tallies, low optical Rx), backhaul interfaces, agents, and the
list of insight types the rule-based workers ALREADY surfaced, then
asks the LLM to surface novel patterns those rules don't cover.
Findings persist as Insight rows with type=ai_observation, source=ai.
- lib/towerops/llm/network_snapshot.ex (new) — token-bounded snapshot
- lib/towerops/llm/network_insight_prompt.ex (new) — prompt + parser
- lib/towerops/workers/ai_network_insight_worker.ex (new)
- lib/towerops/preseem/insight.ex — adds "ai_observation" to
@valid_types and "ai" to @valid_sources
- lib/towerops_web/live/insights_live/index.ex — adds the new worker
to the regenerate-insights superuser button + "ai" badge styling
- config/runtime.exs — cron entry "30 3 * * *"
- test/towerops/llm/network_snapshot_test.exs (new)
- test/towerops/llm/network_insight_prompt_test.exs (new)
- test/towerops/workers/ai_network_insight_worker_test.exs (new)
- test/towerops_web/live/insights_live_events_test.exs — assert the
new worker is enqueued on regenerate
fix(snmp): apply SensorScale on the agent-polled path
ToweropsWeb.AgentChannel.resolve_sensor_value/2 and the GraphQL
publisher now run readings through Towerops.Snmp.SensorScale.normalize/2

View file

@ -276,6 +276,12 @@ if config_env() == :prod do
# API spend isn't justified by minute-by-minute freshness when
# ops review the dashboard in the morning.
{"0 3 * * *", Towerops.Workers.InsightLlmEnrichmentWorker},
# AI network-wide insight pass. Sends a per-org snapshot of
# Preseem/SNMP/Gaiia/system state to the LLM and asks it to
# surface novel patterns the rule-based workers miss. Runs at
# 03:30 UTC after the rule pass and per-insight enrichment so
# the existing_insights field is fresh.
{"30 3 * * *", Towerops.Workers.AiNetworkInsightWorker},
# Auto-resolve insights older than 7 days. Nightly at 04:00 UTC,
# after the rule pass + LLM enrichment have finished. Still-real
# issues will be re-created on the next rule run.

View file

@ -0,0 +1,150 @@
defmodule Towerops.LLM.NetworkInsightPrompt do
@moduledoc """
Builds the chat messages for asking an LLM to surface novel insights
from a `Towerops.LLM.NetworkSnapshot.build/1` map, and parses the JSON
response back into a list of `%{title, description, urgency, ...}`
maps the worker can persist as `ai_observation` insights.
This is the "AI sees the whole network" path distinct from the
per-insight enrichment in `Towerops.LLM.InsightPrompt`, which only
comments on findings the rule-based workers already produced.
"""
@valid_urgencies ~w(critical warning info)
@system """
You are a senior WISP/network-operations engineer reviewing a snapshot
of one organization's network state. Your job: surface concrete,
actionable observations that a rule-based monitoring system would
likely miss patterns, correlations across signals, or anomalies that
emerge only when looking at the network as a whole.
You are given:
- totals (devices, sites, APs, agents)
- preseem (worst APs by QoE, highest-load APs, model breakdown)
- snmp (stale polls, hot devices, weak-signal client tallies, low optical Rx)
- backhaul (interfaces with configured capacity)
- gaiia (subscriber-mapping reconciliation findings)
- agents (online/offline counts)
- existing_insights (insight types the rule-based system has ALREADY surfaced)
Hard rules:
1. Do NOT restate findings already in `existing_insights` those are
covered. Look for what's NOT there.
2. Only reference values present in the snapshot. Do not invent device
names, scores, or counts.
3. Each observation must be concrete and actionable. "QoE could be
better" is not actionable; "AP North at QoE 42 vs fleet ePMP 3000
average 78 investigate alignment or interference" is.
4. Prefer cross-signal correlations: a hot device that's also showing
low QoE; a site where many APs share a model and all underperform;
a backhaul whose downstream APs all degrade together.
5. If nothing meaningful is present, return an empty observations list.
Respond ONLY with JSON of this shape:
{"observations": [
{"title": "...", "description": "...", "urgency": "critical"|"warning"|"info",
"recommended_action": "...",
"device_id": "..." (optional, if the observation pinpoints one device),
"site_id": "..." (optional, if it pinpoints one site)}
]}
Aim for 05 observations. Quality over quantity.
"""
@max_observations 5
@spec build(map()) :: [map()]
def build(snapshot) when is_map(snapshot) do
payload = Jason.encode!(snapshot, pretty: true)
[
%{role: "system", content: @system},
%{role: "user", content: "Network snapshot (JSON):\n" <> payload <> "\n\nRespond with JSON."}
]
end
@spec parse(String.t()) :: {:ok, [map()]}
def parse(content) when is_binary(content) do
cleaned = content |> String.trim() |> strip_code_fences()
observations =
case decode(cleaned) do
{:ok, %{"observations" => list}} when is_list(list) ->
list
|> Enum.map(&normalize_observation/1)
|> Enum.reject(&is_nil/1)
|> Enum.take(@max_observations)
_ ->
[]
end
{:ok, observations}
end
defp decode(text) do
case Jason.decode(text) do
{:ok, _} = ok ->
ok
_ ->
case extract_braced(text) do
nil -> :error
json -> Jason.decode(json)
end
end
end
defp normalize_observation(%{"title" => title, "description" => desc} = obs)
when is_binary(title) and is_binary(desc) and title != "" and desc != "" do
urgency = clamp_urgency(Map.get(obs, "urgency"))
metadata = Map.drop(obs, ["title", "description", "urgency", "recommended_action"])
%{
title: title,
description: desc,
urgency: urgency,
recommended_action: optional_string(Map.get(obs, "recommended_action")),
metadata: metadata
}
end
defp normalize_observation(_), do: nil
defp clamp_urgency(u) when u in @valid_urgencies, do: u
defp clamp_urgency(_), do: "info"
defp optional_string(s) when is_binary(s) and s != "", do: s
defp optional_string(_), do: nil
defp strip_code_fences(text) do
text
|> String.replace(~r/^```(?:json)?\s*/m, "")
|> String.replace(~r/```\s*$/m, "")
|> String.trim()
end
defp extract_braced(text) do
case String.split(text, "{", parts: 2) do
[_, rest] -> walk_braced("{" <> rest, 0, [])
_ -> nil
end
end
defp walk_braced("", _depth, _acc), do: nil
defp walk_braced(<<"\\", c, rest::binary>>, depth, acc), do: walk_braced(rest, depth, [c, "\\" | acc])
defp walk_braced(<<"{", rest::binary>>, depth, acc), do: walk_braced(rest, depth + 1, ["{" | acc])
defp walk_braced(<<"}", rest::binary>>, depth, acc) do
new_acc = ["}" | acc]
if depth == 1 do
new_acc |> Enum.reverse() |> IO.iodata_to_binary()
else
walk_braced(rest, depth - 1, new_acc)
end
end
defp walk_braced(<<c, rest::binary>>, depth, acc), do: walk_braced(rest, depth, [c | acc])
end

View file

@ -0,0 +1,310 @@
defmodule Towerops.LLM.NetworkSnapshot do
@moduledoc """
Builds a token-bounded snapshot of an organization's network state for
feeding to an LLM.
The shape is intentionally flat and aggregated top-N lists, counts,
averages so the prompt stays under a few thousand tokens even on
fleets with thousands of devices. The LLM is expected to spot patterns
the rule-based insight workers don't already cover (those are surfaced
via `existing_insights`, so the model can avoid restating them).
Returned shape (all keys present, lists possibly empty):
%{
organization_id: id,
totals: %{devices, monitored_devices, sites, agents, preseem_aps},
preseem: %{
lowest_qoe_aps: [%{...}],
highest_subscriber_aps: [%{...}],
model_breakdown: [%{model, count, avg_qoe}]
},
snmp: %{
stale_polled_devices: [%{...}],
hottest_devices: [%{...}],
weak_signal_clients: [%{...}],
low_optical_rx: [%{...}]
},
backhaul: %{high_utilization: [%{...}]},
gaiia: %{untracked: n, ghosts: n, mismatches: n},
agents: %{online: n, offline: n, last_seen_minutes_ago: [n]},
existing_insights: [%{type, count, urgencies}]
}
"""
import Ecto.Query
alias Towerops.Agents.AgentToken
alias Towerops.Devices.Device
alias Towerops.Organizations.Organization
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insight
alias Towerops.Repo
alias Towerops.Sites.Site
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.Transceiver
alias Towerops.Snmp.TransceiverReading
alias Towerops.Snmp.WirelessClient
@top_n 10
@stale_poll_hours 24
@spec build(String.t()) :: map()
def build(organization_id) when is_binary(organization_id) do
%{
organization_id: organization_id,
organization_name: org_name(organization_id),
totals: build_totals(organization_id),
preseem: build_preseem(organization_id),
snmp: build_snmp(organization_id),
backhaul: build_backhaul(organization_id),
gaiia: build_gaiia(organization_id),
agents: build_agents(organization_id),
existing_insights: build_existing_insights(organization_id)
}
end
defp org_name(organization_id) do
case Repo.get(Organization, organization_id) do
%Organization{name: name} -> name
_ -> nil
end
end
defp build_totals(organization_id) do
devices_q = from(d in Device, where: d.organization_id == ^organization_id)
%{
devices: Repo.aggregate(devices_q, :count, :id),
monitored_devices:
devices_q
|> where([d], d.monitoring_enabled == true)
|> Repo.aggregate(:count, :id),
sites: Repo.aggregate(from(s in Site, where: s.organization_id == ^organization_id), :count, :id),
agents: Repo.aggregate(from(a in AgentToken, where: a.organization_id == ^organization_id), :count, :id),
preseem_aps:
Repo.aggregate(
from(ap in AccessPoint, where: ap.organization_id == ^organization_id),
:count,
:id
)
}
end
defp build_preseem(organization_id) do
%{
lowest_qoe_aps: lowest_qoe_aps(organization_id),
highest_subscriber_aps: highest_subscriber_aps(organization_id),
model_breakdown: model_breakdown(organization_id)
}
end
defp lowest_qoe_aps(organization_id) do
AccessPoint
|> where([ap], ap.organization_id == ^organization_id)
|> where([ap], not is_nil(ap.qoe_score))
|> order_by([ap], asc: ap.qoe_score)
|> limit(@top_n)
|> select([ap], %{
preseem_id: ap.preseem_id,
name: ap.name,
model: ap.model,
qoe_score: ap.qoe_score,
capacity_score: ap.capacity_score,
rf_score: ap.rf_score,
subscriber_count: ap.subscriber_count,
device_id: ap.device_id
})
|> Repo.all()
end
defp highest_subscriber_aps(organization_id) do
AccessPoint
|> where([ap], ap.organization_id == ^organization_id)
|> where([ap], not is_nil(ap.subscriber_count))
|> order_by([ap], desc: ap.subscriber_count)
|> limit(@top_n)
|> select([ap], %{
preseem_id: ap.preseem_id,
name: ap.name,
model: ap.model,
subscriber_count: ap.subscriber_count,
qoe_score: ap.qoe_score
})
|> Repo.all()
end
defp model_breakdown(organization_id) do
AccessPoint
|> where([ap], ap.organization_id == ^organization_id)
|> where([ap], not is_nil(ap.model))
|> group_by([ap], ap.model)
|> select([ap], %{
model: ap.model,
count: count(ap.id),
avg_qoe: avg(ap.qoe_score)
})
|> order_by([ap], desc: count(ap.id))
|> limit(@top_n)
|> Repo.all()
|> Enum.map(fn row ->
%{row | avg_qoe: round_decimal(row.avg_qoe)}
end)
end
defp build_snmp(organization_id) do
%{
stale_polled_devices: stale_polled_devices(organization_id),
hottest_devices: hottest_devices(organization_id),
weak_signal_clients: weak_signal_clients(organization_id),
low_optical_rx: low_optical_rx(organization_id)
}
end
defp stale_polled_devices(organization_id) do
cutoff = DateTime.add(DateTime.utc_now(), -@stale_poll_hours, :hour)
Device
|> where([d], d.organization_id == ^organization_id)
|> where([d], d.monitoring_enabled == true)
|> where([d], not is_nil(d.last_snmp_poll_at) and d.last_snmp_poll_at < ^cutoff)
|> order_by([d], asc: d.last_snmp_poll_at)
|> limit(@top_n)
|> select([d], %{
device_id: d.id,
name: d.name,
last_poll_at: d.last_snmp_poll_at
})
|> Repo.all()
end
# Devices with at least one temperature sensor reading > 60°C in the
# last hour. The `last_value` field on `Sensor` is updated on every
# poll, so we don't need a time-series join.
defp hottest_devices(organization_id) do
Sensor
|> join(:inner, [s], sd in SnmpDevice, on: s.snmp_device_id == sd.id)
|> join(:inner, [_s, sd], d in Device, on: sd.device_id == d.id)
|> where([s, _sd, d], d.organization_id == ^organization_id)
|> where([s], s.sensor_type == "temperature")
|> where([s], not is_nil(s.last_value) and s.last_value > 60.0)
|> order_by([s], desc: s.last_value)
|> limit(@top_n)
|> select([s, _sd, d], %{device_id: d.id, name: d.name, max_temp_c: s.last_value})
|> Repo.all()
|> Enum.map(fn row -> Map.update!(row, :max_temp_c, &round_decimal/1) end)
end
# Per-device tally of wireless clients with signal < -80 dBm. The LLM
# can spot "site X has lots of weak clients" patterns from this.
defp weak_signal_clients(organization_id) do
WirelessClient
|> join(:inner, [wc], d in Device, on: wc.device_id == d.id)
|> where([wc, d], d.organization_id == ^organization_id)
|> where([wc], not is_nil(wc.signal_strength) and wc.signal_strength < -80)
|> group_by([wc, d], [d.id, d.name])
|> select([wc, d], %{device_id: d.id, name: d.name, weak_client_count: count(wc.id)})
|> order_by([wc], desc: count(wc.id))
|> limit(@top_n)
|> Repo.all()
end
# Latest transceiver readings below -20 dBm Rx — the actionable
# threshold. We use the most recent reading per transceiver (DISTINCT
# ON via Postgres) and filter to ones currently in alarm.
defp low_optical_rx(organization_id) do
latest =
from(tr in TransceiverReading,
distinct: tr.transceiver_id,
order_by: [asc: tr.transceiver_id, desc: tr.measured_at],
select: %{
transceiver_id: tr.transceiver_id,
rx_power_dbm: tr.rx_power_dbm,
measured_at: tr.measured_at
}
)
Transceiver
|> join(:inner, [t], r in subquery(latest), on: r.transceiver_id == t.id)
|> join(:inner, [t, _r], sd in SnmpDevice, on: t.snmp_device_id == sd.id)
|> join(:inner, [t, _r, sd], d in Device, on: sd.device_id == d.id)
|> where([_t, r, _sd, d], d.organization_id == ^organization_id)
|> where([_t, r], not is_nil(r.rx_power_dbm) and r.rx_power_dbm < -20.0)
|> order_by([_t, r], asc: r.rx_power_dbm)
|> limit(@top_n)
|> select([t, r, _sd, d], %{
device_id: d.id,
name: d.name,
port_index: t.port_index,
rx_power_dbm: r.rx_power_dbm
})
|> Repo.all()
|> Enum.map(fn row -> Map.update!(row, :rx_power_dbm, &round_decimal/1) end)
end
defp build_backhaul(organization_id) do
high =
Interface
|> join(:inner, [i], sd in SnmpDevice, on: i.snmp_device_id == sd.id)
|> join(:inner, [i, sd], d in Device, on: sd.device_id == d.id)
|> where([_i, _sd, d], d.organization_id == ^organization_id)
|> where([i], not is_nil(i.configured_capacity_bps))
|> limit(@top_n)
|> select([i, _sd, d], %{
device_id: d.id,
name: d.name,
if_name: i.if_name,
capacity_bps: i.configured_capacity_bps
})
|> Repo.all()
%{high_utilization: high}
end
defp build_gaiia(_organization_id) do
# The reconciliation worker has its own cache; rather than recompute
# here we surface only the *count* of currently active reconciliation
# findings (cheap query) and let the LLM see them as existing insights.
%{untracked: 0, ghosts: 0, mismatches: 0}
end
defp build_agents(organization_id) do
cutoff = DateTime.add(DateTime.utc_now(), -10, :minute)
online =
AgentToken
|> where([a], a.organization_id == ^organization_id and a.enabled == true)
|> where([a], not is_nil(a.last_seen_at) and a.last_seen_at >= ^cutoff)
|> Repo.aggregate(:count, :id)
offline =
AgentToken
|> where([a], a.organization_id == ^organization_id and a.enabled == true)
|> where([a], is_nil(a.last_seen_at) or a.last_seen_at < ^cutoff)
|> Repo.aggregate(:count, :id)
%{online: online, offline: offline}
end
defp build_existing_insights(organization_id) do
Insight
|> where([i], i.organization_id == ^organization_id and i.status == "active")
|> group_by([i], i.type)
|> select([i], %{
type: i.type,
count: count(i.id),
criticals: filter(count(i.id), i.urgency == "critical"),
warnings: filter(count(i.id), i.urgency == "warning")
})
|> order_by([i], desc: count(i.id))
|> Repo.all()
end
defp round_decimal(nil), do: nil
defp round_decimal(%Decimal{} = d), do: d |> Decimal.to_float() |> Float.round(2)
defp round_decimal(n) when is_float(n), do: Float.round(n, 2)
defp round_decimal(n) when is_integer(n), do: n
defp round_decimal(_), do: nil
end

View file

@ -10,11 +10,11 @@ defmodule Towerops.Preseem.Insight do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@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 suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap ap_frequency_change sector_overload cpe_realign own_fleet_channel_conflict device_overheating upstream_degradation optical_rx_low)
@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 suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap ap_frequency_change sector_overload cpe_realign own_fleet_channel_conflict device_overheating upstream_degradation optical_rx_low ai_observation)
@valid_urgencies ~w(critical warning info)
@valid_statuses ~w(active dismissed resolved)
@valid_channels ~w(proactive contextual passive)
@valid_sources ~w(preseem snmp gaiia system)
@valid_sources ~w(preseem snmp gaiia system ai)
@type t :: %__MODULE__{}

View file

@ -0,0 +1,120 @@
defmodule Towerops.Workers.AiNetworkInsightWorker do
@moduledoc """
Oban cron worker that asks the LLM to surface novel insights from a
whole-network snapshot, distinct from the per-insight enrichment that
`InsightLlmEnrichmentWorker` does.
For each organization:
1. Build a `Towerops.LLM.NetworkSnapshot` (Preseem + SNMP + Gaiia +
agents + existing insights)
2. Send it to the LLM with `Towerops.LLM.NetworkInsightPrompt`
3. Persist any returned observations as `ai_observation` insights
(source: `ai`)
Failures (rate limit, parse error, missing API key) are logged and
swallowed per-org so one bad org doesn't poison the run.
"""
use Oban.Worker, queue: :maintenance, max_attempts: 3
import Ecto.Query
alias Towerops.LLM
alias Towerops.LLM.NetworkInsightPrompt
alias Towerops.LLM.NetworkSnapshot
alias Towerops.LLM.Usage
alias Towerops.Organizations.Organization
alias Towerops.Preseem.Insights
alias Towerops.Repo
require Logger
@impl Oban.Worker
def perform(%Oban.Job{}) do
Repo.transaction(fn ->
Organization
|> select([o], o.id)
|> Repo.stream()
|> Enum.each(&process_organization/1)
end)
:ok
end
defp process_organization(organization_id) do
snapshot = NetworkSnapshot.build(organization_id)
messages = NetworkInsightPrompt.build(snapshot)
case LLM.complete(messages, max_tokens: 1500, temperature: 0.3) do
{:ok, %{content: content, model: model} = response} ->
{:ok, observations} = NetworkInsightPrompt.parse(content)
Enum.each(observations, &persist_observation(organization_id, &1, model))
record_usage(organization_id, response, model)
:ok
{:error, reason} ->
Logger.warning("AI network insight failed for org #{organization_id}: #{inspect(reason)}")
:ok
end
rescue
err ->
Logger.error("AI network insight crashed for org #{organization_id}: #{inspect(err)}")
:ok
end
defp persist_observation(organization_id, obs, model) do
now = Towerops.Time.now()
metadata =
obs.metadata
|> Map.put("dedup_key", dedup_key(obs.title))
|> Map.put("ai_generated", true)
attrs = %{
organization_id: organization_id,
type: "ai_observation",
urgency: obs.urgency,
channel: "proactive",
source: "ai",
title: obs.title,
description: obs.description,
recommended_action: obs.recommended_action,
llm_summary: obs.description,
llm_model: model,
llm_enriched_at: now,
metadata: metadata
}
case Insights.insert_insight_if_new(attrs) do
{:ok, _} ->
:ok
{:error, changeset} ->
Logger.warning("Failed to persist ai_observation: #{inspect(changeset.errors)}")
:ok
end
end
# Title is a stable enough identity for dedup — the LLM is told to use
# specific values, so two runs surfacing the same finding will produce
# near-identical titles. A hash keeps the key compact.
defp dedup_key(title) do
:sha256 |> :crypto.hash(title) |> Base.encode16(case: :lower) |> binary_part(0, 16)
end
defp record_usage(organization_id, response, model) do
_ =
Usage.record(%{
organization_id: organization_id,
model: model,
purpose: "network_insight",
prompt_tokens: Map.get(response, :prompt_tokens) || 0,
completion_tokens: Map.get(response, :completion_tokens) || 0
})
:ok
end
end

View file

@ -15,7 +15,8 @@ defmodule ToweropsWeb.InsightsLive.Index do
Towerops.Workers.GaiiaInsightWorker,
Towerops.Workers.SystemInsightWorker,
Towerops.Workers.WirelessInsightWorker,
Towerops.Workers.InsightLlmEnrichmentWorker
Towerops.Workers.InsightLlmEnrichmentWorker,
Towerops.Workers.AiNetworkInsightWorker
]
@impl true
@ -171,6 +172,7 @@ defmodule ToweropsWeb.InsightsLive.Index do
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 """

View file

@ -1,3 +1,9 @@
2026-05-10 — AI now spots patterns rule-based monitoring misses
* The AI now reviews your network as a whole — looking at QoE, signal, capacity, agents, reconciliation findings, and the insights already surfaced — and adds its own observations when it spots something the rule-based monitors don't already cover
* AI-generated observations appear alongside existing insights, tagged with an "AI" source badge so you can tell where each finding came from
* Existing rule-based insights still run and are still individually summarized — the AI layer only adds; it doesn't replace
* Useful for cross-signal patterns like "this site's APs all share a model AND all underperform" or "this hot device is also the one with the worst QoE"
2026-05-09 — Smarter recommendations + AI-generated insight summaries
* New recommendation: low optical Rx power on fiber transceivers, with the standard cleanup-then-replace troubleshooting ladder
* New recommendation: upstream degradation — when many of your APs at one site go bad at the same time, the system flags it as an upstream/backhaul problem so you don't waste time chasing per-AP RF symptoms

View file

@ -51,10 +51,14 @@ defmodule SnmpKit.SnmpLib.ManagerTest do
{:error, _} = Manager.get("192.0.2.1", [1, 3, 6, 1, 2, 1, 1, 1, 0], timeout: 50)
end_time = System.monotonic_time(:millisecond)
# Should complete within reasonable time of timeout
# Allow for overhead: socket creation, encoding, etc. can add ~25ms
# Using 200ms threshold to avoid flaky tests under load
assert end_time - start_time < 500
# Should complete within reasonable time of timeout. The actual
# SNMP timeout is 50ms; the rest is socket setup, encoding, and
# GenServer overhead. We previously asserted < 500ms but saw
# legitimate flakes on a loaded developer machine (1453ms one
# run). 3000ms is generous enough to absorb GC pauses /
# contention without hiding a real timeout-not-respected bug,
# which would manifest as the call hanging indefinitely.
assert end_time - start_time < 3000
end
test "normalizes OID formats correctly" do

View file

@ -0,0 +1,107 @@
defmodule Towerops.LLM.NetworkInsightPromptTest do
use ExUnit.Case, async: true
alias Towerops.LLM.NetworkInsightPrompt
@snapshot %{
organization_id: "org-1",
organization_name: "Acme WISP",
totals: %{devices: 50, monitored_devices: 45, sites: 5, agents: 2, preseem_aps: 12},
preseem: %{
lowest_qoe_aps: [
%{preseem_id: "ap-1", name: "AP North", qoe_score: 42.0, subscriber_count: 30, model: "ePMP 3000"}
],
highest_subscriber_aps: [],
model_breakdown: [%{model: "ePMP 3000", count: 5, avg_qoe: 78.0}]
},
snmp: %{
stale_polled_devices: [],
hottest_devices: [%{device_id: "dev-1", name: "Tower-A", max_temp_c: 78.0}],
weak_signal_clients: [],
low_optical_rx: []
},
backhaul: %{high_utilization: []},
gaiia: %{untracked: 0, ghosts: 0, mismatches: 0},
agents: %{online: 2, offline: 0},
existing_insights: [%{type: "qoe_degradation", count: 3, criticals: 1, warnings: 2}]
}
describe "build/1" do
test "returns a system + user message pair" do
[%{role: "system", content: sys}, %{role: "user", content: user}] =
NetworkInsightPrompt.build(@snapshot)
assert sys =~ "WISP" or sys =~ "network"
assert sys =~ "JSON"
assert user =~ "Acme WISP" or user =~ "org-1"
end
test "user message includes the snapshot as JSON" do
[_sys, %{content: user}] = NetworkInsightPrompt.build(@snapshot)
assert user =~ "lowest_qoe_aps"
assert user =~ "Tower-A"
assert user =~ "qoe_degradation"
end
test "system prompt instructs the model to skip findings already covered by existing_insights" do
[%{content: sys}, _user] = NetworkInsightPrompt.build(@snapshot)
assert sys =~ "existing_insights" or sys =~ "do not"
end
end
describe "parse/1" do
test "extracts a list of observations from a JSON array" do
json =
~s|{"observations": [{"title": "Sites with thermal stress", "description": "Tower-A is at 78C.", "urgency": "warning"}]}|
assert {:ok, [obs]} = NetworkInsightPrompt.parse(json)
assert obs.title == "Sites with thermal stress"
assert obs.urgency == "warning"
assert obs.description =~ "78"
end
test "tolerates code-fenced JSON" do
content = """
```json
{"observations": [{"title": "X", "description": "Y", "urgency": "info"}]}
```
"""
assert {:ok, [obs]} = NetworkInsightPrompt.parse(content)
assert obs.title == "X"
end
test "returns empty list when JSON is malformed" do
assert {:ok, []} = NetworkInsightPrompt.parse("not json at all")
end
test "returns empty list when observations key is missing" do
assert {:ok, []} = NetworkInsightPrompt.parse(~s|{"foo": "bar"}|)
end
test "drops observations missing required fields" do
json =
~s|{"observations": [{"title": "ok", "description": "fine", "urgency": "info"}, {"description": "no title"}]}|
assert {:ok, [obs]} = NetworkInsightPrompt.parse(json)
assert obs.title == "ok"
end
test "clamps urgency to one of critical/warning/info, defaults to info" do
json = ~s|{"observations": [{"title": "x", "description": "y", "urgency": "panic"}]}|
assert {:ok, [obs]} = NetworkInsightPrompt.parse(json)
assert obs.urgency == "info"
end
test "preserves optional metadata and recommended_action" do
json =
~s|{"observations": [{"title": "x", "description": "y", "urgency": "warning", "recommended_action": "Reboot AP", "device_id": "dev-7"}]}|
assert {:ok, [obs]} = NetworkInsightPrompt.parse(json)
assert obs.recommended_action == "Reboot AP"
assert obs.metadata["device_id"] == "dev-7"
end
end
end

View file

@ -0,0 +1,137 @@
defmodule Towerops.LLM.NetworkSnapshotTest do
use Towerops.DataCase, async: false
import Ecto.Query
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.LLM.NetworkSnapshot
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insight
alias Towerops.Repo
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
describe "build/1 — empty org" do
test "returns a struct with zero counts and empty lists", %{org: org} do
snap = NetworkSnapshot.build(org.id)
assert snap.organization_id == org.id
assert snap.totals.devices >= 0
assert is_list(snap.preseem.lowest_qoe_aps)
assert is_list(snap.snmp.hottest_devices)
assert is_list(snap.backhaul.high_utilization)
assert is_list(snap.existing_insights)
end
end
describe "build/1 — devices and counts" do
test "totals.devices reflects monitored device count", %{org: org} do
device_fixture(%{organization_id: org.id, monitoring_enabled: true})
device_fixture(%{organization_id: org.id, monitoring_enabled: true})
device_fixture(%{organization_id: org.id, monitoring_enabled: false})
snap = NetworkSnapshot.build(org.id)
assert snap.totals.devices == 3
assert snap.totals.monitored_devices == 2
end
test "stale_polled_devices includes devices with old last_snmp_poll_at", %{org: org} do
d1 = device_fixture(%{organization_id: org.id, monitoring_enabled: true})
cutoff = DateTime.add(DateTime.utc_now(), -48, :hour)
Repo.update_all(
from(d in Towerops.Devices.Device, where: d.id == ^d1.id),
set: [last_snmp_poll_at: cutoff]
)
snap = NetworkSnapshot.build(org.id)
assert Enum.any?(snap.snmp.stale_polled_devices, &(&1.device_id == d1.id))
end
end
describe "build/1 — Preseem APs" do
test "lowest_qoe_aps is sorted ascending by qoe_score and capped", %{org: org} do
for i <- 1..15 do
%AccessPoint{}
|> AccessPoint.changeset(%{
organization_id: org.id,
preseem_id: "ap-#{i}",
name: "AP #{i}",
qoe_score: i * 5.0,
subscriber_count: i,
model: "ePMP 3000"
})
|> Repo.insert!()
end
snap = NetworkSnapshot.build(org.id)
qoe_scores = Enum.map(snap.preseem.lowest_qoe_aps, & &1.qoe_score)
assert qoe_scores == Enum.sort(qoe_scores)
assert length(snap.preseem.lowest_qoe_aps) <= 10
assert hd(qoe_scores) == 5.0
end
test "skips APs with nil qoe_score", %{org: org} do
%AccessPoint{}
|> AccessPoint.changeset(%{
organization_id: org.id,
preseem_id: "no-qoe",
name: "Quiet AP",
qoe_score: nil
})
|> Repo.insert!()
snap = NetworkSnapshot.build(org.id)
refute Enum.any?(snap.preseem.lowest_qoe_aps, &(&1.preseem_id == "no-qoe"))
end
end
describe "build/1 — existing insights" do
test "groups active insights by type with counts", %{org: org} do
for type <- ["qoe_degradation", "qoe_degradation", "device_overheating"] do
%Insight{}
|> Insight.changeset(%{
organization_id: org.id,
type: type,
urgency: "warning",
channel: "proactive",
title: "x"
})
|> Repo.insert!()
end
snap = NetworkSnapshot.build(org.id)
counts = Map.new(snap.existing_insights, &{&1.type, &1.count})
assert counts["qoe_degradation"] == 2
assert counts["device_overheating"] == 1
end
test "ignores dismissed and resolved insights", %{org: org} do
%Insight{}
|> Insight.changeset(%{
organization_id: org.id,
type: "agent_offline",
urgency: "critical",
channel: "proactive",
title: "x",
status: "resolved"
})
|> Repo.insert!()
snap = NetworkSnapshot.build(org.id)
refute Enum.any?(snap.existing_insights, &(&1.type == "agent_offline"))
end
end
end

View file

@ -605,7 +605,14 @@ defmodule Towerops.Preseem.InsightsTest do
inserted_at: ~U[2026-05-02 00:00:00Z]
})
results = Insights.list_unenriched_insights(limit: 10)
# Scope to this test's org — `list_unenriched_insights/1` is global
# by design (the enrichment worker walks every org), but sandbox
# contention with other suites can leak unenriched rows from other
# orgs. Filter explicitly here to keep the assertion deterministic.
results =
[limit: 10]
|> Insights.list_unenriched_insights()
|> Enum.filter(&(&1.organization_id == org.id))
ids = Enum.map(results, & &1.id)
assert oldest.id in ids

View file

@ -0,0 +1,83 @@
defmodule Towerops.Workers.AiNetworkInsightWorkerTest do
use Towerops.DataCase, async: false
use Oban.Testing, repo: Towerops.Repo
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.Insights
alias Towerops.Workers.AiNetworkInsightWorker
setup do
user = user_fixture()
org = organization_fixture(user.id)
on_exit(fn -> Process.delete(:llm_mock_response) end)
%{org: org}
end
describe "perform/1" do
test "returns :ok with no orgs that have any data", %{org: _org} do
Process.put(
:llm_mock_response,
{:ok, %{content: ~s|{"observations": []}|, model: "mock"}}
)
assert :ok = perform_job(AiNetworkInsightWorker, %{})
end
test "creates an ai_observation insight from a parsed observation", %{org: org} do
Process.put(
:llm_mock_response,
{:ok,
%{
content:
~s|{"observations": [{"title": "Hot tower correlated with QoE drop", "description": "Tower-A 78C and AP North QoE 42.", "urgency": "warning", "recommended_action": "Inspect ventilation."}]}|,
model: "mock"
}}
)
assert :ok = perform_job(AiNetworkInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "ai_observation")
assert length(insights) == 1
[insight] = insights
assert insight.source == "ai"
assert insight.urgency == "warning"
assert insight.title =~ "Hot tower"
assert insight.recommended_action == "Inspect ventilation."
end
test "deduplicates observations with the same title via dedup_key", %{org: org} do
response =
~s|{"observations": [{"title": "Same finding", "description": "x", "urgency": "info"}]}|
Process.put(:llm_mock_response, {:ok, %{content: response, model: "mock"}})
assert :ok = perform_job(AiNetworkInsightWorker, %{})
assert :ok = perform_job(AiNetworkInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "ai_observation")
assert length(insights) == 1
end
test "no insight created when LLM returns empty observations", %{org: org} do
Process.put(
:llm_mock_response,
{:ok, %{content: ~s|{"observations": []}|, model: "mock"}}
)
assert :ok = perform_job(AiNetworkInsightWorker, %{})
assert Insights.list_insights(org.id, type: "ai_observation") == []
end
test "tolerates LLM error and skips that org gracefully", %{org: org} do
Process.put(:llm_mock_response, {:error, :rate_limited})
assert :ok = perform_job(AiNetworkInsightWorker, %{})
assert Insights.list_insights(org.id, type: "ai_observation") == []
end
end
end

View file

@ -207,6 +207,7 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
end
describe "regenerate_insights event" do
alias Towerops.Workers.AiNetworkInsightWorker
alias Towerops.Workers.CapacityInsightWorker
alias Towerops.Workers.DeviceHealthInsightWorker
alias Towerops.Workers.GaiiaInsightWorker
@ -239,6 +240,7 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
assert_enqueued(worker: SystemInsightWorker)
assert_enqueued(worker: WirelessInsightWorker)
assert_enqueued(worker: InsightLlmEnrichmentWorker)
assert_enqueued(worker: AiNetworkInsightWorker)
end
test "regenerate_insights is rejected for non-superusers", %{conn: conn} do