# Building a Verified AI Tutor: How I Built UniUI with 50+ Academic Engines and Socratic Reasoning

> Source: <https://dev.to/panther0508/building-a-verified-ai-tutor-how-i-built-uniui-with-50-academic-engines-and-socratic-reasoning-16c9>
> Published: 2026-08-25 02:27:03+00:00

TL;DR: I built an AI tutor that doesn't guess. It verifies. Here is how I built it with 50+ academic engines, Socratic reasoning, and offline-first PWA. 190+ students are using it.

ChatGPT gives you an answer. You trust it. You fail your exam.

Why? Because ChatGPT **guesses**. It doesn't compute. It doesn't verify. It doesn't know Nigerian curricula, engineering thermodynamics, or organic chemistry pathways.

Nigerian university students have a broken academic support system. Here's what we're up against:

Generic AI tools don't solve this. They **make it worse**—students trust hallucinated answers.

So I built UniUI.

UniUI: A strict, Socratic AI tutor for Nigerian university students. Every answer is verified by 50+ academic engines. Wrong answers get corrected. Vague questions get rejected.

**Live at:** [app.uniui.com.ng](https://app.uniui.com.ng)

UniUI is built on a modern AI stack designed for reliability, verification, and offline-first accessibility.

| Layer | Technology | Purpose |
|---|---|---|
API |
FastAPI (Python) | Backend logic, routing, LLM orchestration |
LLM |
Groq (primary), OpenRouter (fallback) | Answer generation |
Verification |
50+ academic engines (SymPy, ChemPy, PyNiteFEA, etc.) | Answer verification |
Vector DB |
Qdrant Cloud | Semantic search for RAG |
Keyword Search |
Meilisearch | Hybrid search (sparse + dense) |
Database |
Neon (PostgreSQL) | User data, conversations, notes |
Cache |
Redis | Rate limiting, session cache |
Frontend |
Next.js 14 (App Router) + Tailwind CSS | User interface |
Offline |
PWA + Service Worker + IndexedDB | Offline-first experience |
Encryption |
TweetNaCl + localForage | Client-side encrypted storage |
Hosting |
Hetzner VPS (4 vCPU, 8GB RAM) | Self-hosted backend |

``` php
flowchart TD
    A[User Question] --> B[FastAPI Router]
    B --> C{Stage 1: School Notes}
    C -->|Found| D[Qdrant Vector Search]
    C -->|Not Found| E{Stage 2: Curriculum}
    E -->|Found| D
    E -->|Not Found| F{Stage 3: References}
    F -->|Found| D
    F -->|Not Found| G{Stage 4: Internet}
    G -->|Found| H[LLM + Context]
    G -->|Not Found| I{Stage 5: Direct LLM}
    D --> H
    H --> J[50+ Verification Engines]
    J --> K{Verified?}
    K -->|Yes| L[Return Verified Answer]
    K -->|No| M[Return Corrected Answer + Explanation]
```

This is UniUI's **secret weapon**. Every AI-generated answer is cross-checked by **subject-specific engines** before being returned to the student.

`sympy`

– Symbolic mathematics (calculus, algebra, equations)`scipy`

– Numerical computing, linear algebra`numpy`

– Array operations, matrix math`mpmath`

– High-precision arithmetic`sage`

(optional) – Advanced mathematical computing`pydantic`

– Type validation for mathematical inputs`pint`

– Unit-aware physics calculations`sympy.physics`

– Classical mechanics, quantum, relativity`scipy`

– Differential equation solvers`pandas`

– Data analysis (experimental physics)`openstax`

– Physics reference data`astropy`

– Astrophysics calculations`qiskit`

– Quantum computing (optional)`chempy`

– Stoichiometry, equilibrium, thermodynamics`rdkit`

– Molecular fingerprints, SMILES (requires Python 3.10-3.12)`scipy`

– Numerical methods for chemistry`openbabel`

(optional) – Molecular file format conversion`pymatgen`

– Materials science`pynitefea`

– Finite element analysis`pandapower`

– Power systems analysis`python-control`

– Control systems engineering`coolprop`

– Fluid properties (thermodynamics)`py_engineers`

– Structural analysis`openmc`

– Nuclear engineering (optional)`pynt`

– Medical image reconstruction`pypbpk`

– Physiologically based pharmacokinetic modeling`glucostats`

– Glucose monitoring and analytics`biomechanics`

– Motion analysis`opencv`

– Medical image processing`dicom`

– DICOM file handling`dssattools`

– Crop simulation`apsim`

– Agricultural production systems`farmingpy`

– Precision agriculture`geopandas`

– Geospatial simulation`hydropy`

– Hydrology models`pythen`

– Legal reasoning engine`lexnlp`

– Legal text analysis, entity extraction`nltk`

– NLP for legal documents`spacy`

– Legal text processingHere's an example of how verification works for a math question:

``` python
# acadermic_pipeline/backend/verifiers/math_verifier.py

import sympy as sp
import numpy as np
from typing import Dict, Any, Optional

def verify_math_answer(question: str, llm_answer: str) -> Dict[str, Any]:
    """
    Verify a mathematics answer using SymPy.
    Returns: {
        "verified": bool,
        "correct_answer": str,
        "derivation": str,
        "confidence": float
    }
    """
    try:
        # Step 1: Parse question using LLM to extract math expression
        # Step 2: Convert to SymPy expression
        # Step 3: Compute the actual answer
        # Step 4: Compare with LLM answer
        # Step 5: Return verification result

        # Example: Calculate integral of x^2
        x = sp.Symbol('x')
        expression = sp.integrate(x**2, x)  # Returns x** 3/3

        return {
            "verified": True,
            "correct_answer": str(expression),
            "derivation": "∫x²dx = x³/3",
            "confidence": 1.0
        }
    except Exception as e:
        return {
            "verified": False,
            "correct_answer": None,
            "error": str(e),
            "confidence": 0.0
        }
```

UniUI uses a **5-stage cascade retrieval system** to find the most relevant content before generating an answer.

``` python
# academic_pipeline/backend/retrieval/vector_search.py

def vector_search(query: str, faculty: Optional[str] = None) -> List[Dict]:
    """
    Search Qdrant for semantically similar content.
    """
    # Generate embedding for the query
    embedding = get_embedding(query)

    # Build filter (faculty, course_code, etc.)
    filter_condition = None
    if faculty:
        filter_condition = {
            "must": [{"key": "faculty", "match": {"value": faculty}}]
        }

    # Search Qdrant
    results = qdrant_client.search(
        collection_name="uniui_documents",
        query_vector=embedding,
        query_filter=filter_condition,
        limit=10,
        score_threshold=0.7
    )

    return results
python
# academic_pipeline/backend/retrieval/keyword_search.py

def keyword_search(query: str) -> List[Dict]:
    """
    Search Meilisearch for keyword matches.
    """
    results = meilisearch_client.index("curriculum").search(
        query,
        {
            "attributesToRetrieve": ["title", "content", "course_code"],
            "limit": 10
        }
    )
    return results["hits"]
python
# academic_pipeline/backend/retrieval/hybrid_search.py

def hybrid_search(query: str) -> List[Dict]:
    """
    Combine vector + keyword search using Reciprocal Rank Fusion (RRF).
    """
    vector_results = vector_search(query)
    keyword_results = keyword_search(query)

    # RRF fusion (alpha = 60 for best results)
    fused = reciprocal_rank_fusion(vector_results, keyword_results, alpha=60)

    return fused[:10]
python
# academic_pipeline/backend/retrieval/internet_search.py

def internet_search(query: str) -> List[Dict]:
    """
    Search the internet using Exa or SerpAPI.
    """
    try:
        # Use Exa (AI-powered semantic search)
        results = exa_client.search(
            query,
            type="neural",
            num_results=5
        )
        return results["results"]
    except Exception:
        # Fallback: use Jina Reader
        return jina_search(query)
python
# academic_pipeline/backend/retrieval/direct_llm.py

def direct_llm(query: str) -> str:
    """
    Fallback: generate answer directly from LLM (no context).
    """
    response = groq_client.chat.completions.create(
        model="llama-3.3-70b-versatile",
        messages=[
            {"role": "system", "content": "You are a strict academic tutor. Answer accurately."},
            {"role": "user", "content": query}
        ]
    )
    return response.choices[0].message.content
python
# academic_pipeline/backend/routers/ask_router.py

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from academic_pipeline.backend.verifiers import verify_answer
from academic_pipeline.backend.retrieval import hybrid_search
from academic_pipeline.backend.llm import call_llm

router = APIRouter()

class AskRequest(BaseModel):
    question: str
    faculty: Optional[str] = None
    course_code: Optional[str] = None

class AskResponse(BaseModel):
    answer: str
    verified: bool
    verified_answer: Optional[str] = None
    sources: List[Dict[str, str]]
    engine_used: str
    confidence: float

@router.post("/ask", response_model=AskResponse)
async def ask_question(request: AskRequest):
    """
    Main endpoint for asking questions.
    1. Retrieve context (5-stage cascade)
    2. Generate LLM answer with context
    3. Verify answer using academic engines
    4. Return verified result
    """
    # Step 1: Retrieve context
    contexts = hybrid_search(request.question)

    if not contexts:
        # Stage 5: Direct LLM (no context)
        llm_answer = await direct_llm(request.question)
        sources = []
    else:
        # Stage 1-4: LLM with context
        llm_answer, sources = await generate_with_context(
            request.question, contexts
        )

    # Step 2: Verify answer
    verification_result = await verify_answer(
        question=request.question,
        answer=llm_answer,
        faculty=request.faculty
    )

    # Step 3: Build response
    return AskResponse(
        answer=verification_result.get("answer", llm_answer),
        verified=verification_result.get("verified", False),
        verified_answer=verification_result.get("correct_answer"),
        sources=sources,
        engine_used=verification_result.get("engine_used", "groq"),
        confidence=verification_result.get("confidence", 0.0)
    )
python
# academic_pipeline/backend/verifiers/__init__.py

from typing import Dict, Any, Optional
import importlib
import inspect

# Registry of all verifiers
VERIFIERS = {}

def register_verifier(subject: str):
    """Decorator to register verifiers."""
    def decorator(func):
        VERIFIERS[subject] = func
        return func
    return decorator

async def verify_answer(
    question: str,
    answer: str,
    faculty: Optional[str] = None
) -> Dict[str, Any]:
    """
    Verify an answer using the appropriate academic engine.
    """
    # Detect subject from question
    subject = detect_subject(question, faculty)

    # Get the verifier function
    verifier = VERIFIERS.get(subject)
    if not verifier:
        return {
            "verified": False,
            "answer": answer,
            "engine_used": "none",
            "confidence": 0.0
        }

    # Run verification
    try:
        result = await verifier(question, answer)
        result["engine_used"] = subject
        return result
    except Exception as e:
        return {
            "verified": False,
            "answer": answer,
            "engine_used": subject,
            "confidence": 0.0,
            "error": str(e)
        }
# academic_pipeline/backend/verifiers/subject_detection.py

KEYWORDS = {
    "math": ["integrate", "derivative", "calculus", "matrix", "equation"],
    "physics": ["force", "velocity", "energy", "momentum", "gravity"],
    "chemistry": ["molecule", "bond", "reaction", "acid", "base"],
    "engineering": ["beam", "stress", "load", "circuit", "voltage"],
    "medicine": ["cell", "tissue", "disease", "symptom", "blood"],
    "agriculture": ["crop", "soil", "water", "yield", "fertilizer"],
    "law": ["act", "section", "legal", "court", "contract"]
}

def detect_subject(question: str, faculty: Optional[str] = None) -> str:
    """
    Detect the subject of a question using keyword matching.
    """
    if faculty and faculty in KEYWORDS:
        return faculty

    question_lower = question.lower()
    scores = {}

    for subject, keywords in KEYWORDS.items():
        count = sum(1 for kw in keywords if kw in question_lower)
        if count > 0:
            scores[subject] = count

    if not scores:
        return "general"

    # Return the subject with the highest keyword score
    return max(scores, key=scores.get)
```

UniUI's frontend is built with Next.js 14 and uses a strict, Socratic UI design.

``` js
// app/ask/page.tsx

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { api } from '@/lib/api'
import { Mascot } from '@/components/Mascot'
import { StreamingText } from '@/components/StreamingText'

export default function AskPage() {
  const [question, setQuestion] = useState('')
  const [answer, setAnswer] = useState('')
  const [loading, setLoading] = useState(false)
  const router = useRouter()

  const handleAsk = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!question.trim()) return

    setLoading(true)
    setAnswer('')

    try {
      // Use streaming for real-time answers
      const response = await api.askStream({
        question: question,
        faculty: 'engineering'
      })

      // Stream the answer token by token
      const reader = response.body?.getReader()
      const decoder = new TextDecoder()

      while (true) {
        const { done, value } = await reader!.read()
        if (done) break

        const chunk = decoder.decode(value)
        const lines = chunk.split('\n')
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6)
            if (data === '[DONE]') break
            try {
              const parsed = JSON.parse(data)
              setAnswer((prev) => prev + parsed.token)
            } catch {
              // Ignore parse errors
            }
          }
        }
      }
    } catch (error) {
      console.error('Error:', error)
      setAnswer('Sorry, I encountered an error. Please try again.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="container mx-auto px-4 py-8 max-w-3xl">
      {/* Mascot with strict tone */}
      <Mascot state={loading ? 'thinking' : 'idle'} />

      <h1 className="text-2xl font-bold text-white mb-2">
        Ask a Question
      </h1>
      <p className="text-gray-400 mb-6">
        Be precise. Vague questions will be rejected.
      </p>

      <form onSubmit={handleAsk} className="space-y-4">
        <div className="flex gap-4">
          <textarea
            className="flex-1 bg-gray-900 text-white rounded-lg px-4 py-3 border border-gray-700 focus:border-[#7c3aed] focus:outline-none resize-none"
            placeholder="What do you want to learn?"
            rows={3}
            value={question}
            onChange={(e) => setQuestion(e.target.value)}
            disabled={loading}
          />
        </div>

        <button
          type="submit"
          className={`w-full bg-[#7c3aed] text-white font-medium py-3 rounded-lg transition-colors ${
            loading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[#6d28d9]'
          }`}
          disabled={loading}
        >
          {loading ? 'Thinking...' : 'Ask'}
        </button>
      </form>

      {answer && (
        <div className="mt-6 p-4 bg-gray-900 rounded-lg border border-gray-700">
          <h3 className="text-sm text-gray-400 mb-2">Answer:</h3>
          <div className="prose prose-invert max-w-none">
            <StreamingText text={answer} />
          </div>
        </div>
      )}
    </div>
  )
}
js
// components/StreamingText.tsx

'use client'

import { useEffect, useRef, useState } from 'react'

export function StreamingText({ text }: { text: string }) {
  const [displayText, setDisplayText] = useState('')
  const indexRef = useRef(0)

  useEffect(() => {
    // Reset when text changes
    if (text !== displayText) {
      indexRef.current = 0
      setDisplayText('')
    }

    // Animate token by token
    const interval = setInterval(() => {
      if (indexRef.current < text.length) {
        setDisplayText((prev) => prev + text[indexRef.current])
        indexRef.current += 1
      } else {
        clearInterval(interval)
      }
    }, 15) // 15ms per token = ~66 tokens/second

    return () => clearInterval(interval)
  }, [text])

  return (
    <div className="whitespace-pre-wrap">
      {displayText}
      <span className="animate-pulse">▌</span>
    </div>
  )
}
```

Nigerian internet is unreliable. Students cannot depend on being online.

```
// public/sw.js (generated by next-pwa)

// Cache all assets for offline use
const CACHE_NAME = 'uniui-v1'
const ASSETS_TO_CACHE = [
  '/',
  '/ask',
  '/conversations',
  '/_next/static/...',
  '/icon-192.png',
  '/icon-512.png'
]

// Install: cache assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(ASSETS_TO_CACHE))
      .then(() => self.skipWaiting())
  )
})

// Activate: clean old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      )
    })
  )
})

// Fetch: serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => response || fetch(event.request))
      .catch(() => {
        // Offline fallback
        if (event.request.mode === 'navigate') {
          return caches.match('/offline')
        }
        return new Response('Offline', { status: 503 })
      })
  )
})
python
// lib/encryption.ts

import nacl from 'tweetnacl'
import { encodeBase64, decodeBase64 } from 'tweetnacl-util'

// Derive encryption key from passphrase
export function deriveKey(passphrase: string): Uint8Array {
  const encoder = new TextEncoder()
  const data = encoder.encode(passphrase)
  return nacl.hash(data).slice(0, 32) // PBKDF2 would be better
}

// Encrypt data
export function encryptData(obj: any, key: Uint8Array): string {
  const json = JSON.stringify(obj)
  const data = new TextEncoder().encode(json)
  const nonce = nacl.randomBytes(24)
  const encrypted = nacl.secretbox(data, nonce, key)
  const combined = new Uint8Array(nonce.length + encrypted.length)
  combined.set(nonce)
  combined.set(encrypted, nonce.length)
  return encodeBase64(combined)
}

// Decrypt data
export function decryptData(encryptedB64: string, key: Uint8Array): any {
  const combined = decodeBase64(encryptedB64)
  const nonce = combined.slice(0, 24)
  const encrypted = combined.slice(24)
  const decrypted = nacl.secretbox.open(encrypted, nonce, key)
  if (!decrypted) throw new Error('Decryption failed')
  const json = new TextDecoder().decode(decrypted)
  return JSON.parse(json)
}

// Store encrypted data in IndexedDB
export async function storeEncrypted(key: string, data: any, passphrase: string) {
  const derivedKey = deriveKey(passphrase)
  const encrypted = encryptData(data, derivedKey)
  await localforage.setItem(`enc_${key}`, encrypted)
}

// Load encrypted data from IndexedDB
export async function loadEncrypted(key: string, passphrase: string) {
  const encrypted = await localforage.getItem<string>(`enc_${key}`)
  if (!encrypted) return null
  const derivedKey = deriveKey(passphrase)
  try {
    return decryptData(encrypted, derivedKey)
  } catch {
    return null // Wrong passphrase
  }
}
```

| Metric | Performance |
|---|---|
Answer latency |
1.5s average (LLM generation) |
Verification latency |
200ms additional |
Total time |
1.7s from question to verified answer |
RAG retrieval |
150ms (Qdrant + Meilisearch) |
Concurrent users |
1000 tested |
Offline cache size |
< 50MB per user |
Encryption overhead |
< 5ms per operation |

UniUI is live at [app.uniui.com.ng](https://app.uniui.com.ng)

For students in Federal University of Technology Owerri

It will expand to the whole Southern Nigeria Soon

UniUI is a platform that **proves you can build a verified AI tutor without a massive team**. With modern AI tools, open-source libraries, and a clear product vision, a single founder can build something that solves a real problem for 1.5 million students.

**The stack works. The verifications work. The students are using it.**

**If you're building in EdTech, let's connect. Drop a comment below.**

*Built with ❤️ for Nigerian students. Because learning shouldn't be a struggle.*


