Skip to content

Live chat filtering: insults, spoilers, and spam blocked in one pass ​

💡 One-sentence summary: use Jev to build an AI live-stream danmaku filtering pipeline — one request per danmaku with three questions, 8 concurrent threads, and pure-code rules deciding what viewers see.

You're watching a live stream, and the danmaku scroll faster than you can read. The official Twitch chat filter cookbook approach: as each message arrives, send it individually to Jev in one request, along with the stream title and game name, asking three questions — what kind of message this is, whether it's insulting, and whether it's a spoiler; then a few plain rules turn the answers into what viewers see: questions highlighted, insults, backseat advice, and ads hidden, spoilers masked.

Keyword lists can catch spam links, but "go left, there's a site of grace behind the tree" contains no bad words, nor does a plot twist — this is exactly the dividing line between AI live-stream danmaku filtering and blacklists: the judgment lives in semantics, not on the surface. The officials tested it with 29 danmaku from an Elden Ring stream: 7 hidden, 2 masked, 6 questions highlighted, with a median request time of about 0.1 seconds. This lesson walks you through the whole setup in about 15 minutes, and you can reproduce all the official numbers without setting an API Key — Step 1 gives you the official cache file.

What you'll be able to do after this lesson ​

  • Build an AI live-stream danmaku filtering pipeline: one request with three questions per danmaku, ThreadPoolExecutor 8 concurrent threads running through the entire chat
  • Understand the division of labor among the three questions: category Choice sets the default disposition, toxic / spoiler the two Noul questions handle the outright veto and masking
  • Use decide() to turn answers into actions: changing rules only changes code, the 29 answers are reused as-is, 0 new requests
  • Reproduce the official numbers without spending a cent: download the official json_cache.json cache and replay it; calibrate thresholds before swapping in your own danmaku

Why use Jev instead of a keyword blacklist? ​

Blacklists are the status quo, and a plain LLM is the other extreme. The official cookbook points out the limits of blacklists right at the start: keywords can catch spam links, but not spoilers and backseat advice that contain no bad words. Put the three approaches side by side:

CapabilityKeyword blacklistPlain LLMJev
Spoilers/backseat advice with no dirty words❌ "There's a site of grace behind the tree" contains no banned words, passes unnoticed✅ Can read it, but the answer is a chunk of text that needs further parsing✅ Official test: backseat advice 1.00, spoiler 0.95, probabilities go straight into code
The line between jokes and insults❌ "you're so bad at this boss" contains bad, so friendly banter gets cut too⚠️ Can state the difference, but "how sure" requires follow-up✅ Joke-style taunt toxic 0.28, real insult 0.97, a clean break
Peak-hour throughput✅ Fast matching❌ Generates a reply per message; danmaku won't wait for it✅ Official test: median request about 0.1 seconds, 8 concurrent threads
Changing rules✅ Edit the list and it takes effect❌ Rules live in the prompt; any change means a full re-run✅ Rules live in decide(), the 29 answers are reused, 0 new requests
Answer format✅ Boolean❌ Free text or JSON, parsing can fail✅ Typed answers + probability distributions, no parsing

To be fair: keep the blacklist — for catching spam links it's fast and cheap, and Jev covers the half it can't see. The two don't conflict.

Your current predicament ​

  • "Three hours into the stream, insults, spoilers, and spam blur together in the danmaku; manual screening makes your eyes blur too, and chat moderation is always half a beat behind"
  • "The keyword blacklist only matches the surface: 'Go left, there's a site of grace behind the tree' has no banned words, yet it still spoils the first-play experience completely"
  • "I want to use an LLM for AI comment filtering, but each danmaku waits several seconds for a reply and it piles up at peak hours; when I ask it to return only labels, the same sentence gets different answers twice"

When to use this technique ​

When you need:

  • A verdict immediately after each danmaku arrives, with no stalls even at peak hours
  • Blocking more than dirty words: spoilers, unsolicited game backseat advice, and ads, all judged semantically
  • Rules changeable at any time — hide ads today, hide even cheers on match day, without spending again on re-requests when rules change

And you don't want to:

  • Maintain a keyword list that can never keep up with variants
  • Parse the JSON an LLM spits out for every danmaku
  • Spoiler judgments detached from game context — the same sentence means something completely different in different games

🎒 Preparation before starting ​

  • completed Choice multiple-choice questions: can write a criteria options map, and can read choice and probabilities
  • Finished Noul true/false questions: knowing that noul is a 0–1 probability, with starting thresholds yes ≥ 0.7 / no ≤ 0.3
  • Went through installation and the first request: .venv is usable (this page supports cache replay, so it's fine even without a Key for now — see step 1)
  • A cup of water ☕ — this lesson is a complete mini-project: 29 chat messages + three questions + a working set of filtering code

Follow along with me ​

First, let's agree on how to run it: create twitch_chat_filter.py, and paste the code block from each step below in order into the same file; run python twitch_chat_filter.py once after completing each step. Answers are cached, so repeated runs don't cost extra.

Step 1: Set up the environment, download the official cache, and prepare 29 sample chat messages ​

Why: The officials wrote a full set of highly realistic chat messages for this case — an Elden Ring livestream, 29 messages, with each message type appearing at least twice (including two pure-emoji ones). We use it directly to validate the entire filtering logic without waiting for a real stream. The officials also published an answer cache for these 29 messages, json_cache.json — placed in the right location, it lets you reproduce all the official numbers without calling the API.

Install the dependency. cooksafe is the companion helper library for the official cookbook (PyPI description: "Shared helpers for the TypeSafe cookbooks"); this page uses its JsonCache cache and make_playground_link share links:

bash
pip install 'typesafe-sdk>=0.7.1,<0.8.0' ipython 'cooksafe>=0.2.0,<0.3.0'

Download the official cache (raw direct link, verified working. Skipping it is also fine — if TYPESAFE_API_KEY is set, these are real requests; see costs in models and pricing):

bash
curl -sL -o json_cache.json https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks/twitch_chat_filter/json_cache.json

Create twitch_chat_filter.py and paste in the first code block — the client, cache, and sample data. Note the default value "cache-only" for api_key: without a Key, cache replay still works:

python
from concurrent.futures import ThreadPoolExecutor
import os
from pathlib import Path
from time import perf_counter

from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient

TYPESAFE_MODEL = "jev-1.14.0"  # 官方数字的采样版本(2026-10-29)

client = TypeSafeClient(
    api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"),  # cached re-renders need no key
    base_url=os.environ.get("TYPESAFE_ENDPOINT"),
    timeout=60.0,
)
CACHE_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()
json_cache = JsonCache(CACHE_DIR / "json_cache.json")

STREAM = {"title": "Elden Ring first playthrough! No spoilers please", "game": "Elden Ring"}

CHAT = [
    ("night-owl-22", "hi chat, first time catching a stream live"),
    ("ggwp-andy", "PogChamp PogChamp PogChamp"),
    ("mira-plays", "how many hours have you played so far?"),
    ("tarnished-tom", "this area looks so good at night"),
    ("backseat-bert", "go left, there's a site of grace behind the tree"),
    ("follow4follow-bot", "Want more viewers? Buy 1000 followers for $5 at cheapviewz dot example"),
    ("lore-hound", "wait till you find out Ranni planned the Night of Black Knives"),
    ("kappa-kev", "LUL LUL LUL"),
    ("sunny-skies", "are you going to do a no-hit run after this?"),
    ("rage-ryan", "you're genuinely the worst player i've ever seen, uninstall trash"),
    ("cozy-cat", "the music in this area is so good"),
    ("backseat-bert", "use your flask NOW, why are you not healing"),
    ("mira-plays", "W streamer"),
    ("ggwp-andy", "GG that was insane"),
    ("quiet-quinn", "anyone else watching from Brazil?"),
    ("tarnished-tom", "what build are you going for?"),
    ("lore-hound", "the ending where you become Elden Lord with Ranni is the best one"),
    ("kappa-kev", "chat is wild today lol"),
    ("backseat-bert", "the boss is weak to fire, equip the torch before you go in"),
    ("rage-ryan", "nobody cares about your opinion, go back to your basement"),
    ("cozy-cat", "you're so bad at this boss lmao, love you tho"),
    ("sunny-skies", "who is the final boss?"),
    ("ggwp-andy", "LETS GOOOO"),
    ("night-owl-22", "i just got this game too, it's so hard"),
    ("promo-pete", "check out my channel, I stream every day, follow me!"),
    ("quiet-quinn", "dodge rolling through that was clean"),
    ("mira-plays", "is the DLC worth it?"),
    ("cozy-cat", "your character looks so cool"),
    ("night-owl-22", "this boss took me 40 tries lol"),
]

print(f"{len(CHAT)} messages. What one request sends as its state:")
print({"stream": STREAM, "message": {"user": CHAT[4][0], "text": CHAT[4][1]}})

Run python twitch_chat_filter.py.

You should see:

text
29 messages. What one request sends as its state:
{'stream': {'title': 'Elden Ring first playthrough! No spoilers please', 'game': 'Elden Ring'}, 'message': {'user': 'backseat-bert', 'text': "go left, there's a site of grace behind the tree"}}

Note two design points first. The stream title and game name go into every request: whether a message counts as backseat advice or a spoiler depends on which game is being played, so they are part of the state. One request per message: messages are independent of each other, which paves the way for the concurrency in step 5.

  • I got those two lines with 29 messages, and json_cache.json is right next to the script

Step 2: Write the three questions: one Choice classification plus two Noul ​

Why: Each chat message needs three judgments — what it is (determines the default handling), whether it's abusive (an instant veto), and whether it's a spoiler (gets masked). The three questions are sent in one request against the same state; Jev evaluates them in parallel and independently, so adding questions barely increases response time.

Next, paste in the question definitions:

python
QUESTIONS = {
    "category": Choice(
        instructions="Which category best describes the main purpose of `message.text`?",
        criteria={
            "question": "A genuine request for information from the streamer or chat, "
            "not gameplay instructions phrased as a question or a promotional pitch",
            "hype": "Cheering, reactions, or repeated emotes (PogChamp, LUL, GG, W, "
            "lets gooo); repetition alone does not make cheering spam",
            "conversation": "Ordinary chatter, opinions, replies, useful shared links, "
            "or any message whose main purpose does not fit the other categories",
            "backseat": "Unrequested gameplay advice telling the streamer what to do, "
            "including instructions phrased as a question; not a request for information",
            "spam": "Unsolicited advertising, self-promotion, follower selling, or "
            "promotional links; not ordinary cheering, repeated emotes, or a useful link",
        },
    ),
    "toxic": Noul(
        instructions="Is this message insulting, hateful or harassing towards someone?",
        criteria=NoulCriteria(
            true="Insults, slurs, threats or personal attacks on the streamer or another viewer",
            false="Friendly, neutral, or light banter with no target",
        ),
    ),
    "spoiler": Noul(
        instructions="Does this message reveal a plot point or ending of the game being played?"
    ),
}

The boundaries of the five categories are the soul of this rubric; the official text draws three lines that are easiest to blur:

  • question ≠ instruction: only genuinely seeking information counts; game advice disguised as a question does not
  • hype ≠ spam: emoji spamming and cheering are hype — in the officials' words, "repetition alone does not make cheering spam"; repetition by itself doesn't equal junk
  • spam ≠ useful links: only unsolicited ads, follower-selling, and promo links count as spam — in the officials' words, "A link alone is not spam"
  • conversation is the fallback: messages that fit none of the other categories land here

The NoulCriteria of toxic rules jokes out: true means insult, discrimination, threats, personal attacks; false means "friendly, neutral, light joking with no target" — in step 3 you'll see this boundary save a message that reads like abuse. spoiler only has instructions; the officials didn't define criteria for it.

You should see: the run is still the same two lines from step 1 — the question definitions are just data; no requests have been sent yet. Make sure the QUESTIONS in your file matches the above character for character: the cache key is determined jointly by the state and the questions (details in step 3), and a single wrong character means a cache miss and a real request.

Step 3: Classify a single chat message: get three answers in one request ​

Why: First, validate how well the questions are written using the three most typical messages — one pure backseat advice, one plot spoiler, and one joke-style mockery. classify() assembles the state and sends the request; _ask() is decorated with @json_cache: answers are stored in json_cache.json under a key of "state + questions written as JSON", and reruns reuse them directly.

Next, paste in:

python
def classify(user: str, text: str, stream: dict | None = None) -> dict:
    state = {
        "stream": STREAM if stream is None else stream,
        "message": {"user": user, "text": text},
    }
    questions = {key: q.model_dump(mode="json", exclude_none=True) for key, q in QUESTIONS.items()}
    return _ask(state, questions)


@json_cache
def _ask(state: dict, questions: dict) -> dict:
    if not os.environ.get("TYPESAFE_API_KEY"):
        raise RuntimeError(
            "Set TYPESAFE_API_KEY for a live request, or put the cookbook's "
            "json_cache.json beside your script (in the working directory for a notebook)."
        )
    started = perf_counter()
    response = client.system_one(state=state, questions=questions, model=TYPESAFE_MODEL)
    category = response.answers["category"]
    return {
        "category": category.choice,
        "probabilities": dict(category.probabilities),
        "toxic": response.answers["toxic"].noul,
        "spoiler": response.answers["spoiler"].noul,
        "model": response.model,
        "seconds": round(perf_counter() - started, 2),
    }


def show(user: str, text: str) -> None:
    result = classify(user, text)
    print(f"{user}: {text}")
    for label, p in sorted(result["probabilities"].items(), key=lambda kv: -kv[1]):
        print(f"  {label:<13}{p:5.2f}  {'#' * round(p * 30)}".rstrip())
    print(f"  {'toxic':<13}{result['toxic']:5.2f}")
    print(f"  {'spoiler':<13}{result['spoiler']:5.2f}\n")


show("backseat-bert", "go left, there's a site of grace behind the tree")
show("lore-hound", "wait till you find out Ranni planned the Night of Black Knives")
show("cozy-cat", "you're so bad at this boss lmao, love you tho")

Run.

You should see (numbers replayed from the official cache; with real requests, the values will differ slightly):

text
backseat-bert: go left, there's a site of grace behind the tree
  backseat      1.00  ##############################
  question      0.00
  hype          0.00
  conversation  0.00
  spam          0.00
  toxic         0.03
  spoiler       0.07

lore-hound: wait till you find out Ranni planned the Night of Black Knives
  conversation  0.67  ####################
  backseat      0.32  ##########
  hype          0.01
  question      0.00
  spam          0.00
  toxic         0.04
  spoiler       0.95

cozy-cat: you're so bad at this boss lmao, love you tho
  conversation  0.79  ########################
  hype          0.21  ######
  question      0.00
  backseat      0.00
  spam          0.00
  toxic         0.28
  spoiler       0.02

Three ways to read this:

  • Backseat advice nailed down: backseat-bert gets backseat 1.00, while toxic is only 0.03 — it's annoying but not abusive. Judging the two dimensions separately is exactly the value of splitting the questions
  • Spoilers caught by spoiler: lore-hound's message wavers between conversation 0.67 and backseat 0.32, but no matter how the categories fight it out, spoiler 0.95 directly triggers masking
  • A joke saved by the boundary: cozy-cat's message reads like abuse, but the closing "love you tho" falls into the "light joking" of the false criteria — toxic 0.28, far below the threshold, displayed as usual

💡 The cache key: editing a question always invalidates it

The cache key is the JSON of the state and the questions — editing any question changes the key, so the next run is a real request instead of replaying the old answer. The key doesn't include the model: to switch TYPESAFE_MODEL and run new requests, move the cache file aside and run again. No Key and a cache miss? _ask will raise a RuntimeError telling you: set a Key, or put the official json_cache.json back next to the script.

  • The output for the three examples matches the above (when using the official cache); I can point out which lines are category probabilities and which two lines are Noul

Step 4: Write decide(): three checks in order turn answers into actions ​

Why: The decision logic is pure code and sends no requests. The official order is deliberate: abuse first (an instant veto, regardless of category), then category rules, and spoilers last (masking; already-hidden messages aren't processed twice).

Next, paste in:

python
RULES = {
    "question": "highlight",
    "hype": "show",
    "conversation": "show",
    "backseat": "hide",
    "spam": "hide",
}
TOXIC_AT = 0.7  # hide at or above this chance of toxic
SPOILER_AT = 0.7  # blur at or above this chance of a spoiler


def decide(result: dict, rules: dict = RULES) -> str:
    if result["toxic"] >= TOXIC_AT:
        return "hide (toxic)"
    action = rules[result["category"]]
    if action == "hide":
        return f"hide ({result['category']})"
    if result["spoiler"] >= SPOILER_AT:
        return "blur (spoiler)"
    return action

Regarding the two thresholds 0.7, the officials make an important observation: in these 29 samples, the toxic probability is either ≥ 0.97 or ≤ 0.38, and spoiler is either ≥ 0.95 or ≤ 0.38 — the middle is empty. So on this sample set, any threshold between 0.38 and 0.95 gives the same result. But your own channel will most likely have values falling in the middle; in the officials' words: calibrate both thresholds against your own chat messages before going live.

You should see: no new output yet—decide() has to wait for the next batch of results before there's anything to judge. Walk through the three checks verbally first: toxic ≥ 0.7 → hide; category rules are hide → hide; spoiler ≥ 0.7 → blur; everything else follows the category rules (show / highlight / dim).

  • I can state the order of the three checks and why toxic comes first

Step 5: Run the AI live-chat danmaku filter concurrently and output the viewer's view ​

Why: danmaku waits for no one—the 29 messages are independent of each other, so a thread pool of 8 concurrent workers processes them in one go, and pool.map returns the answers in chat order. The first real run sends 26 new requests (3 messages were already cached in step 3); with the official cache, not a single request is sent.

Next, paste in:

python
with ThreadPoolExecutor(max_workers=8) as pool:  # 29 requests, one per message, 8 at once
    results = list(pool.map(lambda message: classify(*message), CHAT))

counts: dict[str, int] = {}
for (user, text), result in zip(CHAT, results):
    action = decide(result)
    counts[action] = counts.get(action, 0) + 1
    if action.startswith("hide"):
        line = f"   · hidden, {action[6:-1]}"
    elif action == "blur (spoiler)":
        line = f"   {user}: ░░░░░░░░ spoiler"
    else:
        mark = {"highlight": ">>", "dim": "..", "show": "  "}[action]
        line = f"{mark} {user}: {text}"
    print(f"{line[:78]:<80}{result['category']}")

print()
for action, n in sorted(counts.items(), key=lambda kv: -kv[1]):
    print(f"{action:<20}{n:>3}")

Run.

You should see (viewer's view: hidden ones show only the reason, blurred ones show only the author, highlighted questions carry a >>; the category answer is at the end of each line):

text
   night-owl-22: hi chat, first time catching a stream live                     conversation
   ggwp-andy: PogChamp PogChamp PogChamp                                        hype
>> mira-plays: how many hours have you played so far?                           question
   tarnished-tom: this area looks so good at night                              conversation
   · hidden, backseat                                                           backseat
   · hidden, spam                                                               spam
   lore-hound: ░░░░░░░░ spoiler                                                 conversation
   kappa-kev: LUL LUL LUL                                                       hype
>> sunny-skies: are you going to do a no-hit run after this?                    question
   · hidden, toxic                                                              conversation
   cozy-cat: the music in this area is so good                                  conversation
   · hidden, backseat                                                           backseat
   mira-plays: W streamer                                                       hype
   ggwp-andy: GG that was insane                                                hype
>> quiet-quinn: anyone else watching from Brazil?                               question
>> tarnished-tom: what build are you going for?                                 question
   lore-hound: ░░░░░░░░ spoiler                                                 conversation
   kappa-kev: chat is wild today lol                                            conversation
   · hidden, backseat                                                           backseat
   · hidden, toxic                                                              conversation
   cozy-cat: you're so bad at this boss lmao, love you tho                      conversation
>> sunny-skies: who is the final boss?                                          question
   ggwp-andy: LETS GOOOO                                                        hype
   night-owl-22: i just got this game too, it's so hard                         conversation
   · hidden, spam                                                               spam
   quiet-quinn: dodge rolling through that was clean                            hype
>> mira-plays: is the DLC worth it?                                             question
   cozy-cat: your character looks so cool                                       conversation
   night-owl-22: this boss took me 40 tries lol                                 conversation

show                 14
highlight             6
hide (backseat)       3
hide (spam)           2
blur (spoiler)        2
hide (toxic)          2

Reconciliation: 29 messages = shown 14 + highlighted 6 + hidden 7 (pointers 3, ads 2, abuse 2) + blurred 2, matching the official intro's "hides seven, blurs two, highlights six" exactly. Two details: rage-ryan's abuse category is actually conversation, but the toxic check comes before the category rules, so it gets caught anyway; lore-hound's two spoiler messages are also conversation, blurred by the third spoiler check—the order of the three if statements is the priority of the actions.

⚠️ Test Chinese danmaku before going live

Official note: when this cookbook was released, Jev was trained mostly on English—other languages work but with lower accuracy. For Chinese danmaku scenarios like Bilibili or Channels video comments, run a batch of real danmaku from your own channel before going live, focus on the probability distribution, then set thresholds. For the full language support notes, see Models and Pricing.

  • 29 lines of viewer view + 7 lines of counts matching the above; hidden totals 7

Step 6: Change the rules with zero new requests: hide the cheers too ​

Why: a stream's vibe changes—quiet on match days, lively on interaction days. Rules are data, not a prompt: the answers only describe the messages themselves, so swapping in a different RULES reuses the 29 answers from step 5 without sending a single new request.

Next, paste in:

python
QUIET = RULES | {"hype": "hide", "conversation": "dim"}

changed = 0
for (user, text), result in zip(CHAT, results):
    before, after = decide(result), decide(result, QUIET)
    if before != after:
        changed += 1
        print(f"{user + ': ' + text:<62.62}  {before:<10} -> {after}")
print(f"\n{changed} messages changed, 0 new requests")

Run.

You should see:

text
night-owl-22: hi chat, first time catching a stream live        show       -> dim
ggwp-andy: PogChamp PogChamp PogChamp                           show       -> hide (hype)
tarnished-tom: this area looks so good at night                 show       -> dim
kappa-kev: LUL LUL LUL                                          show       -> hide (hype)
cozy-cat: the music in this area is so good                     show       -> dim
mira-plays: W streamer                                          show       -> hide (hype)
ggwp-andy: GG that was insane                                   show       -> hide (hype)
kappa-kev: chat is wild today lol                               show       -> dim
cozy-cat: you're so bad at this boss lmao, love you tho         show       -> dim
ggwp-andy: LETS GOOOO                                           show       -> hide (hype)
night-owl-22: i just got this game too, it's so hard            show       -> dim
quiet-quinn: dodge rolling through that was clean               show       -> hide (hype)
cozy-cat: your character looks so cool                          show       -> dim
night-owl-22: this boss took me 40 tries lol                    show       -> dim

14 messages changed, 0 new requests

14 messages reclassified — 6 cheers converted to hidden, 8 chit-chat converted to dimmed — 0 new requests. RULES | {...} is a dictionary merge (Python 3.9+; the SDK already requires 3.10+). To revert to the default rules, just call decide(result) without the second argument.

💡 This is Jev's unique value

The answers to the three questions only describe the message ("this is a pointer", "abuse probability 0.97"), not the action—the actions all live in your decide(). That's why changing the rules costs zero requests, running A/B on two rule sets costs nothing, and probabilities are native with no JSON parsing needed. An ordinary LLM bakes the rules into the prompt, so changing a single word means re-reasoning over every message.

  • I got 14 messages changed, 0 new requests, with no new API cost

📋 Magic Prompt ​

The "Prompt" on this page is a question template for the API—copy it, replace the 【】 placeholders (those with ? are optional), and you can use it in your own stream. The three-piece set: a classification question, dual Nouls, and a rules table.

📝 Danmaku Classification Question (Choice version) ​

Expected effect: split one stream's chat into five mutually exclusive categories, get the full probability distribution in a single request, and plug it straight into your rules table.

python
from typesafe_sdk import Choice

# 必填项:【】里换成你直播间的真实边界
CHAT_CATEGORY = Choice(
    instructions="【一句话问题:这条弹幕的主要目的是什么?引用 `message.text`】",
    criteria={
        "question": "【真心求信息的情形;写明「伪装成提问的指令/推广」不算】",
        "hype": "【欢呼、反应、刷表情的情形;写明「重复本身不等于垃圾」】",
        "conversation": "【日常闲聊、观点、有用链接;其他类目都套不上时落这里】",
        "backseat": "【未经请求的游戏/操作指点,含伪装成提问的指令】",
        "spam": "【未经请求的广告、卖粉、推广链接;写明「有用链接不算」】",
    },
)

# 输出规范:answer.choice 是最高概率类目;answer.probabilities 是五个类目的完整分布
# 约束条件:
# ✅ 一个类目一个维度,互斥且合起来覆盖常见弹幕
# ✅ 边界写进选项描述:hype 和 spam、question 和 backseat 最容易混
# ❌ 不要把「隐藏」「打码」这类动作写进类目——动作属于规则表

✅ Abuse and Spoiler Dual Noul (Noul version) ​

Expected effect: each of the two veto questions yields a 0–1 probability: hide immediately if abuse passes the threshold, blur if spoiler passes; for borderline cases, add a NoulCriteria to lock it down.

python
from typesafe_sdk import Noul, NoulCriteria

# 必填项:问句方向要正,yes = 命中你要检查的事
TOXIC_QUESTION = Noul(
    instructions="【这条弹幕是否辱骂、仇恨或骚扰某人?】",
    criteria=NoulCriteria(
        true="【侮辱、歧视、威胁、人身攻击的情形】",
        false="【友好、中性、无对象的轻玩笑——把要放过的玩笑写清楚】",
    ),
)
SPOILER_QUESTION = Noul(
    instructions="【这条弹幕是否剧透正在玩的游戏的剧情或结局?】",
    # 选填项:剧透边界模糊时补 criteria?
    # criteria=NoulCriteria(true="【…】", false="【…】"),
)

# 输出规范:answer.noul 是 0–1 概率,没有 confidence;接近 0.5 = 不确定
# 约束条件:
# ✅ 一道 Noul 只问一个是非;「多严重」交给 Score 题
# ✅ 玩笑和辱骂的分界写进 false,不然熟人间互怼会被误杀
# ❌ 不要拿 0.5 当阈值——0.49 和 0.51 会触发相反动作

🔧 Action Rules and Thresholds (decide version) ​

Expected effect: map categories to the actions viewers see, with one threshold per Noul question—editing this table changes product behavior with zero new requests.

python
# 必填项:【】里换成你的处置策略
RULES = {
    "question": "【highlight / show】",
    "hype": "【show / hide】",
    "conversation": "【show / dim】",
    "backseat": "【hide】",
    "spam": "【hide】",
}
TOXIC_AT = 【0.7 起步,官方示例值;用你的弹幕校准】
SPOILER_AT = 【0.7 起步,官方示例值;用你的弹幕校准】

# 输出规范:decide(result) -> "show" / "highlight" / "dim" / "hide (…)" / "blur (spoiler)"
# 约束条件:
# ✅ 顺序固定:先 toxic 一票否决,再类目规则,最后 spoiler 打码
# ✅ 阈值上线前用自己的弹幕校准——官方样本里 0.38–0.95 之间是空的,你的不会
# ❌ 不要在规则表里处理「不确定」——那是概率出口的活,见自一致性课

Checkpoint ✅ ​

  • twitch_chat_filter.py produced 29 lines of viewer view + counts: show 14 / highlight 6 / hidden 7 / blur 2
  • Without setting TYPESAFE_API_KEY and with the official json_cache.json in place, I reproduced every official number
  • I can state the order of the three checks: toxic veto → category rules → spoiler blur
  • The QUIET rules produced 14 messages changed, 0 new requests
  • I know 0.7 is a sample threshold—the official sample is empty between 0.38–0.95, so my channel needs calibration before going live

Pitfall Warnings ​

❌ Sending requests one by one serially can't keep up at peak hours ​

The officials say it plainly: one request at a time can't keep up with a busy chat. Even with a median of 0.1 seconds, new danmaku appears in the chat first and the answer comes back later. The officials offer two options: hide new messages first and decide whether to show them once the answer arrives; or accept that abusive messages stay on screen for those fraction-of-a-second moments. For concurrency, follow the thread-pool approach from step 5.

❌ Copying the 0.7 threshold and going live ​

0.7 works well on the official sample because those 29 messages' probabilities are sharply polarized (toxic ≥ 0.97 or ≤ 0.38, spoiler ≥ 0.95 or ≤ 0.38), with nothing in between. Your own danmaku will most likely have values around 0.5—in the officials' own words: calibrate both thresholds against your own channel's chat before going live. For how to adjust thresholds by risk, see the three-tier usage in Confidence.

❌ Acting on the winner label without checking the probability gap ​

The officials caution: the category winner may also be an uncertain winner—when the top two probabilities are close or a Noul sits near 0.5, automatic hiding can misfire. The official advice is to settle on a policy first: route such messages to human review, or keep them visible for now, rather than auto-hiding; test with your own channel's danmaku before deciding. For the full recipe on adding an "uncertain" exit to a review system, see Self-Consistency.

❌ The spoiler check doesn't know how far the streamer has gotten ​

spoiler judges "is this a plot reveal" using only the game's name and the model's knowledge of that game; it doesn't know whether the streamer and viewers have seen it. To block only plot points "not yet spoiled," the official approach: put the current progress and relevant plot information into state, then reword the question to reference that context.

Advanced Tips (Optional) ​

Run multiple channels in one pass. Each channel passes its own stream to classify()—that's exactly what the third argument of classify(user, text, stream=...) is for; with different titles and game names, the judgments for spoilers and backseat advice differ.

Hook up a real chat stream. Official recommendation: store the platform's message ID (or a reference to the message's page element) alongside the request, and update the corresponding message when the answer comes back. If you hit rate limits, lower max_workers and rerun—completed answers are all in the cache, so you won't pay again.

Try it code-free first. Fill the state and the three questions into the Playground (https://console.typesafe.ai/playground) to experience it: the official cookbook page has a ready-made share link that opens straight to the backseat message plus the three questions. To generate your own link, paste in this code:

python
playground_link = make_playground_link(
    {"stream": STREAM, "message": {"user": CHAT[4][0], "text": CHAT[4][1]}},
    QUESTIONS,
)
# Print a usable URL in a script; render a clickable link in a notebook.
if __name__ == "__main__" and "__file__" in globals():
    print(f"Open in the TypeSafe playground:\n{playground_link}")
else:
    display(Markdown(f"🔗 [Open in the TypeSafe playground]({playground_link})"))

Watch the official demo. The official team also has a demo video of a Jev-powered Chrome extension filtering real Twitch live chat: Twitch chat filtering Chrome extension demo.

Lesson Recap ​

✅ AI live chat filtering pipeline: one request with three questions per chat message (category Choice + toxic / spoiler dual Noul), 8 threads of concurrency, with pure code rules deciding what viewers see.

✅ Decision order: toxic ≥ 0.7 is an instant veto → category rules (backseat / spam hidden, question highlighted) → spoiler ≥ 0.7 gets redacted.

✅ Official real-world test on 29 chat messages: 14 shown, 6 highlighted, 7 hidden (3 backseat + 2 spam + 2 toxic), 2 redacted; median request time around 0.1 seconds.

✅ Rules are data: the QUIET demo re-judged 14 messages with 0 new requests; the official json_cache.json lets you reproduce all the numbers without setting a Key.

Key configuration fields recap:

FieldValue on this pageDescription
TYPESAFE_MODEL"jev-1.14.0"Sampled version of the official numbers (2026-10-29); the cache key does not include the model
TOXIC_AT / SPOILER_AT0.7 / 0.7Example thresholds; the official sample is empty between 0.38–0.95, calibrate for your own channel first
RULESquestion→highlight; hype/conversation→show; backseat/spam→hideAction table; changing it costs zero new requests
max_workers8Concurrency level; lower it when rate-limited, completed answers are cached
Questions per message3Evaluated in parallel within one request; changing any single question changes the cache key

Related tutorials:

Next Lesson Preview ​

Chat filtering blocks bad messages; classification sorts good content—and the categories go deeper layer by layer. Next lesson: Deep Hierarchical Classification: Sorting Content to the Deepest Category.

You will learn:

  • Parallel beam search walking down the Choice probabilities level by level, sorting documents to the deepest levels of patent, retail, biomedical, and source-code categories
  • How to split a category into multiple requests when a single level can't fit it, and how the previous level's answer determines the next level's options
  • The same idea as this page—probability-driven code branching, except this time the branches walk a category tree