Skip to content

Case study

HireIQ

A conversation in place of an application form

  • Python
  • FastAPI
  • NVIDIA NIM
  • Next.js 14
  • Supabase
  • WeasyPrint

01

The problem

Job application forms are a disaster for both sides. Candidates dump CVs into black holes; hiring teams drown in unqualified applications. The conversation that should be happening, 'tell me about a project, I'll ask follow-ups', is replaced with a static form. A static form can't ask follow-ups, can't probe weak answers, and can't tell a strong candidate from a polished CV. I built an AI that has the conversation instead, and produces a scored, defensible report at the end.

02

How it fits together

The loop back from the follow-up policy is the product. A thin answer sends the interview round again instead of moving to the next question.Drag sideways if it runs past the edge

03

What this is built on

  • Structured-interview research (Schmidt & Hunter 1998 meta-analysis, more recent McDaniel reviews) is the basis. Structured interviews predict job performance about 2x better than unstructured ones. Key levers: rubric-based scoring, anchored rating scales, multiple criteria per run.
  • Workday, Greenhouse and Lever structure their candidate pipelines around the static form, which is what 1990s HR software was. The conversation is absent from all three.
  • Gemini Flash 2.0 was the choice for latency and cost: sub-second responses, 10x cheaper than GPT-4. For a high-volume conversational interviewer, latency and cost matter more than peak reasoning ability.
  • Legal hiring constraints (EEOC US, GDPR EU, employment law) set the boundaries on what the AI can ask and what the report can say. Protected-category questions are avoided; assessments are role-relevant.
  • WeasyPrint and CSS-print specs are the PDF path. PDF generation from HTML is well-trodden, but small mistakes (missing print stylesheet, browser-quirk fonts) ruin the output.

04

What I couldn't do

  • The conversation has to feel like a real interview. Multi-second response delays kill the flow.
  • Hiring teams have to justify their decisions internally, so the scoring has to be defensible on its own terms.
  • Couldn't pay for GPT-4 on every candidate at scale.
  • Candidates apply on slow connections; all interactions had to feel instant.
  • Render free tier for the Python backend, which means cold starts of ~30s if no traffic for 15 min.

05

The decisions that shaped it

  1. Decision 01

    Gemini Flash 2.0, not GPT-4 or the model.

    Conversational interviewing is high-volume, low-stakes-per-token (the report is where reasoning matters, not the question generation). Flash is sub-second, 10x cheaper than GPT-4, and quality is fine for structured Q&A. Saving budget for scoring, where quality actually matters.

  2. Decision 02

    Adaptive follow-up, not a fixed question list.

    If the candidate gives a weak answer, the system asks for specifics. If they nailed it, it moves on. Mirrors how a good interviewer behaves. A fixed list of questions cannot do that.

  3. Decision 03

    Scored report with strengths + concerns + binary recommendation.

    Hiring teams need to defend decisions to compliance, to other interviewers, to themselves. The report breaks down what the candidate did well, what was weak, and gives a hire/no-hire recommendation. Hiring manager can override but has the structure to override against.

  4. Decision 04

    PDF reports via WeasyPrint, not browser print.

    Recruiters live in PDF, they print, share, attach to ATS systems. Generating the report as styled HTML and converting via WeasyPrint gives me a clean PDF without writing PDF layout code by hand. Picked WeasyPrint over alternatives (ReportLab too low-level, Puppeteer needs Chrome) because it renders CSS faithfully on a Python-only stack.

  5. Decision 05

    LocalStorage session persistence with auto-resume.

    Candidates abandon mid-interview if they lose connection or close the tab. Lost candidates = wasted interview cost. I persist the conversation state to localStorage on every turn. On refresh, the app silently resumes from the last turn without re-asking anything. Removed the auth screen entirely for `/apply`. Anyone with the link can apply, and the session token is in the URL (commits a370f33, 540b575).

  6. Decision 06

    Knockout questions + severity engine, not just open Q&A.

    Some criteria are non-negotiable, such as whether the candidate has legal authorisation to work in country X, or a driver's licence for a delivery role. Those should be knockout questions that auto-reject if failed. Built a severity engine where each question carries a hire/maybe/reject weight, and a single 'reject' on a knockout ends the interview gracefully (commits 920c3d7, 5113156).

  7. Decision 07

    Job form v2 with structured fields (visibility, language proficiency, candidate info sections).

    Original job form was free-text. Realised employers need structured fields for: job visibility (public vs private link), required language proficiency (CEFR levels), candidate info collection sections. Rewrote the form as v2 with these as first-class fields. Took months but the data model now supports filtering by language, by role type, by seniority (commit 7803262).

  8. Decision 08

    Cold-start CORS warmup gate.

    Render free tier sleeps the backend after 15 min idle. First candidate after a sleep hits a 30-second cold start that ALSO causes CORS failures (preflight fails before the function is up). Built a `/health` warmup gate that the frontend pings before showing the chat UI. UI shows 'getting interview ready…' for the cold-start window, then transitions cleanly (commit 0c55a07).

06

What broke and how I changed course

  • First version generated all questions upfront from the job description, then asked them in order. Felt robotic. Switched to streaming question generation per turn, conditioning on prior answers. Slower per turn but the conversation flowed (commit 354462b).
  • Initial scoring model gave wildly different scores for the same answer when re-run. Added temperature=0 plus a structured prompt with an explicit rubric (1-5 on each criterion). Consistency went up 80%.
  • Tried AsyncGroq SDK first, then ran into mysterious connection bugs in production. Switched to direct httpx REST calls (commit f2b896d).
  • Originally used Gemini Flash everywhere. Hit rate-limit issues during traffic spikes. Added Groq as a fallback (commit 5b3df5d, 6874a2e). Multi-provider with automatic failover became table stakes after that.
  • Auth flash bug: candidates would briefly see a login screen even though /apply doesn't require auth. Caused by a race condition, AuthProvider mounted before checking the route. Excluded /apply from AuthProvider entirely (commit 1109b40).
  • OAuth loop: signing in with Google would redirect into an infinite cycle on certain edge cases. Spent two days debugging Supabase OAuth + Vercel SSR + Render API CORS interactions. Fix involved hardcoding the production CORS origin instead of wildcard+credentials (which is invalid CORS), and a fix in the redirect_to flow (commit 3e651e0).

07

What I didn't know, and how I learned

  • I had not built a multi-page Streamlit-like flow with state persistence before. Streamlit's state model resets on every interaction. Switched to Next.js pages with localStorage hydration after a frustrating week.
  • CORS in production with credentials + multiple origins is genuinely confusing. I shipped wildcard+credentials twice before realising it's silently invalid. Now I default to hardcoded explicit origins per environment.
  • I underestimated how brittle SSE streaming is across reverse proxies. Vercel's edge proxy buffered chunks instead of forwarding them. Had to set explicit response headers (`X-Accel-Buffering: no`, `Cache-Control: no-cache, no-transform`) to force pass-through (commit a2bbe53 in coldpilot, but the lesson came from hireiq first).
  • Scoring rubric design is its own field. The first scoring prompt produced inconsistent results because it asked the model to score on multiple dimensions simultaneously without clear weight. The multi-criteria decision-analysis literature is the reference. Scoring was rewritten as per-criterion 1-5 with explicit anchors, then aggregated.

08

What shipped

  • Full pipeline: job posting, then adaptive interview, then scored report, then PDF
  • Runs on NVIDIA's free OpenAI-compatible endpoint (mistral-medium-3.5-128b), after pivoting off Gemini for rate limits and then off Groq
  • Supabase + RLS for candidate data
  • WeasyPrint PDF reports for hiring teams
  • LocalStorage session resume, candidates can refresh / lose connection without losing the interview
  • Knockout question + severity engine
  • Job form v2 with structured fields (visibility, language proficiency, candidate sections)
  • Cold-start warmup gate hides Render's 30s wakeup from candidates

09

What's next

Should have added video question support from the start. Some senior roles want to see candidates speak, not just type. Roadmap. Also should have built an ATS-export integration (Greenhouse, Lever, Workday) earlier, recruiters live in those tools and copying scored reports manually is friction.

What I learned

A static form can be replaced with an interview at roughly the same UX cost if the model is chosen for the job. The remaining work was edge cases: CORS, cold starts, OAuth loops.