GLaDOS Vol 3 · Her Craft
ch 24 / 99
Chapter 24

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.

◆ Note — keywords, not a sentiment model

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

▣ Build · stage 1 — a sentiment from raw text
# 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."

▣ Build · stage 2 — the transition table
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.

▣ Build · stage 3 — state, plus a way back to neutral
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.

▣ Build · stage 4 — the mood reaches her voice
# 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.

⚠ Worked failure — praise crashes her

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

✓ Checkpoint — what you can now do
  • 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_count actually 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_turns value, counting from the last transition.
  • I know why the transition table's mood names must match the profile's moods table, and what set_mood does when they drift.
  • Handed the KeyError: 'positive' traceback, I can name the two messages that produced it and the guard that prevents it.
⚡ Exercises — try first, then reveal
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.