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,
ThreadPoolExecutor8 concurrent threads running through the entire chat - Understand the division of labor among the three questions:
categoryChoice sets the default disposition,toxic/spoilerthe 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.jsoncache 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:
| Capability | Keyword blacklist | Plain LLM | Jev |
|---|---|---|---|
| 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
criteriaoptions map, and can readchoiceandprobabilities - Finished Noul true/false questions: knowing that
noulis a 0–1 probability, with starting thresholds yes ≥ 0.7 / no ≤ 0.3 - Went through installation and the first request:
.venvis 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:
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):
curl -sL -o json_cache.json https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks/twitch_chat_filter/json_cache.jsonCreate 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:
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:
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.jsonis 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:
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:
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):
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.02Three 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:
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 actionRegarding 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:
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):
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) 2Reconciliation: 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:
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:
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 requests14 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.
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.
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.
# 必填项:【】里换成你的处置策略
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.pyproduced 29 lines of viewer view + counts: show 14 / highlight 6 / hidden 7 / blur 2- Without setting
TYPESAFE_API_KEYand with the officialjson_cache.jsonin place, I reproduced every official number - I can state the order of the three checks: toxic veto → category rules → spoiler blur
- The
QUIETrules 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:
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:
| Field | Value on this page | Description |
|---|---|---|
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_AT | 0.7 / 0.7 | Example thresholds; the official sample is empty between 0.38–0.95, calibrate for your own channel first |
RULES | question→highlight; hype/conversation→show; backseat/spam→hide | Action table; changing it costs zero new requests |
max_workers | 8 | Concurrency level; lower it when rate-limited, completed answers are cached |
| Questions per message | 3 | Evaluated in parallel within one request; changing any single question changes the cache key |
Related tutorials:
- LLM Guardrails: Full Security Screening of Inputs and Outputs—the same "probability + threshold" approach, this time intercepting messages entering and leaving an LLM application
- Self-Consistency: Adding an "Uncertain" Exit to Moderation Systems—when the top two probabilities are close, don't act automatically; provide an exit route to a human
- Parallel Questions and Speculative Fan-out—the full expansion of multiple questions in one request: 13 questions at 12.2 times lower cost and 10.0 times faster
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