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

32 KiB
Raw Blame History

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

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 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

# 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.exs cron + 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"

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):

  • 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)