Skip to content
Back to work

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. The static form is the worst possible interface: it can't ask follow-ups, can't probe weak answers, 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 I read before writing code

  • Read structured-interview research (Schmidt & Hunter 1998 meta-analysis, more recent McDaniel reviews). Structured interviews predict job performance ~2x better than unstructured ones. Key levers: rubric-based scoring, anchored rating scales, multiple interviewers (or in our case, multiple criteria within one AI run).
  • Studied how Workday, Greenhouse, Lever structure their candidate pipelines. ATS systems are built around the static form because that's what 1990s HR software was. The conversation is conspicuously absent.
  • Read Gemini Flash 2.0's release notes and benchmarks before picking it. Sub-second responses, 10x cheaper than GPT-4. For a high-volume conversational interviewer, latency and cost matter more than peak reasoning ability, Flash trades a bit of reasoning for the volume math working.
  • Studied legal hiring constraints (EEOC US, GDPR EU, employment law generally), specifically what the AI can ASK and what the report can SAY. Avoided protected-category questions, kept assessments role-relevant.
  • Read WeasyPrint's docs and CSS-print specs. PDF generation from HTML is a well-trodden path but small mistakes (missing print stylesheet, browser-quirk fonts) ruin the output.

04

What I couldn't do

  • Conversation has to feel like a real interview, not a bot quiz, multi-second response delays kill the vibe.
  • 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. A score with no reasoning is useless. 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, 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: 'do you have legal authorisation to work in country X', 'do you have driver's license for this 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. Hides the bug from the user without papering over it (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 + a structured prompt with explicit rubric (1-5 on each criterion). Consistency went up 80%. The lesson: scoring needs to be deterministic in a way generation doesn't.
  • Tried AsyncGroq SDK first, then ran into mysterious connection bugs in production. Switched to direct httpx REST calls, proven working approach (commit f2b896d). 'The library exists' is not the same as 'the library works in production'.
  • 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). Sometimes the right fix is removing the protection from a public page.
  • 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 a research field I had not entered. First scoring prompt produced inconsistent results because I was asking the model to score on multiple dimensions simultaneously without clear weight. Studied multi-criteria decision-analysis literature, rewrote scoring 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
  • Now runs on NVIDIA's free OpenAI-compatible endpoint (mistral-medium-3.5-128b for both the one-shot scoring and the live interview stream), after pivoting off Gemini for rate limits and then off Groq; the `groq_*` names in config are legacy
  • 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

You can replace a static form with intelligence at the same UX cost, if you pick the right model for the job. Don't use GPT-4 for what Flash can do. Production is full of edge cases (CORS, cold starts, OAuth loops) that nobody warns you about, the bug list is the case study.