diff --git a/bridge/templates/safety_recent.html b/bridge/templates/safety_recent.html
new file mode 100644
index 0000000..1848b1a
--- /dev/null
+++ b/bridge/templates/safety_recent.html
@@ -0,0 +1,20 @@
+{# #72 — recent content-filter hits. Rendered into #safety-card-body
+ (innerHTML swap) — no own `.card` chrome. Backed by an in-memory ring
+ (last 20); empty after a bridge restart. #}
+
+
Content filter — recent hits
+ {% if rows %}
+
+ {% for r in rows %}
+ -
+ {{ r.time }}
+ {{ r.tier }}
+ {{ r.rule }}
+ {{ r.prefix }}…
+
+ {% endfor %}
+
+ {% else %}
+
No filter activity.
+ {% endif %}
+
diff --git a/bridge/text.py b/bridge/text.py
index c343925..82adcb1 100644
--- a/bridge/text.py
+++ b/bridge/text.py
@@ -15,6 +15,8 @@
import os
import re
import sys
+import time
+from collections import deque
from pathlib import Path
# Defensive sibling-import shim so this module is standalone-importable
@@ -122,6 +124,21 @@ def truncate_sentences(text: str, max_sentences: int = MAX_SENTENCES) -> str:
(_CF_TIER_REDIRECT_RE, "redirect", logging.WARNING),
]
+# #72 — in-memory ring of recent content-filter hits, surfaced at
+# /ui/safety/recent. In-memory ONLY: the ring is lost on restart and is
+# never written to disk. The matched term recorded here is no more
+# exposed than the `content-filter-hit` log line content_filter() already
+# emits.
+_CF_RECENT_MAX = 20
+_cf_recent: "deque[dict]" = deque(maxlen=_CF_RECENT_MAX)
+
+
+def recent_content_filter_hits() -> list[dict]:
+ """Recent content-filter hits, newest first — the /ui/safety/recent
+ dashboard source (#72). Each entry has: ts, tier, rule (the matched
+ term), prefix (first 8 chars of the filtered text)."""
+ return list(reversed(_cf_recent))
+
def content_filter(text: str) -> str | None:
"""Return a safe replacement if blocked content is found, else None.
@@ -139,6 +156,12 @@ def content_filter(text: str) -> str | None:
"content-filter-hit tier=%s pattern=%r pos=%d len=%d",
tier, match.group(), match.start(), len(text),
)
+ _cf_recent.append({
+ "ts": time.time(),
+ "tier": tier,
+ "rule": match.group(),
+ "prefix": text[:8],
+ })
if dotty_content_filter_hits_total is not None:
try:
dotty_content_filter_hits_total.labels(tier=tier).inc()