From f0058a4d77324dcc99e9f7a7985be4177b78b5f1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 11 May 2026 10:35:35 -0500 Subject: [PATCH] feat(insights): feed AP radio/frequency/interference data to AI network insight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/towerops/llm/network_insight_prompt.ex | 19 ++-- lib/towerops/llm/network_snapshot.ex | 117 +++++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/lib/towerops/llm/network_insight_prompt.ex b/lib/towerops/llm/network_insight_prompt.ex index 96bb6159..68d8dce0 100644 --- a/lib/towerops/llm/network_insight_prompt.ex +++ b/lib/towerops/llm/network_insight_prompt.ex @@ -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. diff --git a/lib/towerops/llm/network_snapshot.ex b/lib/towerops/llm/network_snapshot.ex index b89d07d6..d6d0c1ca 100644 --- a/lib/towerops/llm/network_snapshot.ex +++ b/lib/towerops/llm/network_snapshot.ex @@ -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