Case study // 08
A counselling agent that answers from a catalogue, not from memory.
// CONTEXT
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
// DECISIONS
18 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.
Prompting 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.
Embedding 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.
Vector-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.
On 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.
Letting 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.
The 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.
Authenticating 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.
The 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.
One-shot prompting, which restarts the relationship on every message and makes the student re-establish their situation each time they ask a follow-up.
The 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.
Assigning 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
// REFLECTION
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.