Skip to main content

Anti-Hallucination Architecture

Hitler uses LLM tool use to prevent the LLM from fabricating data. The LLM cannot see any user data unless it explicitly calls a tool to fetch it.

The Problem

LLMs can “hallucinate” - generate plausible-sounding but completely fabricated responses. In a workplace assistant, this could mean:
  • Claiming you have 12 tasks when you have 3
  • Inventing task names that don’t exist
  • Making up mood trends or statistics
  • Creating fake team member information
This destroys user trust and makes the product unreliable.

Our Solution: Tool Use (No Data Without Tools)

Instead of injecting data into the system prompt and hoping the LLM doesn’t make up more, we give the LLM zero data by default and require it to call tools to access anything.

Architecture

User Message
     |
     v
+-------------------+
|  Command Parser   |-- Exact command? --> Direct handler (task, mood, etc.)
|  (parser.ts)      |
+--------+----------+
         | Natural language (unknown)
         v
+-------------------+
|  LLM with Tools   |
|  (chat.service)   |
|                   |-- Calls get_tasks --> Real DB data
|                   |-- Calls log_mood --> Real DB write
|                   |-- Calls create_task_draft --> Real draft
|                   |-- No tool call? --> Just responds (no data)
+-------------------+

Key Insight

The LLM must call a tool to access any data. It cannot:
  • Guess how many tasks you have (must call get_tasks)
  • Fabricate mood history (must call get_mood_history)
  • Invent task statistics (must call get_task_stats)
If the user asks “how many tasks do I have?” and the LLM responds without calling get_tasks, it literally has no data to fabricate from.

Tool Definitions

The LLM has access to 7 tools:
ToolPurposeReturns
get_tasksFetch user’s tasksReal task list from DB
create_task_draftCreate a task (auto-confirmed)Task ID (created immediately)
complete_taskMark task as doneCompleted task
log_moodLog mood entryMood record
get_mood_historyRecent mood entriesReal mood data
get_task_statsTask statisticsComputed from DB
get_pending_draftsQueued/pending tasksReal task list

Tool Use Flow

1. User: "kya tasks hai mere pending ??"
2. LLM receives message (no task data in context)
3. LLM decides to call get_tasks(status_filter: "pending")
4. System executes tool, returns real data from DB
5. LLM receives real data, generates response based on facts
6. Response includes tool results for the adapter to build UI blocks

System Prompt Enforcement

The system prompt explicitly tells the LLM:
# TOOLS

You have tools to access real data. **ALWAYS use tools instead of guessing.**

**CRITICAL**: NEVER fabricate task counts, task names, mood data, or statistics.
If you need data, call the appropriate tool. If the user asks about their tasks,
call get_tasks -- don't guess.

Why This Is Better Than the Old Approach

Old: Rule-Based Parser + Data Injection

Parser tries to detect "how many tasks" with string matching
  -> Fails on Hinglish, typos, new phrasings
  -> Falls through to LLM with 5 pre-injected tasks
  -> LLM told "don't make up more" (unreliable)
Problems:
  • Parser needed 260+ lines of NLP pattern matching
  • Still failed on languages it hadn’t seen
  • LLM had some data, tempting it to extrapolate
  • “Don’t hallucinate” instructions are unreliable

New: Thin Parser + LLM Tool Use

Parser only handles exact commands (50 lines)
  -> Everything else goes to LLM
  -> LLM has ZERO data unless it calls tools
  -> Tools return real data from the database
Benefits:
  • Parser is 50 lines, no NLP, no bugs
  • Works in any language (LLM understands all)
  • Impossible to hallucinate (no data to hallucinate from)
  • Tool results are real database records

Human-in-the-Loop Safety

Even with tool use, task creation goes through a draft system:
  1. LLM calls create_task_draft (not create_task)
  2. A draft is created in the database
  3. User sees confirmation UI with the draft details
  4. User must click “Confirm” before the task exists
The LLM can never create a real task directly.

Testing

The parser tests verify that natural language goes to the LLM (not the parser):
describe("Natural Language -> LLM (returns unknown)", () => {
  it("sends 'kya tasks hai mere pending ??' to LLM", () => {
    const result = parseCommand("kya tasks hai mere pending ??");
    expect(result.type).toBe("unknown"); // LLM handles via tools
  });

  it("sends 'how many tasks do I have' to LLM", () => {
    const result = parseCommand("how many tasks do I have");
    expect(result.type).toBe("unknown"); // LLM calls get_task_stats
  });
});
Explicit commands still work directly:
describe("Task Commands", () => {
  it("parses 'tasks' as list", () => {
    expect(parseCommand("tasks")).toEqual({ type: "task", subcommand: "list" });
  });

  it("parses 'task call john' as create", () => {
    const result = parseCommand("task call john");
    expect(result.type).toBe("task");
    expect(result.subcommand).toBe("create");
  });
});

Key Principles

  1. No data without tools: LLM has zero user data in its context
  2. Tools return real data: Every tool call goes to the actual database
  3. Human confirmation: Task creation always requires user approval
  4. Language-agnostic: LLM understands any language natively, no parser needed
  5. Simple parser: Only exact commands, no NLP pattern matching