What you will build
A missing Python package and a refund request should not follow the same automated route. We will first label the kind of question, then choose the next graph node from that label. Keeping those two decisions separate makes the program easier to inspect.
Everything is still rule-based. The complete file includes the word tokenizer and classifier rather than importing an unexplained project helper. It runs two requests so you can compare the technical and human-review paths side by side.
By the end: Route technical questions and refund questions differently, while retaining an ordered event log.
- What you will build
- What you need before starting
- Understand the idea before the code
- The workflow we are building
- Create the lesson folder
- Complete code for this article
- Run it and compare the result
- Walk through what just happened
- Change one thing yourself
- If your result is different
- What you can now explain
What you need before starting
You need basic Python: variables, functions, dictionaries, loops, and imports. When this lesson introduces a class or a library-specific term, its role is explained below.
Use Python 3.12. These examples were executed on Windows with Python 3.12.4. Use the shown interpreter consistently when installing packages and running code.
You do not need to run an earlier article to make this part execute. The complete files and data used here are supplied below. The series order is for learning the ideas progressively.
Understand the idea before the code
The state is this ticket's dictionary of data. A node is a Python function registered as a workflow step. An edge chooses a possible next step. A compiled graph runs those steps. In this part, the graph will choose one of two routes instead of always executing a straight line.
Classification answers “what kind of question is this?” Our classify_rules function inspects words and returns a category string such as "setup" or "billing". It is a programmed rule, not a trained classifier. Routing answers a different question: “given that category, which node should run next?” The route function returns the name "technical" or "human".
For a question about a missing Python module, the category is setup and the route is technical. A request for a refund goes to human review. “Human” here means the program stops on that branch with a review message; the lesson does not automatically contact a real mentor.
An event is a short log entry such as "classify:setup", recording what happened. A reducer is the function used to combine a field's existing value with a returned update. Without a special reducer, an ordinary field update replaces that field's value.
The workflow we are building
The boxes are named Python operations. Solid arrows are fixed transitions; dashed arrows show possible destinations chosen by routing functions. The image comes from this lesson's actual compiled graph. One particular input follows only the applicable route. Open the full-size graph.
Create the lesson folder
Create this folder and keep the filenames exactly as shown. In particular, a local module is an ordinary file beside the program, not an extra package you must guess how to install.
lesson-folder/
lesson.py
graph_picture.py
requirements.txt
Save requirements.txt
These are the external Python packages for this lesson. The local files above are included in the article; they are not pip packages.
langgraph==1.2.16
langgraph-checkpoint-sqlite==3.1.2
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
On Linux/macOS, use python3 -m venv .venv and .venv/bin/python -m pip install -r requirements.txt. Installation requires internet access. The default examples do not call a paid API.
Complete code for this article
Save this complete file as lesson.py
This is the complete program, including imports, data, function definitions, and the entry point.
from graph_picture import save_graph
import re
def tokens(text: str) -> set[str]:
return set(re.findall("[a-z0-9]+", text.lower()))
def classify_rules(text: str) -> str:
words = tokens(text)
if words & {"refund", "payment", "invoice", "charge", "billing"}:
return "billing"
if words & {"esp32", "port", "serial", "usb", "board", "camera"}:
return "hardware"
if words & {"python", "pip", "venv", "install", "module", "import", "environment"}:
return "setup"
return "other"
"Article 3: choose a branch and append an event log."
import json
from operator import add
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict, total=False):
text: str
category: str
reply: str
events: Annotated[list[str], add]
def classify(state):
category = classify_rules(state["text"])
return {"category": category, "events": [f"classify:{category}"]}
def route(state):
return "technical" if state["category"] in {"setup", "hardware"} else "human"
def technical(state):
return {
"reply": "Next we need a relevant workshop document.",
"events": ["technical"],
}
def human(state):
return {"reply": "A mentor must review this request.", "events": ["human"]}
def build():
builder = StateGraph(State)
for name, fn in [
("classify", classify),
("technical", technical),
("human", human),
]:
builder.add_node(name, fn)
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route, ["technical", "human"])
builder.add_edge("technical", END)
builder.add_edge("human", END)
compiled = builder.compile()
save_graph(compiled, 3)
return compiled
if __name__ == "__main__":
for text in ["My Python module is missing", "Please refund my Python workshop"]:
print(json.dumps(build().invoke({"text": text, "events": []}), indent=2))
Save this complete file as graph_picture.py
This is the complete local diagram renderer imported by lesson.py. Save it in the same folder. It draws the compiled graph; it does not decide the workflow or contact an image service.
import html
from pathlib import Path
ROLES = {
"__start__": "Enter the workflow",
"__end__": "This run is finished",
"clean": "Normalize the question",
"draft": "Prepare a reply",
"classify": "Choose a topic category",
"technical": "Technical branch",
"human": "Ask a mentor to review",
"lookup": "Retrieve local evidence",
"check": "Check source-ID structure",
"revise": "Allow one more draft",
"review": "Pause inside this node",
"approval": "Wait for a human decision",
"queue": "Write one local outbox row",
"escalate": "Finish with needs_human",
}
def positions(part):
if part == 2:
return {
"__start__": (320, 115),
"clean": (320, 250),
"draft": (320, 385),
"__end__": (320, 520),
}
if part == 3:
return {
"__start__": (420, 115),
"classify": (420, 250),
"human": (120, 410),
"technical": (720, 410),
"__end__": (420, 590),
}
if part == 5:
return {"__start__": (320, 115), "review": (320, 265), "__end__": (320, 415)}
return {
"__start__": (420, 115),
"classify": (420, 250),
"lookup": (420, 385),
"draft": (420, 520),
"check": (420, 655),
"revise": (790, 655),
"human": (70, 850),
"escalate": (70, 850),
"approval": (420, 850),
"queue": (420, 1035),
"__end__": (420, 1215),
}
def draw_svg(structure, part):
layout = positions(part)
node_width, node_height = (225, 83)
width = 850 if part in {2, 5} else 1080
height = max((layout[name][1] for name in structure.nodes)) + node_height + 100
chunks = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
f'<title id="title">Part {part}: graph generated from the compiled lesson code</title>',
'<desc id="desc">Solid arrows are fixed edges. Dashed arrows are conditional destinations. Node positions are chosen for readability.</desc>',
'<defs><marker id="solid" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#526c75"/></marker><marker id="conditional" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#b54625"/></marker></defs>',
'<rect width="100%" height="100%" fill="#faf9f5"/>',
'<text x="30" y="35" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#b54625">CODETIPS ACADEMY / GENERATED FROM CODE</text>',
f'<text x="30" y="73" font-family="Arial,sans-serif" font-size="28" font-weight="bold" fill="#183443">Part {part} — the compiled workflow</text>',
]
edge_data = []
for index, edge in enumerate(structure.edges):
sx, sy = layout[edge.source]
tx, ty = layout[edge.target]
if sx == tx:
points = [
(sx + node_width / 2, sy + node_height),
(tx + node_width / 2, ty),
]
elif ty < sy:
points = [
(sx + node_width / 2, sy),
(sx + node_width / 2, ty + node_height / 2),
(tx + node_width, ty + node_height / 2),
]
else:
leftward = tx < sx
x1 = sx if leftward else sx + node_width
x2 = tx + node_width if leftward else tx
y1 = sy + node_height / 2
y2 = ty + node_height / 2
middle = (x1 + x2) / 2 + (index % 3 - 1) * 12
points = [(x1, y1), (middle, y1), (middle, y2), (x2, y2)]
kind = "conditional" if edge.conditional else "solid"
color = "#b54625" if edge.conditional else "#526c75"
dash = ' stroke-dasharray="9 6"' if edge.conditional else ""
chunks.append(
'<polyline points="'
+ " ".join((f"{x:g},{y:g}" for x, y in points))
+ f'" fill="none" stroke="{color}" stroke-width="3"{dash} marker-end="url(#{kind})"/>'
)
edge_data.append(
{
"source": edge.source,
"target": edge.target,
"conditional": bool(edge.conditional),
}
)
for name in structure.nodes:
x, y = layout[name]
display = {"__start__": "START", "__end__": "END"}.get(name, name)
highlight = name in {"review", "approval", "check"}
chunks.append(
f'<rect x="{x}" y="{y}" width="{node_width}" height="{node_height}" rx="12" fill="{('#f8e9df' if highlight else '#eaf0eb')}" stroke="#c9d6ce" stroke-width="2"/>'
)
chunks.append(
f'<text x="{x + 14}" y="{y + 32}" font-family="Consolas,monospace" font-size="23" font-weight="bold" fill="#183443">{html.escape(display)}</text>'
)
chunks.append(
f'<text x="{x + 14}" y="{y + 60}" font-family="Arial,sans-serif" font-size="15" fill="#526c75">{html.escape(ROLES.get(name, name))}</text>'
)
chunks += [
f'<path d="M30 {height - 55} H{width - 30}" stroke="#d9dfd8"/>',
f'<text x="30" y="{height - 25}" font-family="Arial,sans-serif" font-size="16" fill="#526c75">Solid: fixed edge. Orange dashed: conditional destination. See the routing explanation below.</text>',
"</svg>",
]
return (
"\n".join(chunks),
{"part": part, "nodes": list(structure.nodes), "edges": edge_data},
)
def save_graph(graph, part):
structure = graph.get_graph()
svg, _ = draw_svg(structure, part)
Path("workflow.svg").write_text(svg, encoding="utf-8")
Path("workflow.mmd").write_text(structure.draw_mermaid(), encoding="utf-8")
Run it and compare the result
Run commands from the lesson folder. In the commands below, python means the environment you installed into. On Windows, substitute .venv\Scripts\python.exe; on Linux/macOS substitute .venv/bin/python. Do not create the environment with one interpreter and run the lesson with another.
Run 1: python lesson.py
python lesson.py
Captured standard output:
{
"text": "My Python module is missing",
"category": "setup",
"reply": "Next we need a relevant workshop document.",
"events": [
"classify:setup",
"technical"
]
}
{
"text": "Please refund my Python workshop",
"category": "billing",
"reply": "A mentor must review this request.",
"events": [
"classify:billing",
"human"
]
}
The program also writes workflow.svg and workflow.mmd into the current folder. Open the SVG in a browser. The Mermaid file is a text representation of the same nodes and edges.
The displayed output was captured from the supplied default program in a separate folder, with no project-level module on its import path. It is a reproducible demonstration on these examples, not a claim about arbitrary inputs.
Walk through what just happened
1. Read the imports and tokenizer
The file explicitly imports re before calling re.findall. tokens lowercases the text and returns a set of word-like items. A set intersection finds which listed keywords occur in the request.
2. Classify before routing
classify_rules checks billing keywords before technical ones. Consequently, a refund request containing the word Python is still billing. The classify node stores that category; route reads it and returns the next node name.
3. Combine new events correctly
The events field uses Annotated with operator.add as its reducer. New lists are concatenated. Each node therefore returns only its own new log entry; returning the accumulated list would duplicate entries.
4. Read one execution at a time
The diagram contains both possible branches, but a particular input follows one of them. In each printed result, compare category, reply, and events. The event list is a concise execution trace, not hidden model reasoning.
Change one thing yourself
Add “Please refund my ESP32 workshop” as another input. The billing-first rule should send it to human. Explain the result from the classifier’s order, not from a guess about AI behavior.
If your result is different
If only the latest event remains, check the reducer declaration. If events repeat, check that nodes return only their new entries.
Check the complete imports and filenames first. Then compare your input and command with the shown run. Numeric results may differ slightly between platforms; the program's assertions check the intended relationships rather than relying on a decorative screenshot.
What you can now explain
Route technical questions and refund questions differently, while retaining an ordered event log. Next we will look up relevant workshop notes and stop a draft-check loop after a fixed number of attempts.
Technical reference: LangGraph Graph API. This project and its wording are original CodeTips Academy material.