Spaces:
Running
Running
| <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 & 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> | |