My part of the story #
I built the conversational experience, the visual C debugger, and the AI orchestration described here. Socratic Tutor was our capstone project: my partner, Manuel Rodríguez, built the activities feature, and we developed and tested the larger thesis together. My work explored a deceptively difficult question: can an AI tutor help a programming student move forward without quietly doing the learning for them?
Guiding an LLM to teach, not just solve #
An LLM generates text token by token from a probability distribution conditioned on the preceding context. Training and post-training shape those distributions; at inference time, system instructions, conversation history, retrieved material, and tool results steer the response. The model is not the whole assistant: the harness determines which instructions it receives, which tools it can use, and how their results feed back into the conversation. Strong model capabilities do not, by themselves, define a teaching strategy.
Many general-purpose assistants are configured for productivity: help the user complete a task. In that setting, returning a complete solution to a programming exercise can be appropriate behavior. For a student who needs to practice, the same response can bypass the reasoning the exercise was meant to develop. A tutoring harness needs a different interaction policy: ask what the student has tried, identify a misconception, consult the course material, or offer a limited hint before supplying a solution. Instructions can encourage that behavior, but they are not a guarantee; context, tools, harness-level checks, and evaluation matter too.
The hosted models available to us offered stronger capabilities than we could achieve with the open-source models we ran on our infrastructure. Our goal was not to outperform those models. It was to examine the problem that answer-oriented assistance creates for students and explore how to redirect those capabilities toward learning, using entirely free tools. The engineering challenge was to guide a capable model without reducing the experience to a rigid script or giving away the exercise at the first request.
That goal shaped the harness. The system needed identity, class context, persistent conversations, safe retrieval, interactive questions, and a guard intended to distinguish learning support from requests to outsource an answer. Students also needed a way to test their own reasoning. For introductory C programming, I built a visual debugger where they could run code and observe variables and control flow, then bring that evidence back into the tutoring conversation.

The conversational interface I built keeps guidance, course context, and the student’s work in one workspace.
One application, several boundaries #
The implementation is a Spring Boot and Vaadin Flow monolith backed by PostgreSQL. “Monolith” is useful here, not apologetic. A server-side Java UI, domain services, authorization, session memory, retrieval, and AI adapters can share one deployment while retaining explicit internal boundaries. The architecture specification identifies identity, tenancy, authorization, academics, conversations, grounding, formative activities, AI, and UI as separate responsibilities.
At the edge, a Vaadin view handles the student interaction. Services establish the authorized conversation and class membership. ChatService supplies session identifiers, subject context, tool context, and callbacks. A Spring AI ChatClient composes the guard, compaction, memory, dynamic context, and tools around the model. PostgreSQL stores the academic and authorization model, conversation metadata, session events, and vector-backed grounding material. External model APIs remain adapters rather than sources of authority.
This separation answers an important design question: what should the model be trusted to decide? It may choose a pedagogical move or request a tool. It must not choose which class it can read, who owns a conversation, or whether a cursor grants access. Those are application decisions.
Relational context before model context #
The data model starts with an account, but an account is not itself a classroom identity. An account joins a tenant through tenant_account; that membership can join a concrete group_class through group_class_member. The latter records PROFESSOR, STUDENT, or ASSISTANT. A conversation belongs to a group-class member. Roles are namespaced permission bundles, assigned at platform, tenant, or class level. Current role permissions are stored as code strings in a PostgreSQL text[], rather than inferred from role names. The RBAC documentation explains why membership says who someone is in a classroom while RBAC says what they can do.
The following SQL is deliberately simplified from the baseline migration. It shows the relationships, not a literal migration to copy:
-- Simplified editorial excerpt
role(namespace_id, assignment_level, permissions text[])
tenant_account(account_id, tenant_id)
group_class_member(group_class_id, tenant_account_id, member_kind)
conversation(group_class_member_id, title, last_prompt_tokens) Authorization therefore cannot stop at “has conversation:view.” The service also checks the active tenant, the class membership, and personal ownership where required. A professor’s permission to manage grounding is scoped to a class in which that professor is active. A student’s conversation permission does not expose another student’s session. UI route hiding improves usability, but service-layer checks are the security boundary.
The conversation ID is also used as the Spring AI Session ID, and the group-class-member ID as its user ID. This creates a useful second ownership check around memory, after domain authorization. It does not transfer domain ownership to the session library. The conversation row remains authoritative for listing, titles, and access.
Solid lines denote declared foreign keys; dashed lines denote logical associations.
Composing advisors, and being precise about order #
My orchestration work treated a model call as a pipeline rather than a single prompt. AIConfig installs the advisors in this declared order: TutorGuardAdvisor, UsageBasedCompactionAdvisor, SessionMemoryAdvisor, and DynamicContextManagementAdvisor. The guard evaluates untrusted input. Compaction observes provider usage. Memory loads active events and persists approved turns. Dynamic context adds current document/catalog material.
// Simplified from the ChatClient builder configuration
builder.defaultAdvisors(
tutorGuardAdvisor,
usageBasedCompactionAdvisor,
sessionMemoryAdvisor,
dynamicContextManagementAdvisor
).defaultTools(retrieveInformationTool).build(); “Order” needs a caveat. Advisors have before and after phases, streaming aggregation, and tool loops. A list is not a claim that every action runs once from top to bottom. Before-phases proceed into the chain; after-phases unwind around the response. SessionMemoryAdvisor.before() loads history and eagerly appends an approved user message, while its aggregated after-phase stores the final assistant response. Tools can cause internal continuation calls before that final response. The guard architecture documents these operational details.
This is why the guard is ahead of memory. It reads recent active session events directly, adds the request-local message only to its classifier prompt, and then chooses ALLOW, STEER, or SHORT_CIRCUIT. A steered message replaces the latest input before persistence. A short circuit returns a boundary-setting response without reaching the tutor or writing the rejected turn. The guard judges the requested outcome, not taboo words. Asking about prompt injection can be legitimate; asking to bypass hidden instructions is not.
There are still tradeoffs. A second model call adds latency to ordinary allowed turns. Structured output can fail, so malformed or contradictory decisions fail closed. The current design favors data integrity over speculative parallel tutor generation because memory writes and tool side effects would make speculation unsafe without a deeper persistence redesign.
Retrieval as a bounded capability #
The tutor has two complementary course-material tools in RetrieveInformationTool: searchCourseMaterial finds relevant chunks and returns short previews plus read cursors; readCourseMaterialPage follows one of those cursors and can read nearby chunks. Search previews are capped at 600 characters in the current retrieval service. Page size defaults to one and is bounded from one through three. These small surfaces encourage the model to retrieve only what it needs instead of flooding its context.
The security property is more important than the ergonomics. Both operations receive the authorized group-class ID through ToolContext. Retrieval scopes material to that class and to ready documents. In other words, the effective boundary is class plus READY ingestion state. A cursor is navigation state, not authorization. Reading a page supplies the active class again and validates the cursor inside that scope. Possession of a cursor must never become a bearer capability for another class.
// Simplified tool surface; implementation and long descriptions omitted
@Tool(name = "searchCourseMaterial")
DocumentContextResult searchCourseMaterial(
@ToolParam(description = "Specific fact to find") String query,
ToolContext context);
@Tool(name = "readCourseMaterialPage")
DocumentPageResult readCourseMaterialPage(
@ToolParam(description = "Cursor returned by search") String cursor,
@ToolParam(required = false) Integer pageSize,
ToolContext context); The model supplies the search intent or continuation cursor. The application supplies ToolContext, outside the model's argument schema. That split is useful when reviewing a tool: which values describe what the model wants, and which values establish what the caller is allowed to access? A descriptive tool name reduces guesswork; keeping identity out of generated arguments prevents that guesswork from turning into an authorization decision.
{
"note": "Simplified search result, not a literal API contract",
"hits": [{
"preview": "At most 600 characters of a relevant class passage...",
"readCursor": "opaque navigation value"
}],
"nextStep": "read one to three adjacent chunks only when needed"
} Clear tool names help capable models considerably. searchCourseMaterial communicates discovery; readCourseMaterialPage communicates bounded continuation. Parameter descriptions explain that the query should be a specific fact and that page size has a maximum. But model sophistication and good naming do not replace constraints. Server-side class scoping, ingestion-state filters, page limits, cursor validation, auditing, and result DTOs remain necessary when a model supplies the arguments.
Asking the student through a tool #
Sometimes the tutor lacks not a document but an observation. The interrogateUser tool pauses generation and asks one to three diagnostic questions in the interface. It can ask what the student understands, what output they see, or whether they have code that fails. Open questions are preferred; selectable options are reserved for genuinely categorical context. The tool description explicitly excludes “solve it” choices.
This is more than rendering JSON. The active turn waits on a future while the UI displays the panel. Submission returns a structured response. Runtime validation requires one unique qN answer per displayed question and rejects option labels that were never offered. An independent guard then checks custom text before the tool result returns to the tutor. ALLOW preserves it, STEER rewrites unsafe custom text while retaining IDs and selected labels, and SHORT_CIRCUIT throws a targeted exception that aborts tool continuation and displays a direct response.
The schema work exposed a subtle distinction. Swagger’s @Schema describes properties and patterns; @ArraySchema supplies array item and count information; Spring AI’s @ToolParam describes the callable tool argument and whether it is required. Jakarta validation such as @Size is a runtime validation vocabulary, and Spring AI’s schema generator does not automatically translate it into minItems or maxItems in this setup. The schema generator test confirms that a Jakarta @Size example omits those two keywords. Its Swagger test merely asserts generation is non-null, so it does not prove that every desired Swagger keyword appears. Constructor and tool-boundary validation are still indispensable.
// Simplified: schema hints and executable checks serve different jobs
record QuestionSet(
@ArraySchema(maxItems = 3) List<Question> questions) {
QuestionSet {
if (questions.size() > 3) throw new IllegalArgumentException();
}
}
// @ToolParam describes the argument at the tool boundary. Two histories and a finite context window #
Long tutoring sessions create another design problem. The student expects old turns to remain visible, but the model has a finite context window. Socratic Tutor separates the archived event log used to reconstruct UI history from the active event set supplied to the model. Older real events can be archived after summarization; a synthetic summary plus recent raw turns stay active. Display filters include archived real user and assistant messages while excluding synthetic summaries, tool events, branches, blank messages, and assistant tool-call shells. The history-filter documentation makes the principle concise: model memory is compacted, the student’s visible transcript remains real.
Threshold and retention values describe this configuration, not fixed limits.
Compaction is usage-based. UsageBasedCompactionAdvisor does nothing in before. After a completed aggregated response, it reads provider-reported prompt tokens, stores that observation on the conversation, and compacts if the threshold has been reached. The recursive strategy retains a budget of recent real history and summarizes older material, preserving such things as goals, misconceptions, attempts, hints already used, and unresolved questions.
The timing creates an honest limitation: compaction after response N prepares a smaller context for response N+1. It is not a preflight guarantee that response N will fit. If usage metadata is absent, the advisor does not invent an estimate and does not compact from that response. The project’s compaction notes list preflight compaction and overflow recovery as future improvements, not implemented safeguards. Repeated summaries can also lose nuance, and tool output needs careful retention so an assistant result is not detached from the user turn that motivated it.
A debugger turns advice into observation #
Conversation was only half of the experience I wanted. I built a visual C debugger so a learner could run, step, reload, inspect output, and watch program state change. The UI delegates execution to a debugger runner adapter rather than embedding execution details in the chat. That separation gives the learning surface a stable model while allowing the infrastructure implementation to enforce its own runtime controls.

The debugger I built gives students observable evidence they can bring back into the tutoring conversation.
The pedagogical loop is the real feature: predict what a line will do, step it, compare the state with the prediction, and ask a narrower question. The tutor can guide attention, but the debugger supplies evidence. This also avoids pretending that generated prose can substitute for running a program. Its cost is product complexity: streaming chat state, interactive tool panels, Markdown, code rendering, and debugger events all need distinct handlers and resilient UI transitions.
Keeping those interaction modes separate was an architectural choice as well as a UI choice. A chat token, an interactive question, and a debugger event may all appear in one learning journey, but they have different lifecycles. Streaming text can be partial or cancelled. A question panel must wait for a validated student submission. A debugger command changes executable state and needs an explicit result. Treating all three as generic messages would simplify the first demo and complicate every failure path afterward. I instead kept their rendering and handlers isolated, then joined them at the level that matters to the learner: the current problem and the evidence available for discussing it.
What the project demonstrates, and what it does not #
Socratic Tutor demonstrates an architecture for keeping pedagogy, authorization, retrieval, memory, and interactive execution in one coherent experience. The strongest decisions are boundaries: membership before retrieval, guard before persistence, cursor as navigation only, archived transcript separate from active context, and executable validation beside schema descriptions.
It does not demonstrate that the tutor improves grades, reduces completion time, or outperforms another teaching method. I have no defensible metric for those claims here. Observing students use the complementary chat and debugger informed our design, but it is not a controlled evaluation. Model behavior also varies by provider and model capability. Better models tend to follow clear tool names and structured descriptions more reliably, yet they can still generate invalid arguments, over-retrieve, or choose an unhelpful teaching move.
A serious evaluation would define learning outcomes, compare conditions, review whether hints preserve productive struggle, test authorization adversarially, measure guard false positives and false negatives, and inspect summary fidelity over long sessions. It would also report latency and model-call cost across allowed, steered, short-circuited, retrieval, and interactive paths. Until those studies exist, the responsible claim is narrower: this implementation encodes testable safety and product boundaries around an AI tutor, and exposes learning actions beyond chat.

