Code › tail-villain
Making Kovill Follow the Interview State
Aligning Kovill’s reply style, question progression, and language with product-owned state
Kovill’s follow-up questions were technically pointed, but the voice kept drifting.
After a candidate answered, Kovill would first validate the answer, then challenge it. The question itself was often useful. The problem was the shape. A cold interviewer was starting to sound like a polite chatbot that added a critical paragraph afterward.
At first, this looked like an ordinary prompt issue. I tried tightening the forbidden phrases, but that only helped for a short time. The more I listed the phrases I did not want, the more I was showing the model exactly the pattern it was supposed to avoid.
The stronger cause was in the structured output schema. The description for the reply field described it as an acknowledgment or transition sentence. Gemini treated that description as the definition of the field. The system prompt could say “open cold,” but the schema was still telling the model that the spoken reply was supposed to acknowledge the previous answer.
So I changed the definition instead of adding more warnings. The reply field became the next spoken challenge or question. Evaluation stayed in the internal assessment field and was not allowed to leak into the spoken reply. Kovill’s examples also changed from “you said X, however Y” into cold openings that directly asked about the missing evidence, failure mode, or trade-off.
That mattered in the actual conversation. Kovill stopped taking a breath before applying pressure and started with the pressure itself. Persona is not maintained only by a character description. The field the model fills on every turn has to encode the behavior the persona is supposed to show.
The next failure was not voice. It was interview state.
In live testing, the left panel showed that two angles had already been completed, but Kovill was still drilling the same topic. The UI said the interview had moved on. The transcript had not. From the user’s point of view, the product had advanced while the interviewer stayed in the previous room.
The root cause was a small state-machine bug. The code calculated the next angle index, assigned it to currentAngleIndex, then compared nextIndex against currentAngleIndex. After the assignment, that comparison was always comparing the value to itself. The next angle never got marked as current.
That one broken state write multiplied downstream. The progress builder could not find an angle with state current, so the prompt kept receiving the first angle as the active angle. Meanwhile, the stored currentAngleIndex and follow-up counts were moving forward. The UI, the database state, and the prompt context were no longer describing the same interview.
Saving the previous index before mutation fixed the immediate bug. But it also exposed a deeper design problem. The LLM was still responsible for deciding whether to advance and, at the same time, generating the next question.
If the model returned moveToNextQuestion without a next question, state could move forward without a rendered transition. If a cap was reached but the transcript momentum was strong, the model would keep drilling the old topic despite the prompt saying it was time to move on. A rich conversation history can overpower a rule that says “advance now.”
So I moved progression ownership back into the backend. Follow-up turns became single-purpose: evaluate the current answer and ask one follow-up on the current angle. When the cap is reached, the backend decides whether the session will advance. The new angle opening is generated by a separate call that receives only the new angle title and a short summary of the previous angle, not the whole old transcript.
Completion followed the same rule. On the final angle, the product generates a closing instead of allowing the last assistant message to be an unanswered follow-up question. The invariant became simple: the sidebar advances only when a transition and opening have actually been rendered. State is no longer allowed to move ahead of the conversation.
Language had the same kind of boundary problem.
In one session, Kovill opened in English, then switched languages on the first follow-up. That was not just a missing translation instruction. Different paths were using different sources of truth. The opening turn was detecting language from the title, while sendMessage was using the roadmap’s resolved language.
Title detection was too fragile. A topic title can contain English inside an otherwise non-English roadmap, or the reverse. The user’s preferred language was not the right answer either. That setting is the UI display language. It does not always mean the language the interview content should use.
The rule became explicit. User preferred language controls the UI. For roadmap and topic sessions, the coaching language comes from the roadmap’s resolved language, because that is derived from the source content. For coding rounds, where there is no roadmap content, the user’s preferred language is the fallback. Title-based dominant-script detection was removed.
That work happened on the same day as other product fixes, but the language piece belongs to this story. The language Kovill speaks is part of the interview state. If it changes mid-session, even a well-behaved persona feels unstable.
Looking back, this was not really about making Kovill smarter. It was about making the state Kovill must follow more explicit.
The LLM can write a pointed question, judge an answer, and suggest a next move. The product has to own the current angle, the transition boundary, the spoken language, and the difference between internal evaluation and user-facing speech. When those boundaries are loose, the model can sound plausible while still breaking the interview flow.
Kovill should ask cold questions. But that coldness only holds when the state and output structure underneath the prompt are stable enough to keep the interviewer on track.