erin.dev

Back to home

🎯 JobFitCheck

AI-powered job posting analyzer that extracts structured data and scores your fit

Source Code

JobFitCheck turns unstructured job postings into structured, comparable data using OpenAI's function calling API, then scores how well each role matches your profile.

The problem: job postings are inconsistent and time-consuming to parse. The same information — tech stack, experience level, salary — is buried in different formats across companies. Job seekers waste hours reading postings that aren't a good fit.

JobFitCheck solves this with a two-stage pipeline:

  1. Structured extraction — paste a job posting (or URL) and GPT-4 Turbo extracts company info, role details, required/nice-to-have skills, compensation, remote policy, red flags, and highlights into a typed schema via function_call. The schema enforces enums, nullability, and nested structure — no JSON parsing failures.

  2. Deterministic match scoring — a weighted algorithm (not LLM-based) compares extracted data against your profile across 5 dimensions: required skills (40%), experience level (20%), nice-to-have skills (15%), logistics fit (15%), and compensation (10%). Produces a 0–100 score with gap analysis, dealbreaker detection, and a recommendation tier.

The frontend provides a complete workflow: extract → score → track applications → analyze skill gaps across all postings → compare roles side-by-side. An optional LLM-generated narrative summarizes your fit in plain English.

I built a 110-posting evaluation framework with field-level accuracy metrics (exact match, Jaccard overlap, range accuracy, null detection) and used it to benchmark GPT-4 0613 vs GPT-4 Turbo across function calling and JSON mode — Turbo improved key-field accuracy by ~5% while cutting latency by 44% and cost by 67%.

🎯 JobFitCheck

🛠️ Tech Stack

React 19TypeScriptTailwind CSS v4ViteReact RouterPython 3.11FastAPIPydantic v2SQLAlchemy 2.0 (async)PostgreSQLAlembicOpenAI API (GPT-4 Turbo, function calling)BeautifulSoup + httpxDocker Composepytest

🚀 Features

  • Structured extraction via function calling — GPT-4 Turbo extracts company, role, skills, compensation, logistics, red flags, and highlights into a typed Pydantic schema. The function call schema enforces enums and nullability, so the model returns null for missing fields instead of hallucinating values.

  • Weighted match scoring engine — deterministic algorithm scores postings across 5 weighted dimensions (skills, experience, logistics, compensation, nice-to-haves). Reproducible, debuggable, and fast — no API call needed. Dealbreakers like onsite-only override the score to force a skip recommendation.

  • Skill normalization with alias mapping — a curated alias map of ~60 canonical skills handles variants like React.js ↔ ReactJS ↔ react without false positives from edit-distance matching (which would incorrectly link Go to Java).

  • LLM fit narrative — optional second pass using gpt-4o-mini generates a 2–4 sentence plain-English summary of your fit, referencing specific matched/missing skills and posting context.

  • Aggregate skill gap analysis — runs match scoring across all saved postings concurrently and visualizes which skills appear most often in your target roles but are missing from your profile.

  • Side-by-side comparison — select up to 4 postings and compare scores, verdicts, salary ranges, skill overlaps, and dealbreakers in a unified table.

  • Application pipeline tracking — lightweight status progression from analyzed → interested → applied → interviewing → offer/rejected/passed, with inline status editing on the dashboard.

  • Model evaluation framework — 110-posting eval dataset with field-level accuracy metrics (exact match, Jaccard, range, null detection). Benchmarked GPT-4 0613 vs GPT-4 Turbo in both function calling and JSON mode.

🧗‍♂️ Challenges

  • Schema design for null detection — the hardest extraction problem isn't parsing present data, it's knowing when to return null. Many postings omit salary or company stage. I used explicit system prompt instructions ("Use null when information is not present — do not guess") combined with schema-level required vs optional field design to minimize hallucinated values. The eval framework tracks null true-negative rate as a first-class metric — GPT-4 Turbo achieved ~86% on optional fields.

  • Deterministic scoring vs. LLM-based matching — I considered using a second LLM call for scoring but chose a pure-function approach: same inputs always produce the same score, every score component is traceable, and there's zero API latency. The tradeoff is losing nuance (an LLM might understand that "3 years of React" partially covers a "5 years frontend" requirement), but determinism and testability won out for a tool you check repeatedly. The LLM narrative fills the qualitative gap as an optional enhancement.

  • Function calling vs. JSON mode tradeoff — when GPT-4 Turbo launched JSON mode, I ran a head-to-head comparison. Function calling won on accuracy (~+3%) because the schema constrains enum values at the API boundary — the model cannot return "fully-remote" when the enum only allows "remote". JSON mode was marginally faster but required embedding the full schema in the system prompt, increasing token usage.

  • Skill matching without false positives — edit-distance fuzzy matching sounds good in theory but produces bad matches for short skill names (Go ↔ Java, C ↔ C#, R ↔ C). A hand-curated alias map trades recall for precision — it won't catch every variant, but it never produces wrong matches. The normalize() function is designed to accept a fuzzy fallback later without changing the matching interface.

🧠 Learnings

  • Function calling is underrated for structured extraction — it's not just "get JSON from an LLM." The schema enforcement at the API boundary (enum constraints, required fields, nested types) eliminates an entire class of parsing bugs. Pydantic v2's model_validate() on the output catches anything the schema misses. The combination is more reliable than prompting for JSON + parsing.

  • Evaluation frameworks pay for themselves immediately — building the 110-posting eval set and field-level metrics took a full phase, but it made the GPT-4 → GPT-4 Turbo migration a data-driven decision instead of vibes. It also caught a regression where Turbo hallucinated salary values more often than 0613 on agency-style postings (vague text → the model "helpfully" fills in numbers).

  • Denormalize early for dashboard queries — storing company_name, role_title, and salary_min as top-level columns alongside the full extracted_data JSON felt redundant, but the dashboard table needs sorting and filtering on these fields. Querying nested JSON in PostgreSQL is possible but produces ugly queries and poor index utilization. The denormalized columns are always set from the same extraction output, so they never drift.

  • Keep matching logic pure — calculate_match() takes posting data and profile data as arguments, returns a report, and touches no database or API. This made the 43-test suite trivial to write (no mocks, no fixtures beyond dataclasses) and enabled concurrent match scoring across all postings for the skill gap analysis without worrying about connection pools or rate limits.