The Interview Simulator
The Interview Simulator generates personalized mock interviews based on what the user has read. If you've completed posts about Python classes and FastAPI routes, the simulator asks you Python OOP questions and API design questions — at a difficulty level that adapts to your performance.
Data Model
class InterviewSession(Base):
__tablename__ = "interview_sessions"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
status: Mapped[str] = mapped_column(default="in_progress")
overall_score: Mapped[float | None]
question_count: Mapped[int] = mapped_column(default=0)
current_question: Mapped[int] = mapped_column(default=0)
difficulty: Mapped[str] = mapped_column(default="medium")
topics: Mapped[list[str]] = mapped_column(JSONB, default=list)
created_at: Mapped[datetime] = mapped_column(default=func.now())
completed_at: Mapped[datetime | None]Question Generation
When a user starts an interview, the system selects topics based on their reading history:
async def generate_interview(user_id: str) -> InterviewSession:
completed_posts = await db.execute(
select(Post.title, Post.tags).join(
UserProgress, UserProgress.post_id == Post.id
).where(
UserProgress.user_id == user_id,
UserProgress.completed_at.isnot(None),
)
)
topic_counts: dict[str, int] = {}
for title, tags in completed_posts:
for tag in tags:
topic_counts[tag.name] = topic_counts.get(tag.name, 0) + 1
# Select top 3 topics the user has read most
top_topics = sorted(topic_counts, key=topic_counts.get, reverse=True)[:3]
session = InterviewSession(
user_id=user_id,
topics=top_topics,
difficulty="medium",
)
db.add(session)
await generate_next_question(session, db)
return sessionAdaptive Difficulty
After each answer, the difficulty adjusts based on the answer's quality score:
async def adjust_difficulty(session: InterviewSession, score: float):
diffs = ["easy", "medium", "hard"]
current_idx = diffs.index(session.difficulty)
if score >= 0.8:
session.difficulty = diffs[min(current_idx + 1, len(diffs) - 1)]
elif score <= 0.4:
session.difficulty = diffs[max(current_idx - 1, 0)]
# Otherwise, stay at current difficultyThe scoring rubric is simple:
| Score | Criteria |
|---|---|
| 0.0 - 0.3 | No relevant concepts mentioned, major errors |
| 0.3 - 0.5 | Some relevant concepts but incomplete |
| 0.5 - 0.7 | Correct concepts, partial explanation |
| 0.7 - 0.9 | Complete, correct answer with good explanation |
| 0.9 - 1.0 | Comprehensive answer with examples and edge cases |
The Prompt Chain
Instead of one large prompt, the system uses a chain of smaller prompts:
Step 1: Generate Question
Context:
The user is being interviewed on {topic} at {difficulty} level.
They have read: {list_of_posts}
Generate one technical interview question.
Output: {{"question": "..."}}Step 2: Evaluate Answer
Question: {question}
User's answer: {answer}
Expected knowledge areas: {topic_keywords}
Evaluate the answer on:
1. Correctness (0-1): Is the answer technically correct?
2. Completeness (0-1): Does it cover all relevant aspects?
3. Clarity (0-1): Is it well-structured and explained?
Output: {{"correctness": 0.8, "completeness": 0.6, "clarity": 0.9, "feedback": "..."}}Step 3: Generate Follow-Up
Context: The user answered a {difficulty} question about {topic}.
They scored {score}.
Generate a follow-up question that tests deeper understanding.
Output: {{"question": "..."}}Each prompt is independent and stateless. The session state (current question, difficulty, topic) is stored in the database, not in the prompt.
Frontend
The interview UI is a multi-step form:
function InterviewSimulator() {
const [session, setSession] = useState<InterviewSession | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
const [answer, setAnswer] = useState("");
const [feedback, setFeedback] = useState<Feedback | null>(null);
if (!session) return <StartInterview onStart={handleStart} />;
return (
<div className="max-w-2xl mx-auto space-y-8">
<ProgressBar current={session.current_question} total={session.question_count} />
{currentQuestion && !feedback && (
<div className="card p-6">
<p className="text-[10px] font-bold text-amber-500 uppercase tracking-widest mb-2">
Question {session.current_question} of {session.question_count}
</p>
<p className="text-lg font-bold text-white/90 mb-6">
{currentQuestion.text}
</p>
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
className="w-full h-32 p-4 rounded-xl bg-white/[0.03] border border-white/[0.08]
text-sm text-white/80 resize-none focus:outline-none focus:border-amber-500/40"
placeholder="Type your answer..."
/>
<div className="flex justify-end mt-4 gap-3">
<Button variant="secondary" onClick={handleSkip}>
Skip
</Button>
<Button onClick={handleSubmit}>
Submit Answer
</Button>
</div>
</div>
)}
{feedback && (
<FeedbackCard feedback={feedback} onNext={handleNext} />
)}
</div>
);
}The feedback card shows the user how they performed:
function FeedbackCard({ feedback, onNext }: { feedback: Feedback; onNext: () => void }) {
const overall = (feedback.correctness + feedback.completeness + feedback.clarity) / 3;
return (
<div className="card p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-white/90">Feedback</h3>
<span className={`text-lg font-bold ${
overall >= 0.7 ? "text-emerald-400" :
overall >= 0.4 ? "text-amber-400" : "text-rose-400"
}`}>
{Math.round(overall * 100)}%
</span>
</div>
<ScoreBar label="Correctness" score={feedback.correctness} />
<ScoreBar label="Completeness" score={feedback.completeness} />
<ScoreBar label="Clarity" score={feedback.clarity} />
<p className="text-sm text-white/60 bg-white/[0.03] p-4 rounded-xl">
{feedback.feedback}
</p>
<Button onClick={onNext}>
{isLastQuestion ? "See Results" : "Next Question"}
</Button>
</div>
);
}Results & Scoring
After all questions are answered, the system computes an overall score and stores it:
async def complete_interview(session_id: str, db):
session = await db.get(InterviewSession, session_id)
session.status = "completed"
session.completed_at = datetime.utcnow()
answers = await db.execute(
select(InterviewAnswer).where(
InterviewAnswer.session_id == session_id
)
)
scores = [a.score for a in answers.scalars()]
session.overall_score = sum(scores) / len(scores) if scores else 0
if session.overall_score >= 0.8:
badge_name = "Interview Master"
elif session.overall_score >= 0.5:
badge_name = "Interview Ready"
await award_badge_if_not_owned(session.user_id, badge_name, db)Explanation
- Updates an interview session status to "completed" and records completion timestamp
- Retrieves all answers for the session and calculates the average score across all responses
- Assigns a badge name based on the calculated overall score (Interview Master for 80%+, Interview Ready for 50%+)
- Awards the appropriate badge to the user if they haven't already earned it
- Uses async database operations to fetch session data and execute queries efficiently
The results page shows:
function InterviewResults({ session, answers }: Props) {
return (
<div className="space-y-6">
<div className="text-center py-8">
<span className="text-5xl font-bold text-amber-500">
{Math.round(session.overall_score * 100)}%
</span>
<p className="text-sm text-white/50 mt-2">
Across {answers.length} questions
</p>
</div>
<div className="space-y-4">
{answers.map((a, i) => (
<div key={a.id} className="card p-4">
<div className="flex justify-between items-start">
<p className="text-sm font-bold text-white/80">{a.question}</p>
<span className="text-xs font-bold text-amber-500">
{Math.round(a.score * 100)}%
</span>
</div>
<p className="text-xs text-white/40 mt-2">{a.feedback}</p>
</div>
))}
</div>
</div>
);
}What's Next
The next post covers the Knowledge Graph — how D3.js renders a force-directed graph of reading history, how topics are extracted from completed posts, and how edge weights are computed from co-occurrence.
Built with FastAPI, GPT-4o-mini, and zero third-party assessment tools.
Next in this series: Creating a Dynamic Knowledge Graph from Reading History →
