fix(llm): drop model name from footer, harden JSON parser

UI: the AI-generated footer dropped " · deepseek-v4-pro" — model
identity is irrelevant to operators reviewing the recommendation.

Parser: stop letting raw JSON leak into the summary text when the LLM
returns an almost-but-not-quite-valid response.

- Strip ```json fences (already done) PLUS try a balanced-brace
  extraction so a "Here's the JSON: {…}" preamble doesn't break
  Jason.decode
- When all decode paths fail, regex-extract `"summary"` and `"action"`
  fields from the text rather than dumping the whole blob with braces
  intact
- Last-ditch sanitiser strips remaining brace/quote noise so the
  rendered summary never shows literal `"summary"` keys

Maintenance: `Towerops.Maintenance.RepairLlmSummaries.run()` clears
`llm_enriched_at` on already-stored rows whose summaries contain JSON
artifacts so the next worker cycle re-enriches them with the new
parser.

Test: insights_live_events_test now asserts the AI-generated label is
present *and* that the model name does not leak into the rendered HTML.
This commit is contained in:
Graham McIntire 2026-05-10 13:03:25 -05:00
parent f8c3b00ba0
commit dbd4183f37
5 changed files with 179 additions and 7 deletions

View file

@ -162,16 +162,101 @@ defmodule Towerops.LLM.InsightPrompt do
def parse(content) when is_binary(content) do def parse(content) when is_binary(content) do
cleaned = content |> String.trim() |> strip_code_fences() cleaned = content |> String.trim() |> strip_code_fences()
case Jason.decode(cleaned) do cleaned
|> try_decode()
|> case do
{:ok, %{"summary" => s} = m} when is_binary(s) -> {:ok, %{"summary" => s} = m} when is_binary(s) ->
action = m["action"] {:ok, %{summary: s, action: optional_string(m["action"])}}
{:ok, %{summary: s, action: if(is_binary(action), do: action)}}
_ -> _ ->
{:ok, %{summary: cleaned, action: nil}} # Fallback: extract `summary` and `action` via regex from the raw
# text rather than dumping the whole thing — that way a stray
# leading sentence or trailing prose doesn't drag JSON braces
# into the user-visible summary.
{:ok,
%{
summary: extract_field(cleaned, "summary") || strip_json_remnants(cleaned),
action: extract_field(cleaned, "action")
}}
end end
end end
# Try Jason.decode on the trimmed text, then on the first balanced
# `{...}` substring (handles "Here's the JSON: {…}" style preambles).
defp try_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
# Extract the first balanced top-level `{...}` in the text, depth-aware
# so a string containing `}` doesn't truncate it. Returns nil if none.
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])
# Pull just the value out of a `"summary": "..."` (or `"action": "..."`)
# pair. Stops at the first unescaped closing quote.
defp extract_field(text, key) do
pattern = ~r/"#{key}"\s*:\s*"((?:[^"\\]|\\.)*)"/
case Regex.run(pattern, text) do
[_, value] -> value |> unescape_json_string() |> String.trim() |> nil_if_blank()
_ -> nil
end
end
defp unescape_json_string(s) do
s
|> String.replace("\\\"", "\"")
|> String.replace("\\n", "\n")
|> String.replace("\\\\", "\\")
end
defp nil_if_blank(""), do: nil
defp nil_if_blank(s), do: s
defp optional_string(s) when is_binary(s) and s != "", do: s
defp optional_string(_), do: nil
# Last-ditch: strip braces, key labels, and surrounding quotes so a
# totally malformed JSON-ish blob still renders as readable prose.
defp strip_json_remnants(text) do
text
|> String.replace(~r/^[\s\{\[]+/, "")
|> String.replace(~r/[\s\}\]]+$/, "")
|> String.replace(~r/"(summary|action)"\s*:\s*"?/i, "")
|> String.replace(~r/"\s*,\s*"action"\s*:\s*"/, "")
|> String.replace(~r/"\s*$/, "")
|> String.trim()
end
defp strip_code_fences(text) do defp strip_code_fences(text) do
text text
|> String.replace(~r/^```(?:json)?\s*/m, "") |> String.replace(~r/^```(?:json)?\s*/m, "")

View file

@ -0,0 +1,49 @@
defmodule Towerops.Maintenance.RepairLlmSummaries do
@moduledoc """
Resets `llm_enriched_at` (and clears the cached summary fields) on any
insight whose stored summary still contains JSON syntax that leaked
through an earlier enrichment run. The next
`InsightLlmEnrichmentWorker` cycle will re-enrich those rows with the
hardened parser.
Run from the prod release:
/app/bin/towerops eval 'Towerops.Maintenance.RepairLlmSummaries.run()'
"""
import Ecto.Query
alias Towerops.Preseem.Insight
alias Towerops.Repo
require Logger
@spec run() :: :ok
def run do
{count, _} =
Repo.update_all(
from(i in Insight,
where:
not is_nil(i.llm_enriched_at) and
(like(i.llm_summary, "%\"summary\"%") or like(i.llm_summary, "%```%") or like(i.llm_summary, "{%")),
update: [
set: [
llm_summary: nil,
recommended_action: nil,
llm_enriched_at: nil,
llm_prompt_tokens: nil,
llm_completion_tokens: nil
]
]
),
[]
)
msg =
"LLM summary repair: cleared #{count} rows so the next enrichment cycle reprocesses them."
IO.puts(msg)
Logger.info(msg)
:ok
end
end

View file

@ -292,7 +292,7 @@
</p> </p>
<% end %> <% end %>
<p class="mt-1 text-xs text-blue-700 dark:text-blue-300"> <p class="mt-1 text-xs text-blue-700 dark:text-blue-300">
AI-generated{if insight.llm_model, do: " · " <> insight.llm_model, else: ""} AI-generated
</p> </p>
</div> </div>
<% end %> <% end %>

View file

@ -92,5 +92,41 @@ defmodule Towerops.LLM.InsightPromptTest do
test "falls back when JSON has no summary key" do test "falls back when JSON has no summary key" do
assert {:ok, %{summary: _, action: nil}} = InsightPrompt.parse(~s|{"foo": "bar"}|) assert {:ok, %{summary: _, action: nil}} = InsightPrompt.parse(~s|{"foo": "bar"}|)
end end
test "extracts JSON embedded in surrounding prose (preamble)" do
content = ~s|Here is the response: {"summary": "Sector congested.", "action": "Split."}|
assert {:ok, %{summary: "Sector congested.", action: "Split."}} = InsightPrompt.parse(content)
end
test "extracts JSON when wrapped in extra commentary on both sides" do
content = """
Sure, based on the data:
{"summary": "Backhaul saturated at 95%.", "action": "Procure capacity now."}
Hope this helps.
"""
assert {:ok, %{summary: "Backhaul saturated at 95%.", action: "Procure capacity now."}} =
InsightPrompt.parse(content)
end
test "regex-extracts summary/action when JSON parse fails on a trailing comma" do
# LLM returned an almost-JSON blob with a trailing comma (Jason
# rejects, but the field-level regex still picks up the values).
content = ~s|{"summary": "Sector congested at 95%.", "action": "Split sector.",}|
{:ok, parsed} = InsightPrompt.parse(content)
assert parsed.summary == "Sector congested at 95%."
assert parsed.action == "Split sector."
end
test "stripped JSON remnants never leak '\"summary\"' into the rendered text" do
# The kind of garbage that was leaking into the UI before — JSON
# that's almost-but-not-quite-valid. The fallback should sanitize.
garbage = ~s|{"summary": "AP X is hot|
{:ok, parsed} = InsightPrompt.parse(garbage)
refute parsed.summary =~ ~s|"summary"|
refute parsed.summary =~ ~s|{|
end
end end
end end

View file

@ -101,7 +101,7 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
end end
describe "LLM enrichment rendering" do describe "LLM enrichment rendering" do
test "shows llm_summary, recommended_action, and model when present", %{ test "shows llm_summary, recommended_action, and AI-generated label when present", %{
conn: conn, conn: conn,
organization: organization organization: organization
} do } do
@ -126,7 +126,9 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
assert html =~ "AP has 3 clients pinned to MCS0; downstream is congested." assert html =~ "AP has 3 clients pinned to MCS0; downstream is congested."
assert html =~ "Re-aim CPE at -82 dBm or move it to AP-7." assert html =~ "Re-aim CPE at -82 dBm or move it to AP-7."
assert html =~ "deepseek-v4-pro" assert html =~ "AI-generated"
# Model identity is intentionally not exposed in the UI.
refute html =~ "deepseek-v4-pro"
end end
test "does not render the summary block when llm_summary is nil", %{conn: conn} do test "does not render the summary block when llm_summary is nil", %{conn: conn} do