Engineering

Why Your AI Tutor Needs a State Machine, Not Just a Prompt

Why Your AI Tutor Needs a State Machine, Not Just a Prompt

The "Happy Wanderer" Trap

I wasted three weeks trying to learn Kubernetes by casually chatting with a generic AI interface. It felt productive at first. I asked about pods, then sidecars, then inadvertently jumped into complex networking mesh configurations before I even understood basic services. I had no map. I was just wandering through a forest of documentation, led by a tour guide who was hallucinating half the landmarks and didn't care if I was walking in circles.

We’ve all been there. You start a chat session to learn a new technology, and forty minutes later you’re deep in the weeds of an edge-case optimization while missing the fundamental mental model required to actually use the tool.

This is the fundamental flaw of "Chat with PDF" or generic LLM learning interfaces: Lack of Linear Continuity.

I recently stumbled upon a service called Runtric that frames this problem beautifully. Instead of just "answering," it generates a Curriculum first. It forces a structure before allowing the conversation. It creates a map. This got me thinking about the system architecture of learning. As developers, we build state machines for our apps, but we often accept stateless chaos for our learning tools.

Let’s rip this apart. How do we move from "AI as a Search Engine" to "AI as a Professor"? The answer lies in decoupling Curriculum Generation from Contextual Tutoring.


The Architecture of Pedagogy

The Problem: Context Drift

When you use a standard LLM to learn, you are fighting against the context window’s sliding nature. You might ask, "Why is this code failing?" The AI answers based on the immediate snippet. It doesn't know that three messages ago, you established that you are a beginner using a deprecated library for a specific legacy project.

In educational theory, this is a failure of Scaffolding. Effective teaching requires three distinct states that standard chat interfaces conflate:

  1. The Syllabus (Map): The total scope of what needs to be learned, ordered by dependency.
  2. The Current Context (State): Where exactly is the learner on that map?
  3. The Feedback Loop (Correction): Correcting mistakes relative to the current state, not absolute correctness.

If a first-grader asks "What is 5 minus 7?", the pedagogical answer is "You can't do that yet" (in the context of natural numbers), not a lecture on negative integers. Generic AIs fail this test constantly. They give the "correct" answer, which is often the "wrong" teaching move.

Runtric’s approach, generating a curriculum first, solves the first layer. It defines the boundaries. But as system thinkers, we can take this further. We can model learning as a Directed Acyclic Graph (DAG) where each node is a context-locked chat session.


Engineering a Curriculum Generator

Let's build a prototype of this logic. We are making a Learning State Manager. We will use TypeScript and OpenAI to create a two-phase system:

  1. Architect Agent: Generates a strictly typed JSON curriculum.
  2. Tutor Agent: Consumes a specific node of that curriculum to restrict its context.

Step 1: Defining the Structure

We need a schema that forces the LLM to think in chapters and levels, not just text blobs. We'll use zod for schema validation, which helps ensure our "Teacher" doesn't hallucinate the syllabus format.

import { z } from "zod";
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
 
const openai = new OpenAI();
 
// Define the shape of our Curriculum
const ChapterSchema = z.object({
  title: z.string(),
  objective: z.string().describe("What the student should be able to do after this chapter"),
  keyConcepts: z.array(z.string()),
  difficulty: z.enum(["Beginner", "Intermediate", "Advanced"]),
});
 
const CurriculumSchema = z.object({
  topic: z.string(),
  totalChapters: z.number(),
  chapters: z.array(ChapterSchema),
});
 
type Curriculum = z.infer<typeof CurriculumSchema>;
 
// The Architect Agent
async function generateCurriculum(topic: string, level: string): Promise<Curriculum | null> {
  try {
    const completion = await openai.beta.chat.completions.parse({
      model: "gpt-4o-2024-08-06",
      messages: [
        {
          role: "system",
          content: `You are a Senior Curriculum Designer. 
          Create a structured learning path for '${topic}' at a '${level}' level.
          Focus on logical progression. Do not jump to advanced topics before basics are covered.`
        },
        { role: "user", content: `Teach me ${topic}.` },
      ],
      response_format: zodResponseFormat(CurriculumSchema, "curriculum"),
    });
 
    const curriculum = completion.choices[0].message.parsed;
    return curriculum;
  } catch (error) {
    console.error("Failed to architect the course:", error);
    return null;
  }
}

Why this matters

By forcing a JSON output, we strip away the AI's tendency to be conversational and vague. We are extracting pure structure. This Curriculum object becomes our State Machine configuration. The user cannot proceed to Chapter 2 until Chapter 1 is satisfied.


The Context-Aware Tutor

Now, here is where the magic happens. A standard chat bot has a "System Prompt" that is usually static. In our system, the System Prompt must be dynamic based on the current active chapter.

If the user is in "Chapter 3: Async/Await", and they ask "How do I use fs.readFileSync?", the Tutor should ideally say, "We are focusing on asynchronous patterns right now. readFileSync blocks the event loop. Let's look at readFile instead."

This is the difference between a Stack Overflow answer and a Teacher's guidance.

Step 2: The Stateful Session

Let's implement a TutorSession class that holds the curriculum and the current pointer.

class TutorSession {
  private curriculum: Curriculum;
  private currentChapterIndex: number = 0;
  private history: Array<{ role: "user" | "assistant"; content: string }> = [];
 
  constructor(curriculum: Curriculum) {
    this.curriculum = curriculum;
  }
 
  private getCurrentChapter() {
    return this.curriculum.chapters[this.currentChapterIndex];
  }
 
  // Generates a System Prompt specific to the current chapter
  private getSystemPrompt(): string {
    const chapter = this.getCurrentChapter();
    const previousTopics = this.curriculum.chapters
      .slice(0, this.currentChapterIndex)
      .map(c => c.title)
      .join(", ");
 
    return `
      You are a Tutor for the course "${this.curriculum.topic}".
      
      CURRENT STATE:
      - Chapter: ${chapter.title}
      - Objective: ${chapter.objective}
      - Key Concepts: ${chapter.keyConcepts.join(", ")}
      
      CONTEXT RULES:
      1. The student has already learned: [${previousTopics}]. You can reference these.
      2. Do NOT explain concepts from future chapters. If asked, briefly mention it's coming later but refocus on the current objective.
      3. If the user fails a quiz or asks a question, explain it using ONLY concepts from the current or previous chapters.
    `;
  }
 
  public async ask(question: string) {
    const systemPrompt = this.getSystemPrompt();
    
    this.history.push({ role: "user", content: question });
 
    const response = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: [
        { role: "system", content: systemPrompt },
        ...this.history
      ],
    });
 
    const answer = response.choices[0].message.content || "I'm not sure.";
    this.history.push({ role: "assistant", content: answer });
 
    return answer;
  }
 
  public nextChapter() {
    if (this.currentChapterIndex < this.curriculum.chapters.length - 1) {
      this.currentChapterIndex++;
      this.history = []; // Clear history to reset context window for new topic?
      // Or keep it to maintain long-term memory. 
      // In a strict pedagogical system, resetting or summarizing history 
      // often prevents context pollution.
      console.log(`Advanced to: ${this.getCurrentChapter().title}`);
    } else {
      console.log("Course complete!");
    }
  }
}

The "Runtric" Insight

The service mentioned in the feed, Runtric, demonstrates a specific feature: Contextual Recovery.

"When a user fails a quiz, the AI understands the context of the current chapter and helps immediately without the user explaining the situation again."

In my code above, the getSystemPrompt() method mimics this. By injecting objective and keyConcepts into the system prompt on every turn, we ensure the AI doesn't drift. It knows why we are here. If I fail a quiz on "Promises," the bot knows I'm failing because we are in the "Async" chapter, not because I'm bad at math.


Hallucination vs. Pedagogical Drift

We often talk about AI hallucinations as "making up facts." But there is a subtler, more dangerous hallucination in EdTech: Pedagogical Drift.

This happens when the AI forgets the user's skill level. You ask a simple question, and it gives you a senior-architect level answer involving abstractions you haven't learned yet. This destroys confidence.

By strictly scoping the System Prompt to the Current Chapter, we artificially limit the AI's intelligence to match the student's progression. This is counter-intuitive for developers who always want the "best" answer. But in education, the "best" answer is the one the student can understand right now.

The PDF Trap

Runtric's pivot away from "how many PDFs can we read" is significant. Vector databases (RAG) are great for retrieval, but they are terrible teachers.

  • RAG says: "Here is every paragraph in the document that mentions 'State'."
  • Curriculum Engine says: "First learn 'State' in React, then we will talk about 'State' in Redux."

Information Retrieval is not Education. Education requires sequence.


Conclusion

I haven't used Runtric extensively yet, but the philosophy behind it, separating the Content Map from the Instructional Interaction, is the only way AI tutors will ever be useful for serious learning.

If you are building AI tools, stop throwing raw tokens at users. Build a scaffold. Generate a JSON structure first. Force the user to walk the path. The value lies in the generated constraints.

We don't need smarter AIs. We need stricter AIs.

Now, if you'll excuse me, I need to go regenerate my Kubernetes curriculum. I think I skipped the chapter on "Why YAML hates tabs."