All writing

Writing / AI assistants · RAG · Rails

Letting an AI assistant query school records without showing them to the model

How ByteLift's assistant answers from role-specific manuals and looks up live class data, while the language model only picks a tool and never reads a student's result.

ByteLift is a learning management system for schools. I built JKEN, the assistant inside its web application, for students, teachers and administrators.

People ask it two kinds of questions:

  • "How do I publish an exam?" The answer is in the manual.
  • "Who are the top three in my Grade 8 Science class?" The answer is in the database.

In an earlier project I kept everything on one server with local models. JKEN uses a hosted model from OpenAI, so the design question changed. It became: what is allowed to leave the application?

The rules I settled on:

  1. A manual answer must cite a section the person is allowed to open. Otherwise the assistant gives one fixed refusal.
  2. For live data, the model only chooses what to look up. The application runs the query and writes the answer. The model never reads the result.
  3. The person's role is worked out on the server for every question. Nothing the browser sends can widen it.

The manual is Markdown in Git

The manuals live in the same repository as the code, in four folders: shared, student, teacher and administrator. Each file starts with a short header that says who may read it. A change to a feature and the change to its manual go in the same pull request.

Publishing is one command, and it follows the pattern I use for deployments:

  1. Validate every file and split it at its headings.
  2. Upload each section to a vector store with its publication id, audience, version and checksum.
  3. Wait for indexing, then check that every section can be retrieved through its own access filters.
  4. Only then switch the active publication to the new one.

A failure at any step leaves the previous publication live. Old publications are kept, so a citation in last month's conversation still opens the text the person actually saw. A rollback changes one pointer.

Role filters, applied twice

Each role can search a fixed part of the manual:

Role Manual content it can search
Student Shared and student
Teacher Shared, student and teacher
Trainer administrator Shared, plus administrator pages marked as shared or for trainers
Full administrator Everything

The filter is part of the search request, so content for another role is never retrieved. Then the application checks every citation in the answer again: it must belong to the active publication, and to an audience this person may read. If no citation survives, the person gets exactly this:

I couldn't find that in the ByteLift manuals.

The answer is not streamed to the browser while the model writes it. The application receives the whole answer, runs it through moderation, validates the citations and stores it. Only then does the browser reveal it gradually. A sentence that fails a check never appears on screen.

Live data: the model picks, the application answers

Teachers and full administrators can also ask about their classes. For them, and only for them, the request to the model includes eight tools, such as "query students", "query exams" and "query class performance".

A tool's arguments are deliberately narrow: an operation chosen from a fixed list, a few search words and a result limit. There are no table names, no column names, no ids and no SQL.

The model's whole job is to choose a tool and fill in those arguments. The application then:

  • Rejects the reply if it names an unknown tool, makes more than three calls, or mixes a manual search with a data lookup.
  • Builds the query from the person's own classes. A teacher's scope starts here, and every other query joins from it:
def subject_classes
  relation = SubjectClass.active
  relation = relation.where(teacher_id: user.teacher&.id) if user.teacher?
  relation
end
  • Calculates and formats the answer itself. Class rankings reuse the same calculation as the leaderboard teachers already see, so the assistant and the application cannot disagree.

The result is never sent back to the model to be put into nicer words. In later turns of the conversation, the model's history contains a placeholder where the result was.

The cost is that live answers read like a report and not like a chat. I think that is the right trade for a list of students' scores.

Some data is out of reach whatever the question: contact details, birthdays, addresses, exam questions, submitted answers and teachers' remarks. The tools have no way to ask for them.

Follow-up questions without sending names

A live answer lists things: students, classes, exams. Each one carries a control to ask a follow-up about it.

That control holds an encrypted, signed reference. It is tied to the person and the conversation, and it expires after 24 hours. When it is used, the application resolves it locally and checks the person's access to that record again. What the model receives is a generic placeholder, not the student's name.

One conversation showed where my first version fell short. A teacher picked an exam, picked its top student, and asked about that student's performance. The assistant asked which class. The teacher chose one, and got a summary of the class. The student and the original question had been dropped, because a reference could carry only one thing.

A reference can now carry up to five records plus the operation that is waiting for them. Choosing the class resumes the original question, and no second model call is needed.

The release that left production with no manual

The first production publish failed in a way my own safety check caused.

Every section indexed correctly. Then a test question, "Where do I find exams?", did not return the section I expected. The publish was marked as failed and cleaned up, as designed. But this was the first publish, so there was no earlier publication to fall back to, and production had no manual at all.

Two things were wrong. The manual had no section that answered that question directly, and the menu says "Tests" where the page says "Exam". I added the section and explained the two words.

The larger mistake was in the process. I had made a ranking test, which can vary from run to run, a condition for publishing. Publishing now depends only on checks that give the same result every time: every section indexed, every section retrievable through its filters. The natural-language questions run as a separate evaluation that can block a launch but cannot leave an environment empty.

Limits

  • One active conversation per account. It ends after 30 minutes without activity, or 24 hours in total.
  • The model receives at most the last six question-and-answer pairs, capped at 12,000 characters, and requests are sent with storage turned off.
  • 10 questions a minute and 100 a day per account. A question can be 2,000 characters long.
  • Questions and answers both pass moderation. If moderation is unavailable, nothing is answered.
  • The assistant is not available while a student has an exam in progress.
  • Live answers are deleted after 90 days.

Messages are encrypted in the database. The audit log records who viewed or deleted a transcript, and never what it said.

What it does not do

  • It cannot change anything. Every tool is read-only.
  • A name the person types into a question does reach the model, because the model needs the sentence to choose a tool. Only selected records and results are held back.
  • Students and trainer administrators get manual answers only.
  • It does not cover attendance, trends or recommendations.
  • The manual answers and the live data have separate switches, so one can be released without the other.

The evaluation set has 64 manual questions across the four roles, and 22 live-data questions in English and Filipino. The local test suite for this feature is 114 tests.

What I would tell someone building the same thing

  • Decide what may leave your application before you choose a model.
  • Let the model choose the query. Do not let it write the query or read the result.
  • Check access where the data is fetched, and check it again on what comes back.
  • Publish knowledge the way you deploy code: staged, verified, and with the last version kept.
  • Do not make a test that can vary a condition for a release step that must not fail.