{
  "id": "HHRhCg2X00IauLhl",
  "name": "\ud83c\udfaf Mind the Gap \u2014 Job-market skill-demand radar (LLM + webhook)",
  "tags": [],
  "nodes": [
    {
      "id": "9d482430-a708-4eda-9835-15c7288d4fe9",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -560,
        -32
      ],
      "parameters": {
        "width": 480,
        "height": 832,
        "content": "## \ud83c\udfaf Mind the Gap \u2014 Job-market skill-demand radar (LLM + webhook)\n\n### How it works\n\nThis workflow produces a web-based job-market skill-demand radar from a webhook or manual run. It loads the desired role and the user\u2019s current skills, serves a cached HTML radar when inputs have not changed, or fetches recent remote jobs, ranks relevant postings, asks an LLM to extract skills, and renders the demand-versus-gap report as HTML.\n\n### Setup steps\n\n- Configure the webhook URL/path and activate the workflow so the radar can be opened in a browser.\n- Edit the \u201cDesired job config\u201d node with the target role, keywords, LLM endpoint/model settings, and any required API key or authorization header.\n- Edit the \u201cYour Skills\u201d node with the user\u2019s CV text, skill list, tools, or background to compare against market demand.\n- Run the \u201cManual test\u201d trigger once to validate the LLM request/response format and confirm the rendered HTML looks correct.\n\n### Customization\n\nAdjust the target role keywords, ranking rules, cache freshness logic, job source, LLM model, and radar rendering thresholds to tune the analysis for a specific market or career path."
      },
      "typeVersion": 1
    },
    {
      "id": "0ff04a64-3f0b-42fc-ac82-d18b826c3169",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        -32
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 512,
        "content": "## Start radar request\n\nEntry points for opening the radar through the webhook or running the workflow manually for testing."
      },
      "typeVersion": 1
    },
    {
      "id": "423a9925-f6c1-44d6-8490-a81e2a55b7d3",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        320,
        64
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 320,
        "content": "## Configure target profile\n\nDefines the target job search configuration and the user\u2019s known skills that will be used for comparison."
      },
      "typeVersion": 1
    },
    {
      "id": "333758e0-9166-41b0-b7b5-87a09883f78d",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        832,
        64
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 320,
        "content": "## Check cached output\n\nDetermines whether the current inputs match a fresh cached radar page and either returns it immediately or continues to recompute results."
      },
      "typeVersion": 1
    },
    {
      "id": "754f0efc-c8c4-4d75-9d91-a94dd2942bb4",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1360,
        320
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 320,
        "content": "## Fetch and rank jobs\n\nRetrieves recent remote job postings from the configured source, filters them by keywords, ranks them for relevance, and caps the result set."
      },
      "typeVersion": 1
    },
    {
      "id": "c9b90795-0243-42b7-8fe9-d9f9440798a6",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1840,
        320
      ],
      "parameters": {
        "color": 7,
        "width": 672,
        "height": 320,
        "content": "## Extract demanded skills\n\nBuilds per-job extraction prompts, sends them to the LLM endpoint, parses the responses, canonicalizes skills, and emits structured skill-demand rows."
      },
      "typeVersion": 1
    },
    {
      "id": "073a9849-0d2b-4ca7-8f91-4843379768c1",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2560,
        272
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 368,
        "content": "## Render radar page\n\nAggregates extracted market demand, compares it against the user\u2019s skills, and builds the final inlined HTML radar visualization."
      },
      "typeVersion": 1
    },
    {
      "id": "7be4e3b4-cae9-4b78-98b5-13fb4c22d308",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2848,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 336,
        "content": "## Return HTML response\n\nSends either the cached page or the newly rendered radar page back to the webhook caller as HTML."
      },
      "typeVersion": 1
    },
    {
      "id": "view",
      "name": "Webhook for Radar View",
      "type": "n8n-nodes-base.webhook",
      "onError": "continueRegularOutput",
      "position": [
        48,
        144
      ],
      "parameters": {
        "path": "mind-the-gap",
        "options": {},
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "manual",
      "name": "Trigger Manually",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        48,
        320
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "gate",
      "name": "Manage Cache Stability",
      "type": "n8n-nodes-base.code",
      "position": [
        880,
        224
      ],
      "parameters": {
        "jsCode": "// Serve the cached page so refreshes are STABLE \u2014 but recompute whenever the inputs change.\n// Cache is reused only if: same keywords + same skills text, younger than cacheHours, and the\n// caller didn't pass ?refresh=1. Editing Config or Registry busts the cache automatically.\nconst cacheHours = 24;\nconst store = $getWorkflowStaticData('global');\nconst c = store.radar;\nconst sig = JSON.stringify({\n  k: $('Set Job Search Criteria').first().json.keywords || [],\n  s: $('Record Your Skills Data').first().json.skillsText || '',\n});\nlet force = false, viaWebhook = true;\ntry { force = String(($('Webhook for Radar View').first().json.query || {}).refresh || '') === '1'; }\ncatch (e) { viaWebhook = false; }   // manual runs always recompute (handy for testing)\nconst fresh = viaWebhook && !force && c && c.html && c.sig === sig && (Date.now() - (c.ts || 0) < cacheHours * 3600 * 1000);\nreturn [{ json: fresh ? { cached: true, html: c.html } : { cached: false } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "iffresh",
      "name": "Check Cache Freshness",
      "type": "n8n-nodes-base.if",
      "position": [
        1120,
        224
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.cached }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "fetch",
      "name": "Fetch Recent Job Listings",
      "type": "n8n-nodes-base.code",
      "position": [
        1408,
        480
      ],
      "parameters": {
        "jsCode": "// One broad, no-auth source (Himalayas \u2014 recent remote jobs across every field). No company list.\n// Free job APIs don't offer real server-side search, so we pull a wide recent window and let the\n// next node filter/rank by your keywords (with a never-empty fallback).\nconst cfg = $('Set Job Search Criteria').first().json;\nconst H = (typeof $helpers !== 'undefined' && $helpers && $helpers.httpRequest) ? $helpers : this.helpers;\nconst s = (v) => (v == null ? '' : String(v));\nconst strip = (h) => s(h).replace(/<[^>]*>/g, ' ').replace(/&[a-z#0-9]+;/g, ' ').replace(/\\s+/g, ' ').trim().slice(0, 1500);\nconst per = cfg.feedPerPage || 100, pages = cfg.feedPages || 4;\nconst out = [];\nfor (let p = 0; p < pages; p++) {\n  let hb;\n  try { hb = await H.httpRequest({ method: 'GET', url: 'https://himalayas.app/jobs/api?limit=' + per + '&offset=' + (p * per), json: true, timeout: 20000 }); }\n  catch (e) { break; }\n  for (const j of ((hb && hb.jobs) || [])) {\n    out.push({ json: {\n      company: s(j.companyName) || 'unknown',\n      title: s(j.title),\n      description: strip(j.description || j.excerpt),\n      posted_at: j.pubDate ? new Date(j.pubDate * 1000).toISOString() : '',\n      external_id: 'himalayas:' + s(j.guid || j.title),\n    } });\n  }\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "filter",
      "name": "Rank Job Listings",
      "type": "n8n-nodes-base.code",
      "position": [
        1648,
        480
      ],
      "parameters": {
        "jsCode": "// Keyword filter + relevance rank + cap. Relevance = title hits x3 + description hits x1,\n// tie-broken by recency. Empty keywords => keep the newest 'maxJobs' across the whole feed.\nconst cfg = $('Set Job Search Criteria').first().json;\nconst kw = (cfg.keywords || []).map(k => String(k).toLowerCase()).filter(Boolean);\nconst cap = cfg.maxJobs || 25;\nconst scored = $input.all().map(it => {\n  const j = it.json;\n  const t = (j.title || '').toLowerCase(), d = (j.description || '').toLowerCase();\n  let rel = 0;\n  for (const k of kw) { if (t.includes(k)) rel += 3; if (d.includes(k)) rel += 1; }\n  return { j, rel };\n});\nlet keep = kw.length ? scored.filter(x => x.rel > 0) : scored;\nif (keep.length === 0) keep = scored;   // never drop everything \u2014 keep the feed if nothing matched\nkeep.sort((a, b) => (b.rel - a.rel) || String(b.j.posted_at).localeCompare(String(a.j.posted_at)));\nreturn keep.slice(0, cap).map(x => ({ json: x.j }));"
      },
      "typeVersion": 2
    },
    {
      "id": "prompt",
      "name": "Build Extraction Request",
      "type": "n8n-nodes-base.code",
      "position": [
        1888,
        480
      ],
      "parameters": {
        "jsCode": "// One extraction request per job (small models handle this fine; calls fail independently).\nconst cfg = $('Set Job Search Criteria').first().json;\nconst clip = (v, n) => String(v == null ? '' : v).replace(/\\s+/g, ' ').trim().slice(0, n);\nconst sys = [\n  'You extract structured skill requirements from a job posting.',\n  'Return ONLY a JSON object: {\"seniority\":\"<one of: junior, mid, senior, staff, lead, unspecified>\",\"skills\":[{\"name\":\"<skill>\",\"importance\":\"<must|nice>\"}]}.',\n  '- skills are concrete technologies, tools, languages, methods, or domain competencies named in the posting.',\n  '- canonical lowercase names: \"kubernetes\" not K8s, \"go\" not golang, \"ci/cd\" not CICD.',\n  '- max 15 skills, most important first. Skip soft skills (communication, teamwork) and filler (engineering, software).',\n  '- importance \"must\" if required/essential, \"nice\" if preferred/bonus.',\n].join('\\n');\nreturn $input.all().map(it => {\n  const j = it.json;\n  const user = 'Job posting:\\n' + JSON.stringify({ title: j.title, company: j.company, description: clip(j.description, 1500) });\n  const body = { model: cfg.llmModel, messages: [{ role: 'system', content: sys }, { role: 'user', content: user }], temperature: 0, seed: 7, max_tokens: 500 };\n  return { json: { external_id: j.external_id, company: j.company, title: j.title, bodyJson: JSON.stringify(body) } };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "llm",
      "name": "Post to LLM API",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        2128,
        480
      ],
      "parameters": {
        "url": "={{ $('Set Job Search Criteria').first().json.llmUrl }}",
        "method": "POST",
        "options": {
          "timeout": 60000
        },
        "jsonBody": "={{ $json.bodyJson }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "parse",
      "name": "Analyze Extracted Skills",
      "type": "n8n-nodes-base.code",
      "position": [
        2368,
        480
      ],
      "parameters": {
        "jsCode": "// Align prompts<->responses by index, parse JSON, canonicalize, emit one row per (job, skill).\nconst prompts = $('Build Extraction Request').all().map(i => i.json);\nconst responses = $input.all().map(i => i.json);\nconst CANON = {\n  'k8s': 'kubernetes', 'k3s': 'kubernetes', 'eks': 'aws', 'gke': 'google cloud', 'aks': 'azure',\n  'golang': 'go', 'gcp': 'google cloud', 'amazon web services': 'aws', 'microsoft azure': 'azure',\n  'cicd': 'ci/cd', 'ci cd': 'ci/cd', 'ci-cd': 'ci/cd', 'continuous integration': 'ci/cd',\n  'argo cd': 'argocd', 'iac': 'infrastructure as code', 'tf': 'terraform', 'postgresql': 'postgres',\n  'js': 'javascript', 'ts': 'typescript', 'monitoring': 'observability', 'helm charts': 'helm',\n};\nconst out = [];\nfor (let i = 0; i < responses.length; i++) {\n  const p = prompts[i];\n  if (!p) continue;\n  let parsed = null;\n  try {\n    const r = responses[i];\n    let c = (r && r.choices && r.choices[0] && r.choices[0].message) ? (r.choices[0].message.content || '') : '';\n    const m = c.match(/\\{[\\s\\S]*\\}/); if (m) c = m[0];\n    parsed = JSON.parse(c);\n  } catch (e) { continue; }\n  let seniority = String(parsed.seniority || 'unspecified').toLowerCase().trim();\n  if (!['junior', 'mid', 'senior', 'staff', 'lead'].includes(seniority)) seniority = 'unspecified';\n  const seen = new Set();\n  for (const sk of (Array.isArray(parsed.skills) ? parsed.skills : [])) {\n    let name = String((sk && sk.name) || '').toLowerCase().trim().replace(/\\s+/g, ' ');\n    if (!name) continue;\n    if (CANON[name]) name = CANON[name];\n    if (name.length > 60 || seen.has(name)) continue;\n    seen.add(name);\n    out.push({ json: { external_id: p.external_id, company: p.company, title: p.title, skill: name, importance: (sk && String(sk.importance).toLowerCase() === 'must') ? 'must' : 'nice', seniority } });\n  }\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "render",
      "name": "Generate Radar View",
      "type": "n8n-nodes-base.code",
      "position": [
        2608,
        480
      ],
      "parameters": {
        "jsCode": "// Inlined radar renderer \u2014 aggregate market demand and join it against your free-text skills.\nconst rows = $('Analyze Extracted Skills').all().map(i => i.json);\nconst skillsText = String(($('Record Your Skills Data').first().json || {}).skillsText || '').toLowerCase();\n\nconst demand = {};\nfor (const r of rows) {\n  const s = (r.skill || '').toLowerCase().trim();\n  if (!s || s === '__none') continue;\n  if (!demand[s]) demand[s] = { mentions: 0, must: 0 };\n  demand[s].mentions++;\n  if (r.importance === 'must') demand[s].must++;\n}\nconst totalJobs = new Set(rows.map(r => r.external_id)).size;\n\n// loose synonym match so \"k8s\" in your text covers demand for \"kubernetes\"\nconst ALIAS = {\n  kubernetes: ['k8s', 'k3s', 'eks', 'gke', 'aks'], aws: ['amazon web services'],\n  'google cloud': ['gcp'], azure: ['aks'], 'ci/cd': ['cicd', 'jenkins', 'github actions', 'gitlab ci'],\n  go: ['golang'], postgres: ['postgresql'], observability: ['monitoring', 'prometheus', 'grafana'],\n  terraform: ['iac'], javascript: ['js'], typescript: ['ts'],\n};\nconst has = (name) => skillsText.includes(name) || (ALIAS[name] || []).some(a => skillsText.includes(a));\n\nconst ranked = Object.entries(demand)\n  .map(([name, d]) => ({ name, mentions: d.mentions, must: d.must, weight: d.mentions, covered: has(name) }))\n  .sort((a, b) => b.weight - a.weight || b.must - a.must);\n\n// score over the full demand; show only the most-wanted skills so the page stays readable\nlet wSum = 0, wGot = 0;\nfor (const r of ranked) { wSum += r.weight; if (r.covered) wGot += r.weight; }\nconst list = ranked.slice(0, 30);\nconst score = wSum ? Math.round((wGot / wSum) * 100) : 0;\nconst maxW = Math.max(1, ...list.map(r => r.weight));\n\nconst esc = v => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\nconst bar = w => `<div class=\"bar\"><div style=\"width:${Math.round((w / maxW) * 100)}%\"></div></div>`;\nconst tr = r => {\n  const [c, l] = r.covered ? ['#22c55e', 'have'] : ['#ef4444', 'gap'];\n  return `<tr><td><b>${esc(r.name)}</b></td><td>${bar(r.weight)}<span class=\"n\">${r.mentions} of ${totalJobs} jobs${r.must ? ` \u00b7 ${r.must} must` : ''}</span></td><td><span class=\"chip\" style=\"background:${c}22;color:${c};border-color:${c}55\">${l}</span></td></tr>`;\n};\nconst empty = list.length === 0;\n\nconst html = `<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\"><title>mind the gap</title><style>\n:root{color-scheme:dark} body{margin:0;background:#0b0e14;color:#cdd6e4;font:15px/1.5 ui-monospace,Menlo,monospace}\nmain{max-width:820px;margin:0 auto;padding:40px 20px 80px}\nh1{font-size:22px;color:#e8eefb;margin:0} h2{font-size:13px;color:#8b97ab;margin:32px 0 8px;text-transform:uppercase;letter-spacing:.1em}\n.score{font-size:56px;color:#e8eefb;font-weight:700;margin:16px 0 2px} .sub{color:#67738a;font-size:13px}\ntable{width:100%;border-collapse:collapse;margin-top:8px} td{padding:9px 10px;border-top:1px solid #1c2230;vertical-align:top}\ntd:nth-child(2){width:260px} td:nth-child(3){width:90px;text-align:right}\n.bar{background:#1c2230;border-radius:3px;height:8px;overflow:hidden;margin-top:6px} .bar div{background:#3b82f6;height:100%}\n.n{font-size:12px;color:#67738a} .chip{display:inline-block;padding:2px 9px;border-radius:999px;font-size:12px;border:1px solid}\nfooter{margin-top:60px;font-size:12px;color:#2c3548;text-align:center}\n</style></head><body><main>\n<h1>mind the gap</h1>\n<div class=\"sub\">market demand vs. your skills \u2014 ${totalJobs} job${totalJobs === 1 ? '' : 's'} scanned${empty ? '' : ', skills ranked by how many list them'}</div>\n${empty ? '<p class=\"sub\" style=\"margin-top:30px\">No skills extracted yet. Check the keywords in the <b>Config</b> node and that your LLM credential is set on <b>Extract (LLM)</b>.</p>' : `\n<div class=\"score\">${score}%</div>\n<div class=\"sub\">share of in-demand skills your text covers \u00b7 updated ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC</div>\n<h2>demand radar</h2>\n<table>${list.map(tr).join('\\n')}</table>`}\n<footer>\u00b7</footer>\n</main></body></html>`;\n\n// cache the finished page in the workflow's static data so refreshes are STABLE\n// (served by the cache gate) until the TTL expires or ?refresh=1 forces a recompute.\nconst store = $getWorkflowStaticData('global');\nconst sig = JSON.stringify({\n  k: $('Set Job Search Criteria').first().json.keywords || [],\n  s: $('Record Your Skills Data').first().json.skillsText || '',\n});\nstore.radar = { html, ts: Date.now(), sig };\n\nreturn [{ json: { html } }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "resp",
      "name": "Provide HTML Response",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        2896,
        208
      ],
      "parameters": {
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        },
        "respondWith": "text",
        "responseBody": "={{ $json.html }}"
      },
      "typeVersion": 1.5
    },
    {
      "id": "config",
      "name": "Set Job Search Criteria",
      "type": "n8n-nodes-base.code",
      "position": [
        368,
        224
      ],
      "parameters": {
        "jsCode": "// EDIT THIS \u2014 what role / skills are you scanning the market for?\nconst config = {\n  keywords: [\"engineer\"],       // ANY role/skills \u2014 e.g. [\"product manager\"], [\"nurse\"]. Empty = whole feed.\n  maxJobs: 25,                  // one LLM call per job in a single pass \u2014 keep this modest.\n  feedPages: 4, feedPerPage: 100,\n  llmUrl: \"https://integrate.api.nvidia.com/v1/chat/completions\",  // any OpenAI-compatible endpoint\n  llmModel: \"meta/llama-3.1-8b-instruct\",\n};\nreturn [{ json: config }];"
      },
      "typeVersion": 2
    },
    {
      "id": "registry",
      "name": "Record Your Skills Data",
      "type": "n8n-nodes-base.code",
      "position": [
        608,
        224
      ],
      "parameters": {
        "jsCode": "// EDIT THIS \u2014 paste anything that describes what you know: a CV paragraph, a list of tools,\n// prose, comma-separated keywords. No format and no proof required. Any skill the market\n// wants that also appears in this text counts as one you \"have\"; the rest are gaps.\nconst skillsText = `python, javascript, typescript, react, node, sql, postgres, aws, docker, kubernetes,\nterraform, ci/cd, linux, git, rest api, communication, agile, project management\nI've built and run Grafana/Prometheus monitoring in production and write a lot of Bash and Go.`;\n\nreturn [{ json: { skillsText } }];\n"
      },
      "typeVersion": 2
    }
  ],
  "active": true,
  "settings": {
    "binaryMode": "separate",
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "3ae46e86-9065-4ff8-9f6e-b295382eccf0",
  "connections": {
    "Post to LLM API": {
      "main": [
        [
          {
            "node": "Analyze Extracted Skills",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Trigger Manually": {
      "main": [
        [
          {
            "node": "Set Job Search Criteria",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank Job Listings": {
      "main": [
        [
          {
            "node": "Build Extraction Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Radar View": {
      "main": [
        [
          {
            "node": "Provide HTML Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Cache Freshness": {
      "main": [
        [
          {
            "node": "Provide HTML Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Recent Job Listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manage Cache Stability": {
      "main": [
        [
          {
            "node": "Check Cache Freshness",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook for Radar View": {
      "main": [
        [
          {
            "node": "Set Job Search Criteria",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Your Skills Data": {
      "main": [
        [
          {
            "node": "Manage Cache Stability",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Job Search Criteria": {
      "main": [
        [
          {
            "node": "Record Your Skills Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Extracted Skills": {
      "main": [
        [
          {
            "node": "Generate Radar View",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Extraction Request": {
      "main": [
        [
          {
            "node": "Post to LLM API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Recent Job Listings": {
      "main": [
        [
          {
            "node": "Rank Job Listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}