Portfolio | Talent Acquisition | Org Structure | Onboarding | Go-to-Market | Audit & Compliance | API
MEMORANDUM
──────────────────────────────────────────────────────────────────────────────
TO: Candidates preparing for their next interview loop
FROM: InterviewIQ
RE: Interview readiness: deliverables and commitments
──────────────────────────────────────────────────────────────────────────────
✔ Intake resume + job description parsed
✔ Agenda 8 questions generated, grounded in your experience
✔ Review rubric clarity · specificity · result orientation · relevance
✔ Scoring computed by the backend, never by the model
✔ Placement real listings, scored 0–100 against your resume
✖ Fabrication none. Failures are disclosed, and you can retry.
InterviewIQ turns a resume and a target job description into a tailored mock interview. It writes eight grounded questions and scores each answer on a transparent four-part rubric. Then it shows a stronger example answer and tracks your progress over time. The AI Job Finder finds real listings that fit your resume and turns any of them into an interview in one click.
|
Resume + JD analysis, then exactly 8 questions: 2 technical, 2 project deep-dive, 2 behavioral, 1 system design or case study, 1 motivation. Every question is schema validated, with a bounded repair-retry loop. |
Four integer scores (1–5), each with a reason, plus overall feedback and the one
thing to fix. The percentage is |
|
One question at a time, answered in order. Drafts autosave locally. Progress is restored from the database on refresh, and duplicate submits and multiple open tabs are handled safely. |
A STAR-shaped example (behavioral questions only), What changed, missing details
shown as |
|
Upload a PDF/DOCX resume, set roles, location and preferences, and get real listings with a 0–100 match score. Then Prepare for interview on any of them. |
Real aggregates, a score-trend chart (with a table view) and rubric averages. History has search, role filter, sort, pagination, and delete with confirmation. |
■ Further offerings: auth, failure handling, settings, demo mode
- Auth: Supabase email/password sign-up, login, logout, password reset and protected routes. The backend verifies every JWT, via JWKS signing keys or the legacy HS256 secret.
- Failure handling: an answer is stored before evaluation. A failed evaluation is marked
failedand offers Retry, and scores are never fabricated. - Session review: expand or collapse each question, compare your answer with the example, keep the resume hidden by default, and Practice again.
- Settings: display name, theme (light/dark/system), AI provider disclosure, sign out, and account deletion.
- Demo mode: passwordless, isolated demo accounts with labelled sample history, plus an offline heuristic "AI" that's always labelled as not an AI model.
![]() |
![]() |
/ landing |
/dashboard progress and rubric averages |
![]() |
![]() |
/interviews/:id one question at a time |
four-dimension evaluation |
![]() |
|
/jobs AI Job Finder |
|
Note
These screenshots were captured in demo mode: the sample history is fictional and the scores come from the offline heuristic, which the UI labels as such.
/jobs (sidebar: Job finder). Upload a resume (PDF/DOCX, or paste / reuse your latest).
Set target roles, location, level, work arrangement and employment type, and see real
listings scored against your resume. Prepare for interview sends a listing (plus the
full description you paste, if the listing only has a summary) into the interview engine.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#0b1f3a','primaryTextColor':'#ffffff','primaryBorderColor':'#c9a227','lineColor':'#c9a227','secondaryColor':'#2a4a75','tertiaryColor':'#f4f6f9','textColor':'#0b1f3a','edgeLabelBackground':'#f4f6f9','clusterBkg':'#f4f6f9','clusterBorder':'#8a9ab0','titleColor':'#0b1f3a','fontFamily':'Helvetica, Arial, sans-serif'}}}%%
flowchart LR
R[/"Resume"/] -- "contacts redacted" --> P["01 · Screening<br>skills-only profile"]
P -- "keywords + location" --> S["02 · Sourcing<br>Adzuna listings"]
S --> M["03 · Assessment<br>score 0–100"]
M --> I(["Interview prep<br>8-question session"])
| Stage | What runs | Data sent out |
|---|---|---|
01 Analyse resume |
Gemini extracts a skills-only profile | Resume with contact details removed |
02 Discover listings |
Adzuna job-search API (ADZUNA_*) |
Role keywords + location only, never the resume |
03 Evaluate matches |
One batched Gemini call; score computed in code | Profile + redacted resume + listing text |
■ How the 0–100 score is computed
score = 100 × ( 0.40 · skill_coverage ← computed from evidence in your resume
+ 0.25 · experience ┐
+ 0.20 · role alignment ├ AI ratings 1–5, mapped to 0–1
+ 0.15 · responsibilities ) ┘
- If a listing names no skills, the skills weight is dropped and the rest renormalised.
- Labels: ≥75 Strong · ≥55 Good · ≥35 Partial · else Weak.
- It estimates resume-to-listing alignment, not hiring probability.
- It is labelled provisional unless scored against a full description you provided, because Adzuna returns summaries only.
■ Why Adzuna instead of Gemini Google Search grounding
The Gemini API terms for grounding forbid analysing, caching, altering or mixing other content into grounded results, and a job finder must do all of those. Adzuna is a licensed job-search API with a free developer tier.
- Its terms require an "Adzuna" attribution on each listing, which every card renders.
- Organisations may use it on trial for 14 days, and commercial use "may require a licence agreement". Check this with Adzuna before a commercial launch.
- Default limits: 25/min, 250/day, 2,500/month.
■ Honesty rules and storage
- Only listings returned by the provider are shown, and none are ever generated.
- Links are restricted to the provider's own HTTPS listing URLs.
- Missing fields show "Not listed", and predicted salaries are hidden.
- Listings are never claimed to be open.
- Storage:
job_searchesholds preferences, queries and the skills-only profile.job_resultsholds listing fields, match analysis and the linked interview.- Resume files are processed in memory and never stored, and resume text stays in the browser tab.
- Both tables are deleted with the account.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#0b1f3a','primaryTextColor':'#ffffff','primaryBorderColor':'#c9a227','lineColor':'#c9a227','secondaryColor':'#2a4a75','tertiaryColor':'#f4f6f9','textColor':'#0b1f3a','edgeLabelBackground':'#f4f6f9','clusterBkg':'#f4f6f9','clusterBorder':'#8a9ab0','titleColor':'#0b1f3a','fontFamily':'Helvetica, Arial, sans-serif'}}}%%
flowchart LR
subgraph Browser
W["React + Vite SPA<br>Render static site"]
end
subgraph Render
A["FastAPI<br>Docker web service"]
end
subgraph Supabase
AU["Auth<br>login only"]
DB[("Postgres<br>+ RLS")]
end
AI["Gemini / OpenAI<br>server-side keys"]
JOBS["Adzuna<br>job-search API"]
W -- "HTTPS + JWT" --> A
W -. "sign in" .-> AU
A -- "verify JWT (JWKS)" --> AU
A --> DB
A --> AI
A --> JOBS
| Layer | Choice | Where |
|---|---|---|
| Frontend | React 18, TypeScript, Vite, Tailwind, shadcn-style components (Radix), TanStack Query, RHF + Zod, Recharts, Lucide | frontend/ |
| Backend | Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2 (async) | backend/app |
| DB | Supabase Postgres + RLS (prod); SQLite (local demo + tests) | supabase/migrations |
| Auth | Supabase Auth; backend verifies JWT (JWKS or HS256) | app/core/security.py |
| AI | Provider abstraction: gemini, openai, demo, with model fallback |
app/ai/ |
| Jobs | Provider interface + Adzuna adapter, normalisation, deterministic scoring | app/jobs/ |
■ Backend source map
backend/app
├── main.py app factory: config validation, middleware, CORS, routers
├── asgi.py uvicorn entry point
├── core/ config, security (JWT), logging (JSON + request IDs), exceptions, rate limit
├── api/routes/ health, sessions, answers, analytics, account, jobs (+ demo)
├── schemas/ public API request/response models
├── models/ SQLAlchemy ORM (mirrors the SQL migrations)
├── repositories/ all queries, every one scoped by user_id
├── services/ interview, question, evaluation, rewrite, analytics, account, demo, job finder
├── jobs/ resume extraction, job providers, normalisation, match scoring
└── ai/
├── provider.py Gemini / OpenAI over httpx; error mapping; model fallback; cost estimates
├── demo_provider.py offline heuristic (explicitly selected, never a fallback)
├── structured.py bounded retries + JSON repair + schema validation
├── schemas.py AI output schemas (8 questions, 1–5 scores…)
└── prompts/ version-controlled prompts
■ Key design decisions
- The backend owns all data access.
- The browser uses Supabase only for authentication.
- The backend scopes every query by the verified user id.
- It returns 404 rather than 403, so it never reveals whether a record exists.
- RLS policies add defence in depth.
- AI calls happen before writes. Session creation runs both AI steps first, then inserts the session and its questions in one transaction, so a failure leaves no partial rows.
- Idempotency:
- A
UNIQUE(question_id)constraint allows one answer per question. - An identical resubmit returns the stored answer.
- An atomic
UPDATE … WHERE status IN (…)claim stops two tabs from evaluating, and billing, the same answer. - A claim stuck in
evaluatingis reclaimed after 4 minutes.
- A
- Prompt-injection hardening:
- User text travels only as JSON inside
<untrusted_data>, with<and>escaped so it can't close the block. - System prompts tell the model to treat that data as data, never as instructions.
- All model output is schema-validated.
- The LLM has no tools, SQL or credentials.
- User text travels only as JSON inside
- Data minimisation:
- Email addresses, phone numbers and links are redacted before AI calls.
- Logs contain IDs, sizes and durations, never resume text, answers or tokens.
Tip
No credentials needed. Demo mode runs on SQLite with an offline heuristic in place of the AI. Prerequisites: Python 3.12+ and Node 20+.
■ Windows: one command
powershell -ExecutionPolicy Bypass -File .\start-dev.ps1■ Manual: macOS / Linux / Windows
# Backend (demo mode, SQLite, offline demo AI)
cd backend
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements-dev.txt
cp .env.example .env # defaults: DEMO_MODE=true, AI_PROVIDER=demo
uvicorn app.asgi:app --reload --port 8000
# Frontend (new terminal)
cd frontend
npm install
cp .env.example .env.local # VITE_API_BASE_URL=http://localhost:8000
npm run devOpen http://localhost:5173, choose Log in, then Explore the demo. API docs are at http://localhost:8000/docs.
Warning
Seeing {"error":{"code":"not_found",...}}? Port 8000 is the API, not the website.
Open http://localhost:5173 instead (the web app must be running).
To get real AI feedback while still using demo login, set AI_PROVIDER=gemini and
GEMINI_API_KEY=… (or openai / OPENAI_API_KEY) in backend/.env and restart.
■ Full local setup: Supabase + real AI
- Create a Supabase project at supabase.com.
- Apply migrations once
DATABASE_URLis set inbackend/.env(step 5):- Run
cd backend && python -m scripts.apply_migrations. It is safe to re-run. - Or paste
supabase/migrations/*.sqlinto the SQL editor in order, or usesupabase db push. - Then run
python -m scripts.check_setupto verify the database, auth keys and AI key without printing secrets.
- Run
- (Recommended) Run the RLS isolation test on a non-production project:
psql "$DB_URL" -f supabase/tests/rls_isolation_test.sql. It runs inside a rolled-back transaction. - Configure Auth. Under Authentication → URL Configuration, set the Site URL to
http://localhost:5173and addhttp://localhost:5173/reset-passwordas a redirect URL. backend/.env:DEMO_MODE=false DATABASE_URL=postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres DB_AUTO_CREATE=false SUPABASE_URL=https://<ref>.supabase.co SUPABASE_JWT_SECRET= # only if your project still uses legacy HS256 JWT secret SUPABASE_SERVICE_ROLE_KEY=... # optional: lets account deletion remove the auth user AI_PROVIDER=gemini GEMINI_API_KEY=... ADZUNA_APP_ID=... # optional: enables live job search (developer.adzuna.com) ADZUNA_APP_KEY=...
frontend/.env.local:VITE_API_BASE_URL=http://localhost:8000 VITE_SUPABASE_URL=https://<ref>.supabase.co VITE_SUPABASE_ANON_KEY=<public anon / publishable key>
■ Environment variables
| Variable | Where | Required | Purpose |
|---|---|---|---|
APP_ENV |
backend | – | development / test / production (stricter startup checks) |
FRONTEND_ORIGIN |
backend | prod | Comma-separated CORS allowlist |
DATABASE_URL |
backend | prod | Postgres URL (Supabase session pooler recommended) |
DB_AUTO_CREATE |
backend | – | true only for SQLite/dev; production uses migrations |
SUPABASE_URL |
backend | prod | JWKS verification + admin user deletion |
SUPABASE_JWT_SECRET |
backend | legacy | HS256 verification for legacy projects |
SUPABASE_SERVICE_ROLE_KEY |
backend | optional | Deletes the auth user on account deletion |
DEMO_MODE, DEMO_JWT_SECRET, DEMO_SESSIONS_PER_HOUR |
backend | – | Demo accounts (SQLite only) |
AI_PROVIDER |
backend | ✔ | gemini / openai / demo |
GEMINI_API_KEY / OPENAI_API_KEY |
backend | per provider | Never exposed to the browser |
AI_MODEL, AI_FALLBACK_MODELS |
backend | – | Primary model, then comma-separated models tried in order when it is overloaded (503/429/timeout) |
AI_TIMEOUT_SECONDS, AI_MAX_RETRIES, AI_RETRY_BACKOFF_SECONDS |
backend | – | Bounded retry policy |
RATE_LIMIT_PER_MINUTE |
backend | – | Per-user AI request limit |
ADZUNA_APP_ID, ADZUNA_APP_KEY, ADZUNA_COUNTRY |
backend | for job finder | Licensed job-search provider |
JOB_SEARCH_*, JOB_SEARCHES_PER_HOUR, RESUME_UPLOAD_MAX_MB |
backend | – | Job finder cache, limits and upload size |
VITE_API_BASE_URL |
frontend | ✔ | Backend URL |
VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY |
frontend | for real auth | Public values only |
The backend refuses to start with an inconsistent configuration. For example, it rejects a real provider without a key, production with SQLite or localhost CORS, and demo mode with Postgres.
In production now:
| Layer | Host | URL | Source |
|---|---|---|---|
| Web app | Vercel | interviewiq-pink.vercel.app | frontend/ (vercel.json) |
| API | Render (Docker) | interviewiq-api-98cw.onrender.com/health | backend/ (render.yaml) |
| Database + Auth | Supabase | Postgres with RLS, Supabase Auth | supabase/migrations |
Every push to main redeploys both: Vercel builds the web app from frontend/, and Render rebuilds the API image.
To deploy your own copy:
- API on Render: dashboard → New → Blueprint → select this repo, then fill in the secrets it asks for (
DATABASE_URL, Supabase keys,GEMINI_API_KEY, Adzuna keys). The Blueprint also defines an optionalinterviewiq-webstatic site; skip it if you host the frontend on Vercel. - Web app on Vercel: import the repo, set Root Directory to
frontend, and add the three public valuesVITE_API_BASE_URL(the Render API URL),VITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEY. - Connect them: set
FRONTEND_ORIGINon the API to the Vercel URL (comma-separate several origins). The API refuses to start in production with a localhost origin. - Supabase Auth: under Authentication → URL Configuration, set the Site URL to the web app and add its
/loginand/reset-passwordas redirect URLs.
Important
Never put DATABASE_URL, the service-role key or AI keys in a VITE_ variable: those
are bundled into the browser. Render's free plan sleeps when idle, so the first request
after a pause is slow. More detail is in docs/DEPLOYMENT.md.
cd backend
pytest # 128 tests, mocked AI, SQLite: no credentials needed
TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/iq_test pytest # same suite on Postgres
pytest -m live_ai # optional: real provider round-trip (needs a key in .env)
ruff check . && ruff format --check . && mypy app
cd frontend
npm run lint && npm run typecheck && npm test && npm run buildCI (ci.yml) runs all of this on SQLite and Postgres 16.
It also applies the SQL migrations, runs the RLS isolation test, builds the frontend and
builds the Docker image.
■ What the backend tests cover
- Request validation, question-schema and count validation, invalid categories and distribution.
- Score range and percentage maths, malformed-JSON repair, bounded retries, and transient vs permanent vs quota errors.
- IDOR and ownership checks for every endpoint, plus expired, forged and
alg=nonetokens. - Idempotent duplicate submits, out-of-order answers, evaluation failure followed by retry, and refresh restore.
- Rewrite caching and regeneration, analytics aggregation, rate limits, and prompt-injection isolation.
- Job finder:
- PDF/DOCX extraction and rejection, and the Adzuna adapter against a mocked API.
- URL safety, de-duplication, scoring, caching, partial results and per-user isolation.
- Prepare-interview.
- Log hygiene, atomic rollback on DB failure, account deletion, and the evaluator metrics.
■ Demo mode
DEMO_MODE=trueadds Explore the demo to the login page. Each visitor gets an isolated account (random UUID, 12-hour token) seeded with three fictional sessions, badged Demo in the UI.AI_PROVIDER=demoreplaces the AI with a deterministic keyword/statistics heuristic. A banner, a badge on every score and the[Demo heuristic]prefix in feedback all say it is not an AI model.- Demo mode needs the SQLite database, because demo users aren't Supabase Auth users. See DEPLOYMENT.md for a separate public demo backend.
- With a real provider configured, the app never falls back to demo output when the provider fails. It shows an error and keeps the answer for a retry.
■ Evaluator quality
backend/evals/ contains 30 synthetic answers (10 questions × weak/medium/strong) and an
agreement runner. The runner reports MAE, exact agreement, within-one agreement, quadratic
weighted kappa, bias and the largest disagreements. Human labels are intentionally empty,
and no agreement experiment has been run. The runner refuses to report metrics until at
least 30 items are human-labelled. See backend/evals/README.md.
■ Known limitations
- Verified against live services:
- Gemini (question generation, evaluation, resume profile and job matching).
- A hosted Supabase project.
- Live Adzuna job search (real listings, link checks and match scoring).
- Rate limiting is per process and kept in memory. With several instances, move it to Redis.
- Synchronous AI calls: generation and evaluation run inside the HTTP request, which takes 10–40 s for generation. A job queue would be the next step at scale.
- Demo heuristic scores measure only text statistics; they aren't meaningful feedback.
- React Router v6: two moderate advisories (GHSA-wrjc-x8rr-h8h6, GHSA-337j-9hxr-rhxg) are fixed only in v7.
- The app doesn't use SSR.
- Its one redirect from user input (
?next=) goes through a strict same-site validator (safeRedirectPath, unit-tested). - Upgrading to v7 is recommended.
- Cost estimates in
ai_usage_eventsuse a static price table inapp/ai/provider.py. They're approximate. - Resume upload: old
.docfiles and scanned (image-only) PDFs aren't supported; scanned PDFs get a clear message.
■ Requires credentials or manual configuration
- Supabase project, migrations, Auth URL configuration and email templates/SMTP (email confirmation and password reset).
- A Gemini or OpenAI API key for real question generation, evaluation and rewriting.
- Adzuna
app_id/app_keyfor live job search. SUPABASE_SERVICE_ROLE_KEYso account deletion also removes the auth user. Without it, the app deletes all data and tells the user that an admin must remove the login.- Render (API, via the Blueprint) and Vercel (web app) projects.
- Human labels for the evaluation dataset.
API reference · Deployment · Demo script · Evaluator agreement
Prepared by Harsh Gupta · all figures audited by 128 automated tests · ▲ back to top




