Code › tail-villain

Turning a Finished Interview Into the Next Practice Session

Connecting interview results to topic progress, review timing, and the practice loop

An interview could finish, but the product still did not know how to use that finished interview well enough.

The conversation was saved. The score and feedback existed. I could open the page again and see what had happened. But the result did not naturally become the next practice session. From the user’s point of view, one interview was done. From the system’s point of view, it was still not clear enough which topic had improved, what remained weak, or when that topic should come back.

If I kept adding features on top of that, the product would grow in the wrong direction. Better questions, nicer screens, and more characters would not matter much if the practice loop itself stayed open-ended. It would still feel like a fresh mock interview every time.

So instead of adding another large feature, I focused on making a finished interview feed the next one.


In Tail Villain, an interview session is not just a chat log. A user chooses a topic, sits across from a villain interviewer, exposes weak parts of an answer, and receives feedback at the end. If that is the unit of practice, then the topic should change when the session ends.

Take a topic like handling cache consistency in a distributed environment. If the answer was solid, it may not need to come back tomorrow. If the answer missed the main trade-offs, it probably should. Saving only the score is useful for looking back, but it does not guide the next action.

So I started persisting interview results onto the topic itself: latest score, answer quality, summary, strengths, gaps, last practiced time, and next review time. I also added a simple scheduling rule. Strong results get a longer interval, partial results come back sooner, and weak results come back almost immediately.

It was not a sophisticated spaced-repetition algorithm. That would have been overkill at this stage. What I needed first was a small piece of memory: not just “this topic was completed,” but “this topic ended in this state, and it should probably be reviewed around this time.”


Once that data existed, the dashboard changed shape.

Before this, the dashboard was closer to an entry point for roadmaps and status. But when practice results start attaching to topics, the dashboard should tell the user what to do next. How many topics exist, how many have been practiced, and which ones need review now become more important than a generic status panel.

I added separate sections for review-now topics and weak topics. That sounds small, but it matters. A practice tool should reduce the amount of decision-making before practice starts. If the user has to ask “what should I do next?” every time, the system is not doing enough work.

Topic cards also started showing interview progress, and each topic got an interview history dialog. Completed sessions could still be reopened for review, but not mutated. That model felt cleaner. A finished interview is a record of what happened at that time. If old conversations can be resumed and changed, the score and feedback start to lose their meaning.

It is similar to an exam paper. You can review it later, but you do not go back and rewrite the answers after the grade is attached.


The interview flow had its own small failure mode.

Sometimes the AI interviewer would say something like “Good, let’s move on,” but fail to provide the actual next question. In a human conversation, that would be awkward for a second. In a product, it becomes a stalled screen. The user does not know what to answer.

At first, it was tempting to infer whether the response contained a question by looking at the text. Does it have a question mark? Does it look like a question? But natural language breaks those rules quickly. “Tell me more about that” is a question without a question mark. “Good question.” has a question mark conceptually, but it is not asking the user anything.

So the interviewer response needed a structured next-question field. If a nextQuestion value exists, the frontend can treat it as the next prompt and keep the input flow stable. The model can still speak naturally, but the part of the experience that must be guaranteed should not depend on text-shape heuristics.

I also tightened manual completion. When the user clicks complete, the session should not simply close. It should trigger the final AI evaluation first, then move into the completed state. That way, the completion screen becomes a summary of the practice, not just an endpoint.


I also used this pass to tighten authentication.

As practice history became more important, the session and topic data around it needed to be treated more carefully. Refresh tokens were still living in browser localStorage, which is convenient, but not a place I wanted long-lived tokens to remain. I moved refresh handling into an httpOnly cookie flow so browser JavaScript could no longer read the refresh token directly.

I also removed insecure JWT secret fallbacks, tightened production docs exposure, restricted CORS behavior, added API-oriented response security headers, and gated client-driven AI provider/model override headers in production.

None of that changes the screen in an obvious way, but it changes the trust boundary. Once a product starts storing meaningful user practice history, security work cannot stay in the “later” bucket forever.

Of course, changing token storage is never isolated. Backend responses, shared schemas, the frontend API client, auth store hydration, reload behavior, and logout behavior all had to line up. Where a token lives looks like a small implementation detail until the whole login recovery path depends on it.


A few things broke along the way.

After adding topic-progress fields, the Prisma types and database schema were briefly out of sync. With the migration missing, the roadmap API returned 500s. The dashboard also showed zero counts because the list response did not include the topic progress data the frontend needed to calculate anything.

The completed screen duplicated feedback too. The backend had a summary and overall feedback, and the frontend tried to show both helpfully. In practice, that meant the same idea appeared twice. I removed the duplicate surface and moved longer feedback behind an explicit expand action.

These were not interesting algorithm bugs, but they mattered. Users do not see the internal schema, DTOs, or migration state. They see zero counts, repeated feedback, and no next question. That is enough for the product to feel unfinished.


After this work, the center of Tail Villain became a little clearer.

It is not enough for the AI to ask good questions. A good question can create one useful conversation, but good practice happens when the result of that conversation changes what happens next. The product needs to remember which topic was practiced, what went weak, and when it should return.

A chat log is a record. Topic progress is direction. Both are necessary, but they do different jobs. A record without direction makes the user interpret everything again. Direction without a record is hard to trust.

That is why the finished interview needed to remain readable and also become part of the next review cycle. Tail Villain is not just a product that generates conversations. It has to make practice accumulate.