JAAT_DEMO / index.html
pnorlander
Stream results tool-by-tool with live progress UI
e14c6ba
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JAAT — Job Ad Analysis Toolkit</title>
<style>
:root {
--bg: #f4f6fa;
--surface: #ffffff;
--border: #e0e4ef;
--primary: #2d5be3;
--primary-dark: #1a3ebf;
--text: #1a1d2e;
--muted: #6b7280;
--tag-yes: #dcfce7;
--tag-yes-text: #15803d;
--tag-no: #f1f5f9;
--tag-no-text: #64748b;
--radius: 10px;
--tool-border: #c7d2fe;
--done-bg: #f0fdf4;
--done-border: #86efac;
--error-bg: #fef2f2;
--error-border: #fca5a5;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); }
header {
background: var(--primary);
color: white;
padding: 1.2rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
header img { height: 48px; width: auto; border-radius: 8px; }
.header-text h1 { font-size: 1.4rem; font-weight: 700; }
.header-text p { font-size: 0.85rem; opacity: 0.85; margin-top: 0.2rem; }
main {
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
.card h2 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
textarea {
width: 100%;
height: 220px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
outline: none;
transition: border-color 0.2s;
}
textarea:focus { border-color: var(--primary); }
.title-input {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
margin-bottom: 0.75rem;
}
.title-input:focus { border-color: var(--primary); }
.input-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
margin-bottom: 0.35rem;
display: block;
}
.actions { display: flex; gap: 0.75rem; margin-top: 0.75rem; align-items: center; }
button {
padding: 0.6rem 1.4rem;
border-radius: 7px;
border: none;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: background 0.15s;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--border); color: var(--text); }
.btn-secondary:hover { background: #d1d5e0; }
#status { font-size: 0.85rem; color: var(--muted); }
#status.error { color: #dc2626; }
/* ── Tool cards ── */
#results { display: none; flex-direction: column; gap: 1rem; }
.tool-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
transition: border-color 0.3s, background 0.3s;
}
.tool-card.pending { opacity: 0.5; }
.tool-card.running { border-color: var(--primary); border-width: 2px; opacity: 1; }
.tool-card.done { border-color: var(--done-border); background: var(--done-bg); opacity: 1; }
.tool-card.error { border-color: var(--error-border); background: var(--error-bg); opacity: 1; }
.tool-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tool-name {
font-size: 0.9rem;
font-weight: 700;
color: var(--text);
}
.tool-badge {
font-size: 0.65rem;
font-weight: 600;
background: #eef2ff;
color: var(--primary);
border: 1px solid var(--tool-border);
border-radius: 4px;
padding: 0.12rem 0.45rem;
letter-spacing: 0.03em;
}
.tool-status-icon {
margin-left: auto;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.3rem;
font-weight: 600;
color: var(--muted);
}
.tool-status-icon.running { color: var(--primary); }
.tool-status-icon.done { color: var(--tag-yes-text); }
.tool-status-icon.error { color: #dc2626; }
.tool-desc {
font-size: 0.8rem;
color: var(--muted);
margin-top: 0.35rem;
}
.tool-output {
margin-top: 0.75rem;
display: none;
}
.tool-card.done .tool-output,
.tool-card.error .tool-output { display: block; }
/* summary strip */
.summary-strip { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.kv {
background: white;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.55rem 0.85rem;
min-width: 140px;
flex: 1;
}
.kv .label { font-size: 0.68rem; color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.kv .value { font-size: 0.92rem; font-weight: 600; margin-top: 0.15rem; word-break: break-word; }
/* tags */
.tags-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.tag {
padding: 0.3rem 0.8rem;
border-radius: 100px;
font-size: 0.8rem;
font-weight: 600;
}
.tag.yes { background: var(--tag-yes); color: var(--tag-yes-text); }
.tag.no { background: var(--tag-no); color: var(--tag-no-text); }
/* lists */
.result-list { display: flex; flex-direction: column; gap: 0.4rem; }
.result-item {
background: white;
border-radius: 8px;
padding: 0.55rem 0.8rem;
font-size: 0.85rem;
display: flex;
align-items: flex-start;
gap: 0.6rem;
}
.badge {
font-size: 0.68rem;
font-weight: 700;
background: var(--primary);
color: white;
border-radius: 5px;
padding: 0.1rem 0.4rem;
white-space: nowrap;
margin-top: 0.12rem;
flex-shrink: 0;
}
.empty { color: var(--muted); font-style: italic; font-size: 0.85rem; }
.error-msg { color: #dc2626; font-size: 0.85rem; }
/* spinner */
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid #ccc;
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.notice {
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.85rem;
color: #92400e;
}
/* Citation */
.citation {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
font-size: 0.8rem;
color: var(--muted);
line-height: 1.6;
}
.citation pre {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
font-size: 0.75rem;
font-family: "SF Mono", "Consolas", "Liberation Mono", monospace;
overflow-x: auto;
margin-top: 0.5rem;
white-space: pre-wrap;
}
.citation-title { font-weight: 700; color: var(--text); margin-bottom: 0.25rem; }
</style>
</head>
<body>
<header>
<img src="https://github.com/Job-Ad-Research-at-QSB-LUC/JAAT/raw/main/data/logo.png" alt="JAAT Logo" />
<div class="header-text">
<h1>JAAT — Job Ad Analysis Toolkit</h1>
<p>Paste a job advertisement below to extract tasks, skills, title match, firm name, wages &amp; more.</p>
</div>
</header>
<main>
<div class="notice">
Analysis runs each tool sequentially and may take a few minutes total. Results appear as each tool finishes.
</div>
<!-- Input -->
<div class="card">
<h2>Input</h2>
<label class="input-label" for="jobTitle">Job Title (used by TitleMatch)</label>
<input type="text" class="title-input" id="jobTitle" placeholder='e.g. "Software Engineer" or "Registered Nurse"' />
<label class="input-label" for="jobText">Full Job Advertisement Text</label>
<textarea id="jobText" placeholder="Paste the full text of a job posting here, then click Analyze..."></textarea>
<div class="actions">
<button class="btn-primary" id="analyzeBtn" onclick="analyze()">Analyze</button>
<button class="btn-secondary" onclick="clearAll()">Clear</button>
<span id="status"></span>
</div>
</div>
<!-- Tool pipeline -->
<div id="results">
<div class="tool-card pending" id="card-FirmExtract">
<div class="tool-header">
<span class="tool-name">FirmExtract</span>
<span class="tool-badge">JAAT.FirmExtract</span>
<span class="tool-status-icon" id="icon-FirmExtract">Queued</span>
</div>
<div class="tool-desc">Extracts the hiring company or organization name.</div>
<div class="tool-output" id="out-FirmExtract"></div>
</div>
<div class="tool-card pending" id="card-WageExtract">
<div class="tool-header">
<span class="tool-name">WageExtract</span>
<span class="tool-badge">JAAT.WageExtract</span>
<span class="tool-status-icon" id="icon-WageExtract">Queued</span>
</div>
<div class="tool-desc">Extracts wage/salary information from the ad.</div>
<div class="tool-output" id="out-WageExtract"></div>
</div>
<div class="tool-card pending" id="card-TitleMatch">
<div class="tool-header">
<span class="tool-name">TitleMatch</span>
<span class="tool-badge">JAAT.TitleMatch</span>
<span class="tool-status-icon" id="icon-TitleMatch">Queued</span>
</div>
<div class="tool-desc">Matches the job title to the closest O*NET occupation.</div>
<div class="tool-output" id="out-TitleMatch"></div>
</div>
<div class="tool-card pending" id="card-TaskMatch">
<div class="tool-header">
<span class="tool-name">TaskMatch</span>
<span class="tool-badge">JAAT.TaskMatch</span>
<span class="tool-status-icon" id="icon-TaskMatch">Queued</span>
</div>
<div class="tool-desc">Finds O*NET tasks that match language in this job ad.</div>
<div class="tool-output" id="out-TaskMatch"></div>
</div>
<div class="tool-card pending" id="card-SkillMatch">
<div class="tool-header">
<span class="tool-name">SkillMatch</span>
<span class="tool-badge">JAAT.SkillMatch</span>
<span class="tool-status-icon" id="icon-SkillMatch">Queued</span>
</div>
<div class="tool-desc">Identifies skills mapped to European Skills/Competences (ESCO) codes.</div>
<div class="tool-output" id="out-SkillMatch"></div>
</div>
<div class="tool-card pending" id="card-JobTag">
<div class="tool-header">
<span class="tool-name">JobTag</span>
<span class="tool-badge">JAAT.JobTag</span>
<span class="tool-status-icon" id="icon-JobTag">Queued</span>
</div>
<div class="tool-desc">Classifies the ad for various job attributes (10 classifiers).</div>
<div class="tool-output" id="out-JobTag"></div>
</div>
</div><!-- /results -->
<!-- Citation -->
<div class="card citation">
<p class="citation-title">Software & Data Citation</p>
<p>If you use JAAT in your research, please cite:</p>
<pre>@article{meisenbacher2025extracting,
title={Extracting O*NET Features from the NLx Corpus to Build
Public Use Aggregate Labor Market Data},
author={Meisenbacher, Stephen and Nestorov, Svetlozar
and Norlander, Peter},
journal={arXiv preprint arXiv:2510.01470},
year={2025}
}</pre>
</div>
</main>
<script>
const params = new URLSearchParams(window.location.search);
const sign = params.get("__sign");
const API_URL = "/analyze" + (sign ? "?__sign=" + encodeURIComponent(sign) : "");
const TOOLS = ["FirmExtract", "WageExtract", "TitleMatch", "TaskMatch", "SkillMatch", "JobTag"];
const TAG_LABELS = {
CitizenshipReq: "Citizenship Required",
GovContract: "Government Contract",
VisaExclude: "Visa Excluded",
VisaInclude: "Visa Sponsorship",
WorkAuthReq: "Work Auth Required",
driverslicense: "Driver's License",
ind_contractor: "Independent Contractor",
proflicenses: "Professional License",
wfh: "Work From Home",
yesunion: "Union Position",
};
function setToolState(tool, state) {
const card = document.getElementById("card-" + tool);
const icon = document.getElementById("icon-" + tool);
card.className = "tool-card " + state;
if (state === "running") {
icon.className = "tool-status-icon running";
icon.innerHTML = '<span class="spinner"></span> Running';
} else if (state === "done") {
icon.className = "tool-status-icon done";
icon.textContent = "Done";
} else if (state === "error") {
icon.className = "tool-status-icon error";
icon.textContent = "Error";
} else {
icon.className = "tool-status-icon";
icon.textContent = "Queued";
}
}
function renderToolOutput(tool, data) {
const out = document.getElementById("out-" + tool);
if (tool === "FirmExtract") {
out.innerHTML = `<div class="summary-strip">
<div class="kv"><div class="label">Firm / Company</div><div class="value">${data || "Not detected"}</div></div>
</div>`;
}
else if (tool === "WageExtract") {
const min = data?.min != null ? "$" + Number(data.min).toLocaleString() : "Not found";
const max = data?.max != null ? "$" + Number(data.max).toLocaleString() : "Not found";
const freq = data?.frequency || "Not found";
out.innerHTML = `<div class="summary-strip">
<div class="kv"><div class="label">Min Wage</div><div class="value">${min}</div></div>
<div class="kv"><div class="label">Max Wage</div><div class="value">${max}</div></div>
<div class="kv"><div class="label">Pay Frequency</div><div class="value">${freq}</div></div>
</div>`;
}
else if (tool === "TitleMatch") {
if (data?.skipped) {
out.innerHTML = `<p class="empty">${data.skipped}</p>`;
} else if (data) {
const score = data.score != null ? (data.score * 100).toFixed(1) + "%" : "--";
out.innerHTML = `<div class="summary-strip">
<div class="kv"><div class="label">Matched O*NET Title</div><div class="value">${data.matched_title || "--"}</div></div>
<div class="kv"><div class="label">O*NET Code</div><div class="value">${data.onet_code || "--"}</div></div>
<div class="kv"><div class="label">Match Score</div><div class="value">${score}</div></div>
</div>`;
} else {
out.innerHTML = '<p class="empty">No match found.</p>';
}
}
else if (tool === "TaskMatch") {
if (Array.isArray(data) && data.length) {
out.innerHTML = '<div class="result-list">' +
data.map(t => `<div class="result-item"><span class="badge">ID ${t.id}</span><span>${t.description}</span></div>`).join("") +
'</div>';
} else {
out.innerHTML = '<p class="empty">No O*NET tasks matched in this ad.</p>';
}
}
else if (tool === "SkillMatch") {
if (Array.isArray(data) && data.length) {
out.innerHTML = '<div class="result-list">' +
data.map(s => `<div class="result-item"><span class="badge">${s.europa_code}</span><span>${s.label}</span></div>`).join("") +
'</div>';
} else {
out.innerHTML = '<p class="empty">No skills matched in this ad.</p>';
}
}
else if (tool === "JobTag") {
if (data && typeof data === "object") {
out.innerHTML = '<div class="tags-grid">' +
Object.entries(data).map(([cls, val]) => {
const label = TAG_LABELS[cls] || cls;
return `<span class="tag ${val ? "yes" : "no"}">${label} ${val ? "yes" : "no"}</span>`;
}).join("") +
'</div>';
} else {
out.innerHTML = '<p class="empty">No tag data available.</p>';
}
}
}
async function analyze() {
const text = document.getElementById("jobText").value.trim();
const title = document.getElementById("jobTitle").value.trim();
if (!text) { setStatus("Please paste a job ad first.", true); return; }
const btn = document.getElementById("analyzeBtn");
btn.disabled = true;
// Reset all tool cards
TOOLS.forEach(t => {
setToolState(t, "pending");
document.getElementById("out-" + t).innerHTML = "";
});
document.getElementById("results").style.display = "flex";
setStatus('<span class="spinner"></span> Running pipeline...');
try {
const res = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, title }),
});
if (!res.ok) throw new Error(`Server returned an error (${res.status}).`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.tool === "_done") {
setStatus("Analysis complete.");
continue;
}
if (msg.status === "running") {
setToolState(msg.tool, "running");
} else if (msg.status === "done") {
setToolState(msg.tool, "done");
renderToolOutput(msg.tool, msg.data);
} else if (msg.status === "error") {
setToolState(msg.tool, "error");
document.getElementById("out-" + msg.tool).innerHTML =
`<p class="error-msg">Error: ${msg.error}</p>`;
}
} catch (e) {
console.warn("Could not parse line:", line);
}
}
}
} catch (err) {
setStatus("Error: " + err.message, true);
} finally {
btn.disabled = false;
}
}
function setStatus(html, isError = false) {
const el = document.getElementById("status");
el.innerHTML = html;
el.className = isError ? "error" : "";
}
function clearAll() {
document.getElementById("jobTitle").value = "";
document.getElementById("jobText").value = "";
document.getElementById("results").style.display = "none";
TOOLS.forEach(t => {
setToolState(t, "pending");
document.getElementById("out-" + t).innerHTML = "";
});
setStatus("");
}
</script>
</body>
</html>