feat(insights): add 'AI' source filter for LLM-enriched insights

Adds an "ai" pill to the source-filter strip on /insights and a small
blue "AI" badge on every card whose `llm_enriched_at` is set. Selecting
the pill returns every LLM-enriched insight regardless of underlying
source (preseem / snmp / gaiia / system).

`source: "ai"` is a virtual filter — `list_insights/2` translates it to
`WHERE llm_enriched_at IS NOT NULL` rather than equality on the source
column, so existing real source semantics stay intact.
This commit is contained in:
Graham McIntire 2026-05-10 12:11:45 -05:00
parent c9202744a5
commit f6cd35d2d3
3 changed files with 67 additions and 2 deletions

View file

@ -29,7 +29,16 @@ defmodule Towerops.Preseem.Insights do
query = if type, do: where(query, type: ^type), else: query
query = if urgency, do: where(query, urgency: ^urgency), else: query
query = if source, do: where(query, source: ^source), else: query
# `source: "ai"` is a virtual filter — surfaces every insight that has
# been LLM-enriched, regardless of which underlying integration produced
# it. Real source values (preseem/snmp/gaiia/system) match the column.
query =
case source do
nil -> query
"ai" -> where(query, [i], not is_nil(i.llm_enriched_at))
s -> where(query, source: ^s)
end
query
|> preload([:preseem_access_point, :device])

View file

@ -69,7 +69,7 @@
{t("All")}
</.link>
<.link
:for={source <- ~w(preseem gaiia snmp system)}
:for={source <- ~w(preseem gaiia snmp system ai)}
id={"filter-source-#{source}"}
patch={
~p"/insights?#{build_filter_params(%{status: @filter_status, urgency: @filter_urgency}, %{source: source})}"
@ -258,6 +258,13 @@
{insight.source}
</span>
<%!-- AI badge — present on any LLM-enriched insight --%>
<%= if insight.llm_enriched_at do %>
<span class="inline-flex items-center rounded-full bg-blue-600 px-2 py-0.5 text-xs font-semibold text-white dark:bg-blue-500">
AI
</span>
<% end %>
<%!-- Type badge --%>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{String.replace(insight.type, "_", " ")}

View file

@ -354,6 +354,55 @@ defmodule Towerops.Preseem.InsightsTest do
assert hd(snmp_insights).title == "SNMP insight"
end
test "source: \"ai\" returns LLM-enriched insights regardless of underlying source",
%{org: org, access_point: ap} do
# Two unenriched insights from two different sources
{:ok, _preseem_unenriched} =
insert_insight(org, ap, %{
type: "qoe_degradation",
urgency: "warning",
title: "Preseem (no LLM)"
})
{:ok, _snmp_unenriched} =
insert_insight_with_source(org, %{
type: "snmp_cpu_high",
urgency: "warning",
title: "SNMP (no LLM)",
source: "snmp"
})
# Two enriched insights from two different sources — these should match.
{:ok, _preseem_enriched} =
insert_insight(org, ap, %{
type: "qoe_degradation",
urgency: "warning",
title: "Preseem (enriched)",
llm_summary: "summary",
llm_model: "deepseek-v4-pro",
llm_enriched_at: Towerops.Time.now()
})
{:ok, _snmp_enriched} =
insert_insight_with_source(org, %{
type: "snmp_cpu_high",
urgency: "warning",
title: "SNMP (enriched)",
source: "snmp",
llm_summary: "summary",
llm_model: "deepseek-v4-pro",
llm_enriched_at: Towerops.Time.now()
})
ai_insights = Insights.list_insights(org.id, source: "ai")
titles = Enum.map(ai_insights, & &1.title)
assert "Preseem (enriched)" in titles
assert "SNMP (enriched)" in titles
refute "Preseem (no LLM)" in titles
refute "SNMP (no LLM)" in titles
end
test "returns all sources when no source filter", %{org: org, access_point: ap} do
insert_insight(org, ap, %{type: "qoe_degradation", urgency: "warning", title: "Preseem"})