Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions bridge/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "?"
Comment on lines +895 to +899
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")
Expand Down
11 changes: 11 additions & 0 deletions bridge/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,17 @@
</div>
</section>

<section>
<div id="safety-card-body" class="card bg-base-100 shadow-sm overflow-hidden"
hx-get="/ui/safety/recent"
hx-trigger="load, every 30s, dotty-refresh from:window"
hx-swap="innerHTML" hx-target="this">
<div class="card-body p-3 animate-pulse">
<div class="h-4 bg-base-300 rounded w-1/4"></div>
</div>
</div>
</section>

<section class="dotty-feed-section">
<div class="card bg-base-100 shadow-sm overflow-hidden">
<div class="card-body p-0">
Expand Down
20 changes: 20 additions & 0 deletions bridge/templates/safety_recent.html
Original file line number Diff line number Diff line change
@@ -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. #}
<div class="card-body p-3">
<div class="text-xs text-base-content/60 mb-2 px-1">Content filter — recent hits</div>
{% if rows %}
<ul class="divide-y divide-base-300">
{% for r in rows %}
<li class="py-2 flex items-baseline gap-2">
<span class="text-xs text-base-content/50 font-mono">{{ r.time }}</span>
<span class="badge badge-xs {% if r.tier == 'alert' %}badge-error{% elif r.tier == 'log' %}badge-warning{% else %}badge-ghost{% endif %}">{{ r.tier }}</span>
<span class="text-sm font-mono break-all">{{ r.rule }}</span>
<span class="text-xs text-base-content/40 ml-auto font-mono whitespace-nowrap">{{ r.prefix }}…</span>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-sm text-base-content/50 text-center py-4">No filter activity.</div>
{% endif %}
</div>
23 changes: 23 additions & 0 deletions bridge/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down
Loading