Most mock interview tools are question flashcard apps with a submit button. They give you a list of questions, accept your answer, and either show a model answer or give you a thumbs-up/thumbs-down rating. That's not feedback. That's a quiz.
When I built the mock interview engine in Mentora, I wanted something that behaves like a thoughtful senior engineer giving structured feedback — not a rubric checker.
What "Useful" Feedback Actually Means
Before writing any code, I interviewed five people about their interview preparation experience. Every single one said the same thing: "I don't know if my answer was actually good or if I just think it sounds good."
That's the problem to solve. The gap between how an answer sounds to the person giving it and how it lands with the interviewer.
Useful feedback, from those conversations, means:
- What specifically was missing — not "your answer was incomplete" but "you didn't address time complexity, which is always expected for array manipulation questions"
- What was strong — so they know what to preserve, not just what to fix
- A concrete example of a better answer — not to replace their answer, but to show the delta
- A score they can track — so they can tell whether session 3 is better than session 1
That shaped the entire architecture.
The Question Generation System
Interview questions aren't one-size-fits-all. A "tell me about a challenging project" question for a junior frontend developer should probe different things than the same question for a senior backend architect.
// lib/interviewQuestions.ts
export async function generateQuestions({
role,
experienceLevel,
targetCompany,
interviewType,
previousSessionTopics,
}: QuestionGenerationInput): Promise<InterviewQuestion[]> {
const model = genAI.getGenerativeModel({
model: "gemini-1.5-pro",
generationConfig: {
responseMimeType: "application/json",
responseSchema: QuestionsSchema,
},
});
const prompt = buildQuestionPrompt({
role,
experienceLevel,
targetCompany,
interviewType,
previousSessionTopics, // Avoid repeating questions from prior sessions
});
const result = await model.generateContent(prompt);
const questions = JSON.parse(result.response.text());
return questions;
}
function buildQuestionPrompt(params: QuestionParams): string {
return `Generate 8 interview questions for the following candidate:
Role: ${params.role}
Experience Level: ${params.experienceLevel}
Target Company Type: ${params.targetCompany ?? "general"}
Interview Type: ${params.interviewType} (behavioural / technical / system design / mixed)
Requirements:
- Questions should be calibrated to the experience level
- Mix easy, medium, and hard questions (2:4:2 ratio)
- For technical questions: specify what concepts they're testing
- For behavioural questions: specify which competency they're probing (leadership, conflict resolution, etc.)
- Avoid these topics from previous sessions: ${params.previousSessionTopics?.join(", ") ?? "none"}
For each question, provide:
- The question text
- Difficulty: easy | medium | hard
- Type: technical | behavioural | system-design
- What a strong answer should cover (3-5 bullet points)
- Common mistakes candidates make
- Follow-up question to probe deeper`;
}
The "what a strong answer should cover" and "common mistakes" fields aren't shown to the candidate during the interview — they're used by the evaluation engine. Generating them alongside the question ensures the evaluation rubric is question-specific, not generic.
The previousSessionTopics parameter solves a subtle UX problem: candidates who practice repeatedly would otherwise get the same questions. Tracking covered topics per session and excluding them from subsequent generations makes repeated practice genuinely useful.
The Answer Evaluation Engine
This is the hard part. Evaluating a free-text answer to a technical question requires genuine reasoning, not just keyword matching.
// lib/evaluateAnswer.ts
const EvaluationSchema = z.object({
overallScore: z.number().min(0).max(100),
dimensions: z.object({
technicalAccuracy: z.object({
score: z.number().min(0).max(25),
feedback: z.string(),
specificIssues: z.array(z.string()),
}),
completeness: z.object({
score: z.number().min(0).max(25),
feedback: z.string(),
missedConcepts: z.array(z.string()),
}),
communicationClarity: z.object({
score: z.number().min(0).max(25),
feedback: z.string(),
}),
structuredThinking: z.object({
score: z.number().min(0).max(25),
feedback: z.string(),
}),
}),
strengths: z.array(z.string()),
improvements: z.array(z.object({
issue: z.string(),
suggestion: z.string(),
examplePhrase: z.string().optional(),
})),
betterAnswerOutline: z.array(z.string()),
});
export async function evaluateAnswer({
question,
candidateAnswer,
questionRubric,
role,
experienceLevel,
}: EvaluationInput): Promise<EvaluationResult> {
const model = genAI.getGenerativeModel({
model: "gemini-1.5-pro",
generationConfig: {
responseMimeType: "application/json",
responseSchema: buildGeminiSchema(EvaluationSchema),
},
});
const result = await model.generateContent(`
You are a senior ${role} interviewer evaluating a candidate response.
Question: ${question.text}
Experience Level Expected: ${experienceLevel}
Strong answer should cover:
${questionRubric.strongAnswerCriteria.map((c, i) => `${i + 1}. ${c}`).join("\n")}
Common mistakes to watch for:
${questionRubric.commonMistakes.map((m, i) => `${i + 1}. ${m}`).join("\n")}
Candidate's answer:
"""
${candidateAnswer}
"""
Evaluate the answer across four dimensions (0-25 each):
1. Technical Accuracy — Is the information correct? Are there factual errors?
2. Completeness — Does the answer address all key points expected at this level?
3. Communication Clarity — Is the answer well-structured and easy to follow?
4. Structured Thinking — Does the candidate demonstrate systematic problem-solving?
Be specific: point to exact phrases or missing concepts. Don't say "could be more detailed" — say "didn't mention time complexity of the solution, which is expected at ${experienceLevel} level."
For improvements, always include a concrete suggestion and, where possible, an example phrase.`);
const raw = JSON.parse(result.response.text());
return EvaluationSchema.parse(raw);
}
Four dimensions at 25 points each sum to 100. This is deliberate — each dimension is independently meaningful, so a candidate who scores 25/25 on technical accuracy but 5/25 on communication clarity knows exactly where to focus.
The system prompt's final instruction — "don't say 'could be more detailed', say exactly what's missing" — dramatically changed output quality. LLMs default to vague, hedged feedback when given latitude. Constraining the format of the feedback to be specific forces concreteness.
The Scoring Database
Every session is persisted for longitudinal tracking:
// prisma/schema.prisma
model InterviewSession {
id String @id @default(cuid())
userId String
role String
experienceLevel String
sessionType String
completedAt DateTime?
answers SessionAnswer[]
// Aggregated metrics updated after each answer
overallScore Float?
technicalScore Float?
clarityScore Float?
completenessScore Float?
thinkingScore Float?
user User @relation(fields: [userId], references: [id])
}
model SessionAnswer {
id String @id @default(cuid())
sessionId String
questionId String
answerText String @db.Text
evaluation Json // Stores the full EvaluationResult
overallScore Int
submittedAt DateTime @default(now())
session InterviewSession @relation(fields: [sessionId], references: [id])
}
The evaluation field is stored as JSON — the full EvaluationResult object. This means historical sessions can be re-displayed with full detail without re-evaluating (which would cost API credits and might produce different results).
The aggregated score columns on InterviewSession are updated after each answer via a Prisma transaction:
async function updateSessionScores(sessionId: string) {
const answers = await db.sessionAnswer.findMany({
where: { sessionId },
select: { evaluation: true },
});
const evaluations = answers.map((a) => a.evaluation as EvaluationResult);
const avg = (key: keyof EvaluationResult["dimensions"]) =>
evaluations.reduce((sum, e) => sum + e.dimensions[key].score, 0) / evaluations.length;
await db.interviewSession.update({
where: { id: sessionId },
data: {
overallScore: evaluations.reduce((s, e) => s + e.overallScore, 0) / evaluations.length,
technicalScore: avg("technicalAccuracy"),
clarityScore: avg("communicationClarity"),
completenessScore: avg("completeness"),
thinkingScore: avg("structuredThinking"),
},
});
}
The Performance Report
After a session ends, the report is generated from the persisted evaluation data — not a new LLM call:
// components/SessionReport.tsx
export function SessionReport({ session }: { session: InterviewSessionWithAnswers }) {
const scoreHistory = useScoreHistory(session.userId); // Previous sessions
return (
<div className="space-y-8">
{/* Radar chart: current session vs. previous session average */}
<ScoreRadar
current={session}
previous={scoreHistory.previousAverage}
/>
{/* Per-answer breakdown */}
{session.answers.map((answer) => (
<AnswerBreakdown
key={answer.id}
question={answer.question}
answer={answer.answerText}
evaluation={answer.evaluation as EvaluationResult}
/>
))}
{/* Session-level insights */}
<SessionInsights
strengths={aggregateStrengths(session.answers)}
topImprovement={findTopImprovementArea(session.answers)}
trend={scoreHistory.trend} // "improving", "stable", "declining"
/>
</div>
);
}
The radar chart comparing current vs. previous session average is the most-used element in user testing. It answers the question candidates actually care about: "Am I getting better?"
What I'd Build Next
Voice input with transcription. Real interviews are verbal. Typing answers is a weaker proxy. Adding getUserMedia recording and Gemini's audio understanding API would let candidates practice the way they'll actually perform.
Interviewer simulation. Instead of evaluating a completed answer, having the AI play the role of the interviewer — asking follow-ups, probing weak spots, redirecting off-topic answers — would create a much more realistic practice environment.
Company-specific calibration. Google interviews have different expectations than startup interviews. Training the evaluation rubric on known company interview patterns (from public resources) would make the scoring more relevant to a specific target.
The Lesson
AI feedback tools fail when they're too generic to be actionable. "Your answer could be more detailed" is information-free — the candidate already knew they could have said more. Useful feedback tells you what to add, why it matters at the level you're targeting, and how to phrase it.
The engineering is in the prompt structure and the output schema — constraining the model to produce feedback in the form that's actually useful, not the form that's easiest for the model to produce.
Build for the output you want. Then constrain the model to produce it.