Skip to main content
Glama
README.md
# jobpipe

![tests](https://github.com/Jayaramgokul/jobpipe/actions/workflows/tests.yml/badge.svg)
![python](https://img.shields.io/badge/python-3.10%2B-blue)
![MCP](https://img.shields.io/badge/Model_Context_Protocol-server-6E56CF)
![licence](https://img.shields.io/badge/licence-MIT-green)

**An MCP server that lets Claude run a job-search pipeline, where the model's
judgement is checked by code before it is allowed to count.**

Claude does the one thing a model is good at here: reading a job description and
judging fit. Everything else (sourcing, de-duplication, visa-sponsor checks,
hard eligibility rules, storage, calibration) is plain, testable Python. And the
model's scores are not trusted on faith: every score must quote the job
description, and the server verifies the quote exists before the score counts.

It reads only public, unauthenticated sources. No LinkedIn scraping, no session
cookies, no form submission.

---

## Why this is interesting beyond job hunting

The pattern is general: **use an LLM for judgement, and wrap it in deterministic
controls that make the judgement auditable.** It is the same idea I apply
professionally when validating AI systems in financial services.

| Control | How jobpipe implements it |
|---|---|
| Narrow the model's job | Deterministic rules reject impossible postings first, so the model only scores plausible ones |
| Ground every claim | Each rubric dimension needs a verbatim quote from the job description |
| Verify, don't trust | The server checks each quote really appears in the text; fabricated evidence **voids** that dimension (scores 0, weight still counts) |
| Allow honest "don't know" | A dimension can `abstain`; it leaves the denominator but blocks automatic progression |
| Measure it | You label postings by hand and get precision and recall per band |
| Audit trail | Nothing is silently discarded; every rejection keeps its rule and evidence |

## How it works

```mermaid
flowchart LR
    A["Greenhouse / Lever / Ashby<br/>public JSON boards"] --> B[Normalise + dedupe]
    B --> C["UK sponsor register<br/>(GOV.UK CSV)"]
    C --> D["Deterministic rules<br/>no model"]
    D -->|REJECT, with reason| X[(kept for audit)]
    D -->|PASS / REVIEW| E["Claude scores<br/>against rubric"]
    E --> F{"Evidence check<br/>quote in JD?"}
    F -->|no| G[dimension voided]
    F -->|yes| H[score counts]
    G --> I[Band: AUTO_DRAFT / REVIEW / REJECT]
    H --> I
    I --> J["Your labels → precision / recall"]
```

### The evidence check, concretely

The model returns, for each dimension, a score and a quote:

```json
{"job_id": "greenhouse:examplecapital:123",
 "dimensions": {
   "quant_depth": {"score": 4, "abstain": false,
                   "evidence": "backtesting and benchmarking of pricing models"},
   "...": "one entry per rubric dimension"}}
```

`record_assessment` normalises typography and whitespace, then confirms the
quote occurs in the stored description (a 95% fuzzy floor tolerates curly
quotes and line breaks, nothing looser). If it does not, the dimension is voided
and the job cannot reach `AUTO_DRAFT` however high its total. Fabricating
evidence therefore *lowers* the score instead of being ignored. That is the
difference between a prompt that asks for citations and a system that enforces
them.

## Live check (15 October 2026)

The parsers were run against three real public boards, one per ATS, using
[`examples/live_check.py`](examples/live_check.py):

| Board | ATS | Postings parsed | Empty descriptions | Missing dates |
|---|---|---:|---:|---:|
| Monzo | Greenhouse | 68 | 0 | 0 |
| Palantir | Lever | 315 | 0 | 0 |
| Cohere | Ashby | 116 | 0 | 0 |
| **Total** | | **499** | **0** | **0** |

Deterministic pre-filter on those 499 (sponsor register not consulted in this
check):

| Verdict | Count |
|---|---:|
| REJECT (hard rule failed) | 216 |
| REVIEW (soft rule failed) | 200 |
| PASS | 83 |

The security-clearance rule fired 181 times (171 Palantir, 10 Cohere). Spot
checks confirmed these were genuine clearance requirements, not keyword noise.
So before a single token is spent, the model's workload drops from 499 postings
to the 283 that are actually possible.

## Tools exposed to Claude

| Tool | Stage |
|---|---|
| `fetch_jobs` | Poll configured Greenhouse/Lever/Ashby boards, dedupe, store |
| `refresh_sponsor_register` | Download the current GOV.UK register (24h cache) |
| `check_sponsorship` | Match employers → CONFIRMED / AMBIGUOUS / NOT_FOUND |
| `run_prefilter` | Deterministic rules; rejects keep their reasons |
| `get_rubric` | Rubric text + JSON schema for scoring |
| `next_to_score` | Jobs that cleared the pre-filter |
| `record_assessment` | Validate evidence, compute band, store |
| `label_job` | Your own APPLY / MAYBE / NO, for calibration |
| `calibration_report` | Precision and recall against your labels |
| `list_pipeline` | Current state |
| `mark_applied` | Outcome tracking |

Typical session:

```
refresh_sponsor_register
fetch_jobs
check_sponsorship        (companies from the new jobs)
run_prefilter
next_to_score → get_rubric → score each → record_assessment
list_pipeline band=AUTO_DRAFT
```

## Quick start

```bash
git clone https://github.com/Jayaramgokul/jobpipe.git && cd jobpipe
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp config.example.yaml config.yaml      # then list the boards you care about
python -m pytest tests/ -q              # 30 offline tests
python examples/live_check.py           # optional: hits three live boards
```

### Connect it to Claude

**Claude Code / Claude Desktop (local, stdio):**

```bash
claude mcp add jobpipe -- /full/path/to/.venv/bin/python -m jobpipe.server
```

or in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "jobpipe": {
      "command": "/full/path/to/.venv/bin/python",
      "args": ["-m", "jobpipe.server"],
      "env": {
        "JOBPIPE_CONFIG": "/full/path/to/config.yaml",
        "JOBPIPE_DATA": "/full/path/to/data"
      }
    }
  }
}
```

**As a remote connector:** set `JOBPIPE_TRANSPORT=streamable-http` and run it
behind a host that terminates TLS. A remote connector is reached from the
cloud, so localhost will not work. Local stdio is the better default, because
the pipeline data is personal.

## Project layout

```
jobpipe/
  ats.py        fetch + normalise Greenhouse, Lever, Ashby; dedupe
  sponsors.py   GOV.UK sponsor register: download, cache, fuzzy match
  prefilter.py  deterministic HARD / SOFT rules with evidence snippets
  rubric.py     rubric, JSON schema, evidence verification, banding
  store.py      SQLite state, labels, calibration metrics
  server.py     MCP server exposing the 11 tools
tests/          30 offline tests (fixtures, no network)
examples/       live smoke test against real boards
```

## Calibrate before trusting it

The weights and thresholds shipped here are a starting guess, not a
measurement. Label 40 to 60 postings you have already judged by eye, then run
`calibration_report`. Read precision and recall separately because they cost
different things: a false positive wastes a tailoring cycle, a false negative
loses a job you wanted. If AUTO_DRAFT precision is below about 0.85, tighten
the weights or raise the threshold. If recall is below about 0.7, the threshold
is too high.

## Limitations, stated plainly

- **Workday is not covered.** It has no public JSON feed, which excludes most of
  the Big Four and most large banks. Use email alerts for those.
- **Company-name matching is genuinely hard.** The register holds legal entity
  names; boards carry brand names. AMBIGUOUS is returned deliberately rather
  than guessing from the top fuzzy score.
- **Register freshness matters.** Ratings change without notice. Force a
  refresh before acting on anything important.
- **Being on the register is not an offer of sponsorship.** It means the
  employer *can* sponsor, not that it will for a given role.
- **Board slugs rot.** Companies migrate ATS and old boards 404 quietly;
  `fetch_jobs` reports these under `board_errors`.
- **The rubric is tuned to one profile** (UK model risk and AI governance). The
  dimensions live in `rubric.py` and are meant to be edited.

## Licence

MIT