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

Curating Her Own Examples

Improvement has to live somewhere else

Twenty chapters in, every gain she has made shipped as code or config. The model itself has learned nothing, because it cannot: the weights of llama3.2:3b were fixed the day the download finished, and no conversation will ever move them. Each session boots the same frozen model with the same personality.json, so the exchange last Tuesday where her timing was perfect is gone. She produced her best line ever, and the only copy scrolled off your terminal.

The obvious fix is to hand-grow the example list in configs/personality.json: notice a good reply, paste it in, repeat. That decays fast. You forget which phrasings actually landed, the file swells into one giant string you re-edit by hand, and nothing records why a given exchange earned its slot. Within a month the examples drift from your real sessions into a wish list of how you imagine she sounds.

So the design this chapter builds: log every exchange to SQLite with a rating, and make the prompt's example list a query over the highest-rated rows. The model stays read-only; the database becomes the thing that learns. Improving her stops being an editing job and becomes a rating job, and chapter 9's few-shot machinery does the rest without changing a line.

◆ Note — rate afterward, never live

Every row is logged with rating=0, meaning unrated, and unrated rows never reach the prompt. The discipline matters: if fresh replies flowed straight back in as examples, she would be imitating output no one has judged yet, and one bad session would compound into a worse one. You review in a calm moment and score what deserves to survive. Also: memory.db belongs in .gitignore. Your conversation history is data, not source.

Log, filter, inject

▣ Build · stage 1 — one rated row, stored and read back
# labs/self_learning.py
import sqlite3
from datetime import datetime, timezone

conn = sqlite3.connect(":memory:")
conn.execute("""
    CREATE TABLE IF NOT EXISTS interactions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_input TEXT NOT NULL,
        response TEXT NOT NULL,
        rating INTEGER DEFAULT 0,
        created_at TEXT NOT NULL
    )
""")
conn.execute(
    "INSERT INTO interactions (user_input, response, rating, created_at)"
    " VALUES (?, ?, ?, ?)",
    ("Hello", "Oh. You again.", 4, datetime.now(timezone.utc).isoformat()),
)
conn.commit()
print(conn.execute(
    "SELECT user_input, response, rating FROM interactions").fetchall())
$ uv run python labs/self_learning.py
[('Hello', 'Oh. You again.', 4)]

The chapter 11 habits, applied to a new table: prove the schema against :memory: before any file exists, insert through ? placeholders so an apostrophe in her snark can never break the SQL, and stamp created_at in UTC so the strings sort correctly no matter what your wall clock does. The rating column is the only new idea on this table, and the entire chapter hangs off it.

▣ Build · stage 2 — only the good exchanges get out
def log_interaction(conn: sqlite3.Connection, user_input: str,
                    response: str, rating: int = 0) -> None:
    conn.execute(
        "INSERT INTO interactions (user_input, response, rating, created_at)"
        " VALUES (?, ?, ?, ?)",
        (user_input, response, rating, datetime.now(timezone.utc).isoformat()),
    )
    conn.commit()

def get_high_rated_examples(conn: sqlite3.Connection,
                            min_rating: int = 3, limit: int = 5) -> list[dict]:
    rows = conn.execute(
        "SELECT user_input, response FROM interactions"
        " WHERE rating >= ? ORDER BY rating DESC, id DESC LIMIT ?",
        (min_rating, limit),
    ).fetchall()
    return [{"user": r[0], "glados": r[1]} for r in rows]

log_interaction(conn, "Are you OK?", "Define OK.", rating=3)
log_interaction(conn, "What's the weather?", "I am not a window.", rating=1)
print(get_high_rated_examples(conn))
$ uv run python labs/self_learning.py
[{'user': 'Hello', 'glados': 'Oh. You again.'}, {'user': 'Are you OK?', 'glados': 'Define OK.'}]

Three rows in, two rows out: the WHERE rating >= 3 filter is the quality gate, and the 1-rated weather line died at it, which is the whole point. ORDER BY rating DESC, id DESC puts the best exchange first and breaks ties toward newer rows, so the query is deterministic. And the tuples become dicts before they leave the function, keyed user and glados to match the example format personality.json established in chapter 9. Rows convert to named data at the boundary; downstream code should never count columns.

▣ Build · stage 3 — inject, with a cold-start guard
def build_few_shot_prompt(examples: list[dict], base_prompt: str) -> str:
    if not examples:
        return base_prompt
    shots = "\n".join(
        f"User: {e['user']}\nGLaDOS: {e['glados']}" for e in examples)
    return f"{base_prompt}\n\nHere are examples of good responses:\n{shots}"

print(build_few_shot_prompt(get_high_rated_examples(conn), "You are GLaDOS."))
print("---")
print(build_few_shot_prompt([], "You are GLaDOS."))
$ uv run python labs/self_learning.py
You are GLaDOS.

Here are examples of good responses:
User: Hello
GLaDOS: Oh. You again.
User: Are you OK?
GLaDOS: Define OK.
---
You are GLaDOS.

The second print is the one to stare at. A fresh database has no rows above the bar, and without the if not examples guard the function would append a "Here are examples of good responses:" header with nothing under it: a promise to the model that the very next line breaks. The guard makes the cold start degrade to exactly the prompt you had before this chapter existed, which is what graceful fallback means: absent data produces the old behavior, not a new artifact.

▣ Build · stage 4 — the full module, on the real database
# labs/self_learning.py — full file
import sqlite3
from datetime import datetime, timezone
from pathlib import Path

DB_PATH = Path("glados/data/memory.db")

def connect(db_path: Path = DB_PATH) -> sqlite3.Connection:
    db_path.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(db_path)
    conn.execute("""CREATE TABLE IF NOT EXISTS interactions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_input TEXT NOT NULL,
        response TEXT NOT NULL,
        rating INTEGER DEFAULT 0,
        created_at TEXT NOT NULL)""")
    return conn

def log_interaction(conn: sqlite3.Connection, user_input: str,
                    response: str, rating: int = 0) -> None:
    conn.execute(
        "INSERT INTO interactions (user_input, response, rating, created_at)"
        " VALUES (?, ?, ?, ?)",
        (user_input, response, rating, datetime.now(timezone.utc).isoformat()),
    )
    conn.commit()

def update_rating(conn: sqlite3.Connection, interaction_id: int,
                  rating: int) -> None:
    conn.execute("UPDATE interactions SET rating = ? WHERE id = ?",
                 (rating, interaction_id))
    conn.commit()

def get_high_rated_examples(conn: sqlite3.Connection,
                            min_rating: int = 3, limit: int = 5) -> list[dict]:
    rows = conn.execute(
        "SELECT user_input, response FROM interactions"
        " WHERE rating >= ? ORDER BY rating DESC, id DESC LIMIT ?",
        (min_rating, limit),
    ).fetchall()
    return [{"user": r[0], "glados": r[1]} for r in rows]

def build_few_shot_prompt(examples: list[dict], base_prompt: str) -> str:
    if not examples:
        return base_prompt
    shots = "\n".join(
        f"User: {e['user']}\nGLaDOS: {e['glados']}" for e in examples)
    return f"{base_prompt}\n\nHere are examples of good responses:\n{shots}"

def main() -> None:
    conn = connect()
    log_interaction(conn, "Hello", "Oh. You again.", rating=4)
    log_interaction(conn, "Are you OK?", "Define OK.", rating=3)
    log_interaction(conn, "What's the weather?", "I am not a window.", rating=1)
    examples = get_high_rated_examples(conn)
    print(f"Examples that made the cut: {len(examples)}")
    print(build_few_shot_prompt(examples, "You are GLaDOS."))
    conn.close()

if __name__ == "__main__":
    main()
$ uv run python labs/self_learning.py
Examples that made the cut: 2
You are GLaDOS.

Here are examples of good responses:
User: Hello
GLaDOS: Oh. You again.
User: Are you OK?
GLaDOS: Define OK.

connect() points at the same glados/data/memory.db that already holds her facts and conversation summaries: one file, one backup, and CREATE TABLE IF NOT EXISTS is idempotent, so adding a table to a live database is safe on every connect. update_rating is the review tool; you score a row days after it was written, keyed by id. Wiring into the voice loop takes two lines: call log_interaction after each reply with the default rating of 0, and at session start run the personality's system_prompt through build_few_shot_prompt before the first Ollama call.

Why this works: read-only weights, writable context

Everything an LLM knows was baked in at training time; the only input you control at runtime is the context window, the text handed over on each call. Few-shot prompting works because the model is a pattern continuer: show it two exchanges in a voice and it produces a third in the same voice, no retraining involved. Chapter 9 exploited that with hand-written examples. This chapter changes where the examples come from, and that change closes a loop: sessions produce exchanges, ratings select the best, the best become the demonstrations for the next session. Selection pressure on her own output, applied by you.

The split across three functions is what keeps the loop honest. log_interaction grows the corpus and judges nothing; get_high_rated_examples owns the quality bar; build_few_shot_prompt renders whatever survives. Each stage is separately testable, and tuning her means touching data, not code: raise min_rating for a stricter voice, raise limit for a stronger one, re-rate a row to promote or bury it. The same pattern runs the retrieval systems behind serious assistants; theirs select by relevance to the query where yours selects by rating, but the frozen model and the curated context are identical.

⚠ Worked failure — the tuples that would not format

The tempting shortcut is to skip stage 2's dict conversion, since fetchall() already returns the data:

def get_high_rated_examples(conn, min_rating=3, limit=5):
    return conn.execute(
        "SELECT user_input, response FROM interactions"
        " WHERE rating >= ? ORDER BY rating DESC, id DESC LIMIT ?",
        (min_rating, limit),
    ).fetchall()   # list of tuples now, not dicts
$ uv run python labs/self_learning.py
Traceback (most recent call last):
  File "labs/self_learning.py", line 25, in <module>
    print(build_few_shot_prompt(examples, "You are GLaDOS."))
  File "labs/self_learning.py", line 21, in build_few_shot_prompt
    shots = "\n".join(
  File "labs/self_learning.py", line 22, in <genexpr>
    f"User: {e['user']}\nGLaDOS: {e['glados']}" for e in examples)
TypeError: tuple indices must be integers or slices, not str

Read the last line before the code: a tuple is indexed by position, so e['user'] asks it a question it cannot answer, and the string key in the error is the tell. Notice also where it blew up: not in the function that changed, but two frames later in build_few_shot_prompt, which trusted its input's type. When a data-format bug detonates far from its cause, the fix belongs at the boundary where the format was decided: convert rows to dicts inside get_high_rated_examples, and every caller inherits self-describing data. A crash at a distance is the polite version of this bug; silently formatting the wrong column would have been the rude one.

Checkpoint, with a corpus growing

✓ Checkpoint — what you can now do
  • I can explain why her weights cannot learn from a conversation, and name the one runtime input that lets her improve anyway.
  • I can trace one exchange from log_interaction through the rating filter into the prompt, and say which function owns each step.
  • I know why fresh rows default to rating=0 and why unrated output must never feed back in as an example.
  • I can state what the cold-start guard prevents, and what the model would be handed without it.
  • Handed a TypeError about string indices two frames from the real bug, I know to look for a boundary that leaked tuples.
⚡ Exercises — try first, then reveal
Exercise 1 — the review loop. Write a small script that prints every unrated row (rating = 0) with its id, asks you for a score, and applies it with update_rating. Run it after a real session.

A SELECT id, user_input, response FROM interactions WHERE rating = 0, a loop over the rows, an input() parsed with int(), and the existing update_rating per answer. Ten minutes of code, and it becomes the ritual this chapter actually asks of you: a nightly minute of judging her lines. Skip a row by entering 0 and it stays in tomorrow's queue, free of charge.

Exercise 2 — let SQLite do the dicts. Set conn.row_factory = sqlite3.Row in connect() and rewrite get_high_rated_examples without the manual conversion. What does the SELECT need for the keys to come out right?

Column aliases: SELECT user_input AS user, response AS glados, then [dict(r) for r in rows]. sqlite3.Row carries column names with each row, so the boundary conversion shrinks to a cast. Print the result and confirm the keys match stage 2's exactly; the failure box is what happens when a boundary's output format changes and its callers are not told.

Exercise 3 — a budget for the prompt. Write build_budgeted_prompt(examples, base_prompt, max_chars=120) that adds examples only while the total stays under the budget. Seed three rated exchanges and watch which ones survive.

Accumulate a running length starting at len(base_prompt), break before appending any example that would cross the line, and fall back to the bare prompt when nothing fits. With 120 characters and three examples, the third gets cut. The context window is finite, and every example spends space the conversation history also wants; this function is where that argument gets settled, and it previews the token budgeting that the Jetson's smaller headroom will force later in the project.

She now improves from evidence, at the pace you rate it. But everything she knows about you is still hardcoded: what she calls you, how long she talks, how much sarcasm you signed up for. Next chapter moves those choices into a preferences file merged over safe defaults, so an old save survives every new option the project adds.