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:
parent
9a10e01a24
commit
f0058a4d77
2 changed files with 129 additions and 7 deletions
|
|
@ -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 0–5 observations. Quality over quantity.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue