VahterBanBot: honest LLM prompt for empty-text media, sender bio, LLM-kill attribution - #393
Merged
Merged
Conversation
…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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01KVLgQPBYJPa1H9cemwQwHf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Textstayed null. ML scored the null text 0.31478 — inside the warning band — so it escalated to LLM triage. The LLM prompt renderedMessage: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 asBotAutoDeleted { 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
Explicit media placeholder + prompt instruction (
LlmTriage.fs) — when the LLM-triage message has no readable text but carries media, the prompt'sMessage: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.Textis never mutated.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 aBio: ...line ((none)when empty/fetch-failed) alongside username/display name.Deletion-reason attribution (
Types.fs,Bot.fs) —AutoDeleteReasongains a distinctLlmSpam of {| score; modelName |}case, used when the kill came fromLlmVerdict.Kill(Actor.LLM);MlSpamis kept for genuine ML-threshold deletions. Old stored events withreason.Case = "MlSpam"keep deserializing unchanged (compat test added).The cache-key trap avoided
msg.Textfeeds 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 intomsg.Text(e.g. viaAppendText), 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-existingNoCachebranch (now driven by a pulled-outhasStableTextCacheKeypredicate, unit-tested to confirm placeholder-rendered messages still hit it).Testing
dotnet test tests/VahterBanBot.Unit.Tests— 83 passed (new:LlmMediaPlaceholderTests.fscovering placeholder rendering per media type, no-mutation-of-msg.Text,hasStableTextCacheKey/NoCache,formatBioLine;SpamDeleteReasonTests.fscovering LLM-kill vs ML-threshold attribution).dotnet test tests/VahterBanBot.Tests -c Release— 253 passed (local podman Testcontainers; includes newLlmSpamserialization round-trip + backward-compat + fold tests inEventSerializationTests.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