Controlling AI Hallucinations with a SQLite State Machine

Controlling AI Hallucinations with a SQLite State Machine
Photo by Matt Benson / Unsplash

I found that my multi-agent meal planner was great at generating plans, but terrible at changing them. If I wanted to swap just one meal, the system would often hallucinate or lose the context of the rest of the week. I realized that treating every interaction as a fresh "one-shot" generation was a mistake. Instead of trying to force a stateless LLM to "remember" through long prompts, I decided to handle the memory myself using a SQLite state machine. Here is how I built a stateful feedback loop that makes the bot actually useful for day-to-day tweaks.

The "Take It or Leave It" Problem

My initial dual-agent system was great at taking a schedule and generating a cohesive plan. The "Analyst" figured out the meals, and the "Chef" immediately built the shopping list. It was a beautiful, but perfectly linear, pipeline.

But what if I only wanted to change one day? Standard LLM wrappers lose context immediately. To fix the draft, I would either have to regenerate the entire week (burning tokens and losing the meals I actually liked) or build a massive, complex conversational memory system just to remember what we were talking about 30 seconds ago.

I needed the Telegram bot to hold a conversation about a specific draft plan.

Explicit State Machines over "Chat History"

Instead of dumping raw chat logs into the LLM and hoping it figures out what we are talking about, I introduced an explicit state machine backed by SQLite.

When the initial draft plan is generated, the bot opens a Session. This session holds the current state (awaiting_feedback), the user's original request, and a reference to the active draft plan.

// internal/telegram/session_repository.go

// Session represents an active user session (e.g., awaiting adjustment feedback)
type Session struct {
	ID          int64
	UserID      string
	SessionType string
	State       string
	ContextData string
	ExpiresAt   time.Time
	CreatedAt   time.Time
}

// GetActive retrieves the most recent active session for a user (non-expired)
func (sr *SessionRepository) GetActive(ctx context.Context, userID string, now time.Time) (*Session, error) {
	row, err := sr.queries.GetActiveSession(ctx, sessiondb.GetActiveSessionParams{
		UserID:    userID,
		ExpiresAt: now,
	})
	// ... error handling
	return &Session{
        // ... mapping fields
	}, nil
}

Now, when a user sends a message on Telegram, the bot intercepts it. If an active awaiting_feedback session exists, the bot knows this isn't a new request; it's an adjustment to the active draft.

The Dual-State System: Sessions and Drafts

The Session tracks the conversation, but the meal plan itself also needed a lifecycle. I updated the user_meal_plans table to include a status field with three distinct states:

// internal/planner/mealplan.go
type PlanStatus string

const (
	StatusDraft     PlanStatus = "DRAFT"
	StatusFinal     PlanStatus = "FINAL"
	StatusAdjusting PlanStatus = "ADJUSTING"
)

When the Analyst generates a new week, it is saved as DRAFT. If the user asks for a change, the status temporarily shifts to ADJUSTING to lock it from concurrent modifications. Only when the user types "Confirm" does the plan transition to FINAL. This separation of concerns—Conversation State vs. Entity State—makes the database the ultimate source of truth, completely independent of the LLM.

The PlanReviewer Agent

With the state securely anchored in the database, I needed an agent capable of targeted mutations. Enter the PlanReviewer.

Unlike the initial Analyst agent, which builds a week from scratch, the PlanReviewer's sole job is to take an existing plan, a user's critique, and output a modified plan without breaking the batch-cooking cadence.

By passing the existing JSON structure back into the prompt alongside the feedback, we severely constrain the LLM's hallucination space:

# internal/planner/plan_reviewer_prompt.md (Snippet)

You are a Meal Plan Review Specialist. Your role is to intelligently revise an existing meal plan based on user feedback, without regenerating the entire plan from scratch.

**Current Meal Plan**:
{{ range .CurrentPlan }}
- **{{ .Day }}**: {{ .RecipeTitle }} ({{ .PrepTime }})
{{ end }}

**User Feedback/Adjustment Request**:
{{ .AdjustmentFeedback }}

## Task
1. **Parse Feedback**: Identify which specific days or meal types the user wants to change.
2. **Preserve Good Parts**: Only change what the user specifically asked for. Keep recipes that weren't mentioned in feedback.
3. **Maintain Batch-Cooking Patterns**: If changing Monday (Cook), also consider changing Tuesday (Reuse) to maintain consistency.

Deferring Execution: Patience is a Virtue

This new interactive loop required a fundamental change to the architecture: Deferred Execution.

Previously, the Analyst generated the plan and immediately handed it to the Chef to build a shopping list. Now, the Chef has to wait.

sequenceDiagram
    participant User as User (Telegram)
    participant Bot as State Machine (Go)
    participant Analyst as Analyst (Groq)
    participant Reviewer as PlanReviewer (Groq)
    participant DB as SQLite State
    
    User->>Bot: "Plan meals for 2 adults"
    Bot->>Analyst: Generate Draft Plan
    Analyst-->>Bot: Draft Plan JSON
    Bot->>DB: Save Session (State: Awaiting Feedback)
    Bot-->>User: "Here is your draft. Confirm or Adjust?"
    
    User->>Bot: "Make Tuesday vegetarian"
    Bot->>DB: Load Session State
    Bot->>Reviewer: Adjust(Draft Plan, "Make Tuesday vegetarian")
    Reviewer-->>Bot: Revised Draft Plan JSON
    Bot->>DB: Update Session (Revised Draft)
    Bot-->>User: "Updated! Confirm or Adjust?"
    
    User->>Bot: "Confirm"
    Bot->>DB: Mark Session Complete
    Note over Bot: The Chef finally wakes up
    Bot->>Chef: Generate Shopping List

The system only runs the expensive ingredient-scaling and shopping-list generation tasks after the user explicitly confirms the plan. This saves tokens, reduces API calls to Groq, and ensures we only process finalized data.

The Message Router: Intercepting State

Most AI wrapper bots operate on a simple loop: Receive Message -> Send to LLM -> Return Output. Because we introduced explicit state management, the Telegram webhook handler (processMessage) now acts more like an API gateway. It decides which operational mode the system should be in before the LLM ever sees the text.

  1. State Interception First: Before looking at the text, the bot checks SQLite for an active, unexpired session. If a session exists with State == "awaiting_feedback", the bot short-circuits and routes the message to the PlanReviewer. It knows you aren't asking for a new plan; you're talking about the draft.
  2. Clipper Mode (Regex Routing): If the text is a URL, the bot knows you want to save a recipe, not chat. It routes to the Extractor/Clipper workflow.
  3. Default Generation: Only if the message fails all previous checks does it get sent to the Analyst as a brand new request.

This strict routing is what makes the bot feel deterministic. You aren't "prompt engineering" the AI to realize you sent a URL or want to change a draft; the Go code enforces the context before the AI ever turns on.

The Architect's Takeaway

Vibe coding can get you a functional single-pass AI script in minutes. But making an AI feel truly conversational and reliable requires traditional software engineering.

By forcing the LLM to operate within strict, predictable boundaries dictated by a SQLite state machine, we solve the hallucination and memory drift problems common in raw chat wrappers. It’s a stark reminder: the LLM is a powerful engine, but your application code still has to be the steering wheel.