feat(insights): feed AP radio/frequency/interference data to AI network insight

Adds a wireless section to the NetworkSnapshot with per-AP radio state
(channel, frequency, channel width, noise floor, RF/QoE scores), per-channel
occupancy (own-fleet + foreign neighbor counts), and APs with the worst
foreign interference. Updates the system prompt to describe this data and
removes device_id from the expected output schema — the LLM now references
devices by name.
This commit is contained in:
Graham McIntire 2026-05-11 10:35:35 -05:00
parent 9a10e01a24
commit f0058a4d77
2 changed files with 129 additions and 7 deletions

View file

@ -25,6 +25,10 @@ defmodule Towerops.LLM.NetworkInsightPrompt do
- 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)
- wireless (per-AP radio state channel, frequency, channel width,
noise floor, RF score, QoE score, client count; per-channel occupancy
with own-fleet and foreign-neighbor counts; APs with the most foreign
interference by RSSI)
- backhaul (interfaces with configured capacity)
- gaiia (subscriber-mapping reconciliation findings)
- agents (online/offline counts)
@ -36,19 +40,20 @@ defmodule Towerops.LLM.NetworkInsightPrompt do
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;
better" is not actionable; reference specific APs by name and say
what's wrong and why.
4. Prefer cross-signal correlations: a channel where every AP shows high
noise floor and low QoE; APs on the same channel with foreign
interference overlapping; a hot AP whose clients all have weak signal;
a backhaul whose downstream APs all degrade together.
5. If nothing meaningful is present, return an empty observations list.
6. Reference APs and devices by their `name` field from the snapshot,
never by raw IDs.
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)}
"recommended_action": "..."}
]}
Aim for 05 observations. Quality over quantity.

View file

@ -25,6 +25,11 @@ defmodule Towerops.LLM.NetworkSnapshot do
weak_signal_clients: [%{...}],
low_optical_rx: [%{...}]
},
wireless: %{
ap_radios: [%{name, channel, frequency_mhz, channel_width, noise_floor_dbm, rf_score, qoe_score, client_count}],
channel_occupancy: [%{channel, ap_count, interfering_neighbor_count}],
worst_interference: [%{name, channel, neighbor_count, strongest_rssi_dbm, own_fleet_overlap}]
},
backhaul: %{high_utilization: [%{...}]},
gaiia: %{untracked: n, ghosts: n, mismatches: n},
agents: %{online: n, offline: n, last_seen_minutes_ago: [n]},
@ -47,6 +52,7 @@ defmodule Towerops.LLM.NetworkSnapshot do
alias Towerops.Snmp.Transceiver
alias Towerops.Snmp.TransceiverReading
alias Towerops.Snmp.WirelessClient
alias Towerops.Snmp.WirelessNeighborScan
@top_n 10
@stale_poll_hours 24
@ -59,6 +65,7 @@ defmodule Towerops.LLM.NetworkSnapshot do
totals: build_totals(organization_id),
preseem: build_preseem(organization_id),
snmp: build_snmp(organization_id),
wireless: build_wireless(organization_id),
backhaul: build_backhaul(organization_id),
gaiia: build_gaiia(organization_id),
agents: build_agents(organization_id),
@ -244,6 +251,116 @@ defmodule Towerops.LLM.NetworkSnapshot do
|> Enum.map(fn row -> Map.update!(row, :rx_power_dbm, &round_decimal/1) end)
end
defp build_wireless(organization_id) do
%{
ap_radios: ap_radios(organization_id),
channel_occupancy: channel_occupancy(organization_id),
worst_interference: worst_interference(organization_id)
}
end
# APs with known radio state — current channel, frequency, noise floor,
# and linked Preseem scores. The LLM can spot patterns like "all APs on
# channel 36 have low QoE, but channel 149 looks clean."
defp ap_radios(organization_id) do
sub =
from(s in Sensor,
where: s.sensor_type in ~w(noise-floor noise noise_floor),
distinct: s.snmp_device_id,
order_by: [asc: s.snmp_device_id, desc: s.inserted_at],
select: %{snmp_device_id: s.snmp_device_id, noise_floor_dbm: s.last_value}
)
SnmpDevice
|> join(:inner, [sd], d in Device, on: sd.device_id == d.id)
|> join(:left, [sd, d], ap in AccessPoint, on: ap.device_id == d.id)
|> join(:left, [sd], n in subquery(sub), on: n.snmp_device_id == sd.id)
|> where([_sd, d], d.organization_id == ^organization_id)
|> where([sd], not is_nil(sd.current_frequency_mhz))
|> order_by([sd], asc: sd.current_frequency_mhz)
|> limit(@top_n)
|> select([sd, d, ap, n], %{
name: d.name,
channel: sd.current_channel,
frequency_mhz: sd.current_frequency_mhz,
channel_width: sd.current_channel_width_mhz,
noise_floor_dbm: n.noise_floor_dbm,
rf_score: ap.rf_score,
qoe_score: ap.qoe_score,
client_count: ap.subscriber_count
})
|> Repo.all()
|> Enum.map(fn row ->
row
|> Map.update!(:rf_score, &round_decimal/1)
|> Map.update!(:qoe_score, &round_decimal/1)
|> Map.update!(:noise_floor_dbm, &round_decimal/1)
end)
end
# Per-channel occupancy: how many own-fleet APs are on each channel and
# how many foreign neighbors are visible — helps spot crowded channels.
defp channel_occupancy(organization_id) do
own =
from(sd in SnmpDevice,
join: d in Device,
on: sd.device_id == d.id,
where: d.organization_id == ^organization_id,
where: not is_nil(sd.current_channel),
group_by: sd.current_channel,
select: %{channel: sd.current_channel, ap_count: count(sd.id)}
)
foreign =
from(ns in WirelessNeighborScan,
join: d in Device,
on: ns.device_id == d.id,
where: d.organization_id == ^organization_id,
where: ns.is_own_fleet == false,
where: not is_nil(ns.channel),
group_by: ns.channel,
select: %{channel: ns.channel, neighbor_count: count(ns.id)}
)
own_rows = Repo.all(own)
foreign_rows = Repo.all(foreign)
own_map = Map.new(own_rows, fn r -> {r.channel, r.ap_count} end)
foreign_map = Map.new(foreign_rows, fn r -> {r.channel, r.neighbor_count} end)
merged = Map.merge(own_map, foreign_map, fn _ch, o, f -> {o, f} end)
merged
|> Enum.map(fn {ch, {own_ct, f_ct}} ->
%{channel: ch, ap_count: own_ct, interfering_neighbor_count: f_ct}
end)
|> Enum.sort_by(& &1.ap_count, :desc)
|> Enum.take(@top_n)
end
# APs with the most foreign neighbor interference — signals the LLM
# should examine.
defp worst_interference(organization_id) do
WirelessNeighborScan
|> join(:inner, [ns], d in Device, on: ns.device_id == d.id)
|> where([ns, d], d.organization_id == ^organization_id)
|> where([ns], ns.is_own_fleet == false)
|> where([ns], not is_nil(ns.rssi_dbm))
|> where([ns], ns.seen_at > ago(24, "hour"))
|> group_by([ns, d], [d.name, ns.channel])
|> select([ns, d], %{
name: d.name,
channel: ns.channel,
neighbor_count: count(ns.id),
strongest_rssi_dbm: max(ns.rssi_dbm)
})
|> order_by([ns], desc: max(ns.rssi_dbm))
|> limit(@top_n)
|> Repo.all()
|> Enum.map(fn row ->
Map.update!(row, :strongest_rssi_dbm, &round_decimal/1)
end)
end
defp build_backhaul(organization_id) do
high =
Interface