I built an AI agent that reads a CV, extracts a structured profile, searches multiple job boards, and scores every result with an honest 0–100 fit rating and a plain-English explanation. It runs locally, costs pennies per search, and every LLM call is traced. Here’s how the whole thing works.
CV (PDF)
│
▼
cv_reader ──▶ profile.py (LLM → structured Profile)
│
▼
runner.py ──▶ LangGraph StateGraph
│
┌─────────┼─────────┐
▼ ▼ ▼
fetch_jobs rank_jobs reformulate_query
│ │ │
└─────────┼─────────┘
▼
Ranked Jobs ──▶ SQLite (history)
│
▼
Gradio UI
Why I built it
Job boards are noisy. You search “ML Engineer,” get 200 results, and spend hours reading descriptions to figure out which ones actually match your background. I wanted to flip that — hand the machine a CV, let it search, and get back a ranked shortlist with explanations I could trust.
The real motivation was to learn LangGraph properly. Not a toy chatbot — a multi-step agent with tool calls, structured output, conditional loops, and a budget cap. Something where the graph topology actually matters.
The agent graph
The core is a LangGraph StateGraph with three nodes and a conditional loop:
fetch_jobs — The LLM receives the candidate profile and picks search arguments via a tool call. It calls run_search(), which cascades through available job sources. Results are deduplicated against jobs found in prior loops so the agent doesn’t re-rank the same posting twice.
rank_jobs — Jobs are batched in groups of 5. For each batch, the LLM scores every job using structured output (with_structured_output), producing a RankedJob with a 0–100 fit score, a plain-text explanation, matched skills, and identified gaps. Results are sorted by fit descending.
reformulate_query — If fewer than 5 jobs scored 60 or above, and the agent hasn’t already reformulated twice, it broadens the search query and loops back to fetch_jobs. This is the part that makes it an agent rather than a pipeline — it reacts to its own results.
The loop exits when enough good matches are found or the reformulation cap (2) is hit. A hard budget of 25 LLM calls per run (ensure_budget()) acts as a circuit breaker so a bad prompt can’t run up the bill.
Job source cascade
Not every user has API keys, and I didn’t want the app to be unusable without them. The search function tries sources in priority order and falls back gracefully:
- JSearch — Google-for-Jobs aggregator. Best location filtering. Requires an API key (~200 free requests/month).
- Adzuna — International coverage, free registration. Requires app ID + key.
- Remotive — Remote-only jobs. No key needed.
- Offline cache — A committed JSON file with ~247 jobs. Always works.
Each source implements a JobSource protocol and returns normalized JobPosting objects. The cascade means the app runs with zero API keys out of the box — you just get a narrower job pool.
Structured output everywhere
The agent relies heavily on Pydantic models as structured-output targets:
- Profile — name, seniority, primary roles, skills, years of experience, locations, languages, remote preference. Extracted from raw CV text in one LLM call.
- JobScore — fit score (0–100), explanation, matched skills, gaps. One per job, batched 5 at a time.
- RankedJob — a
JobPostingpaired with its score. This is what the user sees.
Structured output is where model quality matters most. Local models (I tried Ollama with Qwen 2.5 7B) struggled with the schema — they’d return malformed JSON, skip required fields, or hallucinate fit scores that didn’t match their own explanations. Switching to Claude Sonnet 4.5 via the Anthropic API made the output reliable. The scores became consistent, the explanations got specific, and the matched-skills lists actually reflected what was in the CV.
The LLM factory
The get_chat_model() function wraps LangChain’s init_chat_model with provider-aware key export. It accepts a provider:model string and handles the rest:
# config.py default
SCOUT_MODEL=anthropic:claude-sonnet-4-5-20250929
# also works
SCOUT_MODEL=openai:gpt-4o-mini
SCOUT_MODEL=ollama:llama3.2
SCOUT_MODEL=groq:llama-3.3-70b-versatile
Models are cached with lru_cache so the same model string doesn’t create multiple clients. The provider prefix determines which API key gets exported to the environment (LangChain’s clients read from os.environ, not from the settings object).
Persistence
The app was originally stateless — results vanished when you closed the tab. I added a SQLite layer so the History tab could browse past searches across restarts. Five tables:
- cvs — raw CV text and filename
- profiles — structured profile linked to a CV
- runs — metadata (model, cost, latency, reformulation count, errors)
- job_postings — deduplicated across runs via
INSERT OR IGNORE - run_jobs — junction table with per-run fit scores and explanations
List fields (skills, tags, gaps) are stored as JSON text columns. Booleans are INTEGER 0/1. Timestamps are ISO 8601. WAL mode is enabled for concurrent reads from the Gradio UI.
All writes are best-effort — if the database is unavailable, the search still works. The persistence layer logs warnings and moves on.
What I learned
Structured output is the bottleneck. The agent graph topology is straightforward. The hard part is getting the LLM to reliably fill a Pydantic schema with accurate, calibrated scores. Small models can’t do it well. Claude Sonnet 4.5 can.
Cascading fallbacks make demos work. Nothing kills a demo faster than “you need three API keys to try this.” The source cascade means make app works immediately, even offline.
Budget caps are essential. Without ensure_budget(), a reformulation loop with a vague CV could make dozens of LLM calls. The 25-call cap turns a potential $2 run into a predictable $0.15 one.
SQLite is underrated for single-user apps. WAL mode, JSON columns, and INSERT OR IGNORE give you 80% of what you’d want from Postgres with zero infrastructure.
The stack
- LangGraph — agent graph with
StateGraphandMemorySavercheckpointer - Anthropic Claude Sonnet 4.5 — default LLM (swappable via env var)
- LangChain — model abstraction, structured output, tool calling
- Gradio — 3-step wizard UI with History tab
- SQLite — persistence with WAL mode
- Pydantic — data models and structured-output targets
- pydantic-settings — configuration from
.envwithSecretStr - httpx — async HTTP for job source APIs
- Prometheus —
/metricsendpoint for homelab monitoring - Ruff — linting and formatting
- pytest + respx — offline test suite (59 tests, no network, no credentials)
The code is at github.com/dirtmerchant/observable-job-agent.