Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Interview Practice App

A single-page Streamlit web app that runs realistic, multi-turn job interview practice sessions powered by LLMs through the OpenRouter API. You paste a job description, choose a difficulty and interviewer style, and the app interviews you one question at a time — remembering the whole conversation, giving feedback, and showing you exactly what each turn costs.

Built as the Sprint 1 capstone of an AI Engineering bootcamp.

How to run it

# 1. Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate        # on Windows: .venv\Scripts\activate

# 2. Install dependencies
pip install -r requirements.txt

# 3. Add your OpenRouter API key
cp .env.example .env
# then edit .env and paste your key (get one at https://openrouter.ai/keys)

# 4. Launch
streamlit run app.py

The app opens in your browser. Paste a job description, pick a difficulty and interviewer style, and start chatting. Toggle Developer mode in the sidebar to change the model, prompting technique, and LLM settings.

The 5 prompting techniques

All five system prompts live in prompts.py. Each one injects the job description and shares the same hardening rules, but the technique — how the model is instructed to behave — is genuinely different:

Zero-shot gives the model a direct instruction with no examples. It's the baseline: fast to write, works well with strong models, but the tone and format of the questions can drift because nothing anchors them. Useful when you just need reasonable behaviour quickly.

Few-shot includes 2–3 example interview questions inside the prompt. The model imitates their style, so questions come out concrete and scenario-based instead of generic. Useful whenever you care about consistent tone or format and can afford a slightly longer (more expensive) prompt.

Chain-of-thought tells the model to privately reason through which competency to probe next (what does the JD require? what's been covered? what's most important now?) before asking, and to hide that reasoning from the user. This makes question selection more deliberate across a long interview. Useful for tasks where the quality of the decision matters more than raw speed.

Persona defines a full interviewer character — "Marta Sanders, a demanding but fair senior engineering manager" — with personality traits and behaviours. The model's answers become more consistent and human-feeling because it has a stable identity to inhabit. Useful for role-play and for making an experience engaging.

Structured forces the model to answer with a single JSON object per turn (competency, difficulty, question, what_good_looks_like). The app parses it and renders each field nicely. Useful whenever the output feeds a UI or another program — machine-readable beats prose.

LLM settings and what they do

These are exposed as sliders/inputs in the Developer mode sidebar:

Temperature controls randomness. Low values (0–0.3) make output focused and repeatable; higher values (0.8+) make it more varied and creative. For an interviewer, ~0.7 is a good middle ground: questions vary between sessions without becoming erratic.

Max tokens caps the length of the model's reply. It protects against runaway (expensive) answers, but set it too low and responses get cut off mid-sentence.

Reasoning effort (low/medium/high) asks reasoning-capable models to think longer before answering. Higher effort improves question selection but costs more tokens and time. The app sends it in OpenRouter's portable form (extra_body={"reasoning": {"effort": ...}}) so it works across providers.

The OpenRouter caveat: OpenRouter silently ignores parameters a model doesn't support — the request still succeeds, the knob just does nothing. The exception is the GPT-5 family, which can reject temperature when reasoning effort is set. openrouter.py therefore only sends temperature when reasoning is off, and if the API still rejects it, retries once without it. An unsupported setting can never crash the app.

System / user / assistant roles

Every API call sends a list of messages, each with a role:

The system message carries the interviewer's instructions — the selected technique's prompt with the job description, difficulty, and tone injected. It's rebuilt from the current UI settings on every call, which is why changing settings mid-interview visibly changes behaviour.

The user messages are the candidate's answers, and the assistant messages are the interviewer's previous questions and feedback. The app keeps the full history in st.session_state and sends all of it on every turn. That's what makes this a real multi-turn chatbot: the model sees the whole interview so far and can ask follow-ups, avoid repeating itself, and track which competencies it has already covered.

Output types

The app uses two output types. Four techniques produce plain text (rendered as markdown in the chat). The structured technique requests JSON output (response_format={"type": "json_object"} plus explicit schema instructions in the prompt); the app parses the JSON and renders the question, competency, difficulty, and a collapsible "what a good answer looks like" hint. If parsing ever fails, it falls back gracefully to showing the raw text instead of crashing.

Security

Two layers, documented in guard.py:

  1. Input guard (guard.check_input) — runs before any API call. It rejects empty or over-long input and uses targeted case-insensitive regex patterns to catch prompt-injection attempts ("ignore previous instructions", "print your system prompt", role-change attempts, etc.). Blocked messages never reach the model, so they cost zero tokens.
  2. Hardened system prompts — every technique ends with the same rules: one question per turn, stay in role, never reveal the instructions, refuse role changes. This catches anything the regex layer misses.

Cost tracking

openrouter.py fetches per-token prices from OpenRouter's GET /models endpoint once per session (cached with functools.lru_cache) and multiplies them by the prompt_tokens / completion_tokens reported in each response's usage. The UI shows the last turn's tokens and USD cost plus running totals. Note that because the full history is resent every turn, prompt tokens (and cost) grow as the interview gets longer.

Known problems / limitations

  • The injection guard is regex-based; a creative attacker can phrase around it. The hardened prompt is the backstop, but neither layer is bulletproof.
  • Chat history grows unbounded — a very long interview will eventually hit the model's context window and cost more per turn. There's no summarising or truncation yet.
  • Cost estimates depend on OpenRouter's /models pricing being reachable; if the endpoint fails, costs display as $0 rather than erroring.
  • The structured mode relies on the model actually returning valid JSON; weaker models occasionally wrap it in markdown fences (handled) or break the schema (falls back to raw text).
  • Session state is per-browser-tab and in-memory only: refreshing the page loses the interview. No persistence layer.

Improvement ideas

  • Add an "end interview" button that produces a final summary report scoring each competency, with per-answer feedback.
  • Summarise older turns once the history passes a token threshold to keep long interviews cheap.
  • Let the user upload a CV/PDF and inject it alongside the JD for even more personalised questions.
  • Replace the regex guard with an LLM-based classifier (small cheap model) as a third layer.
  • Add streamed responses (stream=True) so questions appear token by token.
  • Persist sessions to disk/SQLite so an interview can be resumed.

Requirement mapping

Bootcamp requirement Where it's satisfied
Streamlit front-end app.py (chat UI, selectors, metrics)
OpenRouter call with correct params openrouter.py::chat_completion
5 distinct system prompts prompts.py::PROMPTS
≥1 security guard guard.py + hardened prompts
Hard: full chatbot with history st.session_state.messages, full history sent each turn
Medium: cost shown to user get_pricing + estimate_cost + metrics row
Medium: job-description feeding prep JD text area → build_system_prompt
Medium: settings as sidebar sliders temperature / max tokens / reasoning sliders
Medium: structured JSON output structured technique + render_assistant_message
Medium: choice of multiple LLMs model selector (5 models)
Medium: dev settings separated from UX "Developer mode" toggle
Easy: difficulty levels & personas difficulty + interviewer style selectors

sprintone

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages