- 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
167 lines
5.9 KiB
Elixir
167 lines
5.9 KiB
Elixir
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 0–5 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
|