- {rem.title}
+ {interpolateRuleTemplate(rem.title, primaryResult.matchedConditions)}
@@ -581,7 +588,7 @@ export default function DiagnosisPage() {
{expandedResult === result.ruleId && (
- {formatInlineMarkdown(result.explanation)}
+ {formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))}
{result.remediation?.length > 0 && (
@@ -605,11 +612,11 @@ export default function DiagnosisPage() {
{result.remediation.map((rem, i) => (
- {rem.title}
+ {interpolateRuleTemplate(rem.title, result.matchedConditions)}
{rem.steps.map((step, j) => (
- - {step}
+ - {interpolateRuleTemplate(step, result.matchedConditions)}
))}
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx
index c2c27704..30001afe 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { RuleResult } from "@/types";
import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown";
+import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { useAuth } from "@/contexts/AuthContext";
@@ -230,7 +231,9 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?:
{result.explanation && (
Explanation
-
{formatInlineMarkdown(result.explanation)}
+
+ {formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))}
+
)}
@@ -239,10 +242,12 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?:
Remediation
{result.remediation.map((rem, i) => (
-
{rem.title}
+
+ {interpolateRuleTemplate(rem.title, result.matchedConditions)}
+
{rem.steps.map((step, j) => (
- - {step}
+ - {interpolateRuleTemplate(step, result.matchedConditions)}
))}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts
new file mode 100644
index 00000000..ef96ca07
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect } from "vitest";
+import { interpolateRuleTemplate } from "../interpolateRuleTemplate";
+
+describe("interpolateRuleTemplate", () => {
+ const matched = {
+ ca2023_not_updated: {
+ eventId: "abc",
+ sequence: 23,
+ timestamp: "2026-04-28T18:24:16Z",
+ field: "uefiCA2023Status",
+ value: "unknown",
+ },
+ secureboot_enabled: {
+ eventId: "abc",
+ field: "uefiSecureBootEnabled",
+ value: "True",
+ },
+ };
+
+ it("replaces {{field}} tokens from matchedConditions", () => {
+ const out = interpolateRuleTemplate(
+ "Status: `{{uefiCA2023Status}}`",
+ matched
+ );
+ expect(out).toBe("Status: `unknown`");
+ });
+
+ it("replaces multiple distinct tokens in one string", () => {
+ const out = interpolateRuleTemplate(
+ "SB={{uefiSecureBootEnabled}} CA={{uefiCA2023Status}}",
+ matched
+ );
+ expect(out).toBe("SB=True CA=unknown");
+ });
+
+ it("falls back to signal-name lookup when field is unknown", () => {
+ const out = interpolateRuleTemplate("{{ca2023_not_updated}}", matched);
+ expect(out).toBe("unknown");
+ });
+
+ it("tolerates whitespace inside braces", () => {
+ const out = interpolateRuleTemplate("[{{ uefiCA2023Status }}]", matched);
+ expect(out).toBe("[unknown]");
+ });
+
+ it("leaves unknown tokens untouched so authors notice the typo", () => {
+ const out = interpolateRuleTemplate("{{notARealField}}", matched);
+ expect(out).toBe("{{notARealField}}");
+ });
+
+ it("returns input unchanged when matchedConditions is null/undefined", () => {
+ expect(interpolateRuleTemplate("hello {{x}}", null)).toBe("hello {{x}}");
+ expect(interpolateRuleTemplate("hello {{x}}", undefined)).toBe(
+ "hello {{x}}"
+ );
+ });
+
+ it("handles non-string scalar values (numbers, booleans)", () => {
+ const out = interpolateRuleTemplate("seq={{n}} ok={{b}}", {
+ n: { field: "n", value: 42 },
+ b: { field: "b", value: false },
+ });
+ expect(out).toBe("seq=42 ok=false");
+ });
+
+ it("returns empty string for null/undefined input", () => {
+ expect(interpolateRuleTemplate(null, matched)).toBe("");
+ expect(interpolateRuleTemplate(undefined, matched)).toBe("");
+ });
+
+ it("does not blow up on text with no placeholders", () => {
+ expect(interpolateRuleTemplate("plain text", matched)).toBe("plain text");
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts b/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts
new file mode 100644
index 00000000..05fa6914
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts
@@ -0,0 +1,54 @@
+/**
+ * Replaces `{{token}}` placeholders in a rule's explanation/remediation text
+ * with values pulled from the rule's `matchedConditions` evidence map.
+ *
+ * Resolution order for `{{token}}`:
+ * 1. The first matchedConditions entry whose `field` equals `token`
+ * (e.g. {{uefiCA2023Status}} -> entry where field === "uefiCA2023Status").
+ * 2. A matchedConditions entry whose key (signal name) equals `token`
+ * (e.g. {{ca2023_not_updated}} -> that signal's value).
+ * 3. Unresolved tokens are left untouched so authors notice the typo.
+ *
+ * Token chars: [a-zA-Z0-9_]. Whitespace inside the braces is tolerated.
+ */
+export function interpolateRuleTemplate(
+ text: string | null | undefined,
+ matchedConditions: Record
| null | undefined
+): string {
+ if (!text) return text ?? "";
+ if (!matchedConditions || typeof matchedConditions !== "object") return text;
+
+ const byField = new Map();
+ const bySignal = new Map();
+
+ for (const [signal, evidence] of Object.entries(matchedConditions)) {
+ if (!evidence || typeof evidence !== "object") {
+ if (evidence != null) bySignal.set(signal, formatValue(evidence));
+ continue;
+ }
+ const e = evidence as Record;
+ if (typeof e.field === "string" && "value" in e) {
+ byField.set(e.field, formatValue(e.value));
+ }
+ if ("value" in e) {
+ bySignal.set(signal, formatValue(e.value));
+ }
+ }
+
+ return text.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (raw, token: string) => {
+ if (byField.has(token)) return byField.get(token)!;
+ if (bySignal.has(token)) return bySignal.get(token)!;
+ return raw;
+ });
+}
+
+function formatValue(v: unknown): string {
+ if (v == null) return "";
+ if (typeof v === "string") return v;
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
+ try {
+ return JSON.stringify(v);
+ } catch {
+ return String(v);
+ }
+}