joa-job-extractor-400m-gguf

A small model that reads one job posting and returns 13 fields as a single JSON object: whether the posting is still open, title, company, location, salary (currency, minimum, maximum, period), posted date, whether the poster is an agency, remote type and seniority.

It is IBM Granite 3.1 1B-A400M (a mixture-of-experts model with 1B parameters in total and about 400M active per token) fine-tuned with a LoRA adapter, merged, and converted to GGUF for llama.cpp. It was built by Job Opportunities API to extract pay and other fields from public job postings on its own hardware.

Files (all three are the same model at different precision):

file size sha256
joa-job-extractor-400m-Q4_K_M.gguf 0.82 GB ff9e8b9071f6b56458be9716e2d93fea18a5ae40fc240d3ff141018beb3de4d0
joa-job-extractor-400m-Q8_0.gguf 1.42 GB 95396a08a80a7c0541f6ef389dbf28cf301b98607c4301050ee05537d8e6ee03
joa-job-extractor-400m-f16.gguf 2.67 GB c303b869da88311ba9b556fcba7610c698957f4fc250786f1cf91f3567c5bf05
system_prompt.txt 5.5 KB the exact system prompt the model was trained on (see below)
examples/ one real input and the model's real output

Intended use

  • Reading the text of a job posting that you have already fetched (an employer career page, an ATS posting, JSON-LD) and turning it into structured fields, in batch, on your own machine.
  • Its strongest field is salary: it finds pay that rule-based parsers miss, including in German, French and Dutch postings (see the numbers below).
  • A second pass after a rules-based parser, to recover salaries the rules did not find. This is how its authors intend to use it.

Not intended for: deciding anything about a person, scoring candidates, or extracting data from pages that are not job postings. It does not browse, it does not fetch pages, and it has no tools. It only reads the text you give it.

Prompt format

The model was trained on one fixed prompt, and it only works well with that prompt. Use it unchanged.

  • System message: the contents of system_prompt.txt, with the placeholder {today} replaced by the date the posting was fetched (YYYY-MM-DD). It is about 1.4K tokens. It defines the open or closed decision, the field rules (salary, location, seniority and so on) and the output format.
  • User message: an "evidence bundle", the text of the posting laid out in five sections, always in this order:
EVIDENCE BUNDLE for <posting URL>
fetched_at: <ISO timestamp> | ATS family: <name or "page">
Source priority: 1 rendered page > 2 JSON-LD > 3 ATS API > 4 liveness > 5 JOA stored text (may be stale).

## 1. RENDERED PAGE (what a candidate sees)
final_url: ...
http_status: 200
page <title>: ...
(meta lines, then)
--- page body ---
<visible text of the page>

## 2. JSON-LD JobPosting: <none on the page | key: value lines>

## 3. ATS API RECORDS
<none, or the posting's record from its applicant tracking system>

## 4. LIVENESS SIGNALS (deterministic; hint: ...)
requested_url: ...
final_url: ... (http 200)

## 5. JOA STORED TEXT (lowest priority - possibly stale; prefer sections 1-4)
<plain text of the posting>

Sections you do not have can say none or [not rendered: <reason>], as in the example below. Typical bundles are 4 to 5K tokens (median 4.8K in the training data), and the longest in the evaluations was about 17.7K tokens, so run it with a context of at least 24K.

  • Sampling: temperature 0, max_tokens 1024. No tools, no chat template arguments (the model's own template from the GGUF is used).

Output schema

The reply is exactly one flat JSON object with these keys and no others:

key type meaning
job_in_url bool true only if the page shows this specific job and it is still open
inactive_reason string or null when job_in_url is false: hard_404, soft_404, redirect_to_search, server_error, login_wall, wrong_job or expired_notice; otherwise null
title string or null the title as the posting states it
company string or null the employer as the posting states it
location string or null "City, Region, Country"; US states as two letters and country "USA"; a part left empty rather than invented, for example "Remote, , USA"
salary_currency string or null ISO code (USD, EUR, GBP); a bare "$" follows the job's country
salary_min number or null lower bound, or the single figure
salary_max number or null upper bound, or the single figure
salary_period string or null hour, day, week, month or year, only when a unit is stated
posted_date string or null dd/mm/yyyy, only when a date is stated
is_agency bool or null true if the poster is not the employer itself (job board, staffing firm)
remote_type string or null onsite, hybrid or remote
seniority string or null Entry Level (0 years), Entry Level (1 year), Mid (2-3 years), Senior (4+ years), Lead, Manager, Director or Executive

When job_in_url is false, every other field is null. Not stated means null.

Worked example

This is a real public posting from a US city government career site (City of Fishers, Indiana), unchanged. The page itself could not be rendered because the site's robots.txt disallows it, so the bundle holds only the stored text. The model's answer is below the bundle, also unchanged. The same files are in the examples/ folder.

Input: the user message (evidence bundle)
EVIDENCE BUNDLE for https://cityoffishers.clearcompany.com/careers/jobs/fd1cffd1-14a9-dcfd-0db5-5bc530f02438/apply
fetched_at: 2026-11-03T19:39:48 | ATS family: page
Source priority: 1 rendered page > 2 JSON-LD > 3 ATS API > 4 liveness > 5 JOA stored text (may be stale).

## 1. RENDERED PAGE (what a candidate sees)
[not rendered: robots.txt disallows this URL]

## 2. JSON-LD JobPosting: none on the page

## 3. ATS API RECORDS
none (no known ATS recipe for this host and no job-matched JSON loaded by the page)

## 4. LIVENESS SIGNALS (deterministic; hint: no signal)
requested_url: https://cityoffishers.clearcompany.com/careers/jobs/fd1cffd1-14a9-dcfd-0db5-5bc530f02438/apply

## 5. JOA STORED TEXT (lowest priority - possibly stale; prefer sections 1-4)
Title: Part Time Laborer - DPW Streets

Status: Year-round Part-time (average 29 hours per week)

Compensation: $17 hourly

Description

This year-round part-time position performs general labor to help ensure city streets, right-of-ways, park grounds and facilities are maintained. Position will be required to work some evening, weekend and holiday hours including on-call snowplow/weather event duties as needed.

Fishers Department of Public Works (DPW) is responsible for the maintenance and upkeep of all municipal assets. These responsibilities include maintaining 340 center lane miles of roads and associated right-of-way which includes snow removal, crack sealing, patching, irrigation needs, traffic signals, striping, and street cleaning. DPW also maintains 1300 acres of parks and school grounds that includes over 60 baseball/softball fields, over 60 athletic fields, and over 500 acres of common area. From concerts to 5K runs, DPW staff is on the scene to provide setup and support.

Examples of Duties:
- Assist in curb and street repairs, asphalt and concrete work, etc.
- Trim trees and bushes along city roadways
- Pick up debris/trash along right-of-ways
- Maintain existing public open space areas, empty trash cans, etc.
- Assist in maintaining park managed facilities, athletic fields, irrigation systems and public playgrounds
- Clear brush, pull weed, rake, dig and perform miscellaneous gardening duties in and around municipal facilities and grounds
- Clean public restrooms at various city parks and buildings
- Mowing and weed trimming
- Assist on sanitary/storm sewer cleaning equipment
- Set up of tables, chairs, equipment, etc. for special events
- Clean shop and equipment as necessary
- Assist with snowplow / weather event duties

Minimum Qualifications:
- High school diploma or equivalent;
- Must have valid driver's license and ability to be insured to drive a city vehicle which includes at least 3 years of driving history outside of permit time;
- Must be able and willing to work outside in all weather conditions;
- Position will be required to work some evening, weekend and holiday hours;
- Ability to lift and carry objects weighing up to 50 lbs;
- Ability to climb ladders and maintain balance;
- Ability to interact and communicate effectively with a variety of people;
- Able to handle several projects at the same time;
- Reliable and trustworthy;
- Ability to work independently and within a team.

Model output (the system message is system_prompt.txt with {today} set to 2026-11-03):

{"job_in_url": true, "inactive_reason": null, "title": "Part Time Laborer - DPW Streets", "company": "Fishers Department of Public Works", "location": "Fishers, IN, USA", "salary_currency": "USD", "salary_min": 17, "salary_max": 17, "salary_period": "hour", "posted_date": null, "is_agency": false, "remote_type": "onsite", "seniority": "Entry Level (0 years)"}

The pay ($17 hourly) became salary_currency USD, salary_min and salary_max 17, salary_period hour. The posting states no date, so posted_date is null. The seniority value is the model's inference, since the text does not state one: check it before you rely on it.

Run it with llama.cpp

# 1. Start a server (llama.cpp, any recent build). --cache-ram 0 keeps memory low.
llama-server -m joa-job-extractor-400m-Q4_K_M.gguf -c 24576 -np 1 --jinja --cache-ram 0 --port 8091

# 2. Build the request from system_prompt.txt and your evidence bundle, then post it.
BUNDLE=example_bundle.txt   # your own bundle in the layout above
DATE=$(grep -m1 -o 'fetched_at: [0-9-]*' "$BUNDLE" | cut -d' ' -f2)
SYS=$(cat system_prompt.txt); SYS=${SYS//\{today\}/$DATE}
jq -n --arg s "$SYS" --rawfile b "$BUNDLE" \
  '{messages:[{role:"system",content:$s},{role:"user",content:$b}],temperature:0,max_tokens:1024}' |
curl -s http://127.0.0.1:8091/v1/chat/completions -H 'content-type: application/json' -d @- |
jq -r '.choices[0].message.content' | jq .

Any OpenAI compatible client works the same way. Parse the reply as JSON and take the last top-level object that has a job_in_url key.

Evaluation

All numbers come from Job Opportunities API's own tests, run in November 2026. They are not an independent benchmark.

Test set "t100". 100 held-out postings with a reference labelling, scored with a strict field-by-field F1 (a field counts only when it matches exactly after normalisation). The fully trained adapter served with vLLM on a GPU scored a strict F1 of 0.926 (95% interval 0.902 to 0.948, precision 0.933, recall 0.919, 100 of 100 outputs parsed).

The GGUF files, run with llama.cpp on a GTX 1070 (8 GB, context 16384) on the same 100 postings:

file strict F1 (95% interval) precision recall
Q4_K_M 0.923 (0.895 to 0.946) 0.944 0.902
Q8_0 0.922 (0.896 to 0.946) 0.935 0.910
f16 0.918 (0.892 to 0.942) 0.930 0.907

The three are statistically the same as the GPU result, so Q4_K_M is the practical choice. One posting of 17.7K tokens did not fit the 16K context used in this test and counted as a miss (99 of 100 parsed); a larger context removes that.

Salary on a wider audit. 1,084 postings from 74 job sources, 886 of them checked against the source's own data (vendor API pay fields, JSON-LD, or the visible text). On those 886, for postings whose source states a salary (261):

recall precision F1
this model, salary 86 94 90
the deterministic parser it is compared with, salary 59 97 74

The model found pay in many postings where the parser found none, including pay written in prose and pay in German, French and Dutch postings. The parser is more precise. On the audit, all 1,084 outputs were valid JSON (0 malformed, including 123 long bundles of over 6,000 tokens).

Other fields on the same audit (F1, 886 postings): title 99, company 97, city 95, remote type 90, posted date 97, salary currency 94, salary period 95. Weaker: country 91 and seniority 64 (see limits).

Speed (one posting at a time, about 4.5K prompt tokens and about 130 generated tokens per posting):

hardware file seconds per posting, median
GTX 1070 Q8_0 2.1
GTX 1070 Q4_K_M 2.3
6 busy CPU threads Q4_K_M 29
2 CPU threads Q4_K_M 70

On a CPU this is a batch tool, not an interactive one: the long input dominates the time.

Known limits

  • Invented amounts. About 3 percent of the salary amounts it returned (8 of 271) did not appear in the posting text as written. Always check that the number appears in the posting before you store it. The usual rule is: accept a salary only if its digits can be found in the source text.
  • Currency and period can disagree. When a posting gives both an annual and an hourly figure, the model can mix them (for example an annual amount labelled "hour"). Check that the period fits the amount.
  • Country and seniority are weak. Country is free text and is sometimes missing or wrong (F1 91 against 99 for a rules-based reader). Seniority answers by years of experience, and on the audit it scored 64 against 88 for a title-based rule; part of that gap is a difference in definition, but treat the field as a hint.
  • Not all fields are covered. Employment type (full time, part time) and region are not outputs.
  • Languages. The training postings are mostly English. German, French and Dutch were tested on small samples (60, 47 and 19 postings) and worked for salary; other languages were not measured.
  • Input matters. The model reads the bundle you give it. If the page text was not captured (script-only pages, blocked pages), there is nothing to extract, and the correct answer is null fields.
  • No tools, no browsing. It cannot confirm that a posting is still open on the live web; job_in_url is a judgement from the text and signals in the bundle.
  • It was fine-tuned on one fixed prompt. A different prompt or a different bundle layout will lower quality.

Training data and method

  • Data. About 47,300 training examples. Each is a public job posting from an employer's own career site or an applicant tracking system board (Greenhouse, Lever, Workday, SmartRecruiters and similar), laid out as an evidence bundle, with personal contact details redacted. A further 1,000 postings were held out, and the 100 postings of the test set above were excluded from training. The data is not released.
  • Labels. Produced by a larger teacher model, zai-org/GLM-5.3-Flash (MIT licence), reading each bundle and answering in the output schema above. A grounding filter then removed or corrected values that the posting text did not support. The labels were not checked by hand one by one.
  • Fine-tuning. LoRA on the attention projections (q, k, v, o), rank 64, alpha 128, one epoch, learning rate 1e-4, starting from ibm-granite/granite-3.1-1b-a400m-instruct. The experts of the mixture-of-experts layers were not adapted. The adapter was merged into the base weights and converted with llama.cpp (convert_hf_to_gguf.py to f16, then llama-quantize for Q8_0 and Q4_K_M). Training ran on the EuroHPC Discoverer+ cluster.

License

The model is released under the Apache License 2.0, like its base model, IBM Granite 3.1. The teacher model that produced the labels is MIT licensed. Check the terms of the websites you fetch postings from before you use their content.

Who made it

Job Opportunities API, https://jobopportunitiesapi.org. Questions and feedback: hello@jobopportunitiesapi.org.

Downloads last month
-
GGUF
Model size
1B params
Architecture
granitemoe
Hardware compatibility
Log In to add your hardware

4-bit

8-bit

16-bit

Inference Providers NEW
This model isn't deployed by any Inference Provider. ๐Ÿ™‹ Ask for provider support

Model tree for JobOpportunitiesAPI/joa-job-extractor-400m-gguf