diff --git a/bridge/dashboard.py b/bridge/dashboard.py index beb85b3..9dcf781 100644 --- a/bridge/dashboard.py +++ b/bridge/dashboard.py @@ -885,6 +885,29 @@ async def discord_partial(request: Request) -> Any: return templates.TemplateResponse(request, "discord.html", ctx) +@router.get("/safety/recent", response_class=HTMLResponse, include_in_schema=False) +async def safety_recent(request: Request) -> Any: + """#72 — recent content-filter hits from the in-memory ring (last 20). + In-memory only; empties on a bridge restart.""" + from bridge.text import recent_content_filter_hits + rows: list[dict[str, Any]] = [] + for hit in recent_content_filter_hits(): + ts = hit.get("ts") or 0 + try: + time_str = datetime.fromtimestamp(ts).astimezone().strftime("%H:%M:%S") + except Exception: + time_str = "?" + rows.append({ + "time": time_str, + "tier": hit.get("tier") or "?", + "rule": hit.get("rule") or "", + "prefix": hit.get("prefix") or "", + }) + return templates.TemplateResponse( + request, "safety_recent.html", {"rows": rows}, + ) + + @router.post("/actions/state", response_class=HTMLResponse, include_in_schema=False) async def state_set(request: Request, state: str = Form(...)) -> Any: setter = _state.get("state_setter") diff --git a/bridge/templates/dashboard.html b/bridge/templates/dashboard.html index 8eecd7b..a43f56d 100644 --- a/bridge/templates/dashboard.html +++ b/bridge/templates/dashboard.html @@ -256,6 +256,17 @@ +
+
+
+
+
+
+
+
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()