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
32 KiB
LLM-Enriched Recommendations + AP Frequency-Change Rule
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Make towerops produce concrete, actionable recommendations ("AP X needs a frequency change") backed by structured evidence and explained in natural language by an LLM (DeepSeek). Phase 1 ships the LLM-enrichment foundation against existing insights; Phase 2 adds the first new multi-source rule (frequency change) on top.
Architecture:
- Reuse the existing
preseem_insightstable (already a general insight store written to by 4+ workers). Add anllm_summarytext field and arecommended_actiontext field. Keep deterministic rules generating structured findings; LLM only paraphrases/explains. - DeepSeek client lives in
lib/towerops/llm/deepseek.ex, fronted by a thinTowerops.LLMcontext. API key fromDEEPSEEK_API_KEYenv (system-wide, not per-org). HTTP via:req. - A new Oban worker (
InsightLlmEnrichmentWorker) listens for new active insights without anllm_summaryand fills it in. Failures are non-fatal (insight still shown, just no LLM text). - Phase 2 adds a
wireless_neighbor_scanstable populated by SNMP wireless tables (vendor profiles), and aFrequencyChangeRulethat emitsap_frequency_changeinsights with structured evidence (current channel, top neighbors on same/adjacent channel, recommended channel).
Tech Stack:
- Elixir 1.17 / Phoenix 1.8 / Ecto / Oban (Pro vendored)
- HTTP:
:req(only approved client) - DeepSeek API: OpenAI-compatible
/chat/completionsathttps://api.deepseek.com/v1 - Tests: ExUnit + Mox for HTTP boundary
- TDD: tests first (per project AGENTS.md)
Out of scope (deferred):
- Per-org DeepSeek keys (system-wide for now)
- Streaming LLM responses
- Other rules (sector overload, GPS sync, thermal, backhaul) — same pattern, follow-on plans
- LLM Q&A / RAG over insights
Phase 1 — LLM Enrichment MVP
Task 1: Add llm_summary + recommended_action columns to preseem_insights
Files:
- Create:
priv/repo/migrations/<timestamp>_add_llm_fields_to_preseem_insights.exs - Modify:
lib/towerops/preseem/insight.ex - Test:
test/towerops/preseem/insight_test.exs(existing — extend)
Step 1: Generate migration
cd /Users/graham/dev/towerops/towerops-web && mix ecto.gen.migration add_llm_fields_to_preseem_insights
Step 2: Write migration
defmodule Towerops.Repo.Migrations.AddLlmFieldsToPreseemInsights do
use Ecto.Migration
def change do
alter table(:preseem_insights) do
add :llm_summary, :text
add :recommended_action, :text
add :llm_model, :string
add :llm_enriched_at, :utc_datetime
end
create index(:preseem_insights, [:llm_enriched_at])
end
end
Step 3: Add a failing test that round-trips llm_summary
In test/towerops/preseem/insight_test.exs, add:
test "changeset accepts llm_summary, recommended_action, llm_model, llm_enriched_at" do
attrs = %{
organization_id: Ecto.UUID.generate(),
type: "wireless_signal_weak",
urgency: "warning",
channel: "proactive",
title: "weak signal",
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]
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?
assert get_change(changeset, :llm_summary) == "AP overloaded; consider rebalancing"
assert get_change(changeset, :recommended_action) == "Move 3 clients to neighboring AP"
end
Step 4: Run the test, expect FAIL
cd /Users/graham/dev/towerops/towerops-web && mix test test/towerops/preseem/insight_test.exs --only line:<line>
Expected: FAIL — fields not in cast list.
Step 5: Update Insight schema
In lib/towerops/preseem/insight.ex:
schema "preseem_insights" do
# ... existing fields ...
field :llm_summary, :string
field :recommended_action, :string
field :llm_model, :string
field :llm_enriched_at, :utc_datetime
# ... existing belongs_to and timestamps ...
end
def changeset(insight, attrs) do
insight
|> cast(attrs, [
# ... existing fields ...,
:llm_summary,
:recommended_action,
:llm_model,
:llm_enriched_at
])
# ... existing validations ...
end
Step 6: Run migration and tests
mix ecto.migrate
mix test test/towerops/preseem/insight_test.exs
Expected: PASS.
Step 7: Commit
git add priv/repo/migrations/*add_llm_fields* lib/towerops/preseem/insight.ex test/towerops/preseem/insight_test.exs
git commit -m "feat(insights): add llm_summary, recommended_action, llm_model, llm_enriched_at"
Task 2: DeepSeek client module (boundary, mockable)
Files:
- Create:
lib/towerops/llm.ex(context withcomplete/2callback) - Create:
lib/towerops/llm/deepseek.ex(real implementation) - Create:
lib/towerops/llm/behaviour.ex(@callback complete(prompt, opts) :: {:ok, String.t()} | {:error, term()}) - Modify:
config/config.exs,config/test.exs,config/runtime.exs - Test:
test/towerops/llm/deepseek_test.exs
Step 1: Write the behaviour
# lib/towerops/llm/behaviour.ex
defmodule Towerops.LLM.Behaviour do
@moduledoc "Behaviour for LLM clients used in recommendation enrichment."
@callback complete(messages :: list(map()), opts :: keyword()) ::
{:ok, %{content: String.t(), model: String.t()}} | {:error, term()}
end
Step 2: Write the context
# lib/towerops/llm.ex
defmodule Towerops.LLM do
@moduledoc "Public LLM API. Delegates to the configured implementation."
def complete(messages, opts \\ []) do
impl().complete(messages, opts)
end
defp impl, do: Application.get_env(:towerops, __MODULE__)[:impl] || Towerops.LLM.DeepSeek
end
Step 3: Write the failing client test
# test/towerops/llm/deepseek_test.exs
defmodule Towerops.LLM.DeepSeekTest do
use ExUnit.Case, async: true
alias Towerops.LLM.DeepSeek
setup do
bypass = Bypass.open()
Application.put_env(:towerops, DeepSeek, base_url: "http://localhost:#{bypass.port}", api_key: "test-key")
on_exit(fn -> Application.delete_env(:towerops, DeepSeek) end)
{:ok, bypass: bypass}
end
test "complete/2 posts to /chat/completions and parses content", %{bypass: bypass} do
Bypass.expect_once(bypass, "POST", "/chat/completions", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
assert {:ok, %{"messages" => [%{"role" => "user", "content" => "hello"}], "model" => "deepseek-chat"}} = Jason.decode(body)
auth = Plug.Conn.get_req_header(conn, "authorization") |> List.first()
assert auth == "Bearer test-key"
Plug.Conn.resp(conn, 200, Jason.encode!(%{
"model" => "deepseek-chat",
"choices" => [%{"message" => %{"content" => "hi there"}}]
}))
end)
assert {:ok, %{content: "hi there", model: "deepseek-chat"}} =
DeepSeek.complete([%{role: "user", content: "hello"}], [])
end
test "complete/2 returns {:error, :missing_api_key} when key missing" do
Application.put_env(:towerops, DeepSeek, base_url: "http://example.invalid")
assert {:error, :missing_api_key} = DeepSeek.complete([%{role: "user", content: "x"}], [])
end
test "complete/2 returns {:error, {:http, status, body}} on non-2xx", %{bypass: bypass} do
Bypass.expect_once(bypass, "POST", "/chat/completions", fn conn ->
Plug.Conn.resp(conn, 500, ~s({"error":"boom"}))
end)
assert {:error, {:http, 500, _}} =
DeepSeek.complete([%{role: "user", content: "x"}], [])
end
end
NOTE: Bypass is in deps already (verify with mix deps | grep bypass). If absent, add it under Mix.env() in [:test].
Step 4: Run the test, expect FAIL
mix test test/towerops/llm/deepseek_test.exs
Expected: FAIL — module DeepSeek not defined.
Step 5: Implement DeepSeek client
# lib/towerops/llm/deepseek.ex
defmodule Towerops.LLM.DeepSeek do
@moduledoc """
DeepSeek chat completion client (OpenAI-compatible).
Configuration in `config/runtime.exs`:
config :towerops, Towerops.LLM.DeepSeek,
api_key: System.get_env("DEEPSEEK_API_KEY"),
base_url: System.get_env("DEEPSEEK_BASE_URL") || "https://api.deepseek.com/v1",
model: "deepseek-chat"
"""
@behaviour Towerops.LLM.Behaviour
@default_base_url "https://api.deepseek.com/v1"
@default_model "deepseek-chat"
@receive_timeout 30_000
@impl true
def complete(messages, opts) do
cfg = Application.get_env(:towerops, __MODULE__, [])
api_key = opts[:api_key] || cfg[:api_key]
base_url = opts[:base_url] || cfg[:base_url] || @default_base_url
model = opts[:model] || cfg[:model] || @default_model
cond do
is_nil(api_key) or api_key == "" ->
{:error, :missing_api_key}
true ->
request(base_url, api_key, model, messages, opts)
end
end
defp request(base_url, api_key, model, messages, opts) do
body = %{
model: model,
messages: messages,
temperature: opts[:temperature] || 0.2,
max_tokens: opts[:max_tokens] || 400
}
case Req.post(base_url <> "/chat/completions",
json: body,
headers: [{"authorization", "Bearer " <> api_key}],
receive_timeout: @receive_timeout
) do
{:ok, %{status: status, body: %{"choices" => [%{"message" => %{"content" => content}} | _], "model" => actual_model}}} when status in 200..299 ->
{:ok, %{content: content, model: actual_model}}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
Step 6: Wire config
config/runtime.exs (production runtime block):
config :towerops, Towerops.LLM.DeepSeek,
api_key: System.get_env("DEEPSEEK_API_KEY"),
base_url: System.get_env("DEEPSEEK_BASE_URL") || "https://api.deepseek.com/v1"
config/test.exs:
config :towerops, Towerops.LLM, impl: Towerops.LLM.MockClient
config :towerops, Towerops.LLM.DeepSeek, base_url: "http://localhost", api_key: nil
config/dev.exs (so a dev with the env var set can try it):
config :towerops, Towerops.LLM.DeepSeek,
api_key: System.get_env("DEEPSEEK_API_KEY"),
base_url: "https://api.deepseek.com/v1"
Step 7: Add Mock client for tests
# lib/towerops/llm/mock_client.ex
defmodule Towerops.LLM.MockClient do
@moduledoc "Test stand-in for Towerops.LLM. Returns canned responses configurable via Process dict."
@behaviour Towerops.LLM.Behaviour
@impl true
def complete(_messages, _opts) do
Process.get(:llm_mock_response, {:ok, %{content: "MOCK_RESPONSE", model: "mock"}})
end
end
Tests can then Process.put(:llm_mock_response, {:ok, %{content: "...", model: "deepseek-chat"}}) per-test.
Step 8: Run all tests, expect PASS
mix test test/towerops/llm/
Step 9: Commit
git add lib/towerops/llm.ex lib/towerops/llm/ config/runtime.exs config/dev.exs config/test.exs test/towerops/llm/
git commit -m "feat(llm): add DeepSeek client behind Towerops.LLM context"
Task 3: Insights public API for LLM enrichment
Files:
- Modify:
lib/towerops/preseem/insights.ex - Test:
test/towerops/preseem/insights_test.exs(existing — extend)
Step 1: Add failing test for list_unenriched_insights/1 and apply_llm_enrichment/3
test "list_unenriched_insights/1 returns active insights with nil llm_enriched_at, oldest first" do
org = insert(:organization)
device = insert(:device, organization: org)
i1 = insert(:preseem_insight, organization: org, device: device, status: "active",
inserted_at: ~U[2026-05-01 00:00:00Z])
_i2 = insert(:preseem_insight, organization: org, status: "active",
llm_enriched_at: ~U[2026-05-08 12:00:00Z])
_i3 = insert(:preseem_insight, organization: org, status: "dismissed")
i4 = insert(:preseem_insight, organization: org, status: "active",
inserted_at: ~U[2026-05-02 00:00:00Z])
assert [%{id: id1}, %{id: id4}] = Insights.list_unenriched_insights(limit: 10)
assert id1 == i1.id
assert id4 == i4.id
end
test "apply_llm_enrichment/3 sets llm_summary, recommended_action, llm_model, llm_enriched_at" do
insight = insert(:preseem_insight, status: "active")
assert {:ok, updated} = Insights.apply_llm_enrichment(insight,
%{summary: "AP overloaded.", action: "Rebalance 3 clients."},
"deepseek-chat")
assert updated.llm_summary == "AP overloaded."
assert updated.recommended_action == "Rebalance 3 clients."
assert updated.llm_model == "deepseek-chat"
refute is_nil(updated.llm_enriched_at)
end
You'll likely need to add a preseem_insight factory in test/support/factories.ex (or wherever ex_machina lives — check existing factories first). If factories aren't used, build attrs inline via the existing Insights.insert_insight_if_new/1.
Step 2: Run the test, expect FAIL
mix test test/towerops/preseem/insights_test.exs
Step 3: Implement
In lib/towerops/preseem/insights.ex:
import Ecto.Query
def list_unenriched_insights(opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
from(i in Insight,
where: i.status == "active" and is_nil(i.llm_enriched_at),
order_by: [asc: i.inserted_at],
limit: ^limit
)
|> Repo.all()
end
def apply_llm_enrichment(%Insight{} = insight, %{summary: summary, action: action}, model) do
insight
|> Insight.changeset(%{
llm_summary: summary,
recommended_action: action,
llm_model: model,
llm_enriched_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
|> Repo.update()
end
Step 4: Run tests, expect PASS
mix test test/towerops/preseem/insights_test.exs
Step 5: Commit
git add lib/towerops/preseem/insights.ex test/towerops/preseem/insights_test.exs test/support/
git commit -m "feat(insights): add list_unenriched_insights/1 and apply_llm_enrichment/3"
Task 4: InsightLlmEnrichmentWorker (Oban)
Files:
- Create:
lib/towerops/workers/insight_llm_enrichment_worker.ex - Create:
lib/towerops/llm/insight_prompt.ex(prompt builder, pure) - Test:
test/towerops/llm/insight_prompt_test.exs - Test:
test/towerops/workers/insight_llm_enrichment_worker_test.exs - Modify:
config/runtime.exscron + queue list
Step 1: Write a failing pure-function test for the prompt builder
# test/towerops/llm/insight_prompt_test.exs
defmodule Towerops.LLM.InsightPromptTest do
use ExUnit.Case, async: true
alias Towerops.LLM.InsightPrompt
alias Towerops.Preseem.Insight
test "build/1 returns system + user messages and includes structured metadata as JSON" do
insight = %Insight{
type: "wireless_signal_weak",
urgency: "warning",
title: "Weak signal: AA:BB:CC (-78 dBm)",
description: "Wireless client AA:BB:CC has weak signal.",
metadata: %{"signal_strength" => -78, "threshold" => -75, "device_id" => "abc"}
}
messages = InsightPrompt.build(insight)
assert [%{role: "system", content: sys}, %{role: "user", content: user}] = messages
assert sys =~ "WISP/network operations assistant"
assert user =~ "wireless_signal_weak"
assert user =~ "-78"
assert user =~ "JSON"
end
test "parse/1 extracts summary and action from JSON response" do
json = ~s|{"summary": "AP X overloaded.", "action": "Move clients to AP Y."}|
assert {:ok, %{summary: "AP X overloaded.", action: "Move clients to AP Y."}} =
InsightPrompt.parse(json)
end
test "parse/1 falls back when content is not JSON" do
assert {:ok, %{summary: "raw text", action: nil}} = InsightPrompt.parse("raw text")
end
end
Step 2: Run, FAIL
Step 3: Implement prompt + parser
# lib/towerops/llm/insight_prompt.ex
defmodule Towerops.LLM.InsightPrompt do
@moduledoc """
Builds chat messages for enriching a network-operations insight, and
parses the LLM's JSON response back into a {summary, action} pair.
"""
alias Towerops.Preseem.Insight
@system """
You are a WISP/network operations assistant. The user will give you a structured
insight detected by an automated rule. Your job:
1. Restate the finding in one or two plain sentences a network operator can act on.
2. Suggest one specific next action they should take, citing the numbers in the data.
Do NOT invent numbers. Only reference values present in the structured data.
Respond ONLY with JSON: {"summary": "...", "action": "..."}.
"""
@spec build(Insight.t()) :: [map()]
def build(%Insight{} = insight) do
payload =
%{
type: insight.type,
urgency: insight.urgency,
title: insight.title,
description: insight.description,
metadata: insight.metadata || %{}
}
|> Jason.encode!(pretty: true)
[
%{role: "system", content: @system},
%{role: "user", content: "Insight (JSON):\n" <> payload}
]
end
@spec parse(String.t()) :: {:ok, %{summary: String.t(), action: String.t() | nil}}
def parse(content) when is_binary(content) do
cleaned = content |> String.trim() |> strip_code_fences()
case Jason.decode(cleaned) do
{:ok, %{"summary" => s} = m} when is_binary(s) ->
{:ok, %{summary: s, action: m["action"]}}
_ ->
{:ok, %{summary: cleaned, action: nil}}
end
end
defp strip_code_fences(text) do
text
|> String.replace(~r/^```(json)?\s*/m, "")
|> String.replace(~r/```\s*$/m, "")
|> String.trim()
end
end
Step 4: Run, PASS
Step 5: Write a failing worker test
# test/towerops/workers/insight_llm_enrichment_worker_test.exs
defmodule Towerops.Workers.InsightLlmEnrichmentWorkerTest do
use Towerops.DataCase, async: false
alias Towerops.Workers.InsightLlmEnrichmentWorker
alias Towerops.Preseem.{Insight, Insights}
alias Towerops.Repo
setup do
Application.put_env(:towerops, Towerops.LLM, impl: Towerops.LLM.MockClient)
:ok
end
test "perform/1 enriches active, unenriched insights" do
org = insert(:organization)
insight = insert(:preseem_insight, organization: org, status: "active",
llm_enriched_at: nil,
type: "wireless_signal_weak",
metadata: %{"signal_strength" => -82})
Process.put(:llm_mock_response, {:ok, %{
content: ~s|{"summary":"Weak link to CPE","action":"Re-aim CPE antenna"}|,
model: "deepseek-chat"
}})
assert :ok = perform_job(InsightLlmEnrichmentWorker, %{})
updated = Repo.get!(Insight, insight.id)
assert updated.llm_summary == "Weak link to CPE"
assert updated.recommended_action == "Re-aim CPE antenna"
assert updated.llm_model == "deepseek-chat"
refute is_nil(updated.llm_enriched_at)
end
test "perform/1 leaves insight unenriched on LLM error and does not crash" do
insight = insert(:preseem_insight, status: "active", llm_enriched_at: nil)
Process.put(:llm_mock_response, {:error, :missing_api_key})
assert :ok = perform_job(InsightLlmEnrichmentWorker, %{})
refreshed = Repo.get!(Insight, insight.id)
assert is_nil(refreshed.llm_summary)
assert is_nil(refreshed.llm_enriched_at)
end
test "perform/1 skips already-enriched insights" do
insert(:preseem_insight, status: "active",
llm_enriched_at: ~U[2026-05-01 00:00:00Z],
llm_summary: "existing")
Process.put(:llm_mock_response, {:ok, %{content: "should not be called", model: "x"}})
assert :ok = perform_job(InsightLlmEnrichmentWorker, %{})
end
end
Step 6: Run, FAIL
Step 7: Implement the worker
# lib/towerops/workers/insight_llm_enrichment_worker.ex
defmodule Towerops.Workers.InsightLlmEnrichmentWorker do
@moduledoc """
Oban cron worker that fills in `llm_summary` / `recommended_action` for
active insights that have no LLM enrichment yet.
Run frequency is intentionally modest (every 5 min). Failures (rate limits,
missing key, parse errors) are logged and skipped — the insight is still
visible to the operator without LLM text.
"""
use Oban.Worker, queue: :maintenance, max_attempts: 3
require Logger
alias Towerops.LLM
alias Towerops.LLM.InsightPrompt
alias Towerops.Preseem.Insights
@batch_size 25
@impl Oban.Worker
def perform(%Oban.Job{}) do
Insights.list_unenriched_insights(limit: @batch_size)
|> Enum.each(&enrich_one/1)
:ok
end
defp enrich_one(insight) do
messages = InsightPrompt.build(insight)
case LLM.complete(messages, max_tokens: 400, temperature: 0.2) do
{:ok, %{content: content, model: model}} ->
case InsightPrompt.parse(content) do
{:ok, parsed} ->
Insights.apply_llm_enrichment(insight, parsed, model)
other ->
Logger.warning("LLM parse failed for insight #{insight.id}: #{inspect(other)}")
end
{:error, reason} ->
Logger.warning("LLM enrichment failed for insight #{insight.id}: #{inspect(reason)}")
end
end
end
Step 8: Add cron entry in config/runtime.exs
In the :crontab block, add:
{"*/5 * * * *", Towerops.Workers.InsightLlmEnrichmentWorker}
Step 9: Run tests, PASS
mix test test/towerops/workers/insight_llm_enrichment_worker_test.exs
mix test test/towerops/llm/
Step 10: Commit
git add lib/towerops/llm/insight_prompt.ex lib/towerops/workers/insight_llm_enrichment_worker.ex test/towerops/llm/insight_prompt_test.exs test/towerops/workers/insight_llm_enrichment_worker_test.exs config/runtime.exs
git commit -m "feat(insights): LLM enrichment worker via DeepSeek"
Task 5: Show llm_summary + recommended_action in Preseem Insights LiveView
Files:
- Modify:
lib/towerops_web/live/org/preseem_insights_live.ex(template / render block) - Modify:
lib/towerops_web/live/device_live/show.html.heex(Preseem tab insight cards) - Test:
test/towerops_web/live/insights_live_events_test.exs(existing, extend)
Step 1: Add failing LiveView render test
test "renders llm_summary and recommended_action when present", %{conn: conn, org: org, user: user} do
insight = insert(:preseem_insight, organization: org, status: "active",
llm_summary: "AP overloaded with 80 clients",
recommended_action: "Move 10 clients to neighboring AP")
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")
assert html =~ "AP overloaded with 80 clients"
assert html =~ "Move 10 clients to neighboring AP"
end
Step 2: Run, FAIL
Step 3: Update the LiveView template (locate the insight card render block; render @insight.llm_summary in a soft-bg box and @insight.recommended_action as a prominent "Recommended action" line). Add a small "AI-generated" note + the model name.
A concrete pattern (HEEX):
<div :if={@insight.llm_summary} class="mt-2 rounded-md bg-blue-50 px-3 py-2 text-sm text-blue-900">
<p class="font-medium">Plain-language summary</p>
<p>{@insight.llm_summary}</p>
<p :if={@insight.recommended_action} class="mt-1 font-semibold">
Recommended action: <span class="font-normal">{@insight.recommended_action}</span>
</p>
<p class="mt-1 text-xs text-blue-700">AI-generated by {@insight.llm_model}</p>
</div>
Step 4: Run, PASS
Step 5: Mirror the same render block in device_live/show.html.heex Preseem tab (look for where current insights are listed)
Step 6: Smoke run server in dev manually if convenient (optional — mix phx.server). UI changes auto-reload. Verify visually if dev server is running.
Step 7: Commit
git add lib/towerops_web/live/org/preseem_insights_live.ex lib/towerops_web/live/device_live/show.html.heex test/towerops_web/live/insights_live_events_test.exs
git commit -m "feat(insights): render LLM summary and recommended action in UI"
Task 6: Phase 1 verification + manual smoke
Files: none (verification step)
Step 1: Run full precommit
cd /Users/graham/dev/towerops/towerops-web && mix precommit
Expected: PASS.
Step 2: Manual smoke (only if user has DEEPSEEK_API_KEY in env)
DEEPSEEK_API_KEY=... iex -S mix
iex> Towerops.LLM.complete([%{role: "user", content: "Say 'hello'"}])
{:ok, %{content: "hello", model: "deepseek-chat"}}
Step 3: Update priv/static/changelog.txt and CHANGELOG.txt
priv/static/changelog.txt (top):
2026-05-09
* AI-generated summaries and recommended actions for network insights
CHANGELOG.txt (top):
2026-05-09: feat(insights): LLM enrichment via DeepSeek
- Adds llm_summary, recommended_action, llm_model, llm_enriched_at to preseem_insights
- New Towerops.LLM context with DeepSeek implementation
- InsightLlmEnrichmentWorker (every 5 min) populates summaries for active insights
- LiveView renders LLM summary + recommendation on insights and device pages
Step 4: Commit
git add priv/static/changelog.txt CHANGELOG.txt
git commit -m "docs: changelog entries for LLM enrichment"
Phase 2 — AP Frequency-Change Rule
Note: Phase 1 is independently shippable. Phase 2 builds on it: same insight table, automatic LLM enrichment will apply to the new ap_frequency_change type with no extra wiring.
Task 7: Add wireless_neighbor_scans schema
Files:
- Create:
priv/repo/migrations/<ts>_create_wireless_neighbor_scans.exs - Create:
lib/towerops/snmp/wireless_neighbor_scan.ex - Test:
test/towerops/snmp/wireless_neighbor_scan_test.exs
Schema fields (one row per neighbor seen by an AP per scan window):
idbinary_iddevice_id(FK devices, the observing AP)bssidstring (neighbor MAC)ssidstring (nullable)channelintegerfrequency_mhzintegerchannel_width_mhzinteger (nullable, default 20)rssi_dbmintegeris_own_fleetboolean (set true ifbssidmatches another device in same org)seen_atutc_datetimeinserted_at(no updated_at — append-only)- Indexes:
[device_id, seen_at],[bssid],[device_id, channel]
Step 1: Write failing schema test (changeset/2 validates required fields + accepts optional ones).
Step 2: Generate migration + write schema following same TDD cycle as Task 1.
Step 3: Commit: feat(snmp): wireless_neighbor_scans schema for RF environment scanning
Task 8: Per-device current channel storage
Files:
- Modify:
lib/towerops/snmp/device.ex(addcurrent_channel,current_frequency_mhz,current_channel_width_mhz,last_radio_seen_at) - Migration: alter
snmp_devices - Test: changeset coverage
These are populated by vendor profiles. We're only adding storage here; the actual polling extraction is Task 9.
TDD cycle as Task 1. Commit: feat(snmp): persist AP current radio channel/frequency on snmp_devices
Task 9: Vendor profile extraction (Cambium + UniFi + RouterOS)
Files (modify):
lib/towerops/snmp/profiles/vendors/cambium.exlib/towerops/snmp/profiles/vendors/unifi.exlib/towerops/snmp/profiles/vendors/mikrotik.ex
For each, add OID extraction for current channel + neighbor scan table where supported:
- Cambium:
cambium450MWirelessChannel,whispRfFrequencyetc. — check the vendor MIBs inpriv/mibs/ - UniFi:
UBNT-UniFi-MIBchannel/frequency leaves - RouterOS:
mtxrWlChannel,mtxrWlSurveyTablefor neighbor scan
TDD cycle:
- Test parses a sample SNMP varbind list into
%{current_channel: ..., neighbors: [...]} - Implement minimal walk callback
- Persist via
Snmpcontext
If a vendor doesn't have a public neighbor-scan MIB, skip it and mark as a follow-up.
Commit: feat(snmp): extract AP channel + neighbor scans for cambium/unifi/routeros
Task 10: FrequencyChangeRule
Files:
- Create:
lib/towerops/recommendations/rules/frequency_change.ex - Create:
lib/towerops/recommendations/rule_behaviour.ex(@callback evaluate(org_id) :: [Insight changeset]) - Modify:
lib/towerops/preseem/insight.ex— addap_frequency_changeto@valid_types - Test:
test/towerops/recommendations/rules/frequency_change_test.exs
Rule logic (deterministic):
- Input: a
Towerops.Snmp.Devicewith non-nilcurrent_channel - For each AP, query its
wireless_neighbor_scansfrom last 24h - Find neighbors on same channel with RSSI ≥ −75 dBm OR neighbors on adjacent channel (±1 in 5GHz UNII grid, or ±5 MHz overlap) with RSSI ≥ −70 dBm
- If ≥ 1 strong same-channel neighbor exists, recommend a channel change
- Recommended channel = pick the channel in the AP's allowed channel list (configurable per band) with the lowest aggregate interference score:
Σ 10^((rssi+95)/10)over neighbors observed on that channel in last 24h - Emit insight with metadata:
%{ "current_channel" => 149, "current_frequency_mhz" => 5745, "channel_width_mhz" => 80, "top_offenders" => [ %{"bssid" => "...", "ssid" => "...", "rssi_dbm" => -62, "is_own_fleet" => true}, ... ], "recommended_channel" => 161, "recommended_score" => 3.14, "current_score" => 42.7, "improvement_db" => 11.4 } - Title:
"AP <name> recommend channel change: 149 → 161 (-11 dB interference)" - Urgency:
warning(orcriticalif current_score > 100) - channel:
proactive, source:system
Step 1: Write failing tests for FrequencyChange.evaluate/1:
- empty fixture →
[] - AP with no neighbors → no recommendation
- AP with one −62dBm same-channel neighbor → recommendation present
- Recommended channel beats current by ≥ 6 dB
top_offenderssorted by RSSI desc, capped at 5
Step 2: Implement.
Step 3: Run, PASS.
Step 4: Commit: feat(rules): frequency-change recommendation rule
Task 11: RecommendationsRunWorker (cron, hourly)
Files:
- Create:
lib/towerops/workers/recommendations_run_worker.ex - Test:
test/towerops/workers/recommendations_run_worker_test.exs - Modify:
config/runtime.exscron
Worker iterates orgs, runs all rules, upserts insights via Insights.insert_insight_if_new/1. Cron @hourly. Same TDD cycle.
Commit: feat(workers): hourly recommendations run worker
Task 12: UI evidence card for ap_frequency_change
Files:
- Modify:
lib/towerops_web/live/org/preseem_insights_live.ex(or extract aRecommendationCardcomponent) - Possibly add:
lib/towerops_web/live/components/frequency_change_card.ex - Test: e2e view test
Render:
- Header: "Frequency change recommended"
- Current vs recommended channel side-by-side
- List of top offenders (BSSID/SSID/RSSI/own-fleet badge)
- Improvement in dB (interference score delta)
- LLM summary box (auto-populated by Phase 1 worker — no extra wiring)
- Actions: Dismiss, Mark Actioned, Snooze 7 days
TDD: render test asserts current_channel and recommended_channel appear.
Commit: feat(ui): frequency-change recommendation card
Task 13: Phase 2 verification
Step 1: mix precommit. PASS.
Step 2: Update both changelog files.
Step 3: Final commit.
Out-of-band manual checks (operator-side, post-merge)
- Set
DEEPSEEK_API_KEYin production secrets (towerops-secrets). - Verify
InsightLlmEnrichmentWorkeris in Oban dashboard. - Watch the first hour of cron runs; if rate limits hit, lower batch_size.
- Pick one production AP with known co-channel interference, verify recommendation surfaces with reasonable evidence.
Future work (out of scope)
- Sector overload rule (uses Preseem airtime + own SNMP)
- GPS sync drift rule (vendor-specific)
- Thermal correlation rule
- Backhaul saturation rule
- Per-CPE re-aim rule (needs SNR margin computation against MCS table)
- LLM Q&A over insights (RAG)
- LLM-proposed novel rules (offline analysis of unexplained QoE drops)