Case study // 08
Aura Academic Agent
A counselling agent that answers from a catalogue, not from memory.
- Role
- Engineer
- Year
- 2026
- Category
- AI
- Status
- Live
- Python
- FastAPI
- Gemini
- ChromaDB
- SQLAlchemy
// CONTEXT
The problem
Aura Academic answers the question a school careers adviser exists to answer: what should I study, where, and can I afford it. The people asking are students who arrive with something as vague as “I like maths, what should I do?”, and the answer has to include real courses, real fees and real scholarship deadlines. It runs as one container on a free Hugging Face Space — a FastAPI service, a SQLite file, a vanilla-JavaScript front end and a Gemini key — with no backend team behind it and no institution vouching for what it says.
// CONSTRAINTS
What made it hard
- A general model will produce a tuition figure, an acceptance rate and an application deadline with complete confidence and no source. For someone making a five-figure decision, a plausible fabrication is worse than an admission of ignorance.
- One container, one process, an ephemeral filesystem, and an API key on a free tier whose quota can run out in the middle of a conversation.
- The visitor has to be able to use it immediately — a sign-up wall in front of a demonstration counsellor loses exactly the person it was built for — while anything remembered has to belong to an account.
- Counselling is not one question. The second question depends on the first, so the model has to be handed the conversation rather than left to infer it.
// DECISIONS
What I chose, and what I didn't
- 01
Keep the facts in SQL, retrieve them per question, and instruct the model never to invent a statistic.
Why18 universities, 21 courses, 15 scholarships and 15 careers are held as SQLAlchemy rows and copied into a ChromaDB collection at startup, so a reply about fees or deadlines is quoting a record rather than recalling a plausible number. Grounding also makes the catalogue the unit of correction: fixing an out-of-date fee is an edit to one row, not an attempt to argue with a language model.
Instead ofPrompting alone, which answers every question fluently and can be trusted on none of them — the failure mode here is not a wrong answer, it is a confident one.
- 02
Retrieve down two paths at once: vector search for meaning, a direct scan for names.
WhyEmbedding search is what catches “what should I study to work with money” and returns the finance courses, but it will happily rank a semantically similar scholarship above the one the student actually named. The literal pass over the four tables guarantees an exact name reaches the prompt; the two result sets are merged, deduplicated and capped at ten chunks so the context stays small enough to be read.
Instead ofVector-only retrieval, which is elegant right up to the moment a student asks about the Chevening Scholarship by name and is told about a different one.
- 03
Treat the model being unavailable as a normal operating state, and degrade to the database.
WhyOn a free key, quota exhaustion is not an exception, it is a Tuesday. When the call fails, the chat route answers from SQL with keyword-matched listings of real courses, careers or scholarships, and the assessment falls back to a deterministic mapping from answers to career suggestions. A student who sees five genuine scholarships instead of a generated paragraph has still been helped; a student who sees an error toast has not.
Instead ofLetting the exception surface as a failed request, which converts a rate limit into a dead product for as long as the quota takes to reset.
- 04
Let anyone chat as a guest, and attach only memory to an account.
WhyThe dependency that resolves the user returns None rather than raising on a missing token, so the chat endpoint serves a visitor who has never registered. Everything that persists on their behalf — saved conversations, the profile, assessment history — requires a bcrypt-backed login and a signed token. The registration prompt then arrives when the visitor has a reason to accept it, which is after the tool has proved useful rather than before.
Instead ofAuthenticating the chat route like every other route, which is one line simpler and asks a stranger to create an account to find out whether the thing works.
- 05
Assemble each reply from four inputs — system prompt, student profile, retrieved records and the last twenty messages — rather than sending the question alone.
WhyThe profile is what makes the same question produce different advice: an A-level student with a $5,000 ceiling and a graduate with no budget constraint should not be told the same thing about the same course. The assessment closes the loop by writing its top career matches back into that profile, so a quiz taken once quietly improves every conversation afterwards, and the twenty-message window is what lets “what about the fees for that one?” resolve to the course discussed two turns ago.
Instead ofOne-shot prompting, which restarts the relationship on every message and makes the student re-establish their situation each time they ask a follow-up.
- 06
Escape the model’s output before formatting it into HTML.
WhyThe client renders replies through a hand-written markdown formatter, and the first thing that formatter does is replace the ampersands and angle brackets — the markdown rules only run afterwards. Model output is untrusted input: it is assembled partly from what a user typed, and a model can be talked into emitting a tag. Escaping first means the worst case is a visible piece of markup rather than script running in the reader’s session.
Instead ofAssigning the reply straight into innerHTML because it came from our own model, which is how a language model becomes a delivery mechanism for whatever was pasted into it.
// OUTCOME
What came of it
- A 20-endpoint FastAPI service over ten tables — auth, chat, conversations, profile, four catalogue endpoints, the assessment flow, per-message feedback and an admin analytics summary.
- 69 catalogue records seeded into both SQL and a persistent vector index at startup, idempotently, so a restart on an ephemeral filesystem rebuilds the knowledge base without duplicating it.
- A ten-question assessment that returns a typed result — a personality label, three scored career matches with reasons, three courses, scholarships, study tips and next steps — parsed from the model’s JSON, with a deterministic fallback when that parse fails.
- Voice in both directions through the browser’s own speech APIs, on a 939-line front end with no framework and no build step.
- The whole thing ships as one Docker image running uvicorn as a non-root user, which is what makes a free Space a viable home for it.
// REFLECTION
What I'd do differently
The retrieval step loads every row of all four tables into Python on every single message and substring-matches them there. At 69 records that is invisible, and it is also precisely the shape of code that becomes the bottleneck at 6,900 — the matching belongs in SQL, or in the vector index that already covers it. The related mistake is worse because it is silent: the collection is seeded only when it is empty, so once it exists, a corrected fee in the database is invisible to retrieval forever, and a version stamp or a content hash would have cost about ten lines. The last thing I would change is the repository itself. It still carries the previous incarnation of this project — a README describing a scikit-learn intent classifier that no longer exists, a scheduler importing a training module that was deleted, an empty module and the old template. None of it runs, so none of it ever failed loudly, which is exactly how it survived; and the README is the part that actually costs something, because it is the first thing a reader opens and it describes a different program.
