This workflow follows the Google Sheets → OpenAI recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "***My workflow Sep 26 (Sep 27 at 14:55:29)",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-1040,
224
],
"id": "d0c0db14-6c63-4f4e-a2d0-4eecd1902b46",
"name": "When clicking \u2018Execute workflow\u2019"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1P7ZBC9_JdvpkXyY_SQBZxWm1i2Z8k9wFjseBhO8cYfE",
"mode": "list",
"cachedResultName": "MCP mock data",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1P7ZBC9_JdvpkXyY_SQBZxWm1i2Z8k9wFjseBhO8cYfE/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1312529578,
"mode": "list",
"cachedResultName": "JD",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1P7ZBC9_JdvpkXyY_SQBZxWm1i2Z8k9wFjseBhO8cYfE/edit#gid=1312529578"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.6,
"position": [
-768,
144
],
"id": "4fc9d603-f97d-450d-9a74-fb064e96e1f3",
"name": "Get row(s) in sheet",
"executeOnce": false,
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "chatgpt-4o-latest",
"mode": "list",
"cachedResultName": "CHATGPT-4O-LATEST"
},
"messages": {
"values": [
{
"content": "You are a strict JSON normalizer for job descriptions.\n\nReturn exactly ONE JSON object (no array, no prose). Temperature 0: be literal; do not invent values.\n\nTOP-LEVEL OUTPUT KEYS (Supabase shape only):\njob_id, job_number, company, department, job_title,\nlocation_city, location_state_province, location_country,\nremote,\ndate_posted,\npay_currency, pay_min, pay_max, pay_period,\nmin_year_req, required_education, preferred_education,\nrole_level,\nrequired_skills,\nrequired_qualifications, responsibilities, good_to_have,\nraw_json\n\nRULES\n- job_id: \"JD\" if missing upstream.\n- Strings trimmed; empty \u2192 null. Numbers are integers. Arrays lowercased, de-duped, concise (\u226420 items for required_skills).\n- raw_json contains only normalized_jd (richer details). Do not add any other top-level keys.\n\nINPUT FORMATS (auto-detect)\n- Table: largest consistent block of pipe/tab/fixed-width; first row is header unless numeric. Ignore non-table text.\n- Free text: treat as one JD; split bullets by common markers (\u2022, -, \u2014, *, 1.), respecting wrapped lines.\n\nNORMALIZATION RULES (domain-agnostic)\n- Location \u2192 location_city, location_state_province, location_country. If only \u201cremote/hybrid\u201d, leave city/state null; set country if explicit. Never put \u201cremote\u201d in city.\n- remote (boolean): true if remote OR hybrid allowed; false if onsite only; null if unclear.\n- remote nuance (raw_json.normalized_jd.location.remote_policy): one of remote | hybrid | onsite | remote_or_onsite | unspecified. If onsite cities are listed, include in onsite_offices.\n- Date \u2192 ISO YYYY-MM-DD when present; else null.\n- Compensation:\n \u2022 pay_currency (USD, CAD, EUR, GBP, \u2026) when explicit.\n \u2022 Normalize numbers: 90k\u219290000; 1.2m\u21921200000.\n \u2022 Extract pay_min, pay_max.\n \u2022 pay_period \u2208 [\"yearly\",\"monthly\",\"weekly\",\"daily\",\"hourly\",\"unspecified\"]; map \u201cyear/yr/annual/salary\u201d\u2192\"yearly\".\n \u2022 If JD states structure (e.g., \u201csalary only, no bonus\u201d, \u201cchoose salary vs stock\u201d), add to raw_json.normalized_jd.employment.comp_structure_notes.\n- Experience: min_year_req = smallest explicit minimum (e.g., \u201c2\u20134 years\u201d\u21922). If only \u201cexperience\u201d with no number \u2192 null.\n- Education:\n \u2022 required_education: highest explicit required in [\"bachelor's\",\"master's\",\"phd\"] or null.\n \u2022 preferred_education: same set, only if explicitly preferred.\n- Role level: map to [\"junior\",\"mid\",\"senior\",\"staff\",\"principal\",\"manager\",\"director\",\"vp\",\"unspecified\"]. If unstated \u2192 \"unspecified\".\n\nSKILLS & BUCKETING (strict)\n- required_skills: ONLY hard, verifiable competencies that the JD explicitly requires (look for headings like \u201crequirements\u201d, \u201cmust have\u201d, \u201cminimum\u201d, \u201cX+ years\u201d, or sentences with \u201cmust/required\u201d). Use the JD\u2019s own phrasing; do NOT add synonyms. Do NOT include generic soft traits (e.g., communication, collaboration, leadership) here unless they appear inside the Required section as assessable competencies.\n- good_to_have: competencies called out as \u201cpreferred/nice to have/plus\u201d OR clearly implied by \u201cResponsibilities/What you\u2019ll do\u201d but NOT stated as hard requirements. Place soft skills (communication, storytelling, collaboration, stakeholder management, etc.) here unless the JD explicitly lists them as required.\n- required_qualifications: concise bullet-like phrases from the \u201cRequired/Minimum\u201d section (keep as short fragments, not sentences).\n- responsibilities: verb-led lines from \u201cResponsibilities/What you\u2019ll do\u201d.\n- Keep skills/tools/methods as short noun phrases; preserve multi-word terms as they appear (e.g., \u201ctime series analysis\u201d, \u201cquality control\u201d, \u201clesson planning\u201d). Normalize trivial variants (hyphen/underscore \u2192 space; singular/plural to the dominant form in the JD). Do NOT collapse words (\u201cdata warehousing\u201d, not \u201cdatawarehousing\u201d).\n\nKEYWORDS & RAW JSON (for downstream matching)\n- keywords (top-level is NOT allowed). Instead, populate raw_json.normalized_jd.jd_vocabulary as the de-duped union of:\n must_have_skills + nice_to_have_skills + tools_and_tech (all lowercased, unique).\n- Also populate raw_json.normalized_jd.keywords as a de-duped union of required_skills + tools_and_tech + domain_experience + good_to_have (\u226450).\n\nraw_json.normalized_jd schema (extend only within this object):\n{\n \"location\": {\n \"remote_policy\": \"remote|hybrid|onsite|remote_or_onsite|unspecified\",\n \"onsite_requirement\": \"days_per_week:N|''\",\n \"onsite_offices\": []\n },\n \"employment\": {\n \"type\": \"full_time|part_time|contract|internship|temporary|unspecified\",\n \"pay_currency\": \"\",\n \"pay_min\": null,\n \"pay_max\": null,\n \"pay_period\": \"yearly|monthly|weekly|daily|hourly|unspecified\",\n \"comp_structure_notes\": []\n },\n \"work_auth\": {\n \"visa_sponsorship\": \"yes|no|unspecified\",\n \"security_clearance\": \"none|public_trust|secret|top_secret|other\"\n },\n \"years_experience_min\": null,\n \"education_required\": [],\n \"education_preferred\": [],\n \"certifications_required\": [],\n \"certifications_preferred\": [],\n \"must_have_skills\": [],\n \"nice_to_have_skills\": [],\n \"tools_and_tech\": [],\n \"domain_experience\": [],\n \"responsibilities\": [],\n \"benefits\": [],\n \"eq_eo_statement\": \"\",\n \"anti_requirements\": [],\n \"keywords\": [],\n \"jd_vocabulary\": [],\n \"source_excerpt\": \"\",\n\n // NEW: hidden/implicit requirements inferred from the JD text (for downstream AI matching)\n \"hidden_required_skills\": [\n // up to 7 items, each: { \"term\": \"\", \"rationale\": \"\", \"evidence\": \"\", \"confidence\": 0.0 }\n ]\n}\n\nNEW \u2014 Hidden requirement inference (conservative)\n- Extract up to 7 implicit/hidden requirements a qualified hire would realistically need but that are NOT explicitly listed as required_skills.\n- Each item MUST include:\n \u2022 term: concise, generic capability (lowercased; avoid brand names if a generic exists)\n \u2022 rationale: \u2264 20 words explaining why this is needed\n \u2022 evidence: \u2264 15 words: an exact or near-exact snippet from the JD text that implies it\n \u2022 confidence: 0.0\u20131.0, reflecting strength of implication\n- DO NOT duplicate anything already in required_skills; dedupe across items; keep domain-agnostic.\n- Examples of acceptable hidden terms (when strongly implied by text): \"stakeholder management\", \"experimental design\", \"data governance\", \"version control\", \"process optimization\", \"curriculum design\", \"quality assurance\", \"inventory planning\", etc.\n\nSILENT SELF-AUDIT (before emitting JSON)\n1. All top-level keys present; only allowed keys used.\n2. location_city \u2260 \"remote\".\n3. remote aligns with narrative; if both remote and onsite \u2192 remote=true and remote_policy=\"remote_or_onsite\".\n4. date_posted is ISO or null.\n5. pay_period in allowed set; numbers normalized.\n6. min_year_req is the smallest explicit minimum.\n7. required_education/preferred_education in allowed tokens; or null/[] if not explicit.\n8. required_skills excludes generic soft skills unless explicitly required; soft skills live in good_to_have.\n9. responsibilities are verb-led; do NOT mix into required_skills.\n10. Arrays are lowercased, unique; required_skills \u2264 20 items.\n11. raw_json.normalized_jd.jd_vocabulary is the union of must/nice/tools; keywords populated as defined.\n12. If uncertain \u2192 null/[] rather than guessing.\n13. NEW: hidden_required_skills contains 0\u20137 items with term/rationale/evidence/confidence; none of its terms repeat required_skills.\n",
"role": "system"
},
{
"content": "=Normalize this job description into the required JSON shape.\n\nIf you receive an array of JDs, return ONE object per call (we will batch upstream).\n\nInput payload:\n{{$json.jd_text || $json.message?.content || ''}}\n\nGuidance:\n- Keep the Supabase top-level keys exactly as specified by the system message.\n- Put only hard, explicitly required competencies into required_skills.\n- Move preferred or responsibility-implied competencies (including soft skills like communication/storytelling) into good_to_have unless the JD explicitly marks them as required.\n- Preserve multi-word skill phrases as written in the JD; do not invent synonyms.\n- Populate raw_json.normalized_jd.jd_vocabulary and raw_json.normalized_jd.keywords as instructed.\n- NEW: Also produce raw_json.normalized_jd.hidden_required_skills with up to 7 implicit/hidden requirements, each including {term, rationale, evidence, confidence}, strictly supported by the JD text.\n"
}
]
},
"options": {
"temperature": 0
}
},
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [
-480,
144
],
"id": "d6055329-e9bd-46c0-9bf0-ae43e42d6918",
"name": "Message a model",
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1P7ZBC9_JdvpkXyY_SQBZxWm1i2Z8k9wFjseBhO8cYfE",
"mode": "list",
"cachedResultName": "MCP mock data",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1P7ZBC9_JdvpkXyY_SQBZxWm1i2Z8k9wFjseBhO8cYfE/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1928225974,
"mode": "list",
"cachedResultName": "Resume",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1P7ZBC9_JdvpkXyY_SQBZxWm1i2Z8k9wFjseBhO8cYfE/edit#gid=1928225974"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.6,
"position": [
-768,
320
],
"id": "e9b95850-7fd0-497a-b245-fc62a8b175ad",
"name": "Get row(s) in sheet1",
"executeOnce": false,
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "chatgpt-4o-latest",
"mode": "list",
"cachedResultName": "CHATGPT-4O-LATEST"
},
"messages": {
"values": [
{
"content": "You are a strict JSON generator.\n\nReturn exactly ONE JSON array containing ONE OR MORE objects (one object per resume). No prose, no comments, no explanations, no markdown, no trailing commas.\n\nYour task is to extract COMPETENCIES from resume text (domain-agnostic). Competencies include skills, tools, methods, frameworks, platforms, languages, libraries, analytical techniques, and credentials. Keep interpersonal traits as soft skills (separate).\n\nEACH OBJECT MUST INCLUDE THESE KEYS (all required):\n- candidate_direct_skills // string[]\n- candidate_inferred_skills // string[]\n- hidden_candidate_skills // NEW: Array<{ term, rationale, evidence, confidence }>\n- candidate_education // string[]\n- candidate_soft_skills // string[]\n- meta // object\n\nRULES\n- All arrays: UNIQUE, LOWERCASED strings; trim whitespace; preserve multi-word phrases EXACTLY as written (do NOT invent synonyms or collapse spaces).\n- candidate_direct_skills: ONLY competencies explicitly stated in the resume.\n- candidate_inferred_skills: higher-level competencies STRONGLY implied by the resume\u2019s work (objectively assessable). Do NOT duplicate soft skills here. Prefer generic capability terms (e.g., \u201cdata warehousing\u201d, \u201cetl\u201d, \u201ctime series analysis\u201d).\n- hidden_candidate_skills (NEW): up to 10 implicit capabilities the candidate likely has, each with:\n { \"term\": \"<concise generic capability>\", \n \"rationale\": \"<\u226420 words why this follows from evidence>\", \n \"evidence\": \"<\u226415-word exact or near-exact snippet from resume>\", \n \"confidence\": <0.0\u20131.0> }\n \u2022 Be conservative and evidence-based. \n \u2022 Do NOT include interpersonal traits here (those belong in candidate_soft_skills).\n \u2022 Deduplicate terms; keep domain-agnostic where possible (prefer \u201cexperimental design\u201d over brand-y phrasing when justified by evidence).\n- candidate_education: degrees/certifications explicitly present. If none, output [].\n- candidate_soft_skills: interpersonal/communication traits explicitly present. If none, output [].\n- meta: MUST be an object with at least { \"index\": <0-based position in batch> }. Additionally, IF AND ONLY IF these identifiers appear literally in the resume text, include any of:\n { \"name\": \"...\", \"email\": \"...\", \"phone\": \"...\", \"linkedin\": \"...\", \"github\": \"...\", \"location\": \"...\", \"candidate_id\": \"...\" }\n Do NOT invent values. Omit any key not explicitly present.\n\nBATCH DETECTION\n- If multiple resumes are present, return one object per resume in the SAME order.\n- Possible separators: <RESUME>\u2026</RESUME> or <RESUME id=\"...\">\u2026</RESUME>, lines consisting only of \"====\" or \"----\", XML/HTML-like blocks, or clear blank-line blocks.\n- If no clear separators are found, treat the entire content as ONE resume (array length 1).\n\nCONSERVATIVE INFERENCE GUARDRAILS\n- Never move soft skills into inferred/hidden lists.\n- Do not copy items from candidate_direct_skills into candidate_inferred_skills verbatim; inferred should be broader/umbrella capabilities supported by evidence (e.g., direct: \u201csnowflake\u201d, inferred: \u201cdata warehousing\u201d).\n- When in doubt, exclude rather than guess.\n\nCONSTRAINTS\n- Do not include counts, scores, or IDs outside meta.\n- Do not use nulls; use [] for empty arrays and {} for an empty meta object (but it must at least contain \"index\").\n",
"role": "system"
},
{
"content": "=Content:\n<CANDIDATES>\n{{$json[\"Resume_text\"]}}\n</CANDIDATES>\n\nINSTRUCTIONS\n- Read ONLY the text inside <CANDIDATES>.\n- Detect multiple resumes if present and output one object per resume, preserving order.\n- Using the rules from the system message, return exactly ONE JSON array with ONE OR MORE objects. Each object MUST include:\n candidate_direct_skills,\n candidate_inferred_skills,\n hidden_candidate_skills, // NEW: with {term, rationale, evidence, confidence}\n candidate_education,\n candidate_soft_skills,\n meta\n"
}
]
},
"options": {
"temperature": 0
}
},
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [
-464,
352
],
"id": "e0a63902-96a8-453e-a172-e452d2b19d4c",
"name": "Message a model1",
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// Mode: Run Once for All Items \u2014 JavaScript\n// Robust JSON extractor + \"N/A\" coercion + diagnostics\n// Updated to PRESERVE ARRAYS for skills, move soft skills to good_to_have,\n// and add JOB-AGNOSTIC keyword atomization for universal matching.\n\nconst NA = 'N/A';\nconst DROP_EMPTY_ROWS = false;\n\n// ---------- JSON extraction helpers ----------\nfunction stripCodeFences(s) {\n return s.replace(/```(?:json)?/gi, '').replace(/```/g, '');\n}\nfunction stripPreamble(s) {\n const firstBrace = s.indexOf('{');\n const firstBracket = s.indexOf('[');\n let idx = -1;\n if (firstBrace === -1 && firstBracket === -1) return s;\n if (firstBrace === -1) idx = firstBracket;\n else if (firstBracket === -1) idx = firstBrace;\n else idx = Math.min(firstBrace, firstBracket);\n return s.slice(idx);\n}\nfunction replaceSmartQuotes(s) {\n return s\n .replace(/[\\u201C\\u201D\\u2033]/g, '\"')\n .replace(/[\\u2018\\u2019\\u2032]/g, \"'\");\n}\nfunction unescapeIfQuotedJsonString(s) {\n const t = String(s).trim();\n const looksQuoted = (t.startsWith('\"') && t.endsWith('\"')) || (t.startsWith(\"'\") && t.endsWith(\"'\"));\n if (!looksQuoted) return { ok:false };\n try {\n const once = JSON.parse(t);\n if (typeof once === 'string') return { ok:true, value: once };\n return { ok:false };\n } catch { return { ok:false }; }\n}\nfunction tryParse(s) {\n try { return { ok:true, value: JSON.parse(s) }; }\n catch (e) { return { ok:false, error: e.message }; }\n}\nfunction extractJsonFromText(s) {\n if (typeof s !== 'string') return { ok:false, why:'not_string' };\n let txt = stripCodeFences(replaceSmartQuotes(s)).trim();\n if (!txt.startsWith('{') && !txt.startsWith('[')) txt = stripPreamble(txt);\n\n let p = tryParse(txt);\n if (p.ok) return p;\n\n const iBrace = txt.indexOf('{'), jBrace = txt.lastIndexOf('}');\n if (iBrace !== -1 && jBrace !== -1 && jBrace > iBrace) {\n p = tryParse(txt.slice(iBrace, jBrace + 1));\n if (p.ok) return p;\n }\n const iBracket = txt.indexOf('['), jBracket = txt.lastIndexOf(']');\n if (iBracket !== -1 && jBracket !== -1 && jBracket > iBracket) {\n p = tryParse(txt.slice(iBracket, jBracket + 1));\n if (p.ok) return p;\n }\n\n const unq = unescapeIfQuotedJsonString(txt);\n if (unq.ok) {\n const inner = stripCodeFences(replaceSmartQuotes(unq.value)).trim();\n const inner2 = (!inner.startsWith('{') && !inner.startsWith('[')) ? stripPreamble(inner) : inner;\n let q = tryParse(inner2);\n if (q.ok) return q;\n const ib = inner2.indexOf('{'), jb = inner2.lastIndexOf('}');\n if (ib !== -1 && jb !== -1 && jb > ib) {\n q = tryParse(inner2.slice(ib, jb + 1));\n if (q.ok) return q;\n }\n }\n return { ok:false, why:'json_not_found_or_invalid' };\n}\n\n// ---------- Coercion + normalization helpers ----------\nconst isEmptyStr = v => (typeof v === 'string' && v.trim() === '');\nfunction toNA(v) {\n return (v === undefined || v === null || isEmptyStr(v)) ? NA : String(v);\n}\nfunction numOrNA(v) {\n return (v === 0 || (typeof v === 'number' && !Number.isNaN(v))) ? v\n : (v === null || v === undefined || v === '' ? NA : v);\n}\nfunction boolOrNA(v) { return (v === true || v === false) ? v : NA; }\n\n// Keep arrays (no join); split strings ONLY on , ; | and preserve spaces inside tokens\nfunction toArrayKeep(x) {\n if (x == null) return [];\n if (Array.isArray(x)) return x.map(s => String(s));\n if (typeof x === 'object') return Object.values(x).map(String);\n return String(x).split(/[;,|]/).map(s => s.trim()).filter(Boolean);\n}\n\n// Minimal, job-agnostic canonicalization (NO domain lists)\nconst CANON = {\n 'google tag management': 'google tag manager',\n 'google tag manager': 'google tag manager',\n gtm: 'google tag manager',\n 'ms excel': 'excel',\n 'microsoft excel': 'excel',\n ga4: 'google analytics',\n 'google analytics 4': 'google analytics'\n};\nfunction normSkill(s) {\n const base = String(s).toLowerCase().trim()\n .replace(/[()]/g, '')\n .replace(/[_\\-]+/g, ' ') // unify hyphen/underscore to space\n .replace(/\\s+/g, ' ');\n return CANON[base] || base;\n}\nfunction uniq(a) { return [...new Set((a || []).filter(Boolean))]; }\n\n// Soft skills detector (generic, NOT domain-specific)\nconst SOFT_RE = /\\b(communication|storytelling|collaboration|leadership|stakeholder(?: management)?|mentorship|coaching|team(?:\\s*)work|presentation|interpersonal)\\b/;\nfunction isSoftSkill(token) {\n return SOFT_RE.test(String(token).toLowerCase());\n}\n\n// ---------- UNIVERSAL ATOMIZER v1 (job-agnostic) ----------\nconst STOP = new Set([\n \"a\",\"an\",\"the\",\"and\",\"or\",\"of\",\"to\",\"in\",\"for\",\"with\",\"on\",\"as\",\"by\",\"from\",\"at\",\"into\",\n \"such\",\"that\",\"this\",\"those\",\"these\",\"their\",\"our\",\"your\",\"his\",\"her\",\"its\",\"is\",\"are\",\n \"be\",\"being\",\"been\",\"will\",\"shall\",\"may\",\"might\",\"can\",\"could\",\"should\",\"would\",\n \"ability\",\"capable\",\"capability\",\"skills\",\"skill\",\"proficiency\",\"experience\",\n \"experienced\",\"knowledge\",\"understanding\",\"familiarity\",\"proven\",\"track\",\"record\",\"using\",\n \"use\",\"utilize\",\"including\",\"etc\",\"and/or\",\"per\",\"via\",\"across\",\"within\",\"about\"\n]);\n\nconst DELEADERS = [\n /^(\\d+\\+?\\s*years?|years?)\\b.*?\\b(in|with|of)\\b\\s*/i,\n /^(hands[-\\s]?on|strong|solid|deep|working)\\s+/i,\n /^(proven|demonstrated)\\s+(ability|experience)\\s+(to|in|with)\\s+/i,\n /^(ability|experience|knowledge|understanding)\\s+(to|of|with|in)\\s+/i\n];\n\nfunction cleanPhrase(s){\n let t = String(s||\"\").toLowerCase().trim();\n DELEADERS.forEach(re => t = t.replace(re,\"\"));\n // keep tech punctuation; drop brackets/quotes\n t = t.replace(/[\\u2018\\u2019'\"]/g,\"\")\n .replace(/[()]/g,\" \")\n .replace(/[,;]|\\/+/g,\" \")\n .replace(/\\s+/g,\" \")\n .trim();\n return t;\n}\n\nfunction tokenize(s){\n const words = s.split(/\\s+/).filter(w => w && !STOP.has(w));\n return words;\n}\n\nfunction ngrams(words, maxN=3){\n const out = [];\n for (let n=1;n<=maxN;n++){\n for (let i=0;i<=words.length-n;i++){\n out.push(words.slice(i,i+n).join(\" \"));\n }\n }\n return out;\n}\n\nfunction variants(atom){\n const v = new Set();\n const spaced = atom.trim();\n if (!spaced) return [];\n v.add(spaced); // \"bill of materials\"\n v.add(spaced.replace(/-/g,\" \")); // hyphen \u2192 space\n v.add(spaced.replace(/[\\s-]/g,\"\")); // collapse: \"billofmaterials\"\n const letters = spaced.split(/\\s+/).map(w => w[0]).join(\"\");\n if (letters.length >= 2) v.add(letters); // acronym \"bom\"\n return Array.from(v);\n}\n\nfunction keepToken(t){\n // keep if alnum len>=2, or contains techy chars or digits\n return /[a-z0-9]/.test(t) && (t.length>=2 || /[#+.\\d]/.test(t));\n}\n\nfunction atomizeArray(arr){\n const out = new Set();\n for (const raw of (arr||[])){\n const cleaned = cleanPhrase(raw);\n if (!cleaned) continue;\n const toks = tokenize(cleaned);\n const grams = ngrams(toks, 3);\n for (const g of grams){\n for (const v of variants(g)){\n const t = v.replace(/\\s+/g,\" \").trim();\n if (keepToken(t)) out.add(t);\n }\n }\n }\n return Array.from(out);\n}\n\n// ---------- Contract coerce ----------\nfunction coerceRecord(obj) {\n let rec = obj;\n\n if (rec?.message?.content && typeof rec.message.content === 'string') {\n const parsed = extractJsonFromText(rec.message.content);\n if (parsed.ok) rec = parsed.value;\n else return { rec:null, reason: 'parse_failed_message.content:'+parsed.why };\n } else if (typeof rec?.content === 'string') {\n const parsed = extractJsonFromText(rec.content);\n if (parsed.ok) rec = parsed.value;\n else return { rec:null, reason: 'parse_failed_content:'+parsed.why };\n } else if (rec && typeof rec === 'object') {\n // ok\n } else {\n return { rec:null, reason:'no_payload' };\n }\n\n const keys = [\n 'job_id','job_number','company','department','job_title',\n 'location_city','location_state_province','location_country',\n 'remote','min_year_req','required_skills','good_to_have',\n 'raw_json','njd_must_have_skills','njd_nice_to_have_skills','njd_tools_and_tech'\n ];\n const hasAny = keys.some(k => Object.prototype.hasOwnProperty.call(rec, k));\n if (!hasAny) return { rec:null, reason:'missing_contract_keys' };\n\n return { rec, reason:'ok' };\n}\n\n// ---------- Main ----------\nconst rows = [];\n\nfor (const it of items) {\n const j = it.json ?? {};\n const { rec, reason } = coerceRecord(j);\n\n if (!rec) {\n const row = {\n __status: 'empty',\n __reason: reason,\n\n job_id: NA, job_number: NA, company: NA, department: NA, job_title: NA,\n location_city: NA, location_state_province: NA, location_country: NA,\n remote: NA, date_posted: NA, pay_currency: NA, pay_min: NA, pay_max: NA, pay_period: NA,\n min_year_req: NA, required_education: NA, preferred_education: NA, role_level: NA,\n\n // arrays\n required_skills: [],\n required_qualifications: [],\n responsibilities: [],\n good_to_have: [],\n\n // njd block\n njd_remote_policy: NA, njd_onsite_requirement: NA, njd_onsite_offices: [],\n njd_employment_type: NA, njd_comp_currency: NA, njd_comp_min: NA, njd_comp_max: NA,\n njd_comp_period: NA, njd_comp_structure_notes: [],\n njd_years_experience_min: NA,\n njd_must_have_skills: [], njd_nice_to_have_skills: [], njd_tools_and_tech: [], njd_domain_experience: [],\n njd_benefits: [], njd_keywords: [], njd_eq_eo_statement: NA,\n\n // NEW keywords (empty on error)\n njd_required_keywords: [],\n njd_preferred_keywords: [],\n\n __hard_required_ok: false,\n __missing_required: 'company | job_title | required_skills'\n };\n if (!DROP_EMPTY_ROWS) rows.push({ json: row });\n continue;\n }\n\n const njd = rec?.raw_json?.normalized_jd ?? {};\n\n // ---- skills as arrays, normalized, deduped; spaces preserved ----\n let required_skills_arr = uniq(\n toArrayKeep(rec.required_skills ?? rec.njd_must_have_skills ?? njd.must_have_skills).map(normSkill)\n );\n let good_to_have_arr = uniq(\n toArrayKeep(rec.good_to_have ?? rec.njd_nice_to_have_skills ?? njd.nice_to_have_skills).map(normSkill)\n );\n const tools_and_tech_arr = uniq(\n toArrayKeep(rec.tools_and_tech ?? rec.njd_tools_and_tech ?? njd.tools_and_tech).map(normSkill)\n );\n\n // ---- RE-BUCKET: move obvious soft skills out of required into good_to_have (generic) ----\n const softInRequired = required_skills_arr.filter(isSoftSkill);\n if (softInRequired.length) {\n required_skills_arr = required_skills_arr.filter(s => !isSoftSkill(s));\n good_to_have_arr = uniq(good_to_have_arr.concat(softInRequired));\n }\n\n // Optional cap to keep required concise\n if (required_skills_arr.length > 20) {\n required_skills_arr = required_skills_arr.slice(0, 20);\n }\n\n // ---- NEW: job-agnostic keyword atoms (for universal matching) ----\n const required_keywords = atomizeArray(\n required_skills_arr.length ? required_skills_arr : toArrayKeep(rec.required_qualifications)\n );\n const preferred_keywords = atomizeArray(good_to_have_arr);\n\n // ----- Row build -----\n const row = {\n __status: 'ok',\n __reason: reason,\n\n job_id: toNA(rec.job_id ?? rec.id ?? rec.jobid),\n job_number: toNA(rec.job_number),\n company: toNA(rec.company),\n department: toNA(rec.department),\n job_title: toNA(rec.job_title ?? rec.title),\n location_city: toNA(rec.location_city ?? rec.city),\n location_state_province: toNA(rec.location_state_province ?? rec.state),\n location_country: toNA(rec.location_country ?? rec.country),\n remote: boolOrNA(rec.remote),\n date_posted: toNA(rec.date_posted),\n pay_currency: toNA(rec.pay_currency),\n pay_min: numOrNA(rec.pay_min),\n pay_max: numOrNA(rec.pay_max),\n pay_period: toNA(rec.pay_period),\n min_year_req: numOrNA(rec.min_year_req ?? rec.njd_years_experience_min),\n required_education: toNA(rec.required_education),\n preferred_education: toNA(rec.preferred_education),\n role_level: toNA(rec.role_level),\n\n // arrays preserved\n required_skills: required_skills_arr,\n required_qualifications: toArrayKeep(rec.required_qualifications),\n responsibilities: toArrayKeep(rec.responsibilities),\n good_to_have: good_to_have_arr,\n\n // njd block (keep arrays where applicable)\n njd_remote_policy: toNA(njd?.location?.remote_policy ?? rec.njd_remote_policy),\n njd_onsite_requirement: toNA(njd?.location?.onsite_requirement ?? rec.njd_onsite_requirement),\n njd_onsite_offices: toArrayKeep(njd?.location?.onsite_offices ?? rec.njd_onsite_offices),\n\n njd_employment_type: toNA(njd?.employment?.type ?? rec.njd_employment_type),\n njd_comp_currency: toNA(njd?.employment?.pay_currency ?? rec.njd_comp_currency),\n njd_comp_min: numOrNA(njd?.employment?.pay_min ?? rec.njd_comp_min),\n njd_comp_max: numOrNA(njd?.employment?.pay_max ?? rec.njd_comp_max),\n njd_comp_period: toNA(njd?.employment?.pay_period ?? rec.njd_comp_period),\n njd_comp_structure_notes: toArrayKeep(njd?.employment?.comp_structure_notes ?? rec.njd_comp_structure_notes),\n\n njd_years_experience_min: numOrNA(njd?.years_experience_min ?? rec.njd_years_experience_min),\n\n njd_must_have_skills: required_skills_arr, // mirror for compatibility\n njd_nice_to_have_skills: good_to_have_arr,\n njd_tools_and_tech: tools_and_tech_arr,\n njd_domain_experience: toArrayKeep(njd?.domain_experience ?? rec.njd_domain_experience),\n\n njd_benefits: toArrayKeep(njd?.benefits ?? rec.njd_benefits),\n\n // refreshed: union of atoms + tools (ensures matchable keywords)\n njd_keywords: uniq([\n ...required_keywords,\n ...preferred_keywords,\n ...tools_and_tech_arr\n ]),\n\n // NEW fields\n njd_required_keywords: required_keywords,\n njd_preferred_keywords: preferred_keywords,\n\n njd_eq_eo_statement: toNA(njd?.eq_eo_statement ?? rec.njd_eq_eo_statement)\n };\n\n const missingRequired = [];\n if (row.company === NA) missingRequired.push('company');\n if (row.job_title === NA) missingRequired.push('job_title');\n if (!row.required_skills.length) missingRequired.push('required_skills');\n row.__hard_required_ok = missingRequired.length === 0;\n row.__missing_required = missingRequired.length ? missingRequired.join(' | ') : '';\n\n rows.push({ json: row });\n}\n\nreturn rows;\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
48,
128
],
"id": "cf28c6f1-6811-43e8-acca-232d0265e43f",
"name": "Normalized_JD",
"executeOnce": false,
"alwaysOutputData": false
},
{
"parameters": {
"jsCode": "/**\n * normalize_cand \u2014 Candidate LLM Output Validator & Normalizer (n8n Function/Code node)\n * Works in BOTH modes: Run Once for All Items or Run Once for Each Item.\n * - Robustly extracts a JSON ARRAY from LLM outputs (message.content, choices[0].message.content, etc.)\n * - Emits normalized cand_* fields for downstream logic\n * - Adds candidate_direct_skills, candidate_inferred_skills, candidate_education, candidate_soft_skills (arrays)\n * - Removes soft-skill bleed from inferred (inferred -= soft)\n * - Preserves upstream fields (non-destructive) and promotes meta identifiers\n * - Keeps cand_skills for backward compatibility (union of direct+inferred)\n */\n\nconst PRESERVE_UPSTREAM = true; // keep all existing item.json fields (non-destructive merge)\n\n// ---------------- allowed keys (extended, but keeps your original) ----------------\nconst ALLOWED_KEYS = [\n // legacy/base fields (kept for compatibility)\n \"name\",\"email\",\"phone\",\n \"location_city\",\"location_state_province\",\"location_country\",\n \"remote\",\"total_years_experience\",\"highest_degree\",\n \"latest_title\",\"latest_company\",\n \"skills_text\",\"titles\",\"achievements\",\n \"raw_json\",\n\n // normalized fields used downstream\n \"cand_name\",\"cand_email\",\n \"cand_location_city\",\"cand_location_state_province\",\"cand_location_country\",\n \"cand_remote\",\"cand_total_years_experience\",\"cand_experience_level\",\n \"cand_skills\",\n\n // NEW: explicit arrays & meta + promoted identifiers\n \"candidate_direct_skills\",\"candidate_inferred_skills\",\"candidate_education\",\"candidate_soft_skills\",\n \"meta\",\"cand_index\",\n \"candidate_id\",\"candidate_email\",\"candidate_phone\",\"candidate_name\",\n \"candidate_linkedin\",\"candidate_github\",\"candidate_location\"\n];\n\n// ---------------- helpers: parsing & cleanup ----------------\nfunction stripCodeFences(s){ return s.replace(/```(?:json)?/gi,'').replace(/```/g,''); }\nfunction replaceSmartQuotes(s){ return s.replace(/[\\u201C\\u201D\\u2033]/g,'\"').replace(/[\\u2018\\u2019\\u2032]/g,\"'\"); }\nfunction stripPreamble(s){\n const i1=s.indexOf('['), i2=s.indexOf('{'); let idx=-1;\n if(i1===-1 && i2===-1) return s;\n if(i1===-1) idx=i2; else if(i2===-1) idx=i1; else idx=Math.min(i1,i2);\n return s.slice(idx);\n}\nfunction tryParse(s){ try{ return {ok:true, value: JSON.parse(s)} }catch(e){ return {ok:false, error:e.message} } }\nfunction unescapeIfQuotedJsonString(s){\n const t=String(s).trim();\n const looksQuoted=(t.startsWith('\"')&&t.endsWith('\"'))||(t.startsWith(\"'\")&&t.endsWith(\"'\"));\n if(!looksQuoted) return {ok:false};\n try{ const once=JSON.parse(t); if(typeof once==='string') return {ok:true, value: once}; return {ok:false}; }\n catch{ return {ok:false}; }\n}\nfunction extractJsonArray(s){\n if(typeof s!=='string') return {ok:false, why:'not_string'};\n let txt=stripCodeFences(replaceSmartQuotes(s)).trim();\n if(!txt.startsWith('[') && !txt.startsWith('{')) txt=stripPreamble(txt);\n\n // direct parse\n let p=tryParse(txt);\n if(p.ok) return Array.isArray(p.value) ? {ok:true, value:p.value} : {ok:false, why:'not_array'};\n\n // crop to outermost array\n const i=txt.indexOf('['), j=txt.lastIndexOf(']');\n if(i!==-1 && j!==-1 && j>i){\n p=tryParse(txt.slice(i,j+1));\n if(p.ok) return Array.isArray(p.value) ? {ok:true, value:p.value} : {ok:false, why:'not_array'};\n }\n\n // quoted JSON string containing array\n const unq=unescapeIfQuotedJsonString(txt);\n if(unq.ok){\n const inner=stripCodeFences(replaceSmartQuotes(unq.value)).trim();\n const inner2=(!inner.startsWith('[')&&!inner.startsWith('{'))?stripPreamble(inner):inner;\n let q=tryParse(inner2);\n if(q.ok) return Array.isArray(q.value) ? {ok:true, value:q.value} : {ok:false, why:'not_array'};\n const i2=inner2.indexOf('['), j2=inner2.lastIndexOf(']');\n if(i2!==-1 && j2!==-1 && j2>i2){\n q=tryParse(inner2.slice(i2,j2+1));\n if(q.ok) return Array.isArray(q.value) ? {ok:true, value:q.value} : {ok:false, why:'not_array'};\n }\n }\n return {ok:false, why:'json_array_not_found'};\n}\n\nfunction isNullish(v){ return v===null || v===undefined; }\nfunction passStringOrNull(v){ if(isNullish(v)) return null; const s=String(v).trim(); return s===''?null:s; }\nfunction toLowerNoWhitespaceEmail(v){\n if(isNullish(v)) return null;\n const s=String(v).toLowerCase().replace(/[\\s\\r\\n\\u00A0\\u200B\\u200C\\u200D\\u2028\\u2029\\uFEFF]/g,'');\n return s.includes('@') ? s : null;\n}\nfunction toBoolOrNull(v){\n if(v===true||v===false) return v;\n if(isNullish(v)) return null;\n const s=String(v).trim().toLowerCase();\n if(s==='true' || s==='yes' || s==='hybrid') return true;\n if(s==='false' || s==='no' || s==='onsite' || s==='office') return false;\n return null;\n}\nfunction toNumOrNull(v){\n if(isNullish(v) || v==='') return null;\n if(typeof v==='number' && !Number.isNaN(v)) return v;\n const m=String(v).match(/-?\\d+(\\.\\d+)?/);\n return m ? parseFloat(m[0]) : null;\n}\nfunction ensureString(v, joiner=', '){\n if(isNullish(v)) return '';\n if(Array.isArray(v)){\n const clean=v.map(x=>String(x).trim()).filter(x=>x!=='');\n return clean.join(joiner);\n }\n return String(v);\n}\nfunction onlyAllowedKeys(obj){\n const out={};\n for(const k of ALLOWED_KEYS){ if(Object.prototype.hasOwnProperty.call(obj,k)) out[k]=obj[k]; }\n return out;\n}\n\n// ---------------- skills normalization ----------------\nfunction toArrayKeep(x){\n if (x == null) return [];\n if (Array.isArray(x)) return x.map(String);\n if (typeof x === 'object') return Object.values(x).map(String);\n return String(x).split(/[;,|]/).map(s=>s.trim()).filter(Boolean);\n}\nconst CANON = {\n 'google tag management': 'google tag manager',\n 'google tag manager': 'google tag manager',\n gtm: 'google tag manager',\n 'ms excel': 'excel',\n 'microsoft excel': 'excel',\n 'ga4': 'google analytics',\n 'google analytics 4': 'google analytics',\n 'sql server': 'sql'\n};\nfunction normSkill(s){\n const base = String(s).toLowerCase().trim()\n .replace(/[()]/g,'')\n .replace(/[_\\-]+/g,' ')\n .replace(/\\s+/g,' ');\n return CANON[base] || base;\n}\nfunction uniq(a){ return [...new Set((a||[]).filter(Boolean))]; }\n\n// Soft skills detector (generic)\nconst SOFT_RE = /\\b(communication|storytelling|collaboration|leadership|stakeholder(?: management)?|mentorship|coaching|team(?:\\s*)work|presentation|interpersonal)\\b/;\nfunction isSoftSkill(token){ return SOFT_RE.test(String(token).toLowerCase()); }\n\n// ---------------- level inference ----------------\nfunction inferLevel(years){\n const y = Number(years)||0;\n if (y >= 10) return 'lead';\n if (y >= 6) return 'senior';\n if (y >= 3) return 'mid';\n return 'junior';\n}\n\n// ---------------- LLM text resolver ----------------\nfunction resolveLlmText(payload){\n const direct = ['text','content','data','body','output'];\n for (const k of direct){ if (typeof payload[k] === 'string' && payload[k].trim() !== '') return payload[k]; }\n if (payload.message){\n if (typeof payload.message.content === 'string' && payload.message.content.trim() !== '') return payload.message.content;\n if (Array.isArray(payload.message.content)){\n const parts = payload.message.content.map(p => {\n if (typeof p === 'string') return p;\n if (p && typeof p.text === 'string') return p.text;\n if (p && typeof p.content === 'string') return p.content;\n return '';\n }).filter(Boolean);\n if (parts.length) return parts.join('\\n');\n }\n }\n if (payload.choices && Array.isArray(payload.choices) && payload.choices[0]?.message?.content){\n return payload.choices[0].message.content;\n }\n return null;\n}\n\n// ---------------- record normalizer ----------------\nfunction normalizeRecord(r, fallbackIndex){\n // meta (may contain identifiers and index)\n const meta = (r && typeof r.meta === 'object' && r.meta !== null) ? { ...r.meta } : {};\n if (typeof meta.index !== 'number') meta.index = (typeof fallbackIndex === 'number') ? fallbackIndex : 0;\n\n // base fields from LLM (legacy compatibility)\n const base = {\n name: passStringOrNull(r.name ?? meta.name),\n email: toLowerNoWhitespaceEmail(r.email ?? meta.email),\n phone: passStringOrNull(r.phone ?? meta.phone),\n\n location_city: passStringOrNull(r.location_city ?? (meta.location || null)),\n location_state_province: passStringOrNull(r.location_state_province),\n location_country: passStringOrNull(r.location_country),\n\n remote: toBoolOrNull(r.remote),\n total_years_experience: toNumOrNull(r.total_years_experience),\n\n highest_degree: (()=>{\n const hd = (r.highest_degree==null? null : String(r.highest_degree).toLowerCase().trim());\n if (hd===\"bachelor's\"||hd==='bachelors'||hd==='bachelor') return \"bachelor's\";\n if (hd===\"master's\"||hd==='masters'||hd==='master') return \"master's\";\n if (hd==='phd'||hd==='doctorate') return \"phd\";\n return null;\n })(),\n\n latest_title: passStringOrNull(r.latest_title),\n latest_company: passStringOrNull(r.latest_company),\n\n skills_text: ensureString(r.skills_text, ', '),\n titles: r.latest_title ? String(r.latest_title) : '',\n achievements: ensureString(r.achievements, '; ')\n };\n\n // NEW explicit arrays from LLM (preferred)\n const directLLM = uniq(toArrayKeep(r.candidate_direct_skills).map(normSkill));\n const inferredLLM = uniq(toArrayKeep(r.candidate_inferred_skills).map(normSkill));\n const educationLLM= uniq(toArrayKeep(r.candidate_education).map(s => String(s).toLowerCase().trim()));\n const softLLM = uniq(toArrayKeep(r.candidate_soft_skills).map(normSkill));\n\n // Remove soft-skill bleed from inferred and filter generic soft tokens\n const softSet = new Set(softLLM);\n const inferredClean = inferredLLM.filter(x => !softSet.has(x) && !isSoftSkill(x));\n\n // Fallback skills if LLM didn't provide explicit arrays\n const fallbackSources = [\n ...(Array.isArray(r.skills) ? r.skills : []),\n ...(Array.isArray(r.keywords) ? r.keywords : []),\n ...toArrayKeep(r.skills_text)\n ];\n const fallbackSkills = uniq(fallbackSources.map(normSkill));\n\n // Compose cand_skills for backward compatibility (union of direct + inferred; if empty, use fallback)\n const cand_skills = uniq(\n (directLLM.length || inferredClean.length) ? directLLM.concat(inferredClean) : fallbackSkills\n );\n\n // raw_json passthrough + cleanup (legacy)\n const raw = (typeof r.raw_json === 'object' && r.raw_json !== null) ? { ...r.raw_json } : {};\n if (raw.email) raw.email = toLowerNoWhitespaceEmail(raw.email);\n if (raw.remote !== undefined) raw.remote = toBoolOrNull(raw.remote);\n if (typeof raw.location_country === 'string' && raw.location_country.trim()==='') raw.location_country = null;\n\n // normalized cand_* keys used downstream\n const cand_total_years_experience =\n base.total_years_experience != null ? base.total_years_experience\n : toNumOrNull(raw.total_years_experience);\n\n const cand_experience_level =\n (typeof r.cand_experience_level === 'string' && r.cand_experience_level) ?\n String(r.cand_experience_level).toLowerCase().trim()\n : inferLevel(cand_total_years_experience);\n\n // Build normalized record\n const rec = {\n ...base,\n raw_json: raw,\n\n cand_name: base.name,\n cand_email: base.email,\n cand_location_city: base.location_city,\n cand_location_state_province: base.location_state_province,\n cand_location_country: base.location_country,\n cand_remote: base.remote,\n cand_total_years_experience,\n cand_experience_level,\n\n // Backward-compat skills\n cand_skills,\n\n // NEW explicit arrays\n candidate_direct_skills: directLLM,\n candidate_inferred_skills: inferredClean,\n candidate_education: educationLLM,\n candidate_soft_skills: softLLM,\n\n // meta + promoted identifiers\n meta,\n cand_index: meta.index\n };\n\n // Promote common identifiers from meta if present (non-invented)\n if (meta.name && !rec.candidate_name) rec.candidate_name = String(meta.name);\n if (meta.email && !rec.candidate_email) rec.candidate_email = toLowerNoWhitespaceEmail(meta.email);\n if (meta.phone && !rec.candidate_phone) rec.candidate_phone = String(meta.phone);\n if (meta.linkedin && !rec.candidate_linkedin) rec.candidate_linkedin = String(meta.linkedin);\n if (meta.github && !rec.candidate_github) rec.candidate_github = String(meta.github);\n if (meta.location && !rec.candidate_location) rec.candidate_location = String(meta.location);\n if (meta.candidate_id && !rec.candidate_id) rec.candidate_id = String(meta.candidate_id);\n\n // only keep allowed keys (legacy contract)\n const cleaned = onlyAllowedKeys(rec);\n return cleaned;\n}\n\n// ---------------- main ----------------\nconst INPUT_ITEMS = (typeof items !== 'undefined' && Array.isArray(items)) ? items : [{ json: $json }];\nconst outItems = [];\n\nfor (const it of INPUT_ITEMS) {\n const j = it.json ?? {};\n const text = resolveLlmText(j);\n\n if (text == null) {\n const baseOut = PRESERVE_UPSTREAM ? { ...(it.json || {}) } : {};\n outItems.push({ json: { ...baseOut, error: true, why: 'no_text_from_llm' } });\n continue;\n }\n\n const parsed = extractJsonArray(text);\n if (!parsed.ok) {\n const baseOut = PRESERVE_UPSTREAM ? { ...(it.json || {}) } : {};\n outItems.push({\n json: {\n ...baseOut,\n error: true,\n why: 'llm_output_not_json_array',\n detail: parsed.why,\n snippet: String(text).slice(0, 400)\n }\n });\n continue;\n }\n\n const val = parsed.value;\n for (let i = 0; i < val.length; i++) {\n const r = val[i];\n if (typeof r !== 'object' || r === null) {\n const baseOut = PRESERVE_UPSTREAM ? { ...(it.json || {}) } : {};\n outItems.push({ json: { ...baseOut, error: true, why: 'array_contains_non_object', value: r } });\n continue;\n }\n\n const cleaned = normalizeRecord(r, i);\n\n // Append warning for unknown keys in the LLM object (not upstream)\n const unknown = Object.keys(r).filter(k => !ALLOWED_KEYS.includes(k));\n if (unknown.length) cleaned._warning_unknown_keys_stripped = unknown.join(', ');\n\n // Non-destructive: merge upstream fields unless disabled\n const baseOut = PRESERVE_UPSTREAM ? { ...(it.json || {}) } : {};\n // Avoid carrying giant raw fields if present\n delete baseOut.message;\n delete baseOut.content;\n delete baseOut.data;\n delete baseOut.resume_text;\n delete baseOut.Resume_text;\n\n outItems.push({ json: { ...baseOut, ...cleaned } });\n }\n}\n\nreturn outItems;\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
16,
320
],
"id": "2863f7c6-d388-49e1-8160-8b772455e97c",
"name": "Normalized_Cand"
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "chatgpt-4o-latest",
"mode": "list",
"cachedResultName": "CHATGPT-4O-LATEST"
},
"messages": {
"values": [
{
"content": "You are a STRICT JSON generator.\n\nOutput rules:\n- Output EXACTLY one JSON array of length 1 with a single JSON object.\n- No prose, no code fences, no logs, no wrapper fields.\n- Missing strings \u2192 \"\" ; missing arrays \u2192 [].\n- Arrays must be UNIQUE, LOWERCASED, space-preserving.\n- Temperature = 0 (deterministic).\n\nEcho policy (MANDATORY):\n- Always echo these identifiers exactly as provided in INPUT:\n pair_id, job_number, job_title, company, candidate_email, candidate_name, candidate_phone\n\nSemantic adjustment policy:\n- You receive precomputed lists from the matcher:\n req_dir, req_imp, cand_dir, cand_imp\n- Your job: (1) validate/adjust matches and (2) optionally add hidden items.\n- When you output **req_ai** and **cand_ai**, they MUST contain ONLY AI-added items\n that are NOT already present in req_dir/req_imp (for req_ai) and cand_dir/cand_imp (for cand_ai).\n- Do NOT duplicate items across direct/implied/ai buckets.\n\nQuadrant reporting:\n- adjusted_lists: arrays of requirement matches by quadrant:\n jd_direct_vs_cand_direct, jd_direct_vs_cand_implied, jd_implied_vs_cand_direct, jd_implied_vs_cand_implied.\n- adjusted_pairs: per-match objects: { req, matched_skill, source: \"cand_direct\"|\"cand_inferred\", quadrant }\n\nMissing:\n- missing_required_direct: requirements from req_dir with no valid match after your adjustment.\n- missing_required_implied: requirements from req_imp with no valid match after your adjustment.\n- missing_required_ai: requirements from req_ai with no valid match (rare; usually []).\n\nScoring labels:\n- match_label \u2208 {\"strong\",\"moderate\",\"weak\"} (your judgment on overall quality)\n- decision \u2208 {\"apply\",\"improve\"} (your judgment)\n\nKeep lists \u22645 for: top_strengths, blockers, weak_implied, recommended_actions.\n\nReturn exactly this shape:\n\n[\n {\n \"pair_id\": \"\",\n \"job_number\": \"\",\n \"job_title\": \"\",\n \"company\": \"\",\n \"candidate_email\": \"\",\n \"candidate_name\": \"\",\n \"candidate_phone\": \"\",\n\n \"req_dir\": [],\n \"req_imp\": [],\n \"req_ai\": [],\n\n \"cand_dir\": [],\n \"cand_imp\": [],\n \"cand_ai\": [],\n\n \"adjusted_lists\": {\n \"jd_direct_vs_cand_direct\": [],\n \"jd_direct_vs_cand_implied\": [],\n \"jd_implied_vs_cand_direct\": [],\n \"jd_implied_vs_cand_implied\": []\n },\n \"adjusted_pairs\": [],\n\n \"adjusted_counts\": {\n \"jd_direct_vs_cand_direct_count\": 0,\n \"jd_direct_vs_cand_implied_count\": 0,\n \"jd_implied_vs_cand_direct_count\": 0,\n \"jd_implied_vs_cand_implied_count\": 0\n },\n\n \"missing_required_direct\": [],\n \"missing_required_implied\": [],\n \"missing_required_ai\": [],\n\n \"invalid_implied_matches\": [],\n \"added_hidden_matches\": [], // extra reqs you discovered (mirror req_ai)\n \"recovered_missing_matches\": [], // reqs you managed to recover via reinterpretation\n\n \"match_label\": \"weak\",\n \"decision\": \"improve\",\n \"top_strengths\": [],\n \"blockers\": [],\n \"weak_implied\": [],\n \"recommended_actions\": [],\n \"confidence_pct\": 70,\n \"rationale_short\": \"\"\n }\n]\n",
"role": "system"
},
{
"content": "=You will receive a matcher payload and must adjust matches conservatively.\n\nINPUT (from matcher):\npair_id: {{$json.pair_id || ''}}\njob_number: {{$json.job_number || ''}}\njob_title: {{$json.job_title || ''}}\ncompany: {{$json.company || ''}}\n\ncandidate_email: {{$json.candidate_email || ''}}\ncandidate_name: {{$json.candidate_name || ''}}\ncandidate_phone: {{$json.candidate_phone || ''}}\n\nreq_dir: {{ JSON.stringify($json.required_skills_direct || $json.required_match_direct || []) }}\nreq_imp: {{ JSON.stringify($json.required_skills_implied || $json.required_match_implied || []) }}\nreq_ai: {{ JSON.stringify($json.required_skills_ai || []) }}\n\ncand_dir: {{ JSON.stringify($json.candidate_skills_direct || $json.candidate_direct_skills || []) }}\ncand_imp: {{ JSON.stringify($json.candidate_skills_implied || $json.candidate_inferred_skills || []) }}\ncand_ai: {{ JSON.stringify($json.candidate_skills_ai || []) }}\n\nRules recap:\n- Echo all identifiers.\n- req_ai and cand_ai must be AI-only additions (exclude anything already in the direct/implied lists).\n- Fill adjusted_lists, adjusted_pairs, adjusted_counts.\n- Fill missing_required_* arrays.\n- Keep top_strengths/blockers/weak_implied/recommended_actions \u22645.\n- Be conservative; no hallucinated tools.\n\nReturn exactly ONE array with ONE object, following the System schema.\n"
}
]
},
"simplify": false,
"options": {
"temperature": 0,
"topP": 1
}
},
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [
-32,
736
],
"id": "512062cc-ec76-455a-bd79-06d6c9e21710",
"name": "Message a model2",
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"mode": "combineBySql",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
240,
256
],
"id": "ca151380-097e-42c2-89d6-5267f2dc8868",
"name": "Merge",
"notesInFlow": true,
"notes": "done"
},
{
"parameters": {
"jsCode": "// one_blub \u2014 single item in \u2192 single, well-shaped object out\nconst x = $json;\n\n// helpers\nconst asArr = v => Array.isArray(v) ? v : (v == null || v === \"\" ? [] : [String(v)]);\nconst pick = (...keys) => {\n for (const k of keys) {\n const v = x[k];\n if (v !== undefined && v !== null && String(v).trim() !== \"\") return v;\n }\n return undefined;\n};\n\n// identifiers (with fallbacks)\nconst job_number = pick(\"job_number\",\"njd_job_number\",\"jobNo\",\"jobno\",\"job\") || \"JD001\";\nconst job_title = pick(\"job_title\",\"njd_job_title\",\"title\") || \"\";\nconst company = pick(\"company\",\"njd_company\",\"employer\") || \"\";\n\nconst candidate_email = pick(\"candidate_email\",\"cand_email\",\"email\",\"contact_email\") || \"\";\nconst candidate_name = pick(\"candidate_name\",\"cand_name\",\"name\",\"full_name\") || \"\";\nconst candidate_phone = pick(\"candidate_phone\",\"cand_phone\",\"phone\",\"mobile\",\"cell\",\"contact_phone\") || \"\";\n\nconst cand_index = (x.cand_index ?? 0);\n\n// stable pair id\nconst pair_id = x.pair_id || `${job_number}::${candidate_email || candidate_name || `idx_${cand_index}`}`;\n\n// build output (arrays passed through safely)\nreturn {\n // IDs\n pair_id,\n job_number,\n job_title,\n company,\n\n // Also expose short aliases for downstream nodes that expect cand_* keys\n cand_email: candidate_email,\n cand_name: candidate_name,\n cand_phone: candidate_phone,\n\n // Canonical candidate identifiers\n candidate_email,\n candidate_name,\n candidate_phone,\n\n // JD (pass-through; matcher will normalize further)\n required_skills: asArr(pick(\"required_skills\",\"njd_required_skills\")),\n good_to_have: asArr(pick(\"good_to_have\",\"njd_good_to_have\")),\n required_education: pick(\"required_education\",\"njd_required_education\") || \"\",\n preferred_education: pick(\"preferred_education\",\"njd_preferred_education\") || \"\",\n\n // Candidate (pass-through; matcher will normalize further)\n candidate_direct_skills: asArr(x.candidate_direct_skills),\n candidate_inferred_skills: asArr(x.candidate_inferred_skills),\n candidate_soft_skills: asArr(x.candidate_soft_skills),\n candidate_education: asArr(x.candidate_education),\n\n cand_index\n};\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-736,
912
],
"id": "bd709421-3882-480f-b8c8-004ad5b0bc93",
"name": "one_blub"
},
{
"parameters": {
"options": {
"reset": false
}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
-944,
784
],
"id": "61c3078a-5c00-494f-9c8d-aa8eb54902d3",
"name": "Loop Over Items",
"executeOnce": false
},
{
"parameters": {
"jsCode": "/**\n * Parse OA adjustment \u2192 single compact record (CSV-ready)\n * - Robustly parse OA JSON\n * - Echo identifiers; backfill from current item if OA omitted\n * - Enforce AI-only diffs: req_ai = req_ai \\ (req_dir \u222a req_imp); cand_ai = cand_ai \\ (cand_dir \u222a cand_imp)\n * - Map quadrant matches to final names\n * - Produce EXACTLY 30 columns with lists joined by \"; \"\n */\n\nconst inItem = $json;\n\n// ---------- helpers ----------\nfunction getAssistantText(j) {\n if (typeof j === \"string\") return j;\n if (typeof j?.content === \"string\") return j.content;\n if (Array.isArray(j?.choices) && j.choices[0]?.message?.content) return j.choices[0].message.content;\n if (j?.message?.content) return j.message.content;\n return JSON.stringify(j);\n}\nfunction parseOneObject(txt) {\n let s = String(txt || \"\").trim();\n const m = s.match(/^\\s*```(?:json)?\\s*([\\s\\S]*?)\\s*```\\s*$/i);\n if (m) s = m[1].trim();\n let obj = null;\n if (s.startsWith(\"{\")) obj = JSON.parse(s);\n else {\n const arr = JSON.parse(s);\n obj = Array.isArray(arr) ? arr[0] : arr;\n }\n return obj || {};\n}\nconst norm = (v) => String(v).toLowerCase().trim();\nconst normArr = a => [...new Set((Array.isArray(a) ? a : []).map(v => norm(v)).filter(Boolean))];\nconst diff = (a,b) => {\n const B = new Set(normArr(b));\n return normArr(a).filter(x => !B.has(x));\n};\nconst inter = (a,b) => {\n const B = new Set(normArr(b));\n return normArr(a).filter(x => B.has(x));\n};\nconst join1 = (arr) => (Array.isArray(arr) ? Array.from(new Set(arr)).join(\"; \") : String(arr||\"\"));\nconst uniq = (a) => Array.from(new Set((a||[]).filter(Boolean)));\n\n// ---------- parse OA ----------\nlet oa = {};\ntry {\n oa = parseOneObject(getAssistantText(inItem.__openai_raw || inItem));\n} catch (e) {\n oa = {};\n}\n\n// ---------- identifiers (echo + backfill) ----------\nlet pair_id = oa.pair_id || inItem.pair_id || \"\";\nconst job_no = oa.job_number|| inItem.job_number|| inItem.job_no || \"\";\nconst job_title = oa.job_title || inItem.job_title || \"\";\nconst company = oa.company || inItem.company || \"\";\n\nconst cand_email= oa.candidate_email || inItem.candidate_email || inItem.cand_email || \"\";\nconst cand_name = oa.candidate_name || inItem.candidate_name || inItem.cand_name || \"\";\nconst cand_phone= oa.candidate_phone || inItem.candidate_phone || inItem.cand_phone || \"\";\n\n// fallback for pair_id if missing: \"N/A::<email>\"\nif (!pair_id) pair_id = (cand_email ? `N/A::${cand_email}` : \"N/A::\");\n\n// ---------- buckets (normalize) ----------\nconst req_dir = normArr(oa.req_dir ?? inItem.required_skills_direct ?? inItem.required_match_direct ?? []);\nconst req_imp = normArr(oa.req_imp ?? inItem.required_skills_implied ?? inItem.required_match_implied ?? []);\nlet req_ai = normArr(oa.req_ai ?? inItem.required_skills_ai ?? []);\n\nconst cand_dir = normArr(oa.cand_dir ?? inItem.candidate_skills_direct ?? inItem.candidate_direct_skills ?? []);\nconst cand_imp = normArr(oa.cand_imp ?? inItem.candidate_skills_implied ?? inItem.candidate_inferred_skills ?? []);\nlet cand_ai = normArr(oa.cand_ai ?? inItem.candidate_skills_ai ?? []);\n\n// Enforce AI-only (no duplication with existing buckets)\nconst req_base = uniq([...req_dir, ...req_imp]);\nconst cand_base = uniq([...cand_dir, ...cand_imp]);\nreq_ai = diff(req_ai, req_base);\ncand_ai = diff(cand_ai, cand_base);\n\n// Quadrant-adjusted lists (if OA provided; not strictly required for final columns)\nconst al = oa.adjusted_lists || {};\nconst jd_dd = normArr(al.jd_direct_vs_cand_direct || []);\nconst jd_di = normArr(al.jd_direct_vs_cand_implied || []);\nconst ji_dd = normArr(al.jd_implied_vs_cand_direct || []);\nconst ji_di = normArr(al.jd_implied_vs_cand_implied || []);\n\n// Missing (OA view, keep normalized)\nconst miss_req_dir = normArr(oa.missing_required_direct || []);\nconst miss_req_imp = normArr(oa.missing_required_implied || []);\nconst miss_req_ai = normArr(oa.missing_required_ai || []);\n\n// Counts (source arrays \u2014 normalized)\nconst req_
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleSheetsOAuth2ApiopenAiApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
***My workflow Sep 26 (Sep 27 at 14:55:29). Uses googleSheets, openAi. Event-driven trigger; 19 nodes.
Source: https://github.com/niloufarpourkasraei/novapath-ai-matching-orchestrator/blob/main/workflows/novapath_matcher.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Ask questions like “How much did I spend on food last month?” and get instant answers from your financial data — directly in Telegram.
The Problem That it Solves
This intelligent email automation workflow helps you maximize engagement through domain-based outreach. It utilizes AI-powered personalization and strategic follow-ups to increase response rates. The
Note: Now includes an Apify alternative for Rapid API (Some users can't create new accounts on Rapid API, so I have added an alternative for you. But immediately you are able to get access to Rapid AP
Scrape ads – Pulls Facebook Ad Library data for "ai automation" keywords using Apify Filter & sort – Filters ads by page likes (>1,000) and separates into videos, images, and text ads Analyze creat