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
959 lines
32 KiB
Markdown
959 lines
32 KiB
Markdown
# 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)
|