Send the right message to the right place.
A news monitor that reads from a Hacker News feed, analyzes the sentiment of each article, and routes articles to three different destinations based on their sentiment:
┌→ positive → archive (results.jsonl)
hacker_news → sentiment → split ┤→ negative → alerts (email-style display)
└→ neutral → display (terminal)
One new idea appears here that wasn't in Modules 01 or 02:
Split — a node that routes each message to a specific output port based on your logic. Unlike fanout, which copies every message to every destination, Split sends each message to exactly the destination it belongs in.
This module uses demo components — no API keys needed. Part 3 shows the two-line change to connect real Claude AI.
| File | What it is |
|---|---|
README.md |
This file |
app.py |
The canonical demo app — run this first |
claude_generated_app.py |
Exactly what Claude produced from the Part 4 prompt |
app_live.py |
Same app with real Claude API (Part 3) |
app_extended.py |
Extended version with spam filtering added |
test_module_03.py |
Tests you can run to verify everything works |
From the DisSysLab root directory:
python3 -m examples.module_03.appYou should see something like:
📰 Sentiment Router — Three-Way Split
════════════════════════════════════════════════════════════
hacker_news → sentiment → split → positive → archive
→ negative → alerts
→ neutral → display
[DISPLAY - NEUTRAL]
😐 Stack Overflow Developer Survey results
[ALERT - NEGATIVE]
📧 To: alerts@newsroom.com
📧 Subject: [ALERT] Negative article detected
😞 Why most software projects fail
════════════════════════════════════════════════════════════
✅ Done! Positive articles saved to results.jsonl
If you see output routed to different destinations, everything is working. Move to Part 2.
If something went wrong: make sure you're running from the DisSysLab
root directory. The command starts with python3 -m, not python3 app.py.
Open app.py. One thing is new compared to Modules 01 and 02.
[DemoRSSSource]
|
↓
[sentiment] ← adds sentiment + score to each article
|
↓
[splitter] ← routes each article to one of three ports
| | |
↓ ↓ ↓
out_0 out_1 out_2
| | |
↓ ↓ ↓
[archive][alerts][display]
from dissyslab import network
from dissyslab.blocks import Source, Transform, Sink, Split
from dissyslab.components.sources.demo_rss_source import DemoRSSSource
from dissyslab.components.transformers.prompts import SENTIMENT_ANALYZER
from dissyslab.components.transformers.demo_ai_agent import demo_ai_agent
from dissyslab.components.sinks import DemoEmailAlerter, JSONLRecorderSplit is the new import. It is the fourth basic node type.
rss = DemoRSSSource(feed_name="hacker_news")
sentiment_analyzer = demo_ai_agent(SENTIMENT_ANALYZER)
recorder = JSONLRecorder(path="results.jsonl", mode="w", flush_every=1)
alerter = DemoEmailAlerter(to_address="alerts@newsroom.com",
subject_prefix="[ALERT]")The routing function is the key new concept. It receives one message and returns a list — one element per output port. Non-None elements are sent to the corresponding port. None elements mean "skip this port."
def analyze_sentiment(text):
result = sentiment_analyzer(text)
return {
"text": text,
"sentiment": result["sentiment"],
"score": result["score"]
}
def route_by_sentiment(article):
"""
Route each article to exactly one output port based on sentiment.
Returns a list of 3 elements — one per output port:
out_0 ← positive articles → archive
out_1 ← negative articles → alerts
out_2 ← neutral articles → display
Non-None elements are sent to the corresponding port.
None elements mean "skip this port."
"""
if article["sentiment"] == "POSITIVE":
return [article, None, None ] # → out_0
elif article["sentiment"] == "NEGATIVE":
return [None, article, None ] # → out_1
else:
return [None, None, article] # → out_2
def print_article(article):
icon = {"POSITIVE": "😊", "NEGATIVE": "😞", "NEUTRAL": "😐"}
emoji = icon.get(article["sentiment"], "❓")
print(f" {emoji} {article['text']}")The Split function contract:
- Receives one message
- Returns a list of exactly
num_outputselements - Each element is either the message (to send) or
None(to skip) - The list length must match
num_outputsexactly
source = Source(fn=rss.run, name="rss_feed")
sentiment = Transform(fn=analyze_sentiment, name="sentiment")
splitter = Split(fn=route_by_sentiment, num_outputs=3, name="router")
archive = Sink(fn=recorder.run, name="archive")
alerts = Sink(fn=alerter.run, name="alerts")
display = Sink(fn=print_article, name="display")Split takes a num_outputs parameter. DisSysLab automatically creates
output ports named out_0, out_1, out_2, and so on.
g = network([
(source, sentiment),
(sentiment, splitter),
(splitter.out_0, archive), # ← positive articles → archive
(splitter.out_1, alerts), # ← negative articles → alerts
(splitter.out_2, display) # ← neutral articles → display
])Port references (splitter.out_0, splitter.out_1, splitter.out_2)
connect each output port to its downstream node. The port number corresponds
to the index in the list returned by the routing function.
The network() call specifies a list of edges of a graph, where each edge
is a tuple (from_node, to_node). For Split nodes, the from_node is a
port reference rather than the node itself. DisSysLab starts a thread for
each node, routes messages through queues, and shuts everything down cleanly
when the source runs out of articles.
Fanout (Module 02) copies every message to every destination:
article → [archive, alerts, display] ← all three get every article
Split (this module) routes each message to one destination:
positive article → [archive, None, None ] ← only archive
negative article → [None, alerts, None ] ← only alerts
neutral article → [None, None, display] ← only display
Use fanout when every destination needs every message. Use Split when each message belongs in exactly one place.
app.py uses demo components. app_live.py shows the two-line change for
real Claude AI.
Setup:
export ANTHROPIC_API_KEY='your-key-here'Run:
python3 -m examples.module_03.app_liveapp_live.py sets max_articles=2 to keep API calls and cost low. You
can increase this once you're comfortable with how the app behaves.
Use your DisSysLab Claude project to describe your own routing app.
Build me a DisSysLab app that reads from the hacker_news demo feed, analyzes sentiment, and routes articles to three outputs: positive articles saved to a jsonl file, negative articles printed as email alerts, and neutral articles printed to the terminal. Use demo components.
- "Read from tech_news, detect urgency, and route HIGH urgency articles to an email alert, MEDIUM to a file, and LOW to the terminal."
- "Monitor hacker_news and reddit_python, filter spam, analyze sentiment, and route only positive articles to a file."
- "Read from all three feeds, analyze sentiment, and route positive and neutral articles to a file while dropping negative ones."
| Feed name | What it simulates |
|---|---|
hacker_news |
Programming and tech articles |
tech_news |
General technology news |
reddit_python |
Python community discussions |
| Constant | Returns |
|---|---|
SPAM_DETECTOR |
{"is_spam": bool, "confidence": float, "reason": str} |
SENTIMENT_ANALYZER |
{"sentiment": str, "score": float, "reasoning": str} |
URGENCY_DETECTOR |
{"urgency": str, "metrics": dict, "reasoning": str} |
| Component | What it does |
|---|---|
print |
Prints to terminal |
DemoEmailAlerter |
Prints formatted email-style alerts |
JSONLRecorder |
Saves every result to a .jsonl file |
Three basic node types. Source generates data. Transform processes
it. Sink consumes it. Additional node types — such as Split, Broadcast,
and MergeAsynch — are introduced in later modules.
Split is the fourth basic node type. It routes each message to one or
more specific output ports based on your routing function. Port references
(splitter.out_0, splitter.out_1, etc.) connect each port to its
downstream node.
The Split function contract. Your routing function receives one message
and returns a list of exactly num_outputs elements. Non-None elements are
sent to the corresponding port. None elements skip that port. The list
length must match num_outputs exactly.
Split vs. Fanout. Fanout copies every message to every destination. Split routes each message to the destination it belongs in. Use Split when messages need to be sorted by content.
None drops messages. Any Transform that returns None silently removes
that message. In a Split routing function, None at a list position means
"skip this port" — not a dropped message, just a skipped destination.
Demo and real components are interchangeable. The only difference is the import line.
Module 04 puts it all together — fanin, fanout, and Split combined in one app that reads from multiple sources, filters, analyzes, and routes to multiple destinations.