An AI interviewer that reads a candidate's resume, picks the topics worth testing for a target role, retrieves the supporting material from that role's textbook corpus, and asks questions that are grounded in what it retrieved — then grades the answers against the same passages and writes a screening report.
Nothing in the interview is a stored question template. Every question carries the retrieval query and the exact book passages (with page numbers) it was generated from, and the UI shows them under “Why this question”.
resume ──► profile ──► interview plan ──► retrieval ──► question ──► answer ──► grade
│ │ │ │ │ │
skills topics + hybrid grounded rubric adaptive
technologies queries dense+BM25 + cited scoring follow-up
gaps per topic + MMR │
▼
report + transcript
Documents. docs/design-document.pdf is the full design and
architecture write-up (problem framing, RAG decisions, data model, failure modes, trade-offs).
docs/submission-cover-sheet.pdf is the one-page submission
sheet with the deliverables checklist.
Contents. Quick start · Layout · System flow · API · Design decisions · Operating notes · Tests · Hosting · Demo · Next
Requirements: Python 3.11+ and Node 18+. A GEMINI_API_KEY (Google AI Studio, free tier
is enough) is needed for real question generation; without one the system still runs
end-to-end on deterministic offline stubs.
# 1. backend deps
python -m venv .venv
.venv/Scripts/activate # Windows; source .venv/bin/activate on macOS/Linux
pip install -r backend/requirements.txt
# 2. configuration
cp .env.example .env # then put your GEMINI_API_KEY in it
# 3. corpus: download the assignment's books, then index them
cd backend
python -m scripts.fetch_books # writes data/knowledge_base/<role>/*.pdf
python -m scripts.ingest_kb --all # chunk -> embed -> index (see notes below)
python -m scripts.ingest_kb --status # what is on disk and what is indexed
# 4. run the API
uvicorn app.main:app --reload --port 8000 # docs at http://127.0.0.1:8000/docs
# 5. run the UI (second terminal)
cd frontend
npm install
npm run dev # http://localhost:3000A sample resume to try is in data/samples/sample_resume_ml.md.
Ingestion of all six books (~3,900 indexed chunks) takes under a minute with the default local embedder. To index only part of each book:
python -m scripts.ingest_kb --all --max-pages 200 --skip-leading 10There is also a headless demo that drives the whole flow through the API and prints the plan, the retrieval traces, the grades and the report — handy for a screen recording:
python -m scripts.demo_interview --role ai_ml_engineer --questions 3OFFLINE_MODE=1 uvicorn app.main:app --port 8000The embedder falls back to a deterministic hashing embedder and the LLM to a stub that returns schema-shaped payloads built from the retrieved passages. Question quality is obviously not comparable — the point is that the pipeline, storage and UI stay exercisable (this is also how the test suite runs).
backend/
app/
main.py app factory, CORS, request logging, error translation
core/ settings (env-driven), error hierarchy, logging
api/routes/ health, roles, resumes, sessions, knowledge
api/mappers.py ORM -> DTO, so routes and services stay free of each other
schemas/dto.py the request/response contract
services/ resume_service, interview_service, report_service
rag/ loaders, chunking, embeddings, vector_store, retriever, ingest
llm/ client interface, Gemini implementation, offline stub, prompts
db/ SQLAlchemy models and session handling
domain/roles.py role registry: corpus + competencies + fallback queries
scripts/ fetch_books.py, ingest_kb.py, demo_interview.py
Dockerfile one image: builds the UI, serves it from the API
render.yaml Render Blueprint for the same image
tests/ offline end-to-end and unit tests
frontend/
app/ Next.js App Router: layout, page (stage machine), design tokens
components/ SetupStage, InterviewStage, ReportStage, RetrievalTrace, Stepper
lib/ typed API client and shared types
data/
knowledge_base/<role>/ corpus PDFs (not committed)
vector_store/<role>/ the index: vectors.npy + records.jsonl + meta.json
samples/ a sample resume
docs/
design-document.pdf design & architecture write-up (source: the .html next to it)
submission-cover-sheet.pdf
- Candidate entry. The UI lists roles and shows, per role, which corpus backs it and how many chunks are indexed. A role whose knowledge base is not indexed cannot be selected — the system refuses to interview from an empty corpus rather than hallucinating.
- Resume processing.
POST /api/resumesaccepts PDF/TXT/MD. Two extraction layers run: a deterministic pass (skill dictionary with word-boundary matching, date-range union for years of experience, name heuristics) and an LLM pass that produces the structured profile. They are merged, the deterministic values filling anything the model left empty. The result is a profile with skills, technologies, seniority, strength signals and gap signals. - Context construction.
InterviewService._build_planasks the model for N topics given the profile, the role's competencies and the section headings actually present in the index. Each topic carries: why it was chosen (tied to something in the resume), a difficulty calibrated to seniority, a question type, and — importantly — a retrieval query written in textbook vocabulary, not interview phrasing. Query construction is therefore resume-driven and role-driven, which is exactly what the retrieval step needs. - Knowledge retrieval. Hybrid dense + BM25 search over the role's collection, MMR re-ranking for diversity, and chunk-level exclusion of everything already used earlier in the same interview (so question 5 is not a paraphrase of question 2).
- Question generation. The retrieved passages, the candidate profile and the last few turns go into the question prompt. The model returns the question, its rationale, the 3-5 points a strong answer must contain, and the citations it used.
- Interactive interview. Answers are graded synchronously against the expected points and the retrieved passages — the grader sees the same ground truth the question came from. The candidate sees the score, what was covered and what was missed.
- Adaptation. After each answer: the difficulty of the next planned topic moves up or down one step, and the grader may request a follow-up (budgeted at two per interview) — pushing deeper after a strong answer, or asking for specifics after a vague one.
- Final output. The report combines statistics computed from stored grades (means, per-topic scores, difficulty mix, follow-up count, which books were used) with an LLM narrative. The numbers never come from the model; it is asked to interpret the evidence, not to produce the scores.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/health |
liveness plus dependency status (DB, LLM, per-role index size) |
GET |
/api/roles |
roles with corpus, competencies and index readiness |
GET |
/api/roles/{slug}/knowledge |
index metadata for one role |
POST |
/api/resumes |
multipart upload → parsed profile |
POST |
/api/resumes/text |
same, from pasted text |
POST |
/api/sessions |
start an interview (builds the plan, asks Q1) |
GET |
/api/sessions/{id} |
full session state incl. transcript and traces |
POST |
/api/sessions/{id}/next-question |
pending question, or generate the next (204 when exhausted) |
POST |
/api/sessions/{id}/answers |
submit an answer → grade + updated session |
POST |
/api/sessions/{id}/complete |
close the interview, build the report |
GET |
/api/sessions/{id}/report |
fetch the report |
POST |
/api/knowledge/search |
run a retrieval query directly (introspection/debugging) |
Errors are uniform: {"code": ..., "message": ..., "details": {...}}, produced by one
exception handler translating the domain error hierarchy (ValidationError → 422,
NotFoundError → 404, ConflictError → 409, UpstreamError → 502,
KnowledgeBaseError → 503). Interactive docs: /docs.
Why a service layer instead of route logic. Routes translate HTTP to method calls and
nothing else; InterviewService owns the lifecycle. That is what makes the adaptive rules
testable without a web server, and it is why the offline test suite can drive the entire
flow through the API in 1.7 seconds.
Chunking: structure-aware packing, 1400 chars with 220 overlap. Paragraph boundaries first, sentence packing only when a paragraph is oversized, and a character overlap started at a sentence boundary. 1400 characters is roughly a full textbook argument — a definition plus the paragraph that motivates it — which is the unit a good interview question is built from. Much smaller and the retrieved context is a fragment that cannot support a question; much larger and the embedding averages several ideas and retrieval gets vague. Chunks below 220 characters are folded into their predecessor instead of being indexed as noise, and each chunk keeps its page range so questions can be cited.
A quality filter before embedding. Textbook PDFs extract tables of contents,
bibliographies, index pages and equation soup. Those embed fine and then surface as
"context", producing nonsense questions. is_useful_chunk rejects short blocks, blocks
with a low alphabetic ratio, dot-leader TOC lines and reference-list patterns. On this
corpus it drops ~8% of chunks — cheaper embeddings and better retrieval.
Embeddings: a pluggable provider, defaulting to a local static model. Three
implementations sit behind one interface (EMBEDDING_PROVIDER):
| provider | what it is | indexing 3,900 chunks | why you'd pick it |
|---|---|---|---|
local (default) |
Model2Vec static model (potion-base-8M, 256-d), CPU only |
~40 s | reproducible, no key, no quota |
gemini |
gemini-embedding-001 at 768-d, task-typed |
hours on the free tier | best retrieval quality |
hashing |
hashed n-grams, zero dependencies | seconds | tests, CI, air-gapped runs |
The gemini path is the better encoder — documents are embedded with
RETRIEVAL_DOCUMENT and queries with RETRIEVAL_QUERY, and that asymmetry measurably
helps — but its free tier shares a global per-minute embed quota, and a book-sized
corpus takes hours behind it. That is a poor default for something a reviewer has to be
able to reproduce, so the local static model is the default and the hosted encoder is one
environment variable away. Static embeddings lose ground on subtle paraphrase, which is
part of why retrieval is hybrid rather than dense-only. A collection records the dimension
it was built with, and the store rejects a query vector that does not match, so switching
provider tells you to re-ingest instead of silently returning nonsense.
Retrieval: hybrid dense + BM25, then MMR. Dense embeddings match paraphrase
("regularisation" ≈ "penalising complexity"); BM25 matches the exact token a resume brags
about ("XGBoost", "EM algorithm"). Both are min-max normalised per query and blended
(DENSE_WEIGHT, default 0.7). MMR (λ = 0.65) then trades a little relevance for coverage,
because books repeat themselves and a pure top-k returns five paraphrases of one paragraph
— which yields five near-identical questions. Chunks already used in the session are
excluded outright.
Vector store: a persistent flat index behind an interface. The corpora are books:
10³–10⁴ chunks. An exact normalised matrix-vector product over 10k × 768 floats is ~7
MFLOPs — well under a millisecond in NumPy — so an ANN index would add a dependency and an
approximation for no measurable gain. The partitioning that does matter is per role, and
that is explicit: one collection per role, so a question for one role can never retrieve
another role's corpus. VectorStore is a Protocol; swapping in Chroma/Qdrant/pgvector is
a one-class change, and the retriever would not notice.
Storage: SQLAlchemy + SQLite by default. DATABASE_URL is the only thing standing
between this and Postgres. The schema mirrors the pipeline — resume → session → question →
answer → report — and every question row stores its full retrieval_trace (query, chunk
ids, scores, source, pages, excerpt). Traceability is a stored artefact, not a log line.
Two-layer everything. Resume parsing, grading and reporting each pair a deterministic component with an LLM component. The deterministic half is always right and always available; the LLM half adds judgement. When the LLM is unavailable the system degrades instead of failing: profiling falls back to the dictionary pass, grading stores an "ungraded" answer, and the report emits the statistics with a note.
Prompts live in one module. app/llm/prompts.py holds every prompt and its response
schema. Gemini's responseSchema constrains the output shape, which removes the usual
"parse JSON out of prose" failure mode; parse_json is still tolerant of fenced blocks as
a backstop.
Frontend state. Next.js App Router, one stage machine (setup → interview → report).
The backend is the source of truth for the session; the client stores only the session id
in localStorage, so a mid-interview refresh returns to the current question. The visual
language follows the design tokens in DESIGN-airtable.md (white canvas, dark ink,
signature colour cards, near-black pill CTA).
Model choice. LLM_MODEL defaults to gemini-3.1-flash-lite, not because it is the
strongest model but because free-tier daily caps differ enormously per model —
gemini-2.5-flash allows about 20 generate-content requests per day, which one interview
exhausts. Point LLM_MODEL at gemini-2.5-flash (or a paid key) for sharper questions.
Free-tier rate limits. Both the LLM and the hosted embedder are rate limited per
minute, and the embedding ceiling is a global quota, not a per-key one. Two mechanisms
deal with it: every client honours a server-supplied retryDelay over its own backoff,
the LLM client spaces its own calls (LLM_REQUESTS_PER_MINUTE), and the hosted embedder
paces itself with AIMD — the inter-batch pause shrinks 10% after
each success and grows 60% after a 429 — so a long ingest survives unattended instead of
dying on the first burst. When the LLM is rate-limited past its retries the interview does
not break: the answer is stored ungraded and the report falls back to statistics, which is
the degradation path described above.
Ingestion is idempotent. A role's collection is rebuilt from whatever is in
data/knowledge_base/<role>/. Add a book, re-run, done.
Configuration is entirely environment-driven (.env.example lists every knob):
model names, embedding dimension, chunk size/overlap, RETRIEVAL_TOP_K, DENSE_WEIGHT,
RETRIEVAL_MMR_LAMBDA, question counts, CORS origins, OFFLINE_MODE.
Corpus note. fetch_books.py pulls the books listed in the assignment. The host for
Burkov's Hundred-Page Machine Learning Book serves an invalid TLS certificate, so that
one usually fails to download; the AI/ML role is still backed by Mitchell and ML for
Absolute Beginners. Drop the PDF into data/knowledge_base/ai_ml_engineer/ and re-run
ingestion if you have a copy.
cd backend
python -m pytest -q # 22 tests, fully offline, no API key neededThey cover the PDF text repair, chunk sizing/overlap, the quality filter, embedder determinism, BM25 ranking, vector-store round-trip and citation format, hybrid retrieval grounding and no-repeat behaviour; the orchestration rules (plan statuses advance and persist, the interview exhausts, strong answers earn a budgeted follow-up and push the difficulty ladder up, completion drops an unanswered question); and the full API lifecycle plus the error contracts (409 on double answer, 404 on unknown role/session, 422 on a too-short resume).
The production build is single-origin: next build with STATIC_EXPORT=1 emits a static
site into frontend/out, and the API mounts it at / (after the routers, so /api/* always
wins). One process, one port, no CORS configuration, and the client calls a relative /api.
cd frontend && STATIC_EXPORT=1 npm run build # -> frontend/out
cd ../backend && python -m uvicorn app.main:app --port 8000
# UI on http://127.0.0.1:8000/ , API on /api , docs on /docsContainer. The Dockerfile does both stages and bakes the embedding model into the image
so the first request is not a model download:
docker build -t grounded .
docker run -p 8000:8000 -e GEMINI_API_KEY=... groundedRender. render.yaml is a Blueprint for the free tier — Docker runtime, health check on
/api/health, GEMINI_API_KEY set in the dashboard. Any Docker host works the same way.
The vector index (data/vector_store/, ~9 MB) is committed, so a clone runs immediately
without downloading 90 MB of textbooks. The PDFs themselves are not; run
scripts.fetch_books then scripts.ingest_kb --all to rebuild the index from source.
Temporary public URL. For a demo or a review link, a Cloudflare quick tunnel needs no account and no DNS:
cloudflared tunnel --url http://127.0.0.1:8000It prints a https://<random>.trycloudflare.com URL that proxies to the local app. The URL is
random per run and the tunnel dies with the process — fine for a demo, not a deployment. While
it is open the API is reachable by anyone holding the link, and every interview spends your
Gemini quota.
The flow that shows the most in the least time:
python -m scripts.ingest_kb --status— the corpora on disk and the indexed chunk counts per role.- The role picker: each role shows its books and its index size; an unindexed role cannot be started.
- Paste or upload the sample resume → the extracted profile, with the gaps the interview will probe.
- Start the interview → the plan sidebar (topics, difficulty, question type) and the first question, which names the candidate's own technology.
- Expand “Why this question” — the retrieval query, the cited pages, and the hybrid / dense / BM25 scores behind each passage. This is the part worth lingering on.
- Answer well → the grade with covered/missed points, and watch the next topic's difficulty move up in the sidebar; answer badly → the follow-up probe appears.
- End the interview → the report: recommendation, per-topic meters, transcript with every question's provenance still attached.
- Optionally
/docsfor the API surface andpytest -qfor the suite.
- Move ingestion to a background worker with a progress endpoint, so a corpus can be added from the UI rather than the CLI.
- Cache question generation per (topic, chunk-set) to make demos cheap and repeatable.
- Add a second grader pass for disagreement detection — one model grading, one auditing.
- Voice answers (the answer path is already a plain string; only the input surface changes).
MIT — see LICENSE.