{
  "name": "AI Tailoring Engine",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ai-tailoring-engine",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "aabbccdd-eeff-0011-2233-445566778899",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Support both direct JSON and webhook body wrapper\nconst raw  = $input.first().json;\nconst body = raw.body || raw;\n\nlet resumeJson = body.resume_json;\nlet jdRaw = body.jd_raw || '';\n\n// If resume_json arrived as a string (double-encoded), parse it\nif (typeof resumeJson === 'string') {\n  try { resumeJson = JSON.parse(resumeJson); } catch(e) {}\n}\n\n// Normalise jd_raw to string regardless of how it arrived\nif (typeof jdRaw === 'object') {\n  jdRaw = jdRaw.jd_raw || JSON.stringify(jdRaw);\n}\njdRaw = String(jdRaw).trim();\n\nif (!resumeJson) throw new Error('resume_json is required. Got: ' + typeof body.resume_json + ' keys: ' + Object.keys(body).join(','));\nif (!jdRaw)      throw new Error('jd_raw is required');\n\nconst resumeStr = JSON.stringify(resumeJson, null, 2);\n\n// Trim JD to a reasonable size\nconst jdTrimmed = jdRaw.slice(0, 8000);\n\nconst outputSchema = {\n  tailored_resume: {\n    contact:    { name: 'string', email: 'string', phone: 'string', linkedin: 'string', github: 'string' },\n    summary:    'string \u2014 rewritten to mirror JD language and highlight candidate fit',\n    skills:     [{ category: 'string', items: ['string \u2014 JD-matching skills listed first'] }],\n    experience: [{ company: 'string', title: 'string', dates: 'string', bullets: ['string \u2014 strong verbs + JD keywords'] }],\n    projects:   [{ name: 'string', tech: ['string'], bullets: ['string \u2014 relevance to JD highlighted'] }],\n    education:  [{ institution: 'string', degree: 'string', dates: 'string', gpa: 'string' }]\n  },\n  matched_keywords: ['string \u2014 keywords from JD present or aligned in tailored resume'],\n  ats_score: 'integer 0\u2013100'\n};\n\nconst prompt = `You are a professional resume optimization expert specializing in ATS optimization.\n\nTailor the candidate's resume to the job description and return a structured JSON response.\n\n## CRITICAL RULES:\n1. Only use facts from the candidate's resume. NEVER add skills, companies, titles, or metrics not already present.\n2. You MAY rewrite bullets with stronger action verbs and JD keywords \u2014 only when the underlying fact supports it.\n3. You MAY re-rank skills so JD-matching items appear first within each category.\n4. You MAY rewrite the summary to emphasize the most relevant experience.\n5. Never fabricate GPA, dates, certifications, or quantitative metrics.\n6. Preserve ALL original experience, education, and project entries \u2014 do not drop any.\n7. Return ONLY valid JSON. No markdown fences, no explanatory text.\n\n## CANDIDATE RESUME (JSON):\n${resumeStr}\n\n## JOB DESCRIPTION:\n---\n${jdTrimmed}\n---\n\n## REQUIRED OUTPUT SCHEMA:\n${JSON.stringify(outputSchema, null, 2)}\n\n## ATS SCORE METHODOLOGY:\nCount distinct JD keywords/phrases present in the tailored resume, estimate coverage as 0\u2013100 integer.\n\nReturn the JSON object now:`;\n\nreturn [{ json: { prompt } }];\n"
      },
      "id": "bbccddee-ff00-1122-3344-556677889900",
      "name": "Build Tailoring Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={{ $env.GEMINI_API_KEY }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { contents: [{ role: 'user', parts: [{ text: $json.prompt }] }], generationConfig: { response_mime_type: 'application/json', temperature: 0.2, maxOutputTokens: 8192 } } }}",
        "options": {
          "timeout": 90000
        }
      },
      "id": "ccddeeff-0011-2233-4455-667788990011",
      "name": "Gemini API",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        700,
        300
      ],
      "continueOnFail": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const response = $input.first().json;\n\nif (response.error) {\n  throw new Error('Gemini API error: ' + JSON.stringify(response.error));\n}\n\nlet rawText;\ntry {\n  rawText = response.candidates[0].content.parts[0].text;\n} catch (e) {\n  throw new Error('Unexpected Gemini response: ' + JSON.stringify(response).slice(0, 300));\n}\n\nconst cleaned = rawText\n  .replace(/^```json\\s*/i, '')\n  .replace(/^```\\s*/i, '')\n  .replace(/\\s*```$/i, '')\n  .trim();\n\nlet parsed;\ntry {\n  parsed = JSON.parse(cleaned);\n} catch (e) {\n  throw new Error('JSON parse failed: ' + e.message + ' | Raw: ' + rawText.slice(0, 200));\n}\n\nif (!parsed.tailored_resume || typeof parsed.tailored_resume !== 'object') {\n  throw new Error('Gemini response missing tailored_resume key');\n}\n\nconst tr = parsed.tailored_resume;\nconst normalised = {\n  contact:    tr.contact    || { name: '', email: '', phone: '', linkedin: '', github: '' },\n  summary:    tr.summary    || '',\n  skills:     Array.isArray(tr.skills)     ? tr.skills     : [],\n  experience: Array.isArray(tr.experience) ? tr.experience : [],\n  projects:   Array.isArray(tr.projects)   ? tr.projects   : [],\n  education:  Array.isArray(tr.education)  ? tr.education  : []\n};\n\nconst matched = Array.isArray(parsed.matched_keywords) ? parsed.matched_keywords : [];\nlet score = parseInt(parsed.ats_score, 10);\nif (isNaN(score)) score = 0;\nscore = Math.max(0, Math.min(100, score));\n\nreturn [{ json: { tailored_resume: normalised, matched_keywords: matched, ats_score: score } }];\n"
      },
      "id": "ddeeff00-1122-3344-5566-778899001122",
      "name": "Parse & Validate Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        940,
        300
      ]
    },
    {
      "parameters": {
        "respondWith": "allIncomingItems",
        "options": {}
      },
      "id": "eeff0011-2233-4455-6677-889900112233",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1180,
        300
      ]
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Build Tailoring Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Tailoring Prompt": {
      "main": [
        [
          {
            "node": "Gemini API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gemini API": {
      "main": [
        [
          {
            "node": "Parse & Validate Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse & Validate Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "staticData": null,
  "tags": [],
  "id": 3,
  "active": true
}