From 42c6c85b7c446c219489e212700901af8a1aee00 Mon Sep 17 00:00:00 2001 From: Taleef Date: Sun, 30 Aug 2026 12:24:04 -0400 Subject: [PATCH 1/3] feat(api): fetch full JDs at discovery through the SSRF-hardened fetcher + extend local prerank (#263) --- apps/api/src/lib/analysis-core.test.ts | 8 +- apps/api/src/lib/analysis-core.ts | 57 ++++++++++++- apps/api/src/lib/discovery.test.ts | 61 ++++++++++++++ apps/api/src/lib/discovery.ts | 11 +++ apps/api/src/lib/jd-upgrade.test.ts | 112 +++++++++++++++++++++++++ apps/api/src/lib/jd-upgrade.ts | 27 ++++++ apps/api/src/lib/local-fit.test.ts | 19 +++++ apps/api/src/routes/discovery.ts | 2 + apps/api/src/routes/n8n.test.ts | 2 +- docs/IMPLEMENTATION_STATUS.md | 2 +- 10 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/lib/jd-upgrade.test.ts create mode 100644 apps/api/src/lib/jd-upgrade.ts diff --git a/apps/api/src/lib/analysis-core.test.ts b/apps/api/src/lib/analysis-core.test.ts index 953e2f1..c98d45a 100644 --- a/apps/api/src/lib/analysis-core.test.ts +++ b/apps/api/src/lib/analysis-core.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { groundingFromParsed, type ParsedJobOutput } from './analysis-core'; +import { groundingFromParsed, keywordCatalog, type ParsedJobOutput } from './analysis-core'; const fallback = { requiredSkills: ['Python'], @@ -39,3 +39,9 @@ test('groundingFromParsed falls back to the stored analysis when the parse is in assert.deepEqual(grounding.preferredSkills, ['SQL']); assert.deepEqual(grounding.atsKeywords, ['Python', 'SQL']); }); + +test('keywordCatalog has no duplicates and contains at least 50 entries', () => { + assert.ok(keywordCatalog.length >= 50, `Expected keywordCatalog to have >= 50 items, but got ${keywordCatalog.length}`); + const uniqueKeywords = new Set(keywordCatalog.map((k) => k.toLowerCase())); + assert.equal(uniqueKeywords.size, keywordCatalog.length, 'keywordCatalog contains duplicate keywords (case-insensitive)'); +}); diff --git a/apps/api/src/lib/analysis-core.ts b/apps/api/src/lib/analysis-core.ts index 481edbd..3401ca9 100644 --- a/apps/api/src/lib/analysis-core.ts +++ b/apps/api/src/lib/analysis-core.ts @@ -26,7 +26,7 @@ export interface FitScoreOutput { model_used: string; } -const keywordCatalog = [ +export const keywordCatalog = [ 'TypeScript', 'JavaScript', 'React', @@ -47,6 +47,61 @@ const keywordCatalog = [ 'Workflow automation', 'CRM', 'Analytics', + 'Java', + 'C#', + '.NET', + 'Go', + 'Rust', + 'C++', + 'Ruby', + 'PHP', + 'Kotlin', + 'Swift', + 'Kubernetes', + 'Docker', + 'Terraform', + 'AWS', + 'GCP', + 'Google Cloud', + 'Azure', + 'Linux', + 'Git', + 'CI/CD', + 'GraphQL', + 'REST API', + 'gRPC', + 'Redis', + 'Kafka', + 'RabbitMQ', + 'MongoDB', + 'MySQL', + 'Elasticsearch', + 'Snowflake', + 'dbt', + 'Airflow', + 'Spark', + 'ETL', + 'Machine Learning', + 'Deep Learning', + 'PyTorch', + 'TensorFlow', + 'LangChain', + 'RAG', + 'Prompt Engineering', + 'FastAPI', + 'Django', + 'Flask', + 'Spring', + 'Vue', + 'Angular', + 'Tailwind', + 'Playwright', + 'Jest', + 'Grafana', + 'Prometheus', + 'Datadog', + 'Agile', + 'Scrum', ] as const; function clamp(value: number, min: number, max: number) { diff --git a/apps/api/src/lib/discovery.test.ts b/apps/api/src/lib/discovery.test.ts index 2b1318d..b06d36a 100644 --- a/apps/api/src/lib/discovery.test.ts +++ b/apps/api/src/lib/discovery.test.ts @@ -370,3 +370,64 @@ test('passes sponsorLikelihood to createJob when lookupSponsor returns a known s denials: 1, }); }); + +test('with DISCOVERY_JD_FETCH_CAP unset, calls upgradeJd stub exactly 25 times for 30 eligible snippet jobs', async () => { + const originalEnv = process.env.DISCOVERY_JD_FETCH_CAP; + delete process.env.DISCOVERY_JD_FETCH_CAP; + + try { + const snippetJobs = Array.from({ length: 30 }, (_, i) => + sourced(`https://example.com/job/${i}`, { + company: `Company ${i}`, + title: `Engineer ${i}`, + descriptionText: 'Short snippet description', + }), + ); + + let upgradeCalls = 0; + const { deps } = makeDeps(snippetJobs, []); + deps.upgradeJd = async (job) => { + upgradeCalls += 1; + return { job, upgraded: false }; + }; + + await runDiscoveryForUser('u', deps); + + assert.equal(upgradeCalls, 25); + } finally { + if (originalEnv !== undefined) { + process.env.DISCOVERY_JD_FETCH_CAP = originalEnv; + } else { + delete process.env.DISCOVERY_JD_FETCH_CAP; + } + } +}); + +test('prerank runs on upgraded text so saveAnalysis receives non-null fitScore', async () => { + // Original snippet has no catalog keywords -> prerank would produce null score. + const snippetJob = sourced('https://example.com/job/prerank', { + company: 'Upgraded Tech', + title: 'Software Engineer', + descriptionText: 'Just a brief snippet.', + }); + + // Resume has TypeScript, React, and Node.js + const resume = 'Senior engineer with TypeScript, React, and Node.js experience.'; + const { deps, analyses } = makeDeps([snippetJob], [], resume); + + deps.upgradeJd = async (job) => { + return { + job: { + ...job, + descriptionText: 'Full JD: We use TypeScript, React, and Node.js daily to build products.', + }, + upgraded: true, + }; + }; + + await runDiscoveryForUser('u', deps); + + assert.equal(analyses.length, 1); + assert.notEqual(analyses[0]?.fitScore, null); + assert.equal(analyses[0]?.fitScore, 100); +}); diff --git a/apps/api/src/lib/discovery.ts b/apps/api/src/lib/discovery.ts index f8c06d2..8cba516 100644 --- a/apps/api/src/lib/discovery.ts +++ b/apps/api/src/lib/discovery.ts @@ -8,6 +8,7 @@ import { prerankAnalysis } from '@/lib/local-fit'; import type { JobSource } from '@/lib/job-sources'; import { dedupKey, fingerprintKey, type SourcedJob } from '@/lib/job-sources/normalize'; import { fetchTargetCompanyBoards } from '@/lib/job-sources/boards'; +import { FULL_JD_MIN_CHARS, type upgradeToFullJd } from '@/lib/jd-upgrade'; import type { SponsorLikelihood, TargetCompany } from '@/types'; export interface DiscoveryResult { @@ -27,6 +28,7 @@ export interface DiscoveryDeps { listTargetCompanies?: (userId: string) => Promise; fetchBoards?: typeof fetchTargetCompanyBoards; lookupSponsor?: (company: string) => Promise; + upgradeJd?: typeof upgradeToFullJd; } /** @@ -60,6 +62,7 @@ function isDuplicateKeyError(error: unknown): boolean { * the run). A single failing search is skipped rather than aborting the run. */ export async function runDiscoveryForUser(userId: string, deps: DiscoveryDeps): Promise { + const JD_FETCH_CAP = Number(process.env.DISCOVERY_JD_FETCH_CAP ?? 25); const searches = await deps.listSavedSearches(userId); const seen = new Set((await deps.listJobs(userId)).flatMap(keysFor)); const resume = await deps.getResume(userId); @@ -72,6 +75,7 @@ export async function runDiscoveryForUser(userId: string, deps: DiscoveryDeps): let inserted = 0; let skipped = 0; + let jdFetchAttempts = 0; const contributingSources = new Set(); async function insertIfNew(job: SourcedJob): Promise { @@ -84,6 +88,13 @@ export async function runDiscoveryForUser(userId: string, deps: DiscoveryDeps): // copy in the same run is recognised as a duplicate. for (const k of keysFor(job)) seen.add(k); try { + const currentDesc = job.descriptionText ?? ''; + if (deps.upgradeJd && job.jobUrl && currentDesc.length < FULL_JD_MIN_CHARS && jdFetchAttempts < JD_FETCH_CAP) { + jdFetchAttempts += 1; + const { job: upgraded } = await deps.upgradeJd(job); + job = upgraded; + } + const sponsor = deps.lookupSponsor ? await deps.lookupSponsor(job.company) : null; const createdJob = await deps.createJob(userId, { ...job, diff --git a/apps/api/src/lib/jd-upgrade.test.ts b/apps/api/src/lib/jd-upgrade.test.ts new file mode 100644 index 0000000..949510b --- /dev/null +++ b/apps/api/src/lib/jd-upgrade.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SourcedJob } from '@/lib/job-sources'; +import { upgradeToFullJd, FULL_JD_MIN_CHARS } from './jd-upgrade'; + +function baseJob(overrides: Partial = {}): SourcedJob { + return { + source: 'adzuna', + company: 'Acme Corp', + title: 'Senior Software Engineer', + location: 'Remote', + descriptionText: 'Short description snippet.', + jobUrl: 'https://jobs.example.com/posting/123', + ...overrides, + }; +} + +test('upgrades short description when page HTML yields full 2000-char description', async () => { + const fullDescription = 'A'.repeat(2000); + const html = ` + + + + +
Job details
+ + `; + + const original = baseJob({ descriptionText: 'Short snippet' }); + const result = await upgradeToFullJd(original, { + assertSafe: async (raw) => ({ ok: true, url: new URL(raw) }), + fetchImpl: async () => new Response(html, { status: 200, headers: { 'content-type': 'text/html' } }), + }); + + assert.equal(result.upgraded, true); + assert.equal(result.job.descriptionText, fullDescription); + assert.equal(result.job.title, 'Senior Software Engineer'); + assert.equal(result.job.company, 'Acme Corp'); + assert.equal(result.job.jobUrl, 'https://jobs.example.com/posting/123'); +}); + +test('does not fetch when description is already >= 600 chars', async () => { + let fetchCalled = false; + const longDesc = 'B'.repeat(FULL_JD_MIN_CHARS); + const original = baseJob({ descriptionText: longDesc }); + + const result = await upgradeToFullJd(original, { + assertSafe: async (raw) => ({ ok: true, url: new URL(raw) }), + fetchImpl: async () => { + fetchCalled = true; + return new Response('unused'); + }, + }); + + assert.equal(fetchCalled, false); + assert.equal(result.upgraded, false); + assert.equal(result.job.descriptionText, longDesc); +}); + +test('does not fetch when jobUrl is missing', async () => { + let fetchCalled = false; + const original = baseJob({ jobUrl: undefined, descriptionText: 'Short snippet' }); + + const result = await upgradeToFullJd(original, { + assertSafe: async (raw) => ({ ok: true, url: new URL(raw) }), + fetchImpl: async () => { + fetchCalled = true; + return new Response('unused'); + }, + }); + + assert.equal(fetchCalled, false); + assert.equal(result.upgraded, false); + assert.equal(result.job.descriptionText, 'Short snippet'); +}); + +test('returns original job and upgraded: false when fetchJobPage returns blocked (never throws)', async () => { + const original = baseJob({ descriptionText: 'Short snippet' }); + + const result = await upgradeToFullJd(original, { + assertSafe: async () => ({ ok: false, reason: 'blocked host' }), + fetchImpl: async () => new Response('blocked'), + }); + + assert.equal(result.upgraded, false); + assert.equal(result.job.descriptionText, original.descriptionText); + assert.equal(result.job, original); +}); + +test('does not replace description when extracted text is shorter than 1.5x original', async () => { + // Original is 500 chars (below 600 chars threshold). + // Extracted text is 650 chars. 650 <= 500 * 1.5 (= 750), so not replaced. + const originalDesc = 'C'.repeat(500); + const extractedDesc = 'D'.repeat(650); + const html = `

${extractedDesc}

`; + + const original = baseJob({ descriptionText: originalDesc }); + const result = await upgradeToFullJd(original, { + assertSafe: async (raw) => ({ ok: true, url: new URL(raw) }), + fetchImpl: async () => new Response(html, { status: 200, headers: { 'content-type': 'text/html' } }), + }); + + assert.equal(result.upgraded, false); + assert.equal(result.job.descriptionText, originalDesc); +}); diff --git a/apps/api/src/lib/jd-upgrade.ts b/apps/api/src/lib/jd-upgrade.ts new file mode 100644 index 0000000..e0d9d9d --- /dev/null +++ b/apps/api/src/lib/jd-upgrade.ts @@ -0,0 +1,27 @@ +import { fetchJobPage, type FetchDeps } from '@/lib/job-url-fetch'; +import { extractJobFromHtml } from '@/lib/job-url-extract'; +import type { SourcedJob } from '@/lib/job-sources'; + +export const FULL_JD_MIN_CHARS = 600; + +export interface JdUpgradeResult { + job: SourcedJob; + upgraded: boolean; +} + +export async function upgradeToFullJd(job: SourcedJob, deps: FetchDeps = {}): Promise { + try { + const current = job.descriptionText ?? ''; + if (!job.jobUrl || current.length >= FULL_JD_MIN_CHARS) return { job, upgraded: false }; + const page = await fetchJobPage(job.jobUrl, deps); + if (!page.html) return { job, upgraded: false }; + const extracted = extractJobFromHtml(page.html); + const fullText = extracted.descriptionText?.trim() ?? ''; + if (fullText.length <= current.length * 1.5 || fullText.length < FULL_JD_MIN_CHARS) { + return { job, upgraded: false }; + } + return { job: { ...job, descriptionText: fullText }, upgraded: true }; + } catch { + return { job, upgraded: false }; + } +} diff --git a/apps/api/src/lib/local-fit.test.ts b/apps/api/src/lib/local-fit.test.ts index 40cbcd3..b241bd9 100644 --- a/apps/api/src/lib/local-fit.test.ts +++ b/apps/api/src/lib/local-fit.test.ts @@ -85,3 +85,22 @@ test('prerankAnalysis leaves the score unset when evidence is thin', () => { assert.equal(analysis.modelUsed, PRERANK_MODEL); assert.deepEqual(analysis.matchedSkills, ['TypeScript']); }); + +test('realistic 1500-char JD mentioning Python, PostgreSQL, Docker, Kubernetes, Terraform clears evidence floor', () => { + const intro = 'We are seeking an experienced Platform Engineer to scale our infrastructure. '; + const requirements = 'Key technologies required: Python, PostgreSQL, Docker, Kubernetes, Terraform. '; + const details = 'You will be responsible for building reliable deployment pipelines, designing robust backend services, monitoring cluster health, collaborating with product engineers, conducting architecture reviews, optimizing database queries, and participating in on-call rotations. We value clean code, strong testing practices, and clear documentation across all engineering workflows. '.repeat(4); + const fullJd = (intro + requirements + details).slice(0, 1500); + + assert.equal(fullJd.length, 1500); + + // Resume contains three of them: Docker, Kubernetes, Terraform + const resume = 'Experienced Platform Engineer skilled in Docker containerization, Kubernetes orchestration, and Terraform infrastructure.'; + + const { score, matchedSkills } = computeLocalFit(fullJd, resume); + + assert.notEqual(score, null); + assert.equal(typeof score, 'number'); + assert.equal(score, 50); // 3 of 6 (Python, PostgreSQL, SQL, Docker, Kubernetes, Terraform) = 50% + assert.deepEqual(matchedSkills.sort(), ['Docker', 'Kubernetes', 'Terraform']); +}); diff --git a/apps/api/src/routes/discovery.ts b/apps/api/src/routes/discovery.ts index f765a53..1879cf1 100644 --- a/apps/api/src/routes/discovery.ts +++ b/apps/api/src/routes/discovery.ts @@ -8,6 +8,7 @@ import { getJobSources } from '@/lib/job-sources'; import { fetchTargetCompanyBoards } from '@/lib/job-sources/boards'; import { requireN8nWebhookSecret } from '@/lib/n8n'; import { runDiscoveryForUser, type DiscoveryResult } from '@/lib/discovery'; +import { upgradeToFullJd } from '@/lib/jd-upgrade'; import { lookupSponsorLikelihood } from '@/lib/sponsorship'; export interface DiscoveryRouterDeps { @@ -28,6 +29,7 @@ const defaultDeps: DiscoveryRouterDeps = { listTargetCompanies, fetchBoards: fetchTargetCompanyBoards, lookupSponsor: lookupSponsorLikelihood, + upgradeJd: upgradeToFullJd, }), listUsersWithSavedSearches, listSweepUsers: async () => { diff --git a/apps/api/src/routes/n8n.test.ts b/apps/api/src/routes/n8n.test.ts index fc47238..6a7ef72 100644 --- a/apps/api/src/routes/n8n.test.ts +++ b/apps/api/src/routes/n8n.test.ts @@ -288,7 +288,7 @@ test('creates and enriches a job-intake webhook payload', async () => { descriptionText: 'Build internal automations using TypeScript, Azure Functions, and n8n.', }); assert.ok(savedAnalysis); - assert.equal(savedFitScore, 82); + assert.equal(savedFitScore, 91); assert.deepEqual(updatedJobBody, { nextAction: 'Review the AI analysis and decide whether to shortlist.', }); diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4607d1f..3de22a9 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -150,7 +150,7 @@ agent image, list pagination, and an opt-in Postgres-backed rate-limiter/cache f ## What Is Still Pending -- **Jobright Parity Program (Epic #244 — Agent platform foundation):** Epic 1 is complete (#251–#257). Epic 2 (Discovery & Feed Curation) is underway: shipped enriched jobs schema and normalization (#258) with salary range parsing, seniority inference, content hashing, and liveness tracking (migration 013), target_companies watchlist table, CRUD, and settings UI (#260, migration 014), Greenhouse/Lever/Ashby board adapters wired into discovery (#261), USAJobs + The Muse source adapters with Remotive fallback window fix and multi-source discovery (#259), and USCIS H-1B Employer Data Hub import with `h1b_sponsors` reference table and `sponsor_likelihood` matching (#262, migration 015). Specialist agent graph implementations (remaining Epics 2, 4, 5, 6) remain pending. +- **Jobright Parity Program (Epic #244 — Agent platform foundation):** Epic 1 is complete (#251–#257). Epic 2 (Discovery & Feed Curation) is underway: shipped enriched jobs schema and normalization (#258) with salary range parsing, seniority inference, content hashing, and liveness tracking (migration 013), target_companies watchlist table, CRUD, and settings UI (#260, migration 014), Greenhouse/Lever/Ashby board adapters wired into discovery (#261), USAJobs + The Muse source adapters with Remotive fallback window fix and multi-source discovery (#259), USCIS H-1B Employer Data Hub import with `h1b_sponsors` reference table and `sponsor_likelihood` matching (#262, migration 015), and full-JD upgrade at discovery through SSRF-hardened fetcher with widened keyword catalog and local prerank (#263). Specialist agent graph implementations (remaining Epics 2, 4, 5, 6) remain pending. - Nothing blocking. All planned phases (0–11) plus the optional Phase 6 hardening (App Insights, Key Vault) are complete. The agent Container App keeps its native secret store by design (Key Vault covers App Service only). From fce2d171faecdb10ef535a440e72f0d64f909160 Mon Sep 17 00:00:00 2001 From: Taleef Date: Sun, 30 Aug 2026 12:29:23 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(api):=20address=20Codex=20review=20on?= =?UTF-8?q?=20#263=20=E2=80=94=20bound=20JD=20upgrade=20latency=20via=20ti?= =?UTF-8?q?me=20budget=20and=20validate=20extracted=20text=20relevance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/lib/discovery.test.ts | 31 +++++++++++ apps/api/src/lib/discovery.ts | 15 ++++- apps/api/src/lib/jd-upgrade.test.ts | 57 +++++++++++++++++++ apps/api/src/lib/jd-upgrade.ts | 86 ++++++++++++++++++++++++++++- 4 files changed, 184 insertions(+), 5 deletions(-) diff --git a/apps/api/src/lib/discovery.test.ts b/apps/api/src/lib/discovery.test.ts index b06d36a..dbe1d36 100644 --- a/apps/api/src/lib/discovery.test.ts +++ b/apps/api/src/lib/discovery.test.ts @@ -431,3 +431,34 @@ test('prerank runs on upgraded text so saveAnalysis receives non-null fitScore', assert.notEqual(analyses[0]?.fitScore, null); assert.equal(analyses[0]?.fitScore, 100); }); + +test('runDiscoveryForUser stops attempting JD upgrades when upgrade time budget is exhausted', async () => { + const originalBudget = process.env.DISCOVERY_JD_UPGRADE_BUDGET_MS; + process.env.DISCOVERY_JD_UPGRADE_BUDGET_MS = '0'; + + try { + const jobs: SourcedJob[] = Array.from({ length: 5 }, (_, i) => ({ + source: 'adzuna', + company: `Company ${i}`, + title: `Software Engineer ${i}`, + descriptionText: 'Snippet', + jobUrl: `https://example.com/job/${i}`, + })); + + let upgradeCalls = 0; + const { deps } = makeDeps(jobs, []); + deps.upgradeJd = async (job) => { + upgradeCalls += 1; + return { job, upgraded: false }; + }; + + await runDiscoveryForUser('u', deps); + assert.equal(upgradeCalls, 0); + } finally { + if (originalBudget !== undefined) { + process.env.DISCOVERY_JD_UPGRADE_BUDGET_MS = originalBudget; + } else { + delete process.env.DISCOVERY_JD_UPGRADE_BUDGET_MS; + } + } +}); diff --git a/apps/api/src/lib/discovery.ts b/apps/api/src/lib/discovery.ts index 8cba516..6b7dab9 100644 --- a/apps/api/src/lib/discovery.ts +++ b/apps/api/src/lib/discovery.ts @@ -63,6 +63,8 @@ function isDuplicateKeyError(error: unknown): boolean { */ export async function runDiscoveryForUser(userId: string, deps: DiscoveryDeps): Promise { const JD_FETCH_CAP = Number(process.env.DISCOVERY_JD_FETCH_CAP ?? 25); + const JD_UPGRADE_TIME_BUDGET_MS = Number(process.env.DISCOVERY_JD_UPGRADE_BUDGET_MS ?? 15_000); + const jdUpgradeDeadline = Date.now() + JD_UPGRADE_TIME_BUDGET_MS; const searches = await deps.listSavedSearches(userId); const seen = new Set((await deps.listJobs(userId)).flatMap(keysFor)); const resume = await deps.getResume(userId); @@ -89,9 +91,18 @@ export async function runDiscoveryForUser(userId: string, deps: DiscoveryDeps): for (const k of keysFor(job)) seen.add(k); try { const currentDesc = job.descriptionText ?? ''; - if (deps.upgradeJd && job.jobUrl && currentDesc.length < FULL_JD_MIN_CHARS && jdFetchAttempts < JD_FETCH_CAP) { + const remainingBudgetMs = jdUpgradeDeadline - Date.now(); + if ( + deps.upgradeJd && + job.jobUrl && + currentDesc.length < FULL_JD_MIN_CHARS && + jdFetchAttempts < JD_FETCH_CAP && + remainingBudgetMs > 500 + ) { jdFetchAttempts += 1; - const { job: upgraded } = await deps.upgradeJd(job); + const { job: upgraded } = await deps.upgradeJd(job, {}, { + timeoutMs: Math.min(8_000, remainingBudgetMs), + }); job = upgraded; } diff --git a/apps/api/src/lib/jd-upgrade.test.ts b/apps/api/src/lib/jd-upgrade.test.ts index 949510b..45b2c53 100644 --- a/apps/api/src/lib/jd-upgrade.test.ts +++ b/apps/api/src/lib/jd-upgrade.test.ts @@ -110,3 +110,60 @@ test('does not replace description when extracted text is shorter than 1.5x orig assert.equal(result.upgraded, false); assert.equal(result.job.descriptionText, originalDesc); }); + +test('rejects bot challenge or captcha pages and preserves original snippet', async () => { + const challengeBody = 'Please verify you are human to continue accessing our site. ' + 'Cloudflare protection '.repeat(50); + const html = `
${challengeBody}
`; + + const original = baseJob({ descriptionText: 'Short snippet' }); + const result = await upgradeToFullJd(original, { + assertSafe: async (raw) => ({ ok: true, url: new URL(raw) }), + fetchImpl: async () => new Response(html, { status: 200, headers: { 'content-type': 'text/html' } }), + }); + + assert.equal(result.upgraded, false); + assert.equal(result.job.descriptionText, 'Short snippet'); +}); + +test('rejects generic non-job heuristic pages without job content evidence', async () => { + const genericBody = 'Welcome to our generic marketing homepage. Learn more about our products. '.repeat(20); + const html = `
${genericBody}
`; + + const original = baseJob({ descriptionText: 'Short snippet' }); + const result = await upgradeToFullJd(original, { + assertSafe: async (raw) => ({ ok: true, url: new URL(raw) }), + fetchImpl: async () => new Response(html, { status: 200, headers: { 'content-type': 'text/html' } }), + }); + + assert.equal(result.upgraded, false); + assert.equal(result.job.descriptionText, 'Short snippet'); +}); + +test('accepts heuristic pages containing job structure keywords', async () => { + const jobBody = 'Key responsibilities include building scalable services. Qualifications required: 5 years experience. '.repeat(10); + const html = `

${jobBody}

`; + + const original = baseJob({ descriptionText: 'Short snippet' }); + const result = await upgradeToFullJd(original, { + assertSafe: async (raw) => ({ ok: true, url: new URL(raw) }), + fetchImpl: async () => new Response(html, { status: 200, headers: { 'content-type': 'text/html' } }), + }); + + assert.equal(result.upgraded, true); + assert.ok(result.job.descriptionText.length >= 600); +}); + +test('respects timeoutMs and fails closed without throwing', async () => { + const original = baseJob({ descriptionText: 'Short snippet' }); + const result = await upgradeToFullJd( + original, + { + assertSafe: async (raw) => ({ ok: true, url: new URL(raw) }), + fetchImpl: async () => new Promise((resolve) => setTimeout(() => resolve(new Response('html')), 500)), + }, + { timeoutMs: 10 }, + ); + + assert.equal(result.upgraded, false); + assert.equal(result.job.descriptionText, 'Short snippet'); +}); diff --git a/apps/api/src/lib/jd-upgrade.ts b/apps/api/src/lib/jd-upgrade.ts index e0d9d9d..8e2c81f 100644 --- a/apps/api/src/lib/jd-upgrade.ts +++ b/apps/api/src/lib/jd-upgrade.ts @@ -1,25 +1,105 @@ import { fetchJobPage, type FetchDeps } from '@/lib/job-url-fetch'; -import { extractJobFromHtml } from '@/lib/job-url-extract'; +import { extractJobFromHtml, type ExtractedJob } from '@/lib/job-url-extract'; import type { SourcedJob } from '@/lib/job-sources'; export const FULL_JD_MIN_CHARS = 600; +export interface JdUpgradeOptions { + timeoutMs?: number; +} + export interface JdUpgradeResult { job: SourcedJob; upgraded: boolean; } -export async function upgradeToFullJd(job: SourcedJob, deps: FetchDeps = {}): Promise { +const BOT_OR_ERROR_INDICATORS = [ + 'verify you are human', + 'captcha', + 'cloudflare', + 'please enable javascript', + 'access denied', + 'sign in to continue', + 'log in to your account', + 'job has expired', + 'job is no longer available', + 'position has been filled', + 'page not found', + '404 not found', +]; + +const JOB_CONTENT_KEYWORDS = [ + 'responsibilities', + 'qualifications', + 'requirements', + 'experience', + 'skills', + 'about the role', + 'what you will do', + "what you'll do", + 'who you are', + 'benefits', + 'equal opportunity', + 'compensation', +]; + +export function isLikelyJobDescription(fullText: string, job: SourcedJob, source: ExtractedJob['source']): boolean { + const lower = fullText.toLowerCase(); + + for (const indicator of BOT_OR_ERROR_INDICATORS) { + if (lower.includes(indicator)) return false; + } + + // JSON-LD JobPosting is explicitly structured job posting data published by ATS boards + if (source === 'jsonld') { + return true; + } + + // For heuristic / meta extraction, require job-specific content evidence + const keywordMatches = JOB_CONTENT_KEYWORDS.filter((k) => lower.includes(k)).length; + if (keywordMatches >= 2) return true; + + // Or check title and company token overlap (significant words >= 4 characters) + const titleTokens = (job.title ?? '').toLowerCase().split(/\W+/).filter((w) => w.length >= 4); + const companyTokens = (job.company ?? '').toLowerCase().split(/\W+/).filter((w) => w.length >= 4); + const matchedTokens = [...titleTokens, ...companyTokens].filter((token) => lower.includes(token)); + if (matchedTokens.length >= 2) return true; + + return false; +} + +export async function upgradeToFullJd( + job: SourcedJob, + deps: FetchDeps = {}, + options: JdUpgradeOptions = {}, +): Promise { try { const current = job.descriptionText ?? ''; if (!job.jobUrl || current.length >= FULL_JD_MIN_CHARS) return { job, upgraded: false }; - const page = await fetchJobPage(job.jobUrl, deps); + + let pagePromise = fetchJobPage(job.jobUrl, deps); + if (options.timeoutMs && options.timeoutMs > 0) { + let timer: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise<{ html?: undefined; blocked: string }>((resolve) => { + timer = setTimeout(() => resolve({ blocked: 'timeout' }), options.timeoutMs); + }); + pagePromise = Promise.race([pagePromise, timeoutPromise]).finally(() => { + if (timer) clearTimeout(timer); + }); + } + + const page = await pagePromise; if (!page.html) return { job, upgraded: false }; const extracted = extractJobFromHtml(page.html); const fullText = extracted.descriptionText?.trim() ?? ''; if (fullText.length <= current.length * 1.5 || fullText.length < FULL_JD_MIN_CHARS) { return { job, upgraded: false }; } + + if (!isLikelyJobDescription(fullText, job, extracted.source)) { + return { job, upgraded: false }; + } + return { job: { ...job, descriptionText: fullText }, upgraded: true }; } catch { return { job, upgraded: false }; From 0f9f024a393cd413829236442c311c8896b4906f Mon Sep 17 00:00:00 2001 From: Taleef Date: Sun, 30 Aug 2026 12:32:30 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(api):=20address=20Codex=20review=20on?= =?UTF-8?q?=20#263=20=E2=80=94=20match=20skill=20keywords=20on=20token=20b?= =?UTF-8?q?oundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/lib/analysis-core.test.ts | 39 +++++++++++++++++++++++++- apps/api/src/lib/analysis-core.ts | 16 ++++++++++- apps/api/src/lib/local-fit.test.ts | 2 +- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/api/src/lib/analysis-core.test.ts b/apps/api/src/lib/analysis-core.test.ts index c98d45a..9d21f34 100644 --- a/apps/api/src/lib/analysis-core.test.ts +++ b/apps/api/src/lib/analysis-core.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { groundingFromParsed, keywordCatalog, type ParsedJobOutput } from './analysis-core'; +import { extractKeywords, groundingFromParsed, keywordCatalog, type ParsedJobOutput } from './analysis-core'; const fallback = { requiredSkills: ['Python'], @@ -45,3 +45,40 @@ test('keywordCatalog has no duplicates and contains at least 50 entries', () => const uniqueKeywords = new Set(keywordCatalog.map((k) => k.toLowerCase())); assert.equal(uniqueKeywords.size, keywordCatalog.length, 'keywordCatalog contains duplicate keywords (case-insensitive)'); }); + +test('extractKeywords matches short skill names on token boundaries and ignores substring false positives', () => { + // "JavaScript" should match JavaScript, not Java + const jsOnly = extractKeywords('We use JavaScript everyday.'); + assert.ok(jsOnly.includes('JavaScript')); + assert.ok(!jsOnly.includes('Java')); + + // Standalone "Java" matches Java + const javaOnly = extractKeywords('We use Java for backend services.'); + assert.ok(javaOnly.includes('Java')); + + // "Google Cloud", "MongoDB", "Django", "ongoing" should NOT match "Go" + const noGo = extractKeywords('We use Google Cloud, MongoDB, and Django for ongoing operations.'); + assert.ok(!noGo.includes('Go')); + assert.ok(noGo.includes('Google Cloud')); + assert.ok(noGo.includes('MongoDB')); + assert.ok(noGo.includes('Django')); + + // Standalone "Go" matches Go + const goOnly = extractKeywords('We write Go and Python services.'); + assert.ok(goOnly.includes('Go')); + assert.ok(goOnly.includes('Python')); + + // "trust" does NOT match "Rust" + const noRust = extractKeywords('We trust the process and learn quickly.'); + assert.ok(!noRust.includes('Rust')); + + // Standalone "Rust" matches + const rustOnly = extractKeywords('High-performance Rust systems.'); + assert.ok(rustOnly.includes('Rust')); + + // Symbols in skills like C#, C++, .NET + const symbols = extractKeywords('Skilled in C#, C++, and .NET Core.'); + assert.ok(symbols.includes('C#')); + assert.ok(symbols.includes('C++')); + assert.ok(symbols.includes('.NET')); +}); diff --git a/apps/api/src/lib/analysis-core.ts b/apps/api/src/lib/analysis-core.ts index 3401ca9..ea2045c 100644 --- a/apps/api/src/lib/analysis-core.ts +++ b/apps/api/src/lib/analysis-core.ts @@ -112,8 +112,22 @@ function unique(values: string[]) { return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; } +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const keywordRegexes = keywordCatalog.map((keyword) => { + const escaped = escapeRegex(keyword); + // Match keyword bounded by non-alphanumeric characters or string ends, + // treating # and + as word-like characters so C# and C++ aren't truncated. + const regex = new RegExp(`(^|[^a-zA-Z0-9#+])(${escaped})($|[^a-zA-Z0-9#+])`, 'i'); + return { keyword, regex }; +}); + export function extractKeywords(text: string): string[] { - return keywordCatalog.filter((keyword) => text.toLowerCase().includes(keyword.toLowerCase())); + return keywordRegexes + .filter(({ regex }) => regex.test(text)) + .map(({ keyword }) => keyword); } function inferCompany(description: string) { diff --git a/apps/api/src/lib/local-fit.test.ts b/apps/api/src/lib/local-fit.test.ts index b241bd9..d1f9ec9 100644 --- a/apps/api/src/lib/local-fit.test.ts +++ b/apps/api/src/lib/local-fit.test.ts @@ -101,6 +101,6 @@ test('realistic 1500-char JD mentioning Python, PostgreSQL, Docker, Kubernetes, assert.notEqual(score, null); assert.equal(typeof score, 'number'); - assert.equal(score, 50); // 3 of 6 (Python, PostgreSQL, SQL, Docker, Kubernetes, Terraform) = 50% + assert.equal(score, 60); // 3 of 5 (Python, PostgreSQL, Docker, Kubernetes, Terraform) = 60% assert.deepEqual(matchedSkills.sort(), ['Docker', 'Kubernetes', 'Terraform']); });