towerops/lib/towerops/llm/network_insight_prompt.ex
Graham McIntire 080e5111e4 fix: resolve all dialyzer type warnings across codebase
- Narrow @spec for NetworkInsightPrompt.build/1 and NetworkSnapshot.build/1
- Match/discard unmatched returns in monitoring.ex, alerts.ex, and
  cloud_latency_probe_worker.ex
- Remove unreachable :rate_limited pattern in mikrotik_webhook_worker.ex
  (resolve_error returns {:rate_limited, pos_integer()}, never bare atom)
- Fix gettext pluralisation in insights_live/index.ex: dngettext was
  receiving a string where it expected a non_neg_integer count
2026-06-08 16:17:10 -05:00

167 lines
5.9 KiB
Elixir
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.

defmodule Towerops.LLM.NetworkInsightPrompt do
@moduledoc """
Builds the chat messages for asking an LLM to surface novel insights
from a `Towerops.LLM.NetworkSnapshot.build/1` map, and parses the JSON
response back into a list of `%{title, description, urgency, ...}`
maps the worker can persist as `ai_observation` insights.
This is the "AI sees the whole network" path — distinct from the
per-insight enrichment in `Towerops.LLM.InsightPrompt`, which only
comments on findings the rule-based workers already produced.
"""
require Logger
@valid_urgencies ~w(critical warning info)
@system """
You are a senior WISP/network-operations engineer reviewing a snapshot
of one organization's network state. Your job: surface concrete,
actionable observations that a rule-based monitoring system would
likely miss — patterns, correlations across signals, or anomalies that
emerge only when looking at the network as a whole.
You are given:
- totals (devices, sites, APs, agents)
- preseem (worst APs by QoE, highest-load APs, model breakdown)
- snmp (stale polls, hot devices, weak-signal client tallies, low optical Rx)
- wireless (per-AP radio state — channel, frequency, channel width,
noise floor, RF score, QoE score, client count; per-channel occupancy
with own-fleet and foreign-neighbor counts; APs with the most foreign
interference by RSSI)
- backhaul (interfaces with configured capacity)
- gaiia (subscriber-mapping reconciliation findings)
- agents (online/offline counts)
- existing_insights (insight types the rule-based system has ALREADY surfaced)
Hard rules:
1. Do NOT restate findings already in `existing_insights` — those are
covered. Look for what's NOT there.
2. Only reference values present in the snapshot. Do not invent device
names, scores, or counts.
3. Each observation must be concrete and actionable. "QoE could be
better" is not actionable; reference specific APs by name and say
what's wrong and why.
4. Prefer cross-signal correlations: a channel where every AP shows high
noise floor and low QoE; APs on the same channel with foreign
interference overlapping; a hot AP whose clients all have weak signal;
a backhaul whose downstream APs all degrade together.
5. If nothing meaningful is present, return an empty observations list.
6. Reference APs and devices by their `name` field from the snapshot,
never by raw IDs.
Respond ONLY with JSON of this shape:
{"observations": [
{"title": "...", "description": "...", "urgency": "critical"|"warning"|"info",
"recommended_action": "...",
"device_id": "..." (optional — copy the exact device_id from the snapshot for any device you reference)}
]}
Aim for 05 observations. Quality over quantity.
"""
@max_observations 5
@spec build(map()) :: [%{role: String.t(), content: String.t()}]
def build(snapshot) when is_map(snapshot) do
payload = Jason.encode!(snapshot, pretty: true)
[
%{role: "system", content: @system},
%{role: "user", content: "Network snapshot (JSON):\n" <> payload <> "\n\nRespond with JSON."}
]
end
@spec parse(String.t()) :: {:ok, [map()]}
def parse(content) when is_binary(content) do
cleaned = content |> String.trim() |> strip_code_fences()
observations =
case decode(cleaned) do
{:ok, %{"observations" => list}} when is_list(list) ->
raw_count = length(list)
normalized = list |> Enum.map(&normalize_observation/1) |> Enum.reject(&is_nil/1)
rejected = raw_count - length(normalized)
if rejected > 0 do
Logger.warning("AI network insight: #{rejected}/#{raw_count} observations rejected by normalize_observation")
end
Enum.take(normalized, @max_observations)
_ ->
Logger.warning(
"AI network insight: could not decode LLM response, raw_bytes=#{byte_size(content)} cleaned_bytes=#{byte_size(cleaned)}, raw preview: #{String.slice(String.trim(content), 0, 300)}"
)
[]
end
{:ok, observations}
end
defp decode(text) do
case Jason.decode(text) do
{:ok, _} = ok ->
ok
_ ->
case extract_braced(text) do
nil -> :error
json -> Jason.decode(json)
end
end
end
defp normalize_observation(%{"title" => title, "description" => desc} = obs)
when is_binary(title) and is_binary(desc) and title != "" and desc != "" do
urgency = clamp_urgency(Map.get(obs, "urgency"))
metadata = Map.drop(obs, ["title", "description", "urgency", "recommended_action"])
%{
title: title,
description: desc,
urgency: urgency,
recommended_action: optional_string(Map.get(obs, "recommended_action")),
metadata: metadata
}
end
defp normalize_observation(_), do: nil
defp clamp_urgency(u) when u in @valid_urgencies, do: u
defp clamp_urgency(_), do: "info"
defp optional_string(s) when is_binary(s) and s != "", do: s
defp optional_string(_), do: nil
defp strip_code_fences(text) do
text
|> String.replace(~r/^```(?:json)?\s*/m, "")
|> String.replace(~r/```\s*$/m, "")
|> String.trim()
end
defp extract_braced(text) do
case String.split(text, "{", parts: 2) do
[_, rest] -> walk_braced("{" <> rest, 0, [])
_ -> nil
end
end
defp walk_braced("", _depth, _acc), do: nil
defp walk_braced(<<"\\", c, rest::binary>>, depth, acc), do: walk_braced(rest, depth, [c, "\\" | acc])
defp walk_braced(<<"{", rest::binary>>, depth, acc), do: walk_braced(rest, depth + 1, ["{" | acc])
defp walk_braced(<<"}", rest::binary>>, depth, acc) do
new_acc = ["}" | acc]
if depth == 1 do
new_acc |> Enum.reverse() |> IO.iodata_to_binary()
else
walk_braced(rest, depth - 1, new_acc)
end
end
defp walk_braced(<<c, rest::binary>>, depth, acc), do: walk_braced(rest, depth, [c | acc])
end