Reading the Room
She takes the insult like a compliment
Chapter 12 gave her moods: a table of dispositions and a set_mood switch
that swaps her register in one word. Then it left the switch in your hands. Type
/mood hostile and she turns cold; forget to type it and she stays
whatever she was. Tell her she is useless and she answers with the same even
sarcasm as when you thanked her. An assistant that reacts identically to praise
and abuse feels hollow, because real conversation carries a memory of tone, and
right now she has none.
Two naive fixes, both wrong in opposite directions. Classify each message in isolation ("this message is negative, so reply negatively") and every turn starts from a blank slate: she lurches from hostile to cheerful and back with no continuity. Latch the mood permanently and one "stupid" in the first minute poisons the whole session. The first version has no memory; the second one never forgets. Both read as machinery.
The fix is a finite-state machine, and a small one: a current mood, a table mapping (current mood, detected sentiment) to the next mood, and a decay counter that walks her back to neutral after a run of quiet turns. The rule this chapter keeps: mood is a state you transition, not a label you recompute. Each turn nudges the state or leaves it alone; nothing ever rebuilds it from scratch, and nothing holds it forever. A grudge with an expiry date.
Detection here is a keyword scan, and that is a choice, not a shortcut. It is chapter 18's discipline again: at household scale, the simple tool answers the question that matters. Ask a trained classifier why the mood turned hostile and it shrugs; ask the keyword list and it points at the word "stupid" in your sentence. Learned sentiment models are AI Zero's territory, and the day your keyword list embarrasses itself is the day that upgrade is justified. The state machine you build today will not care: it consumes a sentiment label and never asks who produced it.
Detect, transition, decay, connect
# labs/emotional_state.py
from typing import Optional
SENTIMENT_KEYWORDS = {
"positive": ["thank", "great", "awesome", "love", "please", "good"],
"negative": ["hate", "stupid", "broken", "wrong", "bad", "terrible"],
"frustrated": ["again", "still", "why", "not working", "ugh"],
"curious": ["how", "what", "explain", "tell me", "?"],
}
def analyze_sentiment(text: str) -> Optional[str]:
lower = text.lower()
for sentiment, keywords in SENTIMENT_KEYWORDS.items():
if any(k in lower for k in keywords):
return sentiment
return None
for text in ["Thank you so much", "This is stupid", "How does this work?", "Hello"]:
print(f"{text!r} -> {analyze_sentiment(text)}")
$ uv run python labs/emotional_state.py
'Thank you so much' -> positive
'This is stupid' -> negative
'How does this work?' -> curious
'Hello' -> None
The function returns the first category with a hit, and since Python
dicts iterate in insertion order, the order of the table is the priority order
of the categories: most specific first. Two outputs deserve a second look.
"How does this work?" matched curious only because nothing above
it matched first, and "Hello" returned None: no keyword, no
signal. The no-signal case is the most common case of all
("set a timer" carries no tone), and everything downstream must treat it as
"leave the mood alone."
MOOD_TRANSITIONS = {
"neutral": {"positive": "satisfied", "negative": "hostile", "curious": "curious"},
"hostile": {"positive": "neutral", "negative": "hostile", "curious": "curious"},
"curious": {"positive": "satisfied", "negative": "hostile"},
"satisfied": {"negative": "neutral", "frustrated": "hostile"},
}
def next_mood(mood: str, text: str) -> str:
sentiment = analyze_sentiment(text)
if sentiment:
return MOOD_TRANSITIONS[mood].get(sentiment, mood)
return mood
mood = "neutral"
for text in ["This is stupid", "How do I fix it?", "Thank you"]:
mood = next_mood(mood, text)
print(f"{text!r} -> {mood}")
$ uv run python labs/emotional_state.py
'This is stupid' -> hostile
'How do I fix it?' -> curious
'Thank you' -> satisfied
One entry shows the encoding: MOOD_TRANSITIONS["hostile"]["positive"]
is "neutral", meaning kindness talks her down from hostile, but only
to neutral, never straight to satisfied. She holds a grudge for exactly one
apology. Notice the tables are sparse on purpose: satisfied has no
positive entry because more praise while already pleased changes
nothing. The .get(sentiment, mood) is chapter 12's safe-lookup
habit doing structural work: a missing entry means "stay put," and that single
default makes the machine total, with a defined answer for every (mood,
sentiment) pair. The failure box shows what the bracket version does instead.
class EmotionalState:
def __init__(self, mood_decay_turns: int = 5) -> None:
self.mood = "neutral"
self.mood_turn_count = 0
self.mood_decay_turns = mood_decay_turns
def update(self, text: str) -> str:
self.mood_turn_count += 1
sentiment = analyze_sentiment(text)
if sentiment and self.mood in MOOD_TRANSITIONS:
new_mood = MOOD_TRANSITIONS[self.mood].get(sentiment)
if new_mood:
self.mood = new_mood
self.mood_turn_count = 0
if self.mood_turn_count >= self.mood_decay_turns and self.mood != "neutral":
self.mood = "neutral"
self.mood_turn_count = 0
return self.mood
state = EmotionalState(mood_decay_turns=3)
for text in ["This is stupid", "Turn on the lab lights.",
"Set a timer for ten minutes.", "Play something quiet."]:
print(f"{state.update(text):8} <- {text!r}")
$ uv run python labs/emotional_state.py
hostile <- 'This is stupid'
hostile <- 'Turn on the lab lights.'
hostile <- 'Set a timer for ten minutes.'
neutral <- 'Play something quiet.'
The counter is the subtle part, and one detail carries it:
mood_turn_count measures turns since the last emotional
signal, because every successful transition zeroes it. Skip that reset and
the counter runs from program start, yanking her back to neutral in the middle
of an argument. With it, decay fires only after a
genuine run of quiet turns: the insult sets hostile, three toneless commands
tick the counter to the threshold, and she lets it go. Decay speed lives in the
constructor as a named parameter, not as a bare 5 buried in the
if, because it is a personality knob you will tune.
# labs/emotional_state.py — demo wiring, at the bottom of the file
from personality_engine import PROFILE, build_system_prompt, set_mood
def main() -> None:
state = EmotionalState()
inputs = ["Hello", "This is stupid", "How does this work?", "Thank you so much"]
for text in inputs:
mood = state.update(text)
set_mood(PROFILE, mood)
print(f"{mood:9} <- {text!r}")
print()
print(build_system_prompt(PROFILE))
if __name__ == "__main__":
main()
$ uv run python labs/emotional_state.py
neutral <- 'Hello'
hostile <- 'This is stupid'
curious <- 'How does this work?'
satisfied <- 'Thank you so much'
You are GLaDOS, the rogue AI from Aperture Science. The tests are going well. You are almost tolerable today. Use precise, technical language.
This closes the loop chapter 12 left open: back then, nothing flipped the mood
switch except you; now update reads the room and
set_mood throws the switch, every turn, before the prompt is
rebuilt. One coherence requirement comes with the wiring: the mood names in
MOOD_TRANSITIONS must exist in the profile's moods
table, or set_mood will silently ignore them (its guard against
typos cannot tell a typo from a mood you forgot to add). If you skipped chapter
12's first exercise, add satisfied and curious to the
profile now. If your moods live in the JSON profile on disk, load it with
load_profile first; the demo imports the module's dict.
Why this works: the table is the behavior
A finite-state machine answers one question: given the current state and an
input, what is the next state? A nested dict answers exactly that question in one
expression. MOOD_TRANSITIONS[mood].get(sentiment) is the whole
machine: first key the state, second key the input, value the next state. There
is no hidden control flow to trace, no if ladder to keep consistent
with itself. The table is the behavior, so tuning her temperament means
editing data, and chapter 12's lesson about personality-as-data extends one level
up: not just what each mood sounds like, but how she moves between them.
Decay is the second mechanism, layered on top and deliberately independent: the transition table never mentions the counter, the counter never inspects the table. Together they give the state the two properties that make it feel like a disposition instead of a reflex. Continuity: the mood persists across turns that carry no signal. Recovery: it does not persist forever. Drop either one and you are back at a naive fix from the opening; both at once take about forty lines.
Bracket indexing reads cleaner than .get(), so the first draft of
update tends to use it, and it survives every test where each
message changes the mood:
def update(self, text: str) -> str:
sentiment = analyze_sentiment(text)
if sentiment:
self.mood = MOOD_TRANSITIONS[self.mood][sentiment] # bracket index
return self.mood
state = EmotionalState()
for text in ["Thank you", "Great, it works!"]:
print(state.update(text))
$ uv run python labs/emotional_state.py
satisfied
Traceback (most recent call last):
File "labs/emotional_state.py", line 46, in <module>
print(state.update(text))
File "labs/emotional_state.py", line 40, in update
self.mood = MOOD_TRANSITIONS[self.mood][sentiment]
~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
KeyError: 'positive'
Read the sequence, not just the last line. "Thank you" moved the mood to
satisfied: that is the printed first line, and the crash needs it,
because the second lookup is MOOD_TRANSITIONS["satisfied"]["positive"]
and satisfied has no positive entry. The table is
sparse by design; bracket indexing turns every deliberate gap into a landmine
that only detonates on the right two-message sequence. So the bot crashes when
the user is nice twice in a row, which is close to the last input a test
suite tries. The fix is stage 3's pair: .get(sentiment) plus the
if new_mood: guard, which turns "no entry" into "no change." Same
species as chapter 12's stale-modifier crash: a sparse mapping, a direct index,
and a gap that waited for the one key you never tested.
Checkpoint, in a better mood
- I can say why per-message classification and permanent latching both feel wrong, and which mechanism in this chapter fixes each.
- I can trace any (mood, sentiment) pair through the nested dict and state the result, including the sparse entries that mean "stay put."
- I can explain what
mood_turn_countactually measures, and what goes wrong if a transition fails to reset it. - I can predict the exact turn on which decay fires for any
mood_decay_turnsvalue, counting from the last transition. - I know why the transition table's mood names must match the profile's
moodstable, and whatset_mooddoes when they drift. - Handed the
KeyError: 'positive'traceback, I can name the two messages that produced it and the guard that prevents it.
Exercise 1 — strongest signal wins. "Thanks, but how
do I fix it?" contains both a positive and a curious keyword, and first-hit
detection picks whichever category sits higher in the table. Rewrite
analyze_sentiment to count matches per category and return the
strongest one. Which sentiment does that sentence produce now?
Score each category with sum(k in lower for k in keywords),
take the max, and return None only when the best score is
zero. The test sentence comes out curious: "how" and "?" score
two against one lonely "thank." First-hit made that call by table order;
scoring makes it by evidence, and printing the score dict shows you the
vote every time you disagree with her read of you.
Exercise 2 — live it for a session. Call
state.update(user_text) in your chat loop after each message,
before the prompt is rebuilt, and log the mood beside each interaction in
chapter 21's rated store. After a real session, which mood collected your
best ratings?
The wiring is three lines: update, set_mood, and one extra
column in the log. The correlation is the interesting part. Most people
find their highest-rated replies cluster in curious and
satisfied, which suggests an uncomfortable hypothesis: her
answers did not improve, your questions did. Either way you now have
numbers where you had an impression.
Exercise 3 — watch a grudge expire. Give
EmotionalState a history list that records the
mood after every update. With mood_decay_turns=3, feed one
insult and three toneless commands, and predict the full history before you
print it.
['hostile', 'hostile', 'hostile', 'neutral']. The insult
transitions to hostile and zeroes the counter; the quiet turns tick it to
1, 2, 3; on the third, the threshold check fires inside that same
update call, so the fourth entry is already neutral. The
history list costs one line and becomes your instrument for mood bugs:
print the trace and find the turn where reality and your model of the
table disagree.
She reads your tone, holds it for a few turns, and lets it fade, and every part of that behavior is a number or a table entry you can tune: decay speed, keyword lists, the transitions themselves. Tuning by feel is how those knobs turn into folklore. The next chapter builds the lab notebook: every change recorded as an experiment, with the hypothesis written down before you see the result.