Code › tail-villain

The Conditions for Ending an Interview Session

A Tail Villain note on treating LLM completion signals as advisory instead of letting them end sessions unconditionally

An interview session had been marked completed before the user actually ended it.

At first, it looked like a frontend state problem. I had been polishing the session screen and the history modals, and I was also aligning the roadmap interview page with the study-mode page. Card density, scroll areas, and action-menu placement were all moving at the same time, so the completed label looked like it might be another UI mismatch.

But the backend was ending the session. The user had not pressed the completion button. The sendMessage path was trusting the shouldComplete value returned by the LLM.


An interview in Tail Villain is not just a chat. The user sends an answer, and the villain pushes on the weak parts of that answer with follow-up questions. There are checkpoints, revealed angles, and eventually a score and feedback. In that flow, completing a session is a heavy state transition.

There is a difference between the LLM saying the interview can end and the product actually ending it.

The LLM only sees the current conversation context. If an answer looks sufficient, or if the exchange sounds like it is wrapping up, it can return shouldComplete as true. The product has to check more than that. It needs to know whether the current angle is the final revealed checkpoint, whether previous angles are already complete, whether the evaluation is strong enough, and whether the user explicitly chose to finish.

So I downgraded shouldComplete from a command to a signal. Even if the LLM returns true, automatic completion now requires final-checkpoint gating. Manual completion still exists, but automatic completion has to satisfy product-level conditions.


The same kind of boundary showed up in study mode that day.

Starting a study session returned a 500, but the route and API path were not the issue. The problem was the JSON payload being saved. The LLM-generated state and report could omit optional fields, which left undefined values inside an object headed for a Prisma JSON column.

LLM output can look fine to a person and still be unsafe at a persistence boundary. An omitted field may look like a small detail at runtime, but a database JSON field needs a serializable shape. I normalized the study state and report payloads into Prisma-safe JSON before saving them, then added a regression test to prove omitted optional fields no longer break persistence.

The rule was the same: do not trust an LLM-shaped object as a product-shaped object. Before saving it, the product has to make it fit the data rules.


Report language had the same shape.

On Korean roadmaps, study completion reports could still come back in English. The interactive prompt had enough Korean context, but the completion-report prompt did not explicitly restate the output language. For a separate artifact like a report, the language requirement needed to be written again.

I tied the report language to the roadmap’s resolvedLanguage and cleaned up internal enum labels such as mock_ready and advanced so they would not leak directly into the UI. A user-facing report should not read like a dump of backend state. It should say what level the learner is at and what they should do next.

This was not a flashy feature, but small mismatches like this make the product feel like an internal experiment. AI text, saved JSON, and visible labels all have to point in the same direction.


The frontend cleanup followed the same boundary.

I moved the roadmap interview session page closer to the study-session structure: a direct header frame, a left-side information rail, a sticky chat panel, a compact composer, and a more consistent visual language for assistant and user messages. I did not make the two modes identical, because interview sessions still have checkpoint progress, completion state, scoring, and feedback.

The history modal also needed less weight. Past session cards should be quick to scan, not dashboard cards stuffed with metrics. I tightened the card height, title hierarchy, metric display, and scroll spacing, then fixed clipping in the full-analysis modal so long chips would wrap instead of sliding under the scrollbar.

The delete actions exposed another concrete lesson. I first added the three-dot menu and delete confirmation to the roadmap interview hub, but the screenshot the user was referring to came from the roadmap detail history modal. Same feature, wrong surface. I traced the correct modal, added the flow there as well, and wired backend support so both interview and study sessions could be removed from the mixed session list.


On paper, the day had a lot of unrelated work: menu placement, a study-mode 500, report language, CTA wording, interview-session UI, history modals, delete actions, and the auto-completion guard.

Underneath, it was about who gets authority over session state.

The LLM can write an answer, provide an evaluation signal, and suggest the next move. But the product owns the shape that gets saved, the language that gets shown to the user, and the decision to finish an interview. A completed state can cut off the user’s practice flow, so it needs a conservative gate.

Even if the AI says the interview is done, the product still has to check whether the conditions are actually met.

That was the important fix from that day.