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.
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
# 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.
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.
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.
# 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.
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
- 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_interactionthrough the rating filter into the prompt, and say which function owns each step. - I know why fresh rows default to
rating=0and 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
TypeErrorabout string indices two frames from the real bug, I know to look for a boundary that leaked tuples.
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.