← Back to Dashboard
Technical Report

How the School AI Analytics
Dashboard Works

An end-to-end walkthrough of the whole repository β€” from the raw SQLite database and the multi-agent Mistral pipeline, through file uploads and indexing, to the live HTTPS deployment behind nginx.

1 What This Project Is

The School AI Analytics Dashboard is a self-contained web application that lets school administrators ask plain-English questions about their students, marks, fees, hostel and staff data and get instant, formatted answers. There is no SQL to write and no IT ticket to file β€” a principal simply types "How many students are yet to pay hostel fees?" and the system routes the request, reads the relevant database tables, asks a Large Language Model (Mistral) to analyse them, and returns conversational Markdown with charts and rankings.

It is built from three layers that all live in one folder:

πŸ—„οΈ Data Layer

A local SQLite database (school.db) holding 13 relational tables with 500 generated students, plus a ChromaDB vector store that indexes the schema for semantic lookups.

🧠 Intelligence Layer

A FastAPI backend (server.py) that routes questions to tables and calls the Mistral API through a paced, retry-protected client.

πŸ–₯️ Presentation Layer

A single-page vanilla-JS dashboard (index.html) with Overview, AI Assistant, Data Tables and file-upload views β€” no build step, no framework.

πŸš€ Delivery Layer

uvicorn serves the app on a local port; nginx reverse-proxies it to the public domain with a Let's Encrypt SSL certificate.

2 The Files in the Repository

FileRole
server.pyThe heart of the app. FastAPI server defining every API route, the Mistral client, the question-to-table router, the multi-agent prompt, and the file-upload / indexing engine.
setup_db.pyBuilds school.db from scratch β€” creates the 13 tables and generates 500 students, marks, fees, hostel, transport, staff and event records. Also writes the schema reference files.
index.pyBuilds the ChromaDB vector database (vector_db/) by chunking the schema reference and embedding it into a school_context collection.
index.htmlThe entire front-end: layout, styling, and all client-side JavaScript that calls the API endpoints.
how_it_works.htmlThis report.
start.py / stop.pyCross-platform launch/stop helpers. start.py frees port 8000, runs the DB + index builders, then launches uvicorn with live reload. (See the deployment note in Β§8.)
2_start.ps1 / 3_stop.ps1Windows PowerShell equivalents of the start/stop scripts.
api.txtHolds the line MISTRAL_API_KEY=…. Read at request time to authenticate with Mistral.
requirements.txtPython dependencies: fastapi, uvicorn, requests, chromadb, sentence-transformers, pandas, openpyxl, pypdf, python-multipart, etc.
db_schema_reference.txt
db_schema_mermaid.txt
Auto-generated schema maps (one human-readable, one Mermaid ER diagram) produced by setup_db.py and consumed by index.py.

3 The Database

setup_db.py wipes any old school.db and rebuilds a fully relational schema, then seeds it with deterministic synthetic data so every install looks identical. The 13 core tables are:

TableHolds
gradesThe four grade levels (Grade 5, Middle 8, High 10, Senior 12).
students500 students β€” name, gender, DOB, address, day-scholar vs hostel flag, grade.
Subjects_GradeSubjects mapped to each grade.
MarksTwo subject scores per student (the basis of all leaderboards and averages).
AdmissionFees / HostelDetailsTuition and hostel fee records. A null FeesPaid means "not yet paid".
TransportOpted / TransportFeesDetailsBus route opt-ins and transport fee payments.
Staff / Staff_SubjectFaculty members and the subjects they teach.
PTM_ReportParent-teacher meeting comments and ratings.
EventsSchool events with dates.
Because the seed data is generated from fixed formulas (not random), the dashboard, leaderboards and "top student" are reproducible across machines.

The vector index (ChromaDB)

index.py reads db_schema_reference.txt, splits it on the word Table: into per-table chunks, and stores them in a persistent ChromaDB collection called school_context under vector_db/. This gives the system a semantic memory of the schema's structure that can scale into true retrieval-augmented generation later.

4 The AI Pipeline β€” What Happens When You Ask a Question

When you type a question in the AI Assistant tab, the front-end POSTs it to /api/chat. The backend then runs an agentic pipeline that mimics a human analyst:

1️⃣
Orchestrator / Router. The question is lower-cased and keyword-matched to decide which tables are relevant. "hostel" β†’ HostelDetails + students; "marks/score/chart" β†’ Marks + students + Subjects_Grade; "staff/teacher" β†’ Staff tables; "event" β†’ Events; anything else β†’ students.
↓
2️⃣
Data Query Agent. Only the routed tables are read from SQLite (up to 500 rows each) and serialised into a compact text context β€” keeping the prompt focused and cheap.
↓
3️⃣
Upload Context Merge. Any files you have indexed (see Β§5) are pulled in and appended, so the model can answer about your documents alongside the school tables.
↓
4️⃣
Analytics + Report Agent. A single carefully-built prompt is sent to Mistral (mistral-small-latest). The system prompt instructs it to write clean Markdown, auto-correct casual typos (hospitalβ†’hostel, boysβ†’Male), treat null hostel fees as unpaid, and draw text bar-charts with β–ˆ blocks when a chart is requested.
↓
5️⃣
Response. The Markdown answer is returned to the browser, rendered with marked.js, while a diagnostics object (routed tables, upload flag, status) is logged to the F12 console.

The rate-managed Mistral client

Every call goes through ask_general_mistral(), which is deliberately defensive about the free-tier API limits:

5 File Upload & Indexing

The dashboard lets you upload a file and ask the AI about it. Uploads are not just attached to a single message β€” they are indexed into the same school.db so they become part of the analytics workspace. The handling depends on the file type:

TypeHow it is extracted & indexed
CSV / ExcelParsed with pandas into a real, queryable SQLite table named upload_<id>_<name>. Excel workbooks with multiple sheets are merged with a __sheet column.
PDFText extracted page-by-page with pypdf and stored as document text.
Text / Markdown / JSON / logDecoded and stored directly.
Images (png, jpg, webp, gif…)Sent as base64 to Mistral's Pixtral vision model (pixtral-12b-2409), which extracts all text, tables and numbers from the picture.

A registry table, UploadedDocuments, tracks every upload (filename, type, linked table, extracted text, timestamp). On each chat request, gather_uploaded_context() pulls the most recent uploads (within a character budget) into the model's context. Deleting an upload also drops its generated data table.

Re-running setup_db.py rebuilds school.db from scratch and therefore clears all uploaded files. Normal restarts keep them.

6 The Front-End

index.html is a single self-contained page β€” no framework, no bundler. On load it fires four fetches (loadStats, loadTables, loadQuickQuestions, loadUploads) and renders three switchable panels:

7 API Reference

All routes are served under the base path /ai-azure-table-query.

EndpointPurpose
GET/api/statsTotals, class average, top student, leaderboard and subject averages for the Overview tab.
GET/api/tablesEvery table with its rows and columns for the Data Tables tab.
POST/api/chatThe main Q&A endpoint β€” runs the routing + Mistral pipeline.
GET/api/quick-insightsThe list of suggested "Quick" questions.
GET/api/ai-statusLive health of every AI provider (key valid? online / rate-limited / offline) β€” powers the AI Status tab. Pings each provider's /models endpoint, so it costs no tokens.
POST/api/uploadAccepts a file (≀25 MB), extracts and indexes it.
GET/api/uploadsLists indexed uploads.
DELETE/api/uploads/{id}Removes an upload and drops its data table.
GET/  ·  /how-it-worksServe the dashboard and this report.

8 How It Is Deployed

This instance runs as a managed background service behind nginx with SSL, rather than via the repo's start.py (which is geared toward local foreground development and binds port 8000):

🌐
Browser β†’ https://ai-school.justsimple.online
↓ TLS (Let's Encrypt)
πŸ”
nginx terminates SSL, redirects HTTP→HTTPS, and reverse-proxies to the app.
↓ proxy_pass 127.0.0.1:8100
βš™οΈ
uvicorn runs server:app, managed by a systemd service (ai-school.service) that auto-restarts on failure and starts on boot.
↓
πŸ—„οΈ
SQLite + ChromaDB + Mistral API back the running application.

Managing the service

# status / logs / restart
sudo systemctl status ai-school
sudo journalctl -u ai-school -f
sudo systemctl restart ai-school

Running it locally (development)

python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
echo "MISTRAL_API_KEY=your_key_here" > api.txt
python3 start.py   # builds DB + index, launches uvicorn
The pipeline is intentionally model-agnostic. Swapping providers only touches the LLM client functions (ask_llm() / ask_vision()) β€” the routing, data layer and front-end stay the same.
This deployment runs on a free, multi-provider chain with automatic failover. Text answers try Cerebras (gpt-oss-120b) first, then Groq (llama-3.3-70b-versatile), then Google Gemini (gemini-2.5-flash). ask_llm() walks this chain provider-by-provider β€” if one returns a rate-limit (429) or too-large (413) error, it instantly switches to the next β€” so a single provider running out of free quota never takes the dashboard down. The order is configured by LLM_FALLBACK_ORDER in api.txt. Image uploads are read by Gemini's vision model, falling back to Mistral Pixtral (Cerebras and Groq have no vision model).
You can see the switching happen. Every AI answer in the dashboard carries a small badge showing exactly which provider and model produced it (e.g. ⚑ Cerebras Β· gpt-oss-120b), and a πŸ”„ switched β€” Cerebras, Groq exhausted note appears when it had to fail over. The full attempt trail is also logged to the browser's F12 console and returned in the API's provider_attempts field.

9 Using Claude (Anthropic) Instead of Mistral

The app currently calls Mistral, but because the pipeline is model-agnostic you can point it at Claude instead. First, an important billing distinction that trips a lot of people up:

A Claude Pro / Max subscription (e.g. the $100/month Max plan) is not the same as API access. The subscription powers the claude.ai apps and Claude Code β€” it does not give you a programmatic key for the Messages API that an app like this needs. API usage is billed on a separate developer account (the Anthropic Console) with its own prepaid credits. The two bills are independent.

Step 1 β€” Get an API key (separate from your subscription)

  1. Go to console.claude.com β†’ Billing and add a small amount of prepaid credit (a few dollars is plenty for this app).
  2. Open API Keys β†’ Create Key. It looks like sk-ant-api03-….
  3. Treat it like a password β€” never commit it to a public repo.

Step 2 β€” Models & pricing

ModelModel IDInput /1MOutput /1MBest for
Claude Opus 4.8claude-opus-4-8$5$25Most capable
Claude Sonnet 4.6claude-sonnet-4-6$3$15Balanced (good default here)
Claude Haiku 4.5claude-haiku-4-5$1$5Cheapest / fastest

For a lightweight analytics Q&A like this, the token volume per question is small, so real-world cost is a few cents regardless of model.

Step 3 β€” Wire it into the app

Install the official SDK and store the key alongside the existing one:

# add to requirements.txt and install
pip install anthropic

# api.txt β€” add this line next to MISTRAL_API_KEY
ANTHROPIC_API_KEY=sk-ant-api03-...

Then add a Claude version of the LLM call in server.py and use it wherever ask_general_mistral() is called today:

import anthropic

client = anthropic.Anthropic(api_key=get_anthropic_key())

def ask_general_claude(messages_payload):
    # split the system prompt out of the message list
    system = ""
    msgs = []
    for m in messages_payload:
        if m["role"] == "system":
            system = m["content"]
        else:
            msgs.append(m)

    resp = client.messages.create(
        model="claude-opus-4-8",   # or claude-sonnet-4-6 / claude-haiku-4-5
        max_tokens=4096,
        system=system,
        messages=msgs,
    )
    return "".join(b.text for b in resp.content if b.type == "text")
The Anthropic SDK handles retries and rate-limit back-off for you, so the hand-rolled 429 pacing in ask_general_mistral() isn't needed on the Claude path. Note that the latest Opus/Sonnet models don't accept a temperature parameter β€” just omit it (the Mistral code's temperature: 0.1 has no Claude equivalent here).

Images: the upload feature's vision step (currently Pixtral) can also move to Claude β€” it reads images natively. Send the picture as an image content block:

messages=[{"role": "user", "content": [
    {"type": "image", "source": {
        "type": "base64", "media_type": "image/png", "data": image_b64}},
    {"type": "text", "text": "Extract all text and data from this image."}
]}]
Bottom line: your $100 Max subscription can't be plugged into this app as-is β€” but spinning up a separate API key with a few dollars of credit takes a couple of minutes, and the three changes above (install SDK, add key, swap the call) are all it takes to run the whole dashboard on Claude.

10 Free AI API Options

If you'd rather not pay at all, many providers offer a genuinely free tier you can use in place of Mistral or Claude. Because this app already calls an OpenAI-style /chat/completions endpoint, switching to most of them means changing only three things: the URL, the model name, and the API key. The expanded list below is drawn from the community awesome-free-llm-apis directory.

ProviderWhere to get a keyExample free model(s)Free limits (approx.)
Google Geminiaistudio.google.comgemini-2.5-flash, gemini-2.0-flash15 RPM / 1,500 per day (Flash) β€” very generous
Groqconsole.groq.comllama-3.3-70b-versatile, Llama 4, Kimi K230 RPM / 14,400 per day; ~6k tokens/min
Cerebrascloud.cerebras.aigpt-oss-120b, Qwen3 235B, Llama 3.3 70B30 RPM / 14,400 per day; 60k tokens/min β€” fastest
NVIDIA NIM  β˜…build.nvidia.comLlama 3.3 70B, Qwen3 235B, DeepSeek-R140 RPM (replenishing credits) β€” top-tier large models
OpenRouteropenrouter.ai30+ models ending :free (DeepSeek R1, GPT-OSS…)20 RPM / 200 per day β€” biggest model catalog
Zhipu AI (GLM)open.bigmodel.cnglm-4.5-flash, glm-4.6v-flash (vision)Free "Flash" tier; OpenAI-compatible
Cloudflare Workers AIdash.cloudflare.comLlama 3.3 70B, Qwen QwQ 32B, +47 more~10,000 "neurons"/day β€” generous daily quota
GitHub Modelsgithub.com/marketplace/modelsgpt-4o, Llama 3.3 70B, DeepSeek-R1, Phi-410–15 RPM / 50–150 per day β€” uses your GitHub token
Hugging Facehuggingface.coLlama 3.3 70B, Qwen2.5 72B, Mistral 7B~$0.10 credits/month (auto-refresh)
Pollinations AI  β˜…pollinations.aiopenai, gemini, mistral + image/audio/videoNo signup needed; per-IP hourly limit
Mistralconsole.mistral.aimistral-small-latest1 req/sec; 1B tokens/month β€” app's current vision fallback
Ollama (local)ollama.comllama3.2, qwen2.5Unlimited β€” runs on your machine, no key, no internet
Trade-offs of free tiers: they come with rate limits (slower, fewer requests per minute) and many providers train on the data you send. That's fine for this demo's synthetic records, but don't push real student PII through a free tier. Free models also tend to be smaller, so answers may be a little less polished than Claude/GPT-class models. Availability and limits change often β€” check each provider's current docs.

Drop-in example β€” switching to Groq

Groq is OpenAI-compatible, so the change mirrors the existing ask_general_mistral() almost exactly β€” a new URL, model, and key:

# api.txt
GROQ_API_KEY=gsk_...

# server.py β€” same shape as ask_general_mistral(), three values changed
def ask_general_groq(messages_payload):
    api_key = get_key("GROQ_API_KEY")
    payload = {
        "model": "llama-3.3-70b-versatile",
        "messages": messages_payload,
        "temperature": 0.1,
    }
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
    r = requests.post("https://api.groq.com/openai/v1/chat/completions",
                      headers=headers, json=payload, timeout=60)
    return r.json()["choices"][0]["message"]["content"]

The same function works for OpenRouter (https://openrouter.ai/api/v1/chat/completions), Cerebras (https://api.cerebras.ai/v1/chat/completions), and Gemini's OpenAI-compatible endpoint (https://generativelanguage.googleapis.com/v1beta/openai/chat/completions) β€” just swap the URL, model name, and key.

Best picks (β˜…): beyond the three providers this app already chains (Cerebras / Groq / Gemini), the two strongest additions from the directory are NVIDIA NIM β€” top-tier large models (Qwen3 235B, DeepSeek-R1) at a comfortable 40 RPM, ideal as an extra failover link β€” and Pollinations AI, the only option that needs no signup at all and also generates images, audio and video. To add either, just append its key + model to api.txt and drop its name into the LLM_FALLBACK_ORDER chain. For full privacy with no usage limits, run Ollama locally and point the app at http://localhost:11434.

11 Media Generation in the Chat (Pollinations)

The AI Assistant doesn't just answer questions β€” it can also generate images, audio and video right inside the chat, powered by Pollinations AI. Just describe what you want in plain English:

How it routes

Every chat message first passes through detect_generation(), which scans for media keywords (image / picture / photo / logo / draw → image; audio / speech / voice / say / tts / song → audio; video / animation / clip / gif → video). If a media request is detected the message is sent to Pollinations instead of the analytics pipeline; otherwise it flows to the normal data-analysis path. Words like chart and graph are deliberately not triggers β€” those stay as text bar-charts in the analytics engine.

TypePollinations endpointModelReturned as
Image/v1/images/generationsfluxbase64 JPEG → saved & served
Audio/v1/chat/completionsopenai-audiobase64 MP3 + transcript
Video/v1/images/generationsltx-2MP4

The backend (generate_media()) calls Pollinations with the POLLINATIONS_API_KEY from api.txt, decodes the result, writes it to the generated/ folder, and returns a URL the browser renders as an <img>, <audio> or <video> element inside the AI's reply.

Pollinations uses a "pollen" credit balance. Image generation is cheap, but audio and especially video cost more pollen per request. If the account balance runs low, those requests return a clear "insufficient balance" message in the chat instead of failing silently β€” top up the balance at pollinations.ai to re-enable them.