HireSift
Upload fifty resumes, get a ranked shortlist with a reason for each.
Built from scratch to demonstrate ai resume screening

What this solves
Screening a stack of resumes is slow, and the slowness is the least of it. By resume forty the reviewer is not applying the same standard they applied to resume one.
A model can read all fifty at a consistent standard. The difficulty is that a hiring decision needs to be explainable and auditable, and a raw model response is neither reliable enough to store nor accountable enough to defend.
I built HireSift to demonstrate LLM output treated as untrusted input: validated before storage, and recorded with an audit trail for every decision.
How I approached it
- Resumes arrive singly or as a zip, in PDF, DOCX, DOC, TXT, or RTF. The archive is unpacked and each candidate becomes one background job on a database-backed queue. A malformed file fails alone instead of taking down the batch.
- Each resume is parsed into a structured profile (name, email, skills, years of experience, summary) rather than free text, so candidates are comparable and queryable.
- Each candidate is scored 0 to 100 against the job with a one-line reason for the call, so a recruiter sees why someone ranked where they did rather than an unexplained number.
- Scoring runs through Gemini when an API key is configured, and falls back to a deterministic local analyzer when it is not, so the product works end to end with no API key at all. That fallback is also what makes the demo honest: it never silently does nothing.
- Results feed a sorted review queue. The recruiter works top down and exports the shortlist as CSV or fires a webhook into whatever they already use.
- Duplicate detection flags repeat email addresses within a job, and is order-independent across parallel workers, so the same candidate is caught no matter which worker sees them first.
An LLM returning unreliable structured data.
Why the obvious solution fails
The obvious approach is to ask for JSON and parse the response. It works in testing and fails in production, because the failures are not the ones you plan for. The model returns JSON wrapped in a markdown fence, or valid JSON with a field renamed, or a score as the string 'high' instead of a number, or valid JSON that omits a required field entirely. A parse succeeds and a malformed record lands in the database, where it corrupts a ranking that a human then trusts. Worse, it fails quietly: nothing throws, the candidate just sorts to the wrong place.
What I did instead
- The shape is pinned at the request, not repaired afterwards. The call sets a JSON response type and a typed response schema, with temperature held low, so the model is constrained to the fields the system expects rather than asked politely for them.
- The response is then still treated as untrusted. It has to be a non-empty string that decodes to an array, or it is rejected outright, because a schema request is a strong constraint and not a guarantee.
- Values are bounded on the way in rather than trusted: the score is clamped to 0 to 100, and text fields and skill lists are trimmed and deduplicated before anything is stored. A score of 250 cannot enter the ranking.
- When extraction fails for any reason, the job does not die. It logs, switches mode, and falls back to a deterministic local analyzer, so a candidate always gets a real result instead of a blank record.
- Because every candidate is its own job, one resume that keeps failing cannot block or corrupt the rest of the batch.
How the data flows
A Laravel 11 API on MySQL handles upload, fan-out to queued jobs, extraction, scoring, and export, with Sanctum token auth and a database-backed queue worker. A Next.js 14 frontend presents the review queue.
- A recruiter uploads resumes, singly or as a zip, against a job description.
- The archive is unpacked and each candidate dispatched as its own background job.
- The job extracts text from the file and asks for a structured profile: name, email, skills, years of experience, summary.
- The response is validated before anything is written, so a malformed result never reaches the database.
- A valid profile is scored 0 to 100 against the job with a one-line reason, via Gemini or the deterministic local analyzer.
- Duplicate detection flags repeat emails within the job, order-independent across parallel workers.
- A resume that cannot be processed retries once, then stops and shows the recruiter why: file missing, no readable text because the file is a scan, or the specific error. It fails visibly rather than disappearing from the list.
- The recruiter works the sorted queue, and every approve, pass, and reopen is written to the audit trail.
- The shortlist is exported as CSV or pushed to a webhook.
What I'd do differently
- Scoring every candidate against every job with a separate model call is the dominant cost. Batching candidates per request would cut it substantially at some loss of per-candidate isolation.
- Ranking is only as consistent as the prompt. At production scale I would want a fixed evaluation set of resumes scored on every prompt change, so a change that shifts rankings is caught before it reaches recruiters.
- The fallback degrades quietly, which is right for uptime and wrong for trust. The system already tracks which mode produced a result, so I would surface it: a recruiter comparing two candidates should know if one was scored by the model and the other by the local analyzer.
- Automated screening carries real bias risk. Before this made a hiring decision rather than a sorting suggestion, it would need auditing for disparate impact, and the audit trail is a prerequisite for that rather than a substitute.


Want something like this built?
I build payment systems and AI features inside Laravel apps. Tell me what you need and I will tell you how I would approach it.
Get in touch