towerops/docs/plans/2026-05-09-llm-recommendations-and-frequency-rule.md
Graham McIntire 03f364a956 feat(insights): LLM-powered insight enrichment
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
2026-05-09 16:41:48 -05:00

959 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_insights` table (already a general insight store written to by 4+ workers). Add an `llm_summary` text field and a `recommended_action` text field. Keep deterministic rules generating structured findings; LLM only paraphrases/explains.
- DeepSeek client lives in `lib/towerops/llm/deepseek.ex`, fronted by a thin `Towerops.LLM` context. API key from `DEEPSEEK_API_KEY` env (system-wide, not per-org). HTTP via `:req`.
- A new Oban worker (`InsightLlmEnrichmentWorker`) listens for new active insights without an `llm_summary` and fills it in. Failures are non-fatal (insight still shown, just no LLM text).
- Phase 2 adds a `wireless_neighbor_scans` table populated by SNMP wireless tables (vendor profiles), and a `FrequencyChangeRule` that emits `ap_frequency_change` insights 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/completions` at `https://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**
```bash
cd /Users/graham/dev/towerops/towerops-web && mix ecto.gen.migration add_llm_fields_to_preseem_insights
```
**Step 2: Write migration**
```elixir
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:
```elixir
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**
```bash
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`:
```elixir
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**
```bash
mix ecto.migrate
mix test test/towerops/preseem/insight_test.exs
```
Expected: PASS.
**Step 7: Commit**
```bash
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 with `complete/2` callback)
- 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**
```elixir
# 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**
```elixir
# 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**
```elixir
# 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**
```bash
mix test test/towerops/llm/deepseek_test.exs
```
Expected: FAIL — module DeepSeek not defined.
**Step 5: Implement DeepSeek client**
```elixir
# 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):
```elixir
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`:
```elixir
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):
```elixir
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**
```elixir
# 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**
```bash
mix test test/towerops/llm/
```
**Step 9: Commit**
```bash
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`**
```elixir
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**
```bash
mix test test/towerops/preseem/insights_test.exs
```
**Step 3: Implement**
In `lib/towerops/preseem/insights.ex`:
```elixir
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**
```bash
mix test test/towerops/preseem/insights_test.exs
```
**Step 5: Commit**
```bash
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.exs` cron + queue list
**Step 1: Write a failing pure-function test for the prompt builder**
```elixir
# 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**
```elixir
# 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**
```elixir
# 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**
```elixir
# 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:
```elixir
{"*/5 * * * *", Towerops.Workers.InsightLlmEnrichmentWorker}
```
**Step 9: Run tests, PASS**
```bash
mix test test/towerops/workers/insight_llm_enrichment_worker_test.exs
mix test test/towerops/llm/
```
**Step 10: Commit**
```bash
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**
```elixir
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):
```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**
```bash
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**
```bash
cd /Users/graham/dev/towerops/towerops-web && mix precommit
```
Expected: PASS.
**Step 2: Manual smoke (only if user has DEEPSEEK_API_KEY in env)**
```bash
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**
```bash
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):**
- `id` binary_id
- `device_id` (FK devices, the observing AP)
- `bssid` string (neighbor MAC)
- `ssid` string (nullable)
- `channel` integer
- `frequency_mhz` integer
- `channel_width_mhz` integer (nullable, default 20)
- `rssi_dbm` integer
- `is_own_fleet` boolean (set true if `bssid` matches another device in same org)
- `seen_at` utc_datetime
- `inserted_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` (add `current_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.ex`
- `lib/towerops/snmp/profiles/vendors/unifi.ex`
- `lib/towerops/snmp/profiles/vendors/mikrotik.ex`
For each, add OID extraction for current channel + neighbor scan table where supported:
- Cambium: `cambium450MWirelessChannel`, `whispRfFrequency` etc. — check the vendor MIBs in `priv/mibs/`
- UniFi: `UBNT-UniFi-MIB` channel/frequency leaves
- RouterOS: `mtxrWlChannel`, `mtxrWlSurveyTable` for neighbor scan
**TDD cycle:**
1. Test parses a sample SNMP varbind list into `%{current_channel: ..., neighbors: [...]}`
2. Implement minimal walk callback
3. Persist via `Snmp` context
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` — add `ap_frequency_change` to `@valid_types`
- Test: `test/towerops/recommendations/rules/frequency_change_test.exs`
**Rule logic (deterministic):**
- Input: a `Towerops.Snmp.Device` with non-nil `current_channel`
- For each AP, query its `wireless_neighbor_scans` from 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` (or `critical` if 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_offenders` sorted 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.exs` cron
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 a `RecommendationCard` component)
- 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_KEY` in production secrets (`towerops-secrets`).
- Verify `InsightLlmEnrichmentWorker` is 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)