Skip to content

VahterBanBot: honest LLM prompt for empty-text media, sender bio, LLM-kill attribution - #393

Merged
Szer merged 1 commit into
mainfrom
fix/llm-triage-empty-text-media-placeholder
Aug 19, 2026
Merged

VahterBanBot: honest LLM prompt for empty-text media, sender bio, LLM-kill attribution#393
Szer merged 1 commit into
mainfrom
fix/llm-triage-empty-text-media-placeholder

Conversation

@Szer

@Szer Szer commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Incident

2026-08-18: VahterBanBot auto-deleted an innocent static cat sticker in @AvaloniaRU (msg 217142, user @zefirixx). The sticker had no caption, so Azure OCR found no text and msg.Text stayed null. ML scored the null text 0.31478 — inside the warning band — so it escalated to LLM triage. The LLM prompt rendered Message: with an empty body (null interpolates to ""), so gpt-4o-mini judged a blank message on username/display-name alone and said SPAM. The verdict was recorded as BotAutoDeleted { reason = MlSpam, score = 0.31478 } — mislabeled, since the LLM decided, not the ML threshold.

Per the owner's constraint, the empty-text escalation itself is not disabled or threshold-gated — real spammers post content-less stickers/photos with the spam in their name or bio. The fix gives the LLM honest context instead.

Changes

  1. Explicit media placeholder + prompt instruction (LlmTriage.fs) — when the LLM-triage message has no readable text but carries media, the prompt's Message: body renders a descriptive placeholder ([sticker "🐈‍⬛️" from set "catssenseoflife", no readable text], [photo, no readable text], degrading gracefully when sticker emoji/set_name are absent — the actual prod spam sticker had neither). A new system-prompt line tells the model a media-only, textless message is not itself a spam signal — judge sender signals (username/display name/bio) instead. The placeholder is rendered only in the LLM prompt string; msg.Text is never mutated.

  2. Sender bio in the LLM prompt (LlmTriage.fs) — IUserProfileFetcher.Fetch (already used by reaction triage, 7-day DB cache, never throws) is now also called at message-LLM-triage escalation time, adding a Bio: ... line ((none) when empty/fetch-failed) alongside username/display name.

  3. Deletion-reason attribution (Types.fs, Bot.fs) — AutoDeleteReason gains a distinct LlmSpam of {| score; modelName |} case, used when the kill came from LlmVerdict.Kill (Actor.LLM); MlSpam is kept for genuine ML-threshold deletions. Old stored events with reason.Case = "MlSpam" keep deserializing unchanged (compat test added).

The cache-key trap avoided

msg.Text feeds four things: the ML scorer, the spam-text cache, the LLM verdict-cache key, and the deleted-spam channel post text. SPAM/SKIP verdicts are cached globally by text hash — if the placeholder string had ever leaked into msg.Text (e.g. via AppendText), every sticker/photo with no OCR text would collapse onto the same cache key ("[photo, no readable text]"), and one SPAM verdict on a single photo would globally condemn every future photo. The placeholder therefore only exists inside the prompt-building closure; empty-text messages still take the pre-existing NoCache branch (now driven by a pulled-out hasStableTextCacheKey predicate, unit-tested to confirm placeholder-rendered messages still hit it).

Testing

  • dotnet test tests/VahterBanBot.Unit.Tests — 83 passed (new: LlmMediaPlaceholderTests.fs covering placeholder rendering per media type, no-mutation-of-msg.Text, hasStableTextCacheKey/NoCache, formatBioLine; SpamDeleteReasonTests.fs covering LLM-kill vs ML-threshold attribution).
  • dotnet test tests/VahterBanBot.Tests -c Release — 253 passed (local podman Testcontainers; includes new LlmSpam serialization round-trip + backward-compat + fold tests in EventSerializationTests.fs).
  • dotnet test tests/SerializationCompat.Tests — 51 passed (unaffected, run as a regression check per project scope).
  • dotnet build src/VahterBanBot/VahterBanBot.fsproj -c Release — clean, 0 warnings/errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KVLgQPBYJPa1H9cemwQwHf

…M-kill attribution

Fixes the 2026-08-18 @AvaloniaRU false positive (msg 217142): a caption-less cat sticker had no
OCR text, so the LLM triage prompt rendered `Message:` with an EMPTY body — gpt-4o-mini judged a
blank message SPAM on username/display-name alone.

- LlmTriage.fs: render a descriptive media placeholder (`[sticker "emoji" from set "name", no
  readable text]`, `[photo, no readable text]`, etc.) in the LLM prompt only, never into
  msg.Text — msg.Text still drives the ML scorer, spam-text cache, verdict-cache key, and the
  deleted-spam channel post, so the global-by-text-hash SPAM/SKIP cache can't collapse every
  photo onto one verdict. Empty-text messages still take the NoCache branch. Added a system-
  prompt instruction: media-only-with-no-text is not itself a spam signal — judge sender signals.
- LlmTriage.fs: add the sender's bio (IUserProfileFetcher, 7-day cache, never throws) to the LLM
  prompt at the point of actual escalation.
- Types.fs/Bot.fs: split AutoDeleteReason.MlSpam into MlSpam (ML-threshold verdict) and the new
  LlmSpam (LlmVerdict.Kill decided it) so deletion-reason attribution matches which layer
  actually pulled the trigger; old MlSpam-reason events keep deserializing unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVLgQPBYJPa1H9cemwQwHf
Szer added a commit that referenced this pull request Aug 19, 2026
…ence over bio + media placeholder

Stacks #384 (prompt-injection hardening) on top of #393 (empty-text media placeholder + sender
bio). Resolves the classifyUncached conflict in LlmTriage.fs so the <untrusted-{nonce}> fence
now covers ALL untrusted fields: username, display name, Bio line, and the message body (real
text OR the media placeholder). The placeholder is itself attacker-controlled — a sticker's
set_name/emoji are public pack metadata a spammer can set to anything, e.g. an injection payload
— so it belongs inside the fence exactly like real message text, never as trusted bot-computed
data. Bio is user-authored free text, capped at 1000 chars with "[truncated]" (belt-and-braces;
Telegram itself caps bios at ~140 chars) using the same truncation treatment as message text.
Trusted/bot-computed metadata (message count) stays outside the fence, unchanged.

Updated the static system prompt's untrusted-fields enumeration to name bio and the
attacker-controlled media placeholder, keeping #393's media-only-is-not-a-spam-signal
instruction intact (prompt hash churn is expected/observability-only).

Added two LlmTriageTests.fs regression tests: bio renders inside the fence (and the trusted
message-count line stays outside it), and a text-less sticker's media placeholder renders
inside the fence when the message reaches LLM triage (primed via 5 harmless messages so the
sender's ML feature vector lands the null-text score in the warning band, per an offline
ml-model.bin probe — a brand-new sender's null-text score is ham-range and never reaches LLM
triage at all).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVLgQPBYJPa1H9cemwQwHf
@Szer
Szer merged commit e4752a9 into main Aug 19, 2026
4 checks passed
@Szer
Szer deleted the fix/llm-triage-empty-text-media-placeholder branch August 19, 2026 08:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant